OS Live‑Activity 实战:做一款锁屏常驻便签 App

0 阅读5分钟

本文来自独立开发者实战记录,使用 ActivityKit + SwiftUI,实现一款可以把简短便签常驻显示在 iPhone 锁屏的工具。 大家平时写备忘录、待办,大多依赖 App 或者桌面小组件。但是桌面 Widget 在锁屏点亮状态默认不会展示,想要看提醒必须解锁手机,很多重要提示就被忽略掉。 iOS 从 iOS16.1 提供 Live‑Activity(实时活动),它不仅用于外卖、球赛,我们也可以拿来做常驻锁屏文字便签。 我基于这套能力开发了「钉字条‑锁屏常驻便签」,本文记录开发踩坑、核心代码、系统限制与产品取舍。 ⚠️注意:Live‑Activity 不是无限常驻,系统有严格生命周期限制,开发前必须先读懂系统约束。 一、基础概念:ActivityKit 核心限制(踩坑重点) 很多新手会误以为 Live‑Activity 可以永久挂在锁屏,实际苹果有硬性规则: 活动活跃最长 8 小时,8 小时后灵动岛自动消失;锁屏界面最多继续保留 4 小时,整体最大 12 小时,之后系统自动销毁。 Live‑Activity 进程是独立沙盒,不能直接发起网络请求;更新数据只能本地 App 调用 update,或者推送通知更新。 不支持动画、Transaction;部分 SwiftUI 修饰符会失效。 需要用户授权实时活动权限,用户可以在设置随时关闭权限。 适合展示简短文本,空间有限,不适合大段长文章。 产品取舍:我的便签 App 不会做 “永久常驻” 的虚假宣传,在 UI 和说明里告诉用户系统时间限制,避免用户预期落差。 二、工程准备 Xcode 新建项目,SwiftUI App。 添加 Target → Widget Extension,勾选 Include Live Activity,会自动生成 LiveActivity 模板代码。 项目开启后台模式不是 Live‑Activity 必须;LiveActivity 更新优先在 App 前台调用。 最低版本设置:iOS 16.1+。 关键结构体说明 ActivityAttributes:静态不变的数据,创建时传入,运行过程不会改变。 ContentState:动态可变内容,用于便签文字、颜色,用来 update 更新。 ActivityContent:包装 state、staleDate 过期时间。 三、定义便签的 Attributes 与 ContentState

import ActivityKit

// MARK: 便签LiveActivity模型
struct NoteActivityAttributes: ActivityAttributes {
    public struct ContentState: Codable, Hashable {
        var noteText: String      // 便签正文
        var textColorHex: String  // 文字颜色
        var bgColorHex: String    // 背景颜色
    }
    
    let noteId: String // 静态:笔记唯一id
}

四、定义锁屏 + 灵动岛 UI(Widget 扩展 Target 内) 这里就是锁屏上展示的 UI,SwiftUI 编写,同时实现锁屏视图与灵动岛各区域视图。

import WidgetKit
import SwiftUI

struct NoteLiveActivity: Widget {
    var body: some WidgetConfiguration {
        ActivityConfiguration(for: NoteActivityAttributes.self) { context in
            // MARK: 锁屏展示UI
            VStack(alignment: .leading, spacing: 8) {
                HStack(alignment: .firstTextBaseline, spacing: 4) {
                    Image(systemName: "note.text")
                    Text("待机便签")
                        .font(.caption2)
                        .fontWeight(.semibold)
                        .foregroundColor(Color(hex: context.state.textColorHex).opacity(0.5))
                }
                
                Text(context.state.noteText)
                    .font(.system(size: 26, weight: .heavy))
                    .foregroundColor(Color(hex: context.state.textColorHex))
                    .lineLimit(nil)
                    .minimumScaleFactor(0.4)
                    .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
            }
            .padding(12)
            .activityBackgroundTint(Color(hex: context.state.bgColorHex))
            .activitySystemActionForegroundColor(.white)
            
        } dynamicIsland: { context in
            // MARK: 灵动岛UI
            DynamicIsland {
                DynamicIslandExpandedRegion(.leading) {
                    Text("钉字条")
                }
                DynamicIslandExpandedRegion(.center) {
                    Text(context.state.noteText)
                }
            } compactLeading: {
                Image(systemName: "note.text")
            } compactTrailing: {
                Text(context.state.noteText.prefix(6))
            } minimal: {
                Image(systemName: "note.text")
            }
        }
    }
}

五、App 主工程:封装 Activity 管理器

在主 App Target,写管理器,负责检查权限、启动、更新、关闭实时活动。

import ActivityKit

@MainActor
final class NoteActivityManager {
    static let shared = NoteActivityManager()
    private var currentActivity: Activity<NoteActivityAttributes>?
    
    /// 是否开启权限
    var isEnable: Bool {
        ActivityAuthorizationInfo().areActivitiesEnabled
    }
    
    /// 启动便签活动
    func startNoteActivity(noteId: String, text: String, textHex: String, bgHex: String) async throws {
        guard isEnable else { throw ActivityError.notAuth }
        
        let attr = NoteActivityAttributes(noteId: noteId)
        let state = NoteActivityAttributes.ContentState(
            noteText: text,
            textColorHex: textHex,
            bgColorHex: bgHex
        )
        let content = ActivityContent(state: state, staleDate: nil)
        let activity = try Activity.request(attributes: attr, content: content)
        currentActivity = activity
    }
    
    /// 更新正在运行的便签内容
    func updateNote(text: String, textHex: String, bgHex: String) async {
        guard let act = currentActivity else { return }
        let newState = NoteActivityAttributes.ContentState(
            noteText: text,
            textColorHex: textHex,
            bgColorHex: bgHex
        )
        let newContent = ActivityContent(state: newState, staleDate: nil)
        await act.update(newContent)
    }
    
    /// 结束销毁
    func endActivity() async {
        guard let act = currentActivity else { return }
        await act.end(nil)
        currentActivity = nil
    }
    
    enum ActivityError: Error {
        case notAuth
    }
}

六、开发中遇到的坑点总结

  1. 颜色十六进制解析在 LiveActivity 内崩溃 LiveActivity 渲染进程和主 App 进程隔离,不要写复杂逻辑,颜色解析要简单可靠,不要依赖主 App 全局单例。
  2. 明明调用 update,锁屏 UI 不刷新
  • 检查 ContentState 是否遵守Codable, Hashable;结构体有成员发生变化才会触发更新。
  • Live‑Activity 不能在后台模式频繁更新,尽量在 App 前台执行更新逻辑。
  1. 测试的时候活动频繁消失 不要忽略系统 12 小时最大生命周期;产品层面做好提示,而不是强行对抗系统规则。
  2. 提交 App Store 审核
  • 必须向用户说明 Live‑Activity 用途,不能滥用实时活动。
  • 隐私清单要把 ActivityKit 加入用到的 API 列表。
  1. iCloud 同步笔记 Live‑Activity 本身不能读 iCloud;笔记同步在主 App 完成,同步完成之后手动调用update刷新锁屏 UI。

七、产品层面思考:技术的边界在哪里?

很多用户需求 “永久在锁屏显示便签”,但iOS 系统没有开放永久锁屏常驻的 API。Live‑Activity 是目前最接近的方案,但受 12 小时生命周期约束。

所以产品设计要接受现实:

  1. 把功能定位:短期重要提醒、备考短句、临时清单。
  2. UI 上提示用户系统的时间限制,避免差评。
  3. 搭配普通桌面小组件做补充。

基于以上这套技术,我独立开发完成 App「钉字条‑锁屏常驻便签」:一次性买断、无订阅,iCloud 同步,自定义文字与背景颜色。App Store 可搜索:钉字条。

写在最后 Live‑Activity 不只是外卖、赛事工具,普通工具类 App 也可以玩出很多有意思场景。但是开发前一定要吃透系统的沙盒、生命周期、权限约束,不要做超出系统能力的产品预期。 实际效果:

  1. 线上展示

ea8513120e591bed81dd0cb0694a12c4.jpg 2.锁屏 即见

d195519009af321b9b4bc092b441ce3c.jpg 3. 灵动岛效果

7f541bbfad898d0ee2cc91b644a15ade.jpg 希望本篇实战记录对你有帮助。