通过重用ScrollView中的item来提高ScrollView的性能。
通过对ScrollView更多的扩展来更好落地业务功能。
问题
在使用ListView的时候,有多少个数据就会创建多少个item,并不会重复利用或回收释放。
随着数据量的增加,会对性能造成很大的影响。
分析
有几个修改方向
方案之clone替换create
描述:
clone 改成 create[据说是这样,我没有测试过 = =…]
我们在使用ListView的时候,创建一个item,是通过lua重写的pushBackCustomItemView,
它会先调用ListView的pushBackDefaultItem,通过clone创建一个csb,我们再把数据赋过去。
所以,我们完全可以create一个csb,相对于clone会快一些。
缺点:
这个应该会有点效果,嗯,有点效果而已。
方案之分帧加载(逐帧加载)
描述:
分帧加载(逐帧加载)
并不是在一帧全加载完,而是选择每帧加载一定个数,直到加载完成
缺点:
通过lua现有的协程来实现,但是流畅度不是很好,刚进入界面的时候可能看到item是逐渐加载进来的。
方案之异步加载
描述:
异步加载
这个主要对一些图片多的item,我们如果需要切换图,可以通过异步加载,等图片加载完再换图,这样不影响之后item的加载。
缺点:
会看到 默认图(csb创建的样子) -> 真正效果的转换过程。
方案之边界加载
描述:
滑动到底加载
就是先加载一定数量,监听到底部了,再拉取后面的部分,直到全部加载完。
缺点:
做一系列监听滑动等,没有根本解决问题。
方案之重用 item
描述:
重用item
其实,上面的那些方法,都是优化的技巧,并没有从根本上解决问题。
我们要根本的解决问题,就是创建可视区域可容纳数量+1的item,然后不断重用这些item。
在ListView同一时刻,只能见到5个item,那我就创建6个item,然后不断重用这些item。
解决
机制
首先明确view与inner
view像一个窗口,它的大小就是我们可以见到的大小(当然要设置裁切)
inner是我们创建的所有item添加的地方(item并不是加载ScrollView上,而是加在了inner上)
ScrollView/ListView会监听滑动,同时相应的移动inner的位置,从而让我们看到item位置的变化。
简而言之,item加载inner上,是inner动,不是view动。
想法
在ScrollView或者ListView中,正常情况是这样的:
(前面数字代表item位置,后面数字代表item, —-代表可视区域)
1 | 1 1 1 |
可以发现,
前面的例子中, 只能看见2, 3, 4; 但是看不见的1, 5, 6, 7, 8 依旧存在
后面的例子中, 只能看见4, 5, 6; 但是看不见的1, 2, 3, 7, 8 依旧存在
所以,我们改成下面的样子:
1 | 1 |
因为可视区域只有3个item,我们就创建3个item,然后不断重用它们。(当然实际操作中,需要多创建一个,否则有穿帮风险)
但是,位置,我们依旧留着(划重点,inner大小不变,否则无法滑动),
在往下滑的时候,最上面的跑到下面去顶替下面的item;
往上滑的时候,最下面的跑到上面去顶替上面的item。
实现
实现方法,
可以通过监听ScrollView滑动,每当ScrollView滚动,我们可以知道当前inner位置,
然后知道item的位置,从而判断item需不需要移动位置。
这里,用的是编辑一个绘制方法,每隔一段时间,都看一下各个item位置,然后根据需求移动位置。
我们在加载csb的时候将ScrollView记录下来,在view的update中调用它。
(本来想重写update,但是遇到了一些问题,所以妥协用了它,具体可以看后面 遇到的问题)
init:
1 | --[[ |
主要代码:
1 | -- 得到所需绘制item个数 |
update:
1 | function ScrollView:updateView(dt) |
主要代码:
1 | -- 控制刷新时间 |
使用方法
- 调用 ScrollView:setItemViewModel(item, item总数, 创建item所需的额外参数)
- 所有的item要有方法 item:setIndex(index), 并且以 self.index 作为自己的index[这里可以写一个类来封装,让所有item都继承它]
- 在删除的时候,要将ScrollView的每帧更新方法移除
问题
关于update
在3.x中lua启用定时器有两种方法:
第一种方法 scheduleUpdateWithPriorityLua
1 | scheduleUpdateWithPriorityLua(update, priority) |
此方法在Node类中实现,所以它的子类都可以使用。
此方法默认为每帧都刷新因此,无法自定义刷新时间。
这里,没有用这个方法,是因为ScrollView自己已经实现了update方法。
所以,当我们重新注册给ScrollView一个update的时候,发现无法替换。
这里涉及到计时器存储刷新方法:
刷新方法通过哈希表存储,在主循环期间,不移除已有方法,而是将它暂停,且恢复时不加载新方法,而是将原有方法恢复。
启用定时器的源码如下:
1 | void Node::scheduleUpdateWithPriorityLua(int nHandler, int priority) |
执行: unscheduleUpdate();
会先判断节点是否有update方法,在哈希表中查找,并执行移除方法:
1 | tHashUpdateEntry *element = nullptr; |
上面移除方法,会根据_updateHashLocked值来执行,
它为真时,如果节点原来有update,就先废弃它,废弃的方法是,将它标记为已删除,并让它暂停。注意!这里并没有真正的删除,而是将他表示是否删除的字段改值。
它为假时,直接从哈希表中移除update方法。
执行:1
_scheduler->scheduleUpdate(this, priority, !_running);
加入update,也会先从哈希表中查找update,再执行添加方法。
1 | tHashUpdateEntry *hashElement = nullptr; |
添加方法,会先判断优先级,如果优先级相同,那么就恢复原来的update。
否则,根据 _updateHashLocked 值执行接下来操作。
从移除和添加可以发现,关键值在于 _updateHashLocked的值,
这个值在Scheduler::update中设置,开始的时候设置为true,最后结束设置为false。
所以,如果要修改,就很麻烦,就放弃用这个方法了。
道理同样适用于所有自己已经重写了update,想要更换update情形*
第二种方法,通过定时管理器调用
就是上面指的Scheduler,不过我们不调ScrollView的,而是创建一个新的。
1 | scheduler:scheduleScriptFunc(update, inteval, isOnce) |
注意,如果用这个方法,需要负责创建,也要负责移除。
上面方法会返回一个id,之后可以通过这个id来删除它。
1 | cc.Director:getInstance():getScheduler():unscheduleScriptEntry(id) |
为什么要把item封装成Widget
在刚开始往ScrollView加child时,方法是将item的Node直接往ScrollView addChild(ScrollView封装了它,其实就是往inner addChild)
但是当直接addChild时,会产生很多问题:比如按钮吞噬触摸,无法滑动等等。
那就要问一下了,为什么ListView没事呢?
这其实是Cocos对继承自ccui.Widget的事件的处理。
所有的控件事件监听都是单点触摸,并且会吞噬事件。
1 | _touchListener = EventListenerTouchOneByOne::create(); |
在widget的onTouchBegan, onTouchMove, onTouchEnd中,都会调用 propagateTouchEvent,
这个方法是传播事件,每个子节点会吞噬事件,自己处理完,再向父节点传递,一般ScrollView、ListView、PageView会处理这些事件。
1 | Widget* widgetParent = getWidgetParent(); |
可以看出,只有继承自Widget类的,才会接收到interceptTouchEvent,并进行处理。
而且,ScrollView的interceptTouchEvent 已经处理好了按钮的点击,取消等效果。
1 | void ScrollView::interceptTouchEvent(Widget::TouchEventType event, Widget *sender,Touch* touch) |
之前的方法有问题,就是因为直接将Node addChild到ScrollView,当触摸传递到Node,发现无法转成Widget对象,就放弃了向上传播事件。
所以,需要将item包装成Widget来让它将事件传递给ScrollView。
扩展
真正要当ListView使用,还需要支持很多接地气的方法。
多方向滑动
之前的版本仅仅是纵向而已, 当然要支持横向的滑动了。
横向滑动其实与纵向不同。
纵向滑动
由于ScrollView锚点在(0, 0), 要针对这个做一些处理。
否则, 显示的是如下的样子:
1 | ... |
从下往上排列, 而且滑动是从下往上滑。
显然, 这并不符合常规操作。
正常应该是, 从上往下滑, 且:
1 | 1 |
所以, 需要对它的坐标进行小处理。
这里有两个坐标需要被处理:
- item(要求锚点为(0, 0))
它正常坐标是从(0, 0)开始, 然后随着索引增加变为: (0, itemSize.height index)
修改后的坐标应该是从(0, innerSize.height - itemSize.height)开始, 随着索引增加变为:(0, innerSize.height - itemSize.height index) - inner
正常开始的坐标为(0, 0), 显示的是最底部的信息, 随着滑动y坐标减少。
修改后坐标为(0, scrollviewSize.height - innerSize.height), 显示最顶部的信息, 随着滑动y坐标增加。
横向滑动
横向就没有那么多问题了, 很符合常规的动作。
1 | 1 2 3 4 5 ... |
它的两个坐标就不需要处理:
- item(要求锚点为(0, 0))
坐标从(0, 0)开始, 随着索引增加变为: (itemSize.width * index, 0) - inner
坐标从(0, 0)开始, 随着滑动x坐标增加
实现
1 | local ScrollViewDirection = { |
适配item
根据ScrollView显示区域大小及方向, 适当调整item大小。
更充分重用item, 适应多尺寸item。
如果是纵向的ScrollView, 根据width的值, 来决定放缩值。
如果是横向的ScrollView, 根据height的值, 来决定放缩值。
然后根据放缩值再修改一下item size的值。
实现
以纵向滑动ScrollView为例
- ScrollView inner 大小
1 | local scale = ScrollViewSize.width / (ItemSize.width * multiNum) |
- 需要绘制item的总个数
1 | local totalRow = cond(totalItemNum % multiNum == 0, |
- item的位置
1 | self.iCount = math.min(totalRow, math.ceil(ScrollViewSize.height / ItemSize.height) + 1) |
多行多列
重用item, 这么棒的东西, 肯定要多用用呀。
支持多行多列,是根据ScrollView的滚动方向,再根据传入的行/列值进行设置。
需要重新计算一些数值。(下面均以纵向滑动的ScrollView为例)
实现
初始化
- 放缩值
1 | scale = innerSize.width / (itemSize.width * multiNum) |
- inner size
1 | -- 根据总共需要的行数来计算高度 |
- item position
1 | -- 获得需要重用的行数 |
item数量不够时的居中
主要是有个需求,希望item没有填满view的时候,所有的item居中显示。
其实,item还是按照原来的方式放置,只需要移动inner的位置即可。
1 | --[[ |
刷新数据
创建完ScrollView,除非item变动自己的位置,否则是不会刷新数据的。
所以需要一个手动刷新的方法。
这里充分利用了lua的变长参数,在配合人为默认规定。ie
1 | --[[ |
这里我用了一个映射表。
否则需要嵌套两层循环,复杂度 m * n
做一个映射,只需要 n + m
用空间来换取时间
跳转到指定item
这个功能ListView是支持的,觉得ScrollView也有必要支持一下。
方法是先计算出inner需要移动多少距离,从而知道了index需要变化多少。
主要步骤:(也是以垂直滑动方向为例)
- 计算所需跳转的index在最上方位置是第几行
- 计算inner需要滑动多少距离
- 计算从当前到目标,index需要变动多少
- 按照移动后的index,重新布局item
实现
- 步骤1
1 | local line = (index % self.iMultiNum == 0) and |
- 步骤2
1 | local posY = self:getContentSize().height - self.tContentSize.height + self.tItemContentSize.height * (line - 1) |
- 步骤3
1 | local changeIndex = math.ceil((posY - self:getInnerContainer():getPositionY()) / self.tItemContentSize.height) |
- 步骤4
1 | self:updateViewByChangeIndex(changeIndex * self.iMultiNum) |
根据index,重新布局item
1 | --[[ |
跳转的item在ScrollView中的位置
需要跳转到的item在可视区域的 上、中、下 显示
首先,一定要让使用者传入出现的位置枚举,
然后在计算inner移动的位置上加上偏移量。
如果要在中间显示,需要减去(向下移动) ScrollViewSize.height/2 , 因为初始的位置是按照item在最上面计算的,减去一半高度后,还需要再加上item本身高度的一半 ItemSize.height/2。
如果在底部显示,则需要减去(向下移动) ScrollViewSize.height , 同理,需要再加回来一个item的高度 ItemSize.height。
最后,依然要判定滑动到底部,无法滑动的情况。
1 | SCROLLVIEW_ALIGNMENT = { |
飞入动画
额外再加一个飞入动画的支持吧。
就是从外部飞入到ScrollView的效果。
方法也很简单,就是在开始的时候,让所有的item在ScrollView外部;再一个个飞入到自己本应在的位置。
依旧是以垂直向为例。
1 | -- 遍历所有item |
当然,也要支持多方向ScrollView,并且要支持从前端飞入还是从后端飞入。
这些都是通过改动初始位置及回弹值来实现。
1 | --[[ |