【时光清单|15】HarmonyOS ArkTS 本地状态持久化实战:让保存、删除和页面返回后的数据即时一致
本地应用的数据问题往往不是"有没有写进文件",而是三个时刻能否保持一致:用户点击保存后当前页显示成功,返回首页后列表立即更新,应用被系统回收再启动时仍能恢复同一份数据。只解决其中一个时刻,就会出现很典型的故障:详情页提示保存成功,首页还展示旧标题;删除后返回列表,条目仍停留到下次启动;首帧先出现默认主题,随后又突然切换;多个页面各自维护数组,最终谁也说不清哪份才是真实状态。
时光清单 的真实源码给出了一条可复核的实现链。DataStore.ets 用 ArkData Preferences 保存 JSON 字符串,AnniversaryRepository.ets 持有内存列表并统一增删改查,AppViewModel.ets 向页面暴露业务操作,StateKeys.DATA_VERSION 作为轻量变化信号,首页、全部列表和筛选列表通过 @StorageLink 与 @Watch 重新加载数据。EntryAbility.ets 还在启动阶段同步读取心情背景,避免首帧先使用默认值。
本文重点不是堆砌 Preferences API,而是把"持久化事实、内存事实、页面快照、跨页通知"四层关系讲清楚。文中会区分现有代码与建议演进:当前源码包含纪念日、习惯、日记、相册等轻量数据的本地写入路径;版本号是刷新信号而不是数据本身;JSON 反序列化只有类型断言,没有运行时结构校验;错误目前主要写日志并回退默认值,后续可继续补充可观测错误状态。
本轮结论只来自列出的生产源码和历史错误记录,没有执行新的构建、模拟器、真机、强制结束进程、异常 JSON 注入或并发点击测试。因此,"当前事实"只描述可以从代码直接确认的调用顺序和边界;"历史证据"只描述文档在特定日期记录的现象与修复;所有返回结果类型、数据迁移、写队列和恢复事务都明确属于建议实现。这样划分可以避免把一段示例代码、一次旧构建或一个成功 Toast 误写成已经验证的持久化可靠性。

本文将解决:
- Preferences 初始化、读写和
flush()如何组成可靠存储入口。 - Repository 为什么既维护内存列表,又必须把修改持久化。
DATA_VERSION如何让保存、删除和返回后的页面即时一致。- 同步首帧读取与异步水合分别适合什么数据。
- 双 Key 兼容、JSON 校验、并发写入和备份恢复有哪些边界。
- 怎样验证冷启动、增删改、跨页返回及异常数据场景。
本文唯一标记:
CSDN-SERIES:ALL-163208902
一、先区分四种状态,避免把 AppStorage 当数据库
在这套实现里,同一条纪念日会以四种形态出现:
| 层次 | 真实载体 | 生命周期 | 主要职责 |
|---|---|---|---|
| 持久化事实 | Preferences 中的 JSON 字符串 | 跨进程、跨启动 | 保存用户数据 |
| 仓库内存 | AnniversaryRepository.items |
当前进程 | 集中排序、查找和修改 |
| 页面快照 | @State anniversaries、@State item |
当前组件 | 驱动 ArkUI 渲染 |
| 变化信号 | AppStorage 中的 DATA_VERSION |
当前进程共享 | 通知其他页面重新读取 |
这四层不能互相替代。Preferences 不会自动让页面重绘,AppStorage 版本号也不会在应用重启后恢复业务数据。页面数组适合渲染,却不应该成为多个页面共同修改的永久事实。
真实数据流可以概括为:
用户操作
-> ViewModel
-> Repository 修改内存数据
-> DataStore 写入 Preferences 并 flush
-> DATA_VERSION + 1
-> 观察页面重新 loadData
-> Repository 返回排序后的新快照
把变化信号放在持久化成功之后非常关键。否则其他页面可能先响应版本变化,却读到尚未落盘或尚未更新完成的数据。
历史证据:页面返回后的旧数据曾经真实发生
PROJECT_ERRORS.md 记录了 2026 年 5 月 20 日一次与本文主题直接相关的审核反馈:从首页快捷入口进入倒计时、纪念日或恋爱事项,编辑并保存后,列表仍显示旧内容,必须切换页面才会刷新。记录给出的根因有两层。第一层是保存虽然已经写入数据,但跨页面列表缺少稳定的即时刷新通知;第二层是 ForEach 只使用稳定 id 作为 Key,同一条记录编辑后仍可能复用原来的 UI 节点。
针对这个问题,历史修复记录明确写到:首页、全部列表和筛选列表增加 DATA_VERSION 观察;详情页在保存、删除和置顶后递增版本;可编辑列表的 Key 改为 id + updatedAt。这与当前源码中的 @StorageLink、@Watch、bumpDataVersion() 和组合 Key 能够互相印证,因此可以把它作为"问题曾发生、修复路径被记录、当前代码保留了该路径"的历史证据。
但历史证据仍有边界。文档写有当时 assembleHap 成功,只能说明当时那组修改完成过一次本地构建;它不能替代本轮的当前构建、设备安装、进程重启、存储损坏和并发提交验证。文章后面的异常测试、迁移和串行化方案都属于建议实现,不能写成已经通过的结果。
还要注意,历史修复解决的是"内存状态与页面快照如何重新对齐",并没有改变 DataStore.putJson() 吞掉写入异常的行为。因此当前 Toast 和页面刷新链路可以即时响应,但底层写失败能否向用户明确反馈,仍是需要继续补齐的可靠性问题。
二、DataStore:把 ArkData Preferences 收敛成一个入口
DataStore 是单例,内部保存 preferences.Preferences 引用和初始化 Promise:
export class DataStore {
private static instance: DataStore | null = null;
private pref: preferences.Preferences | null = null;
private initPromise: Promise<void> | null = null;
static getInstance(): DataStore {
if (!DataStore.instance) {
DataStore.instance = new DataStore();
}
return DataStore.instance;
}
init(context: Context): void {
if (this.initPromise) return;
this.initPromise = this.doInit(context);
}
}
单例的价值不是"全局变量更方便",而是避免每个页面分别创建 Preferences 句柄、分别定义存储名和分别处理初始化时序。真实存储名是 timelist_data,所有业务 Key 也集中在 DataKeys:
export class DataKeys {
static readonly ANNIVERSARIES = 'ds_anniversaries';
static readonly WISHES = 'ds_wishes';
static readonly DIARY = 'ds_diary';
static readonly HABITS = 'ds_habits';
static readonly HABIT_WALL = 'ds_habit_wall';
static readonly COUPLE = 'ds_couple';
static readonly ALBUM = 'ds_album';
static readonly MOOD_BACKGROUND = 'ds_mood_background';
static readonly WIDGET_ANNIVERSARY_ID = 'ds_widget_anniversary_id';
}
集中 Key 可以减少拼写漂移,也让数据迁移和清理范围更容易审计。新增字段时应先判断它是轻量设置、可序列化小集合,还是需要查询、索引和迁移的结构化数据;后者更适合关系型存储,而不是继续把大 JSON 塞进 Preferences。

三、写入完成的定义:put、flush 与错误路径
真实 putJson() 先等待初始化,再序列化、写入并刷新:
async putJson<T>(key: string, value: T): Promise<void> {
await this.ensureReady();
if (!this.pref) return;
try {
const json = JSON.stringify(value);
await this.pref.put(key, json);
await this.pref.flush();
} catch (e) {
hilog.error(DOMAIN, TAG,
'putJson [%{public}s] failed: %{public}s',
key, JSON.stringify(e));
}
}
这里有两个值得保留的工程点。第一,调用方拿到 Promise,可以把"保存成功"放在 await 之后;第二,每次写入后执行 flush(),使提交边界清晰。
不过当前实现也有一个真实边界:异常被捕获后只记日志,方法仍然以正常 Promise 结束。页面因此无法区分"已经写入"和"写入失败但被吞掉"。如果业务数据重要,建议把结果改为显式类型:
export interface StoreResult {
ok: boolean;
message?: string;
}
async putJson<T>(key: string, value: T): Promise<StoreResult> {
await this.ensureReady();
if (!this.pref) return { ok: false, message: 'store not ready' };
try {
await this.pref.put(key, JSON.stringify(value));
await this.pref.flush();
return { ok: true };
} catch (e) {
return { ok: false, message: JSON.stringify(e) };
}
}
这是演进建议,不是对当前源码能力的虚构。只有当 Repository 收到成功结果后,页面才应该显示"保存成功"并递增版本号。
四、读取默认值只能兜底,不能代替数据校验
当前异步读取逻辑会在无值、解析失败或存储未初始化时返回调用方提供的默认值:
async getJson<T>(key: string, defaultValue: T): Promise<T> {
await this.ensureReady();
if (!this.pref) return defaultValue;
try {
const raw = await this.pref.get(key, '');
if (typeof raw === 'string' && raw.length > 0) {
return JSON.parse(raw) as T;
}
} catch (e) {
hilog.error(DOMAIN, TAG,
'getJson [%{public}s] failed: %{public}s',
key, JSON.stringify(e));
}
return defaultValue;
}
JSON.parse(raw) as T 只告诉编译器"把它当成 T",不会检查运行时对象是否真的包含 id、title、targetDate 等字段。如果旧版本结构、手工导入文件或意外损坏的数据进入存储,解析成功仍可能在页面计算时出错。
纪念日数组可以增加轻量守卫:
function isAnniversary(value: object): value is Anniversary {
const item = value as Anniversary;
return typeof item.id === 'string' &&
typeof item.title === 'string' &&
typeof item.targetDate === 'number' &&
typeof item.type === 'string';
}
function normalizeAnniversaries(value: Object): Anniversary[] {
if (!Array.isArray(value)) return [];
return value.filter((item: object) => isAnniversary(item));
}
守卫之后还可以补默认字段和版本迁移。原则是:默认值解决"没有数据",校验解决"数据形状错误",迁移解决"旧结构仍然合法但字段语义发生变化",三者不是一回事。
五、同步首帧与异步水合为什么同时存在
EntryAbility.onCreate() 先同步初始化 DataStore,读取心情背景,再执行全局状态初始化和异步水合:
DataStore.getInstance().initSync(this.context);
const mood = DataStore.getInstance()
.getJsonSync<string>(DataKeys.MOOD_BACKGROUND, 'auto');
this.setStorageString(StateKeys.MOOD_BACKGROUND, mood);
AppStore.bootstrap(this.context);
DataStore.getInstance().init(this.context);
this.hydratePersistentState();
这段顺序针对的是首帧一致性。心情背景直接影响首页视觉,如果先使用 'auto' 构建页面,再异步读取真实值,用户可能看到一次闪变。同步读取小型设置可以把真实值提前放进 AppStorage。
但同步读取不应无限扩大。大型列表、图片索引或耗时迁移若阻塞 onCreate(),会延长启动时间。更稳妥的边界是:
| 数据类型 | 建议策略 |
|---|---|
| 主题、语言、极小开关 | 可考虑同步首帧读取 |
| 纪念日、习惯等小列表 | Repository 异步初始化 |
| 大列表、可查询结构 | 使用关系型存储并分页 |
| 图片与备份文件 | 文件 API,异步读取 |
当前 initSync() 成功后会把 initPromise 设为已完成 Promise,随后 init() 检测到初始化 Promise 已存在便直接返回,因此不会重复打开存储。
六、Repository:让页面不直接操作持久化数组
AnniversaryRepository 同时持有内存列表和 DataStore:
private items: Anniversary[] = [];
private initialized: boolean = false;
private store: DataStore = DataStore.getInstance();
async init(): Promise<void> {
if (this.initialized) return;
this.items = await this.store
.getJson<Anniversary[]>(DataKeys.ANNIVERSARIES, []);
this.initialized = true;
}
页面通过 AppViewModel 调用仓库,而不是直接拼 Key、序列化 JSON。这样排序规则、更新规则和持久化时机都集中在一处。
查询返回排序副本:
private sortedCopy(): Anniversary[] {
return [...this.items].sort((a, b) => {
if (a.pinned !== b.pinned) return a.pinned ? -1 : 1;
return a.targetDate - b.targetDate;
});
}
复制后排序避免查询操作改变仓库内部数组顺序。页面拿到的是用于显示的快照,真正的修改仍应回到 Repository。

七、保存事务:先改内存,再持久化,再发通知
新增和编辑都走 save()。存在 ID 时修改原对象,不存在时创建新对象并加入列表,随后统一 persist():
async save(data: AnniversarySaveData): Promise<Anniversary> {
await this.init();
const existing = data.id
? this.items.find((item) => item.id === data.id)
: undefined;
if (existing) {
if (data.title) existing.title = data.title;
if (data.targetDate) existing.targetDate = data.targetDate;
if (data.remark !== undefined) existing.remark = data.remark;
existing.updatedAt = Date.now();
await this.persist();
return existing;
}
const newItem = createAnniversary(data);
this.items.push(newItem);
await this.persist();
return newItem;
}
新增页在 await this.viewModel.saveAnniversary(data) 返回后,才更新卡片默认选择并递增 DATA_VERSION。顺序形成了清晰提交点:
const saved = await this.viewModel.saveAnniversary(data);
const ver = AppStorage.get<number>(StateKeys.DATA_VERSION) ?? 0;
AppStorage.set<number>(StateKeys.DATA_VERSION, ver + 1);
页面输入清空和成功提示也位于持久化调用之后。若后续让 DataStore 返回失败结果,应把清空表单、成功提示和版本递增都放到成功分支,避免用户误以为数据已经保存。
八、删除事务:成功后发信号,再退出详情页
详情页删除链路是另一个关键场景:
const deleted =
await this.viewModel.deleteAnniversary(this.item.id);
if (deleted) {
this.bumpDataVersion();
this.pathStack.pop();
}
Repository 只有在找到目标 ID、从数组移除并完成 persist() 后才返回 true:
async delete(id: string): Promise<boolean> {
await this.init();
const index = this.items.findIndex((item) => item.id === id);
if (index >= 0) {
this.items.splice(index, 1);
await this.persist();
return true;
}
return false;
}
这个顺序保证返回列表之前已经发出变化信号。列表页面即使一直保留在导航栈中,也能通过版本观察重新读取,而不必依赖组件被销毁后再次进入。
需要注意当前 persist() 对底层写入失败没有向上传播,所以 delete() 的 true 更准确地表示"内存中找到并执行了删除流程",不是可证明的磁盘提交成功。若要提升可靠性,仍应把 DataStore 的结果向 Repository 传播。
九、DATA_VERSION 是失效标记,不是业务版本
AppStore.bootstrap() 初始化:
AppStorage.setOrCreate<number>(StateKeys.DATA_VERSION, 0);
首页、全部列表和筛选列表使用相同模式:
@StorageLink(StateKeys.DATA_VERSION)
@Watch('onDataChange')
dataVersion: number = 0;
onDataChange(): void {
this.loadData();
}
版本值本身没有业务含义,不需要持久化,也不要求等于修改次数。它只表示"你手里的页面快照可能过期,请重新读取"。这种设计比在多个页面之间传递新数组更简单,也避免每个写入页面逐一知道有哪些读页面。
版本号不是万能事件总线。若未来需要区分"纪念日变化""习惯变化""主题变化",可以拆成不同领域的 revision,或建立类型化的刷新服务。当前单一 DATA_VERSION 适合规模较小、重新读取成本较低的应用。
十、为什么 aboutToAppear 仍然要保留
页面既在 aboutToAppear() 加载,也监听版本变化:
aboutToAppear(): void {
this.loadData();
}
onDataChange(): void {
this.loadData();
}
两条路径覆盖不同情况。首次进入页面时还没有发生版本变化,需要生命周期加载;页面已经存在于 Tab 或导航栈中时,其他页面保存和删除会触发版本刷新。两者合起来,才能覆盖"第一次打开"和"回来时已经过期"。
要防止快速连续写入造成旧请求覆盖新请求,可以给读取增加序号:
private loadSeq: number = 0;
private async loadData(): Promise<void> {
const seq = ++this.loadSeq;
const items = await this.viewModel.loadAnniversaries();
if (seq !== this.loadSeq) return;
this.anniversaries = items;
}
当前 Repository 读取主要来自内存,竞争窗口较小;若后续改成数据库、文件或网络同步,这个防护会更有价值。
十一、习惯数据双 Key 合并是兼容逻辑
HabitService.list() 同时读取 HABITS 和 HABIT_WALL,按 ID 合并,更新时间较新的记录获胜,然后把结果回写两个 Key:
async list(): Promise<HabitItem[]> {
const habits =
await this.store.getJson<HabitItem[]>(DataKeys.HABITS, []);
const wallHabits =
await this.store.getJson<HabitItem[]>(DataKeys.HABIT_WALL, []);
const merged = this.merge(habits, wallHabits);
await this.persist(merged);
return merged;
}
private async persist(habits: HabitItem[]): Promise<void> {
await this.store.putJson(DataKeys.HABITS, habits);
await this.store.putJson(DataKeys.HABIT_WALL, habits);
}
这说明真实项目里已经存在两个历史入口。合并并双写能保持兼容,但双写并非原子事务:第一个 Key 成功、第二个 Key 失败时会再次出现分叉。后续完成版本迁移后,最好选定唯一 Key,并记录迁移完成标记;若仍需双写,应让写入结果可观察,并定义失败后的重试或回滚策略。
十二、文件备份与 Preferences 不是同一条恢复链
BackupService 把结构化备份写入 context.filesDir,文件名含时间戳:
const fileName = `backup_${Date.now()}.json`;
const filePath = `${this.baseDir}/${fileName}`;
const json = JSON.stringify(data, null, 2);
const file = fileIo.openSync(
filePath,
fileIo.OpenMode.CREATE | fileIo.OpenMode.WRITE_ONLY
);
fileIo.writeSync(file.fd, json);
fileIo.closeSync(file);
导入时会读取文件并检查 version 与 anniversaries。这是一条真实的本地文件能力,但当前校验仍比较基础:没有限制文件大小,没有逐字段验证,也没有在本文所读源码中展示"导入后如何写回各 Repository 和 Preferences"的完整提交事务。
因此不能把"能解析备份文件"直接描述成"已经实现完整恢复"。可靠恢复至少需要:
- 校验文件大小、编码、版本和字段结构。
- 在内存外构建候选数据,不直接覆盖当前数据。
- 写入所有目标存储并检查结果。
- 更新 Repository 内存缓存。
- 递增对应 revision,让页面重新加载。
- 失败时保留原数据并给出明确提示。
十三、并发写入要避免读改写丢失
当前仓库单例把大部分纪念日修改串在同一对象上,降低了页面各自读写整个数组的风险。但 save()、delete() 和 togglePin() 仍是异步方法;用户快速连点或不同页面同时提交时,可能发生多次 persist() 交错。
可在 Repository 内建立简单写队列:
private writeQueue: Promise<void> = Promise.resolve();
private enqueuePersist(): Promise<void> {
this.writeQueue = this.writeQueue
.catch(() => {})
.then(() => this.store.putJson(
DataKeys.ANNIVERSARIES,
this.items
));
return this.writeQueue;
}
同时,按钮应有 saving 或 deleting 状态,提交期间禁用重复点击。这样 UI 防重与数据层串行化形成两道保护。这里同样属于建议演进,当前源码尚未展示写队列。
十四、AppStorage 中不要放完整持久化数据
这套代码只把 DATA_VERSION、主题、安全区和导航栈放进 AppStorage,纪念日数组仍由 Repository 管理。这个边界是合理的:
AppStorage适合跨组件共享的运行时状态和失效信号。- Preferences 适合轻量、可序列化、需要跨启动保存的数据。
- Repository 负责业务规则与数据访问。
- 页面只持有渲染快照和短生命周期输入。
如果把完整数组同时放进 AppStorage 和 Repository,就会出现双真源:某个页面只改共享数组,另一个服务只改仓库,持久化又写入第三份值。版本号模式的优点正是只传"变化发生了",不复制业务事实。
十五、错误处理要覆盖用户可见状态
当前 HomeView.loadData() 使用 finally 结束加载状态,DataStore 则在读取失败时返回默认值。这样页面不会一直转圈,但损坏数据和"确实没有数据"可能都表现为空列表。
更完整的页面状态应至少包括:
type LoadState = 'loading' | 'content' | 'empty' | 'error';
@State loadState: LoadState = 'loading';
@State errorMessage: string = '';
Repository 可以返回结构化错误,页面据此显示"暂无内容"或"读取失败,请重试"。日志只记录 Key、阶段和错误类别,不应输出纪念日备注、日记正文、情侣空间内容或完整备份 JSON。
十六、验证矩阵:不要只验证"下次启动还在"
建议按下面矩阵验证项目实际支持的目标设备:
| 场景 | 操作 | 预期 |
|---|---|---|
| 冷启动 | 已有数据后强制结束并重启 | 列表与主题恢复,无首帧明显闪变 |
| 新增 | 新建纪念日并切回首页 | 首页和全部列表立即出现 |
| 编辑 | 详情修改标题并保存 | 当前详情更新,返回列表显示新标题 |
| 删除 | 详情删除后自动返回 | 原列表立即移除,无残留卡片 |
| 置顶 | 切换置顶状态 | 排序按 pinned 与日期重新计算 |
| 快速点击 | 连续点击保存或删除 | 只提交一次,不重复新增 |
| 数据损坏 | 写入非法 JSON 测试数据 | 不崩溃,有日志和可见错误或安全默认 |
| 旧结构 | 缺失新增字段的数据 | 经 normalize 后可显示 |
| 深浅色 | 切换系统模式并重启 | 主题状态与文字对比正常 |
| 小窗恢复 | 修改后切后台、调整窗口再返回 | 页面读取一致,无旧快照覆盖 |
验证时应同时观察 Preferences 中的值、Repository 返回值、DATA_VERSION 变化和页面最终渲染,不能只看 Toast。
十七、常见问题与定位
| 现象 | 可能原因 | 定位与修复 |
|---|---|---|
| 保存成功但返回仍是旧数据 | 未递增 DATA_VERSION,或观察页面未监听 |
检查提交顺序和 @Watch |
| 删除后列表残留 | 先 pop() 后通知,或删除未成功 |
持久化成功后 bump,再返回 |
| 重启后数据消失 | 未 flush()、Key 不一致或初始化失败 |
核对存储名、DataKeys 与日志 |
| 首帧主题闪动 | 首帧使用默认值后再异步水合 | 仅对极小关键设置使用同步读取 |
| 非法 JSON 变空列表 | catch 后返回默认值 | 增加错误状态、备份与校验 |
| 两个习惯入口数据不同 | 双 Key 写入部分失败 | 引入迁移标记和可观察写入结果 |
| 快速点击产生重复项 | 按钮未禁用、缺少写队列 | 增加 saving 状态和串行提交 |
| 导入后页面不刷新 | 只解析文件,未更新仓库和 revision | 完成恢复事务后统一发通知 |
十八、上线前核对
- Preferences 只保存轻量数据,未把大文件或可查询大集合塞入 JSON。
DataKeys集中管理,旧 Key 有明确迁移和清理策略。- 保存、删除、置顶均在持久化完成后发出 revision。
- 首页、全部列表和筛选列表覆盖首次加载与版本变化。
- 写入失败不会显示成功,也不会清空用户输入。
- JSON 数据有结构校验、默认字段和版本迁移。
- 重复点击有禁用状态,数据层有必要的串行化保护。
- 日志不包含备注、日记、情侣内容或完整备份正文。
- 冷启动、后台恢复、横竖屏和小窗返回均完成实机验证。
- 隐私说明与实际本地存储、备份文件行为保持一致。
十九、总结:一致性来自提交顺序与单一事实源
时光清单 的现有实现已经形成了清晰骨架:DataStore 封装 Preferences,Repository 管理内存事实和持久化,ViewModel 隔离页面与数据层,DATA_VERSION 让已有页面知道快照已过期,EntryAbility 对首帧关键设置做同步读取。保存和删除之所以能在返回后即时一致,不是因为 ArkUI 自动同步了数组,而是因为代码明确执行了"持久化完成、递增版本、观察页面重新读取"的顺序。
下一步优化也应沿着这条边界推进:让存储错误可向上传播,为 JSON 增加运行时校验与迁移,为并发写入增加串行化,为备份恢复补齐完整事务。不要再增加第二份业务真源,也不要让页面直接操作 Preferences。只要持久化事实唯一、提交边界明确、变化信号轻量,HarmonyOS 本地应用就能同时获得快速首帧、可靠保存和跨页面即时一致。
本文基于 D:\huawei\one8 中 DataStore.ets、AnniversaryRepository.ets、AppStore.ets、EntryAbility.ets、AppViewModel.ets、AddView.ets、DetailView.ets、HomeView.ets、AllView.ets、FilteredListView.ets、HabitService.ets 与 BackupService.ets 的真实源码复核。文中标注为"建议"或"演进"的代码未声称已经落地。
AI 辅助声明: 本文内容由作者结合真实项目源码整理,部分内容由 AI 辅助生成,并已进行人工核验与技术校正。