国庆前几天,微信Android大量用户反馈接收或发送类似“15。。。。。。。。。。。。。。。”信息会导致微信聊天界面卡死,程序崩溃。微信研发团队得知问题后第一时间对这个问题进行了紧急修复并在两天内覆盖了全网大部分用户,最终这个问题得到了解决。首次揭露bug背后的故事。
大家好,给大家介绍一下,这是Bug
应该有很多Android的用户熟悉上面这图。。。
一、背景
国庆前几天,微信Android大量用户反馈接收或发送类似“15。。。。。。。。。。。。。。。”信息会导致微信聊天界面卡死,程序崩溃。这对微信来说是很严重的事情啊,一时半会反馈也铺天盖地的过来,我们得知这个问题后,第一时间对这个问题进行了紧急修复并在两天内覆盖了全网大部分用户,最终这个问题得到了解决。追根溯源,毫无疑问这锅开发小弟来背,这次不能冤枉了产品MM哈哈。
与此同时,很多热心的网友也开始分析原因,25号当日就有行内大神通过ANR日志和反编译debug,一步步推敲出此次ANR的根源,给出了卡死的原因。请受小弟一拜,实在佩服佩服!
详情可参考链接:http://www.easemob.com/news/861
下图是网友分析结果图:
根据该网友的推敲,此次卡死的真正原因在于:“这个wwk是始终等于0的,也就是不满足while内部的dVar2的置空条件,也就造成了while死循环”。这里具体怎么做到动态反编译的?
这个知乎的回答很详细,https://www.zhihu.com/question/65828771
二、原因揭晓:
真正的原因确实如网友分析的,主要是卡在了这个while循环里面,这个循环的主要作用是将当前文字内容按具体的规则进行断句排版。
因为dVar2且dVar2.getText一直不为空,一直满足这个条件,所以造成死循环。而dVar2这个值为null的条件取决于下面这个函数
“i4”变量实际是断句算法返回截断的实际位置,dvar2.getLength()实际是当前行的文字长度,这里因为断句算法的bug,造成了”i4”这个变量一直返回0,而当前行文字长度dvar2.getLength()是>0的,所以这个dVar2永远不会被赋值为空。继续追根问底:是什么原因造成断句算法一直返回0呢,实际上断句算法是调用了以下这个函数:
该函数返回了一个对象a其包含两个参数,一个是断句的位置(a.wwk),及断句后的文字长度(a.width),主要是因为在判断换行的时候,因为考虑到标点符号不应该位于行首这条规则,需要将当前行最后一个非标点符号截断到下一行,而截断受另外一条规则限制,截断不可以为英文或者数字,这导致“15。。。。。。。。。。。。。。。”最后返回截断的位置为0,并将结果返回,所以才产生了死循环,造成这个bug。
那么问题来了
很多网友也开始讨论,为什么要自己排版,放着好端端的系统TextView不用?到底好在哪里?效果是怎么样的?
不着急,诸多问题的来龙去脉得容小弟一一道来。
三、为什么有这个需求
实际上,世界上大部分需求都源于用户。这需求还得得益于之前有几个用户会反馈说“微信Android的聊天气泡好像没有iOS的美观,比较死板”。这个问题也引起了我们的关注。
那事实是否如此呢?我们对iOS和Android进行了对比,如下图:
从效果图看,iOS确实比Android好看了些,至少最右边并不会有多余的padding这么明显,简单来说多余的padding产生的原因是气泡宽度受屏幕大小的限制,所以这里TextView即是气泡有了最大的宽度限制,当剩下的空间不足以容下一个字符时,系统排版会选择自动换行,导致了这个问题的产生。
又一个问题
那么,iOS的排版是否就是完美的呢,其实仔细观察并非这样,从上图可以看出,除了Android,iOS也会有这种问题,那就是气泡中的文字左右参差不齐。
一开始我们怀疑,会不会是微信应用本身使用该组件不当的原因造成,而非系统组件的问题。于是乎,在手机上,我们随便找了一些热门app,仔细对比,同样的问题依然存在。
知乎:
掘金:
支付宝:
等等。。。
而且除了移动端,pc端同样也有诸类问题。结合上面这些对比,确实市面上大部分应用都存在这个问题。通过这次反馈,我们也开始在思考能不能在移动客户端的文字排版上做得更人性化一些,体验上更好?。就这个问题,我们找了设计的同学一起探讨,认为确实有这个必要。于是就开始有了下一步。
四、排版要怎么排?
对于文字排版,这容易让人想起,“我的(word)哥”,微软对于这款应用,有没有一些文字左右对齐的手段或者方案可以参考呢?
下图为word的左对齐效果,也就是Android的TextView默认对其方式。
下图为word的居中‘硬’对齐效果:
下图为word的居中‘软’对齐效果:
从这种效果上看,“软对齐方式”更美观,体验最好。
于是我们能想到的就是动态调整字间距的方式来实现这种效果(word也是这么实现的)。
那既然要动态调整字体间距,是不是可以一味的这么做就可以?
答案当然不是,如果这么做就像‘硬对齐方式’一样,显得过于生硬了。
我们就这个问题跟设计组的同事进行讨论,通过他们的调研及尝试,得出了一个合理的方案,那就是最多允许有一个英文字符宽度的调整范围,将调整的宽度平均分配到当前行每个字符中去,对用户来说影响是最小的,同时也保持了一定的美观。
五、实践自定义排版
对于Android来说,实现这条规则并不难,要么是改造系统TextView,要么自己写个自定义view实现文字排版及渲染,最后我们采用了后者这个方案。
原因在于:
系统TextView真正排版及绘制的逻辑不在其本身,而是交给三个继承了Layout的子类负责,分别为StaticLayout、DynamicLayout、BoringLayout,我们更常用的是StaticLayout,它只负责静态的文字处理,关于各自Layout的区别,这里了就不展开讲了。系统TextView并没有暴露接口去代理它们。当然没有接口不意味着做不到,我们完全可以通过反射等手段代理它,但其实这么做的话,代价是比较大的。
原因有三:
其一,从Android 2.3到Android 8.0,TextView的代码虽说变化不会很大,但从Layout来看,实现的逻辑或者接口也好都有所变更,如果通过这个方式,代理的兼容性会是一个问题。
其二,TextView堪称Android最复杂的一个组件之一,几个Layout逻辑代码的复杂程度很高,自己实现所有的Layout接口,本身就是一件复杂且工作量很大的工作。
其三,实际上自己实现一个Layout,基本上就实现了一个显示组件,排版和渲染都是要处理的,所以这样实现的意义不大,甚至反而不灵活
回归正题,我们对系统TextView的规则进行对比,最后我们确定了以下几条规则:
1、最多允许有一个字母字符宽度的来调整字间距
2、对于标点符号尽量规避不出现在行首
3、对于英文单词或数字不截断排版
于是我们开始进行简单的demo实现。效果如下图:
对比优化前的效果,确实这么做效果是明显的。但仔细观察,还是会发现,对于一些特殊的中文全角符号(如,《》()【】等)因为有多余的padding存在,放在行首和行末也会导致参差不齐的效果。于是我们多增加了一条规则
4、对一些常见的有多余padding的全角符号位于行首或行末时,默认减去多余的padding来达到更好的对齐效果。
最后的优化效果,如图:
最后一张是应用了4条规则的效果图,整体文字的对齐效果比系统默认的排版改善了不少。
问题又来了
那既然效果是不错的,是否存在其他问题?确实如此。
一、小语种处理问题
因为微信对小语种是支持的,对于一些特殊的小语种,如泰语,阿拉伯语等,泰语的排版方式并非简单的横排,字符与字符之间是有上下关系的,而对于阿拉伯语,是从右往左排列的。如果只是按上面所讲的几个规则,那么排版后的效果肯定是不合理的。
考虑到小语种存在多样性,排版规则不统一,而且使用小语种用户比例小,但也不能让其排版错误不管,所以对于这种情况,我们通过一个简单的正则表达式去匹配是否属于能处理的字符串范围内,这就是为什么有网友分析”15。。。。。。。。”这个事件时,一开始会怀疑是正则匹配耗时造成的。
下图为该网友的分析:
而实际上,这个简单的正则表达式,如该网友测试的一样,处理起来很快,基本都在1ms内,对性能的影响可忽略。
通过正则去判断后,如果是可处理的字符串则应用上面的规则进行排版,如果是特殊的字符串,则用系统的TextView代理显示。
二、适配率问题
既然小语种的问题可以解决,但这里又产生一个问题,现网上的用户, 使用特殊字符的频率多高?这问题直接关系到我们这个排版组件的适配率,也就是对用户体验改善多少?在我们看来,一般人并不会发些奇奇怪怪的符号在微信里面,所以能应用上这个排版规则的应该占大多数。当然这里只是猜想,如果这样确定可行性也太草率了。
于是我们针对这个问题,进行了一轮灰度,灰度的结果如下:
灰度结果
目标灰度人数40W
setText总次数400w+
平均命中率96%+
通过这次灰度,现网用户能应用上该组件适配的情况达到了预期的结果。
三、性能问题
如果该组件的性能跟系统相差太多,甚至严重影响帧率,造成用户卡顿,这当然也是不可取的。我们针对这个问题,进行了本地的自动化帧率测试及与系统TextView进行函数间的对比:
实验数据:
AttributesCellTextViewTextViewRemarks
FPS(good)53.9354.11聊天界面,各类长短文本,跑同一个case,在好机器上的帧率
FPS(bad)41.9141.41差机器上的帧率
setText(ns)13452088839618相同1000char的文字,连续每隔100ms,setText一次,统计30次平均耗时
onMeasure(ns)21528816111setText触发onMeasure,30次的平均耗时
onDraw(ns)165160242459097setText触发onDraw,30次的平均耗时
sum(ns)2001411311304826setText整个过程,30次的平均耗时
结论:
从微观上,通过函数进行对比,CellTextView对比系统TextView性能稍差2倍,主要差距在于绘制文字时需要单字调整间距。
从宏观上,CellTextView对实际帧率的影响较小,用户无明显感知性能变差。
通过以上的尝试及灰度结果来看,做这个事情其实是很有意义的,那么最后也敲定下了这个优化方案。
结尾
整个需求的来龙去脉就是这样子的,其实梳理这个过程的来龙去脉来,一来可以让自己不断反思该过程存在的一些问题,二来呢,因为本次bug确实对大家造成了不好的影响(真的是深感歉意啊!),可以让大家清楚这个事情是怎么发生的,至少大家不会卡得不明不白的。
写代码万万要小心谨慎,考虑周全啊。这次痛定思痛,吃一堑,长一智吧。愿天下的程序统统没有bug。对,统统没有!!!
最后贴上一张优化后的效果图:
文章写得不好的地方,望见谅,大神莫喷莫喷。小弟我要背锅去面壁了。
文章转自微信移动客户端开发团队官号,微信公众号:WeMobileDev