iOS 滑走后的页面存活与存储
发布于
从 App 切换器滑走 iOS 主屏幕 Web App,不能单独证明文档已经结束,也不能说明 App 重新使用 Safari 的存储。iPhone 16 Pro 的一次 iOS 18.6.2 测试中,滑走后约 6 秒点击通知,原文档仍然存活;另一组测试发现 localStorage 和 IndexedDB 各自隔离,而 cookie 会在添加 App 时复制一次。
通知点击意外聚焦旧页面,或新安装的 App 看不到 Safari 写入的数据时,可以按下面的步骤检查。准备已安装的 HTTPS Web App、Web Push 权限、带有 notificationclick 处理器的 Service Worker 和 Mac Safari Web Inspector。时间和值来自 2026-10-20 的 iPhone 16 Pro、iOS 18.6.2 (22G100) 归档测试(研究数据包,openpwa.net)。
支持范围(Support)
链接到“支持范围(Support)”一节实测的生命周期和存储结果适用于 iPhone 16 Pro 上的 Safari 和 iOS 18.6.2 主屏幕 Web App。Service Worker 规范把 clients.matchAll() 定义为返回现有 client,把 clients.openWindow() 定义为打开新的顶层浏览上下文(Service Workers,matchAll(),w3.org;Service Workers,openWindow(),w3.org)。规范没有规定 iOS 主屏幕 App 被滑走后能存活多久。
WebKit 说明主屏幕 Web App 的网站数据与 Safari 隔离。Safari 17.2 的发布说明补充了复制规则:用户添加 Web App 时复制 cookie,不复制其他本地存储,添加完成后不再共享网站数据(Tracking Prevention in WebKit,webkit.org;WebKit Features in Safari 17.2,webkit.org)。18.6.2 设备测试用一个第一方 cookie、localStorage 和 IndexedDB 值复现了这个模型。
生命周期证据只有一次滑走和约 6 秒后的通知点击,没有覆盖更长等待、内存压力、低电量模式、重启、iPadOS 或其他 iPhone。存储测试没有覆盖 CacheStorage、OPFS、HttpOnly cookie、会话 cookie 和分区 cookie。
分别记录文档身份、通知处理和存储结果,不要让其中一项代替另一项证据。
检测通知测试路径
链接到“检测通知测试路径”一节安排推送前,先检查已安装显示环境和通知测试需要的 API。如果页面位于 Safari,或缺少任一 API,就跳过推送路径,改用后面的文档身份与存储检查。
const standalone = matchMedia('(display-mode: standalone)').matches || navigator.standalone === true;const notificationPath = 'serviceWorker' in navigator && 'Notification' in window && 'PushManager' in window;
if (!standalone || !notificationPath) { console.warn('Push path unavailable; run the identity and storage checks instead.');} else { console.log('Installed context and notification APIs detected.');}回退流程仍可在手动重启前后比较文档身份,并确认当前使用的存储环境,但它不能复现归档测试中的通知点击路径。
为每个文档记录身份
链接到“为每个文档记录身份”一节离开 App 前保存 performance.timeOrigin。它是当前文档性能时间坐标的起点;通知把 App 带回前台后比较两次读数,但要结合导航类型和 history length 判断,不要要求精确相等。归档的 N38 和 N40 记录包含这三项信号,没有采集内存 token。
const identity = { timeOrigin: performance.timeOrigin, navigationType: performance.getEntriesByType('navigation')[0]?.type, historyLength: history.length};
localStorage.setItem('document-identity', JSON.stringify(identity));console.log(identity);N38 重新启动记录的 timeOrigin 是 08:28:29.978Z,导航类型为 navigate,history.length 为 1。后来 N40 滑走 App 并点击通知,读数是 08:28:30.036Z;实验记录说明两次相差 58 ms,属于多次读取 performance.timeOrigin 时的舍入差异,而且两次 history.length 都是 1,因此可判断为同一文档(iOS 18 滑走后的页面生命周期)。
再用内存值补充判断,因为新文档也能读到旧文档留下的 localStorage。下面的回退分支会在内存 token 不存在时把它当作新文档,并创建 token。
if (globalThis.__documentToken) { console.log({ outcome: 'same-js-realm', token: globalThis.__documentToken });} else { globalThis.__documentToken = crypto.randomUUID(); console.log({ outcome: 'new-js-realm', token: globalThis.__documentToken });}滑走前后各运行一次。相同 token 支持 JavaScript realm 延续;新 token 表示这项信号没有保留。归档判断应保留两次 time-origin 读数作为辅助证据,并允许归档测试记录的 58 ms 舍入差异。不要依赖进程 ID,因为测试中的同一 Web App 进程承载了多个页面。
处理通知点击时保留两个分支
链接到“处理通知点击时保留两个分支”一节先查找 window client。找到同源 client 就聚焦;没有匹配项时才调用 openWindow()。两个分支都写日志,测试结果才能区分它们。
self.addEventListener('notificationclick', (event) => { event.notification.close();
event.waitUntil((async () => { const windows = await clients.matchAll({ type: 'window', includeUncontrolled: true });
const existing = windows.find((client) => new URL(client.url).origin === self.location.origin );
if (existing) { try { await existing.focus(); console.log({ windowsFound: windows.length, outcome: 'focused existing window' }); } catch (error) { console.error({ windowsFound: windows.length, outcome: 'focus failed', error }); throw error; } } else { try { const opened = await clients.openWindow('/'); if (opened) { console.log({ windowsFound: 0, outcome: 'opened new window' }); } else { console.error({ windowsFound: 0, outcome: 'openWindow returned null' }); } } catch (error) { console.error({ windowsFound: 0, outcome: 'openWindow failed', error }); throw error; } } })());});归档测试在 08:43:03.022 UTC 创建通知,并在 08:43:09 点击。处理器于 08:43:09.236Z 记录 { windowsFound: 1, outcome: "focused existing window" },页面属性处理器又在 08:43:09.245Z 记录点击。这个时间间隔内,滑走卡片没有进入无 client 分支。
给每个存储环境写标记
链接到“给每个存储环境写标记”一节添加 App 之前,先在 Safari 写入标记。localStorage、IndexedDB 和 cookie 使用各自的键;首次启动 App 后,就能看出哪些值进入了新环境。下面的控制台示例覆盖 localStorage 和 cookie,IndexedDB 记录可用实验 harness 复现。
const context = matchMedia('(display-mode: standalone)').matches ? 'APP' : 'TAB';const marker = `${context}-${new Date().toISOString()}`;
localStorage.setItem('storage-marker', marker);document.cookie = `storage_marker=${encodeURIComponent(marker)}; Path=/; Max-Age=31536000; SameSite=Lax; Secure`;
console.log({ localStorage: localStorage.getItem('storage-marker'), cookie: document.cookie});归档流程先让 Safari 写入 TAB-2026-10-20T07:13:46Z,随后添加 App。PWA Lab 首次读取时,localStorage 和 IndexedDB 都是 null,cookie 则含有 Safari 的标记。App 写入 APP-2026-10-20T07:37:40Z 后,Safari 仍返回先前的 TAB-... 值。同一站点的第二个 App 也使用独立存储(iOS 主屏幕 App 中的 Safari 存储与 cookie)。
解释缺失标记之前,先检查显示环境。matchMedia('(display-mode: standalone)').matches 为 false,且 navigator.standalone 不为 true 时,当前读取来自 Safari,不是已安装 App。App 看不到 localStorage 或 IndexedDB 标记时,应初始化自己的状态或让用户登录,不要把 Safari 的本地数据当作安装迁移。cookie 可以承载 WebKit 文档描述的已测试登录状态复制机制,但后续写入仍保持分离。
运行滑走测试
链接到“运行滑走测试”一节先记录文档身份,再安排 5 秒后的通知,回到主屏幕,打开 App 切换器并滑走 App。通知出现后点击它,再次采集处理器日志和文档身份。归档的 18.6.2 测试找到一个 window;N38 与 N40 的 time-origin 读数相差 58 ms,在研究记录的舍入范围内,两份记录的导航类型都是 navigate,history.length 都是 1。归档测试没有记录内存 token;读者复现时可运行上面的 token 检查,把它作为额外信号。不要要求归档的两次 time-origin 读数精确相等。
如需测试无页面状态,先确认 Web Inspector 已不再列出页面,再从不要求页面存活的来源触发通知。记录等待时间,以及是否发生重启或内存压力。如果处理器没有运行,应保留通知、控制台、Service Worker 和系统 build 证据。只有日志显示代码进入 openWindow() 分支后,才能把失败归到该调用。
存储测试与文档生命周期测试应分开记录。存活文档会保留当前 JavaScript 状态;重新加载的文档仍能打开已安装 App 自己的持久化存储。两种结果都不能证明 Safari 与 App 共用实时存储。