用树型模型管理App数字和红点提示


我们平常接触到的大部分App,在收到新消息的时候一般都会以数字或红点的形式提示出来。比如在微信当中,当某位好友给我们发来新的聊天消息的时候,在相应的会话上就会有一个数字来表示未读消息的数目;再比如当微信朋友圈里有人发布新的内容时,朋友圈的入口就会出现一个红点,而当朋友圈里有人给我们点了赞,或者对我们发布的内容进行了评论的时候,朋友圈的入口就会显示一个数字。

但是,我们在试用一些新的App产品时,总会发现它们在数字和红点展示上存在各种各样的问题。比如,红点怎么点击也清除不掉;或者,发现有数字了,点进去却什么也没有;或者,点进去看到的数字和外面看到的不一样。

那这些问题到底是怎样产生的呢?

我猜测,问题产生的根源是:没有对数字和红点的展示逻辑做一个统一的抽象和管理,以至于各种数字和红点之间的关系错综复杂,牵一发而动全身。这样,在App的维护过程中,稍微有一点改动(比如增加几个数字或红点类型),出现问题的概率就很高。

本文会提出一个树型结构模型,来对数字和红点的层次结构进行统一管理,并会在文章最后给出一个可以运行的Android版的Demo程序,以供参考。

如果您现在手头正好有一部Android手机,那么您可以先扫描下面的二维码(或点击二维码下面的下载链接)下载安装这个Demo,花几分钟看看它是否对您有用。

Demo APK Download

或者点击下载链接

朴素的数字红点管理方式

为了讨论方便,我们首先对一般情况下数字和红点展示的需求做一个简单的整理,然后看看根据这样的需求最直观的实现方式可能是怎样的。

  • 有些新消息是重要的,需要展示成数字;有些新消息不那么重要,需要展示成红点。比如,我收到了新评论,或收到了新的点赞,以数字表示比较合理;而对于一些系统发给我的系统消息,我希望它不会太干扰到我的视线,这时以比较轻的红点形式展示比较合理。
  • 数字和红点是需要分级展示的。当有新消息到来时,用户可以从App首页(即第一级页面)出发,根据数字和红点提示,逐级深入到更深的页面,最终到达展示新消息的终端页面。比如在下面的App截图中,当用户收到新评论的时候,首先会在第2个Tab(即“消息”那个Tab)上出现数字提示,引导用户进入第2个Tab页面,然后在页面中“收到的评论”旁边会继续显示数字提示,引导用户点击进入更深一级的评论页面。

Badge Count Demo

  • 如果某一级的数字提示,在它更深一级的页面上包含多个数字提示,那么本级数字应该是更深一级页面的数字之和。比如上图中的消息数5=4+1。
  • 如果某一级的数字(红点)提示,在它更深一级的页面上既有数字也有红点,那么本级优先按数字展示;如果更深一级的页面上数字都被清掉了,只有红点了,那么本级才按照红点展示。比如下面的App截图中,页面上只有系统消息了,而系统消息是展示红点,所以第2个Tab上也变成红点展示了。

Badge Dot Demo

相信以上总结的几点,跟大多数App的展示逻辑大体类似。即使有一些差别,应该也不妨碍我们接下来的讨论。

好,现在我们就以上面App截图中的具体情形来考虑一下实现。“消息”Tab包含“收到的评论”、“收到的赞”和“系统消息”,其中评论和赞是数字,系统消息是红点。

我们单独考虑“消息”这个Tab上的数字红点展示逻辑,不难写出类似如下的代码(伪码):

1
2
3
4
5
6
7
8
9
10
int count = 评论数 + 赞数;
if (count > 0) {
	展示数字count
}
else if (有系统消息) {
	展示红点
}
else {
	隐藏数字和红点
}

这段代码当然能实现需求,但是缺点也是很明显的。其中最关键的是,它要求在“消息”这个Tab上的展示逻辑要列举下面包含的所有子消息类型(评论、赞、系统消息),并且知道每个类型是数字还是红点。上面只是给出了两级页面的情况,如果出现三级页面甚至更多级呢?那么这些信息就要在各级页面上重复一遍。

这会造成维护和修改变得复杂。想象一下,在“消息”下面又增加了一个新的消息类型,或者某个类型的消息从数字展示变成红点展示了,甚至是某个类型的消息,从一个页面栈移动到了另一个页面栈了。所有这些情况,都要求更高层级的所有页面都对应进行修改。当一个App的消息类型越来越多,达到几十个的时候,可以想象这种修改是很容易出错的。

基于树型模型的数字红点管理方式

上面说的问题,我们在微爱App开发的初期也遇到过。后来,我们重新审视了App中红点和数字展示的结构,使用树型结构来看待它,让维护工作变得简单。

一个App的页面本身就是分级的,对于页面的访问路径本质上就是个树型结构。

Badge Number Tree结构图

如上图所示,节点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下载源代码):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
/**
 * 用于异步返回结果的接口.
 */
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的代码例子如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
    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
我的微信公众号: tielei-blog (张铁蕾)
上篇: 女儿和她的图画书
下篇: Redis内部数据结构详解(4)——ziplist