☰
HarmonyOS仿微信朋友圈动态App实战:ArkTS列表与状态管理
2026/10/14 6:50:25 网站建设 项目流程

如果你平时刷动态刷得比较多,就会对这样一条时间线很熟悉:最上面是头像和昵称,中间是一段文字说明,接着是九宫格图片,底部排列着点赞的人名和评论列表。这套界面看起来不算复杂,但把它放到HarmonyOS上完整实现一遍,几乎可以覆盖应用开发中最核心的环节——数据建模、列表渲染、状态刷新、图片加载、本地持久化。这篇博客记录的是我用ArkTS从零写一个仿微信朋友圈动态展示App的完整过程,包括数据结构设计、LazyForEach列表优化、点赞评论交互、发布新动态、数据落盘,以及在真实调试中踩过的一些坑,适合刚学完ArkUI基础、想找一个完整项目练手的朋友。

我没有把功能做得特别大,只实现了一个本地单机版:不需要登录、不接后端,启动后展示一批预置动态,可以下拉输入文字和选择图片来发新动态,能点赞和取消点赞,也能查看预置的评论列表。这个范围刚好能把HarmonyOS主线的开发流程走通,又不至于被网络协议和账号体系拖住。下面按我实际的开发顺序来写,尽量把每一步为什么这么做也交代清楚。

1. 为什么拿“朋友圈”当练手项目,而不是再做一遍Todo List

1.1 一个动态流页面覆盖的知识点远超预期

很多教程喜欢用“待办事项”来解释列表和状态管理,但说实话,Todo List的数据结构太简单了——就是一组字符串,改一下勾选状态而已。真正的业务场景几乎都是“列表项内部还有嵌套结构”,动态流就是典型例子。

一条动态不是单纯的一行文字,而是一个组合体:用户信息、正文、图片组、点赞列表、评论列表。点赞列表会在点击后插入或删除元素,评论列表也会在发布后增加内容,图片组可能是一张、四张、九张,不同数量对应不同的布局规则。这些需求放在HarmonyOS里,恰好能测试到@State对不同嵌套数据层的响应能力,同时也能把List、Grid、Image、TextInput这些基础组件全线用一遍。

在动手之前,我先梳理了朋友圈动态展示App最核心的交互闭环:滑动浏览动态、点赞互动、查看评论、发布新内容。这个过程听起来和真实产品差不多,但实现时只需要关心本地数据流——从数据源读取,渲染到页面,用户操作后更新数据,数据变化再驱动界面回流。这个“单向数据流”的认知一旦建立起来,后面接入网络层也只是多一步请求和解析。

1.2 我把功能边界收窄到了什么程度

为了避免项目无限膨胀,我明确不做以下几件事:

  • 不接真实账号体系,当前用户就固定用一个本地昵称;
  • 不接后端接口,所有动态数据都从本地 mock 数组读取;
  • 不做下拉加载更多,数据量控制在几十条,重点先放在渲染和交互;
  • 不做朋友圈的权限逻辑(比如“谁可以看”),那不是本地练手项目该纠结的东西。

做了这些裁剪之后,整个项目依然保留了“动态展示App”最核心的东西:数据怎么组织、界面怎么渲染、状态怎么刷新、数据怎么持久化。在我看来,练手项目最重要的不是堆功能,而是把一个核心链路吃透。动态流把列表和状态这两个高频难点吃得很透,比做一堆只有静态页面的小工具要有价值得多。

2. 环境和框架准备:API版本、工程结构、ArkTS的语言边界

2.1 版本选择和工程创建的具体做法

HarmonyOS的版本迭代确实快,不同阶段的API可能会有差异,好在核心机制基本是稳定的。我这里使用的是Stage模型工程、ArkTS语言、ArkUI声明式UI范式,以API 9到API 10的接口作为基准。如果你手头IDE版本更新,优先选择较新的稳定API版本也没有问题,改动通常集中在少数组件名称和权限声明上。

在DevEco Studio里创建一个空工程,选择Empty Ability模板,语言选ArkTS,之后会自动生成entry模块。需要留意的是工程默认会开启代码检查,有些写法在TypeScript里没问题,在ArkTS里会直接报错,这也是很多人刚迁移过来时最不习惯的地方。比如下面两个差异要提前适应:

  • 不能使用any和unknown这类无类型类型,必须给数据定义明确的class或interface;
  • 对象字面量如果和目标class结构不一致,编译器会提示类型错误或缺失属性。

我的习惯是把所有涉及状态的数据都定义成class,而不是用简单的JSON对象。原因后面会反复提到——ArkUI的状态管理对class实例的嵌套监听支持更好,普通JSON对象在深层修改时很容易出现“数据已经变了,UI却不刷新”的诡异问题。

2.2 ArkTS和TypeScript到底差在哪里

很多从Web前端转过来的朋友会问:“ArkTS是不是就是TypeScript?”一句话回答:语法很像,但它还带了完整的UI框架和状态管理机制,更像是一个绑定了UI运行时语言的TypeScript方言。

比如,你写的不是纯逻辑代码,而是带装饰器的声明式描述:

@State private momentList: Moment[] = [];

这里的@State就是核心钥匙。它相当于给数据贴了一张“变更通知标贴”,数据一旦发生变化,ArkUI的运行时会自动重新执行依赖了该数据的build方法,从而刷新界面。用Web开发来类比,@State有点像Vue里的ref或reactive,但它的粒度控制更严格,并不是所有数据都需要加装饰器,也不是所有加了装饰器的数据都能刷新嵌套子属性。

这种“数据驱动UI”的思路,一旦理解了,写起来确实很舒服。你不再需要手动调用setText、notifyDataSetChanged之类的更新方法,只需要保证“改的是正确层级的数据”。可如果数据层级错了、装饰器配对错了,排查起来也比传统命令式更隐蔽——因为界面不报错,只是默默不刷新。

3. 动态流的数据模型与一条动态的UI骨架

3.1 用class把一条动态说清楚

首先要做的是定义“一条动态”的数据结构。我建议不要一上来就写UI,先把模型建好。动态流的模型可以拆成三个类:用户信息、单条评论、单条动态。

用户模型:

@Observed export class User { id: number = 0; nickname: string = ''; avatar: string = ''; constructor(id: number, nickname: string, avatar: string) { this.id = id; this.nickname = nickname; this.avatar = avatar; } }

评论模型:

@Observed export class Comment { id: number = 0; user: User = new User(0, '', ''); content: string = ''; publishTime: string = ''; constructor(id: number, user: User, content: string, publishTime: string) { this.id = id; this.user = user; this.content = content; this.publishTime = publishTime; } }

动态模型:

@Observed export class Moment { id: number = 0; user: User = new User(0, '', ''); content: string = ''; images: string[] = []; likes: string[] = []; comments: Comment[] = []; publishTime: string = ''; constructor(id: number, user: User, content: string, images: string[], likes: string[], comments: Comment[], publishTime: string) { this.id = id; this.user = user; this.content = content; this.images = images; this.likes = likes; this.comments = comments; this.publishTime = publishTime; } }

注意我给三个类都加了@Observed装饰器。这里有个很重要的原因:@Observed的作用是让该类的实例作为属性被监听到子层级。如果去掉它,外层数组可能感知到整体赋值,但不会关心某个图片数组里多了一个元素,UI就不会刷新。

3.2 把一条动态画在页面上的布局思路

模型建好后,接下来就是把每个字段映射到UI。一条动态的视觉结构可以从上到下拆成四个区块:

  • 头部:左侧头像,右侧是昵称和发布时间;
  • 正文:动态的文字内容;
  • 图片区:一张、四张、九张时的九宫格布局;
  • 互动区:点赞人列表、评论列表。

我用一个名为MomentItem的自定义组件来承载单条动态,接收一个@ObjectLink moment: Moment参数。这里@ObjectLink是和@Observed成对出现的,后面第5章会详细讲它为什么不能省。

头部区块的核心代码如下:

Row({ space: 10 }) { Image(this.moment.user.avatar) .width(44) .height(44) .borderRadius(6) .objectFit(ImageFit.Cover) Column({ space: 2 }) { Text(this.moment.user.nickname) .fontSize(16) .fontWeight(FontWeight.Medium) Text(this.moment.publishTime) .fontSize(12) .fontColor('#999999') } .alignItems(HorizontalAlign.Start) } .width('100%')

图片区的九宫格布局,我用了Grid容器,这样比手动摆Row更整洁。设计思路是:根据images.length决定columnsTemplate列数——一张图时显示单列大图,四张图时两列拼接,九张图时三列均分。每张图片设置固定的aspectRatio(1),让它保持正方形。

动态正文和互动区则用Text和ForEach循环渲染。评论列表渲染时,每一行用Row组合“昵称 + 内容”,和真实朋友圈一样,昵称用深色加粗,内容用普通灰色。

3.3 用@Builder提取重复布局

在写列表项时,我发现自定义组件内部经常会重复出现“用户头像 + 昵称”的区块,比如动态头部和评论头部都用到了。起初我直接复制代码,后来发现一改样式要改两处,容易漏。ArkUI提供了@Builder函数,可以用来抽取局部的UI片段:

@Builder UserHeader(user: User, showTime: boolean = false) { Row({ space: 8 }) { Image(user.avatar) .width(36) .height(36) .borderRadius(18) Column() { Text(user.nickname) .fontSize(14) .fontWeight(FontWeight.Medium) if (showTime) { Text(this.moment.publishTime) .fontSize(11) .fontColor('#999999') } } } }

在build里直接调用this.UserHeader(this.moment.user, true)就行。这个做法比新建一个专门组件更轻量,适合复用程度不高但又确实有重复的UI片段。注意@Builder方法内部不能直接访问外部组件的普通成员变量,必须把需要的数据作为参数传进去,否则很容易遇到“变量找不到”的编译错误。

4. 列表性能与滑动体验:从ForEach换成LazyForEach之后

4.1 ForEach和LazyForEach的核心差异

动态流本质是一个长列表,列表渲染的选型直接决定滑动体验。ArkUI提供两个常见的循环渲染方法:ForEach和LazyForEach。它们的差别,我习惯这么理解:

  • ForEach:不管内容在不在屏幕内,先全部渲染出来。数据少时无所谓,数据多到一定程度,首帧时间会明显拉长,卡顿感就来了。
  • LazyForEach:按需创建,只有列表滚动到某个位置时,才创建对应条目的组件;滚出屏幕后还可以被销毁,从而控制内存和绘制开销。

动态流的真实使用场景是“无限往下刷”,所以即使我的本地mock数据只有几十条,也直接用了LazyForEach。这不只是为当前这一点数据量考虑,还为后续接入真实数据源留好了扩展点。

4.2 实现IDataSource的完整骨架

LazyForEach必须配合一个实现了IDataSource接口的数据源类,常见命名是BasicDataSource。核心要重写的方法有四个:

export class MomentDataSource implements IDataSource { private dataList: Moment[] = []; constructor(list: Moment[]) { this.dataList = list; } totalCount(): number { return this.dataList.length; } getData(index: number): Moment { return this.dataList[index]; } getIndex(key: string): number { return this.dataList.findIndex(item => item.id.toString() === key); } push(element: Moment): void { this.dataList.push(element); // 通知监听者,在指定位置插入了数据 this.notifyDataInsert(0); } reload(list: Moment[]): void { this.dataList = list; this.notifyDataReload(); } getDataIndexByKey(key: string): number { return this.getIndex(key); } registerDataChangeListener(listener: IDataChangeListener): void { this.listener = listener; } unregisterDataChangeListener(): void { this.listener = undefined; } notifyDataInsert(index: number): void { this.listener?.onDataInserted(index); } }

这里容易踩坑的地方是getIndex。IDataSource要求每条数据有稳定的key,LazyForEach用它来判断哪个组件需要复用或销毁。我直接用了item.id.toString()作为key,避免用数组下标——因为下标会变,变了之后复用关系就乱了。

4.3 列表项高度抖动与图片适配

实现完LazyForEach后,我遇到了一个很影响观感的问题:滚动时,图片区块的高度会忽大忽小,尤其是文字长度不一致的动态混在一起时,整个列表看起来像在“抖”。

原因很直接:同一列fixture的Grid高度是由图片内容决定的,不同图片的宽高比不同,渲染出来的高度就不一样。解决方案是给图片容器加固定比例,我在图片的Grid上直接约束每行尺寸:

Grid() { ForEach(this.moment.images, (img: string) => { GridItem() { Image(img) .width('100%') .aspectRatio(1) .objectFit(ImageFit.Cover) } }, (img: string) => img) } .columnsTemplate(this.getGridColumns())

这样每张图片都被裁剪成正方形,九宫格整体高度稳定,列表项的高度也稳定了。objectFit(ImageFit.Cover)表示按比例填充并裁剪掉多余部分,这也是朋友圈九宫格最常用的图片显示策略。如果你希望保留图片原比例,则用ImageFit.Contain更合适。

5. 点赞、评论、发布背后的状态管理设计

5.1 @State、@Observed、@ObjectLink的分工

这是整个项目里最核心,也最容易翻车的部分。很多人写完列表后发现:点赞人数明明变了,界面却纹丝不动。要理解为什么,必须先把三个装饰器的分工理清楚。

@State通常加在页面或自定义组件的成员变量上,表示“这个数据发生变化时,当前组件要重新构建”。它负责的是当前组件直接持有的数据层。

@Observed加在class上,表示“这个类的实例内部属性变化也要被观察”。

@ObjectLink加在子组件中,接收一个被@Observed修饰的实例,表示“这个子组件要精细监听该实例内部属性的变化”。

用一句话串起来就是:父组件用@State持有数组,数组里的对象用@Observed标记,子组件用@ObjectLink接收单个对象。这样,moment.likes.push这种深层数组变更,才能触发订阅了该对象的UI刷新。

5.2 点赞和取消点赞的交互链路

点赞功能看起来简单,但它最能检验状态管理的设计是否正确。我的实现里,MomentItem内部维护了一个“当前用户是否已点赞”的计算结果:

get isLiked(): boolean { return this.moment.likes.includes(this.selfName); } toggleLike(): void { if (this.isLiked) { let index = this.moment.likes.indexOf(this.selfName); this.moment.likes.splice(index, 1); } else { this.moment.likes.push(this.selfName); } }

关键点在toggleLike里。如果Moment没有加@Observed,而MomentItem又用普通属性接收数据,那likes数组的push和splice就不会引发UI刷新。加了装饰器后,每次点赞或取消点赞,ArkUI都会自动通知对应组件重新执行build方法,isLiked的计算结果随之更新,红心状态和点赞人数便同步刷新。

这里还有一个细节:不要在build里写if (this.moment.likes.includes(...))作为UI分支判断的同时,又在onClick里手动修改一个单独的liked布尔变量。两个数据源一旦不同步,就会出现“点了赞,心红了,但没多一个人”的割裂。我倾向于让liked完全由moment.likes推导而来,单一数据源,永远一致。

5.3 发布动态时的新对象追加

发布动态比较特殊,因为它是向列表中“新增一个完整对象”,而不是修改已存在对象的内部状态。这种情况下,普通的@State数组反而没问题,只要做了整体赋值或调用了能触发更新的方法。

我的发布流程是:主页面点击右下角发布按钮,弹出一个半屏的新页面,里面有一个多行TextArea和“选择照片”按钮。用户点击发布后,当前页把所有填写内容组装成一个新的Moment实例,然后通过回调传回主页面:

onPublish = (moment: Moment) => { this.dataSource.push(moment); this.momentList = this.dataSource.getDataList(); }

这里需要注意:dataSource.push之后,我还做了this.momentList = this.dataSource.getDataList()。之所以要重新赋值一次,是因为LazyForEach的数据源通知机制和@State数组是两套体系。数据源内部通过notifyDataInsert通知LazyForEach去渲染新条目,但主页面里如果有其他地方依赖this.momentList做统计等逻辑,也需要同步更新,保持两边一致。

5.4 评论发布和预置数据的刷新

评论我一开始只做了预置显示,后来补上了“发送评论”功能。评论数据存在Moment.comments数组里,组件渲染评论时直接遍历这个数组。新增评论就是在数组末尾插入一个新的Comment实例。得益于@ObjectLink的嵌套监听,这个操作也能直接刷新评论区。

需要注意的是,评论列表使用ForEach时也要给每一条评论提供稳定且唯一的key,否则插入新评论后可能出现复用错乱,比如最后一条评论的样式跑到了中间。我用的key是comment.id.toString(),发布时用Date.now()生成新id。

6. 新发布动态的数据落盘:从Preferences到关系型数据库的取舍

6.1 PersistentStorage和AppStorage用来做什么

在真正的动态展示App里,用户新发的动态不能只在内存里存在,重启就没了。HarmonyOS提供了多种本地存储方案。我先用PersistentStorage解决了一个最小的需求:记住是否已经初始化过数据。

PersistentStorage.persistProp('hasInitLocalData', false); AppStorage.setOrCreate('hasInitLocalData', true);

这样做的用途是:第一次启动时,把mock数据拷贝到本地缓存;之后启动直接从持久化数据恢复,避免每次启动都重置。PersistentStorage的本质是把AppStorage里的某个属性同步到本地,适合存一些轻量的标识位。

6.2 动态数据落盘的三种方案对比

动态列表本身是结构化的多级数据,直接选存储方案时,我对比了三种做法:

方案适合场景实现成本需要注意的点
Preferences少量键值对低只能存字符串、数字、布尔等简单类型,需要自己序列化JSON
关系型数据库RDB大量结构化动态、按条件查询中需要建表、写增删改查、处理版本升级
文件读写图片等二进制内容或整份数据备份中需要自己设计文件格式和序列化策略

对我这个项目来说,动态数据量不大,所以选Preferences是性价比最高的实现。做法是把Moment[]转成JSON字符串,然后写入Preferences;启动时再读出来,通过JSON.parse解析并重新装配成class实例。

6.3 一个关键的还原细节

Preferences只能存字符串,所以momentList会被序列化成JSON。但JSON.parse后得到的对象是纯对象,不是Moment实例,也不具备@Observed类的深度监听能力。如果直接把这些纯对象塞回列表,后续点赞可能就失效了。因此必须做一个“还原”步骤:

function restoreMoments(jsonList: Array<any>): Moment[] { return jsonList.map(item => { const user = new User(item.user.id, item.user.nickname, item.user.avatar); const comments = item.comments.map((c: any) => { const commentUser = new User(c.user.id, c.user.nickname, c.user.avatar); return new Comment(c.id, commentUser, c.content, c.publishTime); }); return new Moment(item.id, user, item.content, item.images, item.likes, comments, item.publishTime); }); }

不要省这一步。我最初图省事,直接把JSON对象塞进数组,结果列表展示正常,一点赞就崩或刷新不了,查了好久才定位到是class实例被“降级”成了纯对象。

6.4 发布时的图片处理

发布新动态时,如果用户从相册选了一张图片,通过PhotoViewPicker拿到的通常是一个文件uri。这个uri在应用沙箱里是可用的,但如果只是记在字符串里、原图被系统清理或路径变化,重启后就可能失效。

最稳妥的做法是把选中的图片复制到应用自己的沙箱目录,再保存新的沙箱路径。可以用fileIo能力完成复制:

import { fileIo } from '@kit.CoreFileKit'; async function copyImage(srcUri: string): Promise<string> { let destDir = getContext().filesDir + '/moment_images/'; let fileName = Date.now() + '.jpg'; let srcFile = await fileIo.open(srcUri, fileIo.OpenMode.READ_ONLY); let destFile = await fileIo.open(destDir + fileName, fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.CREATE); await fileIo.copyFile(srcFile.fd, destFile.fd); return destDir + fileName; }

这样发布后重启应用,图片依然能找到。

7. 实际调试中踩过的坑与完整排查过程

7.1 改了嵌套数组,UI却一动不动

这是我调试最久的一个问题。现象非常典型:点击点赞后,控制台打印出来的moment.likes长度已经变了,但界面上的红心状态和点赞人数完全不变。

排查过程我按顺序走了三步:

  • 第一步,确认Moment类上有没有@Observed。发现没有,补上之后问题依旧。
  • 第二步,检查子组件是否用@ObjectLink接收数据。结果发现我写成了@Prop。@Prop只做单向同步,而且不会监听对象内部深层变化,所以在点击后,子组件的UI完全没有感知。把@Prop改成@ObjectLink后,点赞立刻能刷新了。
  • 第三步,确认传入LazyForEach的对象和MomentItem接收的对象确实是同一个实例,而不是通过构造参数复制出来的新对象。如果中间做了一次对象展开或浅拷贝,照样会丢失引用关系。

最后定位到的根因就是@Prop用错了。这里也提醒大家:列表子组件的数据接收,如果是复杂对象且需要监听到深层修改,就不要用@Prop,它本质上更适合简单值的单向同步。

7.2 同一张图片换了几次显示不出来

场景是:发布动态选了一张图,图片在九宫格里正常显示。后来我把这条动态的图片换成另一张图,模拟“编辑”功能时,出现了非常怪的现象——日志里uri已经是新图,屏幕上却还是旧图。

这是Image组件缓存导致的。ArkUI对图片加载本身有缓存机制,同一个key(这里实际是同一个uri)的图片不会重新解码加载。解决思路有两个方向:

  • 如果换成不同的文件uri,一般没有这个问题,因为uri本身就是不同的key;
  • 如果只是内容变化但uri没变,可以在uri后面拼接一个动态参数,比如uri + '?version=' + Date.now(),强制绕过缓存。

我最后选择了“每次编辑都生成新文件”的做法,让uri本身带时间戳,既避免缓存,也方便回滚。

7.3 九宫格布局被底部安全区顶住

在发布按钮和底部输入区靠近屏幕边缘时,手势指示条会把部分内容挡住。ArkUI里的安全区处理很直接:可以给根容器加expandSafeArea属性,或者给底部内容设置padding。

我选择在页面底部容器上加了一个安全距离变量:

@State private bottomSafeHeight: number = 0; aboutToAppear(): void { this.bottomSafeHeight = this.getSafeAreaHeight(); }

把发布按钮包裹在设置了padding({ bottom: this.bottomSafeHeight })的容器里,这样不同设备显示都一致。更简单的替代方案是直接用系统提供的安全区扩展属性,但我在这个项目中需要手动控制高度,所以用了变量方案。

7.4 发布后列表定位到了最底部,体验反而不对

发布完成后,我最初调用scrollToIndex把列表直接滚到最新一条(因为是插入到顶部,实际是滚到第0条),结果有时候页面直接回到了顶部,用户还没看完内容就被强制拉到最上面。

后来调整了策略:发布成功只更新数据,不强行滚动列表。保持用户当前阅读位置,只在顶部露出发布成功的轻提示。这个改动虽然很小,但体验提升明显——用户不会被突然打断。真实产品大多也是这么处理的,发布后的动态也不一定非要立刻出现在屏幕上。

8. 这个项目后续还能怎么扩展

动态展示App的架子搭好之后,可以继续加的功能其实非常多,我简单列一下自己后续准备尝试的方向。

第一个是下拉刷新和上拉加载更多。我目前用的是本地mock数据,但结构上已经用LazyForEach,接入Scroll的刷新事件和List的onReachEnd事件就能做分页。

第二个是图片点击预览。九宫格里点击某张图片,全屏展示大图并支持左右滑动切换。ArkUI里有Swiper容器,配合全屏模态页面可以实现。

第三个是评论的富文本化。目前的评论只有纯文本,如果要加“回复某人”的提示,可以改成Span组合,昵称用不同样式拼接在Text里。

第四个是接入真实数据源。把本地MomentDataSource的reload方法接到fetch请求上,加载Loading状态、错误重试、分页参数都包一层,就能把demo升级成接近可上线状态的应用。

做完这个项目,我最直观的感受是:与其去背各种装饰器的文档,不如亲手写一个有嵌套数据的列表页面。点赞、评论、发布这三件事看起来简单,但每一件都在逼你理解HarmonyOS状态管理的边界——什么地方用@State,什么地方用@Observed加@ObjectLink,什么地方干脆用普通变量就可以。把这些边界摸清楚,再去看复杂的业务页面,心里就有底了。如果你也在学HarmonyOS开发,建议你也找一个自己最熟悉的产品界面,照着拆一遍、重写一遍,比看十篇教程都管用。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询