前言

ExoPlayer作为开源播放器,深受广大Android开发社区的欢迎。特别是降本增效的背景下,作为一款上手简单、可扩展性高、稳定性高、长期维护的Android播放器,深受很多团队喜爱。
我们知道,作为Android系统内置的播放器MediaPlayer,其本身有很多缺陷。MediaPlayer的C/S架构,且其Server部分属于系统服务,因此google是不允许普通应用扩展的;但是另一方面,厂商的权限却非常大,由于设备厂商的改造,MediaPlayer也产生了很多的碎片化问题,比如有些设备Seek后会偶现结束播放,另外有些设备(如海思)的MediaPlayer音量无法关闭等,另外其MediaClock也无法扩展。
鉴于这些问题,Google团队基于MediaCodec的ExoPlayer。
ExoPlayer自从诞生之后,就一直维持着高频率的更新,同时,ExoPlayer项目也推进着Android系统中Codec框架的发展。比如无缝切换问题,由于MediaCodec#setOutputSurface和MediaCodec#flush的不稳定因素,Google在android 10版本改造了SurfaceControl,使得Surface无缝切换比Open GL + EGL方式更简单。
以上是两种播放器简单的对比,但与本文其实关系不大。本文的将围绕Android ExoPlayer升级问题展开,为什么会有升级问题呢?
ExoPlayer 升级问题
作为Google的产品,ExoPlayer内部深度引用了google-guava (Google的瓜),然而,在发包之际,我们发现了个别渠道播放器开始播放失败。
错误堆栈如下
java
E/AVPlayer( 7350): Caused by: java.lang.NoSuchMethodError: No virtual method buildOrThrow()Lcom/google/common/collect/ImmutableMap; in class Lcom/google/common/collect/ImmutableMap$Builder; or its super classes (declaration of 'com.google.common.collect.ImmutableMap$Builder' appears in /system/framework/libyunosfw.jar)
E/AVPlayer( 7350): at com.google.android.exoplayer2.analytics.DefaultAnalyticsCollector$MediaPeriodQueueTracker.updateMediaPeriodTimelines(DefaultAnalyticsCollector.java:1140)
E/AVPlayer( 7350): at com.google.android.exoplayer2.analytics.DefaultAnalyticsCollector$MediaPeriodQueueTracker.onTimelineChanged(DefaultAnalyticsCollector.java:1102)
E/AVPlayer( 7350): at com.google.android.exoplayer2.analytics.DefaultAnalyticsCollector.onTimelineChanged(DefaultAnalyticsCollector.java:480)
E/AVPlayer( 7350): at com.google.android.exoplayer2.ExoPlayerImpl.lambda$updatePlaybackInfo$12(ExoPlayerImpl.java:1925)
E/AVPlayer( 7350): at com.google.android.exoplayer2.-$$Lambda$ExoPlayerImpl$i1PbeQS8whR2JRQzvElmnkdKNn8.invoke(lambda)
E/AVPlayer( 7350): at com.google.android.exoplayer2.util.ListenerSet$ListenerHolder.invoke(ListenerSet.java:330)
E/AVPlayer( 7350): at com.google.android.exoplayer2.util.ListenerSet.lambda$queueEvent$0(ListenerSet.java:214)
E/AVPlayer( 7350): at com.google.android.exoplayer2.util.-$$Lambda$ListenerSet$Q1s2242IGqOFgK3lFhqwOk8KBXE.run(lambda)
E/AVPlayer( 7350): at com.google.android.exoplayer2.util.ListenerSet.flushEvents(ListenerSet.java:236)
E/AVPlayer( 7350): at com.google.android.exoplayer2.ExoPlayerImpl.updatePlaybackInfo(ExoPlayerImpl.java:2017)
E/AVPlayer( 7350): at com.google.android.exoplayer2.ExoPlayerImpl.setMediaSourcesInternal(ExoPlayerImpl.java:2233)
E/AVPlayer( 7350): at com.google.android.exoplayer2.ExoPlayerImpl.setMediaSources(ExoPlayerImpl.java:597)
E/AVPlayer( 7350): at com.google.android.exoplayer2.ExoPlayerImpl.setMediaSources(ExoPlayerImpl.java:591)
E/AVPlayer( 7350): at com.google.android.exoplayer2.ExoPlayerImpl.setMediaSource(ExoPlayerImpl.java:572)
E/AVPlayer( 7350): at com.support.video.renders.VideoExoRender.prepareDataSource(VideoExoRender.java:257)
E/AVPlayer( 7350): at com.smartian.video.renders.VideoRender$SetDataSource.onExecute(VideoRender.java:682)
E/AVPlayer( 7350): at com.common.utils.q.run(SafelyExecutor.java:12)
E/AVPlayer( 7350): at android.os.Handler.handleCallback(Handler.java:739)
E/AVPlayer( 7350): at android.os.Handler.dispatchMessage(Handler.java:95)
E/AVPlayer( 7350): ... 2 more
原因是Guava版本存在冲突,某些国产操作系统的/system/framework 也放置了主版本号为27的guava包,而运行和编译时优先使用了系统guava,这就导致app中的程序加载的guava版本是系统中提供的,而非app中存在的,因此,编译和运行之后,必然出现异常。
为什么早期的版本没有问题?
但是系统中的Guava工具包和早期的ExoPlayer所依赖版本基本一致,因此,早期的app是可以正常运行的。
但是本次应用升级时,将ExoPlayer的版本由2.13.2 升级至了2.18.5,ExoPlayer所依赖的的guava依赖包从主版本27升级至30,导致产生了冲突。
解决方法
我们知道原因之后,接下来就得寻找解决办法。
这里,我们可能首先想到的是从ClassLoader"双亲委任"进行突破。但是,这种做法有一个明显的弊端,就是ClassLoader的拦截无法解决oat编译缓存,因为运行时的部分代码实际上进行过dex2oat或者jit优化,如进行了方法内联、标量化等。
因此,我们需要找出一种较优的方案。
在实际过程中,总共梳理出了如下几种方案。
ClassLoader双亲委任 + ClassLoader替换
上面说过,简单的ClassLoader"双亲委任"只能解决运行时问题。但是Android 5-6的版本编译时是不会使用你自己的ClassLoader的,同样Android N后的JIT + AOT 编译时也不会启动Application来获取你自定义的ClassLoader,显然,这和插件化遇到的问题是一样的: primary dex和 secondary dex 无法被友好的优化。
另外,oat缓存不仅仅影响的性能问题,还可能引发AbstractMethodError问题。
当然,这里可能会想到Tinker的ClassLoader替换 + DexOptimizer方案------ 对primary dex和secondary dex进行编译,显然,这种方案理论上是可行的。但是另一个问题我们知道,在iOT 设备上,磁盘是有限的,然而primary dex和secondary dex进行全部编译之后AppSize会变的很大,因此,这种方案作为了保留方案。
维持ExoPlayer双版本
实际上,ExoPlayer整体上对外部接口的更新比较克制,在ExoPlayer提交记录中发现, 2.16.1 版本还是原理的Guava,因此对特定渠道使用ExoPlayer 2.16.1来规避此问题,由于发版事项比较紧急,当时使用了方案进行了临时过渡。
但是风险也接踵而至,部分用户从其他路径安装了非指定渠道的包,因此ExoPlayer依然无法使用。
因此,此方案仅仅是过渡方案。
包名重定向
我们知道,之所以加载了系统的guava,原因是ClassLoader自身会根据双亲委任优先查找系统中的类,假设,我们需要的guava类和系统中的guava类包名不一样,那意味着ClassLoader只能加载app的guava类。因此这种方案显然是合理的,并且还能解决oat缓存问题。
最终确定了包名重定向方案,因为我们知道,ExoPlayer中引用的Guava工具包时com.google.common
开始的。显然最先想到的解决方式是进行混淆,但事与愿违,在Guava包中有不分类继承自Serializable
的子类。而Android混淆时一般是规避Serializable
继承类的。
强制混淆
不过,在选择包名重定向之前,我们探索了【强制Serializable子类混淆的方案】
在查阅proguard文档时我们发现,!
是可以排除指定包名的,官方提示「谨慎使用」。但实际上这种方式官方连个demo都没有,使用了之后,发现很多不该被混淆的类被混淆了。
最终,下面这种方式是失败的。
java
-keepnames class !com.google.common.** ,* implements java.io.Serializable
-keepnames class !com.google.common.**
终选方案
最终我们确定对包名重定向
java
com.google.commcon -> com.g.common
那这里肯定要进行字节码修改了。
但是字节码修改的工作量还是很大的,我们修改的方法不仅仅是对com.google.common下的类名Remapper ,其次还需要对所以涉及调用的字段、方法返回值、参数、调用指令进行替换。
方案落地
最终,我们做了两手准备
- 自定义gradle插件,利用asm修改字节码,鉴于agp兼容性问题,此方案作为备选方案。
- 通过proguard进行解决,作为优先方案。
为了解决这个问题,查阅了很多proguard指令,包括repackageclasses
,这个指令也是比较鸡肋,无法限定范围。
后来,我们发现gradle有一款插件,名为shadow (抖音团队似乎使用过,文章地址忘记了),似乎可以解决这个问题,但是配置也是相当复杂,同时也不确定修改了common.google.common之后,在ExoPlayer中的调用部分是不是也会跟着变?这种方案缺乏实证,可能出现回到原点的情况。
最终,我们又回到proguard中寻找答案,最终发现有个applymapping
指令,当然官方给的建议是「慎用」,不过好在是有些团队使用了,比如美团的插件化中使用了applymapping防止混淆后的方法冲突。
最后,我们满怀期待的进行了尝试,下面是修复步骤。
问题修复步骤
当然,问题的修复我们需要拿到mapping.txt文件(打包时会生成)。
在测试同学的帮助下,很快写了python脚本,能够从mapping.txt 中提取出com.google.common下所有类的映射和方法映射,在提取的过程中,将com.google.common映射为我们指定的包名 com.g.common。
csharp
com.google.common.base.AbstractIterator -> com.g.common.base.AbstractIterator:
com.g.common.base.AbstractIterator$State state -> state
java.lang.Object next -> next
void <init>() -> <init>
java.lang.Object computeNext() -> computeNext
java.lang.Object endOfData() -> endOfData
boolean hasNext() -> hasNext
boolean tryToComputeNext() -> tryToComputeNext
java.lang.Object next() -> next
void remove() -> remove
com.google.common.base.AbstractIterator$1 -> com.g.common.base.AbstractIterator$1:
int[] $SwitchMap$com$google$common$base$AbstractIterator$State -> $SwitchMap$com$google$common$base$AbstractIterator$State
void <clinit>() -> <clinit>
python脚本最终生成的我们配置到混淆文件中即可
diff
-applymapping proguard_mapping.txt
当然,这种缺陷也是有的,就是在每次升级的时候,需要重新生成proguard_mapping.txt ,因此,对于这种问题,后续我们会做一些监控,比如 proguard_mapping.txt文件的命名上带上guava的版本的相关信息,后续打包强制校验。
总结
通过以上方式解决了与系统类库中的guava版本冲突问题,当然,这种方案实际上应该称为【反-反混淆】。为什么是2个反字呢,我们说过keep了Serializable的子类无法被混淆,实际上我们这里恰恰是需要混淆,因此需要在反混淆的基础上再次混淆。