1. 问题来了:为什么我的App一启动就黑屏闪退?
最近在搞一个TV项目,用的是Android 9.0的系统。项目上线前测试,发现一个挺诡异的问题:Launcher(也就是电视的主界面应用)刚启动的时候,偶尔会出现黑屏或者闪一下屏,然后应用就崩溃退出了。这可不是小问题,用户一开机就看到这个,体验直接降到冰点。
赶紧连上Logcat看日志,果然抓到了“罪魁祸首”。错误堆栈里明明白白地写着:
AndroidRuntime: java.lang.RuntimeException: Unable to create application com.example.myapp.MyApplication: java.lang.IllegalStateException: SharedPreferences in credential encrypted storage are not available until after user is unlocked
翻译一下就是:“无法创建应用,因为抛出了一个非法状态异常:在用户解锁设备之前,存储在凭据加密存储中的SharedPreferences是不可用的。”
看到这个错误,我当时第一反应是有点懵的。SharedPreferences不是用来存一些简单的配置信息,比如用户设置、开关状态什么的吗?它怎么会和“用户解锁”扯上关系?而且我们TV设备很多时候是常开待机的,或者开机就直接进主界面,没有像手机那样的锁屏密码界面,哪来的“解锁”?
后来我花了不少时间查资料、做实验,才把这里面的门道搞清楚。原来,从Android 7.0(Nougat)开始,谷歌引入了一个非常重要的安全特性,叫做“直接启动”(Direct Boot)。这个特性本意是好的,是为了在设备开机后、用户输入密码解锁前,也能让一些关键应用(比如闹钟、来电拦截)有限度地运行。但就是这个特性,给我们这些在“用户解锁”前就需要访问数据的普通应用,挖了一个大坑。
简单来说,Android把存储空间分成了两个区域:
- 设备加密存储(Device Encrypted Storage):设备一启动就能访问。这部分存储的密钥和设备硬件绑定,开机就能解密。
- 凭据加密存储(Credential Encrypted Storage):必须等用户解锁(输入密码、图案、指纹等)后才能访问。这部分存储的密钥和用户的解锁凭证绑定,安全性更高。
而我们平时默认使用的 Context.getSharedPreferences(),数据就是存在凭据加密存储里的!在用户解锁设备前,你去读它写它,系统就会毫不客气地抛出上面那个 IllegalStateException,导致你的应用崩溃。TV应用在开机启动时,系统可能还处在“直接启动”模式下,用户解锁状态未就绪,所以一碰SharedPreferences就“炸”了。
2. 刨根问底:理解“直接启动”与存储加密
要彻底解决这个问题,不能光知道个解决方案就完事,我们得稍微深入一点,理解Android是怎么管理启动和存储安全的。这样以后遇到类似问题,你才能举一反三。
2.1 什么是“直接启动”模式?
你可以把“直接启动”想象成手机的“安全安全模式”。当你按下手机电源键开机,到出现锁屏界面让你输入密码、指纹的这段时间,手机就运行在“直接启动”模式下。
在这个模式下,系统为了安全,做了严格的限制:
- 核心系统服务在运行,保证手机能开机。
- 被显式声明为
directBootAware的应用可以有限运行。这类应用通常是闹钟、电话、短信这些基础功能应用。 - 存储访问受限:只能访问“设备加密存储”区域。我们应用默认的数据目录(也就是凭据加密存储)是禁区。
对于TV或者一些物联网设备,它们可能没有设置锁屏密码,但系统启动流程依然会经历这个阶段。如果你的应用(比如Launcher)被设置为开机自启动,就非常有可能撞上这个阶段,从而触发存储访问异常。
2.2 存储加密的“双分区”
前面提到了,Android文件加密(FBE, File-Based Encryption)把数据分成了两个“保险箱”:
| 存储区域 | 可访问时机 | 密钥来源 | 默认存储位置 |
|---|---|---|---|
| 设备加密存储 (DE) | 设备启动后立即可用 | 与设备硬件绑定 | 系统应用、directBootAware应用的部分数据 |
| 凭据加密存储 (CE) | 用户成功解锁设备后 | 与用户解锁凭证(密码等)绑定 | 所有应用默认的数据目录(包括SharedPreferences) |
/data/data/你的包名/这个我们最熟悉的路径,其实是在CE区域里。所以,在直接启动模式下,这个路径下的所有文件,包括通过getSharedPreferences创建的XML文件,都是“锁着”的。- 系统专门为直接启动模式准备了一个对应的DE存储路径:
/data/user_de/0/你的包名/。只有这里的文件,才能在开机未解锁时被访问。
那么问题就变成了:我们能否让SharedPreferences把文件写到DE区域,或者至少在需要的时候,能正确地从DE区域读取数据?
答案是肯定的,而且Android API已经提供了支持。但具体怎么做,取决于你的应用是“系统应用”还是“普通应用”,这两者的处理方式有根本区别。
3. 解决方案一:系统应用的“绿色通道”
如果你的应用是系统应用(比如预装在 /system/app、/system/priv-app 目录下,或者有系统平台签名),那么恭喜你,解决方案非常简单直接。你甚至不需要修改任何代码。
什么是系统应用? 简单判断:你的APK是随着系统镜像一起编译、烧录到设备里的,而不是用户后来从应用市场安装的。很多设备的Launcher、设置等核心应用都属于这一类。
对于系统应用,你只需要在 AndroidManifest.xml 文件的 <application> 标签里,加上两个属性:
<application
android:directBootAware="true"
android:defaultToDeviceProtectedStorage="true"
... >
...
</application>
这两个属性分别是什么意思呢?
android:directBootAware="true":告诉系统:“我这个应用知道直接启动这回事,并且能在那个模式下正常工作”。系统会在直接启动阶段就初始化你的应用组件(比如BroadcastReceiver接收开机广播)。android:defaultToDeviceProtectedStorage="true":这是关键! 它告诉系统,我这个应用默认使用的存储上下文(Context)应该是设备加密存储(DE)的上下文。这意味着,从此以后,你在应用里调用getSharedPreferences()、openFileOutput()等存储相关API,只要没有指定特殊的Context,它都会自动指向DE存储区域。
实测效果: 我给TV Launcher的Manifest加上这两行后,重新编译系统镜像并烧录。再次开机,黑屏和闪退现象完全消失,Launcher能够正常启动并读取到之前保存的配置。因为现在它读写SharedPreferences时,访问的是随时可用的DE区域,再也不会因为CE区域被锁定而崩溃了。
重要提示:
这个方法仅对系统应用有效。对于普通应用(即用户安装的APP),android:defaultToDeviceProtectedStorage="true” 这个属性是不生效的!系统会直接忽略它。这是出于安全考虑,防止普通应用随意将敏感数据存到安全性较低的DE区域。
4. 解决方案二:普通应用的“精准操作”
我们开发的大多数应用都是普通应用,需要上传到应用市场让用户下载安装。对于这类应用,上面的“绿色通道”走不通了。我们不能默认把所有数据都存到DE区域,因为DE区域的安全性低于CE区域,不适合存放用户隐私数据(比如登录令牌、个人设置等)。
但是,我们可能确实有一些数据需要在开机时就读取,比如:
- App的夜间模式开关。
- 一些不影响隐私的UI配置。
- 记录App是否首次启动的标记。
对于这种需求,Android提供了更精细的控制方式:我们可以创建两个不同的SharedPreferences实例,分别用于存储不同类型的数据。
4.1 核心API:createDeviceProtectedStorageContext()
关键就在于 Context 类里的这个方法:
Context deviceProtectedStorageContext = appContext.createDeviceProtectedStorageContext();
这个方法会返回一个全新的Context对象,这个对象的所有存储操作都会指向设备加密存储(DE)区域。
4.2 实战代码:双存储策略
我们的策略是:将需要在直接启动阶段访问的数据,单独存一份到DE区域。其他敏感或用户解锁后才需要的数据,依然使用默认的CE区域。
第一步:声明应用支持直接启动 即使不是系统应用,如果你想在直接启动阶段做点事情(比如初始化一个不依赖CE存储的组件),也需要在Manifest里声明:
<application
android:directBootAware="true"
... >
...
</application>
第二步:在代码中动态选择存储位置 我们需要一个工具类来管理两种SharedPreferences:
public class SafePrefsManager {
private static final String PREFS_NAME_DEVICE = "myapp_device_prefs";
private static final String PREFS_NAME_CREDENTIAL = "myapp_credential_prefs";
/**
* 获取设备加密存储的SharedPreferences(始终可用)
* 用于存储开机时必须读取的非敏感配置,如:isFirstLaunch, nightMode, defaultLayoutId等
*/
public static SharedPreferences getDeviceProtectedPrefs(Context context) {
Context deContext = context.createDeviceProtectedStorageContext();
return deContext.getSharedPreferences(PREFS_NAME_DEVICE, Context.MODE_PRIVATE);
}
/**
* 获取默认(凭据加密)存储的SharedPreferences(用户解锁后可用)
* 用于存储用户敏感数据,如:userId, authToken, personalSettings等
*/
public static SharedPreferences getCredentialProtectedPrefs(Context context) {
// 使用原生的context,指向CE区域
return context.getSharedPreferences(PREFS_NAME_CREDENTIAL, Context.MODE_PRIVATE);
}
/**
* 一个智能获取方法,根据数据键名自动选择存储位置
* 这需要你事先定义好哪些key存DE,哪些存CE
*/
public static SharedPreferences getPrefsForKey(Context context, String key) {
if (isDeviceProtectedKey(key)) {
return getDeviceProtectedPrefs(context);
} else {
return getCredentialProtectedPrefs(context);
}
}
private static boolean isDeviceProtectedKey(String key) {
// 定义哪些配置需要开机即用
Set<String> deviceKeys = new HashSet<>(Arrays.asList(
"is_first_launch",
"ui_night_mode",
"default_tab_index"
));
return deviceKeys.contains(key);
}
}
第三步:在Application初始化中安全地读取
在你的 Application.onCreate() 方法里,你需要非常小心地初始化:
public class MyApplication extends Application {
@Override
public void onCreate() {
super.onCreate();
try {
// 尝试读取一个必须开机就有的DE配置
SharedPreferences devicePrefs = SafePrefsManager.getDeviceProtectedPrefs(this);
boolean isFirstLaunch = devicePrefs.getBoolean("is_first_launch", true);
if (isFirstLaunch) {
// 执行首次启动逻辑...
devicePrefs.edit().putBoolean("is_first_launch", false).apply();
}
// 读取其他DE配置,如夜间模式
int nightMode = devicePrefs.getInt("ui_night_mode", 0);
applyNightMode(nightMode);
} catch (IllegalStateException e) {
// 理论上,读取DE存储不应该抛出此异常。
// 但如果发生,可能是极端情况,这里做降级处理,比如使用默认值。
Log.e("MyApp", "Failed to read device prefs, use defaults", e);
}
// 注意:此时不要尝试读取 getCredentialProtectedPrefs!
// 用户解锁后,我们可以再读取CE存储的数据。
}
@Override
public void onUnlocked() {
// 这是一个可选的回调,当用户解锁设备后调用(API 24+)
super.onUnlocked();
// 现在可以安全地读取CE存储的敏感数据了
SharedPreferences credentialPrefs = SafePrefsManager.getCredentialProtectedPrefs(this);
String userToken = credentialPrefs.getString("auth_token", null);
// ... 用敏感数据初始化应用的其他部分
}
}
4.3 数据迁移的考虑
如果你的应用已经发布,用户本地CE区域已经存有旧数据,现在想将其中一部分非敏感数据迁移到DE区域以供开机使用,该怎么办?
你需要在用户首次解锁设备后(即能访问CE数据时),执行一次迁移操作:
// 在onUnlocked()或主Activity中用户确认已解锁后执行
private void migrateToDeviceStorageIfNeeded(Context context) {
SharedPreferences oldCredentialPrefs = context.getSharedPreferences("old_prefs", Context.MODE_PRIVATE);
SharedPreferences newDevicePrefs = SafePrefsManager.getDeviceProtectedPrefs(context);
// 检查是否已经迁移过
if (!newDevicePrefs.getBoolean("migration_done", false)) {
SharedPreferences.Editor editor = newDevicePrefs.edit();
// 迁移非敏感数据
editor.putBoolean("ui_night_mode", oldCredentialPrefs.getBoolean("night_mode", false));
editor.putInt("default_tab_index", oldCredentialPrefs.getInt("default_tab", 0));
editor.putBoolean("migration_done", true);
editor.apply();
Log.i("Migration", "Non-sensitive data migrated to DE storage.");
}
}
5. 避坑指南与最佳实践
在实际项目中踩过几次坑之后,我总结了一些非常重要的注意事项,能帮你节省大量调试时间。
1. 严格区分数据敏感性 这是最重要的原则。千万不要把任何用户隐私、密码、令牌、个人身份信息(PII)存放到设备加密存储(DE)中。 DE区域在设备开机后即可被任何具有存储权限的应用(在已Root的设备上更危险)潜在访问,安全性远低于CE区域。只将那些真正影响应用启动、且不涉及隐私的配置项放在DE里。
2. 处理好 createDeviceProtectedStorageContext() 的路径问题
这个方法返回的Context,其文件操作路径和默认Context是不同的。这意味着:
- 你用默认Context存的
shared_prefs/my_prefs.xml文件。 - 和你用DE Context存的
shared_prefs/my_prefs.xml文件。 它们是两个完全不同的文件,存放在物理磁盘的不同位置! 务必在你的代码和文档中清晰区分,避免混淆。
3. 测试,测试,再测试 这个问题在模拟器或已解锁的开发机上很难复现。你必须测试“直接启动”场景:
- 最有效的方法:在真机上测试。重启设备,在出现锁屏界面(或系统完全启动)前,观察你的应用行为。对于TV,测试冷启动开机流程。
- 使用adb命令模拟:你可以通过adb命令将设备切换到“直接启动”模式,但操作相对复杂,需要设备支持且已启用调试。
4. 关注 onUnlocked() 回调
如果你的应用目标API级别在24(Android 7.0)及以上,并且声明了 android:directBootAware=”true”,那么 Application 类会收到 onUnlocked() 回调。这是你安全加载CE区域敏感数据的正确时机。你可以在这里初始化网络模块(需要令牌)、加载用户个人数据等。
5. 备选方案:使用其他存储方式 对于极其简单的、需要在开机时读取的配置(比如一个布尔值或整数),如果觉得管理两套SharedPreferences太麻烦,也可以考虑其他更轻量的、始终可读的存储方式:
Settings.System/Settings.Global:如果你的是系统应用,并且配置项具有全局性,可以考虑写入系统设置。但这需要特定权限,且不适用于普通应用。- 在DE区域直接读写文件:使用
createDeviceProtectedStorageContext().openFileOutput()来读写一个自定义的配置文件。这给了你最大的控制权,但失去了SharedPreferences的便捷性。
处理Android设备加密状态下的存储问题,本质上是在安全与可用性之间寻找平衡点。对于大多数普通应用,采用“双Context策略”来按需访问DE和CE存储,是最灵活、最安全的做法。理解“直接启动”模式和文件级加密的原理,是避免掉坑的关键。下次当你看到 SharedPreferences in credential encrypted storage are not available until after user is unlocked 这个错误时,希望你能会心一笑,然后从容地拿出 createDeviceProtectedStorageContext() 这把钥匙。
376



被折叠的 条评论
为什么被折叠?



