查看原文
其他

全面复盘Android开发者容易忽视的Backup功能

小虾米君 TechMerger 2021-10-12

allowBackup属性大家都不陌生,为了安全起见最好将它关闭。对它的认识好像也仅限于此了,事实上Google在Backup功能上花了很多心思,提供了多个模式的选择和充分的定制接口。本文将带大家全面复盘Backup功能的由来原理定制方法,以备不时之需。篇幅较长,配合收藏更佳。

为节省部分读者的时间,贴出重点章节供快速空降。

  • 关心Backup功能原理的,直接空降2.8章节
  • 关心Backup功能测试的,直接空降3.2章节
  • 关心Backup文件破解的,直接空降3.3章节
  • 关心Backup实战的,直接空降4.0章节
  • 关心Android 12影响的,直接空降5.0章节

1. 前言

两年前我就遇到过一个Backup功能相关的CTS问题,说的是整机恢复到AccessibilitySerivce的时候发生错误。整机备份和恢复非常耗时,我不可能真的跑一遍去定位问题,我得找个高效的办法。

通过查阅Backup的原理我知道了可以单独BackupRestore某个app,甚至可以解密备份文件查看数据内容。有了这些方法的协助,我很快就找到了原因。

虽然很快地解决了那个问题,但我的心里留下了一个想法。Backup功能好像比我想象的复杂有趣,一定要找个时间好好了解一下。恰逢近期在做Backup功能的定制,对这块有了充分的认识,便整理出来分享给大家。

2. 完整认识Backup

2.1 功能由来

手机等智能设备是现代生活中的重要角色,我们会在这些智能设备上做登录账户,设置偏好,拍摄照片,保存联系人等日常操作。

这些数据耗费了我们很多时间和精力,对我们而言极为重要。如果我们的设备换代了或者重新安装了某个应用,之前使用的数据如果能自动保留,那将是非常出色的用户体验。而保留数据的第一步则在于Backup环节。

2.2 数据来源

用户的数据可以笼统地划分为三块:登录账号相关的身份数据、系统设置相关的偏好以及各App的数据。这三块数据的类型不同、位置不同,进而导致Backup的实现也不同。

  • App数据:应用内部的图片,视频等数据。这是我们尤为关心的数据,如何安全完整地转移这些数据是Backup功能的目标所在,也是本文需要讲解的核心内容
  • 身份数据:用户登录的身份数据。可以通过Smart Lock或Account Transfer API在设备间立即恢复登录状态
  • 设置偏好:系统设置App和SettingProvider将记录用户的偏好数据,甚至包括用户授予App的权限记录。系统将针对这些设置数据备份和恢复

2.3 备份对象

我们知道可以将数据存放在App目录,也可以存放于公共目录。但随着Android系统针对公共目录的限制愈加严格,将数据存放到App自己的目录显得更加合理。

App自身目录的这块数据顺理成章地成为Backup功能的主要对象,按照文件的类型可以细分如下。

类型路径取得对应文件的API
data/data/data/com.xxx/getDataDir()/getDir()
files/data/data/com.xxx/files/getFilesDir()
databases/data/data/com.xxx/databases/getDatabasePath()
sharedpreferences/data/data/com.xxx/sp/getSharedPreferences()

注意:

  • 放置在外部存储空间中的文件也是支持的,这里不再赘述
  • cache、nobackup等目录下的文件不在Backup对象内

Backup操作从最外层的data目录开始,按照文件单位逐个读取逐个备份。目录内的文件一般按照文件名的顺序进行备份,但这个顺序无法保证,取决于File#list() API的结果。

graph TD
data --> files --> databases --> sharedpreferences

2.4 如何开启

在Manifest文件里使用allowBackup属性可以控制Backup功能的开关。这个属性早在Android 1.6(API 4)的时候便引入了,起初默认是关闭的。

allowBackup Added in API level 4 Whether to allow the application to participate in the backup and restore infrastructure. If this attribute is set to false, no >backup or restore of the application will ever be performed, even by a full-system backup that would otherwise cause all >application data to be saved via adb. The default value of this attribute is true.

可能是Android设备火了,备份恢复的需求越来越大,自Android 6.0之后默认值变成了true,即默认将支持Backup功能。

2.5 开启的隐患

allowBackup属性开启的话,开发者使用几个简单的adb命令就可以将应用的数据备份和恢复。意味着自己的数据被转移到别人的设备上是非常简单的,这无疑造成了巨大的安全隐患。

后面的实战将会揭晓控制备份和恢复的诸多可能,比如在恢复的逻辑里加入了限制使得恶意的恢复失败。但即便在恢复阶段拦截了,备份的ab文件仍掌握在别人手里,仍不稳妥。因为通过破解备份文件也可以阅读到部分甚至全部内容。

所以涉及到私密数据的App最好将该属性关闭,自行考虑数据的同步方式,比如通过账号系统恢复。

这个属性曾经引发安全隐患,详情可参考如下文章。

https://blog.csdn.net/zihao2012/article/details/44220389

2.6 备份模式

Android 6.0之前Backup功能只有键值对备份(Key-value Backup)这一种模式,而且默认是关闭的。

想要打开键值对备份功能得将allowBackup属性设置为true,并指定BackupAgent实现,即明确地告知诉Backup功能每个文件按照什么key备份到Android Backup Service。简单来讲,必须给Backup功能提供一个备份文件的映射关系,好让它知道备份的源头和恢复的目标。

6.0之后allowBackup属性默认为true,但打开的不是键值对备份,而是新引入的自动备份(Auto Backup)。自动备份模式执行傻瓜式的全体备份和恢复,可供备份文件存放的空间更大,便捷够用更推荐。。

两个模式在备份的频次、文件的存放位置、恢复的执行时机等细节都很不一样。

备份模式键值对备份自动备份
支持版本Android 2.2Android 6.0
开关办法默认关闭,需手动开启allowBackup并指定BackupAgent默认开启,关闭需要将allowBackup置为false
备份定制BackupAgent里指定备份和恢复的文件可以通过XML配置备份和不备份的文件列表,也可以通过BackupAgent改写备份恢复的逻辑,定制性高
备份时机需要App调用API手动发起备份自动进行,大约每天一次
备份的托管位置Android Backup Service/Google服务器Google Drive云盘
备份限制上限只有5M上限有25M
恢复时机APK安装的时候自动恢复,也可以调用API手动发起恢复APK安装的时候自动恢复
原理细节回调到BackupAgent的onBackup()和onRestore()回调到BackupAgent的onFullBackup()和onRestoreFile()

2.7 备份的托管位置

使用键值对模式备份的文件托管在Google服务器(Android Backup Service),Google承诺将会加密传输这些数据,并尊重隐私条款。同时在App关闭Backup功能的时候删除这些备份,可以放心使用。

如果采用了默认的自动备份模式,那数据存放在Google Drive云盘。云盘拥有自己的账号系统,使用的是账号级别的加密保护,更为安全。

2.8 实现原理

那Android系统是如何实现Backup和Restore功能的呢?

在解答这个问题之前,我们先思考下如果你是Google开发者,你会怎么实现?

这里有个ContentProvider方案。简言之,使用一个统一调度的App通过ContentProvider组件向各个实现了特定Uri或Permission的ContentProvider App发出读写数据的请求。

  • 各App通过ContentProvider将需要备份的Data、File、DB以及SP文件传输出去,调度App收集到包名为单位的备份文件集合,加密后上传到服务器或内存卡
  • 恢复则是调度App将文件解密之后通过ContentProvider再回传给各App,各App自行执行恢复的逻辑

但这个方案有点缺憾。对于大部分App来说文件全部备份和恢复就行了,不需要搞特别的定制。但实际情况是需要支持备份恢复的话,就得各自实现一套ContentProvider去做文件的收集和覆盖。

而Google采取的方案是这样的。默认认为每个App都支持Backup功能,然后给App提供同样的BackupAgent去执行自动备份和恢复的处理。当App存在特别定制的需求的时候可以指定扩展的BackupAgent逻辑,更加灵活高效。

内部的实现原理简述如下。

  1. 系统服务BMS(BackupManagerService)收到BackupManager API发起的备份/恢复请求后,该服务将通过IBackupTransport和Cloud端建立连接
  2. BMS通过持有的BackupHandler依据操作参数启动相应的Backup或Restore线程
  3. 任务线程通过AppBackupUtils检查该App是否支持Backup/Restore
  4. 之后依据备份模式创建对应的Engine并通过通过IBackupAgent向App发起实际的操作请求
  5. BackupAgent将按照对应模式去读取或写入文件

3. 发起、调试和解密Backup

3.1 Backup/Restore的发起

3.1.1 代码方式

选取了键值对备份模式的话,需要在数据发生变动的时候手动发起备份。SDK提供了API:BackupManager的dataChanged()。调用之后BackupManager将调度备份请求在适当的时机发起备份处理。

事实上BackupManager还提供了requestRestore()供我们手动发起恢复,API的返回值将告诉我们是否将要执行恢复操作。这个API发起的恢复结束之后不会KILL进程,存在造成数据错乱的隐患,最好依赖于系统自行的恢复操作。※自Android 9.0开始这个API废弃了,调用了也没有反应。

3.1.2 命令方式

ⅰ. adb命令

adb的backup和restore命令可以帮助我们手动发起较为简单的请求,但只支持自动备份模式。

  • Backup
// 比如备份某个App的数据并以指定的名称保存备份文件
adb backup -f <fileName>.ab -apk <packageName>

接下来系统会提示我们输入备份密码。


输完密码之后点击开始备份,系统将弹出备份开始或结束的Toast。当然不输入密码直接备份也是可以的,但备份的数据容易被破解。

  • Restore
// 发起恢复请求的命令很简单
adb restore <fileName>.ab

接下来输入密码开始恢复,同样的会有Toast提示恢复的进度。

ⅱ.bmgr工具

adb backup命令提供的功能不够强大,官方推荐bmgr工具。它将备份和恢复的步骤分得更细,便于我们理清各个环节,更好的协助我们测试备份和恢复的逻辑。

bmgr工具没有UI,完全通过命令在后台默默运行。

首先需要启用它。注意:要确保设置里的Backup功能没有被关闭,Settings > Backup & Restore。

>adb shell bmgr enabled
Backup Manager currently enabled

接着,查看ROM里支持的文件传输服务,*号表示当前选择的服务。

>adb shell bmgr list transports 
    com.android.localtransport/.LocalTransport
    com.google.android.gms/.backup.migrate.service.D2dTransport
  * com.google.android.gms/.backup.BackupTransportService

GMS的传输服务要求设备联网和科学上网,为方面测试我们切换服务为本地传输

>adb shell bmgr transport com.android.localtransport/.LocalTransport
Selected transport com.android.localtransport/.LocalTransport (formerly com.google.android.gms/.backup.BackupTransportService)

查看传输服务的更改是否生效。

>adb shell bmgr list transports
  * com.android.localtransport/.LocalTransport
    com.google.android.gms/.backup.migrate.service.D2dTransport
    com.google.android.gms/.backup.BackupTransportService

针对某个App发起备份。

>adb shell bmgr backupnow <package>

在另一个终端捕捉备份的执行日志,有可能会提示没有设置锁屏密码

Backup  : [CryptoEnableCheck] Should not encrypt backups: device has no lock screen.

设置密码后再次发起备份,可以看到成功备份了。

>adb shell bmgr backupnow <package>
Package xxx with result: Success
Backup finished with result: Success

日志终端也显示回调了App指定的BackupAgent

AndroidRuntime: Calling main entry com.android.commands.bmgr.Bmgr
PFTBT   : backupmanager pftbt token=4081832e
BackupManagerService: awaiting agent for ApplicationInfo{30f779b xxx}
BackupRestoreAgent: MyBackupAgent()
BackupRestoreAgent: onCreate()
BackupManagerService: agentConnected pkg=xxx agent=android.os.BinderProxy@5f88b66
BackupManagerService: got agent android.app.IBackupAgent$Stub$Proxy@c309ea7
BackupRestoreAgent: onBackup()
BackupRestoreAgent: onDestroy()

bmgr工具在手动恢复的时候需要Token信息,通过dumpsys backup获取对应的Token。Token来自于AncestralCurrent两个标签的组合,比如本次的Token为01。

>adb shell dumpsys backup
Backup Manager is enabled / setup complete / not pending init
Auto-restore is enabled
No backups running
Last backup pass started: 1619317275335 (now = 1619319671619)
  next scheduled: 1619332172012
...
Ancestral: 0 ★
Current:   1 ★
...

清空App数据。

>adb shell pm clear <package>

手动恢复数据,从命令和日志两个终端都能看到数据被正确恢复了。

>adb shell bmgr restore 01 <package>
Scheduling restore: Local disk image
restoreStarting: 1 packages
onUpdate: 0 = com.example.alldemo
restoreFinished: 0
done
BackupRestoreAgent: MyBackupAgent()
BackupRestoreAgent: onCreate()
BackupManagerService: agentConnected pkg=com.example.alldemo agent=android.os.BinderProxy@a480a0c
BackupManagerService: got agent android.app.IBackupAgent$Stub$Proxy@5041f55
BackupManagerService: initiateOneRestore packageName=xxx
BackupRestoreAgent: onRestore()
BackupManagerService: restoreFinished packageName=xxx
BackupRestoreAgent: onRestoreFinished()
BackupManagerService: Restore complete, killing host process of xxx ★
BackupRestoreAgent: onDestroy()
BackupManagerService: No more packages; finishing restore
BackupManagerService: Restore complete.

当然将App卸载后通过市场或手动安装可以自动地恢复数据,这个动作由系统在Apk安装的时候自动完成。

Transport服务的选择要小心。如果选了GMS Transport的话,要注意GMS场景的网络问题,不然备份会失败。更加详细的bmgr使用方法可参考如下文档。

https://developer.android.google.cn/studio/command-line/bmgr

3.1.3 Google发起

Google将会按照每日一次的频次对支持自动备份模式的App发起备份操作。

恢复的话则是在设备第一次开机登录Google账号后。Google会将数据从服务器下载通过BackupManager向各个备份过的App发起恢复操作。尚未安装的App则在后期Apk安装完成之后由Google自行发起恢复。

3.2 Backup/Restore的调试

logcat指定BackupManagerService的Tag,可以监听到Backup和Restore的日志,辅助我们把握操作的进度和报错的原因。

>adb logcat -s BackupManagerService

比如针对Google Photos App进行adb备份和恢复操作的时候,将会输出如下日志。

  • Backup
>adb logcat -s BackupManagerService
BackupManagerService: Requesting backup: apks=true obb=false shared=false all=false system=true includekeyvalue=false pkgs=[Ljava.lang.String;@190020e
BackupManagerService: Beginning adb backup...
BackupManagerService: Starting backup confirmation UI, token=1441721864
BackupManagerService: Waiting for backup completion...
BackupManagerService: acknowledgeAdbBackupOrRestore : token=1441721864 allow=true
BackupManagerService: --- Performing adb backup ---
BackupManagerService: Package com.google.android.apps.photos is key-value.
BackupManagerService: Adb backup processing complete.
BackupManagerService: Full backup pass complete.
  • Restore
>adb logcat -s BackupManagerService
BackupManagerService: Beginning restore...
BackupManagerService: Starting restore confirmation UI, token=1694423050
BackupManagerService: Waiting for restore completion...
BackupManagerService: acknowledgeAdbBackupOrRestore : token=1694423050 allow=true
BackupManagerService: --- Performing full-dataset restore ---
BackupManagerService: adb restore processing complete.
BackupManagerService: Full restore pass complete.

一般来说BackupManagerService提供的日志情报足够了,但在调试Transport,使用bmgr工具等场景的时候,还可以使用这些Tag获得更详细的日志:Backup,BackupManager,PFTBT,GmsBackupTransport,PerformBackupTask和RestoreSession等。

>adb logcat -s AndroidRuntime -s Backup -s BackupManager -s BackupManagerService -s PFTBT -s GmsBackupTransport -s -s PerformBackupTask -s RestoreSession

3.3 Backup文件的解密

Backup文件的后缀名为.ab,估计是android backup的缩写。我们用Text打开上面备份的Google Photos文件,可以看到如下信息。

ANDROID BACKUP
5
1
AES-256
C356E772D89C31C0FCAE6BF16BEC2FF90F0503BCD12111B380FF6054B823D80963EEDC661D92DB908788B48499A80B62731C1A9822C8BF5CD8D67AE85FF45CD9
...

整个文件内容包含头和内容,其中头的信息非常重要,关乎到备份的策略和解密的方式。

  • Backup功能的版本号,比如上面的5,定义在源码的UserBackupManagerService文件中
  • Backup备份文件是否压缩,比如上面的1意味着经过了压缩
  • Backup加密方式,比如上面采用了AES-256加密算法,如果未输入密码备份的话,此处会显示none

未输入密码的ab文件。

ANDROID BACKUP
5
1
none
xレb
...

我们可以使用abe.jar来解密备份的文件,如果使用了加密算法的话,还需要Java Cryptography Extension jar包的帮助。

这里简单演示下没有加密的备份文件的破解过程。

// 输入如下命令
java -jar abe.jar unpack backupFileName-nopwd.ab backupFileName-nopwd.tar

未输出任何Exception则表示解密成功,并会生成指定的tar包。解压出来之后是包括DB、SP在内的原始数据。

abe.jar全名为android-backup-extractor,是采用Java语言编写的转为解密Android备份文件的工具,非常好用,如下链接可以下载。

https://sourceforge.net/projects/adbextractor/

除了这个工具,貌似DD命令也可以破解,笔者没有试过,感兴趣的可以参考如下文章进行更深入的尝试。

https://www.52pojie.cn/thread-447261-1-1.html

4. 实战

铺垫了关于Backup功能的大量知识,就是想让完整地认识和理解这个功能。接下来进入最实用的实战环节。

4.1 准备工作

4.1.1 思考Backup的需求

在定制所需的Backup功能前,先了解清楚自己的Backup需求,比如尝试问自己如下几个问题。

  • 备份的数据Size会很大吗?超过5M甚至25M吗?
  • 应用的数据全部都需要备份吗?
  • 如果数据很大,需要对应用的部分数据做出取舍,哪些数据可以舍弃?
  • 如果恢复的数据的版本不同,能直接恢复吗?该怎么定制?
  • 定制后的数据能保证继续读写吗?

4.1.2 准备测试Demo

我们先做个涉及到DataFileDB以及SP这四种类型数据的App,后面针对这个Demo进行各种Backup功能的定制演示。

Demo通过Jetpack Hilt完成依赖注入,写入数据的逻辑简述如下:

  • 首次打开的时候尚未产生数据,点击Init Button后会将预设的电影海报保存到Data目录,电影Bean实例序列化到File目录,同时通过Jetpack Room将该实例保存到DB。如果三个操作成功执行将初始化成功的Flag标记到SP文件
  • 再次打开的时候依据SP的Flag将会直接读取这四种类型的数据反映到UI上

Demo地址:

https://github.com/ellisonchan/BackupRestoreApp

4.2 选择备份模式

如果Backup需求不复杂,那优先选择自动备份模式。因为这个模式提供的空间更大、定制也更灵活。是Google首推的Backup模式。

如果应用数据Size很小而且愿意手动实现DB文件的备份恢复逻辑的话,可以采用键值对备份模式。

4.3 自动备份

鉴于键值对备份的诸多不足,Google在6.0推出的自动备份模式带来了很多改善。

  • 自动执行无需手动发起
  • 更大的备份空间(由原来的5M变成了25M)
  • 更多类型文件的支持(在File和SP文件以外还支持了Data和DB文件)
  • 更简单的备份规则(通过XML即可快速指定备份对象)
  • 更安全的备份条件(在规则中指定flag可限定备份执行的条件)

ⅰ. 基本定制

想要支持自动备份模式的话,什么代码也不用写,因为6.0开始自动备份模式默认打开。但我还是推荐开发者明确地打开allowBackup属性,这表示你确实意识到Backup功能并决定支持它

<manifest ... >
    <application android:allowBackup="true" ... />
</manifest>

开启之后同样使用adb命令模拟备份恢复的过程,通过截图可以看到所有数据都被完整恢复了

// Backup
>adb backup -f auto-backup.ab -apk com.ellison.backupdemo
// Clear data
>adb shell pm clear com.ellison.backupdemo
// Restore
>adb restore auto-backup.ab

ⅱ. 简单的备份规则

通过fullBackupContent属性可以指向包含备份规则的 XML 文件。我们可以在规则里决定了备份哪些文件,无视哪些文件。

比如只需要备份放在Data的海报图片和SP,不需要File和DB文件。

<manifest ... >
    <application android:allowBackup="true"
        android:fullBackupContent="@xml/my_backup_rules" ... />

</manifest>
<!-- my_backup_rules.xml -->
<full-backup-content>
    <!-- include指定参与备份的文件 -->
    <!-- domain指定root代表这个的规则适用于data目录 -->
    <include domain="root" path="Post.jpg"/>
    <!-- path里指定.代表该目录下所有文件都适用这个规则,免去逐个指定各个文件 -->
    <include domain="sharedpref" path="."/>

    <!-- exclude指定不参与备份的文件 -->
    <exclude domain="file" path="."/>
    <exclude domain="database" path="."/>
</full-backup-content>

运行下备份和恢复的命令可以看到如下File和DB确实没有备份成功。

ⅲ.补充规则所需的条件

当某些隐私程度极高的数据,不放心被备份在网络里,但如果数据被加密的话可以考虑。面对这种有条件的备份,Google提供了requireFlags属性来解决。

通过在XML规则里给属性指定如下value可以补充备份操作的额外条件。

  • clientSideEncryption:只在手机设置了密码等密钥的情况下执行备份
  • deviceToDeviceTransfer:只在D2D的设备间备份的情况下执行备份

在上述规则上增加一个条件:只在设备设置密码的情况下备份海报图片。

<!-- my_backup_rules.xml -->
<full-backup-content>
    <include domain="root" path="Post.jpg" requireFlags="clientSideEncryption"/>
    ...
</full-backup-content>

如果设备未设置密码,运行下备份和恢复的命令可以看到图片确实没有备份。

可是设置了密码,而且打开了Backup功能,无论使用backup命令还是bmgr工具都没能将图片备份。clientSideEncryption的真正条件看来没能被满足,后期继续研究。

如果您已将开发设备升级到 Android 9,则需要在升级后停用数据备份功能,然后再重新启用。这是因为只有当在“设置”或“设置向导”中通知用户后,Android 才会使用客户端密钥加密备份。

ⅳ.定制备份的流程

如果XML定制备份规则的方案还不能满足需求的话,可以像键值对备份模式一样指定BackupAgent,来更灵活地控制备份流程。

可是指定了BackupAgent的话默认会变成键值对备份模式。我们如果仍想要更优的自动备份模式怎么办?Google考虑到了这点,只需再打开fullBackupOnly这个属性。(像极了我们改Bug时候不断引入新Flag的操作。。。)

<manifest ... >
    ...
    <application android:allowBackup="true"
                 android:backupAgent=".MyBackupAgent"
                 android:fullBackupOnly="true" ... />

</manifest>
class MyBackupAgent: BackupAgentHelper() {
    override fun onCreate() {
        Log.d(Constants.TAG_BACKUP, "onCreate()")
        super.onCreate()
    }
    
    override fun onDestroy() {
        Log.d(Constants.TAG_BACKUP, "onDestroy()")
        super.onDestroy()
    }
    
    override fun onFullBackup(data: FullBackupDataOutput?) {
        Log.d(Constants.TAG_BACKUP, "onFullBackup()")
        super.onFullBackup(data)
    }

    override fun onRestoreFile(...
    )
 {
        Log.d(Constants.TAG_BACKUP, "onRestoreFile() destination:$destination type:$type mode:$mode mtime:$mtime")
        super.onRestoreFile(data, size, destination, type, mode, mtime)
    }

    // Callback when restore finished.
    override fun onRestoreFinished() {
        Log.d(Constants.TAG_BACKUP, "onRestoreFinished()")
        super.onRestoreFinished()
    }
}

这样子便可以在定制Backup流程的依然采用自动备份模式,两全其美。

>adb backup -f auto-backup.ab -apk com.ellison.backupdemo
>adb logcat -s BackupManagerService -s BackupRestoreAgent
BackupRestoreAgent: MyBackupAgent() 
BackupRestoreAgent: onCreate()
BackupManagerService: agentConnected pkg=com.ellison.backupdemo agent=android.os.BinderProxy@3c0bc60
BackupManagerService: got agent android.app.IBackupAgent$Stub$Proxy@4b5a519
BackupManagerService: Calling doFullBackup() on com.ellison.backupdemo
BackupRestoreAgent: onFullBackup() ★
BackupManagerService: Adb backup processing complete.
BackupRestoreAgent: onDestroy()
AndroidRuntime: Shutting down VM
BackupManagerService: Full backup pass complete. ★

注意:6.0之前的系统尚未支持自动备份模式,allowBackup打开也只支持键值对模式。而fullBackupOnly属性的补充设置也会被系统无视。

ⅴ.进阶定制之限制备份来源

与中国市场上大都售卖无锁版设备不同,海外售卖的不少设备是绑定运营商的。而不同运营商上即便同一个应用,它们预设的数据可能都不同。这时候我们可能需要对备份数据的来源做出限制。

简言之A设备上面备份数据限制恢复到B设备。

如何实现?

因为自动备份模式下不会将数据的appVersionCode传回来,所以判断应用版本的办法行不通。而且有的时候应用版本是一致的,只是运营商不一致。

所以需要我们自己实现,大家可以自行思考。先说我之前想到的几种方案。

  1. 备份的时候将设备的名称埋入SP文件,恢复的时候检查SP文件里的值
  2. 备份的时候将设备的名称埋入新的File文件,恢复的时候检查File文件的值

这俩方案的缺陷:

方案1的缺点在于备份的逻辑会在原有的文件里增加值,会影响现有的逻辑。

方案2增加了新文件,避免对现有的逻辑造成影响,对方案1有所改善。但它和方案1都存在一个潜在的问题。

问题在于无法保证这个新文件首先被恢复到,也就无保证在恢复执行的一开始就知道本次恢复是否需要。

假使恢复进行到了一半,轮到标记新文件的时候才发现本次恢复需要丢弃,那么将会导致数据错乱。因为系统没有提供Roll back已恢复数据的API,如果我们自己也没做好保存和回退旧的文件处理的话,最后必然发生部分文件已恢复部分没恢复的不一致问题。

要理解这个问题就要搞清楚恢复操作针对文件的执行顺序。

自动备份模式在恢复的时候会逐个调用onRestoreFile(),将各个目录下备份的文件回调过来。目录之间的顺序和备份时候的顺序一致,如下备份的代码可以看出来:从根目录的Data开始,接着File目录开始,然后DB和SP文件。

public abstract class BackupAgent extends ContextWrapper {
    ...
    public void onFullBackup(FullBackupDataOutput data) throws IOException {
        ...
        // Root dir first.
        applyXmlFiltersAndDoFullBackupForDomain(
                packageName, FullBackup.ROOT_TREE_TOKEN, manifestIncludeMap,
                manifestExcludeSet, traversalExcludeSet, data);
        // Data dir next.
        traversalExcludeSet.remove(filesDir);
        // Database directory.
        traversalExcludeSet.remove(databaseDir);
        // SharedPrefs.
        traversalExcludeSet.remove(sharedPrefsDir);
    }
}

文件内的顺序则通过File#list()获取,而这个API是无法保证得到的文件列表都按照abcd的字母排序。所以在File目录下放标记文件不能保证它首先被恢复到。即便放一个a开头的标记文件也不能完全保证。

★推荐方案★

一般的App鲜少在根目录存放数据,而根目录最先被恢复到。所以我推荐的方案是这样的。

备份的时候将设备的名称埋入根目录的特定文件,恢复的时候检查该File文件,在恢复的初期就决定本次恢复是否需要。为了不影响恢复之后的正常使用,最后还要删除这个标记文件。

废话不多说,看下代码。

  • Backup里放入标记文件。
class MyBackupAgent : BackupAgentHelper() {
    ...
    override fun onFullBackup(data: FullBackupDataOutput?) {
        // ★ 在备份执行前先将标记文件写入Data目录
        // Make backup source file before full backup invoke.
        writeBackupSourceToFile()
        super.onFullBackup(data)
    }

    private fun writeBackupSourceToFile() {
        val sourceFile = File(dataDir.absolutePath + File.separator
                + Constants.BACKUP_SOURCE_FILE_PREFIX + Build.MODEL)
        if (!sourceFile.exists()) {
            sourceFile.createNewFile()
        }
    }
    ...
}
  • Restore检查标记文件。
class MyBackupAgent : BackupAgentHelper() {
    private var needSkipRestore = false
    ...
    override fun onRestoreFile(
            data: ParcelFileDescriptor?,
            size: Long,
            destination: File?,
            type: Int,
            mode: Long,
            mtime: Long
    )
 {
        if (!needSkipRestore) {
            val sourceDevice = readBackupSourceFromFile(destination)
            // ★ 备份源设备名和当前名不一致的时候标记需要跳过
            // Mark need skip restore if source got and not match current device.
            if (!TextUtils.isEmpty(sourceDevice) && !sourceDevice.equals(Build.MODEL)) {
                needSkipRestore = true 
            }
        }

        if (!needSkipRestore) {
            // Invoke restore if skip flag set.
            super.onRestoreFile(data, size, destination, type, mode, mtime)
        } else {
            // ★ 跳过备份但一定要消费stream防止恢复的进程阻塞
            // Consume data to keep restore stream go.
            consumeData(data!!, size, type, mode, mtime, null
        }
    }
    ...
    private fun readBackupSourceFromFile(file: File?): String {
        if (file == nullreturn ""
        var decodeDeviceSource = ""

        // Got data file with backup source mark.
        if (file.name.startsWith(Constants.BACKUP_SOURCE_FILE_PREFIX)) {
            decodeDeviceSource = file.name.replace(Constants.BACKUP_SOURCE_FILE_PREFIX, "")
        }
        return decodeDeviceSource
    }

    @Throws(IOException::class)
    fun consumeData(data: ParcelFileDescriptor,
                    size: Long, type: Int, mode: Long, mtime: Long, outFile: File?)
 {
        ...
    }
}
  • 无论是Backup还是Restore都要将标记文件移除。
class MyBackupAgent : BackupAgentHelper() {
    ...
    override fun onDestroy() {
        super.onDestroy()
        // 移除标记文件
        // Ensure temp source file is removed after backup or restore finished.
        ensureBackupSourceFileRemoved()
    }

    private fun ensureBackupSourceFileRemoved() {
        val sourceFile = File(dataDir.absolutePath + File.separator
                + Constants.BACKUP_SOURCE_FILE_PREFIX + Build.MODEL)
        if (sourceFile.exists()) {
            val result = sourceFile.delete()
        }
    }
}

接下里验证代码能否拦截不同设备的备份文件。先在小米手机里备份文件,然后到Pixel模拟器里恢复这个数据。

  • 小米里备份
>adb -s c7a1a50c7d27 backup -f auto-backup-cus-xiaomi.ab -apk com.ellison.backupdemo

>adb -s c7a1a50c7d27 logcat -s BackupManagerService -s BackupRestoreAgent
BackupManagerService: --- Performing full backup for package com.ellison.backupdemo ---
BackupRestoreAgent: onCreate()
BackupManagerService: agentConnected pkg=com.ellison.backupdemo agent=android.os.BinderProxy@5e68506
BackupManagerService: got agent android.app.IBackupAgent$Stub$Proxy@852a7c7
BackupManagerService: Calling doFullBackup() on com.ellison.backupdemo
BackupRestoreAgent: onFullBackup()
//  ★标记文件里写入了小米的设备名称并备份了
BackupRestoreAgent: writeBackupSourceToFile() sourceFile:/data/user/0/com.ellison.backupdemo/backup-source-Redmi 6A create:true ★
BackupRestoreAgent: onDestroy()
BackupManagerService: Adb backup processing complete.
BackupRestoreAgent: ensureBackupSourceFileRemoved() sourceFile:/data/user/0/com.ellison.backupdemo/backup-source-Redmi 6A delete:true ★
BackupManagerService: Full backup pass complete.
  • Pixel里恢复,可以看到Pixel的日志里显示跳过了恢复
>adb -s emulator-5554 restore auto-backup-cus-xiaomi.ab

>adb -s emulator-5554 logcat -s BackupManagerService -s BackupRestoreAgent
BackupManagerService: --- Performing full-dataset restore ---
...
BackupRestoreAgent: onRestoreFile() destination:/data/data/com.ellison.backupdemo/backup-source-Redmi 6A type:1  mode:384  mtime:1619355877 currentDevice:sdk_gphone_x86_arm needSkipRestore:false
BackupRestoreAgent: readBackupSourceFromFile() file:/data/data/com.ellison.backupdemo/backup-source-Redmi 6A
BackupRestoreAgent: readBackupSourceFromFile() source:Redmi 6A
BackupRestoreAgent: onRestoreFile() sourceDevice:Redmi 6A
// ★从备份数据里读取到了小米的设备名,不同于Pixel模拟器的名称,设定了跳过恢复的flag
BackupRestoreAgent: onRestoreFile() destination:/data/data/com.ellison.backupdemo/Post.jpg type:1  mode:384  mtime:1619355781 currentDevice:sdk_gphone_x86_arm needSkipRestore:true
BackupRestoreAgent: onRestoreFile() skip restore and consume ★
...
BackupRestoreAgent: onRestoreFinished()
BackupManagerService: [UserID:0] adb restore processing complete.
BackupRestoreAgent: onDestroy()
BackupManagerService: Full restore pass complete.

Pixel模拟器上重新打开App之后确实没有任何数据。

当然如果App确实有在根目录下存放数据,那么建议你仍采用这个方案。

只不过需要给这个特定文件加一个a的前缀,以保证它大多数情况下会被先恢复到。当然为了防止极低的概率下它没有首先被恢复,开发者还需自行加上一个Data目录下文件的暂存和回退处理,以防万一。

更高的定制需求

如果发现备份的设备名称不一致的时候,客户的需求并不是丢弃恢复,而是让我们将运营商之间的diff merge进来呢?

这里提供一个思路。在上述方案的基础之上改下就行了。

比如恢复的一开始通过标记的文件发现备份的不一致,丢弃恢复的同时将待恢复的文件都改个别名暂存到本地。应用再次打开的时候读取暂存的数据和当前数据做对比,然后将diff merge进来

ⅵ.BackupAgent和配置规则的混用

BackupAgent和XML配置并不冲突,在backup逻辑里还可以获取配置的设备条件。比如在onFullBackup()里可以利用FullBackupDataOutput的getTransportFlags()来取得相应的Flag来执行相应的逻辑。

  • FLAG_CLIENT_SIDE_ENCRYPTION_ENABLED 对应着设备加密条件
  • FLAG_DEVICE_TO_DEVICE_TRANSFER 对应D2D备份场景条件
class MyBackupAgent: BackupAgentHelper() {
    ...
    override fun onFullBackup(data: FullBackupDataOutput?) {
        Log.d(Constants.TAG_BACKUP, "onFullBackup()")
        super.onFullBackup(data)

        if (data != null) {
            if ((data.transportFlags and FLAG_CLIENT_SIDE_ENCRYPTION_ENABLED) != 0) {
                Log.d(Constants.TAG_BACKUP, "onFullBackup() CLIENT ENCRYPTION NEED")
            }
        }
    }
}

4.4 键值对备份

键值对备份支持的空间小,而且针对File类型的Backup实现非线程安全,同时需要自行考虑DB这种大空间文件的备份处理,并不推荐使用。

但本着学习的目的还是要了解一下。

ⅰ. 基本定制

使用这个模式需额外指定BackupAgent并实现其细节。

<manifest ... >
    <application android:allowBackup="true"
                 android:backupAgent=".MyBackupAgent" ... >

        <!-- 为兼容旧版本设备最好加上api_key的meta-data -->
        <meta-data android:name="com.google.android.backup.api_key"
            android:value="unused" />

    </application>
</manifest>

BackupAgent的实现在于告诉BMS每个类型的文件采用什么Key备份和恢复。可以选择高度定制的复杂办法去实现,当然SDK也提供了简单办法。

  • 复杂办法:直接扩展自BackupAgent抽象类,需要自行实现onBackup()onRestore的细节。包括读取各类型文件并调用对应的Helper实现写入数据到备份文件中以及考虑旧的备份数据的迁移等处理。需要考虑很多细节,代码量很大
  • 简单办法:扩展自系统封装好的BackupAgentHelper类并告知各类型文件对应的KEY和Helper实现即可,高效而简单,但没有提供大容量文件比如DB的备份实现

以扩展BackupAgentHelper的简单办法为例,演示下键值对备份的实现。

  • SP文件的话SDK提供了特定的SharedPreferencesBackupHelper实现
  • File文件对应的Helper实现为FileBackupHelper,只限于file目录的数据
  • 其他类型文件比如Data和DB是没有预设Helper实现的,需要自行实现BackupHelper
// MyBackupAgent.kt
class MyBackupAgent: BackupAgentHelper() {
    override fun onCreate() {
        ...
        // Init helper for data, file, db and sp files.
        // Data和DB文件使用FileBackupHelper是无法备份的,此处单纯为了验证下
        FileBackupHelper(this, Constants.DATA_NAME).also { addHelper(Constants.BACKUP_KEY_DATA, it) }
        FileBackupHelper(this, Constants.DB_NAME).also { addHelper(Constants.BACKUP_KEY_DB, it) }
        // File和SP各自使用对应的Helper是可以备份的
        FileBackupHelper(this, Constants.FILE_NAME).also { addHelper(Constants.BACKUP_KEY_FILE, it) }
        SharedPreferencesBackupHelper(this, Constants.SP_NAME).also { addHelper(Constants.BACKUP_KEY_SP, it) }
    }
    ...
}

先用bmgr工具执行Backup,然后清除Demo的数据再执行Restore。从日志可以看出来键值对备份和恢复成功进行了。

// 开启bmgr和设置本地传输服务
>adb shell bmgr enabled
>adb shell bmgr transport com.android.localtransport/.LocalTransport

// Backup
>adb shell bmgr backupnow com.ellison.backupdemo
Running incremental backup for 1 requested packages.
Package @pm@ with result: Success
Package com.ellison.backupdemo with result: Success
Backup finished with result: Success

// 清空数据
>adb shell pm clear com.ellison.backupdemo

// 查看Backup Token
>adb shell dumpsys backup
...
Ancestral: 0
Current:   1

// Restore
>adb shell bmgr restore 01 com.ellison.backupdemo
Scheduling restore: Local disk image
restoreStarting: 1 packages
onUpdate: 0 = com.ellison.backupdemo
restoreFinished: 0
done

Demo的截图显示File和SP备份和恢复成功了。但存放在Data目录的海报和DB目录都失败了。这也验证了上述的结论。


因为出于备份文件空间的考虑,官方并不建议针对DB文件等大容量文件做键值对备份。理论上可以扩展FileBackupHelper对Data和DB文件做出支持。但Google将关键的备份实现(FileBackupHelperBaseperformBackup_checked())对外隐藏,使得简单扩展变得不可能。

StackOverFlow上针对这个问题有过热烈的讨论,唯一的办法是完全自己实现,但随着自动备份的出现,这个问题似乎已经不再重要

https://stackoverflow.com/questions/5282936/android-backup-restore-how-to-backup-an-internal-database#

ⅱ.手动发起备份

BackupManager的dataChanged()函数可以告知系统App数据变化了,可以安排备份操作。我们在Demo的Backup Button里添加调用。

class LocalData @Inject constructor(...
                                    val backupManager: BackupManager){
    fun backupData() {
        backupManager.dataChanged()
    }
    ...
}

点击这个Backup Button之后等几秒钟,发现Demo的备份任务被安排进Schedule里,意味着备份操作将被系统发起。

>adb shell dumpsys backup
Pending key/value backup: 3
    BackupRequest{pkg=com.ellison.backupdemo} ★
    ...

我们可以强制这个Schedule的执行,也可以等待系统的调度。

>adb shell bmgr run
BackupManagerService: clearing pending backups
PFTBT   : backupmanager pftbt token=604faa13
...
BackupManagerService: [UserID:0] awaiting agent for ApplicationInfo{7b6a019 com.ellison.backupdemo}
BackupRestoreAgent: onCreate()
BackupManagerService: [UserID:0] agentConnected pkg=com.ellison.backupdemo agent=android.os.BinderProxy@be4cabf
BackupManagerService: [UserID:0] got agent android.app.IBackupAgent$Stub$Proxy@4eab58c
BackupRestoreAgent: onBackup() ★
BackupRestoreAgent: onDestroy()
BackupManagerService: [UserID:0] Released wakelock:*backup*-0-1265

ⅲ.手动发起恢复

除了bmgr工具提供的restore以外还可以通过代码手动触发恢复。但这并不安全会影响应用的数据一致性,所以恢复的API requestRestore()废弃了。

我们来验证下,在Demo的Restore Button里添加BackupManager#requestRestore()的调用。

class LocalData @Inject constructor(...
                                    val backupManager: BackupManager){
    fun restoreData() {
        backupManager.requestRestore(object: RestoreObserver() {
            ...
        })
    }
    ...
}

但点击Button之后等一段时间,恢复的日志没有出现,反倒是弹出了无效的警告。

BackupRestoreApp: LocalData#restoreData()
BackupManager: requestRestore(): Since Android P app can no longer request restoring of its backup.

ⅳ.备份版本不一致的处理

版本不一致意味着恢复之后的逻辑可能会受到影响,这是我们在定制Backup功能时需要着重考虑的问题。

版本不一致的情况有两种。

  1. 现在运行的应用版本比备份时候的版本高,比较常见的场景
  2. 现在运行的应用版本比备份时候的版本低,即App降级,不太常见

默认情况下系统会无视App降级的恢复操作,意味着BackupAgent#onRestore()永远不会被回调。

但如果应用对于旧版本数据的兼容处理比较完善,希望支持降级的情况。那么需要在Manifest里打开restoreAnyVersion属性,系统将意识到你的兼容并包并回调你的onRestore处理。

无论哪种情况都可以在BackupAgent#onRestore()回调里拿到备份时的版本。然后读取App当前的VersionCode,执行对应的数据迁移或丢弃处理。

class MyBackupAgent: BackupAgentHelper() {
    ...
    override fun onRestore(
        data: BackupDataInput?,
        appVersionCode: Int,
        newState: ParcelFileDescriptor?
    ) 
{
        val packageInfo = packageManager.getPackageInfo(packageName, 0)
        if (packageInfo.versionCode != appVersionCode) {
            // Do something.
            // 可以调用BackupDataInput#restoreEntity()
            // 或skipEntityData()决定恢复还是丢弃
        } else {
            super.onRestore(data, appVersionCode, newState)
        }
    }
}

ⅴ.直接扩展BackupAgent

扩展自BackupAgent的需要考虑诸多细节,对这个方案有兴趣的朋友可以参考BackupAgentHelper的源码,也可以查阅官方说明。

https://developer.android.google.cn/guide/topics/data/keyvaluebackup

4.5 系统App的Backup限制

部分系统App的隐私级别较高,即便手动调用了Backup命令,系统仍将无视。并在日志中给出提示。

BackupManagerService: Beginning adb backup...
BackupManagerService: Starting backup confirmation UI, token=1763174695
BackupManagerService: Waiting for backup completion...
BackupManagerService: acknowledgeAdbBackupOrRestore : token=1763174695 allow=true
BackupManagerService: --- Performing adb backup ---
BackupManagerService: Package com.android.phone is not eligible for backup, removing.★提示该App不适合备份操作
BackupManagerService: Adb backup processing complete.
BackupManagerService: Full backup pass complete.

这个限制的源码在AppBackupUtils中,解决办法很简单在Manifest文件里明确指定BackupAgent

其实Google的意图很清楚,这些系统级别的App数据要是被窃取将十分危险,默认禁止这个操作。但如果你指定了Backup代理那代表开发者考虑到了备份和恢复的场景,对这个操作进行了默许,备份操作才会被放行。

4.6 实战总结

4.6.1 Backup定制的总结

当我们遇到Backup定制任务的时候认真思考下需求再对症下药。为使得这个流程更加直观,做了个流程图分享给大家。

4.6.2 Backup相关属性

相关属性说明
allowBackup是否支持Backup,默认为true
backupAgent指定Backup代理进行定制
fullBackupContent指定备份规则XML文件
restoreAnyVersion是否支持高版本数据恢复到低版本应用,默认为false
fullBackupOnly在指定了BackupAgent后仍然采用AutoBackup模式
killAfterRestore全系统恢复期后是否终止应用,默认为 true
backupInForeground即使应用处于前台也可以对其执行自动备份,默认为false
clientSideEncryption只在手机设置密钥的情况下执行备份
deviceToDeviceTransfer只在D2D设备间备份的情况下执行备份

5. Android 12的影响和Backup功能的发展历程

Android 12 Beta版即将公开,其针对Backup功能又做了些改动,先来看看变更的说明。

5.1 D2D 设备到设备备份的规则细分

For apps running on and targeting Android 12 and higher:

  • Specifying android:allowBackup="false" does disable backups to Google Drive, but doesn’t disable D2D transfers for the app.
  • Specifying include and exclude rules with the XML configuration mechanism no longer affects D2D transfers, though it still affects Google Drive backups. To specify rules for D2D transfers, you must use the new configuration covered in the next section.

简言之,Android 12开始即便关闭了allowBackup属性,D2D的Backup功能仍将有效,不再受影响。同时原有的通过fullBackupContent指定的配置规则也将失效。

如果你的App目标版本是Android 12的话,需要使用新属性dataExtractionRules来指定语法规则。

语法规则的所变化主要体现在使用新的属性cloud-backupdevice-transfer明示地区分云端备份和D2D备份的规则,而不再像之前那样采用full-backup-content指定统一的规则。

另外原有的设备条件flag也发生了变化。

  • clientSideEncryption:在新规则里变成了disableIfNoEncryptionCapabilities,且只能应用在cloud-backup标签内
  • deviceToDeviceTransfer:新规则将D2D区分开来了,所以这个flag不需要了
<application
    android:dataExtractionRules="new_config.xml"
    ...>

</application>
<data-extraction-rules>
  <cloud-backup [disableIfNoEncryptionCapabilities="true|false"]>
    <include domain=["file" | "database" | "sharedpref" | "external" |
                        "root"] path="string"/>

 ...
  </cloud-backup>
  <device-transfer>
    <include domain=["file" | "database" | "sharedpref" | "external" |
                        "root"] path="string"/>

    ...
  </device-transfer>
</data-extraction-rules>

原因在于云端备份存在空间的限制,难免需要对备份的文件做出取舍。而D2D的场景文件是存在本地的,没有这种限制了却还对备份文件做出削减显然不太合理。

具体细节可参考官方文档。

https://developer.android.google.cn/about/versions/12/backup-restore

5.2 adb backup命令的限制

To help protect private app data, Android 12 changes the default behavior of the adb backup command. For apps that target Android 12, when a user runs the adb backup command, app data is excluded from any other system data that is exported from the device.

adb backup命令是可以备份整机数据的,从Android 12开始该数据里将不包含App部分的应用数据。除非在Manifest里手动打开debuggable属性。

如果备份单个App也失败的话,那安全性将大大提高。笔者在12 Preview版本上执行该命令仍旧能够正常备份。不知道是不是Target SDK的问题,等正式版出来后再尝试下。

详情可参考官方说明。

https://developer.android.google.cn/about/versions/12/behavior-changes-12

5.3 Backup功能的发展历程

简要回顾下Backup功能的发展历程,供快速查阅。

版本变化内容
Android 1.6加入allowBackup属性默认关闭
Android 2.2开始使用键值对备份模式
Android 6.0开始支持自动备份模式,默认打开allowBackup属性
Android 7.0Backup功能将自动备份和恢复用户授予App的权限
Android 9.0新增了加密存储备份文件
Android 12D2D场景的Backup规则变更和adb backup命令的限制

6. 结语

Google针对Backup功能的频繁改动可以看出来其对于这个功能的重视,总结起来就是在功能的易用性,安全性,合理性之间反复优化。

针对这些变化开发者需要不断调整Backup功能的开发策略,我也给出一些实用建议。

  1. 思考App是否支持备份,明示地设置allowBackup属性
  2. 自动备份模式提供的备份空间更大,定制灵活,更为推荐
  3. 隐私级别很高的数据可以添加设备加密的备份条件
  4. 复写BackupAgent可以灵活定制备份和恢复的流程,值得好好研究
  5. 出于学习和调查的目的可以尝试了解和破解Backup文件
  6. backup命令已不推荐,调试Backup功能尽量尝试功能更为强大的bmgr工具

未决悬念

  1. 官方文档说明键值对备份从2.2开始提供支持,可是allowBackup属性自1.6便于导入,那在2.2之前的备份采取哪种模式呢?想找到一台2.2以前的设备去验证动作不太现实,打算在2.2之前的系统源码里找到答案。
  2. Android 12上目标SDK为12的话如果debuggable未开的话,无论备份整机还是单个app都将失败?
  3. clientSideEncryption表示Backup功能打开且设置了密码均可开始执行备份,但实际测试不是,总是没有执行备份。

DEMO

https://github.com/ellisonchan/BackupRestoreApp

参考资料

https://developer.android.google.cn/guide/topics/data/backup

https://developer.android.google.cn/guide/topics/data/keyvaluebackup

https://developer.android.google.cn/guide/topics/data/autobackup

https://developer.android.google.cn/guide/topics/data/testingbackup

https://developer.android.google.cn/studio/command-line/bmgr

推荐阅读

Jetpack Hilt有哪些改善又有哪些限制?

Dagger2和它在SystemUI上的应用

除了SQLite你一定要试试Room

: . Video Mini Program Like ,轻点两下取消赞 Wow ,轻点两下取消在看

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存