HarmonyOS实战《疆域纪行》第05篇|本地状态管理:Preferences 保存收藏、搜索历史和行程清单
这一篇把 App 从“只读内容”推进到“有个人状态”。疆域纪行不登录、不联网,但用户依然需要收藏、搜索历史和行程清单。轻量本地状态用 Preferences 就能完成闭环。
项目落点:
- 本地状态模型:
LocalAppState - 存储服务:
entry/src/main/ets/services/LocalAppStore.ets - 页面状态:
favoriteIds、searchHistory、itineraryIds - 写入动作:
toggleFavorite()、commitSearchTerm()、toggleItinerary()
读完这一篇,你可以复用一套 local-first 状态方案:页面维护 @State,服务层封装 Preferences,持久化只保存稳定 id,不复制完整内容对象。
本文解决什么
- 用 Preferences 保存轻量用户状态,让离线 App 也能保留个人化操作。
- 把存储读写收进
LocalAppStore,页面不直接散落 Preferences key。 - 用 id 列表持久化收藏和行程,避免内容对象复制后产生版本不一致。
工程流程怎么走
页面出现时读取 Preferences,用户点击后更新 @State 数组,再异步写入本地存储。重启后重新读取状态,UI 自动恢复。
这张流程图建议放在文章靠前位置。读者先知道处理顺序,再看后面的代码,理解成本会低很多。
工程结构怎么拆
结构图把 LocalAppState、LocalAppStore、页面状态、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();
}
这里没有直接 push 或 splice,而是给 favoriteIds 重新赋值。这样更符合响应式状态更新习惯,也能减少 UI 不刷新的问题。
对应持久化方法:
private async persistFavorites(): Promise<void> {
await saveFavoriteIds(this.getHostContext(), this.favoriteIds);
}
UI 交互和存储写入形成闭环:
- 点击收藏。
- 更新
favoriteIds。 - UI 立即变化。
- 异步写入 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 稳定。
下一篇进入详情页:如何做沉浸式头图、固定操作栏、信息宫格和不同类型内容的详情展示。
更多推荐



所有评论(0)