一、卡死 App 字符串的神秘之处

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

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

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

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

Unicode 断行算法

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

算法的完整定义在这里:

UAX #14: Unicode Line Breaking Algorithm[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),是指具有相同嵌入等级的字符所形成的最大子串,该子串与其直接接触的前后字符的嵌入等级不相同。

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

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 则表示到左边的偏移量。

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

如上图,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 是怎么得来的

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

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

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

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

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

为什么没有被赋值?

我们继续来看代码,如果在满足了上述额外再添加一个 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 末尾双零的情况。

到目前为止,我们无法判断这是刻意为之还是这段处理逻辑的 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 并没有得到处理,于是问题就出现了。

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

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

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

上图是此前导致微信出现卡死的神秘代码的 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 被分到了第一行。

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

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

如上图,在 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 呢?

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

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

  1. 一段双向文本里面存在 LRI + 空格的组合
  2. 这段双向文本被作为 Spanable 设置到了 TextView 里面
  3. 为了处理 Span 的准确点击,拦截 onTouch 事件,代码里使用了类似 LlinkMovementMethodOverride 的处理逻辑,即通过 getLineForVertical、getOffsetForHorizontal、getSpans 去获取 Span,然后触发相关事件
  4. 这个 TextView 的字体大小等配置恰好导致了在 LRI + 空格的下一个字符位置换行
  5. 用户触摸了这个 TextView 存在“LRI + 空格”组合的行
  6. 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

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

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

    一.事情是这样的 我正在热火朝天地吃着面的时候呢,老大在群里 @ 了我一下,吓得我碗都掉面里了. 于是无知的我为了体验一下这个 Bug,就把神秘代码复制直接发群里了.发完之后,点了点,戳了戳,发现没卡 ...

  2. ZEROC究竟是何方神圣? Leader-us 大神来的回答 Leader-us mycat的发起者

    ZEROC究竟是何方神圣? 标签:框架类,其他发布于 2016-08-10 15:26:09 本文整理自<ZeroC Ice权威指南>作者Leader-us针对网友提出的ZeroC 问题的 ...

  3. 技术17期:近几年崛起的Pytorch究竟是何方神圣?

    谷歌的 TensorFlow已经是一个非常成熟的框架,但是最几年Facebook 的 PyTorch却异军突起,逐渐成为热门,而且似乎有要赶超TensorFlow的趋势,PyTorch究竟是何方神圣? ...

  4. IoT边缘,你究竟是何方神圣?

    摘要:IoT边缘扮演着纽带的作用,连接边缘和云,将边缘端的实时数据处理,云端的强大计算能力两者结合,创造无限的价值. 本文分享自华为云社区<IoT边缘如何实现海量IoT数据就地处理>,作者 ...

  5. 天眼——究竟是何方神圣?

    外星人到底长啥样?究竟有没有外星人?外星人.不明飞行物一直在人们的视线中若隐若现,引得众多好奇者的探寻,但又没有真实确切见过他们.但你还别说,我们可能真的快找到外星人了. 外星人到底长啥样?究竟有没有 ...

  6. Atlas200I DK A2开发者套件,究竟为何方神圣?

    Atlas 200I DK A2是众多昇腾AI开发者所期待的产品,今天,小编特别针对这个产品,带来了最新前方消息,它究竟是何方神圣呢?让我们一起来看下吧. 这是一款面向AI算法验证和应用开发的开发者套 ...

  7. 让Intel决绝放弃傲腾的CXL,究竟是何方神圣?

    2022年8月,一场存储行业的盛会FMS在美国Santa Clara成功举办,作为存储行业人士,FMS的内容每年都会成为大家讨论的焦点.今年,CXL技术---一种新的CPU.GPU.TPU互联标准成功 ...

  8. 让阿里京东疯狂掐架的方兴东,究竟是何方神圣?

    点击上方"CSDN",选择"置顶公众号" 关键时刻,第一时间送达! 我们都知道阿里巴巴和京东一直是竞争关系,毕竟前有双十一线上线下的火拼,后有刘强东一直以来都很 ...

  9. 让华为小米抱团 统一推送联盟究竟是何方神圣?

    如果你是一个安卓用户,相信你一定遇到过这样的问题:每次解锁手机时都会收到一堆恼人的推送消息,即使这个软件并没有在后台运行.垃圾推送消息,这个比较恼人的话题在不久之后可能就会成为历史--在10月份宣布成 ...

最新文章

  1. 文本纠错与BERT的最新结合,Soft-Masked BERT
  2. uWSGI+Nginx安装、配置
  3. OSPF身份验证配置实例
  4. SQLSTATE[42S22]: Column not found: 1054 Unknown column 'tbl_contact' in 'where clause'.
  5. RichTextBox 右键显示 ContextMenuTrip
  6. spring面向AOP之动态代理
  7. win10证书服务器不可用,win10系统提示“安全证书的吊销信息不可用”的修复方法...
  8. 堂堂小米手表竟比不上小天才电话手表?不支持视频和拍照...
  9. Jvm内存分析入门篇
  10. NYOJ975 - 关于521
  11. 【二维码识别】基于matlab GUI 灰度+二值化+校正二维码生成与识别【含Matlab源码 635期】
  12. Android手机怎么打开exe,安卓手机如何打开.exe文件 安卓手机exe文件怎么打开
  13. Java实现MD5加盐加密算法
  14. 解析搜狗微信文章页面源码的日期publish_time为空的解决办法(只谈思路,不提供代码)
  15. Linux内核设计与实现 总结笔记(第六章)内核数据结构
  16. “黎明”号新任务继续“锁定”谷神星
  17. elasticsearch进阶(3)—— ilm policy
  18. 读完这篇文章,颠覆你之前对硬盘开盘的认知!
  19. 莫队算法(普通莫队、带修莫队、树上莫队、不删除莫队)学习笔记【理解+套路/核心代码+例题及题解】
  20. 硬件工程师入门基础知识(一)基础元器件认识(一)

热门文章

  1. electron-vue operation not permitted
  2. java数组精讲-多案例-够详细
  3. 基于MySQL的加密解密方式
  4. C# 三十二、Hashtable(哈希表)
  5. PM3破解辅助计算器
  6. ue中的经纬高转xyz的问题
  7. U3d脚本注意事项及两个基本函数的简单介绍
  8. java-php-python-ssm流浪动物救助网站设计与实现计算机毕业设计
  9. 电脑密码的十二种破解方法
  10. 这个是干货,好久没和大家见面了,一直在学,但是Python犹如大海,无尽无止