ARTICLE DETAIL

资讯详情

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

Swift与AppKit实战:打造macOS原生Markdown编辑器

Swift与AppKit实战:打造macOS原生Markdown编辑器 最近在开发一个需要频繁编写技术文档的 macOS 应用时我一直在寻找一款能完美融入系统、写作体验流畅的 Markdown 编辑器。市面上的许多编辑器要么功能臃肿要么界面风格与 macOS 格格不入尤其是“边写边看”的实时预览功能要么延迟高要么预览窗格割裂了写作区域体验总差那么一点。于是我决定自己动手用 100% 纯 Swift 和原生 AppKit 打造一款真正为 macOS 设计的、拥有丝滑行内实时预览的 Markdown 编辑器。本文将完整分享从零到一的开发历程涵盖核心原理、Swift 与 AppKit 实战、性能优化以及最终的打包发布。无论你是想学习 macOS 原生开发还是对构建文本编辑器感兴趣都能从中获得一套可直接复用的代码和清晰的实现思路。1. 项目核心概念与技术选型在开始编码之前我们需要明确两个核心概念以及为什么选择特定的技术栈。1.1 什么是“macOS-first”与“行内实时预览”macOS-first指的不仅仅是软件能在 macOS 上运行更是深度遵循苹果的人机界面指南HIG。这意味着应用应该拥有原生的外观和交互模式例如支持系统级的深色模式、使用标准的菜单栏和快捷键CmdS 保存、CmdZ 撤销、集成系统服务如分享、打印并且能流畅地配合 Mission Control、Split View 等 macOS 特性。一个“macOS-first”的应用会让用户感觉它是系统的一部分而非一个外来客。行内实时预览Inline Live Preview是本文项目的核心亮点。它不同于传统的双栏或分屏预览模式。在传统模式下你在一侧编辑纯文本 Markdown在另一侧查看渲染后的 HTML 效果视线和思维需要在两个区域间频繁切换。而行内预览旨在消除这种割裂感它直接在编辑区域将 Markdown 语法如**粗体**、# 标题实时地替换或叠加为渲染后的样式如加粗的文本、大号标题让你在“所见即所得”的环境中写作同时底层保留完整的 Markdown 源文本以便于后续编辑。这对需要专注内容创作的场景如写博客、记笔记体验提升巨大。1.2 为什么选择 100% Swift 与 AppKitSwift作为苹果主推的现代编程语言Swift 具有安全、快速、表达力强的特点。其值类型、可选类型等特性非常适合构建稳定可靠的应用程序。对于 macOS 原生开发Swift 是首选。AppKit这是 macOS 上构建图形用户界面GUI的原生框架。相比于跨平台框架AppKit 能提供最地道、性能最优的 macOS 体验。它提供了NSTextView这个强大的文本编辑组件这是我们实现编辑器和行内预览功能的基础。拒绝 Catalyst/SwiftUI当前阶段虽然 SwiftUI 是未来趋势但对于需要深度定制文本渲染、处理复杂文本布局的场景AppKit 的NSTextView目前提供了更成熟、更底层的控制能力。本项目聚焦于实现一个专业的编辑器因此选择更稳定、可控的 AppKit。技术栈总结我们将使用 Swift 语言基于 AppKit 框架主要依赖NSTextView和NSLayoutManager等核心类来构建编辑器主体并利用NSAttributedString来实现 Markdown 的样式渲染。2. 开发环境准备在开始写第一行代码前请确保你的环境已就绪。2.1 硬件与操作系统Mac 电脑必须。任何支持最新 Xcode 的 Intel 或 Apple Silicon Mac 均可。macOS 版本建议 macOS 12 (Monterey) 或更高版本。本文示例在 macOS 13 (Ventura) 上开发测试。部分网络热词中提到的“要求 macOS 13.0 或更高版本”的报错提醒我们注意部署目标Deployment Target的设置。2.2 软件与工具Xcode从 Mac App Store 下载并安装最新稳定版本的 Xcode。这是 macOS 开发的唯一官方 IDE。安装后请确保同时安装了命令行工具通常在首次启动时会自动提示安装。Swift 版本随 Xcode 安装的 Swift 版本即可。本文使用 Swift 5.7。Git可选但推荐用于版本控制。可通过终端命令git --version检查是否安装。2.3 创建新项目打开 Xcode选择 “Create a New Xcode Project”。在模板选择界面选择“macOS”-“App”点击 Next。填写项目信息Product Name:MacMarkdownEditor(可自定义)Team: 你的开发者账号或个人团队。Organization Identifier: 你的反向域名如com.yourname。Interface:Storyboard(本项目使用传统 Storyboard 进行界面布局更直观)。Language:Swift。取消勾选 “Use Core Data” 和 “Include Tests”为简化演示。选择项目存储位置点击 Create。至此一个最基本的 macOS 应用项目就创建完成了。3. 核心原理与架构设计实现行内实时预览的编辑器关键在于理解 AppKit 的文本系统。我们不会一次性替换掉 Markdown 符号而是通过自定义文本的显示属性来实现“预览”。3.1 AppKit 文本系统基础NSTextView是显示和编辑文本的控件。其背后有一个NSTextStorage对象负责存储文本内容一个NSAttributedString一个NSLayoutManager对象负责管理文本的布局和显示以及一个NSTextContainer对象定义文本绘制的区域。这三者构成了Text Kit的核心。我们的核心思路是监听文本变化通过NSTextStorage的委托在用户输入或修改文本时立即得到通知。解析 Markdown在内存中对当前的纯文本进行 Markdown 语法解析识别出哪些段落是标题哪些文本需要加粗等。应用视觉属性根据解析结果计算并更新NSTextStorage中相应文本范围的属性如字体、颜色、段落样式从而改变其在NSTextView中的显示外观。保留原始文本至关重要的一点是我们只修改文本的“显示属性”而不删除或修改原始的 Markdown 字符如#,**。这些字符依然存在于存储中光标可以定位到它们用户也可以正常编辑。3.2 项目架构设计我们将采用一个清晰的分层结构视图层 (View)Main.storyboard中的NSTextView以及相关的窗口、按钮。控制器层 (Controller)ViewController.swift负责协调视图和模型处理用户交互。模型/逻辑层 (Model/Logic)MarkdownParser.swift负责将纯文本字符串解析为结构化的数据例如识别出标题、粗体、列表等元素及其位置。TextAttributeRenderer.swift负责将解析出的 Markdown 元素转换为 AppKit 可理解的NSAttributedString属性如NSFont,NSForegroundColorAttributeName并应用到NSTextStorage上。4. 完整实战构建编辑器让我们一步步实现这个编辑器。4.1 构建基础界面打开Main.storyboard。从对象库中拖拽一个NSTextView到默认的NSWindowController的窗口内容区域将其铺满整个窗口。在右侧属性检查器中可以勾选一些选项以提升体验Behavior: 勾选Editable,Selectable。Allows Non-contiguous Layout: 勾选这对于长文档的性能有好处。打开辅助编辑器Assistant Editor将NSTextView与ViewController.swift建立IBOutlet连接命名为textView。// 文件路径MacMarkdownEditor/ViewController.swift import Cocoa class ViewController: NSViewController { IBOutlet var textView: NSTextView! // 我们稍后会添加文本存储的引用和解析器 override func viewDidLoad() { super.viewDidLoad() setupTextView() } private func setupTextView() { // 设置等宽字体方便代码书写 textView.font NSFont.monospacedSystemFont(ofSize: 14, weight: .regular) textView.autoresizingMask [.width, .height] } }4.2 创建 Markdown 解析器这是一个简化的解析器用于演示核心原理。在实际项目中你可能需要集成更强大的库如cmark或Ink。// 文件路径MacMarkdownEditor/MarkdownParser.swift import Foundation struct MarkdownElement { enum ElementType { case heading(level: Int) // 1-6 case bold case italic case codeInline case unorderedList // 可以扩展更多类型... } let type: ElementType let range: NSRange // 在原文中的位置包含Markdown符号 let contentRange: NSRange // 实际内容的位置不包含Markdown符号 } class MarkdownParser { func parse(text: String) - [MarkdownElement] { var elements: [MarkdownElement] [] let nsString text as NSString // 1. 解析标题 (# ## ###) let headingPattern try! NSRegularExpression(pattern: ^(#{1,6})\\s(.)$, options: [.anchorsMatchLines]) headingPattern.enumerateMatches(in: text, options: [], range: NSRange(location: 0, length: nsString.length)) { match, _, _ in guard let match match, match.numberOfRanges 3 else { return } let levelRange match.range(at: 1) let level levelRange.length // #的数量就是级别 let contentRange match.range(at: 2) elements.append(MarkdownElement(type: .heading(level: level), range: match.range, contentRange: contentRange)) } // 2. 解析粗体 (**text** 或 __text__) let boldPattern try! NSRegularExpression(pattern: (\\*\\*|__)(.?)\\1) boldPattern.enumerateMatches(in: text, options: [], range: NSRange(location: 0, length: nsString.length)) { match, _, _ in guard let match match, match.numberOfRanges 3 else { return } let contentRange match.range(at: 2) elements.append(MarkdownElement(type: .bold, range: match.range, contentRange: contentRange)) } // 3. 解析行内代码 (code) let codePattern try! NSRegularExpression(pattern: ([^])) codePattern.enumerateMatches(in: text, options: [], range: NSRange(location: 0, length: nsString.length)) { match, _, _ in guard let match match, match.numberOfRanges 2 else { return } let contentRange match.range(at: 1) elements.append(MarkdownElement(type: .codeInline, range: match.range, contentRange: contentRange)) } return elements } }4.3 创建文本属性渲染器这个类负责将解析出的元素转换为视觉属性。// 文件路径MacMarkdownEditor/TextAttributeRenderer.swift import Cocoa class TextAttributeRenderer { // 定义Markdown样式对应的文本属性 private let headingAttributes: [Int: [NSAttributedString.Key: Any]] [ 1: [.font: NSFont.boldSystemFont(ofSize: 32), .foregroundColor: NSColor.textColor], 2: [.font: NSFont.boldSystemFont(ofSize: 24), .foregroundColor: NSColor.textColor], 3: [.font: NSFont.boldSystemFont(ofSize: 19), .foregroundColor: NSColor.textColor], ] private let boldAttributes: [NSAttributedString.Key: Any] [ .font: NSFont.boldSystemFont(ofSize: NSFont.systemFontSize) ] private let codeAttributes: [NSAttributedString.Key: Any] [ .font: NSFont.monospacedSystemFont(ofSize: NSFont.systemFontSize, weight: .regular), .backgroundColor: NSColor.quaternaryLabelColor, .foregroundColor: NSColor.systemOrange ] func applyAttributes(for elements: [MarkdownElement], to textStorage: NSTextStorage) { // 先清除所有我们可能添加的自定义属性使用一个自定义key来标记 let fullRange NSRange(location: 0, length: textStorage.length) textStorage.removeAttribute(.customMarkdown, range: fullRange) // 遍历所有解析出的元素应用样式到“内容范围” for element in elements { var attributesToApply: [NSAttributedString.Key: Any] [:] switch element.type { case .heading(let level): attributesToApply headingAttributes[level] ?? [:] case .bold: attributesToApply boldAttributes case .codeInline: attributesToApply codeAttributes default: continue } // 关键将样式应用到 contentRange并标记这是我们添加的 attributesToApply[.customMarkdown] true textStorage.addAttributes(attributesToApply, range: element.contentRange) } } } // 定义一个自定义属性键用于标识我们添加的样式 extension NSAttributedString.Key { static let customMarkdown NSAttributedString.Key(CustomMarkdownAttribute) }4.4 在 ViewController 中集成与联动现在我们需要在ViewController中将文本视图、解析器和渲染器连接起来实现文本变化时实时更新预览。// 文件路径MacMarkdownEditor/ViewController.swift (更新版) import Cocoa class ViewController: NSViewController { IBOutlet var textView: NSTextView! private let markdownParser MarkdownParser() private let attributeRenderer TextAttributeRenderer() private var isApplyingAttributes false // 防止递归触发 override func viewDidLoad() { super.viewDidLoad() setupTextView() // 设置文本存储的委托监听文本变化 textView.textStorage?.delegate self // 初始渲染一次 processTextStorageChanged() } private func setupTextView() { textView.font NSFont.monospacedSystemFont(ofSize: 14, weight: .regular) textView.autoresizingMask [.width, .height] // 允许撤销/重做 textView.allowsUndo true } private func processTextStorageChanged() { guard let textStorage textView.textStorage, !isApplyingAttributes else { return } isApplyingAttributes true defer { isApplyingAttributes false } let fullText textStorage.string let elements markdownParser.parse(text: fullText) // 在应用属性前开始一个编辑组保证撤销操作的完整性 textStorage.beginEditing() attributeRenderer.applyAttributes(for: elements, to: textStorage) textStorage.endEditing() } } // 实现 NSTextStorageDelegate 以响应文本变化 extension ViewController: NSTextStorageDelegate { func textStorage(_ textStorage: NSTextStorage, didProcessEditing editedMask: NSTextStorage.EditActions, range editedRange: NSRange, changeInLength delta: Int) { // 当文本内容发生变化时重新解析并渲染 // 为了性能可以在这里做优化例如延迟处理、仅处理受影响的行等。 DispatchQueue.main.async { self.processTextStorageChanged() } } }4.5 运行与验证在 Xcode 中点击运行按钮或按CmdR。应用启动后尝试在编辑器中输入以下 Markdown 文本# 这是一级标题 这是一段普通文本。 **这是粗体文字** 这是包含行内代码的句子。 ## 这是二级标题你应该能立即看到效果“这是一级标题” 会以大号粗体显示。“这是粗体文字” 会加粗显示但**符号依然存在且可编辑。“行内代码” 会有背景色和等宽字体。“这是二级标题” 会以稍小的粗体显示。至此一个具备基础行内实时预览功能的 macOS Markdown 编辑器核心就完成了。5. 性能优化与体验增强上面的基础版本在输入时可能会因为频繁解析全文而导致卡顿尤其是文档很长时。我们需要进行优化。5.1 优化解析与渲染策略延迟处理使用DispatchQueue.main.asyncAfter或Timer在用户停止输入一小段时间如 0.3 秒后再进行解析渲染避免每次击键都触发。增量更新NSTextStorageDelegate提供了editedRange。我们可以只解析和重新渲染受影响的段落或行而不是整个文档。后台解析将耗时的 Markdown 解析工作放到后台线程非主线程完成后再回到主线程更新 UI。// 文件路径MacMarkdownEditor/ViewController.swift (优化版片段) class ViewController: NSViewController { // ... 其他属性 ... private var renderWorkItem: DispatchWorkItem? extension ViewController: NSTextStorageDelegate { func textStorage(_ textStorage: NSTextStorage, didProcessEditing editedMask: NSTextStorage.EditActions, range editedRange: NSRange, changeInLength delta: Int) { // 取消之前未执行的任务 renderWorkItem?.cancel() // 创建新的延迟任务 let workItem DispatchWorkItem { [weak self] in self?.processTextStorageChanged() } renderWorkItem workItem // 延迟 0.3 秒执行如果用户连续输入此任务会被取消重设 DispatchQueue.main.asyncAfter(deadline: .now() 0.3, execute: workItem) } } }5.2 处理光标与选择区域当样式被应用后光标可能会位于被隐藏的 Markdown 符号如**内部这可能导致奇怪的编辑行为。我们需要确保编辑操作如光标移动、删除是基于用户可见的“内容”而非原始符号。这涉及到更复杂的NSLayoutManager和NSTextContainer的定制属于高级话题。一个简单的初步方案是在渲染时尝试将光标位置调整到可见内容区域内。5.3 支持更多 Markdown 语法扩展MarkdownParser和TextAttributeRenderer以支持列表、链接、图片、引用块等。对于复杂元素如代码块可能需要使用NSTextAttachment或自定义的绘制方式。5.4 深色模式适配我们之前使用的NSColor.textColor是系统动态颜色会自动适配浅色/深色模式。确保你定义的所有自定义颜色都使用类似的系统颜色如NSColor.labelColor,NSColor.secondaryLabelColor,NSColor.controlBackgroundColor或者检查NSApp.effectiveAppearance来手动切换颜色方案。6. 常见问题与排查思路在开发过程中你可能会遇到以下问题问题现象可能原因解决思路应用启动崩溃报错NSInvalidArgumentExceptionStoryboard 中IBOutlet连接断开或类名设置错误。1. 检查ViewController.swift中IBOutlet变量名是否与 Storyboard 中的连接一致。2. 检查 Storyboard 中 View Controller 的 “Custom Class” 是否设置为ViewController。输入文本后预览不更新NSTextStorageDelegate未设置或方法未正确触发解析器正则表达式错误。1. 在viewDidLoad中确认textView.textStorage?.delegate self已执行。2. 在textStorage(_:didProcessEditing:)方法内添加打印语句确认其被调用。3. 调试MarkdownParser.parse方法检查正则表达式是否能匹配到测试文本。预览样式错乱如粗体范围不对NSRange计算错误特别是在多字节字符如中文环境下。使用(text as NSString).range(of: substring)或正则表达式时确保基于NSString计算范围因为NSString使用 UTF-16与NSTextStorage兼容。SwiftString的RangeString.Index需要转换。输入时界面卡顿每次文本变化都进行全文解析和属性更新主线程阻塞。实现5.1节的优化策略延迟处理、增量更新、后台解析。使用 Instruments 的 Time Profiler 工具定位耗时函数。无法撤销样式变化在修改NSTextStorage属性时未将其纳入撤销管理器。确保在调用textStorage.beginEditing()和textStorage.endEditing()之间进行属性修改。这会让文本系统将此次修改记录为一次可撤销的操作。打包后在其他 Mac 上无法运行部署目标Deployment Target设置过高而对方系统版本过低。在 Xcode 项目设置中将 “Deployment Target” 设置为一个较低的、兼容的 macOS 版本如 macOS 11。注意使用低版本 SDK 编译时不能调用高版本系统的 API。7. 工程化与最佳实践要将这个 demo 变成一个真正可用的产品还需要考虑以下方面项目结构将MarkdownParser和TextAttributeRenderer等核心逻辑组件化可以考虑用 Swift Package 管理方便单元测试和复用。单元测试为核心逻辑编写单元测试特别是MarkdownParser的解析功能。确保各种边界情况空字符串、嵌套语法、错误语法下行为正确。错误处理正则表达式初始化try!在生产代码中应改为do-catch进行错误处理。解析器应能容忍部分错误语法而不崩溃。本地化与可访问性如果面向国际用户需要将界面文本进行本地化。确保编辑器对 VoiceOver 等辅助功能友好。偏好设置允许用户自定义字体、主题、预览延迟时间等。使用UserDefaults或更强大的设置管理库来存储偏好。文件操作实现标准的NSDocument架构以支持多窗口、文件恢复、版本浏览等 macOS 文档应用的高级特性。发布与分发代码签名与公证使用 Apple Developer 账号对应用进行签名并通过 Apple 的公证Notarization流程确保用户在 Gatekeeper 下能顺利打开。打包使用 Xcode 的 Archive 功能导出.app文件或制作.dmg磁盘映像。分发可以上传至 Mac App Store或通过自己的网站分发。如果上架 App Store需严格遵守其审核指南。开发一个 macOS 原生的 Markdown 编辑器是一次深入理解 AppKit 文本系统、Swift 编程和 macOS 应用设计规范的绝佳实践。从监听文本变化、解析语法到应用属性每一步都涉及到 macOS 开发的核心概念。本文提供的代码和思路是一个坚实的起点你可以在此基础上不断扩展例如集成更完整的 CommonMark 解析器、实现大纲视图、添加导出 HTML/PDF 功能或者探索用 SwiftUI 重构部分 UI 组件。最重要的是享受创造一款与自己工作流完美契合的工具的过程。
返回列表