在我们调试Flutter 鸿蒙应用过程中,如果遇到视频播放画面黑屏卡顿,相机预览画面不更新,动画卡等异常场景...那么今天的这篇关于外接纹理异常的专题应该能帮助到你!
一、外接纹理概念
外接纹理(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 上面,叠加、动画、裁剪会有各种边界问题。
二、外接纹理注册流程
如果你的日志显示如下,代表就成功了:
css
I Flutter: RegisterExternalTexture api type 2 texture_id 1
I Flutter: OH_NativeImage_AcquireNativeWindow() success
I Flutter: OH_NativeImage_GetSurfaceId() success, surfaceId = 12345678
注销的时候,必须先停生产者,再注销纹理。如果代码顺序搞反了就会出现崩溃问题:
scss
// 正确顺序
stopProducer();
// 1. 先停:暂停视频 / 相机
textureRegistry.unregisterTexture(textureId);
// 2. 后注销
// 错误顺序:先注销,生产者还在写入 → 崩溃
可以基于以下代码加深理解:
ArtTS侧:
typescript
/*
* 外接纹理注册演示插件(ArkTS 侧)
*
* 流程与引擎源码的对应:
* registerTexture(textureId)
* → 引擎内: OH_NativeImage_Create + AcquireNativeWindow + GetSurfaceId
* → 返回 SurfaceTextureEntry, getSurfaceId() 即"给生产者用的地址"
* AVPlayer 把 surfaceId 设上后, 解码出的每一帧都写进这条传送带
* 引擎收到帧回调(OnNativeImageFrameAvailable)后通知 Flutter 重绘 Texture 控件
*
* 写法参照本机 video_player_ohos / camera_ohos 官方插件实现。
*/
import { MethodCall, MethodCallHandler, MethodChannel, MethodResult } from '@ohos/flutter_ohos';
import { FlutterPlugin, FlutterPluginBinding } from '@ohos/flutter_ohos/src/main/ets/embedding/engine/plugins/FlutterPlugin';
import { TextureRegistry } from '@ohos/flutter_ohos/src/main/ets/view/TextureRegistry';
import media from '@ohos.multimedia.media';
import { BusinessError } from '@kit.BasicServicesKit';
const TAG: string = 'NativeVideoTexture';
/** 一路视频 = 一个生产者(AVPlayer) + 一条传送带(注册进引擎的外接纹理) */
class VideoTextureEntry {
textureId: number = -1;
surfaceId: string = '';
avPlayer: media.AVPlayer | null = null;
releasePending: boolean = false; // AVPlayer.release 是异步的, 记住"谁先谁后"
}
export class NativeVideoTexturePlugin implements FlutterPlugin, MethodCallHandler {
private static readonly CHANNEL = 'demo.native_video/texture';
private channel: MethodChannel | null = null;
private textureRegistry: TextureRegistry | null = null;
private entries: Map<number, VideoTextureEntry> = new Map();
// ---------- 插件生命周期 ----------
onAttachedToEngine(binding: FlutterPluginBinding): void {
this.textureRegistry = binding.getTextureRegistry();
this.channel = new MethodChannel(binding.getBinaryMessenger(), NativeVideoTexturePlugin.CHANNEL);
this.channel.setMethodCallHandler(this);
}
onDetachedFromEngine(binding: FlutterPluginBinding): void {
this.channel?.setMethodCallHandler(null);
this.channel = null;
this.textureRegistry = null;
}
// ---------- MethodChannel 分发 ----------
onMethodCall(call: MethodCall, result: MethodResult): void {
switch (call.method) {
case 'create':
// uri 例: 'https://xxx.mp4'
this.create(call.argument('uri') as string, result);
break;
case 'play':
this.play(call.argument('textureId') as number, result);
break;
case 'pause':
this.pause(call.argument('textureId') as number, result);
break;
case 'dispose':
this.dispose(call.argument('textureId') as number, result);
break;
default:
result.notImplemented();
}
}
// ---------- 核心: 注册三步 ----------
private async create(uri: string, result: MethodResult): Promise<void> {
const registry = this.textureRegistry;
if (registry === null) {
result.error('NO_REGISTRY', 'TextureRegistry not ready', null);
return;
}
const entry = new VideoTextureEntry();
// ★ 注册三步(引擎内完成 ①创建OH_NativeImage ②取NativeWindow ③取surfaceId ④注册到引擎)
entry.textureId = registry.getTextureId();
const surfaceEntry = registry.registerTexture(entry.textureId); // ①②③④ 一次完成
entry.surfaceId = surfaceEntry.getSurfaceId().toString(); // 生产者要用的"地址"
console.info(`${TAG} texture_id=${entry.textureId} surfaceId=${entry.surfaceId}`);
// 生产者: AVPlayer 解码每一帧都写入 surfaceId 对应的传送带
try {
const avPlayer = await media.createAVPlayer();
entry.avPlayer = avPlayer;
avPlayer.on('stateChange', async (state: string) => {
switch (state) {
case 'idle':
avPlayer.url = uri; // 设置源后进入 initialized
break;
case 'initialized':
// ★ surfaceId 必须在 prepare 之前设置(官方 video_player_ohos 同款时机),
// 晚了生产者没有窗口可写 → 黑屏, 日志会出现 No DlImage available
avPlayer.surfaceId = entry.surfaceId;
avPlayer.prepare();
break;
case 'prepared':
// 画面尺寸确定后设置生产者窗口尺寸, 防拉伸/黑边
// (画面尺寸变化时同样要调用, 配合 notifyTextureResizing)
registry.setTextureBufferSize(entry.textureId, avPlayer.width, avPlayer.height);
avPlayer.play();
break;
case 'released':
// AVPlayer 真正释放完毕后才注销纹理(先停生产者的"完成信号")
if (entry.releasePending) {
registry.unregisterTexture(entry.textureId); // 传送带拆除
this.entries.delete(entry.textureId);
}
break;
default:
break;
}
});
this.entries.set(entry.textureId, entry);
result.success(entry.textureId); // 把 textureId 交给 Dart 侧的 Texture 控件
} catch (e) {
// 创建失败也要把已注册的纹理拆掉, 否则引擎里留死纹理
registry.unregisterTexture(entry.textureId);
result.error('CREATE_FAILED', `${e}`, null);
}
}
private play(textureId: number, result: MethodResult): void {
const entry = this.entries.get(textureId);
entry?.avPlayer?.play();
result.success(true);
}
private pause(textureId: number, result: MethodResult): void {
const entry = this.entries.get(textureId);
entry?.avPlayer?.pause();
result.success(true);
}
// ---------- 释放: 铁律 = 先停生产者, 再注销纹理 ----------
private dispose(textureId: number, result: MethodResult): void {
const entry = this.entries.get(textureId);
if (entry === undefined || entry.avPlayer === null) {
// 播放器已不在, 直接拆传送带
this.textureRegistry?.unregisterTexture(textureId);
this.entries.delete(textureId);
result.success(true);
return;
}
// 先停生产者: release 是异步的, 真正 released 后才在 stateChange 回调里
// unregisterTexture ------ 顺序反了会出现"纹理释放后还在访问"崩溃
entry.releasePending = true;
entry.avPlayer.release().catch((err: BusinessError) => {
console.error(`${TAG} release failed: ${err.code} ${err.message}`);
this.textureRegistry?.unregisterTexture(textureId);
this.entries.delete(textureId);
});
result.success(true);
}
}
Dart侧:
dart
/// 外接纹理演示 ------ Dart 侧通道封装
///
/// ArkTS 侧完成注册后只回传一个 textureId,
/// Dart 侧用它构建 Texture 控件, 画面即从原生传送带流入 Flutter。
library;
import 'dart:async';
import 'package:flutter/services.dart';
/// 一路原生视频纹理。
///
/// 用法:
/// ```dart
/// final video = await NativeVideoTexture.create(uri);
/// video.play();
/// Texture(textureId: video.textureId);
/// video.dispose(); // 必须调用: 先停生产者再注销(插件内已保证顺序)
/// ```
class NativeVideoTexture {
static const MethodChannel _channel =
MethodChannel('demo.native_video/texture');
/// 注册一路纹理并创建 AVPlayer 生产者, 返回可用的 [NativeVideoTexture]。
static Future<NativeVideoTexture> create(String uri) async {
final textureId =
await _channel.invokeMethod<int>('create', {'uri': uri});
if (textureId == null) {
throw PlatformException(
code: 'CREATE_FAILED', message: 'textureId 为空, 注册失败');
}
return NativeVideoTexture._(textureId);
}
NativeVideoTexture._(this.textureId);
/// 传给 Texture 控件的 id。
final int textureId;
bool _disposed = false;
Future<void> play() =>
_channel.invokeMethod('play', {'textureId': textureId});
Future<void> pause() =>
_channel.invokeMethod('pause', {'textureId': textureId});
/// 释放: 插件内先 release 生产者(AVPlayer), 等 released 后再注销纹理。
///
/// 顺序反了 = "快递箱扔了还有人往里放东西" → 纹理访问崩溃。
Future<void> dispose() async {
if (_disposed) return;
_disposed = true;
await _channel.invokeMethod('dispose', {'textureId': textureId});
}
}
php
/// 外接纹理演示页:原生视频画面显示在 Flutter 里
///
/// 页面结构: 按钮触发注册 → Texture 控件展示 → 退出时按"先停生产者再注销"释放。
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'native_video_texture.dart';
class TextureDemoPage extends StatefulWidget {
const TextureDemoPage({super.key, this.uri = 'https://media.w3.org/2010/05/sintel/trailer.mp4'});
/// 换成你自己的视频地址
final String uri;
@override
State<TextureDemoPage> createState() => _TextureDemoPageState();
}
class _TextureDemoPageState extends State<TextureDemoPage> {
NativeVideoTexture? _video;
String? _error;
Future<void> _create() async {
setState(() => _error = null);
try {
final video = await NativeVideoTexture.create(widget.uri);
setState(() => _video = video);
} on PlatformException catch (e) {
// 对应黑屏排查图第 1 步: 日志里搜不到 RegisterExternalTexture
// 说明注册链路就断了, 看这里拿到什么错误
setState(() => _error = '${e.code}: ${e.message}');
}
}
@override
void dispose() {
// 铁律: 先停生产者(AVPlayer.release), 再注销纹理(unregisterTexture)
// ------ 顺序在插件 dispose 内部保证, 这里只管调用
_video?.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final video = _video;
return Scaffold(
appBar: AppBar(title: const Text('外接纹理演示')),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (video != null)
// ★ 画面入口: textureId 对上, 传送带上的帧就会画进这一块
AspectRatio(
aspectRatio: 16 / 9,
child: Texture(textureId: video.textureId),
)
else if (_error != null)
Padding(
padding: const EdgeInsets.all(24),
child: Text('创建失败: $_error',
style: const TextStyle(color: Colors.red)),
)
else
const Text('点击下方按钮注册纹理并开始播放'),
const SizedBox(height: 24),
if (video != null)
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
IconButton(
onPressed: video.play, icon: const Icon(Icons.play_arrow)),
IconButton(
onPressed: video.pause, icon: const Icon(Icons.pause)),
],
)
else
FilledButton(onPressed: _create, child: const Text('创建纹理 + 播放')),
],
),
),
);
}
}
EntryAbility:
javascript
import { FlutterAbility, FlutterEngine } from '@ohos/flutter_ohos';
import { GeneratedPluginRegistrant } from '../plugins/GeneratedPluginRegistrant';
import { NativeVideoTexturePlugin } from '../plugins/NativeVideoTexturePlugin';
export default class EntryAbility extends FlutterAbility {
configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
GeneratedPluginRegistrant.registerWith(flutterEngine)
flutterEngine.getPlugins()?.add(new NativeVideoTexturePlugin()) // ← 加这行
}
}
三、外接纹理在哪些场景会用到,常见的问题有哪些?
以下业务场景通常会用到外接纹理,场景:(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再牛,技术不能丢"