🀄

実牌麻雀専用Apple Watchアプリを作った話【にんにんLT】

2024/12/16に公開1

はじめに

株式会社ハックツ 代表のどりーです。
今回は年末に開催予定のネタLTイベント「にんにんLT」のためだけに開発した
『実牌麻雀専用Apple Watchアプリ』を紹介します🀄️

前提

開発物について

実用性や操作性、著作権などを全く考えずに作ったジョークアプリです。
現状リリースする予定もありません。

開発環境

環境 バージョン
Mac OS Sequoia 15.2
Xcode 16.2
Swift 6.0.3
iOS 18.2
WatchOS 10.6.1

*執筆時の最新ver

機能

今回は2機能実装してみました。

1.ざわ・・・ざわ・・・心拍数計測機能


心拍数を計測し、一定数値以上の心拍数になったとき上記画像のようにざわ・・・ざわ・・・します。
また、画像出現時にざわ・・・ざわ・・・音声も再生しています。
https://youtu.be/g4anfRPrkSo?list=PL0NoqJMUpnYam-KHo1dsMENNAPUyjHEs4

2.超煽り!ツモ演出機能


ツモ演出動画をAppleWatch上で再生することができます。
また、リーチボタンを押した後、勢いよくツモる or 手を素早く振る とツモ音声のみ再生されます。
*Watchを装着している手に合わせます。

https://youtu.be/zuWWAASSFcI?list=PL0NoqJMUpnYam-KHo1dsMENNAPUyjHEs4
https://youtu.be/r_Qn0McjbwI?list=PL0NoqJMUpnYam-KHo1dsMENNAPUyjHEs4

実装

それぞれどのように実装しているのか紹介していきます。

心拍数計測

Appleが提供している公式フレームワークHealthKitを使用しています。

セットアップ(導入)

HealthKitを使用する際には実装以外にXcode上でセットアップを行う必要があります。

プロジェクト → TARGETS → {プロジェクト名} にアクセスして下記3工程を行ってください。

  1. Info → “Privacy – Health Update(/Share/Record) Usage Description”を追加
    Privacy – Healthから始まる3つ全部入れておけば安心。(実際は使用するものだけでいいです。)
    Valueにはユーザーへ表示されるメッセージになりますので、いい感じに入れてください。
  2. General → Frameworks, Libraries, and Embedded Content タブ内の "+"から”HealthKit.framework”を追加
  3. Signing & Capabilities → ”+ Capability”をクリックして”HealthKit”を追加

実装は amorosoluciano さんのコードをコピペ参考にさせていただきました。
https://github.com/amorosoluciano/SwiftFun/blob/master/ContentView.swift

部分ごとに紹介

WatchにHealthKitを承認してもらうための通知を飛ばす関数

.heartRate→心拍数
なのでこれをHealthKitのtypeにセットします。
そしてrequestAuthorization(toShare:read:)を呼び出してHealthKitStoreにアクセス権限を要求します。
{}の中にエラーハンドリングを書いておくとより安全です。
また、SwiftUIではhealthDataAccessRequest(store:shareTypes:readTypes:trigger:completion:)を用いてViewの中にmodifierとして書くこともできます。
(自分はView部分が長くなりすぎてあんまり好きじゃないので参考のように関数上で実装する派です)

func autorizeHealthKit() {
    let healthKitTypes: Set = [
        HKObjectType.quantityType(forIdentifier:HKQuantityTypeIdentifier.heartRate)!]
        
        healthStore.requestAuthorization(toShare: healthKitTypes, read: healthKitTypes) { _, _ in }
}

心拍数を受信するクエリ

startHeartRateQuery(quantityTypeIdentifier: .heartRate)で心拍数の受信を行います。
例えばここを.stepCountにすると歩数計測ができます。

startHeartRateQueryではクエリを設定していきます。

  1. クエリに使用するPredicateを設定(クエリのフィルターみたいなもの)
    今回は自分のデバイス(Watch)に限定
  2. クエリに使用するUpdateを設定(変更を監視)
  3. process関数に関する設定
  4. クエリを生成
  5. healthStoreを上記クエリで実行
func start() {
    autorizeHealthKit()
    startHeartRateQuery(quantityTypeIdentifier: .heartRate)
}

private func startHeartRateQuery(quantityTypeIdentifier: HKQuantityTypeIdentifier) {
        
    // 1
    let devicePredicate = HKQuery.predicateForObjects(from: [HKDevice.local()])
    // 2
    let updateHandler: (HKAnchoredObjectQuery, [HKSample]?, [HKDeletedObject]?, HKQueryAnchor?, Error?) -> Void = {
    query, samples, deletedObjects, queryAnchor, error in
            
        // 3
        guard let samples = samples as? [HKQuantitySample] else {
            return
        }
        self.process(samples, type: quantityTypeIdentifier)
    }
        
    // 4
    let query = HKAnchoredObjectQuery(type: HKObjectType.quantityType(forIdentifier: quantityTypeIdentifier)!, predicate: devicePredicate, anchor: nil, limit: HKObjectQueryNoLimit, resultsHandler: updateHandler)
        
    query.updateHandler = updateHandler
        
    // 5
    healthStore.execute(query)
}

心拍数をprocess関数で受信

process関数でwatchから検出された最後のbpm値を含む心拍数を検出します。
このself.valueの値をSwiftUI上で表示させたりしている。

private func process(_ samples: [HKQuantitySample], type: HKQuantityTypeIdentifier) {
    var lastHeartRate = 0.0
        
    for sample in samples {
        if type == .heartRate {
            lastHeartRate = sample.quantity.doubleValue(for: heartRateQuantity)
        }
        self.value = Int(lastHeartRate)
    }
}

音声再生

公式ライブラリであるAVFAudioAVFoundationを使用して実装しています。
こちらはimportするだけでOK!

SwiftUIで音声再生起動

先ほどの心拍数情報valueを用いて画像の表示/非表示をコントールし、
画像表示時に音声再生関数を実行します。

.onAppearはオブジェクトが表示される時に処理実行します。
今回の実装ではifを用いて心拍数valueが一定数を超えた時にImageを表示させ、
その度に.onAppearによってsetAudioSession()soundPlayer.musicPlay()が呼ばれるようにしました。

if value > 100 {
    Image("zawazawa")
        .renderingMode(.original)
        .resizable()
        .frame(width: 128, height: 72)
        .onAppear {
            setAudioSession()
            soundPlayer.musicPlay(flagNum: 1)
        }
}

setAudioSessionでオーディオ再生の設定を指定

SwiftUIで呼び出しているsetAudioSession()ではオーディオ再生の設定を指定しています。
ここをちゃんと設定することで音声再生の挙動を安定させます。
設定次第では背景音(他の音とミックスされる)にすることができたりします。

今回は音楽トラックの再生に適したplaybackに設定しています。
これに設定するとマナーモード(消音モード)でも音を鳴らすことができます。

setAudioSession.swift
import Foundation
import AVFAudio

func setAudioSession(){
    let audioSession = AVAudioSession.sharedInstance()
    do {
        // カテゴリの設定
        try audioSession.setCategory(.playback, mode: .moviePlayback, options: [])

        // AVAudioSessionの有効化
        try audioSession.setActive(true)
    } catch {
        print(error)
    }
}

AVAudioPlayerで音声再生

AVFoundationライブラリのAVAudioPlayerを用いてAssetsに登録した音声ファイルを再生します。
Assetsへはドラッグ&ドロップで入れるだけでOK!

今回は複数の音声を使用するので関数を呼び出すときにどの音声ファイルかを指定できるように実装しています。

zawaPlayer.swift
import UIKit
import AVFoundation

class zawaPlayer: NSObject {

    //assetsに入れた音声ファイルを指定
    let musicDataShort = NSDataAsset(name: "zawa")!.data
    let musicDataLong = NSDataAsset(name: "zawazawazawa")!.data
    let musicDataReach = NSDataAsset(name: "reach")!.data
    let musicDataTsumoWashizu = NSDataAsset(name: "tsumoWashizu")!.data
    
    
    var player: AVAudioPlayer!
    
    func musicPlay(flagNum: Int) {
        do {
            if flagNum == 1 {
                player = try AVAudioPlayer(data: musicDataLong)
            } else if flagNum == 2 {
                player = try AVAudioPlayer(data: musicDataShort)
            } else if flagNum == 3 {
                player = try AVAudioPlayer(data: musicDataReach)
            } else if flagNum == 4 {
                player = try AVAudioPlayer(data: musicDataTsumoWashizu)
            }
            
            player.play()
        } catch {
            print("Error")
        }
    }
}

SwiftUI全体コード

あえてelse ifにしていないのは、ざわざわ画像を2つ表示して焦っている感をより演出するためです。

struct zawazawaView: View {
    private var healthStore = HKHealthStore()
    let heartRateQuantity = HKUnit(from: "count/min")
    
    @State private var value = 0
    
    let soundPlayer = zawaPlayer()
    
    var body: some View {
        VStack{
            HStack{
                if value > 100 {
                    Image("zawazawa")
                        .renderingMode(.original)
                        .resizable()
                        .frame(width: 128, height: 72)
                        .onAppear {
                            setAudioSession()
                            soundPlayer.musicPlay(flagNum: 1)
                        }
                }
                
                if value > 80 {
                    Image("zawazawa")
                        .renderingMode(.original)
                        .resizable()
                        .frame(width: 128, height: 72)
                        .onAppear {
                            setAudioSession()
                            soundPlayer.musicPlay(flagNum: 2)
                        } 
                } else {
                    Text("❤️")
                        .font(.system(size: 50))
                }
                Spacer()
            }
            HStack{
                Text("\(value)")
                    .fontWeight(.regular)
                    .font(.system(size: 50))
                
                Text("BPM")
                    .font(.headline)
                    .fontWeight(.bold)
                    .foregroundColor(Color.red)
                    .padding(.bottom, 28.0)
                
                Spacer()
            }
        }
        .padding()
        .onAppear(perform: start)
    }
//以下省略(心拍数の関数)
}

動画再生

公式ライブラリのAVKitAVFoundationを使用して実装しています。

音声とは異なり、オブジェクト(Viewで表示できる物体)が存在するため、
AVKitVideoPlayer(player: )でViewを作ります。

また、動画ファイルの指定が音声ファイルとは参照が異なるので注意が必要です。
今回はswiftファイルと同様の階層に動画ファイルを設置してから呼び出しています。
forResource: "Assets/tsumo"として呼び出せると思っていたがなんかうまくいかなかった。
ofType:は動画ファイルの拡張子に合わせて変更してください。
↓対応ファイルタイプ
https://developer.apple.com/documentation/avfoundation/avfiletype

performanceView.swift
import SwiftUI
import AVKit
import AVFoundation

struct performanceView: View {
    private let path = Bundle.main.path(forResource: "tsumo", ofType: "mp4")
    
    var body: some View {
        let url = URL(fileURLWithPath: path!)
        let player = AVPlayer.init(url: url)
        
        VideoPlayer(player: player)
    }
}

加速度計測

勢いよくツモった時に「ツモ」音声流したい!ということで、
加速度を計測して一定数値以上になった時に音声再生を行う実装をしてみました。
公式ライブラリCoreMotionを使用して実装しています。

加速度計測のためのView

まずは先ほどのperformanceView.swiftに加速度計測を起動する実装を追加します。
加速度計測を常に使用すると麻雀中に誤爆してしまう恐れがあることと後述しているとある理由のため、「リーチ」ボタンをタップしたらトラッキングを起動をする実装にしました。

performanceView.swift
import SwiftUI
import AVFAudio
import AVKit
import AVFoundation
import CoreMotion

struct performanceView: View {
    
    @ObservedObject private var motionManager = MotionManager()
    
    let soundPlayer = zawaPlayer()
    
    private let path = Bundle.main.path(forResource: "tsumo", ofType: "mp4")
    
    var body: some View {
        
        let url = URL(fileURLWithPath: path!)
        let player = AVPlayer.init(url: url)
        
        if motionManager.tsumoFlag {
            Text("ツモ!")
                .onAppear() {
                    setAudioSession()
                    soundPlayer.musicPlay(flagNum: 4)
                    self.motionManager.stop()
                }
        }
        VideoPlayer(player: player)
        Button(action: {
            if self.motionManager.isStarted {
                self.motionManager.stop()
            } else {
                setAudioSession()
                soundPlayer.musicPlay(flagNum: 3)
                self.motionManager.start()
            }
        }) {
            self.motionManager.isStarted ? Text("リーチ中") : Text("リーチ")
        }
        .controlSize(.mini)
    }
}

加速度計測処理

CMMotionManager()でx,y,z方向の加速度を計測していきます。

motionManager.deviceMotionUpdateIntervalで更新頻度を調整できます。
間隔の単位は秒です。

xStr = String(deviceMotion.userAcceleration.x)では計測データをString型にキャストして代入しています。
Viewに表示したい時やdebugの際はこのxStrを使うのが便利です。
*今回は使用していませんが一応書いています。

if abs(deviceMotion.userAcceleration.x) > 2.0 || abs(deviceMotion.userAcceleration.y) > 2.0 || abs(deviceMotion.userAcceleration.z) > 2.0では計測データの絶対値を取り、x,y,z方向のいずれかのデータが±2.0以上になった時を検出しています。
*数値は秘伝のタレ感ありますが、かなり素早く振らないと2.0超えません。

import Foundation
import CoreMotion

final class MotionManager: ObservableObject {
    
    @Published var isStarted = false
        
    @Published var xStr = "0.0"
    @Published var yStr = "0.0"
    @Published var zStr = "0.0"
    
    @Published var tsumoFlag = false
        
    let motionManager = CMMotionManager()
        
    func start() {
        if motionManager.isDeviceMotionAvailable {
            motionManager.deviceMotionUpdateInterval = 0.1
            motionManager.startDeviceMotionUpdates(to: OperationQueue.current!, withHandler: {(motion:CMDeviceMotion?, error:Error?) in
                    self.updateMotionData(deviceMotion: motion!)
                })
        }
        isStarted = true
    }
        
    func stop() {
        isStarted = false
        motionManager.stopDeviceMotionUpdates()
    }
        
    private func updateMotionData(deviceMotion:CMDeviceMotion) {
        xStr = String(deviceMotion.userAcceleration.x)
        yStr = String(deviceMotion.userAcceleration.y)
        zStr = String(deviceMotion.userAcceleration.z)
        
        if abs(deviceMotion.userAcceleration.x) > 2.0 || abs(deviceMotion.userAcceleration.y) > 2.0 || abs(deviceMotion.userAcceleration.z) > 2.0 {
            tsumoFlag = true
        } else {
            tsumoFlag = false
        }
        
    }
}

なぜさっきの動画再生ではないのか

本当は動画を再生したかったのですが、
CoreMotionを用いた加速度測定を行っている時に同時に動画を再生することができませんでした。
Viewに表示しているVideoPlayerを物理的にタップしても再生されないことからそういう仕様だと割り切るしかありませんでした。無念。。。
ただ勢いよくツモったと同時に演出画面(動画)を見せることができないということに気づき、音声再生に変更をしても同じ体験が得られると思い変更をさせていただきました。

まとめ

今回は
Viewや処理をまとめてSwiftUI
心拍数計測をHealthKit
音声再生をAVFAudio,AVFoundation
動画再生をAVKit,AVFoundation,
加速度計測をCoreMotion
を用いて実装してみました。

作っているものはふざけていますが、
これまで自分がwatchOSアプリを作る時に使った技術を総動員させる大作になりました。

前提でリリースする予定はないとお伝えしましたが、
リリースしてほしい!というお声があれば、操作性の改善・著作権の考慮・Apple社との戦い(審査)を覚悟しながら取り組みたいと思います。

おまけ

AppleWatchの実機ビルドはクソ

今回の開発で一番時間をかけたことは何か。
それはXcodeとAppleWatchの接続です。
割合にして、接続:実装時間=9:1です。
終わってます。

なぜXcodeとAppleWatchの接続に時間を要したのか

Xcodeから実機ビルドを行う際、iOS端末等では有線接続が可能です。
しかしAppleWatchはというと有線接続が存在しません。
ではどうするのか。そう無線接続なんです。
これが最も罠です。
https://developer.apple.com/documentation/xcode/running-your-app-in-simulator-or-on-a-device#Connect-real-devices-to-your-Mac
こちら公式のドキュメントですが、実機接続について記述があります。
AppleWatchの部分を引用すると、

To pair an Apple Watch to a Mac, connect its companion iPhone to the Mac with a cable, and ensure that the iPhone is paired for development. After this step, follow any instructions on the Apple Watch to trust the Mac. When paired through an iPhone running iOS 17 or later, Xcode connects to the Apple Watch over Wi-Fi. Series 5 and older models of Apple Watch additionally require the Apple Watch and Mac to be associated with the same Bonjour-compatible Wi-Fi network. When paired through an iPhone running older versions of iOS, Xcode requires the iPhone to remain connected to the Mac in order to develop on any model of Apple Watch.
(翻訳)Apple Watch を Mac にペアリングするには、ケーブルを使用してコンパニオン iPhone を Mac に接続し、iPhone が開発用にペアリングされていることを確認します。この手順の後、Apple Watch の指示に従って Mac を信頼します。iOS 17 以降を実行している iPhone を介してペアリングすると、Xcode は Wi-Fi 経由で Apple Watch に接続します。さらに、Series 5 以前のモデルの Apple Watch では、Apple Watch と Mac が同じ Bonjour 互換 Wi-Fi ネットワークに関連付けられている必要があります。古いバージョンの iOS を実行している iPhone を介してペアリングすると、Xcode では、どのモデルの Apple Watch でも開発を行うために、iPhone が Mac に接続されたままになっている必要があります。

つまり、MacOS端末とwatchとペアリングしているiOS端末を有線接続させた上で、MacOS,watchOS,iOSすべてを同一ネットワーク上に接続する必要があります。
また、MacOS端末とペアリングしているiOS端末とwatchOS端末とXcodeをすべて最新版にしないと不具合が起きることもあります。

面倒だけどこれで大丈夫になったね!と思ったそこのあなた。

ここまでやって接続されるかは運ゲーです。
今でもこの接続の問題はForumでもよく(悪い意味で)賑わっています。
https://forums.developer.apple.com/forums/thread/734694

自分はたまたま今うまくいっていますが、それでもビルドする度に下記をやっています。

  1. AppleWatchのWi-Fi接続を確認(大体切れている)
  2. MacPCを一度Wi-Fiオフにしてから再度オンに
  3. Xcode → Window → Devices & Simulatorsを起動して端末状態確認
  4. 接続が確認されるまでWatch上で別のアプリ起動したりしてみる
  5. 接続が確認されたらビルド実行
  6. ビルド完了するまでWatchをずっと注視(そうしないとロックがかかり接続が途切れる)

これでもうまくいかない時は各種再起動を試したり、
AppleWatchのデベロッパーモードをオンオフして再度MacPCを信頼したり、
ネットワークを変更してみたり、
Devices & SimulatorsからAppleWatchをUnpair Deviceしたり色々やっています。
Forumにはここまでやって上手くいってない人もいるぐらいです。

自分はハッカソンで宿泊していた場所のネットワークがおそらくBonjour-compatible Wi-Fi networkではなかったことが原因で6時間潰しました。

これだけネガキャンしてるけど大丈夫?

Apple Watchに良いアプリが少ない原因はここにあると私は考えているので、
むしろ色々な方に各方面でこのことを言いふらしてほしいです。
ストライキの意味も込めて。

ただし、Swiftは素晴らしい言語ということは覚えておいてください。
悪いのはAppleWatchとXcodeとAppleです。

参考文献

SwiftUI x watchOS

https://zenn.dev/naoya_maeda/articles/0f2de0f33b19a0

HealthKit

https://medium.com/display-and-use-heart-rate-with-healthkit-on/display-and-use-heart-rate-with-healthkit-on-swiftui-for-watchos-2b26e29dc566
https://zenn.dev/muranaka/articles/3053acaa9145cd
https://zenn.dev/i_shinga/scraps/15acabe31cc48a
https://i-doctor.sakura.ne.jp/font/?p=47914

音声

https://zenn.dev/entaku/articles/e75c4aa914c6cf

実機テスト

https://forums.developer.apple.com/forums/thread/734694
https://qiita.com/shintaro_aw/items/69b79f64d4ffe849f626
https://umikazeken.hatenablog.com/entry/2023/05/02/000051
https://stackoverflow.com/questions/78511979/apple-watch-cant-always-reconnect

公式ドキュメント

https://developer.apple.com/documentation/avfoundation/avfiletype
https://developer.apple.com/documentation/healthkit/hkquantitytypeidentifier
https://developer.apple.com/documentation/healthkit/hkquery/1614769-predicateforobjects
https://developer.apple.com/documentation/xcode/running-your-app-in-simulator-or-on-a-device#Connect-real-devices-to-your-Mac
https://developer.apple.com/documentation/swiftui/view/controlsize(_:)
https://developer.apple.com/documentation/coremotion/cmmotionmanager/devicemotionupdateinterval

Discussion