一、外接纹理概念
外接纹理(External Texture) :是 Flutter Engine 提供的一种外部图像数据接入机制,允许鸿蒙原生侧产生的 GPU 图像帧直接注册到 Flutter 渲染管线,Flutter 通过Texture Widget在 Dart 层进行布局、变换、叠加渲染,而不是把图像像素拷贝到 Dart 内存里。
外接纹理与PlatformView 的区别:
外接纹理:原生输出 GPU 纹理帧,由 Flutter 统一合成,属于 Flutter LayerTree 里的TextureLayer。支持 Flutter 的动画、旋转、缩放、圆角、透明度叠加;z 轴和 Flutter UI 完全融合。
PlatformView:原生是独立的 XComponent 窗口,Flutter 只是 “挖个洞” 把原生视图盖在 Flutter 上面,叠加、动画、裁剪会有各种边界问题。
二、外接纹理注册流程
如果你的日志显示如下,代表就成功了:
<span>I</span> Flutter: RegisterExternalTexture api type <span>2</span> texture_id <span>1</span>
I Flutter: <span>OH_NativeImage_AcquireNativeWindow</span>() success
I Flutter: <span>OH_NativeImage_GetSurfaceId</span>() success, surfaceId = <span>12345678</span>
注销的时候,必须先停生产者,再注销纹理。如果代码顺序搞反了就会出现崩溃问题:
<span>// 正确顺序</span>
<span>stopProducer</span>();
<span>// 1. 先停:暂停视频 / 相机</span>
textureRegistry<span>.unregisterTexture</span>(textureId);
<span>// 2. 后注销</span>
<span>// 错误顺序:先注销,生产者还在写入 → 崩溃</span>
可以基于以下代码加深理解:
ArtTS侧:
<span>/*
* 外接纹理注册演示插件(ArkTS 侧)
*
* 流程与引擎源码的对应:
* registerTexture(textureId)
* → 引擎内: OH_NativeImage_Create + AcquireNativeWindow + GetSurfaceId
* → 返回 SurfaceTextureEntry, getSurfaceId() 即"给生产者用的地址"
* AVPlayer 把 surfaceId 设上后, 解码出的每一帧都写进这条传送带
* 引擎收到帧回调(OnNativeImageFrameAvailable)后通知 Flutter 重绘 Texture 控件
*
* 写法参照本机 video_player_ohos / camera_ohos 官方插件实现。
*/</span>
<span>import</span> { <span>MethodCall</span>, <span>MethodCallHandler</span>, <span>MethodChannel</span>, <span>MethodResult</span> } <span>from</span> <span>'@ohos/flutter_ohos'</span>;
<span>import</span> { <span>FlutterPlugin</span>, <span>FlutterPluginBinding</span> } <span>from</span> <span>'@ohos/flutter_ohos/src/main/ets/embedding/engine/plugins/FlutterPlugin'</span>;
<span>import</span> { <span>TextureRegistry</span> } <span>from</span> <span>'@ohos/flutter_ohos/src/main/ets/view/TextureRegistry'</span>;
<span>import</span> media <span>from</span> <span>'@ohos.multimedia.media'</span>;
<span>import</span> { <span>BusinessError</span> } <span>from</span> <span>'@kit.BasicServicesKit'</span>;
<span>const</span> <span>TAG</span>: <span>string</span> = <span>'NativeVideoTexture'</span>;
<span>/** 一路视频 = 一个生产者(AVPlayer) + 一条传送带(注册进引擎的外接纹理) */</span>
<span>class</span> <span>VideoTextureEntry</span> {
<span>textureId</span>: <span>number</span> = -<span>1</span>;
<span>surfaceId</span>: <span>string</span> = <span>''</span>;
<span>avPlayer</span>: media.<span>AVPlayer</span> | <span>null</span> = <span>null</span>;
<span>releasePending</span>: <span>boolean</span> = <span>false</span>; <span>// AVPlayer.release 是异步的, 记住"谁先谁后"</span>
}
<span>export</span> <span>class</span> <span>NativeVideoTexturePlugin</span> <span>implements</span> <span>FlutterPlugin</span>, <span>MethodCallHandler</span> {
<span>private</span> <span>static</span> <span>readonly</span> <span>CHANNEL</span> = <span>'demo.native_video/texture'</span>;
<span>private</span> <span>channel</span>: <span>MethodChannel</span> | <span>null</span> = <span>null</span>;
<span>private</span> <span>textureRegistry</span>: <span>TextureRegistry</span> | <span>null</span> = <span>null</span>;
<span>private</span> <span>entries</span>: <span>Map</span><<span>number</span>, <span>VideoTextureEntry</span>> = <span>new</span> <span>Map</span>();
<span>// ---------- 插件生命周期 ----------</span>
<span>onAttachedToEngine</span>(<span>binding</span>: <span>FlutterPluginBinding</span>): <span>void</span> {
<span>this</span>.<span>textureRegistry</span> = binding.<span>getTextureRegistry</span>();
<span>this</span>.<span>channel</span> = <span>new</span> <span>MethodChannel</span>(binding.<span>getBinaryMessenger</span>(), <span>NativeVideoTexturePlugin</span>.<span>CHANNEL</span>);
<span>this</span>.<span>channel</span>.<span>setMethodCallHandler</span>(<span>this</span>);
}
<span>onDetachedFromEngine</span>(<span>binding</span>: <span>FlutterPluginBinding</span>): <span>void</span> {
<span>this</span>.<span>channel</span>?.<span>setMethodCallHandler</span>(<span>null</span>);
<span>this</span>.<span>channel</span> = <span>null</span>;
<span>this</span>.<span>textureRegistry</span> = <span>null</span>;
}
<span>// ---------- MethodChannel 分发 ----------</span>
<span>onMethodCall</span>(<span>call</span>: <span>MethodCall</span>, <span>result</span>: <span>MethodResult</span>): <span>void</span> {
<span>switch</span> (call.<span>method</span>) {
<span>case</span> <span>'create'</span>:
<span>// uri 例: 'https://xxx.mp4'</span>
<span>this</span>.<span>create</span>(call.<span>argument</span>(<span>'uri'</span>) <span>as</span> <span>string</span>, result);
<span>break</span>;
<span>case</span> <span>'play'</span>:
<span>this</span>.<span>play</span>(call.<span>argument</span>(<span>'textureId'</span>) <span>as</span> <span>number</span>, result);
<span>break</span>;
<span>case</span> <span>'pause'</span>:
<span>this</span>.<span>pause</span>(call.<span>argument</span>(<span>'textureId'</span>) <span>as</span> <span>number</span>, result);
<span>break</span>;
<span>case</span> <span>'dispose'</span>:
<span>this</span>.<span>dispose</span>(call.<span>argument</span>(<span>'textureId'</span>) <span>as</span> <span>number</span>, result);
<span>break</span>;
<span>default</span>:
result.<span>notImplemented</span>();
}
}
<span>// ---------- 核心: 注册三步 ----------</span>
<span>private</span> <span>async</span> <span>create</span>(<span>uri</span>: <span>string</span>, <span>result</span>: <span>MethodResult</span>): <span>Promise</span><<span>void</span>> {
<span>const</span> registry = <span>this</span>.<span>textureRegistry</span>;
<span>if</span> (registry === <span>null</span>) {
result.<span>error</span>(<span>'NO_REGISTRY'</span>, <span>'TextureRegistry not ready'</span>, <span>null</span>);
<span>return</span>;
}
<span>const</span> entry = <span>new</span> <span>VideoTextureEntry</span>();
<span>// ★ 注册三步(引擎内完成 ①创建OH_NativeImage ②取NativeWindow ③取surfaceId ④注册到引擎)</span>
entry.<span>textureId</span> = registry.<span>getTextureId</span>();
<span>const</span> surfaceEntry = registry.<span>registerTexture</span>(entry.<span>textureId</span>); <span>// ①②③④ 一次完成</span>
entry.<span>surfaceId</span> = surfaceEntry.<span>getSurfaceId</span>().<span>toString</span>(); <span>// 生产者要用的"地址"</span>
<span>console</span>.<span>info</span>(<span>`<span>${TAG}</span> texture_id=<span>${entry.textureId}</span> surfaceId=<span>${entry.surfaceId}</span>`</span>);
<span>// 生产者: AVPlayer 解码每一帧都写入 surfaceId 对应的传送带</span>
<span>try</span> {
<span>const</span> avPlayer = <span>await</span> media.<span>createAVPlayer</span>();
entry.<span>avPlayer</span> = avPlayer;
avPlayer.<span>on</span>(<span>'stateChange'</span>, <span>async</span> (<span>state</span>: <span>string</span>) => {
<span>switch</span> (state) {
<span>case</span> <span>'idle'</span>:
avPlayer.<span>url</span> = uri; <span>// 设置源后进入 initialized</span>
<span>break</span>;
<span>case</span> <span>'initialized'</span>:
<span>// ★ surfaceId 必须在 prepare 之前设置(官方 video_player_ohos 同款时机),</span>
<span>// 晚了生产者没有窗口可写 → 黑屏, 日志会出现 No DlImage available</span>
avPlayer.<span>surfaceId</span> = entry.<span>surfaceId</span>;
avPlayer.<span>prepare</span>();
<span>break</span>;
<span>case</span> <span>'prepared'</span>:
<span>// 画面尺寸确定后设置生产者窗口尺寸, 防拉伸/黑边</span>
<span>// (画面尺寸变化时同样要调用, 配合 notifyTextureResizing)</span>
registry.<span>setTextureBufferSize</span>(entry.<span>textureId</span>, avPlayer.<span>width</span>, avPlayer.<span>height</span>);
avPlayer.<span>play</span>();
<span>break</span>;
<span>case</span> <span>'released'</span>:
<span>// AVPlayer 真正释放完毕后才注销纹理(先停生产者的"完成信号")</span>
<span>if</span> (entry.<span>releasePending</span>) {
registry.<span>unregisterTexture</span>(entry.<span>textureId</span>); <span>// 传送带拆除</span>
<span>this</span>.<span>entries</span>.<span>delete</span>(entry.<span>textureId</span>);
}
<span>break</span>;
<span>default</span>:
<span>break</span>;
}
});
<span>this</span>.<span>entries</span>.<span>set</span>(entry.<span>textureId</span>, entry);
result.<span>success</span>(entry.<span>textureId</span>); <span>// 把 textureId 交给 Dart 侧的 Texture 控件</span>
} <span>catch</span> (e) {
<span>// 创建失败也要把已注册的纹理拆掉, 否则引擎里留死纹理</span>
registry.<span>unregisterTexture</span>(entry.<span>textureId</span>);
result.<span>error</span>(<span>'CREATE_FAILED'</span>, <span>`<span>${e}</span>`</span>, <span>null</span>);
}
}
<span>private</span> <span>play</span>(<span>textureId</span>: <span>number</span>, <span>result</span>: <span>MethodResult</span>): <span>void</span> {
<span>const</span> entry = <span>this</span>.<span>entries</span>.<span>get</span>(textureId);
entry?.<span>avPlayer</span>?.<span>play</span>();
result.<span>success</span>(<span>true</span>);
}
<span>private</span> <span>pause</span>(<span>textureId</span>: <span>number</span>, <span>result</span>: <span>MethodResult</span>): <span>void</span> {
<span>const</span> entry = <span>this</span>.<span>entries</span>.<span>get</span>(textureId);
entry?.<span>avPlayer</span>?.<span>pause</span>();
result.<span>success</span>(<span>true</span>);
}
<span>// ---------- 释放: 铁律 = 先停生产者, 再注销纹理 ----------</span>
<span>private</span> <span>dispose</span>(<span>textureId</span>: <span>number</span>, <span>result</span>: <span>MethodResult</span>): <span>void</span> {
<span>const</span> entry = <span>this</span>.<span>entries</span>.<span>get</span>(textureId);
<span>if</span> (entry === <span>undefined</span> || entry.<span>avPlayer</span> === <span>null</span>) {
<span>// 播放器已不在, 直接拆传送带</span>
<span>this</span>.<span>textureRegistry</span>?.<span>unregisterTexture</span>(textureId);
<span>this</span>.<span>entries</span>.<span>delete</span>(textureId);
result.<span>success</span>(<span>true</span>);
<span>return</span>;
}
<span>// 先停生产者: release 是异步的, 真正 released 后才在 stateChange 回调里</span>
<span>// unregisterTexture —— 顺序反了会出现"纹理释放后还在访问"崩溃</span>
entry.<span>releasePending</span> = <span>true</span>;
entry.<span>avPlayer</span>.<span>release</span>().<span>catch</span>(<span>(<span>err: BusinessError</span>) =></span> {
<span>console</span>.<span>error</span>(<span>`<span>${TAG}</span> release failed: <span>${err.code}</span> <span>${err.message}</span>`</span>);
<span>this</span>.<span>textureRegistry</span>?.<span>unregisterTexture</span>(textureId);
<span>this</span>.<span>entries</span>.<span>delete</span>(textureId);
});
result.<span>success</span>(<span>true</span>);
}
}
Dart侧:
<span>///<span> 外接纹理演示 —— Dart 侧通道封装</span></span>
<span>///</span>
<span>///<span> ArkTS 侧完成注册后只回传一个 textureId,</span></span>
<span>///<span> Dart 侧用它构建 Texture 控件, 画面即从原生传送带流入 Flutter。</span></span>
<span>library</span>;
<span>import</span> <span>'dart:async'</span>;
<span>import</span> <span>'package:flutter/services.dart'</span>;
<span>///<span> 一路原生视频纹理。</span></span>
<span>///</span>
<span>///<span> 用法:</span></span>
<span>///<span> <span>```dart</span></span></span>
<span>///<span><span> final video = await NativeVideoTexture.create(uri);</span></span></span>
<span>///<span><span> video.play();</span></span></span>
<span>///<span><span> Texture(textureId: video.textureId);</span></span></span>
<span>///<span><span> video.dispose(); // 必须调用: 先停生产者再注销(插件内已保证顺序)</span></span></span>
<span>///<span><span> ```</span></span></span>
<span><span>class</span> <span>NativeVideoTexture</span> </span>{
<span>static</span> <span>const</span> MethodChannel _channel =
MethodChannel(<span>'demo.native_video/texture'</span>);
<span>///<span> 注册一路纹理并创建 AVPlayer 生产者, 返回可用的 [NativeVideoTexture]。</span></span>
<span>static</span> Future<NativeVideoTexture> create(<span>String</span> uri) <span>async</span> {
<span>final</span> textureId =
<span>await</span> _channel.invokeMethod<<span>int</span>>(<span>'create'</span>, {<span>'uri'</span>: uri});
<span>if</span> (textureId == <span>null</span>) {
<span>throw</span> PlatformException(
code: <span>'CREATE_FAILED'</span>, message: <span>'textureId 为空, 注册失败'</span>);
}
<span>return</span> NativeVideoTexture._(textureId);
}
NativeVideoTexture._(<span>this</span>.textureId);
<span>///<span> 传给 Texture 控件的 id。</span></span>
<span>final</span> <span>int</span> textureId;
<span>bool</span> _disposed = <span>false</span>;
Future<<span>void</span>> play() =>
_channel.invokeMethod(<span>'play'</span>, {<span>'textureId'</span>: textureId});
Future<<span>void</span>> pause() =>
_channel.invokeMethod(<span>'pause'</span>, {<span>'textureId'</span>: textureId});
<span>///<span> 释放: 插件内先 release 生产者(AVPlayer), 等 released 后再注销纹理。</span></span>
<span>///</span>
<span>///<span> 顺序反了 = "快递箱扔了还有人往里放东西" → 纹理访问崩溃。</span></span>
Future<<span>void</span>> dispose() <span>async</span> {
<span>if</span> (_disposed) <span>return</span>;
_disposed = <span>true</span>;
<span>await</span> _channel.invokeMethod(<span>'dispose'</span>, {<span>'textureId'</span>: textureId});
}
}
<span>/// 外接纹理演示页:原生视频画面显示在 Flutter 里</span>
<span>///</span>
<span>/// 页面结构: 按钮触发注册 → Texture 控件展示 → 退出时按"先停生产者再注销"释放。</span>
import <span>'package:flutter/material.dart'</span>;
import <span>'package:flutter/services.dart'</span>;
import <span>'native_video_texture.dart'</span>;
<span><span>class</span> <span>TextureDemoPage</span> <span>extends</span> <span>StatefulWidget</span> </span>{
<span>const</span> <span>TextureDemoPage</span>({super.key, this.uri = <span>'https://media.w3.org/2010/05/sintel/trailer.mp4'</span>});
<span>/// 换成你自己的视频地址</span>
<span>final</span> String uri;
@override
State<TextureDemoPage> <span>createState</span>() => <span>_TextureDemoPageState</span>();
}
<span><span>class</span> <span>_TextureDemoPageState</span> <span>extends</span> <span>State</span><<span>TextureDemoPage</span>> </span>{
NativeVideoTexture? _video;
String? _error;
Future<<span>void</span>> <span>_create</span>() async {
<span>setState</span>(() => _error = <span>null</span>);
<span>try</span> {
<span>final</span> video = await NativeVideoTexture.<span>create</span>(widget.uri);
<span>setState</span>(() => _video = video);
} on <span>PlatformException catch </span>(e) {
<span>// 对应黑屏排查图第 1 步: 日志里搜不到 RegisterExternalTexture</span>
<span>// 说明注册链路就断了, 看这里拿到什么错误</span>
<span>setState</span>(() => _error = <span>'${e.code}: ${e.message}'</span>);
}
}
@override
<span>void</span><span> dispose</span>() {
<span>// 铁律: 先停生产者(AVPlayer.release), 再注销纹理(unregisterTexture)</span>
<span>// —— 顺序在插件 dispose 内部保证, 这里只管调用</span>
_video?.<span>dispose</span>();
super.<span>dispose</span>();
}
@override
<span>Widget build</span>(BuildContext context) {
<span>final</span> video = _video;
<span>return</span><span> Scaffold</span>(
<span> appBar</span>: <span>AppBar</span>(<span>title</span>: <span>const</span><span> Text</span>(<span>'外接纹理演示'</span>)),
<span> body</span>: <span>Center</span>(
<span> child</span>: <span>Column</span>(
<span> mainAxisAlignment</span>: MainAxisAlignment.center,
<span> children</span>: [
<span>if</span><span> </span>(video != <span>null</span>)
// ★ 画面入口: textureId 对上, 传送带上的帧就会画进这一块
<span>AspectRatio</span>(
<span> aspectRatio</span>: <span>16</span> / <span>9</span>,
<span> child</span>: <span>Texture</span>(<span>textureId</span>: video.textureId),
)
<span>else</span><span> if </span>(_error != <span>null</span>)
<span>Padding</span>(
<span> padding</span>: <span>const</span> EdgeInsets.<span>all</span>(<span>24</span>),
<span> child</span>: <span>Text</span>(<span>'创建失败: $_error'</span>,
<span> style</span>: <span>const</span><span> TextStyle</span>(<span>color</span>: Colors.red)),
)
<span>else</span>
<span>const</span><span> Text</span>(<span>'点击下方按钮注册纹理并开始播放'</span>),
<span>const</span><span> SizedBox</span>(<span>height</span>: <span>24</span>),
<span>if</span><span> </span>(video != <span>null</span>)
<span>Row</span>(
<span> mainAxisAlignment</span>: MainAxisAlignment.center,
<span> children</span>: [
<span>IconButton</span>(
<span> onPressed</span>: video.play,<span> icon</span>: <span>const</span><span> Icon</span>(Icons.play_arrow)),
<span>IconButton</span>(
<span> onPressed</span>: video.pause,<span> icon</span>: <span>const</span><span> Icon</span>(Icons.pause)),
],
)
<span>else</span>
<span>FilledButton</span>(<span>onPressed</span>: _create,<span> child</span>: <span>const</span><span> Text</span>(<span>'创建纹理 + 播放'</span>)),
],
),
),
);
}
}
EntryAbility:
<span>import</span> { <span>FlutterAbility</span>, <span>FlutterEngine</span> } <span>from</span> <span>'@ohos/flutter_ohos'</span>;
<span>import</span> { <span>GeneratedPluginRegistrant</span> } <span>from</span> <span>'../plugins/GeneratedPluginRegistrant'</span>;
<span>import</span> { <span>NativeVideoTexturePlugin</span> } <span>from</span> <span>'../plugins/NativeVideoTexturePlugin'</span>;
<span>export</span> <span>default</span> <span>class</span> <span>EntryAbility</span> <span>extends</span> <span>FlutterAbility</span> {
<span>configureFlutterEngine</span>(<span>flutterEngine: FlutterEngine</span>) {
<span>super</span>.<span>configureFlutterEngine</span>(flutterEngine)
<span>GeneratedPluginRegistrant</span>.<span>registerWith</span>(flutterEngine)
flutterEngine.<span>getPlugins</span>()?.<span>add</span>(<span>new</span> <span>NativeVideoTexturePlugin</span>()) <span>// ← 加这行</span>
}
}
三、外接纹理在哪些场景会用到,常见的问题有哪些?
以下业务场景通常会用到外接纹理,场景:(1)视频播放(2)相机预览(3)动画播放(4)WebView(5)直播 SDK等
可以按以下现象初步判断下问题在哪:
| 看到的现象 | 可能的原因 |
|---|---|
| 视频、相机画面黑屏 | 纹理创建失败 / 生产者没产出帧 |
| 画面第一帧后不更新 | 帧闸门开启 / Surface 销毁 / onInactive 被误触发 |
| 画面卡顿、丢帧 | 消费过慢 / 跳帧 |
| 画面拉伸、变形 | 尺寸变更未收敛 |
| 应用闪退 | 纹理释放后还在访问 |
| 退后台后还在耗电 | 可见区域监控未启用 |
四、黑屏或不显示 如何排查
常见问题原因及修复方法
| 原因 | 怎么修 |
|---|---|
| 纹理未注册 | 检查注册代码 |
| NativeImage 创建失败 | 检查系统资源 |
| 生产者没产出帧 | 确保视频已开始播放、相机已启动 |
| Texture 控件 size 为 0 | 检查 Widget 布局 |
| surfaceId 不对 | 确认 surfaceId 传递正确 |
五、卡顿 / 丢帧 如何排查
四步进行排查:
(1)搜 skip one frame(slow consumer)搜到说明消费过慢引擎在跳帧,需要检查 Raster 线程是否被其他任务阻塞,同时检查 buffer_queue_size 是否过小。
(2)搜 MarkNewFrameAvailable avail-seq:avail-seq 不增长,是生产者没产出帧;avail-seq 增长但 paint-seq 不增长,那就是 Raster 线程卡了。
(3)搜 GpuReclaim:搜到说明 GPU 回收导致中断了。
(4)搜 get error buffer queue size:搜到说明缓冲队列异常(超过 100)。
六、拉伸 / 变形 如何排查
搜 size change 相关日志:
| 日志 | 含义 |
|---|---|
| size change took N frames | 尺寸变更在 N 帧内完成,正常 |
| stop size change state: frame > 10 | 尺寸变更超过 10 帧,异常 |
| direct release size changed buffer | 缓冲尺寸变了但绘制区域没变,防拉伸 |
修复方法:确保 setTextureBufferSize 和 notifyTextureResizing 调用一致。
七、以下为日志关键字速查表
| 关键字 | 含义 | 程度 |
|---|---|---|
| RegisterExternalTexture api type | 纹理注册 | — |
| OH\_NativeImage\_Create() failed | 创建失败 | 高 |
| No DlImage available | 无可绘制画面,黑屏 | 中 |
| frame gate enabled, drain-only | 后台帧闸门开启 | 正常 |
| skip one frame(slow consumer) | 消费过慢跳帧 | 中 |
| MarkNewFrameAvailable avail-seq | 帧序号监控 | — |
| OnGrContextCreated texture\_id | GPU 上下文重建 | — |
| size change took N frames | 尺寸变更完成 | 正常 |
| PlatformViewVisibleAreaEventCallback | 可见区域变化 | — |
| UnRegisterExternalTexture | 纹理注销 | — |
| ~OHOSExternalTexture | 纹理析构 | — |
外接纹理相关的内容比较多,本次主要是讲个概念和基本的排查方法,希望能帮助到大家~更进一步的排查方法就需要依赖工具了,相关专题最近也在进行规划,大家可以持续关注,后续会持续更新:
小伙伴们记得点赞+关注
关注 CPF-Flutter 社区
“AI再牛,技术不能丢”
外接纹理是 Flutter 鸿蒙音视频类应用的排障基石,文中日志关键字速查表实用性强,适合客户端开发与音视频方向同学对照定位渲染异常。