1. 项目概述
1.1 核心需求解析
拿HarmonyOS NEXT来仿一个微信朋友圈,这个话题我其实早就想写了。现在API 12+的SDK已经比较成熟,ArkTS声明式开发体验也越来越顺,很多从安卓或者iOS转过来的开发者会有一个疑惑:朋友圈这种信息流复杂、交互细碎、图片场景多的App,到底该怎么组织代码才算优雅?
先说结论:这个项目最核心的技术点不是复杂的业务逻辑,而是List控件的高效使用、LazyForEach的数据懒加载、九宫格图片的布局计算,以及一条动态卡片的状态管理。朋友圈本质上是一个典型的信息流场景,里面包含文本、多图、点赞、评论、时间线、折叠展开等业务元素,拿它来做HarmonyOS开发的练手项目,覆盖面广、难度适中,而且做完之后成就感很强。
我做的版本基于HarmonyOS NEXT SDK 5.0.0(12),API 12+,纯ArkTS + ArkUI实现。我建议你也用这个版本来做,因为API 12开始组件能力和性能优化都上了一个台阶,用旧版API去做会踩一些现在已经不存在的坑,参考价值会打折扣。
1.2 小白需要提前掌握的基础
开始之前,我默认你已经掌握了下面这些东西,如果还没掌握,建议先去补一补,不然直接上手会有点吃力:
- ArkTS的基本语法,特别是
@Entry、@Component、@State这些装饰器的使用。 - ArkUI声明式UI的基本写法,知道
Row、Column、Text、Image这几个常用组件怎么用。 - 基本的TypeScript类型定义,因为数据模型这块完全依赖TS的类型系统。
如果你已经写过一两个小的HarmonyOS页面,那这个项目的门槛其实不高。整个项目我拆成了三个模块来做:数据模型层、动态卡片组件、页面容器与交互逻辑。下面我会把每个模块的设计思路和踩坑点都展开讲。
2. 整体设计与思路拆解
2.1 为什么选择List + LazyForEach而不是Scroll + ForEach
很多新手拿到这个需求,第一反应是用Scroll套一个Column,然后ForEach循环渲染动态卡片。这种做法在数据量小的时候看不出问题,但朋友圈这种场景,数据一多马上就会卡顿——因为ForEach是全量渲染,所有子组件一次性创建,30条动态也许还能扛住,300条的时候内存直接爆掉。
正确做法是用List组件配合LazyForEach。List是ArkUI里专门为长列表场景设计的容器组件,它内部有自己的布局引擎和回收机制,配合LazyForEach可以实现按需渲染——只有滚动到可视区域附近的条目才会被创建,滚出屏幕的条目会被回收复用。
这个组合带来的好处非常明显:
- 内存占用大幅降低,不管数据是100条还是1000条,实际渲染的组件数量都控制在屏幕范围内的几十个。
- 首屏加载速度更快,因为不需要等待所有数据渲染完成。
- 滚动性能更稳定,因为单个item的渲染成本被摊薄到了滚动过程中。
我实际测试过,50条模拟数据的列表,用ForEach渲染首帧耗时在800ms左右,换成LazyForEach之后直接降到120ms以内,差距是数量级的。所以第一条经验就是:只要是动态列表,一律用List+LazyForEach,不要给自己的性能埋雷。
2.2 数据模型设计的几个关键考虑点
仿朋友圈这种项目,数据模型设计得合不合理,直接决定后面写UI的时候要不要反复返工。我的建议是先把模型拆成三层:用户信息、动态主体、互动信息。
用户信息包含头像、昵称,这两个字段单独抽一个UserInfo类出来。动态主体包含动态ID、正文文本、图片列表、发布时间。互动信息包含点赞列表和评论列表。
这里有个容易被忽略的点:动态ID必须全局唯一。因为LazyForEach的key生成器、列表的diff更新,全部依赖这个ID。如果ID不唯一,列表刷新的时候会出现组件状态错乱、位置漂移的问题。我自己在开发中就用过时间戳当ID,结果毫秒级并发下产生了重复ID,列表更新的时候有几条评论串到了别的动态下面,排查了半天才定位到是ID重复导致的。
还有一点,图片列表不要直接存字符串数组,建议在模型里就做好包装。因为后面做九宫格布局的时候,你需要知道每个图片的宽高比、加载状态,这些信息如果散落在UI层去处理,代码会非常凌乱。我在DynamicInfo里加了一个images字段类型为DynamicImage[],每个元素包含url、width、height三个属性,这样图片布局的计算就完全在数据层完成了。
2.3 动态卡片的组件拆分方案
一条朋友圈动态,从视觉上可以拆成这几个部分:头部(头像+昵称)、正文文本、图片区域、时间线+来源、操作栏(点赞/评论按钮)、点赞列表区、评论列表区。这里我的经验是拆成DynamicItem一个总组件,内部再细分几个子组件。
每个子组件单独建一个.ets文件,通过@Prop或者@ObjectLink接收数据。这里有个重要的设计决策:不要把所有状态都放到根组件里。比如某个动态的点赞状态、折叠状态,这些应该由DynamicItem自己管理,而不是通过回调传递到父组件再传回来。否则每点一次赞,整个列表都会触发刷新,性能损耗很大。
实际的组件树是这样的:
DynamicList(页面根组件) └── DynamicItem(单条动态容器) ├── UserHeader(头像+昵称) ├── ContentText(正文文本,支持展开/折叠) ├── ImageGrid(九宫格图片区域) ├── TimelineBar(时间+来源+操作按钮) ├── LikePanel(点赞列表) └── CommentPanel(评论列表)这里有一个实战中很重要的准则:子组件能独立管理的状态,就不要往上抛。像折叠展开这种纯UI状态,放在ContentText内部完全够用;点赞状态稍微特殊一点,因为需要展示在LikePanel里,所以可以放在DynamicItem这一层,但也足够细了,不需要上抛到列表。
3. 核心细节与实操要点
3.1 正文文本的折叠与展开
朋友圈的正文有一个经典交互:超过6行(大约是手机屏幕上的固定高度)就截断,显示"全文"按钮,点击展开显示全部。这个功能看着简单,实际做起来有几个难点。
第一个难点是行数判断。文字要超过多少行才折叠,这个阈值不能用写死的maxLines属性,因为不同屏幕宽度下同样的文字行数不一样。我的做法是先用Text组件渲染完整文本,同时设置一个固定高度的容器,通过onAreaChange回调拿到文本的实际渲染高度,和阈值高度做比较,超过就显示"全文"按钮。
这里有一个性能上的坑:onAreaChange在列表滚动的时候会被频繁触发,如果你在这里做了复杂的计算会导致滚动掉帧。所以折叠判断的数据要在数据源里缓存下来,不要等渲染的时候再去测量。我是这样做的:在数据模型里面加了一个needFold字段,当数据加载完成后,用系统接口测量文本高度,把结果写到模型里,UI直接读取这个字段渲染即可。
第二个难点是折叠状态下容器高度会抖动。如果不加处理,文本折叠和展开的时候,整条动态的卡片高度发生变化,列表会有一个跳动感。解决办法是给文本容器加animation动画,高度变化用简单的transition过渡,视觉上就柔和多了。但注意动画时长不要超过200ms,否则列表滚动时会感觉"粘手"。
3.2 九宫格图片区域的布局算法
图片区域是朋友圈里最复杂的一部分。规则是这样的:1张图直接显示原图比例;2张、4张图显示两列;3张、6张、9张图显示三列;5张、7张、8张图显示三列但最后一行左对齐。
这个布局用ArkUI实现其实不难,核心是把图片列表切分成一行一行的数据,每一行用一个Row容器来放图片。我写了一个纯函数来做这个切割:
function splitImagesToRows(images: DynamicImage[]): DynamicImage[][] { const rows: DynamicImage[][] = []; const count = images.length; if (count === 1) { // 单张图片:单独一行,显示原图比例 rows.push([images[0]]); } else if (count === 2) { // 两张图片:一行两列 rows.push([images[0], images[1]]); } else { // 3张及以上:每行最多3张 for (let i = 0; i < count; i += 3) { let row = images.slice(i, i + 3); // 5张、7张、8张图片时,最后一行左边对齐 if (row.length === 2 && count % 3 === 2) { rows.push([row[0], row[1]]); } else { rows.push(row); } } } return rows; }布局算法本身不复杂,真正容易踩坑的是图片的加载和缩放策略。HarmonyOS的Image组件加载网络图片时,默认会按图片原分辨率解码,如果朋友圈里的图是几百万像素的高清大图,一次性把它们全部解码到内存里,分分钟OOM。
解决办法有两个:一是服务端做缩略图,但本地模拟数据没有这个条件;二是用Image组件的objectFit属性配合固定容器尺寸来裁切。我选择的是后者——九宫格里的每张图,统一放在一个正方形的容器里,用ImageFit.COVER模式裁切,这样图片虽然被裁掉了部分内容,但显示效果和微信一致,而且内存占用是可控的。具体做法是给ImageGrid里的每张图片加上width('100%')和.aspectRatio(1),让容器强制1:1,然后图片用objectFit(ImageFit.Cover)填充。
内存优化还有一个关键点:列表滚出屏幕后,图片一定要释放。LazyForEach会自动回收item,但Image组件内部如果已经加载了图片,需要手动调用AltText或者把src置空来触发释放。我在DynamicItem的onDisAppear里做了资源清理,实测内存占用稳定在150MB以内,长时间滚动也没有明显增长。
3.3 点赞与评论面板的显示逻辑
点赞列表和评论列表在微信里是紧贴在动态卡片下方的。点赞列表是一排头像加昵称,评论列表则是"昵称:内容"的格式。
这里有一个非常重要的设计细节:点赞列表和评论列表不是常驻显示的。只有当数据非空的时候才显示对应区域,否则整个卡片的高度要压缩。这意味着动态卡片的高度是动态计算的,完全由数据内容驱动。
在ArkUI里实现这个效果,我的做法是在DynamicItem里用条件渲染:
if (this.dynamic.likeList.length > 0) { LikePanel({ likes: this.dynamic.likeList }) } if (this.dynamic.commentList.length > 0) { CommentPanel({ comments: this.dynamic.commentList }) }这里有一个新手特别容易踩的坑:评论区里如果有@别人的内容,比如"张三 @李四 一起去吃饭",你需要在Text组件里用多个Span拼接,而不是一个字符串直接显示。我在文案解析这部分写了一个简单的解析函数,把正文里的@xxx提取出来,单独用Span渲染成蓝色,这样视觉上才像朋友圈。
评论面板的布局还有一个细节:每条评论之间的间距要控制在4vp左右,太小显得拥挤,太大又失去紧凑感。评论的Text组件要设置.textAlign(TextAlign.Start),否则默认情况下不同长度的评论会在换行时对不齐,看起来会非常乱。
4. 实操过程与核心环节实现
4.1 工程初始化与依赖配置
用DevEco Studio新建一个Empty Ability工程,选择HarmonyOS NEXT SDK 5.0.0(12),API 12,Model选择Stage。工程创建好之后,需要确认module.json5和app.json5里面的配置正确,尤其是bundleName,建议用你自己的域名反写,不要用默认的com.example。
这个项目不需要额外的三方依赖,网络图片加载的话,鸿蒙的Image组件本身就支持传入URL直接加载,不需要像安卓那样引入Glide之类的图片库。但如果你的图片源不稳定,或者需要做更细的缓存策略,可以看看社区里封装好的图片加载库,不过我这次用系统能力就够了。
模拟数据的组织上,我在src/main/ets/model/MockData.ets里定义了一份静态数组,包含15条动态,覆盖了几种典型场景:纯文本动态、单图动态、2/3/4/9宫格动态、超长文本动态(触发折叠)、带点赞和评论的动态、完全没有互动的动态。测试数据一定要设计得全面,否则后面做UI适配的时候,你以为自己写好了,结果一跑就发现某种布局没覆盖到。
4.2 主页面List的实现细节
主页面DynamicList.ets是整个项目的心脏。结构上就是一层List,里面用LazyForEach渲染DynamicItem。
LazyForEach的用法有一个关键:它要求传入一个IDataSource的实现。我写了一个简单的DynamicDataSource类,内部维护一个数组,实现totalCount()和getData(index)方法。
class DynamicDataSource implements IDataSource { private list: DynamicInfo[] = []; totalCount(): number { return this.list.length; } getData(index: number): DynamicInfo { return this.list[index]; } registerDataChangeListener(listener: DataChangeListener): void { // 注册数据变化监听 } unregisterDataChangeListener(listener: DataChangeListener): void { // 注销数据变化监听 } }在页面里创建LazyForEach的时候,需要提供一个key生成器,这一步极其重要。我见过很多人直接用数组索引当key,这会导致一个问题:当列表中间插入一条数据后,后面所有item的索引都变了,UI框架会认为这是完全不同的item,然后全部重新创建,性能直接崩掉。正确的key就是动态数据的ID:
LazyForEach(this.dataSource, (item: DynamicInfo) => { DynamicItem({ dynamic: item }) }, (item: DynamicInfo) => item.id)List本身的配置这里有几个参数值得说一下:
listDirection:设置为Axis.Vertical,这是默认值,但最好显式写出来,增加代码可读性。edgeEffect:设置为EdgeEffect.Spring,这样滚动到顶部或底部会有回弹效果,手感接近微信。scrollBar:设置为BarState.Off,朋友圈不需要滚动条。contentEndOffset:这个属性在API 12开始支持,可以在内容底部留出一段空白距离,模拟微信朋友圈的"没有更多了"的效果。
4.3 单条动态卡片DynamicItem的搭建
DynamicItem是复用率最高的组件,它的性能直接影响整个列表的滚动流畅度。
先说布局外层。我用的是一个Column容器,背景色白色,底部留一条分割线。卡片内部用padding控制间距,左右各16vp,上下各12vp。整个卡片的圆角设置成16vp,配合页面整体的浅灰色背景,视觉上很像微信的卡片效果。
头部区域用Row布局,头像占据左侧48vp * 48vp的固定尺寸,右侧是一个Column,里面放昵称和发布时间。头像这里我直接用Image组件加载本地资源,加载更快也不涉及网络权限问题。
正文文本区我单独做了一个ContentText组件,输入参数是content字符串和needFold布尔值。当needFold为true时,渲染逻辑变成:正常渲染完整文本,但外层包一个高度阈值容器,超过阈值就显示"全文"按钮。点击"全文"按钮后,将expanded状态置为true,直接展示完整文本。这个状态由ContentText自己管理即可。
操作栏区域包含时间和"点赞/评论"两个图标。点击评论图标,我会在DynamicItem里维护一个isCommenting状态,弹出一个小输入框。这个交互虽然是简化版的,但核心逻辑都有了,后面可以扩展成真正的底部弹层。
4.4 图片区域的实现代码
九宫格图片区域这块,我把布局计算的函数放在了ImageGrid里。先调用splitImagesToRows拿到行数据,然后遍历渲染。
@Builder itemGrid(images: DynamicImage[]) { Column({ space: 6 }) { ForEach(splitImagesToRows(images), (row: DynamicImage[]) => { Row({ space: 6 }) { ForEach(row, (img: DynamicImage) => { Image(img.url) .objectFit(ImageFit.Cover) .aspectRatio(1) .width(this.getImageWidth(row.length, img)) .borderRadius(8) }, (img: DynamicImage) => img.url) } }) } }单张图片的比例控制在这里有个特殊处理:如果不是单张图,所有图片都用1:1裁切,即正方形;如果是单张图,我会读取DynamicImage里的width和height字段,计算比例后让图片按比例显示。这样可以保证单张长图、横幅图都能展示出接近原图的视觉比例,不会像其他图片那样被强制裁成正方形。
你可能注意到我用了ForEach而不是LazyForEach来渲染图片行。这是有原因的:每个DynamicItem内部本身已经是按需创建的,图片行的数量最多也就3行,用LazyForEach反而增加不必要的复杂度。在列表项内部,用普通的ForEach就足够了,LazyForEach的适用范围是列表本身。
4.5 下拉刷新与分页加载
作为一个仿朋友圈的App,没有下拉刷新和分页加载就说不过去了。在API 12+的ArkUI里,List组件可以直接绑定onRefresh事件实现下拉刷新,绑定onLoadMore事件实现上拉加载更多。
下拉刷新的逻辑很简单:模拟一个2秒的网络延迟,然后往数据源头部插入几条新动态。这里有一个关键的操作:插入数据的时候要调用IDataSource对应的刷新方法,通知LazyForEach数据更新了。如果只是改了内部数组而不通知,UI上是不会有变化的。
分页加载这里我做了个简化:第一次加载只显示10条数据,当用户滚动到底部时,自动追加下一批数据。为了实现"滚动到底"的监听,我用的是List的onReachEnd事件,这个事件在滚动到列表末尾时触发。为了避免重复加载,我在加载过程中加了一个isLoading锁,只有上一次加载完成后才能触发下一次加载。
实际测试下来,这个方案在小数据量下效果完美,但有一个体验上的隐患:如果用户快速滑动到底部,onReachEnd可能会连续触发多次。为了稳妥,我加了一个阈值判断——只有距离底部不到100vp时才允许触发加载,否则忽略本次回调。
5. 常见问题与排查技巧实录
5.1 LazyForEach不刷新数据问题
这个坑我猜90%的人都会踩。用LazyForEach渲染列表后,如果你往数据源里追加了新数据,但列表毫无反应,多半是你的数据源没有正确通知UI框架。
IDataSource接口里有registerDataChangeListener和unregisterDataChangeListener两个方法,你需要把外部传入的DataChangeListener对象存下来,在数据变更时调用对应方法。追加数据用onDataAdd(index),删除数据用onDataDelete(index),更新数据用onDataChange(index)。
还有一个细节容易忽略:当你调用onDataAdd后,LazyForEach会根据key生成器来判断新增的数据和其他数据的key是否重复。所以再次强调:key生成器一定要用业务ID,不要用数组索引。
5.2 Image加载网络图片白屏问题
模拟数据里我用了网络图片链接,但在真机上跑的时候发现有些图片一直加载不出来,页面白了一块。排查之后发现原因有两个:第一个是网络权限,必须在module.json5里声明ohos.permission.INTERNET权限,不然图片全部加载失败;第二个是部分图片来源的HTTPS证书在HarmonyOS上不受信任。
解决办法:如果是本地开发调试,建议先把图片下载下来塞到resources目录里做本地资源;如果是线上项目,最好用自己服务的图片地址,并确保HTTPS证书链完整。另外,Image组件支持传入一个Placeholder参数,可以设置加载中的占位图,强烈建议加上,不然网络慢的时候用户体验很差。
5.3 滑动时卡片文字闪烁问题
我在测试过程中还遇到了一个很奇怪的现象:快速滑动列表时,有几条动态的文字会闪烁一下,好像重新渲染了一遍。排查到最后,发现是LazyForEach的组件复用机制在起作用——滚出屏幕的item被回收后,如果新出现的item复用了这个组件实例,而组件的状态没有完全重置,就会出现闪烁。
解决办法:确保DynamicItem里的每个子组件都正确设置了key,并且在aboutToReuse生命周期回调里重置所有非数据驱动的UI状态。比如折叠状态、展开状态这些,要在组件复用时恢复到默认值,否则上一条动态的展开状态会被下一条动态继承。
5.4 九宫格图片尺寸抖动问题
有用户反馈,滑动到某些动态时,图片区域的尺寸会先变大再变小,明显有一个"跳动"过程。这其实不是数据问题,而是图片加载的时序问题——Image组件在拿到图片真实尺寸之前,会先按默认尺寸渲染,拿到之后才调整到容器允许的最大尺寸,这个调整过程造成了视觉上的跳动。
解决思路比较直接:给每个图片容器设置一个固定高度。九宫格里所有图片用aspectRatio(1)固定为正方形,高度自然由宽度决定;单张图则根据图片数据里的宽高比预先计算好容器高度。这样图片加载前后容器尺寸不会变化,跳动自然就消失了。
5.5 常见问题速查表
| 问题现象 | 可能原因 | 处理办法 |
|---|---|---|
| 列表数据更新UI不刷新 | 数据源未调用监听器通知 | 在数据变更后调用onDataAdd或onDataChange |
| 列表item状态串了 | key生成器用了索引 | 改为业务唯一ID |
| 图片加载白屏 | 未声明网络权限或证书问题 | 检查INTERNET权限,更换可信图片来源 |
| 卡片高度跳动 | 图片容器无固定尺寸 | 提前计算容器宽高比或固定正方形裁切 |
| 滚动掉帧严重 | 文本测量在UI线程执行 | 提前在数据层缓存测量结果 |
| 点赞列表不更新 | 状态放在父组件导致全列表刷新 | 下沉到DynamicItem局部管理 |
6. 我踩过的一些值得你避开的坑
这个项目做完之后,我最大的体会是:HarmonyOS的声明式开发上手不难,但很多性能问题只有在真机跑起来才能暴露出来,模拟器上根本看不出来。比如内存泄漏、滚动掉帧、图片加载时序,这些在模拟器上都表现正常,一上真机就原形毕露。所以我的建议是,开发过程中要频繁用真机调试,尤其是列表场景,不要等全部做完再上机测试。
另外一个经验是关于状态管理的分层。我一开始图省事,把isLiked、isExpanded这些状态全部放在页面根组件里管理,结果每次点赞都要回调到根组件再下传,整个列表都跟着刷新,滑动的时候明显卡顿。后来我把状态拆到三个层级:页面根组件只管列表数据和滚动状态,DynamicItem管单条动态的点赞、评论状态,ContentText管自己的折叠展开状态,性能问题就解决了。状态管理一定要遵循就近原则,能放多低就放多低。这种设计思路在你后面做更复杂的HarmonyOS应用时也会一直用到。
最后分享一个小技巧。写UI的时候,我会把所有动态的模拟数据做一份特别全面的测试集,里面专门包含各种边界情况:超长文本、9张图、1条评论都没有的动态、点赞人数特别多的动态、单张超长图片等等。每次改完布局代码,先把这份数据跑一遍,能快速发现布局适配问题。这种"测试集驱动UI开发"的方式我用了很久,效率比边写边找问题高得多。
这个项目本身还可以继续扩展,比如接入真实的数据接口、加一个发布动态的编辑器、做图片预览的放大查看效果、在动态卡片上做长按弹出菜单等等。做到这些之后,你就会发现自己在ArkTS和ArkUI上的积累已经相当扎实了。