1. 功能定位与整体设计思路
1.1 这个“使用说明功能”到底要解决什么
先聊一个很实际的场景。很多HarmonyOS应用做完之后,功能模块堆得满满当当,用户第一次打开根本不知道从哪里下手。尤其是工具类、设置类应用,界面里塞了十几个按钮,用户要么瞎点一通,要么直接放弃。这时候一个设计合理的“使用说明”入口,就能把用户从“迷茫”拉回“上手”的轨道上。
我这次在HarmonyOS 6上做的这个“使用说明功能”,核心就三件事:浮动按钮、弹窗、偏好设置。听起来都不复杂,但把它们串起来之后,你会发现体验完全不一样。用户点击悬浮的“?”按钮,弹窗展示当前页面的操作指引,同时记住用户是否已经看过说明——下次进来就不再打扰。这套逻辑在很多成熟应用里都有,比如一些办公软件首次打开时的引导浮层、设置页右上角的帮助入口,本质都是一个路子。
适合谁来参考?如果你在开发HarmonyOS应用,正好需要给用户做引导提示、新手教学、帮助文档入口,或者想优化设置页的交互体验,这篇文章可以直接抄作业。我用的开发环境是DevEco Studio 5.0.3,SDK版本是HarmonyOS 6(对应API 18左右),项目语言是ArkTS。
1.2 为什么选浮动按钮+弹窗+偏好设置这个组合
先说浮动按钮。HarmonyOS里实现悬浮控件其实有两种选择:一种是用Stack布局把一个普通按钮堆叠在页面右上角,另一种是用系统级的悬浮窗能力(window模块的floatingWindow)。我最后选的是Stack布局方案,原因有两个:
- 第一,
Stack方案完全可控,不涉及窗口权限申请,在普通应用页面里就能跑通,不需要用户授予“悬浮窗”权限。系统级悬浮窗在HarmonyOS上需要申请ohos.permission.SYSTEM_FLOAT_WINDOW,这个权限在应用市场上审核比较严格,适合特定场景,不适合普通App做帮助入口。 - 第二,这个使用说明功能是“页面内”的引导,不是全局悬浮球。用户进入某个页面才需要看到说明,离开页面就不需要了,
Stack方案天然契合这种生命周期。
弹窗部分我用了@CustomDialog,这是HarmonyOS官方推荐的弹窗方案之一。相比AlertDialog、promptAction.showDialog,@CustomDialog的定制能力最强,可以自由塞入富文本、图片、按钮组,适合做篇幅较长的使用说明。
偏好设置则用到@ohos.data.preferences,它是HarmonyOS提供的轻量级键值对存储。我用它来记录“当前页面的说明是否已被查看”这个布尔值,实现“首次进入显示,之后不再显示”的交互逻辑。
整个组合的设计逻辑很清晰:浮动按钮负责“发现入口”,弹窗负责“内容展示”,偏好设置负责“状态记忆”。三者缺一不可——没有偏好设置,每次进来都弹窗,用户会觉得烦;没有浮动按钮,说明内容没有固定入口;没有弹窗,内容没有合适的载体。
2. 核心模块拆解与实现要点
2.1 浮动按钮的UI设计与交互细节
浮动按钮的位置我建议放在页面右下角,距离底部约80vp、右边距约16vp。为什么要这个位置?因为用户右手持机时拇指自然覆盖区域正好在右下角,而且右上角通常会被返回键、菜单键占据,右下角是视觉盲区但又是拇指可达区,既不干扰主内容阅读,又能被轻松触发。
按钮本身我用了Button组件,配了圆角样式和半透明背景。这里有几个细节值得注意:
- 图标建议用系统自带的
SymbolGlyph,它相比图片资源更轻量,且支持多态颜色。我用的是一个问号图标,语义明确。 - 背景色不要用纯色,建议用带有透明度的灰黑色(
rgba(0, 0, 0, 0.6)),避免遮挡页面内容时太突兀。 - 按钮要加上
.shadow()阴影提升层级感,阴影颜色可以用rgba(0, 0, 0, 0.2),模糊半径12vp。
响应事件上,onClick回调里判断当前页面说明是否已读,然后决定是直接弹出说明弹窗还是先弹一个“是否重新查看”的确认弹窗。这个逻辑后面会细讲。
还有一个容易被忽略的点:浮动按钮的层级。在Stack布局中,浮动按钮必须放在内容区的后面声明,这样才能出现在最上层。代码结构大概是:
Stack({ alignContent: Alignment.BottomEnd }) { Column() { // 这里是页面主体的业务内容 }.width('100%').height('100%') // 浮动按钮放在Stack的最后一个子组件,层级最高 Button({ type: ButtonType.Circle }) { SymbolGlyph($r('sys.symbol.questionmark_circle')) .fontSize(24) .fontColor(['#FFFFFF']) } .width(48) .height(48) .backgroundColor('rgba(0, 0, 0, 0.6)') .margin({ right: 20, bottom: 80 }) .onClick(() => { this.handleHelpButtonClick() }) }2.2 弹窗的两种实现路径权衡
HarmonyOS 6里自定义弹窗我试过两种写法,一种是用@CustomDialog装饰器,另一种是在build()里用if条件渲染一个模态遮罩层。两种我都实测过,各自适用场景不同。
@CustomDialog的优势在于系统级的生命周期管理,它会自动处理遮罩层、点击空白关闭、转屏适配,代码也相对干净。但有个坑:如果你在弹窗内容里放了List或Scroll组件,并且数据量较大,首次打开时会有轻微的卡顿感。这个问题在HarmonyOS 6的低端设备上比较明显,我推测和弹窗创建时的测量布局有关。
另一种条件渲染方案是完全自己控制,适合弹窗内容需要大量动态更新、或者需要做复杂动画的场景。它的缺点是遮罩层、关闭手势、返回键处理都要自己写,代码量会增加不少。
我这次的使用说明弹窗内容不算复杂,就是标题、说明文字、一张示例图片、一个“知道了”按钮,所以用了@CustomDialog方案,省心。自定义弹窗的关键代码结构如下:
@CustomDialog struct HelpDialog { controller: CustomDialogController title: string = '' content: string = '' needShowAgain: boolean = false build() { Column() { Text(this.title) .fontSize(20) .fontWeight(FontWeight.Bold) .margin({ top: 24, bottom: 12 }) Scroll() { Text(this.content) .fontSize(16) .lineHeight(24) .textAlign(TextAlign.Start) } .layoutWeight(1) .width('90%') .margin({ bottom: 16 }) Button('知道了') .width('80%') .onClick(() => { this.controller.close() }) } .width('85%') .height(300) .backgroundColor(Color.White) .borderRadius(16) } }这里需要特别提醒:@CustomDialog中的组件宽度不要直接写死500这类像素值,最好用百分比或者vp单位,否则在折叠屏、平板和手机之间切换时会出现弹窗过宽或过窄的问题。我在MatePad上实测,写死500的弹窗在手机上是正常的,但在平板上会显得太窄,改成'85%'之后就正常了。
2.3 偏好设置的存取策略与生命周期管理
偏好设置这里我踩过一个小坑,分享出来供大家参考。HarmonyOS的@ohos.data.preferences在读取和写入时都是异步API,而且每个Preferences实例的创建是有一定开销的。如果你在每个页面的aboutToAppear里都重新getPreferences,页面切来切去时会浪费不少性能。
我的做法是在EntryAbility的onWindowStageCreate阶段就初始化一个全局的Preferences实例,然后通过AppStorage绑定到全局状态,页面里直接读取即可。这样既避免了重复创建实例,也简化了页面代码。
具体代码思路是这样:
// EntryAbility中初始化 let preferences = dataPreferences.getPreferencesSync(this.context, { name: 'app_preferences' }) AppStorage.setOrCreate('appPreferences', preferences)页面中读取和写入:
// 读取 let prefs = AppStorage.get<dataPreferences.Preferences>('appPreferences') let hasShownHelp = prefs.getSync('help_shown_' + this.pageName, false) as boolean // 写入 prefs.putSync('help_shown_' + this.pageName, true) prefs.flush()这里强调一下key的设计:我用的是help_shown_加页面名的组合,比如help_shown_profile、help_shown_settings。为什么要用页面名做后缀?因为一个应用往往有多个页面都带使用说明功能,如果只用一个固定key,那用户看过一个页面的说明后,所有页面的说明都不会再弹了,逻辑就错了。
还有一个细节:flush()是必须调用的,否则写入的数据只在内存中,App被杀掉之后就丢了。flush()返回的是一个Promise,如果对写入可靠性要求高,建议用await等待它完成。
3. 实操过程与核心环节实现
3.1 环境准备与新建项目
我是从空白工程开始做的。用DevEco Studio新建Project,选择Empty Ability模板,目标SDK版本选HarmonyOS 6(API 18)。这里有个小建议:如果你的开发机已经装了HarmonyOS 6真机,建议直接跑真机调试,Previewer对@CustomDialog的支持有时候不准,尤其在弹窗内的滚动交互上,预览器和真机表现有差异。
新建完工程后,我加了三个依赖(默认都在SDK里,不需要额外引入三方库):
@ohos.data.preferences(偏好设置,系统库)@ohos.promptAction(备用轻提示,系统库)@kit.ArkUI(ArkUI组件库,自动集成)
3.2 实现浮动按钮的完整代码
在页面Index.ets中,我先定义了一个帮助弹窗和一个二次确认弹窗。为什么需要两个弹窗?因为交互逻辑是这样的:
- 用户第一次进入页面,
hasShownHelp为false,直接弹出帮助说明。 - 用户关闭帮助说明后,再次点击浮动按钮,此时
hasShownHelp为true,弹出“说明已经看过,是否重新查看?”的确认框。
这个设计避免了一个问题:如果用户已经看过说明,再次点击按钮时仍然直接弹出说明,会显得很死板。加一个确认步骤,用户可以选择不再看,也可以选择重新看,更符合实际使用习惯。
核心页面结构:
@Entry @Component struct Index { pageName: string = 'main_page' @State hasShownHelp: boolean = false // 说明弹窗控制器 helpDialogController: CustomDialogController = new CustomDialogController({ builder: HelpDialog({ title: '如何使用本页面', content: '1. 点击右上角按钮进行xxx\n2. 左滑列表可删除xxx\n3. 长按卡片可编辑xxx' }), autoCancel: true, alignment: DialogAlignment.Center }) // 二次确认弹窗控制器 confirmDialogController: CustomDialogController = new CustomDialogController({ builder: ConfirmDialog({ onConfirm: () => { this.helpDialogController.open() } }), autoCancel: true, alignment: DialogAlignment.Center }) aboutToAppear(): void { let prefs = AppStorage.get<dataPreferences.Preferences>('appPreferences') let hasShown = prefs?.getSync('help_shown_' + this.pageName, false) as boolean this.hasShownHelp = hasShown if (!hasShown) { // 延后打开,等待页面完全渲染 setTimeout(() => { this.helpDialogController.open() }, 300) } } handleHelpButtonClick(): void { if (this.hasShownHelp) { this.confirmDialogController.open() } else { this.helpDialogController.open() } } build() { Stack({ alignContent: Alignment.BottomEnd }) { // 主内容区 Column() { Text('这是页面主体内容区域') .fontSize(24) .fontWeight(FontWeight.Bold) // ... 更多业务内容 } .width('100%') .height('100%') .backgroundColor('#F5F5F5') // 浮动按钮 Button({ type: ButtonType.Circle }) { SymbolGlyph($r('sys.symbol.questionmark_circle')) .fontSize(22) } .width(48) .height(48) .backgroundColor('rgba(0, 0, 0, 0.65)') .margin({ right: 16, bottom: 90 }) .shadow({ radius: 12, color: 'rgba(0, 0, 0, 0.2)', offsetY: 4 }) .onClick(() => { this.handleHelpButtonClick() }) } .width('100%') .height('100%') } }3.3 弹窗内容排版与交互细节
帮助弹窗的内容我建议不要直接堆一长段文字,用户根本看不进去。我用的是分条展示,每条前面加上序号,文字保持简洁。如果说明内容确实很多,建议在弹窗里再加一个Tab或折叠面板,按功能区分类展示。
我这次做的内容是一个关于“数据管理页面”的使用说明,文案分为三块:
- 如何新增数据:点击右下角的“+”按钮。
- 如何删除数据:在列表项上左滑,点击“删除”按钮。
- 如何编辑数据:长按列表项卡片,在弹出的编辑器中修改内容。
这个文案风格不需要太正式,越口语化越好。用户看说明书本来就没什么耐心,你用“第一步、第二步”这种话术会让人更焦虑,反过来用“点这里、划一下”这种操作指令更友好。
弹窗关闭时我也做了一个小交互:关闭之后更新hasShownHelp为true,同时写入偏好设置。这个动作不能在弹窗的cancel回调里做,因为用户可能点击遮罩层关闭,也可能按返回键关闭,这两个路径都不会触发按钮的onClick。正确做法是在onDidAppear之外的onWillDismiss或者控制器回调里统一处理。
@CustomDialog的onWillDismiss回调在API 16以后支持了,但要注意:在这个回调里调用controller.close()要加一个标志位,否则容易造成递归调用。
3.4 偏好设置写入的时机与性能优化
写入偏好设置我选择在弹窗关闭之后立即执行。前面已经提到flush()是必须调的,但flush()本身是同步磁盘操作吗?不是,它在内部还是异步的。所以如果你连续调用多次flush(),理论上会造成不必要的IO开销。
我采用的策略是:在一个页面生命周期内,对同一个key的写入最多执行一次。比如用户第一次看完说明,写入true,后续哪怕再打开一次确认弹窗再关闭,也不会重复写入。具体做法是在写入前先判断当前hasShownHelp是否已经是true,如果是就直接跳过写入。
markHelpAsShown(): void { if (this.hasShownHelp) { return } let prefs = AppStorage.get<dataPreferences.Preferences>('appPreferences') prefs?.putSync('help_shown_' + this.pageName, true) prefs?.flush() this.hasShownHelp = true }这个优化看起来不起眼,但在列表页、详情页这种高频切换的场景下,能省掉不少无意义IO。
3.5 真机调试中的适配问题和处理
这部分是我实操中花时间最多的环节。HarmonyOS 6的设备形态太多了,手机、平板、折叠屏、甚至车机,屏幕尺寸差异极大。浮动按钮的位置、弹窗的宽度、文字的字号,都需要适配。
我的做法是用MediaQuery监听设备类型,在不同宽度下调整浮动按钮的大小和弹窗的宽度比例。比如在手机(宽度小于600vp)上,浮动按钮48vp、弹窗宽度85%;在平板(宽度大于600vp)上,浮动按钮56vp、弹窗宽度60%。这样在MatePad上弹窗不会显得太窄,手机上也不会显得太满。
@State isPhone: boolean = true aboutToAppear(): void { let mediaQuery = mediaquery.matchMediaSync('(width <= 600vp)') this.isPhone = mediaQuery.matches mediaQuery.on('change', (result) => { this.isPhone = result.matches }) }然后浮动按钮的尺寸和弹窗宽度引用这个状态变量即可。实际跑下来,手机和平板之间的切换表现都正常。
4. 常见问题与排查技巧实录
4.1 浮动按钮点击无响应的排查思路
这个是最多人踩的坑。点击浮动按钮没有任何反应,常见原因有三个:
- 按钮被某个透明的遮罩层覆盖了。比如页面中如果有半透明的
Row或Column铺满了全屏,即使它是透明的,也会拦截点击事件。解决方法是检查Stack布局中子组件的声明顺序,确保浮动按钮在最后。 onClick事件被父组件的gesture手势拦截了。如果父容器绑定了PanGesture或TapGesture,子组件的点击事件可能会被手势识别器抢走。解决方法是给浮动按钮加上.priorityGesture()或者调整手势的GestureMask。- 按钮本身
enabled状态被置为false。这个比较隐蔽,通常发生在按钮绑定了状态变量但初始化时有误,导致按钮处于禁用状态。
我自己的排查方法是打开DevEco Studio的ArkUI Inspector工具,直接看页面元素树,能很直观地判断出浮动按钮上面是否覆盖了其他组件。
4.2 弹窗弹出时页面背后闪一下的解决方法
这个现象出现在API 16以上的真机上。弹窗打开时,背景页面会先变白一瞬间,然后弹窗才显示出来。我排查了一圈,发现原因是我在弹窗打开前调用了setTimeout延迟300毫秒,而在这300毫秒内页面发生了重新布局,导致渲染管线多走了一帧。
解决办法有两个,任选其一即可:
- 去掉延迟,直接在
aboutToAppear里打开弹窗。但这样可能面临页面未完全渲染的问题,在部分机型上弹窗背景会空一块。 - 保留延迟,但把页面主体内容放在
Column中并给Column一个明确的背景色,同时在弹窗打开前不要触发任何状态更新。
我最后采用的是方案二,给页面根组件设置了backgroundColor,同时把延迟时间调整为250毫秒,实测闪白问题不再出现。
4.3 偏好设置读取结果为null的坑
如果你的AppStorage.get拿到的是undefined,最常见的原因是在EntryAbility中还没有执行setOrCreate,页面就已经开始运行了。这在冷启动时偶尔会发生,因为EntryAbility的初始化是异步的,页面创建和Ability初始化之间存在竞态条件。
我的解决方案是:不在EntryAbility里初始化Preferences,而是封装一个工具类,使用懒加载的方式获取实例。第一次调用时再创建,之后复用。
class PreferenceUtil { private static prefs: dataPreferences.Preferences | null = null static getInstance(): dataPreferences.Preferences { if (!this.prefs) { let context = getContext(this) this.prefs = dataPreferences.getPreferencesSync(context, { name: 'app_preferences' }) } return this.prefs } }这样在任何时机调用PreferenceUtil.getInstance()都能保证返回值不为空。
4.4 弹窗内长文本滚动卡顿的优化方案
如果你的使用说明内容特别长,比如包含了多张截图、一大段FAQ,Scroll组件在弹窗内滚动时可能掉帧。这个问题在@CustomDialog中尤其明显,因为弹窗本身自带一层半透明模糊背景,模糊效果的渲染开销叠加了滚动时的重绘开销。
优化的思路是:不要在弹窗里放超过一屏半的内容。如果内容确实多,把弹窗改为全屏半模态页面,或者把说明内容拆成多个Tab。另一个有效的方法是给弹窗背景去掉模糊,用纯色不透明背景,滚动流畅度会明显提升。
5. 体验优化与扩展建议
5.1 从“一次性说明”到“帮助中心”的演进
当前实现的是一个很轻量的“每页一次性说明”。如果你想把功能做得更完整,可以在此基础上加一个“帮助中心”页面,把所有页面的说明聚合展示。这时浮动按钮可以变成打开帮助中心的入口,而各页面的说明通过路由参数直接跳转到帮助中心对应位置。
这个演进的好处是:用户任何时候想重新查看说明,都能从帮助中心找到入口,不依赖页面内的浮动按钮。
5.2 用“小红点”提示未读说明
在使用说明功能上线后,还有个常见需求是:如何让用户注意到浮动按钮?我的做法是在浮动按钮的左上角加了一个小红点,用于提示“当前页面有新的使用说明未读”。小红点显示的逻辑正好复用偏好设置的已读标记——已读则不显示,未读则显示。这个小改动对新手引导率提升挺明显的。
5.3 动画过渡的细节打磨
弹窗打开和关闭的动画,系统默认的是淡入淡出加轻微缩放,我已经觉得很够用了。如果你想让体验更“高级”,可以在弹窗内容里给标题加一个渐入效果,或者给文字加逐行浮现的动画。不过要注意,动画时长不要超过300毫秒,否则用户会觉得拖沓。
6. 经验总结与踩坑心得
这个功能做完之后,我最大的体会是:不要小看任何一个小功能。浮动按钮、弹窗、偏好设置,单独拿出来都是基础组件,但组合在一起做成“使用说明”,需要考虑的交互细节其实很多,包括首次进入时机、二次查看逻辑、设备适配、性能优化,每一环都值得认真设计。
再分享一个容易被忽略的小技巧:浮动按钮的zIndex默认是按照子组件声明顺序来的,但如果页面内容里有用到Navigation或Scroll,它们的内部子组件可能会创建新的绘制层级,导致浮动按钮被覆盖。遇到这种情况,直接给浮动按钮加一个.zIndex(999),简单有效,不用去纠结层级关系。
最后说一句实际开发的建议:不要在最后才加使用说明功能。最好的时机是在每个页面开发阶段就同步设计好说明文案和入口,因为后期再补,往往会因为页面结构已经定型,导致浮动按钮位置找不到合适的角落、弹窗文案和实际交互对不上这些问题。我就是因为中途才决定加这个功能,反反复复调了好几个页面的布局。
如果你也在做HarmonyOS应用,不妨在下一个版本里把使用说明功能加上,体验提升非常直观。有问题欢迎在评论区交流,我看到都会回复。