
1. 为什么RN在OpenHarmony上的Image混合模式不是加个属性就完事先交代一下我这个需求的背景。在做的是一个海报编辑页React Native 0.72.5 的工程需要把商品图和一张纹理图做正片叠底还要在图上叠一层半透明白色渐变效果和设计稿对齐。iOS 和 Android 上都好办风格里写上mixBlendMode: multiply基本就能出效果。等跑到 OpenHarmony 的适配器上问题就来了图片显示出来了但混合模式完全没生效整个区域要么是黑块要么是单张图片的原始效果。这篇文章不打算复述官方文档只讲我在做RN Image 图片混合模式这个专项时踩过的坑、查过的代码和最终沉淀下来的实现方案。适合三类人看一是正在把 RN 工程移植到 OpenHarmony 的二是想在鸿蒙原生里做多图层混合的三是准备自定义 RN 原生组件绕开适配层限制的。1.1 这事的核心矛盾RN 在 OpenHarmony 上不是直接跑一套自己的渲染引擎而是通过社区维护的 RN-OHOS 适配层把 JS 声明的虚拟节点转成 ArkUI/ArkTS 的组件树。所以你对 Image 写的 style最终能不能生效完全取决于适配层把这个 style 翻译成了什么。我打开适配层源码里 Image 组件的映射逻辑发现它处理了resizeMode、tintColor、borderRadius、overlayColor这些唯独没有mixBlendMode。也就是说标准 RN 里那套混合模式写法在鸿蒙上根本不会被透传到原生侧。你写了它也不报错因为 RN 的样式系统在 JS 层就把它归为已知样式但到原生就悄悄丢掉了。RN 的Image在鸿蒙上最终渲染成的是一个原生视图这个视图内部再用 ArkUI 的Image去展示解码后的 PixelMap。适配层在组件创建时会把图片路径和缩放模式传下去但样式对象里其他跟绘制相关的键它根本没有逐一识别。第一次定位到这一步时心里其实松了口气——至少不是渲染引擎的 bug只是桥没接上。1.2 各平台在混合模式上的实现差异把几个平台放一起看就很清楚平台渲染层能力RN 侧使用方式WebCSSmix-blend-modeGPU 合成直接用 styleAndroidCanvasPorterDuffXfermode部分版本有封装iOSCGBlendMode部分版本有封装OpenHarmonyArkUI 组件blendMode、NativeDrawingOH_Drawing_BlendMode、CanvasglobalCompositeOperation适配层未透出表格里最后一行是最关键的信息鸿蒙不是没有混合模式能力而是 RN 适配层没有把这个能力接进来。所以第一件事并不是去改造底层引擎而是先想清楚鸿蒙侧到底有什么现成的 API 可以给我们用。2. 第一版尝试直接套ArkUI的blendMode结果渲染异常2.1 ArkUI确实支持但只对ArkUI组件有效在纯 ArkUI 页面里Image组件确实提供了blendMode通用属性写法像这样Image($r(app.media.img_bg)) .blendMode(BlendMode.MULTIPLY)这个属性是组件级混合意思是这个组件显示的时候和它下方的兄弟组件的最终绘制结果做一次混合。听着正好是我们要的但问题是 RN 的页面渲染出来以后页面上的内容不是两个同层 ArkUI 组件而是一棵以原生视图为根节点的视图树嵌入到 ArkUI 容器里。RN 内部的兄弟 Image 之间并不等价于 ArkUI 里的两个兄弟Image节点。举个例子你在 ArkUI 里写两个Image它们是同一个父容器下的两个独立节点GPU 合成时可以拿到两者的离屏 layer 做混合。但 RN 里的两个 Image 是一整个原生视图内部的自绘内容外层对 ArkUI 来说就是一个黑盒组件级blendMode根本不知道黑盒里拆成了几个图层。2.2 症状先是整块黑屏然后边缘发黑第一个版本我干了一件事临时改适配层把mixBlendMode从 RN 侧透传到一个 ArkUIImage的blendMode上。结果在真机上一跑海报区域直接变成黑块。把混合模式换成SRC_OVER才稍微好一点但纹理图边缘始终有一圈杂色。这个黑屏现象在社区里被说得最多的是openharmony 画面渲染异常一半是真渲染引擎的 bug另一半是因为用法不对。我们这次明显属于后者。我最后定位下来是两个原因ArkUI 的组件级blendMode依赖组件自身绘制到一个独立层Layer上再和下层做合成。RN 的图片视图如果没走 ArkUI 的 Layer 合成通道混合结果就是未定义的设备驱动一碰到未知状态就容易整个区域黑掉。纹理图 PNG 本身是带透明通道的但适配层解码的时候用的像素格式不是预乘 alphaPremultiplied Alpha。用非预乘的源图去做混合GPU 在做MULTIPLY的时候会把透明区域当成黑色参与计算于是边缘就黑了一圈。这个边缘发黑的现象特别容易误判成OpenHarmony 的混合模式不支持 PNG 透明通道其实不是纯粹是 alpha 类型没对上。2.3 用最小复现把锅甩回正确的一层遇到这种玄学渲染问题我的习惯是先做最小复现把问题从业务代码里剥离出来。步骤很简单单独建一个 ArkUI 页面放两张 Image一张底图、一张纹理用blendMode(BlendMode.MULTIPLY)。确认这个页面正常说明 ArkUI 本身的混合没问题。在同一个 ArkUI 页面里放一个 RN 组件占位的容器RN 内部只画两张图再调适配层的 blendMode 透传。观察截图和 hilog。做完第 2 步基本就把底层的锅摘掉了剩下来的问题都在 RN 视图和 ArkUI 的合成边界上。到了这一步我决定不再死磕适配层而是换一条更可控的路。3. 最终落地方案自定义Native组件把混合模式下沉到离屏绘制3.1 整体架构我的方案是写一个自定义的 RN 原生组件BlendImage在原生侧把两张图片解码成 PixelMap然后在一张离屏 PixelMap 上做混合绘制绘制结果再交给 RN 层展示。架构上分三层JS 层一个BlendImage组件接收source、overlaySource、blendMode三个 props内部映射成原生组件。桥接层通过requireNativeComponent或 codegen 注册到 RN 运行时。原生层OpenHarmony 的自定义组件持有两个PixelMap执行离屏绘制最终通过Image展示结果。选择在原生侧做离屏绘制是因为这样可以完全绕开RN 视图和 ArkUI 组件树合成的边界问题。离屏画布是我们自己创建的背景图、叠加图、混合模式都在同一张画布上按顺序画出来合成规则完全由我们自己控制。关于 RN 原生组件在 OpenHarmony 上的开发方式一般就是在 ETS 文件里写一个自定义Component再通过适配层提供的注册接口把它挂到 RN 的requireNativeComponent名称下。这个流程和 Android/iOS 上写自定义 ViewManager 是同一个套路只是把平台 API 换成了鸿蒙的 image、canvas 模块。3.2 混合模式值映射表RN 侧传进来的字符串和 OpenHarmony 侧绘制枚举之间需要一张映射表。Android 上你可能见过PorterDuff.Mode.MULTIPLYiOS 上是CGBlendMode.multiply到 OpenHarmony 的 NativeDrawing 里是OH_Drawing_BlendMode的一系列枚举。以常用的几种为例RNmixBlendModeNativeDrawingOH_Drawing_BlendMode对应效果normalBLEND_MODE_SRC_OVER正常覆盖multiplyBLEND_MODE_MULTIPLY正片叠底screenBLEND_MODE_SCREEN滤色overlayBLEND_MODE_OVERLAY叠加darkenBLEND_MODE_DARKEN变暗lightenBLEND_MODE_LIGHTEN变亮differenceBLEND_MODE_DIFFERENCE差值exclusionBLEND_MODE_EXCLUSION排除提示ArkUI 组件属性的BlendMode枚举和 NativeDrawing 的OH_Drawing_BlendMode枚举不一定是同一个。我在实现里都走 NativeDrawing 的离屏绘制路径组件属性的枚举只作为兜底。如果你只需要multiply和screen这两种最常见的混合用 ArkUI 的 Canvas API 就够了一旦要支持color-dodge、hard-light、hue、saturation这些偏门模式强烈建议查一下目标 SDK 版本里 NativeDrawing 到底支持哪些枚举。不同版本的 API 覆盖度不太一样提前确认能省掉后面排查为什么这个模式没效果的时间。3.3 关键绘制流程核心流程分四步示意代码如下// 1. 按目标尺寸创建离屏 PixelMap let initOps: image.InitializationOptions { size: { width: targetW, height: targetH }, pixelFormat: image.PixelMapFormat.RGBA_8888, alphaType: image.AlphaType.PREMUL, }; let offscreen: image.PixelMap await image.createPixelMap(new ArrayBuffer(targetW * targetH * 4), initOps); // 2. 拿离屏画布上下文 let ctx: CanvasRenderingContext2D this.getCanvasContext(offscreen); // 3. 按顺序画底图和叠加图中间切换混合模式 ctx.drawImage(bgPixelMap, 0, 0, targetW, targetH); ctx.globalCompositeOperation multiply; ctx.drawImage(overlayPixelMap, 0, 0, targetW, targetH); // 4. 把结果设置给 Image 组件 this.imageController.src offscreen;这里的globalCompositeOperation取值其实和 Web 的混合模式基本对应可以直接把 RN 侧传过来的字符串映射过去。如果你的场景对色准要求特别高或者要支持更冷门的混合模式再改用OH_Drawing_Canvas加画笔混合模式的底层 API只是代码量会大不少。这里有个容易忽略的地方目标尺寸是targetW和targetH不是原始图片的宽高。组件在原生侧要先通过布局拿到自身的尺寸通常等于 RN style 里设置的宽高。如果直接用原图尺寸Image 在展示时再做缩放混合结果和布局尺寸不一致边缘抗锯齿会被二次采样搞脏。这一点在 Android 上也有类似经验纯离屏合成的方案都绕不开先定画布尺寸这一步。3.4 JS 侧封装JS 侧代码长这样把原生组件封成一个友好组件并把常用 props 透传下去import React from react; import { requireNativeComponent, ViewStyle, ImageSourcePropType } from react-native; type NativeBlendImageProps { source: ImageSourcePropType; overlaySource: ImageSourcePropType; blendMode: string; style?: ViewStyle; }; const NativeBlendImageView requireNativeComponentNativeBlendImageProps(OHOSBlendImageView); export type BlendMode | normal | multiply | screen | overlay | darken | lighten | difference | exclusion; export const BlendImage (props: OmitNativeBlendImageProps, blendMode { blendMode: BlendMode }) { return NativeBlendImageView {...props} /; };需要注意的是requireNativeComponent注册的名字要和原生侧注册的名字严格一致大小写都要一样。我第一版写成了OHOSBlendImage原生注册的是OHOSBlendImageView结果报组件不存在这个坑很小但很浪费时间。另外React Native 较新的版本里更推荐用 codegen 生成类型安全的组件接口但如果只是内部工具组件requireNativeComponent完全够用不需要为了它把整个 codegen 流程拉起来。4. 踩坑实录从画面渲染异常到定位根因的完整排查链路4.1 现象一图片整体发黑第一次跑通离屏绘制我以为完事了结果海报区域还是黑的。当时判断逻辑是离屏绘制这条路应该没问题了但黑就是黑必须从头查。我先在原生侧给离屏画布填充了一个纯白色背景再画底图结果白底显示正常说明createPixelMap和 Canvas 的绑定没问题。接着只画底图没问题只画纹理也没问题两个一起画黑。这时候基本确定是混合模式对透明区域的处理不对。翻了一下 image 模块的参数发现createPixelMap的alphaType如果没传默认可能是非预乘类型而 Canvas 混合期望的是预乘 alpha。把alphaType显式指定为预乘之后黑色立刻消失。这个细节如果你没踩过真的很难从文档里看出来因为文档只会告诉你alphaType 用来指定透明度类型不会告诉你混合模式依赖它。另外如果业务侧传的是 base64 图片解码前最好先做一次 base64 校验。无效的 data URI 在解码时可能直接抛异常表现就是图片区域空白或者黑色。我在 Android 上见过类似的invalid token image/jpeg报错鸿蒙这边虽然报错文案不同但预防方式一样在 JS 层或原生侧先判断前缀和长度避免把脏数据丢给解码器。4.2 现象二滚动列表后花屏、闪烁第二个问题出现在海报编辑器进入列表页之后。首页用FlatList渲染多个海报卡片每个卡片里有一个BlendImage。刚进入页面时正常往下滚动再回来卡片就开始花屏、闪烁。花屏的本质是BlendImage是一个原生视图它内部用同一个PixelMap作为 Image 的 source而在第一次绘制还没结束时下一轮更新的离屏绘制就来了。GPU 可能在读取这块内存的同时我们又在往里面写数据于是画面就花了。解决方式是引入双缓冲每次离屏绘制都生成一个新的PixelMap旧的不改。等新结果生成完再把 Image 的 source 原子替换成新的。替换完成后旧 PixelMap 才允许回收。这里一定不要为了省内存去原地复用同一个 PixelMap。排查时我还用 hilog 打了每个步骤的时间戳发现从收到新 props到绘制完成之间可能有几百毫秒的延迟。花屏不一定每次都在绘制过程中出现更多时候是 Image 组件在 source 切换的瞬间读到了半成品。所以除了双缓冲我还加了一个简单的判断绘制任务开始前先检查组件是否还在挂载状态如果已经卸载就直接丢弃结果。4.3 现象三内存增长停不下来离屏绘制最直接的代价是内存。一张 1080×1440 的 RGBA 图一个 PixelMap 就是 1080×1440×4差不多 6MB。海报页一次预览要画至少三张混合结果再叠加 FlatList 的缓存内存不到一分钟就能冲到很高然后被系统杀掉。排查工具我用的是内存查看命令一行一行看 native heap 和 graphics 的占用。最后发现两个泄漏点每次设置新PixelMap给 Image 后旧的没有调用release()。ImageSource.create打开的资源没有关闭解码器句柄一直在增长这类泄漏比 PixelMap 本身更隐蔽。修完之后我额外给组件加了一个简单的 LruCache按[底图路径, 叠加图路径, 混合模式, 目标宽高]做 key。第二次渲染同一个海报卡片时直接命中缓存不再重新解码和绘制。这里有一个很关键的细节缓存 key 里必须带上目标宽高。有人只在 key 里放图片路径和混合模式结果一旦卡片宽度变化拿到的还是旧尺寸的混合结果父容器resizeMode一缩放就糊了。鸿蒙这边表现和 Android 一样我用两个不同宽度的卡片复现过这个现象。4.4 复盘归类最后复盘把遇到的问题归成三类RN 适配层没透传mixBlendMode这是适配层能力缺失靠自定义组件解决。组件级blendMode与 RN 视图树合成冲突这是渲染架构边界问题靠离屏绘制绕开。像素格式、预乘 alpha、内存回收这是我们原生代码没写对属于基本功。如果你也遇到openharmony 画面渲染异常这个关键词相关的现象我建议先按这个顺序排查不要一上来就怀疑是系统渲染引擎的问题。大部分时候问题出在调用方式而不是引擎本身。5. 性能与缓存混合结果不能每次现算5.1 缓存策略上面提到的 LruCache 是这次性能优化的关键。我不建议按张数设容量应该按总字节数设。比如预算 200MB每次往缓存里放 PixelMap 时记录它的尺寸超过预算就把最久未用的release()掉。为什么要按字节而不是按张数因为一张 2000×2000 的图和一张 200×200 的图占的内存差了 100 倍按张数控制缓存完全没有参考意义。缓存 key 我用的是src overlaySrc blendMode width height。如果图片是网络 URL建议把 URL 的 query 参数也带上否则不同 CDN 参数可能导致同一张图缓存冲突。5.2 大图解码别放在UI线程图片解码和离屏绘制都是 CPU/GPU 密集操作。初次进入页面时如果直接在主线程上跑掉帧会非常明显。OpenHarmony 侧可以用taskpool把解码和绘制丢到后台线程。一个现实问题是后台线程创建出来的PixelMap能不能直接传给主线程的 UI 组件这和 SDK 版本、指针传递方式都有关系不是所有版本都允许直接传对象。我当时的做法是后台线程只负责把混合结果写到一个字节数组里回到主线程再用这个字节数组创建PixelMap。这样规避了对象跨线程传递的兼容性问题。如果你的目标 API 版本支持直接传递可共享对象可以省掉这一步。5.3 动态切换混合模式要节流海报编辑器里有个滑块用户拖一下就从 multiply 切到 screen 再切到 overlay。如果每个值都立刻触发生成新的 PixelMap会有一堆无效任务排队最终显示的还不是最新的。我的处理是原生侧收到新的 blendMode 后不立即执行而是清掉上一次的定时器攒到 32ms 左右再执行一次如果 32ms 内又来了新值就继续往后推。同时维护一个自增版本号每次真正开始绘制的时候记录版本号任务回来时如果版本号已经变了这次结果直接丢弃。这个思路和前端 debounce 是一回事但在原生线程里更要注意结果回来时组件的状态可能已经变了。6. 工程选择复盘自定义组件 vs 直接改RN适配层源码6.1 两种方案对比做到一半的时候我其实纠结过要不要直接改适配层源码把mixBlendMode正式加到 RN 的 Image 组件里去。如果改成了页面里所有Image style{{mixBlendMode:multiply}}都能直接生效不需要业务侧改代码听上去更正统。但实际对比下来维度自定义组件改适配层源码改动隔离性高只影响指定页面低影响全局所有 Image实现成本中等约一周较高要熟悉适配层渲染流程RN 升级影响几乎无每次升级都要重新打补丁对业务侵入需替换组件无侵入适合场景少量页面、特定效果全 App 都要用、长期维护适配层6.2 我为什么选自定义组件这个项目里的混合模式只出现在海报编辑页其他页面用不到。自定义组件可以把改动限制在一块很小的代码里出问题回滚也快。而且 RN-OHOS 适配层的发版节奏明显慢于原生 OpenHarmony直接改源码意味着每次升级都要和老旧的补丁做一次痛苦地合并太不划算了。还有一个隐藏成本改适配层源码意味着所有使用 RN Image 的页面都可能在这次改动里有回归风险。哪怕你只是加了一个可选属性也需要重新跑一遍全量图片场景的回归测试。自定义组件就没有这个问题因为你不改核心渲染路径只新增了一个独立组件测试范围是可控的。6.3 什么时候该改适配层如果你的业务是全 App 范围内的图片都要支持混合模式且你有专门的人长期维护 RN-OHOS 分支那直接改适配层确实值得。或者你的场景需要把混合结果应用在瀑布流里大量复用的普通 Image 上自定义组件在原生视图层面的创建成本会比适配层方案高这时候也该考虑改适配层。取舍的核心是这个能力到底是某一个页面的需求还是全局的基础能力。前者用自定义组件后者才考虑改适配层。7. 写在最后这套方案的边界和还能怎么扩展最后补几个实际验证时值得注意的点。验证混合模式对不对最可靠的方式是三层对比Web 端用 CSS 渲染一张基准图Android/iOS 上各渲染一张OpenHarmony 真机上再渲染一张三张图放到一起看差异。用截图自动对比像素也可以但注意 OpenHarmony 真机的色彩管理、色域映射和 Android 不一样纯数字对比会有一定的误报最好先人工看一遍再定阈值。这个方案能扩展的方向也不少。比如在海报里叠渐变遮罩、图片阴影、文字水印只要把它们当作第二层 source 喂给BlendImage理论上都能实现。前提是第二层也要是PixelMap或者能解码成PixelMap的资源。另一个思路是纯离线处理如果混合效果是产品固定的不需要用户实时调节完全可以让服务端提前把两张图合成好客户端只需要加载一张图。这样性能最优也不需要考虑混合模式的兼容性。我个人在实际项目里的体会是RN 加 OpenHarmony 的组合最大的成本不是写代码而是找边界——你永远要先搞清楚能力到底在哪一层是 JS 层、适配层、ArkUI 层还是底层绘制引擎。混合模式这个需求恰好把每一层的边界都撞了一遍。下次如果再遇到别的样式属性在鸿蒙上失效先别急着骂渲染引擎按JS 是否传了 → 适配层是否接了 → ArkUI 是否有对应能力 → 绘制引擎是否支持这条链路查下去绝大多数问题都能定位到具体环节。