给旧手机续命:用 Capacitor 把 PWA 打包成原生 iOS App(下篇)

0 阅读4分钟

banner

给旧手机续命:用 Capacitor 把 PWA 打包成原生 iOS App(下篇)

showcase

showcase-1

showcase-2

上篇回顾

上篇讲了怎么用 Vue3 + Vite 手搓一个防烧屏全屏时钟 PWA。功能基本完善,在 Android 和桌面浏览器上表现完美。但部署到 iOS 上之后,发现了一个无法在 Web 层面解决的问题。

问题:iOS 18 的 Home Indicator(底部小横条)

PWA 写完部署上线,在 iPhone 上添加到主屏幕,一切看起来很好——直到我盯着那个底部的白色小横条看了几分钟。

iOS 18 上,PWA 全屏模式下 Home Indicator 始终可见。不会淡出,不会消失。对于一个 7×24 小时亮屏的防烧屏时钟来说,这根细条就是一个定时炸弹——确定性地烧出一条永久残影。

调研了一圈:

  • Web 技术(CSS / JS / PWA manifest)完全没有控制 Home Indicator 显隐的 API
  • 这是 Apple 的系统级设计决策,PWA 开发者无能为力
  • 只有原生 iOS App 才能通过 prefersHomeIndicatorAutoHidden 请求系统在无交互时自动隐藏 Home Indicator

所以问题变成了:怎么用最小的代价,把现有的 Vue3 PWA 套上一层原生壳?

方案选型

方案评估
纯 Swift 重写代价太大,Web 版功能已经完善
React Native + WebView过度设计
Cordova能用但太老了,社区基本停滞
CapacitorIonic 出品,专门为"Web App 套原生壳"设计,对 Vite 项目友好

选了 Capacitor。它的设计哲学就是"你的 Web 代码不用改,我帮你包一层原生",和我的需求完全匹配。

接入过程

Capacitor 的接入本身非常简单:

pnpm add @capacitor/core @capacitor/ios
pnpm add -D @capacitor/cli

# 创建 capacitor.config.ts,指定 webDir: 'dist'

# 构建 Web 资产并添加 iOS 平台
CAPACITOR=1 pnpm build
pnpm exec cap add ios

几分钟就能在 Xcode 里看到项目。但从"能跑"到"能用",中间踩了不少坑。

踩过的坑

坑 1:Capacitor 8 + Xcode 26 无法 override prefersHomeIndicatorAutoHidden

这是整个过程中最折腾的一个问题。

按照网上所有教程和 Capacitor 官方文档,做法是新建 ViewController.swift,继承 CAPBridgeViewController,然后 override:

class ViewController: CAPBridgeViewController {
    override var prefersHomeIndicatorAutoHidden: Bool {
        return true
    }
}

编译报错:

Overriding non-open property outside of its defining module

意思是 CAPBridgeViewController(属于 Capacitor 模块)内部的 prefersHomeIndicatorAutoHidden 没有标记为 open,Swift 的模块边界规则禁止在外部 override。这在 Capacitor 7 / Xcode 15 时代是没问题的,但 Capacitor 8 配合 Xcode 26 的 Swift 6 编译器收紧了这个限制。

viewDidLoadpreferredScreenEdgesDeferringSystemGestures 也去掉,只留 prefersHomeIndicatorAutoHidden——还是报错。这个属性本身就不让 override。

最终解决:放弃编译期 override,改用 ObjC Runtime 在运行时替换方法实现(method swizzling)。

private func swizzleHomeIndicatorAutoHidden() {
    let selector = NSSelectorFromString("prefersHomeIndicatorAutoHidden")
    let trueImp = imp_implementationWithBlock(
        { (_: AnyObject) -> Bool in true } as @convention(block) (AnyObject) -> Bool
    )

    for name in ["Capacitor.CAPBridgeViewController", "CAPBridgeViewController"] {
        guard let cls = NSClassFromString(name) else { continue }
        if let m = class_getInstanceMethod(cls, selector) {
            method_setImplementation(m, trueImp)
        }
        break
    }
}

ObjC Runtime 操作的是运行时方法表,不受 Swift 编译器的 open / public 访问修饰符约束。把这段放在 AppDelegate.swiftdidFinishLaunchingWithOptions 里调用即可。

坑 2:Swizzle 成功了但 Home Indicator 还是不消失

Swizzle 函数执行了,日志也确认方法替换成功,但 Home Indicator 依然纹丝不动。

原因:UIKit 不会主动重新查询 prefersHomeIndicatorAutoHidden。你在启动后替换了方法实现,但 UIKit 在初始化时已经读过一次旧值(false),之后就不会再问了,除非你主动通知它。

解决:在 applicationDidBecomeActive 里调用 setNeedsUpdateOfHomeIndicatorAutoHidden()

func applicationDidBecomeActive(_ application: UIApplication) {
    DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
        UIApplication.shared.connectedScenes
            .compactMap { $0 as? UIWindowScene }
            .flatMap { $0.windows }
            .forEach { $0.rootViewController?.setNeedsUpdateOfHomeIndicatorAutoHidden() }
    }
}

这里还有一个子坑:最开始用的是 self.window?.rootViewController,结果是 nil。因为 Capacitor 8 使用的是 UIScene 生命周期,AppDelegate.window 不再被赋值。必须通过 connectedScenes 遍历所有 window scene 才能找到 rootViewController。

坑 3:NSClassFromString("CAPBridgeViewController") 返回 nil

Swizzle 函数的第一步是通过类名字符串找到 Capacitor 的 ViewController 类。最开始用 "CAPBridgeViewController" 查找,返回 nil,直接跳过了 Capacitor 的 swizzle 逻辑,只走了 UIViewController 基类的保底分支——效果不对。

原因:Swift 编译后的类名带有模块前缀。Capacitor 模块里的 CAPBridgeViewController 在 ObjC Runtime 中的实际类名是 Capacitor.CAPBridgeViewController

解决:用候选列表逐个尝试:

for name in ["Capacitor.CAPBridgeViewController", "CAPBridgeViewController"] {
    guard let cls = NSClassFromString(name) else { continue }
    // swizzle...
    break
}

坑 4:还需要 swizzle childViewControllerForHomeIndicatorAutoHidden

即使 prefersHomeIndicatorAutoHidden 的 swizzle 和通知都做对了,仍有可能不生效。

原因:UIKit 在查询 Home Indicator 偏好时,会先查 childViewControllerForHomeIndicatorAutoHidden——如果该方法返回了一个子 ViewController,UIKit 会从那个子 VC 而不是根 VC 去读取偏好。而 CAPBridgeViewController 内部可能返回了某个子 VC,我们 swizzle 的是根 VC,自然无效。

解决:额外 swizzle childViewControllerForHomeIndicatorAutoHidden 使其返回 nil,强制 UIKit 从根 VC 查询:

let childSelector = NSSelectorFromString("childViewControllerForHomeIndicatorAutoHidden")
let nilImp = imp_implementationWithBlock(
    { (_: AnyObject) -> AnyObject? in nil } as @convention(block) (AnyObject) -> AnyObject?
)
if let m = class_getInstanceMethod(cls, childSelector) {
    method_setImplementation(m, nilImp)
}

坑 5:Storyboard 引用的 ViewController 类找不到

最开始的思路是新建 ViewController.swift 子类,然后修改 Main.storyboardcustomClassCAPBridgeViewController 改成我们的 ViewController

构建运行后黑屏,控制台报:

Unknown class ViewController in Interface Builder file.

原因:我们是在 VS Code 里创建的 ViewController.swift 文件,虽然文件存在于 ios/App/App/ 目录下,但 Xcode 项目并不会自动识别文件系统上新增的文件。需要手动在 Xcode 里 "Add Files to App" 并勾选 "Add to target: App"。

最终解决:后来整个 override 方案改成了 AppDelegate 里的 swizzling,不再需要自定义 ViewController 子类,所以把 Storyboard 改回了直接引用 CAPBridgeViewController。这个坑也就自动绕过了。

坑 6:WKWebView 背景白边

时钟界面出来了,但底部和右侧有白边——纯黑背景没有完全铺满屏幕。

原因:WKWebView 的默认背景色是白色,在内容加载完成前或内容没有覆盖到的区域会露出白底。

解决:在 capacitor.config.ts 里设置 iOS 的背景色:

ios: {
    backgroundColor: '#000000',
    scrollEnabled: false,
    contentInset: 'always',
}

坑 7:免费 Apple ID 无法通过 Xcode Archive 导出 IPA

试图通过标准流程 Product → Archive → Distribute 导出 IPA 时报错:

Team "xxx (Personal Team)" is not enrolled in the Apple Developer Program.

免费的 Personal Team 只能通过 Xcode 直接安装到手机,不能走 Distribute 流程导出 IPA。但我需要 IPA 文件才能交给 AltStore 管理续签。

解决:手动从 Xcode 的 DerivedData 中打包。Xcode Cmd+R 构建后,.app 文件已经生成在 DerivedData 里了,IPA 本质上就是一个特定目录结构的 zip 包:

APP_PATH=$(find ~/Library/Developer/Xcode/DerivedData \
  -name "App.app" -path "*/Debug-iphoneos/*" \
  -not -path "*/Index.noindex/*" | head -1)

mkdir -p /tmp/Payload
cp -r "$APP_PATH" /tmp/Payload/
rm -f /tmp/Payload/App.app/__preview.dylib  # 去除 Preview 辅助库
cd /tmp && zip -r ~/Desktop/BurnClock.ipa Payload/

注意 不能删除 App.debug.dylib——在 Debug 构建中这是主二进制的实际代码,删掉后 App 会闪退。只需要去掉 __preview.dylib(Xcode Preview 专用,AltStore 重签名时会报 format error)。

坑 8:AltStore 侧载的 App 数量限制

老手机安装 IPA 时 AltStore 报错:maximum number of installed apps using a free developer profile

免费 Apple ID 最多只能同时侧载 3 个 App,包括 AltStore 自身。如果已经装了 AltStore + 其他侧载 App(比如 YouTube),就没有多余的槽位了。这个不是技术问题,是 Apple 的策略限制,只能 3 选 2。

坑 9:WebView 并没有铺满全屏(右侧和底部的白/黑边)

把 WebView 背景色改成深红调试后,发现 WebView 容器确实铺满了屏幕,但是 PWA 的 HTML 内容却没有填满 WebView,右侧和底部露出了背景色。

原因:在横屏模式 + viewport-fit=cover(延伸到刘海区)的情况下,CSS 的 width: 100% 往往只会撑满安全区域(Safe Area)的宽度,而不会覆盖刘海背后的整个物理屏幕区域。

解决:放弃 100%,改用 100vw100vh 结合 position: fixed

html,
body {
  width: 100vw;
  height: 100vh;
  overflow: hidden;
}
.app-bg {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  width: 100vw;
  height: 100vh;
}

这样就能强制越过边界,真正铺满整个物理屏幕。

坑 10:iOS 把 App 识别为旧机型尺寸并降级运行(Letterboxing)

即使改了 CSS,在某些大屏设备上周围还是有一圈无法消除的黑边或白边。

原因:Xcode 项目默认的 LaunchScreen.storyboard 里的 View 尺寸固定在 375×667(iPhone 8),且 没有设置自动拉伸autoresizingMask 为空)。iOS 启动应用时,如果判断启动屏不能适配当前物理屏幕,就会认为这是一个未适配新机型的老旧 App,强制在“兼容模式”的黑框(Letterbox)里运行它,导致 WebView 的真实可视区域变小。

解决:修改 ios/App/App/Base.lproj/LaunchScreen.storyboard

  1. 补上自动拉伸:<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
  2. 将背景色设为纯黑,避免启动瞬间闪白光。

最终的 AppDelegate 代码

经过上面这些坑,最终的核心代码其实很简洁——一个 swizzleHomeIndicatorAutoHidden() 函数加一处 setNeedsUpdate 通知:

// AppDelegate.swift

func application(_ application: UIApplication,
                 didFinishLaunchingWithOptions launchOptions: [...]) -> Bool {
    UIApplication.shared.isIdleTimerDisabled = true    // 防止自动锁屏
    swizzleHomeIndicatorAutoHidden()                    // 运行时替换
    return true
}

func applicationDidBecomeActive(_ application: UIApplication) {
    DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
        UIApplication.shared.connectedScenes
            .compactMap { $0 as? UIWindowScene }
            .flatMap { $0.windows }
            .forEach { $0.rootViewController?.setNeedsUpdateOfHomeIndicatorAutoHidden() }
    }
}

Swizzle 函数通过 Capacitor.CAPBridgeViewController 类名找到目标类,替换 prefersHomeIndicatorAutoHidden(返回 true)和 childViewControllerForHomeIndicatorAutoHidden(返回 nil),再用 UIViewController 基类做保底。

最终的工作流程

封装成了三条 pnpm 脚本,以后改完代码走一遍就行:

pnpm sync:ios       # 构建 Web 资产 + 同步到 Xcode(自动禁用 Service Worker)
# Xcode Cmd+R       # 构建安装到手机
pnpm package:ios    # 从 DerivedData 打包 IPA 到桌面

然后 AirDrop 发到手机,用 AltStore 安装。AltStore + AltServer 会在手机连接同一 WiFi 时自动续签,7 天一次,无需手动操作。

小结

整个原生打包部分的代码改动量很小——Capacitor 配置文件 + AppDelegate 里二十来行 Swift。但调试过程比写代码本身痛苦得多:Swift 模块边界、ObjC Runtime 类名前缀、UIKit 的惰性查询机制、Xcode 的文件管理方式、免费签名的各种限制……每一步都是"看起来应该能工作,但就是不行",然后需要深入到框架内部去理解为什么。

好在最终效果是值得的:Home Indicator 在无操作几秒后自动淡出,屏幕不会自动锁屏,底部没有常亮的静态像素。一个退役的 iPhone 终于可以安心当一台纯粹的桌面时钟了。