V1.4.1: 修复 Android 5.1.1 设备一打开就闪退(API 守卫缺失)
根因(极米 H1S / MStar 6A928 / GMUI2.0 / Android 5.1.1):
- MainActivity 把 API 23 才有的 android.media.AudioDeviceCallback 写成了
**字段初始化器**(private val micDeviceCallback = object : ...)。
字段初始化在 Activity 构造期执行,早于 onCreate 里的 SDK_INT 判断,
于是下面的 registerMicInputWatcher() 版本守卫形同虚设 ——
构造 MainActivity 即抛 NoClassDefFoundError,表现为一打开就闪退。
- 顺带修复 36 处同类未守卫的高版本 API 调用(lint NewApi 全量清零):
* AudioDeviceCallback / AudioDeviceInfo.isSource/getType (API 23)
→ 改为可空字段 + 仅在 SDK_INT>=23 分支按需创建(@TargetApi)
* HashMap.putIfAbsent ×5 (API 24) → MutableMap.getOrPut
* Comparator.comparing ×3 + comparingInt ×1 (API 24) → Kotlin compareBy
(同时移除 java.util.function 导入)
* ConcurrentHashMap.newKeySet ×2 (API 24) → Collections.newSetFromMap
(连带 16 处 KeySetView.add/remove/contains)
* ConcurrentHashMap.computeIfAbsent (API 24) → ConcurrentHashMap.putIfAbsent(API 1)
* URLConnection.getContentLengthLong ×5 (API 24) → 新增 Compat.contentLengthCompat()
* ByteArrayOutputStream.toString(Charset) (API 33) → String(bytes, UTF_8)
防回归:
- app/build.gradle 打开 lint 闸门: checkReleaseBuilds true + abortOnError true +
fatal += NewApi(并 disable ExpiredTargetSdkVersion,避免它挤占闸门)。
已实测: 植入一个 NewApi 违规后 assembleRelease 直接 FAILED。
- AGENTS.md 新增「低版本设备闪退(API 守卫)」条目,记录根因、替代写法与自查命令。
版本号 1.4.0(16) → 1.4.1(17);签名证书不变,可覆盖安装。
This commit is contained in:
@@ -160,11 +160,34 @@ $BT/apksigner verify 目标名.apk
|
||||
|
||||
### 版本号
|
||||
|
||||
- `app/build.gradle`:`versionName "1.4.0"` / `versionCode 16`
|
||||
- `app/build.gradle`:`versionName "1.4.1"` / `versionCode 17`
|
||||
- App 内显示统一用纯版本号(不加 `(versionCode)` 括号)
|
||||
- ⚠️ **发布构建已开启 NewApi 闸门**(`lint { checkReleaseBuilds true; fatal += ['NewApi'] }`):
|
||||
任何「调用了高于 `minSdk 21` 且未做版本守卫的 API」都会让 `assembleRelease` 直接失败。
|
||||
被阻断时按报错位置补版本判断或改用兼容写法,**不要**把 `checkReleaseBuilds` 改回 `false`
|
||||
来绕过 —— 这道闸门专为「低版本设备一打开就闪退」而设(见第三节同名条目)
|
||||
|
||||
## 三、关键实现约束(避免踩坑)
|
||||
|
||||
- **⚠️ 低版本设备闪退(API 守卫)**:`minSdk 21`,但**实际客户里有 Android 5.1.1 的机器**
|
||||
(极米 H1S / MStar 6A928 / GMUI2.0)。以下三类写法会让 app 在 API 22 上**一打开就闪退**,
|
||||
必须避免:
|
||||
1. **把高版本 API 的类写在 `Activity`/`Fragment`/`object` 的字段初始化器里** ——
|
||||
字段初始化在**构造期**执行,早于 `onCreate` 里的 `SDK_INT` 判断,因此守卫形同虚设。
|
||||
v1.4.0 的闪退根因就是 `private val micDeviceCallback = object : android.media.AudioDeviceCallback() {...}`
|
||||
(`AudioDeviceCallback` 是 API 23)→ 构造 `MainActivity` 时 `NoClassDefFoundError`。
|
||||
正确写法:字段声明为可空、在 `SDK_INT >= 23` 分支里**按需创建**(见 `ensureMicDeviceCallback()`)。
|
||||
2. **Java 8 的集合默认方法**:`Map.getOrDefault` / `Map.putIfAbsent` / `ConcurrentHashMap.newKeySet`
|
||||
/ `computeIfAbsent` / `Comparator.comparing` / `List.sort` 等都是 **API 24+**,
|
||||
API 21~23 上抛 `NoSuchMethodError`。替代:`getOrPut`、
|
||||
`Collections.newSetFromMap(ConcurrentHashMap())`、`ConcurrentHashMap.putIfAbsent`(API 1)、
|
||||
Kotlin `compareBy`。
|
||||
3. 其它漏网的:`URLConnection.getContentLengthLong()`(API 24,用 `Compat.kt` 的
|
||||
`contentLengthCompat()`)、`ByteArrayOutputStream.toString(Charset)`(API 33)。
|
||||
- 自查命令(必须为 0 条):`./gradlew :app:lintRelease` 后
|
||||
在 `app/build/reports/lint-results-release.txt` 里搜 `NewApi`。
|
||||
- 根因扫描捷径:`grep -n "private val .* = object : android\." app/src/main/java/com/local/ktv/*.kt`
|
||||
—— 字段初始化器里出现 `android.*` 的高版本类就是要闪退。
|
||||
- **歌曲源**:`app/src/main/assets/mobile/ktv_api.js` 的 `CONFIG.HOSTS`(多源自动排序,失败源沉底)
|
||||
- **JS 热更新**:改动 `ktv_api.js` 必须同步提升 `KTV_BRIDGE_API: N` 标记(JS 与 `KtvJsBridge.kt` 两处校验),否则设备仍用缓存旧脚本
|
||||
- **曲库**:只走官方 Muse 同步(`MuseDbSync`),不再依赖代码仓库数据库文件
|
||||
|
||||
+14
-5
@@ -11,8 +11,8 @@ android {
|
||||
applicationId "com.local.ktv"
|
||||
minSdk 21
|
||||
targetSdk 25
|
||||
versionCode 16
|
||||
versionName "1.4.0"
|
||||
versionCode 17
|
||||
versionName "1.4.1"
|
||||
ndk {
|
||||
// 双 ABI 打包:兼容 64 位(Exynos 1580 等 arm64-v8a)与 32 位设备(极米 Play 超越版
|
||||
// 的 Amlogic 950X2/S905X2 采用 32 位 Android 用户态,必须包含 armeabi-v7a,否则
|
||||
@@ -52,10 +52,19 @@ android {
|
||||
noCompress 'ts', 'mp3', 'mp4', 'aac', 'm4a', 'flac', 'wav'
|
||||
}
|
||||
|
||||
// 关闭发布构建的 lint vital 检查(本地分发的 APK 不需要 Google Play 上架要求)
|
||||
// lint 策略:
|
||||
// - 「调用了高于 minSdk 且未做版本守卫的 API」(NewApi) 必须**阻断发布构建**:
|
||||
// 这类问题在 Android 5.x / 6.x 上直接 NoSuchMethodError / NoClassDefFoundError 闪退,
|
||||
// 而本地打包测试用的都是较新设备, 极容易漏出去(v1.4.0 在极米 H1S / Android 5.1.1
|
||||
// 一打开就闪退, 根因就是 AudioDeviceCallback(API 23) 被写进了 Activity 的字段初始化器)。
|
||||
// - ExpiredTargetSdkVersion 默认也是 fatal 级, 但本地分发的 APK 不上架 Google Play,
|
||||
// 显式关掉, 否则会挤占下面这道闸门。
|
||||
// - abortOnError 必须为 true: 设为 false 会把 lintVital 的失败一并吞掉, 闸门会失效。
|
||||
lint {
|
||||
checkReleaseBuilds false
|
||||
abortOnError false
|
||||
fatal += ['NewApi']
|
||||
disable += ['ExpiredTargetSdkVersion']
|
||||
checkReleaseBuilds true
|
||||
abortOnError true
|
||||
}
|
||||
|
||||
// 启用 BuildConfig 生成,供 AboutFragment 读取版本号/版本Code等信息
|
||||
|
||||
@@ -77,7 +77,7 @@ object AppUpdateManager {
|
||||
}
|
||||
try {
|
||||
require(connection.responseCode in 200..299) { "下载失败:HTTP ${connection.responseCode}" }
|
||||
val total = connection.contentLengthLong
|
||||
val total = connection.contentLengthCompat()
|
||||
connection.inputStream.use { input ->
|
||||
FileOutputStream(partial).use { output ->
|
||||
val buffer = ByteArray(128 * 1024)
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.local.ktv
|
||||
|
||||
import android.os.Build
|
||||
import java.net.URLConnection
|
||||
|
||||
/**
|
||||
* 低版本兼容工具。
|
||||
*
|
||||
* 背景:`minSdk 21`,但代码里用过若干 API 22 不存在的方法,在 Android 5.x
|
||||
* (如极米 H1S / GMUI2.0 / Android 5.1.1)上会在运行期抛 `NoSuchMethodError`。
|
||||
* 这里把这类调用收敛成带版本判断的兼容函数。
|
||||
*/
|
||||
|
||||
/**
|
||||
* 读取 HTTP 响应体长度。
|
||||
*
|
||||
* `URLConnection.getContentLengthLong()` 是 **API 24(N)** 才加入的;API 21~23 上直接调用
|
||||
* 会抛 `NoSuchMethodError`。低版本回退到 `getContentLength()`(int,2GB 以内等价)。
|
||||
*/
|
||||
fun URLConnection.contentLengthCompat(): Long =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
contentLengthLong
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
contentLength.toLong()
|
||||
}
|
||||
@@ -271,7 +271,7 @@ class KtvJsBridge(context: Context) {
|
||||
if (connection.responseCode != HttpURLConnection.HTTP_OK) {
|
||||
throw IllegalStateException("HTTP ${connection.responseCode}")
|
||||
}
|
||||
val declaredLength = connection.contentLengthLong
|
||||
val declaredLength = connection.contentLengthCompat()
|
||||
require(declaredLength < 0 || declaredLength <= MAX_RESPONSE_BYTES) { "JS file is too large" }
|
||||
val bytes = connection.inputStream.use { it.readBytes() }
|
||||
require(bytes.size <= MAX_RESPONSE_BYTES) { "JS file is too large" }
|
||||
@@ -508,7 +508,8 @@ class KtvJsBridge(context: Context) {
|
||||
out.write(readExact(input, size.toLong()))
|
||||
readAsciiLine(input) // 吃掉 chunk 尾部 CRLF
|
||||
}
|
||||
return out.toString(Charsets.UTF_8)
|
||||
// ByteArrayOutputStream.toString(Charset) 是 API 33+;低版本用 String(bytes, charset)。
|
||||
return String(out.toByteArray(), Charsets.UTF_8)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.local.ktv
|
||||
|
||||
import android.Manifest
|
||||
import android.annotation.TargetApi
|
||||
import android.app.AlertDialog
|
||||
import android.content.DialogInterface
|
||||
import android.content.DialogInterface.OnShowListener
|
||||
@@ -106,8 +107,6 @@ import java.util.LinkedHashMap
|
||||
import java.util.concurrent.ExecutorService
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.function.Function
|
||||
import java.util.function.ToIntFunction
|
||||
import kotlin.Any
|
||||
import kotlin.Array
|
||||
import kotlin.Boolean
|
||||
@@ -534,7 +533,7 @@ class MainActivity : AppCompatActivity() {
|
||||
private fun registerMicInputWatcher() {
|
||||
val manager = audioManager ?: return
|
||||
if (Build.VERSION.SDK_INT >= 23) {
|
||||
manager.registerAudioDeviceCallback(micDeviceCallback, null)
|
||||
manager.registerAudioDeviceCallback(ensureMicDeviceCallback(), null)
|
||||
} else {
|
||||
runCatching {
|
||||
val filter = IntentFilter(Intent.ACTION_HEADSET_PLUG)
|
||||
@@ -546,20 +545,37 @@ class MainActivity : AppCompatActivity() {
|
||||
private fun unregisterMicInputWatcher() {
|
||||
val manager = audioManager ?: return
|
||||
if (Build.VERSION.SDK_INT >= 23) {
|
||||
manager.unregisterAudioDeviceCallback(micDeviceCallback)
|
||||
micDeviceCallback?.let { manager.unregisterAudioDeviceCallback(it) }
|
||||
micDeviceCallback = null
|
||||
} else {
|
||||
runCatching { unregisterReceiver(micHotplugReceiver) }
|
||||
}
|
||||
}
|
||||
|
||||
private val micDeviceCallback = object : android.media.AudioDeviceCallback() {
|
||||
override fun onAudioDevicesAdded(addedDevices: Array<out android.media.AudioDeviceInfo>) {
|
||||
handleMicInputDevicesChanged(addedDevices.any { isMicInputDevice(it) })
|
||||
}
|
||||
/**
|
||||
* 输入设备变化回调(`android.media.AudioDeviceCallback`, API 23+)。
|
||||
*
|
||||
* ⚠️ **必须按需创建,不能写成字段初始化器**:字段初始化在 Activity 构造时执行,
|
||||
* 早于 `onCreate`,而 `Build.VERSION.SDK_INT` 判断发生在 `onCreate` 之后。
|
||||
* Android 5.x(API 22) 没有 `AudioDeviceCallback` 这个类 —— 对象在构造期被实例化会直接
|
||||
* `NoClassDefFoundError` 闪退(极米 H1S / GMUI2.0 / Android 5.1.1 的闪退根因)。
|
||||
*/
|
||||
private var micDeviceCallback: android.media.AudioDeviceCallback? = null
|
||||
|
||||
override fun onAudioDevicesRemoved(removedDevices: Array<out android.media.AudioDeviceInfo>) {
|
||||
handleMicInputDevicesChanged(removedDevices.any { isMicInputDevice(it) })
|
||||
@TargetApi(23)
|
||||
private fun ensureMicDeviceCallback(): android.media.AudioDeviceCallback {
|
||||
micDeviceCallback?.let { return it }
|
||||
val created = object : android.media.AudioDeviceCallback() {
|
||||
override fun onAudioDevicesAdded(addedDevices: Array<out android.media.AudioDeviceInfo>) {
|
||||
handleMicInputDevicesChanged(addedDevices.any { isMicInputDevice(it) })
|
||||
}
|
||||
|
||||
override fun onAudioDevicesRemoved(removedDevices: Array<out android.media.AudioDeviceInfo>) {
|
||||
handleMicInputDevicesChanged(removedDevices.any { isMicInputDevice(it) })
|
||||
}
|
||||
}
|
||||
micDeviceCallback = created
|
||||
return created
|
||||
}
|
||||
|
||||
private val micHotplugReceiver = object : BroadcastReceiver() {
|
||||
@@ -568,7 +584,8 @@ class MainActivity : AppCompatActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
/** 是否为可用的麦克风输入设备(内置/耳机/USB 等)。 */
|
||||
/** 是否为可用的麦克风输入设备(内置/耳机/USB 等)。仅由 API 23+ 的输入设备回调调用。 */
|
||||
@TargetApi(23)
|
||||
private fun isMicInputDevice(device: android.media.AudioDeviceInfo): Boolean {
|
||||
if (!device.isSource) return false
|
||||
return when (device.type) {
|
||||
@@ -4635,10 +4652,7 @@ class MainActivity : AppCompatActivity() {
|
||||
song
|
||||
)
|
||||
}
|
||||
Collections.sort<Song?>(
|
||||
visibleSongs,
|
||||
Comparator.comparing<Song?, String?>(Function { song: Song? -> song!!.title!!.lowercase() })
|
||||
)
|
||||
Collections.sort(visibleSongs, compareBy { it.title!!.lowercase() })
|
||||
renderSongs()
|
||||
}
|
||||
|
||||
@@ -4652,10 +4666,7 @@ class MainActivity : AppCompatActivity() {
|
||||
))
|
||||
) visibleSongs.add(song)
|
||||
}
|
||||
Collections.sort<Song?>(
|
||||
visibleSongs,
|
||||
Comparator.comparing<Song?, String?>(Function { song: Song? -> song!!.title!!.lowercase() })
|
||||
)
|
||||
Collections.sort(visibleSongs, compareBy { it.title!!.lowercase() })
|
||||
renderSongs()
|
||||
}
|
||||
|
||||
@@ -4710,10 +4721,7 @@ class MainActivity : AppCompatActivity() {
|
||||
song
|
||||
)
|
||||
}
|
||||
Collections.sort<Song?>(
|
||||
visibleSongs,
|
||||
Comparator.comparing<Song?, String?>(Function { song: Song? -> song!!.title!!.lowercase() })
|
||||
)
|
||||
Collections.sort(visibleSongs, compareBy { it.title!!.lowercase() })
|
||||
renderSongs()
|
||||
if (pageInfo != null) pageInfo!!.setText("本地 " + visibleSongs.size + " 首")
|
||||
}
|
||||
@@ -5563,7 +5571,7 @@ class MainActivity : AppCompatActivity() {
|
||||
SongFileValidator.requiresTransportStream(file),
|
||||
).valid
|
||||
) {
|
||||
localSongs.putIfAbsent(stableId(song), song)
|
||||
localSongs.getOrPut(stableId(song)) { song }
|
||||
}
|
||||
}
|
||||
val allLocalSongs = localSongs.values.toList()
|
||||
@@ -5971,10 +5979,7 @@ class MainActivity : AppCompatActivity() {
|
||||
BufferedReader(InputStreamReader(FileInputStream(file), "UTF-8")).use { reader ->
|
||||
var line: String?
|
||||
while ((reader.readLine().also { line = it }) != null) parseLyricLine(line)
|
||||
Collections.sort<LyricLine?>(
|
||||
lyricLines, Comparator.comparingInt<LyricLine?>(
|
||||
ToIntFunction { item: LyricLine? -> item!!.timeMs })
|
||||
)
|
||||
Collections.sort(lyricLines, compareBy { it!!.timeMs })
|
||||
updateLyricView()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
@@ -8415,7 +8420,7 @@ class MainActivity : AppCompatActivity() {
|
||||
.plus(library.allSongs().asSequence())
|
||||
.forEach { song ->
|
||||
if (song.hasLocalFile() || isDownloaded(song)) {
|
||||
localSongs.putIfAbsent(stableId(song), song)
|
||||
localSongs.getOrPut(stableId(song)) { song }
|
||||
}
|
||||
}
|
||||
val filtered = localSongs.values.filter {
|
||||
@@ -8686,7 +8691,7 @@ class MainActivity : AppCompatActivity() {
|
||||
).valid
|
||||
) {
|
||||
song.path = resolved.absolutePath
|
||||
result.putIfAbsent(stableId(song), song)
|
||||
result.getOrPut(stableId(song)) { song }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8711,13 +8716,13 @@ class MainActivity : AppCompatActivity() {
|
||||
val databaseSongs = library.muse.songsByFilenames(filesByName.keys)
|
||||
databaseSongs.forEach { song ->
|
||||
filesByName[song.filename]?.let { song.path = it.absolutePath }
|
||||
result.putIfAbsent(stableId(song), song)
|
||||
result.getOrPut(stableId(song)) { song }
|
||||
}
|
||||
val matchedNames = databaseSongs.mapNotNullTo(HashSet()) { it.filename }
|
||||
validFiles.filter { it.name !in matchedNames }.forEach { file ->
|
||||
val song = Song.local(file.absolutePath, file.name)
|
||||
song.filename = file.name
|
||||
result.putIfAbsent(stableId(song), song)
|
||||
result.getOrPut(stableId(song)) { song }
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -194,7 +194,7 @@ object MuseDbSync {
|
||||
}
|
||||
val code = conn.responseCode
|
||||
check(code in 200..299) { "HTTP $code" }
|
||||
val total = conn.contentLengthLong
|
||||
val total = conn.contentLengthCompat()
|
||||
conn.inputStream.buffered(256 * 1024).use { input ->
|
||||
dest.outputStream().buffered(256 * 1024).use { out ->
|
||||
val buffer = ByteArray(256 * 1024)
|
||||
|
||||
@@ -39,7 +39,9 @@ object SingerAvatarLoader {
|
||||
return
|
||||
}
|
||||
|
||||
val waiters = waiting.computeIfAbsent(key) { CopyOnWriteArrayList() }
|
||||
// ConcurrentHashMap.computeIfAbsent 是 API 24+;putIfAbsent 自 API 1 起可用,语义等价。
|
||||
val created = CopyOnWriteArrayList<WeakReference<ImageView>>()
|
||||
val waiters = waiting.putIfAbsent(key, created) ?: created
|
||||
waiters += WeakReference(view)
|
||||
if (waiters.size > 1) return
|
||||
val cacheDir = File(view.context.applicationContext.cacheDir, "singer_avatars").apply { mkdirs() }
|
||||
@@ -70,7 +72,7 @@ object SingerAvatarLoader {
|
||||
connection.instanceFollowRedirects = true
|
||||
connection.setRequestProperty("User-Agent", "MaidongKTV/${BuildConfig.VERSION_NAME}")
|
||||
require(connection.responseCode in 200..299)
|
||||
require(connection.contentLengthLong <= 0 || connection.contentLengthLong <= MAX_DOWNLOAD_BYTES)
|
||||
require(connection.contentLengthCompat() <= 0 || connection.contentLengthCompat() <= MAX_DOWNLOAD_BYTES)
|
||||
var total = 0L
|
||||
connection.inputStream.buffered().use { input ->
|
||||
FileOutputStream(temporary).buffered().use { output ->
|
||||
|
||||
@@ -32,12 +32,16 @@ object SongOkDownloadManager {
|
||||
}
|
||||
|
||||
private val tasks = ConcurrentHashMap<String, OkDownloadTask>()
|
||||
private val pending = ConcurrentHashMap.newKeySet<String>()
|
||||
// ConcurrentHashMap.newKeySet() 是 API 24+(低版本 NoSuchMethodError);
|
||||
// Collections.newSetFromMap 自 API 9 起可用,且返回普通 Set,add/remove/contains 均成立于 API 1。
|
||||
private val pending: MutableSet<String> =
|
||||
Collections.newSetFromMap(ConcurrentHashMap<String, Boolean>())
|
||||
private val callbacks = ConcurrentHashMap<String, DownloadCallback>()
|
||||
private val progress = ConcurrentHashMap<String, Int>()
|
||||
private val totalLengths = ConcurrentHashMap<String, Long>()
|
||||
private val forcedRetryReasons = ConcurrentHashMap<String, String>()
|
||||
private val manuallyCanceled = ConcurrentHashMap.newKeySet<String>()
|
||||
private val manuallyCanceled: MutableSet<String> =
|
||||
Collections.newSetFromMap(ConcurrentHashMap<String, Boolean>())
|
||||
private val io = Executors.newFixedThreadPool(4)
|
||||
private val watchdog = Executors.newSingleThreadScheduledExecutor()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user