跳转到主内容
◆ 文章 · Article

NW-HTMLviewer 移动端 HTML 工作台

一款工业级移动端 HTML 编辑器与全屏预览应用:CodeMirror 6 离线编辑、双渲染内核(系统 WebView / GeckoView)、离线资源缓存与完整文件管理,一次围绕「浏览器能力边界」的工程实践。

Android Kotlin WebView GeckoView CodeMirror
[ 00 ]

目录

按章节顺序排列,点击直接跳转。

  • 8 个章节
  • 约 7 分钟
[ 01 ]

项目概览

正文里出现过的关键读数先汇总在这里:渲染内核版本、API 级别、桥接方法与缓存上限。

  • [*]Renderer 接口loadHtml · loadFile · reload · executeJs · destroy
  • [*]能力探测属性touchpad · console · resourceCache
  • [*]构建变体Full / Lite(GECKO_ENABLED)
  • [*]Binder 单次调用上限32 KB
  • [*]内容分块粒度32k · 50k · 64k
  • [*]触摸板灵敏度1.6
  • [*]回收站撤销窗口5 s
  • [*]缓存响应头max-age=31536000, immutable
  • [*]缓存文件名规则sha1(url) 前 24 位 + 扩展名
  • [*]技术栈Kotlin 2.3 · AGP 8.13 · Room · Hilt
  • [*]开源许可GPL-3.0
GeckoView 内核版本 153
minSdk · API 级别 26
Lite 构建体积缩减 99%
单资源缓存上限 20MB
[ 02 ]

双渲染内核

把渲染抽象成一个 Renderer 接口:能力先探测、再降级,而不是假装拥有。

预览场景最大的痛点是渲染行为不一致:同一段 HTML 在系统 WebView 与 GeckoView 下表现可能完全不同。项目把渲染抽象成一个 Renderer 接口, 统一提供 loadHtml / loadFile / reload / executeJs / destroy,并配一组 能力探测属性——触摸板、console、资源缓存各自是否支持——不支持的 能力直接降级,而不是假装拥有。

kotlin

      interface Renderer {  val view: View  fun loadHtml(html: String, baseUrl: String?)  fun loadFile(file: File)  fun executeJs(script: String)  fun reload()  fun destroy()   // 能力探测:做不到的就诚实降级  val touchpadSupported: Boolean  val consoleSupported: Boolean  val resourceCacheSupported: Boolean}
  
  • 轻量模式(WebViewRenderer):系统 Chromium 内核,零额外开销,拥有 console 收集、请求拦截、evaluateJavascript 全部能力;
  • 兼容模式(GeckoRenderer):内置 GeckoView 153,独立内核、渲染行为固定,配合持久磁盘缓存;代价是 Gecko 没有公共的 JS 注入 / console 回调 / 请求拦截 API,这些特性随之关闭;
  • Full / Lite 两种构建变体由 GECKO_ENABLED 编译开关控制,Lite 版仅系统 WebView,体积缩小约 99%。

"好的抽象不是把所有内核伪装成一样,而是声明各自能做什么、不能做什么。"

[ 03 ]

离线资源缓存

首次加载时把 http(s) 子资源固化到本地,之后 URL 不变、直接走本地。

移动端编辑 HTML 最头疼的是依赖 CDN 的页面一离线就没法看。方案是首次加载时把 http(s) 子资源下载并固化到 HTML 同目录的隐藏文件夹 .htmlviewer_cache,之后 URL 不变直接走本地。实现挂在 shouldInterceptRequest 里:先查内存索引 serve 命中,未命中 再 download 固化后返回。

kotlin

      override fun shouldInterceptRequest(  request: WebResourceRequest): WebResourceResponse? {  val url = request.url.toString()  if (!url.startsWith("http")) return null   // 命中缓存直接返回本地响应  resourceCache.serve(url)?.let { return it }   // 未命中:下载并固化到 .htmlviewer_cache  return resourceCache.download(url)}
  
  • 索引为每行一条 TSV 记录的 index.tsv(url / fileName / mime / size / time / hash),内存持有镜像避免每次读盘;
  • 文件名取 sha1(url) 前 24 位 + 扩展名,临时文件 + renameTo 原子落盘,单资源上限 20MB;
  • 响应携带 Cache-Control: max-age=31536000, immutable 长缓存头,让 WebView 自身也参与命中。

Gecko 内核没有请求拦截 API,此功能仅 WebView 可用——这正是上一节「能力探测」落地的例子。

[ 04 ]

CodeMirror 集成

离线 IIFE bundle + HVBridge 跨进程通信,以及 Binder 限制下的一串坑。

编辑器本体是 CodeMirror 6,用 esbuild 在构建期打成离线 IIFE bundle 放进 assets,运行时由独立 WebView 加载 editor.html,Android 侧通过 HVBridge 这个 JavascriptInterface 与页面通信。踩过的坑比想象的多:

  • Binder 32KB 限制:单次跨进程调用有大小上限,内容按 32k / 50k / 64k 分块拉取与回写(saveChunk / getContentChunk);
  • EditContext 开关:Chromium ≥ 126 的 EditContext 会让 Android 端触摸与滚动失效,必须设置 EditorView.EDIT_CONTEXT = false;
  • 一键格式化:prettier standalone 按文件扩展名选 parser(html / css / babel),插件首次使用时才动态加载;
  • 滚动双保险:JS 端 touchmove 接管滚动 + Kotlin 侧 onTouchEvent 每 50ms 检查一次 __hvTouchSeen,JS 接管失效就注入 HVEditor.scrollBy 兜底;
  • 超过 100 万字符自动关闭语法高亮,保住输入性能。

"把编辑器跑在 WebView 里不难,难的是让它在 Binder 限制、EditContext 巨变和触摸失效之间仍然稳定。"

[ 05 ]

编码兼容

检测逻辑 BOM 优先,没有 BOM 就靠严格解码试出真实编码。

存量 HTML 大量是 GBK。检测逻辑BOM 优先(UTF-8 / UTF-16LE / UTF-16BE), 没有 BOM 就尝试「严格 UTF-8 解码」能否成功,失败再试 GB18030(GBK 超集)严格解码, 仍失败则兜底 UTF-8。检测结果随文件元数据持久化,编辑保存保持原编码,GBK 文件可一键转存 UTF-8。

kotlin

      fun detect(bytes: ByteArray): TextEncoding {  // BOM 优先  if (bytes.startsWith(UTF8_BOM)) return UTF_8  if (bytes.startsWith(UTF16LE_BOM)) return UTF_16LE  if (bytes.startsWith(UTF16BE_BOM)) return UTF_16BE   // 严格 UTF-8 解码失败,再试 GB18030(GBK 超集)  if (canDecode(bytes, Charsets.UTF_8)) return UTF_8  if (canDecode(bytes, GB18030)) return GBK  return UTF_8 // 兜底}
  

注意严格解码要用 CodingErrorAction.REPORT——默认的 REPLACE 会把非法字节 悄悄替换成「?」,导致乱码被误判为正常文本。

[ 06 ]

模拟鼠标

合成事件的注入补偿:默认行为与伪类都要手动补回来。

预览模式支持触摸板式模拟鼠标:屏幕上出现一个箭头光标,单指滑动按 相对位移(灵敏度 1.6)移动光标,轻点等于单击,双指上下滑滚动页面。因为全部是 JS 注入的 合成事件,浏览器不会自动触发 CSS :hover / :active,也不会执行 默认行为——所以高亮要手动打,a 跳转、checkbox 切换、input 聚焦这些默认行为 也要手动补。

js

      // 单指滑动:相对位移移动光标touch.addEventListener('move', function (e) {  const dx = (e.dx * 1.6) | 0;  const dy = (e.dy * 1.6) | 0;  moveCursor(dx, dy);   // 合成事件不触发 :hover,手动打高亮并派发 Mouse + Pointer  const el = document.elementFromPoint(cx, cy);  hover(el);  dispatchMouse(el, 'mousemove');});
  
  • 双指滚动:scrollByPx 向上查找可滚动容器直接改 scrollTop(合成 wheel 事件不会触发默认滚动);
  • 手势接管:capture 阶段监听 + touch-action: none;
  • 注入脚本带 __HV_TP_CLEANUP__ 完整拆卸,页面跳转后自动重注入,注入失败重试 5 次。
[ 07 ]

文件管理与回收站

文件本体与元数据分离;删除走同分区 rename,换来 5 秒可撤销。

文件本体与元数据分离:文件在应用专属目录(无需存储权限),收藏、最近打开、编码记忆等 元数据存 Room。file_meta 表以绝对路径为主键,保存行数/字符数等统计, 列表扫描时实时合并进 UI。

kotlin

      @Entity(tableName = "file_meta")data class FileMetaEntity(  @PrimaryKey val path: String,    // 绝对路径主键  val isFavorite: Boolean, // 是否收藏  val groupId: Long?,        // 收藏分组  val lastOpenedAt: Long?,   // 最近打开  val encoding: String,      // UTF-8 / GBK / UTF-16LE  val lineCount: Int,  val charCount: Int,  val createdAt: Long)
  

删除做了5 秒可撤销:文件不直接删,而是 renameTo 到同挂载点的 .htmlviewer-trash——rename 底层是 rename(2),跨文件系统会 EXDEV 失败,所以回收站必须与应用根目录在同一分区。撤销栈用 ArrayDeque 记录 (trashFile, originalPath),Snackbar 提供「撤销」入口,5 秒 TTL 到期自动清理, 进程启动时还会清掉上次的残留。

[ 08 ]

写在最后

把「浏览器能力边界」当成第一公民之后,留下的选型与收束。

这个项目最大的收获,是把「浏览器能力边界」当成第一公民来设计:内核不同、API 有无、 Binder 限制、编码差异,每一项都先探测、再降级、最后兜底。技术选型上 Jetpack Compose + Material 3 撑起 UI,MVVM + Repository + Hilt 撑起架构, Room + DataStore 管数据,CodeMirror 6 与双内核管渲染——层层解耦,各自可替换。


Kotlin 2.3 AGP 8.13 minSdk 26 GPL-3.0
评论区 留下您的想法
教程