本文作者:23 786
本文分类:Apple开发 浏览:1328
阅读时间:7280字, 约8-12分钟
RingtoneHY V2 项目详解
1 前言
RingtoneHY V2 项目开源在 GitHub 仓库: AFObject/RingtoneHY,前身是 2020 年疫情期间的初版,在 2022 年网课期间进行了完全重构,是一款 macOS 原生 App,用于在网课期间提供模拟校园铃声的功能。
前有 C+++ 对抗 ClickIDE,后有 RingtoneHY 对抗 HYRing,好啊!!1
本项目的开发经历了一个完整的流程,在本文中进行详细的阐述。
(图 1.1 社团课演示文稿截图)
因为是小程序,整个的开发路子基本都是按照我之前总结的这个来,没有大的改动。
2 立项
究其原因是:
- 班主任及多名任课教师在晨会 / 课上提到相关内容;
- 懒得在 iPad 上设闹钟;
- 秀 / 锻炼一下自己的码力。
有了 20 年 V1 版本的经验,在开发之前将本次开发的主要内容确定为:
- 增强 UI 界面的美观性、易用性;
- 动态编辑铃声的功能;
- 在实现功能的同时试试看简化、优化代码,至少让我自己满意。
3 模型搭建
Code: RingtoneHY/TaskStorage.swift
Reference:
3.1 模型的基本维护
因为早就想好了,上来就直接写数据结构。
我们在这个 App 里需要维护的是什么?(一个星期中)【(每一天的)【(每一节课 / 任务的)【(开始、结束时间的)【时、分、秒和铃声】】】】。
上面这一句话的排版有点乱,但已经很明确了——对于每个加粗的部分,都写一个结构体来保存数据。
struct Time { // 一个响铃时间点
var hour: Int
var minute: Int
var music: Int
}
struct Task { // 一天
var start: Time
var end: Time
var name: String
}
struct TaskList { // 一天的任务列表
var tasks: [Task]
}
struct TaskStorage { // 一周七天,七个任务列表的集合
var lists: [TaskList]
}
这是整个 TaskStorage.swift
150 行的精髓。接下来就要考虑的是细枝末节的东西:如何将数据变成系统可读的格式保存在本地?如何快速地新建一个结构体?如何配置默认日程表?……
- Q1:如何将数据变成系统可读的格式保存在本地?
- A1:对于每个结构体,使其遵守
Codable
协议。使用JSONEncoder
/JSONDecoder
来解 / 编码数据。最后写几个方便操作的结构体内函数。
var data: Data {
return try! JSONEncoder().encode(self)
}
static func from(data: Data?) -> TaskStorage {
if let data = data {
do {
return try JSONDecoder().decode(TaskStorage.self, from: data)
} catch {
print(error)
}
}
return .default
}
- Q2:如何快速地新建一个结构体?
- A2:在每个结构体内写好方便的构造函数。例如下面这段,输入一个五位数字串,自动转换成一个时间点的结构体。
init(_ string: String) {
hour = Int(string.prefix(2)) ?? 0
minute = Int((string as NSString).substring(with: NSMakeRange(2, 2))) ?? 0
music = Int(string.suffix(1)) ?? 0
}
- Q3:如何配置默认日程表?
- A3:在
TaskStorage
内写一个常量即可。例如:
static var `default`: TaskStorage {
let ver1 = TaskList(tasks: [
Task("07401", "07450", " 点名 "),
Task("08001", "08402", " 第 1 节课 "), // trimmed
Task("16051", "16452", " 第 9 节课 ")
])
let ver2 = TaskList(tasks: [
Task("07401", "07450", " 点名 "),
Task("08001", "08402", " 第 1 节课 "), // trimmed
Task("15451", "16252", " 第 9 节课 ")
])
return TaskStorage(lists: [.empty, .empty, ver1, ver1, ver1, ver1, ver2, .empty])
}
3.2 重中之重:为什么不用面向对象?
注意我在上文中自始至终贯穿了「结构体」一词。为什么我不把 struct
换成 class
?
温习一遍:struct
是值,而 class
是引用。
When you make a copy of a value type, it copies all the data from the thing you are copying into the new variable. They are 2 separate things and changing one does not affect the other. [1]
我要维护的东西,再简单不过,就是几个时间点罢了。请问这些时间点何德何能逼着我去用引用类型来维护它?想象一下,我要把周二的课程表拷贝到周一的课程表上面去,我所希望的,仅是它们两个是值相同的不同的两个结构体,而不是什么别的。
用一个形象的比喻:Class 是活物,它可以灵活变通,有生有死也有变化;Struct 是死物,它只能长那样,我虽然可以改变它的值,但它永远只包含这些值,没有生命。可不希望这些课程表亦或是时间点的结构体变成活的,然后在运行的时候跳出来干些预料之外的事情!
4 主界面与铃声报时
这是整个 App 的核心功能,也是最简单最易实现的,因为有去年的基础。
当然得有 ViewController(这回用了系统的 Visual Effect 模糊背景),还得有 Storyboard。
算法:每天开始运行时,或是更改设置时,从保存的日程表中拉取数据,把所有的时间节点排好序放进一个队列里,每 2 秒(Timer
)获取一次系统时间(可以自己写一些东西方便实现),到时间了就响铃(AVFoundation
库),并把队首弹出。
let timer = Timer(timeInterval: 2.0, target: self, selector: #selector(updateTime), userInfo: nil, repeats: true)
RunLoop.current.add(timer, forMode: .default)
private var queue: [(Time, String, Bool)] = []
private func initializeQueue() {
storedWeekday = DateManager.weekday
let list = TaskStorage.shared.lists[DateManager.weekday]
queue = []
for i in list.tasks {
if i.start.music > 0 && i.start > .now {
queue.append((i.start, i.name, true))
}
if i.end.music > 0 && i.end > .now {
queue.append((i.end, i.name, false))
}
}
queue.sort(by: { lhs, rhs in
lhs.0 < rhs.0
})
}
@objc func updateTime() {
dateLabel.stringValue = "\(DateManager.month) 月 \(DateManager.day) 日 / 周 \(String.weekdayName(of: DateManager.weekday))"
timeLabel.stringValue = Time.now.timeString
if DateManager.weekday != storedWeekday {
initializeQueue()
}
while queue.count > 0 && queue.first!.0 < .now {
queue.removeFirst()
}
if let current = queue.first {
if Time.timeEqual(current.0, .now) {
AVAudioPlayer.ring(type: current.0.music)
queue.removeFirst()
}
}
if let current = queue.first {
taskLabel.stringValue = "下一项:\(current.0.timeString) \(current.1)\(current.2 ? "开始" : "结束")"
} else {
taskLabel.stringValue = "今日无其他日程"
}
}
5 设置界面:SwiftUI 的正片开始!
设置界面,狗都不用 AppKit
中的控件!从现在开始,使用 SwiftUI。
Code:
5.1 AppKit 与 SwiftUI 的衔接
老样子,用 NSHostingController
。保存设置后让设置界面消失是当时的一个难点,这种问题直接在更高一层的 ViewController
中宏观控制就行了(否则为啥叫它 Controller
)。
@IBAction func showSettings(_ sender: Any?) {
settingsController = NSHostingController(rootView: SettingsView(onCompletion: {
self.settingsController?.dismiss(nil)
self.initializeQueue()
}))
self.presentAsSheet(settingsController!)
}
5.2 SwiftUI 界面结构的搭建
老样子,你之前数据结构部分怎么套娃,现在也怎么套。接下来贴出的代码都是核心省略版。
import SwiftUI
struct SettingsView: View {
var onCompletion: () -> () = {} // 配合 5.1 中的衔接部分
@State private var taskStorage: TaskStorage = .default
var body: some View {
VStack {
ScrollView {
Form {
ForEach(1...7, id: \.self) { i in
Section {
Text(verbatim: "周" + .weekdayName(of: i))
.font(.title)
TaskListView(taskList: $taskStorage.lists[i]) // 套娃!
}
Divider()
}
}.padding()
}.frame(width: 540, height: 360)
.onAppear {
taskStorage = .shared
}
.onChange(of: taskStorage) { newValue in
UserDefaults.standard.set(taskStorage.data, forKey: .storageKey)
} // 重要!保存数据至本地。
}
}
}
import SwiftUI
struct TaskView: View {
@Binding var task: Task
var body: some View {
VStack(alignment: .leading) { // ... trimmed
}.sheet(isPresented: $editing) { // 套娃!
TaskEditView(task: $task)
}
}
}
struct TimeEditView: View { // 底层套娃
var label: String
@Binding var time: Time
var body: some View {
HStack {
DatePicker(label, selection:
Binding(get: {
Calendar.current.date(from: DateComponents(hour: time.hour, minute: time.minute))!
}, set: { date in
let components = Calendar.current.dateComponents([.hour, .minute], from: date)
time.hour = components.hour!
time.minute = components.minute!
}), displayedComponents: .hourAndMinute
) // DatePicker 编辑时间
Picker("铃声", selection: $time.music) {
ForEach(0...3, id: \.self) { i in
Text(verbatim: .audioName(of: i)).tag(i)
}
} // 普通 Picker 编辑铃声
}
}
}
struct TaskEditView: View {
@Binding var task: Task
@Environment(\.presentationMode) var presentationMode
var body: some View {
VStack {
TextField("任务名称", text: $task.name)
TimeEditView(label: "起始时间", time: $task.start) // 套娃!
TimeEditView(label: "结束时间", time: $task.end) // 套娃!
Spacer()
HStack {
Spacer()
Button("完成") {
presentationMode.wrappedValue.dismiss()
}
}
}.frame(width: 300, height: 120).padding()
}
}
struct TaskListView: View, TaskViewManager {
@Binding var taskList: TaskList
@State private var newTask: Task = Task("08001", "08402", "新任务")
@State private var editing = false
let rows = Array(repeating: GridItem(), count: 3)
var body: some View {
LazyVGrid(columns: rows) {
ForEach($taskList.tasks) { i in
TaskView(task: i, manager: self) // 套娃!
}
Button(action: addItem) // ... trimmed
}.frame(width: 500, alignment: .leading)
.sheet(isPresented: $editing, onDismiss: {
taskList.tasks.append(newTask)
newTask = Task("08001", "08402", " 新任务 ")
}) {
TaskEditView(task: $newTask) // 新建任务时的套娃!
}
}
func addItem() {
editing = true
}
}
以上的代码即可完成编辑界面的 UI 基本功能。可以看出,SwiftUI 的一大特点就是通过套娃来节省代码量(别的地方也挺常见的),你只需要良好地完成单一界面的一个功能,并设置一个接口(例如支持同步修改的 @Binding var ...
)就可以在别的任意地方调用它。
被省略的代码中还有一部分实现了任务的删除操作。与添加操作不同的是,任务的删除操作是针对单个任务的,它的按钮也放在单个任务的显示控件内。所以我另外写了一个协议(TaskManager
),让它可以通知它的 Superview(不知表述是否准确)来控制它。一样的思路,还是想方设法把控制权交给上一级试图。
此外,SwiftUI 中的 onChange(of:perform:)
也是相当重要的一个功能。因为前文中我们使用了结构体,所以就可以放心大胆的使用它了,只要值有修改,立刻就会触发它!
6 测试与发布
本项目的功能主体开发用时 3 天左右,测试与发布前准备用时也是 3 天左右,也可见其重要性。
首先就是自己个人的使用了。用了一天,主要是两个问题:一个是不断需要鼠标去点才能看到时间,所以我补了自动置顶的功能;还有一个是长的音频不能自动停止,所以我补了点击窗口停止铃声的功能。
在完成全部功能并在本机(macOS 12 Monterey)上测试完运行良好后,我修改了部分代码使其支持 macOS 11 Big Sur(这时还跳了坑,有些 SwiftUI 的东西在两个平台上实现得完全不一样),并在家里的旧电脑上测试成功。这个时候才能不动代码,开始发布前期的准备工作。
发布前的准备工作主要有两项:一是 App Icon 的设计;二是用户手册的编写与打包。
前者,在 Apple Developer 官网上就可以得到设计资源与设计建议。去年的图标我是网上嫖的,这次我照着去年的样子,自己用 PS 重新画了一遍,添加了阴影等细节,算是半个原创吧(笑)。至于用户手册的编写,用尽量书面化的语言、尽量完备地讲全软件的使用方式和 FAQ 即可,我选择了用 LaTeX 编辑。
耗时一共一个礼拜不到,显然算是极小的项目;但积累的经验应该是不少的。最终发布也没有引起什么波澜,方便了自己和一小部分人。
7 总结
本文写于 6 月 11 日至 12 日凌晨,感谢 macOS 13 的台前调度能让我专注地赶在 nth 之前写完这个东西(不是)。
用上我之前复活纪念的那篇文章的话,这可能是我第一个从头到尾自己做的、良好运行的 App,即使简单但很有意义。不过这大概也是初中能写的最后一个小项目了,之后也没什么时间,希望能记住这次开发带来的宝贵经验吧。
如果全文含有表述不准确或者明显错误的内容,请务必指出!
关于作者23 786
- 好难啊我有点看不懂我也是六年级啊不过我有亲身体会,这种题目要自己做 要不然还是会不懂的要不你去 问问老师父母或同学
- Email: yixuanzhuzhuzhu123@163.com
- 注册于: 2020-04-07 01:52:33
即使我的文章没丢,你写得当然也比我这只鸽子快(恼怒
以后代码建议用区块里的enlighter