做鸿蒙 PC 应用有一个很有意思的现象:很多人觉得 PC 版就是把手机版放大一下,结果一适配就翻车。桌面窗口的尺寸、信息密度、交互方式,和手机完全不是一个逻辑。“冀游助手”这个项目,就是我想在鸿蒙 PC 上验证信息类应用到底该怎么组织页面才不别扭。我的最终产物是一个没有任何后端依赖的河北简介应用,概括起来就是三件事:首页按分类展示河北的景点、美食、民俗手工艺;点进详情能看图文长文;看中的条目可以收藏。全部代码都在本地,用 ArkTS 编写,UI 用 ArkUI 声明式组件搭建,数据由 JSON 驱动。
这篇东西适合谁看?如果你是刚接触鸿蒙应用开发,又想顺手做点能放进作品集的小项目,这篇文章可以当成一条从环境搭建到打包运行的完整路线图。我会把搭建过程中踩过的坑、调过的参数一并写出来,尽量让你照着能复现。项目本身难度不高,但信息展示类应用恰恰是开发中最常见、最能锻炼基本功的场景,把这里面的逻辑吃透了,后面做任何内容型应用都能少走弯路。
1. 项目从哪来:做一款“冀游助手”的缘起与整体思路
1.1 为什么选择鸿蒙PC版当载体
我在决定做这个项目之前,先盘了一下手里的技术路线:手机端应用我已经写过几个,平板端也试过,但 PC 窗口一直没正经做过。之所以这次选鸿蒙 PC 版,首先是盯上了它的“一多”能力,也就是一套代码能跑在不同形态的设备上。PC 窗口比手机大得多,信息展示类应用的体验天然更舒服。旅游简介这种内容,在手机上看图、看文、收藏,来回切换非常割裂;但放到 PC 屏幕上,左边是景点卡片列表,右边是详情内容,鼠标一点就能切换,这个效率完全是另一档。
其次是交互方式的差异。PC 端有鼠标和完整的键盘事件,可以支持悬浮预览、右键快捷收藏、Ctrl+F 之类的操作。我在冀游助手里就做了一个很简单的交互:鼠标放到卡片上时,卡片会有轻微放大和阴影加深的效果,这在手机上是很难自然呈现的。HarmonyOS 对窗口交互事件的支持已经比较完整,使用 ArkUI 的鼠标事件和状态样式就能实现,不需要额外引入复杂的库。
再说市场定位。现在 PC 应用商店里真正用心做本地文化展示的应用并不多,大多数城市类应用都把重点放在“能导航、能订票”这样的工具性功能上,反而不太关注阅读体验。冀游助手不做太重的功能,它的定位就是“深入阅读一个地方”,这种小而美的内容应用放到 PC 大屏上反而更有优势。用 PC 的时候,用户注意力相对集中,看完一篇文章、浏览一套资料是常见行为。
1.2 冀游助手的产品边界:不做大而全,做小而美
产品范围我刻意收得很窄。一开始有人建议我把河北省所有地级市都做进去,每个城市再挂上酒店、交通、购物这些信息,我果断拒绝了。一个练习项目如果把范围铺开,大概率会出现两种结局:要么收尾收不干净,要么每一块都做得特别浅。最终我定了三个模块:景点、美食、手工艺。景点我拿了几个有代表性的场景,美食选了大家比较熟悉的品类再加两个小众的,手工艺则完全做成文化介绍性质。
这种取舍背后有三个考虑。第一,数据量小意味着我可以把所有精力放在界面交互和状态管理上,毕竟这个项目的核心目标是实践 ArkUI 组件和调试流程。第二,信息展示类应用的卡点从来不是“内容够不够多”,而是“内容多的时候列表性能能不能扛住”,所以我在项目里特意做了列表渲染方式的对比。第三,内容范围收窄以后,详情页可以写得比较深,每篇文章控制在 300 到 500 字,配图也能保持统一风格,整体完成度会高很多。
从工程角度看,这个产品边界也给后续扩展留了路。数据文件是 JSON 格式,页面展示完全由数据驱动,以后想加城市模块只需要在 JSON 里新增条目,对应的详情页路由会自动处理好。所以冀游助手虽然现在只是展示局部内容,架构上已经是可以横向扩充的。
2. 开发前的准备:环境搭得顺,后面少遭罪
2.1 环境与SDK:先跑通Hello World再谈UI
鸿蒙开发环境的搭建比较直接,目前官方主推的是 DevEco Studio 这套 IDE。第一次启动的时候,按向导把 HarmonyOS SDK 配置好,打开设备管理,创建一个本地模拟器。这里我有一个非常实际的建议:不要急着开始写业务代码,先把工具链跑通。我见过太多人一上来就照着教程写 UI,结果一运行才发现在模拟器创建环节就卡住了,后面全部代码都得推倒重来。
创建项目的时候,我选择的是 Empty Ability 模板,语言直接选 ArkTS。这个模板会生成一个最简单的可运行应用,包含一个文本页面和一个默认的入口能力。先用这个模板在模拟器上跑一次 Hello World,确认三个关键链路是通的:编译没问题、模拟器能启动、应用能安装。这两个环节任何一个出问题,后面都很难排查,因为你会分不清问题是出在环境上还是出在你的代码上。
DevEco Studio 自带的预览器 Previewer 也很好用,它可以在不启动模拟器的情况下快速渲染单个页面。我在写首页卡片布局的时候,就是一边改代码一边看预览器的实时效果。但预览器有几个局限:它不能完全模拟 PC 窗口的真实尺寸,也不能验证路由跳转和系统能力调用。所以我的习惯是:写页面时用预览器快速调样式,做流程验证和最终测试时用模拟器。
2.2 工程结构设计:一个页面搞定还是拆文件?
很多初学者会觉得项目就那么几个页面,全部塞进 Index.ets 里不就行了?这个想法在体验阶段没问题,但只要页面一多,维护成本立刻上来。我给冀游助手设计的工程结构是这样:
entry/src/main/ets/ ├── entryability/ │ └── EntryAbility.ets ├── pages/ │ ├── Index.ets │ └── Detail.ets ├── component/ │ ├── SpotCard.ets │ └── SectionTitle.ets ├── model/ │ └── Spot.ets ├── data/ │ └── hebei_data.json └── common/ └── Constants.etspages 目录只放路由页面,component 放公用的 UI 子组件,model 定义数据模型,data 放 JSON 数据文件。这样拆有什么好处?最直接的一点是,当我要改卡片样式的时候,只需要打开 SpotCard.ets,完全不用动页面逻辑;当我要增加一个模块分类时,也只需要在 JSON 里加内容,再决定要不要新增一个页面。
还有一个容易被忽略的点:模型类建议独立成文件。ArkTS 对结构化类型检查比较严格,把字段类型错误的数据传进组件,编译阶段可能察觉不到,但运行时很容易拿不到预期字段。model 层定义了清晰的数据契约,比如 Spot 类里有 id、name、brief、desc、coverUrl、category 这些字段,数据加载完转成数组之后,页面用起来就非常安心。我在这个项目里是先把数据结构和页面框架都定好,再填充具体内容的,顺序对调的话会反复改代码。
3. 页面是怎么做出来的:ArkUI核心组件实操拆解
3.1 首页:卡片式列表和轮播图
首页是冀游助手的门面,我设计成三段式布局:顶部一个横幅轮播区展示重点推荐,中间是分类标题,下面是一个可滚动的卡片列表。这个结构非常适合信息类应用,因为它做到了“先吸引眼球、再引导阅读”的节奏。
横幅轮播用的是 Swiper 组件,ArkUI 的轮播组件内置了循环播放、自动切换、手势滑动等能力,不需要自己写定时器。在 PC 上使用 Swiper 要特别注意两点:一是宽度要设置为窗口宽度的百分比而不是固定像素,这样窗口拉宽时才不会露馅;二是高度要有一个合理的上限,PC 屏幕是横向的,轮播图太高会挤压下面的列表区域。我在项目里把它控制在 180 到 200 之间,视觉上比较舒服。
卡片列表的核心是 List 和 ListItem。ArkUI 的 List 容器支持懒加载机制,配合 ForEach 遍历数据数组就能自动生成列表项。这里我要说一个非常重要的实操细节:ForEach 的第三个参数“键值生成器”一定要写。它的作用是告诉框架“怎么判断一个列表项是新增、删除还是移动”,如果不提供默认使用数组索引,那列表的顺序变化时框架会认为所有项都没变。在实际项目里,我用的是每一条数据独有的 id,这样能减少很多不必要的渲染性能问题。
import { Spot } from '../model/Spot'; import { loadSpotData } from '../data/DataSource'; @Entry @Component struct Index { @State spots: Spot[] = []; aboutToAppear(): void { this.spots = loadSpotData(); } build() { Column() { Text('冀游助手') .fontSize(24) .fontWeight(FontWeight.Bold) .margin({ top: 16, bottom: 12 }) Swiper() { ForEach(this.spots, (item: Spot) => { Image(item.coverUrl) .width('100%') .height(190) .objectFit(ImageFit.Cover) .borderRadius(12) }, (item: Spot) => item.id) } .height(210) .loop(true) .autoPlay(true) .interval(3500) .indicator(true) .margin({ left: 16, right: 16 }) List({ space: 12 }) { ForEach(this.spots, (item: Spot) => { ListItem() { this.SpotCardView(item) } }, (item: Spot) => item.id) } .layoutWeight(1) .margin({ top: 12 }) .padding({ left: 16, right: 16 }) } .width('100%') .height('100%') .backgroundColor('#F5F6FA') } @Builder SpotCardView(item: Spot) { Row() { Image(item.coverUrl) .width(120) .height(84) .borderRadius(8) .objectFit(ImageFit.Cover) Column({ space: 6 }) { Text(item.name) .fontSize(18) .fontWeight(FontWeight.Medium) Text(item.brief) .fontSize(14) .fontColor('#666666') .maxLines(2) .textOverflow({ overflow: TextOverflow.Ellipsis }) } .alignItems(HorizontalAlign.Start) .layoutWeight(1) .margin({ left: 12 }) } .width('100%') .padding(10) .backgroundColor(Color.White) .borderRadius(12) } }这套代码跑起来后,首页就已经具备完整的信息流结构。这里有一个技巧:ArkUI 的 @Builder 修饰符可以把一段 UI 封装成一个方法,在列表里通过 this.SpotCardView(item) 调用。这样写的好处是卡片视图可以复用于其他页面,比如后面做的收藏页,同样可以调用同一个 @Builder 方法。
3.2 详情页:大图、图文介绍与收藏入口
从首页点进详情页,数据传递用的是路由参数。我在跳转的时候通过 router.pushUrl 把当前条目的 id 带过去,详情页再根据这个 id 从全局数据源里取对应对象。这种“列表负责展示,详情按 id 查询”的模式非常经典,数据量不大时足够用,而且逻辑清爽。
详情页的布局是单列滚动:顶部是大图,标题和收藏按钮放在一行,下面是一段图文长文。单列滚动在 PC 上也可以接受,但要注意限制正文最大宽度,否则一行文字会拉得特别长,阅读体验很差。我做法是给正文加了一个 .constraintSize({ maxWidth: 860 }) 的限制,在窗口特别宽的时候内容仍然集中在中间区域。
收藏按钮是这个小项目里最有意思的状态管理场景。我用了 @State 修饰一个布尔变量 isFavorite,点击按钮后取反,同时按钮的图标和文字都会跟着变化。刚开始我以为这就完了,直到后来测试发现:离开详情页再回来,收藏状态丢了。这是因为页面销毁后 @State 变量也随之消失。要真正持久化收藏状态,需要用到持久化存储能力,也就是鸿蒙的 AppStorage 配合 Preference 框架。
正确做法是启动应用时先读取本地存储的收藏列表,放进 AppStorage 作为全局状态;详情页里每次点击收藏都同步写入全局状态和本地存储。这样即使杀掉应用再打开,收藏记录依然在。实现收藏后还有一个交互细节:已收藏的条目在首页卡片上应该显示一个小角标,这时候首页也需要订阅这个全局状态。用 @StorageLink 可以实现页面和全局存储的双向绑定,首页读到的收藏列表每次变化时都会自动刷新,完全不需要手动通知。
这里我把核心的收藏逻辑写得很细:用 Set 来存收藏的 id 集合,判断某条是否已收藏时直接查集合,时间复杂度是 O(1)。如果你用数组去存然后每次 indexOf,虽然在这个数据规模下也不至于慢,但从一开始就养成选对数据结构的习惯,对后续项目只有好处。
4. 数据从哪来:静态数据组织与图片资源的正确处理
4.1 把河北简介拆成结构化数据
冀游助手的数据是纯静态的,我把它们放在一个 JSON 文件里。这样做的考虑是想把项目的重点放在 UI 和交互上,但如果连数据读取都做得不干净,后面接真实接口时就得改一大堆。所以我按照真实接口的返回格式来设计 JSON,页面代码只认数据模型,不关心数据到底来自本地还是网络。
JSON 的结构如下:
{ "spots": [ { "id": "spot_001", "name": "山海关", "category": "scenery", "coverUrl": "/resources/rawfile/images/shanhaiguan.webp", "brief": "天下第一关", "desc": "这里被誉为明长城的东部起点...", "highlights": ["古城墙", "海楼", "老龙头"] }, { "id": "food_001", "name": "驴肉火烧", "category": "food", "coverUrl": "/resources/rawfile/images/lvrouhuoshao.webp", "brief": "街头巷尾的经典味道", "desc": "外皮酥脆,内馅鲜香...", "highlights": ["火烧", "驴肉", "焖子"] } ] }数据加载有几种方式,我最后选择了直接使用 import 引用 JSON。ArkTS 支持将 JSON 文件作为模块导入,框架会自动解析成对象。初始化数据源的方法看起来很直接:
import data from './hebei_data.json'; export function loadSpotData(): Spot[] { const raw = data as any; return raw.spots.map((item: any) => { return new Spot(item); }); }这里还有一个严格类型的问题要提醒:ArkTS 的编译检查比 JavaScript 要严格,直接从抽象对象上取字段会报类型错误。所以我在 model 层里写了一个 Spot 构造函数,负责从原始对象转换为类型安全的实例。这个过程在行业里叫数据清洗或对象映射,写习惯了之后会觉得它特别重要,尤其是团队协作时,它相当于一条明确的数据边界。
4.2 图片资源与多倍图适配:PC窗口要注意什么
图片是整个项目里最影响观感的部分。PC 屏幕上图片会被拉得比手机大得多,如果原图分辨率不够,放大后全是马赛克。我的经验是,封面图最少要有 1280 像素宽度,首屏轮播图则应该准备 1920 宽度的版本。这个要求不苛刻,但确实需要提前注意。
在项目里,图片放到了 resources/rawfile 目录下,程序运行时通过相对路径加载。注意 rawfile 的文件名和路径不能出现中文字符,我踩过这个坑:一开始想用“山海关.png”直接做文件名,编译不报错,但运行时表现不稳定。后来统一改成拼音或英文短横线命名,问题就消失了。
图片格式上我强烈建议用 WebP 而不是 PNG。同样的画质,WebP 的文件大小经常只有 PNG 的三分之一不到。在 PC 端应用里,一个几兆的 PNG 图片会明显拖慢应用安装包和首屏加载速度,而 WebP 则完全没压力。你可以在资源目录里放两张同内容的图片对比一下,体积差距肉眼可见。
再说一点多倍图的概念。鸿蒙的 UI 单位是 vp,在 PC 上 1vp 对应的物理像素可能超过手机的默认值。如果一张图片的 CSS 概念上的尺寸是 120vp,在手机屏幕上也许只要 240 物理像素宽度的图就够了,但在 PC 上可能需要 360 甚至 480 物理像素。我一般会在资源目录下建 multi-column 多分辨率适配的机制,对低分辨率设备用 1x,PC 用 2x。这样应用在不同设备上都能保持清晰,不会出现模糊或者文件过大的尴尬。
5. 调试、打包与真机效应:把应用跑到PC上
5.1 预览器与模拟器:两种调试方式怎么搭配
冀游助手开发过程中,我用到了两种调试方式:本地预览器 Previewer 和模拟器 Emulator。它们的定位完全不同。
Previewer 最大的优势是快。我改一行代码,几百毫秒之内界面就刷新了,非常适合调间距、颜色、字体这些“看感觉”的东西。但它有几个致命问题:不支持完整的路由跳转和系统能力调用,而且在多窗口场景下表现不精确。我试过在 Previewer 里跑模拟器引导的完整流程,结果是点击跳转根本没反应,后来才知道 Previewer 只负责单页面渲染。
模拟器则是一个完整的系统环境。冀游助手最终验收阶段全在模拟器上进行,我会刻意调整窗口大小,从 1280x720 拉到 1920x1080,观察页面布局是否自适应。这也是 PC 开发和手机开发最大的差别:手机屏幕尺寸是固定的,而 PC 窗口可以被用户随意拖拽,所以布局必须使用弹性布局和百分比宽度,不能写死像素。
我在项目里有一个专门用于验证自适应的方式:把窗口拖到极端宽度,比如 800 像素的窄窗口和 2400 像素的宽窗口,然后逐个页面检查。第一次跑的时候,详情页的正文在 2400 像素宽度下直接变成一行超长文本,后来加了 maxWidth 限制才解决。这类问题不靠模拟器实测,光看预览器永远发现不了。
5.2 打包与安装:HAP 是怎么生成的
应用开发完成后,最终要产出安装包。鸿蒙应用的安装包叫 HAP,也就是 Harmony Ability Package。在 DevEco Studio 里,构建流程非常透明:点击菜单栏的 Build,选择 Build Hap(s)/APP(s),如果只构建当前模块就直接生成对应模块的 HAP 包。
构建之前必须处理签名问题。真机安装需要有效的签名证书,模拟器则宽松一些。第一次接触签名的开发者可能会被各种 .p12 和 .cer 文件搞晕。简单说,签名就是给应用打一个数字指纹,确保安装包没有被篡改且来源可信。DevEco Studio 提供了自动签名模式,你只需要登录账号并关联一个项目,IDE 会自动帮你完成证书的申请和配置。
我要提醒一点:HAP 包直接性质类似于安卓的 APK,但它不能直接双击安装,需要通过 DevEco Studio 的 Run 按钮或者专门的命令行工具安装到模拟器或真机上。我用得最多的还是 IDE 的 Run 按钮,点击后它会自动完成构建、签名、安装、启动这一条流程。调试时这么做最省心。
5.3 调试踩坑实录:那些必须写进备忘的问题
第一个坑是列表滑动卡顿。刚写完首页的时候,我在模拟器上测试滑动,明显感觉不流畅。原因是我在 ForEach 里做了一些图片的复杂裁剪操作,每次列表项进入可视区域都重新计算。解决方法是把图片裁切参数固定下来,并把列表项的图片加载改为自适应尺寸的异步加载模式。对于数据量再大一些的场景,则建议使用 LazyForEach 配合懒加载,这样列表项真正进入屏幕前不会去初始化资源。
第二个坑是路由跳转传参的类型丢失。我用 router.pushUrl 传递了一个对象,结果详情页接收的时候打印出来是 undefined。查了半天才发现,路由参数只能传递基本类型或者序列化后的字符串,传对象需要先 JSON.stringify 再传,到详情页再 JSON.parse。之后我就不再传整个对象了,只传 id,再通过数据源重新查询,这样既规避了类型问题,也符合数据驱动页面的原则。
第三个坑是应用启动白屏。有一次我把 JSON 数据文件放在了 rawfile 外的位置,导致 import 之后编译虽然通过,但运行时冷启动找不到资源,页面白屏。这个问题非常隐蔽,因为编译器不报错。解决方法是把所有静态资源统一放在 rawfile 目录下,并且在启动逻辑里加一个数据源加载失败的兜底提示,这样出了问题至少屏幕上能直接看到原因,而不是一片白。
第四个坑我现在还记得很清楚:模拟器冻结。当时的场景是项目里有一个 .gif 格式的动图作为加载动画,在模拟器上转到某一步突然整个模拟器卡死。查下来是模拟器对 gif 的多帧解码支持不太好,换成 WebP 动图后问题消失。这个经历让我养成了一个习惯:涉及动图或者复杂动画,先在真机上测试,模拟器虽然方便,但渲染能力毕竟和真机有差距。
6. 回顾与扩展思路
6.1 用下来的直接感受
现在回看整个冀游助手的开发过程,最大的体会是“在一个限定边界里做到完整”这件事比想象中更有价值。我没有把它做成一个功能堆砌的展示品,而是从数据到页面到收藏交互都有闭环,每一步都能讲清楚为什么这么做。这种感觉和以前写小 demo 完全不一样。
ArkUI 这套声明式 UI 框架用熟了以后效率很高。它和传统的 XML 布局很不一样,更像是把 UI 当函数处理,状态一变界面自动更新,省掉了大量的 DOM 操作代码。鸿蒙的文档虽然已经比较完善,但很多细节还是要靠在实际调试中摸索,好在 Previewer 反馈快,踩坑成本低。
6.2 接下来还能怎么扩展
冀游助手目前的定位是本地静态数据,但它的骨架已经具备接真实数据的基础。后续如果想升级,第一个可以做的是把数据源切到远端 API,通过模块化数据层替换掉本地的 import,页面的其余部分完全不用动。第二个方向是加入地图能力,HarmonyOS 提供了地图组件,可以把景点列表渲染到地图上,点击标注跳回详情页,这在 PC 上会是一个很惊艳的体验。
还有一个我一直在惦记的方向是深色模式。PC 用户对深色模式的需求明显比手机用户高,尤其在晚上写代码或者调资料的时候。如果要做,需要把颜色值统一收口到一个 Theme 类里,而不是在每个组件里散布色号,这样一套配色能反复切换。工作量和代码改动其实不太大,但对应用质感的提升非常明显。
个人而言,这个项目让我对鸿蒙 PC 开发的整体情况有了底。现在无论再拿到什么信息展示类的需求,我都能比较快地判断出用什么布局结构、会不会有性能风险、怎么组织数据。如果你也想用鸿蒙做点东西入门,强烈建议从一个“简介展示应用”入手,它麻雀虽小,五脏俱全,能让你完整走完从环境到打包全流程,后面做正式项目时心里就不慌了。