1. 从一个闲置盒子说起:WebHomeTV 到底想解决什么问题
家里那台用了两年的 Android 影音盒子,硬件其实一点不差——四核 A55、2GB 内存、16GB 存储,跑个 1080P 视频解码毫无压力。但原厂系统里塞满了各种用不上的预装应用,桌面布局改不了,想加个自己写的小工具还得先找 APK 安装包,装完发现界面跟电视遥控器根本不搭。这种“硬件够用、软件憋屈”的状态,大概是很多折腾过 Android 盒子的人都遇到过的。
WebHomeTV 这个项目的出发点就一句话:把 Android 影音盒子变成一个可编程的网页应用平台。听起来有点抽象,拆开说就是——你不再需要为每个小功能单独开发一个 APK,而是用网页技术(HTML/CSS/JavaScript)写好界面和逻辑,丢进盒子里,通过一个常驻的宿主应用加载运行。盒子开机后看到的不是原厂桌面,而是你自己定义的一套 Web 应用入口,点进去就是全屏的网页应用,遥控器方向键和确认键直接映射成网页里的焦点切换和点击。
这个思路解决的核心痛点有三个。第一是开发门槛,写网页比写原生 Android 界面快得多,尤其是做信息展示类、工具类、轻交互类的界面,一个前端开发者半小时就能出一个能用的页面。第二是部署效率,改一行代码刷新页面就能看到效果,不用重新打包 APK、不用重新签名、不用 adb install。第三是资源占用,一个 WebView 宿主加上几个轻量网页,内存占用远低于装一堆独立 APK。
适合谁来参考这个项目?如果你手上有闲置的 Android 盒子想物尽其用,或者你在做智能家居中控、门店信息屏、家庭公告板这类场景,又或者你单纯想学一下 Android 和 Web 混合开发的路子,这个项目的思路都值得一看。它不要求你会写复杂的 Android 原生代码,但需要你对 Web 前端有基本了解,至少知道 HTML 标签和 JavaScript 事件是怎么回事。
注意:这个项目定位是“平台”而不是“成品应用”。它提供的是承载网页应用的容器和运行环境,具体网页内容需要你自己编写或引入。别指望装完就有现成的影视聚合功能,那是另一回事。
2. 整体架构设计:为什么选 WebView 宿主而不是纯原生或纯网页
2.1 三种技术路线的取舍逻辑
把 Android 盒子变成网页应用平台,摆在面前的路其实有三条。第一条是纯原生开发,用 Kotlin 或 Java 写一个启动器,每个功能模块都是独立的 Activity 或 Fragment。第二条是纯网页方案,直接做一个浏览器全屏应用,所有内容都在浏览器里跑。第三条就是 WebHomeTV 采用的混合方案——一个原生宿主应用内嵌 WebView,原生负责系统级能力(开机自启、遥控器按键拦截、全屏沉浸、文件访问),WebView 负责界面渲染和业务逻辑。
纯原生方案的问题在于开发效率。做一个设置页面,原生要写 XML 布局、Activity 生命周期、数据绑定,同样的界面用 HTML 写可能只要三分之一的时间。而且原生方案下每加一个功能就要重新编译打包,迭代速度上不来。纯网页方案的问题在于系统能力缺失,浏览器没法做到开机自动全屏启动,也没法优雅地拦截遥控器按键事件,文件系统访问也受限。混合方案刚好取两者之长:原生做它擅长的事,Web 做它擅长的事。
WebHomeTV 的架构可以概括为“一个宿主、多个页面、一套桥接”。宿主是一个标准的 Android APK,里面只有一个 MainActivity,这个 Activity 的布局就是一个全屏 WebView。WebView 加载本地 assets 目录下的 index.html 作为入口,index.html 里用前端路由或 iframe 的方式组织多个子页面。宿主通过 JavaScriptInterface 向网页暴露一组原生能力接口,网页通过 window.xxx 调用这些接口来实现原生功能。
2.2 宿主 APK 的最小化设计
宿主 APK 的设计原则是“能不加就不加”。不引入任何第三方 UI 库,不依赖 Google Play 服务,不申请不必要的权限。整个 APK 的体积控制在 2MB 以内,这在 Android 应用里算是极简了。为什么这么在意体积?因为盒子设备的存储空间通常比较紧张,而且体积小的 APK 安装速度快,在低端设备上冷启动也更快。
宿主的 AndroidManifest.xml 里需要声明的关键配置包括:android:launchMode="singleTask"保证只有一个实例,android:configChanges处理屏幕旋转和键盘变化避免重建,android:theme设置为全屏无标题栏主题。权限方面,如果只是加载本地网页,INTERNET权限都不是必须的,但考虑到很多网页应用需要访问网络接口,还是加上比较稳妥。存储权限视需求而定,如果要让网页能读写本地文件,需要申请READ_EXTERNAL_STORAGE和WRITE_EXTERNAL_STORAGE,Android 11 以上还需要处理分区存储的适配。
WebView 的初始化配置是宿主的核心。需要开启 JavaScript 支持、DOM Storage、数据库支持,设置允许混合内容(如果网页里有 HTTP 资源),关闭缩放控件,设置缓存策略。还有一个容易被忽略的点是 UserAgent,建议在默认 UA 后面追加一个标识字符串,这样网页端可以通过 UA 判断自己是否运行在 WebHomeTV 环境里,从而决定是否调用原生桥接接口。
2.3 网页应用的组织方式
网页应用放在 assets 目录下,结构大概是这样的:根目录一个 index.html 作为启动页,下面按功能分目录,每个目录里是一个独立的网页应用。index.html 本身不承载具体功能,它只做一件事——展示应用列表,点击后跳转到对应应用的入口页面。这个列表可以硬编码在 HTML 里,也可以用一个 JSON 文件动态生成,后者更灵活,加新应用只需要改 JSON 不用动 HTML。
每个网页应用建议遵循统一的目录约定:入口文件命名为 index.html,样式放在同级的 css 目录,脚本放在 js 目录,静态资源放在 assets 目录。这样宿主在加载时只需要知道应用名称,就能拼出完整的路径。应用之间的跳转用相对路径或 hash 路由,避免使用绝对路径导致在 WebView 里加载失败。
实操心得:assets 目录下的文件在 APK 打包后是只读的,网页应用如果需要保存用户数据(比如设置项、书签),不能直接写 assets,要用 localStorage 或者通过桥接接口写到外部存储。localStorage 在 WebView 里默认是可用的,但要注意清理缓存时会被清掉,重要数据还是走原生存储比较稳。
3. 核心细节拆解:WebView 配置、JS 桥接与遥控器适配
3.1 WebView 初始化的关键参数
WebView 的配置直接决定了网页应用的运行体验,几个必须设置的参数我逐个说明。setJavaScriptEnabled(true)是基础中的基础,不开这个网页里的脚本全废。setDomStorageEnabled(true)让 localStorage 和 sessionStorage 可用,很多前端框架依赖这个。setDatabaseEnabled(true)虽然现在用得少了,但有些老库还需要。setAllowFileAccess(true)和setAllowContentAccess(true)让 WebView 能加载本地文件,加载 assets 里的网页必须开这个。
缓存策略建议用LOAD_DEFAULT,让 WebView 自己根据 HTTP 头决定是否走缓存。如果网页资源更新频繁,可以在加载时加时间戳参数强制刷新。缩放方面,setBuiltInZoomControls(false)和setDisplayZoomControls(false)关掉缩放按钮,盒子场景下用户不会去捏合缩放,留着反而碍事。setLoadWithOverviewMode(true)和setUseWideViewPort(true)配合使用,让网页按视口宽度自适应,避免出现横向滚动条。
还有一个参数容易被忽略:setMediaPlaybackRequiresUserGesture(false)。默认情况下 WebView 里的音视频自动播放会被拦截,需要用户手势才能播放。盒子场景下很多信息屏应用需要自动播放背景视频或提示音,把这个设为 false 就能绕过限制。但要注意,这个设置在某些 Android 版本上可能不生效,需要配合网页端的 autoplay 属性一起用。
3.2 JavaScript 桥接的设计与安全边界
JS 桥接是 WebHomeTV 的灵魂。没有桥接,网页就是一个孤立的沙箱,拿不到任何系统能力。桥接的实现方式是在宿主里定义一个 Java 类,类里的方法加上@JavascriptInterface注解,然后通过webView.addJavascriptInterface(instance, "NativeBridge")注册。网页里就可以用window.NativeBridge.methodName()来调用。
桥接接口的设计要遵循“最小必要”原则。我建议至少暴露这几类方法:getDeviceInfo()返回设备型号、屏幕分辨率、Android 版本;setFullscreen(boolean)控制沉浸式模式;exitApp()退出当前网页应用回到桌面;openApp(packageName)启动其他 Android 应用;showToast(message)显示原生提示。如果要做文件读写,再加readFile(path)和writeFile(path, content),但这两个方法一定要做路径校验,防止网页端越权访问系统文件。
安全边界是桥接设计里最需要警惕的地方。@JavascriptInterface暴露的方法在 WebView 里是全局可访问的,如果网页加载了不受信任的第三方内容,这些方法就可能被恶意调用。所以第一,桥接方法里要做参数校验,比如openApp的包名要检查是否在白名单里;第二,如果网页需要加载外部 URL,建议用shouldOverrideUrlLoading做域名白名单拦截;第三,Android 4.2 以下版本@JavascriptInterface有安全漏洞,虽然现在盒子设备基本都在 5.0 以上,但知道这个背景有助于理解为什么桥接要谨慎。
// 宿主端桥接类示例 public class NativeBridge { private Context context; private WebView webView; public NativeBridge(Context context, WebView webView) { this.context = context; this.webView = webView; } @JavascriptInterface public String getDeviceInfo() { JSONObject info = new JSONObject(); try { info.put("model", Build.MODEL); info.put("sdk", Build.VERSION.SDK_INT); info.put("width", context.getResources().getDisplayMetrics().widthPixels); info.put("height", context.getResources().getDisplayMetrics().heightPixels); } catch (JSONException e) { e.printStackTrace(); } return info.toString(); } @JavascriptInterface public void showToast(String message) { Toast.makeText(context, message, Toast.LENGTH_SHORT).show(); } }3.3 遥控器按键映射的完整方案
盒子场景下用户手里拿的是遥控器,不是触摸屏。遥控器能发出的按键事件主要是方向键(上/下/左/右)、确认键、返回键、主页键、菜单键。WebView 默认对方向键的处理是滚动页面,对确认键的处理是触发当前焦点元素的 click 事件。这个默认行为在简单页面里能用,但在复杂布局里经常出问题——焦点跳转顺序不符合预期,或者某些自定义组件收不到按键事件。
更可控的方案是在宿主层拦截按键事件,通过桥接转发给网页,由网页自己决定怎么处理。具体做法是在 MainActivity 里重写dispatchKeyEvent,判断按键码,如果是方向键或确认键,就通过webView.evaluateJavascript调用网页里预先注册的回调函数,把按键码传过去。网页端收到按键码后,根据自己的焦点管理逻辑决定是移动焦点还是触发动作。
@Override public boolean dispatchKeyEvent(KeyEvent event) { int keyCode = event.getKeyCode(); if (event.getAction() == KeyEvent.ACTION_DOWN) { switch (keyCode) { case KeyEvent.KEYCODE_DPAD_UP: case KeyEvent.KEYCODE_DPAD_DOWN: case KeyEvent.KEYCODE_DPAD_LEFT: case KeyEvent.KEYCODE_DPAD_RIGHT: case KeyEvent.KEYCODE_DPAD_CENTER: case KeyEvent.KEYCODE_ENTER: webView.evaluateJavascript( "javascript:onRemoteKey(" + keyCode + ")", null); return true; } } return super.dispatchKeyEvent(event); }网页端需要实现一个onRemoteKey全局函数,在里面根据当前页面状态和焦点位置做相应处理。焦点管理建议用 CSS 的:focus伪类和tabindex属性配合,给可交互元素加上tabindex="0",然后用 JavaScript 维护一个焦点元素列表,方向键按下时在列表里移动焦点。这个方案比依赖浏览器默认的 Tab 顺序要可靠得多,因为默认顺序在复杂布局里几乎不可控。
注意事项:返回键的处理要特别小心。如果网页应用有多层页面栈,返回键应该先触发网页内的返回逻辑,只有网页栈为空时才退出应用。实现方式是在网页端维护一个页面栈数组,返回键按下时先检查栈是否为空,非空则 pop 并渲染上一页,空则调用桥接的
exitApp()。
4. 实操过程:从零搭建一个可运行的 WebHomeTV 环境
4.1 开发环境准备与项目骨架搭建
先明确工具链。Android 端用 Android Studio,版本建议用较新的稳定版,老版本对 Kotlin 和 Gradle 的支持可能有问题。新建项目时选 “Empty Views Activity” 模板,语言选 Java 或 Kotlin 都行,我习惯用 Java 因为桥接部分的示例代码更多。最低 SDK 版本建议设到 21(Android 5.0),覆盖绝大多数盒子设备,目标 SDK 设到 33 或 34 即可。
项目建好后,第一件事是改build.gradle里的minSdk和targetSdk,然后删掉模板自带的多余资源。res/layout下的布局文件改成只放一个 WebView,res/values下的主题改成全屏无标题栏。AndroidManifest.xml里把 Activity 的android:exported设为 true(如果要做启动器的话),加上android:launchMode="singleTask"。
assets 目录默认不存在,需要手动创建。在src/main下新建assets文件夹,然后在里面建www目录作为网页应用的根目录。www下先放一个最简单的index.html,内容就是一行文字,用来验证 WebView 能不能正常加载。
<!-- activity_main.xml --> <?xml version="1.0" encoding="utf-8"?> <FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent"> <WebView android:id="@+id/webview" android:layout_width="match_parent" android:layout_height="match_parent" /> </FrameLayout>4.2 宿主 MainActivity 的完整实现
MainActivity 的代码量不大,但每个配置都有讲究。onCreate里先设置全屏,用WindowCompat或传统的setSystemUiVisibility都行,后者在低版本上兼容性更好。然后初始化 WebView,设置前面提到的那些参数。加载 URL 用file:///android_asset/www/index.html,注意这个路径是固定的,assets 目录在 APK 里映射为android_asset。
onBackPressed要重写,先通过evaluateJavascript询问网页端是否处理了返回事件,网页端返回 “true” 表示已处理,宿主就不做任何事;返回 “false” 或空则执行默认的super.onBackPressed()退出应用。这个交互是异步的,evaluateJavascript的回调里才能拿到结果,所以逻辑要写在回调里。
@Override public void onBackPressed() { webView.evaluateJavascript( "javascript:onBackPressed()", value -> { if (!"true".equals(value)) { super.onBackPressed(); } }); }onDestroy里记得销毁 WebView,先removeAllViews()再destroy(),避免内存泄漏。如果 WebView 加载了外部页面,还要在onPause和onResume里调用onPause和onResume让 WebView 的生命周期跟 Activity 同步,否则后台时网页里的定时器还在跑,浪费资源。
4.3 网页端入口页与桥接调用示例
网页端的index.html是整个平台的入口。它需要做几件事:检测是否在 WebHomeTV 环境里运行(通过 UA 或桥接对象是否存在),渲染应用列表,处理遥控器按键。应用列表可以用一个 JavaScript 数组定义,每项包含名称、图标、入口路径,渲染成一个个卡片。卡片要加tabindex="0"和:focus样式,这样遥控器方向键才能在上面移动。
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>WebHomeTV</title> <style> body { margin: 0; background: #1a1a2e; color: #eee; font-family: sans-serif; } .app-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 20px; padding: 40px; } .app-card { background: #16213e; border-radius: 12px; padding: 30px; text-align: center; cursor: pointer; transition: all 0.2s; border: 3px solid transparent; } .app-card:focus { border-color: #e94560; transform: scale(1.05); outline: none; } </style> </head> <body> <div class="app-grid" id="appGrid"></div> <script> var apps = [ { name: '天气', path: 'apps/weather/index.html' }, { name: '日历', path: 'apps/calendar/index.html' }, { name: '设置', path: 'apps/settings/index.html' } ]; var grid = document.getElementById('appGrid'); apps.forEach(function(app) { var card = document.createElement('div'); card.className = 'app-card'; card.tabIndex = 0; card.textContent = app.name; card.onclick = function() { location.href = app.path; }; grid.appendChild(card); }); // 遥控器按键处理 function onRemoteKey(keyCode) { var focusable = document.querySelectorAll('[tabindex="0"]'); var current = document.activeElement; var index = Array.prototype.indexOf.call(focusable, current); if (keyCode === 19) { // 上 index = index <= 0 ? focusable.length - 1 : index - 1; } else if (keyCode === 20) { // 下 index = index >= focusable.length - 1 ? 0 : index + 1; } else if (keyCode === 21) { // 左 index = index <= 0 ? focusable.length - 1 : index - 1; } else if (keyCode === 22) { // 右 index = index >= focusable.length - 1 ? 0 : index + 1; } else if (keyCode === 23 || keyCode === 66) { // 确认 if (current) current.click(); return; } if (focusable[index]) focusable[index].focus(); } function onBackPressed() { // 入口页没有上级页面,返回 false 让宿主退出 return false; } </script> </body> </html>这个示例里方向键的处理是简单的线性移动,实际项目中你可能需要根据网格布局做二维移动,那就需要记录每个卡片的行列位置,根据当前焦点位置计算上下左右的目标。逻辑不复杂,但需要细心处理边界情况,比如第一行按上键应该跳到最后一行的同列位置。
4.4 打包签名与安装到盒子的完整流程
开发调试阶段可以直接用 Android Studio 的 Run 按钮把应用装到盒子上,但盒子通常没有 USB 调试接口,需要走网络调试。先在盒子上开启开发者选项和网络调试(不同品牌盒子的开启方式不一样,一般在设置-关于里连点版本号),然后用adb connect 盒子IP:5555连接。连接成功后 Android Studio 就能识别到设备,直接 Run 即可。
正式发布需要打包签名。在 Android Studio 里选 Build-Generate Signed Bundle/APK,选 APK,创建一个新的签名密钥库(keystore),填好密码和别名。签名完成后在输出目录拿到 APK 文件。这个 APK 可以直接用 U 盘拷到盒子上安装,也可以通过adb install命令安装。
实操心得:盒子安装 APK 时经常遇到“解析包错误”,多半是因为 APK 的 minSdk 高于盒子系统版本,或者 APK 用了盒子不支持的 CPU 架构。WebView 宿主本身不包含原生库,所以架构问题一般不存在,重点检查 minSdk。另外有些盒子限制了第三方 APK 安装,需要在设置里允许“未知来源”安装。
5. 常见问题与排查技巧实录
5.1 网页加载失败与白屏问题
白屏是 WebView 开发里最常见的问题,原因可能有很多。第一检查路径,file:///android_asset/www/index.html这个路径里android_asset是固定写法,不能写成assets。第二检查 assets 目录的位置,必须在src/main/assets下,放在项目根目录或者其他位置打包后是找不到的。第三检查文件名大小写,Android 文件系统区分大小写,Index.html和index.html是两个不同的文件。
如果路径没问题还是白屏,打开 WebView 的调试开关看看控制台有没有报错。在onCreate里加WebView.setWebContentsDebuggingEnabled(true),然后用 Chrome 的chrome://inspect就能看到 WebView 的页面和控制台输出。这个工具在排查 JavaScript 错误时特别好用,能看到具体的报错行号和错误信息。
还有一种白屏是 WebView 初始化失败导致的,在低版本 Android 上偶尔出现。可以在onCreate里加一个 WebView 可用性检查,如果WebView类加载失败就弹个提示让用户安装系统 WebView 组件。不过现在主流盒子都自带 WebView,这个问题遇到的概率不高。
5.2 遥控器按键无响应或焦点乱跳
按键无响应先确认宿主有没有拦截按键事件。如果dispatchKeyEvent里返回了 true 但没有正确调用网页端的回调,按键就被吞掉了。检查evaluateJavascript的调用是否正确,字符串拼接有没有语法错误。可以在网页端加一个console.log打印收到的按键码,通过 Chrome inspect 看有没有输出。
焦点乱跳通常是焦点管理逻辑的问题。默认的 Tab 顺序是按 DOM 顺序来的,如果你的卡片是动态生成的,顺序可能跟视觉顺序不一致。解决办法是给每个卡片显式设置tabindex,值按视觉顺序递增,这样 Tab 顺序就固定了。或者干脆不用默认 Tab 行为,完全用 JavaScript 控制焦点,方向键按下时手动计算目标元素并调用focus()。
还有一个坑是:focus样式在 WebView 里可能不生效,尤其是低版本 Android。可以用 JavaScript 在焦点变化时手动加一个 class,比如focused,然后用.focused选择器写样式。这样兼容性更好,而且可以做更复杂的焦点效果。
5.3 内存占用过高与页面卡顿
WebView 本身比较吃内存,如果网页应用写得不够优化,在 2GB 内存的盒子上跑久了容易卡。几个优化方向:第一,及时清理不再使用的 DOM 节点和事件监听器,尤其是单页应用里切换页面时,旧页面的定时器和监听器要手动销毁。第二,图片资源用合适的尺寸,别在盒子上加载 4K 大图然后缩放到 1080P 显示,浪费内存又浪费解码时间。第三,避免频繁的 DOM 操作,批量更新用documentFragment或者先隐藏容器再操作。
如果页面里有动画,优先用 CSS transform 和 opacity,这两个属性走 GPU 合成,性能比改 width/height 好得多。requestAnimationFrame 里不要做重计算,计算逻辑放到外面,帧里只做渲染。盒子的 GPU 性能普遍不强,复杂的 CSS 滤镜和阴影能省就省。
常见问题速查表:
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 白屏 | 路径错误或 JS 报错 | Chrome inspect 看控制台 |
| 按键无响应 | 宿主未拦截或回调未执行 | 网页端 log 按键码 |
| 焦点乱跳 | Tab 顺序与视觉顺序不一致 | 显式设置 tabindex |
| 内存过高 | DOM 节点未清理或图片过大 | 开发者工具看内存快照 |
| 视频无法播放 | 自动播放被拦截 | 设置 mediaPlaybackRequiresUserGesture |
| 页面缩放异常 | viewport 配置缺失 | 检查 meta viewport 标签 |
5.4 网络请求跨域与混合内容限制
网页应用如果需要请求外部接口,会遇到跨域问题。WebView 里的跨域限制跟浏览器一样,接口没开 CORS 就会失败。解决办法有两个:一是让接口服务端加上Access-Control-Allow-Origin头,二是通过宿主桥接代理请求,网页端调桥接方法,宿主用原生 HTTP 客户端请求后把结果返回给网页。后者更可控,但实现起来麻烦一些。
混合内容限制是指 HTTPS 页面里加载 HTTP 资源会被拦截。如果网页应用本身是本地文件(file:// 协议),加载 HTTP 接口一般不会被拦,但加载 HTTPS 接口时如果证书有问题也会失败。可以在 WebView 里重写onReceivedSslError忽略证书错误,但这样做有安全风险,只建议在内部测试环境用。
6. 进阶玩法:让这个平台真正好用起来
6.1 用 JSON 配置驱动应用列表
硬编码应用列表在应用多了以后不好维护。更好的做法是把应用列表抽到一个apps.json文件里,网页启动时用 fetch 加载这个 JSON 然后渲染。JSON 里可以包含应用的名称、图标、入口路径、排序权重、是否隐藏等字段。这样加一个新应用只需要往 JSON 里加一条记录,把网页文件丢到对应目录就行,不用改任何代码。
{ "apps": [ { "name": "天气", "icon": "icons/weather.png", "path": "apps/weather/index.html", "order": 1 }, { "name": "日历", "icon": "icons/calendar.png", "path": "apps/calendar/index.html", "order": 2 }, { "name": "设置", "icon": "icons/settings.png", "path": "apps/settings/index.html", "order": 99 } ] }fetch 加载本地 JSON 在 WebView 里需要注意,file://协议下 fetch 可能被 CORS 拦截。解决办法是用 XMLHttpRequest 代替 fetch,或者把 JSON 内容直接写成一个 JavaScript 变量文件用 script 标签加载。后者最简单也最可靠,缺点是 JSON 变了要重新打包 APK。如果不想重新打包,可以把 JSON 放到外部存储,通过桥接接口读取。
6.2 开机自启与启动器替换
要让 WebHomeTV 成为盒子的默认桌面,需要在 AndroidManifest.xml 里给 MainActivity 加上android.intent.category.HOME和android.intent.category.DEFAULT两个 category。这样开机后系统会弹出选择桌面的对话框,选 WebHomeTV 并勾选“始终”即可。有些盒子系统不允许替换默认桌面,那就只能手动启动应用,或者用第三方启动器管理工具来设置。
开机自启还可以通过 BroadcastReceiver 监听BOOT_COMPLETED广播来实现,但这种方式在 Android 10 以上限制越来越多,而且需要申请RECEIVE_BOOT_COMPLETED权限。替换默认桌面的方式更干净,系统原生支持,不需要额外权限。
注意事项:替换默认桌面后,如果 WebHomeTV 崩溃了,盒子可能会陷入没有桌面的状态。建议在应用里保留一个“恢复默认桌面”的入口,或者确保宿主足够稳定。开发阶段可以先不替换桌面,用普通应用的方式启动调试,稳定后再切换。
6.3 网页应用的调试与热更新
开发网页应用时,每次改完都要重新打包 APK 太慢了。一个高效的调试方案是:宿主加载的 URL 支持从外部存储读取,开发阶段把网页文件放到盒子的/sdcard/webhometv/www/目录下,宿主优先加载这个目录,找不到再回退到 assets。这样改完网页文件用 adb push 推送到盒子,刷新页面就能看到效果,不用重新打包。
热更新也是类似的思路。正式发布后如果只想更新网页内容,可以把新的网页文件打包成一个 zip 放到服务器,应用启动时检查版本号,有新版本就下载解压到外部存储,然后加载外部存储的版本。这样不用重新发 APK 就能更新界面和功能。当然,涉及原生桥接变更的更新还是得走 APK 升级。
6.4 多盒子部署与远程管理
如果你有多个盒子需要部署同一套 WebHomeTV,手动一个个装 APK 太费劲。可以写一个简单的部署脚本,用 adb 批量连接盒子,自动安装 APK 和推送网页文件。盒子的 IP 列表维护在一个文本文件里,脚本遍历列表逐个执行adb connect和adb install。
远程管理方面,可以在网页端加一个管理页面,通过桥接接口获取设备信息,通过 HTTP 接口上报到管理服务器。管理服务器可以下发配置更新、应用列表变更、定时任务等。这个架构适合门店信息屏、酒店客房终端这类需要集中管理的场景。不过要注意,远程管理接口一定要做认证,别让任何人都能控制你的盒子。
7. 我踩过的坑与最后分享的几个技巧
第一个坑是 WebView 的loadUrl在onCreate里调用太早,WebView 还没完全初始化,加载会失败。解决办法是把加载逻辑放到onResume里,或者用webView.post()延迟执行。第二个坑是 assets 里的中文文件名在某些 Android 版本上会乱码,建议所有文件名都用英文和数字。第三个坑是遥控器确认键在某些盒子上映射的 keyCode 不是KEYCODE_DPAD_CENTER而是KEYCODE_ENTER,两个都要处理。
最后分享一个小技巧:在网页端加一个隐藏的调试面板,通过遥控器输入特定按键序列(比如上上下下左右左右)触发显示,面板里可以看设备信息、切换页面、清理缓存、重启应用。这个面板在盒子没有键盘鼠标的情况下特别有用,排查问题时不用连电脑。
这个项目后续还可以往几个方向扩展:一是加一个简单的应用商店,从远程拉取网页应用列表并一键安装;二是加一个定时任务系统,让盒子在指定时间自动打开某个网页应用;三是把桥接接口做得更丰富,比如支持蓝牙设备扫描、串口通信,这样就能对接更多外设。WebHomeTV 的核心价值在于它提供了一个足够简单的容器,剩下的玩法完全取决于你想让盒子做什么。