Cocos2d lua 之 ScrollView重用item

1.做了什麼

問題:

在使用ListView的時候,有多少個數據就會創建多少個item,並不會重複利用或回收釋放。
隨着數據量的增加,會對性能造成很大的影響。

解決方案:

  • clone 改成 create[據說是這樣,我沒有測試過 = =…]
    我們在使用ListView的時候,創建一個item,是通過lua重寫的pushBackCustomItemView,
    它會先調用ListView的pushBackDefaultItem,通過clone創建一個csb,我們再把數據賦過去。
    所以,我們完全可以create一個csb,相對於clone會快一些。

    缺點: 這個應該會有點效果,嗯,有點效果而已。

  • 分幀加載(逐幀加載)
    並不是在一幀全加載完,而是選擇每幀加載一定個數,直到加載完成

    缺點: 通過lua現有的協程來實現,但是流暢度不是很好,剛進入界面的時候可能看到item是逐漸加載進來的。

  • 異步加載
    這個主要對一些圖片多的item,我們如果需要切換圖,可以通過異步加載,等圖片加載完再換圖,這樣不影響之後item的加載。

    缺點: 會看到 默認圖(csb創建的樣子) -> 真正效果的轉換過程。

  • 滑動到底加載
    就是先加載一定數量,監聽到底部了,再拉取後面的部分,直到全部加載完。

    缺點: 做一系列監聽滑動等,沒有根本解決問題。

  • 重用item[本次實現的方法]
    其實,上面的那些方法,都是優化的技巧,並沒有從根本上解決問題。
    我們要根本的解決問題,就是創建可視區域可容納數量+1的item,然後不斷重用這些item。
    在ListView同一時刻,只能見到5個item,那我就創建6個item,然後不斷重用這些item。




2.怎麼做的

機制

首先明確view與inner,
view像一個窗口,它的大小就是我們可以見到的大小(當然要設置裁切),
inner是我們創建的所有item添加的地方(item並不是加載ScrollView上,而是加在了inner上)
ScrollView/ListView會監聽滑動,同時相應的移動inner的位置,從而讓我們看到item位置的變化。
簡而言之,item加載inner上,是inner動,不是view動。

想法

在ScrollView或者ListView中,正常情況是這樣的:
(前面數字代表item位置,後面數字代表item, ~~~~代表可視區域)

1   1                   1
    ~~~~
2   2                   2
3   3                   3
                        ~~~~
4   4                   4
    ~~~~                
5   5                   5
6   6                   6
                        ~~~~
7   7                   7
8   8                   8

可以發現,
前面的例子中, 只能看見2, 3, 4; 但是看不見的1, 5, 6, 7, 8 依舊存在
後面的例子中, 只能看見4, 5, 6; 但是看不見的1, 2, 3, 7, 8 依舊存在

所以,我們改成下面的樣子:

1                        
    ~~~~
2   2                    
3   3                    
                        ~~~~
4   4                   4
    ~~~~                
5                       5
6                       6
                        ~~~~
7                        
8                        

因爲可視區域只有3個item,我們就創建3個item,然後不斷重用它們。(當然實際操作中,需要多創建一個,否則有穿幫風險)
但是,位置,我們依舊留着(劃重點,* inner大小不變 *,否則無法滑動),
在往下滑的時候,最上面的跑到下面去頂替下面的item;
往上滑的時候,最下面的跑到上面去頂替上面的item;

做法

實現方法,
可以通過監聽ScrollView滑動,每當ScrollView滾動,我們可以知道當前inner位置,
然後知道item的位置,從而判斷item需不需要移動位置。
這裏,用的是編輯一個繪製方法,每隔一段時間,都看一下各個item位置,然後根據需求移動位置。
我們在加載csb的時候將ScrollView記錄下來,在view的update中調用它。
(本來想重寫update,但是遇到了一些問題,所以妥協用了它,具體可以看後面 遇到的問題)

init:
    --[[
        name            :   item類名 
        totalItemNum    :   item總數
        ...             :   創建item時需要的參數
    --]]
    function ScrollView:setItemViewModel(name, totalItemNum, ...)

    主要代碼:
        -- 得到所需繪製item個數
        local count = math.ceil(self:getContentSize().height / self.tItemContentSize.height) + 1
        for i = 1, count do
            -- 創建item
            local view = CSBReaderLoad(name)
            view:init(...)
            if view.setIndex then
                view:setIndex(i)
            end

            -- 將父節點轉爲widget類型(原因可見 遇到的問題)
            local widget = quick.packageNodeToWidget(view.pLayer:getChildByName("LayerTouch"))
            view.pLayer = widget
            -- widget隨父節點透明度變化,默認是false
            widget:setCascadeOpacityEnabled(true)
            self:addChild(view.pLayer)

            -- 總數小於所需繪製item個數,需要隱藏多創建的
            view.pLayer:setVisible(i > 0 and i <= self.totalItemNum)
            -- 設置位置,注意我們加item是從下往上加的
            view.pLayer:setPositionY(self.tItemContentSize.height * (self.totalItemNum - i))
            table.insert(self.tItemView, view)
        end
update:
    function ScrollView:updateView(dt)

    主要代碼:
        -- 控制刷新時間
        self.updateTimer = self.updateTimer + dt
        if (self.updateTimer < self.updateInterval) then
            return 
        end
        self.updateTimer = 0;

        ...

        -- 遍歷所有創建的item,如果它們需要移動位置,就移動它們的位置,並讓它們重繪自己
        for i = 1, length do
            local item = self:getItemView(i)
            local viewPos = self:getPositionInView(item.pLayer);
            if increaseVal < 0 then
                if viewPos.y < minBoundary then
                    if item.index and item.setIndex then
                        item:setIndex(item.index - length)
                        item.pLayer:setVisible(item.index > 0 and item.index <= self.totalItemNum)
                    end

                    item.pLayer:setPositionY(item.pLayer:getPositionY() + offset)
                end
            elseif increaseVal > 0 then
                if (viewPos.y > maxBoundary and item.pLayer:getPositionY() - offset > -self.tContentSize.height) then
                    if item.index and item.setIndex then
                        item:setIndex(item.index + length)
                        item.pLayer:setVisible(item.index > 0 and item.index <= self.totalItemNum)
                    end

                    item.pLayer:setPositionY(item.pLayer:getPositionY() - offset)
                end
            end
        end




3. 遇到的問題

關於update

在3.x中lua啓用定時器有兩種方法:

  • 第一種方法 scheduleUpdateWithPriorityLua(update, priority)
    update - 刷新函數,
    priority - 優先級,

    此方法在Node類中實現,所以它的子類都可以使用。
    此方法默認爲每幀都刷新因此,無法自定義刷新時間。
    這裏,沒有用這個方法,是因爲ScrollView自己已經實現了update方法。
    所以,當我們重新註冊給ScrollView一個update的時候,發現無法替換。
    這裏涉及到計時器存儲刷新方法:
    刷新方法通過哈希表存儲,在主循環期間,不移除已有方法,而是將它暫停,且恢復時不加載新方法,而是將原有方法恢復。

啓用定時器的源碼如下:

void Node::scheduleUpdateWithPriorityLua(int nHandler, int priority)
{
    unscheduleUpdate();

#if CC_ENABLE_SCRIPT_BINDING
    _updateScriptHandler = nHandler;
#endif

    _scheduler->scheduleUpdate(this, priority, !_running);
}

執行: unscheduleUpdate();
會先判斷節點是否有update方法,在哈希表中查找,並執行移除方法:

tHashUpdateEntry *element = nullptr;
HASH_FIND_PTR(_hashForUpdates, &target, element);
if (element)
{
    if (_updateHashLocked)
    {
        element->entry->markedForDeletion = true;
    }
    else
    {
        this->removeUpdateFromHash(element->entry);
    }
}

上面移除方法,會根據_updateHashLocked值來執行,
它爲真時,
如果節點原來有update,就先廢棄它,廢棄的方法是,將它標記爲已刪除,並讓它暫停。注意!這裏並沒有真正的刪除,而是將他表示是否刪除的字段改值。
它爲假時,
直接從哈希表中移除update方法。

執行:_scheduler->scheduleUpdate(this, priority, !_running);
加入update,也會先從哈希表中查找update,再執行添加方法。

tHashUpdateEntry *hashElement = nullptr;
HASH_FIND_PTR(_hashForUpdates, &target, hashElement);
if (hashElement)
{
    // check if priority has changed
    if ((*hashElement->list)->priority != priority)
    {
        if (_updateHashLocked)
        {
            CCLOG("warning: you CANNOT change update priority in scheduled function");
            hashElement->entry->markedForDeletion = false;
            hashElement->entry->paused = paused;
            return;
        }
        else
        {
            // will be added again outside if (hashElement).
            unscheduleUpdate(target);
        }
    }
    else
    {
        hashElement->entry->markedForDeletion = false;
        hashElement->entry->paused = paused;
        return;
    }
}

添加方法,會先判斷優先級,如果優先級相同,那麼就恢復原來的update。
否則,根據 _updateHashLocked 值執行接下來操作。

從移除和添加可以發現,關鍵值在於 _updateHashLocked的值,
這個值在Scheduler::update中設置,開始的時候設置爲true,最後結束設置爲false。
所以,如果要修改,就很麻煩,就放棄用這個方法了。
道理同樣適用於所有自己已經重寫了update,想要更換update情形。


  • 第二種方法,通過定時管理器調用
    就是上面指的Scheduler,不過我們不調ScrollView的,而是創建一個新的。
    scheduler:scheduleScriptFunc(update, inteval, isOnce)
    scheduler - cc.Director:getInstance():getScheduler()
    update - 更新方法
    inteval - 刷新時間間隔
    isOnce - 是否只執行一次

注意,如果用這個方法,需要負責創建,也要負責移除。
上面方法會返回一個id,之後可以通過這個id來刪除它。

cc.Director:getInstance():getScheduler():unscheduleScriptEntry(id)


爲什麼要把item包裝成Widget

在剛開始往ScrollView加child時,方法是將item的Node直接往ScrollView addChild(ScrollView封裝了它,其實就是往inner addChild)
但是當直接addChild時,會產生很多問題:比如按鈕吞噬觸摸,無法滑動等等。

那就要問一下了,爲什麼ListView沒事呢?
這其實是Cocos對繼承自ccui.Widget的事件的處理。
所有的控件事件監聽都是單點觸摸,並且會吞噬事件。

_touchListener = EventListenerTouchOneByOne::create();
CC_SAFE_RETAIN(_touchListener);
_touchListener->setSwallowTouches(true);
_touchListener->onTouchBegan = CC_CALLBACK_2(Widget::onTouchBegan, this);
_touchListener->onTouchMoved = CC_CALLBACK_2(Widget::onTouchMoved, this);
_touchListener->onTouchEnded = CC_CALLBACK_2(Widget::onTouchEnded, this);
_touchListener->onTouchCancelled = CC_CALLBACK_2(Widget::onTouchCancelled, this);
_eventDispatcher->addEventListenerWithSceneGraphPriority(_touchListener, this);

在widget的onTouchBegan, onTouchMove, onTouchEnd中,都會調用 propagateTouchEvent,
這個方法是傳播事件,每個子節點會吞噬事件,自己處理完,再向父節點傳遞,一般ScrollView、ListView、PageView會處理這些事件。

Widget* widgetParent = getWidgetParent();
if (widgetParent)
{
    widgetParent->_hittedByCamera = _hittedByCamera;
    widgetParent->interceptTouchEvent(event, sender, touch);
    widgetParent->_hittedByCamera = nullptr;
}

可以看出,只有繼承自Widget類的,纔會接收到interceptTouchEvent,並進行處理。
而且,ScrollView的interceptTouchEvent 已經處理好了按鈕的點擊,取消等效果。

void ScrollView::interceptTouchEvent(Widget::TouchEventType event, Widget *sender,Touch* touch)
{
    if(!_touchEnabled)
    {
        Layout::interceptTouchEvent(event, sender, touch);
        return;
    }
    if(_direction == Direction::NONE)
        return;
    Vec2 touchPoint = touch->getLocation();
    switch (event)
    {
        case TouchEventType::BEGAN:
        {
            _isInterceptTouch = true;
            _touchBeganPosition = touch->getLocation();
            handlePressLogic(touch);
        }
        break;
        case TouchEventType::MOVED:
        {
            _touchMovePosition = touch->getLocation();
            // calculates move offset in points
            float offsetInInch = 0;
            switch (_direction)
            {
                case Direction::HORIZONTAL:
                    offsetInInch = convertDistanceFromPointToInch(Vec2(std::abs(sender->getTouchBeganPosition().x - touchPoint.x), 0));
                    break;
                case Direction::VERTICAL:
                    offsetInInch = convertDistanceFromPointToInch(Vec2(0, std::abs(sender->getTouchBeganPosition().y - touchPoint.y)));
                    break;
                case Direction::BOTH:
                    offsetInInch = convertDistanceFromPointToInch(sender->getTouchBeganPosition() - touchPoint);
                    break;
                default:
                    break;
            }
            if (offsetInInch > _childFocusCancelOffsetInInch)
            {
                sender->setHighlighted(false);
                handleMoveLogic(touch);
            }
        }
        break;

        case TouchEventType::CANCELED:
        case TouchEventType::ENDED:
        {
            _touchEndPosition = touch->getLocation();
            handleReleaseLogic(touch);
            if (sender->isSwallowTouches())
            {
                _isInterceptTouch = false;
            }
        }
        break;
    }
}

之前的方法有問題,就是因爲直接將Node addChild到ScrollView,當觸摸傳遞到Node,發現無法轉成Widget對象,就放棄了向上傳播事件。
所以,需要將item包裝成Widget來讓它將事件傳遞給ScrollView。




4. 總結

怎麼用這個呢?

  1. 調用 ScrollView:setItemViewModel(item, item總數, 創建item所需的額外參數)
  2. 所有的item要有方法 item:setIndex(index), 並且以 self.index 作爲自己的index[這裏可以寫一個類來封裝,讓所有item都繼承它]
  3. 在刪除的時候,要將ScrollView的每幀更新方法移除

現在,ScrollView已經可以重用item了。
但是,還是比較粗糙;做爲一個控件,僅僅是這樣可不行。
之後,會對這個控件慢慢優化,讓它支持更多的功能,更加得心應手。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章