-
Notifications
You must be signed in to change notification settings - Fork 8
librime 向 WebAssembly 的移植
RIME 引擎需要 boost、yaml-cpp、marisa、OpenCC、glog 这 5 个库的支持才能运行。emscripten 给我们提供了一套友好的编译环境,我们使用 emscripten 提供的 emmake、emcmake 等工具就可以实现库的交叉编译。每个库编译好后,只需运行库自带的 make install 命令,就可以把库安装到 emscripten 提供的一个虚拟 sysroot 中,后续编译其他程序的时候就可以直接使用。
依赖库的下载和编译无疑是一个繁琐的过程,每个库的编译过程和编译选项都不尽相同,需要调试。我最初移植 RIME 引擎到 WASM 环境的时候,花了两天才搞定所有的依赖项。为了使后续安装依赖项变得更简单,我们把这个过程包装成了一个“一键”的脚本:wasm-builder/dep/build-all。只需要运行这个脚本,就会自动编译安装所有依赖库。在安装时,对于 boost 和 OpenCC 库,需要打两个小补丁,脚本会自动帮我们打上。
RIME 重度依赖于文件系统,其配置、词库等都必须从文件系统中读取。WebAssembly 标准本身并不包含文件系统的内容,我们必须使用 emscripten 环境提供的文件系统支持。emscripten 提供了多种文件系统支持的方式,本项目选用比较新的 WasmFS 方式。WasmFS 的核心是一套标准的接口,这套接口支持多种后端实现,对上层的程序暴露统一的标准文件系统 API,而底层不同的后端可以用不同的方式实现数据的读取。
提升输入法的启动速度对用户体验非常关键,而这就要求文件系统的实现必须非常快。为了尽可能地提高性能,我们没有选用现有的后端,而是自己实现了一个后端,命名为 Fast IndexedDb Backend。该后端的实现分为两个部分:C++ 部分和 TypeScript 部分,C++ 部分的代码在 librime 仓库的 tools/custom-backend/wasm_fast_indexeddb.cpp 文件,主要是提供了对 TypeScript 部分的包裹。TypeScript 部分在 rime-extension 仓库的 fs.ts 文件实现,在这里面调用 IndexedDb 实现具体的功能。
我们的 fs 实现会在 IndexedDB 中创建两个 store:files 和 blobs。在 files 里面,存放了每个文件的元数据,元数据包含文件名、修改时间等基础信息,以及文件内容的 blob 列表,列表包含 blob 的 ID 和大小。blobs 中是类似于一个键值对的结构,键是 blob 的 ID,值是 blob 的数据。在进行列目录、文件信息读取的操作时,我们只需读取文件的元数据(体积较小),而无需读取完整的文件内容,只有当实际需要文件内容时,才会读取 blob。一个文件可以包含一个或多个 blob,在读取文件内容时,代码会根据 blob 列表中每个 blob 的大小,以及要读取的文件位置和长度,只读取对应区域的 blob。而在写入文件内容时,由于 IndexedDb 的数据只能整体更新,代码会判断出文件需要更新的区域,对区域中的 blob 进行替换,或在文件的 blob 列表末尾增加 blob。如果对一个文件多次写入,可能会出现 blob 数较多从而导致碎片化的问题,这是这样实现的缺陷。不过就 RIME 引擎而言,不存在这样的情况。
在使用 IndexedDB 时会发现,IndexedDB API 延迟较高,单个请求发出后要等待 3ms 左右才会返回结果。因此,需要尽量减少对数据库的操作次数。对于体积较小但访问次数多的 blob,我们会将其缓存起来,下次如果再读取就直接从缓存中取,无需操作 IndexedDB。当 blob 对应的文件被关闭掉,我们就释放缓存。此外,文件系统为了性能,忽略了一些对正确性的检查,这些检查都需要带来额外的延迟。例如创建文件时,并没有检查其父目录是否存在。
在写入文件、删除文件时,会出现被丢弃不用的 blob。因此,fs 中提供了collectGarbage 函数,可以遍历文件系统中所有文件,并删除不需要的 blob。目前在前端界面中没有提供回收垃圾的按钮,后续如果实现用户自定义的方案,就需要实现此按钮。
RIME 引擎的方案词库(table)和查找表(prism)采用二进制的数据结构实现,在 deploy 输入法方案时,将文本形式的 txt 词库或 yaml 词库编译到 trie 数据结构,以 .bin 文件存储,从而能够快速地查找访问。在一般的 OS 环境下,RIME 引擎使用 mmap 或类似的机制直接将文件映射到地址空间,从而不用将文件完整读到内存中就可以访问,在 librime 仓库的 src/rime/dict/mapped_file.cc 文件中实现。
在 WebAssembly 环境中,并不能实现 mmap 类似的机制,因为根本没有虚拟内存的支持。因此,在我们的 RIME 实现中,将 MappedFile 重写。打开文件时,直接一次性读取全部文件内容;关闭文件时,如果内容是以 read write 方式打开,就将内容重新全部写入文件系统。经过实际测试,读取 60MB 大小的数据文件,大约耗时 500ms 以下,完全可以接受,缺点就是内存消耗较大,这个没办法。
RIME 的用户词库使用 LevelDB 数据库来存储。不同于方案词库,用户词库的规模较小,但需要在运行时动态读写。最初我们将 LevelDB 库编译到了 WASM 平台上,但发现 WasmFS 库缺乏一些必要的特性,如没有对文件锁的支持,导致 LevelDB 库无法运行。后来我们去除了 LevelDB 的依赖,尝试直接将用户词库相关的代码和浏览器的 IndexedDB 库做对接(事实上,Chrome 里面 IndexedDB 底层就是用的 LevelDB 来实现),这样虽然可以用,但是性能较差。在打字时,RIME 对 LevelDB 的查询较多,比如你输入 r,RIME 会在用户词库中逐个查询 ri,rang,rui 等 r 开头的全部读音,查询一次就是 3ms 的延迟,所以造成打字体感非常不流畅,很卡。最终,我们使用“混合式”的处理方法。数据仍然存储在 IndexedDB 中,每次启动时,都把 DB 里的数据完整读入到一个 C++ 的 std::map 的数据结构中(C++ 的 map 是有序的,比较类似于 LevelDB 的结构),读取时直接从 std::map 里面读取,这样速度就非常快。写入词库时,同时写入 IndexedDB 和 std::map,这样数据就能持久化。
整个 RIME 库通过 Embind 对外提供接口。Embind 可以将 C++ 的函数封装为 js 函数,C++ 的类封装成 js 的对象,并自动做参数类型的转换(如把 C++ std::string 转换为 js 的 string),以及提供类生命周期的管理。整个项目所需的接口全部在 tools/rime_emscripten.cpp 文件中,该文件的底部 EMSCRIPTEN_BINDINGS 中定义的内容就是 C++ 部分对外暴露的接口。这里面的接口在 extension 中的 background/engine.ts 进一步封装,转换为 TypeScript 强类型的接口,以及提供异步锁的保护。