长列表的图片加载有两个经典症状:首屏刷出来的那一刻所有图片位置都是白块,要等一段时间才逐个填上内容;快速滚动时新进入可视区的 item 也是先白再亮。这两个问题的根源相同——图片从"组件创建"到"画面上屏"之间存在一条完整的延迟链路,而组件创建本身就是这条链路的第一环。

ImageKnifePro 提供了 preCreateImageKnifeComponentconnectContentSlot 两个接口来打破这条链路,让图片加载提前到组件上屏之前就开始执行。

预创建与普通加载的时序对比

一、普通加载的延迟链路

ImageKnifeComponent 组件的 aboutToAppear 生命周期里调用 createNativeRoot,这个调用在 C++ 侧创建 ImageKnifeNodeImage 节点、解析 ImageKnifeOption、把节点挂载到 ContentSlot、发起图片加载请求。这些操作是同步的,执行完后请求进入 Dispatcher 队列,排队等待子线程执行内存缓存查询、文件缓存查询、网络下载、解码等步骤。

// ImageKnifeComponent.ets
aboutToAppear(): void {
    this.componentId = this.getUniqueId().toString();
    nativeNode.createNativeRoot(this.rootSlot, this.componentId,
        this.imageKnifeOption, this.animatorOption, this.getDefaultContext(),
        {syncLoad: this.syncLoad, draggable: this.imageDraggable,
         contentTransition: this.contentTransition});
}

在长列表场景下,LazyForEach 创建 item 时触发 aboutToAppear,此时组件刚被创建,图片加载还没开始。用户看到白块是因为从 aboutToAppear 到图片实际送显之间的所有步骤都需要时间。

如果图片命中了内存缓存,这个延迟很短(毫秒级)。但如果是首次加载或缓存已淘汰,需要走文件缓存甚至网络下载,延迟就会很明显。

这条链路的每个环节都有耗时:aboutToAppear 中 NAPI 调用需要做参数解析(napi_value 到 C++ 对象的转换),创建 ImageKnifeNodeImage 需要分配 ArkUI 节点、设置同步加载属性、注册事件回调等。节点创建完成后,Execute 把请求推入 Dispatcher 队列。Dispatcher 根据并发数限制决定是立即执行还是排队。如果当前已有 8 个请求在执行(默认并发数),新请求就要等前面的请求完成后才能开始。

首屏场景下的问题尤其突出:页面一次性创建了多个图片组件,所有组件几乎同时调用 aboutToAppear,产生的请求同时涌入队列。即使网络带宽足够,并发数限制也会让后面的请求排队。排队时间加上实际的网络下载和解码时间,导致最后几张图片的显示延迟可能达到秒级。

二、preCreateImageKnifeComponent:提前创建节点

preCreateImageKnifeComponent 允许在列表 item 上屏之前就创建好图片节点并启动加载。ArkTS 侧的调用很简单:

ImageKnife.getInstance().preCreateImageKnifeComponent("item_0",
    new ImageKnifeOption({ loadSrc: "https://example.com/image.jpg" }));

C++ 侧的 PreCreateNativeNode 方法创建一个 ImageKnifeNodeImage,但不把它挂载到任何 ContentSlot 上:

napi_value ImageKnifeNapi::PreCreateNativeNode(napi_env env, napi_callback_info info)
{
    std::string customId = NapiParser::ParseString(env, args[0]);
    auto imageKnifeOption = std::make_shared<ImageKnifeOptionNapi>(env, args[1]);
    auto imageNode = std::make_shared<ImageKnifeNodeImage>(imageKnifeOption);
    imageNode->SetComponentId(customId);
    ImageKnifeInternal::GetInstance().SetPreCreatedNode(customId, imageNode);
    // 直接Execute,不上树不关联
    ImageKnifeInternal::GetInstance().Execute(imageNode->GetImageKnifeRequest());
    return nullptr;
}

节点被存入 preCreatedNodeMap_ 并立即调用 Execute 发起加载请求。此时节点没有关联 ContentSlot,图片加载完成后暂时不能送显,但缓存查询、下载、解码都已经在后台进行了。等列表滚动到对应位置、组件的 aboutToAppear 触发时,再通过 connectContentSlot 把已经加载好(或正在加载中)的节点关联上去。

注意预创建时 ImageKnifeNodeImage 的构造函数会创建一个 ARKUI_NODE_IMAGE 节点,设置同步加载属性和百分比宽高。这些操作都在主线程完成。预创建的调用时机应该在列表数据准备好之后、列表渲染之前——比如在数据请求返回后,遍历数据列表,对即将显示的前 N 个 item 调用 preCreateImageKnifeComponent

如果需要加载本地图片(Resource 类型),需要在 ImageKnifeOption.context 上设置对应页面的 context。预创建接口使用的不是组件所在页面的默认 context,所以本地资源路径的解析依赖调用方显式传入 context。

三、connectContentSlot:关联预创建节点

ImageKnifeComponent 检测到 customId 属性不为空时,走预创建关联路径而不是普通的创建路径:

// ImageKnifeComponent.ets
aboutToAppear(): void {
    this.componentId = this.getUniqueId().toString();
    if (this.customId != undefined) {
        nativeNode.connectContentSlot(this.componentId, this.customId,
            this.rootSlot, this.imageKnifeOption, this.animatorOption,
            this.getDefaultContext(), {...});
    } else {
        nativeNode.createNativeRoot(...);
    }
}

C++ 侧 ConnectContentSlot 的核心逻辑是:用 customId 查找预创建节点,找到就复用,找不到就当场创建一个新的。

napi_value ImageKnifeNapi::ConnectContentSlot(napi_env env, napi_callback_info info)
{
    std::string componentId = NapiParser::ParseString(env, args[0]);
    std::string customId = NapiParser::ParseString(env, args[1]);
    auto imageNode = ImageKnifeInternal::GetInstance().GetPreCreatedNode(customId, true);

    ArkUI_NodeContentHandle contentHandle;
    OH_ArkUI_GetNodeContentFromNapiValue(env, args[contentSlotIndex], &contentHandle);

    auto imageKnifeOption = std::make_shared<ImageKnifeOptionNapi>(env, args[imageKnifeOptionIndex]);
    bool exist = true;
    if (imageNode == nullptr) {
        exist = false;
        imageNode = std::make_shared<ImageKnifeNodeImage>(imageKnifeOption);
    }
    imageNode->SetComponentId(componentId);
    ImageKnifeInternal::GetInstance().SetRootNode(componentId, contentHandle, imageNode);
    imageNode->Execute();
    if (exist) {
        imageNode->Update(imageKnifeOption);
    }
    return nullptr;
}

GetPreCreatedNode(customId, true) 的第二个参数 true 表示取出后从预创建池中移除。取出的节点从此变成普通节点,由 imageNodeMap_ 管理。

如果预创建节点已经加载完成,Update 会发现主图 key 没有变化,直接用已缓存的图片数据送显——用户看到的就是图片瞬间出现,没有白块。如果还在加载中,关联 ContentSlot 后加载完成时就能正常送显。

ConnectContentSlot 还处理了一种边界情况:预创建时使用的 option 和实际上屏时的 option 可能不同(比如用户滑动过程中数据源更新了)。exist 为 true 时调用 Update(imageKnifeOption),Update 内部比较新旧 option 的主图 key。如果 key 变了(loadSrc 不同,或 signature 不同),会取消旧请求,用新 option 重新加载。如果 key 没变,就复用已有的加载结果。

还有一个细节:componentIdcustomId 是两个不同的 ID。customId 是预创建时指定的标识符,用于在池中查找节点。componentId 是组件实际上屏后的运行时 ID(由 getUniqueId() 生成)。关联完成后,节点的组件 ID 从 customId 切换为 componentId,后续的 Update、Clear、Destroy 都用 componentId 操作。不要使用纯数字字符串或 "C_" 加数字作为 customId,这两种格式被 ImageKnifePro 内部使用。

connectContentSlot关联流程

四、预创建池的容量管理

预创建节点会占用内存(节点对象本身、正在加载的图片数据、解码后的 PixelMap)。如果不限制数量,提前创建几百个节点会导致内存膨胀。

ImageKnifeInternal 用一个链表 preCreatedNodeIdList_ 维护预创建节点的先后顺序,maxPreCreateNodeCount_ 限制最大数量(默认 10,上限 64):

void ImageKnifeInternal::SetPreCreatedNode(
    std::string id, std::shared_ptr<ImageKnifeNodeInternal> node)
{
    if (preCreatedNodeMap_.find(id) != preCreatedNodeMap_.end()) {
        return;
    }
    while (preCreatedNodeIdList_.size() >= maxPreCreateNodeCount_) {
        auto nodeId = preCreatedNodeIdList_.back();
        CancelRequest(preCreatedNodeMap_[nodeId]->GetImageKnifeRequest());
        preCreatedNodeMap_.erase(nodeId);
        preCreatedNodeIdList_.pop_back();
    }
    preCreatedNodeMap_[id] = node;
    preCreatedNodeIdList_.push_front(id);
}

当池子满了,从链表尾部(最早创建的)开始淘汰。淘汰时先取消该节点正在进行的加载请求(CancelRequest),释放下载和解码过程中占用的资源,然后从 map 和链表中移除。新节点从链表头部插入,形成 FIFO 淘汰策略。

setMaxPreCreateNodeCount 接口允许调整池子大小。上限硬编码为 64,超过这个值会被截断。这个上限的考量是:64 个预创建节点已经覆盖了绝大多数列表场景(一屏通常显示 4-8 个图片 item,预创建 64 个意味着提前加载了 8-16 屏的内容)。

releasePreCreatedNode 可以一次性清空所有未关联的预创建节点:

void ImageKnifeInternal::ReleasePreCreatedNode()
{
    for (auto &item : preCreatedNodeMap_) {
        CancelRequest(item.second->GetImageKnifeRequest());
    }
    preCreatedNodeMap_.clear();
    preCreatedNodeIdList_.clear();
}

在页面退出或列表数据源切换时调用这个接口,避免无用的预创建节点继续占用内存和网络资源。

五、preload 与 preCreate 的区别

ImageKnifePro 同时提供了 preloadpreCreateImageKnifeComponent 两个"提前加载"接口,它们解决的问题不同。

preload 只提前执行加载管线(缓存查询、下载、解码),不创建组件节点。加载完成后图片数据进入缓存。等组件实际上屏时通过正常的缓存命中路径获取数据。

// preload:只预热缓存
let request = ImageKnife.getInstance().preload("https://example.com/image.jpg");
// 后续可以取消
ImageKnife.getInstance().cancel(request);

preCreateImageKnifeComponent 不仅提前执行加载管线,还提前创建了 C++ 侧的节点对象(ImageKnifeNodeImage)。等组件上屏时通过 connectContentSlot 直接把已存在的节点挂载到 ContentSlot 上,省去了节点创建和初始化的时间。

preload 适用于确定会加载但不确定何时显示的场景(比如下一页的数据)。preCreate 适用于确定很快会显示的场景(比如列表即将滚入可视区的几个 item)。preCreate 的优势在于节点创建这一步也被提前了,但代价是占用了更多内存(节点对象加上 ArkUI 资源)。

还有一个 preloadCache 接口,和 preload 类似但返回的是文件缓存路径:

let filePath = await ImageKnife.getInstance().preloadCache("https://example.com/image.jpg");

preloadCache 执行完整的加载管线(下载、解码、写缓存),返回 Promise,resolve 时携带文件缓存路径字符串。调用方拿到路径后可以用于其他用途(比如传给 Video 组件做封面)。加载失败时返回空字符串。

三个接口的选择逻辑:只需要预热缓存用 preload/preloadCache,需要提前创建节点用 preCreate。preload 可以取消(返回 request 对象),preloadCache 不能取消(返回 Promise)。preCreate 的预创建节点可以通过 releasePreCreatedNode 一次性清空。

六、ImageKnifeComponent 的生命周期配合

预创建机制能正常工作,依赖 ImageKnifeComponent 的生命周期方法正确配合:

aboutToAppear 时根据 customId 是否存在选择创建路径。aboutToRecycle 在组件被列表复用框架回收时调用 clearNativeRoot,清空当前显示的图片、终止动图播放、取消正在进行的加载请求。aboutToDisappear 在组件销毁时调用 destroyNativeRoot,释放节点占用的所有资源。

aboutToRecycle() {
    nativeNode.clearNativeRoot(this.componentId);
}
aboutToDisappear(): void {
    nativeNode.destroyNativeRoot(this.componentId);
}

C++ 侧的 Clear 方法把节点状态设为 CLEARED,终止动图播放,移除已显示的图片。DisposeNode 方法执行注册的清理事务(反注册事件、释放 ArkUI 资源),然后从 imageNodeMap_ 中移除。

列表复用场景下,aboutToRecycle + 再次 aboutToAppear 的组合意味着同一个组件 ID 可能先 Clear 再 Update。Update 方法内部通过 IsUpdateNecessary 判断:如果主图 key 发生了变化,取消旧请求重新加载;如果 key 没变(同一张图),图片还在就直接显示,不重复请求。

Clear 和 DisposeNode 的区别在于:Clear 只是清空显示状态,保留节点对象,组件可以被复用。DisposeNode 彻底释放节点,包括反注册所有 ArkUI 事件、从 nodeMap 中移除、释放节点 handle。一旦 Dispose,节点不可复用。

ImageKnifeNodeInternal 内部维护了 componentVersion_ 计数器。每次 Reuse 时版本号加一,异步回调中通过比对版本号判断回调是否过期。如果一个加载请求在 Clear 之前发起,Clear 后又发起了新请求,旧请求的回调到达时发现版本号不匹配,就不会把旧图片显示到新内容上。这个版本号机制和 ImageKnifeNodeImage 中动图的 imageVersionId_ 是相同的设计思路,都是用单调递增的 ID 解决异步回调过期问题。

SetContentSlot 方法在关联 ContentSlot 时把 Image 节点作为 ContentSlot 的子节点挂载上去。ArkUI 的 ContentSlot 是一个容器占位符,C++ 侧创建的节点通过 OH_ArkUI_NodeContent_AddNode 挂载后,渲染框架就会在对应位置显示该节点的内容。

组件生命周期与预创建的配合

以上就是本篇内容的所有了~有什么问题欢迎在评论区提出


项目地址:ImageKnifePro

标签: none

添加新评论