查看原文
其他

卡死 App 的神秘字符串,究竟是何方神圣(下)

快手大前端技术 快手大前端技术 2021-10-12

小编导读


上篇中,我们简单分析了神秘字符串导致 App 卡死的问题,找到了出现死循环的位置。但是,我们仍然不知道为什么会出现这样的情况,这段字符串的神秘之处在哪里?在本篇文章中,我们将继续来分析这个问题。


文 / aWang

编辑 / finn

本文共5170字,预计阅读时间13分钟。

#

背景介绍

内容已在本文上篇中推送,请点击上方链接阅读

#

“卡死”是怎么产生的

内容已在本文上篇中推送,请点击上方链接阅读

#

尝试问题复现

内容已在本文上篇中推送,请点击上方链接阅读

#

卡死 App 字符串的神秘之处

Unicode 断行算法

Unicode 双向算法 (Bidi 算法)

runs 末尾的双 0

runs 是怎么得来的

为什么没有被赋值

尝试构造能导致 ANR 的字符串

#

如何规避/处理该场景下的ANR

ANR 到底是怎么出现的

怎么避免这种场景下的 ANR


01

卡死 App 字符串的神秘之处


延续上篇内容,本文将继续分析神秘字符串导致 App 卡死的问题。


在继续翻阅源码和 Debug 的过程中,我们会遇到几个概念,比如 runs 和 level,在 getOffsetBeforeAfter 的入参也能看到它们。


在继续分析这个问题之前,我们先来了解几个关于文字排版的基础知识,以帮助我们更加顺畅地理解后面的分析过程。


这部分包含对网络资料的摘录整理以及一些个人的理解,如有纰漏还请老铁们不吝赐教。


Unicode 断行算法


断行是为了在显示一长串文字时能够自动换行 (在没有明确的换行符的情况下),比如对于同一段文字,我们调整 TextView 的宽度时会呈现不同的显示。但它并不是完全按照文字的宽度紧密排布的,而是有一套断行算法:告诉你什么时候必须断行,什么时候可以断行以及什么时候不能断行等。


算法的完整定义在这里:

UAX #14: Unicode Line Breaking Algorithm[1]


图 1


如图 1 所示,我们不断调整 TextView 的宽度,会发现自动换行时候的一些特点,比如对中文而言,书名号右半部分始终与其左边的文字一起换行,而英文单词基本上不会被拆分换行,这些都是由断行算法所定义的。


断行算法的核心逻辑是对所有字符归类,然后根据一定的规则判断字符间是否可以换行,其主要用途在于解析出给定字符串可以换行的地方。这里我们不对算法做过多的解析,下面给出几条其中的规则,第一条是后文分析中最为重要的一条:


 英语字母与空格一起时允许空格后面换行

○ 当英语字母处于破折号前面时不允许换行,允许在其后面换行;如果破折号后面是数字的话则不允许换行

○ 当左括号后面接着英语字母或者数字时不允许换行;当英语字母或者数字紧挨着右括号时不允许换行


Unicode 双向算法 (Bidi 算法)


大部分语言的文本在水平方向上都是按照从左到右 (Left To Right,简称 LTR) 的顺序显示字符的,但是也有不少语言是按照从右到左 (Right To Left,简称 RTL)的顺序。最简单的情况是一段文字里面只有一个方向,但在实际情况中,也存在一个字符串中同时包含 LTR 和 RTL 的文本,这就是双向文本。双向算法即用来处理双向文本的排版。


算法的完整定义在这里 :

UAX #9: Unicode Bidirectional Algorithm[2]


Bidi 算法把每一个字符进行了分类(Category),包括强字符、弱字符、中性字符等,每一种分类又有不同的类型(Type)。其中强字符表示它具有明确的方向信息,并且会影响其前后的中性字符的方向。


比如,中文汉字就是强字符,类型为 L;阿拉伯语、希伯来语也是强字符,类型为 R;空格为中性字符,而数字英文句号和逗号等为弱字符。这里的 L 也是 Left To Right 的意思,但和前面的 LTR 不是用来表达同一个事情:LTR 更多用来表示一段文字的方向,而 L 和 R 用来表示一个字符的类型(Type),如 L 表示强字符分类中类型为从左向右的字符。


另外 ,Bidi 类型不止 L 和 R,还有 AL、EN 等,详见 Bidirectional Character Types[3]。


除了以上三种字符分类外,还有一类“定向格式化字符” (也可以叫作“显式格式码”),如图 2 中的 RLE 和 PDF,可以用来控制或改变其他字符的显示顺序 (注意只影响显示顺序,其他方面比如文本比较、断句、词法分析等影响都会被忽略)。


这里再介绍几个概念,在后续的代码分析过程中也会涉及到:


嵌入等级(level)

也称嵌入水平,表示字符的嵌入层次,数字越大嵌入得越深。在Bidi算法中,字符串中的每个字符都有一个嵌入等级。


基础方向(base direction)

分段的方向被称为基础方向,它决定了该段文本从左侧还是右侧开始书写。


运行等级(level run)

也称定向运行(directional run),是指具有相同嵌入等级的字符所形成的最大子串,该子串与其直接接触的前后字符的嵌入等级不相同。


如图 2 所示,我们可以在 Unicode 官方网站上通过其提供的工具,去计算字符串的嵌入层级、显示顺序等信息,同时也会给出类型变换时所应用的规则。 (Unicode Utilities)[4]


图 2


Bidi 算法的部分执行过程


把文本分段 (在 Android 中,这里的段是一个按换行符分割后的字符串,代码位于 StaticLayout 类的 generate 方法),然后以段为作用域进行后续的算法处理。


首先,确定各个字符的嵌入等级。这就像是填空题,先填强类型,再填弱类型 (弱类型受离它最近的强类型影响,如果为L则为L,为R则为R),之后再填中性类型。然后是定向格式化字符,中间也会涉及到转换,以确认最终类型。接下来,会按照句子的层级 (EL) 以逻辑顺序从左到右去标记每一个字符的层级,也即嵌入等级。最后,我们得到的是一个 levels 数组,表示对应字符的嵌入等级。


有了嵌入等级之后,在计算出每一行 (这里的行是通过前面介绍的断行算法断行后的每一行) 中字符的运行方向,我们会得到一个 runs 数组。之后再根据以上信息,得到一行文本的视觉顺序 (显示顺序)。


关于更多算法细节,可以参考文章 "Unicode双向算法(bidi算法)详解"[5]。



理解了前面两个基础概念之后,我们再回来分析 ANR 出现场景下的问题。


在继续分析之前,可能有同学会问,介绍了这么多文字排版的内容,难道这个 ANR 的发生和文字排版有关系?那为什么一开始显示的时候没有出现 ANR 呢?


其实应该这样说,排版算法的一些特性构造出了 ANR 产生的条件,而在这个特定条件下,如果我们调用了 getOffsetToLeftRightOf 方法,就可能触发 ANR。下面让我们继续来分析这个过程。


runs 末尾的双 0


通过前面正向推导的过程,我们已经知道了 ANR 发生的位置,以及是因为死循环导致的 ANR。接下来,我们采用反向推导的方式来进行分析,先知道 ANR 产生的条件,包括数据和方法参数,然后去分析这些条件又是怎么构造出来的,以及这些条件为什么会导致ANR。


首先,我们已经知道问题出在了下述方法的调用里:"TextLine.getOffsetToLeftRightOf(int cursor, boolean toLeft)" ,可以看到,这个方法有两个参数。


这时我们会想,是不是所有的参数都会导致 ANR 的出现呢?我们可以写一个 for 循环从 0~len(str) 作为 cursor,同时变换 toLeft 的 true/false 看看哪几个参数会触发问题。最后我们得出结果,即如下条件时会触发 ANR:


1、getOffsetForHorizontal 的 line 参数为第 0 行

2、cursor 等于 lineEndOffset (也即这一行的长度)

3、toLeft 为 false


getOffsetToLeftRightOf 即获取指定 cursor 的偏移量。若 toLeft 为 false 则表示到右边的偏移量;若 toLeft 为 true 则表示到左边的偏移量。


这时我们需要思考为什么会是这几个参数值?它们影响和决定了什么?让我们继续来看代码。


图 3


如图 3,TextLine.getOffsetToLeftRightOf 方法的部分代码:


cursor == lineEnd 决定了 runIndex = runs.length,同时也决定了流程不能进入 L465~529,newCaret 还是 -1,无法返回,进入后续的边界处理流程。这里的 runs 就是前面提到的一行文本的方向;runIndex 是为了遍历每一个 run (Direction) 对信息的索引。


toLeft == false 决定了 advance 为 false (提前透露下 paraIsRtl 为 true),进一步决定了 otherRunIndex >=0 && < runs.length 条件满足,这样才能进入下面的分支条件,也就是进入 "getOffsetBeforeAfter" 方法。


接下来我们看影响 getOffsetBeforeAfter 方法的参数。在前面条件的基础上,经过测试,我们发现这里有一个关键参数 after 一定要为 true,也就是前面的 otherRunIsRtl 一定要为 false,一步一步反推,得到 "runs[otherRunIndex+1] = 0",也即 runs 的最后一个元素的值一定要为 0。在调试时进一步发现,触发 ANR 的场景下,runs 末尾倒数第二个元素也为 0。


runs 是怎么得来的


从上文图 3 我们可以看到,runs = mDirections.mDirections。从 Directions 的注释也可以看出,runs 是两个元素为一组存储了字符串的视觉顺序(非逻辑顺序)的信息,第一个为偏移量,第二个包含了运行等级的 length 和 level 等信息。


图 4


mDirections 的计算逻辑位于 AndroidBidi.directions 方法,runs 末尾的双零也在这里产生的。


其中方法参数 levels 就是前面介绍的嵌入等级,来自 AndroidBidi.runBidi 方法,进一步调用 icu 库所提供的功能。看到 ICU 不要慌不要怕哈,此 icu 非彼 ICU。在这里 ICU 的意思是 International Components for Unicode。


如图 5 所示,runCount 表示 pair 的长度,代码会遍历参数传入的 levels,发现相邻两个 level 不相等时,便会自增加1。我们可以把这里的 runCount 理解为运行等级的个数,而末尾双零也即最后一个运行等级没有被成功赋值。


而最后一个运行等级是因为满足 L84~L102 的条件 "(curLevel & 1) != (baseLevel & 1)" 被添加的,这里的条件可以理解为 “最后一个 runs 的方向和整一行的方向是否一致”,那为什么添加之后却又没有做赋值处理呢?


图 5


为什么没有被赋值?


我们继续来看代码,如果在满足了上述额外再添加一个 run 的条件的情况下,同时又满足刚好 “levels[len - 1] != levels[len - 2]” 条件,也即倒数第一个和倒数第二个 levels 不相等,比如 [1, 2, 1, 2, 2, 4] 就会出现问题。


这种情况下,runCount = 5,但 L122~L137 的 for 循环在执行过程中只处理了前 3 位,第 4 位进入 L139~L142 完成填充。而第五位,也就是 L100 增加的这一位,没有位置去对其进行赋值,也就出现了 runs 末尾双零的情况。


图 6


到目前为止,我们无法判断这是刻意为之还是这段处理逻辑的 Bug。


如果此处的双零不是 Bug,那么问题则是出在了上层使用 runs 数据的时候没有考虑到这种边界情况,并对其进行处理,就导致进入 L522 (getOffsetBeforeAfter) 之后在内部出现死循环,无法退出。


于是我便去翻找了一下 AOSP 上这段代码设计初衷[6],以及它的测试用例[7],还有后续的修改记录。如下图的测试用例可见,此处确实是为了处理空格位于一行末尾的时候 Case,但是也只考虑了 L 和 R 的组合场景,没有考虑定向格式化字符导致嵌入等级的改变。所以此处可以理解为代码 Bug,该 Bug 延续到了最新的安卓版本[8]也没有对其进行修复处理。


下面测试用例的 levels 为 [1, 2, 2, 2, 2, 2],其 runCount = 3。


但是假如构造一个 levels 为 [1, 2, 1, 2, 2, 2] 的字符串,并且最后一个字符为空格,那么其本身 runCount 为 4,而上述逻辑新增的另外一个 run 并没有得到处理,于是问题就出现了。


图 7


再回过头来看看神秘代码。


分析到这里,我们就会想,只要构造出上述 levels 的字符串是不是就能复现这个问题?


是的。不过在这之前,我们先看看前面的那一段神秘代码。揭开它的面纱,看看能否为我们构造字符串提供一些思路。


图 8


上图是此前导致微信出现卡死的神秘代码的 ASCII 值的 char 数组,每一行按照使用 AndroidBidi.bidi 得到的 levels 进行分组换行,注释为其 level 值,以方便查阅。


我们来看一下这几个首尾的“特殊”字符:


46: 句号 ".",FULL STOP (它是弱类型,所以会被其右侧的 1766 所影响变成 R)

○ 194: 带回旋的拉丁大写字母a (LATIN CAPITAL LETTER A WITH CIRCUMFLEX)

○ 8620: 带环的右箭头 (RIGHTWARDS ARROW WITH LOOP)

32: 空格 (注意空格是中性类型,其前一个字符为 8294: LRI LEFT-TO-RIGHT ISOLAT,是一个定向格式化字符,8294 32 的这个组合也是导致 ANR 的关键)

○ 1766: 阿拉伯语小叶 (ARABIC SMALL YEH)

○ 8675: 向下虚线箭头

 (DOWNWARDS DASHED ARROW)

 10032: 带阴影的白色五角星

 (SHADOWED WHITE STAR)


这样看来,这段神秘代码就没那么深奥了。它的关键其实就是利用了 Bidi 算法的特性,组合了阿拉伯语字符和定向格式化字符,其中一些其他不可见字符以及不常用字符只是为了增加其神秘性 (而阿拉伯语在这里是类型为 R 的字符,与类型为 L 的字符组合在一起就形成了双向文本,所以其他 R 类型的字符也可以,比如希伯来语)。


为什么刚好在这里?


针对上面的字符串,我们使用 Layout.getLineEnd(0) 得到的值是 81 (前面出现死循环的位置就是在这里),也就是 32, 32 的下一个字符位置,也就是说 32, 32 被分到了第一行。


图 9


在这里,我们可以看到 StaticLayout.generate 的处理逻辑:L773 和 774 调用了 nGetWidths 和 mComputeLineBreaks 去获取字符宽度以及换行信息,实际上这里的换行信息在此之前已经计算好了,在这里只是读取。


计算逻辑位于 L734~771 中。这里调用了 MeasuredText.addStyleRun 方法。该方法会一步步最终调用到 StaticLayout.nAddMeasuredRun 进入 C 层处理,计算断行的候选位置,处理过程由 minikin[9] 库实现。


图 10


如上图,在 MeasuredText.addStyleRun 方法里可以看到,如果是非 easy 模式 (即存在双向文本的情况),会根据相同 levels 的字符分批去调用 StaticLayout.Builder.addStyleRun 方法。


所以也能看出,levels 也会作为断行的依据 (是依据不是必然条件)。但是在这个场景下,前面我们介绍的 “Unicode 断行算法”里面提到的 “英语字母与空格一起时允许空格后面换行” 的优先级更高。这也能够说明为什么 8294 的 level 为 1,32 的 level 为 2,但是断行却断在了两个 32 之后。


尝试构造能导致 ANR 的字符串


经过前面的分析,我们已经知道应该如何构造这个神秘字符串了。我把干扰字符全部移除,换成了 A,之后各位老铁也可以根据自己的喜好,将其换成其他 Bidi 类型为 L 的字符即可。


构造这样一个字符串需要的条件如下:


1、阿拉伯语字符或者希伯来语字符

2、8294 (LRI) + 32

3、中间插入任意数量其他 Bidi 类型为 L 的字符


这里有一个小提示:8294 + 32 的组合出现次数越多,那么它被选为断行的概率越高,也就越容易出现 ANR。所以,为了确保一定能够出现 ANR,至少要保证有两到三个以上这样的组合出现在字符串里面。


char arabicChar = 1766;

arabicChar = 1727;

char[] chars = new char[]{

  arabicChar, 'A', 'A', 'A', 'A', 'A', 'A', 'A', 'A', 'A', 'A',

  arabicChar, 'A', 'A', 'A', 'A', 'A', 'A', 'A', 'A', 'A', 'A', 8294, 

  32, 'A', 'A', 'A',

  arabicChar, 'A', 'A', 'A', 'A', 'A', 'A', 'A', 'A', 'A', 'A',

  arabicChar, 'A', 'A', 'A', 'A', 'A', 'A', 'A', 'A', 'A', 'A', 8294,

  32, 'A', 'A', 'A',

  arabicChar, 'A', 'A', 'A', 'A', 'A', 'A', 'A', 'A', 'A', 'A',

  arabicChar, 'A', 'A', 'A', 'A', 'A', 'A', 'A', 'A', 'A', 'A', 8294,

  32, 'A', 'A', 'A',

};

↑可上下滑动查看


想必老铁们已经知道这个秘诀了,可以去尝试体验一下,不要干坏事哦~



这里,留个思考题:

是否只有 8294 + 32 才能复现这个 Case ?

除了 8294 以外,是否还有其他字符可以触发这个 Case 呢?


02

怎么规避或处理这种场景下的 ANR


要讨论这个问题,我们先来再总结一下 ANR 到底是怎么出现的。


一段双向文本里面存在 LRI + 空格的组合

这段双向文本被作为 Spanable 设置到了 TextView 里面

为了处理 Span 的准确点击,拦截 onTouch 事件,代码里使用了类似 LlinkMovementMethodOverride 的处理逻辑,即通过 getLineForVertical、getOffsetForHorizontal、getSpans 去获取 Span,然后触发相关事件

这个 TextView 的字体大小等配置恰好导致了在 LRI + 空格的下一个字符位置换行

用户触摸了这个 TextView 存在“LRI + 空格”组合的行

ANR 就这样产生了


怎么避免这种场景下的 ANR


下面为老铁们提供一些解决的思路。


其实,将上面流程中的某些因素改变就能规避这个问题,但不一定满足业务场景,大家可以权衡使用。


方式一: 在没有国际化需求的情况下,可以忽略 RTL 的逻辑,移除定向格式化字符 (它是不占宽度的)。


方式二: 判断行尾是否为类似 8294 + 32 的组合 (或者当前行最后一对 runs 是否为双 0),以及当前行的方向 (使用 Layout.getParagraphDirection 获取) 与当前行最后一个字符方向 (Layout.isRtlCharAt) 是否不一致,若是,则不进行本次 getOffsetForHorizontal 的调用。 


方式三: 使用 Paint.measureText 计算字符宽度,然后去比对 getOffsetForHorizontal 的 horiz 参数,也能达到同样的效果。


相关链接

[1]http://www.unicode.org/reports/tr14/8.1.0_r39/core/java/android/text/TextLine.java#640

[2]https://unicode.org/reports/tr9/

[3]https://unicode.org/reports/tr9/#Bidirectional_Character_Types

[4]https://util.unicode.org/UnicodeJsps/bidi.jsp?a=ab%14cd+%E2%80%ABef%14%D7%A4%D7%A1%E2%80%AC%14qr&p=Auto

[5]https://www.sohu.com/a/348173901_298038

[6]https://android.googlesource.com/platform/frameworks/base/+/9f7a4442b89cc06cb8cae6992484e7ae795323ab

[7]https://android.googlesource.com/platform/frameworks/base/+/9f7a4442b89cc06cb8cae6992484e7ae795323ab/core/tests/coretests/src/android/text/StaticLayoutDirectionsTest.java#114

[8]https://android.googlesource.com/platform/frameworks/base/+/master/core/java/android/text/AndroidBidi.java

[9]https://android.googlesource.com/platform/frameworks/minikin/+/refs/tags/android-8.1.0_r39


欢迎加入

快手主站技术部客户端团队由业界资深的移动端技术专家组成,通过领先的移动技术深耕工程架构、研发工具、动态化、数据治理等多个垂直领域,积极探索创新技术,为亿万用户打造极致体验。团队自2011年成立以来全面赋能快手生态,已经建立起业内领先的大前端技术体系,支撑快手在国内外的亿万用户。


在这里你可以获得:

  • 提升架构设计能力和代码质量

  • 通过大数据解决用户痛点的能力 

  • 持续优化业务架构、挑战高效研发效能

  • 和行业大牛并肩作战


我们期待你的加入!请发简历到:

app-eng-hr@kuaishou.com


: . Video Mini Program Like ,轻点两下取消赞 Wow ,轻点两下取消在看

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存