一、前言
Android 应用的数据存储问题也是一个被讨论多年的老话题了,伴随 Android 从诞生到现在的 Android 10。
时至今日还有很多问题在系统侧没有被很好的解决,同大多数开发者一样,微信也遇到了很多应用存储设计问题上的困扰。
本文想借此跟大家聊聊我们遇到的问题,以及微信在存储设计上做出的一些思考和尝试。
二、微信数据存储上的问题与思考
1. 有限的内部存储
早期 Android 手机自带存储空间只有内部存储,而且空间很有限。也是因为这样的原因,应用一般要将语音、图片、视频等文件放在 SD 卡上,否则放入内部存储会很快被用尽存储空间,导致用户看到手机还有空间,而 App 却不能正常使用。
2. 目前还不安全的外部(私有)存储
Android 的权限管理只防君子不防小人,SD 卡存储读写权限只要应用申请了基本都可以获取到。
虽然 Android 也提供了不获取权限直接可用的外部私有存储目录如 Context.getExternalFilesDir()。但目前这样的设计对应用数据的安全来说没有帮助,因为外置(私有)存储仍可以被有权限的应用读取。
所以在发现这种问题后,我们选择对部分敏感的外置存储的部分文件进行加密或混淆。
注:原本 Android 10 计划将外部(私有)存储(Scoped Storage)的共享权限彻底收回是个还不错的解决方案,不过对 native 支持的不好及因一次性改变太大开发者反映强烈,计划被推迟一年。
3. 迁移到内部存储是否可行
随着 Android 手机硬件配置提升,内置存储空间也越来越大,是否可以迁移到内部存储也被多次的讨论,但经过多番考虑后我们还是无法全部简单粗暴的将数据迁入内部存储。
这一方面出于兼容低端机的考虑,而另一方面由于内部和外部存储的文件系统不同,迁移过程需要拷贝,再加上用户存在大量历史数据(或手机搬家过来的数据),一次性迁移也会在体验上让用户必须经过较长时间等待。此外,我们还担心太多文件写入内部存储可能推高聊天数据库的损坏概率,因此必须谨慎。
所以最终,我们选择的方案是通过技术手段渐进式的迁移,在不影响用户使用体验的情况下,完成外置存储数据的安全化处理。根据数据的特点(如敏感性、是否可再生、大小等),将部分数据试探性迁往内部存储并控制大小,剩下的逐步迁往外部(私有)存储等待明年 Android 11 相对更完善的外部私有存储空间进行权限隔离控制。
所以综合来看,迁移须慢慢做,加密混淆也要配合,我们希望能通过渐进的迁移和文件加密组合的方式来解决这些存储问题。不过,面对微信用户的大量数据和繁多的业务模块,技术上如何实现这些,实际上有很多困难要解决:
- 要实现大数据量文件迁移,同时还不影响能使用体验和性能
- 要让众多模块的开发同学都能轻松且高质量的实现迁移或加密,而不带来太繁重的研发负担
- 要应对各种不同的存储需求,如不同的加密需要、迁移方式、文件保留策略等等
为了能彻底性的解决这一切,我们设计了 wechat-vfs 通用存储组件,可以更轻松高效的完成迁移等等需要。
三、通用存储组件 wechat-vfs
通用存储组件 wechat-vfs,全称是 WeChat Virtual File System。组件的首要设计目标就是实现高效高可用的数据迁移能力。
1. 文件迁移
对于迁移数量庞大的文件情况来说,会有几大难题导致开发同学不能轻易大胆的实施。
首先是文件迁移要什么时候做?最简单的方法是启动时做,将文件全部转移了再进入应用,这样业务不需要管迁移的细节,因为文件已经全部转移到新的位置了,除了改个路径,其他业务逻辑不需要变动。这样做缺点很明显:非常耗时,特别是 GB 级数据量和大量小文件的情况,如果不能整个目录移动,迁移可能要耗掉几十分钟,用户看着启动界面已经抓狂了。
其次各个业务的开发同学还需要做很多迁移需要上的考量。很显然 App 启动后在后台用户不感知的情况下做迁移更合理,但这样业务就要感知到迁移并做适配,比如下面的问题就需要解决:
- 想要访问一个文件时,不知道它到底是否已经迁移了,要分别在迁移前和迁移后的位置做尝试
- 适配逻辑要业务来写,要改的地方不少,要考虑边界条件,啰嗦且难维护
- 大量拷贝、移动操作可能在设计不佳的迁移方案上造成卡顿影响体验
这些问题在微信也遇到了,而且由于微信用户的数据量一般都比较多,并且大量数据是不可再生的(丢失就无法恢复),所以用户手机上的历史数据必须进行保留和迁移。这不像其他内容、新闻类应用可以简单的再次通过后台拉取数据,不用太考虑迁移问题。
因此 VFS(wechat-vfs 后面简称 VFS) 考虑到这些点,提供了一套解决方案,基本接管了文件迁移方面的一切工作。方案具体是如何设计的?
首先要路径抽象化。业务要接入迁移,第一个要面对的就是路径问题,对具体文件,完成迁移前用老路径,完成迁移后用新路径,迁移失败用老路径,等等的情况,都是复杂度的来源。VFS 通过注册映射关系的方式支持不同路径访问同一个文件。接入并配置好映射后,业务可以用老路径,可以用新路径,也可以用 VFS 新引入的,基于 URI 的抽象化路径(类似Content Provider URI),来访问这个迁移的文件,而不需要管它目前存放的具体位置。业务唯一要做的,就是(通过查找替换)将文件访问的接口换成 VFS 的等效接口。
然后,业务接入之后,迁移的事情就集中给 VFS 这边来实现了。业务先给 VFS 注册迁移源(可以多个)和迁移目标(一个),VFS 将自动完成下面工作:
- 应用启动(准确来说是VFS准备阶段)时,先尝试能瞬间完成的方式——直接移动目录,如果成功了,迁移就完成了;
- 若不行,VFS 先提供一个分发,通过迁移前或迁移后的路径访问文件的时候,同时尝试迁移前和迁移后路径,保证兼容性;
- 系统空闲且不耗电时(灭屏 充电中),启动后台服务,扫描迁移源目录,逐个目录逐个文件移动或拷贝到新路径,直到完成迁移;
- 若在迁移过程中退出灭屏 充电状态,迁移将中断,继续使用路径分发的方式支持两个目录同时存在文件,直到下次灭屏充电时继续。
这样的话,业务接入只需要考虑自己的数据需求并进行配置就可以自动进行迁移了。
2. 抽象文件系统
如何完成前面介绍的迁移设计呢?
为了实现上面的路径抽象和将迁移实现逻辑同业务隔离,VFS 做了一层文件系统的抽象。不考虑迁移或其他附加功能的时候,业务访问文件的方式是单纯的,就是通过一个确定的路径访问一个确定的文件。
要简化迁移接入,最好的方式就是接口上保留路径 => 文件这样简单的对应关系,所以 VFS 选择将迁移前后的路径,包括“迁移”这么个动作都隐藏起来,抽象为一个“虚拟文件系统”。
抽象文件系统和普通文件系统一样提供 open、list、exists 等操作,具体操作则根据业务需要实现,比如迁移,则是:
- 写操作直接操作迁移目标路径
- 读操作先找迁移目标,文件不存在则逐个遍历迁移源
而且,这些接口可以将请求分发给下一级实现,灵活实现各种不同功能的组合。
路径也是同样的,业务希望用一个统一的路径来访问迁移中的文件,而不是数个不同路径,因此引入了抽象 URI 路径,同时也兼容用迁移前或后的路径,这些路径都可以同时访问到迁移前和迁移后的文件。
为了调用这套文件系统和路径抽象的逻辑,VFS 提供了一套和 Java I/O API 等效的 API,实现效果与 Java 基础库一致,只是换成调用抽象文件系统。
- File -> VFSFile
- FileInputStream -> VFSFileInputStream
- FileReader -> VFSFileReader
- ……
有了这套抽象逻辑,实现迁移只需要:
1) 注册迁移路径和映射关系
// 新建一个迁移文件系统,指定迁移路径FileSystem fs = new MigrationFileSystem(true, new NativeFileSystem("/path/to/migrate/to"), new NativeFileSystem("/path/to/migrate/from"));
// 注册进VFS并指定路径映射FileSystemManager.instance().edit() .install("migration", fs) .mount("/path/to/migrate/to", "migration") .mount("/path/to/migrate/from", "migration") .commit();
2) 相关的文件访问接口换成 VFS 等效接口
// File -> VFSFile// FileInputStream -> VFSFileInputStreamVFSFile file = new VFSFile("/path/to/migrate/from/xxx.jpg");if (file.exists()) { InputStream stream = new VFSFileInputStream(file); Bitmap bitmap = BitmapFactory.decodeStream(stream, ...);}
3. 文件加密
有了 VFS,实际上不仅仅迁移变轻松了,实现任意的文件加密都变得更简单,因为只要提供一个加密虚拟文件系统的实现来承担所有的加密需要,并且可以任意组合到其他的 VFS 虚拟文件系统。接入的方法和迁移一致,在注册的时候启用加密和设置密钥生成器,就能使用 VFS 文件操作接口实现落地数据加密,不需要业务修改代码。
FileSystem fs = new EncryptedFileSystem( new NativeFileSystem("/path/to/encrypt"););
FileSystemManager.instance().edit() .install("encryption", fs) .mount("/path/to/encrypt", "encryption") .commit();
4. 清理和统计
很重要的文件清理和统计能力,一样可以通过 VFS 在内部实现。只需要在注册时开启对应的功能,设置清理阈值,灭屏充电的时候就会做对应的清理工作。清理的逻辑可以自己实现,目前实现了有两种规则已经比较够用:
- 时效规则:文件时间超过特定值时(比如7天)清理,适用于 Timeline 类缓存的管理;
- LRU规则:如果目录总大小超过阈值,则按最久没访问顺序来清理,适用于头像等缓存管理。
FileSystem fs = new QuotaFileSystem( new NativeFileSystem("/path/to/cleanup"), 8 * 1024 * 1024, // 目标大小:8MB,每次清理会删文件到小于这个大小 16 * 1024 * 1024 // 阈值大小:16MB,大于这个大小开始执行清理);
FileSystemManager.instance().edit() .install("cleanup", fs) .mount("/path/to/cleanup", "cleanup") .commit();
统计是类似的,在灭屏充电时扫描指定目录,获取文件数量、大小、迁移成功率等信息,可用于监控上报。
和迁移一样,退出灭屏充电状态后会中止操作,避免卡顿和耗电。
四、总结
通用存储组件 wechat-vfs 被设计之初就是为了实现将部分文件迁移到内部存储用和加密文件用,是我们一直以来想根本解决 Android 上各种复杂情况和多样存储需求的一次尝试。恰巧在应用 wechat-vfs 不到一年时间里,我们得知 Android Q 将计划施行 Scoped Storage 安全隔离策略,这一契机让 wechat-vfs 的设计展现出方案上的优势。同时,虚拟文件系统设计上带来了很大的灵活性,可以让我们将文件以任意的自定义方式进行存储,例如可以将小文件存储在超大文件桶里等等,非常多变,十分好用。
以上,就是我们在 Android 数据存储问题上做的一些尝试,也希望对大家有所帮助。