動手做一支 Apple Watch App 吧!(Swift)

ZhgChgLi
ZRealm Dev.
Published in
32 min readFeb 5, 2019

--

watchOS 5 手把手開發Apple Watch App 從無到有

[最新] Apple Watch Series 6 開箱&使用兩年體驗心得 >>>點我前往

前言:

暨上一篇 Apple Watch 入手開箱文 後已經過了快三個月,最近終於找到機會研究開發Apple Watch App啦。

結婚吧 — 最大婚禮籌備App

補一下使用三個月後的心得:
1. e-sim(LTE)依然還想不到什麼時候會用到,所以也還沒申請沒用過
2.常用功能:靠近解鎖Mac電腦、舉手查看通知、Apple Pay
3.健康提醒:過了三個月已開始懶了,通知提醒都看看,沒達成圓圈也無感
4.第三方App支援度依然很差
5.錶面可依照心情任意更換增加新鮮感
6.更詳細的運動紀錄:例如走遠一點路去買晚餐,手錶會自動偵測詢問是否要記錄運動

使用三個月後整體來說,還是如原開箱文所寫就像是多個生活小助手,幫你解決瑣碎的事.

第三方App支援度依然很差

在我實際開發過Apple Watch App之前還很納悶,為何Apple Watch上的App都很陽春甚至就只是「堪用」罷了,包括LINE(訊息不同步而且從未更新)、Messenger(就是堪用);直到我實際開發過Apple Watch App之後才知道這些開發者的苦衷….

首先,了解Apple Watch App的定位,化繁為簡

Apple Watch的定位「不是取代iPhone,而是輔助」不論是官方介紹、官方App、watchOS API都是這個走向;所以才會覺得第三方APP很陽春、功能很少(抱歉,我太貪心了Orz)

我們的App為例,有搜尋商家、查看專欄、討論區、線上詢問…等等功能;線上詢問就是有價值搬上Apple Watch的項目,因為他需要即時性而且更快速的回覆代表更有機會獲得訂單;搜尋商家、查看專欄、討論區這些功能相對複雜,在手錶上就算做的到也意義不大(螢幕能呈現的資訊太少、也不需要即時性)

核心概念還是「以輔助為主」,所以並不是什麼功能都需要搬上Apple Watch;畢竟使用者很少很少時間會是只有戴手錶沒帶手機,而遇到這種情況時,使用者的需求也只有重要的功能(像查看專欄文章這種沒有重要到一定要立刻馬上用手錶看)

讓我們開始吧!

這也是我第一次開發Apple Watch App,文章內容可能不夠深入,敬請大家指教!!
本篇只適合有開發過iOS App/UIKit基礎的讀者閱讀
本篇使用:iOS ≥ 9、watchOS ≥ 5

為iOS專案新建 watchOS Target:

File -> New -> Target -> watchOS -> WatchKit App

*Apple Watch App無法獨立安裝,一定要依附在 iOS App 之下

新建好之後目錄會長這樣:

你會發現有兩個Target項目,缺一不可:

  1. WatchKit App: 負責存放資源、UI顯示
    /Interface.storyboard:同 iOS,裡面有系統預設建立的視圖控制器
    /Assets.xcassets:同 iOS,存放用到的資源項目
    /info.plist:同 iOS,WatchKit App 相關設定
  2. WatchKit Extension: 負責程式呼叫、邏輯處理(*.swift)
    /InterfaceController.swift:預設的視圖控制器程式
    /ExtensionDelegate.swift:類似Swift的AppDelegate,Apple Watch App 啟動入口
    /NotificationController.swift:用於處理Apple Watch App上的推播顯示
    /Assets.xcassets:這裡不使用,我統一放在WatchKit App的Assets.xcassets下
    /info.plist:同 iOS,WatchKit Extension 相關設定
    /PushNotificationPayload.apns:推播資料,可用在模擬器上測試推播功能

細節會在後面做介紹,先大概了解一下目錄及文件內容功能即可。

視圖控制器:

在AppleWatch中視圖控制器不叫ViewController而是InterfaceController,你可以在WatchKit App/Interface.storyboard中找到Interface Controller Scence,控制它的程式就放在WatchKit Extension/InterfaceController.swift中(同iOS概念)

Scene預設會和Notification Controller Scene擠在一起 (我會把它拉上面一點分開)

可在右方設定InterfaceController的標題顯示文字.

標題顏色部分吃的是Interface Builder Document/Global hint設定,整個App的風格顏色會是統一的.

元件庫:

沒有太多複雜的元件,元件功能也都簡單明瞭

UI 排版:

萬丈高樓從View起,排版的部分沒有 UIKit(iOS) 中的Auto Layout、約束、圖層,全都使用參數進行排版設置,更簡單有力(排起來有點像 UIKit 中的 UIStackView)

一切排版由Group組成,類似UIKit中的 UIStackView 但能設置更多排版參數

Group的參數設置
  1. Layout:設置被包在裡面的子View排版方式(水平、垂直、圖層堆疊)
  2. Insets:設置Group的上下左右間距
  3. Spacing:設置被包在裡面的子View之間的間距
  4. Radius:設置Group的圓角,沒錯!WatchKit自帶圓角設置參數
  5. Alignment/Horizontal:設置水平對齊方式(左、中、右)與鄰居、外層包覆的View設置會有所連動
  6. Alignment/Vertical:設置垂直對齊方式(上、中、下)與鄰居、外層包覆的View設置會有所連動
  7. Size/Width:設置Group的大小,有三種模式可選「Fixed:指定寬度」、「Size To Fit Content:依照內容子View大小決定寬度」、「Relative to Container:參照外層包覆的View大小為寬度(可設%/+-修正值)」
  8. Size/Height:同Size/Width,此項是設置高度

字型/字體大小設置:

可直接套用系統的Text Styles,或使用Custom(但這邊我測試使用Custom無法設定字體大小);所以我是使用System自訂各顯示Label的字體大小

做中學:以Line排版為例

排版部分不像 iOS 那麼複雜,所以我直接透過範例示範給大家看,就能直接上手;以 Line 的主頁排版為例子:

在WatchKit App/Interface.storyboard中找到Interface Controller Scence:

1.整個頁面,相當於 iOS App 開發中會使用到的 UITableView,在Apple Watch App 中簡化了操作,名字也改叫做「WKInterfaceTable」
首先就先拉一個Table到Interface Controller Scence中

同UIKit UITableView,有Table本體、有Cell(Apple Watch中叫做Row);使用起來簡化許多,你可以直接在此介面上進行Cell的設計排版!

2. 分析排版架構,設計Row顯示樣式:

要排出一個左邊有圓角滿版的Image且堆疊一個Label,右邊平均分配上下兩個區塊,上方放Label,下方也放Label的區塊

2–1: 拉出左右兩區塊的架構

拉兩個Group到Group中,並對Size參數分別設定:

左邊綠色部分:

Layout設定Overlap,裡面子View要做未讀訊息Label的圖層堆疊顯示
設固定長寬40的正方形

右邊紅色部分:

Layout設定Vertical,裡面子View要做上下兩個顯示
寬度設定參照外層,比例100%,扣掉左邊綠色部分40

左右容器內排版:

左邊部分:拉入一個Image,再拉入一個包覆Lable的Group對齊設右下(Group設底色再設間距及圓角)

右邊部分:拉入兩個Label,一個對齊設左上,一個對齊設左下即可

為Row命名(同UIKit UITableView為Cell設定identifier):

選定Row->Identifier->輸入自訂名稱

Row的呈現樣式不只一種呢?

非常簡單,只要在拉一個Row放在Table裡(實際要顯示哪個樣式的ROW由程式控制)並輸入Identifier命名即可

這邊我再拉一個Row用於呈現無資料時的提示

排版相關資訊

watchKit的hidden不會佔位,可拿來做交互應用(有登入才顯示Table;沒登入顯示提示Label)

排版到此告一段落,可依照個人設計做修改;上手容易,多排個幾次、玩玩對齊參數,就能熟悉!

程式控制部分:

接續Row,我們需要建立一個Class對Row進行參照操作:

class ContactRow:NSObject {
}
class ContactRow:NSObject {
var id:String?
@IBOutlet var unReadGroup: WKInterfaceGroup!
@IBOutlet var unReadLabel: WKInterfaceLabel!
@IBOutlet weak var imageView: WKInterfaceImage!
@IBOutlet weak var nameLabel: WKInterfaceLabel!
@IBOutlet weak var timeLabel: WKInterfaceLabel!
}

拉outlet、儲存變數

Table部分ㄧ樣拉Outlet到Controller中:

class InterfaceController: WKInterfaceController {

@IBOutlet weak var Table: WKInterfaceTable!
override func awake(withContext context: Any?) {
super.awake(withContext: context)

// Configure interface objects here.
}

override func willActivate() {
// This method is called when watch view controller is about to be visible to user
super.willActivate()
}

struct ContactStruct {
var name:String
var image:String
var time:String
}

func loadData() {
//Get API Call Back...
//postData {
let data:[ContactStruct] = [] //api returned data...

self.Table.setNumberOfRows(data.count, withRowType: "ContactRow")
//如果你有多種ROW需要呈現則用:
//self.Table.setRowTypes(["ContactRow","ContactRow2","ContactRow3"])
//
for item in data.enumerated() {
if let row = self.Table.rowController(at: item.offset) as? ContactRow {
row.nameLabel.setText(item.element.name)
//assign value to lable/image......
}
}

//}
}

override func didDeactivate() {
// This method is called when watch view controller is no longer visible
super.didDeactivate()
loadData()
}

//處理Row點選時:
override func table(_ table: WKInterfaceTable, didSelectRowAt rowIndex: Int) {
guard let row = table.rowController(at: rowIndex) as? ContactRow,let id = row.id else {
return
}
self.pushController(withName: "showDetail", context: id)
}
}

Table的操作簡化許多沒有delegate/datasource,設定資料方式只要呼叫setNumberOfRows/setRowTypes指定Row數量和形態,再使用rowController(at:) 設定每列的資料內容即可!

Table的Row選擇事件也只需 override func table(_ table: WKInterfaceTable, didSelectRowAt rowIndex: Int) 即可操作!(Table也只有這個事件)

如何跳頁?

首先為Interface Controller設定Identifier

watchKit有兩種跳頁模式:

1.類似iOS UIKit push
self.pushController(withName: Interface Controller Identifier, context: Any?)

push方式可左上返回

返回上一頁同iOS UIKit:self.pop()

返回根頁面:self.popToRootController()

開新頁面:self.presentController()

2.頁籤顯示方式
WKInterfaceController.reloadRootControllers(withNames: [Interface Controller Identifier], contexts: [Any?])

亦或是在Storyboard上,在第一頁的Interface Controller上按Control+Click拖曳到第二頁選擇「next page」也可

頁籤顯示方式可以左右切換頁面

兩種跳頁方式不能混用.

跳頁參數?

不像iOS需要使用自訂delegate或segue方式傳遞參數,watchKit跳頁帶參數方式就是將參數放入上方方法中的contexts中即可.

接收參數在 InterfaceController 的 awake(withContext context: Any?)

例如我在A頁面要跳到B頁面並帶入id:Int時:

A 頁面:

self.pushController(withName: "showDetail", context: 100)

B 頁面:

override func awake(withContext context: Any?) {
super.awake(withContext: context)
guard let id = context as? Int else {
print("參數錯誤!")
self.popToRootController()
return
}
// Configure interface objects here.
}

程式控制元件部分

相比iOS UIKit一樣簡化許多,有開發過iOS的應該上手很快!
例如label變成setText()
p.s. 而且居然沒有getText的方法,只能extension變數或放在外部變數儲存

與iPhone之間同步/資料傳遞

如果有開發過iOS 相關 Extension 的話;下意識一定是用App Groups共享UserDefaults的方式,當初我也興沖沖的這樣做,然後卡了好久發現資料一直過不去,直到上網一查才發現,watchOS>2之後就不再支援此方法了….

要使用新的WatchConnectivity方式讓手機跟手錶之間進行通訊(類似socket概念),iOS手機及手錶watchOS兩端都需要實做,我們寫成singleton模式如下:

手機端:

import WatchConnectivity

class WatchSessionManager: NSObject, WCSessionDelegate {
@available(iOS 9.3, *)
func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {
//手機端session啟用完成
}

func session(_ session: WCSession, didReceiveUserInfo userInfo: [String : Any] = [:]) {
//手機端接受到手錶傳回的UserInfo
}

func session(_ session: WCSession, didReceiveMessage message: [String : Any], replyHandler: @escaping ([String : Any]) -> Void) {
//手機端接受到手錶回傳的Message
}

//另外還有didReceiveMessageData,didReceiveFile同樣都是處理收到手錶回傳的資料
//看你的資料傳遞接收需求決定要用哪個

func sendUserInfo() {
guard let validSession = self.validSession,validSession.isReachable else {
return
}

if userDefaultsTransfer?.isTransferring == true {
userDefaultsTransfer?.cancel()
}

var list:[String:Any] = [:]
//將UserDefaults放入list....

self.userDefaultsTransfer = validSession.transferUserInfo(list)
}

func sessionReachabilityDidChange(_ session: WCSession) {
//與手錶APP連接狀態改變時(手錶開啟APP時/手錶關閉APP時)
sendUserInfo()
//我是當狀態改變,如為手錶開啟APP時就同步一次UserDefaults
}

func session(_ session: WCSession, didFinish userInfoTransfer: WCSessionUserInfoTransfer, error: Error?) {
//完成同步UserDefaults(transferUserInfo)
}

func sessionDidBecomeInactive(_ session: WCSession) {

}

func sessionDidDeactivate(_ session: WCSession) {

}

static let sharedManager = WatchSessionManager()
private override init() {
super.init()
}

private let session: WCSession? = WCSession.isSupported() ? WCSession.default : nil
private var validSession: WCSession? {
if let session = session, session.isPaired && session.isWatchAppInstalled {
return session
}
//回傳有效且連接中且手錶APP開啟中的session
return nil
}

func startSession() {
session?.delegate = self
session?.activate()
}
}

WatchConnectivity 手機端的 Code

並在iOS/AppDelegate.swift的application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?)中加入WatchSessionManager.sharedManager.startSession()
以在啟動手機APP後連接上session

手錶端:

import WatchConnectivity

class WatchSessionManager: NSObject, WCSessionDelegate {
func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {
}

func sessionReachabilityDidChange(_ session: WCSession) {
guard session.isReachable else {
return
}

}

func session(_ session: WCSession, didFinish userInfoTransfer: WCSessionUserInfoTransfer, error: Error?) {

}

func session(_ session: WCSession, didReceiveUserInfo userInfo: [String : Any] = [:]) {
DispatchQueue.main.async {
//UserDefaults:
//print(userInfo)
}
}

static let sharedManager = WatchSessionManager()
private override init() {
super.init()
}

private let session: WCSession? = WCSession.isSupported() ? WCSession.default : nil

func startSession() {
session?.delegate = self
session?.activate()
}
}

WatchConnectivity 手錶端的 Code

並在WatchOS Extension/ExtensionDelegate.swift中的applicationDidFinishLaunching() 加入
WatchSessionManager.sharedManager.startSession()
以在啟動手錶APP後連接上session

WatchConnectivity 資料傳遞方式

傳資料用:sendMessage,sendMessageData,transferUserInfo,transferFile
收資料用:didReceiveMessageData,didReceive,didReceiveMessage
兩端傳接收方法都ㄧ樣

可以看到手錶傳資料到手機都通,但手機傳資料到手錶僅限手錶APP開啟中

watchOS推播處理

專案目錄底下的PushNotificationPayload.apns這時就派上用場了,這是用來在模擬器上測試推播之用,在模擬器上部署Watch App target,安裝完啟動App就會收到一則以這個檔案內容的推播,讓開發者更容易測試推播功能.

如要修改/啟用/停用 PushNotificationPayload.apns,請選擇Target後Edit Scheme

watchOS 推播處理:

同iOS我們實做UNUserNotificationCenterDelegate,在watchOS中我們也實作一樣的方法,在watchOS Extension/ExtensionDelegate.swift中

import WatchKit
import UserNotifications
import WatchConnectivity

class ExtensionDelegate: NSObject, WKExtensionDelegate, UNUserNotificationCenterDelegate {

func applicationDidFinishLaunching() {

WatchSessionManager.sharedManager.startSession() //前面提到的WatchConnectivity連線

UNUserNotificationCenter.current().delegate = self //設定UNUserNotificationCenter delegate
// Perform any final initialization of your application.
}

func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
completionHandler([.sound, .alert])
//同iOS,此做法可讓推播在APP前景時依然會顯示
}

func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {
//點擊推播時
guard let info = response.notification.request.content.userInfo["aps"] as? NSDictionary,let alert = info["alert"] as? Dictionary<String,String>,let data = info["data"] as? Dictionary<String,String> else {
completionHandler()
return
}

//response.actionIdentifier可得點擊事件Identifier
//預設點擊事件:UNNotificationDefaultActionIdentifier

if alert["type"] == "new_ask") {
WKExtension.shared().rootInterfaceController?.pushController(withName: "showDetail", context: 100)
//取得目前root interface controller 並 push
} else {
//其他處理....
//WKExtension.shared().rootInterfaceController?.presentController(withName: "", context: nil)

}

completionHandler()
}
}

ExtensionDelegate.swift

watchOS 推播顯示,分成三種:

  1. static: 預設推播顯示方式
會同手機推播,這邊手機端iOS有實做UNUserNotificationCenter.setNotificationCategories在通知下方增加按鈕;Apple Watch預設亦然會出現
  1. dynamic:動態處理推播顯示樣式(重組內容、顯示圖片)
  2. interactive:watchOS ≥ 5 後支援,在dynamic的基礎下再增加支援按鈕
可在Interface.storyboard中的Static Notification Interface Controller Scene設定推播處理方式

static沒什麼好說的,就是走預設的顯示方式,這邊先介紹dynamic,勾選「Has Dynamic Interface」後會出現「Dynamic Interface」可在此視圖設計你自訂的推播呈現方式(不能使用Button):

我的自訂推播呈現設計
import WatchKit
import Foundation
import UserNotifications

class NotificationController: WKUserNotificationInterfaceController {

@IBOutlet var imageView: WKInterfaceImage!
@IBOutlet var titleLabel: WKInterfaceLabel!
@IBOutlet var contentLabel: WKInterfaceLabel!

override init() {
// Initialize variables here.
super.init()
self.setTitle("結婚吧") //設定右上方標題
// Configure interface objects here.
}

override func willActivate() {
// This method is called when watch view controller is about to be visible to user
super.willActivate()
}

override func didDeactivate() {
// This method is called when watch view controller is no longer visible
super.didDeactivate()
}

override func didReceive(_ notification: UNNotification) {

if #available(watchOSApplicationExtension 5.0, *) {
self.notificationActions = []
//清除iOS實做的UNUserNotificationCenter.setNotificationCategories在通知下方增加的按鈕
}

guard let info = notification.request.content.userInfo["aps"] as? NSDictionary,let alert = info["alert"] as? Dictionary<String,String> else {
return
}
//推播資訊

self.titleLabel.setText(alert["title"])
self.contentLabel.setText(alert["body"])

if #available(watchOSApplicationExtension 5.0, *) {
if alert["type"] == "new_msg" {
//如果是新訊息推播則在通知下方增加回覆按鈕
self.notificationActions = [UNNotificationAction(identifier: "replyAction",title: "回覆", options: [.foreground])]
} else {
//其他則增加查看按鈕
self.notificationActions = [UNNotificationAction(identifier: "openAction",title: "查看", options: [.foreground])]
}
}


// This method is called when a notification needs to be presented.
// Implement it if you use a dynamic notification interface.
// Populate your dynamic notification interface as quickly as possible.

}
}

程式部分,ㄧ樣拉outlet到controller並實做功能

再來講到interactive,同dynamic,只是能多加Button,能跟dynamic設同個Class控制程式;interactive我沒有使用,因為我的按鈕是用程式self.notificationActions加上去的,差異如下:

左使用interactive,右使用self.notificationActions

兩個做法都需watchOS ≥ 5 支援.

使用self.notificationActions增加按鈕則按鈕事件處理由ExtensionDelegate中的 userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) 處理,並以identifier識別動作

選單功能?

在元件庫中拉入Menu,再拉入選單項目Menu Item,再拉IBAction到程式控制

在頁面重壓就會出現:

內容輸入?

使用內建的presentTextInputController方法即可!

@IBAction func replyBtnClick() {
guard let target = target else {
return
}

self.presentTextInputController(withSuggestions: ["稍後回覆您","謝謝","歡迎與我聯絡","好的","OK!"], allowedInputMode: WKTextInputMode.plain) { (results) in

guard let results = results else {
return
}
//有輸入值時

let txts = results.filter({ (txt) -> Bool in
if let txt = txt as? String,txt != "" {
return true
} else {
return false
}
}).map({ (txt) -> String in
return txt as? String ?? ""
})
//預處理輸入


txts.forEach({ (txt) in
print(txt)
})
}
}

總結

謝謝你看到這!辛苦了!

到這裡文章已告一段落,大略提了一下UI排版、程式、推播、介面應用部分,有開發過iOS的上手真的很快,幾乎差不多而且許多方法都做了簡化使用起來更簡潔,但能做的事確實也變少了(像是目前還不知道怎麼針對Table做載入更多);目前能做的事確實很少,希望官方在未來能開放更多API給開發者使用❤️❤️❤️

MurMur:

Apple Watch App Target 部署到手錶真的有夠慢 — Narcos

有任何問題及指教歡迎與我聯絡

--

--

ZhgChgLi
ZRealm Dev.

探索世界、求知若渴、教學相長;更愛電影、美劇、西音、運動、生活. www.zhgchg.li