2
5

More than 1 year has passed since last update.

【Unity】iOS で HealthKit から歩数を取得する NativePlugin 開発

Posted at

本記事について

HealthKit を用いた歩数取得を通じて NativePlugin の開発方法 についての知見をまとめるための記事
HealthKit のコンセプトや使い方については深くは触れない。

前提

【Unity】Swift 経験0から iOS と macOS のNativePluginを作る

ここで作ったプロジェクトを拡張する形で進める。(Unityプロジェクト、Nativeアプリ、SwiftPackageの元がある状態)

Native アプリで歩数を取得

スクリーンショット 2023-06-13 21.51.59.png

Native アプリの画面にはボタンとテキストがすでにある。
このボタンを押したら、HealthKit から歩数を取得してテキストに表示するようにする。

まずいきなりだが、 HealthKit を扱うクラスを作成する。

必要なのは以下2つの機能。

  • HealthKit から歩数を取得するためにユーザーの許可をもらう
  • 今日の日付を取得する

SamplePlugin/Sources/SamplePlugin/HealthKitData.swift

HealthKitData.cs
import Foundation
import HealthKit

public class HealthKitData {
    public init() {}

    // HealthKitのデータにアクセスするためにユーザーの許可を求める。
    // 複数回呼び出しても構わない。(すでに許可済みの場合にはcompletionでtrueが渡される)
    public func authorize(completion: @escaping (Bool) -> Void) {
        guard HKHealthStore.isHealthDataAvailable() else {
            print("is not health data available")
            completion(false)
            return
        }

        let typesToRead = Set([HKObjectType.quantityType(forIdentifier: .stepCount)!])

        HKHealthStore().requestAuthorization(toShare: nil, read: typesToRead) { (success, error) in
            if let error {
                print("requestAuthorization failed. error: \(error.localizedDescription)")
                completion(false)
                return
            }
            // successはリクエストが成功したかどうか。
            // ユーザーが許可したことを示す値ではない。
            completion(success)
        }
    }

    public func getStepsToday(completion: @escaping (Int) -> Void) {
        let now = Date()
        let startOfDay = Calendar.current.startOfDay(for: now)
        let type = HKSampleType.quantityType(forIdentifier: .stepCount)!
        let predicate = HKQuery.predicateForSamples(withStart: startOfDay, end: now)
        let query = HKStatisticsQuery(quantityType: type, quantitySamplePredicate: predicate, options: .cumulativeSum) { (query, statistics, error) in
            // The results come back on an anonymous background queue.
            DispatchQueue.main.async {
                guard let statistics else {
                    print("getStepsToday failed. error: \(error?.localizedDescription ?? "nil")")
                    completion(0)
                    return
                }

                let sum = statistics.sumQuantity()
                let steps = sum?.doubleValue(for: HKUnit.count())
                completion(Int(steps ?? 0))
            }
        }
        HKHealthStore().execute(query)
    }
}

本クラスの中身である HealthKit の使い方は本記事の主旨ではない省略。後に示すPRに書く。

これらのメソッドをNative アプリ側から呼び出す。

ContentView.swift
import SwiftUI
import SamplePlugin

struct ContentView: View {
    @State var steps = 0
    
    var body: some View {
        VStack {
            Text("\(steps)")
                .font(.largeTitle)
                .fontWeight(.bold)
                .foregroundColor(.accentColor)

            Button(action: {
                HealthKitData().getStepsToday { steps in
                    self.steps = steps
                }
            }) {
...
            }
        }
        .padding()
        .onAppear {
            HealthKitData().authorize { success in
                print("authorize: \(success)")
            }
        }
    }
}

...

ここからは実機で実行するための設定。

HealthKit のプロパティを追加

スクリーンショット_2023-06-13_22_22_25.png

スクリーンショット_2023-06-13_22_25_39.png

ここの +ボタンを押して新しい行を追加。
こんな感じで適当に記述

スクリーンショット 2023-06-13 22.45.36.png

Health 「Share」 Usage Description。間違えやすいので注意。

次に Entitlements の設定

スクリーンショット_2023-06-13_22_35_24.png

スクリーンショット 2023-06-13 22.35.57.png

HealthKit が追加される。チェックは不要
スクリーンショット 2023-06-13 22.37.10.png

この操作によってアプリ名.entitlementsが作成される。

スクリーンショット 2023-06-13 22.38.28.png

また、BuildSettingsに以下が追加されていることが差分から確認できる

CODE_SIGN_ENTITLEMENTS = SamplePluginApp/SamplePluginApp.entitlements;`

HealthKit を使用する iOS の最小バージョンを Package.swift に設定しておく

Package.swift.diff
import PackageDescription

let package = Package(
    name: "SamplePlugin",
+   platforms: [
+       .iOS(.v15)
+   ],

8以上なら大丈夫だと思うけれど、適当に15と指定。

Xcode で Run。

歩数にチェックして許可。ボタンを押すと本日の歩数が取得できる。

許可画面 歩数取得
IMG_5413.PNG IMG_5414.PNG

これでNativeアプリによる歩数取得の確認はOK。

本章の変更は以下のPRで確認可能。

HealthKitData クラスのポイント

アクセス修飾子について

Swift のデフォルトのアクセス修飾子はinternal
何もつけないと Native アプリからアクセスできないためpublicをつけた。

プロパティーと Entitlements について

必要なものは以下のページに書いてある

参考

Unity で機能作成

作るものはボタンを押したら歩数をテキストに表示するというもの。

以下の手順で作っていく

  1. UIを作る。
  2. C# から呼び出すメソッドをプラグイン側で定義
  3. プラグインを呼び出す部分作成
  4. UIに反映

UIを作る

スクリーンショット 2023-06-16 21.36.12.png

このstepって書いてあるところに歩数を入れる。
ここの解説は省略。

C# から呼び出すメソッドをプラグイン側で定義

以下を追記

SamplePlugin.swift
#if os(iOS)
public typealias SamplePluginAuthorizeCompletion = @convention(c) (Bool) -> Void

@_cdecl("sample_plugin_authorize")
public func sample_plugin_authorize(_ completion: @escaping SamplePluginAuthorizeCompletion) {
    HealthKitData().authorize(completion: completion)
}

public typealias SamplePluginGetStepsTodayCompletion = @convention(c) (Int32) -> Void

@_cdecl("sample_plugin_get_steps_today")
public func sample_plugin_get_steps_today(_ completion: @escaping SamplePluginGetStepsTodayCompletion) {
    HealthKitData().getStepsToday { steps in
        completion(Int32(steps))
    }
}
#endif

HealthKit は iOSでないと呼び出せないため iOS でのみコンパイルするようにしている

PR

参考

プラグインを呼び出す部分作成

まずはframework をビルドして Unity 側にコピー。
Inspector から Add to Embedded binariesのチェックをつけることを忘れないように注意。

NativeMethods.cs
...
    internal static partial class NativeMethods
    {
#if UNITY_IOS
        const string SAMPLE_PLUGIN = "__Internal";
#else
        const string SAMPLE_PLUGIN = "SamplePluginBundle";
#endif

...

#if UNITY_IOS && !UNITY_EDITOR
        [UnmanagedFunctionPointer(CallingConvention.Cdecl)]
        internal delegate void SamplePluginAuthorizeCompletion(bool requestSuccess);

        [DllImport(SAMPLE_PLUGIN, CallingConvention = CallingConvention.Cdecl)]
        internal static extern void sample_plugin_authorize(SamplePluginAuthorizeCompletion completion);

        [UnmanagedFunctionPointer(CallingConvention.Cdecl)]
        internal delegate void SamplePluginGetStepsTodayCompletion(int steps);

        [DllImport(SAMPLE_PLUGIN, CallingConvention = CallingConvention.Cdecl)]
        internal static extern void sample_plugin_get_steps_today(SamplePluginGetStepsTodayCompletion completion);
#endif
    }
...
}


SamplePlugin.cs
...
    public class SamplePlugin
    {
...
#if UNITY_IOS && !UNITY_EDITOR
        static Action<bool> authorizeCompletion = null;

        [AOT.MonoPInvokeCallback(typeof(NativeMethods.SamplePluginAuthorizeCompletion))]
        static void OnAuthorizeCompleted(bool requestSuccess)
        {
            authorizeCompletion?.Invoke(requestSuccess);
            authorizeCompletion = null;
        }

        public static void Authorize(Action<bool> completion)
        {
            authorizeCompletion = completion;
            NativeMethods.sample_plugin_authorize(OnAuthorizeCompleted);
        }

        static Action<int> getStepsTodayCompletion = null;

        [AOT.MonoPInvokeCallback(typeof(NativeMethods.SamplePluginGetStepsTodayCompletion))]
        static void OnGetStepsTodayCompleted(int steps)
        {
            getStepsTodayCompletion?.Invoke(steps);
            getStepsTodayCompletion = null;
        }

        public static void GetStepsToday(Action<int> completion)
        {
            getStepsTodayCompletion = completion;
            NativeMethods.sample_plugin_get_steps_today(OnGetStepsTodayCompleted);
        }
#else
        public static void Authorize(Action<bool> completion)
        {
            completion?.Invoke(true);
        }

        public static void GetStepsToday(Action<int> completion)
        {
            completion?.Invoke(0);
        }
#endif
    }
}

エディタでは HealthKit 使えないため、ダミーの結果を返すようにしている。

コンパイル条件が複雑すぎるため、もうちょっとシンプルにしたほうが良さそうではある。

参考
https://neue.cc/2023/03/09-csbindgen.html

UI に反映

SampleScene.cs
using System.Collections;
using System.Collections.Generic;
using Sample;
using UnityEngine;
using UnityEngine.UI;

public class SampleScene : MonoBehaviour
{
    [SerializeField] Text label;
    bool isAuthorized = false;
    bool isProcessing = false;

    public void OnClick()
    {
        if (isProcessing)
        {
            return;
        }

        isProcessing = true;

        if (isAuthorized)
        {
            SamplePlugin.GetStepsToday(steps =>
            {
                label.text = steps.ToString();
                isProcessing = false;
            });
        }
        else
        {
            SamplePlugin.Authorize(requestSuccess =>
            {
                isAuthorized = requestSuccess;
                if (isAuthorized)
                {
                    SamplePlugin.GetStepsToday(steps =>
                    {
                        label.text = steps.ToString();
                        isProcessing = false;
                    });
                }
                else
                {
                    label.text = "Not authorized";
                    isProcessing = false;
                }
            });
        }
    }
}

authorizeのタイミングはボタンを押した時にしている。
Startとかでauthorizeしても特に問題はない。
コールバック地獄なので読みづらいが、書く時は Copilot がよしなに書いてくれるため割と楽。

ここまでできたらエディタでボタンを押して0が表示されるか確認して完了

スクリーンショット 2023-06-17 22.13.41.png

Unity で iOS ビルド

ここが本記事のメイン。(一番つまったところ)

「Native アプリ」の xcproject で手動で設定したことを整理すると以下。

  • プロパティリストに Privacy - Health Share Usage Description を追加
  • Capabilityの追加 (entitlementsファイルの設定、CODE_SIGN_ENTITLEMENTS の設定)

これを Unity から iOS ビルド後に、毎回手動で設定するのは避けたい。
だから自動化する。

BuildPostprocessor.cs に機能を追加していく。

プロパティリストに Privacy - Health Share Usage Description を追加

Unity で iOS ビルドを行うと以下に Info.plist が現れる。これを編集する。

スクリーンショット_2023-06-17_22_33_16.png

.cs
        void UpdateInfoPlist(string buildOutputPath, Dictionary<string, string> plistData)
        {
            var plistPath = Path.Combine(buildOutputPath, "Info.plist");
            var plist = new PlistDocument();
            plist.ReadFromFile(plistPath);
            var rootDict = plist.root;
            foreach (var kvp in plistData)
            {
                rootDict.SetString(kvp.Key, kvp.Value);
            }
            plist.WriteToFile(plistPath);
        }

        Dictionary<string, string> InfoPlistData()
        {
            return new Dictionary<string, string>()
            {
                { "NSHealthShareUsageDescription", "read HealthKit Data" }
            };
        }

Capability の追加

Native アプリを手動で生成された entitlements をそのまま使う。これ

Assets と同じ階層に BuildSettings フォルダを作成(この名前はなんでもいい)
その下に entitlements ファイルをコピーして Unity-iPhone という名前に rename
スクリーンショット 2023-06-17 22.40.22.png

次に、PlayerSettings > Other Settings > Configuration > Automatically add capabilities を確認

スクリーンショット_2023-06-16_22_29_55.png

チェックがついていたらこれを外す
(いい感じに設定してくれないため、自分で設定する)

あとは以下のように iOS ビルド後に設定する。

.cs
        // PBXProjectクラスによってentitlementsに「com.apple.developer.healthkit.access」を追加することができなかったため、手動で作ったものをコピーする
        void AddEntitlementsFile(string buildOutputPath, PBXProject pbxProject)
        {
            string baseEntitlementsFilePath = "BuildSettings/Unity-iPhone.entitlements";
            string entitlementsFileRelativePath = "Unity-iPhone/Unity-iPhone.entitlements";
            var entitlementsFilePath = Path.Combine(buildOutputPath, entitlementsFileRelativePath);
            File.Copy(baseEntitlementsFilePath, entitlementsFilePath);
            // Xcodeプロジェクトが読み込むentitlementsのパスを指定する
            // 競合させないために PlayerSettings > Other Settings > Configuration > Automatically add capabilities はオフにする
            pbxProject.AddBuildProperty(pbxProject.GetUnityMainTargetGuid(), "CODE_SIGN_ENTITLEMENTS", entitlementsFileRelativePath);

            // Xcodeプロジェクトを開いたときにNavigation Areaにentitlementsファイルが表示されるようにする
            string entitlementsFileProjectPath = Path.GetFileName(entitlementsFileRelativePath);
            pbxProject.AddFile(entitlementsFileRelativePath, entitlementsFileProjectPath, PBXSourceTree.Source);
        }

最後の pbxProject.AddFile は必須ではないが、これやっておかないと xcode 側のファイル欄にentitlementsが出ない。

最終的にBuildPostprocessor.csは以下のようになる。

.cs
using System;
using System.Collections.Generic;
using System.IO;
using UnityEditor;
using UnityEditor.Build;
using UnityEditor.Build.Reporting;
using UnityEditor.iOS.Xcode;
using UnityEngine;

namespace PluginSampleEditor
{
    public class BuildPostprocessor : IPostprocessBuildWithReport
    {
        public int callbackOrder { get { return 1; } }

        public void OnPostprocessBuild(BuildReport report)
        {
            var buildTarget = report.summary.platform;
            if (buildTarget == BuildTarget.iOS)
            {
                ProcessForiOS(report);
            }

            Debug.Log("BuildPostprocessor.OnPostprocessBuild for target " + report.summary.platform + " at path " + report.summary.outputPath);
        }

        void ProcessForiOS(BuildReport report)
        {
            var buildOutputPath = report.summary.outputPath;
            UpdateInfoPlist(buildOutputPath, InfoPlistData());
            UpdatePBXProject(buildOutputPath, project =>
            {
                AddEntitlementsFile(buildOutputPath, project);
                DisableBitcode(project);
            });
        }

        void UpdateInfoPlist(string buildOutputPath, Dictionary<string, string> plistData)
        {
            var plistPath = Path.Combine(buildOutputPath, "Info.plist");
            var plist = new PlistDocument();
            plist.ReadFromFile(plistPath);
            var rootDict = plist.root;
            foreach (var kvp in plistData)
            {
                rootDict.SetString(kvp.Key, kvp.Value);
            }
            plist.WriteToFile(plistPath);
        }

        Dictionary<string, string> InfoPlistData()
        {
            return new Dictionary<string, string>()
            {
                { "NSHealthShareUsageDescription", "read HealthKit Data" }
            };
        }

        void UpdatePBXProject(string buildOutputPath, Action<PBXProject> action)
        {
            var pbxProjectPath = PBXProject.GetPBXProjectPath(buildOutputPath);
            var project = new PBXProject();
            project.ReadFromFile(pbxProjectPath);
            action(project);
            project.WriteToFile(pbxProjectPath);
        }

        // PBXProjectクラスによってentitlementsに「com.apple.developer.healthkit.access」を追加することができなかったため、手動で作ったものをコピーする
        void AddEntitlementsFile(string buildOutputPath, PBXProject pbxProject)
        {
            string baseEntitlementsFilePath = "BuildSettings/Unity-iPhone.entitlements";
            string entitlementsFileRelativePath = "Unity-iPhone/Unity-iPhone.entitlements";
            var entitlementsFilePath = Path.Combine(buildOutputPath, entitlementsFileRelativePath);
            File.Copy(baseEntitlementsFilePath, entitlementsFilePath);
            // Xcodeプロジェクトが読み込むentitlementsのパスを指定する
            // 競合させないために PlayerSettings > Other Settings > Configuration > Automatically add capabilities はオフにする
            pbxProject.AddBuildProperty(pbxProject.GetUnityMainTargetGuid(), "CODE_SIGN_ENTITLEMENTS", entitlementsFileRelativePath);

            // Xcodeプロジェクトを開いたときにNavigation Areaにentitlementsファイルが表示されるようにする
            string entitlementsFileProjectPath = Path.GetFileName(entitlementsFileRelativePath);
            pbxProject.AddFile(entitlementsFileRelativePath, entitlementsFileProjectPath, PBXSourceTree.Source);
        }

        // ENABLE_BITCODE は非推奨のパラメータのためNOにする
        void DisableBitcode(PBXProject project)
        {
            var mainTargetGuid = project.GetUnityMainTargetGuid();
            project.SetBuildProperty(mainTargetGuid, "ENABLE_BITCODE", "NO");

            var unityFrameworkTargetGuid = project.GetUnityFrameworkTargetGuid();
            project.SetBuildProperty(unityFrameworkTargetGuid, "ENABLE_BITCODE", "NO");
        }
    }
}

PR

これで実機で歩数取得ができる。

許可画面 歩数取得
IMG_5431.PNG IMG_5432.PNG

補足

Plugin Inspector について

スクリーンショット 2023-06-17 22.55.33.png
...
スクリーンショット 2023-06-17 22.55.53.png

ここの HealthKit にチェックつけなくていいのか?
=> 良い。

framework からしか使っていない場合にはdependencyに追加する必要ないっぽい。

PBXProject.AddCapability を使用しない理由

Unity 2021.3.20f1では、

PBXProject.AddCapability は呼んでも何も起こらない。

ProjectCapabilityManager.AddHealthKitで生成される entitlements は期待と異なる (足りない)から。

おわりに

entitlements まわりが、Unity, Xcode ともに良い資料が見つからず手探りで探しました。
従って、これが本当に正しい手法かに関してはあまり自信ないため、もしより良いやりかたあったら教えていただけると助かります!

2
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
5