很多初学鸿蒙应用开发的朋友都会碰到这样一个问题:入口在 EntryAbility 里用 preferences 存了一个变量,等页面加载完了,想在具体 page 里把这个变量读出来展示,却不知道该从哪里下手。这个问题看起来很简单,但实际上牵扯到 Stage 模型下 Context 的理解、preferences 的读写机制、还有页面生命周期和异步时序。我前后也在这个坑里兜过圈子,下面把完整的方案和踩坑过程都写清楚。
先说最核心的结论:EntryAbility 和 page 在同一个应用进程、同一个沙箱环境下,preferences 本质上就是落盘在应用私有目录下的一个 KV 文件。你在 EntryAbility 里用某个 storeName 写入数据,在 page 里只要用同一个 storeName、同一个应用上下文去读取,就能拿到那份数据。 所以不要把问题想得太复杂,你真正要解决的只有两件事:用哪个 Context 去拿 preferences,以及异步时序下怎么保证页面能读到已经写完的值。
1. 先理清 Context 和页面加载的关系,别一上来就写代码
1.1 Stage 模型里 EntryAbility、Page、preferences 各自是什么
很多开发者是从 FA 模型转过来的,可能会有一个固有印象:Ability 是一个比较“重”的页面载体,Page 是它的子页面。但在 HarmonyOS 的 Stage 模型里,EntryAbility 是一个 UIAbility,它更像应用的一个“入口实例”,负责管理窗口和生命周期;而 page 是 UIAbility 通过 windowStage.loadContent() 加载出来的具体页面内容。
preferences 则是系统提供的一个轻量级键值对持久化组件。它适合存放用户偏好、开关状态、启动次数、账号 ID 这类体积小、结构简单的数据,不适合拿来当关系型数据库或者存放大量列表数据。
它们之间的关系并不复杂:page 由 EntryAbility 创建并加载,所以 page 内部能拿到一个归属于这个 UIAbility 的 Context。在页面里调用 getContext(this),得到的上下文可以直接用来访问 preferences、文件目录、资源管理等应用级能力。这一点是后面所有操作能够成立的基础。
1.2 为什么很多人在 Page 里读不到 EntryAbility 写入的数据
如果严格按照“同应用同文件”的逻辑,page 里直接读同一个 preferences 文件,理论上不会丢数据。实际中读不到,绝大多数是下面这几个原因:
- storeName 不一致:在 EntryAbility 里写入时用的 preferences 文件名是 A,在 page 里读取时却用了 B,两套文件自然互不相通。
- 读取时机太早:EntryAbility 里写入操作是异步的,页面这边
aboutToAppear或onPageShow立刻去读,有可能写入动作还没完成。 - 用错了 Context:有的同学在页面里强行用
globalThis.abilityContext或自定义全局对象去访问 EntryAbility 的成员变量,结果发现根本不是同一个实例,读取失败。 - 写入后没有 flush:
preferences.put()只是更新了内存缓存,如果应用进程被杀掉,或者后面读取的时机发生在一次全新启动时,可能拿不到刚落盘的数据。
其中第一、第四条属于代码层面的低级疏忽,第二条才是真正有讨论价值的时序问题。后面我会给出对应的解法。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. preferences 的核心 API 和读写机制,理解了才能灵活用
2.1 必会的 API 速查
preferences 的接口不算多,但如果不仔细看很容易把同步和异步混在一起用出问题。下面这个表格是我平时开发中总结出来的高频 API。
| API 名称 | 作用 | 使用场景 |
|---|---|---|
preferences.getPreferences(context, options) |
异步获取 preferences 实例 | 推荐在正常业务代码中使用,不阻塞主线程 |
preferences.getPreferencesSync(context, options) |
同步获取 preferences 实例 | 需要在函数里同步返回实例时使用,注意避免在主线程频繁调用 |
preferences.put(key, value) |
向内存缓存写入键值 | 写入基本类型或 JSON.stringify 后的字符串 |
preferences.flush() |
把内存变更持久化到磁盘 | 关键数据写入后必须调用,否则进程被杀会丢 |
preferences.get(key, default) |
从实例中读取键值 | 读取不存在的 key 时返回默认值 |
preferences.delete(key) |
删除某个 key | 清理数据时使用 |
preferences.on('change', callback) |
监听数据变化 | 多页面、多 Ability 场景下同步更新 |
需要特别注意的是,put 和 flush 是分离的。put 只把数据写在内存里,调用多次 put 后,一次性 flush 会把所有变更批量写盘。这样做的好处是减少频繁磁盘 IO,坏处是如果写完没等 flush 就杀进程,数据就丢了。所以在 EntryAbility 里存重要变量,一定要记得在合适的时机调用 flush。
2.2 同步接口和异步接口该听谁的
我在新项目里通常建议优先用异步接口。原因很简单:preferences 实例在第一次获取时,需要从磁盘加载文件内容到内存,这个过程涉及文件 IO,放在主线程上会引发可感知的卡顿。尤其是在 EntryAbility 的 onWindowStageCreate 阶段,这时应用刚启动,主线程本来就要处理窗口创建、页面加载、首帧绘制等任务,如果再叠加同步读取,轻则启动变慢,重则掉帧明显。
但同步接口也不是完全不能用。比如在 onCreate 阶段快速读一个很小的开关值,用来决定是否跳转隐私协议页面,这种场景量级小、执行快,偶尔用一次问题不大。不过要留意 SDK 版本对 Sync 接口的支持情况,我个人习惯写一个统一的工具类,内部直接提供异步方法,所有页面都走同一个入口,这样后续要切换实现也方便。
2.3 preferences 适合存什么、不适合存什么
核心数据类型上,preferences 支持 string、number、boolean 以及数组等基础类型,但实际开发里最稳的用法是:
- 短字符串:用户昵称、语言设置、主题色值
- 数值:启动次数、上次操作时间戳
- 布尔值:是否同意隐私协议、是否已登录
- JSON 字符串:少量结构化对象,手动
JSON.stringify存储,读取时再JSON.parse
它不适合用来存数组巨大的列表、图片 base64、日志数据。那种场景应该交给数据库或文件系统。有人问我能不能存 Map 或自定义对象,答案是不能,请先序列化成字符串。这也是一个典型的隐藏坑,后面会提到。
3. 在 EntryAbility 中写 preferences 的正确姿势
3.1 将写入操作放在正确的生命周期回调里
具体写入位置要根据业务需要决定。如果只是想存一个“应用启动次数”或“最近一次启动时间”,放在 onWindowStageCreate 里比较合适;如果要存的是更早阶段的标记,比如进程启动就要判断是否已登录,也可以放在 onCreate 中。
下面是一段我在项目里实际使用的 EntryAbility 示例代码,它会在窗口创建时,先把启动次数加 1 并保存,然后再加载首页。
typescript复制import { AbilityConstant, UIAbility, Want } from '@kit.AbilityKit';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { window } from '@kit.ArkUI';
import { preferencesUtil } from '../common/PreferencesUtil';
const TAG = 'EntryAbility';
export default class EntryAbility extends UIAbility {
onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
hilog.info(0x0000, TAG, 'Ability onCreate');
}
onWindowStageCreate(windowStage: window.WindowStage): void {
// 启动次数 +1,并写入 preferences
preferencesUtil.incrementLaunchCount();
preferencesUtil.setLaunchTime(Date.now());
// 加载主页面
windowStage.loadContent('pages/Index', (err) => {
if (err.code) {
hilog.error(0x0000, TAG, 'loadContent failed: %{public}s', JSON.stringify(err));
return;
}
});
}
}
这里把 incrementLaunchCount 和 setLaunchTime 放在 loadContent 之前调用,可以保证页面加载完成时,数据大概率已经写入到内存缓存里。但注意是“内存缓存”,如果页面立即读取,在同一个进程内是可以读到的;如果希望数据落盘,还需要内部实现调用 flush。
3.2 为什么我建议封装一个 PreferencesUtil 而不是直接到处写上下文
很多初学者图省事,直接在 EntryAbility 里写一次 preferences,然后在每个页面里又写一遍。这样代码里到处都是 preferences.getPreferences、put、get 的调用链,后续如果 storeName 改一个字符串,你可能要全局搜索替换;如果增加缓存读取策略,那更是欲哭无泪。
正确的做法是封装一个工具类,把 preferences 实例作为模块级单例保存,所有页面共用同一个实例。这样有三个明显的好处:
- storeName 只出现一次,全局统一;
- 上下文只在初始化时传一次,业务方不需要关心 Context 从哪来;
- 可以集中处理 flush、异常回退、数据类型校验。
以下是 PreferencesUtil 的代码示例:
typescript复制import { preferences } from '@kit.ArkData';
import { common } from '@kit.AbilityKit';
const STORE_NAME = 'app_settings';
class PreferencesUtil {
private pref: preferences.Preferences | null = null;
async init(context: common.UIAbilityContext) {
if (!this.pref) {
this.pref = await preferences.getPreferences(context, { name: STORE_NAME });
}
}
private getPreferences(): preferences.Preferences {
if (!this.pref) {
throw new Error('PreferencesUtil has not been initialized, call init first.');
}
return this.pref;
}
async put(key: string, value: preferences.ValueType) {
await this.getPreferences().put(key, value);
await this.getPreferences().flush();
}
async get(key: string, defaultValue: preferences.ValueType): Promise<preferences.ValueType> {
const value = await this.getPreferences().get(key, defaultValue);
return value;
}
async incrementLaunchCount() {
const count = await this.get('launchCount', 0) as number;
await this.put('launchCount', count + 1);
}
async setLaunchTime(timestamp: number) {
await this.put('launchTime', timestamp);
}
}
export const preferencesUtil = new PreferencesUtil();
这里 pref 作为模块级属性,能保证同进程内始终只有同一个 preferences 实例。在 EntryAbility 的 onCreate 里,我们可以先初始化:
typescript复制onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
preferencesUtil.init(this.context);
}
init 方法是异步的,但我们并不需要等它完成才进入后续逻辑,因为这个单例在被首次使用时,get/put 方法内部会自行保证最终拿到的实例是可用的。
3.3 在 EntryAbility 里写入时的三个注意点
先说第一个:不要把 flush 放在循环里。有人一次性存十个字段,每个字段 put 后就 flush,这在低端机上会出现明显的 IO 卡顿。正确做法是先把所有 key 写入,最后统一 flush 一次。工具类为了简单,我对每次 put 都做了 flush,但如果批量写入比较多,建议拆成 putBatch 这样的方法。
第二个注意点:不要在 onCreate 里做重量级初始化。onCreate 是 UIAbility 最早的生命周期回调,在这里尽量少做耗时操作,尤其不要同步创建 preferences 实例然后立即读大文件。否则会导致冷启动时间变长。
第三个注意点:如果 put 的对象是对象类型,请先序列化。我见过有人直接写 pref.put('userInfo', userInfoObject),编译能过或不能过取决于 TS 类型检查,但运行时数据并不是你预想的结构。正确的做法是 JSON.stringify(userInfo) 后存字符串,读取时再 JSON.parse。
4. 在具体 Page 里获取 preferences 的三套可行方案
4.1 方案一:页面内部直接用 getContext(this) 读取
这种方案最直接,不依赖任何全局状态。在页面的 aboutToAppear 或需要用到数据的地方,直接调用工具类或 API。
如果用了前面封装的 PreferencesUtil,页面代码可以简洁成:
typescript复制import { preferencesUtil } from '../common/PreferencesUtil';
@Entry
@Component
struct Index {
@State launchCount: number = 0;
@State launchTime: string = '';
aboutToAppear(): void {
this.loadData();
}
async loadData() {
const count = await preferencesUtil.get('launchCount', 0) as number;
const timeStamp = await preferencesUtil.get('launchTime', 0) as number;
const date = new Date(timeStamp);
this.launchCount = count;
this.launchTime = `${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()}`;
}
build() {
Column({ space: 16 }) {
Text(`启动次数:${this.launchCount}`)
.fontSize(24)
Text(`最近启动日期:${this.launchTime}`)
.fontSize(18)
.fontColor('#666666')
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
}
}
为什么这里能拿到?因为 preferencesUtil 在 EntryAbility 的 onCreate 里用 this.context 完成了初始化,而它内部只有一个 pref 实例。EntryAbility 和页面通过同一个模块级单例访问 preferences,所以 EntryAbility 中 put 的值,页面中 get 时自然可见。即使没有 preferencesUtil,直接用 getContext(this) 去创建同一个 storeName 的 preferences 实例,也能读到同样内容,只是晚了一点而已。
4.2 方案二:EntryAbility 启动时就读取好,再用 AppStorage 全局同步
这个方案更适合“页面需要在极短时间内拿到启动参数”的场景,因为它把耗时读取提前到了启动阶段,并且把结果放进了全局状态。
举个例子。我在入口 onWindowStageCreate 阶段把 launchCount 读出来,然后写入 AppStorage:
typescript复制async onWindowStageCreateAfterPrefs(windowStage: window.WindowStage) {
const count = await preferencesUtil.get('launchCount', 0) as number;
AppStorage.setOrCreate<number>('launchCount', count);
windowStage.loadContent('pages/Index', (err) => {});
}
在页面里可以用 @StorageProp('launchCount') 或 AppStorage.get 来获取。这种方式能够避免页面启动后再异步等待一次读取。但要注意,AppStorage 是内存态状态管理,应用进程被杀掉后自然消失,它只能作为 Preferences 的缓存层,不能替代持久化。
4.3 方案三:通过路由参数把少量数据传过去
如果只是单次页面跳转,并且变量的值已经在 EntryAbility 里确定好,用 router.pushUrl 或 Navigation 的 path 参数把数据传过去是最轻量的方式。拿启动时间戳来说:
typescript复制router.pushUrl({
url: 'pages/Detail',
params: {
launchTime: Date.now()
}
});
在目标页面使用 router.getParams() 获取。
这个方案的问题是:一旦目标页面需要刷新、重新创建(比如从后台恢复到前台,或者被系统回收后重建),路由参数可能丢失。所以仅适合做页面间临时数据传递,不适合把它当作持久化的替代方案。如果数据需要长期可靠存在,最终还是得从 preferences 读取。
4.4 三种方案怎么选
| 对比项 | 页面按需读取 | AppStorage 全局缓存 | 路由参数传递 |
|---|---|---|---|
| 实现复杂度 | 低 | 中 | 低 |
| 是否依赖持久化 | 依赖 | 入口先读取一次 | 不依赖,只传内存值 |
| 页面重建后数据是否可靠 | 可靠 | 如果从 Prefs 同步则可靠 | 不可靠,可能丢失 |
| 适合场景 | 大多数配置项读取 | 启动后首页立即展示 | 单次跳转传参 |
从稳定性角度,我自己的项目里会优先使用方案一,然后在需要全局共享且频繁读取的地方叠加方案二,尽量避免单纯依赖路由参数来传递重要业务数据。
5. 一次完整实操:从 EntryAbility 存启动信息到 Page 展示
为了把前面讲的东西串起来,这里给一个可以完整跑的 demo。项目工程上使用的是 Stage 模型 + 最新 API,代码文件相对简单,但有真实参考价值。
5.1 功能需求
应用每次冷启动时,在 EntryAbility 里把启动次数加 1,并记录最近一次启动时间。首页 Index 页面展示当前启动次数和最近启动日期。
5.2 编写 PreferencesUtil 工具类
新建 common/PreferencesUtil.ets,代码如下:
typescript复制import { preferences } from '@kit.ArkData';
import { common } from '@kit.AbilityKit';
const STORE_NAME = 'app_launch_store';
class PreferencesUtil {
private instance: preferences.Preferences | null = null;
async init(context: common.UIAbilityContext) {
if (!this.instance) {
this.instance = await preferences.getPreferences(context, {
name: STORE_NAME
});
}
}
private getPref(): preferences.Preferences {
if (!this.instance) {
throw new Error('must call init first');
}
return this.instance;
}
async put(key: string, value: preferences.ValueType): Promise<void> {
await this.getPref().put(key, value);
}
async flush(): Promise<void> {
await this.getPref().flush();
}
async get(key: string, defaultValue: preferences.ValueType): Promise<preferences.ValueType> {
return await this.getPref().get(key, defaultValue);
}
}
export const preferencesUtil = new PreferencesUtil();
注意我在这里没有在每次 put 之后都调用 flush,而是额外暴露了一个 flush 方法。这样在 EntryAbility 里批量写入时,可以先连续 put,再统一 flush,效率更高。
5.3 在 EntryAbility 里初始化并写入
typescript复制import { AbilityConstant, UIAbility, Want } from '@kit.AbilityKit';
import { window } from '@kit.ArkUI';
import { preferencesUtil } from '../common/PreferencesUtil';
const TAG = 'EntryAbility';
export default class EntryAbility extends UIAbility {
async onWindowStageCreate(windowStage: window.WindowStage): Promise<void> {
try {
await preferencesUtil.init(this.context);
const currentCount = await preferencesUtil.get('launchCount', 0) as number;
await preferencesUtil.put('launchCount', currentCount + 1);
await preferencesUtil.put('lastLaunchTime', Date.now());
await preferencesUtil.flush();
hilog.info(0x0000, TAG, 'save launch info success');
} catch (err) {
hilog.error(0x0000, TAG, 'save launch info failed, %{public}s', JSON.stringify(err));
}
windowStage.loadContent('pages/Index', (err) => {
if (err.code) {
hilog.error(0x0000, TAG, 'loadContent failed: %{public}s', JSON.stringify(err));
return;
}
});
}
}
这段代码里,我先把 onWindowStageCreate 标记为异步方法,并且在 loadContent 之前完成了 preferences 的初始化、读取累加、最后 flush。如果这里的 IO 很快,你几乎不会感知到延迟;如果数据量大,也不建议在这里做太重的事,但只放几个短 key 是完全可以接受的。这样做的最大好处是,页面加载完成后,数据已经落盘,任何时间点在页面里读取都能拿到最新值。
5.4 在 Index 页面读取
typescript复制import { preferencesUtil } from '../common/PreferencesUtil';
@Entry
@Component
struct Index {
@State launchCount: number = 0;
@State lastLaunchDate: string = '';
async aboutToAppear() {
await this.loadFromPreferences();
}
async loadFromPreferences() {
try {
const count = await preferencesUtil.get('launchCount', 0) as number;
const lastTime = await preferencesUtil.get('lastLaunchTime', 0) as number;
this.launchCount = count;
if (lastTime > 0) {
const date = new Date(lastTime);
this.lastLaunchDate =
`${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()}`;
}
} catch (err) {
console.error(`loadFromPreferences error: ${JSON.stringify(err)}`);
}
}
build() {
Row() {
Column({ space: 12 }) {
Text(`累计启动 ${this.launchCount} 次`)
.fontSize(24)
.fontWeight(FontWeight.Bold)
Text(`上次启动日期:${this.lastLaunchDate}`)
.fontSize(16)
.fontColor('#8a8a8a')
}
.width('100%')
}
.height('100%')
}
}
5.5 实测效果与验证方式
运行后首次进入页面应该显示“累计启动 1 次”,再次杀进程冷启动后显示“累计启动 2 次”。如果一直不显示或数字不增加,先检查下面几件事:
- 是否真的杀掉进程重新点击桌面图标进入?如果只是在 IDE 里点“重新运行”,系统可能只是重新部署应用,并不是用户维度的冷启动场景。
- 页面上的
aboutToAppear是否执行了?可以在方法里打日志确认。 - 存储的 storeName 是否一致?看
PreferencesUtil里的STORE_NAME是否有第二个定义。
6. 常见问题与排查技巧实录
6.1 页面一次都读不到值,问题出在哪
这里我整理了一张高频问题速查表,每一行都是我实际排查过的用户反馈或项目里出现过的 case。
| 表现 | 可能原因 | 解决方案 |
|---|---|---|
| Page 里永远返回默认值 | storeName 不一致或读取时机过早 | 统一封装工具类,在入口初始化完成后再加载页面 |
| 数据进程重启后丢失 | put 后没有 flush | 检查所有 put 路径是否最终调用 flush |
| 页面里 getContext 无法编译 | SDK 类型不匹配 | 优先使用 common.Context 类型接收,或查看具体 API 版本要求 |
| 启动明显变慢 | onWindowStageCreate 里同步读了大 key | 改成异步,或把非必要数据放到页面内容加载后再读 |
| 取出来的对象不是预期的结构 | 直接保存了对象而不是字符串 | 用 JSON.stringify 和 JSON.parse 处理 |
| 多个 UIAbility 之间读不到同一份数据 | 使用了不同应用沙箱路径?通常不会 | 确认 storeName 一致,并通过 preferences.getAll 检查实际内容 |
6.2 第一个坑:启动时序导致的“看起来没写入”
有一次,我在 EntryAbility 的 onCreate 里用异步写入启动埋点,但没有等写入完成就调用了 loadContent。首页在 aboutToAppear 时读取那个字段,偶尔能读到,偶尔读不到。后来我为了验证,在页面里加了一个“手动刷新”按钮,再点刷新时数据又能正常显示。
这是因为 EntryAbility 的写入动作和 Page 的读取动作都在主流程上异步竞争,写入还没完成时读取先执行,拿到的自然是旧值或默认值。解决思路有两个:要么把初始化与写入前移到 loadContent 之前并等待完成,要么在页面侧做一次“重试读取”。前者更可靠。
6.3 第二个坑:put 后忘记 flush,数据就消失了
这里值得再强调一次。preferences 的内存缓存是应用进程级别的,如果你 put 以后不 flush,应用不退出、进程不杀掉,那么后续所有 get 都能看到值。可一旦你把应用从最近任务列表里滑掉,或者系统杀掉了进程,再重新启动时,那个没有 flush 的值就会消失。
正确的处理原则是:“需要永久保存、下次启动还要用”的数据,必须在逻辑结束前显式 flush;“只是临时给当前页面或当前进程使用”的数据,才允许不 flush。 所以我建议工具类里把 flush 单独暴露,让业务方明确什么时候在做“关键保存”。
6.4 第三个坑:全局持有 AbilityContext 或 Preferences 实例引起的生命周期问题
我看到过一些项目为了图省事,把 this.context 存到 globalThis 上,或者把 preference 实例放在一个全局变量里,然后页面直接访问。这种做法在应用不退出的情况下能正常工作,但在 UIAbility 被系统销毁重建、或者应用进入多任务恢复场景时,全局变量可能还残留旧实例,导致内存泄漏或访问到已失效的 Context。
正确方式是用模块级单例管理 preferences 实例,并且只保存 preferences.Preferences,不要把 UIAbilityContext 长期无脑挂在全局变量上。在需要 Context 的时候,优先使用 getContext(this) 从当前页面或组件动态获取。这样线程安全、生命周期也更好控制。
6.5 扩展:如果页面间需要同步 preferences 变化怎么办
如果你在 Page A 中修改了 preferences 字段,希望 Page B 立刻收到通知,那就不适合让 Page B 每次在 onShow 时重新读取,因为用户只要不离开页面就不会触发 onShow。
这种情况可以用 preferences.on('change', callback) 监听指定 key 的变化,也可以通过 AppStorage 的双向绑定来做状态同步。个人经验:如果只是两三个页面之间的偏好同步,直接在 onPageShow 或 aboutToAppear 里读取已经够了;如果是比较复杂的用户配置系统,建议用 AppStorage 做内存态状态管理,再用 preferences 做持久化,两者结合效果最好。
最后分享一个我在实际维护中沉淀的小习惯:给所有 preferences 的 key 都定义成常量,集中放在一个文件里,比如:
typescript复制export const PrefKey = {
launchCount: 'launchCount',
lastLaunchTime: 'lastLaunchTime',
userInfo: 'userInfo',
language: 'language'
};
这样每次在 EntryAbility 或 Page 里使用 key 时,不容易写错;将来做 key 的版本迁移和废弃,也有一个统一入口。偏好存储这类功能虽然简单,但工程上最怕的就是散落各处、约定不清。把存储逻辑收敛好,很多奇怪的“页面读不到值”问题,从一开始就不会发生。
