17 KiB
一份「先搬砖再装修」的优化清单
先说结论:这个项目的业务复杂度堆得比工程基建快很多。嗅探→解析→原生播放这条链路写得很实在(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.kt1717 行 / 68 个函数:dispatchTouchEvent单函数 114 行(1417-1537)、onCreate75 行、dispatchKeyEvent84 行、handlePlayerError/resolveAndPlay/tryFailover交织。合理切法:PlaybackSession(源/线路/失败换线)、EpisodeNavigator(选集/预解析/连播)、PlayerOsdController(OSD/进度/网速/反馈)、PlayerGestureHandler(触摸手势)。dialog_settings.xml880 行 / 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/shouldBlockScriptAdBlockHelper.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,可以在首页显示或退到后台时停掉这个轮询。 notifyDataSetChanged9 处(HomeCardAdapter.kt:40,47、PlayerActivity.kt:915,937、VodSearchDialogHelper.kt:102等),选集动辄上百项,建议改DiffUtil或至少notifyItemRangeChanged。
P2 · 健壮性/代码卫生
- 19 处空
catch静默吞异常(BrowserUiHelper.kt6 处、VideoWebActivity.kt3 处、RemoteControlDialog.kt2 处…),排查线上问题时这些会变成「什么都查不到」。 - 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~2 天,风险低收益高):删 4 个无用依赖 + 清理整文件死代码与废弃布局 + 补全 Gradle Wrapper + 提交
classes.jar移出仓库 + 补.gitattributes+ 收紧 Manifest(allowBackup)+ 日志门控。产出:包变小、换机器能编译、代码库清爽。 - 第二批(安全与核心 Bug 修复):修复快传
uploads路径嵌套与file_paths.xml+ 修复快传视频FileProvider调外部播放器崩溃 + 支持PlayerActivity前台接收手机遥控按键 + 遥控去除通配 CORS 与同源 Token 校验 +PlayerSsl作用域收敛 + 年份判定改造。 - 第三批(可测性,收益长期):建
src/test,先把VideoSnifferHelper/ContentFilterHelper/PlaylistCache/SiteInfo的纯函数用 JUnit 锁住——这一步做完再动嗅探规则才安全。 - 第四批(可维护性):83KB 内联 JS 迁
assets/js;抽JsonListStore统一四个 Repository(补齐UserScriptRepository缓存);设置项收敛到一个SettingsRepository。 - 第五批(重构,按需):
PlayerActivity按会话/选集/OSD/手势四块拆分;dialog_settings.xml按面板拆分;高频颜色资源化。