Android应用本地数据加密实战:为开源便签添加密码锁功能

发布时间:2026/7/15 13:27:33
Android应用本地数据加密实战:为开源便签添加密码锁功能 1. 项目概述最近在折腾一个老项目——小米便签MIUI Notes的开源版本也就是 MiCode/Notes。这个项目在 GitHub 上挺有名的算是早期国内大厂开源 Android 应用的典范了。项目本身功能完整UI 也是经典的 MIUI 风格但用久了总觉得缺了点什么隐私保护。原版便签没有任何加密或密码保护功能所有笔记都以明文形式存储在本地数据库里。这意味着一旦手机被别人拿去用几分钟或者应用数据被备份出来你的私密笔记、待办清单、甚至是临时记下的账号密码就完全暴露了。这显然不符合现代用户对隐私的基本要求。所以我决定动手给这个开源便签增加一个密码锁功能。目标很明确在不破坏原有简洁体验的前提下为整个应用或单条笔记提供可靠的加密保护。这不是简单地加个登录界面而是涉及到 Android 的 Activity 生命周期管理、本地数据加密策略、密钥安全存储以及用户体验平滑过渡等一系列问题。如果你手头有这个源码或者对 Android 开发、应用安全加固感兴趣这篇从零到一的改造实录应该能给你不少启发。整个过程完全免费所有用到的技术和工具都是开源的。2. 核心需求与方案设计2.1 功能边界与核心需求拆解在动手写代码之前必须把需求想清楚。拍脑袋加功能后期大概率会变成“屎山”。我对这个密码功能的核心诉求有以下几点入口控制应用启动时或从后台回到前台时需要验证密码才能进入主界面查看笔记列表。这是第一道防线。笔记级加密可选用户可以为单条或部分笔记单独设置密码。未加密的笔记正常显示加密的笔记在列表里只显示标题或占位符点击后需输入密码才能查看和编辑内容。这提供了更细粒度的控制。密码设置与修改用户首次使用功能时需要设置一个主密码。后续应能修改密码且修改密码流程必须验证旧密码确保安全。用户体验平衡密码验证不能太烦人。比如应用在后台短时间如30秒内切换回来可能不需要重复验证。但也要防止绕过。数据安全密码不能明文存储。加密后的笔记内容也必须安全存储即使有人拿到了数据库文件没有密码也无法解密。向后兼容新增功能不能影响原有便签的数据和功能。老用户更新应用后他们的笔记必须完好无损且在没有设置密码前体验应和原来完全一致。基于这些需求我排除了几种方案单纯的界面遮挡只在列表页加一个锁屏界面通过WindowManager添加一个覆盖层。这种方式容易被绕过比如通过ADB启动Activity且无法保护单条笔记内容安全性太差。使用系统屏保或设备密码依赖系统级认证。虽然安全但不够灵活且无法实现笔记级的独立加密。在线账户系统需要后端服务器复杂度高背离了“离线优先”的便签核心也非免费方案所能及。因此我决定采用“本地密码 对称加密”的核心方案。应用层面使用密码来守卫入口和权限数据层面使用基于密码派生的密钥对笔记内容进行加密。2.2 技术方案选型与架构设计确定了核心思路接下来就是技术选型。这就像装修选材料用对了事半功倍。1. 密码存储与验证密码绝对不能明文存。常见的做法是存储密码的哈希值。Android 提供了SharedPreferences但直接存哈希也不够安全。这里我选择使用 AndroidX Security 库中的EncryptedSharedPreferences。它会在底层自动使用 Android Keystore System 来加密存储SharedPreferences的键值对为哈希值又加了一层硬件级如果设备支持的安全保障。验证时只需计算用户输入密码的哈希值与存储的哈希值比对即可。2. 数据加密算法对于笔记内容的加密需要选用对称加密算法。AESAdvanced Encryption Standard是当前的标准选择安全且高效。在 Android 中我们可以通过javax.crypto.Cipher类很方便地使用 AES。 这里有一个关键决策加密模式。我选择AES/GCM/NoPadding。GCMGalois/Counter Mode是一种认证加密模式它不仅能提供保密性还能提供完整性校验。这意味着如果有人篡改了加密后的密文在解密时会失败并抛出异常这比传统的 CBC 模式更安全。NoPadding 是因为 GCM 模式不需要对数据进行填充。3. 密钥管理这是安全的核心。我们不能直接用用户密码作为 AES 密钥因为密码长度和随机性可能不足。标准的做法是使用PBKDF2Password-Based Key Derivation Function 2算法从密码“派生”出一个安全的密钥。PBKDF2 会通过多次哈希迭代例如10万次增加暴力破解的难度。派生出的密钥将用于实际的 AES 加密解密操作。 这个派生出来的密钥本身也需要安全存储。一种方案是每次需要时都用密码重新派生但这会消耗计算资源。另一种方案是将派生出的密钥加密后存储。我倾向于后者但为了初版简化我选择将密码哈希和加密用的盐Salt安全存储每次解密时实时派生机密。主密码验证通过后在内存中保存派生出的密钥一段时间用于解密多条笔记但绝不持久化存储该密钥。4. 应用架构调整原版便签的架构是经典的 Android 单 Activity 多 Fragment 结构假设如此常见于早期应用。主 Activity 负责显示笔记列表。 我的改造思路是新增一个LockActivity作为应用的启动入口在AndroidManifest.xml中将其设为LAUNCHER。它的唯一职责就是展示密码输入界面、验证密码。改造原主MainActivity它现在受保护。只有在LockActivity验证密码成功后通过 Intent 携带一个“令牌”比如一个时间戳或随机数启动MainActivityMainActivity需要校验这个令牌的有效性例如是否在短时间内。生命周期管理需要在MainActivity中监听应用切换到后台的事件onPause,onStop并启动一个计时器。当用户再次返回应用时onResume检查计时器是否超时。如果超时则自动跳转回LockActivity要求重新验证。数据库与模型层改造在笔记的数据模型如Note类中增加字段如isEncrypted(boolean)、cipherText(String, 存储加密后的内容)、iv(String, 存储加密使用的初始化向量GCM模式需要)。同时需要修改数据库访问层DAO在保存和读取笔记时根据isEncrypted字段决定是直接存储明文还是先加密/解密。整个数据流如下图所示此处用文字描述用户输入密码 -LockActivity验证密码哈希 - 验证通过启动MainActivity并传递令牌 -MainActivity运行用户操作 - 用户创建加密笔记前端标记isEncryptedtrue输入内容 - 数据层调用加密方法使用从密码派生的密钥加密内容生成cipherText和iv存入数据库 - 用户查看加密笔记数据层读取cipherText和iv使用密钥解密返回明文给前端显示。3. 核心模块实现详解3.1 安全基石密码管理与加密工具类这是整个功能的发动机必须写得健壮、安全。我创建了一个SecurityManager单例类来集中管理所有安全操作。public class SecurityManager { private static final String TAG SecurityManager; private static final String PREFS_FILE note_lock_prefs; private static final String KEY_PWD_HASH password_hash; private static final String KEY_PWD_SALT password_salt; // 用于密码哈希的盐 private static final String KEY_ENCRYPTION_SALT encryption_salt; // 用于派生加密密钥的盐 private static final String KEY_LOCK_ENABLED lock_enabled; private static final int PBKDF2_ITERATIONS 100000; private static final int KEY_LENGTH 256; // AES-256 private SharedPreferences mEncryptedPrefs; private SecretKey mCachedAesKey null; // 内存中缓存的密钥 private long mKeyCacheTimestamp 0; private static final long KEY_CACHE_TIMEOUT_MS 5 * 60 * 1000; // 密钥缓存5分钟 private SecurityManager(Context context) { try { // 初始化加密的SharedPreferences String masterKeyAlias MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC); mEncryptedPrefs EncryptedSharedPreferences.create( PREFS_FILE, masterKeyAlias, context, EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM ); } catch (Exception e) { Log.e(TAG, Failed to initialize EncryptedSharedPreferences, e); // 降级方案使用普通SharedPreferences但安全性降低 mEncryptedPrefs context.getSharedPreferences(PREFS_FILE, Context.MODE_PRIVATE); } } // ... 单例获取方法省略 /** * 设置或修改密码 */ public boolean setupPassword(String newPassword) { if (newPassword null || newPassword.length() 4) { return false; // 密码长度检查 } try { // 1. 生成随机盐用于哈希密码 SecureRandom random new SecureRandom(); byte[] pwdSalt new byte[16]; random.nextBytes(pwdSalt); // 2. 生成密码的哈希值 (SHA-256) String passwordHash hashPassword(newPassword, pwdSalt); // 3. 生成另一个随机盐用于派生加密密钥 byte[] encSalt new byte[16]; random.nextBytes(encSalt); // 4. 存储哈希值和盐 mEncryptedPrefs.edit() .putString(KEY_PWD_HASH, passwordHash) .putString(KEY_PWD_SALT, bytesToHex(pwdSalt)) .putString(KEY_ENCRYPTION_SALT, bytesToHex(encSalt)) .putBoolean(KEY_LOCK_ENABLED, true) .apply(); // 5. 清除旧的缓存密钥 mCachedAesKey null; return true; } catch (Exception e) { Log.e(TAG, Setup password failed, e); return false; } } /** * 验证密码 */ public boolean verifyPassword(String inputPassword) { String storedHash mEncryptedPrefs.getString(KEY_PWD_HASH, null); String storedSaltHex mEncryptedPrefs.getString(KEY_PWD_SALT, null); if (storedHash null || storedSaltHex null) { // 尚未设置密码 return true; // 或者 false取决于你的逻辑。这里假设没密码时直接通过。 } byte[] salt hexToBytes(storedSaltHex); String inputHash hashPassword(inputPassword, salt); return storedHash.equals(inputHash); } /** * 使用PBKDF2从密码派生AES密钥 */ private SecretKey deriveKeyFromPassword(String password) throws Exception { String saltHex mEncryptedPrefs.getString(KEY_ENCRYPTION_SALT, null); if (saltHex null) { throw new IllegalStateException(Encryption salt not found. Please set up password first.); } byte[] salt hexToBytes(saltHex); KeySpec spec new PBEKeySpec(password.toCharArray(), salt, PBKDF2_ITERATIONS, KEY_LENGTH); SecretKeyFactory factory SecretKeyFactory.getInstance(PBKDF2WithHmacSHA256); byte[] keyBytes factory.generateSecret(spec).getEncoded(); return new SecretKeySpec(keyBytes, AES); } /** * 获取AES密钥带缓存 */ public SecretKey getOrCreateAesKey(String password) throws Exception { long now System.currentTimeMillis(); if (mCachedAesKey ! null (now - mKeyCacheTimestamp) KEY_CACHE_TIMEOUT_MS) { return mCachedAesKey; } SecretKey key deriveKeyFromPassword(password); mCachedAesKey key; mKeyCacheTimestamp now; return key; } /** * 加密文本 * return 一个包含IV和密文的Base64编码字符串格式为 IV:CIPHERTEXT */ public String encryptText(String plaintext, SecretKey key) throws Exception { if (plaintext null || plaintext.isEmpty()) { return ; } Cipher cipher Cipher.getInstance(AES/GCM/NoPadding); byte[] iv new byte[12]; // GCM推荐12字节IV SecureRandom random new SecureRandom(); random.nextBytes(iv); GCMParameterSpec spec new GCMParameterSpec(128, iv); // 128位认证标签 cipher.init(Cipher.ENCRYPT_MODE, key, spec); byte[] ciphertext cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8)); // 将IV和密文一起存储 return bytesToHex(iv) : Base64.encodeToString(ciphertext, Base64.NO_WRAP); } /** * 解密文本 * param encryptedData 格式为 IV:CIPHERTEXT 的字符串 */ public String decryptText(String encryptedData, SecretKey key) throws Exception { if (encryptedData null || !encryptedData.contains(:)) { throw new IllegalArgumentException(Invalid encrypted data format); } String[] parts encryptedData.split(:, 2); byte[] iv hexToBytes(parts[0]); byte[] ciphertext Base64.decode(parts[1], Base64.NO_WRAP); Cipher cipher Cipher.getInstance(AES/GCM/NoPadding); GCMParameterSpec spec new GCMParameterSpec(128, iv); cipher.init(Cipher.DECRYPT_MODE, key, spec); byte[] plaintext cipher.doFinal(ciphertext); return new String(plaintext, StandardCharsets.UTF_8); } // 一些辅助方法hashPassword, bytesToHex, hexToBytes 等省略... }注意EncryptedSharedPreferences需要引入 AndroidX Security 库依赖。在app/build.gradle中添加implementation androidx.security:security-crypto:1.1.0-alpha06。另外MasterKeysAPI 在 Android 6.0 (API 23) 及以上才完全支持对于更低版本需要额外的兼容性处理这里为了简化未展示。实操心得盐Salt至关重要无论是哈希密码还是派生密钥都必须使用随机生成的、唯一的盐。这能有效防御彩虹表攻击。两个用户即使密码相同其哈希值和加密密钥也会因为盐的不同而完全不同。密钥缓存与超时在内存中缓存派生出的 AES 密钥能提升用户体验比如连续解密多条笔记时不用重复计算但必须设置合理的超时时间并在应用切换到后台时考虑清除缓存。异常处理加密解密操作可能因为数据损坏、密钥错误等原因失败。必须用try-catch包裹并给用户友好的错误提示而不是让应用崩溃。GCM模式的IVGCM要求每次加密使用不同的IV初始化向量通常推荐12字节随机数。IV不需要保密但必须和密文一起存储解密时使用相同的IV。3.2 门卫与调度LockActivity 与 MainActivity 改造LockActivity 实现要点这个 Activity 界面很简单一个密码输入框一个确认按钮。核心逻辑在onCreate和按钮点击事件中。public class LockActivity extends AppCompatActivity { private EditText mPasswordEditText; private SecurityManager mSecurityManager; Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); // 可以先检查是否设置了密码如果没设置直接跳转到MainActivity mSecurityManager SecurityManager.getInstance(getApplicationContext()); if (!mSecurityManager.isLockEnabled()) { startMainActivityAndFinish(); return; } setContentView(R.layout.activity_lock); mPasswordEditText findViewById(R.id.et_password); Button unlockButton findViewById(R.id.btn_unlock); unlockButton.setOnClickListener(v - attemptUnlock()); // 可选支持指纹解锁如果设备支持 tryFingerprintUnlock(); } private void attemptUnlock() { String input mPasswordEditText.getText().toString(); if (mSecurityManager.verifyPassword(input)) { // 密码正确生成一个简单的令牌这里用时间戳 long token System.currentTimeMillis(); // 可以将令牌存入一个临时存储或者通过Intent传递 startMainActivityAndFinish(token); } else { // 密码错误提示用户 mPasswordEditText.setError(密码错误); mPasswordEditText.setText(); } } private void startMainActivityAndFinish(long token) { Intent intent new Intent(this, MainActivity.class); intent.putExtra(UNLOCK_TOKEN, token); startActivity(intent); finish(); // 结束自己避免回退栈问题 } // ... 其他方法 }MainActivity 改造要点修改启动模式在AndroidManifest.xml中将MainActivity的launchMode设为singleTask或singleTop并移除LAUNCHER属性将其赋予LockActivity。这确保了应用入口是锁屏。令牌验证在MainActivity的onCreate中检查是否携带了有效的解锁令牌。可以简单检查令牌是否在最近几秒内生成。private boolean validateUnlockToken(Intent intent) { long token intent.getLongExtra(UNLOCK_TOKEN, 0); long currentTime System.currentTimeMillis(); // 令牌在10秒内有效 return (currentTime - token) 10 * 1000; }后台超时锁定在MainActivity中重写onPause和onResume配合一个计时器或记录时间戳实现超时自动锁定的逻辑。private long mLastPauseTime 0; private static final long LOCK_TIMEOUT 30 * 1000; // 30秒 Override protected void onPause() { super.onPause(); mLastPauseTime System.currentTimeMillis(); } Override protected void onResume() { super.onResume(); if (mLastPauseTime 0) { long duration System.currentTimeMillis() - mLastPauseTime; if (duration LOCK_TIMEOUT) { // 超时跳转回LockActivity Intent intent new Intent(this, LockActivity.class); startActivity(intent); finish(); // 结束当前MainActivity } } mLastPauseTime 0; // 重置 }注意这种简单的onPause/onResume计时在应用被系统杀死后重启时会失效。更健壮的做法是将超时逻辑和验证放在LockActivity中每次启动都检查。或者使用一个全局的Application类来管理认证状态。避坑指南回退栈管理务必在LockActivity跳转到MainActivity后调用finish()否则按返回键会回到锁屏界面体验割裂。启动速度LockActivity应尽可能轻量避免复杂的初始化否则会影响用户感知的启动速度。指纹/生物识别集成这是一个提升用户体验的好功能。可以使用 AndroidX Biometric 库来标准化集成。在LockActivity中如果设备支持且用户已录入指纹可以优先显示指纹验证提示。验证成功后同样生成令牌并跳转。3.3 数据层改造笔记的加密与解密这是功能实现的关键需要修改数据库相关的模型类和操作类。假设原项目使用 SQLite 数据库有一个Note表和对应的Note数据类。1. 扩展数据模型在Note类中增加字段public class Note { private long id; private String title; private String content; // 注意这个字段含义变了如果是加密笔记这里存的是加密后的密文 // 新增字段 private boolean isEncrypted false; private String cipherData; // 存储加密后的内容可能包含IV和密文 private String iv; // 如果cipherData不包含IV则需要单独存储。根据前面设计我们合在一起了。 // ... getter and setter }实际上为了最小化改动我们可以重新定义content字段的用途当isEncrypted为true时content字段存储的就是SecurityManager.encryptText返回的IV:CIPHERTEXT字符串。这样就不需要新增cipherData字段了。title是否加密为了列表可读性通常标题保持明文。2. 修改数据库访问逻辑在负责插入和更新笔记的 DAO 或 Helper 类中需要根据笔记的加密状态决定如何处理content。public class NoteDbHelper { private SecurityManager mSecurityManager; private SecretKey mCurrentKey; // 当前已解锁的密钥 // 插入或更新笔记 public long saveNote(Note note, String passwordForEncryption) { String contentToSave note.getContent(); if (note.isEncrypted() mCurrentKey ! null) { // 如果是加密笔记且我们已经有了密钥用户已解锁则需要对内容进行加密后再存储 try { contentToSave mSecurityManager.encryptText(note.getContent(), mCurrentKey); } catch (Exception e) { Log.e(TAG, Encrypt note content failed, e); return -1; // 保存失败 } } // 否则contentToSave 就是明文或者是已经加密好的字符串在编辑加密笔记时 // 执行SQL插入/更新将contentToSave存入数据库的content字段 // ... } // 读取笔记 public Note loadNote(long id) { // 从数据库读取原始数据包括content字段可能是明文或“IV:CIPHERTEXT” Note note ... // 执行SQL查询 if (note.isEncrypted() mCurrentKey ! null) { try { String decryptedContent mSecurityManager.decryptText(note.getContent(), mCurrentKey); note.setContent(decryptedContent); // 将解密后的明文设置回note对象供UI显示 } catch (Exception e) { Log.e(TAG, Decrypt note content failed, e); note.setContent(【内容解密失败】); } } // 如果笔记未加密或者当前未解锁mCurrentKey为null则content字段就是原始数据明文或密文字符串 // 对于未解锁的情况UI层应该显示为“加密内容”或类似占位符 return note; } // 设置当前密钥在LockActivity验证密码成功后调用 public void setCurrentKey(SecretKey key) { this.mCurrentKey key; } }3. UI层适配在笔记列表界面对于加密的笔记可能需要特殊显示比如在标题旁加一个锁形图标或者将内容预览替换为“已加密”字样。 在笔记编辑界面当用户新建一条笔记并勾选“加密”选项或者打开一条已加密的笔记时UI 需要与NoteDbHelper交互确保在保存时触发加密在加载时触发解密前提是已解锁。重要提醒加解密操作是 CPU 密集型任务特别是 PBKDF2 密钥派生。绝对不能在主线程UI线程中执行必须使用AsyncTask、Thread或协程等异步机制并在操作期间给用户显示加载提示。4. 进阶优化与安全加固基础功能跑通后可以从以下几个方向进一步提升体验和安全性4.1 用户体验优化点免密时间窗口就像手机锁屏有“5分钟后锁定”一样我们可以让用户设置一个免密时间如立即、1分钟、5分钟。这需要更精细地管理MainActivity的超时逻辑和LockActivity的启动判断。指纹/面部识别使用BiometricPromptAPI 提供生物识别解锁选项这是当前移动设备的标配。集成后在LockActivity优先尝试生物识别失败再回退到密码输入。伪密码或紧急联系人可以设计一个“伪密码”功能输入特定密码后正常进入应用但显示的是虚假的、不敏感的笔记内容。这在某些场景下能起到保护作用。自动锁定触发器除了超时还可以监听一些系统事件比如屏幕关闭、用户切换应用等作为立即锁定的条件。4.2 安全强化措施密钥派生迭代次数PBKDF2_ITERATIONS的值上文设为10万次需要权衡安全与性能。次数越多暴力破解越难但派生密钥越慢。可以针对不同性能的设备进行动态调整或设置为一个较高的固定值如60万次因为用户只在解锁时感知一次延迟。防止暴力破解在LockActivity中记录连续失败的密码尝试次数。超过一定次数如5次后强制延迟一段时间指数增长才能再次尝试或者锁定该功能一段时间。数据完整性校验除了使用 GCM 模式自带的认证标签还可以考虑对重要的元数据如笔记ID、创建时间进行 HMAC 签名防止数据库被篡改。密钥缓存的安全清除确保在SecurityManager中缓存的SecretKey对象在应用进程被杀死、用户主动锁定、或超时后能够被垃圾回收。避免在序列化对象时意外泄露密钥。备份与恢复如果应用支持备份笔记数据到云端或本地文件必须谨慎处理加密笔记。备份的应该是密文。恢复时如果用户忘记了密码这些加密笔记将永久无法解密。这是一个需要明确告知用户的风险点。4.3 与原有代码的融合技巧MiCode 便签源码结构清晰但毕竟是别人的代码改造时要尽量遵循“开闭原则”。使用接口或抽象层如果可能定义一个INoteSecurity接口包含encryptContent,decryptContent,isLockEnabled等方法。然后提供一个默认实现NoteSecurityImpl即我们的SecurityManager。在原有的数据管理类中通过依赖注入或简单的单例获取来使用这个接口。这样安全逻辑就和业务逻辑解耦了。利用 ContentProvider 或 ViewModel如果原项目使用了 Android 架构组件可以将加解密逻辑放在ViewModel或Repository层这样 UI 层几乎无需改动。数据库迁移如果修改了数据库表结构比如增加了is_encrypted字段需要实现一个Migration类并在 Room 数据库构建时添加如果原项目用了 Room。或者通过SQLiteOpenHelper的onUpgrade方法处理。务必做好数据备份和迁移测试。5. 常见问题与故障排除在实际集成和测试过程中我遇到了不少坑这里记录下最典型的几个问题和解决方法。Q1集成后应用启动就崩溃报错java.security.InvalidKeyException: Illegal key size。A1这是经典的“JCE 无限强度管辖权策略”问题。Android 系统默认限制了加密算法的密钥强度。AES-256 可能需要额外的策略文件。但在现代 Android 系统API 级别 26上通常已经支持。如果遇到此问题请检查是否在非 Android 环境如单元测试下运行需要安装 Java 的 JCE 无限强度管辖策略文件。确保使用的SecretKeySpec的密钥长度是合法的AES 支持 128, 192, 256位。我们的KEY_LENGTH是 256。在 Android 上如果还出现问题可以尝试将算法名称从AES改为AES/GCM/NoPadding并确保使用KeyGenerator或SecretKeyFactory正确生成密钥。Q2加密后的笔记在另一台设备上恢复后无法解密。A2这几乎肯定是盐Salt的问题。用于派生密钥的盐是随机生成并存储在EncryptedSharedPreferences中的。这台设备上的盐和另一台设备上的盐不同导致从相同密码派生出的密钥不同自然无法解密。解决方案如果跨设备同步是需求那么用于加密的盐必须能够被安全地同步到所有设备。一种方案是将盐与加密笔记一起通过用户的主密码进行加密或使用一个从主密码派生出的、专门用于加密盐的密钥然后存储到云端或备份文件中。这样只要用户记住主密码就可以在任何设备上解密出盐进而解密笔记。这大大增加了复杂性需要仔细设计密钥层级。Q3用户忘记了密码有没有“后门”或重置方法A3从安全设计上绝对不应该留有后门。否则密码保护形同虚设。必须明确告知用户密码是隐私保护的最后一道防线请务必牢记。如果忘记密码所有已加密的笔记将永久无法恢复。可以提供“重置密码”功能但它的含义是用新密码重新加密所有当前未加密的笔记而已加密的旧笔记将永远丢失。在实现上就是清除旧的密码哈希和盐设置新的并将数据库中所有isEncrypted为true的记录标记为不可用或删除。Q4加解密大量笔记时应用变得很卡顿。A4加解密是计算密集型操作必须在后台线程执行。在列表加载时如果每条加密笔记都需要解密预览内容性能肯定无法接受。因此对于列表项只显示标题和加密状态图标即可不要解密内容预览。只有当用户点击进入某条加密笔记的详情页时才在后台线程解密该条笔记的内容并显示加载动画。批量操作如批量加密、解密务必放在后台任务中并妥善处理进度通知和可能的中断。Q5EncryptedSharedPreferences在 Android 6.0 以下版本崩溃。A5AndroidX Security Crypto 库对 API 23 以下版本的支持需要androidx.security:security-crypto-ktx的特定版本并且需要应用启用android:allowBackupfalse或进行额外的配置。对于需要支持低版本的应用可以考虑以下降级方案使用普通的SharedPreferences但自己用AndroidKeyStoreAPI 18 支持 RSA/ECAPI 23 支持 AES对存储的密码哈希和盐进行二次加密。这更复杂。或者对于 API 23 以下的设备直接使用经过混淆的普通SharedPreferences并在文档中说明安全等级降低。这需要权衡安全性和兼容性。给一个开源项目增加密码功能远不止是加一个输入框那么简单。它涉及到应用架构的调整、数据安全模型的建立、密钥生命周期的管理以及用户体验的细致打磨。这次对 MiUI 便签源码的改造让我重新审视了一个“简单”功能背后的复杂性。安全无小事任何一个环节的疏忽都可能让整个保护机制失效。最终实现的效果在保障核心数据安全的同时也尽量维持了原应用的简洁流畅。如果你也在尝试类似的功能希望这篇详细的实践记录能帮你避开我踩过的那些坑。代码虽然重要但背后的设计思路和决策过程往往才是解决问题的关键。