ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

把MacBook刘海变成滚动行情屏幕:SwiftUI菜单栏应用开发实战

把MacBook刘海变成滚动行情屏幕:SwiftUI菜单栏应用开发实战 先问一个问题你平时打开 MacBook第一眼会注意到什么除了桌面壁纸可能就是那块标志性的“刘海”了。对于很多开发者来说这个刘海一开始确实有点碍眼尤其是刚换新机那阵子。但换个角度想这块被系统状态栏占据的区域其实是 macOS 屏幕上最稳定、最不会被遮挡的“黄金位置”。今天要聊的项目叫做TickerNotch它在 Hacker News 上引发了不少讨论。简单来说就是让 MacBook 的刘海区域“活”起来实时滚动显示股票行情、加密货币价格、财经新闻标题。听起来有点像把证券交易所的跑马灯搬到了电脑屏幕上对效果差不多而且它是原生跑在菜单栏和刘海区域里的不是屏幕上的悬浮窗。这篇文章不会只停留在介绍这个软件本身。我会从它的核心思路出发拆解“在 MacBook 刘海里显示实时信息”这件事涉及的技术点菜单栏应用开发、刘海区域避让、实时数据拉取、滚动文字渲染。同时会给出一个完整的 SwiftUI 示例工程让你可以自己动手写一个简化版的“刘海底端跑马灯”。无论你是想给自己的 Mac 加点实用小工具还是想研究 macOS 菜单栏应用的开发技巧这篇文章都值得看到最后。1. TickerNotch 是什么为什么它能“藏”进刘海1.1 从 Hacker News 上的一个点子说起TickerNotch 最初出现在 Hacker News 的 “Show HN” 板块。Show HN 是开发者展示自己作品的地方这个项目的亮点非常直接它不是做一个普通的桌面小组件而是把信息滚动条放进了 MacBook Pro 的刘海区域。为什么要这么做因为 MacBook Pro 的刘海在硬件上是一块真实存在的屏幕缺口系统在刘海两侧渲染状态栏内容。绝大多数应用对这块区域的态度是“避开”有刘海就不让窗口内容延伸到那里。但 TickerNotch 反其道而行之把它当成一块天然的、独立的显示区域来用。从用户体验角度来看这个设计有几个天然优势不影响正常桌面工作区不遮挡代码、文档或浏览器窗口。与系统状态栏融为一体视觉上不突兀。刘海区域本身是常亮的适合放一些“不需要主动去看但偶尔扫一眼就能获取”的信息。1.2 它到底显示了什么TickerNotch 的核心功能可以拆成三个模块股票行情显示美股、加密货币等资产的实时价格和涨跌幅。新闻标题从指定 RSS 或新闻 API 拉取标题按一定频率滚动显示。自定义文本也可以把它当作一个常驻的滚动通知栏显示自定义文字。用大白话说就是“把一个滚动的小纸条贴在了屏幕最顶上”。1.3 开发者为什么要关注这类项目抛开 TickerNotch 本身的功能不谈它的技术价值在于展示了 macOS 菜单栏应用的另一种玩法。涉及如何适配带刘海的 MacBook 屏幕。用到了实时数据拉取、定时刷新、动画渲染等常见技术点。提供了一个很好的“小而美”工具类应用的设计范本。所以接下来我会从环境准备开始手动实现一个简化版本地跑马灯应用包含股票价格滚动展示功能最终效果和 TickerNotch 的核心逻辑一致。2. 环境准备与版本说明在开始写代码之前先确认一下开发环境。本文的示例工程基于以下环境你的版本不需要完全一致但需要保证大版本兼容。项目建议版本说明操作系统macOS 13.0刘海屏 MacBook 或外接显示器均可运行无刘海机型也可以显示在菜单栏右侧XcodeXcode 14.0需要包含 SwiftUI 和 AppKit 支持SwiftSwift 5.7使用 async/await 特性拉取网络数据数据接口Yahoo Finance 非官方接口 / CoinGecko 免费 API演示用生产环境建议使用官方数据源版本需要根据你的项目实际情况调整本文示例以常见环境为例重点演示配置思路。2.1 准备一个测试用的 Apple Developer 账号如果你只是本地运行调试普通 Apple ID 就够了。如果后续要分发安装包才需要 Apple Developer Program 账号。2.2 创建 Xcode 项目打开 Xcode选择App模板产品名称可以填NotchTickerDemo界面选择SwiftUI生命周期选择SwiftUI App。这里需要注意我们最后会把应用做成菜单栏常驻应用所以不需要主窗口但 Xcode 模板默认会生成一个 Window 入口稍后我会说明如何处理。3. 核心原理拆解在刘海里显示内容到底难不难3.1 显示原理为什么状态栏可以“绕过”刘海MacBook Pro 的刘海是屏幕硬件上的一个缺口系统状态栏在刘海两侧会自动分配显示空间。对于普通 App 来说它的窗口永远不会延伸到状态栏区域。但对于开发菜单栏应用Menu Bar App来说系统提供了专门的 API 让开发者把自己的内容加到状态栏里。关键 API 是NSStatusBar和NSStatusItem。你可以为NSStatusItem设置一个自定义视图这个视图可以包含任何 AppKit/ SwiftUI 内容。对于带刘海的 MacBook Pro菜单栏被分成了左右两段左侧是应用菜单。右侧是状态项时间、Wi-Fi、电池等。TickerNotch 的思路就是把自己的状态项往右侧塞并把内容区做成可以横向滚动的长条视图。当内容滚动时会自然经过刘海下方区域。从视觉上看文字就像是从刘海里“钻”出来一样。3.2 滚动文字实现方案在 macOS 上实现横向滚动文字有几种常见方案使用 SwiftUI 的TimelineViewoffset动画使用 NSTimer 定期更新文本位置使用 Canvas 手动绘制文字本文选择第一种代码简洁适配 SwiftUI 时代的主流写法。通过TimelineView(.animation)获取一个持续更新的时间线然后根据时间计算文本的 x 偏移量当文本完全滚出右侧边界后重新从左侧进入。3.3 数据更新机制实时行情最重要的就是数据要够“实时”。对于免费接口一般不需要做到毫秒级推送5 到 10 秒刷新一次就足够。实现上可以用一个异步循环func startPolling() async { while !Task.isCancelled { await fetchData() try? await Task.sleep(nanoseconds: 10_000_000_000) } }3.4 菜单栏应用的特殊处理菜单栏应用没有传统窗口也不会出现在 Dock 栏。需要在 App 入口做配置设置LSUIElement为true表示这是一个纯菜单栏应用。在applicationDidFinishLaunching中创建NSStatusItem。如果使用 SwiftUI App 生命周期需要把主窗口隐藏改为手动管理状态栏视图。4. 实战写一个简化版 TickerNotch下面我们来手动实现一个完整可运行的示例。这个示例会在菜单栏右侧添加一个滚动文字视图。每隔 10 秒从 CoinGecko 免费 API 拉取比特币当前价格。把价格格式化后以跑马灯方式在菜单栏滚动显示。点击状态栏图标可以退出应用。4.1 创建项目结构首先确保你的 Xcode 项目结构如下NotchTickerDemo/ ├── NotchTickerDemoApp.swift ├── TickerStatusItem.swift ├── TickerService.swift ├── TickerScrollingView.swift └── Info.plist如果使用 Xcode 模板生成默认只有NotchTickerDemoApp.swift和ContentView.swift需要手动创建其他文件。4.2 修改 Info.plist 为菜单栏应用如果你的项目使用 Info.plist添加以下键值keyLSUIElement/key true/如果使用新版本 Xcode 的Info配置可以在 Target 的Info标签页中添加Application is agent (UIElement)为YES。设置为LSUIElement true后应用启动时不会激活普通窗口也不会出现在 Dock 中。4.3 编写应用入口把NotchTickerDemoApp.swift修改为// 文件路径NotchTickerDemo/NotchTickerDemoApp.swift import SwiftUI main struct NotchTickerDemoApp: App { NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate var body: some Scene { Settings { EmptyView() } } } class AppDelegate: NSObject, NSApplicationDelegate { private var statusItem: NSStatusItem? private var tickerService TickerService() func applicationDidFinishLaunching(_ notification: Notification) { NSApp.setActivationPolicy(.accessory) statusItem NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) if let button statusItem?.button { let hostingView NSHostingView( rootView: TickerScrollingView(service: tickerService) ) hostingView.frame NSRect(x: 0, y: 0, width: 360, height: 24) button.addSubview(hostingView) } Task { await tickerService.startPolling() } } }代码说明NSStatusItem.variableLength让状态栏项目宽度可以自适应内容。NSHostingView是 SwiftUI 视图嵌入 AppKit 的桥梁。setActivationPolicy(.accessory)进一步确保应用不占用 Dock 和 CmdTab 切换。这里把滚动视图的初始宽度设为 360 点实际使用时可以根据内容动态调整。4.4 实现数据服务创建一个名为TickerService.swift的文件// 文件路径NotchTickerDemo/TickerService.swift import Foundation import SwiftUI struct TickerPrice: Codable { let bitcoin: BitcoinPrice struct BitcoinPrice: Codable { let usd: Double } } MainActor class TickerService: ObservableObject { Published var tickerText Loading... private let coinGeckoURL URL(string: https://api.coingecko.com/api/v3/simple/price?idsbitcoinvs_currenciesusd)! func startPolling() async { while true { await fetchPrice() try? await Task.sleep(nanoseconds: 10_000_000_000) } } private func fetchPrice() async { do { let (data, _) try await URLSession.shared.data(from: coinGeckoURL) let price try JSONDecoder().decode(TickerPrice.self, from: data) let formatted String(format: BTC $%.2f, price.bitcoin.usd) tickerText ⚡️ formatted | formatted } catch { tickerText ⚠️ 价格获取失败 } } }这里有个细节为了让滚动效果更自然tickerText里我把同一条价格文本拼接了两遍。这样滚动时首尾能无缝衔接不会出现空白间隙。4.5 实现滚动文字视图创建TickerScrollingView.swift// 文件路径NotchTickerDemo/TickerScrollingView.swift import SwiftUI struct TickerScrollingView: View { ObservedObject var service: TickerService var body: some View { TimelineView(.animation) { context in let time context.date.timeIntervalSinceReferenceDate let textWidth: CGFloat 180 let containerWidth: CGFloat 360 let offset -CGFloat(time.truncatingRemainder(dividingBy: textWidth containerWidth)) Text(service.tickerText) .font(.system(size: 12, weight: .medium, design: .monospaced)) .foregroundColor(.primary) .fixedSize() .offset(x: offset containerWidth, y: 0) } } }这段代码是滚动效果的核心。拆开解释TimelineView(.animation)会以屏幕刷新率持续更新闭包。time是当前时间戳随每一帧变化。textWidth containerWidth是单次滚动周期需要移动的总距离。truncatingRemainder让偏移量在 0 到总距离之间循环。最终offset从containerWidth开始逐渐向左移动直到整个文本滚出左侧然后再回到起点。这样做的效果就是文本从右侧进入向左滚动经过刘海区域最后从左侧离开形成跑马灯。4.6 添加退出菜单现在应用运行后只能通过 Activity Monitor 或者强制退出来关闭这不够优雅。我们在 AppDelegate 里加一个右键菜单让用户可以直接退出。在applicationDidFinishLaunching末尾追加// 构建菜单 let menu NSMenu() let quitItem NSMenuItem( title: Quit, action: #selector(NSApplication.terminate(_:)), keyEquivalent: q ) menu.addItem(quitItem) statusItem?.menu menu加了menu之后点击状态栏图标会出现菜单。但这样会覆盖左键点击的默认行为如果后续需要左键点击触发其他操作需要再自定义button.action。4.7 运行与验证在 Xcode 中直接 CmdR 运行。预期效果菜单栏右侧出现一个滚动文字区域显示⚡️ BTC $xxxxx.xx文本每 10 秒更新一次价格文本持续向左滚动循环不断菜单栏上的滚动文字经过刘海区域时视觉上就像从刘海里钻出来如果是在无刘海的 MacBook 或外接显示器上文字会在菜单栏右侧平滑滚动同样有效。5. 生产环境优化从 Demo 到可发布工具上面的代码是一个能跑的最小 Demo但距离一个可以日常使用的工具还有一段距离。下面从实际工程角度补充几个重点优化方向。5.1 动态宽度计算我在示例中把容器宽度固定为 360 点。但不同状态下文本长度可能不同比如⚠️ 价格获取失败就比BTC $12345.67短很多。如果宽度固定短文本滚动时可能会出现大片空白。优化思路根据当前文本宽度动态更新NSStatusItem的长度。func updateStatusItemWidth() { let textWidth (tickerText as NSString).size( withAttributes: [.font: NSFont.monospacedSystemFont(ofSize: 12, weight: .medium)] ).width let newWidth max(320, textWidth 80) statusItem?.length newWidth }在tickerText每次更新后调用这个方法可以让状态栏项目宽度和文本长度匹配。5.2 数据源抽象不同用户关注的信息不一样有人看股票有人看加密货币有人看新闻。更好的设计是把数据源抽象成协议protocol TickerDataSource { var tickerText: String { get async } } struct BitcoinDataSource: TickerDataSource { var tickerText: String { get async { ... } } } struct NewsDataSource: TickerDataSource { var tickerText: String { get async { ... } } }这样以后新增数据源只需要实现协议不需要改动 UI 层。5.3 错误处理和容灾免费 API 经常有访问频率限制。如果拉取失败示例代码只是显示一条错误文本。更好的做法是连续失败时增加重试间隔比如 10 秒 → 30 秒 → 60 秒。保留最后一次成功的数据避免页面突然变成错误提示。把错误信息写入日志方便排查。5.4 颜色与视觉定制当前的文字只使用了.primary。实际应用中价格变化时可以用颜色区分涨了用绿色。跌了用红色。横盘用灰色。这不仅是美观问题更是信息传达效率问题。用户扫一眼颜色就知道涨跌不用仔细读数字。6. 常见问题与排查思路6.1 菜单栏什么都没有显示问题现象常见原因解决思路运行后菜单栏没有出现滚动文字LSUIElement设置后应用没有正确创建NSStatusItem在applicationDidFinishLaunching加断点确认代码执行到了只有图标没有文字NSHostingView的 frame 宽度为 0确保设置了 frame 且不为空文字不滚动TimelineView(.animation)在菜单栏中不更新确认是否将 SwiftUI 视图嵌入到了NSStatusItem中TimelineView(.animation)在 AppKit 的NSHostingView中一般会正常工作但如果遇到不刷新的情况可以改用Timer发布者来驱动。6.2 网络请求失败CoinGecko 免费 API 在没有代理的情况下国内访问偶尔不稳定。如果你遇到⚠️ 价格获取失败优先确认是否能够正常访问https://api.coingecko.com是否触发了请求频率限制是否在 Info.plist 中配置了NSAppTransportSecurity以允许 HTTP如果使用的是 http 接口如果是生产环境建议把数据拉取放到自己的后端服务由后端统一对接行情源前端只负责展示。6.3 滚动文字出现卡顿可能原因Text使用了复杂的字体或阴影效果。刷新频率过高。文本内容过长导致每帧绘制性能下降。解决方向把TimelineView的更新频率从.animation调整为自定义的 60Hz 或 30Hz。将静态不变化的文字拆分成独立视图避免整行重新布局。如果文本特别长考虑用Canvas手动绘制替代 SwiftUI Text。6.4 外接显示器上刘海检测TickerNotch 在非刘海屏上也能显示但效果肯定不如刘海屏惊艳。如果你需要检测当前屏幕是否有刘海可以使用 NSScreen 的辅助方法extension NSScreen { var hasNotch: Bool { if #available(macOS 12.0, *) { return self.safeAreaInsets.top 0 } return false } }注意safeAreaInsets在 macOS 12 中才有并且只有当窗口真正显示在带刘海的屏幕上才能正确获取值。7. 最佳实践与工程建议7.1 在刘海中做文章的有效与克制虽然把信息放进刘海很酷但也要克制。刘海区域本质上是在用户视线边缘增加额外的持续信息流。如果信息过多、滚动过快会造成视觉疲劳。个人建议控制滚动速度单次循环时长不低于 8 秒。内容不要超过两屏长度过长会让人失去阅读耐心。提供暂停/隐藏功能用户需要专注时可以一键关闭。7.2 合理使用系统状态栏菜单栏空间非常宝贵。如果你同时装了很多菜单栏应用空间会非常拥挤。TickerNotch 这类工具应该提供“精简模式”或“固定宽度模式”让用户可以按需调整。7.3 数据来源合规性实时行情数据是一个敏感领域。免费接口可以用于个人学习和 Demo但如果要公开发布或商用必须确认数据供应商的使用条款。申请合法的 API Key。做请求缓存避免高峰期频繁调用被限流。在应用内明示数据来源和延迟。7.4 打包与分发开发完成后打包成独立应用可以使用xcodebuild archive。注意确保发布版本使用 Release 模式。需要签名和公证Notarization否则其他 Mac 打开时会报安全警告。如果只是自己用本地 Archive 导出即可。7.5 架构上预留扩展性TickerNotch 的模式可以扩展到很多场景显示日历提醒和待办事项。拉取 GitHub 仓库 Star 数量。监控服务器负载和告警。显示每日一句或定时提醒。建议把“数据获取”和“滚动展示”彻底解耦。数据层只负责产出文本展示层只负责滚动效果中间通过一个状态对象传递内容。这样后续加数据源就是写一个协议实现的事。8. 从 Demo 到你的第一个菜单栏应用到这里你已经掌握了一个简化版 TickerNotch 的核心实现路径。回顾一下关键步骤创建NSStatusItem作为菜单栏入口。用NSHostingView把 SwiftUI 内容嵌入状态栏。用TimelineViewoffset实现跑马灯滚动效果。用异步循环 URLSession拉取实时行情。用LSUIElement让应用变成纯菜单栏工具。如果接下来想继续深入可以从这几个方向入手给滚动视图增加点击交互点击后展开下拉面板显示完整行情列表。增加自定义配置界面让用户选择数据源、滚动速度、颜色主题。学习SettingsScene 的用法给菜单栏应用添加设置页。尝试用Canvas重写滚动文字获得更精细的帧率控制。写一个菜单栏工具可能是 macOS 开发里最“小”但又最见功力的事之一。代码量不大但涉及生命周期、屏幕适配、实时刷新、性能优化等多个层面。TickerNotch 给了我们一个很好的启发——有时候不是功能不够强而是展示方式不够聪明。把信息放到一个用户每天本来就会看的位置用滚动这种低干扰的方式持续传递这本身就是一种优秀的产品设计。如果你想动手试试建议先直接复制本文的代码跑通基础 Demo然后逐步替换数据源、调整样式。你会发现用自己的代码在刘海区域看到实时滚动的行情时那种“原来这也能做到”的感觉比直接用任何现成工具都来得过瘾。
返回列表