癒し系のアプリを個人開発で長く運用していると、ユーザーから「今日どれだけ歩いたかを、ホーム画面の壁紙と一緒にそっと見せてほしい」といった要望が届きます。数字でせかすのではなく、静かに寄り添う見せ方をしたい。そう考えたとき、自前で歩数を数えるのではなく、iPhone がすでに記録しているヘルスデータを借りる方が筋が良いと感じました。
Rork Max はブラウザ上でネイティブ Swift アプリを生成できるため、React Native では一手間かかる HealthKit のような Apple 純正フレームワークにも素直に手が届きます。ここでは、生成されたコードに HealthKit 連携を足していく過程を、権限設計から本番でデータが取れないときの切り分けまで通しで整理します。
なぜ React Native ではなく Rork Max を選ぶ場面なのか
HealthKit は Swift / Objective-C から直接触ることを前提に設計されたフレームワークです。Expo(React Native)でも react-native-health のようなブリッジ経由で扱えますが、ヘルスデータの種類が増えるたびにブリッジの対応状況に振り回されます。私自身、Expo ベースの検証アプリで心拍変動(HRV)を扱おうとして、ブリッジが該当の HKQuantityType を公開しておらず手詰まりになった経験があります。
Rork Max が生成するのは素のネイティブアプリなので、HealthKit を import すれば Apple が公開している型にそのままアクセスできます。歩数・睡眠・心拍・ワークアウトまで、ブリッジの実装待ちを気にせず使えるのは大きな利点です。一方で、ネイティブだからこそ権限まわりとバックグラウンド配信の作法を正しく踏まないと、審査でも本番でも静かに失敗します。そこを丁寧に押さえていきます。
Step 1: Capability と Info.plist を最小範囲で設定する
最初にやるのは権限の宣言です。Rork Max のプロジェクト設定で HealthKit の Capability を有効化したうえで、使用目的を Info.plist に書きます。ここで欲張って多くの型を要求すると、App Store の審査で「なぜこのアプリに必要なのか」を問われ、リジェクトの原因になります。
< key >NSHealthShareUsageDescription</ key >
< string >歩いた距離をホーム画面のテーマと一緒に振り返るために、歩数と睡眠の記録を読み取ります。</ string >
< key >NSHealthUpdateUsageDescription</ key >
< string >アプリ内で記録したリラックス時間を、ヘルスケアのマインドフルネス記録として保存します。</ string >
ポイントは、説明文に「何を」「何のために」読むかを具体的に書くことです。「ヘルスデータを使用します」のような抽象的な文面は審査で弾かれやすく、私の場合、初回提出で NSHealthShareUsageDescription の文面が曖昧だという指摘を受けて差し戻されました。ユーザーの許可ダイアログにそのまま表示される文章なので、誠実に書くことが信頼にもつながります。
Step 2: 読み取りと書き込みの権限を分けて要求する
HealthKit の権限は「読み取り」と「書き込み」で別物です。歩数は読むだけ、瞑想時間は書き込むだけ、というように、型ごとに必要な方向だけを要求します。
import HealthKit
final class HealthStore {
let store = HKHealthStore ()
// 読み取りたい型と書き込みたい型を分けて定義する
private let readTypes: Set <HKObjectType> = [
HKQuantityType (.stepCount),
HKQuantityType (.heartRate),
HKCategoryType (.sleepAnalysis)
]
private let writeTypes: Set <HKSampleType> = [
HKCategoryType (.mindfulSession)
]
func requestAuthorization () async throws {
guard HKHealthStore. isHealthDataAvailable () else {
throw HealthError.unavailable // iPad など非対応端末で必ず確認する
}
try await store. requestAuthorization ( toShare : writeTypes, read : readTypes)
}
}
enum HealthError : Error { case unavailable , denied , noData }
isHealthDataAvailable() のチェックを省くと、HealthKit 非対応の端末(一部の iPad 構成など)でクラッシュします。私は本番のクラッシュログでこれに気づきました。最初から入れておくべきガードです。
Step 3: 歩数を読み取って「今日の合計」を出す
権限を得たら、実際に歩数を集計します。HealthKit はサンプルの集合を返すため、合計を出すには HKStatisticsQuery を使うのが素直です。
extension HealthStore {
func todayStepCount () async throws -> Int {
let type = HKQuantityType (.stepCount)
let start = Calendar.current. startOfDay ( for : Date ())
let predicate = HKQuery. predicateForSamples ( withStart : start, end : Date ())
return try await withCheckedThrowingContinuation { continuation in
let query = HKStatisticsQuery (
quantityType : type,
quantitySamplePredicate : predicate,
options : .cumulativeSum
) { _ , stats, error in
if let error { continuation. resume ( throwing : error); return }
let steps = stats ? . sumQuantity () ? . doubleValue ( for : . count ()) ?? 0
continuation. resume ( returning : Int (steps))
}
self .store. execute (query)
}
}
}
ここで ?? 0 を返している点が実務的に重要です。権限を許可していても、その日にまだ歩いていなければサンプルは 0 件で返ります。これを「権限エラー」と取り違えると、無駄なリトライやエラー表示でユーザーを不安にさせてしまいます。「データが無い」と「権限が無い」は別物として扱う設計にしておきます。
Step 4: アプリを閉じている間も歩数を取り込む
癒し系アプリの体験として大切なのは、ユーザーが開いた瞬間に最新の数字がそろっていることです。そのためには、アプリが前面にいない間も歩数の更新を受け取る仕組みが要ります。HealthKit では HKObserverQuery と Background Delivery を組み合わせます。
extension HealthStore {
func startStepObserver ( onUpdate : @escaping () -> Void ) {
let type = HKQuantityType (.stepCount)
let observer = HKObserverQuery ( sampleType : type, predicate : nil ) { _ , completion, error in
if error == nil { onUpdate () }
completion () // ← これを呼ばないと iOS が更新を止めてしまう
}
store. execute (observer)
store. enableBackgroundDelivery ( for : type, frequency : .hourly) { success, error in
if let error { print ( "background delivery failed: \( error ) " ) }
}
}
}
completion() の呼び忘れは、私が最も時間を溶かしたハマりどころです。これを呼ばないと iOS は「アプリが更新を処理しきれていない」と判断し、以降のバックグラウンド配信を黙って止めます。テスト中は動いていたのに本番でだんだん更新が来なくなる、という厄介な症状の正体がこれでした。配信頻度も .hourly 程度に抑えるのが現実的で、.immediate を指定するとバッテリー消費とのバランスが崩れます。
Step 5: アプリ内のリラックス時間を書き戻す
読み取りだけでなく、アプリ内で計測した瞑想・呼吸セッションを「マインドフルネス」としてヘルスケアに書き戻すと、ユーザーの記録が一箇所に集まり満足度が上がります。
extension HealthStore {
func saveMindfulSession ( start : Date, end : Date) async throws {
let type = HKCategoryType (.mindfulSession)
let sample = HKCategorySample (
type : type,
value : HKCategoryValue.notApplicable. rawValue ,
start : start,
end : end
)
try await store. save (sample)
}
}
書き込み権限は、ユーザーが拒否していてもエラーにならず「保存したことにする」挙動になる点に注意が必要です。Apple はプライバシー保護のため、書き込み権限の有無をアプリから判別できないようにしています。そのため、保存後に読み返して確認するのではなく、書き込みは「成功した前提で UI を進める」設計が正しい振る舞いになります。
本番で「データが 0 件」のときに何を疑うか
リリース後にいちばん多く届くのが「許可したのに数字が出ない」という声です。私はこの切り分けを次の順序で行うようにしています。第一に、端末の設定アプリ → プライバシー → ヘルスケアで該当アプリの読み取りが実際に ON か。ユーザーが許可ダイアログで一部だけオフにしているケースが想像以上に多いです。第二に、その日の実データが本当に存在するか。深夜に問い合わせが来た場合、単純にまだ歩いていないだけのことがあります。第三に、isHealthDataAvailable() が false を返す端末ではないか。これらを UI 上のメッセージで切り分けられるようにしておくと、サポート対応が一気に楽になります。
収益の観点でも、ヘルス連携は無料機能として広く開放し、AdMob の通常配信で支えつつ、詳細な振り返りや長期グラフを月額メンバーシップに置く構成が相性良く感じています。健康データそのものを課金の人質にするのは、誠実さの面でも審査の面でも避けるべきだと考えています。
バックグラウンド配信の頻度はどう決めるか
配信頻度の設計は、体験の鮮度とバッテリー消費のトレードオフです。私の癒し系アプリでは、歩数の更新は .hourly を基準にしています。理由は、1日のうちユーザーがアプリを開くのはせいぜい2〜3回で、分単位の鮮度は体験にほとんど寄与しないからです。検証端末で .immediate と .hourly を1週間ずつ比較したところ、体感のバッテリー消費は無視できない差になり、レビュー欄でも電池持ちへの不満は売上に直結する離脱要因でした。
判断の目安として、私は次の基準を推奨しています。ユーザーが能動的に数字を見にくる「振り返り型」のアプリなら .hourly で十分です。逆に、目標達成を即時通知したい「リマインド型」なら配信頻度を上げる代わりに、対象を歩数1種類に絞ってバッテリー影響を抑えます。HealthKit は監視する型を増やすほどバックグラウンドの起動回数が増えるため、型の数と頻度は常にセットで考える、というのが本番運用で固まった私の方針です。
次の一手
まずは歩数の読み取りひとつだけを、上記の Step 1〜3 の範囲で本番に出してみることをおすすめします。HealthKit は要求する型を増やすほど審査の説明責任が重くなるので、最小構成で出してユーザーの反応を見てから睡眠・心拍へ広げる順序が、個人開発では無理がありません。私自身、最初の一本は歩数だけで出し、反応を見ながら睡眠を足していきました。同じようにヘルス連携で静かな体験を作ろうとしている方の役に立てば幸いです。