本文目录9 节

这次 Bug 最后只改了两处调用。

难的不是怎么改,而是先确认为什么只应该改这两处。问题同时经过 Vue 页面、JavaScript 工具函数、Capacitor 插件和 Android 原生代码,中间还夹着一段已经被遗忘的 Git 历史。如果只看最终 diff,它很像一次普通的函数替换;如果没有前面的证据,这个替换其实只是猜测。

三个看起来矛盾的现象

Android 端最初有四个表现:

  • 在角色相册中长按图片或视频,点击 Save 后保存失败;
  • 同一个长按菜单中的 Delete 可以正常执行;
  • 点击媒体进入预览页后,再点击 Save,却可以正常保存;
  • 另一个生成结果页面中的图片和视频也无法保存。

第一反应很容易落到权限、媒体 URL 或 Android 存储上。但“同一个媒体在预览页可以保存”改变了排查方向。

Delete 正常,说明长按识别、菜单和当前媒体对象没有整体失效。媒体能够显示并进入预览,说明 URL 至少不是普遍为空。预览页又能写入相册,说明 Android 的保存权限和相册能力也没有整体失效。

剩下更值得怀疑的是:这些入口并没有走同一条保存链路。

正常路径就是现成的对照组

我从用户实际点击的 Save 往下追,得到三条路径:

角色相册长按保存
→ handleAlbumItemSave
→ savePicture(url)
→ 失败
角色相册预览保存
→ SaveImg
→ saveImageToGallery(url, isVideo)
→ 成功
生成结果保存
→ saveMedia
→ savePicture(url)
→ 失败

把结果放在一起,第一次分叉已经很清楚:

保存入口 保存方法 结果
角色相册长按 savePicture 失败
角色相册预览 saveImageToGallery 成功
生成结果页面 savePicture 失败

所有失败入口都经过 savePicture,正常入口则经过 saveImageToGallery

这一步让我没有继续扩大搜索范围。权限、下载和 MediaStore 仍然可能参与问题,但应该先看两个函数到底有什么不同。

旧链路首先暴露了视频问题

saveImageToGallery 明确接收媒体类型:

export const saveImageToGallery = async (photo: string, isVideo: boolean) => {
  return isVideo
    ? ChatSavePhoto.downloadVideo({ url: photo })
    : ChatSavePhoto.savePhoto({ url: photo })
}

图片和视频从这里开始就是两条原生路径。

savePicture 的做法不同。它先通过 CapacitorHttp 下载网络资源,再把结果交给另一个保存方法:

export const savePicture = async (webUrl: string) => {
  const response = await CapacitorHttp.get({
    url: webUrl,
    responseType: 'blob',
  })

  await savePhotoOnline(response.data)
}

继续向下看,文件名被固定成了 JPG:

await SaveImage.saveBase64({
  data: base64Data,
  fileName: `${Date.now()}.jpg`,
})

这条链路没有 isVideo、视频 MIME Type 或 .mp4 扩展名。即使原生插件仍然可用,它也不应该承担视频保存。

这能解释视频失败,却还不能解释图片为什么也失败。下一步必须确认 SaveImage 在 Android 中到底是什么。

registerPlugin() 只创建了代理

前端仍然保留着插件声明:

const SaveImage = registerPlugin('SaveImage')

这行代码很容易制造一种错觉:插件已经注册,所以 Android 应该能够调用它。

但 Capacitor 的 registerPlugin() 只会在 JavaScript 层创建代理。Android 端还需要存在名称对应的原生实现,例如:

@CapacitorPlugin(name = "SaveImage")
public class SaveImagePlugin extends Plugin {
    // ...
}

我先查看当前 Android 插件列表:

npx cap ls android

结果中能找到 chat-savephoto,找不到 SaveImage。继续搜索 Android 原生工程,也没有对应的插件类和注册代码。

于是图片失败的链路闭合了:

savePicture(url)
→ SaveImage.saveBase64(...)
→ JavaScript 代理存在
→ Android 原生实现不存在
→ 运行时失败

这类问题可以绕过前端静态检查。import 正常、函数存在、TypeScript 不报错、生产构建通过,都不能证明目标平台真的实现了这个插件。只有调用进入原生桥时,缺失才会暴露。

正常插件已经完成了 Android 适配

插件列表只能证明 ChatSavePhoto 已被 Android 识别,我又继续查看了它的原生实现。

图片保存会下载 URL,根据文件内容识别 JPEG、PNG、GIF 或 WebP,设置对应的 MIME Type 和扩展名,再写入 MediaStore.Images。视频保存使用 .mp4video/mp4,写入 MediaStore.Video

它也处理了 Android 版本差异:Android 10 及以上使用 MediaStore,更低版本写入公共目录后通知媒体扫描器刷新。

因此,saveImageToGallery 不是一个只适用于 iOS 的前端包装。它背后的插件已经同时具备 Android 图片和视频保存能力。角色相册预览页的真机结果又提供了直接证据:这条链路在当前应用中确实可以工作。

Git 历史补上了缺失的一半

当前状态已经足够解释 Bug,但还解释不了“为什么项目里会同时存在一条有效链路和一条失效链路”。

查看 Git 历史后,我发现旧 Android 工程曾经有 SaveImagePluginsavePicture() 在那个阶段并不是死代码。后来 Android 工程调整包路径和原生目录,旧插件被删除,新入口没有重新注册它;前端包装函数却继续保留。

之后公共代码中出现了平台判断:

if (Capacitor.getPlatform() === 'ios') {
  return saveImageToGallery(url, isVideo)
}

return savePicture(url)

这段代码在 iOS 上只会执行 saveImageToGallery,不会进入 Android 的旧路径。只验证 iOS 时,自然看不到 SaveImage 已经从 Android 原生工程消失。

等公共代码进入 Android 分支,旧路径再次被实际执行,问题才暴露出来。

所以这不是 Git 冲突解决错误。代码没有丢失,也没有残留冲突标记。真正失效的是一条历史假设:公共代码仍然认为 Android 保留着旧保存插件,而 Android 原生工程早已换了实现。

Merge 成功
≠ 平台假设仍然成立

Git 能合并文本,不能替我们验证插件、权限和平台生命周期。

为什么没有恢复旧插件

定位完成后有两个选择。

第一个选择是恢复 SaveImagePlugin。这样可以继续兼容 savePicture(),但需要重新处理 Base64 解码、存储权限、MediaStore、Android 版本差异和错误回调。更麻烦的是,这条链路固定使用 .jpg,恢复后仍然不能自然支持视频。

第二个选择是复用 ChatSavePhoto。它已经支持 Android 和 iOS,也已经区分图片与视频;当前预览入口和 Android 真机都证明它可用。

两者相比,恢复旧插件是在重新维护一套重复能力。复用现有插件只需要修正调用入口,风险更小,也不会扩大到原生工程。

最终,两处失败代码统一为:

const result = await saveImageToGallery(url, isVideo)

同时删除对应文件中不再使用的 savePicture import 和平台判断。没有修改生成接口、媒体 URL、删除、分享、预览或 Android 原生插件。

验证的不只是“能够保存”

修改后,我重新检查了几层结果:

  • 两个失败入口都不再调用 savePicture
  • 图片和视频仍然传入正确的 isVideo
  • 生产构建通过,diff 没有无关格式变化;
  • Android 真机重新执行原复现路径,保存恢复正常;
  • 删除、生成、分享和预览逻辑没有被这次修改触碰。

项目原有的 lint 配置还有独立问题。这次没有顺手修它,因为它既不影响根因判断,也不属于媒体保存的修改范围。存量项目里,验证边界和修改边界同样重要。

这次真正留下的方法

这个 Bug 没有复杂算法,最终改动也很小。它值得记录,是因为排查过程可以迁移到其他混合应用问题。

以后再遇到“一处正常、一处失败”,我会先画出两条路径,找到第一次分叉,而不是先列出所有可能原因。涉及 Capacitor 时,我也不会再把 JavaScript 中的 registerPlugin() 当成原生能力存在的证明,而会继续核对平台插件列表、原生类、方法实现和真机行为。

跨平台合并也需要同样的检查。合并完成后,文件、相册、相机、权限、分享、支付、推送和系统返回等平台能力,不能只依赖前端构建验证。

最后只改两处并不意味着前面的排查多余。恰恰是因为正常路径、插件状态和 Git 历史都指向同一个结论,才可以确定不需要恢复旧插件,也不需要扩大修改范围。

Recommended

继续阅读

  1. 3000 条聊天记录下,怎样保持历史消息加载后的阅读位置前端与移动端 · 7 分钟
  2. 一次 Android 返回手势问题的完整排查过程前端与移动端 · 6 分钟