一、背景:一个看似简单的图片问题

在鸿蒙开发中,如果一个业务模块最终以 HAR 组件包的形式交付,那么组件内部通常会携带自己的资源:

HAR
├── src/
├── ets/
└── resources/
    └── base/
        └── media/
            └── poster.png

在 HAR 自己的页面中,可以非常自然地:

Image($r('app.media.poster'))

图片能够正常显示。

到这里,一切都很美好。

但业务继续往下走,问题出现了:

这张图片不是只用来显示,而是需要参与 PixelMap 图片合成。怎么办?

例如:

HAR 内置海报
       +
动态二维码
       +
用户信息
       ↓
最终合成图片

这时候我们需要的不再是一个 $r() 资源引用,而是真正的:

PixelMap

或者一个可以重新读取的图片文件。

于是问题从“怎么显示图片”变成了:

如何跨越 HAR 资源和图片处理 API 之间的边界?


二、先理解鸿蒙为什么要这样设计:HAR 不是一个独立应用

很多问题的根源,其实来自一个很容易被忽略的事实:

HAR 和 HAP 的定位完全不同。

可以简单理解成:

HAP
│
├── Ability
├── 应用生命周期
├── Context
├── 应用资源
└── 应用运行环境

而 HAR 更接近:

HAR
│
├── 代码
├── 组件
├── 页面
└── 资源

它是一个可被宿主应用集成的组件包

因此:

宿主 HAP
   │
   ├── EntryAbility
   ├── EntryAbilityContext
   │
   └── 集成 HAR
           │
           ├── HAR代码
           └── HAR资源

这里非常重要的一点是:

HAR 并没有因为被宿主加载,就自动拥有一个独立的 Ability 生命周期和独立的 EntryAbilityContext。

因此,当 HAR 代码拿到:

EntryAbilityContext

它本质上拿到的是:

宿主应用的运行上下文。

这就解释了我们最开始遇到的问题。


三、为什么宿主 Context 不应该被认为是 HAR 的 ResourceManager?

假设:

宿主 HAP
resources/
    base/
        media/
            host.png

HAR:

HAR
resources/
    base/
        media/
            poster.png

宿主给 HAR 一个:

EntryAbilityContext

那么:

context.resourceManager

天然对应的是宿主运行环境的资源管理体系

而不是:

HAR/resourceManager

这种理解其实很符合鸿蒙组件化的设计思想。

因为如果 HAR 可以随意通过宿主 Context 去访问自己的所有内部资源,那么组件边界就会变得非常模糊:

宿主 Context
   ↓
任意 HAR
   ↓
任意资源
   ↓
任意模块

这会让模块资源管理变得非常混乱。

所以鸿蒙这里实际上体现的是一种比较典型的组件化思想:

组件可以携带资源,但组件资源的访问应该遵循组件自身的资源解析机制,而不是简单把宿主 Context 当成组件自己的 Context。


四、那为什么 $r() 又能工作?

这恰恰是整个问题最值得理解的地方。

我们写:

Image($r('app.media.poster'))

并不是:

读取一个普通文件

而是在告诉鸿蒙:

我要引用这个组件/模块声明的资源。

资源引用本身属于鸿蒙 UI/资源体系。

因此:

HAR资源
   ↓
$r()
   ↓
资源解析
   ↓
Image
   ↓
渲染系统

这个过程不要求我们手动拿着宿主 EntryAbilityContext 去:

filesDir
resourceManager
rawfile

逐层寻找。

这也是为什么:

Image($r('app.media.poster'))

可以正常工作。

而:

context.resourceManager.getMedia(...)

却不能简单理解成“当然应该能找到 HAR 资源”。

二者实际上处于不同的抽象层。


五、真正的问题:我们需要的是“资源”,还是“渲染结果”?

到了这里,可以把问题重新定义。

最开始我们一直在问:

怎么从 HAR 中把 poster.png 读出来?

其实这个问题本身就有点偏。

因为我们真正需要的是:

poster.png
    ↓
PixelMap
    ↓
图片合成

而鸿蒙已经帮我们完成了:

HAR Resource
    ↓
Image

那么为什么不继续利用 UI 系统已经提供的能力?

于是思路发生变化:

不要:

HAR Resource
    ↓
Context
    ↓
文件系统
    ↓
PixelMap

而是:

HAR Resource
    ↓
$r()
    ↓
Image
    ↓
ComponentSnapshot
    ↓
PixelMap

这就是最终方案的核心。


六、ComponentSnapshot 本质上是什么?

这里不能把 ComponentSnapshot 简单理解成:

“截一张屏。”

这个理解太浅。

更准确地说,它提供了一条从:

UI Component

到:

图像数据

的桥梁。

也就是:

声明式 UI
   ↓
布局
   ↓
渲染
   ↓
组件最终视觉结果
   ↓
PixelMap

因此:

const snapshot = uiContext.getComponentSnapshot()
const pixelMap = await snapshot.get(componentId)

本质上是在说:

我不再关心这个 Image 最初的资源来自哪个模块,我只关心这个组件最终渲染出来的图像。

这个思路非常重要。

因为此时:

HAR资源边界

已经不再重要了。

系统已经把它变成:

UI渲染结果

而 UI 渲染结果恰恰是 ComponentSnapshot 所能处理的对象。


七、这其实是一次“资源边界 → UI边界”的转换

把整个过程抽象一下:

                 模块边界
                    │
HAR Resource ────────┤
                    │
                    ▼
                  Image
                    │
                    │
             UI Render Boundary
                    │
                    ▼
            ComponentSnapshot
                    │
                    ▼
                 PixelMap

我们实际上做了一件很有意思的事情:

没有突破 HAR 的资源边界,而是让资源正常进入 UI 渲染体系,再从 UI 渲染体系获得图像数据。

这也是为什么这个方案在架构上是成立的。

它不是:

“偷偷绕过鸿蒙的资源机制。”

而是:

利用鸿蒙已经公开提供的 UI 能力,把一个组件资源转换成标准图像数据。


八、但是,这个方案也不是“完美方案”

这里必须客观一点。

如果把:

Image → Snapshot → PNG → ImageSource → PixelMap

作为鸿蒙官方意义上的“资源读取 API”,那显然是不合适的。

因为它本质上是:

通过 UI 渲染链路间接获得资源数据。

所以它存在几个明显的缺点。


8.1 第一个缺点:存在一次额外的渲染转换

正常情况下:

图片资源
    ↓
PixelMap

理论上可以直接完成。

现在却变成:

图片资源
    ↓
Image
    ↓
布局
    ↓
渲染
    ↓
Snapshot
    ↓
PixelMap

中间多了一整套 UI 流程。

因此:

  • 有额外开销
  • 依赖 UI 组件存在
  • 依赖 UIContext
  • 依赖组件已经进入可 Snapshot 的状态

这显然不是最高效的资源读取方式。


九、第二个缺点:Image 尺寸会影响最终结果

这是实际使用中最容易踩坑的地方。

例如:

Image($r('app.media.poster'))
  .width(100)
  .height(100)

你不能简单认为:

HAR原图 = 1280 × 1280

那么 Snapshot 一定还能得到:

1280 × 1280

因为你实际上已经让这张图片进入了:

100 × 100

的 UI 渲染环境。

所以最终应该:

Image($r('app.media.poster'))
  .width(1280)
  .height(1280)
  .opacity(1)

如果后续需要:

1280 × 1280

那么 Image 的布局尺寸也应该尽量满足这个要求。


十、为什么这和 Android 的思维不一样?

这是非常值得单独强调的一点。

Android 开发者很容易带着这样的经验:

ImageView尺寸
        ≠
Drawable原始尺寸

例如一个 Drawable 本身:

1280 × 1280

即使 ImageView 很小,底层 Drawable 的原始数据依然存在。

但这里我们的目标不是:

获取 Drawable 原始数据

而是:

获取 UI Component 的 Snapshot

Snapshot 本质上面对的是:

渲染后的组件。

因此:

Image尺寸
     ↓
布局尺寸
     ↓
渲染结果
     ↓
Snapshot尺寸

这就是为什么不能简单照搬 Android 的经验。


十一、第三个缺点:不能完全透明

既然 Image 只是“中转站”,第一反应通常是:

.opacity(0)

这样用户就看不到它了。

但是:

Image
 ↓
opacity = 0
 ↓
Snapshot

得到的自然也是:

完全透明

所以如果需要 Snapshot 获取实际图片内容:

.opacity(1)

才是安全的。

如果不希望用户看到,可以利用组件层级让 Image 被其他 UI 覆盖。

这也恰好说明:

ComponentSnapshot 获取的是组件最终视觉表现,而不是简单地读取 Image 背后的原始资源文件。


十二、第四个特点:Image 被遮挡并不等于资源消失

这里又是一个很有意思的地方。

如果:

Image
 ↓
其他 UI 覆盖

并不意味着:

Image资源不存在

只要这个 Image 组件仍然存在于组件树,并且能够正常被 Snapshot 获取,就可以继续:

ComponentSnapshot
       ↓
PixelMap

所以:

“用户看不到”与“组件不存在”是两个完全不同的问题。

这也是为什么我们最终不需要依靠:

opacity(0)

去隐藏 Image。


十三、为什么最终要把 PixelMap 再保存成文件?

其实严格来说:

HAR Image
 ↓
Snapshot
 ↓
PixelMap

已经解决问题了。

为什么还要:

PixelMap
 ↓
PNG
 ↓
PixelMap

多绕一圈?

原因是生命周期和业务解耦

如果整个业务链路都依赖:

UIContext

那么会变成:

页面
 ↓
UIContext
 ↓
资源获取
 ↓
图片合成
 ↓
更多业务

这会导致 Context 在整个链路中到处传递。

而保存到:

context.filesDir/ai_images/

之后:

HAR资源阶段
       ↓
   保存一次
       ↓
     文件
       ↓
普通业务阶段
       ↓
   PixelMap

资源准备和图片业务就被彻底拆开了。


十四、这也是为什么最终选择“静态无状态工具类”

最终没有:

class Manager {
    private context: Context
}

也没有:

static context: Context

更没有单例长期保存:

UIContext

而是:

static async saveImageFromComponent(
    uiContext: UIContext,
    componentId: string,
    fileName: string
)

和:

static async getPixelMap(
    context: Context,
    fileName: string
)

两个阶段各自传入自己真正需要的 Context。

这样:

save阶段
UIContext
   ↓
Snapshot
   ↓
文件
   ↓
UIContext生命周期结束

之后:

get阶段
EntryAbilityContext
   ↓
文件
   ↓
PixelMap

工具类本身不持有任何 Context。

这是比“单例缓存 Context”更加干净的设计。


十五、这个方案真正好在哪里?

如果从系统设计角度评价,这个方案最大的优点并不是:

“终于把图片保存出来了。”

而是:

1. 没有破坏 HAR 的资源封装

没有要求:

HAR
 ↓
直接暴露资源路径

也没有要求宿主:

把 HAR 资源复制一份出来

HAR 仍然可以保持自己的资源封装。


2. 没有要求宿主了解 HAR 内部资源结构

宿主不需要知道:

poster.png
qr_bg.png
xxx.png

这些东西具体放在哪里。

HAR 自己通过:

$r(...)

加载。


3. 图片业务和组件资源解耦

一旦转换成:

filesDir/ai_images/poster.png

后面的图片合成模块就不需要知道:

这张图来自 HAR

它只知道:

这里有一个 PNG

这对于组件化非常重要。


4. Context 生命周期不会被工具类污染

工具类不持有:

UIContext
EntryAbilityContext

所以不会因为一个图片工具类,把整个页面生命周期拖住。


十六、但是从“理想设计”来看,我们其实希望鸿蒙提供什么?

这才是这件事情真正值得讨论的地方。

如果鸿蒙提供一个明确的:

HAR Resource
       ↓
PixelMap

或者:

Resource
       ↓
ImageSource
       ↓
PixelMap

能力,那么整个过程可以变成:

HAR Resource
      ↓
ResourceManager
      ↓
ImageSource
      ↓
PixelMap

而不需要:

HAR Resource
      ↓
Image
      ↓
布局
      ↓
渲染
      ↓
Snapshot
      ↓
PixelMap

后者明显更重。

所以从 API 设计角度,我认为更理想的是:

鸿蒙应该让组件模块能够在不依赖宿主 AbilityContext 的情况下,对自身声明的资源进行标准化访问,并提供统一的 Resource → ImageSource/PixelMap 转换能力。

这样才能真正做到:

组件资源
     ↓
组件资源管理器
     ↓
PixelMap

而不是:

组件资源
     ↓
UI
     ↓
Snapshot
     ↓
PixelMap

十七、为什么鸿蒙可能没有直接把这条路做得这么简单?

这其实涉及一个系统设计上的取舍。

鸿蒙需要同时处理:

应用资源
模块资源
HAR资源
HSP资源
系统资源
Ability资源

如果所有资源都允许:

任意 Context
   ↓
任意资源

那么资源隔离、模块化、依赖关系都会变得非常复杂。

所以它更倾向于:

模块
 ↓
声明资源
 ↓
资源引用
 ↓
运行时解析
 ↓
UI/业务使用

这种方式。

它的好处是:

  • 模块边界清晰
  • 宿主与组件解耦
  • 资源归属明确
  • 更适合组件化

但缺点也很明显:

当开发者需要“资源原始数据”而不是“资源 UI 表现”时,API 会显得不够顺手。

这正是我们这次遇到的问题。


十八、所以这个方案应该怎么看?

我认为应该把它定义为:

一个合理的工程性解决方案,而不是理想的资源访问方案。

这两个概念一定要区分。

从工程实践看

Image
 ↓
ComponentSnapshot
 ↓
PixelMap
 ↓
文件

完全可以使用。

尤其适合:

  • HAR 内置图片
  • 海报模板
  • 二维码背景
  • 固定素材
  • 需要转换成 PixelMap 的资源
  • 不希望宿主感知 HAR 内部资源结构的场景

从系统 API 设计看

它并不理想。

因为:

资源读取

本来应该属于:

Resource API

结果我们借用了:

UI Snapshot API

来完成。

这相当于:

为了拿到厨房里的苹果,我们先让苹果做成一道菜,再从菜里把苹果捞出来。

能吃,甚至很好吃。

但显然不是最短路径。


十九、最终推荐的工程架构

因此,如果现在项目必须使用 HAR,而且当前 API 条件不允许直接获得 HAR 资源对应的 PixelMap,那么我建议采用:

┌───────────────────────────────┐
│             HAR               │
│                               │
│  resources/base/media         │
│           ↓                   │
│      Image($r(...))           │
└──────────────┬────────────────┘
               │
               │ UIContext
               ▼
      ComponentSnapshot
               │
               ▼
            PixelMap
               │
               ▼
        ImagePacker
               │
               ▼
       filesDir/ai_images
               │
               │
       ┌───────▼────────┐
       │   业务图片层    │
       │                │
       │ getPixelMap()  │
       └───────┬────────┘
               │
               ▼
          图片合成

其中最重要的一条边界是:

              资源获取边界
                    │
                    ▼
HAR ──→ Image ──→ Snapshot ──→ 文件
                                  │
                                  │
              业务处理边界        │
                    │             │
                    ▼             ▼
                PixelMap ←── 文件

前半段负责“把组件资源变成普通文件”,后半段负责“把普通文件当普通图片使用”。

这比让整个业务链路一直携带 UIContext 要合理得多。


二十、总结:真正需要理解的不是“怎么拿图片”

这次问题表面上是:

鸿蒙 HAR 里的图片怎么变成 PixelMap?

但深入以后其实是一个非常典型的鸿蒙组件化问题:

模块资源属于谁?Context 属于谁?UI 资源引用和文件资源访问之间是什么关系?

最终可以得到几个结论。

第一,HAR 是组件资源载体,不应该简单等同于宿主 HAP。

所以:

宿主 EntryAbilityContext
≠
HAR ResourceContext

不能想当然地认为宿主 Context 可以直接访问 HAR 内部所有资源。

第二,$r()Context.resourceManager 解决的是不同层次的问题。

$r()
→ 资源引用/解析/UI使用

Context
→ 运行环境/文件/应用生命周期

不要把两者混成一个概念。

第三,ComponentSnapshot 提供了一条非常有价值的“UI → PixelMap”通道。

因此:

HAR Resource
 → Image
 → Snapshot
 → PixelMap

虽然不是资源访问的最优路径,但在当前组件化场景下是一条合理的工程路径。

第四,这个方案的核心不是“绕过 HAR”,而是“顺着鸿蒙 UI 体系把资源转换成标准图像数据”。

这也是它为什么能够工作。

第五,这个方案同时存在明显局限。

它依赖:

  • UIContext
  • Image 组件
  • 正确的组件 ID
  • 正确的 Image 尺寸
  • 非透明渲染
  • Snapshot 时机
  • 一次额外的图像转换

所以它更适合作为:

HAR 资源向业务图片数据转换的适配层。

而不是整个项目通用的资源读取机制。

最后,如果未来鸿蒙提供:

HAR Resource
       ↓
ImageSource / PixelMap

这种不经过 UI 渲染层的直接资源数据访问能力,那么应该优先迁移到那条路径。

因为从系统设计角度看,最合理的终点始终应该是:

资源属于哪个模块
        ↓
由哪个模块的资源管理体系解析
        ↓
直接得到资源数据

而不是:

资源
 ↓
先画出来
 ↓
再截图
 ↓
再把截图当资源

我们现在的方案,是在鸿蒙现有组件化边界下找到的一条可行的桥;但这座桥解决的是工程问题,不代表它就是系统设计上的终点。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐