Android-Direct Boot 阶段与 getFilesDir() 路径变更问题深度分析

18 阅读9分钟

Android Direct Boot 阶段与 getFilesDir() 路径变更问题深度分析

一次"应用一行代码都没动,日志却悄悄换了目录"的踩坑记录。本文从 FBE 加密、CE/DE 存储、Direct Boot 阶段,一路追到 Application 实例化时机,给出完整的根因链和落地方案。


一、问题现象

某车机导航应用的本地日志,之前一直稳定写到:

/data/data/com.example.vehicle.navi.app/files/VehicleNav/BaiduMapAutoSDK/log

从某一次系统切换之后,应用代码一个字节都没改,日志路径却变成了:

/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 归属决定

// 普通 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 的关键:

[开机上电]
    │
    ▼  系统启动、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 的三个地方:

<!-- 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>

它的作用一句话: "这个组件可以在用户还没解锁时就运行"

具体影响有三:

  1. 广播路由LOCKED_BOOT_COMPLETED 只发给 directBootAware 的 receiver;BOOT_COMPLETED 只发给非 directBootAware 的 receiver。非 directBootAware 组件在被解锁前收不到任何广播、也不会被拉起。
  2. 进程准入:阶段1里只有 directBootAware 的进程允许被 fork 并跑起来。
  3. Context 归属(最关键,见下一节):进程在阶段1被创建时,其 Application 是 DE Context。

工程里常见的开机调度器早已按这个属性分流(日志里能看到):

... 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=trueDE 归属(此刻只有 DE 能访问,必须保证 Application 能读写文件)/data/user_de/0/...
阶段2/3(已解锁)CE 归属(默认)/data/user/0/...

这个 Application 实例一旦创建,其 storage 归属在进程整个生命周期里都不变。 哪怕用户后来解锁了,这个 Application 还是 DE Context,getFilesDir() 还是返回 /data/user_de/0/...。这就是"明明后来解锁了,路径仍然不回退"的原因。


六、日志证据链验证

打开一份复现问题的开机日志,按时间排序的关键事件如下:

时间事件含义
T+30sstartApp: com.example.vehicle.navi.app which encryption=true when system locked调度器在锁定状态拉起导航,且该应用标记 directBootAware
T+30.1sActivityManager: Start proc <pid>:com.example.vehicle.navi.app ... for added application进程被 fork
T+31sNotebook: isUserUnlocked: false此刻用户尚未解锁,处于 Direct Boot 阶段
T+32.5sVehicleNaviApplication: processName ... onCreate!!!!(此处为泛化后保留的类名示意)Application.onCreate 在未解锁时执行
T+34smakeInitParam CC.getFilesDir() = /data/user_de/0/.../files makeInitParam CC.isDeviceProtectedStorage() = true因为 onCreate 发生在 Direct Boot,framework 用 DE Context 创建 Application
T+~2minBluetoothManagerService: MESSAGE_USER_UNLOCKED用户此刻才真正解锁(时钟跳变后的相对值)
T+~2minNaviInitHelper: 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+):

// 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 标志位防重复:

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 写。


十一、验证清单

落地后按以下几项验证:

  1. ro.build.typefingerprint:确认当前版本;

  2. 应用启动后打印 CC.getApplication().getFilesDir().getAbsolutePath()

    • A 方案应稳定返回 /data/user/0/.../files(或 /data/data/.../files 别名);
    • CC.getApplication().isDeviceProtectedStorage() 应为 false
  3. 实际查看日志目录有新文件写入:

    adb shell ls -la /data/data/<pkg>/files/VehicleNav/BaiduMapAutoSDK/log
    
  4. 重启机器(不解锁)→ 确认应用此刻没在 Direct Boot 阶段初始化 SDK / 写日志(A 方案下应用根本没起来;B 方案下起来了但不初始化日志)。

只读核查命令:

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

十二、回顾与一句话结论

这次踩坑的核心教训:

  1. getFilesDir() 不是常量,是 Context storage 归属的函数;
  2. Context storage 归属由 Application 实例化那一刻用户的解锁状态决定,且进程生命周期内不可变;
  3. 任何"开机加速 / 分阶段拉起"优化,只要把 directBootAware 应用前移到锁定阶段拉起,就会把它的 getFilesDir() 从 CE 切到 DE——这是不可逆的;
  4. build type (userdebug/user) 只是"放大器":userdebug 可能没有真实 Direct Boot 阶段而掩盖问题,user 版本把阶段1做实后才暴露出来。

一句话: "路径变了"不是 SDK 变了,也不是框架变了,是 Application 出生的时机变了。 这个问题是由于之前优化过开机时序,应用拉起时序进行了优化导致,这也引出了我下一个想写的性能优化

排查这类问题最快的两步:在 getFilesDir() 那一行旁打两个打印——

getFilesDir().getAbsolutePath()
isDeviceProtectedStorage()

看到 isDeviceProtectedStorage() == true 就直接追究"这个进程被 fork 时是不是还没解锁"——答案几乎一定是 yes。