ImageKnifePro 源码解读(十一):缩略图三阶段加载与动图逐帧解码
一张网络大图从发起请求到最终显示,用户看到的可能是三张不同的图:先是一个极小的占位图,接着是中等分辨率的缩略图,最后才是完整的主图。ImageKnifePro 把这三个阶段拆成了三条独立的加载管线,每条管线各自走拦截器链、各自查缓存,互不阻塞。动图场景下还有另一个维度的复杂度——GIF/WebP 的帧数乘以分辨率可以轻松撑爆内存,逐帧解码就是为了解决这个问题。 这里有一个关键的短路逻辑: 同步加载( 缩略图的加载通过 缩略图和主图使用不同的 三种图片类型的显示互斥规则:主图 > 缩略图 > 占位图。 占位图(placeSrc)的处理和缩略图类似,但有几个不同点。占位图不调用 占位图的显示条件最严格:只有主图和缩略图都没有完成时才显示。一旦缩略图或主图到达,占位图立即被替换,不需要额外的清理逻辑。 一张 360x640、60 帧的 GIF,批量解码后所有帧的 PixelMap 总内存约 360 * 640 * 4 * 60 = 52 MB。如果分辨率更高、帧数更多,内存占用会线性增长。逐帧解码模式( 解码模式的选择在 逐帧解码的数据准备发生在 解码通过 这个并行设计通过 Running 状态下, Paused 状态下,取消延时任务,动图停在当前帧。从 Paused 恢复到 Running 时,重新计算首尾帧并从当前帧继续播放。 Initial 状态显示首帧。如果之前是 Running 状态切换而来,触发 组件可见性与播放状态联动。 逐帧解码中一个容易忽略的性能细节是 这个优化对帧数多的动图效果尤其明显。一张 100 帧的 GIF 如果每帧都创建 ImageSource,仅头信息解析的累积耗时就可能导致播放卡顿。复用后只有首帧承担创建开销,后续帧的解码路径更短。 批量解码路径在 逐帧解码的优势是内存占用固定——无论动图有多少帧,同一时刻只有一个帧的 PixelMap 在内存中。代价是每帧都要经历一次解码过程。为了减少解码开销, 以上就是本篇内容的所有了~有什么问题欢迎在评论区提出 项目地址:ImageKnifePro一、Enqueue——三阶段加载的调度入口
ImageKnifeDispatcher::Enqueue 是所有加载请求的统一入口。它按照 mainSrc -> thumbnailSrc -> placeSrc 的顺序依次处理三种图片源。void ImageKnifeDispatcher::Enqueue(std::shared_ptr<ImageKnifeRequestInternal> request)
{
if (!RequestChooseLoader(request)) return;
if (!IsMainSrcNeedMeasure(request)) {
if (EnqueueMainSrc(request)) return;
// 主图没有内存缓存,上树组件
auto node = GetImageKnifeNode(request);
if (node != nullptr) node->UploadNodeHandle();
}
if (request->IsSyncLoad()) return;
if (EnqueueThumbnail(request)) return;
// 加载占位图
auto placeJob = GenerateJob(request, ImageRequestType::PLACE_SRC);
if (placeJob.imageSrc != nullptr && !LoadSrcFromMemory(placeJob)) {
ExecuteJob(placeJob);
}
}EnqueueMainSrc 如果返回 true,说明主图已经从内存缓存中找到并显示了,后面的缩略图和占位图就不需要加载了。EnqueueThumbnail 返回 true 也是同理——缩略图有内存缓存就不加载占位图。这个优先级保证了用户看到的始终是当前可用的最高质量图片。IsSyncLoad)不会加载占位图和缩略图,因为同步加载会阻塞主线程,占位图的视觉意义不大。二、EnqueueThumbnail——缩略图的独立加载管线
EnqueueThumbnail 发起。它和主图走完全一样的 GenerateJob 流程:生成 memoryKey(包含图片源、变换、降采样等信息)、先查内存缓存、没有缓存再决定排队还是立即执行。bool ImageKnifeDispatcher::EnqueueThumbnail(
std::shared_ptr<ImageKnifeRequestInternal> &request)
{
auto thumbnailJob = GenerateJob(request, ImageRequestType::THUMBNAIL_SRC);
if (thumbnailJob.imageSrc == nullptr) return false;
if (LoadSrcFromMemory(thumbnailJob)) return true;
if (loadingJobMap_.Size() >= maxRequests && !IsLocalSource(thumbnailJob.imageSrc)) {
jobQueuePtr_->Add(thumbnailJob);
} else {
ExecuteJob(thumbnailJob);
}
return false;
}ImageRequestType,这影响了几个行为。在 GenerateJob 中,缩略图不参与降采样信息的 key 拼接(因为缩略图本身就是小图)。在 DisplayImage 中,如果主图已经加载完成,缩略图就不再显示。在 ProcessSucceedRequest 中,缩略图成功后只标记 MarkThumbnailSrcReady,不标记整个请求完成。DisplayImage 方法在送显前检查更高优先级的图片是否已完成。三、占位图的特殊处理
EnqueueThumbnail 那样的独立方法,而是直接在 Enqueue 末尾内联处理。占位图没有自己的 Mark 状态——它的存在只是为了在主图和缩略图都没准备好时给用户一个视觉反馈。四、FRAME_BY_FRAME——动图的逐帧解码模式
FRAME_MODE)在播放时才解码当前需要显示的那一帧,内存中始终只保持一帧的 PixelMap。AnimationDecodeMode 枚举中定义。AUTO 模式下,图片的宽x高x帧数小于 720 * 720 * 32 时走批量解码,否则走逐帧解码。用户也可以强制指定 BATCH_MODE 或 FRAME_MODE。PrepareDecodeTask 阶段。下载完成后,原始的图片二进制数据不走常规的批量解码路径,而是包装成 ImageDataCacheInternal 存储原始文件 buffer。播放时每一帧从这个 buffer 创建 ImageSource 并解码指定帧。五、DecodeFrame——逐帧解码的执行
ImageKnifeNodeImage::DecodeFrame 是逐帧解码的核心方法。每次播放到新的一帧时调用:void ImageKnifeNodeImage::DecodeFrame(bool immediately)
{
// 创建请求和task
decodeTask_ = std::make_shared<ImageKnifeTaskInternal>(
fileBuffer, animationData_->GetImageCacheSize(),
currentFrame_, animationData_->requestType, request);
decodeTask_->SetFileTypeInfo(animationData_->GetFileTypeInfo());
decodeTask_->SetDesiredSize(animationData_->GetPixelmapSize());
// 子线程执行解码
auto execute = [/*...*/](std::shared_ptr<ImageKnifeTaskInternal> task) {
task->SetDecodeImageSource(imageSource);
ImageKnifeDispatcher::GetInstance().ProcessFrameModeDecoding(task);
};
// 解码完成回调(回主线程)
auto complete = [/*...*/](std::shared_ptr<ImageKnifeTaskInternal> task) {
if (immediately || task->IsDecodeFrameFinished()) {
self->DisplayNextFrame(versionId);
} else {
task->MarkDecodeFrameFinished();
}
};
TaskWorker::GetInstance()->PushTask(execute, complete, decodeTask_);
}ProcessFrameModeDecoding 走标准的解码拦截器链,但设置了帧索引(OH_DecodingOptions_SetIndex),只解码指定的那一帧。immediately 参数区分两种调用场景。首次播放或恢复播放时 immediately = true,解码完成后立即显示,不等待延时。正常帧间切换时 immediately = false,解码和帧延时并行进行——两者都完成才显示下一帧。IsDecodeFrameFinished 标志位协调。延时任务到达时检查解码是否完成:如果已完成就显示;否则标记延时已到达,等解码完成回调去触发显示。解码完成回调同样检查延时是否已到达。谁后到达谁触发显示,避免重复触发。六、AnimatorOption——动图播放状态机
ImageKnifeNodeImage 内部维护了一个四状态的播放状态机:Running、Paused、Initial、Stopped。DisplayNextFrame 循环执行:显示当前帧 -> 计算下一帧索引 -> 投递延时任务。帧索引到达尾帧后回到首帧,currentIteration_ 递增。如果设置了播放次数限制且已达到,状态切换为 Stopped。onCancel 回调。Stopped 状态显示尾帧,触发 onFinish 回调。OnNodeHide 时自动 Pause;OnNodeShow 时如果之前不是 Stopped 状态,恢复 Running。已经 Stopped 的动图在组件恢复可见时不会重新播放。reverse 属性控制播放方向。reverse = true 时首帧是最后一帧,尾帧是第一帧,帧索引递减。播放方向和播放状态独立控制,可以在运行时通过 UpdateAnimatorOption 动态切换。imageVersionId_ 用于防止过期的延时任务干扰新图片的播放。每次加载新图片或恢复播放时 imageVersionId_ 递增,DisplayNextFrame 开头检查版本号是否匹配,不匹配直接返回。这比取消延时任务更可靠——因为延时任务可能已经在执行队列中无法取消。七、ImageSource 复用——逐帧解码的性能优化
ImageSource 对象的复用。每次调用 OH_ImageSourceNative_CreateFromData 创建 ImageSource 都有初始化开销——解析图片头信息、建立帧索引等。如果每帧都重新创建 ImageSource,这部分开销会随帧数线性累积。ImageKnifeNodeImage 通过 decodeImageSource_ 成员变量在帧间缓存 ImageSource 对象。首帧解码时(immediately = true)创建新的 ImageSource 并缓存;后续帧直接使用缓存的 ImageSource 解码,只改变帧索引。当图片更新(imageVersionId_ 变化)时,旧的 ImageSource 随 CancelPreviousAnimation 一起释放。if (immediately) {
imageSource = std::make_shared<DecodeImageSourceDefault>(
task->product.imageBuffer.get(), task->product.imageLength);
} else {
imageSource = decodeImageSource_; // 复用缓存的 ImageSource
}八、批量解码 vs 逐帧解码
DecodeInterceptorDefault::Decode 中走 DecodePixelmapList。它一次性调用 OH_ImageSourceNative_CreatePixelmapList 解码所有帧,生成一个 PixelMap 数组和一个帧延时数组。播放时直接按索引取帧显示,没有解码开销。ImageKnifeNodeImage 缓存了 decodeImageSource_ 对象在帧间复用——每次创建 ImageSource 的开销远大于复用已有的。AUTO 模式下的阈值 720 * 720 * 32 约等于 63 MB(RGBA 格式)。低于这个值时批量解码的内存开销可控,高于这个值时切换到逐帧解码保护内存。这个阈值是一个经验值,在大多数中端鸿蒙设备上能平衡流畅性和内存安全。