
做移动端开发的这几年图片加载一直是个绕不开的坎。尤其是这次把 RcImage 组件的占位系统和加载状态处理放到 HarmonyOS 6 上来做前前后后花了半年时间打磨。这篇文章是这个系列的第二篇主体是占位系统的设计思路、状态机的流转规则以及实际落地时踩过的一堆坑。如果你正在做鸿蒙应用里的图片加载或者被占位图闪烁、加载状态管理混乱、弱网体验差这些问题困扰这篇内容值得仔细看一遍。1. 为什么花半年时间打磨一个图片占位系统先说一个真实的业务背景。我们做一个内容型应用信息流里面有大量图片头图、头像、配图、广告图来源不同、尺寸不同、清晰度也不同。最开始直接用基础 Image 组件网络慢的时候图片位置一片空白用户以为页面卡死了加载失败就显示一个裂图图标体验非常差。后来产品侧提了一个硬性要求任何情况下图片位置不能出现空白加载过程必须有明确的状态反馈弱网环境要有合理的降级方案。1.1 核心痛点空白、裂图与用户流失我在实际开发中总结了三个最典型的痛点。第一个是空白。没有占位图或者占位图处理不到位图片加载期间就是一块空白区域。在信息流场景里这种空白很容易被用户理解成“内容没加载出来”退出率明显上升。第二个是裂图。加载失败后显示一个破碎图标的默认行为既不美观也不友好尤其是电商类的商品图一个裂图丢掉的可能是整单转化。第三个是状态失控。加载中、成功、失败这三种状态散落在业务代码里没有一个统一的管理方式导致图片快速滚动或者网络抖动时界面状态混乱。这三个痛点其实指向同一个本质问题占位和加载是两件事但大部分方案把它们混在一起处理了。占位图应该是一套独立的视觉策略加载状态才是一套独立的状态流转机制两者可以协作但不能互相绑定。1.2 方案选型对比与解耦决策当时看了一批开源方案和社区做法。有些库的占位图是固定的一套本地资源所有场景都用同一张图省事但不灵活头像和商品图的占位需求完全不一样。有些方案把占位图绑在图片 URL 上不同 URL 维护不同的占位图配置业务一多配置表变得非常难维护。还有一些方案强行上骨架屏做出来的页面和原生布局差异大视觉割裂感很强。我最终选择占位与状态分离的路线占位图的管理独立于图片加载流程加载状态用一个状态机统一管理两者通过一个控制器对接。这样占位策略可以随时更换加载状态也能在一个地方统一处理后续新增占位场景只需要加一种类型不需要动加载流程。这个决策源于之前在某跨平台系统里踩过坑那时候把占位逻辑和加载逻辑揉在一个类里后来加了三种新场景代码改得面目全非。所以这次从一开始就把这两个维度拆开。2. 占位系统的整体设计占位系统说白了就是一组规则决定图片还没显示出来之前那个位置应该展示什么样的替代内容。它不只是放一张灰色底图那么简单需要考虑场景、色系、容器尺寸、图片重要程度。2.1 占位分级从默认兜底到数据驱动我把占位图分成三个层级。第一级是全局默认占位适用于大多数普通内容图片。通常是一张浅灰色的几何图形作用是告诉用户这个位置有内容、正在加载视觉上不需要太花哨。这个兜底方案必须存在防止业务方没有传入任何占位配置时出现空白。第二级是场景定制占位。比如头像区域用圆形的人物剪影商品图区域用商品轮廓图形广告位可以用品牌色打底。这一层由业务方在创建组件时传入通过枚举类型区分。第三级是数据驱动的个性化占位。根据图片的宽高比、主题色动态生成一张带渐变色的画布上面放一个与内容相关的文字标签。这个方案用在了长列表的图集场景但有个重要教训每次请求都重新生成占位图会频繁闪动必须配合缓存策略让同一个容器在复用期间保持一致的占位视觉。分级设计的好处在于把“展示什么样的占位图”和“什么时候展示占位图”彻底分开。业务方只关心前者后者完全由状态机来控制。2.2 占位与加载状态解耦的实现基础解耦的核心是引入一个状态控制器。组件收到图片加载的进度和结果以后把状态通知给控制器控制器再去决定占位图怎么显示。占位图本身不知道加载流程加载流程也不感知占位图的具体实现。我在代码层面定义了一个RcImageController负责接收外部传入的状态枚举管理占位组件的显示与隐藏。占位组件是一个独立的自定义组件内部根据当前状态渲染不同的内容。这里有一个实现细节必须注意占位组件和图片组件不能互相遮挡。我给图片组件设置了固定的 zIndex占位组件放在下面一层图片加载完成后自然覆盖上去。这样做的好处是可以避免占位图消失时出现明显的闪烁。3. 加载状态机的设计与流转图片加载从开始到结束状态其实只有有限几个未加载、加载中、加载成功、加载失败、占位中、重试中。关键不是定义状态本身而是定义状态之间的流转规则以及每个状态下组件应该做什么。3.1 状态定义与流转规则我定义了一张状态表实际代码里的枚举就是照着这张表设计的。状态触发条件占位表现Idle组件初始化完成显示默认占位图Loading网络请求发出显示加载中占位图可含进度动画Success图片解码完成隐藏占位图显示真实图片Error网络失败或解码失败显示失败占位图提供重试入口Empty数据源为空显示空数据占位图状态流转是单向的Idle - Loading - Success或者 Idle - Loading - Error。从 Error 可以回到 Loading也就是重试。但这个回退操作必须由用户主动触发不能由组件自动无限重试。为什么要强调单向流转因为我早期在这里踩过坑。弱网环境下自动重试会造成请求风暴一个页面上几十张图片同时重试服务端直接被冲垮。所以现在的重试要么是用户点击触发要么是严格受控的重试次数上限。3.2 重试机制与降级策略从请求风暴到优雅降级重试机制我设置了两个参数maxRetryCount和retryDelayMs。超过重试上限后状态进入 Error不再自动发请求。降级策略分成三个层面图片降级URL 加载失败后尝试用缓存中的缩略图替换至少保证用户能看到一张图片。占位降级如果场景定制占位图拉取失败回退到全局默认占位图不让图片位置空着。功能降级弱网环境下关闭预加载减少同时进行的图片请求数保证首屏图片优先加载。这套降级策略是在一次线上问题之后补上的。当时用户反馈弱网环境下信息流加载特别慢排查发现是预加载机制和占位系统互相抢资源。后来加了全局的并发控制预加载只能在空闲时执行才把问题缓解下来。4. 关键实现细节与代码分析这一部分是系列的重点我尽量给出可以直接落地的代码片段和设计思路。环境是 HarmonyOS 6语言是 ArkTS。4.1 占位 View 的懒加载与复用占位 View 不能每次状态变化都重建那样性能太差。我给占位 View 做了工厂方法配合 LazyForEach 做复用。Builder buildPlaceholder(type: PlaceholderType) { if (this.controller.isPlaceholderVisible()) { switch (type) { case PlaceholderType.Default: this.buildDefaultPlaceholder(); break; case PlaceholderType.Custom: this.buildCustomPlaceholder(); break; case PlaceholderType.Gradient: this.buildGradientPlaceholder(); break; default: this.buildDefaultPlaceholder(); break; } } }这里有一个易踩的坑ArkUI 的 Builder 方法里面必须做条件判断否则占位组件在状态切换时不会更新。我一开始直接把状态变量传进占位组件结果状态变了但界面没变排查了很久才发现是组件没有触发重绘机制。复用方面我给每个占位组件实例设置了一个 key由容器 ID 加占位类型组成。列表滚动时 LazyForEach 会复用组件实例这里必须通过 key 判断是否需要替换占位图否则会出现旧占位图闪一下再变成新占位图的问题。4.2 加载状态的线程切换与生命周期保护图片解码是耗时操作不能放在 UI 线程。RcImage 组件内部使用了一个专门的解码线程池状态回调统一通过 UI 线程发布。private onImageLoadComplete(result: ImageResult) { this.uiRunner.post(() { this.controller.updateState(LoadState.Success, result); }); }这里有两件容易出问题的细节。第一文件描述符溢出。图片解码失败时如果回调异常没有妥善关闭输入流会导致 fd 泄漏。排查的时候发现一个页面几十张图加载完fd 数量持续增长最后统一在 finally 块里关闭流并加了引用计数保护才解决。第二线程切换的时序问题。如果组件在图片加载完成前被销毁回调还是会触发这时候访问 UI 状态就会崩溃。解决方法是增加一个isReleased标志位回调里先判断状态必要时用弱引用持有组件上下文。4.3 内存占用、缓存与采样压缩策略图片内存占用是线上不稳定因素第一名。RcImage 组件内部对图片做了采样压缩根据目标容器尺寸计算缩采样比例避免把原图完整加载到内存里。private calcSampleSize(containerWidth: number, containerHeight: number, sourceWidth: number, sourceHeight: number): number { let sample: number 1; while (sourceWidth / sample containerWidth * 2 sourceHeight / sample containerHeight * 2) { sample * 2; } return sample; }这里的倍率选择 2 而不是 1是因为很多图片有圆角、描边等视觉效果裁剪后还需要一定余量避免清晰度不够。缓存策略上内存缓存使用 LRU 算法上限设为应用可用内存的八分之一。磁盘缓存做了二级目录一级存原图缓存二级存压缩图缓存。加载顺序是内存缓存优先、磁盘压缩图其次、最后才走网络。实测下来信息流场景的重复图片命中率能达到七成以上。4.4 业务方的接入方式接入方式直接决定这套系统能不能真正落地。我提供的是一个链式接口业务方创建 RcImage 组件时可以指定占位类型、状态回调、降级策略。RcImage({ source: item.imageUrl, placeholderType: PlaceholderType.Gradient, retryConfig: { maxRetryCount: 2, retryDelayMs: 1500 } }) .onLoadStateChange((state: LoadState) { // 业务方可以根据状态做额外处理 })这样设计的好处是业务方不需要理解状态机的内部实现只需要按需配置参数。同时也避免业务方在页面里散落大量状态判断代码所有逻辑收敛在组件内部。5. 实测问题排查实录半年时间踩了不少坑我挑几个典型的记下来给后来人参考。5.1 快速滚动时的图片闪烁现象是列表快速滚动时已经加载成功的图片会闪一下变成占位图然后又被图片覆盖。排查下来问题出在 LazyForEach 的组件复用机制上。复用的组件保留了旧的加载状态新图片源设置后状态来不及切换到 Loading旧状态残留导致占位图显示。解决办法是加载新图片前主动把状态重置为 Idle再走完整流程。同时在获取到新图片 URL 时先清空组件内部的解码缓存避免多个容器共用同一个解码结果导致状态混乱。5.2 头像占位图串显问题有一次是头像列表里部分用户的头像占位图变成了别人的占位图。从用户反馈来看这个问题的偶发性很强不是每个用户都触发。排查过程花了半天时间因为问题不好复现。最后加了一行日志把当前占位图的 key 和用户 ID 打印出来才发现是占位组件使用了一个全局单例资源不同用户头像的占位图被同时复用导致串显。修复方式是把占位图资源和用户 ID 绑定每个用户一个隔离的资源容器。这里想强调一下日志是排查偶现问题最好的武器不要凭感觉改代码。5.3 低内存设备的 OOM 崩溃在低内存设备上大量图片解码经常导致 OOM。试了很多参数组合最后确定了一套可行方案限制图片解码并发数、使用采样压缩、设置内存缓存上限、监听系统内存压力回调清理缓存。onMemoryLevel(level: MemoryLevel) { if (level MemoryLevel.LOW) { this.imageCache.clear(); } }在 HarmonyOS 6 上这个回调在系统内存不足时触发拿到之后立即清空内存缓存可以有效降低崩溃率。我们接入后低内存设备的崩溃率下降了六成以上。5.4 弱网环境的加载超时与卡顿弱网环境下最常见的问题是加载超时和页面卡顿。RcImage 组件内部对网络请求设置了超时时间超时后直接进入 Error 状态避免请求挂死。超时时间我设置了三档首屏图片 10 秒列表图片 15 秒预加载图片 20 秒。区别对待的原因是首屏图片用户感知最强超时应该尽快返回失败状态让用户看到占位图或重试入口而预加载是后台行为不需要太激进。卡顿问题多来自图片请求并发过高。我给全局加了一个请求队列最大并发限制为 4超过上限的请求排队等待。这个数字是压测出来的太小影响首屏速度太大容易拖垮网络通道。6. 半年实践下来的几点体会回头梳理这套 RcImage 组件的占位系统我最大的感受是难点不在具体某个功能怎么写而在所有设计都要围绕几个原则来执行。占位和加载要解耦这一条决定了后续所有扩展的灵活性。状态流转必须单向可控加上重试上限和降级策略才能保证弱网环境下系统不崩。内存和缓存必须有上限管理没有上限的缓存策略最后都会变成 OOM 炸弹。所有耗时操作都不能上 UI 线程线程切换时的生命周期判定也必须严格处理。在实际操作中我发现最容易翻车的不是核心逻辑而是边缘场景列表复用、组件销毁后的回调、弱网超时、低内存清理。这些场景单独看起来都不难但一旦叠加在一起问题就变得非常复杂。半年时间里我大概有一多半的时间都花在这类边缘情况的处理上。这套系统后续还可以往两个方向扩展一个是用系统级的能力去做更智能的预加载调度另一个是让占位图和骨架屏动效做深度融合。等这两个方向有实际进展了我再写这个系列的第三篇。