1. 项目背景与整体方案选型最近在整理账号信息的时候发现一个尴尬的问题各种网站、App、服务器的密码散落在备忘录和聊天记录里真到需要用的时候反而翻不到。正好那段时间在看 XyKey 这类桌面密码管理工具的交互逻辑发现它的核心思路其实不复杂——无非是分类管理密码条目再加一个加密记事本。我那时正准备认真接触移动开发就想着能不能用 uniApp 加本地数据库在手机端做一个功能类似的密码管理记事本。这个项目断断续续做了一段时间踩了不少坑也把 sqlite 在 uniApp 里的玩法摸了个七七八八今天把这些过程完整记录下来。这个仿 XyKey 的密码管理记事本本质上是一个单机数据管理应用用 sqlite 存放密码条目和笔记用主密码作为唯一入口所有敏感内容在写入数据库之前完成加密离开 App 后不保留任何明文。对刚接触移动开发的同学来说它覆盖了数据建模、本地存储、加密、页面路由、异步处理这些基础能力是一个非常合适的练手项目。下面我会按项目推进的节奏来复盘从方案选型一直写到具体的踩坑记录。1.1 为什么选 uniApp 而不是纯原生开发刚开始我确实纠结过要不要直接用原生 Android 来写。Kotlin 加 Room 是安卓本地数据库应用的经典组合资料也多但问题是它只能覆盖安卓端iOS 还得再学一套 Swift对初探移动开发的人来说成本偏高。Flutter 和 React Native 我也简单了解过前者要重新适应 Dart 语法后者在原生模块配置上经常卡环境。相比之下uniApp 用的是 Vue 语法官方文档和组件生态都齐全一套代码能出 App 和 H5学习曲线最平滑。从实际推进来看uniApp 对本地能力的封装也确实省了不少事。sqlite 这个模块在 App 平台上有 plus.sqlite 可以直接调用打开数据库、执行 SQL、查询结果都是现成的接口不需要自己写原生桥接代码。当然这意味着 H5 端用不了 sqlite因为浏览器没有这个原生模块我后面在“常见问题”里会专门说怎么处理这个平台差异。1.2 为什么选 sqlite 而不是其他本地存储方案确定 uniApp 之后存储方案其实还有几个选择。最直接的是 uniApp 自带的 uni.setStorage / uni.getStorage也就是键值对存储但密码管理工具需要按分类筛选、按标题搜索、保持条目间的有序关系键值对存起来简单查起来全是手动遍历数据一多就会拖慢启动速度。云数据库虽然查询能力强但密码这种数据放到远端服务和我的初衷相悖网络不可用时应用就没法用了。sqlite 是关系型数据库一张表的结构、字段约束、索引、事务都能用上。移动端底层本来就内置了 SQLiteuniApp 通过 plus.sqlite 调用它既不用单独起服务数据也只留存在本地数据库文件中符合密码管理工具这种本地优先的场景。后面设计表结构时你会看到一个 entry 表加上分类表基本就能把所有密码和笔记的关系表达清楚这也是我最终选择 sqlite 的理由。2. 功能规划与数据模型设计我参考 XyKey 的信息架构把功能收敛成一个最小可用版本设置主密码、按分类管理密码条目、记加密笔记、搜索、自动锁定。界面不需要很花哨但数据流必须完整尤其是“明文永远不落盘”这条底线从一开始就要守住。2.1 核心功能拆解密码条目、分类、加密笔记先把用户场景列清楚。用户打开 App 后先输入主密码进入主界面后能看到几个分类比如“社交账号”“工作邮箱”“服务器”等点进分类是一个条目列表每一条是一个账号密码或一段加密笔记。用户新增条目时可以填写标题、账号、密码、网址、备注如果是一个笔记就是标题加正文。复制密码后一段时间内自动清空剪贴板App 离开后台太久再次进入需要重新解锁。这是整个应用的主流程。这个功能集合看起来简单但实际上它把密码管理和记事本两套功能统一在了一个模型里。我当时做了一个决定密码条目和笔记共用一张 entries 表用 type 字段区分。原因是两者的核心元数据是一样的都需要标题、创建时间、内容区别只在于内容字段的语义不同强行拆成两张表反而让查询和排序逻辑重复。表里的 content 字段对密码条目来说存放加密后的密码文本对笔记来说存放加密后的正文account、url 这些字段在笔记场景下留空即可。2.2 数据库表结构设计整个数据库只需要三张表。meta 表存放主密码派生出的盐和校验值categories 表存分类entries 表存密码条目和笔记。这个设计是在多次改动之后稳定下来的早期我甚至给密码和笔记分别建了表后来统一成一张 entries 表代码量明显减少。CREATE TABLE IF NOT EXISTS meta ( key TEXT PRIMARY KEY, value TEXT ); CREATE TABLE IF NOT EXISTS categories ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, icon TEXT, sort_order INTEGER DEFAULT 0, created_at TEXT DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS entries ( id INTEGER PRIMARY KEY AUTOINCREMENT, category_id INTEGER, type TEXT NOT NULL DEFAULT password, title TEXT NOT NULL, account TEXT, encrypted_password TEXT, url TEXT, content TEXT, sort_order INTEGER DEFAULT 0, created_at TEXT DEFAULT CURRENT_TIMESTAMP, updated_at TEXT DEFAULT CURRENT_TIMESTAMP );meta 表很关键它不存用户主密码只存两样东西随机盐 salt以及一个校验值 verifier。校验值由主密码和盐一起派生出来用户每次解锁时重新计算比对一致才认为密码输入正确。categories 表存分类名称和排序值sort_order 用于控制分类的显示顺序这个字段在后期调整分类顺序时很实用。entries 表是应用的核心。我用 type 字段区分 password 和 note 两类数据category_id 关联分类account、url、encrypted_password 这三个字段只在 password 类型下有意义content 字段则统一承载密码原文或笔记正文的密文。这里有个容易被忽略的点加密后的密文长度会明显大于明文所以字段类型不能设置得太短全用 TEXT 最稳妥。created_at 和 updated_at 两个时间字段主要用来做列表排序比如“最近更新”视图。2.3 加密方案的选型与权衡加密是密码管理工具的命脉。我一开始想得很简单用 AES 加密用户输入的密码就能做但很快意识到主密码本身不能直接作为密钥暴露在使用过程中。通用做法是引入 PBKDF2 密钥派生函数设置主密码时生成一个随机盐用主密码加盐反复迭代哈希派生出真正的 AES 密钥这个密钥只保存在内存会话变量里不写入数据库。校验主密码时用输入的主密码和保存的盐重新派生将结果与数据库里的 verifier 比对。uniApp 项目可以直接安装 crypto-js在 JavaScript 环境里完成这些密码学操作。加密算法我建议用 AES-CBC 加随机 IV每次加密都生成一个新的初始化向量把 IV 和密文拼接在一起存储。不要用 ECB 模式因为它对相同的明文会产生相同的密文在密码管理这种数据高度重复的场景下非常不安全。// utils/crypto.js import CryptoJS from crypto-js; export function deriveKey(masterPassword, salt, iterations 10000) { return CryptoJS.PBKDF2(masterPassword, salt, { keySize: 256 / 32, iterations }); } export function encryptText(plainText, key) { const iv CryptoJS.lib.WordArray.random(128 / 8); const encrypted CryptoJS.AES.encrypt(plainText, key.toString(), { iv: iv, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 }); return iv.toString() : encrypted.toString(); } export function decryptText(cipherText, key) { const parts cipherText.split(:); const iv CryptoJS.enc.Hex.parse(parts[0]); const encryptedData parts.slice(1).join(:); const decrypted CryptoJS.AES.decrypt(encryptedData, key.toString(), { iv: iv, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 }); return decrypted.toString(CryptoJS.enc.Utf8); }这个方案对学习项目来说够用但我要强调一点这个加密流程离生产级密码管理器还有距离。专业工具会使用更复杂的密钥派生参数会把主密码的验证值和加密密钥分离甚至引入硬件安全模块。如果你只是想做个人练手项目这个加密链路能让你把密码学的基本流程跑通但别拿它去存真正重要的生产环境凭证。3. 实操过程与关键环节实现这一部分把项目从零到跑通的核心环节拆开来讲包括数据库封装、主密码流程、页面交互和安全体验优化。每个环节我都尽量给出可以直接照搬的代码或伪代码同时说明当时的取舍原因。3.1 环境搭建与数据库封装创建 uniApp 项目之后我专门写了一个 utils/db.js 文件把 plus.sqlite 的调用封装成 Promise。封装的原因很简单plus.sqlite 的接口是回调风格多个 SQL 连续执行时会出现严重的回调嵌套而且后续页面里到处都是数据库操作统一封装后代码会清爽很多。// utils/db.js const DB_NAME password_manager; function openDatabase() { return new Promise((resolve, reject) { // #ifdef APP-PLUS plus.sqlite.openDatabase({ name: DB_NAME, path: _doc/password_manager.db, success: resolve, fail: reject }); // #endif // #ifndef APP-PLUS resolve(null); // #endif }); } function executeSql(db, sql) { return new Promise((resolve, reject) { plus.sqlite.executeSql({ name: DB_NAME, sql: sql, success: resolve, fail: reject }); }); } function selectSql(db, sql) { return new Promise((resolve, reject) { plus.sqlite.selectSql({ name: DB_NAME, sql: sql, success: resolve, fail: reject }); }); }数据库路径这里有一个非常容易踩的坑plus.sqlite.openDatabase 的 path 参数必须以_doc/开头它代表应用文档目录这样才能找到正确的沙盒路径。我一开始直接填了password_manager.db结果每次打开数据库都失败。这个问题我会在后面的“常见问题”里再次展开。封装完成后我在启动时先调用 openDatabase然后在回调里把初始化表结构的 SQL 逐条执行这样后续页面都可以直接使用这个已经打开的数据库实例。3.2 主密码设置与解锁校验主密码流程是整个应用最核心的交互也是第一次能把密码学知识串起来的环节。首次启动时meta 表为空应用进入设置主密码页面用户输两次密码确认后生成一个随机盐用 PBKDF2 派生密钥把盐和派生结果写入 meta 表同时将派生出的 key 保存到全局的内存变量里作为本次会话的解密密钥。// pages/set-password.js import CryptoJS from crypto-js; import { deriveKey } from ../../utils/crypto.js; export function setupMasterPassword(password) { const salt CryptoJS.lib.WordArray.random(128 / 8).toString(); const key deriveKey(password, salt); const verifier key.toString(); // 写入 meta 表的两条数据建议放在同一个事务里 // INSERT INTO meta (key, value) VALUES (salt, salt); // INSERT INTO meta (key, value) VALUES (verifier, verifier); }解锁流程就是逆过程用户输入主密码后先从 meta 表取出盐重新执行同样的 PBKDF2 派生与 meta 表里的 verifier 比对。一致则把 key 存到内存变量中不一致则提示密码错误。这里有一个很容易忽略的安全点校验用的 verifier 实际上就是 AES 密钥本身的一个十六进制字符串。如果你的应用做了主密码修改操作重新派生后 verifier 会变所有用旧密钥加密的数据必须全部解密再用新密钥重新加密否则旧条目将永久无法解密。这个“重加密”流程我在第二版才补上最初做了一个修改主密码的功能修改后才发现所有历史密码条目都打不开了最后只能重新实现迁移逻辑。3.3 页面搭建与核心交互逻辑页面结构上我分了四块主密码解锁页、首页分类列表、条目列表页、新增和编辑页。首页使用分类表格形式展示所有分类点击分类进入条目列表。列表页只查询轻量字段不加载密文列避免解密大量数据导致卡顿详情页再根据条目 id 拿到完整数据并解密。新增和编辑页的保存逻辑需要注意操作顺序。用户点击保存时先对输入内容做加密再执行 INSERT 或 UPDATE然后回到列表页重新查询刷新。如果只做 INSERT 不刷新列表页会显示旧数据如果先刷新再做异步加密界面逻辑会变得混乱。我最终的方案是所有数据库操作用 await 串起来保存完成后调用列表页的加载函数保证用户看到的一定是最新数据。条目列表的每个 item 上我做了两个轻交互单击进入详情页长按弹出删除确认框。密码详情页有一个“复制密码”按钮调用 uni.setClipboardData 复制到剪贴板这里有一个体验问题uni.setClipboardData 默认会弹一个系统提示如果用户在详情页操作提示还能接受如果做成侧滑快速复制频繁弹窗会非常烦躁所以我通过 showToast 参数关掉了默认提示。3.4 安全细节与体验优化一个密码管理工具如果只做到加密存储离“管理”两个字还差得远。安全体验上有几个细节不能省。第一是自动锁定全局变量里记录最后一次进入前台的时间App 在 onShow 时检查当前时间和上次记录的时间差如果超过设定的阈值比如 60 秒就清除内存中的 sessionKey跳回解锁页需要重新输入主密码。这能有效避免手机被别人拿起来一滑就看到密码列表的情况。第二是剪贴板清理。复制密码后我在内存里记下时间戳启动一个定时器30 秒后调用 uni.setClipboardData 把剪贴板内容覆盖为空。这个功能看起来简单但至少保证即使密码泄漏也只会泄漏一小段时间。剪贴板本身是系统级资源任何应用都能读取密码长期留在剪贴板里等于明文裸奔。第三是 H5 平台的降级处理。plus.sqlite 在 App 端可用在浏览器中不存在所以我在 db.js 里加了平台判断。如果检测到当前不是 App 环境就打开一个基于 localStorage 的模拟存储模块用内存数组保存数据虽然功能上有折扣但至少能保证调试页面时页面结构不会因为缺数据而崩溃。4. 常见问题与排查技巧实录这个章节收集了我在开发过程中踩过的大部分坑。很多问题看起来是小问题但排查过程非常耗时间我希望你能直接避开。4.1 浏览器预览报“plus is not defined”初学 uniApp 的第一课就是HTML5 的能力只在 App 平台生效。直接在浏览器里预览plus 对象是不存在的报错内容就是plus is not defined。解决方法是两层第一所有使用 plus.sqlite 的代码都用条件编译包裹比如// #ifdef APP-PLUS和// #endif第二在数据库模块里做一个降级拦截非 App 平台返回一个 mock 数据库对象保证页面运行不崩溃。这里要额外提醒的是真机调试时HBuilderX 自带的调试基座和自定义基座间可能存在差异。如果数据库初始化没问题但 H5 预览时所有功能不可用不要怀疑是代码逻辑错先确认当前运行平台是不是 App。4.2 数据库文件路径和“数据消失”数据库打不开或数据时有时无大概率是路径问题。plus.sqlite.openDatabase 的 path 参数要写成_doc/xxx.db_doc是 HTML5 内置的文档目录常量不能省略。如果你写了相对路径数据库默认会创建在错误位置甚至直接初始化失败。还有一个非常隐蔽的坑每次启动应用时调用 openDatabase 打开同一个数据库但如果某个页面无意间执行了 closeDatabase而后续页面仍在操作这个数据库就会出现“数据消失”的假象。我的建议是App 启动时打开数据库整个生命周期不关闭直到应用退出。4.3 添加条目时 SQL 报错标题里的单引号把语句截断了plus.sqlite.executeSql 在执行 SQL 时使用的是原始字符串拼接不支持参数化查询。如果你把用户输入的标题直接拼进 SQL标题里一旦出现英文单引号SQL 语句就会被截断不仅报错还可能被构造出恶意语句。我的处理是写一个通用的 escapeSql 函数把字符串中的单引号替换成两个单引号这是 SQLite 官方的转义方式。export function escapeSql(value) { if (value null || value undefined) return NULL; return ${String(value).replace(//g, )}; }SQLite 在执行时会把两个单引号解析为一个单引号这样标题里再出现引号都不会破坏语句结构。排序字段的拼接也要注意如果用户可能影响排序字段名建议搞一个白名单映射只用固定的几个字段名不要直接拼用户输入。4.4 列表卡顿和界面刷新不及时条目多了以后如果列表页一次性查出所有字段包括大段的密文页面渲染会明显变慢。优化方案是列表页只查询 id、title、type、created_at 这几个轻量字段点击详情时再按 id 查完整记录并解密。这样做的代价是详情页多一次数据库查询但对移动端来说少渲染几十条大字符串带来的流畅度提升更值。界面刷新不及时的问题主要出在保存操作后没有等待数据库回调。如果你调用了 executeSql然后又立刻调用 selectSql后者很可能拿到旧数据因为逻辑上它不依赖前者的完成状态。所有数据库操作尽量封装成 Promise 并用 async/await 串联保存之后再刷新就不会出现“保存完列表没反应”的情况。我把这些常见问题整理成一个速查表方便以后排查现象原因处理方式浏览器中报 plus 未定义HTML5 能力仅 App 可用条件编译加平台降级数据库打不开路径缺少 _doc 前缀统一为_doc/xxx.db数据时而出现时而不见数据库被意外关闭启动时打开应用生命周期内不关闭标题包含单引号时报错SQL 未转义使用 escapeSql 函数列表滚动明显卡顿列表查询包含大段密文列表只查轻量字段保存后列表不更新没有等待异步完成用 await 串联再刷新5. 项目复盘与后续扩展方向这个项目做完之后我对移动开发的理解比之前翻了多少倍最大的收获不是会用了一个框架而是把“数据从哪里来、存到哪里去、怎么保证安全”这条链路完整走了一遍。很多笔记型应用看起来只是增删改查但一旦加入加密整个流程就会变得完全不同加解密时机、密钥生命周期、异常恢复每一个环节都需要认真思考。如果让我重新做一遍我会从第一天就把主密码修改和条目迁移机制设计好而不是等做到一半才临时补。数据备份恢复也可以提前考虑sqlite 文件本身是一条备份途径但导出的文件里全是密文恢复时只要有主密码就能解开这种“文件级导出”的方案对个人工具来说非常实用。后续我还想加三个东西指纹或面容解锁这个需要接原生插件对初探者来说是一个很好的桥接练习密码强度分析直接在前端读取明文密码做规则判断不落库不传输最后是安全键盘防止输入密码时被系统输入法的云词库记录敏感的输入场景确实有必要。等技术储备够了还可以考虑更完整的加密方案把加密算法换成经认证的加密模式。给准备做类似项目的同学一句话不要贪功能先把“设置主密码、加密写入、解密读取”这条最小数据流跑通整个应用的骨架就立住了。界面细节、动画、备份导出这些都是后话数据流通了后面怎么做都顺。