🏥

React Native でヘルスケアを利用する設計パターンについて考えてみた

に公開

この記事は Ubie Tech Advent Calendar 2025 の 7日目の記事です。

https://adventar.org/calendars/12070

はじめに

React Native × Expo でヘルスケア機能を実装したときの話をまとめておこうと思います。

React Native でサードパーティサービスを統合するとき、実装パターンは大きく2つに分かれます。

  • 統一SDKがある場合(Firebase, Adjust など)
    • OS差異はSDKが吸収してくれるため、import して使うだけでOK🙆
  • 統一SDKがない場合(HealthKit / Health Connect)
    • OSごとにAPIも思想も全く異なるため、自前で吸収する必要がある

この記事では、統一SDKが存在しないヘルスケア機能を実装するにあたって辿り着いた3層アーキテクチャについて書いていきます。

OSによる「思想」の違い

単に「メソッド名が違う」程度であれば、if (Platform.OS === 'ios') で分岐すれば済む話ですが、「データの返り方(レスポンス構造)」「前提となる思想」 が根本的に異なるので、「ちゃんと設計しないと破綻するな...」となり、この形に落ち着きました。

具体的に、「時間ごとの歩数を取得して表示する」 というケースで説明します。

直感的な期待としては、

「開始時間と終了時間を指定すれば、その間のデータを配列でくれるんじゃない?」

と思っていました。しかし、その「配列の中身」に対するアプローチが、両OSで異なりました。

Android (Health Connect) の場合

Androidは比較的シンプルで、期間を指定すると、開始・終了時刻が含まれたデータが返ってきます。

// Android: 期間を指定して集計 (aggregateGroupByDuration)
const results = await readRecords({
  timeRangeFilter: { 
    operator: 'between', 
    startTime, 
    endTime 
  },
  aggregateMetric: 'Steps',
  groupBy: 'Duration', // 1時間ごと、などでグルーピング
});

// [
//   { startTime: "10:00", endTime: "11:00", count: 100 }, ...
// ]

iOS (HealthKit) の場合

一方iOSは、「期間」だけでなく、「アンカー(基準時刻)」 と 「インターバル(間隔)」 を指定して統計クエリ(HKStatisticsCollectionQuery)を作成・実行する必要があります。

// iOSの実装イメージ (@kingstinct/react-native-healthkit)
const results = await HealthKit.queryStatisticsCollectionForQuantity(
  quantityType,
  ['cumulativeSum'], // 計算方法を指定
  anchorDate,        // 基準時刻(ここからバケットが作られる!)
  { hour: 1 },       // インターバル
  predicate          // 期間フィルタ
);

// [
//   { sumQuantity: { quantity: 100 } }, ... // 時刻情報が明示的ではない
// ]

そのため、iOSでは取得した配列のインデックスと「アンカー(基準時刻)」を使って、各データの時刻を自前で計算する必要があります。

特に注意が必要なのは 「アンカーの指定」 です。例えば、13:30からデータを取りたい場合でも、アンカーを正しく設定(例: 00:00)しないと、「13:30〜14:30」のような中途半端な区切りで集計されてしまい、Android側の「13:00〜14:00(正時区切り)」のデータと整合性が取れなくなってしまいます・・・

3層アーキテクチャ

OS固有の複雑さを完全に分離するため、以下の3層アーキテクチャを採用しました。

Application Code(アプリケーション層)

  • 「何が欲しいか」だけに注力できる。(歩数、身長、体重 など)
  • OSごとのデータ構造の違いや、権限の複雑さを気にすることなく利用できる層です。

Abstraction Layer (Interface Definition)

  • 「歩数データは必ずこの型で返す」という共通インターフェースを保証し、リクエストを適切なAdapterへ振り分けます。
// platform/getSteps.ts
export const getSteps = async (start: Date, end: Date) => {
  if (Platform.OS === 'ios') {
    return await getStepsIOS(start, end);
  }
  // Android特有の権限チェックなどはここで吸収せず、Android層に任せるか、フローを制御する
  return await getStepsAndroid(start, end);
};

Implementation Layer (Adapter Pattern)

  • 「どうやるか」を解決し、データを正規化する層です。
  • iOS/Androidそれぞれの固有SDK(HealthKit/Health Connect)と対話し、バラバラな形式で返ってくる生データを、共通の型に変換して返します。
// ディレクトリ構造はこんな感じです:
src/libs/health/
├── platform/      # プラットフォーム抽象化層
│   └── getSteps.ts
├── ios/           # iOS実装層(HealthKitを叩く)
│   └── steps.ts
├── android/       # Android実装層(Health Connectを叩く)
│   └── steps.ts
└── types.ts       # 共通型

この設計のメリット

1. 変更に強い

iOSの仕様が変わったとしても、修正するのは ios/ ディレクトリだけで済みます。Androidのロジックを壊す心配がありません。

2. メモリ効率とInline Requires

React Nativeの Metro Bundler には Inline Requires という機能があり、import を実際に使う瞬間まで遅延させることができます。

ファイルを物理的に分割し、必要なOSでのみ読み込むようにすることで、起動時のメモリ消費を最小限に抑えることができます。

// platform/getSteps.ts
export const getSteps = async (start: Date, end: Date) => {
  if (Platform.OS === 'ios') {
    // 実際にこの行が実行されるまで、ios/steps.ts はメモリに展開されない
    const { getStepsIOS } = require('../ios/steps');
    return await getStepsIOS(start, end);
  }
  // ...
};

3. テストしやすい

platform/ 層をモックすれば、実機依存の強いヘルスケア機能でも、ビジネスロジックのテストが容易になります。

4. コードの見通しが良くなる

単一ファイルに両OS分のロジックを書くこともできます。実際、Expo の Platform Shaking により、Platform.OSで条件分岐しても各プラットフォーム向けビルドから不要なコードは削除されます。

ただ、それぞれのロジックが肥大化すると、単一ファイルだと可読性が下がってしまいます。
ファイルを分割することで、各OS固有のロジックに集中でき、レビューしやすく、ファイル名で責務が明確になります。

おわりに

統一SDKが存在しないヘルスケア開発において、3層アーキテクチャは「過剰設計」ではなく、「持続可能な開発のための設計」 だと思っています。
コストや時間を気にしなければもっとシンプルに実装できたかもしれませんが、長期的な保守性を考えるとこの形に落ち着いて良かったなと感じています。

Ubie テックブログ

Discussion