安卓输入法插件化的思考

探讨 Xime 输入法插件系统的设计思考:从基于 apk 的 DexClassLoader 方案(存在 R8 边界问题和性能损耗),到改用嵌入 lua 运行时的小语言插件方案。

发布于 约 4 分钟

叠个甲,我不是安卓开发工程师,以下内容只是我的个人愚见。

Xime 输入法从1.0 以来,就有一个插件系统。这个灵感来源于一个漫画阅读器(mihon),当时看到这个漫画阅读器的源是通过 apk 插件实现的,觉得非常有趣。

Xime 实现的时候,我的想法很单纯,app 本体只保留我需要的基本功能,其他一堆有的没的滚到插件里面去,比如表情包,比如,一些 AI 功能。

Xime 在 1.0 的时候,智能联想模型都是一个插件。 2.0 的时候,我灰溜溜地把智能联想内置进了主app,至此,插件功能只有表情包了。

一个很现实的问题,模型的推理,是需要推理框架运行时的。如果每个插件都自带一个运行时的话,这画面不敢想象。

如果把运行时集成进主app呢?插件还是有很大的开发难度。模型的推理工程,比一般的开发要难得多,这也会造成提供出来也没有太大的意义,而且还会存在可能的效率问题。

更重要的是,手机是一个资源非常受限,非常非常看中功耗的应用场景。这意味着,你无论做什么事,首先想到的,应该是省电,其实才是能不能用,好不好用。

Xime 里的模型推理代码,最开始还是在安卓层实现的,但是从2.5 版本开始后, 为了更好的效率,所有推理代码都不在安卓层了,直接移动到了 cpp 层。

如果用插件实现,这很难做到更快的效率。

难用的插件系统

我自己的理解是,无论是mihon 还是xime 还是fcitx-android 的插件系统,使用 apk 格式的插件包,本质上其实还是只是一个伪装成apk安装包的资源包,用户安装后,主app 还是会把它复制到自己的数据空间里引用(DexClassloader)。

由于安卓包在 release 构建的时候,都会走 R8 优化的。使用 DexClassloader 机制最大的问题是,边界问题,一不小心,你可能在插件里使用到了一个方法,但是这个方法可能主app 没用到,直接被 R8 优化掉了。

也就是说,apk 插件里并不是安卓的那些 API 都能用的,这个后果就是造成主app的崩溃。

开放更多的 App ProGuard rules 就意味着,APP 包的膨胀。我有强迫症,无法接受。

有没有其他方案可以参考呢?

我用的安卓 app 不多,我确实没见到有哪些app (除了上面使用 apk 插件以外)有插件系统。也就是说,没作业可抄了。

但是其实原理大差不差,都是通过嵌入一个图灵完备的小语言 runtime 作为插件执行系统,比如 quickjs、lua,甚至 python。

rime 本身的方案系统就有通过 lua 来实现的一些小逻辑。因此,我决定在安卓app里嵌入 lua runtime 。

问题1

既然 Xime 已经支持rime 的lua 功能了,也就是说已经在 cpp 层集成的lua runtime,为什么还要在安卓层集成 lua runtime?

答案是,如果使用 cpp 层的 lua runtime,调用链是 kotlin->cpp->lua。 这里出现了双层的 FFI 调用,别小看这个调用链的耗时,在一些场景里可能真的会是卡顿的原因。而 luaj 其实是纯 java 实现的,因此不存在2层的FFI 调用。

当然,luaj 效率怎么样,我也不太清楚。目前感觉还可以。

问题2

为什么不用其他更流行的语言?比如 quickjs,比如 python。

其实使用js或者python 理论上应该是最适合的,因为这些最流行。但是我们不得不考虑的是性能问题。lua 它真的很小很小,quickjs 都比lua 大,更不要说python了。

还有一个就是上面的第一个问题原因,由于rime 使用的就是lua,那么从使用惯性上来说,也还是lua。

lua 插件系统

所以,基于 lua 的插件系统,替换掉了原来难用的 DexClassloader 方案。

这是一个非常简洁的插件方案,比如这个调用了千问平台的插件:

➜  plugins git:(main) ✗ tree funasr-asr 
funasr-asr
├── main.lua
├── manifest.yaml
└── resources
    └── icon.png

为了方便管理资源以及识别,插件会被打包成 xipk 后缀(和 apk 一样,其实就是 zip)。