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

17 KiB
Raw Blame History


一份「先搬砖再装修」的优化清单

先说结论:这个项目的业务复杂度堆得比工程基建快很多。嗅探→解析→原生播放这条链路写得很实在(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 按面板拆分;高频颜色资源化。