Files
tv/优化检测.md
2026-11-09 15:49:32 +08:00

143 lines
17 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
# 一份「先搬砖再装修」的优化清单
先说结论:这个项目的**业务复杂度堆得比工程基建快很多**。嗅探→解析→原生播放这条链路写得很实在(`PlaylistCache` 解决一次性 m3u8、`seriesPayloads` 规避 `TransactionTooLargeException`、`PlayerSsl` 兜底老电视证书,都是有真实现场经验的方案,注释也讲清了 why)。真正欠账的是**安全边界、功能细节 Bug、构建可复现性、可测试性、架构解耦与颜色资源化**。
---
## P0 · 安全与功能级 Bug(建议优先做,改动小、风险降幅最大)
**1. 手机遥控 HTTP 服务完全没有鉴权,且常驻监听 `0.0.0.0:8766`**
- `RemoteControlServer.kt:69-97` 的 `serve()` 不校验任何 token/cookie/来源;`RemoteControlServer.kt:56-67` 还返回 `Access-Control-Allow-Origin: *`。同一个 WiFi 下任何设备(甚至同一浏览器里打开的任意网页)都能 `POST /cmd`。
- 通过 `/cmd` 能做的:`open_url` 让电视加载任意地址(`BrowserActivity.kt:899-907`)、`text` 往当前输入框注入文字、`key` 模拟按键。
- 更严重的是 `/upload`(`RemoteControlServer.kt:162-219`)→ 落盘 → `.apk` 触发安装器(`RemoteControlHelper.kt:171-199`,配合 Manifest 的 `REQUEST_INSTALL_PACKAGES`)。即"局域网内任意人可投递 APK 到电视,只差用户点一下确认"。
- 建议方向:去掉 `Access-Control-Allow-Origin: *`(阻断恶意网页通过 JS 跨站请求 `127.0.0.1:8766` 的本地 CSRF),并在服务端渲染 `remote.html` 时内嵌随机 Session Token 供同源请求校验;上传目录加扩展名白名单,`.apk` 弹窗明确展示来源并走二次确认。
**2. `PlayerSsl.enableLenient()` 是进程级、永久、不可回滚的全局降级**
- `PlayerSsl.kt:60-79` 改了 `HttpsURLConnection.setDefaultSSLSocketFactory` + `setDefaultHostnameVerifier`,一旦播放中遇到一次 SSL 错误,**整个进程**后续所有 `HttpsURLConnection`(`VodSearchHelper` 抓站、其他网络请求)都不再校验证书。
- 建议:不要动全局默认值。给 ExoPlayer 单独配 `OkHttpDataSource.Factory` 或自定义 `DataSource` 的 SSLSocketFactory,作用域收敛到播放器;`VodSearchHelper` 用独立的 client。
**3. Manifest 备份与安装权限偏松**
- `allowBackup="true"`:收藏、浏览历史、播放记录会被云备份/`adb backup` 导出。
- `REQUEST_INSTALL_PACKAGES` 与上面的上传链路组合即攻击面,见第 1 条。
**4. 写死年份的"系统时钟异常"判定会漂移**
- `BrowserActivity.kt:334-338` 用 `Calendar.YEAR < 2025` 放行证书错误,`BrowserUiHelper.kt:373` 又是 `< 2024`。两个阈值未来会慢慢变成"全年无条件放行/永远不提示"。建议改成"设备时间与 HTTPS 响应 Date 头偏差超过 N 天"这类不随时间腐烂的判据。
**5. 快传文件目录多套一层 `uploads`,导致过期清理失效 & 无外置存储时崩溃**
- **路径嵌套错位**:`RemoteControlServer.kt:180` 写了 `File(context.getExternalFilesDir("uploads") ?: context.filesDir, "uploads")`。当 `getExternalFilesDir("uploads")` 非空时,其路径本身已是 `.../files/uploads`,再拼一层 `"uploads"` 会将文件实际存入 `.../files/uploads/uploads/`。
- **过期清理完全失效**:`BrowserUiHelper.cleanExpiredUploadFilesAsync`(`BrowserUiHelper.kt:204-219`)仅遍历 `getExternalFilesDir("uploads")` 一级目录下的 `f.isFile`,而子目录 `uploads` 的 `isFile` 为 `false`,导致启动时的「3 天过期上传文件清理」永远不会删掉任何文件。
- **内部存储回退崩溃**:当部分电视盒子无外置存储回退到 `context.filesDir/uploads` 时,`res/xml/file_paths.xml` 仅声明了 `<external-files-path>` 和 `<cache-path>`,缺少 `<files-path>`,此时 `RemoteControlHelper.promptInstallApk`(`RemoteControlHelper.kt:173-177`)调用 `FileProvider.getUriForFile` 会直接抛出 `IllegalArgumentException`。
**6. 快传视频使用 `Uri.fromFile` 导致调起外部播放器必崩(`FileUriExposedException`)**
- `RemoteControlHelper.handleFileUploaded`(`RemoteControlHelper.kt:162`)通过 `Uri.fromFile(file).toString()` 生成 `file://` 地址传给 `PlayerActivity`。
- 内置 ExoPlayer 可直接播放 `file://`,但若用户在播放器内点击「外部播放」(`PlayerActivity.launchExternalPlayer`,`:1319-1340`),在 Android 7.0+(本项目 `targetSdk = 34`)下向外部应用发送 `file://` URI 会直接抛出 `FileUriExposedException`(被 catch 后误提示为"未找到已安装的外部视频播放器")。应统一通过 `FileProvider` 生成 `content://` URI 并授予 `FLAG_GRANT_READ_URI_PERMISSION`。
**7. 进入 `PlayerActivity` 全屏看片时,手机遥控器的方向键/暂停/返回失效**
- `RemoteControlHelper` 目前强绑定在 `BrowserActivity` 上,其回调 `BrowserActivity.onRemoteKey`(`BrowserActivity.kt:880-897`)只向 `BrowserActivity` 分发按键。
- 当电视跳转到全屏 `PlayerActivity` 播放视频时,用户在手机遥控器网页上按下的方向键(快进/快退/呼出选集)、返回键、菜单键全部发往处于后台的 `BrowserActivity`,无法控制正在前台播放的 `PlayerActivity`。建议将遥控事件路由给当前处于前台 `RESUMED` 状态的 Activity。
---
## P1 · 构建、依赖与死代码清理(能直接减小体积、让换机器能编译)
| 问题 | 证据 | 影响 |
|---|---|---|
| 3 个依赖零引用 | `leanback 1.2.0-alpha04`、`material`、`constraintlayout` 在 Kotlin 与 layout 中均 0 处引用 | alpha 版 leanback 还把它的人声资源 `lb_voice_*.ogg`、`exo_ic_*` 打进 APK |
| `nanohttpd-websocket` 零引用 | 全仓库无 `NanoWSD` / `WebSocket`,`remote.html` 用的是 `XMLHttpRequest`(3 处) | 声明了双向通道依赖但从未实现 |
| **整文件死代码与废弃布局未清理** | `VideoWebActivity.kt`(及 `AndroidManifest.xml:54-58` 与 `activity_video_web.xml`)、`BookmarksDialog.kt`、`BookmarkAdapter.kt`(及 `dialog_bookmarks.xml`、`item_bookmark.xml`)、`video_control_bar.xml` 全工程 0 处引用 | 首页已改用网格卡片 `HomeCardAdapter`,旧版弹窗与 Web 播放页纯属历史遗留负重 |
| **死方法未清理** | `RemoteControlDialog.getLocalIpAddress()`(`:192-212`,实际调的是 `NetworkChecker`)、`VideoControlHelper` 中的 `JS_HAS_VIDEO`/`JS_GET_STATE`/`JS_GET_VIDEO_SRC`/`JS_GET_CONTENT_URL`、`PlaySource.listToJson`/`listFromJson`(`VideoSource.kt:42-56`) | 增加阅读与维护干扰 |
| 测试依赖声明了却没用 | `libs.versions.toml:34-36` 有 junit/junit-ext/espresso,`app/build.gradle.kts` 里从未引用,也不存在 `src/test`、`src/androidTest` 目录 | 见 P1「零测试」 |
| **缺少 Gradle Wrapper 脚本** | 只有 `gradle/wrapper/gradle-wrapper.properties`,`gradlew`、`gradlew.bat`、`gradle-wrapper.jar` 都不存在 | 换机器/CI 只能用 Android Studio 内建 Gradle,命令行无法构建 |
| 无 release 签名配置 | `app/build.gradle.kts:18-27` 的 `release {}` 没有 `signingConfig` | `assembleRelease` 产出未签名包 |
| 版本全线落后 | AGP 8.2.2 / Gradle 8.2 / Kotlin 1.9.22 / media3 1.3.1 / compileSdk 34 | `kotlinOptions` 已废弃;`targetSdk` 偏低在长期分发上会被卡 |
| `gradle.properties` 偏保守 | 只有 `-Xmx2048m`,无 `parallel` / `caching` / `configuration-cache` | 构建慢 |
| 1.4MB 的 `classes.jar` 提交进了仓库根目录 | `git ls-files` 可查 | 与项目无关的构建产物污染仓库 |
| 无 `.gitattributes` | 实测 62 个 CRLF 文件 / 19 个 LF 文件混用 | Windows 与其他平台协作会产生无意义 diff |
| 无 CI、无 `.github` | — | 没有最低限度的「编译 + lint」护栏 |
体积实测(`app-debug.apk`):压缩后 10.4MB,解压 21.35MB,含 **5 个 dex(9.2MB + 8.3MB 两个大 dex)**、`resources.arsc` 1.6MB。移除上面无用依赖与死代码 + 开启 R8 full mode 能实打实变小。
---
## P1 · 架构与可维护性(决定后续还能不能改)
**1. 两个巨型文件**
- `PlayerActivity.kt` **1717 行 / 68 个函数**:`dispatchTouchEvent` 单函数 114 行(1417-1537)、`onCreate` 75 行、`dispatchKeyEvent` 84 行、`handlePlayerError`/`resolveAndPlay`/`tryFailover` 交织。合理切法:`PlaybackSession`(源/线路/失败换线)、`EpisodeNavigator`(选集/预解析/连播)、`PlayerOsdController`(OSD/进度/网速/反馈)、`PlayerGestureHandler`(触摸手势)。
- `dialog_settings.xml` **880 行 / 110 个 view**,且 5 个面板全在一个文件里。建议按面板拆成 5 个 `View`/`include` 子布局。
**2. 83KB 内联 JavaScript 塞在 Kotlin 三引号字符串里**
`VideoSnifferHelper.kt` 28KB、`PurifyHelper.kt` 27KB、`ContentFilterHelper.kt` 12KB、`VideoControlHelper.kt` 3.8KB、`UserAgentHelper.kt` 2.9KB、`AdBlockHelper.kt` 2.8KB、`RemoteControlHelper.kt` 1.6KB。后果:没有语法高亮、不能单独 lint、改一次要重新编译 APK、无法在浏览器里独立调试、diff 全是噪音。建议迁到 `assets/js/*.js`,首次需要时读取 + 缓存,甚至可以做一次 `JS 端单测`(Node 跑一遍语法与关键规则)。
**3. 三份几乎相同的 JSON Repository(且 `UserScriptRepository` 漏了内存缓存)**
`BookmarkRepository.kt` / `HistoryRepository.kt` / `PlaybackHistoryRepository.kt` 是同一套「静态缓存 + `synchronized(Companion)` + `JSONArray` 全量重写 + `apply()`」的复制粘贴,约 200 行重复;而 `UserScriptRepository.kt` 甚至漏掉了静态内存缓存,每次 `onPageFinished` 都在主线程重读 SharedPreferences 并解析 JSON。抽 `JsonListStore<T>(prefsName, key, maxEntries, encode, decode)` 即可统一全部四个 Repository 的缓存、并发与裁剪策略。
**4. 设置项散落在 9 个 SharedPreferences 文件里**
`tv_browser_settings` 被 `BrowserActivity.kt:84` 和 `AppSettings.kt:12` **两处重复定义常量各自访问**;此外还有 `_adblock` / `_bookmarks` / `_content_filter` / `_history` / `_playback_history` / `_search_engine` / `_user_scripts` / `_vod_search`。建议收敛为一个 `SettingsRepository`(键集中 + 迁移版本号),`ContentFilterHelper`/`AdBlockHelper` 里那些 `@Volatile cachedXxx` 的手写缓存也可以统一。
**5. Activity 强耦合与进程重建状态丢失**
`SettingsDialog.kt` 里 12 处 `(activity as? BrowserActivity)?` 强耦合具体 Activity;**全项目 0 个 `onSaveInstanceState`**——进程被杀后,`PlayerActivity` 的选集/线路数据本来只存在内存 token 里(`PlayerActivity.kt:1619-1638`,为规避 Binder 限制的巧妙做法),此时无法直接恢复内存列表。可考虑落一个轻量 JSON 缓存文件存剧集页 + 集号 + 进度。
**6. 硬编码颜色与废弃资源条目**
| 指标 | 数量 |
|---|---|
| layout 里十六进制颜色 | **109 处**,19 种颜色,`#FFFFFF` 出现 30 次、`#38BDF8` 22 次、`#94A3B8` 20 次 |
| `colors.xml` 里真正被用到的 | 只有 `black` / `white` / `toolbar_bg`,`primary` / `accent` / `focus_highlight` / `url_bar_bg` 基本没人用 |
| `strings.xml` 废弃条目 | 78 条中有 **12 条从未被引用** |
建议清理未引用的 `strings.xml` / `colors.xml` 条目,并将高频的 `#FFFFFF`、`#38BDF8`、`#94A3B8` 等主题色抽入 `colors.xml` 统一管理。
**7. 默认影视站名单硬编码在代码里**
`BookmarkRepository.kt:111-141` 有 14 个默认站点 + 4 个版本的新增迁移表(`DEFAULTS_ADDED_BY_VERSION` 这个设计其实很好)。问题是站点会死,改一次要发版。建议把这套表挪到远端 JSON(失败时回落到内置表),迁移版本号机制可以原样保留。
**8. 小事但会误导用户**
`SettingsDialog.kt:386` 把 `"${BuildConfig.VERSION_NAME} (Release)"` 写死,debug 包也显示 "(Release)"。
---
## P1 · 零测试(这是最值得投资的"优化")
项目里 **`src/test` 与 `src/androidTest` 目录都不存在**,但代码中恰恰有一大批**纯函数、无 Android 依赖、非常好测**的点:
- `VideoSnifferHelper.isVideoStreamUrl` / `isHls` / `isMediaSegmentUrl` / `isLikelyVariantOf` / `buildPlaybackHeaders` / `isWebOnlyStream`(`:27-190`)
- `ContentFilterHelper.registrableDomain` / `shouldBlockImage` / `shouldBlockScript`
- `AdBlockHelper.shouldBlockUrl` / `isHijackScheme`(这里用 `lower.contains(pattern)` 的朴素子串匹配,误伤风险需要用例兜住)
- `PlaylistCache` 的 `isMaster` / `isVod` / `take` 语义(一次性链接取完即弃,逻辑微妙)
- `SiteInfo.normalizeUrl`、`AppSettings.seriesKey`、`PlaybackRecord.displaySubtitle` / `getProgressPercent`
这些写 30~50 个 JUnit 用例就能覆盖嗅探与过滤的核心判定,未来改规则时才敢动。这一步做完,`libs.versions.toml` 里躺着的 junit 依赖也就名正言顺了。
---
## P2 · 性能与资源
- **`onProgressChanged` 每个进度回调都注入一次脚本**:`BrowserActivity.kt:343-347` 只要 `hasDocStartHook == false`,每次进度变化都 `evaluateJavascript(JS_DOCUMENT_START_HOOK)`。这个 hook 是 20KB+ 的字符串(`VideoSnifferHelper.kt:213-435`),值得只在 `onPageStarted` 里做、并用一个"已注入"标志位替代 `hasDocStartHook` 的反复尝试。
- **`updateProgress()` 高频无差别干活太重**:`PlayerActivity.kt:1132-1183` 每 500ms 做 `TrafficStats.getUidRxBytes` + 新建 `Calendar` 实例 + 多次 `String.format`,并且每 5 秒走一次 `saveCurrentProgress()` → 整个播放历史 **全表 JSON 序列化 + `prefs.apply()`**(`PlaybackHistoryRepository.kt:180-199`)。建议改为仅在控制栏可见(`isOsdVisible()`)时才计算网速与格式化 UI 文本(并复用 `Calendar`),并将断点落盘改为 15~30 秒一次(配合 `onPause/onStop`/切集时必存),主线程压力会明显下降。
- **30 处 `Log.d` / `Log.v` 在 release 也打**,并且 `BrowserActivity.kt:355-358` 把**网页所有 console 消息**全量转进 Logcat。建议统一到一个 `Logx` 门面,由 `BuildConfig.DEBUG` 关闭。
- **崩溃日志无上限**:`TVBrowserApplication.kt:35-64` 每次崩溃写一个文件,从不清理,长期会占满 `filesDir`(而 `crash_logs` 里可能含 URL 等隐私)。加"保留最近 20 个 + 启动时清理"即可。
- **每 3 秒的常驻轮询**:`BrowserActivity.kt:138-144` 的 `remoteStatusChecker` 无条件改 `homeStatusBar.visibility = GONE`,可以在首页显示或退到后台时停掉这个轮询。
- **`notifyDataSetChanged` 9 处**(`HomeCardAdapter.kt:40,47`、`PlayerActivity.kt:915,937`、`VodSearchDialogHelper.kt:102` 等),选集动辄上百项,建议改 `DiffUtil` 或至少 `notifyItemRangeChanged`。
---
## P2 · 健壮性/代码卫生
- **19 处空 `catch` 静默吞异常**(`BrowserUiHelper.kt` 6 处、`VideoWebActivity.kt` 3 处、`RemoteControlDialog.kt` 2 处…),排查线上问题时这些会变成「什么都查不到」。
- **52 处 `catch (Exception/Throwable)`**、**23 处 `!!`**——大部分在 WebView/媒体回调里,可以换成更具体的异常 + 明确兜底。
- **`BrowserActivity.kt:318-330` 的新旧 `onReceivedError` 导致重复报错 Toast**:新版回调调用了 `super.onReceivedError(view, request, error)`,而系统默认实现会在主框架出错时转调旧版回调,导致主框架报错弹两次 Toast;且旧版回调未加 `API < 23` 守卫,低版本系统下子资源加载失败也会误弹 Toast。
- **零协程**:全项目 0 个 `lifecycleScope`/`suspend`,异步全靠 **17 处裸 `Thread { }` + 17 处 `postDelayed`**(`BrowserUiHelper.kt:365-385` 甚至在线程里调 `activity.getString`,Activity 已销毁时会崩)。引入 `lifecycleScope` 会同时解决可取消性与线程归属。
- **日志 TAG 分散**:`Log.d("TVBrowser.AdBlock", ...)`(`BrowserActivity.kt:239`)这类裸字符串与其他 `private const val TAG` 混用。
- 仓库习惯:**5 次提交全部同一天、提交信息是 `init` 和 4 个 `4k`**——即使单人也值得写清 commit message,否则半年后无法回溯「这个兜底为什么加」。
---
## 建议的落地顺序
1. **第一批(1~2 天,风险低收益高)**:删 4 个无用依赖 + 清理整文件死代码与废弃布局 + 补全 Gradle Wrapper + 提交 `classes.jar` 移出仓库 + 补 `.gitattributes` + 收紧 Manifest(`allowBackup`)+ 日志门控。产出:包变小、换机器能编译、代码库清爽。
2. **第二批(安全与核心 Bug 修复)**:修复快传 `uploads` 路径嵌套与 `file_paths.xml` + 修复快传视频 `FileProvider` 调外部播放器崩溃 + 支持 `PlayerActivity` 前台接收手机遥控按键 + 遥控去除通配 CORS 与同源 Token 校验 + `PlayerSsl` 作用域收敛 + 年份判定改造。
3. **第三批(可测性,收益长期)**:建 `src/test`,先把 `VideoSnifferHelper`/`ContentFilterHelper`/`PlaylistCache`/`SiteInfo` 的纯函数用 JUnit 锁住——**这一步做完再动嗅探规则才安全**。
4. **第四批(可维护性)**:83KB 内联 JS 迁 `assets/js`;抽 `JsonListStore` 统一四个 Repository(补齐 `UserScriptRepository` 缓存);设置项收敛到一个 `SettingsRepository`。
5. **第五批(重构,按需)**:`PlayerActivity` 按会话/选集/OSD/手势四块拆分;`dialog_settings.xml` 按面板拆分;高频颜色资源化。