数据序列化与类型安全——ArkTS 中的 JSON 处理

在这里插入图片描述

引言

在 HarmonyOS 应用开发中,Preferences 键值存储本质上只能存储基本类型(stringnumberboolean)和可序列化的对象。当我们需要存储复杂结构(如对象数组、嵌套对象)时,序列化与反序列化就成为了一个绕不开的话题。ArkTS 作为 TypeScript 的超集,提供了 JSON.stringifyJSON.parse 等熟悉的工具,但在实际使用中存在许多值得注意的陷阱。本文将以项目中的实际代码为例,深入分析 ArkTS 中数据序列化和类型安全的实践要点。

一、隐式序列化:PreferenceUtil 的自动处理

在项目的 Manager 层,写入数据时通常不显式调用 JSON.stringify。这是因为 PreferenceUtil.put 方法接受的 value 类型是 preferences.ValueType

// NewWordManager 写入生词列表
private saveNewWords(words: NewWordItem[]): void {
  PreferenceUtil.getInstance().put(this.NEW_WORD_KEY, words);
}

// StatisticsManager 写入统计数据
PreferenceUtil.getInstance().put(this.STATISTICS_KEY, mergedStats);

重要发现preferences.ValueType 在 ArkTS 中支持 ObjectArray 类型,Preferences API 内部会处理序列化/反序列化。这意味着 words 数组或 mergedStats 对象会被自动序列化为 JSON 字符串并存储。

读取时同样自动反序列化:

// 读取生词列表
let words = PreferenceUtil.getInstance().get(this.NEW_WORD_KEY, []) as NewWordItem[];

// 读取统计数据
let stats = PreferenceUtil.getInstance().get(this.STATISTICS_KEY, undefined) as PracticeStatistics;

二、显式序列化的应用场景

虽然 Preferences API 支持自动序列化,但在某些场景下,显式序列化仍然有必要:

2.1 复杂嵌套结构的深度复制

// 学习计划克隆 - 使用手动深度复制而非 JSON 序列化
private cloneLearningPlan(plan: LearningPlan): LearningPlan {
  return {
    dailyWordGoal: plan.dailyWordGoal,
    dailyListeningGoal: plan.dailyListeningGoal,
    // ... 逐字段复制
    badges: plan.badges.concat() // 数组需要 .concat() 深复制
  };
}

为什么不直接用 JSON.parse(JSON.stringify(plan))?因为 Date 对象在 JSON 序列化中会变成字符串,反序列化后不再是 Date 类型。

2.2 部分更新时的空值合并

LearningPlanManager 的部分更新中,?? 空值合并运算符扮演了关键角色:

private mergeLearningPlans(currentPlan: LearningPlan, plan: Partial<LearningPlan>): LearningPlan {
  return {
    dailyWordGoal: plan.dailyWordGoal ?? currentPlan.dailyWordGoal,
    dailyListeningGoal: plan.dailyListeningGoal ?? currentPlan.dailyListeningGoal,
    // ...
    badges: plan.badges ? plan.badges.concat() : currentPlan.badges.concat()
  };
}

为什么使用 ?? 而非 ||

  • ?? 只在左侧为 nullundefined 时取右侧值。
  • || 在左侧为 false0'' 等 falsy 值时也会取右侧值。
  • 对于 dailyWordGoal: 0 这样的有效值,?? 可以正确处理,而 || 会错误地取默认值。

三、反序列化后的类型安全

3.1 as 类型断言的局限性

在 ArkTS 中,as 关键字只是编译时类型断言,不会在运行时做类型校验:

// 编译时通过,但运行时可能不是 NewWordItem[]
let words = PreferenceUtil.getInstance().get(this.NEW_WORD_KEY, []) as NewWordItem[];

如果存储的数据格式被意外修改(如版本升级改变了数据结构),as 断言不会抛出任何错误,直到代码访问不存在的属性时才会出现运行时错误。

3.2 运行时安全性检查

更安全的做法是在反序列化后做校验,或者提供默认值兜底:

// StatisticsManager 中的安全读取
public getHistoryStatistics(): PracticeStatistics {
  try {
    let stats = PreferenceUtil.getInstance().get(this.STATISTICS_KEY, undefined) as PracticeStatistics;
    if (!stats) {
      return this.getEmptyStatistics();  // 返回空统计而非 null
    }
    return stats;
  } catch (e) {
    Logger.error('StatisticsManager', `获取历史统计数据失败: ${JSON.stringify(e)}`);
    return this.getEmptyStatistics();
  }
}

3.3 undefined 作为默认值标识

当需要区分"未设置"和"空数组"时,使用 undefined 作为默认值:

// 学习计划 - undefined 表示还未设置过
let plan = PreferenceUtil.getInstance().get(this.LEARNING_PLAN_KEY, undefined) as LearningPlan;
if (!plan) {
  plan = this.cloneLearningPlan(DEFAULT_LEARNING_PLAN);
  this.saveLearningPlan(plan);
}

// 生词本 - 空数组 [] 是有效值
let words = PreferenceUtil.getInstance().get(this.NEW_WORD_KEY, []) as NewWordItem[];

四、日期对象的序列化陷阱

日期对象的序列化是 JSON 处理中最常见的陷阱之一:

let date = new Date('2025-01-15');
let json = JSON.stringify(date);
console.log(json); // '"2025-01-15T00:00:00.000Z"' —— 变成了字符串

let parsed = JSON.parse(json);
console.log(parsed instanceof Date); // false —— 不再是 Date 对象

在项目中,正确的做法是将日期存储为时间戳(number),而不是 Date 对象:

// 存储时间戳(推荐)
addTime: Date.now()  // number 类型,安全可序列化

// 读取后转换为 Date
let addDate = new Date(wordItem.addTime);

// 连续天数比较中使用时间戳
let today = new Date();
today.setHours(0, 0, 0, 0);
let todayTimestamp = today.getTime();  // 存储的是 number

最佳实践:所有需要序列化的日期值都使用 number 时间戳,而非 Date 对象。

五、版本兼容与默认字段

当应用版本升级、数据模型新增字段时,旧数据中会缺少新字段。通过 ?? 和默认值可以优雅地处理版本兼容:

// 假设 LearningPlan 在新版本中新增了 reminderEnabled 字段
// 旧数据中没有这个字段,读取后为 undefined
// 使用 ?? 可以提供默认值
private mergeLearningPlans(currentPlan: LearningPlan, plan: Partial<LearningPlan>): LearningPlan {
  return {
    reminderEnabled: plan.reminderEnabled ?? currentPlan.reminderEnabled,  // undefined 时保留旧值
    // ...
  };
}

// 更好的方式是提供 DEFAULT 常量
export const DEFAULT_LEARNING_PLAN: LearningPlan = {
  reminderEnabled: false,  // 新字段默认值
  reminderTime: '08:00',   // 新字段默认值
  // ...
};

六、最佳实践

6.1 统一的序列化入口

通过 PreferenceUtil 封装所有的序列化逻辑,避免在各 Manager 中散落 JSON.stringify 调用。

6.2 避免引用共享

反序列化的对象与原始对象没有引用关系,这是安全的。但在 Manager 内部操作时应避免直接修改反序列化的结果:

// ❌ 直接修改反序列化的引用
let plan = this.getLearningPlan();
plan.todayWordCount += count;  // 修改了内存中的对象
this.saveLearningPlan(plan);   // 序列化后写入

// ✅ 读取后立即写入是安全的(读取-修改-写回模式)

6.3 日志序列化

在 Logger 中记录复杂对象时,记得使用 JSON.stringify

Logger.info('PreferenceUtil', `put: ${key} = ${JSON.stringify(value)}`);

如果直接传入对象,Logger 可能输出 [Object object],无法看到具体内容。

七、总结

ArkTS 中的 JSON 序列化处理需要开发者注意以下几点:

  1. 隐式序列化:Preferences API 对 Object/Array 类型自动处理序列化,Manager 层无需显式调用 JSON.stringify
  2. 类型断言as 关键字仅有编译时作用,运行时需要通过默认值和空值检查确保安全。
  3. 日期处理:永远使用时间戳(number)而非 Date 对象进行持久化。
  4. 版本兼容:通过 ?? 运算符和默认值常量(DEFAULT_LEARNING_PLAN)处理数据模型升级。
  5. 引用安全:采用"读取-修改-写回"模式,避免意外修改共享引用。

理解这些要点后,开发者可以在 ArkTS 中编写出既类型安全又健壮的数据持久化代码。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐