经常看我博客的朋友们都知道并不知道, 过去几年里我一共写了4篇Android开发笔记, 里面包括遇到过的各种各样的坑, 而如今由于当前在做的这个项目的Android端已经有人开发了, 所以我就转行去做iOS开发了, 才学习了一个月, 我就已经需要写第一篇笔记了, 看来iOS虽然比Android的碎片化和历史包袱强了一点, 但是也是一坨.
这个需求来自于音乐播放器App的悬浮歌词功能, 有小伙伴说QQ音乐是有悬浮歌词的, 那理论上我们也能够实现, 所以就来研究一下吧. 在Android上, 悬浮歌词可以使用悬浮窗实现, 尽管Android的悬浮窗充满了历史包袱(主要是权限相关的), 但是我在之前的Android应用开发笔记(4)中已经把能踩的坑都踩过了一遍, 再加上Android端的compose并非完全自绘, 因此实现起来并不困难. 而在iOS上麻烦就大了, 首先iOS并没有悬浮窗功能, 唯一最接近Android悬浮窗的是画中画功能, 其次我们App采用的是Compose Multiplatform, CMP在非Android平台上都是用skia自绘的, 如何渲染到画中画上也是个大问题. 如果只是单纯的在画中画里渲染UIView, 那么网上已经有很多教程了, 甚至有封装好的库, 我也没必要再写教程了, 但是就是因为采用了CMP, 导致这个问题变的极其复杂, 我研究了整整一周才搞定, 所以还是有记录下来的必要的, 让看到这篇文章的同学少走些弯路.
iOS 9~14的办法
苹果从iOS 9起就引入了画中画功能. 画中画, 顾名思义, 是用来在屏幕上以小窗显示视频的, 但是不知道是谁先发现了画中画窗口其实是一个windowLevel
为 -10000000的UIWindow(PGHostedWindow)
, 所以在使用画中画显示视频后可以获取到画中画窗口, 然后在上面添加UIView
就实现在画中画里显示任意控件的效果了, iOS14和15中还能和添加的控件进行交互, iOS16以上就不能了, 除此之外还可以调用隐藏的接口去除播放相关按钮和实现点击画中画跳转回App, 详细做法可以参考: https://juejin.cn/post/7310111620368449555
// 去除播放相关按钮 [controller setValue:[NSNumber numberWithInt:1] forKey:@"controlsStyle"]; // iOS 16 及以上版本支持点击画中画直接回到 App [controller setValue:[NSNumber numberWithInt:2] forKey:@"controlsStyle"];
这种做法十分简便, 代码量也很少, 但是缺点也很明显, 必须要播放一个占位视频才能开启画中画, 且向画中画窗口中添加控件是未公开的特性, 随时可能失效
iOS 15以上的办法
那么还有没有更好的办法呢, 当然有了, 在iOS 15中, 也许是为了视频通话等应用考虑, 苹果新增了用内容源初始化PIP控制器的方式, 而内容源不只可以传AVPlayerLayer
进去, 也可以传AVSampleBufferDisplayLayer
, 这就意味着我们不需要非得创建一个播放器了, 可以将任意的UIView
的Layer类型改为AVSampleBufferDisplayLayer
, 然后用来初始化PIP控制器. 当需要在画中画上渲染控件时, 我们只需要把控件渲染为图像, 然后送入AVSampleBufferDisplayLayer
即可显示在画中画上, 流程为: UIView -> UIImage -> CVPixelBuffer -> CMSampleBuffer
这里需要注意的是承载AVSampleBufferDisplayLayer
的UIView
不需要和要渲染的UIView
为同一个或有任何的父子关系, 只要保证他们都被添加到当前的UI树上被渲染即可, 如果不想出现在界面上可以设置为隐藏
如果你做的是iOS原生开发, 那么看到这里了解了原理就够用了, 接下来可以去调库了, 我推荐使用ColdGrub1384/Pipable: Picture in Picture for any UIView这个库, 只需要给自定义的UIView
实现Pipable
协议即可拥有显示在画中画上的能力, 这个库是把承载AVSampleBufferDisplayLayer
的UIView
放在要显示在画中画上的UIView
里面的, 如果要反过来, 或者需要更自由的组合, 例如不使用UIView
而是直接渲染图像(如下节所述), 可以使用我修改的代码: ylcs-kmp/iosApp/core/PIPView.swift at main · rachel-ylcs/ylcs-kmp
将Compose任意图像绘制到画中画上
考虑到Pipable这个库代码质量还行, 而且正符合我们的需求, 于是我先费尽力气把这个库移植了过来(因为截至写稿时KMP还不支持和Swift互操作, 而且这些代码没法完全用Kotlin重写, 必须用Swift). 这里我遇到了第一个坑, 不过根据KMP的文档很容易就能解决, 只要把Swift代码放到单独的pod模块里, 再导出objc接口给Kotlin调用就行了
然后我想当然的以为CMP和UIView没什么区别, 毕竟从代码上来讲, Compose是用一个ComposeUIViewController
渲染的, 于是我就费尽力气新建了一个悬浮歌词的ComposeUIViewController
, 把它加到了根view controller里, 把所有view加到PIPView
里再把PIPView
加到根view controller的view里, 然后点击运行, 打开悬浮窗, 报错Error Domain=PGPegasusErrorDomain Code=-1003
, 我翻遍全网也没找到这是个什么错误, 只能在twitterX上找到有一个人在问同样的问题, 以及这是渲染器报的错, 在反复尝试后, 还和Pipable能跑的代码做了对比, 发现唯一的区别是启动画中画前没有往AVSampleBufferDisplayLayer
里送帧, 再联想到画中画的大小是根据AVSampleBufferDisplayLayer
的大小确定的, 一帧都没有那当然报错了, 于是我在开启画中画前加了行送帧的代码, 画中画果然出现了!
画中画虽然出现了, 但是无论歌词怎么变化都是黑屏, 在确认不是公共代码的问题后, 我决定关掉PIPView
的hidden
属性, 看下是不是歌词的view没有正确渲染, 结果我看到了下面这幕:
(实在找不到当时拍的照片了, 大概就是上边图片里这样的, 但这个是已经适配Compose的了, 我p了一下)
然后我突然意识到, CMP在iOS上是自绘的啊, 和Android不一样, Compose iOS的ui树是虚拟ui树, 最终会渲染到根canvas上. 再根据我在issues中的搜索结果, 旧版本的Compose是不支持子UIViewController
的, 后来支持了, 但是并不是渲染两次, 而是把子UIViewController
中的Compose组件合并到根UIViewController
中去渲染. 所以最后就会导致这种结果, 因为后添加的子UIViewController
只是个空壳, 它并没有渲染任何控件.
那么解决办法自然也很简单, 甚至比原来渲染UIView
的步骤还简单, 只需要调skia的renderComposeScene
接口把Compose渲染为位图, 转成UIImage
再走上面的流程渲染在画中画里即可, 核心代码如下所示(假设用了我魔改的Pipable库). 这个Compose都不需要在ui树里, 也就是说, 与其把它叫做在画中画中渲染任意控件, 不如叫做在画中画中绘制任意图像.
(pipView.layer as? AVSampleBufferDisplayLayer)?.flush() // 在这更新ui状态 val imageData = renderComposeScene( CGRectGetWidth(pipView.frame).toInt(), CGRectGetHeight(pipView.frame).toInt(), content = { // 要渲染的compose组件 } ).encodeToData() // 优化点: 这里转换成png了, 虽然png编解码很快, 但是不知道能不能直接用位图, 甚至零拷贝 val nsData = imageData?.bytes?.toNSData() val uiImage = nsData?.let { UIImage.imageWithData(it) } val buffer = uiImage?.asSampleBuffer() (pipView.layer as? AVSampleBufferDisplayLayer)?.enqueueSampleBuffer(buffer)
最后效果如下图所示, 怎么样, 还不错吧(笑, 对标QQ音乐了属于是, 这可是连wyy都做不出来的功能🤣