用樹型模型管理App數字和紅點提示
我們平常接觸到的大部分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的消息類型越來越多,達到幾十個的時候,可以想象這種修改是很容易出錯的。
基于樹型模型的數字紅點管理方式
上面說的問題,我們在 微愛 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下載: https://github.com/tielei/BadgeNumberTree 。
下面我們把關鍵部分分析一下。
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實現。
來自:http://zhangtielei.com/posts/blog-badge-number-tree.html