ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Android WebView加载assets HTML:方案、坑点与离线包

Android WebView加载assets HTML:方案、坑点与离线包

做移动端混合开发的人,几乎都绕不开一个动作:把打包好的 HTML、CSS、JS 塞进 APK 里,让 WebView 直接读本地文件。原因很实在——首屏不等网络、页面不怕断网、发版节奏跟 App 走。但真正动手时,"Android WebView 加载 assets 下的 html 文件"这件事远不是一行loadUrl就能收工的,路径前缀写错一个字母会白屏,file://的同源策略会让fetch直接失败,targetSdk 升到 30 之后setAllowFileAccess的默认值又悄悄变了,还有 MIME 类型不对导致 CSS 不生效、图标全丢。这篇内容把这条链路从头到尾走一遍:三种加载姿势各自适合什么场景、代码怎么写、每一种会踩到什么坑、出了问题按什么顺序排查。刚接触混合开发的同学可以照着抄,已经在做离线包的老手也可以对着排查表核一遍自己的实现。

1. 资源放哪儿:先把路径这件事想明白

1.1 三种资源来源的取舍逻辑

Android 工程里能放前端资源的地方不止一个,assets、res/raw、res/drawable都能塞文件,但它们的定位完全不同。assets保留原始目录结构和文件名,可以用AssetManager.open("www/js/app.js")按路径访问,天然适合放一整个前端站点;res/raw只能平铺,文件名还必须是小写字母加下划线,一个稍微像样的前端工程放进去马上就乱成一锅粥;res/drawable更不用说,它是给图片和 Nine-Patch 用的,系统还会按密度做缩放,放 HTML 属于用错工具。

真正需要对比的是另一种选择:放服务器还是放本地。放服务器的好处是改完就生效,坏处是弱网下首屏体验不可控,而且很多页面本身就是"壳"性质的功能页,压根不需要联网。放本地的好处是确定性极强,坏处是包体积变大、更新要跟着 App 走。实际项目里最常见的做法是"本地兜底 + 远程可选更新":assets 里放一份完整可用的基线版本,首次启动直接加载本地,后台再检查有没有增量包,有就下载到应用私有目录,下次启动优先读私有目录。这个策略后面第 6 节会展开写。

还有一个容易忽略的点:assets 里的文件不会被当作资源去编译,所以你放进去的 HTML 里写@drawable/xxx是无效的,所有引用必须是相对路径或者 Web 侧约定的绝对路径。这一点在刚接触时特别容易混,以为 assets 跟 res 一样能被 R 文件索引。

1.2file://协议的真实边界

file:///android_asset/index.html这个前缀是很多人背下来的第一行代码,注意中间是android_asset,单数,不是android_assets,写错就是一片白。它能用,但它的能力边界比大多数人以为的要窄得多。

关键在于同源策略。file://页面在 WebView 里的 origin 是不透明的,任何指向http://或https://的XMLHttpRequest、fetch都会被 CORS 拦下来,报错信息通常是一句干巴巴的 "Cross origin requests are only supported for protocol schemes: http, data, chrome, https"。本地 CSS 和 JS 能加载,是因为<script src>和<link rel="stylesheet">属于经典资源加载,不受这套限制;可一旦你的框架用了fetch去读一个本地 JSON 配置文件,立刻挂掉。

localStorage、sessionStorage、IndexedDB在file://下的表现也因系统版本而异,有的版本能写进去,有的版本在 WebView 被回收后数据就没了。凡是依赖本地存储保存登录态、缓存列表的页面,都不建议跑在file://下。这也是为什么我更倾向于用后面 2.2 讲的WebViewAssetLoader——它把页面挂在一个虚拟的https://域名下,页面在 Web 眼里就是一个正常的 HTTPS 站点,同源、存储、Service Worker 全都按标准行为走。

注意:WebSettings.setAllowFileAccess(false)并不会影响file:///android_asset/和file:///android_res/的访问,官方文档写得很清楚,它只控制文件系统路径。所以别以为关掉这个开关就等于禁止了 assets 加载,两回事。

1.3 assets 目录的实际组织方式

我习惯在src/main/assets下再建一层www,把所有前端产物放进去,形成assets/www/这样的结构。多这一层的好处是以后要加别的资源类型(比如离线数据库初始化文件、模板文件)时,根目录不会混在一起。前端工程的构建产物通常是dist/,直接让构建脚本输出到src/main/assets/www就行,不用手工拷。

目录结构大概是这样:

app/src/main/ ├── assets/ │ └── www/ │ ├── index.html │ ├── css/ │ │ └── main.css │ ├── js/ │ │ ├── vendor.js │ │ └── app.js │ └── img/ │ └── logo.png └── java/com/demo/app/ └── MainActivity.kt

包名这里用com.demo.app这种纯示意写法,实际项目按自己的命名来。前端打包时要注意一点:路由如果用了 history 模式,loadUrl加载完首页后再跳/detail/1这种路径,WebView 会当成真实的网络请求发出去,直接 404。要么改 hash 模式,要么在拦截器里做路径回退到index.html,这个在第 5 节讲多页面路由时细说。

2. 三种加载姿势,从土办法到工程化

2.1 最直接的loadUrl直连 file 协议

一行代码搞定,适合验证阶段和极简页面:

val webView = findViewById<WebView>(R.id.web_view) webView.settings.javaScriptEnabled = true webView.loadUrl("file:///android_asset/www/index.html")

它的优点是零依赖、零配置、任何 Android 版本都能跑。缺点是前面说过的同源问题、存储问题,以及安全审查时容易被挑出来的allowUniversalAccessFromFileURLs。有些团队为了让fetch能用,会把这两个开关打开:

webView.settings.apply { allowFileAccess = true allowFileAccessFromFileURLs = true allowUniversalAccessFromFileURLs = true }

这么写能让本地页面跨域访问任意 URL,等于把 WebView 变成了一个可以读取本地文件并向外发送的通道。如果页面里存在任何可被注入的入口(比如加载了不可信的富文本),风险会立刻放大。我从几年前就不再在生产包里开这两个开关了,能用后面的方案就用后面的方案。

2.2WebViewAssetLoader:官方给的中间件

androidx.webkit库里提供了一个WebViewAssetLoader,思路很干净:注册一个虚拟域名和路径前缀,shouldInterceptRequest里把匹配到的请求映射到 assets 或者 res 目录,返回给 WebView。页面里看到的地址是https://开头的,Web 侧的所有标准行为都正常。

依赖先加上:

implementation("androidx.webkit:webkit:1.11.0")

初始化代码:

private val assetLoader by lazy { WebViewAssetLoader.Builder() .setDomain("appassets.local") .addPathHandler("/assets/", WebViewAssetLoader.AssetsPathHandler(this)) .addPathHandler("/res/", WebViewAssetLoader.ResourcesPathHandler(this)) .build() } webView.webViewClient = object : WebViewClientCompat() { override fun shouldInterceptRequest( view: WebView, request: WebResourceRequest ): WebResourceResponse? { return assetLoader.shouldInterceptRequest(request.url) } } webView.loadUrl("https://appassets.local/assets/www/index.html")

这里把domain设成了appassets.local。这个域名不需要真实存在,因为请求在shouldInterceptRequest就被截住了,压根不会走到网络层。用自定义域名而不是默认域名的理由很实际:默认值是个官方预留的虚拟域名,多个 WebView 实例共用时不容易做区分,自定义一个语义化的名字,日志里一眼能看出这是本地资源请求。

AssetsPathHandler内部会把/assets/之后的部分当作 assets 里的相对路径去open,www/index.html就这样被读出来了。资源加载走的是同一个拦截器,所以 CSS、JS、图片全都是这个域名下的相对路径,天然同源,fetch("./config.json")也不会报跨域。

有一个细节要注意:WebViewAssetLoader只处理匹配到的路径,匹配不上就返回null,请求会落回正常网络。这意味着你可以把本地资源和远程接口混在同一个页面里,本地走appassets.local,接口走真实域名,互不干扰。

2.3 自己写拦截器做完全控制

不想引androidx.webkit,或者需要做一些WebViewAssetLoader覆盖不到的事(比如按版本号映射到不同目录、加密资源解密、远程包和本地包统一路由),那就自己实现shouldInterceptRequest。核心代码不长:

override fun shouldInterceptRequest( view: WebView, request: WebResourceRequest ): WebResourceResponse? { val url = request.url if (url.host != "appassets.local") return null val rawPath = url.path?.removePrefix("/assets/") ?: return null val safePath = rawPath.substringBefore('?').substringBefore('#') if (safePath.contains("..")) return null // 目录穿越防护 return try { val stream = assets.open("www/$safePath") WebResourceResponse(guessMimeType(safePath), null, 200, "OK", securityHeaders(), stream) } catch (e: IOException) { WebResourceResponse("text/plain", "UTF-8", 404, "Not Found", emptyMap(), null) } }

MIME 判断不能偷懒,猜错了浏览器行为会很怪——text/html猜成text/plain,页面直接当纯文本显示源码;application/javascript猜成text/plain,模块脚本会因为 MIME 校验失败而不执行。一个够用的映射表:

private fun guessMimeType(path: String): String { return when (path.substringAfterLast('.', "").lowercase()) { "html", "htm" -> "text/html" "js", "mjs" -> "application/javascript" "css" -> "text/css" "json" -> "application/json" "png" -> "image/png" "jpg", "jpeg" -> "image/jpeg" "gif" -> "image/gif" "svg" -> "image/svg+xml" "webp" -> "image/webp" "ico" -> "image/x-icon" "woff" -> "font/woff" "woff2" -> "font/woff2" "ttf" -> "font/ttf" "wasm" -> "application/wasm" "map" -> "application/json" else -> "application/octet-stream" } }

注意编码参数:文本类资源传"UTF-8"没问题,二进制资源(图片、字体、wasm)第四个参数必须传null,否则 WebView 会尝试用 UTF-8 解码二进制流,图片花屏、字体加载失败就是这么来的。这个坑我踩过一次,一张 PNG 显示成一片雪花,排查了半天才发现是编码参数写死了。

2.4 三种方案怎么选

方案页面域名同源/fetch本地存储安全风险适用场景
loadUrl("file:///android_asset/...")file://不支持表现不一致高(易被诱导开全权限)静态展示页、临时验证
WebViewAssetLoader虚拟 https 域名支持正常低绝大多数生产项目
自定义拦截器自定义域名支持正常可控需要版本映射、解密、双源路由

如果项目里只加载一个静态说明页,第一种完全够用,别为了架构而架构。一旦页面里出现前端路由、fetch、localStorage中的任何一个,直接上第二种。第三种是给已经有离线包体系的项目准备的,属于"早晚要写"的那一类。

3. 一次完整的实操:把离线站点装进 APK

3.1 工程配置与初始化时机

先把WebView的基本设置配好。这块的每一项都有理由,不是抄来的模板:

webView.settings.apply { javaScriptEnabled = true // 现代前端页面几乎都依赖 domStorageEnabled = true // localStorage/sessionStorage 的前提 databaseEnabled = true // IndexedDB 相关 loadsImagesAutomatically = true mixedContentMode = WebSettings.MIXED_CONTENT_NEVER_ALLOW allowFileAccess = false // 本地资源走虚拟域名,不需要开 allowContentAccess = false cacheMode = WebSettings.LOAD_DEFAULT mediaPlaybackRequiresUserGesture = false // 按需,自动播放才需要 }

allowFileAccess = false这一行值得单独说。用虚拟域名方案之后,页面根本不需要file://访问能力,关掉它可以直接消除一整类攻击面。mixedContentMode设成NEVER_ALLOW,防止 HTTPS 页面里静默加载 HTTP 子资源——本地资源全是自己的,不存在必须混载的情况。

初始化时机上,WebView的创建有开销,不建议在Activity的onCreate里直接 new 然后同步加载,尤其是首屏有动画的场景。我一般的做法是页面骨架先显示出来,WebView在onCreate里创建但loadUrl放到onResume之后的第一帧,或者用post推迟一帧,视觉上的体感会顺很多。

3.2 核心加载流程的完整代码

把前面的碎片拼起来,一个可运行的Activity大概长这样:

class MainActivity : AppCompatActivity() { private lateinit var webView: WebView private val assetLoader by lazy { WebViewAssetLoader.Builder() .setDomain(DOMAIN) .addPathHandler("/assets/", WebViewAssetLoader.AssetsPathHandler(this)) .build() } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) webView = findViewById(R.id.web_view) configureWebView(webView) webView.loadUrl("https://$DOMAIN/assets/www/index.html") } private fun configureWebView(view: WebView) { view.settings.apply { javaScriptEnabled = true domStorageEnabled = true allowFileAccess = false allowContentAccess = false cacheMode = WebSettings.LOAD_DEFAULT } view.webViewClient = object : WebViewClientCompat() { override fun shouldInterceptRequest( view: WebView, request: WebResourceRequest ): WebResourceResponse? = assetLoader.shouldInterceptRequest(request.url) override fun onReceivedError( view: WebView, request: WebResourceRequest, error: WebResourceErrorCompat ) { if (request.isForMainFrame) { showFallbackPage(error.errorCode) } } } view.addJavascriptInterface(Bridge(this), "nativeBridge") } override fun onDestroy() { webView.removeJavascriptInterface("nativeBridge") (webView.parent as? ViewGroup)?.removeView(webView) webView.destroy() super.onDestroy() } companion object { private const val DOMAIN = "appassets.local" } }

onReceivedError里判断isForMainFrame是个容易漏的细节。WebView 会对每一个子资源加载失败都回调这个方法,一张图片 404 也会触发。早期代码里不加判断就弹"页面加载失败",用户看到的是一个功能完好的页面配上一条莫名其妙的错误提示,投诉都解释不清。

3.3 相对路径与资源引用的实测验证

代码跑起来不等于资源都通了。我习惯在index.html里临时塞一段自检脚本,一次性把关键路径全验一遍:

<script> const checks = [ fetch('./data/config.json').then(r => r.ok ? 'json ok' : 'json fail'), new Promise(res => { const img = new Image(); img.onload = () => res('img ok'); img.onerror = () => res('img fail'); img.src = './img/logo.png'; }), document.fonts.load('16px AppFont').then(f => f.length ? 'font ok' : 'font fail') ]; Promise.all(checks).then(r => console.log('[SELFCHECK]', r.join(' | '))); </script>

然后adb logcat里 grepSELFCHECK,三项全 ok 才算这套加载链路是通的。这个自检脚本在切换到新的拦截方案时特别有用,能一次性暴露 MIME、相对路径、跨域三类问题。

相对路径有个隐含前提:index.html的基准地址是https://appassets.local/assets/www/index.html,所以./css/main.css解析成https://appassets.local/assets/www/css/main.css,拦截器里去掉/assets/得到www/css/main.css,assets.open("www/" + 那个)正好命中。整条链路是自洽的,唯一要注意的是 HTML 里别写成了以/开头的绝对路径,/css/main.css会解析成https://appassets.local/css/main.css,去掉前缀后变成css/main.css,assets.open("www/css/main.css")直接FileNotFoundException。这是 404 的头号成因,占了我在项目里见过的 404 问题的一半以上。

3.4 JS 桥接与接口注入

页面加载完之后,原生能力通常要暴露给 JS。addJavascriptInterface的最小可用写法:

class Bridge(private val context: Context) { @JavascriptInterface fun getAppVersion(): String = "1.0.0" @JavascriptInterface fun openPage(url: String) { if (!url.startsWith("https://$DOMAIN/")) return context.startActivity(Intent(context, SecondActivity::class.java).apply { putExtra("url", url) }) } }

两个安全点:方法必须标@JavascriptInterface,否则不会被注入;参数必须按白名单校验,特别是跳转类的接口,不加校验等于把任意页面跳转能力交给了页面内容。虽然本地页面是自己写的有一定可信度,但一旦接入了任何远程可配置的内容,这道校验就是最后一道防线。

JS 侧调用时,桥接对象是异步就绪的。正确的姿势是在页面里等window.nativeBridge出现,或者原生在onPageFinished里主动触发一次初始化回调。onPageFinished的触发时机不完全可靠(比如页面里有重定向或者长期不结束的请求),生产环境更稳的做法是页面 JS 主动notifyReady(),原生收到后再放行后续交互。

4. 踩坑记录:白屏、404、跨域与乱码

4.1 白屏的五种成因与判断顺序

白屏是最常见的反馈,用户只会说"页面打不开",你得自己定位。我一般按这个顺序查:

第一种,路径前缀写错。android_asset写成android_assets,或者assets/www里的www层级跟代码里的assets.open("www/...")对不上。判断方法很简单,把 APK 用解压工具打开,看assets/下面到底是什么结构,跟代码里的拼接串逐字符对。

第二种,虚拟域名没生效。shouldInterceptRequest根本没被调用,请求真的发到网上去了,当然 404。加一行日志确认拦截器是否命中,如果没有,检查webViewClient是否被后续代码覆盖了——有些第三方 WebView 封装库会替换webViewClient,把你设的拦截器冲掉。这也是我不太喜欢在业务里混用多个 WebView 封装层的原因。

第三种,MIME 类型错。页面源码直接显示成文本,说明 HTML 被当成了text/plain。查guessMimeType。

第四种,JS 报错导致整个渲染中断。现代框架页面在script执行失败时通常还能出骨架,要是连骨架都没有,八成是 HTML 结构本身有问题。开WebView.setWebContentsDebuggingEnabled(true),用桌面浏览器连上去看控制台,比猜快得多。

第五种,loadUrl的时机太早,WebView还没attach到窗口。这种偶尔发生在Fragment里,onCreateView时WebView的context还在构建中。把加载推迟到onViewCreated之后。

提示:WebView.setWebContentsDebuggingEnabled(true)只建议在 debug 构建里开,release 包打开会让任何能连 USB 的人都能调试你的 WebView 内容。用BuildConfig.DEBUG包一层。

4.2 404 与 MIME 类型问题的排查表

现象最可能原因快速验证处理方式
主页面白屏,日志有 404路径拼接与 assets 实际结构不符解压 APK 看 assets 目录修正前缀层级
CSS 不生效但 HTML 正常MIME 返回application/octet-stream看日志里响应的 Content-Type补扩展名映射
图片显示为破图二进制响应传了"UTF-8"编码检查WebResourceResponse第四参二进制传null
字体文件加载失败字体 MIME 缺失或不支持DevTools Network 面板补font/woff2等映射
页面源码直接显示HTML MIME 被猜成text/plain同上修正text/html
fetch报跨域仍在用file://加载看页面location.origin切换虚拟域名方案

这张表覆盖了我在实际项目里遇到的绝大多数"页面坏了"的情况。真遇到表里没有的,第一件事永远是把 DevTools 打开看 Network 和 Console,比起盯着代码猜,能省下大量时间。

4.3 编码问题与中文乱码

乱码的经典场景是 HTML 里没写<meta charset="utf-8">,WebView 按系统默认编码猜,中文就全变成问号。加上这一行基本解决。另一个场景是 JS 文件里有中文,文件本身存成了 GBK,但响应头声明 UTF-8,浏览器按 UTF-8 解码 GBK 字节,结果是乱码。前端构建工具一般都输出 UTF-8,但手工引入的第三方脚本要注意,尤其是从老项目搬过来的那些。

还有一个不太常见但确实存在的坑:WebResourceResponse的encoding参数对某些老系统版本上的text/html不完全生效,实际解码依赖页面内的 meta 声明。稳妥做法是两边都声明对,页面里写 meta,响应头也传"UTF-8"。

4.4 版本升级带来的行为变化

targetSdkVersion从 29 升到 30 的时候,setAllowFileAccess的默认值从true变成了false。如果你之前靠着默认值偷懒、页面又恰好跑在file://下,升级后行为可能变化。虽然官方说 assets 和 res 不受影响,但依赖file://访问存储卡上文件的老代码会静默失效,而且不报错,就是读不到。

类似的还有系统 WebView 版本的差异。用户设备上的 WebView 是独立更新的,同一个 APK 在不同设备上跑的可能是 Chromium 90,也可能是 Chromium 120,fetch、ES 新语法、CSS 新特性的支持度全不一样。做离线包的时候,前端构建目标不要设得太激进,browserslist里该降到什么程度,取决于你的用户设备分布,这个要看真实数据而不是拍脑袋。

5. 体积、性能与安全的平衡

5.1 离线包体积的估算与控制

assets 里的文件会被打进 APK,直接影响下载安装体积。一个中等复杂度的前端站点,未压缩大概 2~5 MB,gzip 之后 600 KB ~ 1.5 MB。APK 打包时 assets 里的文本类文件默认会被 zip 压缩,所以安装包的实际增量大致接近 gzip 后的数字。

控制体积的几个手段,按性价比排序:第一,前端构建开 tree-shaking 和代码分割,只把首屏必需的资源打进本地包,非首屏的走远程或者懒加载;第二,图片统一转 WebP,图标用 SVG 雪碧图,node_modules里带进来的*.map文件一定要在构建时剔除,我见过好几个项目 APK 里躺着几 MB 的 sourcemap,纯属白送体积;第三,字体文件只保留实际用到的字重和字符集。

如果想进一步省,可以对 HTML/CSS/JS 做一次 gzip 存储,运行时在拦截器里解压。代价是每次请求都要解压,首屏 CPU 开销上升,一般不值得,除非包体积到了必须抠的程度。

5.2 首屏耗时的实测与优化点

本地加载理论上很快,但实际体感未必。我在中低端机型上做过对比,从loadUrl调用到onPageFinished,简单的本地页面大概在 150~400 ms 之间,复杂一点的(大量 DOM、框架初始化)会到 800 ms 以上。这个数字受设备性能影响很大,只作为量级参考。

优化点上,收益最大的不是加载本身,而是初始化时机。把WebView的创建提前到 Application 启动阶段做预热,能让首屏体感明显变好,代价是启动内存占用上升,要权衡。其次是减少首屏依赖的 JS 体积,框架的初始化开销往往比资源读取本身大得多。第三是拦截器里避免同步 IO 阻塞,assets.open本身是同步的,如果单个文件很大(比如几百 KB 的 JSON),会阻塞渲染线程,能拆就拆。

还有一个实践:给 WebView 容器加一个自带骨架的原生占位,页面加载完成前显示骨架,加载完成后再切换。用户感知到的"快"很多时候来自这个过渡,而不是真实耗时缩短。

5.3 安全设置清单

用虚拟域名方案之后,安全配置其实很简单,但每一条都要落实:

设置项建议值理由
allowFileAccessfalse虚拟域名方案不需要文件系统访问
allowContentAccessfalse防止通过content://读取其他应用数据
allowFileAccessFromFileURLsfalse关闭 file 页面跨文件访问
allowUniversalAccessFromFileURLsfalse关闭 file 页面的全量跨域
mixedContentModeNEVER_ALLOW阻止 HTTPS 页面加载 HTTP 子资源
setWebContentsDebuggingEnabled仅 debug避免线上被调试
addJavascriptInterface白名单 + 参数校验防止桥接接口被滥用
onReceivedSslError不调用proceed()杜绝忽略证书错误

最后一条特别要强调。有些老代码在onReceivedSslError里直接handler.proceed(),理由是"不然页面打不开",这个写法的危害不需要展开。真遇到证书问题,正确做法是修证书链,而不是在客户端放行。

6. 多页面路由与离线包更新思路

6.1 前端路由在本地加载下的处理

页面里只要用了前端路由,就会碰到路径解析的问题。hash 模式最省事,#/detail/1不会产生新的资源请求,全在同一个index.html里解决。history 模式麻烦一些,pushState('/detail/1')之后如果用户刷新或者页面内部触发了一次真实导航,WebView 会去请求https://appassets.local/assets/www/detail/1,拦截器找不到这个文件,404。

处理办法是在拦截器里做一次回退:请求的路径没有对应扩展名(或者明确不是静态资源后缀),就返回index.html的内容,让前端路由自己接管。代码上就是在catch分支里判断一下:

val fallback = if (safePath.substringAfterLast('.', "").isEmpty()) { assets.open("www/index.html") } else null if (fallback != null) { WebResourceResponse("text/html", "UTF-8", fallback) } else { WebResourceResponse("text/plain", "UTF-8", 404, "Not Found", emptyMap(), null) }

多页面场景下还有一点:不同页面如果放在不同子目录,相对路径的基准就变了。assets/www/detail/index.html里的../css/main.css会解析成assets/www/css/main.css,这条链路是通的,但构建工具生成路径时容易出错。我的建议是统一用根目录绝对路径风格的相对写法(以./或../明确层级),少用隐式依赖。

6.2 本地基线与增量更新的双源策略

assets 是只读的,改不了,所以要做离线包更新,必须引入第二个源:应用私有目录。整体策略是启动时判断私有目录里有没有可用的更新包,有就用更新的,没有就回落到 assets 里的基线版本。

伪代码逻辑:

fun resolveEntry(): String { val updated = File(filesDir, "offline/www/index.html") return if (updated.exists() && versionOk(updated)) { "https://$DOMAIN/offline/www/index.html" } else { "https://$DOMAIN/assets/www/index.html" } }

对应的,拦截器里要同时支持/assets/和/offline/两个前缀,后者映射到filesDir/offline/www/。更新包下载完成后要校验完整性(通常是一个签名或者 hash),校验通过才写一个版本标记文件,标记文件存在才认为这个包可用。这样即使下载过程中断,也不会把用户卡在一个损坏的包上。

回滚机制一定要有。最简单的做法是保留上一版目录,新版本连续启动失败 N 次(比如主页面 3 秒内没有触发 ready 回调)就自动切回旧版本,并把新版标记为不可用。这个逻辑不加,一旦推了一个坏包,用户就只能等下一个版本修复,中间这段时间是全量事故。

注意:下载下来的离线包如果包含 HTML/JS,本质上是在执行远端代码。校验环节不能省,至少要校验来源和完整性,别让私有目录里的文件来源不明。

6.3 什么情况下不该用 assets

最后说个反向的判断。assets 加载适合"页面本身稳定、更新频率低、对首屏确定性要求高"的场景,比如帮助中心、协议页、设置向导、活动落地页的骨架。如果一个页面天天改文案、每周发版上新活动,把它塞进 assets 只会让发版节奏被拖住,这种情况老老实实走远程 H5,本地只留一个骨架和错误兜底页就够了。

我在实际项目里的体会是,assets 加载这条链路的价值不在于"省一次网络请求",而在于它把页面的可用性从"依赖网络和服务端"变成了"依赖安装包",这个确定性在弱网场景和离线场景下带来的体验差异,远比省下的那几百毫秒重要。但也正因为如此,别把所有页面都往里塞,包体积一旦失控,安装转化率的损失会把这部分收益全吃掉。定位清楚哪些页面值得离线,比把技术方案做得多花哨更重要。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进