Pure Live 网页搜索生命周期、返回语义与状态布局审计详解

原创2026-10-16 08:47:061,882 阅读
文章标签:音视频直播

Pure Live 网页搜索生命周期、返回语义与状态布局审计详解

本文以 Pure Live(纯粹直播)仓库中的源码审查文档 WEB_SEARCH_LIFECYCLE_AND_LAYOUT_AUDIT_2026_09_12.md 为主体骨架,结合 lib/domains/live/presentation/search/ 下的真实实现,系统梳理"搜索结果页 → 网页搜索 WebView"全链路的启动参数校验、WebView 所有权、浏览历史返回、直播链接识别确认、加载错误/重试、外部浏览器回落、日志脱敏与发布版调试入口等关键设计。读完本文,你将掌握 Pure Live 网页搜索从路由入参到直播间跳转的完整生命周期语义,理解为什么"系统返回"与"顶栏返回"共用同一套 PopScope 逻辑,以及如何用代次(generation)与任务合并隔离迟到异步结果。

审计范围与基线

本次审计基线为提交 7bb6a4cbd3ea3442cf424fa8b87295f166991ab1,范围覆盖搜索结果页进入网页搜索之后的以下环节:

  • 启动参数(路由 arguments)解析与合法性校验;
  • WebView 所有权(谁创建、谁替换、谁销毁);
  • 浏览历史返回(浏览器内前进/后退与页面退出);
  • 直播链接识别与确认(检测到房间号后的弹窗确认);
  • 加载错误、进度与重试;
  • 外部浏览器回落(Linux 桌面端);
  • 页面日志与发布版调试入口。

需要强调的是,该轮审计只做源码审查、确定性单元/Widget 回归、静态分析与仓库审计,没有连接手机、打开真实平台网页、修改 Cookie、构建安装包或发布版本——也就是说,所有结论均建立在静态代码与确定性测试之上,真实平台行为仍需后续设备验证(见"验收边界"一节)。

一、网页搜索的入口:从搜索结果页到 WebView

1.1 路由与启动参数

网页搜索页在路由表中注册为 RoutePath.kWebSearch(值 /web_search),见 route_path.dart 与 app_pages.dart。控制器通过 WebSearchBinding 以 Bind.lazyPut 方式懒加载,见 page_bindings.dart。

进入网页搜索的调用点在 search_controller.dart:

final url = buildSearchUrl(site.id, keyword);
if (Platform.isLinux) {
  final opened = await launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication);
  if (_active && !opened) ToastUtil.show(i18n('external_browser_not_opened'));
  return;
}
if (Platform.isWindows && !_isWebView2Available) {
  showWebView2MissingDialog();
  return;
}
Get.toNamed(RoutePath.kWebSearch, arguments: {'url': url, 'platform': site.id});

入口逻辑包含三个平台分支:

  • Linux:直接交给系统浏览器(LaunchMode.externalApplication),这也是控制器 usesExternalBrowser 的默认值来源(Platform.isLinux);
  • Windows:要求 WebView2 运行时可用,否则弹出 showWebView2MissingDialog() 引导用户安装;
  • Android:走应用内 InAppWebView(Android System WebView)。

buildSearchUrl(platform, keyword) 按平台生成对应站内搜索地址,例如:

case Sites.bilibiliSite:
  return "https://search.bilibili.com/live?keyword=$q&from_source=webtop_search&spm_id_from=444.7&search_source=3";
case Sites.douyuSite:
  return "https://www.douyu.com/search?kw=$q&dyshid=0-ed88b042da9bbc4cf4abc97500021601";
case Sites.douyinSite:
  return "https://www.douyin.com/search/$q?type=live";
case Sites.huyaSite:
  return "https://www.huya.com/search?hsk=$q";
case Sites.kuaishouSite:
  return "https://live.kuaishou.com/search?keyword=$q";
case Sites.ccSite:
  return "https://cc.163.com/search/all/?query=$q&only=all";
case Sites.twitchSite:
  return "https://www.twitch.tv/search?term=$q";
case Sites.soopSite:
  return "https://www.sooplive.co.kr/?szKeyword=$q";

1.2 平台能力开关

并不是每个平台都开放网页搜索。能力表定义在 search_capability.dart:

  • 支持网页搜索(supportsWebSearch: true,含默认值 true):B 站、斗鱼、虎牙、抖音、快手、网易 CC、Twitch、SOOP、YY、AcFun、Picarto、Twitcasting、KILAKILA、niconico 以及未注册的未知平台(_unknown 默认 webOnly);
  • 明确关闭(supportsWebSearch: false):微博、SHOWROOM、CHZZK、17LIVE、LiveMe、TikTok、YouTube、Bigo、Pandalive、FC2Live、Steam Broadcast、京东、酷狗、六间房、LookLive、小红书、Missevan、映客、IPTV 等。

如果当前选中平台不支持网页搜索,openWebSearch() 会弹出 search_web_unavailable 提示;未选平台时提示 select_platform_for_web_search。搜索结果页的错误横幅(MaterialBanner)中也只在 canOpenWebSearch 为真时展示"继续网页搜索"按钮。

二、严格启动请求解析:把异常挡在创建 WebView 之前

审计复查发现的第一类问题就是路由参数强制转换:旧实现直接 Map/String 强转,参数缺失、类型错误、非 HTTP(S) 地址甚至带内嵌凭据(userInfo)的地址都会在页面初始化阶段抛异常。

修订后的解析函数位于 web_search_controller.dart,采用"返回 null 而非抛异常"的严格校验:

WebSearchLaunchRequest? parseWebSearchLaunchRequest(Object? arguments) {
  if (arguments is! Map) return null;
  final rawUrl = arguments['url'];
  final rawPlatform = arguments['platform'];
  if (rawUrl is! String || rawPlatform is! String) return null;
  final uri = Uri.tryParse(rawUrl.trim());
  final platform = rawPlatform.trim().toLowerCase();
  if (uri == null ||
      (uri.scheme != 'http' && uri.scheme != 'https') ||
      uri.host.trim().isEmpty ||
      uri.userInfo.isNotEmpty ||
      platform.isEmpty) {
    return null;
  }
  return WebSearchLaunchRequest(uri: uri, platform: platform);
}

校验规则可以归纳为一张参数表:

校验项 规则 说明
arguments 类型 必须为 Map 否则直接拒绝
url / platform 类型 必须为 String 缺失或类型错误拒绝
scheme 仅 http / https 其他协议一律拒绝
host 非空 拒绝无主机地址
userInfo 必须为空 拒绝 https://user:pass@host/ 这类带内嵌凭据地址
platform 非空(trim 后小写化) 平台 ID 参与后续日志与房间映射

WebSearchLaunchRequest 是一个不可变数据类,只携带 uri 与 platform 两个字段。onInit() 中解析失败时:showWebView.value = false、进入 web_search_invalid_address 错误终态、写入 <a href="https://link.gitcode.com/i/24c47c3ae96c39b6ef036333d887bbe9" target="_blank">WebSearch] Rejected invalid launch arguments. 警告日志,全程不创建 WebView。页面层面对应 [web_search_page.dart 的 _buildFailure(context, retryable: false)——不可重试、只能关闭返回搜索页。

三、WebView 所有权与可替换浏览器抽象

3.1 WebSearchBrowser 接口

为了统一"加载、重试、历史返回、停止、销毁"这些操作并便于测试注入,控制器定义了一个浏览器抽象接口(web_search_controller.dart):

abstract interface class WebSearchBrowser {
  Future<void> load(Uri uri);
  Future<void> reload();
  Future<bool> canGoBack();
  Future<void> goBack();
  Future<void> stopLoading();
  Future<void> openDevTools();
  void dispose();
}

生产实现 _InAppWebSearchBrowser 只是对 InAppWebViewController 的薄封装(loadUrl、reload、canGoBack、goBack、stopLoading、openDevTools、dispose),而测试则可以注入任意 fake 浏览器来验证生命周期。

3.2 重建先释放旧实例

审计发现"WebView 重建直接覆盖旧控制器句柄"会造成旧实例泄漏。修订后的 attachBrowser(@L198-L223)保证替换顺序:先保存旧句柄 → 赋值新句柄 → 异步释放旧实例 → 再发起主文档加载:

final previous = _browser;
_browser = browser;
_nativeController = nativeController;
if (previous != null && !identical(previous, browser)) {
  await _disposeSpecificBrowser(previous);
}
if (_closed || !identical(_browser, browser)) return;
_beginMainFrameLoad();
try {
  await browser.load(request.uri);
} catch (error) { ... _setFailure('web_search_load_failed'); }

_disposeSpecificBrowser 内部先 stopLoading() 再 dispose(),两次调用各自 try/catch,避免一次失败中断后续清理。同时,所有来自原生 WebView 的回调(onLoadStart、onLoadStop、onReceivedError 等)都会先经过 _acceptNativeController(controller)(@L463-L465)核对"未关闭且句柄是当前控制器",杜绝旧 WebView 的迟到事件污染新状态。

四、返回语义:先浏览器历史,后退出页面

4.1 PopScope 统一系统返回与顶栏返回

页面层使用 PopScope<Object?>(web_search_page.dart):

return PopScope<Object?>(
  canPop: _allowPop,
  onPopInvokedWithResult: (didPop, result) {
    if (!didPop) unawaited(_handleBack());
  },
  ...

顶栏的 BackButton 与系统返回走同一个 _handleBack();顶栏 close 按钮走 _handleClose()。两者最终都由控制器裁决:

Future<WebSearchBackDisposition> _performBack() async {
  if (_closed) return WebSearchBackDisposition.closePage;
  final browser = _browser;
  if (browser != null) {
    try {
      if (await browser.canGoBack()) {
        if (_closed || !identical(_browser, browser)) return WebSearchBackDisposition.closePage;
        await browser.goBack();
        return WebSearchBackDisposition.stayOnPage;  // 留在页面
      }
    } catch (error) { ... }
  }
  await closeWebSearch();
  return WebSearchBackDisposition.closePage;  // 退出页面
}

语义非常明确:只要浏览器历史里还有上一页,返回键就只做站内后退;历史退无可退时,才整体关闭网页搜索页。_schedulePop() 通过 setState(() => _allowPop = true) 配合 addPostFrameCallback 执行 Navigator.maybePop(),并带有 _popScheduled 防重入标记,避免同一次关闭触发多次 pop。

4.2 任务合并与退出代次

审计指出旧实现"返回、关闭和确认直播间各自重复停止/销毁 WebView,并依赖固定 500 毫秒等待"。修订后:

  • _backOperation、_closeOperation、_externalOpenOperation、_promptOperation 四个 Future 字段分别缓存进行中的同名任务,重复调用直接返回同一 Future(if (existing != null) return existing;);
  • closeWebSearch()(@L435-L450)置 _closed = true、_generation++、清空待确认目标与观察键、隐藏 WebView,然后统一走一次 _disposeBrowser();
  • 所有异步回调在恢复执行前都检查 _isCurrent(generation)(!_closed && generation == _generation),代次一旦推进,迟到加载、外部打开和确认结果全部作废。这就是审计中"退出代次隔离迟到加载、外部打开及确认结果"的实现。

五、直播链接识别与单一确认队列

5.1 链接解析器

WebSearchRoomParser.parse(web_search_room_parser.dart)负责把网页中出现的 URL 解析为 WebSearchRoomTarget{platform, roomId}。其策略分三层:

  1. 专用链接解析器优先:niconico、KILAKILA(仅 owner 型)、小红书、微博、Missevan、映客、Picarto、Twitcasting、SHOWROOM、CHZZK、17LIVE、LiveMe、TikTok、YouTube、Bigo、Pandalive、FC2Live、Steam Broadcast、京东、酷狗、百度、六间房、LookLive 各有独立 parse 实现;
  2. 主机名 + 路径段规则:huya.com(首段 [a-zA-Z0-9_-]+)、live.douyin.com(数字)、douyu.com(数字)、live.kuaishou.com/u/xxx、cc.163.com(数字)、live.bilibili.com(数字)、twitch.tv、sooplive.co.kr、yy.com、live.acfun.cn/live/xxx(19 位内数字)等;
  3. 保留段过滤:search、category、directory、game、video、user、index、topic、login、settings 等路径段会被 isRoomIdentifier 拒绝,避免把搜索页/分类页/账号页误判为直播间。
static const Set<String> _reservedSegments = {
  'search', 'category', 'categories', 'directory', 'directories',
  'game', 'games', 'video', 'videos', 'user', 'users',
  'index', 'topic', 'topics', 'downloads', 'settings',
  'login', 'signup',
};

该解析器同样被 live_url_tool.dart 复用,用于"已知房间链接"识别与分享链接解析,属于同一套房间标识逻辑。

5.2 单一确认队列

审计指出旧实现"确认期间的新地址被直接丢弃;页面退出后迟到确认仍可能打开直播间"。修订后的核心是 observeUrl + _drainPromptQueue 的单一确认队列(@L311-L375):

Future<void> _drainPromptQueue(int generation) async {
  while (_isCurrent(generation)) {
    final target = _pendingTarget;
    _pendingTarget = null;
    if (target == null) return;
    if (_dismissedTarget == target.key) continue;

    roomId.value = target.roomId;
    bool? confirmed;
    try {
      confirmed = await _confirmRoom(target);   // 弹窗确认
    } catch (error) { ... }
    if (!_isCurrent(generation)) return;
    if (_observedTargetKey != target.key) continue;   // 期间已导航到别处
    if (confirmed != true) {
      _dismissedTarget = target.key;                 // 取消后不再追问
      continue;
    }
    ...
    await _openRoom(LiveRoom(roomId: target.roomId, platform: target.platform));
    return;
  }
}

关键行为可以归纳为:

  • 确认期间的最新目标被保存在 _pendingTarget,弹窗对话框结束后队列会继续处理最新目标,而不是丢弃;
  • 取消(confirmed != true) 把该目标写入 _dismissedTarget 去重,同键地址不再重复弹窗;
  • 确认前后都核对生命周期:确认返回后先查 _isCurrent(generation),再核对 _observedTargetKey == target.key——确认期间网页已导航离开该文档则放弃打开;
  • 退出后迟到确认:closeWebSearch() 会 _pendingTarget = null、_observedTargetKey = null 并推进代次,确认回调回来时 _isCurrent 为假,直接 return,不再打开直播间。

默认确认弹窗是 AppPromptDialogs.showAlertDialog(文案 detected_room_id_open:"检测到房间号,是否打开直播间?"),确认后默认通过 AppNavigator.offAndToRoomDetail 跳转直播详情。打开失败时会 _setFailure('get_room_info_failed_retry') 并恢复 WebView 显示。

六、加载状态、错误与重试

审计指出"控制器已有 HTTP/加载错误回调但页面未接线"。修订后页面通过 Obx 订阅 viewStatus(loading / ready / failed)与 loadProgress:

  • 加载中:顶栏下方一条 LinearProgressIndicator,progress 介于 0~100 时显示确定性进度,否则显示不定进度条,并带有 Semantics(label: i18n('web_search_loading')) 无障碍标签;
  • 失败:整页覆盖可滚动的错误说明 + 重试按钮;
  • 就绪:WebView 正常显示。

错误来源与回调(均只处理主文档,request.isForMainFrame == true):

回调 触发条件 动作
onReceivedHttpError 主文档 HTTP 非 2xx 记录 Main document HTTP status <code> from <host> 后 _setFailure('web_search_load_failed')
onReceivedError 主文档加载错误 记录 Main document load error on <host> (<error.type>) 后置失败态
onReceivedServerTrustAuthRequest 不受信任证书 记录警告并以 ServerTrustAuthResponseAction.CANCEL 拒绝
shouldOverrideUrlLoading 任意导航请求 非 HTTP(S) 一律 CANCEL 拦截;HTTP(S) 则 ALLOW 并同步观察 URL

retry()(@L378-L395)先清空错误、复位为 loading、显示 WebView,然后对现有 _browser 调用 reload();重试同样受 _closed/identical(_browser, browser) 保护。失败终态文案对应 web_search_error_title("网页搜索暂不可用")与 web_search_load_failed("网页加载失败,请检查网络或代理后重试。"),可在 zh.json 中核对。

七、外部浏览器回落与进行中状态

Linux 桌面端默认不创建 WebView,而是展示外部浏览器回落页(web_search_page.dart):图标 + 文案 linux_web_search_external_tip + "使用系统浏览器打开"按钮。

按钮点击 openExternalBrowser()(@L161-L181):

Future<void> openExternalBrowser() {
  final existing = _externalOpenOperation;
  if (existing != null) return existing;   // 合并连续点击
  ...
  isOpeningExternal.value = true;
  late final Future<void> task;
  task = _openExternal(request.uri, generation).whenComplete(() {
    if (identical(_externalOpenOperation, task)) {
      _externalOpenOperation = null;
      if (_isCurrent(generation)) isOpeningExternal.value = false;
    }
  });
  _externalOpenOperation = task;
  return task;
}
  • 连续点击合并:进行中的外部打开操作直接复用同一 Future;
  • 进行中状态:isOpeningExternal 置位后按钮禁用并显示 CircularProgressIndicator;
  • 失败/异常统一提示:默认启动器是 launchUrl(uri, mode: LaunchMode.externalApplication),任何异常被捕获,未成功打开时弹出 external_browser_not_opened("系统浏览器未打开,请检查默认浏览器设置")。

八、日志脱敏与发布版调试入口

审计发现的两处发布风险在此收口:

  1. 开发者工具默认关闭:页面按钮与 InAppWebViewSettings.isInspectable 都统一为 _showDeveloperTools => widget.showDeveloperTools ?? kDebugMode(web_search_page.dart),发布版(非 kDebugMode)不再暴露调试入口;openDevTools() 内部同样有 if (!kDebugMode || _closed) return; 双重防护。按钮仅在内嵌浏览器模式显示(!_showDeveloperTools && !usesExternalBrowser 之外的组合)。

  2. 日志最小化:初始化只记录 scheme://host 与平台名;HTTP 错误只记录状态码与主机;加载错误只记录主机与错误类型;不受信任证书只记录主机。查询词、响应头、房间 ID、网页控制台正文一律不进入应用日志——onConsoleMessage 仅在 kDebugMode 且消息级别为 ERROR 时打印一句固定的 [WebSearch] Browser console reported an error.,不输出任何控制台内容。日志写入统一走 LogController(Log.i / Log.w),未注册时回落 debugPrint,保证页面日志与全局日志系统一致。

九、布局与无障碍:窄屏大字号终态可滚动

针对 320×480、3.0 倍字号英文环境的审计要求,修订后的所有说明态(外部浏览器提示、错误说明、非法地址终态)都使用 SingleChildScrollView + PureLiveScrollPhysics,内容约束在 maxWidth: 560 内居中,ConstrainedBox(minHeight: ...) 保证短内容也能垂直居中。失败态根节点带 ValueKey('web-search-failure'),外部打开按钮带 ValueKey('web-search-open-external'),便于测试与无障碍定位。文案均来自 i18n 键(如 web_search、open_in_system_browser、web_search_devtools、retry、close),中英文双语键位于 zh.json 与 en.json。

十、验证证据与验收边界

10.1 审计验证结果

审计文档"验证"章节记录了三层证据:

  • 新增 test/web_search_lifecycle_test.dart 13/13 PASS,覆盖启动参数、外部打开合并/异常、历史返回、重复关闭、WebView 替换、最新房间队列、退出后迟到确认、加载/重试错误以及两种窄屏大字号终态(该测试文件未包含在当前镜像签出中,结论以审计记录为准);
  • 网页链接解析、搜索请求取消、Windows WebView2 生命周期、搜索页布局与翻译合同联合六文件 100/100 PASS;
  • 全库 flutter analyze:No issues found(105.1 秒);
  • 最终质量门禁(local-artifacts/build-records/20260912T021049042Z-quality-focused.json)总用时 141.617 秒,状态 succeeded;门禁内审计(local-artifacts/repository-audits/20260912T020824366Z-focused.json)与文档收口后复核(local-artifacts/repository-audits/20260912-web-search-lifecycle-final.json)错误均为 0、保留既有警告 2。

10.2 验收边界与下一候选

  • 宏观验收项 A1-04 继续保持 RUN:确定性回归只闭环了页面生命周期与布局,不代替 Android System WebView、Windows WebView2、Clash 代理、平台登录 Cookie 和实际直播跳转等真机行为;
  • 下一累计候选需按 Android/Windows 分别验证:网页加载、站内前进/后退、系统返回、失败重试、连续识别两个直播链接、取消/确认以及退出后无迟到导航;
  • 宏观验收汇总为 20 PASS / 33 RUN / 9 NR,共 42 组未闭环,网页搜索生命周期属于尚未在真机闭环的 RUN 组之一。

小结:一套可测试的浏览器生命周期契约

从这次审计可以看到 Pure Live 网页搜索模块的最终形态:严格的入参校验(parseWebSearchLaunchRequest)→ 可替换的浏览器抽象(WebSearchBrowser)→ 统一的返回/关闭语义(PopScope + requestBack)→ 单一确认队列(_drainPromptQueue)→ 代次隔离(_generation)→ 最小化日志与发布版安全。这条链路的每一环都以"可注入依赖 + 确定性测试"为目标:浏览器、外部启动器、房间确认、房间打开、Cookie 刷新、提示通知全部通过构造参数注入,使 13 项生命周期回归可以在无真机环境下稳定复现。对希望在自有直播客户端中实现同类"网页搜索 → 直播间"能力的开发者而言,这份审计及其对应的 web_search_controller.dart 与 web_search_page.dart 实现是一份可直接借鉴的完整参考。

登录后查看全文
pure_live