dotnet慣れした私がSwift CombineのAnyCancellableの取り扱いでハマった話

趣味でSwiftをいじっている私ですが`@Published`プロパティラッパーとかを見て、「dotnetアプリ開発でお世話になっているReactivePropertyっぽいな...」と思ってました。

https://github.com/runceel/ReactiveProperty

ってことはとっつきやすいのでは？と思い[SwiftUI/Swift: 既存のプロジェクトをMVVMパターンに変更する - Qiita](https://qiita.com/sYamaz/items/7b72e26ed48579eb814b)で`@Published`等を使ってみたのですが、２時間くらいハマった出来事があったので共有します。

# ハマったこと

`@Published var value:T`の値を変更しているのに監視側で定義した`sink()`内の処理が実行されない

# 状況再現アプリ

ボタンとカウント表示があるだけのカウントアップアプリを作ります。

C#でもSwiftでも、`ボタンをクリックするとModelの値が＋１されて、その変更通知がViewに届く`という作りです。

# 環境

* dotnet WPF app
    * dotnet5
    * ReactiveProperty 7.12.0
* Swift app
    * XCode 13
    * Swift 5


# dotnet WPF app

今回論点となるViewModel。`Subscribe()`の戻り値であるIDisposableはクラスのフィールドとして参照を保持していなくてもカウントアップした結果はViewにまで到達します。

```csharp
    public class MainWindowViewModel
    {
        // model
        private CountStore store = new CountStore();

        public MainWindowViewModel()
        {
            this.CountUpCommand = new RelayCommand(_ => store.CountUp());
            Count = new ReactiveProperty<int>(store.Count.Value);

            var _ = store.Count.Subscribe(onNext: count => this.Count.Value = count);
        }

        // ボタンクリック時に実行されるCommand
        public ICommand CountUpCommand { get; }
        // Modelの値が反映されるプロパティ
        public ReactiveProperty<int> Count { get; private set; }
    }
```

CountStore.cs　カウントアップした値を保持するクラスです

```csharp
    public class CountStore
    {
        public CountStore()
        {
            Count = new ReactiveProperty<int>(0);
        }
        public ReactiveProperty<int> Count { get; private set; }
        public void CountUp()
        {
            Count.Value++;
        }
    }
```

View(コードビハインドは無し）

```xml
<Window x:Class="WpfApp1.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:WpfApp1"
        mc:Ignorable="d"
        Title="MainWindow" Height="450" Width="800">
    <Window.DataContext>
        <local:MainWindowViewModel/>
    </Window.DataContext>
    <Grid>
        <StackPanel>
            <TextBlock Text="{Binding Count.Value}" TextAlignment="Center"/>
            <Button Command="{Binding CountUpCommand}" >Count up</Button>
        </StackPanel>
    </Grid>
</Window>
```



結果、ちゃんとカウントアップします。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2088399/546c1781-4b3b-ede5-ecad-ad8b8e3fded8.png)

# Swift iOS app

ViewModel

```swift
class ContentViewModel: ObservableObject{
    // modelの値が反映されるプロパティ
    @Published private (set) var value:Int = -1
    private let store:CounterStore = .init()
    init(){
        let _ = store.$count.sink(receiveValue: {count in self.value = count})
    }
    
    // ボタンクリック時に実行される関数
    public func countUp() -> Void{
        store.countUp()
    }
}
```

CountStore

```swift
class CounterStore: ObservableObject{
    @Published private (set) var count:Int = 0
    public func countUp() -> Void{
        count += 1
    }
}
```

View

```swift
struct ContentView: View {
    @ObservedObject var vm = ContentViewModel()
    var body: some View {
        VStack(alignment: .center, spacing: nil){
            Text("\(vm.value)").padding()
            Button("Count up"){vm.countUp()}
        }
    }
}
```

結果、カウントアップしないです。ボタンをクリックしてもずっと０のまま。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2088399/3b0fd8cc-49a4-3022-1b4c-f99d056a65a6.png)


# どういうことなのか

公式ドキュメント[[AnyCancellable - developper.apple.com](https://developer.apple.com/documentation/combine/anycancellable)]より

> An AnyCancellable instance automatically calls cancel() when deinitialized.　（意訳：保持しとかないと使い終わったとみなして勝手にキャンセルするぞ）

SwiftではARCという名のメモリ管理が行われているらしく、この件もAnyCancellableへの参照数が0になり即破棄されたため起きた問題だったようです。ARCについては知識として身につけておきたいところです。（ARCについてとても参考になった記事：[ARCの光と影](https://qiita.com/m__ike_/items/c021e280c5b2c659c59d)）


つまり、今回の場合ViewModelを次のようにしてAnyCancellableの参照をキープしておく必要があるわけです。

```swift
class ContentViewModel: ObservableObject{
    @Published private (set) var value:Int = -1
    private let store:CounterStore = .init()
    private var subscriptions = Set<AnyCancellable>()
    init(){
        // NG: init()のスコープを抜けると破棄される
        //let _ = store.$count.sink(receiveValue: {count in self.value = count})
        
        // OK: Set<AnyCancellable>に保持しておくことで参照が保たれる
        store.$count.sink(receiveValue: {count in self.value = count}).store(in: &subscriptions)
    }
    
    public func countUp() -> Void{
        store.countUp()
    }
}
```

OK！

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2088399/78aa4e0d-37be-b7e4-97e8-74dea33f0464.png)