成为全栈·Flutter App 篇·内置 WebView:网页历史、App 返回与外链安全

发布时间:2026/10/15 17:28:39

成为全栈·Flutter App 篇·内置 WebView:网页历史、App 返回与外链安全 成为全栈·Flutter App 篇·内置 WebView网页历史、App 返回与外链安全做这个功能的时候我被一个看起来根本不该成为问题的问题卡住了。用户点文章里的一个外部链接进入内置网页。在网页里点了两个链接网页内部 A → B然后按手机上的返回键。应该回到哪儿按浏览器的习惯回到 A上一页。按 App 的习惯回到那篇文章。两种都对取决于用户把当前界面理解成什么。我一开始没想清楚就实现了浏览器版的于是用户反馈我在文章里点个链接怎么要按三次返回才能回去。这篇讲features/web_page.dart的选择以及顺带发现的另一件事WebView 里不该带 App 的登录态。明确网页归网页路由归路由先看现在的实现。返回按钮是这么写的/// 网页浏览只占一个 App 路由返回按钮始终退出网页回到来源页面。classWebPageextendsStatefulWidget{constWebPage({super.key,requiredthis.url});finalUriurl;而导航委托里/// 所有网页导航只更新 WebView不向 App 导航栈追加路由。NavigationDelegatenavigationDelegate()NavigationDelegate(onNavigationRequest:(request){finaluriUri.tryParse(request.url);if(uri!nullisWebAddress(uri)){returnNavigationDecision.navigate;// ← 继续在 WebView 里}if(request.isMainFramemounted){notice(context,此链接暂不支持在网页中打开);}returnNavigationDecision.prevent;// ← 拦住非 http/https},关键在于 WebView 内部导航不走 App 路由栈。网页里 A → B 的跳转currentUrl变了但Navigator的栈没有变。所以那个网页历史和App 路由历史是两套完全独立的东西Flutter 栈 Article → WebPage 两层返回即回文章 WebView 内部 URL A → URL B 不体现在 Flutter 栈上 App 返回 WebPage → Article而系统返回键Android 硬件返回、iOS 边缘滑动也走 Flutter 路由不是 WebView 历史——因为PopScope没被改成检查controller.canGoBack()。所以现在的口径是用户只要按返回就一定回到来源文章。这个选择符合这个 App 的场景。文章里的链接通常是参考资料“官方文档”用户点进去是为了看一眼然后回来继续读。如果实现成浏览器式返回用户在文档里点了三层链接之后想回文章得按好几次返回而且每次按都以为是回到上一层网页——认知负担和实际预期不匹配。顺带说菜单里给了在系统浏览器打开需要真正的浏览器式浏览时用户有出口。把两种需求分开比在 App 里实现一个半吊子的浏览器体验要好。那当前 URL到底是哪个既然 WebView 会自己跳转那在系统浏览器打开该打开哪个地址不是最初那个 URL。用户从文章点进来的可能是example.com/docs网页里点了链接现在实际在example.com/docs/api。这时候在浏览器打开应该打开后者——用户看到的就是他正在看的东西。FuturevoidopenInBrowser()async{try{finalactualUri.tryParse(awaitcontroller?.currentUrl()??)??currentUrl;finaluriisWebAddress(actual)?actual:currentUrl;if(!isWebAddress(uri)||!awaitlaunchUrl(uri,mode:LaunchMode.externalApplication)){if(mounted)notice(context,无法打开系统浏览器);}}catch(_){if(mounted)notice(context,无法打开系统浏览器);}}优先用controller.currentUrl()失败才退回currentUrl。因为currentUrl是我们在onUrlChange里维护的副本它可能比 controller 内部状态旧一点回调是异步的。而currentUrl的维护有两条路径onPageStarted:(url){if(!mounted)return;setState((){currentUrlUri.tryParse(url)??currentUrl;titlenull;// ← 新页面开始清掉旧标题errornull;progress0;});},onUrlChange:(change){finaluriUri.tryParse(change.url??);if(!mounted||urinull||!isWebAddress(uri))return;setState(()currentUrluri);updateTitle();},onPageStarted和onUrlChange都会更新两者可能重复到达同一地址——所以不是赋值而是tryParse成功就用。而onUrlChange里那个!isWebAddress(uri)的检查是防御about:blank这类中间地址也会触发回调不能当成真实位置。title null那行也重要。新页面开始加载时顶部标题还停留在上一页——用户会以为没跳转成功。清掉之后标题栏显示 fallbacktitle:Text(title??(currentUrl.host.isEmpty?网页:currentUrl.host),maxLines:1,overflow:TextOverflow.ellipsis,),真实标题 域名 “网页”三级降级。标题每秒轮询一次这里有个不太优雅的实现我先说为什么这么写// Also follow document.title changes made by single-page websites.if(mounted){titleTimerTimer.periodic(constDuration(seconds:1),(_)updateTitle(),);}因为 WebView 没有title 变了的回调。单页应用SPA不会触发页面导航onPageFinished只在首次加载时来一次之后路由变了不会有任何通知。所以只能轮询。一秒一次是我权衡后的结果更快比如 200 毫秒对一个只在标题栏显示的文字毫无意义还多几次跨进程调用更慢比如 3 秒会明显滞后。而轮询必须防重入FuturevoidupdateTitle()async{if(readingTitle||controllernull||!mounted)return;readingTitletrue;finalurlcurrentUrl;try{finalnext(awaitcontroller!.getTitle())?.trim();if(mountedurlcurrentUrlnext!title){setState(()titlenext?.isNotEmptytrue?next:null);}}catch(_){// A page may be navigating or the platform view may be closing.}finally{readingTitlefalse;}}三个防护readingTitle标志防重入。getTitle()是异步的如果上一次还没回来下一秒的定时器又触发就会堆积。堆积的getTitle回来时可能已经过了页面跳转所以——url currentUrl是关键。记下发起查询时的 URL回来时如果它已经不是当前 URL 了这次结果丢弃。否则会出现用户已经跳到下一页标题栏又被上一页的标题覆盖。这个模式和 M4-09 的start ! epoch、M4-12 的ticket ! serial完全同构异步发起时记身份回来时核对身份。这是全项目出现第三次了。catch (_) {}吞掉异常是有意的。注释写得很清楚——页面正在跳转、或者平台视图正在销毁时getTitle()会抛异常。这在一个每秒调一次的轮询里是常态不是异常。标题栏最多 1 行、超出省略号避免一个很长的页面标题把布局撑开。错误处理区分主框架和子资源这是 WebView 里最容易搞错的一处onWebResourceError:(failure){if(mountedfailure.isForMainFrametrue){setState((){error网页加载失败请重试;progress100;});}},isForMainFrame这个判断不能省。一个网页加载时会请求几十个子资源图片、字体、脚本、统计代码。任何一张图片 404、一个广告被拦截都会触发onWebResourceError。如果没有这个判断用户的体验会是页面正常显示但顶部覆盖一层网页加载失败请重试而点重试还找不到原因——因为网页本身是好的。只处理主框架错误语义才对主文档加载失败 用户真的看不到内容。子资源失败浏览器自己会降级处理不需要 App 插手。对应地错误状态是全屏覆盖if(error!null)Positioned.fill(child:ColoredBox(color:context.colors.surface,child:Center(child:StateMessage(title:error!,onRetry:isWebAddress(widget.url)?retry:null,),),),),而onRetry: isWebAddress(widget.url) ? retry : null是个细节如果最初的 URL 就不合法比如mailto:不给重试按钮。因为重试一个无效地址毫无意义。那个retry()里也有一层判断Futurevoidretry()async{setState((){errornull;progress0;});if(controllernull){awaitinitialize();// 控制器还没建好重新初始化return;}try{awaitcontroller!.loadRequest(currentUrl);}catch(_){if(mounted)setState(()error网页加载失败请重试);}}用currentUrl而不是widget.url重试。因为用户可能已经跳转了——他加载失败的地方是 B重试应该重试 B不是重试最初那个 A。协议白名单不能只判断是不是链接判断一个地址能不能在 WebView 里打开第一版我写成boolisWebAddress(Uriuri)(uri.schemehttps||uri.schemehttp)uri.host.isNotEmpty;这个判断现在是唯一的守门人if(uri!nullisWebAddress(uri)){returnNavigationDecision.navigate;}if(request.isMainFramemounted){notice(context,此链接暂不支持在网页中打开);}returnNavigationDecision.prevent;为什么必须限制 scheme因为javascript:和data:这类 scheme 如果放行等于让网页执行任意代码——javascript:后面可以跟任意 JS而这段 JS 是在 WebView 的上下文里跑的。host.isNotEmpty也必要http:///path这种解析出来 scheme 是 http 但没有主机是个畸形地址。而isMainFrame那个条件让非主框架iframe 里的、target_blank的不走这个提示——它们静默阻止就行弹提示会很吵。不过要说明这个白名单的边界它只挡 scheme没有做 host 白名单。也就是说如果产品要求只能打开本站文章链接那得再加一层 host 判断。现在没加是因为这个 App 的场景是文章里的外部参考链接本来就该允许任意 http/https 站点。不注入登录态这是最重要的一条现在讲安全也是这个功能里我最想强调的一点。webview_flutter允许给所有请求注入请求头。这个能力很诱人——注入 Authorization 之后网页里就能直接调我们的 API用户不用登录。现在的实现是什么都不注入Futurevoidinitialize()async{try{finalwebWebViewController();controllerweb;awaitweb.setJavaScriptMode(JavaScriptMode.unrestricted);awaitweb.setNavigationDelegate(navigationDelegate());没有setNavigationDelegate之外的任何请求头设置。理由要说清楚因为这是个容易被质疑的决定。假设注入了 Authorization用户的登录令牌就进入了 WebView 的环境。而这个 WebView 加载的是第三方网站。也就是说那个网站的 JS 可以读取它所在页面的任何东西包括注入的请求头。现在的身份认证靠什么access token refresh tokenM4-09。refresh token 是长期有效的一旦泄露攻击者可以在用户不知情的情况下长期冒充他。而这正好撞上 JavaScript 的那个设置awaitweb.setJavaScriptMode(JavaScriptMode.unrestricted);unrestricted意味着允许任意 JavaScript 执行。这是有意为之——很多现代站点文档站、后台、单页应用不跑 JS 就是一片空白限制它等于放弃大部分网站。但这个决定和不注入 token是一体的允许任意 JS 注入长期凭据 把用户账号交出去。如果哪天产品要求WebView 里也能用登录态那必须同时限制 host 白名单、换成短期且可撤销的凭据、把 JS 模式降到restricted。三个条件缺一个都不能做。Markdown 正文里的图片 URL 也遵循同一条线——那些图片走的是原生图片加载reader_image.dart不是 WebView两者是不同的信任边界。一条我一开始想省掉的事onWebResourceError里的isForMainFrame判断我第一版没写。当时的测试方式是打开一个正常网页和一个不存在的网页都正常。于是这个 bug 没被发现。直到我在测试时随手打开了一个有失效广告的网站——页面顶部盖着网页加载失败请重试而网页明明显示得好好的。点重试也没用因为主框架一直是好的。这个 bug 的恶劣之处在于它会误报。一个只报错的页面用户会怀疑 App 坏了一个该报错却不报错的页面用户会白等。误报比漏报更伤。所以判断标准变成宁可漏报子资源失败也不要误报主框架失败。因为子资源失败用户基本感知不到而主框架的误报会直接破坏对 App 的信任。同一个判断标准也用在别处场景宽松宁可误报严格宁可漏报WebView 错误✅ 用这个目录锚点失配✅ 灰掉不点缓存失效判断✅ 只有 forbidden 才清空三处的选择是一致的涉及让用户看到内容的宁可少做。因为漏掉的代价是少一个功能误报的代价是App 不可信。小结内置 WebView 看着只是放个浏览器进去实际要处理五件事两套历史—— WebView 内部 URL 历史和 Flutter 路由栈独立明确选返回回来源文章同时提供在系统浏览器打开作为出口。当前地址跟踪—— 用controller.currentUrl()而非初始 URL失败降级到本地维护的currentUrl。异步身份核对—— 轮询标题时记下发起时的 URL回来时不一致就丢弃url currentUrl。错误分级—— 只有主框架失败才算失败子资源失败静默降级。不注入凭据——JavaScriptMode.unrestricted与不注入 Authorization必须绑定决策。第 3 条是全系列第三次出现同一个模式M4-09 的epoch、M4-12 的serial、这里的url比较。在 Flutter 里做异步答案永远是发起时记身份回来时核对。第 5 条则是这个系列里最重要的安全结论允许任意 JS和注入长期凭据不能共存。前者让第三方站点能执行代码后者让这些代码有机会偷走用户的身份。选一个就行两个都要就是给账号开后门。下一篇讲点赞收藏的乐观更新——那是这个系列里最后一个写操作密集的模式也是失败回退最需要仔细设计的地方。延伸阅读Flutter Markdown 阅读器支持范围与渲染边界服务端目录与文章辅助阅读锚点、上下篇、浏览量和阅读历史移动端错误与限流429、重试和写请求边界如果这篇文章对你有帮助欢迎订阅我的 CSDN 专栏「成为全栈」 专栏地址https://blog.csdn.net/fungleo/category_13204651.html 本系列配套代码仓库https://github.com/fengcms/become-a-full-stack-developer
延伸阅读

更多相关文章

2026/10/15 17:28:39

恶意软件分类数据集实战:从特征工程到模型训练避坑指南

简介:面向恶意软件检测与机器学习研究的数据集资源,适合安全分析人员、算法工程师及高校学生用于恶意软件分类模型训练与特征工程实践。压缩包共25个文件,约19.12MB,涵盖9个CSV特征表与标签文件、6个Python脚本、2个ASM汇编与2个B…

2026/10/15 17:28:39

DDS核心原理与QoS配置:分布式实时通信中间件实践指南

搞过机器人、自动驾驶或者工业控制这类分布式系统的人,大概率绕不开DDS这个名字。DDS全称是Data Distribution Service,数据分发服务,是一套面向分布式实时通信的中间件技术标准。它能解决的核心问题很直白:在几十个甚至上百个节点…

2026/10/15 18:14:13

ROS导航/odom轨迹可视化:从rosbag离线绘制到matplotlib实时显示

做机器人导航调试的时候,除了在 rviz 里盯着机器人实时跑,我经常需要把/odom发出来的轨迹单独拉出来画成一张图——比如比赛复盘、实验报告、论文插图,或者单纯想对比两版参数谁的路径更平滑。这个需求看着不起眼,真动手写的时候&…

2026/10/15 18:14:13

libssh2在Windows下的编译、链接与运行避坑指南

简介:libssh2 1.7.0的Windows开发包为使用Visual Studio 2015的C/C开发者提供了完整的SSH/SFTP集成支持。其中包含编译好的静态库与动态链接库,以及配套头文件,解决在Win10环境下开发时为库文件缺失或版本不匹配而烦恼的问题。压缩包共6个文件…

2026/10/15 18:14:13

SSL证书选型指南:DV、OV、EV与通配符/多域名证书怎么选

做了几年网站运维和咨询,我碰到最多的问题不是“证书怎么装”,而是“我该买哪种证书”。SSL证书选择这件事,看起来就是花几百到几千块买一段加密文件,实际上牵扯到验证门槛、域名数量、浏览器兼容性、续期成本,还有最容…

2026/10/14 11:31:03

Python调用Gemini Structured Outputs实现工单路由门禁

客服工单最怕的不是模型“答错一句话”,而是它给出一段看起来合理的说明,程序却从中猜错优先级。通俗做法是:要求模型只交 JSON(JavaScript Object Notation,轻量数据格式),再让代码验证它。Gem…

2026/10/14 17:41:07

Spring Boot超市进销存系统毕设实战:从需求拆解到答辩通关

最近带的一个学生项目组里,有A同学跑来问我:选什么毕设题目最稳妥,既能让评审老师觉得工作量够,又不会在答辩时被问到语无伦次。我第一反应就是推荐基于Spring Boot的超市仓库管理系统——也就是超市进销存系统。这个题目乍一看平…

2026/10/15 13:42:16

Flutter StatefulWidget 生命周期核心解析

很多刚开始接触 Flutter 的朋友,在看完一堆“Hello World”和基础组件之后,大概率都会撞上同一堵墙:StatefulWidget 里那堆 initState、build、dispose 方法,到底什么时候被调用?为什么顺序是那样?在里面到…

2026/10/15 0:02:22

防关联浏览器哪个好?三款产品实测差异解析

新手做多店铺,最纠结的就是防关联浏览器选哪个。飞跨、网易、站斧这三款被问得最多,先给结论:要一体化省心、长期便宜,选站斧;要网易招牌和稳,选网易;预算紧做东南亚 Lazada,选飞跨。…

2026/10/15 0:02:22

影刀RPA深度实战:大模型Fine-tuning定制RPA专用Prompt与指令生成

影刀RPA深度实战:大模型Fine-tuning定制RPA专用Prompt与指令生成 一、技术背景 大模型(LLM)在通用场景下表现强大,但当我们将 GPT-4 或 Qwen 直接用于生成影刀RPA操作指令和 XPath 定位时,准确率通常只有 40%-60%。原因…

还想了解更多?直接咨询顾问

免费诊断 + 免费方案 + 透明报价。

全国咨询热线400-8866-253
免费获取方案
☎咨询二维码 ☎ ↑