SwiftUI使用Combine监听并动态管理菜单栏案例
SwiftUI使用Combine监听并动态管理菜单栏案例

SwiftUI使用Combine监听并动态管理菜单栏案例

案例分析

在SwiftUI中,我想要通过修改配置同步修改状态栏。

class AppStorage:ObservableObject {
    static var shared = AppStorage()
    
    // 菜单栏显示图标,true为显示
    @Published var displayMenuBarIcon = true
}

当我修改displayMenuBarIcon变量后,移除macOS的状态栏。

但是状态栏的生命周期是通过AppDelegate管理的:

class AppDelegate: NSObject, NSApplicationDelegate {
    var statusBarController: StatusBarController?
    
    func applicationDidFinishLaunching(_ notification: Notification) {
        // 根据设置中的菜单栏选项,创建菜单栏
        if AppStorage.shared.displayMenuBarIcon {
            statusBarController = StatusBarController()
        }
    }
}
class StatusBarController:ObservableObject {
    private var statusItem: NSStatusItem?
    
    init() {
        // 创建系统菜单栏图标
        statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
    }
}

如果说SwiftUI的场景,可以使用@StateObject / @ObservedObject 自动监听 ObservableObject。

在非SwiftUI的上下文(比如AppDelegate)里,就需要主动使用Combine去订阅 @Published 生成的 Publisher。

@Published 实际上生成的是一个 Publisher:可以通过 $propertyName 获取到它(比如 $displayMenuBarIcon)。每次更新 @Published,都会自动发送新值给所有订阅者(包括 AppDelegate 中的订阅)。

使用Combine

在AppStorage中,@Published是Combine的发布者,会自动生成一个Publisher。

AppDelegate监听AppStorage.shared.$displayMenuBarIcon(Combine 订阅),控制 statusBarController 的创建与销毁。

1、在StatusBarController添加移除菜单栏图标的方法

class StatusBarController {
    private var statusItem: NSStatusItem!
    
    init() {
        statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
    }
    
    // 添加移除方法
    func removeFromStatusBar() {
        if let item = statusItem {
            NSStatusBar.system.removeStatusItem(item)
            statusItem = nil
        }
    }
}

通过调用StatusBarController的removeFromStatusBar方法,移除菜单栏图标。

2、在 AppDelegate 中添加Combine 订阅支持

import Combine

class AppDelegate: NSObject, NSApplicationDelegate {
    var statusBarController: StatusBarController?
    var cancellables = Set<AnyCancellable>()
    
    func applicationDidFinishLaunching(_ notification: Notification) {
        // 初始化时根据设置显示菜单栏图标
        if AppStorage.shared.displayMenuBarIcon {
            statusBarController = StatusBarController()
        }
        
        // 监听 displayMenuBarIcon 的变化
        AppStorage.shared.$displayMenuBarIcon
            .receive(on: RunLoop.main)
            .sink { [weak self] showIcon in
                guard let self = self else { return }
                if showIcon {
                    if self.statusBarController == nil {
                        self.statusBarController = StatusBarController()
                    }
                } else {
                    self.statusBarController?.removeFromStatusBar()
                    self.statusBarController = nil
                }
            }
            .store(in: &cancellables)
        
        // 初始化主窗口逻辑 ...
    }
}

3、代码解析

关键Combine代码:

AppStorage.shared.$displayMenuBarIcon
    .receive(on: RunLoop.main)
    .sink { [weak self] showIcon in
        ...
    }
    .store(in: &cancellables)

1)AppStorage.shared.$displayMenuBarIcon

$displayMenuBarIcon 是 Combine 提供的投影属性(projected value)。

因为它是用 @Published 声明的,所以系统自动提供一个 Publisher(类型为 Published<Bool>.Publisher)。

每当 displayMenuBarIcon 的值发生变化,Publisher 就会发出新值(Bool)。

订阅的其实是这个“Publisher”,不是值本身。

2).receive(on: RunLoop.main)

指定接收数据的线程/调度器。

因为在回调里更新 UI(创建或销毁 status bar 控制器),所以必须回到主线程执行。

关于.recive(on:)相关知识,可以查看《Apple处理异步任务的Combine框架》的文章底部扩展知识的“.receive(on:)”部分。

3).sink { [weak self] showIcon in … }

.sink 是最常用的订阅者(Subscriber)方法,用于对 Publisher 发出的值做出响应。

showIcon 是每次值变化时的新值(Bool)。

[weak self] 防止 AppDelegate 和闭包之间造成强引用循环(内存泄漏)。

4).store(in: &cancellables)

sink 返回一个 AnyCancellable,表示一个“订阅关系”。

为了保持订阅有效,必须把它存储起来。

一旦 cancellables 被释放(或手动取消),订阅就会停止,回调不再被调用。

总结

在Swift中使用Combine监听,可以实现动态管理菜单栏的功能。

首先是在管理的单例中使用ObservableObject + @Published,创建Combine发布者,每次更新@Published变量都会自动发送新值。

在AppDelegate的生命周期中,设置AppStorage.shared.$displayMenuBarIcon获取Combine的投影属性,利用 .sink 接收 @Publsihed 发送的新值。

最后根据值决定创建/移除菜单栏图标,同时确保UI都在主线程中执行(确保安全)。

相关文章

Apple处理异步任务的Combine框架:https://fangjunyu.com/2024/12/01/apple%e5%a4%84%e7%90%86%e5%bc%82%e6%ad%a5%e4%bb%bb%e5%8a%a1%e7%9a%84combine%e6%a1%86%e6%9e%b6/

扩展知识

为什么 var cancellables = Set<AnyCancellable>() 是 Set 类型?

因为可能会有多个订阅:

AnyCancellable 表示一个具体的订阅(比如你用 .sink 创建的订阅)。

在一个真实项目中,很可能会订阅多个 Publisher,每个都会返回一个 AnyCancellable。

如果只用一个变量去存储,后一个会覆盖前一个,导致前一个订阅被取消。

使用Set<AnyCancellable>可以统一管理多个订阅:

var cancellables = Set<AnyCancellable>()

publisher1
    .sink { ... }
    .store(in: &cancellables)

publisher2
    .sink { ... }
    .store(in: &cancellables)

当 cancellables 被释放或清空时,里面所有的订阅都会自动取消。

这是 Combine 的自动资源释放机制的一部分。

.store(in: &cancellables) 是“绑定”吗?

不是“绑定”概念,更准确说是“持有”(retain)这个订阅对象。

.store(in: &cancellables) 会将 AnyCancellable 插入 Set 中,防止其在作用域结束后被释放,从而保持订阅持续有效。

其他监听方式

在非SwiftUI的上下文(比如AppDelegate)里,除了Combine,还有其他的监听方式么?

实际上有下面几种可选方案:

1、KVO(Key-Value Observing)

可用于 Objective-C 或继承自 NSObject 的类,但不推荐用于 Swift 原生 struct/class 的属性。

object.addObserver(self, forKeyPath: "value", options: [.new], context: nil)

不推荐,因为它对 Swift 类型兼容性差、语法冗长、易出错。

2、KVO 的 Swift 包装方式(通过 @objc dynamic)

如果把 AppStorage 设计成继承自 NSObject,再加 @objc dynamic 修饰属性,是可以用 KVO 的:

@objc dynamic var displayMenuBarIcon: Bool = true

但这不是纯 Swift 做法,而且 KVO 没有 Combine 的声明式、链式语法优雅。

3、NotificationCenter

可以在属性改变时手动发送通知,在需要监听的地方订阅:

// 发出通知
NotificationCenter.default.post(name: .didChangeDisplayMenuBarIcon, object: nil)

// 监听
NotificationCenter.default.addObserver(forName: .didChangeDisplayMenuBarIcon, ...)

适合跨模块通信,但没有类型安全,写起来繁琐、容易错。

4、自定义闭包回调机制

这是最“原始”的方式:

class AppStorage {
    var displayMenuBarIcon: Bool = true {
        didSet {
            onDisplayMenuBarIconChanged?(displayMenuBarIcon)
        }
    }

    var onDisplayMenuBarIconChanged: ((Bool) -> Void)?
}

可控性强,适合小场景,但扩展性差、可读性低,不如 Combine。

在这些监听方式中,Combine类型安全、语法优雅、支持链式、生命周期易管理,是 Swift 现代推荐方式。

   

如果您认为这篇文章给您带来了帮助,您可以在此通过支付宝或者微信打赏网站开发者。

欢迎加入我们的 微信交流群QQ交流群,交流更多精彩内容!
微信交流群二维码 QQ交流群二维码

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注