Android ExoPlayer版本升级遇上系统的”瓜“

前言

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 ,其次还需要对所以涉及调用的字段、方法返回值、参数、调用指令进行替换。

方案落地

最终,我们做了两手准备

  1. 自定义gradle插件,利用asm修改字节码,鉴于agp兼容性问题,此方案作为备选方案。
  2. 通过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的子类无法被混淆,实际上我们这里恰恰是需要混淆,因此需要在反混淆的基础上再次混淆。

参考文章

深入 Android 混淆实践:多模块打包爬坑之旅

相关推荐
—Qeyser3 小时前
用 Deepseek 写的uniapp血型遗传查询工具
前端·javascript·ai·chatgpt·uni-app·deepseek
codingandsleeping3 小时前
HTTP1.0、1.1、2.0 的区别
前端·网络协议·http
小满blue3 小时前
uniapp实现目录树效果,异步加载数据
前端·uni-app
天天扭码5 小时前
零基础 | 入门前端必备技巧——使用 DOM 操作插入 HTML 元素
前端·javascript·dom
咖啡虫5 小时前
css中的3d使用:深入理解 CSS Perspective 与 Transform-Style
前端·css·3d
拉不动的猪6 小时前
设计模式之------策略模式
前端·javascript·面试
旭久6 小时前
react+Tesseract.js实现前端拍照获取/选择文件等文字识别OCR
前端·javascript·react.js
独行soc6 小时前
2025年常见渗透测试面试题-红队面试宝典下(题目+回答)
linux·运维·服务器·前端·面试·职场和发展·csrf
AD钙奶-lalala6 小时前
某车企面试备忘
android
uhakadotcom6 小时前
Google Earth Engine 机器学习入门:基础知识与实用示例详解
前端·javascript·面试