# 占位图标让一个 PWA 始终无法安装

> 一篇 dev.to 报告把一个始终无法安装的 Web 应用追到了两个 68 字节的占位图标文件，而它的 manifest 声明的是全尺寸图标。

2026-09-19，dev.to 账号 thekilteddev 发表了一篇题为 "Never installable" 的报告，讲的是一个
由一位技术人员与某 NHS 营养科团队协作搭建的概念验证型饮食记录 Web 应用；报告开篇给出的结论是，
这个应用在真机上从来没有一次是可安装的。报告写明，manifest 声明的是全尺寸图标，而这些声明背后
的两个实际图标文件是 68 字节的占位桩文件，尺寸为一像素乘一像素；同时写明 service worker、
manifest 以及底下的缓存逻辑从一开始就都是正确的
([该报告](https://dev.to/thekilteddev/never-installable-2fh4))。

报告用同样的口径描述了发现这一点之前的三天：应用能用，或者说看上去能用，每项测试都通过，每轮
评审都没有问题，也从来没有任何东西提示过安装并不可用。这篇文章写成的是一份调试日志，而不是一则
产品公告，而它记录的这些缺陷，正是只有物理设备才会暴露出来的那一类
([该报告](https://dev.to/thekilteddev/never-installable-2fh4))。

## 这篇报告追到了什么

报告的顺序是从症状往文件走，下面按顺序逐条列出它记录的每一步，用的是报告本身以及它所涉及的文档
的措辞。贯穿全文的有三条线：图标声明、先于图标出现的两种错误解释，以及同一轮排查中一起浮现出来
的一组导出失败
([该报告](https://dev.to/thekilteddev/never-installable-2fh4))。

### 两个占位文件

报告对图标缺陷的交代，具体到了 Chrome 面对一条自己无法兑现的声明时会做什么。它写明 Chrome 会
检查一个 PWA 声明的图标是否真的以它所声称的尺寸存在；当它们并不存在时，控制台里不会有任何警告，
也不会触发任何错误，"Install app" 选项就是不出现。它还补充说，"Add to Home Screen" 仍然可用，
但只能产出一个书签式快捷方式，而不是一个真正已安装的应用，因此离线也从来没有生效过
([该报告](https://dev.to/thekilteddev/never-installable-2fh4))。

MDN 记录了这项检查在 manifest 一侧的要求。它写明基于 Chromium 的浏览器，包括 Google Chrome、
Samsung Internet 与 Microsoft Edge，要求 manifest 包含 `name` 或 `short_name`、一个
`start_url`、`display` 和/或 `display_override`、`prefer_related_applications` 为 `false`
或不出现，以及一个必须包含 192px 和 512px 图标的 `icons` 成员。它同时写明，虽然 service worker
并不是一个 PWA 可安装的前提条件，但许多 PWA 用它来提供离线体验，而这正是报告从另一端撞上的依赖
顺序
([MDN，Making PWAs installable](https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Guides/Making_PWAs_installable))。

`icons` 参考页补上了让一个被声明的尺寸承重的那个细节。MDN 写明，对于 PNG 这类位图格式，推荐写明
确切可用的尺寸；对于 SVG 这类矢量格式，可以用 `any` 表示可缩放。于是一个 `sizes` 值就是一项关于
磁盘上字节的断言，而报告里那两个文件正是同一项断言、背后什么都没有
([MDN，icons](https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Manifest/Reference/icons))。

### 先被怀疑的两种解释

报告写明，在有人打开那两个图标文件之前，另外两种解释先背了锅。第一种是浏览器：也许 DuckDuckGo
对 PWA 的支持并不完整。第二种是主机：也许 Namecheap 提供文件的方式与那个在 Netlify 上一直正常
的版本有某种差别。报告写明这两种解释都说得通，也都不成立，并且写明此前针对这两家主机的一次对比
已经被记成了 "decisive, host-specific"
([该报告](https://dev.to/thekilteddev/never-installable-2fh4))。

浏览器那条解释之所以说得通，有一个有文档依据的理由。MDN 写明，在 Android 上，唯一会把 PWA 以
WebAPK 方式安装的浏览器是带 Google Mobile Services 的设备上的 Chrome 以及三星设备上的
Samsung Internet；而 Firefox、Edge、Opera 以及其他浏览器，包括在不带 Google Mobile Services
的设备上的 Chrome，加上的是一个带浏览器徽标的主屏快捷方式，点开后在浏览器里打开站点。因此，一个
行为像书签的主屏条目，在若干 Android 浏览器上本来就是预期结果，与任何图标缺陷无关，而这恰恰
使它成为一个很差的排查信号
([MDN，Making PWAs installable](https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Guides/Making_PWAs_installable))。

报告对主机那条解释的收尾是：真正的差别在于缓存的冷热，而不在于托管方。它写明 Netlify 的
service worker 缓存已经热了好几个月，而 Namecheap 那边是冷的；这份热缓存一直在悄悄掩盖两个
互不相关的缺陷；而让三个缺陷一次全部浮出水面的，是一份全新的缓存第一次迫使每条代码路径在真机上
真正跑一遍
([该报告](https://dev.to/thekilteddev/never-installable-2fh4))。

### 导出路径是在真机上才暴露的

报告给出了四个导出缺陷，并把这四个都归因于真机测试。第一个是在 Android 手机上用 DuckDuckGo 点
"Download" 时的静默空操作，原因是一个 service worker 没有缓存过的 CDN 脚本
`xlsx.full.min.js` 可能加载失败而不抛出任何可见的东西。为它所做的修复又引入了第二个：下载回来
的文件是 `.bin` 而不是 `.xlsx`，因为那条回退下载机制在该浏览器上没有可靠地设置文件的 MIME
类型；报告的修法是改为用一个显式的 Blob 来构造下载
([该报告](https://dev.to/thekilteddev/never-installable-2fh4))。

第三个是背后有一条平台规则的那个。报告写明，应用报告了 "Downloaded"，而实际上什么都没出现，
原因是点击处理函数在触发下载之前等了三次 IndexedDB 往返，而这段等待长到足以让手机悄悄把这次
点击触发下载的许可作废。它的修法是在页面一打开时就把导出预先构造好，让这次点击几乎不需要再等
任何东西
([该报告](https://dev.to/thekilteddev/never-installable-2fh4))。

MDN 对瞬时激活的定义描述的正是那个过期掉的状态。它写明瞬时激活是一种窗口状态，表示用户最近直接
且有意义地与该窗口发生过交互；该状态在诸如 `touchend` 事件这样的交互之后被启用；如果没有进一步
交互续期，它会在一个超时之后过期；它也可能在使用某些受限特性之后被消耗或失效。它还写明，瞬时激活
常被用作一种机制，用来确保某个 Web API 只有在由用户交互触发时才能工作
([MDN，Transient activation](https://developer.mozilla.org/en-US/docs/Glossary/Transient_activation))。

第四个缺陷报告归给了平台而不是应用。它写明，在 Chrome for Android 上把文件分享到 WhatsApp 时
抛出了一个权限错误，尽管浏览器自己的 API 已经说过可以分享；一次 A/B 测试给出了原因：一个纯文本
文件分享正常，而把同一次分享换成真正的 xlsx MIME 类型则每次都失败。报告的读法是，Android
Chrome 自己的分享实现在原生层拒绝了这个文件类型，而修法是一个回退而不是绕路：捕获这一个特定的
失败，退回到那条行得通的下载路径
([该报告](https://dev.to/thekilteddev/never-installable-2fh4))。

MDN 的 `share()` 参考页同时带着这个形状的两半。它写明，当一次文件分享因安全考虑被阻止时，promise
可能以 `NotAllowedError` 被拒绝；当指定了文件但实现不支持文件分享时，则以 `TypeError` 被拒绝。
在它列出的通常可分享的文件类型里，`.txt` 对应 `text/plain`；它还写明，你应当始终用
`navigator.canShare()` 测试这次分享是否会成功
([MDN，Navigator: share()](https://developer.mozilla.org/en-US/docs/Web/API/Navigator/share))。

## 对 PWA 开发者为什么重要

报告自己的推广说法讲的是反馈的缺席，而不是图标。关于安装缺陷，它写明控制台不会警告、不会触发任何
错误、安装选项就是缺席，这让开发者手里只剩一个需要解释的否定事实，没有任何事件可以挂断点。关于
导出缺陷，它写明四个里有两个没有留下任何线索，并且是真机上的一份全新缓存、而不是一套测试，迫使
每条代码路径都跑了一遍
([该报告](https://dev.to/thekilteddev/never-installable-2fh4))。

那些有文档的要求解释了为什么一次全绿的构建在这里能说明的事情如此之少。MDN 列出了基于 Chromium
的浏览器所要求的 manifest 成员，其中包括一个 192px 和一个 512px 图标
([MDN，Making PWAs installable](https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Guides/Making_PWAs_installable))；
而 `icons` 参考页写明，对于 PNG 这类位图格式，应当指定确切可用的尺寸
([MDN，icons](https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Manifest/Reference/icons))。
报告写明，Chrome 会检查声明的图标是否真的以所声称的尺寸存在，不满足时不会有任何警告，而它自己给出
的规则是：在真正执行安装的那台设备上验证安装流程
([该报告](https://dev.to/thekilteddev/never-installable-2fh4))。

瞬时激活那一例推得更远，因为它跟 manifest 毫无关系。MDN 写明，激活状态若不续期会在一个超时之后
过期，并且它决定一个 API 能不能工作。于是任何在调用受限 API 之前先去等存储、网络或 worker 往返
的处理函数，都是在和这个超时赛跑；而报告的修法，也就是把昂贵的工作挪到点击之前，正是从这条定义
推出来的形状
([MDN，Transient activation](https://developer.mozilla.org/en-US/docs/Glossary/Transient_activation))。

## 站内相关条目

- [能力](/zh/reference/capabilities/)：可以把这条交叉引用与报告里的安装提示失败、以及 MDN 列为
  基于 Chromium 的浏览器推广安装所需的那些 manifest 成员放在一起看。
- [性能](/zh/reference/performance/)：报告的第三个导出缺陷是一笔延迟预算而不是一处逻辑错误，
  三次 IndexedDB 往返熬过了这次点击的瞬时激活；可以把这条交叉引用与这个细节放在一起看。
- [存储](/zh/reference/storage/)：可以把这条交叉引用与报告关于一份热的 service worker 缓存
  连续数月掩盖两个缺陷、以及 IndexedDB 读取坐在用户手势关键路径上的交代放在一起看。

## 待核实与存疑之处

- 安装选项缺席这一现象是在哪些 Chrome 与 Android 版本上观察到的，尚未确认。
- Chrome 的可安装性检查是把声明的 `sizes` 与解码后的图像尺寸比较，还是只检查文件存在且可解码，
  尚未确认。
- 打包器、linter 或 manifest schema 校验器能否发现一个声明的图标尺寸与其背后文件不符，尚未确认。
- 报告第三个导出缺陷所撞上的瞬时激活超时，是否有测试框架能够模拟，尚未确认。
- 如果 manifest 诚实地把一个 68 字节的一像素 PNG 声明为一像素图标，它是否同样会不满足 192px 与
  512px 要求，尚未确认。
- 那三次 IndexedDB 往返是否超过了某个具体的瞬时激活超时、以及在该设备上这个超时是多少，尚未确认。
- Android Chrome 对表格 MIME 类型的拒绝是一份固定的允许清单、一种特定版本的行为，还是一项设备级
  策略，尚未确认。
- 报告所说被热的 Netlify 缓存掩盖的那两个缺陷，究竟是导出缺陷、图标缺陷，还是两者的某种组合，
  尚未确认。

## 参考来源

- [Never installable](https://dev.to/thekilteddev/never-installable-2fh4)（dev.to）
- [Making PWAs installable](https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Guides/Making_PWAs_installable)（developer.mozilla.org）
- [icons](https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Manifest/Reference/icons)（developer.mozilla.org）
- [Transient activation](https://developer.mozilla.org/en-US/docs/Glossary/Transient_activation)（developer.mozilla.org）
- [Navigator: share()](https://developer.mozilla.org/en-US/docs/Web/API/Navigator/share)（developer.mozilla.org）