[SwiftUI 100天] BetterRest · part4

貓克杯發表於2020-03-12
譯自 Connecting SwiftUI to Core ML
更多內容,歡迎關注公眾號 「Swift花園」
收藏是白嫖,點贊才是真愛

連線 SwiftUI 和 CoreML

每當你新增一個 .mlmodel 檔案到 Xcode 的時候,它會自動地建立一個同名的 Swift 類,但是你看不到這個類,也不需要看到 —— 它是編譯過程自動生成的。不過呢,這也意味著,如果你給模型檔案起了一個古怪的名字,那麼那個自動生成的類名字也會一樣古怪。

在我們的案例,我們的檔案叫 “BetterRest 1.mlmodel” ,因此 Xcode 會生成一個叫 BetterRest_1 的 Swift 類。不管你的模型叫什麼名字,現在請把它重新命名為 “SleepCalculator.mlmodel” ,這樣自動生成的類就會叫 SleepCalculator。

我們怎麼確定這一點呢?你可以選中這個檔案,Xcode 會顯示更多資訊。你會看到它知道模型的作者,描述,以及生成的 Swift 類的名字,還正如 SwiftUI 讓 UI 開發變得更簡單一樣,Core ML 讓機器學習變得更簡單了。有多簡單呢?這麼說吧,一旦你完成了模型訓練,只需要兩行程式碼你就能做預測 —— 你只需要傳入數值作為輸出,然後讀取結果就行了。

在我們的案例中,我們已經利用 Xcode 的 Create ML app 建立了一個 Core ML 的模型。你可能已經把它儲存到你的桌面了。現在把這個模型檔案拖到 Xcode 的專案導航器裡 —— 放到 Info.plist 下方就OK 。

有一組輸入的特徵項的列表,對應的型別,以及輸出的型別 —— 這些都在模型檔案中做了編碼。

接下來我們編寫 calculateBedtime()方法。首先,我們需要用到一個 SleepCalculator 類的例項,像這樣:

let model = SleepCalculator()複製程式碼

這個東西負責讀取我們所有的資料,然後輸出預測。我們之前用 CSV 檔案訓練模型時包含下面這些欄位:

  • “wake”: 使用者想要什麼時候起床。這個欄位是以午夜之後的秒數來表示的,所以早上 8 點就是 8 小時乘以 60 再乘以 60 ,也就是 28800 。
  • “estimatedSleep”: 使用者想要的睡覺時長,4 到 12 小時之間,每 15 分鐘為步長。
  • “coffee”: 使用者每天喝的咖啡杯數。

因此,為了用模型預測,我們需要提供上面這些值。

我們已經有兩個了, sleepAmount 和 coffeeAmount 屬性夠用 —— 我們只需要把 coffeeAmount 從整型轉換成 Double 讓編譯器滿意。

但是搞明白起床時間需要費點事,因為我們的 wakeUp 屬性Date而不是代表秒數的Double。萬幸,Swift 的 DateComponents 型別就是為此而生的:它把表示一個日期的所有部分單獨儲存,也就是說,我們可以只讀取小時和分鐘元件,忽略剩下的。然後我們只需要把分鐘乘以 60 (得到秒數),把小時乘以 60 再乘以 60 (也是為了得到秒數)。

我們可以從 Date 中拿到 DateComponents 例項,藉助 . 語法。我們傳入起床日期,請求小時和分鐘元件,拿到的結果是可選型,所以需要解包。

把下面的程式碼放進 calculateBedtime():

let components = Calendar.current.dateComponents([.hour, .minute], from: wakeUp)
let hour = (components.hour ?? 0) * 60 * 60
let minute = (components.minute ?? 0) * 60複製程式碼

如果小時和分鐘不能被讀取的話,我們用 0 代替,不過實際上這不可能發生。

下一步是把特徵值提供給 Core ML 然後看會輸出什麼。如果 Core ML 遭遇問題,這個過程可能會失敗,所以我們需要使用 do 和 catch。老實說,我自己沒遇過預測失敗的情況,不過保險無害!

我們將建立一個 do/catch 塊,在塊裡面呼叫模型的prediction() 方法,它需要起床時間,估計的睡覺時長,喝咖啡杯數,所以的引數都以 Double 值形式提供。我們只需要把小時和分鐘換成秒數,相加然後傳入。

把下面的程式碼新增到 calculateBedtime() :

do {
    let prediction = try model.prediction(wake: Double(hour + minute), estimatedSleep: sleepAmount, coffee: Double(coffeeAmount))

    // more code here
} catch {
    // something went wrong!
}複製程式碼

上述程式碼就位後, prediction 現在包含實際需要的睡覺時長。這個數值幾乎不可能在我們的模型資料中看到,因為它是由 Core ML 演算法動態計算得到的。

但是,這個數值對使用者來說還不是很有用 —— 它是一個秒數。我們需要把它轉換成上床睡覺的時間。因此我們得用期望起床時間減去這個秒數。

歸功於 Apple 強大的 API ,這也是一行程式碼的事。你可以直接從 一個 Date型別裡減去一個秒數值,然後你會拿到一個新的Date! 在預測之後新增這行程式碼:

let sleepTime = wakeUp - prediction.actualSleep複製程式碼

現在我們知道使用者需要上床睡覺的準確時間了。我們最後的挑戰是把這個時間展示給使用者。我麼將通過一個 alert 來實現。

先新增三個屬性來確定 alert 的標題,訊息以及是否展示:

@State private var alertTitle = ""
@State private var alertMessage = ""
@State private var showingAlert = false複製程式碼

在 calculateBedtime() 中可以用上這些屬性。如果你的計算出錯了 —— 預測丟擲錯誤 —— 我們可以把// something went wrong 註釋換成下面的程式碼:

alertTitle = "錯誤"
alertMessage = "抱歉,計算就寢時間時出錯了"複製程式碼

不管預測是否成功,我們都應該展示 alert ,它可能包含預測結果或者是錯誤訊息,但都是有用的。所以在 calculateBedtime()的 catch 塊之後,新增這行程式碼:

showingAlert = true複製程式碼

接下來是挑戰的部分:如果預測成功,我們建立一個包含使用者需要上床睡覺的時間的常量,叫 sleepTime。但它是一個 Date ,並不是一個格式化的字串沒所以我們需要用 Swift 的DateFormatter來改善它。

DateFormatter 可以通過它的 dateStyle and timeStyle屬性以各種樣式格式化日期。在我們的案例中,我們只想要一個時間字串,以便放進 alertMessage。

把下面這些程式碼放在 calculateBedtime()的最後,即設定 sleepTime 常量之後:

let formatter = DateFormatter()
formatter.timeStyle = .short

alertMessage = formatter.string(from: sleepTime)
alertTitle = "你的理想就寢時間是…"複製程式碼

最後,我們需要新增一個 alert() modifier ,在showingAlert 變為 true 時展示 alert 。

給 VStack新增下面的 modifier :

.alert(isPresented: $showingAlert) {
    Alert(title: Text(alertTitle), message: Text(alertMessage), dismissButton: .default(Text("OK")))
}複製程式碼

執行 app —— 雖然外觀還不怎麼樣,不過工作正常。

整理 UI

雖然 app 已經工作了,不過它還不是那種你想上架到 App Store 的東西 —— 它至少有一個可用性上的問題,設計上也不夠標準。

首先來看一下可用性問題,因為你之前可能沒有遇到過這種問題。當你建立一個新的 Date 例項時,它會被自動設定到當前日期和時間。因此,當我們用新建的 date 建立 wakeUp 屬性時,預設的起床時間就被設定成當前時間了,不管它是什麼時間。

雖然這個 app 需要能夠處理各種時間 —— 我們並不希望排除上夜班的人 —— 但我認為起床時間預設在早上 6 點到 早上 8 點之間的人應該對大多數使用者更有用。

為了解決這個問題,我們需要在 ContentView 結構體中加一個計算屬性,它包含一個當天早上 7 點的 Date 值。 我們只要建立一個新的 DateComponents ,然後用Calendar.current.date(from:) 把元件轉換成完整的日期。

把下面的程式碼新增到 ContentView

var defaultWakeTime: Date {
    var components = DateComponents()
    components.hour = 7
    components.minute = 0
    return Calendar.current.date(from: components) ?? Date()
}複製程式碼

於是我們就可以用它來作為 wakeUp 的預設值:

@State private var wakeUp = defaultWakeTime複製程式碼

如果你嘗試編譯上面的程式碼,編譯將會失敗。原因在於我們正在一個屬性中訪問另一個屬性 —— Swift 並不知道屬性建立的順序,因此它不允許這種操作。

修復方案很簡單,我們只要讓 defaultWakeTime 稱為一個靜態變數,意味著它屬於 ContentView 結構體本身而不是任何一個結構體例項。這樣我們就可以任何時候讀取 defaultWakeTime ,因為它並不依賴任何其他屬性的存在。

把屬性定義改成這樣:

static var defaultWakeTime: Date {複製程式碼

上面的修改就解決了可用性問題,因為大部分使用者會發現預設起床時間和他們想要選擇的時間很接近。

至於我們的外觀樣式,需要做的努力更多。一個簡單的改動是,切換到 Form 而不是VStack,找到這裡:

NavigationView {
    VStack {複製程式碼

替換成:

NavigationView {
    Form {複製程式碼

這個簡單的動作立刻就能改善 UI —— 我們得到了一個清晰分段的表單,而不是各種控制元件居中在白色空間。

當我們切換到Form 時, DatePicker 的樣式跟原來的不同。如果你更喜歡原來的樣式,可以通過 .datePickerStyle(WheelDatePickerStyle()) modifier 用回原來的樣式。

修改程式碼讓 DatePicker 用回原來的樣式:

DatePicker("請輸入一個時間", selection: $wakeUp, displayedComponents: .hourAndMinute)
    .labelsHidden()
    .datePickerStyle(WheelDatePickerStyle())複製程式碼

提示:滾盤樣式的只在 iOS 和 watchOS 上可用,所以如果你打算寫的 SwiftUI 程式碼是給 macOS 或者 tvOS 用的話,要避免用上面的樣式。

表單裡還有一個惱人的地方:表單裡每個檢視都被當做一行來對待,這樣文字檢視就和相同邏輯的檢視分開了。

我們可以用 Section 檢視,其中用文字檢視作為標題 —— 你可以自己動手試試。不過,這裡可以直接利用 VStack 把成對的文字檢視和輸入控制元件組合到一起。

三組控制元件都用 VStack 包起來,並且用 .leading對齊,0 作為間距:比如,把下面這兩個檢視:

Text("要求的睡覺時長")
    .font(.headline)

Stepper(value: $sleepAmount, in: 4...12, step: 0.25) {
    Text("\(sleepAmount, specifier: "%g") hours")
}複製程式碼

包進 VStack ,像這樣:

VStack(alignment: .leading, spacing: 0) {
    Text("要求的睡眠時長")
        .font(.headline)

    Stepper(value: $sleepAmount, in: 4...12, step: 0.25) {
        Text("\(sleepAmount, specifier: "%g") hours")
    }
}複製程式碼

再次執行 app ,這就完工了。幹得漂亮!

我的公眾號 這裡有Swift及計算機程式設計的相關文章,以及優秀國外文章翻譯,歡迎關注~

[SwiftUI 100天] BetterRest · part4


相關文章