Web 容器里的下载异常,最常见的误判不是“没有地址”,而是把不属于同一个对象的地址放到同一条结论里。页面正在显示的 URL、WebviewController.startDownload() 的触发 URL、下载对象的请求 URL、下载对象的原始 URL,以及下载对象返回的引用页 URL,字段都像地址,证据层却完全不同。

我处理下载问题时,会先问一个很具体的问题:异常发生后,这个 URL 是由哪个 WebDownloadItem 在哪一次回调中给出的? 只有能回答这个问题,原始 URL 才能帮助回溯资源;否则页面把一个固定地址展示得再完整,也只是背景信息。

本文使用的工程不是正常文件落盘页,而是“下载回调失败留痕”页面。它以受控 data: 下载触发真实 WebDownloadItem 回调,在回调中读取 URL 字段,再向 item.start() 传入无效下载路径,观察失败阶段、进度字段和人工补证出口。这个工程能证明本地容器如何接收对象、读取字段与记录失败;不能证明远程资源真实可下载、文件已保存,或业务系统已经收到下载结果。

先把五类地址和四层状态拆开

同一个下载动作里,至少会出现下列对象。它们不能因字段名称相似而互相回填。

项目 本文工程中的来源 能确认 不能确认
测试页 base URL loadData(..., 'https://evidence.local/failure.html', ...) Web 内容加载时传入了固定页面条件 它就是下载的引用页 URL
触发 URL failureTriggerUrl 容器向控制器发起了本地测试下载 文件已经开始写入
请求 URL item.getUrl() 当前回调对象可提供请求字段 一定等于原始 URL 或最终落盘地址
原始 URL item.getOriginalUrl() 当前回调对象可提供原始地址字段 经历了重定向、地址必然不同或资源可用
引用页 URL item.getReferrerUrl() 当前回调对象报告的来源字段 当前 Web 页面一定被内核当作 referrer

页面状态也要单独分层。callbackState 表示下载代理或真实回调的页面状态;fieldState 表示 getter 读取结果;callbackProgressState 描述页面认为自己处于触发、读取、提交失败路径或收到失败回调的哪个阶段;downloadStatedownloadErrorCode 才用于记录下载对象后续状态。任何一层有值,都不能自动替另外一层背书。

例如,“真实回调已触发”能说明 onBeforeDownload 已进入;“真实回调字段读取成功”能说明当前三个 getter 没在该次读取中抛错;“已传入失败下载路径”只能说明页面调用过 item.start()。只有在 onDownloadFailed 到达后,页面才会得到失败回调及尝试读取的错误码。即使如此,它仍是本地受控失败测试,而不是服务器端资源失败的结论。

在这里插入图片描述

为什么我只在 WebDownloadItem 上读取原始 URL

页面控制器和下载对象都可能提供 URL 相关信息,但关注的对象不同。控制器描述 Web 容器当前持有的页面;WebDownloadItem 描述某一次下载任务。一个页面可以触发多次下载,也可以在下载开始后跳转到另一个页面。把控制器地址写进每条下载日志,会把一对多的下载关系压扁成同一个页面背景,后续根本无法判断某一条异常记录属于哪个资源。

本页在 WebDownloadDelegateonBeforeDownload 中接收 item,并且在同一个回调作用域连续读取 URL 字段:

delegate.onBeforeDownload((item: webview.WebDownloadItem) => {
  let readError = '';
  try {
    this.requestUrl = item.getUrl();
    this.originalUrl = item.getOriginalUrl();
    this.referrerUrl = item.getReferrerUrl();
    this.fieldState = '真实回调字段读取成功';
  } catch (error) {
    readError = formatRuntimeError(error as Error);
    this.fieldState = '回调字段读取失败';
  }
  // 后续再作下载处置
});

这段代码能够证明:三个字段读取针对同一个回调传入的 item;如果读取阶段发生异常,页面会把 fieldState 改为“回调字段读取失败”,而不是用页面 URL 或预期常量替代失败字段。它不能证明每一种下载协议都会返回非空值,也不能说明 request、original 和 referrer 在任何运行条件下都不同。

把读取集中在一次回调里还有一个时序原因。若先从 UI 保存页面地址,再由另一个异步任务读取原始 URL,最后才补写下载状态,三者可能来自不同的页面会话或不同时间点。对排障而言,地址正确但对象错了,和地址根本没记录一样危险。对象边界优先于展示完整度。

自动触发只负责让对象出现,不代表文件开始下载

工程使用固定的 data:text/plain,download-failure 作为 failureTriggerUrl。控制器在页面与代理关联后才自动调用 startDownload()

private triggerFailureDownload(): void {
  if (!this.bound) {
    this.triggerState = '自动触发失败:Web 测试页尚未关联控制器';
    return;
  }
  this.callbackProgress = 0;
  this.callbackProgressState = '已自动触发测试下载,等待 WebDownloadItem 回调';
  this.downloadPercent = -1;
  this.downloadState = '已发起测试链接,等待真实下载状态';
  try {
    this.controller.startDownload(this.failureTriggerUrl);
    this.triggerState = `已通过 startDownload 自动触发下载,并传入失败路径:${this.failureDownloadPath}`;
  } catch (error) {
    this.triggerState = '自动触发失败:Web 测试链接未执行';
    this.callbackProgress = 100;
    this.callbackProgressState = '回调处置失败:未能执行自动下载触发';
    this.errorText = formatRuntimeError(error as Error);
  }
}

bound 是这条链的第一道门。它只在 onControllerAttached() 调用 attach() 后置为 true,因此自动触发前先确认 Web 控制器已经关联。若这一条件不成立,页面停在“尚未关联控制器”,不会伪造回调字段。这比单纯在按钮上显示“开始下载”更有价值:读者能区分“应用准备发起测试”和“内核实际已创建下载对象”。

本地 data: 资源刻意减少 DNS、网络、服务端响应头和证书变量,适合验证对象字段与失败处理路径。它不形成真实网络下载,也不用于比较多级重定向中的 URL 差异。若要验证重定向时 getOriginalUrl() 的含义,需要另行建立可控 HTTP 服务、记录每一跳请求和响应,再把该服务器日志与同一个下载事件关联;不能从本页的受控样本推导出网络环境中的跳转语义。

在这里插入图片描述

回调先读取字段,失败测试再决定下载处置

下载对象到达后,本页不会把“字段读到了”误写成“下载成功”。页面随后向 item.start() 传入一个明确无效的路径 invalid://forced-download-failure

try {
  item.start(this.failureDownloadPath);
  this.cancelState = `已传入失败下载路径:${this.failureDownloadPath}`;
  this.callbackProgress = 75;
  this.callbackProgressState = readError === ''
    ? '失败下载路径已提交,等待真实下载失败回调'
    : '字段读取异常,失败下载路径仍已提交';
} catch (error) {
  this.cancelState = '失败下载路径被 WebDownloadItem 拒绝';
  readError = `${readError} ${formatRuntimeError(error as Error)}`;
  this.callbackProgress = 100;
  this.callbackProgressState = '真实下载失败:WebDownloadItem.start() 拒绝失败路径';
  this.downloadState = '真实下载启动失败:无效下载路径被拒绝';
  this.downloadErrorCode = formatRuntimeError(error as Error);
}

这里有两个容易混淆的分支。

第一类是 item.start() 直接抛出。此时失败发生在“提交失败下载路径”阶段,页面立刻记录 downloadStatedownloadErrorCode,没有资格等待一个并不存在的后续失败回调。第二类是 start() 没有抛出,但后续内核把任务推进到失败状态。这时页面先保留“失败下载路径已提交”,再等待 onDownloadFailed。二者的用户可见文字不同,是为了让 Web 容器开发者知道失败停在哪一层,而不是把所有异常缩成“下载失败”。

更关键的是,字段读取失败不应阻止失败测试处置。即使 getOriginalUrl() 或其他字段读取抛错,代码仍会尝试调用 item.start(),并把状态写为“字段读取异常,失败下载路径仍已提交”。这能避免测试任务因日志读取异常而无控制地进入另一个未知处理分支。它不是生产下载器的通用模板:真实业务应根据权限、目标目录、文件类型、用户决策和安全策略决定开始、取消或拒绝任务。

onDownloadFailed 才让失败进入可观察状态

如果下载对象接受了失败路径,后续的错误来自 onDownloadFailed。页面在该回调中更新进度、读取错误码并开放人工补证出口:

delegate.onDownloadFailed((item: webview.WebDownloadItem) => {
  this.updateDownloadProgress(item, '已收到真实下载失败回调');
  try {
    this.downloadErrorCode = `${item.getLastErrorCode()}`;
  } catch (error) {
    this.downloadErrorCode = formatRuntimeError(error as Error);
  }
  this.callbackProgress = 100;
  this.callbackProgressState = '真实下载失败回调已收到,可登记失败处置';
  this.errorText = `WebDownloadItem 下载失败:${this.downloadErrorCode}`;
  this.manualState = '已收到真实下载失败回调,可登记人工补证。';
});

这段代码能够证明页面尝试在失败回调中读取 getPercentComplete()getLastErrorCode(),并将错误码展示为本地状态。它不能证明错误码的业务解释、服务端失败原因或网络可用性,因为这条测试路径的失败由应用传入的无效下载路径触发。页面的 onDownloadFinish 还专门保留“真实下载已完成(与失败测试预期不符)”分支,防止任何完成状态被默认为测试成功。

我会把证据按如下顺序记录:先有触发动作,后有 onBeforeDownload,再有三个字段的读取状态,之后才是 start() 的提交结果,最后才等 onDownloadFailed 或异常完成回调。若用户说“页面显示下载失败”,这条顺序能够回答:是控制器未关联、代理未安装、getter 读取失败、无效路径被拒绝,还是失败回调已经真实到达。

在这里插入图片描述

原始 URL 与请求 URL 相同,仍是需要保留的事实

getOriginalUrl() 的价值不在于强行制造两个不同地址。当前测试输入是固定 data: URL,若 getUrl()getOriginalUrl() 在页面上显示相同,只能说明该对象在这一受控条件下没有提供可观察到的差异。它不是 API 无效,也不是页面可以只保留一个字段的理由。

日志仍然应将 requestUrloriginalUrl 分列保存,并携带来源方法名。原因有三点:

  1. 两个字段由不同 getter 得到;即使值相同,也不能用一次读取复制成两个结论。
  2. 当前样本没有 HTTP 重定向,值相同属于测试条件内的合理结果;换成服务端跳转、鉴权下载或脚本生成资源后,字段关系可能变化。
  3. 结构化事件保留比较结果后,排障者可以先判断“当前是否观察到差异”,再决定要不要进入重定向、缓存、协议或页面脚本排查,而非用经验猜测。

与其把 UI 写成“原始 URL 已获取,下载来源正确”,不如展示为“原始 URL 字段已读取”并同时保留样本条件。前者把字段读取越级解释成业务归因,后者保留了事实和下一步验证条件。Web 容器职责是把对象给出的字段和状态准确交给页面与日志系统,不是替浏览器内核或服务器证明资源来源。

引用页空值也不是可随意回填的空缺

页面同样读取 item.getReferrerUrl(),但不能因为 loadData() 时提供了 https://evidence.local/failure.html,就把这个 base URL 填进 referrerUrlloadData() 的 base URL 是 Web 内容装载条件,getReferrerUrl() 是下载对象的字段;二者是否相同取决于下载协议、内核行为和当前场景。

空字符串至少要与三种情况分开:

现象 页面应记录 不能写成
getter 正常返回空串 字段已读取,值为空 页面没有加载或下载回调失败
getter 抛出异常 字段读取失败与错误文本 引用页为空
UI 尚未刷新 当前页面渲染状态待检查 下载对象一定未提供字段
页面有 base URL 测试页加载条件 下载对象已报告引用页

这不是文字游戏。若容器用页面 URL 回填空的 referrer,报表“完整率”会提高,调查却会失去真实空值。后续团队无法分辨是协议本来没有来源、下载对象没有提供、还是应用偷偷写入了自己猜测的地址。空值是一个正常但需要保持原貌的结果;异常和未刷新则是不同的工程问题。

进度未知时不填充进度条

下载回调页没有把“正在下载”翻译成一个假进度。downloadPercent 初始为 -1downloadPercentLabel() 会将它展示为“未知”;只有 updateDownloadProgress() 成功读取 item.getPercentComplete() 后,页面才会用实际值更新进度宽度。

private updateDownloadProgress(item: webview.WebDownloadItem, state: string): void {
  try {
    this.downloadPercent = item.getPercentComplete();
    this.downloadState = state;
  } catch (error) {
    this.downloadState = `读取真实下载状态失败:${formatRuntimeError(error as Error)}`;
  }
}

private downloadProgressWidth(): string {
  return this.downloadPercent < 0 ? '0%' : `${this.downloadPercent}%`;
}

这段代码可以证明“未知”不会被虚构成百分比。它不能保证 getPercentComplete() 在每次失败、每种协议或每个系统版本中都可读;若读取异常,页面只保存“读取真实下载状态失败”,不会继续显示旧进度当作本次结果。对于 Web 容器来说,这种保守状态比视觉连续更重要:用户看到 0% 或未知,知道仍需等待回调或检查失败;用户看到伪造的 75%,反而会误以为文件曾经真实传输到该位置。

人工补证只登记缺口,不生成成功结果

页面提供“登记字段失败处置”和“转人工补证”两个入口,但它们没有修改下载对象,也不会生成新的 URL。canManual() 的判断是:尚未收到回调,或者 errorText 不再是“暂无错误”时,才允许进入人工补证;否则页面会提示“当前没有失败或缺失回调”。

private canManual(): boolean {
  return this.callbackCount === 0 || this.errorText !== '暂无错误';
}

private manual(): void {
  this.manualState = this.canManual()
    ? '人工补证出口已登记,不生成回调字段或放行下载。'
    : '人工补证未执行:当前没有失败或缺失回调。';
}

这是一个很重要的容器边界。人工动作可以补充日志、截图、复现条件或外部系统查询结果,但不能替代 WebDownloadItem 回调本身。若回调字段本来没有到达,人工补证状态只能告诉用户“需要进一步核查”,不能把页面 URL、旧回调或测试常量塞进原始 URL 栏位。否则后续看到完整字段的人无法识别它是 API 结果还是人工推测。

同理,“重置本地会话”只恢复组件状态。源码明确把触发状态写为“页面已完成自动触发;重置不会伪造新的下载回调”。重置后页面再次显示“等待真实回调”,不是下载记录被清空或任务被撤回,而是当前显示层回到等待状态。真实下载对象是否存在、是否继续、是否失败,应继续以回调和外部日志为准。

生命周期顺序决定字段是否有机会出现

下载 getter 写对了,也不保证回调一定出现。此页的生命周期顺序是:Web 节点通过 onControllerAttached() 进入 attach()attach() 只允许一次绑定;先安装 WebDownloadDelegate;再执行 loadData();最后用 setTimeout 调度自动下载。

private attach(): void {
  if (this.bound) {
    return;
  }
  this.bound = true;
  this.installDelegate();
  try {
    this.controller.loadData(this.html, 'text/html', 'UTF-8',
      'https://evidence.local/failure.html', 'https://evidence.local/history.html');
    this.scheduleFailureDownload();
  } catch (error) {
    this.errorText = formatRuntimeError(error as Error);
    this.callbackProgress = 100;
    this.callbackProgressState = '回调处置失败:测试页未加载';
  }
}

这里 bound 防止重复安装代理与重复调度。autoFailureScheduled 则在 scheduleFailureDownload() 中避免第二次触发。两层保护分别对应“控制器关联只做一次”和“自动测试下载只排队一次”,不能只靠一次按钮禁用替代。若重复安装 delegate,日志可能出现多次回调而误判为重复下载;若重复调度,测试会让相同页面状态相互覆盖,最后看不出哪一次 URL 属于哪一轮操作。

loadData() 只表示页面内容加载动作已发起。页面没有把这一步写成“测试页已渲染完成”,也没有在它返回后立即伪造 URL 字段。对 Web 容器而言,控制器关联、代理安装、内容加载、下载触发、回调收到是五个独立节点;每个节点失败时都应保留不同状态,才能避免把“字段为空”错诊为“API 不支持”。

一个可回溯的下载事件至少包含什么

页面字段只是最小观察面。若将此链路接入真实 Web 容器,应给一次用户操作分配关联标识,再按阶段记录而不是只打印完整 URL。一个可审计的事件建议至少包含:

  1. 请求上下文:页面会话标识、用户动作时间、触发控件或业务入口。它描述谁在什么界面发起动作,不替代下载对象字段。
  2. 对象字段getUrl()getOriginalUrl()getReferrerUrl() 的原始读取状态,外加是否为空、是否读取异常和字段来源方法名。
  3. 处置阶段:代理是否已安装、是否进入 onBeforeDownload、应用尝试执行的是开始、取消还是策略拒绝,以及该动作是否抛错。
  4. 后续回调:进度是否可读、失败/完成回调是否收到、错误码是否可读。它们与字段读取时间分开记录。
  5. 人工处理:是否登记补证、补证原因和后续核查人。人工信息必须有来源标记,不能覆盖 API 原始字段。

完整 URL 往往包含临时令牌、用户标识、查询参数或内部路径。生产日志不应为了“方便排障”无限期存储原文。常规事件可以保留 scheme、host、路径摘要、脱敏查询键、requestEqualsOriginalreferrerState 和错误阶段;只有在合规授权的短期调试会话中才采集更详细内容。这样既能统计回调缺失和字段空值,也不会让原始 URL 本身成为新的泄露面。

我按这条顺序核对异常资源

面对“下载点了没反应”或“原始 URL 不知道从哪来”的问题,我不会先让用户重新点击,而是按以下次序核查:

  1. 确认页面已经进入 onControllerAttached(),并且 callbackState 不是“下载代理安装失败”。
  2. 确认自动触发或用户触发动作已发生;若 bound 为假,停在控制器关联问题,不检查 getter。
  3. 确认 onBeforeDownload 是否把回调状态推进到“已收到真实 WebDownloadItem 回调”。未收到时,不能写任何对象 URL。
  4. 检查 fieldState 是“真实回调字段读取成功”还是“回调字段读取失败”,并分别保存 URL 值、空值或错误文本。
  5. 区分 item.start() 直接拒绝无效路径与后续 onDownloadFailed 到达;错误阶段不同,下一步也不同。
  6. 若需要人工补证,只登记当前缺口、运行版本和复现条件,不把页面地址或历史字段回填到本次对象记录。

这六步的目的不是把测试页包装成下载成功案例,而是防止 Web 层把“触发过”“收到回调”“字段可读”“失败回调到达”“用户拿到文件”混成一句话。它们每一个都是不同证据等级。把阶段写清,用户知道下一步该检查代理、字段、下载路径、系统状态还是外部文件目录。

本文边界与下一步验证

当前工程能够静态证明以下事实:页面注册了 DownloadCallbackFailurePage;页面安装 WebDownloadDelegateonBeforeDownload 从同一个 WebDownloadItem 读取请求、原始和引用页 URL;页面以无效路径进行受控失败处置;失败回调中尝试读取进度和错误码;人工补证不生成字段、不放行下载。

它不能单独证明:远程资源可达;HTTP 重定向链中 original 与 request 的实际差异;引用页 URL 在某个真实页面中一定非空;文件已写入沙箱或用户目录;用户已经看见、打开或使用下载文件。这些结论需要分别补充真实网络请求、服务端日志、文件系统证据、设备观察和业务系统回执。

WebDownloadItem.getOriginalUrl() 的意义,是把异常资源的追踪起点固定到下载对象,而不是让容器从页面 URL 反向猜测。只要 Web 容器始终把对象字段、回调时序、失败处置和人工补证分开记录,原始 URL 即使与请求 URL 相同、即使引用页为空,也仍是一条可用于缩小排障范围的真实事实。

必要条件|运行前检查清单

检查项 当前工程要求 运行前动作
SDK/API与构建工具 HarmonyOS 6.1.1、API 24、ArkTS、ArkUI、Hvigor 用 DevEco Studio 同步 API 24,执行 assembleHap
Kit引入 按本文技术点引入对应 @kit.* 或系统模块 检查 import 与 API 24 类型声明一致
模块/页面配置 entry 为 Stage 模型,页面登记在 main_pages.json 核对页面路由和 module.json5
设备权限/动态授权 Camera、AI字幕使用 CAMERA/MICROPHONE;网络使用 INTERNET 首次运行时完成授权,拒绝时不得继续调用能力 API
系统能力/硬件 按技术点检查摄像头、麦克风、地图、视觉模块或文件能力 在目标设备确认能力存在,再执行核心操作

运行前固定准备三张图片,并在对应检查项说明后插入。01必须能看清 DevEco Studio 的 API 24/构建配置;02必须能看清模拟器或真机权限状态;03必须能看清系统版本、API 级别和设备能力。

在这里插入图片描述

设备权限检查完成后插入:

在这里插入图片描述

MapKit文章增加服务配置检查:在 AppGallery Connect 创建/选择项目,绑定与工程一致的包名和签名,开通 MapKit 并按控制台要求完成应用服务凭据/授权配置后再运行地图能力。截图只能保留包名、服务开关和脱敏项目标识,不能展示密钥或私钥。

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐