记一例 Android 无障碍服务(Accessibility)引发的崩溃

Logs

来自线上用户的一个神奇崩溃,日志如下:

java.lang.IndexOutOfBoundsException: setSpan (-1 ... -1) starts before 0
    at android.text.SpannableStringBuilder.checkRange(SpannableStringBuilder.java:1330)
    at android.text.SpannableStringBuilder.setSpan(SpannableStringBuilder.java:684)
    at android.text.SpannableStringBuilder.setSpan(SpannableStringBuilder.java:676)
    at android.view.accessibility.AccessibilityNodeInfo.setText(AccessibilityNodeInfo.java:2645)
    at android.widget.TextView.onInitializeAccessibilityNodeInfoInternal(TextView.java:11652)
    at android.view.View.onInitializeAccessibilityNodeInfo(View.java:8257)
    at android.view.View.createAccessibilityNodeInfoInternal(View.java:8216)
    at android.view.View.createAccessibilityNodeInfo(View.java:8201)
    at android.view.AccessibilityInteractionController$AccessibilityNodePrefetcher.prefetchDescendantsOfRealNode(AccessibilityInteractionController.java:1204)
    at android.view.AccessibilityInteractionController$AccessibilityNodePrefetcher.prefetchAccessibilityNodeInfos(AccessibilityInteractionController.java:1029)
    at android.view.AccessibilityInteractionController.findAccessibilityNodeInfoByAccessibilityIdUiThread(AccessibilityInteractionController.java:341)
    at android.view.AccessibilityInteractionController.access$400(AccessibilityInteractionController.java:75)
    at android.view.AccessibilityInteractionController$PrivateHandler.handleMessage(AccessibilityInteractionController.java:1393)
    at android.os.Handler.dispatchMessage(Handler.java:107)
    at android.os.Looper.loop(Looper.java:214)
    at android.app.ActivityThread.main(ActivityThread.java:7356)
    at java.lang.reflect.Method.invoke(Native Method)
    at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:492)
    at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:930)

当用户触发无障碍服务 (Accessibility) 时,会遍历可点击的 TextView。TextView 再去创建 AccessibilityNodeInfo 传递给无障碍服务,但 AccessibilityNodeInfo 在获取文本用于构造 SpannableStringBuilder 时却发生了异常—— java.lang.IndexOutOfBoundsException

Why?

下面这段摘抄自 AccessibilityNodeInfo.java 的代码告诉了我们原因:

AccessibilityNodeInfo.java
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
public void setText(CharSequence text) {
enforceNotSealed();
mOriginalText = text;
// Replace any ClickableSpans in mText with placeholders
if (text instanceof Spanned) {
ClickableSpan[] spans =
((Spanned) text).getSpans(0, text.length(), ClickableSpan.class);
if (spans.length > 0) {
Spannable spannable = new SpannableStringBuilder(text);
for (int i = 0; i < spans.length; i++) {
ClickableSpan span = spans[i];
if ((span instanceof AccessibilityClickableSpan)
|| (span instanceof AccessibilityURLSpan)) {
// We've already done enough
break;
}
int spanToReplaceStart = spannable.getSpanStart(span);
int spanToReplaceEnd = spannable.getSpanEnd(span);
int spanToReplaceFlags = spannable.getSpanFlags(span);
spannable.removeSpan(span);
ClickableSpan replacementSpan = (span instanceof URLSpan)
? new AccessibilityURLSpan((URLSpan) span)
: new AccessibilityClickableSpan(span.getId());
spannable.setSpan(replacementSpan, spanToReplaceStart, spanToReplaceEnd,
spanToReplaceFlags);
}
mText = spannable;
return;
}
}
mText = (text == null) ? null : text.subSequence(0, text.length());
}

上述代码关键是在替换 text中的 ClickableSpan 对象为 AccessibilityURLSpan或者AccessibilityClickableSpan

  1. 首先,从原始的 text 中获取的 ClickableSpan 对象数组 spans
  2. 其次,遍历获取每个 ClickableSpan 在原始 text 中的位置。
  3. 最后,替换掉 Spannable 对应位置的 ClickableSpan

崩溃就发生最最后一步 spannable.setSpan(...)。程序执行到这里的时候, spanToReplaceStartspanToReplaceEnd都是 -1,就是说对应的 ClickableSpan 在经过 SpannableStringBuilder 拷贝后不见了 !!

why ???

如何代码获取 Flutter APP 的 FPS

众所周知,官方提供了好几个办法来让我们在开发 Flutter app 的过程中可以使用查看 fps等性能数据,如devtools,具体见文档 Debugging Flutter appsFlutter performance profiling 等。

但是这些工具统计到的数据充其量只能算开发过程中的“试验室”数据,假如需要统计app 在线上在用户手机上的运行情况,该如何在 flutter 端代码里自己计算性能数据,比如 fps 这个值呢?

经过阅读源码,发现其实很简单,给window对象注册 onReportTimings 即可,去看api文档

1
2
3
4
5
6
7
8
void main() {
runApp(...);
window.onReportTimings = _onReportTimings;
}

void _onReportTimings(List<FrameTiming> timings) {
// TODO
}

代码给你看

考虑计算fps,只需要保留最近 NFrameTiming 来计算即可,最好用类似stack的数据结构存起来,参考了文档,我们选用 Queue ,N 指定为 100

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const maxframes = 100; // 100 帧足够了,对于 60 fps 来说
final lastFrames = ListQueue<FrameTiming>(maxframes);

void _onReportTimings(List<FrameTiming> timings) {
// 把 Queue 当作堆栈用
for (FrameTiming timing in timings) {
lastFrames.addFirst(timing);
}

// 只保留 maxframes
while (lastFrames.length > maxframes) {
lastFrames.removeLast();
}
}

lastFrames 的头就是最后一帧,尾是队伍里最开始的一帧,现在你可以计算 FPS 了:

1
2
3
4
5
6
7
8
double get fps {
int frames = lastFrames.length;
var start = lastFrames.last.timestampInMicroseconds(FramePhase.buildStart);
var end = lastFrames.first.timestampInMicroseconds(FramePhase.rasterFinish);
var duration = (end - start) / Duration.microsecondsPerMillisecond;

return frames * Duration.millisecondsPerSecond / duration;
}

但,你会发现,这样算出来和官方工具算的对不上,而且错的离谱。

why ??

捕获 flutter app的崩溃日志并上报

flutter 的崩溃日志收集主要有两个方面:

  1. flutter dart 代码的异常(包含app和framework代码两种情况,一般不会引起闪退,你猜为什么)
  2. flutter engine 的崩溃日志(一般会闪退)

Flutter App 代码异常捕获

人写的代码是无数异常交织起来的偶然产物,代码发生异常才是正常情况。

除了在关键的地方加上 try-catch 让它们变成已知异常之外,抓到未知异常才是真本事。

比如下面的一段代码中的try-catch是无效的:

1
2
3
4
5
try {
Future.error("asynchronous surprise");
} catch (e){
print(e)
}

好在,Dart 有一个 Zone 的概念,有点类似sandbox的意思。不同的 Zone 代码上下文是不同的互不影响,Zone 还可以创建新的子Zone。Zone 可以重新定义自己的printtimersmicrotasks还有最关键的how uncaught errors are handled 未捕获异常的处理

1
2
3
4
5
runZoned(() {
Future.error("asynchronous error");
}, onError: (dynamic e, StackTrace stack) {
reportError(e, stack);
});

reportError 里即可以进行上报处理(详见后面介绍)。

Flutter framework 异常捕获

注册 FlutterError.onError 回调,用于收集 Flutter framework 外抛的异常。

1
2
3
FlutterError.onError = (FlutterErrorDetails details) {
reportError(details.exception, details.stack);
};

该 error 一般是由 Widgetbuild 的时候抛出,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@override
void performRebuild() {
Widget built;
try {
built = build();
} catch (e, stack) {
built = ErrorWidget.builder(_debugReportException(ErrorDescription("building $this"), e, stack));
} finally {
_dirty = false;
}
try {
_child = updateChild(_child, built, slot);
} catch (e, stack) {
built = ErrorWidget.builder(_debugReportException(ErrorDescription("building $this"), e, stack));
_child = updateChild(null, built, slot);
}
}

简单几行代码给你的android应用生成更难以阅读的混淆字典

普遍的,不论大小Android应用都会配置 proguard 在release 编译的时候混淆自己的代码:

1
2
3
4
5
6
7
8
android {
buildTypes {
release {
minifyEnabled true
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
}
}

但无论 Proguard 还是 R8,他们的混淆字典默认都太简单了,只是 abcdefg 而已,反编译后还是很容易阅读的,如下所示:

1
2
3
4
5
6
7
8
final class b {
Object a;
aes b;
ada c;

b() {
}
}

所幸,Proguard 支持自定义字典:

1
2
3
-obfuscationdictionary dict.txt
-classobfuscationdictionary dict.txt
-packageobfuscationdictionary dict.txt

如果,有那么一个字典,里面的字或词不仅费眼睛电脑字体还没收录的更佳。

万能的github 上还真有符合要求的。但是直接生成好的字典文件一直用也是有隐患的,如果两个版本之间类、方法的个数差别不大的话,最终的mapping其实是很相似的,有可能前一个版本叫 aa,现在叫 ab。

而且翻看了部分实现方案,要么是字典文件里词汇量不够大,要么生成代码实现可能有其它bug。故而干脆自己撸起袖子几行代码搞定。

flutter run lost libflutter.so when using abiFilters

Reproduce

Add abiFilters to android/app/build.gradle :

1
2
3
4
5
6
7
android {
defaultConfig {
ndk {
abiFilters 'armeabi-v7a'
}
}
}

Run command:

1
$ flutter run

Flutter app crashed by java.lang.UnsatisfiedLinkError:

java.lang.UnsatisfiedLinkError: dalvik.system.PathClassLoader[DexPathList[[zip file “/data/app/xxxx/base.apk”],nativeLibraryDirectories=[/data/app/xxxx/lib/arm, /data/app/xxxx/base.apk!/lib/armeabi-v7a, /system/lib, /vendor/lib, /product/lib]]] couldn’t find “libflutter.so”
at java.lang.Runtime.loadLibrary0(Runtime.java:1011)
at java.lang.System.loadLibrary(System.java:1660)
at io.flutter.view.FlutterMain.startInitialization(FlutterMain.java:156)
at io.flutter.view.FlutterMain.startInitialization(FlutterMain.java:133)
at io.flutter.app.FlutterApplication.onCreate(FlutterApplication.java:22)

As you can see, flutter run seems like forgot to put “libflutter.so” into apk.

程序员是个热爱学习的群体

cat.jpg

譬如你跟他说某开源项目很好值得深入学习,他可能点个star表示已阅,或者点个fork表示可以深入研究,clone代码那是万万不可能的。

但假如在某程序员聚集地说某项目源码泄漏了,那景象好比炸窝,蜂拥而上,生怕吃不上热的,star+fork+clone素质三连,忙得不亦乐乎。

费劲周折毕竟拿到代码了,也不管看不看得懂,像是对着名画,总要品头论足一番,凭着一知半解发表几点高论。

开动鹰眼扫代码要是发现点奇特的地方,那如同发现新大陆,喜不自胜,更丝毫不吝啬自己的言辞到处去说。

程序员真的是个热爱学习的群体。

修改 Gradle 插件(Plugins)的下载地址(repositories)

Gradle 也可以用下面的方式声明使用的插件:

1
2
3
4
// build.gradle
plugins {
id 'com.example.plugin', version '1.0'
}

其实是从 Gradle 官方的插件仓库 https://plugins.gradle.org/m2/ 下载的。

但是,众所周知的原因,某些地区会连不上,导致下载不到需要的插件,例如出现如下错误:

1
2
3
4
5
6
7
* What went wrong:
A problem occurred configuring root project 'MyApp'.
> Could not resolve all files for configuration ':classpath'.
> Could not download jimfs.jar (com.google.jimfs:jimfs:1.1)
> Could not get resource 'https://plugins.gradle.org/m2/com/google/jimfs/jimfs/1.1/jimfs-1.1.jar'.
> Could not HEAD 'https://plugins.gradle.org/m2/com/google/jimfs/jimfs/1.1/jimfs-1.1.jar'.
> Connect to d29vzk4ow07wi7.cloudfront.net:443 [d29vzk4ow07wi7.cloudfront.net/54.192.84.6, d29vzk4ow07wi7.cloudfront.net/54.192.84.168, d29vzk4ow07wi7.cloudfront.net/54.192.84.128, d29vzk4ow07wi7.cloudfront.net/54.192.84.173] failed: Read timed out

又或者,插件是不对外的,存在某个私有仓库的,该如何修改或者添加额外的私有仓库地址呢?

如何简单快速搭建 Android 大仓

书接上文,上回提到 B 站Android团队为了解决组件化后协作上的问题,已经采用了大仓(monorepo)的方案来组织代码。

国内实践大仓的团队少之又少,更别提 Android 的大仓了,几乎没有来自其它团队的可借鉴经验。在这条路上,我们可以算作先行者。本文粗陋,文中所列思路不可能适用所有团队,仅给同样想实践Android 大仓的人些许启发。

一个标准的 Gradle 项目

首先回顾一下 Android 项目的组织方式。自从13年开始官方逐渐迁移到 Android Studio 做为 IDE 后,Android 项目的开发和编译就绑在 Gradle 上了。

一个标准的 Gradle 项目结构如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
MyApp/
├── build.gradle
├── settings.gradle
├── gradle
│ └── wrapper
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
├── gradlew
├── gradle.properties
└── app
├── build.gradle
└── src
└── main
├── java
├── res
└── AndroidManifest.xml

通常,会有多个Gradle Module存在:

1
2
3
4
5
6
7
8
9
10
11
12
MyApp/
├── build.gradle
├── settings.gradle
├── app
│ ├── build.gradle
│ └── src
├── lib1
│ ├── build.gradle
│ └── src
└── lib2
├── build.gradle
└── src

其中 settings.gradle 会注册所有的 Module

1
include ':app', ':lib1', ':lib2'

多仓库

随业务的扩张,Module 数量会越来越多。遵循多数人实践过的组件化的思路,按业务分仓库存放便理所当然:

1
2
3
4
5
6
7
8
9
10
11
12
13
android group/
├── MyApp/
│ ├── build.gradle
│ └── settings.gradle
├── app1/
│ ├── build.gradle
│ └── settings.gradle
├── app2/
│ ├── build.gradle
│ └── settings.gradle
└── libs/
├── build.gradle
└── settings.gradle

每个仓库都是一个标准 Gradle 项目,通过 publishing 插件将module 都上传 aar(或者jar)到 maven私服(如nexus)上,再在 MyApp/build.gradle 中以 maven 组件的形式依赖它们,最终打包成apk:

1
2
3
4
5
6
7
8
9
10
11
repositories {
maven {
name = "myRepo"
url = "http://myrepo.example.com/android"
}
}
dependencies {
implementation 'com.example.android:app-a:1.0.0'
implementation 'com.example.android:app-b:1.0.0'
implementation 'com.example.android:lib-a:1.1.0'
}

此时的代码组织方式便是上文中所述的多仓库形态(可能许多团队正处于当前阶段)。

多仓到大仓

那么,如何既能快速搭建出适用于 Android 的大仓,又能不影响当前的团队协作流程,还要尽量避免迁移带来的开发效率降低?

B 站 Android 代码库的演进历程

早在2012年,B 站 Android APP 便已上线。当时开发者不过一人,而如今,业务线众多、隶属不同团队的Android 端开发人员数以百计。从单兵作战到百花争鸣,代码库的组织管理也随之经过数次的改革、演进。

单仓库

2014年底,Android 端的常驻开发人员一只手也数的过来。业务发展迅速,为追求效率,方便管理,所有代码都在一个仓库中,甚至包括第三方的、开源的代码(个别用 git submodule 管理)。Clone下来导入 Eclipse 就可以开干。

到大约15年中旬,开始使用 Android Studio,得益于 Gradle 的项目管理理念,分出了多个 library module。外部依赖使用 maven。也是这一期间开始搭建了内网 maven 服务。

这期间代码库组织结构是:单仓库 + 个别 git submodule

这种组织方式好处显而易见:

  1. 项目结构简单:随时随地 clone 下来导入 IDE 即可以开始开发,代码所有人可见,没有额外的限制。
  2. 方便快速迭代:改动可以快速入库,适合小团队,review方便,改动透明

但是,约莫到16年中,业务发展,新团队纷纷成立,招聘要求降低人员迅速膨胀。这种小而美的代码库已经不适用了,主要有以下缺点:

  1. 代码结构混乱,模块之间依赖关系混沌:倒不如说是技术债,前期的疲于业务迭代,以及没有及时的规划出好的代码层级架构,如今人员纷杂水平不一,之前追求的“没有限制”反而诱发了恶果
  2. 编译时间变长:业务增速发展,代码量爆炸式增长,单机编译越来越慢,开发幸福感跌到谷地

Hexo 使用 DisqusJS 代理评论

博客目前用的是Hexo,没有后端,为静态博客,评论一般用的第三方系统,如常用的 Disqus。但众所周知的原因,在“火星”上无法正常访问Disqus。

隆重推荐一个通过api实现评论的项目:DisqusJS

这里说一下它的配置技巧。

添加DisqusJS 到 hexo主题

这里以很多人用的 Next 主题为例,其它类似。

修改_config.yml 中的disqus配置为:

1
2
3
4
5
6
7
8
# for DisqusJS, https://github.com/SukkaW/DisqusJS
disqus:
enable: true
shortname: #你的shortname
api: #https://disqus.com/api/
apikey: #你申请的public api key
admin: #你的名字
adminLabel: #你的特别标识

具体含义见DisqusJS

修改 layout/_partials/head/custom-head.swig

1
2
3
{% if theme.disqus.enable %}
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/disqusjs@0.2.5/dist/disqusjs.css">
{% endif %}