V1.4.4: 修复曲库下载期间界面卡死/极慢(高频进度回调压死主线程)

现象: 极米 H1S 上曲库开始下载后速度极慢,并有概率卡死(ANR)。

根因: MuseDbSync.downloadFile 每读一个 256KB 分块就回调一次进度,
430MB 压缩包 = 约 1700 次回调;每次回调都 main.post -> showDatabaseLoading(),
而该函数每次刷新都在主线程做:
  setText + 改 ProgressBar + setOnClickListener(null) + bringToFront()
(bringToFront 会让整棵视图树 requestLayout)。
1700 次全屏布局把主线程 Looper 队列压满 -> 界面停止响应(卡死);
同时整机 CPU 被布局任务吃掉,网络线程与 FUSE 写盘都被拖慢 -> 下载本身也变慢。
两个症状同一个根因,不是网络问题。

修复:
- 进度回调两级去重: MuseDbSync 按百分比去重; MainActivity 按最终显示文案去重,
  刷新次数从约 1700 降到不超过 100。
- showDatabaseLoading 更新路径只做真正变化的事: 文本不同才 setText、进度不同才改
  progress、不再每次 bringToFront()、不再每次重设监听。
- 解压(约 1GB)与 md5 校验(430MB)阶段补上进度明细(例如 正在解压曲库 512MB),
  避免界面长时间停在某个百分比被误判为卡死。
- 下载速率按每 10% 上报远程日志: MUSE-DB progress 30% 129MB/430MB 1.85MB/s,
  可直接区分网络慢还是磁盘慢。
- 回调签名 (Int)->Unit 扩展为 (Int, String?)->Unit 以携带明细文案。
- AGENTS.md 增补 高频回调不能直接刷 UI 条目。

版本 1.4.3(23) 到 1.4.4(24);签名证书不变。
This commit is contained in:
zijunchiang
2026-09-26 16:23:46 +08:00
parent 62216b0fb8
commit a467719675
5 changed files with 105 additions and 28 deletions
+16 -1
View File
@@ -160,7 +160,7 @@ $BT/apksigner verify 目标名.apk
### 版本号 ### 版本号
- `app/build.gradle`:`versionName "1.4.3"` / `versionCode 23`(18~21 被本机临时诊断包占用过) - `app/build.gradle`:`versionName "1.4.4"` / `versionCode 24`(18~21 被本机临时诊断包占用过)
- App 内显示统一用纯版本号(不加 `(versionCode)` 括号) - App 内显示统一用纯版本号(不加 `(versionCode)` 括号)
- ⚠️ **发布构建已开启 NewApi 闸门**(`lint { checkReleaseBuilds true; fatal += ['NewApi'] }`): - ⚠️ **发布构建已开启 NewApi 闸门**(`lint { checkReleaseBuilds true; fatal += ['NewApi'] }`):
任何「调用了高于 `minSdk 21` 且未做版本守卫的 API」都会让 `assembleRelease` 直接失败。 任何「调用了高于 `minSdk 21` 且未做版本守卫的 API」都会让 `assembleRelease` 直接失败。
@@ -226,6 +226,21 @@ $BT/apksigner verify 目标名.apk
4. 曲库同步每一步失败都会 `MiliLog.warn("MUSE-DB ..." / "DB-SYNC ...")` 上报。 4. 曲库同步每一步失败都会 `MiliLog.warn("MUSE-DB ..." / "DB-SYNC ...")` 上报。
老设备上「不能下载曲库」先看远程日志这几行 —— 以前只写 logcat,界面只剩 老设备上「不能下载曲库」先看远程日志这几行 —— 以前只写 logcat,界面只剩
「未找到可用曲库」兜底文案,排查极其困难。 「未找到可用曲库」兜底文案,排查极其困难。
- **⚠️ 高频回调不能直接刷 UI(曲库下载 = 430MB)**:`MuseDbSync.downloadFile` 每读一个
256KB 分块回调一次进度,430MB 就是 **约 1700 次**。若每次回调都 `main.post { 改文本 +
改进度 + bringToFront() }`,主线程 Looper 队列会被压满 —— 实测表现为**界面卡死、进度停滞**,
而且整机 CPU 被布局任务吃掉后**下载本身也变慢**(极米 H1S 上就是这个现象)。
约定:
1. 进度回调必须**按最终显示文案去重**(`MuseDbSync` 侧按百分比去重、
`showDatabaseDownloadProgress` 侧按文本去重),刷新次数上限 ~100 次;
2. `showDatabaseLoading()` 的更新路径**只做真正变化的事**:文本不同才 `setText`、
进度不同才 `progress=`、**不要每次 `bringToFront()`**(整棵树 `requestLayout`)、
不要每次重设 `setOnClickListener`;
3. 解压(约 1GB)与 md5 校验(430MB)阶段**也要报进度**(回调带明细文案,
如「正在解压曲库 512MB」),否则界面会长时间停在某个百分比、看着像卡死;
4. 下载速率会按每 10% 上报一次 `MUSE-DB progress 30% 129MB/430MB 1.85MB/s`,
判断"是网络慢还是磁盘慢"直接看这几行;
5. 新增任何"周期性/高频"回调(进度、心跳、轮询)时都要按同样标准评估主线程开销。
- **歌曲源**:`app/src/main/assets/mobile/ktv_api.js` 的 `CONFIG.HOSTS`(多源自动排序,失败源沉底) - **歌曲源**:`app/src/main/assets/mobile/ktv_api.js` 的 `CONFIG.HOSTS`(多源自动排序,失败源沉底)
- **JS 热更新**:改动 `ktv_api.js` 必须同步提升 `KTV_BRIDGE_API: N` 标记(JS 与 `KtvJsBridge.kt` 两处校验),否则设备仍用缓存旧脚本 - **JS 热更新**:改动 `ktv_api.js` 必须同步提升 `KTV_BRIDGE_API: N` 标记(JS 与 `KtvJsBridge.kt` 两处校验),否则设备仍用缓存旧脚本
- **曲库**:只走官方 Muse 同步(`MuseDbSync`),不再依赖代码仓库数据库文件 - **曲库**:只走官方 Muse 同步(`MuseDbSync`),不再依赖代码仓库数据库文件
+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId "com.local.ktv" applicationId "com.local.ktv"
minSdk 21 minSdk 21
targetSdk 25 targetSdk 25
versionCode 23 versionCode 24
versionName "1.4.3" versionName "1.4.4"
ndk { ndk {
// 双 ABI 打包:兼容 64 位(Exynos 1580 等 arm64-v8a)与 32 位设备(极米 Play 超越版 // 双 ABI 打包:兼容 64 位(Exynos 1580 等 arm64-v8a)与 32 位设备(极米 Play 超越版
// 的 Amlogic 950X2/S905X2 采用 32 位 Android 用户态,必须包含 armeabi-v7a,否则 // 的 Amlogic 950X2/S905X2 采用 32 位 Android 用户态,必须包含 armeabi-v7a,否则
@@ -67,7 +67,7 @@ object DatabaseBootstrapper {
}.getOrNull() }.getOrNull()
/** 下载曲库:仅官方 Muse 同步源;成功后记录同步时间(重置 30 天时效)。 */ /** 下载曲库:仅官方 Muse 同步源;成功后记录同步时间(重置 30 天时效)。 */
fun download(onProgress: (Int) -> Unit): Result<File> { fun download(onProgress: (Int, String?) -> Unit): Result<File> {
return runCatching { return runCatching {
val file = MuseDbSync.download(onProgress) val file = MuseDbSync.download(onProgress)
// MuseDbSync 内部已保存版本文件;这里记录同步时间作为时效起点。 // MuseDbSync 内部已保存版本文件;这里记录同步时间作为时效起点。
+44 -16
View File
@@ -615,6 +615,7 @@ class MainActivity : AppCompatActivity() {
private fun initializeDatabase() { private fun initializeDatabase() {
if (databaseBootstrapRunning) return if (databaseBootstrapRunning) return
databaseBootstrapRunning = true databaseBootstrapRunning = true
lastDatabaseProgressText = ""
showDatabaseLoading(true, "正在初始化曲库...", null) showDatabaseLoading(true, "正在初始化曲库...", null)
io.execute { io.execute {
var bootstrapError: Throwable? = null var bootstrapError: Throwable? = null
@@ -684,14 +685,28 @@ class MainActivity : AppCompatActivity() {
} }
} }
private fun showDatabaseDownloadProgress(value: Int) { /**
* 最近一次真正刷到界面上的曲库进度文案。
*
* ⚠️ 曲库下载是 430MB,会回调约 1700 次;每次都在主线程做「改文本 + 改进度 + 置顶视图」
* 会把 UI 线程压死(表现为界面卡死、进度停滞,整机 CPU 被布局任务吃掉后下载本身也变慢)。
* 因此这里按**最终显示文案**去重:既能挡掉高频分块回调,又能让「正在解压曲库 xxxMB」
* 这类明细正常刷新。
*/
@Volatile private var lastDatabaseProgressText = ""
private fun showDatabaseDownloadProgress(value: Int, detail: String?) {
val percent = value.coerceIn(0, 100)
val phase = when { val phase = when {
value < 80 -> "正在下载并解压曲库" percent < 80 -> "正在下载曲库"
value < 90 -> "正在完成解压" percent < 90 -> "正在解压曲库"
value < 100 -> "正在校验数据库" percent < 100 -> "正在校验曲库"
else -> "完成" else -> "曲库就绪"
} }
main.post { showDatabaseLoading(true, "$phase ${value}%", value) } val text = detail ?: "$phase $percent%"
if (text == lastDatabaseProgressText) return
lastDatabaseProgressText = text
main.post { showDatabaseLoading(true, text, percent) }
} }
private fun warmRemoteBrowseCache(count: Int) { private fun warmRemoteBrowseCache(count: Int) {
@@ -8883,18 +8898,31 @@ class MainActivity : AppCompatActivity() {
root.addView(overlay, FrameLayout.LayoutParams(-1, -1)) root.addView(overlay, FrameLayout.LayoutParams(-1, -1))
databaseLoadingOverlay = overlay databaseLoadingOverlay = overlay
} }
databaseLoadingOverlay?.visibility = View.VISIBLE // ⚠️ 这里是**每次进度刷新都会走**的路径(曲库下载 430MB)。必须做到「只在真正变化时
databaseLoadingText?.text = message // 才改 View、且不做重排」:早先的实现每次都 setText + 改 progress + setOnClickListener(null)
databaseLoadingProgress?.apply { // + bringToFront()(整棵视图树 requestLayout),把主线程压死 → 界面卡死、下载也变慢。
visibility = if (progressValue == null) View.INVISIBLE else View.VISIBLE val overlay = databaseLoadingOverlay ?: return
progress = progressValue ?: 0 if (overlay.visibility != View.VISIBLE) {
overlay.visibility = View.VISIBLE
overlay.bringToFront()
} }
databaseRetryButton?.apply { val textView = databaseLoadingText
visibility = View.GONE if (textView != null && textView.text?.toString() != message) {
isEnabled = true textView.text = message
setOnClickListener(null) }
val bar = databaseLoadingProgress
if (bar != null) {
val wanted = if (progressValue == null) View.INVISIBLE else View.VISIBLE
if (bar.visibility != wanted) bar.visibility = wanted
val percent = progressValue ?: 0
if (bar.progress != percent) bar.progress = percent
}
val retry = databaseRetryButton
if (retry != null && retry.visibility == View.VISIBLE) {
retry.visibility = View.GONE
retry.isEnabled = true
retry.setOnClickListener(null)
} }
databaseLoadingOverlay?.bringToFront()
} }
private fun showDatabaseLoadingFailure(message: String) { private fun showDatabaseLoadingFailure(message: String) {
+42 -8
View File
@@ -50,8 +50,12 @@ object MuseDbSync {
file.version file.version
}.getOrNull() }.getOrNull()
/** 下载并安装曲库。失败抛异常,由调用方决定是否回退到其它源。 */ /**
fun download(onProgress: (Int) -> Unit): File { * 下载并安装曲库。
* @param onProgress (百分比, 明细文案);明细非空时优先显示(如「正在解压曲库 512MB」),
* 因为解压/校验阶段很慢,只给百分比会让界面长时间不动、看着像卡死。
*/
fun download(onProgress: (Int, String?) -> Unit): File {
val cookie = login() ?: error("登录曲库服务失败") val cookie = login() ?: error("登录曲库服务失败")
val file = sync(cookie) ?: error("曲库同步失败或无可用文件") val file = sync(cookie) ?: error("曲库同步失败或无可用文件")
val version = file.version val version = file.version
@@ -59,23 +63,32 @@ object MuseDbSync {
val gzFile = File(target.parentFile, "${target.name}.gz.download") val gzFile = File(target.parentFile, "${target.name}.gz.download")
val tmpDb = File(target.parentFile, "${target.name}.download") val tmpDb = File(target.parentFile, "${target.name}.download")
try { try {
downloadFile(file.url, gzFile) { pct -> onProgress((pct * 80 / 100).coerceIn(0, 80)) } downloadFile(file.url, gzFile) { pct -> onProgress((pct * 80 / 100).coerceIn(0, 80), null) }
onProgress(80) onProgress(80, "正在校验曲库文件")
if (file.md5.isNotEmpty()) { if (file.md5.isNotEmpty()) {
check(md5(gzFile).equals(file.md5, ignoreCase = true)) { "曲库文件校验失败" } check(md5(gzFile).equals(file.md5, ignoreCase = true)) { "曲库文件校验失败" }
} }
onProgress(84) onProgress(84, "正在解压曲库 0MB")
GZIPInputStream(gzFile.inputStream().buffered(256 * 1024)).use { gz -> GZIPInputStream(gzFile.inputStream().buffered(256 * 1024)).use { gz ->
BufferedOutputStream(FileOutputStream(tmpDb)).use { out -> BufferedOutputStream(FileOutputStream(tmpDb)).use { out ->
val buffer = ByteArray(256 * 1024) val buffer = ByteArray(256 * 1024)
var written = 0L
var lastStep = -1
while (true) { while (true) {
val count = gz.read(buffer) val count = gz.read(buffer)
if (count < 0) break if (count < 0) break
out.write(buffer, 0, count) out.write(buffer, 0, count)
written += count
// 解压约 1GB、在该设备上要几分钟:每 64MB 报一次,界面才不会像是卡死。
val step = (written / (64L * 1024 * 1024)).toInt()
if (step != lastStep) {
lastStep = step
onProgress(84, "正在解压曲库 ${written / 1048576}MB")
}
} }
} }
} }
onProgress(96) onProgress(96, "正在安装曲库")
val backup = File(target.parentFile, "${target.name}.backup") val backup = File(target.parentFile, "${target.name}.backup")
backup.delete() backup.delete()
if (target.exists()) check(target.renameTo(backup)) { "无法备份旧曲库" } if (target.exists()) check(target.renameTo(backup)) { "无法备份旧曲库" }
@@ -85,7 +98,7 @@ object MuseDbSync {
} }
backup.delete() backup.delete()
saveVersion(version) saveVersion(version)
onProgress(100) onProgress(100, "曲库就绪")
return target return target
} finally { } finally {
gzFile.delete() gzFile.delete()
@@ -204,6 +217,8 @@ object MuseDbSync {
val code = conn.responseCode val code = conn.responseCode
check(code in 200..299) { "HTTP $code" } check(code in 200..299) { "HTTP $code" }
val total = conn.contentLengthCompat() val total = conn.contentLengthCompat()
val startedAt = System.currentTimeMillis()
var lastPercent = -1
conn.inputStream.buffered(256 * 1024).use { input -> conn.inputStream.buffered(256 * 1024).use { input ->
dest.outputStream().buffered(256 * 1024).use { out -> dest.outputStream().buffered(256 * 1024).use { out ->
val buffer = ByteArray(256 * 1024) val buffer = ByteArray(256 * 1024)
@@ -213,7 +228,26 @@ object MuseDbSync {
if (count < 0) break if (count < 0) break
out.write(buffer, 0, count) out.write(buffer, 0, count)
read += count read += count
if (total > 0) onProgress(((read * 100) / total).toInt().coerceIn(0, 99)) if (total > 0) {
val percent = ((read * 100) / total).toInt().coerceIn(0, 99)
// ⚠️ 必须按百分比去重:430MB 会读约 1700 个 256KB 分块,
// 每块都回调一次会把主线程 Looper 队列压满(界面卡死、整机 CPU 被
// 布局任务吃掉,下载本身也跟着变慢)。
if (percent != lastPercent) {
lastPercent = percent
onProgress(percent)
if (percent % 10 == 0) {
val seconds = ((System.currentTimeMillis() - startedAt) / 1000.0)
.coerceAtLeast(0.001)
val mbps = read / 1048576.0 / seconds
MiliLog.info(
"MUSE-DB progress $percent% " +
"${read / 1048576}MB/${total / 1048576}MB " +
"${String.format(Locale.ROOT, "%.2f", mbps)}MB/s",
)
}
}
}
} }
} }
} }