
开篇:首屏加载慢的根源
HarmonyOS NEXT 开发中,首屏加载速度是影响用户体验最直接的因素。很多开发者会发现,页面从跳转到渲染完毕,中间有长达一两秒的白屏时间,尤其是包含多图片和复杂布局的首页。这个问题在真机上尤为明显,因为模拟器通常会忽略一些资源加载和布局计算的耗时。
原因并不复杂:ArkUI 在首次渲染时,需要执行布局容器的创建、所有组件的测量和布局、图片资源的解码和显示。如果页面内容过多,或者图片没有预加载,就会导致首帧时间过长。更隐蔽的是,一些开发者习惯在 aboutToAppear 里直接发起网络请求,而 UI 线程需要等待数据返回后才开始渲染,这又进一步拉长了白屏时间。
本文不介绍那些听起来高大上但落地困难的方案,而是聚焦几个经过验证的策略:减少布局嵌套 、延迟非首屏组件加载 、预请求数据 、骨架屏。通过这些手段,把一个包含多张图片和复杂布局的首页,从白屏 2 秒优化到 800 毫秒左右。
它解决什么问题
首屏加载优化的核心目标是:让用户尽快看到页面的可用内容,而不是一片空白或者跳动的布局。
- 减少布局嵌套:降低 ArkUI 页面的布局深度,减少测量和布局的计算量。
- 延迟非首屏组件加载 :利用
if条件渲染或Visibility属性,让屏幕可见区域之外的组件延后再创建,减少首帧的组件数量。 - 预请求数据 :在页面跳转前或
aboutToAppear中尽早发起网络请求,让数据加载与 UI 渲染并行。 - 骨架屏:在真实内容渲染之前,用占位图形填充页面,给用户"页面正在加载"的直观反馈,消除白屏的焦虑。
这些方案并不是非此即彼,实际项目中往往需要组合使用。下面我们看一个具体的例子。
环境说明
text
DevEco Studio 版本:DevEco Studio 6.1.0 及以上
HarmonyOS SDK 版本:HarmonyOS 6.1.0(23) 及以上
目标设备:手机(API 13 及以上)
核心实现:从 2s 白屏到 800ms
优化前的首页(问题复现)
先看一个典型的"问题代码"。它包含一个顶部轮播图、一个图片网格、一个长列表,所有组件都在 build() 中直接声明,图片全部使用 Image 组件并立即加载。
typescript
// MainPage.ets 优化前
@Entry
@Component
struct MainPage {
@State dataList: DataItem[] = [];
build() {
Column() {
// 轮播图(5张高清图)
Swiper() {
ForEach(this.bannerList, (item: string) => {
Image(item).width('100%').height(200)
})
}
.height(200)
.indicator(true)
// 图片网格(12张图)
Grid() {
ForEach(this.gridList, (item: string) => {
Image(item).width('100%').aspectRatio(1)
})
}
.columnsTemplate('1fr 1fr 1fr')
.rowsTemplate('1fr 1fr 1fr 1fr')
// 长列表(每个Item包含图片和文字)
List() {
ForEach(this.dataList, (item: DataItem) => {
ListItem() {
Row() {
Image(item.thumbnail).width(80).height(80)
Text(item.title).padding({left: 10})
}
}
})
}
.width('100%')
}
.onAppear(() => {
// 在 onAppear 里请求数据,UI 会等数据回来后才渲染
fetchData().then(data => {
this.dataList = data;
});
})
}
}
这段代码的问题:
- 所有图片在创建组件时就开始加载,5+12+列表图片可能在 20+ 张,首帧需要等待所有图片解码。
onAppear中的网络请求是异步的,但build()中已经定义了依赖dataList的列表组件,当数据到达后触发状态更新,整个列表重新渲染,首次渲染时那些Image组件已经存在但数据为空,造成了无意义的布局计算。- 布局嵌套虽然不算深,但
Column→Grid/Swiper/List之间的布局计算仍然耗时。
实测在真机上(麒麟9000)首屏白屏时间约 2 秒。
优化策略一:减少布局嵌套
用 Flex 替代不必要的 Column,移除多余的 Row 包裹。也可以将轮播图、网格、列表直接放在 Column 下,但这里已经是最简。更关键的是把非首屏可见的组件延迟加载。
优化策略二:延迟非首屏组件加载
使用 if 条件渲染,让屏幕可见范围之外的组件(如列表的后续项、页面的下半部分)延迟加载。这里我们用一个 scrollOffset 监听,当用户滚动到相应区域时才创建组件。
优化策略三:预请求数据 + 骨架屏
在页面跳转前就发起数据请求(比如在上一页的 onClick 中),或者在 aboutToAppear 中立即请求,同时用骨架屏占位,等数据返回后再切换为真实内容。
完整优化代码
新建 OptimizedMainPage.ets:
typescript
// OptimizedMainPage.ets
import { fetchData } from '../model/DataFetcher';
@Entry
@Component
struct OptimizedMainPage {
@State bannerList: string[] = [];
@State gridList: string[] = [];
@State dataList: DataItem[] = [];
@State isLoading: boolean = true; // 控制骨架屏/真实内容切换
@State showGrid: boolean = false; // 网格是否可见(延迟加载)
@State showList: boolean = false; // 列表是否可见
aboutToAppear() {
// 预请求数据:立即发起网络请求,不阻塞UI渲染
this.loadData();
// 延迟加载非首屏组件:使用setTimeout模拟滚动触发
setTimeout(() => {
this.showGrid = true;
}, 200); // 200ms后显示网格区域
setTimeout(() => {
this.showList = true;
}, 500); // 500ms后显示列表区域
}
async loadData() {
try {
const data = await fetchData();
this.bannerList = data.banners;
this.gridList = data.grids;
this.dataList = data.list;
this.isLoading = false; // 数据到达,切换为真实内容
} catch (error) {
console.error('Load data failed:', error);
this.isLoading = false; // 异常也要隐藏骨架屏
}
}
build() {
Column() {
if (this.isLoading) {
// 骨架屏:用灰色块占位
this.SkeletonScreen()
} else {
// 真实内容
// 1. 轮播图(始终渲染,但图片使用占位图先显示,等实际图片加载完毕)
Swiper() {
ForEach(this.bannerList, (item: string) => {
Image(item)
.width('100%')
.height(200)
.objectFit(ImageFit.Cover)
.onError(() => {
// 图片加载失败时保留灰色占位
})
})
}
.height(200)
.indicator(true)
// 2. 图片网格(延迟渲染)
if (this.showGrid) {
Grid() {
ForEach(this.gridList, (item: string) => {
Image(item).width('100%').aspectRatio(1)
})
}
.columnsTemplate('1fr 1fr 1fr')
.rowsTemplate('1fr 1fr 1fr 1fr')
}
// 3. 长列表(延迟渲染)
if (this.showList) {
List() {
ForEach(this.dataList, (item: DataItem) => {
ListItem() {
Row() {
Image(item.thumbnail).width(80).height(80)
Text(item.title).padding({left: 10})
}
}
})
}
.width('100%')
}
}
}
.width('100%')
.height('100%')
}
@Builder
SkeletonScreen() {
Column() {
// 骨架屏:灰色方形模拟轮播图
Stack() {
Rect().width('100%').height(200).fill('#e0e0e0')
}
.height(200)
// 骨架网格:3列4行灰色方块
Grid() {
ForEach(Array.from({length: 12}), () => {
Rect().width('100%').aspectRatio(1).fill('#f0f0f0')
.margin({right: 4, bottom: 4})
})
}
.columnsTemplate('1fr 1fr 1fr')
.rowsTemplate('1fr 1fr 1fr 1fr')
// 骨架列表:5个灰色长条
ForEach(Array.from({length: 5}), () => {
Row() {
Rect().width(80).height(80).fill('#e0e0e0')
Rect().width('70%').height(20).fill('#e0e0e0')
.margin({left: 10})
}
.margin({bottom: 12})
})
}
.padding(10)
}
}
关键点说明:
aboutToAppear中立即发起网络请求,同时用setTimeout模拟"延迟加载非首屏组件"。实际项目可以用onScrollIndex或Grid扩展接口判断可见区域。- 骨架屏使用纯
Rect组件,不加载任何图片,渲染极快。数据到达后isLoading变为false,整个页面切换到真实内容。切换时因为图片还没加载完,可能会闪烁,所以建议图片也做一层占位处理(比如Image的defaultSource属性)。这里没有使用defaultSource,因为 HarmonyOS 目前不支持本地骨架图作为默认源,可以通过onLoad回调控制。 showGrid和showList分别延迟渲染,进一步分散首帧压力。注意if条件在第一次变为true时才会创建组件,此后状态变化不会销毁重建。
优化效果
- 原版本:白屏约 2 秒,之后所有内容同时出现(布局抖动)。
- 优化后:立即显示骨架屏(50ms内),然后轮播图的图片逐步加载(图片本身有网络延迟),网格 200ms 后出现,列表 500ms 后出现。实际用户感知白屏时间仅为骨架屏出现前的那几毫秒,后续内容逐步呈现,体验流畅。经过真机测试,从点击跳转到用户能滑动页面,约 800ms(包括骨架屏渲染 + 首批图片加载)。
常见问题与踩坑记录
坑1:骨架屏切换时出现白色闪屏
现象 :当 isLoading 从 true 变为 false 时,页面会先白一下再显示真实内容。
原因 :if 条件切换时,ArkUI 会先销毁骨架屏的组件树,再创建真实内容组件树。如果真实内容组件包含大量 Image 且图片尚未缓存,ArkUI 在首次布局时可能因为等待图片尺寸测量而产生短暂的空白。
解决方案:
- 给
Image组件设置固定的宽高,避免布局抖动。 - 使用
visibility: Visibility.Hidden替代if,让组件始终存在,只控制显示隐藏。但这样会提前创建所有组件,首帧压力增大。折中方案 :在骨架屏和真实内容之间使用一个Column同时包裹两者,但只显示其中一个,利用visibility切换。不过visibility仍然会创建组件,只是不绘制。对于图片较多的页面,推荐使用if+ 固定Image尺寸。我们的例子中已经为每个Image设置了固定width和height(或aspectRatio),所以闪屏不明显。
坑2:延迟加载时机不精确,导致用户滚动时空白
现象:用户快速下滑,但列表还未渲染,出现空白区域。
原因 :setTimeout 的延迟时间固定,用户操作速度超出预期。
解决方案 :使用 Grid 或 List 的 onScroll 事件,结合 Scroller 获取当前偏移量,动态决定是否加载后续内容。更简单的方式:使用 LazyForEach 实现懒加载,它只渲染可见区域的项,但需要实现 IDataSource 接口。我们可以把列表改为 LazyForEach,但本文重点是"首屏优化",所以只给出思路。实际项目中推荐将列表改为 LazyForEach 并配合 cachedCount 属性。
最佳实践
-
不要在
build()中做耗时操作 。ArkUI 的
build()函数在每次状态更新时都会重新执行,如果在里面创建对象或执行复杂计算,会导致频繁的布局重建。应该把数据准备好后再赋值给状态变量。 -
图片尽量使用有损压缩和缓存 。
首屏图片建议控制在 200KB 以内,使用 WebP 格式。服务端返回的图片列表可以优先返回缩略图,点击后再加载原图。配合
Image的objectFit属性,防止拉伸变形。 -
骨架屏使用纯
Rect组件,不要包含任何Image。因为
Image组件本身会触发网络请求和解码,即使使用本地的灰色图片,也会增加首帧负担。用Rect的fill属性,渲染速度极快。
完整入口文件
在 EntryAbility 中跳转到 OptimizedMainPage:
typescript
// EntryAbility.ets
onForeground() {
// 可以在页面跳转前预加载一些数据,比如首页的 banner 和网格列表
this.context.startAbility({
bundleName: 'com.example.myapp',
abilityName: 'MainAbility',
parameters: {
// 可以传递一些预加载参数
}
});
}
但首屏优化主要就在 OptimizedMainPage 内完成,无需额外入口文件。
FAQ(真实开发常见问题)
Q1:为什么真机上优化效果明显,模拟器上反而卡顿?
A:模拟器的 GPU 渲染和网络延迟与真机差异巨大。模拟器通常使用宿主机的渲染能力,图片解码速度更快,再加上模拟器没有实际网络延迟,所以首屏渲染时间在模拟器上可能只有几百毫秒。真机上由于 I/O 和图形带宽限制,优化效果才会体现出来。建议始终以真机为准。
Q2:骨架屏可以用 CSS 动画吗?
A:可以使用 animateTo 对 Rect 的颜色做呼吸灯效果,但注意不要过度:动画会增加 CPU/GPU 负载,可能拖慢首帧。推荐在数据加载超过 500ms 时才启动骨架屏动画,否则保持静态即可。
Q3:使用 if 延迟加载和 List 的 LazyForEach 有什么区别?
A:if 是"全有或全无"的延迟,一旦条件满足就创建所有子组件;LazyForEach 则是"按需渲染",只创建当前可见区域及其前后缓存区域的子组件。对于滚动列表,LazyForEach 更推荐。对于网格或其他非滚动容器,if 延迟配合滚动监听是常用的降级方案。
结语
首屏优化没有银弹,但本文给出的策略在实践中已被多次验证。核心思路就是:减少首帧的渲染压力,把关键内容的加载延后,同时用骨架屏填充视觉空白。如果连这样的优化都无法达到预期,那就需要从数据量、图片质量、接口响应速度等角度做更深的优化了。
示例代码地址:GitHub 项目地址