musicLineアプリ開発日記

作曲を誰でも楽しく簡単に♪

Observation (iOS17+) の注意点

今回は監視フレームワーク・通知機構のObservationの話。
Observationを使用してメモリが圧迫する状況があったので調査しました。

Observationでメモリが圧迫



はじめに

musicLineではMIDI楽譜を編集するためのデータ構造として、トラックやフレーズ、音符、座標というように階層的にモデルを構築しています。
例えば、曲データは複数のトラック、トラックは複数のフレーズを保持しているような構造です。

https://cdn-ak.f.st-hatena.com/images/fotolife/m/musicline_developer/20230513/20230513152016.png
データ階層

詳しくはこちら


このような階層的なデータ構造だと、Viewへ変更通知を送る場合に監視は自身のプロパティだけでなく、下層モデルのプロパティまで広がるため、適切な監視体制が求められます。


Combineの問題点

CombineのObservableObjectでは@Publishedを付与したプロパティが監視対象となりますが、変更通知はインスタンスを保持している上層にしか届きません。
そのため、Viewの層(@ObservedObject@StateObject)まで通知を届けるとなると、親のモデルもObservableObjectにして、通知を繋ぎ込む(リレー的に監視する)必要がありました。

// 子を監視して、子からの変更通知があれば上へ流す
counter.objectWillChange.sink{
    self.objectWillChange.send()
}.store(in: &anyCancellable)

詳しくはこちら


Observationで解決?

iOS17でObservationを使えるようになりました。
CombineのObservableObjectはクラス毎に変更を監視することに対し、Observationの@Observableではプロパティ毎に監視しています。
そのためどれだけModel階層が深くても、Viewでプロパティを使用すれば通知が届くようになりました。

つまり、Combineのように通知を繋ぎ込むことなくViewの層まで変更通知を届けられるため、中間の煩雑なコードが削減できます。

qiita.com

またmusicLineでは、音符やフレーズの追加・削除が頻繁にされるため、その度に繋ぎこむ処理が必要なので監視対象の管理も大変でした。
この動的な監視対象の管理問題もObservationにより解決できましたが、新たな問題がありました。


Observationの問題点

@Observableにより末端モデルの音符を監視します。
移動した音符は黄色になっており、通知が適切にViewに届くことを確認できます。
しかしスクロールするたびに、メモリが増え続ける問題がありました。
試しに、1トラック300フレーズの中に音符約1000個を敷き詰めてメモリ量を確認しました。

Observationでメモリ使用量が増加

スクロールで画面を再描画する度にメモリが上がり続けることがわかります。iPhoneのメモリは8GBとか16GBなので、これは見逃せる増加量ではないですね。

そもそも、音符を配置しただけでメモリが1GBを超えているのも問題です。
ここまで音符を敷き詰めることは稀ではありますが、メモリはなるべく少なく抑えたいところです。

音符は4個のプロパティ(位置とサイズ等)があるため、120万(300 x 1000 x 4)のプロパティを監視している計算になります。
この時点で120万のプロパティを全て監視しようとする使い方が間違えているなあという感じはしました。


そして、Observationの挙動調査へ

musicLineの音符のように膨大なプロパティを監視する状況では、Observationの使用は不適切だと認識しました。(メモリ増加問題を除いても)
しかし他の場面(コミュニティの曲リスト等)で使用できるか確認したかったので、どういうパターンの時にメモリ増加問題が起こるかを調査しました。

なおmusicLineのモデル実装が悪い可能性があるので、Observationの最小限サンプルを作成しました。




Observationの挙動調査

調査では通知を上層へ繋ぐことを想定して、MVVMでCounterを実装しました。ただしメモリ増加がわかりやすくなるように、1000個のCounterViewをリストで表示しています。
また比較対象にCombineでも同様に実装しました。

CounterView

CounterViewのリスト


メモリが安定するパターン

Observation(上)とCombine(下)

30MBから始まり、下へスクロールすることで45MBまで増加しますが、その後は何度かスクロールを繰り返しても50MB前後で安定しました。
最初の下スクロールでメモリ増加する理由は、Listで表示しているので最初のスクロールでCounterViewCounterViewModelを作成していると思われます。
その後はCounterViewの作成は行われないためメモリが安定します。


Observationの実装

View

struct Page: View {
    let counters = (0..<1000).map{ _ in Counter() }
    var body: some View {
        List(0..<1000) { num in
            let counter = counters[num]
            CounterView(label: "Counter\(num)", viewModel: .init(counter: counter))
        }
    }
}
struct CounterView: View{
    let label: String
    let viewModel: CounterViewModel
    
    var body: some View {
        HStack {
            Text("\(label):")
            Text("\(viewModel.count)")
            Button("+") { viewModel.increment() }
        }
    }
}

ViewModel

class CounterViewModel {
    var count: Int {
        counter.count
    }
    private let counter: Counter

    init(counter: Counter) {
        self.counter = counter
    }
    
    func increment() {
        counter.increment()
    }
}

Model

@Observable
class Counter {
    var count = 0

    func increment() {
        self.count += 1
    }
}


Combineの実装

View

struct CounterView: View{
    ...
    @ObservedObject var viewModel: CounterViewModel
    ...
}

ViewModel

class CounterViewModel: ObservableObject {
    ...
    private var anyCancellable = Set<AnyCancellable>()
    
    init(counter: Counter) {
        self.counter = counter
        counter.objectWillChange.sink{
            self.objectWillChange.send()
        }.store(in: &anyCancellable)
    }
}

Model

class Counter: ObservableObject {
    @Published var count = 0
    ...
}



メモリ増加になるパターン

前のパターンでは下スクロール後はCounterViewの作成も再描画も行われませんでした。
次はCounterViewの再描画が頻繁に行われるパターンで検証してみます。
今回はGeometryReaderにより、スクロールに応じてアニメーションを付けることで、頻繁に再描画する状況を作りました。

struct CounterView: View{
    let label: String
    let viewModel: CounterViewModel
    
    var body: some View {
        GeometryReader{ geometry in
            let shift = sin(geometry.frame(in: .global).minY / geometry.size.height) * 10
            let x = geometry.size.width * 0.5 - shift
            let y = geometry.size.height * 0.5
                HStack {
                    Text("\(label):")
                    Text("\(viewModel.count)")
                    Button("+") { viewModel.increment() }
                }
                .position(x: x, y: y)
    }
}

Observation(上)とCombine(下)

そうすると、Observation実装の方ではスクロールごとにメモリがどんどん使われることがわかります。どうやらObservableを付与したプロパティのGetterへ頻繁にアクセスすることが原因のようです。

追加検証

試しに、Getterのアクセス(@Observableのaccessの呼び出し)を制限してみます。

@Observable
class Counter {
    var count: Int{
        get {
            if !passingAccess{
                passingAccess = true
                access(keyPath: \._count )
            }
            return _count
        }
        
        set {
            passingAccess = false
            withMutation(keyPath: \._count ) {
                _count  = newValue
            }
        }
    }

    @ObservationIgnored
    var _count = 0

    @ObservationIgnored
    var passingAccess = false
}

accessの呼び出しを制限してメモリ増加を抑える

そうすると、メモリ増加が収まりました。
ただし、この方法では適切な通知はできないようです。

スクロール後の通知はされない



Observation注意点のまとめ

GeometryReaderで座標によりアニメーションするような場合はObservationによるプロパティ監視は控えた方がいいです。そのような場合はViewの再描画によりGetterに頻繁にアクセスしますが、それがメモリ増加が止まらない問題に繋がっていると考えられます。
ObservationではプロパティのGetterで監視イベントを登録するような仕組みになっているため、このような現象が起こるかもしれません。

そもそもSwiftUIでは必要のないViewの再描画を極力抑える思想なので、再描画が頻繁に行われる設計が問題なのかもしれません。
でもスクロールによるアニメーション等で再描画が頻繁に行われるケースは結構ありそうな気もします。




監視フレームワークを再考

Observationの問題がわかりましたが、結局musicLineの音符のように膨大なプロパティを監視するための適切なフレームワークはどうするべきでしょう。もう一度Combineを使う方針に戻って考えてみます。

Combineでは次の観点で問題になることがあります。

  • メモリの圧迫
  • 監視対象の管理が大変


これは特に

  • モデル階層が深い
  • 監視するプロパティが膨大
  • 監視対象が動的に変化

といった特徴のときに起こります。


EventBus

Combineは膨大なモデルを全てObservableObjectとして監視するため、メモリが圧迫します。さらに、階層が深くなることで通知を繋ぎこむ処理が必要になります。

この問題を解消するためにObservableObjectは1個のオブジェクトのみにしました。つまり監視するオブジェクトはEventBusのみにして、EventBusを仲介して通知を送ります。

greenrobot.org

EventBusを介して通知

中間の通知を繋ぎこむコードがなくなり、メモリもCombineの実装より3MBほど軽減されました。

ただし、EventBusだと不必要なViewを再描画してしまう欠点があります。EventBusは監視を1箇所にまとめるため、カウントアップしたCounterViewのみを再描画することはできず、再描画するときは必ず画面全体になります。またEventBusは通知を飛ばせるので便利ですが、基本的にはモデルの構造を無視するシングルトンみたいなものなので、あまり多用すると処理が追えなくなるのも注意です。


実装

EventBusの実装を単純化するためにシングルトンでDIしています。

View

struct Page: View {
    ...
    @StateObject private var eventBus = EventBus.shared
    ...
}

Model

class Counter {
    var count = 0 {
        willSet {
            eventBus.send()
        }
    }
    private let eventBus = EventBus.shared
}

class EventBus: ObservableObject {
    static let shared = EventBus()
    
    func send(){
        objectWillChange.send()
    }
}


なおサンプルでは簡易的な実装ですが、Eventの種類やプロパティを送れるようにSwiftで実装しているライブラリがありました。

swiftpackageindex.com


検証用のサンプル全コードを表示

import SwiftUI

struct Page_Observation: View {
    let counters = (0..<1000).map{ _ in Counter_Observation() }
    var body: some View {
        content(CounterView_Observation.self, counters: counters)
    }
}

struct Page_Combine: View {
    let counters = (0..<1000).map{ _ in Counter_Combine() }
    var body: some View {
        content(CounterView_Combine.self, counters: counters)
    }
}

struct Page_EventBus: View {
    let counters = (0..<1000).map{ _ in Counter_EventBus() }
    @StateObject private var eventBus = EventBus.shared
    
    var body: some View {
        content(CounterView_EventBus.self, counters: counters)
    }
}

fileprivate func content<TCounterView: CounterView, TCounter: Counter>( _: TCounterView.Type, counters: [TCounter]) -> some View where  TCounter == TCounterView.TViewModel.TCounter {
    List(0..<1000) { num in
        let counter = counters[num]
        TCounterView(label: "Counter\(num)", viewModel: .init(counter: counter))
            .buttonStyle(.borderless)
    }
}
import SwiftUI

protocol CounterView: View {
    associatedtype TViewModel: CounterViewModel

    var label: String { get }
    var viewModel: TViewModel { get }
    
    init(label: String, viewModel: TViewModel)
}

struct CounterView_Observation: CounterView{
    let label: String
    let viewModel: CounterViewModel_Observation
    
    var body: some View {
        animationContent(label: label, viewModel: viewModel)
    }
}

struct CounterView_Combine: CounterView{
    let label: String
    @ObservedObject var viewModel: CounterViewModel_Combine
    
    var body: some View {
        animationContent(label: label, viewModel: viewModel)
    }
}

struct CounterView_EventBus: CounterView{
    let label: String
    let viewModel: CounterViewModel_EventBus
    
    var body: some View {
        animationContent(label: label, viewModel: viewModel)
    }
}

fileprivate func animationContent(label: String, viewModel: some CounterViewModel) -> some View{
    GeometryReader{ geometry in
        let shift = sin(geometry.frame(in: .global).minY / geometry.size.height) * 10
        let x = geometry.size.width * 0.5 - shift
        let y = geometry.size.height * 0.5
        content(label: label, viewModel: viewModel)
            .font(.body.monospacedDigit())
            .position(x: x, y: y)
    }
}

fileprivate func content(label: String, viewModel: some CounterViewModel) -> some View{
    HStack {
        Text("\(label):")
        Button("-") { viewModel.decrement() }
        Text("\(viewModel.count)")
        Button("+") { viewModel.increment() }
    }.font(.body.monospacedDigit())
}
import SwiftUI
import Combine

protocol CounterViewModel {
    associatedtype TCounter: Counter
    
    var counter: TCounter { get }
    
    init(counter: TCounter)
}

extension CounterViewModel {
    
    var count: Int {
        counter.count
    }
    
    func increment() {
        counter.increment()
    }
    func decrement() {
        counter.decrement()
    }
}

class CounterViewModel_Observation: CounterViewModel {
    
    let counter: Counter_Observation
    
    required init(counter: Counter_Observation) {
        self.counter = counter
    }
}

class CounterViewModel_Combine: ObservableObject, CounterViewModel {

    let counter: Counter_Combine
    private var anyCancellable = Set<AnyCancellable>()
    
    required init(counter: Counter_Combine) {
        self.counter = counter
        counter.objectWillChange.sink{
            self.objectWillChange.send()
        }.store(in: &anyCancellable)
    }
}

class CounterViewModel_EventBus: CounterViewModel {
    
    let counter: Counter_EventBus
    
    required init(counter: Counter_EventBus) {
        self.counter = counter
    }
}
import Foundation

protocol Counter: AnyObject {
    var count: Int { get set }
}

extension Counter {
    func increment() {
        self.count += 1
    }

    func decrement() {
        self.count -= 1
    }
}

@Observable
class Counter_Observation: Counter {
    var count = 0
}

class Counter_Combine: ObservableObject, Counter {
    @Published var count = 0
}

class Counter_EventBus: Counter {
    var count = 0 {
        willSet {
            eventBus.send()
        }
    }
    private let eventBus = EventBus.shared
}


class EventBus: ObservableObject {
    static let shared = EventBus()
    
    private init() {}
    
    func send(){
        objectWillChange.send()
    }
}




おわりに

今回はObservationの挙動について調査して注意点をまとめました。
Observationは監視フレームワークとして便利ですが、現時点ではGetterに頻繁にアクセスするような場合はメモリ増加が止まらない問題があるようです。
この問題を除いても、監視対象が膨大にある状況では全てプロパティを監視することになるのでメモリ圧迫に繋がります。 その場合は、従来通りCombineやEventBusのような方法を考えるといいと思います。

今回の結果を踏まえて、musicLineにEventBusの仕組みで通知機構を組み込んだのがこちら

改善後のメモリ

なんということでしょう。
1GB以上使用していたメモリが100MB以下に抑えられています。
フレームワークは慎重に選ばないと大変なことになることがわかりました。
ともかく問題が解決してよかった。



SwiftUIでダイアログ

今回はSwiftUIでダイアログを表示する話。
様々なダイアログを表示してモデルのプロパティを変更できる機構を考えます。

ダイアログの動作



はじめに

musicLineではMIDI楽譜を編集するために様々なダイアログ(ポップアップでプロパティを設定するView)を表示します。

例えば、

  • 曲名やテンポの変更
  • 楽器の変更
  • 編集トラックの変更
  • フレーズの追加 等

ダイアログの使用例

AndroidではDialogFragmentの継承することでダイアログをカスタマイズしてました。
ダイアログを表示する際は、.showメソッドによりFragmentManagerに加えて表示していました。

iOSでも同じような使い方ができるようにSwiftUIで実装しました。


ちなみに、そこまでダイアログのデザインや挙動を制御する必要がないのであれば、モディファイア.confirmationDialog.sheetも使用できます。

capibara1969.com

blog.code-candy.com



ダイアログの仕様

今回の想定するダイアログの仕様です。

  1. ヘッダーに X ボタンとタイトルを表示
  2. X ボタンかダイアログ外をタップして閉じる
  3. ダイアログの中身を変更できるようにする

ダイアログの仕様

またダイアログは、どこからでも呼び出せるようにします。

myDialog.open(model: instance)



実装

ダイアログの動作

今回は各々のボタンで異なるダイアログが表示されるViewを作ってみました。


共通ダイアログ

まず、仕様を満たす抽象的なダイアログとそのモデルを定義します。

struct Dialog<TView: View, TDialogModel: DialogModelable>: View {
    
    let dialogModel: TDialogModel
    @ViewBuilder let content: () -> TView
    ...

    var body: some View {
        if dialogModel.isOpen {
            ZStack{
                // 黒背景(タップで閉じる)
                blackScreen
                
                // ダイアログの中身
                content()
                    ...
            }
            .zIndex(1)
        }
    }
    
    private var blackScreen: some View {
        Color.black
            .opacity(0.3)
            .ignoresSafeArea()
            .onTapGesture {
                dialogModel.close()
            }
            .contentShape(Rectangle())
    }
    ...

}
protocol DialogModelable{
    var title: String { get }
    var isOpen: Bool { get }
    func close()
}

dialogModel.isOpenにより、表示と非表示を切り替えます。
背景blackScreenをタップした時に、dialogModel.close()でダイアログを閉じます。 ダイアログの中身は派生ダイアログでカスタマイズできるように、contentクロージャーにしています。


また、Dialogのヘッダーの実装について、

struct Dialog<TView: View, TDialogModel: DialogModelable>: View {
    
    ...
    private let headerHeight = 35.0
    
    var body: some View {
    ...
                // ダイアログの中身
                content()
                    .padding(.top, headerHeight)
                    .overlay(titleBar)
                    ...
    }
    
    // ヘッダー
    private var titleBar: some View {
        VStack{
            ZStack(alignment: .top){
                Color.gray.frame(height: headerHeight)
                HStack{
                    Image(systemName: "multiply")
                        .onTapGesture {
                            dialogModel.close()
                        }
                        .padding(10)
                    Spacer()
                }
                Text(dialogModel.title)
                    .lineLimit(1)
                    .font(.headline)
                    .padding(6)
            }
            Spacer()
        }
    }
}

content().paddingで上に空間を作って、overlayでヘッダーを付けてます。
ヘッダーでは X ボタンとタイトルdialogModel.titleを表示しています。


ダイアログをカスタマイズ

次に、DialogDialogModelableを派生させて、独自のダイアログへカスタマイズします。
例えば、ダイアログの中身をステッパーにする場合

struct NumberStepperDialog: View {
    
    @Bindable var numberStepper: NumberStepperDialogModel
    
    var body: some View {
        Dialog(dialogModel: numberStepper){
            Stepper("\(numberStepper.number.value)",value: $numberStepper.number.value)
                .frame(width: 200)
        }
    }
}
@Observable 
class NumberStepperDialogModel: DialogModelable{
    let title = "Stepper"
    var number = Number()
    private(set) var isOpen = false
    
    func open(number: Number){
        self.number = number
        isOpen = true
    }
    
    func close(){
        isOpen = false
    }
}

なお、Viewへの通知はObservationを使っているので、iOS17以降で動作します。
それ以前でしたら、Combine等に置き換えてください。


ダイアログモデルをシングルトンで管理

ダイアログをどこからでも呼び出せるようにシングルトンでダイアログモデルを管理します。

class DialogManager {
    
    static let shared = DialogManager()
    let numberStepper = NumberStepperDialogModel()
    ...
}
@Observable 
class Number{
    var value = 0
}
let number = Number()    // 編集対象モデル

// ダイアログを呼び出す
DialogManager.shared.numberStepper.open(number: number)


ダイアログを貼り付ける

呼び出された時にダイアログを表示できるように、Viewにダイアログを貼り付けます。

struct ContentView: View {

    ...
    let dm = DialogManager.shared

    var body: some View {
        ZStack{

            NumberStepperDialog(numberStepper: dm.numberStepper)
            ...
        }
    }

}


全コードを表示

import SwiftUI

struct ContentView: View {
    
    // MARK: Property
    let number = Number() // 編集対象モデル
    let dm = DialogManager.shared

    var body: some View {
        ZStack{
            VStack{
                menu.padding()
                Text("\(number.value.description)")
            }
            
            NumberStepperDialog(numberStepper: dm.numberStepper)
            NumberPickerDialog(numberPicker: dm.numberPicker)
            NumberTextFieldDialog(numberTextField: dm.numberTextField)
        }
    }
    
    @ViewBuilder
    var menu: some View{
        Button("Dialog 1"){
            dm.numberStepper.open(number: number)
        }
        Button("Dialog 2"){
            dm.numberPicker.open(number: number)
        }
        Button("Dialog 3"){
            dm.numberTextField.open(number: number)
        }
    }
}
import SwiftUI

struct NumberStepperDialog: View {
    
    @Bindable var numberStepper: NumberStepperDialogModel
    
    var body: some View {
        Dialog(dialogModel: numberStepper){
            Stepper("\(numberStepper.number.value)",value: $numberStepper.number.value)
                .frame(width: 200)
        }
    }
}

struct NumberPickerDialog: View {
    
    @Bindable var numberPicker: NumberPickerDialogModel
    
    var body: some View {
        Dialog(dialogModel: numberPicker){
            
            Picker("\(numberPicker.number.value)", selection: $numberPicker.number.value) {
                ForEach(0...10, id: \.self) { number in
                    Text("\(number)")
                }
            }
            .pickerStyle(.wheel)
            .frame(width: 200)
        }
    }
}

struct NumberTextFieldDialog: View {
    
    @Bindable var numberTextField: NumberTextFieldDialogModel
    
    var body: some View {
        Dialog(dialogModel: numberTextField){
            TextField("\(numberTextField.number.value)", value: $numberTextField.number.value, format: .number)
                .keyboardType(.numberPad)
                .multilineTextAlignment(.center)
                .frame(width: 50, height: 50)
                .overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.gray))
                .padding(.top, 15)
        }
    }
}
import SwiftUI

@Observable 
class NumberStepperDialogModel: DialogModelable{
    let title = "Stepper"
    var number = Number()
    private(set) var isOpen = false
    
    func open(number: Number){
        self.number = number
        isOpen = true
    }
    
    func close(){
        isOpen = false
    }
}

@Observable 
class NumberPickerDialogModel: DialogModelable{
    let title = "Picker"
    var number = Number()
    private(set) var isOpen = false
    
    func open(number: Number){
        self.number = number
        isOpen = true
    }
    
    func close(){
        isOpen = false
    }
}

@Observable 
class NumberTextFieldDialogModel: DialogModelable{
    let title = "Text Field"
    var number = Number()
    private(set) var isOpen = false
    
    func open(number: Number){
        self.number = number
        isOpen = true
    }
    
    func close(){
        isOpen = false
    }
}
import Foundation

@Observable 
class Number{
    var value = 0
}
class DialogManager {
    
    // MARK: Property
    static let shared = DialogManager()
    let numberStepper = NumberStepperDialogModel()
    let numberPicker = NumberPickerDialogModel()
    let numberTextField = NumberTextFieldDialogModel()
    
    
    // MARK: Initializer
    private init() {}
}
import SwiftUI

struct Dialog<TView: View, TDialogModel: DialogModelable>: View {
    
    // MARK: Property

    // Stored
    let dialogModel: TDialogModel
    @ViewBuilder let content: () -> TView

    private let headerHeight = 35.0
    private let padding = 10.0
    private let margin = 10.0
    private let cornerRadius = 10.0
    
    var body: some View {
        if dialogModel.isOpen {
            ZStack{
                // 黒背景(タップで閉じる)
                blackScreen
                
                // ダイアログの中身
                content()
                    .padding(padding)
                    .padding(.top, headerHeight)
                    .frame(minWidth: 300, minHeight: 100)
                    .overlay(titleBar)
                    .background(Color.white)
                    .cornerRadius(cornerRadius)
                    .padding(margin)
            }
            .zIndex(1)
        }
    }
    
    private var blackScreen: some View{
        Color.black
            .opacity(0.3)
            .ignoresSafeArea()
            .onTapGesture {
                dialogModel.close()
            }
            .contentShape(Rectangle())
    }
    
    private var titleBar: some View{
        VStack{
            ZStack(alignment: .top){
                Color.gray.frame(height: headerHeight)
                HStack{
                    Image(systemName: "multiply")
                        .onTapGesture {
                            dialogModel.close()
                        }
                        .padding(10)
                    Spacer()
                }
                Text(dialogModel.title)
                    .lineLimit(1)
                    .font(.headline)
                    .padding(6)
            }
            Spacer()
        }
    }
}
protocol DialogModelable{
    var title: String { get }
    var isOpen: Bool { get }
    func close()
}



おわりに

今回はAndroidのようにダイアログを管理して、SwiftUIでダイアログを表示する方法を模索してみました。
ダイアログの中身に色々なViewを適用できるようにするため、Dialog.titleBarがトリッキーな実装になってしまいました。もう少しスマートで適切な実装があるかも?

実際にはanimationをつけたり、キーボード表示の挙動など考慮する点があると思います。
あとシングルトンを使ってますが、@Environmentにしてライフサイクルを意識した方がいいかもしれないですね。
それとこの実装だと同じダイアログを複数開くことができないことも気になります。。
(そもそもそんな状況を作ったらいけないか?)

とりあえずこのダイアログの機構をベースに改善していきます。



シングルトンの活用方法(依存性の注入2)

今回もmusicLineの内部実装の話。(前回の続き)
Transformを末端のクラスNoteへ共有する方法(依存性の注入)において、シングルトンを用いた方法について考えてみました。

Transformの共有



はじめに

musicLineでは、画面上に音符を表示して、タップやスワイプにより音符を編集します。そのため、 音符位置をスクリーン座標系へ座標変換するTransformが必要です。

Transformの情報


前回の記事では、ルートModelで保持しているTransformを末端のNoteへ共有する際にはシングルトンを使用することでコードがスッキリするが、使用は最小限に抑えることが望ましいとまとめました。

Transformをシングルトンへ

詳細はこちら


シングルトンはどこからもでアクセスできて便利ですが、状態を複数持てないことや依存関係が分かりづらくなるデメリットがあります。
今回はシングルトンを利用することの問題について考え、改善案を提示していきたいと思います。




シングルトンの問題

シングルトンパターンは便利で簡単に使いがちですが、使い所が難しいデザインパターンとして知られています。

下手に使用すると不具合の温床になりかねないため、特徴を深く理解することが大切です。

シングルトンの問題として、

  • 状態を複数持てない
  • 依存関係が複雑になる

があります。


状態を複数持てない

Transformは作曲画面で1つの状態しかないですが、musicLineでは編集曲を変更するときに前回の保存したTransformを適用する仕様があります。曲を読み込む時にTransformの状態が複数(編集中の曲、読み込む曲)存在することになり、シングルトンだと一時的にTransformがどちらか一方の状態になります。

そのため、Transformはシングルトンではなく、もう少し工夫する必要があります。

EditManager編集中の管理クラス

Transformは状態が複数になる可能性があるため、シングルトンを解除し、代わりに編集中のインスタンスを管理するクラスEditManagerをシングルトンにします。Transformの状態は複数になる可能性がありますが、編集中の画面に表示するTransformは必ず1つの状態なるはずです。

インスタンスを共有する流れとしては、

  1. 曲変更時に、EditManagerで管理しているtransformを更新
  2. Noteの作成時にEditManagerからtransformを取得
  3. 座標変換時にNoteで保持しているtransformを使用

となります。

作成時のコンストラクタでTransformを保持することで編集状態に影響されず、インスタンス作成から破棄までのライフサイクルで必ず同じTransformを共有することができます。

なお、関数内で直接シングルトンにアクセスするとTransformのシングルトンを解除した意味がなくなり、複数の状態を表現できていないことになります。この状態だとTransformが複数ある時(曲を読み込む場合)に参照しているインスタンスが代わり、予期せぬインスタンスへのアクセスで不具合が発生する可能性があります。


状態を複数持てないことでの不具合例 Android開発では編集中の拍子情報をEditManagerで管理しており、曲を読み込む時に拍子情報を参照してマイグレート(保存バージョンを新しいバージョンへ変換する作業)をしてました。
しかし、曲読み込みの際はまだ編集中の拍子情報は更新されていないため、マイグレートで異なる拍子情報を参照し、保存データが変わってしまう不具合がありました。



依存関係が複雑になる

本来ならば、TransformはルートModelから末端Noteへクラスを跨ぐ必要がありますが、シングルトンはその上層から下層に情報を橋渡しする冗長さをなくすことができます。
さらに、シングルトンEditManagerのプロパティでインスタンスTransformを保持することで、シングルトンではないクラスも簡単に共有できます。

しかし、どこからでもアクセスできる便利さが(グローバル変数と同様)依存関係を複雑にします。ツリー構造(階層的なデータ構造)であれば、他ブランチのデータからの影響はなく、依存関係がわかりやすくなりますが、シングルトンに頼ると他ブランチからでもデータアクセスができるため、ツリー構造の利点が破壊されます。

データアクセスの自由度が高くなっても(シングルトンの利用)依存関係を最小限に留めるためには、共有するインスタンスTransformの使い方を限定することが有効的だと思います。 使い方を限定するとは、Transformを直接共有するのではなく、サービスクラスを作りTransformをパッケージして共有することです。

ServiceManagerサービスクラスを共有

Transformを直接共有すると、意図しない使い方ができてしまい、クラス間の影響を把握することが難しくなります。
例えば、Transformの共有は「頂点位置を算出する目的」を意図しているのに、Transformの状態を変更したり、プロパティ(スクロールの位置等)を単体で取得したりとやりたい放題できてしまいます。
シングルトンで共有する目的を明確にするためにも、サービスクラスを作成してTransformを利用する処理をメンバー関数で提供します。

役割を限定したサービスクラスを共有することでTransformの使い方を制限し、インスタンスを共有する目的をコードで説明できていることが望ましいです。


依存関係が複雑になることでの不具合例 Android開発ではEditManagerで編集曲editSongを共有したことが、依存関係を複雑にしました。
編集曲の情報を共有すれば、末端Noteからも編集曲の拍子やトラックの楽器等を簡単に取得できて便利になりました。しかしシングルトンに依存すると、上層から降ろすはずの依存関係について、不自然なところが気づかなくなり、実際は構造上の問題があるにも関わらず無理やり実装を完了したような状態になります。このような些細な歪みが積み重なることで、構造を破綻することになり、多くの不具合に繋がりました。




DIコンテナー

シングルトンの問題は共有インスタンスTransformをサービスクラスにパッケージして、シングルトンServiceManagerで管理することで解消しました。
今回は具体例としてTransformの場合を考えましたが、他にも同様のパターンがあると思われます。その際に、ServiceManagerのプロパティを増やすよりは、クラスとインスタンスを紐づけて管理する連想配列がいいでしょう。
ちなみに、このようなオブジェクトの依存関係を管理する役割をDIコンテナーと言います。また依存関係を解決することを依存性の注入 (DI)と言います。

DIコンテナー

あとDIコンテナーでサービスクラスを共有する利点として、末端Noteで同じサービスクラスを何度も作成しなくて済み、メモリを節約することができます。

シングルトンの代わりとしてDIコンテナーを利用する時に、なるべくシンプルな依存関係になるようにサービスクラスに限定して共有します。その際に、サービスクラスでもDIすると依存関係が複雑になり、依存解決する(インスタンス作成する)順番を考慮する必要性が出てきます。そのため、サービスクラスで他のサービスを使用する時はシンプルにコンストラクタ引数で渡すようにします。
musicLineでは編集曲が変わるタイミング(編集サイクル)でDIコンテナーを更新するようにしています。

SwiftでDIコンテナーを実装する記事はこちら




おわりに

今回の調査で、シングルトンパターンは正しく理解し、使いどきを見極めることが大切だとわかりました。

シングルトンの扱いに注意しないと、

  • 状態を複数持てず、異なるインスタンスを参照してしまう
  • 依存関係が複雑になり、思わぬ循環参照に陥ってしまう

と不具合に繋がります。

シングルトンパターンの性質を理解し、

  • 状態が複数になり得ない状況に限定して管理する
  • 使用用途を限定したサービスクラスで共有する

と対策を取ることで不具合を事前に防ぐことができます。

ちなみに、アプリ内で必ず状態を1つにしたい場合はシングルトンの使いどきです。musicLineでは、アカウント管理(ログイン状況等の管理)や音楽プレイヤー(再生曲等の管理)に対してシングルトンが向いていると思います。



インスタンスの共有方法(依存性の注入1)

今回はmusicLineの内部実装の話。
Transform(スクロール量等の画面状態)の情報を末端のクラスへどのように共有するか(依存性の注入)について考えてみました。

Transformの共有方法は?



はじめに

musicLineでは、画面上に音符を表示して、タップやスワイプにより音符を編集します。そのため、 音符位置をスクリーン座標系へ座標変換するTransform(スクロール量等の画面状態)の情報が必要になります。

Transformの情報

座標変換の詳細はこちら


Transformは作曲の際に必要となる情報で、ピアノやスクロールエリアをスクロールすることで状態を変更します。そのため、音符や曲の情報というよりは作曲全体の情報なので、ルートのモデルでTransformを保持することが自然だと考えられます。
そうした時に、musicLineの作曲モデルを大まかに表すとこうなります。

作曲モデル

Modelで編集中のSongTransformを保持しています。SongからTrackPhraseNoteと枝分かれしてインスタンス数が多くなり広がっていきます。
ここでTransformNoteに注目するとルートと末端の関係になっています。ルートのModelではTransformを保持しているため、取得・編集することができますが、画面に音符を表示するためには末端のNoteでもTransformが必要です。
ルートで保持しているTransformを末端のNoteへどのように共有するべきでしょう。




インスタンスの共有方法

ルートで保持しているインスタンスを末端でも参照したいような状況で、インスタンスを共有する方法は次の3パターンくらいあると思います。


インスタンスを下層へ伝搬する

下層へ伝搬

利点依存方向が分かりやすい
欠点不要なプロパティが増える


作成時にコンストラクタでインスタンスTransformを渡す方法です。
単純に親から子へTransformを伝搬するだけなので、依存方向がわかりやすいです。依存方向がわかりやすいと、階層的なデータ構造では影響範囲がわかりやすくなります。
しかし、末端までTransformを伝搬していくためには、その間のクラスを跨ぐ必要があります。そのため、階層が深いほど不要にTransformを保持しなければいけない状況が増えます。



インスタンス使用をルートに集約する

ルートに集約

利点参照場所がまとまる
欠点末端だけで処理できない


インスタンスTransformを保持しているクラスを介す方法です。
例えば、音符を表示するときは座標をNoteから直接取得するのではなく、Transformを保持しているModelで座標変換してから取得します。
つまり、末端でTransformを保持するのではなく、関数を使う時にTransformを渡します。これにより、Transformが散らばることを防ぎ、インスタンスの管理が容易になります。
しかし、末端だけではTransformを利用した処理ができず、必ず上層でハンドリングする必要があります。



シングルトンでインスタンスを1つにする

シングルトン利用

利点どこからでも参照可能
欠点複数の状態を持てない


インスタンスTransformをシングルトンとして独立させる方法です。
エンティティに保持させようとはせずに、絶対的な存在として1つのTransformをどこからでもアクセスできるようにします。
しかし、必ず1つの状態しか持てないため、状態が複数になる可能性があるときは使用できません。
また、シングルトンはグローバル変数のようなものなので不必要にシングルトンを利用すると、影響範囲が絞り込めず、依存関係が分かりずらくなります。



まとめ

利点欠点
下層へ伝搬依存方向が分かりやすい不要なプロパティが増える
ルートに集約参照場所がまとまる末端だけで処理できない
シングルトンどこからでも参照可能複数の状態を持てない


基本は単純に下層へ伝搬することで良いですが、階層が深くなるとルートに集約することやシングルトンを検討した方がスッキリすると思います。

今回のパターンのようにTransformで表示するだけの場合は、ルートに関数を作って座標変換後のNote座標を取得することでも良いような気がします。しかしNoteは表示するだけではなく、タップや矩形選択に必要な当たり判定の処理があったり、他にも音符に関する役割を色々と与えたくなると考えられます。その際に、Transformが必要な処理をルートに集約させるとルートクラスが肥大化する問題があります。(サービスクラスに分散させる等の工夫もできますが。。) また、オブジェクト指向的にもNoteに関する処理はNote自身でTransformにアクセスして処理した方がわかりやすくなります(関心の分離)。

シングルトンパターンを利用することで上記の問題は解決できますが、シングルトンを多用すると依存関係が複雑になり、網の目参照や循環参照になってしまう危険性があります。依存関係が複雑にならないようにできる限り階層的なデータ構造にモデリングし、最低限のシングルトンに抑えた方が望ましいです。




終わりに

今回はTransformを末端のNoteへ共有する方法について考えてみました。
シングルトンパターンを使うと綺麗なコードになりますが、依存関係が複雑になるため注意です。そのため、シングルトンを使えばいいというわけではなく、もう少し深く考える必要がありそうです。
次回はシングルトンの上手な活用法について考えてみたいと思います。



作曲のモデル設計

musicLineの作曲モデルを設計した話。

モデル設計
音符選択動作



はじめに

musicLineでは全体的な依存関係がわかりやすくなるようにマルチモジュールで開発しています。

モジュール構成

単一モジュールは保守が大変 Android開発では単一モジュールで、ModelもViewmodel、Viewも同じモジュールでした。そうなると、機能追加や修正を行うときは、膨大なファイル数から変更が必要な部分を探し出す必要があり大変でした。
さらに、単一モジュールだと依存が複雑になりやすく、不必要な依存関係で予期せぬ不具合が発生することもあります。


モジュールを役割ごとに分けることで、その役割で関係のないプロパティや関数を除外して、役割の目的に専念してコーディングできます。つまり、DDDでいうドメイン(問題解決する領域)に対して、モジュール分割により境界付けられたコンテキストを実現しています。


作曲の問題を解決する場合

例えばドメインが作曲の場合、Modelが作曲の問題を解決します。ただし、ModelはUI(ユーザーが操作する画面)を提供する機能はないので、ViewModelとViewで画面表示し、UIによりデータを操作します。

また、一括りに作曲の問題を解決するといっても様々な側面があります。

  • データの永続化
  • 音符の操作
  • 譜面の再生


側面毎に必要なデータ構造が異なるため、モジュールにより境界を作ります。

役割 モジュール
データの永続化 Domain
音符の操作 Composition
譜面の再生 Midi

ドメインの境界をモジュールで表現
(全Modelで共通事項はCommonへまとめる)



モデル設計

マルチモジュールでは様々なメリットがありますが、モジュール間を跨ぐ時に各々のデータ構造へ変換する作業が肝になります。各々のモジュールを深く理解して、変換できるようにモデル設計することが大切です。

今回はDomainCompositionの音符のデータ構造を例に設計を紹介します。


Domain

Domainのデータ構造

Domainはデータを保存する役割であり、継承しているモジュールのデータ構造を適切に復元できるように網羅性を考えます。また、余計なプロパティを保持せずシンプルな構造にすることで、保存データのサイズを小さくします。


Domainデータ構造の図解

NoteContainerは位置と幅、NoteBlockNoteNoteContainerからの相対位置を保持します。また、NoteBlockの個数により、NoteBlockNoteの幅が決定します。

全体のクラス図を表示

全体の構造としては曲 > トラック > フレーズ > 音符の階層となります。
トラックには音階があるトラックやドラムのためのトラック等種類があり、トラックの種類に応じてフレーズや音符の種類が異なります。


Composition

Compositionのデータ構造

データ構造の詳細を表示

音符の3要素

  • 座標
  • サイズ
  • 状態

は音符の種類ごとにクラスを作ります。

音符種別 座標 サイズ 状態
Vertex VertexCoord - -
RootNote RootNoteCoord RootNoteSize RootNoteState
TupletNote TupletNoteCoord TupletNoteSize BaseNoteState
ChordNote ChordNoteCoord ChordNoteSize NoteState

これにより、音符種別ごとに3要素の細かな制御ができるようになります。

音符を編集するためには、クラス同士が作用する仕組みが必要になり、Domainより複雑なデータ構造になります。
また、編集するための機能も追加で実装する必要があります。

  • レンダラー(画面表示)
  • 変更通知(画面更新)
  • 当たり判定(音符操作)


Compositionデータ構造の図解

音符の関係がDomainのような内包構造ではなく、RootNoteからChordNoteへ枝分かれしていくようなツリー構造になっています。

内包構造
ツリー構造

例えば、音符の移動と選択の挙動について考えてみます。

音符移動
音符選択

このようにツリー構造にすることで、親の音符が動いた時に子の音符を追従させることができます。選択に関しても、ツリー構造にすることで子のノードに影響を与えることができます。



おわりに

今回はmusicLineのDomainCompositionモジュールの音符データ構造を紹介しました。同じ音符データでも役割によってデータ構造がかなり違うことがわかりました。

また、モデル設計ではいかに抽象化して、処理を共通化できるかが腕の見せどころです。
例えば、フレーズの種類が3種類あるため、ScaleTrackDrumTrackの違いで、作成するクラスがトラック種別 x フレーズ種別の数だけ増えます。

トラックの違いで派生するクラス
2 (トラック種別) x 3 (フレーズ種別)


しかし、ScaleTrackDrumTrackも格納している要素がVertexBeatContainerの違いだけで、基本的な役割は変わりません。

基本的な構成は変わらない

うまく抽象化することで、ツールでの操作でもトラック種別を気にせずに共通化して操作することができます。このあたりのProtocolやGenericsを使って抽象化する方法はまたの機会に紹介したいと思います。


描画コストの最適化

musicLineでは画面をSwiftUIを使って描画しています。
今回はその描画のコストが気になったので、その調査と最適化した話。

描画コストの表示


はじめに

musicLineは作曲するアプリですが、壮大な曲になると音符が多くなります。画面に表示する音符が増えると、描画コストが高くなり操作感が悪くなります。

例えば、画面移動する時や音符を移動する時に描画コストが高いと、操作に時差が出たりコマ落ちしてカクカクした動きになります。できれば作り込んだり縮小したりして、画面内の音符が多くなっても、画面は滑らかに動いて欲しいものです。

その辺りが気になったので、プロジェクトが大きくなる前に、SwiftUIで無駄なく適切に描画できているか調査しました。
具体的には、描画コストとして3項目を確認しました。

  • Viewの描画回数
  • ViewModelの作成回数
  • ViewModelからの通知回数

描画コストの表示
Viewが33回の描画、ViewModelが1回の作成・10回の通知している状態


描画コストの表示は自作したDrawCostMonitorViewを調査対象のViewに貼り付けることで行っています。DrawCostMonitorViewの実装はまたの機会に紹介します。


調査対象の操作パターン

今回は2パターンの操作で描画コストを調査しました。

  • 音符を動かす
  • 画面を動かす


この操作で影響するViewは5個あります。

  • CompositionView
  • TransformView
  • SongView
  • EditTrackView
  • MelodyNotesView
    (親から子の順)


なお、各々のViewは以下の画面部分について描画の役割を担っています。

各ビューの描画部分

Viewのクラス図等の詳細はこちら


現状の問題

調査対象の操作パターンを行い、現状の問題を表示しました。

音符移動
画面移動

SwiftUIの挙動として、ViewModelからViewに通知した場合、Viewを再描画してViewに貼ってある子Viewが再作成される流れになります。

そのため、Viewと一緒にViewModelを作成していると、ViewModelも再作成されます。

つまり、Viewが描画される度に子ViewのViewModelが再作成されることがコスト上問題になります。


解決方法

Viewが描画される時にViewModelの再作成を回避する方法を調査しました。

ViewModelをキャッシュする

ViewのプロパティにViewModelを保持していますが、今まではObservedObjectでラップしてました。また、Viewを作成する時に、コンストラクターの引数にViewModelを作成して渡していました。

struct TrackView: View {
    @ObservedObject var viewModel: TrackViewModel
    ...
}

TrackView(viewModel: song.createTrackViewModel())

ObservedObject@PublishedObservableObjectからの通知を受け取り、再描画します。

通知の詳細はこちら


描画コストを考えなければObservedObjectで問題ないですが、ここでStateObjectでラップすることで、ObservedObjectの機能に加えて、さらにインスタンスをキャッシュする機能も付与できます。

struct TrackView: View {
    @StateObject var viewModel: TrackViewModel
    ...
}

TrackView(viewModel: song.createTrackViewModel())

Viewを再作成するときに、Viewの状態(他のプロパティ値)が異なる場合に再描画(bodyの内容に更新)されますが、StateObjectでラップしているプロパティはキャッシュしたインスタンスを再利用します。


ちなみに、StateObjectのコンストラクタを見ると、@autoclosureになっており、Viewを新しく作成する時にファクトリーのような感じでクロージャーを使用していると考えられます。なので、実際には初期化の時点でViewModelのインスタンスを作成している訳ではないです。

 @inlinable public init(wrappedValue thunk: @autoclosure @escaping () -> ObjectType)


StateObjectの確認サンプル

StateObjectでラップしたインスタンスが本当にキャッシュされるかを確認するために、サンプルを作ってみました。

StateObjectの挙動確認

StateConterViewObservedCounterViewが子Viewとして、親のCounterViewにぶら下がっている構造です。
CounterViewのカウントをアップすると、各々の子Viewの親カウントもアップしますが、StateConterViewObservedCounterViewで挙動が異なります。
通知されたCounterViewは再描画で子Viewを再作成しますが、StateConterViewのViewModelは作成していません。そのため、StateConterViewではカウントアップした状態で親Viewを再描画しても、カウントを維持できています。

全コードを表示

struct CounterView: View {        
    @StateObject private var counter =  Counter()
        
    var body: some View{
        VStack {
            Spacer()
                
            DrawCostMonitorView(id: "CounterView", observedObject: counter)
            Button {
                counter.count += 1
            } label: {
                CountView(title: "カウントアップ", count: counter.count)
            }
            Spacer()
                
            Text("子View")
            StateCounterView(counter: Counter(), parentCount: $counter.count).padding().border(Color.black)
            ObservedCounterView(counter: Counter(), parentCount: $counter.count).padding().border(Color.black)
        }
    }
}
struct StateCounterView: View {        
    @StateObject var counter: Counter
    @Binding var parentCount: Int

    var body: some View{
        VStack {
            DrawCostMonitorView(id: "StateCounterView", observedObject: counter)
            HStack(spacing: 50) {
                CountView(title: "親カウント", count: parentCount)
                Button {
                    counter.up()
                } label: {
                    CountView(title: "カウントアップ", count: counter.count)
                }
            }
        }
    }
}
struct ObservedCounterView: View {        
    @ObservedObject var counter: Counter
    @Binding var parentCount: Int

    var body: some View {
        VStack {
            DrawCostMonitorView(id: "ObservedCounterView", observedObject: counter)
            HStack(spacing: 50) {
                CountView(title: "親カウント", count: parentCount)
                Button {
                    counter.up()
                } label: {
                    CountView(title: "カウントアップ", count: counter.count)
                }
            }
        }
    }
}
struct CountView: View {
    let title: String
    let count: Int

    var body: some View {
        VStack {
            Text("\(count)").font(.title)
            Text(title).font(.caption)
        }
        .padding()
    }
}
class Counter: ObservableObject {
    @Published var count = 0
    func up() { count += 1 }
}


配列をObservableObjectでラップする

複数のViewModelを配列にして作成する箇所があり、これだと描画する度に多くのViewModelが再作成されることになります。

var body: some View {
    ZStack {
        let viewModels = song.createTrackViewModels()
        ForEach(viewModels, id: \.id){
            TrackView(trackViewModel: $0)
        }
    }
}


配列もStateObjectでキャッシュできれば良いですが、配列はObservableObjectではないのでStateObjectでラップできません。

そのため、配列をObservableObjectでラップします。

public class ListContainerViewModel<TObject: Any>: ObservableObject{
    
    @Published public var list: [TObject]
    
    public init(list: [TObject]) {
        self.list = list
        print("ListContainerViewModel.init")
    }
}

ObservableObjectを継承したListContainerViewModelに配列を格納することで、StateObjectでキャッシュできるようになりました。

後は新たにViewを作成して、ListContainerViewModelプロパティをキャッシュします。

@StateObject var viewModel: ListContainerViewModel<TrackView>

var body: some View {
    ZStack {
        ForEach(viewModel.list, id: \.id){
            TrackView(trackViewModel: $0)
        }
    }
}
var body: some View {
    TracksView(trackViewModel: song.createTracksContainerViewModel())
}


おわりに

解決方法を適用することで、描画コストを最適化しました。

音符移動
画面移動

音符移動や画面移動時に、必要以上にViewModelを再作成せず、Viewを再描画することができました。


DIでSwiftUIのプレビューを高速化

必要ないモジュール依存を省いてSwiftUIのプレビューを快適にした話。

musicLineではマルチモジュールで開発を行っており、それが原因かSwiftUI のプレビューが遅いのが気になっていました。


はじめに

プレビューするとBuild for Previewsとなり、プレビューのための再ビルドが走り、数分待つことになります。これが起動時に1回程度だといいのですが、コードを修正した時など?不意にビルドが発生します。あまり頻繁にプレビューで待たされると、デバッグの環境としては良くないですね。

どうやらRealmのモジュールで再ビルドが発生しているようでした。Realmのモジュールは変更してないので、ビルドする必要はないように思えますが。。
また、Realmの謎のエラーでプレビューが失敗したりすることもあり、どうにかしないとダメだと思いました。

そもそもプレビューに必要ないモジュールに依存するのを避けたいです。
そこで、必要な時だけモジュールのクラスを参照し、あとはダミークラスを参照するようにしました。


SwiftでDI

必要な時だけ特定のクラスを参照できる構造へ変更して、クラス使用時にDI(Dependency Injection)で解決します。構造の変更は以下のページを参考にしました。


1. プロトコル(抽象型)を定義

クラスや構造体を参照すると、そこで依存が決定してしまうため、プロトコルを定義します。

protocol Person{
    func greet()
}

この例では、挨拶する人を抽象的に定義しています。
どんな挨拶をするかは分かりません。


2. クラスや構造体(具体型)を定義

プロトコルに準じて使用したいクラスや構造体を定義します。

struct GoodPerson: Person{
    func greet(){
        print("Hellow")
    }
}

または

struct BadPerson: Person{
    func greet(){
        print("...")
    }
}

この例では、挨拶する人を具体的にして「良い人」と「悪い人」を構造体で定義しています。
良い人は「Hellow」、悪い人は「...」(無視)しています。


3. 具体を避けて抽象に依存

依存を回避したいプロパティの部分で、具体的な(クラスや構造体)型の使用を避けて抽象的な(プロトコル)型を使用にします。

struct Room{
    let person: Person
    func visit(){
        person.greet()
    }
}

この例では、Roomを定義していますが、Roomにいるpersonはどうのような人物かわからないように定義しています。こうすることで、特定のクラスや構造体に依存しなくても大丈夫になります。


4. 型解決

抽象的な参照は上位層で実際にインスタンスを作成するときに具体的な型を指定します。

let room1 = Room(person: GoodPerson())
room1.visit()

let room2 = Room(person: BadPerson())
room2.visit()

この例では、room1には良い人、room2には悪い人がおり、部屋を訪れた際の挨拶が変わります。このように、上位層で具体的な型でインスタンス作成することで型解決をして、下位層で必要のない型に依存しなくても良い構造となります。


実装方法

調査した中でSwiftでのDI実装方法として以下の方法がありました。

  1. コンストラクタでインスタンスを渡す
  2. DIコンテナーのライブラリを使う
  3. PropertyWrapperを使う


上記の例のように、1. コンストラクタでインスタンスを渡すことが一番簡単ですが、モジュールが多いと、型解決する時に上位層から下位層へインスタンスを渡していく必要があり、影響するクラスが多くなることで可読性が下がります。
また、SwiftでもDIコンテナーのライブラリがあるようで、2. DIコンテナーのライブラリを使うことも有効な手段です。

しかし、musicLineではガチガチに抽象化を行い、積極的にDIを使用していく予定もないため、なるべく簡易的でシンプルに使える3. PropertyWrapperを使う方法を採用することにしました。

あまり依存性を抽象化しすぎると、プロトコルが増えてしまったり、クラスの定義位置にジャンプできなかったりと開発しづらくなると思います。

プロパティラッパーについては以下のページが参考になりました。


おわりに

musicLineのモジュール構成はモジュールの深いところでRealmに依存しないようになりました。
上位層で必要な時にRealmを参照し、SwiftUIプレビューも快適に動きます。

改善前:下位層でRealmに依存

改善後:上位層でRealmに依存