fix: Android 4.2 兼容性修复 - 降级第三方库 + 移除 ExoPlayer + multidex 配置

1. OkHttp 3.14.9 -> 3.12.0(Android10Platform 引用 Android 10+ API 导致 VerifyError)
2. Gson 2.10.1 -> 2.8.6(ReflectionHelper 引用 Java 9+ API 导致 VerifyError)
3. Retrofit 2.9.0 -> 2.6.0(与客户环境一致,配合 OkHttp 3.12.0)
4. Coroutines 1.7.3 -> 1.6.4(1.7+ 要求 minSdk 21)
5. 移除 ExoPlayer 2.19.1,改用系统原生 MediaPlayer + SurfaceView
6. 主题 MaterialComponents -> AppCompat(避免 MaterialComponentsViewInflater multidex 问题)
7. multidex-config.pro 新增 kotlinx.coroutines.** 和 retrofit2.** 到主 dex
8. 新增 Android 4.2 兼容性改动文档
This commit is contained in:
zhaogd
2026-07-28 19:47:57 +08:00
parent eee8203a68
commit 37738504b9
7 changed files with 275 additions and 75 deletions

View File

@@ -0,0 +1,156 @@
# Android 4.2 兼容性改动文档
## 背景
项目 original 目标为 Android 5.0+,需降级兼容 Android 4.2(API 17)。Android 4.2 使用 Dalvik 虚拟机(非 ART),对 multidex 和高版本 API 引用有严格限制,导致一系列兼容性问题。
## 问题分类
兼容性问题分为两大类:
1. **Dalvik multidex 类加载失败**:Dalvik 在框架级反射和内部类加载场景下,无法从 secondary dex 加载类,导致 `ClassNotFoundException` / `NoClassDefFoundError` / `VerifyError`
2. **高版本 API 引用**:第三方库的某些类引用了 Android 10+ 或 Java 9+ 的 API,Dalvik 验证器拒绝加载,导致 `VerifyError`
---
## 一、Multidex 配置改动
### 1.1 multidex-config.pro(主 dex 保留类)
**文件**:`app/multidex-config.pro`
**原因**:Dalvik multidex 环境下,以下场景无法从 secondary dex 加载类:
- `LayoutInflater.createViewFromTag`(框架级反射创建 View)
- `AppCompatDelegateImpl.createSubDecor`(inflate 内部装饰布局)
- 协程内部机制加载 `CoroutineExceptionHandlerImplKt`
- Retrofit `KotlinExtensions.await()` 被 `HttpServiceMethod` 运行时调用
**保留到主 dex 的包**:
```
- com.pukka.mediaapp.** # 项目自身所有类
- androidx.appcompat.app.** # AppCompatDelegateImpl、AppCompatViewInflater
- androidx.appcompat.widget.** # FitWindowsLinearLayout、AppCompatButton 等
- kotlinx.coroutines.** # 协程核心类
- retrofit2.** # KotlinExtensions、HttpServiceMethod 等
```
### 1.2 AndroidManifest.xml
**文件**:`app/src/main/AndroidManifest.xml`
**改动**:
- 移除 `com.google.android.exoplayer2` 的 `overrideLibrary`(已移除 ExoPlayer 依赖)
- 保留其他 AndroidX 库的 `overrideLibrary`(core-ktx 1.12.0、appcompat 1.6.1 等的 minSdk 为 19-21,通过 override 覆盖为 17)
- 移除 `androidx.startup.InitializationProvider`(Android 4.x Dalvik multidex 下 Provider 启动时无法加载 secondary dex 中的初始化类,App 手动初始化所有组件)
---
## 二、第三方库版本降级
### 2.1 OkHttp:3.14.9 → 3.12.0
**问题**:OkHttp 3.12.1+ 包含 `Android10Platform` 类,引用 Android 10+ 的 SSL API(`SSLParameters.setApplicationProtocols`)。`Platform.findAndroidPlatform()` 在静态初始化时尝试加载该类,Dalvik 验证器拒绝 → `VerifyError`
**修复**:
```groovy
implementation 'com.squareup.okhttp3:okhttp:3.12.0'
implementation 'com.squareup.okhttp3:logging-interceptor:3.12.0'
// 防止其他依赖传递引入 3.12.1+
configurations.all {
resolutionStrategy.force 'com.squareup.okhttp3:okhttp:3.12.0'
}
```
### 2.2 Gson:2.10.1 → 2.8.6
**问题**:Gson 2.9+ 引入 `ReflectionHelper` 类,引用 Java 9+ 的反射 API(`java.lang.invoke.MethodHandles`)。`ReflectiveTypeAdapterFactory.create()` 调用该类,Dalvik 验证器拒绝 → `VerifyError`
**修复**:
```groovy
implementation 'com.google.code.gson:gson:2.8.6'
```
### 2.3 Retrofit:2.9.0 → 2.6.0
**问题**:Retrofit 2.9.0 传递依赖 OkHttp 3.14.9(含 `Android10Platform`)。与客户环境保持一致使用 2.6.0。
**修复**:
```groovy
implementation 'com.squareup.retrofit2:retrofit:2.6.0'
implementation 'com.squareup.retrofit2:converter-gson:2.6.0'
```
### 2.4 Kotlin Coroutines:1.7.3 → 1.6.4
**问题**:kotlinx-coroutines 1.7+ 要求 minSdk 21(Android 5.0),1.6.4 兼容 API 16+。
**修复**:
```groovy
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4'
```
---
## 三、ExoPlayer 移除,改用系统原生 MediaPlayer
**问题**:ExoPlayer 2.14+ 要求 minSdk 21,2.19.1 的类大量引用 Android 5+ API,在 Android 4.2 上会 `VerifyError`。即使降到 2.13.0,API 差异大且仍有兼容性风险。
**修复**:完全移除 ExoPlayer,改用 Android 系统原生 `MediaPlayer` + `SurfaceView`(API 1+,零兼容性问题)。
**涉及文件**:
| 文件 | 改动 |
|------|------|
| `app/build.gradle` | 移除 `exoplayer:2.19.1` 依赖 |
| `app/src/main/AndroidManifest.xml` | 移除 `com.google.android.exoplayer2` overrideLibrary |
| `app/src/main/res/layout/activity_player.xml` | `PlayerView` → `SurfaceView` |
| `app/src/main/java/.../ui/VideoPlayerActivity.kt` | 重写为 `MediaPlayer` + `SurfaceHolder.Callback` |
---
## 四、主题改动
**文件**:`app/src/main/res/values/themes.xml`
**改动**:`Theme.MaterialComponents.NoActionBar` → `Theme.AppCompat.NoActionBar`
**原因**:
- `MaterialComponentsViewInflater` 继承 `AppCompatViewInflater`,被框架反射加载时需在主 dex
- 保留整个 `com.google.android.material.**` 到主 dex 会导致方法数超限(80949 > 65536)
- 布局中只用了标准控件(TextView/EditText/Button/ProgressBar),无需 Material 主题
- 改用 AppCompat 主题后,使用 `AppCompatViewInflater`,避免 Material 的 multidex 问题
---
## 五、build.gradle 完整依赖对照
| 依赖 | 修复前 | 修复后 | 原因 |
|------|--------|--------|------|
| retrofit | 2.9.0 | **2.6.0** | 传递依赖 OkHttp 3.14.9 不兼容 |
| converter-gson | 2.9.0 | **2.6.0** | 同上 |
| okhttp | 3.14.9(传递) | **3.12.0**(force) | Android10Platform VerifyError |
| logging-interceptor | 3.12.12 | **3.12.0** | 同上 |
| gson | 2.10.1 | **2.8.6** | ReflectionHelper VerifyError |
| coroutines | 1.7.3 | **1.6.4** | 1.7+ 要求 minSdk 21 |
| exoplayer | 2.19.1 | **移除** | minSdk 21,改用 MediaPlayer |
| appcompat | 1.6.1 | 1.6.1(不变) | overrideLibrary 覆盖 |
| core-ktx | 1.12.0 | 1.12.0(不变) | overrideLibrary 覆盖 |
| material | 1.11.0 | 1.11.0(不变) | 改用 AppCompat 主题 |
| lifecycle | 2.3.1 | 2.3.1(不变) | 兼容 API 16+ |
| activity | 1.3.1 | 1.3.1(不变) | 兼容 API 16+ |
| multidex | 2.0.1 | 2.0.1(不变) | 兼容 API 16+ |
---
## 六、问题排查记录(按发现顺序)
| 序号 | 错误 | 根因 | 修复 |
|------|------|------|------|
| 1 | `VerifyError: AppCompatViewInflater` | Dalvik multidex 无法加载 secondary dex 的 AppCompat 类 | multidex-config.pro 保留 `androidx.appcompat.**` |
| 2 | `ClassNotFoundException: FitWindowsLinearLayout` | AppCompat 内部 widget 不以 `AppCompat` 开头,通配符未覆盖 | 改用包级别 `androidx.appcompat.widget.**` |
| 3 | `NoClassDefFoundError: CoroutineExceptionHandlerImplKt` | 协程内部类加载失败 | multidex-config.pro 保留 `kotlinx.coroutines.**` |
| 4 | `VerifyError: Android10Platform` | OkHttp 3.12.1+ 引用 Android 10+ API | OkHttp 降级 3.12.0 + force 锁定 |
| 5 | `VerifyError: ReflectionHelper` | Gson 2.9+ 引用 Java 9+ API | Gson 降级 2.8.6 |
| 6 | `NoClassDefFoundError: retrofit2.KotlinExtensions` | Retrofit suspend 支持类在 secondary dex | multidex-config.pro 保留 `retrofit2.**` |
| 7 | 主 dex 方法数超限 80949 > 65536 | 保留 material.** 到主 dex | 改用 AppCompat 主题,移除 material 保留 |

View File

@@ -72,23 +72,25 @@ dependencies {
// multidex // multidex
implementation 'androidx.multidex:multidex:2.0.1' implementation 'androidx.multidex:multidex:2.0.1'
// Retrofit + OkHttp(3.12.x 兼容 API 16+) // Retrofit 2.6.0 + OkHttp 3.12.0(与客户环境一致,兼容 Android 4.x)
implementation 'com.squareup.retrofit2:retrofit:2.9.0' // Retrofit 2.6.0 自然依赖 OkHttp 3.12.0(无 Android10Platform,不会 VerifyError)
implementation 'com.squareup.retrofit2:converter-gson:2.9.0' // OkHttp 3.12.1+ 包含 Android10Platform,引用 Android 10+ API,在 Android 4.x 上 VerifyError
implementation 'com.squareup.okhttp3:logging-interceptor:3.12.12' implementation 'com.squareup.retrofit2:retrofit:2.6.0'
implementation 'com.squareup.retrofit2:converter-gson:2.6.0'
implementation 'com.squareup.okhttp3:okhttp:3.12.0'
implementation 'com.squareup.okhttp3:logging-interceptor:3.12.0'
// Gson // Gson 2.8.6(2.9+ 引入 ReflectionHelper,引用 Java 9+ API,在 Android 4.x 上 VerifyError)
implementation 'com.google.code.gson:gson:2.10.1' implementation 'com.google.code.gson:gson:2.8.6'
// Coroutines // Coroutines 1.6.4(1.7+ 要求 minSdk 21,1.6.4 兼容 API 16+)
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3' implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4'
// JetsenxSdk + NewTVSDK // JetsenxSdk + NewTVSDK
implementation files('libs/JetsenxSdk-api-future-release-v1.23.aar') implementation files('libs/JetsenxSdk-api-future-release-v1.23.aar')
implementation files('libs/NewTVSDK-v4.0.3-app-lite.jar') implementation files('libs/NewTVSDK-v4.0.3-app-lite.jar')
// ExoPlayer // 视频播放使用系统原生 MediaPlayer + SurfaceView,无需 ExoPlayer
implementation 'com.google.android.exoplayer:exoplayer:2.19.1'
// commons-codec(SDK 依赖) // commons-codec(SDK 依赖)
implementation 'commons-codec:commons-codec:1.15' implementation 'commons-codec:commons-codec:1.15'
@@ -103,3 +105,9 @@ dependencies {
implementation 'net.jpountz.lz4:lz4:1.3.0' implementation 'net.jpountz.lz4:lz4:1.3.0'
implementation 'org.slf4j:slf4j-android:1.7.36' implementation 'org.slf4j:slf4j-android:1.7.36'
} }
// 强制 OkHttp 3.12.0(3.12.1+ 包含 Android10Platform,引用 Android 10+ API,在 Android 4.x 上 VerifyError)
// 其他依赖传递引入 3.12.13,需 force 锁定 3.12.0
configurations.all {
resolutionStrategy.force 'com.squareup.okhttp3:okhttp:3.12.0'
}

View File

@@ -1,6 +1,28 @@
# Android 4.x Dalvik multidex 兼容配置 # Android 4.x Dalvik multidex 兼容配置
# InitializationProvider 已从 manifest 移除,仅需保留 App 自身启动类 # InitializationProvider 已从 manifest 移除,仅需保留 App 自身启动类
#
# 核心原则:Dalvik multidex 下,只有以下两种场景无法加载 secondary dex 中的类
# 1. Android 框架级反射:LayoutInflater.createView 使用框架 ClassLoader
# 创建 View 时找不到 secondary dex 中的 AppCompat 控件类
# 2. 协程内部类加载:kotlinx.coroutines 内部加载 CoroutineExceptionHandlerImplKt
# 等核心类时,在 Dalvik multidex 下失败
# 注意:已将主题从 MaterialComponents 改为 AppCompat,避免 MaterialComponentsViewInflater
# 的 multidex 类加载问题,同时减少主 dex 方法数(material 包约 20000+ 方法)
# App 自身核心类 # === 项目自身所有类 ===
-keep class com.pukka.mediaapp.PartnerApp { *; } -keep class com.pukka.mediaapp.** { *; }
-keep class com.pukka.mediaapp.ui.PartnerLaunchActivity { *; }
# === AppCompat 完整包(app + widget)===
# LayoutInflater.createViewFromTag 反射创建:FitWindowsLinearLayout、AppCompatButton 等
# AppCompatDelegateImpl.createSubDecor inflate 内部装饰布局
-keep class androidx.appcompat.app.** { *; }
-keep class androidx.appcompat.widget.** { *; }
# === Kotlin Coroutines ===
# 协程启动时内部加载 CoroutineExceptionHandlerImplKt(无法从 secondary dex 加载)
# 覆盖 flow(StateFlow)、android(Dispatchers)、internal 等
-keep class kotlinx.coroutines.** { *; }
# === Retrofit ===
# KotlinExtensions.await() 被 HttpServiceMethod 在运行时调用,suspend 函数支持
-keep class retrofit2.** { *; }

View File

@@ -9,7 +9,6 @@
<!-- 覆盖依赖库的 minSdk 要求,兼容 Android 4.2(API 17) --> <!-- 覆盖依赖库的 minSdk 要求,兼容 Android 4.2(API 17) -->
<uses-sdk tools:overrideLibrary=" <uses-sdk tools:overrideLibrary="
com.google.android.exoplayer2,
androidx.core, androidx.core,
androidx.appcompat, androidx.appcompat,
com.google.android.material, com.google.android.material,

View File

@@ -1,28 +1,27 @@
package com.pukka.mediaapp.ui package com.pukka.mediaapp.ui
import android.media.MediaPlayer
import android.net.Uri import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.util.Log import android.util.Log
import android.view.SurfaceHolder
import android.view.SurfaceView
import android.view.View import android.view.View
import android.view.WindowManager import android.view.WindowManager
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import com.google.android.exoplayer2.ExoPlayer
import com.google.android.exoplayer2.MediaItem
import com.google.android.exoplayer2.Player
import com.google.android.exoplayer2.source.DefaultMediaSourceFactory
import com.google.android.exoplayer2.upstream.DefaultDataSource
import com.google.android.exoplayer2.upstream.DefaultHttpDataSource
import com.google.android.exoplayer2.util.Util
import com.pukka.mediaapp.databinding.ActivityPlayerBinding import com.pukka.mediaapp.databinding.ActivityPlayerBinding
/** /**
* 视频播放页面 - 使用 ExoPlayer * 视频播放页面 - 使用系统原生 MediaPlayer + SurfaceView
* 兼容 Android 4.2+,无需第三方播放器库
*/ */
class VideoPlayerActivity : AppCompatActivity() { class VideoPlayerActivity : AppCompatActivity() {
private lateinit var binding: ActivityPlayerBinding private lateinit var binding: ActivityPlayerBinding
private var player: ExoPlayer? = null private var mediaPlayer: MediaPlayer? = null
private var hasError = false private var hasError = false
private var videoUrl: String = ""
private var surfaceReady = false
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@@ -38,7 +37,7 @@ class VideoPlayerActivity : AppCompatActivity() {
binding = ActivityPlayerBinding.inflate(layoutInflater) binding = ActivityPlayerBinding.inflate(layoutInflater)
setContentView(binding.root) setContentView(binding.root)
val videoUrl = intent.getStringExtra(EXTRA_VIDEO_URL).orEmpty() videoUrl = intent.getStringExtra(EXTRA_VIDEO_URL).orEmpty()
val title = intent.getStringExtra(EXTRA_TITLE).orEmpty() val title = intent.getStringExtra(EXTRA_TITLE).orEmpty()
Log.d(TAG, "onCreate: videoUrl=$videoUrl title=$title") Log.d(TAG, "onCreate: videoUrl=$videoUrl title=$title")
@@ -50,55 +49,71 @@ class VideoPlayerActivity : AppCompatActivity() {
return return
} }
setupPlayer(videoUrl) setupPlayer()
} }
private fun setupPlayer(videoUrl: String) { private fun setupPlayer() {
showLoading(true) showLoading(true)
val userAgent = Util.getUserAgent(this, "PartnerTestApp")
val httpDataSourceFactory = DefaultHttpDataSource.Factory() val surfaceView = binding.surfaceView
.setUserAgent(userAgent) surfaceView.holder.addCallback(object : SurfaceHolder.Callback {
.setConnectTimeoutMs(15000) override fun surfaceCreated(holder: SurfaceHolder) {
.setReadTimeoutMs(30000) Log.d(TAG, "surfaceCreated")
.setAllowCrossProtocolRedirects(true) surfaceReady = true
startPlayback()
val dataSourceFactory = DefaultDataSource.Factory(this, httpDataSourceFactory)
player = ExoPlayer.Builder(this)
.setMediaSourceFactory(DefaultMediaSourceFactory(dataSourceFactory))
.build()
binding.playerView.player = player
binding.playerView.useController = true
binding.playerView.controllerAutoShow = true
binding.playerView.controllerHideOnTouch = true
binding.playerView.controllerShowTimeoutMs = 5000
val mediaItem = MediaItem.fromUri(Uri.parse(videoUrl))
player?.setMediaItem(mediaItem)
player?.prepare()
player?.playWhenReady = true
player?.volume = 1f
player?.addListener(object : Player.Listener {
override fun onPlaybackStateChanged(state: Int) {
when (state) {
Player.STATE_READY -> showLoading(false)
Player.STATE_BUFFERING -> showLoading(true)
Player.STATE_ENDED -> showLoading(false)
Player.STATE_IDLE -> showLoading(false)
}
} }
override fun onPlayerError(error: com.google.android.exoplayer2.PlaybackException) { override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {}
Log.e(TAG, "onPlayerError", error)
showLoading(false) override fun surfaceDestroyed(holder: SurfaceHolder) {
showError("播放错误: ${error.message}") Log.d(TAG, "surfaceDestroyed")
surfaceReady = false
mediaPlayer?.release()
mediaPlayer = null
} }
}) })
} }
private fun startPlayback() {
if (!surfaceReady) return
try {
mediaPlayer = MediaPlayer().apply {
setDataSource(this@VideoPlayerActivity, Uri.parse(videoUrl))
setDisplay(binding.surfaceView.holder)
setOnPreparedListener { mp ->
Log.d(TAG, "onPrepared, duration=${mp.duration}")
showLoading(false)
mp.start()
mp.setVolume(1f, 1f)
}
setOnCompletionListener {
Log.d(TAG, "onCompletion")
showLoading(false)
}
setOnErrorListener { mp, what, extra ->
Log.e(TAG, "onError what=$what extra=$extra")
showLoading(false)
showError("播放错误: $what/$extra")
true
}
setOnBufferingUpdateListener { _, percent ->
Log.d(TAG, "onBufferingUpdate percent=$percent")
}
prepareAsync()
}
} catch (e: Exception) {
Log.e(TAG, "startPlayback failed", e)
showLoading(false)
showError("播放失败: ${e.message}")
}
}
private fun showLoading(show: Boolean) { private fun showLoading(show: Boolean) {
binding.progressBar.visibility = if (show) View.VISIBLE else View.GONE binding.progressBar.visibility = if (show) View.VISIBLE else View.GONE
} }
@@ -108,28 +123,30 @@ class VideoPlayerActivity : AppCompatActivity() {
showLoading(false) showLoading(false)
binding.tvError.text = message binding.tvError.text = message
binding.tvError.visibility = View.VISIBLE binding.tvError.visibility = View.VISIBLE
binding.playerView.visibility = View.GONE binding.surfaceView.visibility = View.GONE
} }
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
if (!hasError) player?.playWhenReady = true if (surfaceReady && mediaPlayer != null && !hasError) {
mediaPlayer?.start()
}
} }
override fun onPause() { override fun onPause() {
super.onPause() super.onPause()
player?.playWhenReady = false mediaPlayer?.pause()
} }
override fun onStop() { override fun onStop() {
super.onStop() super.onStop()
player?.stop() mediaPlayer?.stop()
} }
override fun onDestroy() { override fun onDestroy() {
super.onDestroy() super.onDestroy()
player?.release() mediaPlayer?.release()
player = null mediaPlayer = null
} }
override fun onWindowFocusChanged(hasFocus: Boolean) { override fun onWindowFocusChanged(hasFocus: Boolean) {

View File

@@ -1,15 +1,13 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" <FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:background="#000000"> android:background="#000000">
<com.google.android.exoplayer2.ui.PlayerView <SurfaceView
android:id="@+id/playerView" android:id="@+id/surfaceView"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent" />
app:resize_mode="fit" />
<ProgressBar <ProgressBar
android:id="@+id/progressBar" android:id="@+id/progressBar"

View File

@@ -1,5 +1,5 @@
<resources> <resources>
<style name="Theme.PartnerTest" parent="Theme.MaterialComponents.NoActionBar"> <style name="Theme.PartnerTest" parent="Theme.AppCompat.NoActionBar">
<item name="colorPrimary">#444466</item> <item name="colorPrimary">#444466</item>
<item name="colorPrimaryDark">#1A1A2E</item> <item name="colorPrimaryDark">#1A1A2E</item>
<item name="colorAccent">#00FF88</item> <item name="colorAccent">#00FF88</item>