🐾

Kyome流SwiftUI×AppKitでの個人開発アーキテクチャ

2023/01/27に公開

個人開発しているmacOSアプリのメンテナンス性を向上するためにアーキテクチャの導入を進めてきました[1]が、ようやくある程度納得のいく形になったので文書化します。

アーキテクチャを導入するモチベーション

  • 機能追加の手順をテンプレート化し、思考のリソースを抑えつつ品質を担保したい。
  • SwiftUIのプレビューやテストのモックなどで依存性の注入ができるようにしたい。
  • OSSで公開しているものもあるので、第三者がコントリビューションしやすいコードにしたい。

要件

  • あくまで基本は個人開発向けのため、複雑でないこと
  • 特別なフレームワークやライブラリを必要としないこと

アーキテクチャの概要

依存の方向を一方向にするように心がけたレイヤードなアーキテクチャです。
大きくDataDomainPresentationの3つの層で構成します。

  • Data層:データの読み書きを担当
    • Entity
    • Repository
  • Domain層:ビジネスロジックを担当
    • AppModel
    • Model
    • ViewModel
  • Presentation層:UIの提供を担当
    • App
    • View

各項目の詳細

Data層:Entity

structenumなど全体で利用するデータの型を定義します。
ここでは複雑なビジネスロジックは書きません。
私はアプリ固有のエラーの種類もここで定義することにしました。

Data層:Repository

UserDefaultsやファイル(JSONやCSV)、データベースの読み書きを処理します。
また、データが更新されたことを通知する場合もあります。

RepositoryはEntity層で定義したデータの型以外には依存しません。

命名規則として、〇〇Repositoryのようにします。依存性の注入を実現するために、AnyObjectに準拠したprotocolでインタフェースを定義し、それに準拠した〇〇RepositoryImplclassで定義しデータアクセスの処理を書きます。また、プレビュー用にダミーの処理を定義した〇〇RepositoryMockもセットで用意します。

Domain層:AppModel

1つのアプリにつき1つだけ存在する特殊なModelです。
アプリ起動直後・終了直前のイベントハンドリングをすることと、一部のRepositoryModelViewのインスタンスをシングルトンで保持することが責務です。それぞれのインスタンスの生成時に依存性の注入も行います。この責務以外のことはなるべく何もさせません。

命名規則として、アプリ名+AppModelのようにします。ViewModelを初期化する際にRepositoryModelを渡すために、ObservableObjectに準拠したprotocolでインタフェースを定義します。それに準拠した〇〇AppModelImplclassで定義します。

Domain層:Model

Presentation層と直接関係のないビジネスロジックをここに書きます。

ModelRepositoryや別のModelに依存することができます。ただし、AppModelへの依存と三つ巴のように循環するような依存は禁止します。

命名規則として、〇〇Modelのようにします。依存性の注入を実現するために、AnyObjectに準拠したprotocolでインタフェースを定義し、それに準拠した〇〇ModelImplclassで定義しビジネスロジックを書きます。また、プレビュー用にダミーの処理を定義した〇〇ModelMockもセットで用意します。

Domain層:ViewModel

Viewに対応するModelとして、Viewで表示すべきデータの手配とユーザーのアクションをModelに伝達する役割を担当します。

ViewModelRepositoryModelに依存することができます。ただし、AppModelと別のViewModelへの依存は禁止します。(AppModelのもつRepositoryModelを受け取ることは許します。)

命名規則として、View名+Modelのようにします。依存性の注入を実現するために、AnyObjectまたはObservableObjectに準拠したprotocolでインタフェースを定義し、それに準拠したView名+ModelImplclassで定義しビジネスロジックを書きます。また、プレビュー用にダミーの処理を定義したView名+ModelMockもセットで用意します。

また、macOS向けの特殊なViewModelとして、WindowModelMenuBarModelも用意します。それぞれウインドウの管理とメニューバーの管理を担当します。

Presentation層:App

SwiftUIベースのアプリなら1つ存在しているアプリ名+App@mainstructです。
AppModelを保持し、WindowGroupWindowSettingsなどウインドウの定義をします。
RepositoryModelの受け渡しのために必要であれば、.environmentObject()ViewAppModelを流します。

Presentation層:View

基本的にはSwiftUIのViewです。ウインドウ内のビューヒエラルキーの中で最も低いViewには対応するViewModelを持たせます。(ViewViewModelに依存することができます。)

RepositoryModelViewModelに渡すために必要であれば、@EnvironmentObjectAppModelを持ちます。プレビューにはRepositoryMockModelMockを使います。

常駐型アプリの場合は、macOS向けの特殊なViewとしてMenuBarを用意します。

データやイベントの伝達手段

基本的に依存の方向性に対して決まる。

  • 依存先へはfuncで伝達する。
  • 依存先からの伝達はCombineで購読して受け取る。

例外的にAppModelからModelへの伝達は依存の方向性を無視してfuncを用いる。

実装例

https://github.com/Kyome22/GitGrass

https://github.com/Kyome22/ShiftWindow

脚注
  1. ほとんどのアプリがアーキテクチャのことをほとんど知らない学生時代に作ったもので、まともな設計ではありませんでした。 ↩︎

Discussion