程序员不说人话头像

iOS 小组件开发,Windows 上用 KXBuild 加 WidgetKit 扩展 给 App 加 iOS 桌面小组件

iOS 小组件开发用 WidgetKit:在 Windows 上给 SwiftUI 工程加小组件扩展,写时间线和不同尺寸界面,用 App Group 共享数据并主动刷新,装到 iOS 17 及以上的 iPhone 调试,随 ipa 一起上架。

iOS 小组件开发用 WidgetKit:在 Windows 上用 VS Code 插件或 kxbuild project add-target --type widget 给 SwiftUI 工程加一个小组件扩展,写好时间线和各尺寸界面,用 App Group 和 App 共享数据,App 里调用 WidgetCenter 主动刷新,装到 iOS 17 及以上的 iPhone 上看效果,最后随 ipa 一起上架。本文给工程 MyApp 加一个「喝水打卡」桌面小组件,App 里点一下记一杯,主屏幕上的小组件跟着更新。

文中的工程已经能用 KXBuild 编译并装到真机上。还没走到这一步的,先看[第一个 SwiftUI App]。

什么时候适合加 iOS 小组件?

小组件是一段单独运行的 SwiftUI 界面,放在主屏幕、锁屏或今天视图里。它不能滚动,不能输入文字,也不是一直在运行。系统按你提前给出的时间线显示内容,到点才换下一条。

适合的场景是「一眼看完」的信息,比如计数、进度、倒计时、最新一条消息。需要频繁交互或实时刷新的内容不适合做成小组件,锁屏上的实时进度(外卖、比分)用实时活动(Live Activity)。

小组件和 App 是两个独立的程序,各有自己的 Bundle ID,数据要通过 App Group 共享。下面按添加、写代码、共享数据、签名、装机、发布的顺序走一遍。

怎么添加小组件扩展?

以下两种做法任选其一。

  • VS Code 插件。在 KXBuild 视图中右键 MyApp 这个 target,选 KXBuild: 添加 Target…,也可以在命令面板运行它。类型选「小组件」,名称用默认的 MyAppWidget,App Group 填 group.com.example.myapp。完成后右下角的提示里会列出要在开发者后台注册的 App ID。
  • 命令行。在工程所在目录执行下面的命令。
kxbuild project add-target -P .\MyApp.xcodeproj --type widget --name MyAppWidget --app-group group.com.example.myapp

App Group 一般写成 group. 加 App 的 Bundle ID。小组件要读 App 里的数据,添加时就把它填上,App 和小组件会同时加上这个 App Group。

小组件的 Bundle ID 自动取 App 的 Bundle ID 加名称,这里是 com.example.myapp.myappwidget。另外它要求 iOS 17 及以上,App 支持更低版本也没关系,只是低版本系统上看不到这个小组件。插件的其他类型和参数见[添加 Target]和 [kxbuild 命令参考]。

添加后生成了哪些文件?

工程目录下多出一个 MyAppWidget 文件夹,已经可以直接编译。

文件作用
MyAppWidgetBundle.swift小组件扩展的入口,列出这个扩展提供的所有小组件
MyAppWidget.swift小组件本身,包括时间线、条目、界面和配置
Info.plist声明这是一个 WidgetKit 扩展,一般不用改
Assets.xcassets小组件的强调色和背景色
MyAppWidget.entitlements小组件的 App Group

App 这边的 MyApp.entitlements 也加上了同一个 App Group,工程里原来没有这个文件的会新建一个。小组件已经设置为随 App 一起构建、打包和安装,不用再改工程设置。

打开 MyAppWidget.swift,里面几个类型的分工如下。

  • SimpleEntry。一条要显示的数据,date 是它开始显示的时间。
  • Provider。给系统提供数据。placeholder 是加载前的占位内容,getSnapshot 用在添加小组件时的预览,getTimeline 返回接下来一段时间要显示的条目,以及什么时候再来要。
  • MyAppWidgetEntryView。把一条数据画成界面。
  • MyAppWidget。小组件的配置,包括名称、说明和支持的尺寸。

文件末尾的 MyAppWidget_Previews 是预览画布用的代码,可以保留,也可以删掉。

怎么用 WidgetKit 写小组件?

把 MyAppWidget.swift 换成下面的内容。它读 App 记下的今日杯数,支持主屏幕小号、中号和锁屏圆形三种尺寸。

import WidgetKit
import SwiftUI

let appGroup = "group.com.example.myapp"

var sharedDefaults: UserDefaults {
    UserDefaults(suiteName: appGroup) ?? .standard
}

/// 今天记了几杯,日期不是今天就算 0
func todayCups() -> Int {
    let today = Calendar.current.startOfDay(for: Date()).timeIntervalSince1970
    guard sharedDefaults.double(forKey: "cupsDay") == today else { return 0 }
    return sharedDefaults.integer(forKey: "cups")
}

struct CupsEntry: TimelineEntry {
    let date: Date
    let cups: Int
}

struct Provider: TimelineProvider {
    func placeholder(in context: Context) -> CupsEntry {
        CupsEntry(date: Date(), cups: 3)
    }

    func getSnapshot(in context: Context, completion: @escaping (CupsEntry) -> Void) {
        completion(CupsEntry(date: Date(), cups: todayCups()))
    }

    func getTimeline(in context: Context, completion: @escaping (Timeline<CupsEntry>) -> Void) {
        let now = Date()
        let midnight = Calendar.current.date(byAdding: .day, value: 1, to: Calendar.current.startOfDay(for: now))!
        // 现在显示当前杯数,零点自动归零,之后再来要新的时间线
        let entries = [
            CupsEntry(date: now, cups: todayCups()),
            CupsEntry(date: midnight, cups: 0),
        ]
        completion(Timeline(entries: entries, policy: .atEnd))
    }
}

struct MyAppWidgetEntryView: View {
    @Environment(\.widgetFamily) var family
    var entry: Provider.Entry
    let goal = 8

    var body: some View {
        switch family {
        case .accessoryCircular:
            Gauge(value: Double(min(entry.cups, goal)), in: 0...Double(goal)) {
                Image(systemName: "drop.fill")
            } currentValueLabel: {
                Text("\(entry.cups)")
            }
            .gaugeStyle(.accessoryCircularCapacity)
        case .systemMedium:
            HStack(spacing: 16) {
                VStack(alignment: .leading, spacing: 4) {
                    Label("今日喝水", systemImage: "drop.fill")
                        .font(.caption)
                        .foregroundStyle(.blue)
                    Text("\(entry.cups) / \(goal) 杯")
                        .font(.title.bold())
                }
                ProgressView(value: Double(min(entry.cups, goal)), total: Double(goal))
                    .tint(.blue)
            }
        default:
            VStack(alignment: .leading, spacing: 6) {
                Label("喝水", systemImage: "drop.fill")
                    .font(.caption)
                    .foregroundStyle(.blue)
                Text("\(entry.cups)")
                    .font(.system(size: 44, weight: .bold, design: .rounded))
                Text("目标 \(goal) 杯")
                    .font(.caption)
                    .foregroundStyle(.secondary)
            }
            .frame(maxWidth: .infinity, alignment: .leading)
        }
    }
}

struct MyAppWidget: Widget {
    let kind: String = "MyAppWidget"

    var body: some WidgetConfiguration {
        StaticConfiguration(kind: kind, provider: Provider()) { entry in
            MyAppWidgetEntryView(entry: entry)
                .containerBackground(.fill.tertiary, for: .widget)
        }
        .configurationDisplayName("喝水打卡")
        .description("显示今天喝了几杯水。")
        .supportedFamilies([.systemSmall, .systemMedium, .accessoryCircular])
    }
}

几个要点。

  • 时间线一次给多条。系统不会每秒来问小组件要数据,而是拿到整条时间线后按每条的 date 自己切换。这里提前放好零点归零的那一条,不用在零点唤醒小组件。
  • 刷新策略。.atEnd 表示最后一条显示后再来要,.after(date) 表示到指定时间再来要,.never 表示除非 App 主动通知,否则不再更新。
  • 尺寸。supportedFamilies 决定用户能选哪些尺寸,界面里用 widgetFamily 分别布局。锁屏尺寸以 accessory 开头,系统会把它们渲染成单色。
  • 背景。iOS 17 起小组件必须用 containerBackground 设置背景,不设会显示一条提示而不是你的内容。

MyAppWidgetBundle.swift 不用改。以后想再加一个小组件,写好新的 Widget 结构体后加进它的 body 里即可。

App 和小组件怎么共享数据?

小组件和 App 各有各的存储,UserDefaults.standard 互相看不到。两边都用 UserDefaults(suiteName: "group.com.example.myapp") 读写,数据就放在 App Group 的共享区域里。

把 App 的 ContentView.swift 换成下面的内容。点一下按钮记一杯,然后通知小组件刷新。

import SwiftUI
import WidgetKit

struct ContentView: View {
    private let defaults = UserDefaults(suiteName: "group.com.example.myapp") ?? .standard
    @State private var cups = 0

    var body: some View {
        VStack(spacing: 24) {
            Image(systemName: "drop.fill")
                .font(.system(size: 56))
                .foregroundStyle(.blue)
            Text("今天喝了 \(cups) 杯水")
                .font(.title.bold())
            Button("喝了一杯") {
                addCup()
            }
            .buttonStyle(.borderedProminent)
        }
        .padding()
        .onAppear { cups = todayCups() }
    }

    private func todayCups() -> Int {
        let today = Calendar.current.startOfDay(for: Date()).timeIntervalSince1970
        guard defaults.double(forKey: "cupsDay") == today else { return 0 }
        return defaults.integer(forKey: "cups")
    }

    private func addCup() {
        cups = todayCups() + 1
        defaults.set(cups, forKey: "cups")
        defaults.set(Calendar.current.startOfDay(for: Date()).timeIntervalSince1970, forKey: "cupsDay")
        WidgetCenter.shared.reloadAllTimelines()
    }
}

WidgetCenter.shared.reloadAllTimelines() 让系统马上重新调用小组件的 getTimeline。只想刷新某一个小组件时用 reloadTimelines(ofKind: "MyAppWidget"),参数就是小组件配置里的 kind。

App Group 的名字在 App、小组件代码和 entitlements 里必须一字不差。项目大了可以把读写的代码放进一个两边都能用的 Swift 文件,这里为了看得清楚直接写在了两处。

开发者后台要准备哪些 App ID 和描述文件?

小组件是一个单独的程序,签名用自己的描述文件。登录 developer.apple.com,进入 Certificates, Identifiers & Profiles,按下面的顺序操作。

  1. 创建 App Group。在 Identifiers 页点加号,选 App Groups,标识符填 group.com.example.myapp。
  2. App 的 App ID 开启 App Groups。点开 com.example.myapp,在 Capabilities 里勾选 App Groups,点旁边的 Configure 选中刚建的 App Group,保存。
  3. 给小组件注册 App ID。点加号选 App IDs → App,Bundle ID 选 Explicit,填 com.example.myapp.myappwidget,同样勾选 App Groups 并选中同一个 App Group。
  4. 生成描述文件。在 Profiles 页给小组件的 App ID 新建一份 Development 描述文件,勾选证书和测试机。App 原来的描述文件是开启 App Groups 之前生成的,点开它 Edit 后直接 Save,重新下载。

把两份描述文件都放进 KXBuild 的 keychain 目录,删掉 App 的旧描述文件。构建时会按 Bundle ID 自动给 App 和小组件各配一份,不用在工程里指定,目录位置见 [Windows 给 iOS App 签名]。

通配 App ID(com.example.*)不能开启 App Groups。只做界面、暂时不共享数据的话,添加时 App Group 留空,一份通配的开发描述文件就能同时签 App 和小组件。App ID、描述文件和证书目录的完整步骤见[在 Windows 上申请 iOS 开发证书、描述文件和 p12]。

怎么装到 iPhone 上看桌面小组件效果?

在状态栏选好 iPhone,按 F5。KXBuild 会把 App 和小组件一起构建、签名并安装,然后启动 App。在 App 里点几下「喝了一杯」。

回到主屏幕,长按空白处进入编辑状态,点左上角的「编辑」或加号,选「添加小组件」,在列表里找到 MyApp,挑一个尺寸添加。锁屏尺寸在锁屏上长按,选「自定」后添加。再回 App 点一下,小组件的数字会跟着变。

调试时注意几点。

  • F5 调试的是 App。小组件运行在自己的进程里,App 里的断点管不到它。小组件的逻辑尽量简单,数据的计算放在 App 里完成,写进共享存储后让小组件只负责显示。
  • 刷新次数有限。系统每天给每个小组件的刷新次数有上限,时间线间隔设得很短也不会真的那么频繁地刷新。App 在前台时调用 reloadAllTimelines() 不占这个次数,数据变化时由 App 主动通知是最可靠的做法。
  • 改了代码看不到变化。重新 F5 安装后,桌面上的小组件一般会自动换成新代码。没有变化时删掉小组件重新添加一次。

带小组件的 App 怎么打包 ipa 上架?

上架时 App 和小组件都要有 App Store 类型的描述文件。在 Profiles 页分别给两个 App ID 生成 App Store 描述文件,放进 keychain 目录。然后以下两种做法任选其一。

  • VS Code 插件。在 KXBuild 视图的「构建配置」节点切到 Release,命令面板运行 KXBuild: 构建。
  • 命令行。在工程所在目录执行下面的命令。
kxbuild build .\MyApp.xcodeproj -c Release

生成的 build\MyApp.ipa 里已经带着小组件,iOS 小组件开发的成果随 App 一起提交。App Store 要求扩展和 App 的版本号一致,以后改版本号时,在[工程设置编辑器]里把 MyApp 和 MyAppWidget 两个 target 一起改。上传和提交审核的流程见[在 Windows 上上架 App Store]。

常见问题

添加小组件的列表里找不到怎么办?

先确认手机是 iOS 17 及以上,再打开一次 App 后回到添加列表。仍然没有的,重启手机后再看。

小组件一片空白或一直是灰色占位是什么原因?

多半是 getTimeline 没有调用 completion,或者小组件代码崩溃了,检查强制解包和数组越界。灰色块是 placeholder 的样子,正常情况下很快会换成真实内容。只显示一行提示文字的,是没有设置 containerBackground。

App 里改了数据,小组件为什么不更新?

通常是两边没用同一个 App Group、App 没通知刷新,或者系统限制了刷新次数,按下面逐条检查。

  • App 和小组件的 App Group 名字不一致,或者有一边还在用 UserDefaults.standard。
  • App 写完数据后没有调用 WidgetCenter.shared.reloadAllTimelines()。
  • 系统限制了刷新次数。App 不在前台时发出的刷新请求可能被推迟,打开 App 再操作一次看看。

构建时小组件签名报错怎么办?

先确认 keychain 目录里有小组件的描述文件,并且是在开启 App Groups 之后生成的。

  • 提示找不到匹配的描述文件,说明 keychain 目录里没有 com.example.myapp.myappwidget 的描述文件,或者它不包含这台测试机。
  • 提示描述文件不含 App Groups,说明 App ID 开启 App Groups 之后没有重新生成描述文件,或者用的是通配描述文件。
  • 更多签名和安装报错的原因与处理见 [iOS 签名报错与 ipa 安装失败,16 条常见错误及解决办法]。

更多教程查看官网:https://www.kxbuild.net/zh

转载自 CSDN-专业IT技术社区

原文链接:https://blog.csdn.net/2501_91600747/article/details/169845282

文章来源转载

评论

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--