Apple Watchで霧吹きを作る方法(後編)

apple-watch-moyashidx-eyecatch

 

本記事はApple Watchで霧吹きを作ってみた(前編)の続きとなります。

 

本記事でできること

・HealthKitの利用
・複雑なUIの調整
ソースコピペで実装

 

 

1.HealthKitを使う

1.1 HealthKitを使うための実装

HealthKitを使う場合は、本当に適した使い方をしているかどうかがかなり重要です(経験者は語る)。また、国によっては法律で心拍数を取得できないようです。というのも、これら2つの理由からリジェクトをされてしまいHealthKitの導入を諦めたからです。Watchターゲットを含んでいると申請時に色々問題が発生するので注意。

ここでは、HealthKitの使い方だけ示します。。。

HealthKitを使うためにユーザの許可を得なくてはなりません。その実装をAppController.hAppController.mに追加します。HealthKitフレームワークがないと動作しないので追加します。

apple-watch-moyashidx2-5-1

 

 

AppController.h

#import < UIKit/UIKit.h >
#import < HealthKit/HealthKit.h >
#include "RootViewController.h"

@interface AppController : NSObject < UIApplicationDelegate > {
    UIWindow *window;
    HKHealthStore *healthStore;
}

@property(nonatomic, readonly) RootViewController* viewController;

@end

 

AppController.m

    // Init the health store
    healthStore = [[HKHealthStore alloc] init];

 

#pragma mark - HealthKit
// authorization from watch
- (void)applicationShouldRequestHealthAuthorization:(UIApplication *)application
{
    [healthStore handleAuthorizationForExtensionWithCompletion:^(BOOL success, NSError * _Nullable error) {
        
    }];
}

 

通信の時と同様に専用のWorkoutSessionLogicクラスを追加します。

WorkoutSessionLogic.swift

import Foundation
import HealthKit
import WatchKit

/// ワークアウトが利用できるか否かを判定する時間間隔
private let WORKOUT_INTERVAL:Float = 1.0

class WorkoutSessionLogic: NSObject, HKWorkoutSessionDelegate {
    
    private var m_interfaceControllerDelegate:InterfaceControllerDelegate?
    var interfaceControllerDelegate: InterfaceControllerDelegate {
        get {
            return self.m_interfaceControllerDelegate!
        }
        set (newValue) {
            m_interfaceControllerDelegate = newValue
        }
    }
    
    // 心拍数関連
    private var m_workoutElapsedTime:Float = 0.0
    private let m_healthStore = HKHealthStore()
    private let m_heartRateUnit = HKUnit(fromString: "count/min")
    private var m_workoutSession:HKWorkoutSession? = nil
    private var m_anchor = HKQueryAnchor(fromValue: Int(HKAnchoredObjectQueryNoAnchor))
    private var m_query:HKQuery? = nil
    private var m_workoutActive = false
}

typealias WorkoutSessionLogicMain = WorkoutSessionLogic
typealias WorkoutSessionLogicDelegate = WorkoutSessionLogic
typealias WorkoutSessionLogicUpdates = WorkoutSessionLogic
extension WorkoutSessionLogicMain
{
    /// 時間計測中に一定インターバルで実行される
    func updateWorkoutTimer(delta:Float) {
        m_workoutElapsedTime += delta
        checkAvailableHealthKit()
    }
    
    /// センサーが利用できれば実行
    func checkAvailableHealthKit() {
        // 指定時間毎にセンサーが利用できるか調べる
        if m_workoutElapsedTime < WORKOUT_INTERVAL
        {
            return
        }
        
        // 既にセッション起動中
        guard (self.m_workoutSession != nil) == false else
        {
            m_workoutElapsedTime = 0.0
            return
        }
        
        guard HKHealthStore.isHealthDataAvailable() == true else
        {
            m_workoutElapsedTime = 0.0
            return
        }
        
        guard let quantityType = HKQuantityType.quantityTypeForIdentifier(HKQuantityTypeIdentifierHeartRate) else
        {
            m_workoutElapsedTime = 0.0
            return
        }
        
        let dataTypes = Set(arrayLiteral: quantityType)
        m_healthStore.requestAuthorizationToShareTypes(nil, readTypes: dataTypes) { (success, error) -> Void in
            if success == false
            {
                self.m_workoutElapsedTime = 0.0
            }
            else
            {
                // センサーを起動
                self.startWorkoutSesstion()
            }
        }
    }
    
    /// ワークアウトセッション開始
    func startWorkoutSesstion() {
        // 既にセッション起動中であればReturn
        guard (self.m_workoutSession != nil) == false else
        {
            return
        }
        
        // セッション作成
        self.m_workoutSession = HKWorkoutSession(activityType: HKWorkoutActivityType.Walking, locationType: HKWorkoutSessionLocationType.Indoor)
        if self.m_workoutSession != nil
        {
            // HealthKitのデリゲート設定
            self.m_workoutSession!.delegate = self
            
            // start a new workout
            self.m_workoutActive = true
            
            m_healthStore.startWorkoutSession(self.m_workoutSession!)
        }
    }

    /// ワークアウトセッション終了
    func endWorkoutSesstion() {
        if self.m_workoutSession != nil
        {
            // finish the current workout
            self.m_workoutActive = false
            
            m_healthStore.endWorkoutSession(self.m_workoutSession!)
            self.m_workoutSession = nil
        }
    }

    /// 本当はデータを送信する
    func sendData(heartRate:Int) {
        self.m_interfaceControllerDelegate?.updateUI(String("心拍数:") + String(heartRate));
    }
}

extension WorkoutSessionLogicDelegate
{
    /// ワークアウトセッションの状態が変化した時にコール
    func workoutSession(workoutSession: HKWorkoutSession, didChangeToState toState: HKWorkoutSessionState, fromState: HKWorkoutSessionState, date: NSDate) {
        switch toState {
        case .Running:
            workoutDidStart(date)
        case .Ended:
            workoutDidEnd(date)
        default:
            NSLog("Unexpected state (toState)")
        }
    }
    
    /// エラー発生時にコール
    func workoutSession(workoutSession: HKWorkoutSession, didFailWithError error: NSError) {
        NSLog("Workout error: (error.userInfo)")
    }
}

extension WorkoutSessionLogicUpdates
{
    // クエリを初期化
    func endWorkoutQuery() {
        if m_query != nil
        {
            m_healthStore.stopQuery(m_query!)
            m_query = nil
        }
    }
    
    /// ワークアウト開始直後
    func workoutDidStart(date : NSDate) {
        // クエリが残っている可能性を考慮
        endWorkoutQuery()
        
        m_query = createHeartRateStreamingQuery(date)
        if m_query != nil
        {
            m_healthStore.executeQuery(m_query!)
        }
    }
    
    /// ワークアウト終了直後
    func workoutDidEnd(date : NSDate) {
        endWorkoutQuery()
    }
    
    /// クエリを作成
    func createHeartRateStreamingQuery(workoutStartDate: NSDate) -> HKQuery? {
        guard let quantityType = HKObjectType.quantityTypeForIdentifier(HKQuantityTypeIdentifierHeartRate) else
        {
            return nil
        }
        
        let heartRateQuery = HKAnchoredObjectQuery(type: quantityType, predicate: nil, anchor: m_anchor, limit: Int(HKObjectQueryNoLimit)) { (query, sampleObjects, deletedObjects, newAnchor, error) -> Void in
            guard let newAnchor = newAnchor else
            {
                return
            }
            
            self.m_anchor = newAnchor
            self.updateHeartRate(sampleObjects)
        }
        
        heartRateQuery.updateHandler = {(query, samples, deleteObjects, newAnchor, error) -> Void in
            self.m_anchor = newAnchor!
            self.updateHeartRate(samples)
        }
        
        return heartRateQuery
    }
    
    /// 心拍数を更新
    func updateHeartRate(samples: [HKSample]?) {
        guard let heartRateSamples = samples as? [HKQuantitySample] else
        {
            return
        }
        
        dispatch_async(dispatch_get_main_queue()) {
            guard let sample = heartRateSamples.first else
            {
                return
            }
            let value = sample.quantity.doubleValueForUnit(self.m_heartRateUnit)
            self.sendData(Int(value))
        }
    }
}

WorkoutSessionLogicクラス追加に伴い、InterfaceControllerクラスも修正します。updateTimerを利用しているのは、ワークアウトセッションを実行するにあたりユーザの許可が必要なので、許可が得られた時点で自動的にワークアウトセッションを開始するためです。許可が得られなければ何度も許可を求める通知がなされます。また、willActivatedidDeactivateを使うことでセンサーのOn/Offを切り替えることができます。

InterfaceController.swift

    /// 心拍数を検知するクラス
    private var m_workoutLogic: WorkoutSessionLogic!
    
    /// 時間管理用
    private var m_timer:NSTimer = NSTimer()

    override func awakeWithContext(context: AnyObject?) {
        super.awakeWithContext(context)
    }

    override func willActivate() {
        super.willActivate()
        initLogic()
        startTimer()
    }

    override func didDeactivate() {
        super.didDeactivate()
        endTimer()
    }

    /// ロジック初期化
    func initLogic() {
        m_wcsessionLogic = WCSessionLogic()
        m_wcsessionLogic.initSession()
        m_wcsessionLogic.interfaceControllerDelegate = self
        
        m_workoutLogic = WorkoutSessionLogic()
        m_workoutLogic.interfaceControllerDelegate = self
    }
    
    /// 時間計測を開始する
    func startTimer() {
        // 既に計測されていなければ時間計測を開始する
        if !m_timer.valid
        {
            // 時間計測開始
            m_timer = NSTimer.scheduledTimerWithTimeInterval(
                1.0/60.0,
                target: self,
                selector: "updateTimer:",
                userInfo: ["TIMER_KEY" : 1.0/60.0],
                repeats: true)
            m_timer.fire()
        }
    }
    
    /// 時間計測中に一定インターバルで実行される
    func updateTimer(inTimer:NSTimer) {
        let delta:Float = inTimer.userInfo?.objectForKey("TIMER_KEY") as! Float
        m_workoutLogic.updateWorkoutTimer(delta)
    }
    
    /// 時間計測を終了する
    func endTimer() {
        if m_timer.valid
        {
            m_timer.invalidate()
        }
        
        if m_workoutLogic != nil
        {
            m_workoutLogic.endWorkoutSesstion()
        }
    }

 

1.2 プロビジョニングを更新する

HealthKitを使う場合はプロビジョニングも更新する必要がありました。Apple DeveloperのMember CenterからApp IDsとプロビジョニングを更新します。

apple-watch-moyashidx2-5-2

 

適切なプロビジョニングであればXcodeのHealthKit利用をOnにした時に下図のようにエラーが出ないはずです。

apple-watch-moyashidx2-5-3

 

apple-watch-moyashidx2-5-4

 

1.3 実機で確認

Watchアプリを起動するとiPhone側に許可を求める通知が表示されると思います。許可をするとワークアウトセッションが開始され、Watchの裏側の緑に光るセンサーが起動します。

apple-watch-moyashidx2-5-5

 

apple-watch-moyashidx2-5-6

 

 

2.UIを組み立てる

2.1 メイン画面を作る

完成した霧吹きの画面が下図になります。これまでも嫌いながらに少しはxib、storyboardを使ったことがありましたので、UIの調整で苦労するとは思ってもいませんでした。UI調整の難易度を上げる問題として、LabelImageといった必ず使うであろう部品を重ねることができないという事が挙げられます。しかし、私にとって救世主みたいな存在で、唯一重ねることができるGroupという部品があったので、これをうまく使っていくことで下図の霧吹き画面が作れました。画面設計を考える時はstoryboardで、どのように配置していくのか予め考えておくことが重要です。

また、下図の霧吹き画面では配置しているヘッダー、ラベル、ゲージ、背景はどれもプログラムから書き換えられる恰好になっています。

apple-watch-moyashidx2-5-7

 

通常であれば、上図の霧吹き画面なんて画像配置して画像の上にラベルを乗っけるからZオーダを上にして…のようなノリ簡単に配置できるだろうと考えるのですが、実際には下図のようになりました\(^o^)/

どうみてもグループ使いすぎです笑 ここはかなり苦労しました…。Watchの42mmと38mm両方のUIを作らなければならなかったので苦行でしたね笑

apple-watch-moyashidx2-5-8

 

グループには下図のBackgroundから背景を設定できるので、結果として画像と他部品が重ねられるということになります。(画像の上に部品を重ねることに苦労するなんて…

apple-watch-moyashidx2-5-10

 

ちなみに画像はAssets.xcassetsにドラッグアンドドロップすると使えるようになりました。

apple-watch-moyashidx2-5-11

 

 

 

2.2 霧吹きアニメーションを作る

Watchでアニメーションを実現するには、公式のサンプルを見る限りパラパラ漫画のように画像を切替えていくのが普通みたいです。霧吹きアニメーションは15枚の画像を切り替えることで実現しました。

 

apple-watch-moyashidx2-5-12

 

このアニメーションについてもグループに設定しました。spray_としておけば勝手に番号部分は付け足してくれるようですね。

 

apple-watch-moyashidx2-5-13

 

再生したい場所で下記のコードを実行!

            // 霧吹きアニメーション開始
            self.m_effectLayer.startAnimatingWithImagesInRange(NSMakeRange(0, 14), duration: 0.5, repeatCount: 1)

 

 

3.まとめ

今回はWatchアプリとiPhoneアプリ間での通信、HealthKitを利用した心拍数の取得、storyboardを用いたUIの作成を行いました。それぞれ実装している段階や申請時にも細かい修正・調整が必要であることに気づくと思います。例えば、通信にもBackground TransfersとInteractive messagingがあるので目的にあった方式にしないと期待した挙動にならないですし、申請時にHealthKitを含んでいると最低OSバージョンが8.0でアーカイブし直したり、バイナリのアップロードが済み申請ボタンをポチッと押した後にHealthKitがUIRequiredDeviceCapabilities関連で怒られエラーになったりと色々ありました。

やはり一番苦労したのはUI