Android Direct Boot 阶段与 getFilesDir() 路径变更问题深度分析
一次"应用一行代码都没动,日志却悄悄换了目录"的踩坑记录。本文从 FBE 加密、CE/DE 存储、Direct Boot 阶段,一路追到
Application实例化时机,给出完整的根因链和落地方案。
一、问题现象
某车机导航应用的本地日志,之前一直稳定写到:
bash
/data/data/com.example.vehicle.navi.app/files/VehicleNav/BaiduMapAutoSDK/log
从某一次系统切换之后,应用代码一个字节都没改,日志路径却变成了:
bash
/data/user_de/0/com.example.vehicle.navi.app/files/VehicleNav/BaiduMapAutoSDK/log
玩家第一反应是"是不是 SDK 升级了?是不是 build type 切到 user 了?"------都对,但都不是根因。根因是应用被拉起的阶段变了。
二、基础知识:FBE 加密与两个独立存储区
从 Android 7.0 (N) 起,系统默认启用 File-Based Encryption (FBE,基于文件的加密) ,把应用私有数据的存储空间一切为二:
| 存储区 | 全称 | 典型路径 | 加密密钥 | 可访问时机 |
|---|---|---|---|---|
| DE 区 | Device-Protected Storage(设备加密区) | /data/user_de/0/<pkg>/... |
设备密钥,开机即可派生 | 开机后立即可读写,无需用户解锁 |
| CE 区 | Credential-Protected Storage(凭据加密区) | /data/user/0/<pkg>/...(即 /data/data/<pkg> 的本体) |
用户凭据密钥,需首次解锁后才能派生 | 只有用户首次解锁后才能读写 |
两个目录在文件系统层就是不同的目录、不同的加密密钥。开机后 DE 区已挂载可写,CE 区此时还是加密乱码,访问会直接 I/O error,直到用户解锁。
Context.getFilesDir() 返回哪个目录,不是写死的常量,而是由当前 Context 的 storage 归属决定:
scss
// 普通 Context(CE 归属)
context.getFilesDir(); // → /data/user/0/<pkg>/files
context.isDeviceProtectedStorage(); // → false
// Device Protected Context(DE 归属)
context.getFilesDir(); // → /data/user_de/0/<pkg>/files
context.isDeviceProtectedStorage(); // → true
换言之,同一个应用,在两种 Context 下拿到的 getFilesDir() 路径完全不同。这就埋下了"路径莫名切换"的伏笔。
三、知识前提:开机的三个阶段
一次开机被切成三段,这是理解 Direct Boot 的关键:
ini
[开机上电]
│
▼ 系统启动、zygote fork、system_server 起来
[阶段1: Direct Boot / 锁定阶段] ← sys.boot_completed 还没置 1
│ - DE 区已挂载、可读写
│ - CE 区未解密、不可访问
│ - 系统发广播: LOCKED_BOOT_COMPLETED(仅 directBootAware 组件可收)
│ - 此刻只有 directBootAware 应用/组件才能被拉起
│
▼ 用户首次输入 PIN/密码/手势解锁(或车机无锁则由系统"假解锁")
[阶段2: 用户解锁瞬间] ← 系统派生 CE 密钥、解密 CE 区
│ - 发广播: ACTION_USER_UNLOCKED
│ - CE 区 此刻起可读写
│
▼
[阶段3: 启动完成] ← sys.boot_completed=1
│ - 发广播: BOOT_COMPLETED
│ - 此刻所有 directBootAware=false 的普通应用才被允许拉起
Direct Boot 阶段,就是指"阶段1"------开机后、用户首次解锁前 这段时间。这段时间 CE 区还是加密的,普通应用根本跑不了;只有声明了 directBootAware=true 的应用/组件,才有资格在这段时间被启动,并且它们只能访问 DE 区。
四、directBootAware 属性的作用
android:directBootAware="true" 可以写在 manifest 的三个地方:
xml
<!-- 1. 应用级(所有组件都 directBootAware) -->
<application android:directBootAware="true" ...>
<!-- 2. 组件级 -->
<receiver android:name=".BootReceiver" android:directBootAware="true">
<intent-filter>
<action android:name="android.intent.action.LOCKED_BOOT_COMPLETED"/>
</intent-filter>
</receiver>
它的作用一句话: "这个组件可以在用户还没解锁时就运行" 。
具体影响有三:
- 广播路由 :
LOCKED_BOOT_COMPLETED只发给 directBootAware 的 receiver;BOOT_COMPLETED只发给非 directBootAware 的 receiver。非 directBootAware 组件在被解锁前收不到任何广播、也不会被拉起。 - 进程准入:阶段1里只有 directBootAware 的进程允许被 fork 并跑起来。
- Context 归属 (最关键,见下一节):进程在阶段1被创建时,其
Application是 DE Context。
工程里常见的开机调度器早已按这个属性分流(日志里能看到):
ini
... startApp: com.example.vehicle.navi.app which encryption=true when system locked ← 立即拉起
... startApp: com.example.xxx.service which encryption=false when system locked need delayed ← 推迟到解锁后
encryption=true 对应 directBootAware=true,在锁定阶段即可拉起;encryption=false 对应 directBootAware=false,被标记 need delayed,必须等解锁。所以"应用什么都没改"是错觉------应用的 directBootAware 属性 + 调度器把它提前到了锁定阶段拉起,才是路径变化的真因。
五、根因:Application 在 Direct Boot 阶段实例化 = DE Context
这是整件事的核心。framework 在创建应用进程的 Application 时,会根据"进程被启动时用户是否已解锁"选择 Context 的 storage 归属:
| 进程被 fork 的时刻 | Application Context 归属 | getFilesDir() 返回 |
|---|---|---|
| 阶段1(未解锁)+ directBootAware=true | DE 归属(此刻只有 DE 能访问,必须保证 Application 能读写文件) | /data/user_de/0/... |
| 阶段2/3(已解锁) | CE 归属(默认) | /data/user/0/... |
这个 Application 实例一旦创建,其 storage 归属在进程整个生命周期里都不变。 哪怕用户后来解锁了,这个 Application 还是 DE Context,getFilesDir() 还是返回 /data/user_de/0/...。这就是"明明后来解锁了,路径仍然不回退"的原因。
六、日志证据链验证
打开一份复现问题的开机日志,按时间排序的关键事件如下:
| 时间 | 事件 | 含义 |
|---|---|---|
T+30s |
startApp: com.example.vehicle.navi.app which encryption=true when system locked |
调度器在锁定状态拉起导航,且该应用标记 directBootAware |
T+30.1s |
ActivityManager: Start proc <pid>:com.example.vehicle.navi.app ... for added application |
进程被 fork |
T+31s |
Notebook: isUserUnlocked: false |
此刻用户尚未解锁,处于 Direct Boot 阶段 |
T+32.5s |
VehicleNaviApplication: processName ... onCreate!!!!(此处为泛化后保留的类名示意) |
Application.onCreate 在未解锁时执行 |
T+34s |
makeInitParam CC.getFilesDir() = /data/user_de/0/.../files makeInitParam CC.isDeviceProtectedStorage() = true |
因为 onCreate 发生在 Direct Boot,framework 用 DE Context 创建 Application |
T+~2min |
BluetoothManagerService: MESSAGE_USER_UNLOCKED |
用户此刻才真正解锁(时钟跳变后的相对值) |
T+~2min |
NaviInitHelper: run: isUserUnlocked=true |
业务代码此刻才检测到已解锁 |
证据闭合:
- 调度器明确把应用放到
when system locked拉起; - Application.onCreate 发生在
isUserUnlocked=false之前; - framework 据此用 DE Context 创建 Application;
- 后续即便
MESSAGE_USER_UNLOCKED到来,Application 实例的 storage 归属也不会再变,路径稳定停在 DE。
与 build type 无强绑定:这份日志的 fingerprint 实际还是 userdebug。userdebug/user 只影响 Direct Boot 阶段是否真实存在;真正决定 CE/DE 的是 Application 的实例化时机。在 userdebug 老版本上,开机即"假解锁",应用在解锁后被拉起 → CE;分阶段优化把应用前移到锁定阶段拉起 → DE。这就是"什么都没改却变了"的真相。
七、手工切换 storage 归属的 API
如果一定要在锁定阶段跑、但想拿到 CE 路径,Android 给了显式包装 API(API 24+):
scss
// DE → CE 包装
Context ceContext = context.createCredentialProtectedStorageContext();
ceContext.getFilesDir(); // → /data/user/0/.../files
ceContext.isDeviceProtectedStorage(); // → false
// CE → DE 包装(反向)
Context deContext = context.createDeviceProtectedStorageContext();
deContext.getFilesDir(); // → /data/user_de/0/.../files
硬约束 :createCredentialProtectedStorageContext() 拿到的 CE Context,只有在用户已解锁后 getFilesDir() 才能真正读写成功;在 Direct Boot 阶段用它,CE 区还是加密的,SDK 试图 mkdir/写文件会失败或抛异常。所以"在 Direct Boot 阶段切到 CE 写日志"这条路走不通------必须等解锁。
八、剩下的一个关键变量:拉起时机
根因确认后,问题的可控变量收束为一个:这个应用被分配到哪个阶段拉起。
| 拉起阶段 | Application 归属 | getFilesDir() |
写日志是否可行 |
|---|---|---|---|
| 阶段1(锁定,directBootAware) | DE | /data/user_de/0/... |
可写(DE 区已解密) |
| 阶段2/3(已解锁) | CE | /data/user/0/... |
可写 |
换句话说:想固定回老路径 /data/data/...,就让 Application 在解锁后被创建 ;想要开机即用、容忍 DE 路径,就让它停留在锁定阶段。两者只能择一,"在锁定阶段拉起 + 写到 CE" 在系统层就做不到(CE 此刻加密不可写)。
九、落地方案对比
| 方案 | 做法 | 优点 | 代价 |
|---|---|---|---|
| A(治本,最干净) | 调度器把该应用归到 encryption=false / need delayed 分组,推迟到 USER_UNLOCKED/BOOT_COMPLETED 后再拉起 |
Application 在解锁后创建 → CE → 路径自然回 /data/data/...;不用改应用代码 |
解锁前导航不可用 |
| B(妥协) | 保留锁定阶段拉起(导航开机即用),但在收到 USER_UNLOCKED 前不初始化 SDK / 不写日志 ;解锁后用 createCredentialProtectedStorageContext().getFilesDir() 拿 CE 路径再初始化 SDK |
导航 UI 可尽早可见 | 解锁前无地图能力;需 SDK 支持延迟初始化;若 SDK 在 Application.onCreate 里就写日志,B 不成立 |
| C | 接受 DE 路径,把日志采集策略改为读 /data/user_de/0/.../log |
零改动 | 不符合"固定到 /data/data"的要求 |
A 是最干净的,前提是产品能接受"开机到用户解锁前导航暂不工作"。B 适合"导航必须开机即用"的场景,但要看 SDK 是否允许把初始化推迟到解锁之后、是否暴露 setLogDir 之类接口供二次切换。
十、补充:迁移旧日志
方案 A/B 落地后,DE 区里可能还残留历史日志,建议在首次解锁后做一次性迁移(同应用私有目录内移动无权限问题),用 SharedPreferences 标志位防重复:
ini
File oldDir = new File("/data/user_de/0/<pkg>/files/VehicleNav/BaiduMapAutoSDK/log");
File newDir = new File(ceContext.getFilesDir(), "VehicleNav/BaiduMapAutoSDK/log");
if (oldDir.exists() && !migratedFlag) {
moveRecursive(oldDir, newDir);
migratedFlag = true;
}
迁移只在升级当天那一次开机跑一次,之后稳定在 CE 写。
十一、验证清单
落地后按以下几项验证:
-
ro.build.type与fingerprint:确认当前版本; -
应用启动后打印
CC.getApplication().getFilesDir().getAbsolutePath():- A 方案应稳定返回
/data/user/0/.../files(或/data/data/.../files别名); CC.getApplication().isDeviceProtectedStorage()应为false;
- A 方案应稳定返回
-
实际查看日志目录有新文件写入:
bashadb shell ls -la /data/data/<pkg>/files/VehicleNav/BaiduMapAutoSDK/log -
重启机器(不解锁)→ 确认应用此刻没在 Direct Boot 阶段初始化 SDK / 写日志(A 方案下应用根本没起来;B 方案下起来了但不初始化日志)。
只读核查命令:
perl
adb shell dumpsys user # 看 UserState.state
adb shell getprop sys.boot_completed # 1 表示已 BOOT_COMPLETED
adb shell dumpsys package <pkg> | grep -i "boot|Direct"
adb shell getprop ro.build.type
十二、回顾与一句话结论
这次踩坑的核心教训:
getFilesDir()不是常量,是 Context storage 归属的函数;- Context storage 归属由 Application 实例化那一刻用户的解锁状态决定,且进程生命周期内不可变;
- 任何"开机加速 / 分阶段拉起"优化,只要把 directBootAware 应用前移到锁定阶段拉起,就会把它的
getFilesDir()从 CE 切到 DE------这是不可逆的; - build type (userdebug/user) 只是"放大器":userdebug 可能没有真实 Direct Boot 阶段而掩盖问题,user 版本把阶段1做实后才暴露出来。
一句话: "路径变了"不是 SDK 变了,也不是框架变了,是 Application 出生的时机变了。 这个问题是由于之前优化过开机时序,应用拉起时序进行了优化导致,这也引出了我下一个想写的性能优化
排查这类问题最快的两步:在 getFilesDir() 那一行旁打两个打印------
scss
getFilesDir().getAbsolutePath()
isDeviceProtectedStorage()
看到 isDeviceProtectedStorage() == true 就直接追究"这个进程被 fork 时是不是还没解锁"------答案几乎一定是 yes。