Engineering

Vigilare の macOS と iOS で共有するコアを VigilareKit に切り出した

  • iOS
  • macOS
  • Swift
  • SwiftUI
  • Vigilare

Vigilare は macOS と iOS の両方を、同じ Apple Reminders 連携と同じタスク管理ロジックの上で動かしています。共通する部分を VigilareKit という Swift Package に切り出して、Models や Stores や Services や UseCases や ViewModels や DesignSystem を共有し、Views だけプラットフォーム別に持つ構造にしました。

vigilare.labee.devVigilare - Floating Reminders for macOS | Always VisibleNever miss a task with Vigilare's floating window for Apple Reminders. Always visible on your Mac, even in fullscreen apps. $9.99 one-time purchase.

この記事では、共通化の範囲をどこまで広げるか検討した3案、ViewModels まで共有に振った3つの判断軸、いま VigilareKit に入っているもの・入れなかったものを順に扱います。iOS 版の UI 設計そのものは Vigilare の iOS 版を作っている で扱いました。

共通化で落としたくなかったもの

Vigilare のコアは「Apple Reminders に書いたタスクを、別アプリへ切り替えずに視界に入れる」体験です。macOS と iOS を同じプロダクトとして出すなら、データソースの一致と挙動の一致だけは絶対に崩したくありません。

落としたくなかったのは次の2点です。

  • 同じ Apple Reminders を一次データソースに使うこと (アプリ内に独自のタスクストレージを作らない)
  • 同じタスク管理ロジック (フィルター、ソート、ステータス遷移、コメント、URL 検出) を両プラットフォームで同じ挙動にすること

逆に、UI 表現はプラットフォームごとに別物として持つ判断を先にしていました。macOS は常時最前面のフローティングウィンドウ、iOS は 3 tab + FAB という体験で、Views を揃えると両方の OS で妥協のある画面になります。

データソースと挙動は共有、UI 表現は別物、というラインを引くと、共通化の範囲をどこまで広げるかが次の問いになりました。

モジュール分割を整理する

レイヤーを並べると、Vigilare のコードは次のように分類できます。

レイヤー役割macOS / iOS の差
Modelsデータ構造 (Reminder, List, Status, RecurrenceRule 等)差なし
StoresEventKit のラッパー差なし
ServicesReminder, Preferences, DeepLink差なし
UseCasesフィルター・ソート・ステータス遷移・コメント差なし
ViewModelsUI 状態 + 操作ロジックは差なし、SwiftUI で View に渡るだけ
DesignSystemColor, Spacing, Typography, Animation差なし (SwiftUI Color 経由で表現)
Utilities日付計算、URL 検出、文字列処理など差なし
Platform 差吸収NSColor / UIColor のような OS 固有型のラッパーここだけ差を内側で吸収
Views画面別物 (macOS は NSWindow と IconPanel、iOS は TabView と Sheet)

Models から ViewModels までは UI から独立しているので、macOS と iOS で挙動を変える理由がありません。逆に Views は OS の前提が直接効くので、共有しようとすると両 OS で妥協のある画面ができあがります。Platform 差吸収 (例えば PlatformColor のような) の薄いレイヤーだけが、共有レイヤーと OS 固有 API の境目に立ちます。

3 つのモジュールの関係を描くと次のようになります。

graph TD
  macOS["Vigilare (macOS app target)<br/>Views, Windows, AppDelegate<br/>MCP server, Settings ViewModel"]
  iOS["VigilareiOS (iOS app target)<br/>Views, App entry"]
  Kit["VigilareKit<br/>Models / Stores / Services / UseCases<br/>ViewModels / DesignSystem / Utilities"]
  EventKit

  macOS --> Kit
  iOS --> Kit
  Kit --> EventKit

macOS app と iOS app の両 target が VigilareKit に依存し、VigilareKit が EventKit に依存します。逆方向の依存は無く、VigilareKit は macOS app / iOS app のどちらの存在も知りません。VigilareKit の内側にも OS 固有の API は出てこず、NSColor / UIColor のような差分は PlatformColor で吸収します。

共通化の範囲を3案並べた

「同じデータ・同じ挙動」を成立させるための共通化範囲には選択肢がありました。

案A Models だけ共有

Reminder などのデータ構造だけを共有モジュールに入れて、Stores 以上は macOS / iOS で別物として持つ案です。

データの型は揃いますが、EventKit との同期、フィルターやソートの挙動、ステータス遷移のルールを macOS と iOS で2回書くことになります。片側で機能を入れたら片側にも入れる、というメンテナンスが恒常的に発生します。同じ挙動を保つコストが高い割に、共有の恩恵が薄い案です。

案B Stores と Services まで共有

EventKit ラッパー (Stores) と Reminder / Preferences のサービス層まで共有モジュールに入れる案です。データ同期と外部 I/O は揃います。

ただ、フィルターやソートやステータス遷移のロジックは ViewModel か Services のどちらに持たせるかで揺れます。Services に寄せると Services が膨らみ、ViewModel に持たせると macOS と iOS で「ほぼ同じ ViewModel」を2回書く形になります。共有のラインを Services で止めた時点で、その上のレイヤーをどこに置くかが新しい問いとして出てきます。

案C ViewModels と DesignSystem まで共有

Models / Stores / Services / UseCases / ViewModels / DesignSystem を共有モジュールにまとめて、Views だけ macOS / iOS 別に持つ案です。

書く量で見ると、共有モジュールに入る行数が一番多くなります。一方で、両 OS で「同じ挙動」を保つコストは最も低い。Views 以外を共有してしまえば、画面の組み方だけが platform 別に分かれて、ロジックは1箇所に集まります。

3案を比べた結果、案 C を採用しました。共有ラインを高い位置に引くほうが、両 OS で挙動を揃え続けるコストが下がります。

ViewModels まで共有に振った3つの判断軸

案 C を選んだとき、ぶれないように立てた判断軸が3つあります。

1つ目は、UI 状態の管理ロジックも「アプリの挙動」だと捉える軸です。フィルターの適用、ソート、ステータス遷移、編集中の debounce、検索のデバウンス、といったユーザーから見える挙動は、Views ではなく ViewModel に集まります。ここを Views 側に置くと、macOS と iOS で「微妙に違うアプリ」になります。Apple Reminders を扱う1つのアプリとして出す以上、こういう微妙な違いは作らないと決めました。

2つ目は、SwiftUI の View / ViewModel 境界が両 OS で同じだったことです。SwiftUI 自体が macOS と iOS で同じ API を提供するので、ViewModel を共通化しても OS 固有のラッパーがほぼ要りません。NSColor / UIColor のような OS 固有型に触る部分だけ薄いラッパーで吸収すれば済みます。共通化のコストが想定より低かったのが、案 C に踏み込めた直接の理由です。

3つ目は、共通化のラインを高い位置に引くほど、両 OS で挙動を揃えるコストが下がる、という整理です。低い位置に引くと、Stores と Services の上に Views ごとに「ほぼ同じ ViewModel」を書く羽目になります。書く量を増やしても挙動の一致が下がる、という設計は選びにくい。書く量と一致のしやすさが逆相関にならない位置を選んだ結果が ViewModels までの共有でした。

VigilareKit にいま入っているもの・入れなかったもの

切り出しの実体は1コミットの大きな移動でした。Vigilare/Models, Vigilare/Services, Vigilare/Stores, Vigilare/ViewModels, Vigilare/DesignSystem などが VigilareKit/* に移り、xcodeproj 上のターゲット構成は次のようになりました。

Vigilare.xcodeproj/
├── Vigilare/             # macOS app target — Views, Windows, AppDelegate, MCP server, Settings ViewModel
├── VigilareiOS/          # iOS app target — Views, App entry
└── VigilareKit/          # 両 target が import する共通ドメイン層

VigilareKit に入っているのは Models / Stores (EventKit ラッパー) / Services / UseCases / ViewModels / DesignSystem / Utilities / Extensions と、Platform 差を吸収する PlatformColor です。一方で、macOS 固有の MCP server (Claude Code などから Apple Reminders を操作する用の Stdio transport) と Settings 系の ViewModel は macOS app target 側に残しました。MCP の Stdio transport は macOS と iOS でランタイム制約が違うため、ここを慌てて共通化する案ではありません。

FloatingReminderViewModel は名前こそ macOS の floating window 由来ですが、iOS の3 tab + FAB 構成からもそのまま使っています。

入れなかったのは、macOS の NSWindow / IconPanel まわり、iOS の TabView / Sheet 構成、プラットフォーム固有のメニューや Keyboard Shortcut の配線です。これらは OS 固有の API を直接触る場所で、共通化しても得るものがありません。Vigilare 側 (macOS) と VigilareiOS 側 (iOS) にそれぞれ残しました。

NSColor と UIColor の差は PlatformColor という薄いラッパーで吸収しています。SwiftUI Color に橋渡しするだけのレイヤーで、ここに OS 固有のロジックは置きません。共有レイヤーと OS 固有 API の境目を、できるだけ薄く・できるだけ少なくしておく方針です。

いま残っている課題

VigilareKit に切り出した後にも、手を入れる余地が残っています。

1つは名前の整理です。FloatingReminderViewModel の名前が AddReminderViewModel や EditReminderViewModel のような用途に直結した名前と並ぶと、Floating だけ旧称のまま残っているのが目立ちます。リネームすると参照箇所の差分が大きいので後回しにしていますが、追加 ViewModel を作るたびに名前のずれが大きくなる方向の負債です。

2つ目は Utilities の分類です。Converters / DateHelpers / Detectors / Filters / Formatters / Parsers / Sorting / Validators と細かく切ってあるものの、ファイル間で目的が近いものが混ざっている部分があり、共通化の境界線を後で再整理する予定です。Models や UseCases に寄せるべきものが Utilities に紛れて入っているケースも出てきていて、レイヤー分割としては緩い状態です。