标签 鸿蒙应用开发 下的文章

Matrix 首页推荐 

Matrix 是少数派的写作社区,我们主张分享真实的产品体验,有实用价值的经验与思考。我们会不定期挑选 Matrix 最优质的文章,展示来自用户的最真实的体验和观点。 

文章代表作者个人观点,少数派仅对标题和排版略作修改。


人生中的第一个独立开发的 APP 终于通过审核了,这篇文章的重心不在应用推介,更多是记录我作为一名运营独自一人开发应用上架的完整历程。年纪大了,如果当下的感受没有被及时记录,很容易会被时间冲淡。

虽然当前版本图标对不上,但能通过审核本身就是胜利了

文章不会涉及到太多专业术语露出,无论你对 AI 编程是否感兴趣,都可以把它当个有趣的故事看下去。

前言

我貌似一直对写应用 / 做产品有一种执念,尽管我连 GitHub 怎么用都一头雾水。

2014 年,因为经常要写 APP 推荐文的原因,为了丰富截图美观度,联合少数派的 Android 开发小哥倒腾了个【带壳截图】。当时的我,负责的只是想法、素材设计和宣传推广,没有参与过半行代码的编写。

2023 年,当时部门被公司一锅端,突然失业的我申领失业金频繁受挫,靠着每天把免费版 GPT 额度用完,很是艰难地倒腾了个如何申领失业金的微信小程序。这是我自己第一个手搓代码的的产品,这阶段的我开始掌握了「如何插入广告代码」这一核心技能。

2025~2026 年,因为工作缘故要经常输出鸿蒙相关的内容,我先是做了个缓解鸿蒙升级阵痛的小程序,后来在 Gemini 的帮助下,我正式提交了人生中第一款独立开发的 APP,一个能联网的、打通服务端和前端的、有实际功能的、不再是静态页封装的应用。

所幸每一个阶段的产品,我都在少数派写过文章,留下过印记。

使用 AI 编程的挑战

自我设限

早在去年鸿蒙推出开发者激励计划的时候,老麦就问过我能不能倒腾个鸿蒙应用,我说这超出了我的能力范畴。其实彼时的我已经写过好几个小程序了,然而在我的固有认知里,小程序和 APP 开发的区别应该比美图秀秀和 PS 还要大...... 加上现在鸿蒙编译工具和原生开发语言才推出市场没多久,可能 AI 都没有收集到足够的数据来应对。

事实证明一切都是借口,真正实践起来,我这个 GitHub 都用不明白的家伙,从 12 月 30 日配置到鸿蒙开发环境,到 1 月 5 号正式动工,最后 1 月 10 日提交审核,减去中间的元旦假期和周末,整个开发周期差不多就一个星期左右。

挣脱了思想的牢笼后,一切就豁然开朗了。

网络环境

无论是 Gemini、ChatGPT,还是其他更进阶的 AI 编程服务,对网络和地区的要求都是极高的。网络的波动,经常会导致「地区限制、IP 污染」等拒绝访问的情况发生,哪怕能顺利进入,也会有一定概率只能新建聊天但无加载历史对话。

付钱也是个大问题,搞定了网络,也舍得每个月掏出 20 美元甚至更高的费用去订阅,但如果没有一张境外发行的银行卡或海外账户,那么大概率无法完成关键的付款操作。当然,真想要给钱还是有路子的,只不过给个钱甚至比软件破解还要费劲,又会劝退很大一拨人。

技术门槛

社交媒体上铺天盖地的都是「不懂代码也能编程」的帖子和教程,但目前市面上一些主流的 AI 编程工具其实都是需要使用者具备一定专业技能的,强如 Cursor 一打开就让我关联 GitHub 的代码仓库,单就这一步就直接难倒我了。

同时,零代码基础,意味着你无法判断 AI 输出的方案优劣 / 对错 / 是否最优解,AI 会偷懒、会造假、会胡说八道、会消极怠工...... 没有专业能力去支撑你的判断与决策,一旦涉及关键模块的改动,轻则影响项目进度,重则前功尽弃,推倒重来。

备案和审核

大 Boss 藏在临门一脚的收尾阶段。

从 2025 年开始,所有联网的应用都需要进行 APP 备案。然后备案要买服务器,买完服务器提示要买域名,买好域名之后又提示域名也要备案,要域名和服务器备案好了 APP 备案才算完成。兜兜转转,搞了差不多 1 个月才搞定。所幸只是耗时长,过程并不复杂。

11.20 创建备案申请,12.17 备案审核通过

来到核心的应用审核环节,由于之前提交审核的版本功能实在过于简单(体验与小程序保持一致),多次上架驳回,让我下定心思真正做一个有实际功能用途的 APP。于是乎,我拾起了 10 年前的带壳截图项目,因为应用名称已经备案了,所以我还是沿用【NEXT 升级站】这个名称,在截图带壳的基础上,新增了图层顺序调整、设备素材云端下载与更新、添加贴纸、设备形态切换等功能。


1 月 10 日,我将重新构造的 NEXT 升级站提交到鸿蒙应用商店;1 月 16 日,经过了多次沟通和调整之后,NEXT 升级站终于顺利通过审核,正式登陆鸿蒙平台。得知应用审核通过的瞬间心情还是非常激动的,毕竟花了这么多心思去打造,肯定是想让它呈现在公众面前,供有需求的人去使用。

与 Gemini 的角色分工

因为 Chat 形态的 Gemini 不能直接操作项目,所以从配置环境的安装到最后的签名打包,涉及到开发环节的每一步,全都是 Gemini 输出文字指引,我来进行操作。虽然是原始了点,但一步一脚印,也不算是一件坏事。在这个鸿蒙应用开发项目里,我主要扮演产品经理和交付验收专员的角色,Gemini 则负责以下工作:

  • 产品项目 / 需求评估
  • 解决方案输出
  • 100% 代码编写
  • 问题定位与修复
  • LOGO 初稿输出与绘制教程
  • 机型素材整理方案输出
  • 快速生成图片配置文档

我不太清楚近期火热的 Vibe Coding 能否全自动地完成项目的代码编写和程序编译,因为我没真正使用过,一来是文章开篇提及到的网络问题,二来也是我自身专业度不足以支撑的问题,当然最主要的还是自我设限,认为自己驾驭不到。

有机会尝试的话,再给各位输出一篇关于 Vibe Coding 的体验文章。

擅长开新坑

Gemini 的靠谱程度,很大程度取决于你是「开新坑」还是「优化屎山代码」。如果是「开新坑」,决策准、速度快、效率高、完成度高,会是我对它的评价;一般这种情况下的需求指令都不会特别清晰或具体,这时候的它有足够的发挥空间,Gemini 擅长写半开放式作文。

一个具体的例子,10 月初我想把初始版本的【NEXT 升级站】小程序想快速移植为鸿蒙应用,在 GitHub 找到了滴滴出品的星河小程序转译鸿蒙应用的开源项目,专门请教了公司的开发同事,询问一下这件事情在技术层面的可行性,得到的结论是不行,斩钉截铁的不行。

随后,我将这个具体的想法交代给 Gemini 后,它给出的答案也是不行,但同时提出了另一种解决方案:因为我的产品架构很简单(本地搭好页面框架,从腾讯云读取数据),无需转译,直接用鸿蒙原生开发工具写一个更简单。

然后,它就手把手教我从如何安装配置鸿蒙开发环境、如何配置页面、如何调用组件、如何读取数据、如何解决编译报错、如何真机调试等等。因为我小程序已经写好了逻辑,所以它列了几个关键的 js 文件让我发过去,它就能复用对应页面的数据读取、字段展示、排序逻辑、元素布局等。

效果非常惊人,花了 1 天的下班时间,我就已经完成从搭建鸿蒙开发环境到输出 Demo 能在真机安装运行了。虽然一开始 Gemini 并没有告诉我 Beta 版编译工具签名的 APP 无法提交审核,但那是后话了......

优化能力不详,像鬼打墙


但场景一旦切换到「具体功能优化、Bug 修复」时,当需求越具体,它就会变得越固执、越短视、喜欢钻牛角尖、重复造轮子、简单的事情复杂化:

  • 能调用系统图标的它偏要自己画;
  • 在正常编译的云服务配置文档新增一个字段读取处理,它偏不按那个版本结构逻辑去写,硬是要自己优化,结果每个自作聪明优化的代码版本都不能编译;
  • 我说哪个功能上有问题,它就只是把这个问题修复好,全然不告诉我它为了修复这个问题,偷偷把其他能正常运行的功能删了,把数据读取逻辑从服务器端改成了本地虚拟数据......

诸如此类各种数不尽的骚操作,逐渐倒逼着我自己去管控整个项目走向。慢慢地,我这个毫无感情的代码复制粘贴机器,也开始系统性地判断 Gemini 输出的技术方案思路是不是可行的,给出的方案有哪些考虑不周到的地方,存在哪些风险,需要做哪些准备工作,备份哪些关键文件,实施过程中可能会发生的问题以及应对方案等:

  • 比如在进行一些关键页面/功能的修改时,是不是可以创建一个隔离环境,先验证功能可行性,再合并到正式页面里等;
  • 又比如在复制粘贴代码的时候会多留一个心眼,观察代码量变化,很多时候往往只是修改一个极小的细节问题,但输出的完整代码量和上一个版本竟然相差 200 多行,我就知道它又开始偷懒了。

见证我踩坑与进化的,是每次下达精准修改需求时越来越长的注意事项:

  1. 必须精准修改,不要动已有的功能布局与逻辑,尤其是不要自作聪明覆盖本地数据和功能 ,不要悄默默的删掉功能,你这是惯犯
  2. 输出方案之前,要严格关联上下文,涉及到需要验证的解决方案,必须要在最小单元内测试是否有效,再全面推广
  3. 优先使用系统组件、遵从鸿蒙设计/开发规范,不要简单的问题复杂化
  4. 涉及到需要修改的页面,需要输出完整代码,减少手动操作带来的误操作
  5. 不要偷懒,不要在不告知我的情况输出精简 DEMO 来替代我现有的功能界面和布局
  6. 一步步详细的列明每一个操作步骤,不要精简和省略,包括需要修改的文件具体路径,尤其是涉及到一些不可逆或容易误操作的地方,要特别标注出来
  7. 需要涉及需要在本地新增素材或引用云端字段/系统能力,要和我提前说,并告知具体的文件存放位置和作用,减少因为「资源缺/对不上」造成的编译错误,尽量做到每一次输出的方案都是不报错的;
  8. 输出方案的时候要明确说明思路、方向、修改了什么,可能会发生的问题,以及应对思路
  9. ......

背后的心酸,只有我和 Gemini 才能知晓。

虽然开发环节总是会出现这样那样的问题,但在整个应用构建过程,我始终保持着非常激动甚至亢奋的心情。关键的转变在于我从某个环节的螺丝钉变成了整个链条的掌舵手,提出想法的是我,需求评估的是我,原型设计的是我,敲定技术实现方案的是我,字段配置、代码编译、功能验收、BUG 修复、功能迭代的还都是我......

每天结束代码编译工作时,我都会和 Gemini 复盘一下今日的成果、踩过的坑、明天的计划、以及突然冒出来的鬼点子。看着应用从最初的原型图,到一步步完善,最后成为能在真机运行的应用,成就感可以用爆棚来形容。

主角登场:NEXT 升级站

NEXT 升级站

聚焦截图编辑与创作

铺垫了这么久,是时候要请出主角了。NEXT 升级站聚焦于截图编辑与创作,支持带壳截图、快速切换设备形态、添加贴纸、云端更新素材库等。应用支持联网更新,即使不更新应用,也能获取到最新的设备素材与贴纸。

在产品架构设计阶段,应用内几乎每个环节我都加上了支持运营控制的字段与配置入口。除了机型素材和贴纸中心,还包括创作页背景、机型默认壁纸,甚至连遮罩颜色和透明度等,都可以在云端直接修改更新。节假日定期换个应景的素材,或和其他应用联名搞搞活动,是我作为一名老运营的职业习惯。

核心的截图创作页上,我将「机型系列」作为一个最小单位,一个单元内对应多个 SKU,下载对应素材后,可以左右切换更换同系列的姐妹机型与颜色。如果是涉及到折叠屏这种多形态变化的产品,同样可以通过左右切换,快速更换设备形态。

同时,「贴纸中心」的加入,大大丰富了截图的可玩性,这也是 NEXT 升级站区别于同类应用的一大特色功能。支持图层顺序调整带来了无限大的拓展空间:除了常规的表情贴纸,它可以是画布壁纸,还可以是契合产品的具体使用场景、更可以是模特手持的特写海报。

一些遗憾

由于贴纸引入了图层概念,所以正常情况下只需新增一个图层字段或贴纸类型,就可以实现「图片背景」这一功能了,写好逻辑本地处理,当检测到图层字段等于 0 时,图片自动置底且铺满画框。道理是这个道理,但可惜,目前我的水平无法支撑这个需求的实现;不仅没有实现,还出现了同一个素材从创作页添加是正常的,但从贴纸中心添加就不能显示的奇特 Bug,折腾了好久才恢复到原样来。

此外,在原本的产品规划里,我是打算将 Navigation bar 和 tabBar 统一都设置为半透明的毛玻璃效果,让壁纸能够完整铺满整个屏幕,体验更加沉浸。但这涉及到全局组件的改动,加上当时风险管理意识不足,一番操作下来,布局全乱,软件元素和系统安全区叠加在一起,越改越乱,最后不得不代码回滚。

这也是这个版本里为数不多的遗憾。

图标绘制

我对图标尤为看重,7 天的开发周期,图标绘制就占了我整整一天的时间,可见重视程度之高。我希望 NEXT 的图标是有质感的、且符合应用使用场景的,在小红书找了几个我想要的效果素材发给 Nano Banana Pro,结果输出的第一个方案里就有对胃口的版本,这让我极其欣喜。

我完整阐述一下我的图标绘制操作和思路:

  1. 在 Gemini 工具栏里选择「生成图片」,输出详细设计需求并附上参考图,让它出 n 个方案;
  2. 从中选择合心意的版本,进行细致修改;
  3. 确定最终方案后,让 Gemini 输出 Figma 绘制教程;
  4. 根据教程重绘矢量图标。

为什么要重绘图标?

我个人不太建议直接使用 AI 输出的图片作为图标。

一来是 Gemini 无法输出透明背景的 png,虽然市面上大把移除图片背景的工具和插件,但移除背景这个动作本身就会对图片质量本身产生较大影响,如边缘锯齿、阴影裁切、残留白边等;

二来应用图标在软件项目构造里并不是一张单纯的圆角矩形图,它是由一张透明背景的主体图 + 一张保留直角的背景图组成的;

三是考虑到 AI 输出的图片可能存在的版权归属问题,以及后续图标的拓展延伸(如面向付费用户提供多种图标切换、应用周边制作、品牌宣传露出等),几乎每个场景都需要你有「源文件」在手,而不仅仅只是一张 AI 提供的固定分辨率、放大会有锯齿的位图;

所以让 AI 输出方案 + 重绘修改,会是一个相对稳健且方便后续运营拓展的方案。哪怕你是设计新手也不要紧,目前主流的 AI 基本上可以做到专属教程产出,发一张图片过去,询问如何在 Photoshop 或 Figma 上绘制出一模一样的效果,它就会输出详尽的教程,包含每个图层需要叠加的效果参数、渐变色值等。

当然,图标重绘并不意味着百分百的还原 AI 稿,更多是根据实际情况进行风格和元素的调整,毕竟是手把手操作,灵活度上还是要比输入关键词指令更精准一些。我对这个工作流输出的图标成品很是满意(目前应用商店显示的图标和实际图标对不上,我争取下个版本修复)

名字来源和背后故事

介绍完应用功能和图标,我想展开聊聊 NEXT 升级站名字的来源和背后的功能变更。

故事的开始是去年我主力使用的华为设备升级到鸿蒙 5,在日常使用中或多或少都会有一些困扰与不习惯,于是我针对常见痛点梳理了解决方案,拾起老本行做了个微信小程序承载。想着解决他人问题之余还能靠流量主赚点广告费,没成想鸿蒙版的微信小程序并不支持加载流量主广告 😂 路径依赖失效了~

NEXT 升级站小程序

但每个月 19.99 的腾讯云套餐是无论如何都节省不了的支出,怎么样才能把这 19.99 用回本成为了我的新课题。既然腾讯云能被小程序调用,是不是也能被第三方网站或 APP 调用?我向 Gemini 提出了这个问题,得到了肯定的答复,随后我就搞了个页面,通过云函数将小程序的内容同步展示到网页来。不过这个页面更多是技术验证,并没对外开放访问。

小程序导流网页

小程序有了,引流页面也有了,作为一个面向鸿蒙用户提供解决方案的产品,没有鸿蒙原生应用似乎说不过去。刚开始我是打算通过「小程序转译」的方式去实现,结果 Gemini 告诉我直接原生编译工具写更简单。接下来的故事前文也提及过了,初始版本的应用多次被驳回,一是联网应用没备案,二是功能实在太过简单。

NEXT 升级站首个鸿蒙版本,功能布局与小程序保持一致

应用审核被驳回,但耗时一个月的应用备案下来了,秉持着备案不能白白浪费的原则,我又硬着头皮搞了如今以截图编辑与创作为核心的【NEXT 升级站】并成功上架,也算是给 10 年前的【带壳截图】一次秽土重生的机会。

所以现在的 NEXT 升级站处在一个非常神奇的阶段,同一个名字在不同渠道是两个完全不同形态的存在。在微信小程序里,它是提供各种常见问题解决方案的实用工具箱;在鸿蒙原生应用里,它是可以实现以带壳截图为核心的截图创作工具。至于后面究竟是逐渐融合还是单独区分,就有待后续故事的发展了,现在的我也说不准。

回顾 NEXT 升级站每一次的更迭,基本上都是脑海里的灵光一闪在稍纵即逝之际被 Gemini 及时验证可行性并给出实施方案,我才得以踏出下一步的。我认为这是 AI 存在最大的价值,通过 AI 快速验证各种天马行空想法的可行性,并以最低成本踏出第一步,只要出发了,距离终点就不远了。

开发费用


我来简单盘点一下本次开发全链路的所需费用。

  • 腾讯云:¥19.9/月
  • 服务器:¥69/年
  • 域名:¥33/年

以一年时间为例,最基础的费用支出是 340.8 元。当然,实际上远不止这个价格, 正常情况下 Gemini 应该是最费钱的一项。除符合资格的学生优惠外,最近 Gemini 还推出了 $99.99/年的多人共享活动,就是对地区、账号和付款方式都有一定要求,感兴趣的可以去了解一下。

写在最后

这是 2026 年我送给自己的新年礼物,突破身为一个运营原定能力边界的礼物。

简单评价一下这个开发周期只有 7 天的应用,我认为功能完成度是大大超出我预期的。代码质量我不好评价,后续版本维护上我也比较担忧,但在产品架构、功能完善度、可玩性上,我有信心,NEXT 升级站起码是合格的,甚至是超过平均水平线的。

当然,初个版本还是有很多不足的地方,受限于技术水平与人力原因,很多东西距离「尽善尽美」还有很长一段距离,不过大框架搭好了,素材也能支持云端更新,后续保持一定的频率更新,问题也不大。

> 关注 少数派小红书,感受精彩数字生活 🍃

> 实用、好用的 正版软件,少数派为你呈现 🚀

    前言

    在鸿蒙应用的开发历程中,页面跳转一直是大家最先接触的功能之一。很长一段时间里,Router 模块都是我们手中的标配武器,那句 router.pushUrl 相信每一位开发者都烂熟于心。但在构建大型应用,尤其是面对平板、折叠屏这些复杂设备时,老旧的 Router 逐渐显露出了疲态。它是一个页面级别的全局单例,难以处理分屏、弹窗嵌套路由以及模块化的动态加载。这就像是用一把瑞士军刀去砍伐整片森林,虽然能用,但效率极低且手感生涩。

    在 HarmonyOS 6 的时代,官方明确推荐我们全面拥抱 Navigation 组件。这不仅仅是一个组件的更替,更是一次架构思维的升级。Navigation 不再是一个简单的 API 调用,它是一个容器,一个能够容纳完整路由栈、标题栏和工具栏的超级容器。它将路由的管理权从系统底层交还到了开发者手中,让我们能够像操作数组一样精准地控制页面的进出栈。

    今天,我们就把那个陈旧的 Router 放在一边,深入探讨如何利用 Navigation V2 架构和 NavPathStack 构建一个现代化、健壮的应用导航体系。

    一、 从 Router 到 Navigation:架构的范式转移

    要理解 Navigation 的强大,我们先得明白它解决了什么痛点。传统的 Router 是基于 Page(页面)的,每一个页面都是一个独立的 Ability 或者窗口层级。当我们想要在一个弹窗里再做一套局部导航,或者在平板的左侧菜单里嵌入一个独立的路由栈时,Router 就束手无策了。

    Navigation 组件的出现彻底改变了这一局面。它本质上是一个 UI 组件,这意味着它可以被放置在界面的任何位置。你可以把它放在根节点作为全屏导航,也可以把它放在一个 Dialog 内部,甚至可以嵌套使用。

    在 API 20 中,Navigation 采用了 组件级路由 的概念。每一个“页面”不再是 @Entry 修饰的独立文件,而是被 NavDestination 包裹的自定义组件。这种设计让页面变得极其轻量,页面的切换本质上就是组件的挂载与卸载,性能得到了巨大的提升。更重要的是,它配合 NavPathStack 实现了路由栈的可编程化,我们终于可以像操作数据一样去操作界面了。

    二、 核心大脑:NavPathStack 路由栈管理

    如果说 Navigation 是躯壳,那么 NavPathStack 就是它的灵魂。在 V2 版本中,我们不再直接调用组件的方法来跳转,而是创建一个 NavPathStack 的实例,并将其绑定到 Navigation 组件的 pathStack 属性上。这个栈对象就是我们操控界面的遥控器。

    你需要实现一个复杂的登录流程:用户点击购买 -> 跳转登录 -> 跳转注册 -> 注册成功 -> 直接返回购买页(跳过登录页)。在旧的 Router 模式下,你需要计算 delta 索引或者使用 replace 模式小心翼翼地堆叠。而在 NavPathStack 中,就方便多了。你可以随时调用 popToName 直接回到指定的路由锚点,或者操作栈数组,精准地移除中间的某几个页面。

    数据的传递也变得优雅。当我们调用 pushPath 时,可以直接传入一个 param 对象。而在目标页面中,我们不需要再写繁琐的 router.getParams(),而是直接在 NavDestination 的 onShown 生命周期或者组件初始化时,从栈中获取参数。这种参数传递是类型安全的,且完全受控。此外,NavPathStack 还提供了强大的拦截器机制(Interception),让我们可以在路由跳转发生前进行鉴权拦截,比如用户未登录时直接重定向到登录页,这一切都在路由层面被优雅地拦截处理了。

    三、 页面构造:NavDestination 与路由表设计

    在 Navigation 架构下,我们的一级页面(根页面)通常直接写在 Navigation 的闭包里,而二级、三级页面则通过 NavDestination 来定义。这里有一个关键的概念转变:我们需要构建一个 路由映射表

    我们不再是通过文件路径去跳转,而是通过 路由名称(Name)。我们需要在 Navigation 组件中配置 navDestination 属性,它接收一个 @Builder 构建函数。当 NavPathStack 请求跳转到 "DetailPage" 时,这个构建函数就会被触发,我们需要在这个函数里根据传入的 name 返回对应的 NavDestination 包裹的组件。

    这种设计模式天然支持模块化开发。我们可以把不同模块的路由表分散在各自的 HAR 包中,最后在主工程中进行聚合。每个 NavDestination 都是一个独立的沙箱,它拥有自己的标题栏、菜单栏和生命周期(onShown, onHidden)。这对于开发者来说非常友好,我们可以在 onWillAppear 中发起网络请求,在 onWillDisappear 中保存草稿,页面的生命周期完全掌握在自己手中。

    四、 界面定制:摆脱默认样式的束缚

    Navigation 自带了标准的标题栏(TitleBar)和工具栏(ToolBar),这在快速开发原型时非常方便。但在实际的商业项目中,设计师往往会给出天马行空的顶部导航设计,比如透明渐变背景、复杂的搜索框或者异形的返回按钮。

    很多初学者会困惑:我是该用系统自带的,还是自己画?我的建议是按需定制。Navigation 和 NavDestination 都提供了 titlemenustoolBar 属性。如果设计风格符合系统规范,直接传入资源配置即可,系统会自动适配深色模式和折叠屏布局。但如果设计差异巨大,我们可以通过 .hideTitleBar(true) 彻底隐藏系统标题栏,然后在内容区域(Content)的顶部放置我们自定义的 NavBar 组件。

    这里有一个细节需要注意,当我们隐藏了系统标题栏后,原本的滑动返回手势依然有效,但左上角的返回箭头没了。我们需要自己实现一个返回按钮,并调用 this.pageStack.pop() 来手动触发返回。这种灵活性让我们既能享受系统手势的便利,又能完全掌控视觉呈现。

    import { promptAction } from '@kit.ArkUI';
    
    // 1. 定义路由参数模型
    interface ContactParams {
      id: string;
      name: string;
      phone: string;
    }
    
    @Entry
    @Component
    struct NavigationBestPracticePage {
      // 核心修正:使用 @Provide 而不是 @State
      // 这样后代组件 (DetailPage) 才能通过 @Consume 直接获取该对象
      @Provide('pageStack') pageStack: NavPathStack = new NavPathStack();
    
      // 模拟的首页数据
      @State contacts: ContactParams[] = [
        { id: '1', name: '张三', phone: '13800138000' },
        { id: '2', name: '李四', phone: '13900139000' },
        { id: '3', name: '王五', phone: '15000150000' }
      ];
    
      // -------------------------------------------------------
      // 路由工厂:根据路由名称动态构建页面
      // -------------------------------------------------------
      @Builder
      PagesMap(name: string, param: Object) {
        if (name === 'DetailPage') {
          // 跳转到详情页
          DetailPage({
            contactInfo: param as ContactParams
          })
        } else if (name === 'EditPage') {
          // 跳转到编辑页
          EditPage({
            contactInfo: param as ContactParams
          })
        }
      }
    
      build() {
        // 根容器:Navigation
        Navigation(this.pageStack) {
          // 首页内容区域
          Column() {
            Text('通讯录 (V2)')
              .fontSize(24)
              .fontWeight(FontWeight.Bold)
              .margin({ top: 20, bottom: 20 })
              .width('100%')
              .padding({ left: 16 })
    
            List() {
              ForEach(this.contacts, (item: ContactParams) => {
                ListItem() {
                  Row() {
                    // 这里使用系统图标模拟头像,实际请替换为 app.media.xxx
                    Image($r('app.media.startIcon'))
                      .width(40)
                      .height(40)
                      .borderRadius(20)
                      .margin({ right: 12 })
                      .backgroundColor('#E0E0E0') // 兜底背景色
    
                    Column() {
                      Text(item.name).fontSize(16).fontWeight(FontWeight.Medium)
                      Text(item.phone).fontSize(14).fontColor('#999')
                    }
                    .alignItems(HorizontalAlign.Start)
                    .layoutWeight(1)
    
                    // 跳转按钮
                    Button('查看')
                      .fontSize(12)
                      .height(28)
                      .onClick(() => {
                        // 核心动作:压栈跳转
                        this.pageStack.pushPathByName('DetailPage', item, true);
                      })
                  }
                  .width('100%')
                  .padding(12)
                  .backgroundColor(Color.White)
                  .borderRadius(12)
                  .margin({ bottom: 8 })
                }
              })
            }
            .padding(16)
            .layoutWeight(1)
          }
          .width('100%')
          .height('100%')
          .backgroundColor('#F1F3F5')
        }
        // 绑定路由映射构建器
        .navDestination(this.PagesMap)
        // 首页的标题模式
        .titleMode(NavigationTitleMode.Mini)
        .hideTitleBar(true) // 首页隐藏系统标题栏,使用自定义内容
        .mode(NavigationMode.Stack) // 强制使用堆叠模式
      }
    }
    
    // -------------------------------------------------------
    // 子页面 1:详情页 (使用 @Consume 获取 Stack)
    // -------------------------------------------------------
    @Component
    struct DetailPage {
      // 接收参数
      contactInfo: ContactParams = { id: '', name: '', phone: '' };
    
      // 获取当前的路由栈 (对应父组件的 @Provide)
      @Consume('pageStack') pageStack: NavPathStack;
    
      build() {
        NavDestination() {
          Column({ space: 20 }) {
            Image($r('app.media.startIcon'))
              .width(80)
              .height(80)
              .borderRadius(40)
              .margin({ top: 40 })
              .backgroundColor('#E0E0E0')
    
            Text(this.contactInfo.name)
              .fontSize(24)
              .fontWeight(FontWeight.Bold)
    
            Text(this.contactInfo.phone)
              .fontSize(18)
              .fontColor('#666')
    
            Button('编辑资料')
              .width('80%')
              .margin({ top: 40 })
              .onClick(() => {
                // 继续压栈,跳转到编辑页
                this.pageStack.pushPathByName('EditPage', this.contactInfo);
              })
          }
          .width('100%')
          .height('100%')
        }
        .title('联系人详情') // 设置系统标题
      }
    }
    
    // -------------------------------------------------------
    // 子页面 2:编辑页 (使用 onReady 获取 Stack)
    // -------------------------------------------------------
    @Component
    struct EditPage {
      @State contactInfo: ContactParams = { id: '', name: '', phone: '' };
      @State newName: string = '';
    
      // 独立维护 Stack 引用,不依赖 @Consume,解耦性更好
      private stack: NavPathStack | null = null;
    
      aboutToAppear(): void {
        this.newName = this.contactInfo.name;
      }
    
      build() {
        NavDestination() {
          Column({ space: 16 }) {
            Text('修改姓名:')
              .fontSize(14)
              .fontColor('#666')
              .width('90%')
              .margin({ top: 20 })
    
            TextInput({ text: $$this.newName, placeholder: '请输入新名字' })
              .backgroundColor(Color.White)
              .width('90%')
              .height(50)
              .borderRadius(10)
    
            Button('保存并返回')
              .width('90%')
              .margin({ top: 20 })
              .onClick(() => {
                // 模拟保存操作
                if (this.stack) {
                  this.stack.pop(true); // 出栈
                  promptAction.showToast({ message: `保存成功: ${this.newName}` });
                }
              })
          }
          .width('100%')
          .height('100%')
          .backgroundColor('#F1F3F5')
        }
        .title('编辑')
        .onReady((context: NavDestinationContext) => {
          // 最佳实践:在 onReady 中获取当前页面的 stack
          // 这种方式不需要父组件必须使用 @Provide,适用性更广
          this.stack = context.pathStack;
        })
      }
    }

    五、 总结与实战

    Navigation 组件配合 NavPathStack,标志着鸿蒙应用开发进入了 单窗口多组件(Single Window, Multi-Component) 的架构时代。它解决了 Router 时代的诸多顽疾,提供了更灵活的嵌套能力、更强大的路由栈控制以及更轻量的页面切换开销。

    对于任何一个立志于构建专业级鸿蒙应用的开发者来说,尽早重构代码,迁移到 Navigation 架构,是提升应用质量的关键一步。

    在这里插入图片描述

    摘要

    随着 HarmonyOS / OpenHarmony 在手机、平板、智慧屏、车机等多设备上的落地,应用的复杂度正在明显提升。页面不再只是简单展示,而是伴随着网络请求、数据计算、设备协同等大量逻辑。如果这些逻辑处理不当,很容易出现页面卡顿、点击无响应,甚至 Ability 被系统回收的问题。

    线程阻塞,已经成为鸿蒙应用开发中最容易踩坑、也最影响体验的问题之一。本文将结合实际开发场景,用尽量口语化的方式,聊一聊在鸿蒙系统中如何系统性地避免线程阻塞,并给出可以直接运行的 Demo 代码。

    引言

    在早期的应用开发中,很多开发者习惯把逻辑直接写在点击事件里,或者在页面加载时同步读取数据。这种写法在简单页面中问题不大,但在 HarmonyOS 这种强调流畅体验和多设备协同的系统中,很容易暴露问题。

    鸿蒙的 UI 是声明式的,系统对主线程(UI 线程)非常敏感。一旦主线程被占用,页面掉帧、动画卡住、操作延迟都会立刻出现。因此,理解哪些操作会阻塞线程,以及如何把这些操作合理地“挪走”,是每个鸿蒙开发者绕不开的一课。

    下面我们从原理、工具、代码和真实场景几个角度,完整地拆解这个问题。

    为什么线程阻塞在鸿蒙中这么致命

    UI 线程到底在忙什么

    在 HarmonyOS 中,UI 线程主要负责三件事:

    • ArkUI 页面渲染
    • 用户事件分发(点击、滑动等)
    • Ability 生命周期回调

    简单理解就是:只要和“看得见、点得动”有关的事情,几乎都在 UI 线程上完成

    一旦你在这里做了耗时操作,比如计算、IO、网络等待,页面就会立刻表现出“卡”的感觉。

    常见的阻塞来源

    在实际项目中,最容易导致阻塞的操作通常包括:

    • 同步网络请求
    • 文件读写
    • 数据库查询
    • 大量 for 循环计算
    • 人为 sleep 或死循环

    这些操作本身不一定是错的,问题在于它们被放在了不该放的线程上

    鸿蒙中避免线程阻塞的核心思路

    一个总原则

    可以把鸿蒙里的线程使用总结成一句话:

    UI 线程只处理 UI,其他事情交给异步、线程池或 Worker。

    围绕这个原则,系统也提供了多种工具,帮助开发者把任务“分流”。

    异步编程是第一道防线

    使用 async / await 处理耗时逻辑

    在 ArkTS 中,官方推荐优先使用 Promise 和 async / await。它的好处是代码结构清晰,而且不会阻塞 UI 线程。

    示例:页面加载网络数据

    @Entry
    @Component
    struct AsyncDemo {
      @State message: string = '加载中...'
    
      build() {
        Column() {
          Text(this.message)
            .fontSize(20)
            .margin(20)
    
          Button('重新加载')
            .onClick(() => {
              this.loadData()
            })
        }
      }
    
      async loadData() {
        this.message = '请求中...'
        let response = await fetch('https://example.com/data')
        let result = await response.text()
        this.message = result
      }
    }

    代码说明

    • loadData 使用 async 声明,不会阻塞 UI
    • await 只是暂停当前函数执行,不会卡住页面
    • UI 更新完全由状态变化驱动

    这是最基础、也是最常用的一种防阻塞方式。

    TaskPool:处理计算和 IO 的利器

    什么时候该用 TaskPool

    当你遇到下面这些情况时,TaskPool 几乎是必选项:

    • 大量计算
    • 批量数据处理
    • 文件压缩、解析

    可运行 Demo 示例

    import taskpool from '@ohos.taskpool'
    
    @Concurrent
    function calculateSum(count: number): number {
      let sum = 0
      for (let i = 0; i < count; i++) {
        sum += i
      }
      return sum
    }
    
    @Entry
    @Component
    struct TaskPoolDemo {
      @State result: string = '等待计算'
    
      build() {
        Column() {
          Text(this.result)
            .fontSize(18)
            .margin(20)
    
          Button('开始计算')
            .onClick(() => {
              this.startTask()
            })
        }
      }
    
      startTask() {
        this.result = '计算中...'
        taskpool.execute(calculateSum, 1000000).then(res => {
          this.result = `结果是:${res}`
        })
      }
    }

    代码说明

    • @Concurrent 表示该函数可以并发执行
    • TaskPool 自动管理线程,不需要开发者手动创建线程
    • UI 线程只负责接收结果和更新状态

    在真实项目中,使用 TaskPool 往往能立刻解决页面卡顿问题。

    Worker:长期后台任务的选择

    Worker 的使用场景

    如果任务具有下面这些特点,就更适合使用 Worker:

    • 长时间运行
    • 需要持续处理数据
    • 与 UI 强隔离

    比如日志分析、音视频处理、复杂解析等。

    示例:使用 Worker 处理数据

    主线程代码

    let worker = new Worker('workers/data_worker.ts')
    
    worker.postMessage({ action: 'start' })
    
    worker.onmessage = (e) => {
      console.log('收到结果:', e.data)
    }

    Worker 线程代码

    onmessage = function (e) {
      if (e.data.action === 'start') {
        let result = 0
        for (let i = 0; i < 500000; i++) {
          result += i
        }
        postMessage(result)
      }
    }

    代码说明

    • Worker 与 UI 线程完全独立
    • 即使计算时间较长,也不会影响页面交互
    • 通过消息机制进行通信

    结合实际场景的应用示例

    场景一:列表页面加载大量数据

    问题:

    • 首次进入页面时一次性处理全部数据
    • 页面明显卡顿

    解决思路:

    • 网络请求使用 async
    • 数据整理放入 TaskPool
    async loadList() {
      let data = await fetchData()
      taskpool.execute(processData, data).then(list => {
        this.list = list
      })
    }

    场景二:文件导入与解析

    问题:

    • 文件较大
    • 解析过程耗时

    解决思路:

    • Worker 负责解析
    • UI 只显示进度
    worker.postMessage({ filePath })

    场景三:复杂计算驱动 UI 更新

    问题:

    • 计算逻辑和 UI 耦合

    解决思路:

    • 计算完全放到 TaskPool
    • UI 只订阅结果

    QA 环节

    Q:async / await 会不会阻塞线程?
    A:不会,它只是让出执行权,不会卡住 UI 线程。

    Q:TaskPool 和 Worker 怎么选?
    A:短期、一次性的任务优先 TaskPool,长期或持续任务用 Worker。

    Q:能不能在生命周期里做耗时操作?
    A:不建议,生命周期函数应尽量轻量。

    总结

    线程阻塞并不是某一个 API 的问题,而是设计问题。在 HarmonyOS 中,系统已经为我们准备好了异步模型、TaskPool 和 Worker,只要遵循“UI 线程只做 UI”的原则,大多数卡顿问题都可以提前避免。

    在真实项目中,提前做好任务拆分、线程规划,比后期排查卡顿要省心得多。这也是鸿蒙开发从“能跑”到“跑得顺”的一个重要分水岭。