第四个鸿蒙 App——VivLink:把平板变成 Mac 的第二块屏
动手之前,我以为难点在「怎么把画面传过去」:编码、码率、延迟。做完才发现,真正费时间的是另外几件事 ——
第一,鸿蒙目前的公开 API 里,没有「接收外部画面」这一条。 官方那条扩展屏投播的路,解决的是另一个问题。
第二,平板拿到 1080p 后,画面底下有一条永久马赛克,而 720p 完全正常。
第三,也是最费时间的:这个项目里最难查的 bug 都不在代码里,而在那些「看起来成功了」的瞬间。
这篇文章从选型讲起,中间有一块我觉得值得一提的硬骨头 —— 在 macOS 上凭空造出一块系统承认的显示器,最后落到第三件事。
一、先说选型:官方那条路,解决的不是这个问题
鸿蒙官方文档里确实有《扩展屏投播开发指导》,API 23 可用,看起来正是我要的:
// 官方路径(示意)
avSession.createAVSession() → session.on('castDisplayChange')
→ getAllCastDisplays() → startAbility({...}, { displayId }) // 把 UIAbility 画到虚拟扩展屏
但它有三条硬约束,三条都指向同一个结论:
| 约束 | 官方原文的含义 | 对我的影响 |
|---|---|---|
| 方向 | 本端(平板)是投屏源,把自己镜像/扩展到远端 | 我要的是反过来:接收外部画面进 App |
| 远端设备 | 远端须支持 Cast+ 或 Miracast | macOS 默认只做 AirPlay 接收端,不是 Cast+/Miracast sink |
| 使用限制 | 需系统先发起投屏才有有效返回;文档标注为手机设备 | 平板 / 2in1 不在承诺清单 |
实测(0 代码,macOS + MatePad Pro):MatePad 控制中心的「无线投屏」扫描列表里不出现这台 Mac。这与上表第二条完全吻合 —— 不是平板扫不到,是 macOS 默认只做 AirPlay 接收端,本身不是 Cast+ / Miracast sink,所以这台上游设备本就不在它的可选范围内。
结论:这条路我们放弃了。 不是说它不好,是它解决的是另一个问题 —— 它能做的用例只剩「鸿蒙电视当平板的扩展屏」,方向依然相反。
最终路线是:Mac 端单自包含 Companion(自研虚拟屏)+ 自研传输协议 + 鸿蒙端自研客户端。这里有个决定工程形态的事实:
AVCodec Kit 只提供 C 接口,纯 ArkTS 没有低时延解码送显的 API。
所以这个项目从"纯 ArkTS 应用"变成了 ArkTS UI + NDK C++ 媒体层的混合工程:媒体能力(收流 / 组装 / 解码 / 送显)全在 native,ArkTS 只负责 UI、路由与资源,页面把 XComponent 的 surfaceId 交给 native。
| 维度 | 传统 Android / iOS 投屏 | 这套(ArkTS + ArkUI + NDK) |
|---|---|---|
| UI 层 | View 体系 / SwiftUI | ArkUI 声明式,@Component + 状态驱动 |
| 媒体层 | MediaCodec / VideoToolbox(有 Java / Swift 封装) | AVCodec 仅 C 接口 ⇒ 必须 NDK + XComponent |
| 送显方式 | Surface / AVSampleBufferDisplayLayer | Surface 模式解码:NV12 直接写进上屏缓冲 |
| 设备发现 | 各自为政 | mDNS(Bonjour)广播,零配置 |
| 输入回传 | 平台私有 | 自研消息(控制通道)+ Mac 端 CGEvent 注入 |
二、技术点 ①:零拷贝上屏,和一个「720p 正常、1080p 花屏」的坑
鸿蒙端的解码链路短得让人安心:把 surfaceId 交给 native,创建 codec,设好 Surface,之后解码器直接往这块缓冲里写——没有"解出来再转格式再上传"那一层。
// 真实调用序列(节选自 spike_decoder.cpp,省略错误处理)
OH_NativeWindow_CreateNativeWindowFromSurfaceId(surfaceId, &nativeWindow_);
// 🔴 关键一步:把消费端的缓冲几何钉死成码流尺寸
OH_NativeWindow_NativeWindowHandleOpt(nativeWindow_, SET_BUFFER_GEOMETRY, width, height);
codec_ = OH_VideoDecoder_CreateByMime(OH_AVCODEC_MIMETYPE_VIDEO_AVC);
OH_VideoDecoder_RegisterCallback(codec_, cb, this);
OH_AVFormat* format = OH_AVFormat_Create();
OH_AVFormat_SetIntValue(format, OH_MD_KEY_WIDTH, width);
OH_AVFormat_SetIntValue(format, OH_MD_KEY_HEIGHT, height);
OH_AVFormat_SetIntValue(format, OH_MD_KEY_PIXEL_FORMAT, AV_PIXEL_FORMAT_NV12);
OH_AVFormat_SetIntValue(format, OH_MD_KEY_VIDEO_ENABLE_LOW_LATENCY, 1);
OH_VideoDecoder_Configure(codec_, format);
坑就藏在中间那行 SET_BUFFER_GEOMETRY。
症状是:真机 1080p 下,画面底部有一条永久马赛克,而且能扛过 IDR(换关键帧也刷不掉)。更误导人的是:文件解码路径完全干净,只有真机实时 1080p 会出问题。
排查方向本能地指向解码器:是不是 NAL 切分错了?是不是丢包?是不是驱动 bug?——都不是。根因是:
XComponent的 surface 可能协商出一个非 16:9 的默认缓冲几何。1920×1080 的帧按 16:9 写进去,就溢到缓冲底部。而 720p 恰好匹配那个默认值,所以 720p 一切正常、文件解码(刚好是 720p 样本)也一切正常。
教训:这类"某个档位正常、另一个档位坏"的 bug,第一分诊点应该是两端尺寸契约,而不是解码能力。把几何钉死之后,1080p 干净了。
钉死几何之后的实测数据(真机,1080p@8Mbps):连续跑 92s,err = 0、无花屏与撕裂、静止段 0 丢;文件解码段(10s × 60fps = 600 帧)60.0 fps、discard 0%。数字不算大样本,只说明这条路在常规负载下是通的。
三、技术点 ②:12 字节定长头里装了什么
产品的形态最终收敛成单自包含 Companion:两端都是自己写,没有要兼容的既有生态,协议就按自己的需要定,不必迁就任何现成标准。
设计目标只有三条:避开 IP 分片、能判帧完整性、够简单。
媒体面走 UDP,payload 上限 1200B(避开 IP 分片),定长 12 字节头:
// MediaProtocol.swift —— 12B 定长头,多字节小端
struct Header {
var flags: Flags // 1B:lastFrag / parity / hasParams / idr
var fragIndex: UInt16 // 2B:本帧第几片
var fragCount: UInt16 // 2B:本帧共几片(parity 片同帧同 n)
var frameNum: UInt32 // 4B:帧号 —— 有它才有「帧完整性」可言
var payloadLen: UInt16 // 2B:本片有效载荷长度
}
为什么是这 5 个字段:
frameNum+fragIndex+fragCount:裸 UDP 没有序号也没有重传,没有这三个字段就无法区分「这帧还没收完」和「这帧丢了」。有了它们,平板侧的 assembler 才能明确判定DeclareGap(帧号前跳 = 整帧全丢)与FinalizeStale(帧开启 >400ms 未凑齐)。flags.parity:奇偶 FEC —— 同帧多补一片校验片,代价小、实现简单。实测结论也写在这里:在持续剧烈拖拽的码率尖峰下,奇偶 FEC 复证无效(恢复≈0),所以它没有被当成救命稻草,而是被明确记为「已试过、不解决这一类丢包」。flags.idr+hasParams:平板侧 1.5s 无帧就催needIdr(500ms 节流)—— 这是丢包自愈闭环的入口。
控制面走 TCP:2B 长度前缀(LE)+ JSON UTF-8;输入回传也走这条,同一套消息格式,不另起一套。
端口分工固定且两端硬编码:控制 48620 / 媒体 5004 / 信标 48621 / 发现 8765。这里有一条明确的反向决策:
不做「自动换端口」。 端口做开关会让"两端硬编码的协议级常量"变成配置项,故障面反而变大;而且 48620 在做端口转发 / 防火墙规则时根本换不掉。
方向改为失败可见 + 可恢复:绑失败从「只打日志」抬到菜单栏与面板、publishService()挪进.ready分支、失败时清 listener 并允许重绑、文案直接指向「可能已经开了另一个 VivLink Mac 端」。
这条链路的断线恢复实测:强杀 Mac 端进程后,平板侧 transport 层在 4–6s 内自动重连到新进程,计数不清零、状态不损坏。
四、技术点 ③:在 macOS 上,凭空造一块「系统承认的屏」
这个产品要成立,第一件事很朴素:Mac 上得真的多出一块屏,窗口才能拖过去。
但 macOS 的公开 API 里没有「创建一块显示器」这一条 —— CGDisplayStream 和公开的 Display API 只能操作已经存在的屏。要凭空造一块扩展屏,只有私有接口一条路。
我用的是私有 ObjC 类族 CGVirtualDisplay(注册在 CoreGraphics 里)。苹果自家的 Sidecar / AirPlay 走的就是同一套机制 —— 这一点也是当时决定往下试的依据。
问题是:它没有官方文档。 能读的只有三份开源先例(FluffyDisplay 最完整、KhaosT/CGVirtualDisplay 是最小示例、BetterDummy 是这条机制的源头),外加 macOS_headers 里攒出来的私有头档案。
签名我没有去猜 —— 是用 objc runtime 自省(class_copyMethodList / copyIvarList)从本机导出来的,方法的真实形状以运行时为准,不靠反编译推测:
// VirtualDisplay.m —— 创建序列(节选自真实实现,省略错误处理)
CGVirtualDisplayDescriptor *desc = [[CGVirtualDisplayDescriptor alloc] init];
desc.dispatchQueue = dispatch_get_main_queue();
desc.name = [NSString stringWithUTF8String:name];
desc.maxPixelsWide = width;
desc.maxPixelsHigh = height;
CGVirtualDisplay *vd = [[CGVirtualDisplay alloc] initWithDescriptor:desc];
if (vd == nil) return 0;
CGVirtualDisplayMode *mode = [[CGVirtualDisplayMode alloc] initWithWidth:width
height:height
refreshRate:60.0];
CGVirtualDisplaySettings *settings = [[CGVirtualDisplaySettings alloc] init];
settings.modes = @[mode];
[vd applySettings:settings]; // ← 屏上线
收益是:注册进系统的,是一块货真价实的显示器 —— 不是「把画面合成出来,再想办法让它看起来像第二块屏」。也因此,下面这些几乎是白送的:
- 窗口能拖过去、能最大化、能跨屏;
- DPI 缩放、刷新率、显示器排列由系统自己管;
- 平板那头只要「把这块屏推出去」就行 —— 我们自己一行窗口管理器代码都不用写。
代价也说清楚:私有接口没有文档,随系统版本存在漂移的可能;也正因为走了私有 API,Mac 端不可能上 Mac App Store,只能走官网直链分发。
两个踩过的坑,我觉得都挺典型:
坑一:applySettings 并不会切换当前模式。
它只是把你请求的分辨率加进可用模式表,虚拟屏总是以默认的 1280×720 启动。换句话说,我们早期「720p 一切正常」并不是配置成功,只是正好撞上了默认值。要 1080p,得等显示注册 settle 之后(在 runloop 应用里注册是异步的)再显式 CGDisplaySetDisplayMode 切过去;而且只有 owner 进程内切换才生效 —— 跨进程调用会返回 0,但静默不生效。
坑二:在编码回调线程里拆屏会死锁。
收工时如果在回调线程直接 exit(0),atexit 里的释放就会在非主线程上销毁 CGVirtualDisplay —— 表现是进程残留、屏不拆。改法是把收尾整个搬回主线程:回调线程只发停止信号,主线程依次停流、释放 session、vd_destroy,再退出。
回头看,这两个坑都不是「API 难用」,而是我们对它的默认行为做了想当然的假设。
五、三个用事故换来的教训
① 判据必须落在被测对象自己身上。
项目里最贵的一次事故是这样:平板明明白白显示「激活成功」,Mac 重启后却仍然跳激活面板。两边都"没错",结论却相反。查下去是一条链:
一个没退出的测试骨架占着 48622
↓ 真 Companion 绑不上端口 → 面板报 Address already in use
⚠️ 但 publishService() 是无条件调用的 ⇒ Bonjour 照样发布(平板看得见这台 Mac)
↓ 平板 POST 打到那个骨架(它自己处于已激活状态)→ 回 alreadyActivated
↓ 平板按「成功 或 已激活」判成功 → 置标记、显示「激活成功」
↓ Mac 从头到尾没收到凭证 → 重启仍是「需要激活」
测试骨架冒充了生产。 它占着生产的端口,而且能按协议正确应答 —— 从现象上完全看不出是被冒名。所以后来的规矩是:判据只能落在被测对象自己身上,不能落在"命令没报错""日志看着对""上次也是这样"。
② 文档里的「已接受」,不是结论,是路障。
4K 显示器接上 Mac,主屏会被系统从 4K 降到 1080。这事从 09-06 就记下了,中途在文档里收口成「产品已接受该行为,不再尝试规避」。09-18 重开预研,结论是降档可消除 —— 那句"已接受"是错的。
它难查,是因为它长得和别的结论一模一样,还带着一个听起来很有力的理由,而且没人会去复核——复核一条"已接受"等于承认之前判错了。修复它的动作不只是改代码,还要在文档里补一句反向指针:「那两处仍写着『接受』,再读到时别当结论」。
如果没有写清"试过哪几条路、各自为什么不通","已接受"就只是个没人敢再碰的路障。
③ 现象和原因可以不搭。
有一类故障长这样:进程在 ✅、%cpu 0.0 ⚠️、日志 0 行 🔴、三个端口一个都不绑。本能会往"哪一步 early return""是不是死锁"想 —— 实际原因是屏幕上有一个钥匙串授权弹窗:脚本 ad-hoc 重签每次都换签名身份,macOS 认为是"另一个程序要读钥匙串",而在用户点「允许」之前,App 就卡在启动路径的第一步上,所以日志一行都没有。
排查口诀就这么定下了:「App 起来了却什么都没干」→ 先看屏幕上有没有钥匙串弹窗。
最后
跨端项目里最花时间的,常常不是写代码,而是「证明它是对的」。 判据要落在被测对象自己身上、把「查不到文档」当成一项要专门解决的工作、对每一条写进文档的结论保持怀疑 —— 这几样东西,比任何一个具体的 API 用法都更经用。
华为应用市场搜索「VivLink」可以体验(鸿蒙端已上架,Mac 端在官网 vivlink.hanhan.pro 免费下载)。
转载请注明文章来源: