Swift实现摩尔斯电码发送器:从算法到音频播放的完整实践
1. 项目概述用Swift敲出摩尔斯电码最近在整理个人项目时翻到了一个几年前用Swift写的小玩意儿——一个摩尔斯电码发送器。当时写它纯粹是出于对复古通信技术和Swift语言本身的好奇。摩尔斯电码这个诞生于19世纪的电报通信标准用简单的“点”Dot.和“划”Dash-组合来编码字母和数字其简洁和高效至今仍让我着迷。而Swift作为苹果生态下现代、安全且高效的编程语言用它来实现这样一个经典逻辑有种奇妙的时空交错感。这个“Swift Morse Code Sender”项目本质上是一个将用户输入的文本实时转换为摩尔斯电码序列并通过设备的声音、闪光或振动等方式“发送”出去的小工具。它非常适合Swift初学者用来练习字符串处理、状态机、定时器以及系统API如AVFoundation的调用对于有经验的开发者也可以作为一个有趣的载体探索更复杂的并发控制、自定义UI反馈或者与外部硬件如通过蓝牙控制LED的交互。接下来我将详细拆解这个项目的核心思路、实现细节以及我踩过的一些坑希望能为你复现或扩展这个项目提供一份清晰的路线图。2. 核心设计与思路拆解2.1 为什么选择Swift与摩尔斯电码的组合首先从技术选型上聊聊。Swift是苹果平台iOS, macOS, watchOS, tvOS的官方编程语言其语法清晰、安全性高并且与Cocoa/Cocoa Touch框架无缝集成。这意味着我们可以轻松地调用AVFoundation来播放“滴滴答答”的声音用UIKit/SwiftUI构建直观的交互界面或者利用Core Haptic来提供触觉反馈。选择摩尔斯电码作为载体是因为它的规则极其明确是一个绝佳的算法练习场景它只涉及查表字符到电码符号的映射和时序控制点、划、字符间间隔、单词间间隔的时长。这种“明确的规则”加上“丰富的表现形式”让项目既有扎实的编程练习价值又不失趣味性和扩展空间。2.2 项目核心架构与数据流整个发送器的核心工作流可以抽象为一个简单的数据处理管道输入用户输入一段文本例如 “HELLO WORLD”。清洗与标准化将文本转换为大写过滤掉摩尔斯电码标准不支持的特殊字符或者定义它们的替代表示。转换根据摩尔斯电码对照表将每个字符转换为一串由“.”和“-”组成的符号序列字符之间用空格分隔单词之间用特定的分隔符如“/”或更长的空格标识。时序解析将符号序列翻译成一系列具有时间属性的“单元”。例如一个“点”持续1个单位时间time unit一个“划”持续3个单位时间符号内的间隔点与划之间为1个单位时间字符间间隔为3个单位时间单词间间隔为7个单位时间。输出调度与执行根据解析出的时序调度并触发不同的输出动作。例如在“点”或“划”持续的时段内让手机发出声音或屏幕闪光在间隔时段内则保持静默。这个架构的关键在于状态管理和定时精度。我们需要一个状态机来清晰地管理当前是处于“播放信号”还是“静默间隔”状态并且要确保定时器的调度足够准确以免电码节奏紊乱。2.3 工具与框架选型考量对于这个项目我们主要会用到以下几个部分语言与基础框架毫无疑问是Swift。我们将主要使用Swift标准库进行字符串和集合操作。用户界面为了快速原型和更好的可移植性我强烈推荐使用SwiftUI。它的声明式语法让界面构建变得直观并且能轻松适配iOS、macOS等平台。当然如果你对传统的UIKit更熟悉用它也完全没问题。音频反馈使用AVFoundation框架中的AVAudioPlayer或更底层的AudioToolbox来播放简短的“滴”对应点和“答”对应划声音样本。AVAudioPlayer更易于控制音量、循环等属性。视觉反馈可以用SwiftUI的.blink()修饰符自定义、Animation动画来闪烁屏幕特定区域如一个指示灯或者直接改变视图的背景色。触觉反馈对于支持Taptic Engine的设备可以导入Core Haptic来生成短暂的点击震动模拟电报机的触感这能极大提升体验的真实感。定时器使用DispatchQueue配合asyncAfter进行调度或者使用Timer类。对于这种序列化的、离散的事件调度使用DispatchQueue通常更灵活且易于管理能避免Timer在滚动视图等场景下的常见问题。注意在iOS上播放音频记得在Info.plist中添加Privacy - Microphone Usage Description或确保你的音频会话配置正确使用.playback类别否则可能在静音模式下无声。3. 核心细节解析与实操要点3.1 摩尔斯电码的数据建模首先我们需要一个准确、高效的映射表。在Swift中使用字典Dictionary是最自然的选择。let morseCodeDict: [Character: String] [ A: .-, B: -..., C: -.-., D: -.., E: ., F: ..-., G: --., H: ...., I: .., J: .---, K: -.-, L: .-.., M: --, N: -., O: ---, P: .--., Q: --.-, R: .-., S: ..., T: -, U: ..-, V: ...-, W: .--, X: -..-, Y: -.--, Z: --.., 0: -----, 1: .----, 2: ..---, 3: ...--, 4: ....-, 5: ....., 6: -...., 7: --..., 8: ---.., 9: ----., .: .-.-.-, ,: --..--, ?: ..--.., : .----., !: -.-.--, /: -..-., (: -.--., ): -.--.-, : .-..., :: ---..., ;: -.-.-., : -...-, : .-.-., -: -....-, _: ..--.-, \: .-..-., $: ...-..-, : .--.-., : / // 空格代表单词分隔 ]这里有几个细节需要注意字符类型字典的键是Character而不是String因为我们在遍历输入字符串时通常以字符为单位。大小写为了简化查找我们在转换前会将输入字符串统一为大写inputText.uppercased()。空格处理我将空格映射为/这是一个通用的摩尔斯电码表示法用于在最终符号序列中清晰标识单词边界。在后续的时序解析中这个/会被翻译成更长的静默间隔。未定义字符对于字典中不存在的字符如中文需要有处理策略。可以忽略它们也可以用特定的符号如?对应的电码替代并在UI上给用户提示。我通常的做法是过滤掉并在转换日志中注明。3.2 时序系统电码节奏的灵魂摩尔斯电码的可读性完全依赖于精确的时序。国际标准以一个“点”的时长作为基本时间单位T。点Dot 时长 1T划Dash 时长 3T符号内间隔点与划之间的间隔 时长 1T字符内间隔一个字符内最后一个符号与下一个字符第一个符号之间的间隔 时长 3T (通常实践)单词间间隔 时长 7T在我们的代码中需要定义一个时间单位比如100毫秒0.1秒。那么一个“点”播放100毫秒。一个“划”播放300毫秒。播放完一个“点”或“划”后等待100毫秒符号内间隔再播放下一个符号。播放完一个字符的最后一个符号后等待300毫秒字符内间隔再播放下一个字符。遇到单词分隔符“/”时等待700毫秒。实操心得直接使用Thread.sleep或Task.sleep来等待是不可取的它会阻塞主线程导致界面卡死。正确的做法是使用异步队列和调度将每一个“播放”和“等待”任务按顺序排入队列。我们可以用一个DispatchQueue比如一个专用的串行队列来管理整个发送序列确保事件按顺序执行。3.3 音频反馈的实现细节使用AVAudioPlayer需要准备两个简短的音频文件dot.wav短促高频和dash.wav较长低频。在项目中预加载它们import AVFoundation class AudioManager { static let shared AudioManager() private var dotPlayer: AVAudioPlayer? private var dashPlayer: AVAudioPlayer? private init() { dotPlayer loadSound(named: dot) dashPlayer loadSound(named: dash) // 配置音频会话确保在静音模式下也能播放 try? AVAudioSession.sharedInstance().setCategory(.playback, mode: .default) try? AVAudioSession.sharedInstance().setActive(true) } private func loadSound(named filename: String) - AVAudioPlayer? { guard let url Bundle.main.url(forResource: filename, withExtension: wav) else { return nil } return try? AVAudioPlayer(contentsOf: url) } func playDot() { dotPlayer?.currentTime 0 // 每次播放从头开始 dotPlayer?.play() } func playDash() { dashPlayer?.currentTime 0 dashPlayer?.play() } }注意AVAudioPlayer的play()方法是异步的它会立即返回。这意味着我们调用playDot()后不需要等待音频播完就可以立即调度下一个等待或播放任务。音频的时长由音频文件本身控制我们需要确保dot.wav的时长约等于我们定义的“点”的时长如100msdash.wav约等于300ms。4. 实操过程与核心环节实现4.1 构建SwiftUI用户界面界面需要简洁明了一个输入框、一个发送按钮、一个状态指示灯或许再加一个显示转换后电码符号的文本框。import SwiftUI struct ContentView: View { State private var inputText HELLO SWIFT State private var morseSequence State private var isSending false State private var indicatorColor Color.gray let morseSender MorseCodeSender() var body: some View { VStack(spacing: 30) { Text(摩尔斯电码发送器) .font(.largeTitle) .bold() TextField(输入要发送的文本, text: $inputText) .textFieldStyle(RoundedBorderTextFieldStyle()) .padding(.horizontal) .disabled(isSending) // 发送时禁用输入 Button(action: { sendMorseCode() }) { Text(isSending ? 发送中... : 发送) .frame(maxWidth: .infinity) .padding() .background(isSending ? Color.blue.opacity(0.5) : Color.blue) .foregroundColor(.white) .cornerRadius(10) } .padding(.horizontal) .disabled(isSending || inputText.trimmingCharacters(in: .whitespaces).isEmpty) // 状态指示灯 Circle() .fill(indicatorColor) .frame(width: 60, height: 60) .overlay(Circle().stroke(Color.black, lineWidth: 2)) .shadow(radius: isSending ? 10 : 0) .animation(.easeInOut(duration: 0.2), value: indicatorColor) // 显示生成的摩尔斯序列 ScrollView { Text(morseSequence) .font(.system(.body, design: .monospaced)) // 等宽字体更清晰 .padding() .frame(maxWidth: .infWidth, alignment: .leading) .background(Color.gray.opacity(0.1)) .cornerRadius(8) } .padding(.horizontal) Spacer() } .padding() } func sendMorseCode() { // 转换并发送的逻辑 } }4.2 核心发送引擎的实现这是项目的心脏——MorseCodeSender类。它负责转换文本、管理时序和调度任务。import Foundation import AVFoundation class MorseCodeSender { // 1. 定义时间常量 (单位秒) let timeUnit: TimeInterval 0.1 // 100ms let dotDuration: TimeInterval let dashDuration: TimeInterval let intraSymbolPause: TimeInterval // 符号内间隔 let interCharPause: TimeInterval // 字符间间隔 let wordPause: TimeInterval // 单词间间隔 private let audioManager AudioManager.shared private let dispatchQueue DispatchQueue(label: com.yourapp.morssender, qos: .userInitiated) private var currentWorkItem: DispatchWorkItem? init(timeUnit: TimeInterval 0.1) { self.timeUnit timeUnit self.dotDuration timeUnit * 1 self.dashDuration timeUnit * 3 self.intraSymbolPause timeUnit * 1 self.interCharPause timeUnit * 3 self.wordPause timeUnit * 7 } /// 将文本转换为摩尔斯符号序列带空格和/分隔符 func textToMorseSymbols(_ text: String) - String { let uppercased text.uppercased() var morseSymbols for char in uppercased { if let morseChar morseCodeDict[char] { morseSymbols.append(morseChar ) // 字符后加一个空格 } else if char { morseSymbols.append(/ ) // 单词分隔符 } // 其他字符可选择忽略或替换 } return morseSymbols.trimmingCharacters(in: .whitespaces) // 去掉末尾多余空格 } /// 发送摩尔斯符号序列 func send(symbols: String, progressUpdate: ((String) - Void)? nil, completion: (() - Void)? nil) { // 取消任何正在进行的发送任务 cancelSending() let symbolsArray Array(symbols) var currentIndex 0 func scheduleNext() { guard currentIndex symbolsArray.count else { // 所有符号发送完毕 DispatchQueue.main.async { completion?() } return } let symbol symbolsArray[currentIndex] currentIndex 1 let workItem DispatchWorkItem { self.processSymbol(symbol, progressUpdate: progressUpdate) // 递归调度下一个任务 self.dispatchQueue.asyncAfter(deadline: .now() self.getPauseDuration(after: symbol), execute: scheduleNext) } currentWorkItem workItem dispatchQueue.asyncAfter(deadline: .now(), execute: workItem) } // 开始调度 dispatchQueue.async(execute: scheduleNext) } /// 处理单个符号播放声音、更新UI等 private func processSymbol(_ symbol: Character, progressUpdate: ((String) - Void)?) { DispatchQueue.main.async { progressUpdate?(String(symbol)) // 实时更新当前发送的符号 } switch symbol { case .: audioManager.playDot() Thread.sleep(forTimeInterval: dotDuration) // 注意这里在后台线程sleep模拟信号持续时间 case -: audioManager.playDash() Thread.sleep(forTimeInterval: dashDuration) case : // 字符间间隔已在外部调度中处理这里可以什么都不做或触发一个“静默”视觉反馈 break case /: // 单词间间隔同样在外部调度中处理 break default: break } } /// 根据当前符号决定后续的暂停时长 private func getPauseDuration(after symbol: Character) - TimeInterval { switch symbol { case ., -: return intraSymbolPause // 符号播完后的间隔 case : return interCharPause - intraSymbolPause // 字符间间隔已包含一个符号内间隔需减去 case /: return wordPause - intraSymbolPause // 单词间间隔同理 default: return 0 } } /// 取消当前发送任务 func cancelSending() { currentWorkItem?.cancel() currentWorkItem nil } }关键点解析异步调度整个发送过程在一个自定义的串行DispatchQueue中运行不阻塞主线程。递归调度scheduleNext函数递归地调度下一个任务形成了事件链。每个任务执行processSymbol播放声音后会根据getPauseDuration计算出的间隔延迟调度下一个任务。进度反馈通过progressUpdate闭包将当前处理的符号实时回调给UI用于更新指示灯或显示进度。取消功能保存当前的DispatchWorkItem可以在用户点击取消或开始新的发送时取消尚未执行的任务。Thread.sleep的使用在processSymbol中我们使用Thread.sleep来阻塞工作线程模拟“点”或“划”的持续时长。这是可以接受的因为它发生在后台队列不会影响UI响应。音频播放是异步的sleep确保了在声音文件播放的同时工作线程“等待”相应的时长然后再进行下一步调度。4.3 整合界面与逻辑回到ContentView的sendMorseCode方法将其补充完整func sendMorseCode() { guard !isSending else { return } isSending true indicatorColor .gray // 1. 转换文本为摩尔斯序列 let sequence morseSender.textToMorseSymbols(inputText) morseSequence sequence // 在UI上显示 // 2. 开始发送 morseSender.send(symbols: sequence) { [weak self] currentSymbol in // 这个闭包在每次处理一个符号时被调用在主线程 DispatchQueue.main.async { self?.updateIndicator(for: currentSymbol) } } completion: { [weak self] in // 发送完成 DispatchQueue.main.async { self?.isSending false self?.indicatorColor .green // 完成亮绿灯 // 2秒后恢复灰色 DispatchQueue.main.asyncAfter(deadline: .now() 2.0) { self?.indicatorColor .gray } } } } private func updateIndicator(for symbol: String) { switch symbol { case .: indicatorColor .yellow // 可以附加一个短暂的闪烁动画 case -: indicatorColor .orange // 划用更深的颜色表示 case , /: indicatorColor .gray // 间隔时恢复灰色 default: break } }现在一个功能完整的摩尔斯电码发送器就实现了。用户可以输入文本点击发送看到指示灯随着“点”和“划”闪烁并听到对应的声音。5. 常见问题与排查技巧实录在实际开发和测试中我遇到了不少典型问题这里总结一下5.1 音频播放问题问题点击发送按钮后没有声音。排查检查音频文件dot.wav,dash.wav是否已被正确添加到项目靶标的“Copy Bundle Resources”构建阶段中。检查AVAudioSession的类别是否设置为.playback。特别是在iOS上如果类别是.ambient那么静音开关打开时就会没声音。检查设备是否处于静音模式且音频会话类别不支持混合.playback通常可以覆盖静音。在AudioManager的初始化方法中添加打印语句确认音频播放器是否成功加载url是否为nil。问题声音播放有延迟或卡顿特别是快速连续发送时。解决预加载AVAudioPlayer实例正如我们在AudioManager单例中所做而不是每次播放时都重新初始化。AVAudioPlayer的初始化需要时间实时创建会导致延迟。5.2 定时与节奏不准问题电码节奏越来越慢或者间隔时长不一致。排查避免在主线程睡眠确保Thread.sleep只在后台的工作队列中被调用。检查时间累积误差我们的调度模式是“播放 - sleep - 调度下一个(带延迟)”。DispatchQueue.asyncAfter的延迟是相对于当前任务开始调度的时间而不是绝对时间。这种链式调度理论上不会产生累积误差因为每个任务的延迟是独立计算的。但如果processSymbol中的sleep或音频播放的实际时长超过了理论值就会导致后续任务整体后移。确保你的音频文件时长精确匹配dotDuration和dashDuration。使用更精确的定时器对于极高精度要求可以考虑使用CADisplayLink与屏幕刷新率同步或DispatchSourceTimer但对此项目而言DispatchQueue.asyncAfter的精度通常足够。5.3 内存管理与取消逻辑问题快速多次点击“发送”按钮或者发送过程中退出界面可能导致旧的任务仍在后台运行与新任务冲突或访问已释放的UI。解决强引用循环在闭包中捕获self时务必使用[weak self]防止MorseCodeSender或ContentView无法被释放。取消机制MorseCodeSender中的cancelSending()方法至关重要。在ContentView的sendMorseCode方法开始处调用它在ContentView的.onDisappear生命周期方法中也应调用它。状态保护使用isSending状态变量来防止并发发送并在发送期间禁用发送按钮。5.4 UI更新滞后问题指示灯颜色的变化跟不上快速的电码节奏。解决所有UI更新必须在DispatchQueue.main.async中执行。确保从MorseCodeSender回调给progressUpdate闭包的代码以及在闭包内更新State变量的代码都包裹在DispatchQueue.main.async中。SwiftUI的State属性包装器要求在其声明的body计算属性所在的线程主线程进行修改。5.5 扩展功能与优化建议速度调节允许用户动态调整timeUnit例如从50ms到200ms从而改变发送速度。这只需要将MorseCodeSender的初始化参数改为变量并在UI上添加一个滑块Slider来绑定它。输出模式切换除了声音可以增加纯视觉模式快速闪烁屏幕、纯震动模式或者组合模式。这需要抽象一个OutputHandler协议让不同的输出器音频、触觉、视觉去实现。历史记录与回放将发送过的文本和对应的摩尔斯序列保存到UserDefaults或本地数据库并可以随时选择回放。学习模式实现一个反向功能——播放或显示一段摩尔斯电码让用户输入对应的文本用于练习和解码。后台播放配置音频会话为.playback并设置AVAudioSession.sharedInstance().setActive(true, options: .notifyOthersOnDeactivation)可以让音频在后台播放但要注意App的后台模式配置。这个项目虽然不大但涵盖了Swift和iOS/macOS开发中许多核心概念状态管理、异步编程、音频处理、UI绑定。最重要的是它很有趣。当你第一次听到自己编写的程序将“HELLO”翻译成“.... . .-.. .-.. ---”并滴滴答答地播放出来时那种成就感是纯粹的。希望这份详细的拆解能帮助你顺利搭建自己的摩尔斯电码发送器甚至激发你加入更多创意功能。