0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

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

0
Posted at

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

ってことはとっつきやすいのでは?と思いSwiftUI/Swift: 既存のプロジェクトをMVVMパターンに変更する - Qiita@Published等を使ってみたのですが、2時間くらいハマった出来事があったので共有します。

ハマったこと

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

状況再現アプリ

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

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

環境

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

dotnet WPF app

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

    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 カウントアップした値を保持するクラスです

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

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

<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

Swift iOS app

ViewModel

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

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

View

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()}
        }
    }
}

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

image.png

どういうことなのか

公式ドキュメント[AnyCancellable - developper.apple.com]より

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

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

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

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

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?