鸿蒙实战:UserDao 用户本地管理与三路登录体系

前言

在这里插入图片描述

图:鸿蒙实战第88篇:UserDao 用户本地管理与三路登录体系 运行效果截图(HarmonyOS NEXT)

用户系统是几乎所有 App 的基础设施。本篇聚焦 UserDao——"鹿鹿"最轻量的数据访问层组件,仅 83 行代码,却支撑起三路登录体系(华为 HMS 一键登录 / 手机验证码 / 纯本地模式)的完整底层持久化。通过拆解这个 83 行精简 DAO,你将掌握:

  • relationalStore RDB 的 CRUD 完整套路(增/查/改三操作)
  • 幂等性设计ensureXxx 命名约定的含义与实践)
  • AppStorage + RDB 双重用户态(内存态 + 持久化态的分工)
  • %{public}s hilog 日志标记的安全含义

系列文章导航:00 开篇与项目总览 · 87 DemoSeeder 数据播种器 · 89 LoginPage 华为一键登录

官方文档参考:relationalStore API · hilog API


一、用户系统整体架构

1.1 三路登录 × 双层状态

"鹿鹿"的用户体系采用 “三路登录入口 + 双层状态管理” 架构:

华为一键

手机号

游客模式

用户进入 App

已有 Token?

跳转 HomePage

LoginPage

选择登录方式

HMS Account Kit
生产: AccountAuthService
调试: Mock Token

PhoneLoginPage
验证码 + SMS

本地模式
open_id='local'

UserDao.ensureLocalUser

DemoSeeder.seedIfNeeded
播种演示数据

AppStorage.setOrCreate
user_id / user_token / user_name

登录方式 open_id 存储来源 线上/调试
华为 HMS HMS 返回的 openId 字符串 AccountAuthService.signIn() 预留注释
手机号 phone_ + 手机号后 4 位 SMS 云端验证 预留注释
本地/游客 'local' 字面量 无外部依赖 ✅ 当前可用

1.2 AppStorage vs RDB 分工

内存态(AppStorage)
  └── user_id     ← 快速读取,无需查 DB
  └── user_token  ← 登录态判断
  └── user_name   ← 首页展示

持久化(RDB user 表)
  └── open_id     ← 唯一标识
  └── name        ← 可修改的昵称
  └── avatar      ← 头像 URI
  └── created_at  ← 首次登录时间

设计原则:AppStorage 只存"当次会话"所需的最小状态集,RDB 存全量用户数据。App 重启时通过 findByOpenId 重建 AppStorage。


二、数据表结构

2.1 user 表定义

user 表在 DatabaseService.ets 中定义,结构如下:

const CREATE_USER_TABLE = `
  CREATE TABLE IF NOT EXISTS user (
    id         INTEGER PRIMARY KEY AUTOINCREMENT,
    open_id    TEXT    NOT NULL UNIQUE,
    name       TEXT    NOT NULL DEFAULT '鹿鹿用户',
    avatar     TEXT,
    created_at INTEGER NOT NULL
  )
`
  • open_idUNIQUE 约束,防止重复插入时产生脏数据
  • avatar 允许 NULL,首次注册时可为空
  • created_at 存 Unix 毫秒时间戳(Date.now()

2.2 对应实体类

// features/data/Models.ets
export interface UserEntity {
  id: number
  open_id: string
  name: string
  avatar?: string
  created_at: number
}

avatar? 标记为可选字段,与 SQL 中的 NULL 语义对应。


三、UserDao 完整实现

3.1 整体结构(83 行精简 DAO)

// entry/src/main/ets/features/data/UserDao.ets
import relationalStore from '@ohos.data.relationalStore'
import { DatabaseService } from './DatabaseService'
import { UserEntity } from './Models'
import { hilog } from '@kit.PerformanceAnalysisKit'

const TAG = 'UserDao'
const TABLE = 'user'

export class UserDao {
  private static getStore(): relationalStore.RdbStore {
    return DatabaseService.getInstance().getStore()
  }

  static async ensureLocalUser(name: string): Promise<UserEntity>
  static async findByOpenId(openId: string): Promise<UserEntity | null>
  static async updateName(id: number, name: string): Promise<void>
}

三个方法的职责划分:

  1. ensureLocalUser — 创建或获取(幂等)
  2. findByOpenId — 按 openId 查询(支持三路登录的统一查询口)
  3. updateName — 更新昵称(个人中心设置页调用)

3.2 ensureLocalUser:幂等创建

static async ensureLocalUser(name: string): Promise<UserEntity> {
  try {
    const store = UserDao.getStore()
    // ① 先查询是否已存在
    const existing = await UserDao.findByOpenId('local')
    if (existing) {
      return existing  // 幂等:已存在则直接返回
    }
    // ② 不存在则插入
    const now = Date.now()
    const values: relationalStore.ValuesBucket = {
      open_id: 'local',
      name,
      created_at: now
    }
    const id = await store.insert(TABLE, values)
    hilog.info(0x0000, TAG, '本地用户创建成功 id=%{public}d', id)
    return { id, open_id: 'local', name, created_at: now }
  } catch (e) {
    hilog.error(0x0000, TAG, 'ensureLocalUser 失败: %{public}s', JSON.stringify(e))
    throw new Error('ensureLocalUser 失败: ' + JSON.stringify(e))
  }
}

设计亮点

  • 先查后插避免了 open_id UNIQUE 约束导致的插入异常
  • 'local' 固定字符串是本地模式的约定标识
  • 函数命名哲学ensure 前缀代表"保证存在",调用者无需判断是创建还是获取

3.3 findByOpenId:统一查询入口

static async findByOpenId(openId: string): Promise<UserEntity | null> {
  try {
    const store = UserDao.getStore()
    const predicates = new relationalStore.RdbPredicates(TABLE)
    predicates.equalTo('open_id', openId)
    const rs = await store.query(
      predicates,
      ['id', 'open_id', 'name', 'avatar', 'created_at']
    )
    let result: UserEntity | null = null
    if (rs.goToFirstRow()) {
      result = {
        id:         rs.getLong(rs.getColumnIndex('id')),
        open_id:    rs.getString(rs.getColumnIndex('open_id')),
        name:       rs.getString(rs.getColumnIndex('name')),
        avatar:     rs.getString(rs.getColumnIndex('avatar')),
        created_at: rs.getLong(rs.getColumnIndex('created_at'))
      }
    }
    rs.close()  // ⚠️ 必须关闭,否则连接泄漏
    return result
  } catch (e) {
    hilog.error(0x0000, TAG, 'findByOpenId 失败: %{public}s', JSON.stringify(e))
    return null
  }
}

关键模式

  • store.query(predicates, columns) — 第二参数指定列名,避免 SELECT *
  • rs.goToFirstRow() — 返回 false 则无记录,无需 while 循环(单条记录查询)
  • rs.close()非常重要,RDB ResultSet 持有底层数据库连接,不关闭会导致连接池耗尽

3.4 updateName:条件更新

static async updateName(id: number, name: string): Promise<void> {
  try {
    const store = UserDao.getStore()
    const predicates = new relationalStore.RdbPredicates(TABLE)
    predicates.equalTo('id', id)
    const affected = await store.update(
      { name } as relationalStore.ValuesBucket,
      predicates
    )
    hilog.info(0x0000, TAG, 'updateName 影响行数: %{public}d', affected)
  } catch (e) {
    hilog.error(0x0000, TAG, 'updateName 失败: %{public}s', JSON.stringify(e))
  }
}

{ name } as relationalStore.ValuesBucketArkTS 类型断言——字面量对象 { name } 的推断类型是 { name: string },不完全匹配 ValuesBucket(索引签名),需要手动断言。


四、与登录页的联动

4.1 完整初始化链

登录成功后的初始化调用顺序严格有序

// LoginPage / PhoneLoginPage 登录成功回调
async function afterLoginSuccess(openId: string, userName: string) {
  // ① 初始化 RDB
  await DatabaseService.getInstance().init(
    getContext(this) as common.UIAbilityContext
  )

  // ② 确保用户记录存在
  const user = await UserDao.ensureLocalUser(userName)

  // ③ 播种演示数据(仅首次)
  await DemoSeeder.seedIfNeeded(user.id)

  // ④ 写入 AppStorage(内存态)
  AppStorage.setOrCreate<number>('user_id', user.id)
  AppStorage.setOrCreate<string>('user_token', 'LU_MOCK_' + Date.now())
  AppStorage.setOrCreate<string>('user_name', userName)

  // ⑤ 跳转首页
  this.getUIContext().getRouter().replaceUrl({ url: 'pages/HomePage' })
}

顺序不可颠倒:DatabaseService.init() 必须在所有 DAO 调用之前,否则 getStore() 返回 undefined。

4.2 登录态预检

每次进入 LoginPage 时会先检查是否已有 Token:

aboutToAppear(): void {
  const token = AppStorage.get<string>('user_token')
  if (token) {
    // 已登录:跳过登录页直接进首页
    this.getUIContext().getRouter().replaceUrl({ url: 'pages/HomePage' })
    return
  }
  // 未登录:开始入场动画
  this.startEntranceAnimation()
}

注意:此处 Token 是 AppStorage 的内存值。App 重启后 AppStorage 清空,即使 RDB 中有用户记录,也需要重新登录写入 Token。这是有意的设计:保证每次冷启动都经过登录验证。


五、hilog 安全日志标记

5.1 %{public}s vs %{private}s

项目所有 hilog 调用都使用 %{public}s 格式化符:

hilog.info(0x0000, TAG, '用户查询: %{public}s', openId)
hilog.error(0x0000, TAG, '错误详情: %{public}s', JSON.stringify(e))
标记 含义 适用场景
%{public}s 日志内容可见于日志抓取工具 非隐私字段:错误消息、操作类型
%{private}s 日志内容隐藏(显示为 <private> 隐私字段:手机号、身份证、密码
%s(无标记) 默认行为,Release 构建隐藏 不推荐,行为依赖构建类型

最佳实践:涉及用户 nameopen_id 等身份信息时应使用 %{private}s;调试时可临时改为 %{public}s,发布前务必还原。


六、DAO 设计模式对比

"鹿鹿"共有 5 个 DAO,所有 DAO 遵循相同的四步模式:

DAO 表名 特殊方法 行数
UserDao user ensureLocalUser(幂等创建) 83
ArchiveDao archive incrementRecord(计数更新) ~180
HandwritingDao handwriting batchUpdateArchive(批量迁移) ~200
ReportDao report queryRecentByUser(聚合查询) ~220
RelationDao relation findByUser(关联查询) ~150

四步统一模式(所有 DAO 方法均遵循):

try {
  const store = XxxDao.getStore()   // ① 获取 RDB 实例
  const rs = await store.query(...) // ② 执行操作
  // rs.goToFirstRow() + getXxx()   // ③ 遍历结果
  rs.close()                        // ④ 关闭 ResultSet
} catch (e) {
  hilog.error(TAG, '...', JSON.stringify(e))
}

七、常见问题排查

7.1 getStore() 返回 undefined

现象:调用 DAO 方法时报 Cannot read property 'insert' of undefined

原因DatabaseService.init() 尚未调用或 await 未完成就调用了 DAO

解决:确保在 EntryAbility.onCreate() 或登录成功后第一步调用 DatabaseService.getInstance().init(context)

7.2 用户记录重复插入

现象:数据库中出现多条 open_id = 'local' 的记录

原因:并发调用 ensureLocalUser,两次都通过了 findByOpenId 的判断

解决:已由 open_id UNIQUE 约束兜底,第二次 insert 会抛异常被 catch 捕获;如需更严格的幂等,可加分布式锁或在应用入口串行化调用

7.3 ResultSet 未关闭导致内存泄漏

现象:长时间使用后 App 报"数据库连接数达上限"错误

原因rs.close() 忘记调用或在异常路径未执行

解决:使用 try-finally 确保 rs.close() 始终执行:

const rs = await store.query(predicates, columns)
try {
  if (rs.goToFirstRow()) {
    // ... 读取数据
  }
} finally {
  rs.close()  // finally 保证执行
}

八、注意事项与常见问题

8.1 开发注意事项

在实际开发过程中,需特别注意以下几点:

  • API 兼容性:部分接口仅在特定 HarmonyOS NEXT 版本中可用,需做版本条件判断
  • 权限模型:采用静态声明(module.json5)+ 动态申请(requestPermissionsFromUser)的两阶段授权
  • 生命周期:合理使用 aboutToAppear()aboutToDisappear() 管理资源初始化与释放
  • 状态同步:跨页面数据通过 AppStorage 共享,组件内状态使用 @State / @Prop / @Link 装饰器

8.2 常见错误与解决方案

常见问题快速排查表:

问题类型 排查方向 参考方法
应用崩溃 查看 hilog 错误日志 hilog.error(TAG, "...", e.message)
状态丢失 检查 AppStorage 键名拼写 统一使用常量管理键名
动画不流畅 避免在 animateTo 回调中执行 I/O 动画与数据操作分离

总结

本篇拆解了 UserDao 的 83 行代码,核心收获:

  • ensureLocalUser 幂等设计:先查后插 + UNIQUE 约束双重保障
  • 三路登录统一底层open_id 字段屏蔽登录方式差异,DAO 层完全无感
  • AppStorage + RDB 职责分离:内存态(快速判断)+ 持久化态(数据恢复)
  • rs.close() 不可省略:RDB ResultSet 持有连接资源,必须手动释放
  • hilog 安全标记%{public}s vs %{private}s 是生产日志的隐私保护机制

下一篇预告第89篇 LoginPage——华为一键登录与 Canvas 手绘插画

如果这篇文章对你有帮助,欢迎点赞👍、收藏⭐、关注🔔,你的支持是我持续创作的动力!


相关资源:

如果这篇文章对你有帮助,欢迎点赞👍、收藏⭐、关注🔔,你的支持是我持续创作的动力!

UserDao 设计精髓:83 行代码实现三路登录的底层持久化,核心在于「幂等性设计」(ensureUser)和「双重用户态」(内存 AppStorage + 持久化 RDB)的分工协作。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐