现象: 极米 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);签名证书不变。
30 KiB
麦动 KTV — 项目开发约定
本文件供后续开发(含 AI 助手会话)遵循,优先级高于默认习惯。
一、代码同步与发布策略(2026-09-14 更新)
代码主仓库:Gitea https://gitea.kasugano.cn/zijunchiang/maidong-ktv
| 远端 | 地址 | 用途 |
|---|---|---|
origin |
https://gitea.kasugano.cn/zijunchiang/maidong-ktv.git |
✅ 代码主仓库,唯一的代码同步目标 |
gitee |
https://gitee.com/zijunchiang/maidong-ktv.git |
📦 不推代码,只用于发布 Release;仓库内只保留 README 文档 + Releases 附件 |
四条硬性规则
- 推送失败只重试 3 次:Gitea 不可用(网络不通/服务器故障/返回非 200)时,最多重试 3 次
(间隔约 60s)。3 次仍失败就停止重试,改动保留为本地 commit,留到下次一起同步
(不要无限重试、不要为了"推上去"而改推 Gitee)。恢复同步时直接
git push origin master把积压的 commit 一并推上即可。 - Gitee 不再同步代码,只发布版本:Gitee 仓库保持「仅 README 文档 + Releases 附件」状态,
禁止推送源码(
pre-push钩子会拦截gitee.com)。Gitee 上只做一件事:创建 Release 并上传 APK 附件。 - 每次更新版本号 +1:完成一次修改就把版本号递增(
versionName与versionCode同步递增), 例如1.3.0→1.3.1→1.3.2。不要在同一个版本号下反复改动交付。 - 发布是手动操作:未收到明确的「发布新版本」指令时,默认只在本地打包测试——
产物按内部测试约定命名
maidong-ktv-<版本>-<改动说明>-双ABI-release.apk, 不创建 Release、不上传附件、不推送 tag、不在 Gitee 建 Release。 只有收到发布指令后,才按「发布新版本」小节在 Gitea(以及需要的 Gitee)建 Release。⚠️ 不要混淆"交付"与"发布":向 Gitea
git push origin master同步代码(规则 1)、 本地构建/签名出*-release.apk(规则 4 前半段)都不算发布,不需要、也不得顺带 建 Release / 打 tag / 传附件。发布动作只有三个:建 Release、打 tag、上传附件; 三者的触发条件只有一个 —— 收到明确的人工发布指令。若不确定是否已发布, 用GET /repos/{owner}/{repo}/releases与/tags核对,不要凭"打过包"推断。
- 凭据:
- Gitea(代码主仓库):访问令牌
dsh-sync-20260926(2026-09-26 数据迁移后重建;可在 Gitea 「设置 → 应用 → 访问令牌」随时吊销),存放于~/.git-credentials-gitea(权限 600), 由按主机限定的仓库级credential.https://gitea.kasugano.cn.helper引用; - Gitee(仅发布 Release,按规则 2 不推代码):访问令牌存放于
~/.git-credentials-gitee(权限 600),由全局credential.https://gitee.com.helper引用;只用于 API 建 Release / 传附件,不得用于推送源码。 - 账号密码不写入仓库配置。
- ⚠️ 两个 helper 都必须按主机限定作用域(
credential.https://<host>.helper,不要用无主机限定的credential.helper):git 认证成功后会调用每个 helper 的store,无主机限定会把 Gitee 凭据 追加写进 Gitea 的凭据文件(一个文件两行),此后「取第一行」式的令牌提取会拿到错误令牌、API 报 401。 - ⚠️ 提取令牌必须按主机过滤:用
sed -nE 's|https://[^:]+:([^@]+)@<host>.*|\1|p', 不要用不带主机锚点的sed -E 's|https://[^:]+:([^@]+)@.*|\1|'(多行文件会输出多行、拼成错令牌)。
- Gitea(代码主仓库):访问令牌
推送方式
cd /home/zijun/dsh_data/maidongktv/maidong-ktv
git push origin master # origin = Gitea(唯一的代码同步目标)
Gitee 不参与代码同步;确需回推文档时用
git push --no-verify gitee master, 但按规则 2 正常情况下不应执行。
运行时端点(已全部迁移到 Gitea)
App 内部两处端点已指向 Gitea(随本版 APK 生效):
AppUpdateManager.kt:gitea.kasugano.cn/api/v1/repos/zijunchiang/maidong-ktv/releases/latest(App 内检查更新)KtvJsBridge.kt:gitea.kasugano.cn/zijunchiang/maidong-ktv/raw/branch/master/app/src/main/assets/mobile/ktv_api.js(JS 热更新)
注意:Gitea 的 raw 规范路径是
/raw/branch/<分支>/<文件>;/raw/<分支>/<文件>会 303 跳转。 Gitea 的 raw 与 release 下载对无 User-Agent 的请求同样放行(App 裸 socket 可直连)。
发布新版本(Gitea Release)
TOKEN=$(sed -nE 's|https://[^:]+:([^@]+)@gitea\.kasugano\.cn.*|\1|p' ~/.git-credentials-gitea)
# 1) 创建 Release(tag 需高于当前 versionName 才会提示更新)
curl -X POST -u "zijunchiang:$TOKEN" -H "Content-Type: application/json" \
-d '{"tag_name":"v1.2.6","name":"麦动KTV V1.2.6","body":"更新说明","target_commitish":"master"}' \
https://gitea.kasugano.cn/api/v1/repos/zijunchiang/maidong-ktv/releases
# 2) 上传 APK 附件(用返回的 release id;附件名必须 ASCII)
curl -X POST -u "zijunchiang:$TOKEN" \
-F "attachment=@麦动KTV-V1.2.6-release.apk" \
"https://gitea.kasugano.cn/api/v1/repos/zijunchiang/maidong-ktv/releases/<id>/assets?name=maidong-ktv-V1.2.6-release.apk"
⚠️ 附件文件名必须为 ASCII:Gitea 1.27 的规范下载路径
/releases/download/<tag>/<文件名>对非 ASCII 文件名返回 404(/attachments/<uuid>直链虽可用, 但releases/latestAPI 返回的是规范路径)。因此 Gitea 上的附件用maidong-ktv-V1.2.x-release.apk这类 ASCII 名;本地留存的发布包仍可命名麦动KTV-V1.2.x-release.apk。上传 22MB APK 约需 70s,注意命令超时设置。
发布新版本(Gitee Release)
GT=$(sed -nE 's|https://[^:]+:([^@]+)@gitee\.com.*|\1|p' ~/.git-credentials-gitee)
# 1) 创建 Release(Gitee 会自动创建同名 tag)
curl -X POST -H "Authorization: token $GT" -H "Content-Type: application/json" \
-d '{"tag_name":"v1.4.0","name":"麦动KTV V1.4.0","body":"更新说明","target_commitish":"master"}' \
https://gitee.com/api/v5/repos/zijunchiang/maidong-ktv/releases
# 2) 上传 APK 附件(表单字段名是 file,不是 attachment;用返回的 release id)
curl -X POST -H "Authorization: token $GT" \
-F "file=@麦动KTV-V1.4.0-release.apk" \
"https://gitee.com/api/v5/repos/zijunchiang/maidong-ktv/releases/<id>/attach_files"
Gitee 附件名可用中文(ASCII 限制只针对 Gitea 1.27),上传 22MB 约 4s。 发布后务必校验下载:
curl -sL <browser_download_url> | md5sum应与本地发布包一致 (Gitea 侧同样要校验/releases/download/<tag>/<文件>规范路径返回 200)。
二、构建与产物
构建(release,双 ABI)
cd /home/zijun/dsh_data/maidongktv/maidong-ktv
. /home/zijun/dsh_data/maidongktv/env.sh # 导入 JAVA_HOME / ANDROID_HOME / PATH
./gradlew assembleRelease
产物:app/build/outputs/apk/release/app-release.apk(已由 app/build.gradle 的 signingConfig 签名)
本机工具链(2026-09-26 数据迁移后重建)
数据迁移后的本机是纯净环境且当前用户无 sudo,因此工具链全部装在用户态,
不要再用系统路径(/usr/lib/jvm/... 不存在)。统一入口:
. /home/zijun/dsh_data/maidongktv/env.sh # 已加入 ~/.bashrc,新开 shell 自动生效
| 组件 | 路径 | 来源 |
|---|---|---|
| JDK 17 | /home/zijun/tools/jdk-17(Temurin 17.0.20.1) |
Adoptium tarball 解包 |
| git | /home/zijun/tools/bin/git(包装脚本 → /home/zijun/tools/gitroot/usr/bin/git) |
apt-get download git + dpkg -x |
| Android SDK | /home/zijun/Android/Sdk(与 local.properties 的 sdk.dir 一致) |
cmdline-tools + sdkmanager |
| 已装 SDK 包 | platforms;android-35、build-tools;34.0.0、build-tools;36.0.0、platform-tools |
sdkmanager |
| 签名密钥 | /home/zijun/dsh_data/maidongktv/ktv_keystore.jks(仓库外,勿入库) |
数据迁移 |
git包装脚本是必需的:deb 内 git 的编译期exec-path写死为/usr/lib/git-core(无 root 无法创建),包装脚本显式设置GIT_EXEC_PATH/GIT_TEMPLATE_DIR后才找得到git-remote-https等子命令。- 迁移会把仓库文件权限改成 744(git 记为 1288 个纯 mode change、0 行内容变化)。
遇到大量
mode change 100644 => 100755时,按索引恢复权限即可,不要当成内容改动:git ls-files -s -z | ... chmod(或临时git config core.fileMode false)。
签名与校验(单独签名/改名时)
BT=/home/zijun/Android/Sdk/build-tools/36.0.0
$BT/apksigner sign --ks /home/zijun/dsh_data/maidongktv/ktv_keystore.jks \
--ks-key-alias ktv --ks-pass pass:123456 --key-pass pass:123456 \
--out 目标名.apk app/build/outputs/apk/release/app-release.apk
$BT/apksigner verify 目标名.apk
- 密钥:
/home/zijun/dsh_data/maidongktv/ktv_keystore.jks(仓库外,勿入库) - APK 命名约定:
麦动KTV-V<版本号>-release.apk(对外发布)/maidong-ktv-<版本>-<改动说明>-双ABI-release.apk(内部测试)
版本号
app/build.gradle:versionName "1.4.4"/versionCode 24(18~21 被本机临时诊断包占用过)- 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 上一打开就闪退, 必须避免:- 把高版本 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())。 - 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)、 KotlincompareBy。 - 其它漏网的:
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.*的高版本类就是要闪退。
- 把高版本 API 的类写在
- ⚠️ 存储根目录必须运行时解析(不要再写死
/storage/emulated/0):AppPaths曾把根目录写死成/storage/emulated/0/MaidongKTV,在极米 H1S(GMUI2.0 / Android 5.1.1) 上该路径不可写 →ensureDirectories()的check(it.exists() || it.mkdirs())抛异常 → App 一打开就闪退(这台机器历来所有版本都打不开的真正原因,2026-09-26 定位)。 现在的约定:AppPaths.init(context)在KtvApplication.attachBaseContext最前面调用,按 「Environment.getExternalStorageDirectory()→ 历史硬编码路径 →getExternalFilesDir()→filesDir」顺序挑第一个真正可建且可写的目录(幂等);- 所有用到根路径的地方必须走
AppPaths.*的 getter,绝不能用const val把路径编译期内联 (MuseDatabase.VIDEO_ROOT、MuseDatabase.defaultDbFile()曾经就是const val内联的, 改存储根后必须一起改成运行时取值); - 目录创建不许抛异常:
AppPaths.ensureDirectories()返回Boolean,失败只记日志,MainActivity拿到 false 也只是告警继续,绝不能让存储问题变成闪退; - 用户可见的目录提示文案也要用
AppPaths.*Dir.absolutePath拼,不要写死/sdcard/...; - 启动时会往远程日志打一行
STORAGE root=... by=... envState=... probe=[...], 换盒子/换 ROM 时先看这行就能确认存储根落在哪。
- 自查:
grep -rn "/storage/emulated\|/sdcard/MaidongKTV" app/src/main/java --include='*.kt'除AppPaths的候选清单外不应再有硬编码。
- ⚠️ 老系统(API < 24)的 TLS 根证书:
*.cherryonline.cn(曲库同步mconn/ 点歌登录mws) 与gitea.kasugano.cn都用 Let's Encrypt 新链(leaf ← YR2 ← Root YR ← ISRG Root X1)。res/xml/network_security_config.xml里的三个内置根证书只在 Android 7.0(API 24)+ 生效, Android 5.1.1 上被完全忽略、系统信任库又没有 ISRG 新根 →SSLHandshakeException: Trust anchor for certification path not found→ 曲库登录失败 →「不能下载曲库」(这就是极米 H1S 上第二个卡点的根因,2026-09-26 定位)。 约定:TlsCompat.install(context)(在KtvApplication.attachBaseContext)在 API<24 上把 「系统默认信任 ∪ 内置根证书」的复合X509TrustManager装到全局HttpsURLConnection.setDefaultSSLSocketFactory,一次覆盖 App 内所有HttpsURLConnection(曲库同步、JS 热更新下载、更新检查、头像下载)。主机名校验保持系统默认,绝不放行任意证书;- WebView 的 TLS 信任无法用 SSLContext 覆盖,而 JS(
ktv_api.js)会直连https://mws.cherryonline.cn。因此在KtvJsBridge的onReceivedSslError里绝不盲目proceed():先用TlsCompat.isHostTrustedByOurStore(host)对同一主机独立做一次 TLS 校验, 通过才放行,否则cancel(); - 以后替换/新增内置根证书时,
TlsCompat.BUNDLED_CERTS与network_security_config.xml两处要同步改(一个管 API<24、一个管 API>=24); - 曲库同步每一步失败都会
MiliLog.warn("MUSE-DB ..." / "DB-SYNC ...")上报。 老设备上「不能下载曲库」先看远程日志这几行 —— 以前只写 logcat,界面只剩 「未找到可用曲库」兜底文案,排查极其困难。
- ⚠️ 高频回调不能直接刷 UI(曲库下载 = 430MB):
MuseDbSync.downloadFile每读一个 256KB 分块回调一次进度,430MB 就是 约 1700 次。若每次回调都main.post { 改文本 + 改进度 + bringToFront() },主线程 Looper 队列会被压满 —— 实测表现为界面卡死、进度停滞, 而且整机 CPU 被布局任务吃掉后下载本身也变慢(极米 H1S 上就是这个现象)。 约定:- 进度回调必须按最终显示文案去重(
MuseDbSync侧按百分比去重、showDatabaseDownloadProgress侧按文本去重),刷新次数上限 ~100 次; showDatabaseLoading()的更新路径只做真正变化的事:文本不同才setText、 进度不同才progress=、不要每次bringToFront()(整棵树requestLayout)、 不要每次重设setOnClickListener;- 解压(约 1GB)与 md5 校验(430MB)阶段也要报进度(回调带明细文案, 如「正在解压曲库 512MB」),否则界面会长时间停在某个百分比、看着像卡死;
- 下载速率会按每 10% 上报一次
MUSE-DB progress 30% 129MB/430MB 1.85MB/s, 判断"是网络慢还是磁盘慢"直接看这几行; - 新增任何"周期性/高频"回调(进度、心跳、轮询)时都要按同样标准评估主线程开销。
- 进度回调必须按最终显示文案去重(
- 歌曲源:
app/src/main/assets/mobile/ktv_api.js的CONFIG.HOSTS(多源自动排序,失败源沉底) - JS 热更新:改动
ktv_api.js必须同步提升KTV_BRIDGE_API: N标记(JS 与KtvJsBridge.kt两处校验),否则设备仍用缓存旧脚本 - 曲库:只走官方 Muse 同步(
MuseDbSync),不再依赖代码仓库数据库文件 - 曲库同步时效:默认 30 天(
DatabaseBootstrapper.SYNC_TTL_DAYS)。未到期启动不联网、直接用本地曲库;到期后才查询服务器版本,有更新才下载。时效起点记录在db_sync_at.txt,由DatabaseBootstrapper.markSynced()写入(下载成功或校验为最新时)。设置页「曲库 → 更新」为手动强制检查(绕过时效,仅在服务器版本更新时下载) - 麦克风:
MicActivator常驻占用输入通道(TV/投影需占用后才有演唱声音输出),外接设备热插拔会自动重建 - 设备 MAC:统一由
DeviceMac解析当前在用网卡的硬件地址(12 位小写十六进制)。优先wlan*/eth*/en*,忽略虚拟/蜂窝/VPN 接口;Android 6 起WifiInfo.getMacAddress()恒为02:00:00:00:00:00,因此直接读NetworkInterface.getHardwareAddress();读出02:00:00:00:00:00或全 0 视为无效。读取失败回退DeviceMac.FALLBACK_MAC(历史抓包值),保证身份永不为空。 结果按进程缓存,网络切换时由remoteNetworkMonitor调用DeviceMac.refresh()重解析。 消费方:KtvJsBridge.DEVICE_MAC(JS 端mac)、whoami 心跳的_mac/androidid/_cid(抓包时三者同值,保持一致) - 播放信息实时刷新:
refreshNowPlayingLabels()是播放界面左上角「当前播放 / 下一首」、首页播放条、 旧版播放区与顶部状态栏的唯一刷新入口,只在文本变化时写 View;已挂到lyricTicker(500ms)上, 因此队列变化(本地点歌 / 扫码点歌 / 自动切歌)最迟 500ms 内必然同步。新增播放信息展示时请复用它, 不要另起刷新路径 - 远程日志:接入 GlogCenter 集中式日志(HTTP 接口,单条 JSON)。域名属敏感信息,不得写入任何文档
(端点常量见代码
MiliLog.ENDPOINT,需要时查代码,勿在 README/AGENTS 中记录域名)。字段:system/date/text/servername/serverip/clientip/loglevel/traceid/user;date必须为yyyy-MM-dd HH:mm:ss.SSS,否则按时间检索异常。其中serverip= 主机 IP(公网出口 IP, 由MiliLog.PUBLIC_IP_URL查询,结果缓存 5 分钟、失败 1 分钟后重试,查询在独立后台线程, 绝不在主线程发起),clientip= 客户端 IP(设备当前网卡获取到的局域网 IP,由MiliLog.updateDeviceContext(ip = ...)注入,网络变化时自动刷新并顺带复核公网 IP)。 客户端为MiliLog(有界队列 + 后台线程 + 失败重试),调用点MiliLog.info/notice/warn/error保持不变。启动上报(reportDeviceBootInfo())只采集这 10 项:包名、版本号、安装时间、品牌、型号、 设备名、Android 版本、CPU 架构、内核版本、屏幕分辨率 - 网络状态与断网恢复:
KtvNetworkMonitor(object,走ConnectivityManager回调、不轮询) 维护全局online;MainActivity.onNetworkStateChanged()负责:- 断网 → 播放页右上角
fullScreenNetworkBadge显示「网络未连接」(在全屏控制层 header 内、 标题右侧,不遮挡右上角二维码); - 恢复 →
refreshNetworkState():重解析网卡 MAC + 日志上下文、重建歌曲源会话、 复位Eac19WindowGuard、重启本地点歌服务并刷新二维码、自动重试断网期间失败的下载(最多 8 首)。 换网/换 IP 时remoteNetworkMonitor走同一个refreshNetworkState()(静默,不弹提示)。 ⚠️ 「断网重连后再也下载不了、日志却显示歌曲源获取成功」的根因:SongApiClient.clearTokenCache()原本是空实现,JS 侧window.api.tokens与_apiInitPromise会被一直复用;而歌曲源按「出口 IP + device」识别、试用窗口也记在出口 IP 上, 断网/重连(尤其换出口 IP)后旧 token 已失效 —— 取址这一步看起来成功,真正下载却 403/失败。 现在clearTokenCache()会真正下发KtvJsBridge.resetApiSession()(置空window.api与_apiInitPromise,下次取址重新构造 API 实例、回到默认源顺序并重新登录)。 下载层的网络类失败由SongOkDownloadManager结合KtvNetworkMonitor.online与异常类型/HTTP 0 判定并记入networkFailed,网络恢复后由retryNetworkFailed()自动重试(手动取消的不入集合)。 ⚠️ 顺带修掉一个隐患:离线时localIp()为空,原remoteNetworkMonitor会每 5s 误判「IP 变化」 而反复重启本地点歌服务(并连带触发刷新);现已加空值短路,离线只等网络回调。
- 断网 → 播放页右上角
- 播放界面右上角二维码:跟随全屏控制层一起出现/隐藏 —— 在
setFullScreenChromeVisible()里同步fullScreenQrOverlay的可见性,updateFullScreenQrOverlay()新建时按fullScreenChromeVisible初始化。不要再按「手机是否在点歌」自动隐藏 (原qrAutoHideMonitor已删除,扫码弹窗里的对应提示文案也一并删除) - 远程页面滑杆(触控):
<input type="range">绝不能被<label>包裹 —— label 会把 内部任意位置的点击转发给 range,表现为"点轨道/点文字就跳值"。行容器用<div class="audio-row">/<div class="eq-band">;CSS 选择器也随之用类选择器而不是标签选择器。 另外滑杆元素本身高度设 32px(触控目标够大),视觉细轨道由::-webkit-slider-runnable-track/::-moz-range-track绘制,滑块 22px + 5px 边框。attachThumbOnlyDrag()仍作为第二道防线(命中滑块才放行原生拖动,容差按 32px 滑块算) - 远程搜索性能:曲库 DB 有 67 万首 / 约 1GB(
muse.db),搜索是 I/O 密集操作。 实测(同一台机器直连该 DB,rec_score复合索引齐全):- 中文
name_trim LIKE '%kw%':热态 2~280ms(走idx_rec_score顺序扫描 + 早停,无临时排序) - 拼音
name_cap/name_full GLOB 'kw*':热态 0.2~150ms(前缀范围扫描 + 临时 B-tree 排序;A*这类高频前缀会扫很多行),冷态(首次读 1GB 文件)可达 1~15s - 歌手检索(
EXISTS+ singers LIKE):热态 34~70ms 结论与对策:瓶颈在冷态 I/O 与重复查询,因此
- 设备端加
remoteSearchCache(LRU 24 条 = 最近 4 个查询 × 6 页,TTL 60s,曲库刷新时清空), 同一「类型+关键词+页码」直接复用 JSON,翻页与重复搜索不再查库; - 手机端:搜索请求用
AbortController取消旧请求、同关键词不重复请求、 首页返回后预热第 2 页(把设备缓存捂热)、搜索期间暂停 1s 的状态轮询避免抢 I/O。 ⚠️ 不要试图用「一次多取几页」来省请求:实测LIMIT 201的代价远高于LIMIT 21(name_full GLOB301 行 6.2s vs 21 行 0.7s,冷态),会把体验做得更差
- 中文
- 焦点填充与「纯色遮罩层」:
TvFocusStyler.install()会把背景整体换成不透明红→橙渐变, 作为遥控器焦点/按下的反馈(prepareTvFocusableTree(window.decorView)与对话框的installTree(decorView)会遍历整棵树施加)。按钮/Tab/卡片/list 行需要这个反馈;但 大面积展示型表面绝不能加 —— 否则触摸一次(state_pressed)就用纯色盖住整屏内容。 已排除的表面:播放画面宿主(home_player_container/player_container)、视频层 (persistentVideo)、播放触摸层(playerTouchOverlay)、全屏播放容器(R.id.container)、 启动加载遮罩(databaseLoadingOverlay)、手机点歌二维码浮层、全屏音量面板。 新增此类铺满/覆盖播放画面的 View 时,必须调用TvFocusStyler.suppressFill(view) - 音量默认值:
KtvStore.DEFAULT_MUSIC_VOLUME(当前 85%)是唯一来源,KtvStore默认值与optInt兜底、MainActivity.musicVolume初值、调音弹窗「默认」按钮 都引用它;远程页面assets/mobile/{index.html,app.js}的默认值需手工跟改 - 音量控制(只控 App 输出,不动系统音量):
musicVolume(0-100) 是 App 内部输出音量, 统一在applyPlaybackMode()内按线性系数施加到播放器(KtvVideoView.setPlaybackVolume/selectAudioChannel)与副音轨播放器(vocalPlayer/embeddedVocalEngine)。 严禁调用AudioManager.setStreamVolume/adjustStreamVolume修改系统音量。 入口只有两处,且都必须收敛到applyPlaybackMode():「调音」弹窗(showTuningDialog(), 播放界面与主界面右上角共用同一个弹窗)的音量横向控制杆、远程控制-音乐音量 (remoteAudio()的musicVolume)。声音设置弹窗里不再有音量滑杆与均衡器入口 (避免同一功能两套入口),它只保留「切到点播歌曲时音量/原伴唱」这类策略选择。曲线是纯衰减(100% 即 unity gain),不做系统级增益; 设备最终响度仍取决于系统音量,App 不再替用户改动它 - 音频均衡器:内核
KtvEqualizer(参考开源项目 OpenEQ,Turbofan3360/OpenEQ,GPL-3.0 的EQUtils.kt)——直接用系统android.media.audiofx.Equalizer,按设备真实频段数与增益范围自适应 (频段是动态生成的,不要写死 5 段)。默认把 EQ 挂到实际播放会话:player.audioSessionId()/vocalPlayer.audioSessionId/embeddedVocalEngine.audioSessionId()(IJK 的原生IjkMediaPlayer_getAudioSessionId返回真实 AudioTrack 会话)。会话会随歌曲变化, 因此由 500ms 的lyricTicker→syncEqualizerSessions()周期对齐,消失的会话延迟 3s 释放。globalMix(会话 0)是设备不支持按会话挂载时的显式开关,会影响本机所有声音,默认关闭。 均衡器已并入**「调音」弹窗**(showTuningDialog(),自上而下:音量横向杆 → 均衡器开关 → 预设 → EQ 频段横向杆,每个频段一行:频率 | 滑杆 | 增益),主界面右上角与播放界面「调音」 是同一个弹窗;另有远程点歌页面「均衡器」卡片(PATCH /api/v1/player/equalizer,状态随GET /api/v1/state的equalizer字段下发)。预设:App 内为「自定义/流行/摇滚/舞曲」 (appPresetNames(),手动改频段即转「自定义」并自动保存),远程页面额外保留「标准」 供「恢复标准」使用。设置持久化在KtvStore.eq*; 刻度固定 30 档(eqSliderSteps),步进由设备增益范围均分,保证 0 dB 一定可选中。 音量与频段共用自绘控件EqSlider(横向),已suppressFill并自绘焦点/调节描边。 ⚠️ 它的交互是刻意定制的,不要换回 SeekBar:- 遥控:焦点在控件上时方向键不改数值(先让焦点在控件间移动);按确认键进入调节态 (描边变亮变粗)后方向键才调值,再按确认键退出调节态;调节态下 BACK 只退出调节态、 不关弹窗;失焦自动退出。这是"需按确认键才可调节控件点、同理按确认键退出控制"的实现。
- 触控:必须按住滑块(控件点)才拖动,点轨道空白不跳值(
hitsThumb命中判断, 容差thumbTouchSlop)。远程点歌页面同规则,见attachThumbOnlyDrag()(app.js,对input[type=range]做指针命中判断,未命中则 preventDefault)
- 公放随机清单:
RandomPlayQueue(纯内存单例)——随机续播不再「每次现随机挑一首」, 而是一次性生成打乱顺序的临时清单,一轮内不重复;播完一首markFinished+1;next()每次都重新比对曲库,自动吸纳新增歌曲、剔除已删除歌曲;一轮播完自动开新一轮。 不显示(界面不展示)、不关联已点清单(orderQueue完全独立,随机歌曲不入队)、 App 退出即清理(onDestroy里isFinishing时clear(),不落盘) - 弹窗分辨率自适应:TV/投影/盒子的分辨率与 density 差异很大(1280x720、1920x1080,
density 1.0~2.5),固定 dp 尺寸在矮屏上会被屏幕裁切。统一用
fitDialogToScreen(dialog, scroll, content, preferredWidthDp, …):宽度取min(preferredWidthDp, 屏幕宽*0.94),高度把内容所在的 ScrollView 限制为屏幕高*0.92 - 弹窗标题/按钮框架(chromeDp)—— 不要再用WRAP_CONTENT作为 弹窗高度。内容高度由「屏幕预算 - 固定区高度」反推(调音弹窗的 EQ 频段区就是这样自适应), 预算不足时弹窗内滚动而不是被裁掉。已应用:调音弹窗、扫码点歌弹窗、声音设置弹窗。 新增/改动弹窗时请复用该 helper,别再写死setLayout(w, WRAP_CONTENT)。 ⚠️ 文字行必须 wrap_content:label()自带 6dp 上下内边距,固定矮行(曾用 18/28/42dp) 会把文字裁掉 —— 这是"文字显示不全"的根因。做法是「文字行 wrap_content + 只有无文字区域 才用计算高度」:调音弹窗里唯一可伸缩的是 EQ 频段区,采用两遍 fit(先panel.measure()实测内容高度,超出预算就把频段区减掉,再调fitDialogToScreen),这样收缩的永远是频段区 而不是文字;频段区下限 dp(96),再放不下才滚动。大字体(系统字体缩放)同理自动兜住 - 仓库可见性:Gitea 仓库保持 public(若后续把 App 更新/热更新端点迁到 Gitea,非 public 会导致 403)