RingtoneHY 项目详解(全)

Hello, 欢迎登录 or 注册!

/ 1评 / 1

本文作者:  本文分类:Apple开发  浏览:138
阅读时间:7280字, 约8-12分钟

RingtoneHY V2 项目详解

1 前言

RingtoneHY V2 项目开源在 GitHub 仓库: AFObject/RingtoneHY,前身是 2020 年疫情期间的初版,在 2022 年网课期间进行了完全重构,是一款 macOS 原生 App,用于在网课期间提供模拟校园铃声的功能。

前有 C+++ 对抗 ClickIDE,后有 RingtoneHY 对抗 HYRing,好啊!!1

本项目的开发经历了一个完整的流程,在本文中进行详细的阐述。


(图 1.1 社团课演示文稿截图)

因为是小程序,整个的开发路子基本都是按照我之前总结的这个来,没有大的改动。


2 立项

究其原因是:

  1. 班主任及多名任课教师在晨会 / 课上提到相关内容;
  2. 懒得在 iPad 上设闹钟;
  3. 秀 / 锻炼一下自己的码力。

有了 20 年 V1 版本的经验,在开发之前将本次开发的主要内容确定为:


3 模型搭建

Code: RingtoneHY/TaskStorage.swift

Reference:

  1. StackOverflow 24217586

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 行的精髓。接下来就要考虑的是细枝末节的东西:如何将数据变成系统可读的格式保存在本地?如何快速地新建一个结构体?如何配置默认日程表?……

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
}
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
}
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 主界面与铃声报时

Code: RingtoneHY/ViewController.swift

这是整个 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,即使简单但很有意义。不过这大概也是初中能写的最后一个小项目了,之后也没什么时间,希望能记住这次开发带来的宝贵经验吧。

如果全文含有表述不准确或者明显错误的内容,请务必指出!

关于作者

本作品采用 知识共享署名-非商业性使用 3.0 (CC BY-NC 3.0) 许可协议进行许可。
  1. Tianheng Ni说道:

    即使我的文章没丢,你写得当然也比我这只鸽子快(恼怒
    以后代码建议用区块里的enlighter

发表评论

您的电子邮箱地址不会被公开。