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

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>

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

具体影响有三:

  1. 广播路由LOCKED_BOOT_COMPLETED 只发给 directBootAware 的 receiver;BOOT_COMPLETED 只发给非 directBootAware 的 receiver。非 directBootAware 组件在被解锁前收不到任何广播、也不会被拉起。
  2. 进程准入:阶段1里只有 directBootAware 的进程允许被 fork 并跑起来。
  3. 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 写。


十一、验证清单

落地后按以下几项验证:

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

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

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

    bash 复制代码
    adb shell ls -la /data/data/<pkg>/files/VehicleNav/BaiduMapAutoSDK/log
  4. 重启机器(不解锁)→ 确认应用此刻没在 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

十二、回顾与一句话结论

这次踩坑的核心教训:

  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() 那一行旁打两个打印------

scss 复制代码
getFilesDir().getAbsolutePath()
isDeviceProtectedStorage()

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

相关推荐
-今昭-1 小时前
Logstash 管理
java·服务器·前端
测试运维日常笔记1 小时前
VMware vSphere、ESXi 与 vCenter Server:核心概念与关系详解
java·开发语言
阿巴斯甜1 小时前
Android Studio Detekt 使用
android
风流 少年1 小时前
Spring AI 2.0:Flux
java·人工智能·spring
m0_547486661 小时前
《面向对象与Java程序设计》全套PPT课件2026
java·开发语言
杉氧1 小时前
Flutter 跨平台多端适配与 Android/iOS 一键自动化打包发布
android·前端·flutter
jason成都2 小时前
ignav-next|Java 层封装 GNSS/INS 组合导航框架,隔离 RTKLIB 与 INS 内核,双模块独立演进、最小侵入
java·开发语言
抓不住时间的沙2 小时前
butterfly主题美化,打造属于自己的个性博客
java·开发语言·前端·javascript·node.js·github
Zane19942 小时前
从一个发短信的类到多态调用:封装、继承、多态到底是怎么长出来的
java·后端