用樹型模型管理App數字和紅點提示(附原始碼Demo)

張鐵蕾發表於2016-06-30

我們平常接觸到的大部分App,在收到新訊息的時候一般都會以數字或紅點的形式提示出來。比如在微信當中,當某位好友給我們發來新的聊天訊息的時候,在相應的會話上就會有一個數字來表示未讀訊息的數目;再比如當微信朋友圈裡有人釋出新的內容時,朋友圈的入口就會出現一個紅點,而當朋友圈裡有人給我們點了贊,或者對我們釋出的內容進行了評論的時候,朋友圈的入口就會顯示一個數字。

但是,我們在試用一些新的App產品時,總會發現它們在數字和紅點展示上存在各種各樣的問題。比如,紅點怎麼點選也清除不掉;或者,發現有數字了,點進去卻什麼也沒有;或者,點進去看到的數字和外面看到的不一樣。

那這些問題到底是怎樣產生的呢?

我猜測,問題產生的根源是:沒有對數字和紅點的展示邏輯做一個統一的抽象和管理,以至於各種數字和紅點之間的關係錯綜複雜,牽一髮而動全身。這樣,在App的維護過程中,稍微有一點改動(比如增加幾個數字或紅點型別),出現問題的概率就很高。

本文會提出一個樹型結構模型,來對數字和紅點的層次結構進行統一管理,並會在文章最後給出一個可以執行的Android版的Demo程式,以供參考。

如果您現在手頭正好有一部Android手機,那麼您可以先掃描下面的二維碼(或點選二維碼下面的下載連結)下載安裝這個Demo,花幾分鐘看看它是否對您有用。

或者點選下載連結

樸素的數字紅點管理方式

為了討論方便,我們首先對一般情況下數字和紅點展示的需求做一個簡單的整理,然後看看根據這樣的需求最直觀的實現方式可能是怎樣的。

  • 有些新訊息是重要的,需要展示成數字;有些新訊息不那麼重要,需要展示成紅點。比如,我收到了新評論,或收到了新的點贊,以數字表示比較合理;而對於一些系統發給我的系統訊息,我希望它不會太乾擾到我的視線,這時以比較輕的紅點形式展示比較合理。
  • 數字和紅點是需要分級展示的。當有新訊息到來時,使用者可以從App首頁(即第一級頁面)出發,根據數字和紅點提示,逐級深入到更深的頁面,最終到達展示新訊息的終端頁面。比如在下面的App截圖中,當使用者收到新評論的時候,首先會在第2個Tab(即“訊息”那個Tab)上出現數字提示,引導使用者進入第2個Tab頁面,然後在頁面中“收到的評論”旁邊會繼續顯示數字提示,引導使用者點選進入更深一級的評論頁面。

  • 如果某一級的數字提示,在它更深一級的頁面上包含多個數字提示,那麼本級數字應該是更深一級頁面的數字之和。比如上圖中的訊息數5=4+1。
  • 如果某一級的數字(紅點)提示,在它更深一級的頁面上既有數字也有紅點,那麼本級優先按數字展示;如果更深一級的頁面上數字都被清掉了,只有紅點了,那麼本級才按照紅點展示。比如下面的App截圖中,頁面上只有系統訊息了,而系統訊息是展示紅點,所以第2個Tab上也變成紅點展示了。

相信以上總結的幾點,跟大多數App的展示邏輯大體類似。即使有一些差別,應該也不妨礙我們接下來的討論。

好,現在我們就以上面App截圖中的具體情形來考慮一下實現。“訊息”Tab包含“收到的評論”、“收到的贊”和“系統訊息”,其中評論和贊是數字,系統訊息是紅點。

我們單獨考慮“訊息”這個Tab上的數字紅點展示邏輯,不難寫出類似如下的程式碼(偽碼):

int count = 評論數 + 贊數;
if (count > 0) {
    展示數字count
}
else if (有系統訊息) {
    展示紅點
}
else {
    隱藏數字和紅點
}複製程式碼

這段程式碼當然能實現需求,但是缺點也是很明顯的。其中最關鍵的是,它要求在“訊息”這個Tab上的展示邏輯要列舉下面包含的所有子訊息型別(評論、贊、系統訊息),並且知道每個型別是數字還是紅點。上面只是給出了兩級頁面的情況,如果出現三級頁面甚至更多級呢?那麼這些資訊就要在各級頁面上重複一遍。

這會造成維護和修改變得複雜。想象一下,在“訊息”下面又增加了一個新的訊息型別,或者某個型別的訊息從數字展示變成紅點展示了,甚至是某個型別的訊息,從一個頁面棧移動到了另一個頁面棧了。所有這些情況,都要求更高層級的所有頁面都對應進行修改。當一個App的訊息型別越來越多,達到幾十個的時候,可以想象這種修改是很容易出錯的。

基於樹型模型的數字紅點管理方式

上面說的問題,我們在微愛{:target="_blank"}App開發的初期也遇到過。後來,我們重新審視了App中紅點和數字展示的結構,使用樹型結構來看待它,讓維護工作變得簡單。

一個App的頁面本身就是分級的,對於頁面的訪問路徑本質上就是個樹型結構。

如上圖所示,節點1代表第1級頁面,這個頁面下面包含三個更深一級(第2級)的頁面入口,分別對應節點2,3,4。再深一級就到了終端頁面,以綠色的方形節點表示。

這個樹型的模型可以如下表述:

  • 葉子節點(綠色方形的節點)表示最終要展示訊息的終端頁面。訊息在葉子節點上如何展示,是產品設計的時候就定好的。比如,它可以直接把訊息展示出來,或者先展示一個數字,點進去再展示訊息內容(就像前面App截圖中的評論數提示),也或者可以彈框來提示。總之,它的展示樣式是固化在產品業務的程式碼中的。
  • 中間節點(圓形的橙色節點)表示從第1級頁面到達訊息終端頁面訪問路徑上的頁面。中間節點上的展示一般就是數字或紅點。
  • 每一個訊息型別,我們稱為一個Badge Number。它具有三個屬性:
    • type: Badge Number型別。
    • count: 計數,對於每個Badge Number,每個使用者一個計數。
    • displayMode: 當前badge number在父節點上的顯示方式。0表示紅點,1表示數字。
  • Badge Number根據所屬業務型別的不同,分屬不同的大類(Category)。每個大類內的Badge Number型別type分配在同一個型別區間內。比如上面樹型結構圖中2,3,4節點就分別對應三個業務型別,也就是三個大類,它們對應的型別區間分別為[A, C], [X, Y], [R, T]。再舉一個實際的例子,比如微信朋友圈是一個業務大類,裡面的Badge Number型別包括:有人評論我(數字),有人給我點贊(數字),好友有新訊息釋出(紅點),等。

為了使得一個大類內的Badge Number能用一個型別區間來表達,我們在為型別分配值的時候,可以採取類似這樣的方式:用一個int來表示Badge Number型別,而它的高16位用來表示大類。比如“訊息”大類高16位是0x2的話,那麼它包含的三種Badge Number型別(type)就可以這樣分配:

  • 收到的評論:(0x2 << 16) + 0x1
  • 收到的贊:(0x2 << 16) + 0x2
  • 系統訊息:(0x2 << 16) + 0x3

這樣,“訊息”這一大類就可以用一個型別區間[(0x2 << 16) + 0x1, (0x2 << 16) + 0x3]來表達。

有了型別區間之後,我們重新看一下樹型模型裡面的中間節點。它們都可以用一個或多個型別區間來表示。它們的展示邏輯(是展示成數字,還是紅點,還是隱藏),需要對所有子樹的型別區間求和。具體求和過程是:

  • 先對所有型別區間裡的數字型別進行求和,如果大於0,則展示數字;否則,
  • 對所有型別區間裡的紅點型別進行求和,如果大於0,則展示紅點;否則,
  • 隱藏數字和紅點。

樹型模型的程式碼實現

樹型模型的實現,我們稱為Badge Number Tree,本文提供了一個Android版的Demo實現,原始碼可以從GitHub下載:github.com/tielei/Badg…

下面我們把關鍵部分分析一下。

Android版本的主要實現類為BadgeNumberTreeManager,它的關鍵程式碼如下(為了不影響我們理解主要邏輯,非關鍵程式碼在下面忽略了,沒有貼出。如需檢視請到GitHub下載原始碼):

/**
 * 用於非同步返回結果的介面.
 */
public interface AsyncResult<ResultType> {
    void returnResult(ResultType result);
}

/**
 * 樹型結構的badge number管理器.
 */
public class BadgeNumberTreeManager {
    /**
     * 設定badge number
     * @param badgeNumber
     * @param asyncResult 非同步返回結果, 會返回一個Boolean引數, 表示是否設定成功了.
     */
    public void setBadgeNumber(final BadgeNumber badgeNumber, final AsyncResult<Boolean> asyncResult) {
        ...
    }

    /**
     * 累加badge number
     * @param badgeNumber
     * @param asyncResult 非同步返回結果, 會返回一個Boolean引數, 表示是否累加操作成功了.
     */
    public void addBadgeNumber(final BadgeNumber badgeNumber, final AsyncResult<Boolean> asyncResult) {
        ...
    }

    /**
     * 刪除指定型別的badge number
     * @param type 指定的badge number型別.
     * @param asyncResult 非同步返回結果, 會返回一個Boolean引數, 表示是否刪除成功了.
     */
    public void clearBadgeNumber(final int type, final AsyncResult<Boolean> asyncResult) {
        ...
    }

    /**
     * 獲取指定型別的badge number
     * @param type 型別。取聊天的badge number時,傳0即可。
     * @param asyncResult 非同步返回結果, 會返回指定型別的badge number的count數.
     */
    public void getBadgeNumber(final int type, final AsyncResult<Integer> asyncResult) {
        ...
    }

    /**
     * 根據一個型別區間列表計算一個樹型父節點總的badge number。
     * 優先計算數字,其次計算紅點。
     *
     * 一個型別區間列表在實際中對應一個樹型父節點。
     *
     * @param typeIntervalList 指定的badge number型別區間列表, 至少有1一個區間
     * @param asyncResult 非同步返回結果, 會返回指定型別的badge number的情況(包括顯示方式和總數).
     */
    public void getTotalBadgeNumberOnParent(final List<BadgeNumberTypeInterval> typeIntervalList, final AsyncResult<BadgeNumberCountResult> asyncResult) {
        //先計算顯示數字的badge number型別
        getTotalBadgeNumberOnParent(typeIntervalList, BadgeNumber.DISPLAY_MODE_ON_PARENT_NUMBER, new AsyncResult<BadgeNumberCountResult>() {
            @Override
            public void returnResult(BadgeNumberCountResult result) {
                if (result.getTotalCount() > 0) {
                    //數字型別總數大於0,可以返回了。
                    if (asyncResult != null) {
                        asyncResult.returnResult(result);
                    }
                }
                else {
                    //數字型別總數不大於0,繼續計算紅點型別
                    getTotalBadgeNumberOnParent(typeIntervalList, BadgeNumber.DISPLAY_MODE_ON_PARENT_DOT, new AsyncResult<BadgeNumberCountResult>() {
                        @Override
                        public void returnResult(BadgeNumberCountResult result) {
                            if (asyncResult != null) {
                                asyncResult.returnResult(result);
                            }
                        }
                    });
                }
            }
        });
    }


    private void getTotalBadgeNumberOnParent(final List<BadgeNumberTypeInterval> typeIntervalList, final int displayMode, final AsyncResult<BadgeNumberCountResult> asyncResult) {
        final List<Integer> countsList = new ArrayList<Integer>(typeIntervalList.size());
        for (BadgeNumberTypeInterval typeInterval : typeIntervalList) {
            getBadgeNumber(typeInterval.getTypeMin(), typeInterval.getTypeMax(), displayMode, new AsyncResult<Integer>() {
                @Override
                public void returnResult(Integer result) {
                    countsList.add(result);
                    if (countsList.size() == typeIntervalList.size()) {
                        //型別區間的count都有了
                        int totalCount = 0;
                        for (Integer count : countsList) {
                            if (count != null) {
                                totalCount += count;
                            }
                        }

                        //返回總數
                        if (asyncResult != null) {
                            BadgeNumberCountResult badgeNumberCountResult = new BadgeNumberCountResult();
                            badgeNumberCountResult.setDisplayMode(displayMode);
                            badgeNumberCountResult.setTotalCount(totalCount);
                            asyncResult.returnResult(badgeNumberCountResult);
                        }
                    }
                }
            });
        }
    }

    private void getBadgeNumber(final int typeMin, final int typeMax, final int displayMode, final AsyncResult<Integer> asyncResult) {
         ...
   }


    /**
     * badge number型別區間。
     */
    public static class BadgeNumberTypeInterval {
        private int typeMin;
        private int typeMax;

        public int getTypeMin() {
            return typeMin;
        }

        public void setTypeMin(int typeMin) {
            this.typeMin = typeMin;
        }

        public int getTypeMax() {
            return typeMax;
        }

        public void setTypeMax(int typeMax) {
            this.typeMax = typeMax;
        }
    }

    /**
     * badge number按照一個型別區間計數後的結果。
     */
    public static class BadgeNumberCountResult {
        private int displayMode;
        private int totalCount;

        public int getDisplayMode() {
            return displayMode;
        }

        public void setDisplayMode(int displayMode) {
            this.displayMode = displayMode;
        }

        public int getTotalCount() {
            return totalCount;
        }

        public void setTotalCount(int totalCount) {
            this.totalCount = totalCount;
        }
    }

}複製程式碼

在這段程式碼中我們需要注意的點包括:

  • 前面對於Badge Number的增刪改查4個操作——setBadgeNumber、addBadgeNumber、clearBadgeNumber、getBadgeNumber,它們都比較簡單,實現程式碼這裡沒有貼出來。實際上在Demo中,是基於SQLite本地儲存來實現的。我們需要注意的是各個操作的應用場景:
    • setBadgeNumber用於一般的新訊息提醒,在新訊息提醒產生時被呼叫,將Badge Number存入本地。這些Badge Number中的count值由伺服器來維護,所以以伺服器為準,每次從伺服器獲取到之後,就調動setBadgeNumber覆蓋本地的值。
    • addBadgeNumber用於本地累加計數的訊息提醒,比如聊天訊息。一個使用者接收的新聊天訊息是依靠本地計數的,因此使用addBadgeNumber累加計數。
    • clearBadgeNumber用於清除指定型別的Badge Number。通常來說,當使用者在訊息終端頁面(樹型的葉子節點)上閱讀完新訊息後,需要清除Badge Number。
    • getBadgeNumber,根據指定型別獲取Badge Number的值,用於在訊息終端頁面(樹型的葉子節點)上展示訊息的時候呼叫。
  • 最後有一個private的getBadgeNumber方法,它和前面public的過載方法不同,它不是取指定的某一個型別的Badge Number,而是取一個型別區間[typeMin, typeMax]裡的指定顯示方式(displayMode)的Badge Number總數。這個方法是實現中間節點上Badge Number展示邏輯的基礎。這裡的實現程式碼也沒有貼出來,它的實現其實也比較簡單,在Demo中是基於SQLite做的一個求和(sum)操作來實現的。
  • public的getTotalBadgeNumberOnParent是一個關鍵的方法,它用於實現中間節點上Badge Number展示邏輯。輸入的typeIntervalList引數是一個型別區間的列表,對應一箇中間節點。它的非同步輸出引數是一個BadgeNumberCountResult物件,可以表達三種展示結果:數字、紅點、隱藏(無顯示)。這個方法的實現是呼叫了它的另一個私有過載方法,先後對型別區間列表上的數字型別和紅點型別分別進行求和(這就是前面講的對中間節點所有子樹型別區間求和的實現)。

呼叫getTotalBadgeNumberOnParent的程式碼例子如下:

    BadgeNumberTypeInterval typeInterval = new BadgeNumberTypeInterval();
    typeInterval.setTypeMin(BadgeNumber.CATEGORY_NEWS_MIN);
    typeInterval.setTypeMax(BadgeNumber.CATEGORY_NEWS_MAX);

    List<BadgeNumberTypeInterval> typeIntervalList = new ArrayList<BadgeNumberTypeInterval>(1);
    typeIntervalList.add(typeInterval);

    BadgeNumberTreeManager.getInstance().getTotalBadgeNumberOnParent(typeIntervalList, new AsyncResult<BadgeNumberCountResult>() {
        @Override
        public void returnResult(BadgeNumberCountResult result) {
            if (result.getDisplayMode() == BadgeNumber.DISPLAY_MODE_ON_PARENT_NUMBER && result.getTotalCount() > 0) {
                //展示數字
                showTabBadgeCount(tabIndex, result.getTotalCount());
            } else if (result.getDisplayMode() == BadgeNumber.DISPLAY_MODE_ON_PARENT_DOT && result.getTotalCount() > 0) {
                //展示紅點
                showTabBadgeDot(tabIndex);
            } else {
                //隱藏數字和紅點
                hideTabBadgeNumber(tabIndex);
            }
        }
    });複製程式碼

關於實現上的一些補充說明

  • 在Demo程式中,BadgeNumberTreeManager的底層儲存使用的是SQLite。但是,由於BadgeNumberTreeManager的介面呼叫很頻繁,因此在實現中還加入了中間一級記憶體快取(詳見GitHub程式碼)。
  • 客戶端通過某種方式獲取到新的Badge Number後,將它存入本地(通過BadgeNumberTreeManager的setBadgeNumber和addBadgeNumber介面)。而客戶端獲取Badge Number的方式可能有多種,比如通過長連線推送到客戶端(App自己實現的長連線,或者第三方平臺的長連線),或者通過HTTP服務拉取得到(這種方式適用於實時性不強的新提示)。
  • 中間節點Badge Number的展示重新整理邏輯(即呼叫BadgeNumberTreeManager的getTotalBadgeNumberOnParent介面),需要在必需的所有時機執行。以本文給出的Android版Demo為例,這些時機包括:頁面onResume的時候,子Tab切換的時候,獲取到新的Badge Number的時候。展示重新整理邏輯執行的時機不精確,或者有遺漏,也是App數字紅點展示出現問題的一個常見原因。
  • 中間節點Badge Number的清除,常見的有兩種情況:(1)所有子節點都清除了它才清除;(2)只要點選了就清除,而不管子節點是否都清除了。本文給出的Demo是按前一種情況實現的。如果想實現後一種情況,需要為每個中間節點再單獨記錄一個標記,但這個改動並不大。
  • 雖然本文給出的程式碼示例是基於Android Java的,但本文給出的樹型模型,也可以用於非Android Java版本的App實現。

相關文章