Android 学习笔记 D18|WebView 阅读页:两条返回栈和一次正确的加载
发布摘要:为文章详情加入原文阅读,重点处理网页历史、应用返回、重复加载、地址校验和资源释放。标签:Android、WebView、Compose、导航。
用户从收藏列表进入详情,再打开原文 A,随后点进网页 B。此时按一次返回,合理行为是回 A;再按一次,才退出阅读页。如果只让应用导航弹栈,网页历史被跳过;如果始终吞掉返回,用户又可能无法离开。WebView 集成的难点在于宿主要管理网页以外的规则。
本文对应 D18 和《第一行代码》第 11 章 WebView、网络部分,AndroidView 是现代补充。示例为教学核心,尚未在用户工程编译运行;需要网络权限、Compose UI、Activity Compose,以及项目已有的导航配置。
一、先定义允许做什么
练习版针对受控、不含表单的页面和普通链接导航,内嵌入口限制为业务确认过的 HTTPS 主机;回调捕获的其他合法 HTTPS 链接交给外部浏览器,异常协议拒绝。主机应解析后精确比较,不能使用“字符串包含本站域名”,否则本站域名.evil.example也会通过。首次加载与回调捕获的网页内跳转使用同一判断。
这里的允许列表不代表自动审查所有子资源,也不是完整浏览器隔离方案。shouldOverrideUrlLoading不覆盖 POST 导航,不能用下面的客户端证明任意网页的全部主框架导航都被限制;不受控的复杂页面优先交给浏览器,若需要更强限制必须另行设计和验证。WebViewClient 回调边界 不要对任意文章页面暴露原生 JavaScript 接口;页面不需要脚本时保持关闭。证书校验失败应中止,不通过调用继续加载来隐藏错误。不安全 URI 加载
第一步写地址校验函数;第二步做纯原生 WebView 小实验;第三步再放入 AndroidView;第四步接入返回逻辑。这样可以区分问题来自网页本身、地址策略,还是 Compose 生命周期。
二、记录业务输入,不要用当前网页地址判断是否重载
网页从 A 跳转到 B 后,WebView 当前地址已经变了。如果update每次都比较“当前地址是否等于文章原始地址”,下一次重组就会把 B 拉回 A。应该记录最后一次接收到的业务入口地址,仅在用户切换文章时重新加载。
以下完整控件类展示这个边界。导入来自 Android 平台,不需要额外 WebView 库。允许列表由调用方提供;onExternal只处理已验证的外部 HTTPS 主页面,并负责处理找不到浏览器的情况。
importandroid.content.Contextimportandroid.net.Uriimportandroid.webkit.WebResourceRequestimportandroid.webkit.WebViewimportandroid.webkit.WebViewClientimportjava.util.LocaleclassArticleWebView(context:Context,privatevalhosts:Set<String>,privatevalonExternal:(Uri)->Unit,privatevalonHistory:(Boolean)->Unit):WebView(context){privatevarrequestedEntry:String?=nullprivatefunvalidHttps(uri:Uri):Boolean=uri.isHierarchical&&uri.scheme.equals("https",true)&&uri.userInfo==null&&!uri.host.isNullOrBlank()&&(uri.port==-1||uri.port==443)privatefunallowed(uri:Uri):Boolean=validHttps(uri)&&uri.host!!.lowercase(Locale.ROOT)inhostsinit{settings.javaScriptEnabled=falsewebViewClient=object:WebViewClient(){overridefunshouldOverrideUrlLoading(view:WebView,request:WebResourceRequest):Boolean{if(!request.isForMainFrame)returnfalseif(allowed(request.url))returnfalseif(validHttps(request.url))onExternal(request.url)returntrue}overridefundoUpdateVisitedHistory(view:WebView,url:String?,isReload:Boolean){onHistory(view.canGoBack())}}}funopenArticle(url:String):Boolean{if(!allowed(Uri.parse(url)))returnfalseif(requestedEntry!=url){requestedEntry=urlloadUrl(url)}returntrue}}本片段使用 API 24 及以上的请求重载回调,若工程最低版本更低,需要按官方说明补兼容重载。允许当前网页继续加载时返回 false;由宿主拦截后返回 true,不能先手动loadUrl再返回 true,造成重复导航。WebView 导航说明
三、返回与释放分别由明确的边界处理
Compose 用 AndroidView 的 factory 创建该控件,update 调用openArticle。宿主把onHistory的结果保存为可观察状态;当前阅读页可见且能网页后退时启用 BackHandler,调用 goBack。没有网页历史时让应用导航处理返回,工具栏按钮使用同一优先级。
加载反馈可通过 WebChromeClient 更新进度,主页面错误通过 WebViewClient 的错误回调显示。子资源图片失败不能一律把整篇文章替换成错误页;必须判断是否为主框架请求。重试是显式用户事件,重新加载当前入口时应明确调用 reload 或提供专门重试方法,因为上述去重函数会忽略相同入口。
AndroidView 的 onRelease 表示不再使用此 View,可在主线程停止加载、移除宿主监听,从父容器移除后 destroy,并清空宿主持有的实例。销毁后不能再调用该对象。暂停与销毁不是同一件事;onPause 不代表所有脚本都停止,pauseTimers 又可能影响进程内其他 WebView。WebView API、AndroidView 释放回调
练习版旋转后重新打开文章入口,并明确暂不恢复网页历史。需要历史恢复时,再设计 saveState、restoreState 与体积控制,不要把“平时不重载”写成“进程重建后自动保留”。
四、故障实验与预期
先从 A 点击到 B,再改变收藏状态制造一次重组。错误实现会返回 A;修复后预期停留 B。第二次实验连续按返回,记录网页 URL 和应用返回栈长度,预期先改变网页历史,最后才移除阅读目的地。
第三次输入一个协议不支持的地址、一个类似可信主机的假域名、一个无效页面。预期分别被拒绝、交给既定外部策略或拒绝、显示主页面错误,不能靠崩溃结束流程。测试只用自有或受控地址,不需要访问真实恶意站点。
最后重复进入退出阅读页,记录创建和释放次数。日志配对是必要证据,但不能证明所有原生资源都已释放;若内存持续增长,应进一步检查对象引用和 WebView 进程情况。
五、原创面试问答与追问
以下是课程自拟题,可联系题库的Activity 生命周期复习宿主责任。
问:为什么返回不能只交给导航框架?答:WebView 还有内部历史,导航框架不知道产品希望先退出哪一层。追问:顶部返回和系统返回应不同吗?本练习采用相同规则,避免操作结果冲突。
问:如何防止重组把网页拉回入口?答:比较最后一次业务输入,不比较正在浏览的当前地址。追问:同一地址失败后怎样重试?使用独立重试事件,不能让普通状态刷新代替重试。
问:白名单通过就能开启任意原生桥吗?答:不能,桥接权限、页面来源、导航和脚本内容仍有独立边界。追问:本项目需要桥吗?只阅读外部文章,不需要为此新增桥接能力。
六、当天练习与验收
二十分钟画两条栈,四十分钟阅读客户端回调,一百五十分钟实现,三十分钟执行四组实验,一小时复述职责。提交一张栈变化图、地址策略说明和真实运行记录;未执行的故障案例保持“预期”表述。
无参考练习是增加“用浏览器打开当前页面”按钮,复用地址检查和异常提示。通过标准是能解释当前 URL、业务入口和应用详情编号为什么是三份不同的数据。