这一篇把 App 从“只读内容”推进到“有个人状态”。疆域纪行不登录、不联网,但用户依然需要收藏、搜索历史和行程清单。轻量本地状态用 Preferences 就能完成闭环。

项目落点:

  • 本地状态模型:LocalAppState
  • 存储服务:entry/src/main/ets/services/LocalAppStore.ets
  • 页面状态:favoriteIdssearchHistoryitineraryIds
  • 写入动作:toggleFavorite()commitSearchTerm()toggleItinerary()

读完这一篇,你可以复用一套 local-first 状态方案:页面维护 @State,服务层封装 Preferences,持久化只保存稳定 id,不复制完整内容对象。

本文解决什么

  • 用 Preferences 保存轻量用户状态,让离线 App 也能保留个人化操作。
  • 把存储读写收进 LocalAppStore,页面不直接散落 Preferences key。
  • 用 id 列表持久化收藏和行程,避免内容对象复制后产生版本不一致。

工程流程怎么走

页面出现时读取 Preferences,用户点击后更新 @State 数组,再异步写入本地存储。重启后重新读取状态,UI 自动恢复。

这张流程图建议放在文章靠前位置。读者先知道处理顺序,再看后面的代码,理解成本会低很多。

工程结构怎么拆

结构图把 LocalAppStateLocalAppStore、页面状态、Preferences 和卡片展示串起来。页面负责交互,服务层负责持久化。

这类结构图适合 CSDN 阅读场景:不截小字号代码,而是把模块职责和数据流讲清楚。

实际改造时,可以按“配置层、生命周期层、页面层、服务层、资源层”逐项检查:配置层只放声明,生命周期层只负责启动和销毁,页面层只处理状态和交互,服务层负责读写和兜底,资源层统一管理图片、颜色和图标。职责拆清楚后,后面排查白屏、状态丢失、图片不显示这类问题会快很多。

单机 App 也需要“用户状态”

《疆域纪行》是一款离线旅行攻略 App,不需要登录,也不依赖云端。但这不代表它没有用户状态。

真实用户会希望:

  • 收藏想去的景点。
  • 保存感兴趣的路线。
  • 记住搜索过的关键词。
  • 把景点加入自己的行程清单。
  • 关闭 App 后再次打开,状态还在。

这些数据都很轻量,适合用 HarmonyOS 的 Preferences 存储。

状态 保存内容 不保存什么
收藏 内容 id 数组 不复制标题、图片和摘要
搜索历史 关键词数组 不保存每次输入过程
行程清单 景点或路线 id 数组 不保存完整路线对象

这个边界很关键。Preferences 适合保存小而稳定的状态,不适合承载不断增长的大型业务记录。把边界说清楚,后续升级到 relationalStore 时也不会推翻页面结构。

本地状态模型

项目先在 AtlasModels.ets 里定义了状态结构:

export interface LocalAppState {
  favoriteIds: string[];
  searchHistory: string[];
  itineraryIds: string[];
}

注意这里保存的是 id,不是完整对象。

这是一个很重要的设计选择。收藏一条景点时,只保存 spot_sayram 这样的稳定 id,而不是把标题、图片、简介都复制一份。

这样有三个好处:

  • 存储体积小。
  • 内容更新后收藏仍能指向最新内容。
  • 收藏、行程和搜索逻辑都更简单。

页面展示收藏时,再用 id 回到内容库里查完整对象。

服务层封装 Preferences

本地存储代码放在:

entry/src/main/ets/services/LocalAppStore.ets

顶部定义了存储名和 key:

const STORE_NAME = 'jiangyu_atlas_store';
const FAVORITE_KEY = 'favorite_ids';
const SEARCH_HISTORY_KEY = 'search_history';
const ITINERARY_KEY = 'itinerary_ids';

这些常量不要散落在页面里。以后如果要改 key 或做迁移,只需要改服务层。

获取 Preferences 实例

async function getStore(context: common.UIAbilityContext): Promise<preferences.Preferences> {
  try {
    return await preferences.getPreferences(context, STORE_NAME);
  } catch (error) {
    throw new Error('Failed to open local preferences.');
  }
}

这里需要 UIAbilityContext。页面中通过:

private getHostContext(): common.UIAbilityContext {
  return this.getUIContext().getHostContext() as common.UIAbilityContext;
}

拿到宿主上下文,再传给服务层。

这种方式的好处是:Preferences 相关 API 不直接散在 UI 事件里,页面只负责调用语义化函数。

安全解析数组

Preferences 存储的是字符串,数组需要 JSON 序列化。项目定义了一个解析函数:

function parseStringList(raw: string): string[] {
  try {
    const parsed = JSON.parse(raw) as string[];
    return parsed.filter((item: string) => item.trim().length > 0);
  } catch (error) {
    return [];
  }
}

这个函数有两个细节:

  • 解析失败时返回空数组,避免影响 App 启动。
  • 过滤空字符串,避免脏数据进入页面状态。

本地存储一定要假设数据可能损坏。比如开发调试时写入了错误格式,或者未来版本 key 结构改变。如果解析异常直接抛到页面,用户可能会打不开 App。

读取完整本地状态

export async function loadLocalAppState(context: common.UIAbilityContext): Promise<LocalAppState> {
  try {
    const store = await getStore(context);
    const favoriteRaw = await store.get(FAVORITE_KEY, '[]');
    const historyRaw = await store.get(SEARCH_HISTORY_KEY, '[]');
    const itineraryRaw = await store.get(ITINERARY_KEY, '[]');
    return {
      favoriteIds: parseStringList(`${favoriteRaw}`),
      searchHistory: parseStringList(`${historyRaw}`),
      itineraryIds: parseStringList(`${itineraryRaw}`)
    };
  } catch (error) {
    return {
      favoriteIds: [],
      searchHistory: [],
      itineraryIds: []
    };
  }
}

这段代码有一个很好的实践:即使读取失败,也返回默认状态。

对离线 App 来说,收藏丢失当然不好,但比 App 无法启动要好。稳定性优先级应该高于局部状态完整性。

写入字符串数组

async function saveStringList(context: common.UIAbilityContext, key: string, value: string[]): Promise<void> {
  try {
    const store = await getStore(context);
    await store.put(key, JSON.stringify(value));
    await store.flush();
  } catch (error) {
  }
}

put 后调用 flush(),确保数据写入持久化存储。这里捕获错误但没有弹窗,因为收藏和历史属于轻量状态,不应该因为一次写入失败打断用户浏览。

对更重要的数据,比如用户创作内容、订单草稿、长表单,则应该给出明确失败提示。

页面启动时恢复状态

Index.ets 中:

async aboutToAppear() {
  const state: LocalAppState = await loadLocalAppState(this.getHostContext());
  this.favoriteIds = state.favoriteIds;
  this.searchHistory = state.searchHistory;
  this.itineraryIds = state.itineraryIds;
}

页面出现时读取本地状态,然后赋给 @State。之后页面所有收藏按钮、搜索历史、行程列表都会自动跟着状态刷新。

这是 ArkUI 的自然写法:持久化层只负责数据,UI 层由状态驱动。

收藏切换逻辑

private isFavorite(id: string): boolean {
  return this.favoriteIds.indexOf(id) >= 0;
}

private toggleFavorite(id: string): void {
  if (this.isFavorite(id)) {
    this.favoriteIds = this.favoriteIds.filter((item: string) => item !== id);
  } else {
    this.favoriteIds = [...this.favoriteIds, id];
  }
  this.persistFavorites();
}

这里没有直接 pushsplice,而是给 favoriteIds 重新赋值。这样更符合响应式状态更新习惯,也能减少 UI 不刷新的问题。

对应持久化方法:

private async persistFavorites(): Promise<void> {
  await saveFavoriteIds(this.getHostContext(), this.favoriteIds);
}

UI 交互和存储写入形成闭环:

  1. 点击收藏。
  2. 更新 favoriteIds
  3. UI 立即变化。
  4. 异步写入 Preferences。

这就是轻量 optimistic UI。用户感知更快。

搜索历史持久化

搜索历史和收藏类似:

private commitSearchTerm(term: string): void {
  const keyword = term.trim();
  if (keyword.length === 0) {
    return;
  }
  this.searchText = keyword;
  this.searchHistory = [keyword, ...this.searchHistory.filter((item: string) => item !== keyword)].slice(0, 6);
  this.persistSearchHistory();
}

历史记录只存关键词数组。这样页面既可以展示最近搜索,也可以在点击历史词时直接把它变成当前搜索词。

清空历史也很简单:

private clearSearchHistory(): void {
  this.searchHistory = [];
  this.persistSearchHistory();
}

行程清单复用同一套模式

行程清单保存的是 itineraryIds

private toggleItinerary(id: string): void {
  if (this.isInItinerary(id)) {
    this.itineraryIds = this.itineraryIds.filter((item: string) => item !== id);
  } else {
    this.itineraryIds = [...this.itineraryIds, id];
  }
  this.persistItinerary();
}

展示时再按 id 查完整对象:

private getItinerarySpots(): AtlasItem[] {
  return this.itineraryIds
    .map((id: string) => this.spots.find((item: AtlasItem) => item.id === id))
    .filter((item: AtlasItem | undefined) => !!item) as AtlasItem[];
}

这里还有一个容错点:如果某个 id 在新版内容库里找不到,会被过滤掉,不会让页面报错。

我的页面展示本地信息

“我的”页面里有一张信息卡:

this.InfoListRow('运行方式', '无需登录,无需联网')
this.InfoListRow('数据存储', '收藏、搜索历史、行程仅保存在本机')

这是产品层面的好习惯。既然应用不登录、不联网,就应该清楚告诉用户数据边界。对于本地收藏类功能,透明说明比隐藏细节更可信。

Preferences 适合什么,不适合什么

在这个项目里,Preferences 很合适,因为数据都是小数组:

  • 收藏 id 列表
  • 搜索历史列表
  • 行程 id 列表

但如果后续要支持:

判断方式可以很直接:如果一条记录需要独立编辑、删除、排序、导出,或者记录数量会持续增长,就不要继续把它塞进 Preferences。这个项目的收藏和历史只是 id 列表,仍然是合适的;自定义多日行程如果要支持拖拽和备注,就应该升级存储模型。

  • 多个自定义行程
  • 每日行程排序
  • 用户笔记
  • 大量浏览历史
  • 内容离线包索引

就应该考虑 relationalStore 或 JSON 文件存储,并设计 schema version 和迁移逻辑。

验证清单

检查项 操作方式 通过标准
首次启动没有存储数据时返回空数组 对照项目文件或真机/模拟器操作 结果符合文章描述
收藏后按钮状态立即变化 对照项目文件或真机/模拟器操作 结果符合文章描述
关闭重启后收藏仍存在 对照项目文件或真机/模拟器操作 结果符合文章描述
搜索历史最多保留指定数量 对照项目文件或真机/模拟器操作 结果符合文章描述
损坏 JSON 不影响 App 启动 对照项目文件或真机/模拟器操作 结果符合文章描述

常见问题和处理

问题现象 优先排查 处理方式
收藏后重启丢失 检查是否调用 flush() 确认保存函数没有被异常吞掉
页面不刷新 检查是否重新赋值数组 避免只在原数组上 push/splice
旧 id 找不到内容 详情查找要做 fallback 展示前过滤 undefined

本篇小结

这一篇我们完成了本地持久化闭环:

  • LocalAppState 表达本地状态。
  • 用 Preferences 存储轻量数组。
  • 页面启动时读取状态。
  • 收藏、搜索历史、行程清单都保存 id。
  • 写入失败不影响核心浏览。
  • 解析失败时提供默认值,保证 App 稳定。

下一篇进入详情页:如何做沉浸式头图、固定操作栏、信息宫格和不同类型内容的详情展示。

Logo

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

更多推荐