コネヒト開発者ブログ

コネヒト開発者ブログ

ママリiOSアプリで今年取り組んだ画面の状態管理の改善について

「コネヒト Advent Calendar 2024」の3日目のブログです!

adventar.org

iOSエンジニアのyoshitakaです。

先日、初めての船釣りにコネヒトの釣り好きメンバーと一緒に行ってきました!

釣り、楽しいですね🎣 天気が良くて景色も最高でした☀️

3時間ほど船の上にいて一匹も釣れなかったのですが、それでも楽しいと思えました!

次回、まずは一匹釣れるように頑張りたいと思います💪


さて、今回は今年ママリiOSアプリで取り組んだ改善について紹介します。

どんな課題を改善したのかを説明する前に、ママリiOSアプリについて簡単に説明します。

ママリでは、ユーザー同士が質問を投稿し、回答をもらうことができるQ&Aプラットフォームを提供しています。 アプリ内の多くの画面において、リスト形式の質問フィードを表示しています。 この質問フィードでは、ページングやPullToRefreshで新しい質問を取得することができます。

この前提を踏まえて、抱えていた課題と改善した内容を紹介します。

課題に感じていたこと

画面に表示するコンテンツを取得する上での異常系の状態管理が複雑になっていました。

例えば、コンテンツの取得に失敗した状態と言っても2つのケースがありました。

  • 初期コンテンツ取得時にエラーが発生した場合: エラー画面を表示し、リトライを促す。表示するコンテンツがないため、リスト画面は表示しない
  • ページングやPullToRefreshでエラーが発生した場合: アラートを表示する。取得済みのコンテンツがあるため、リスト画面は表示する

このようなパターンを考慮して実装する際、ViewModelの状態管理が複雑になり、コードの可読性が低下していました。

どんな改善をしたか

初期コンテンツの取得が成功したか失敗したかで画面の状態を明確に分けることにしました。

  • 初期表示の画面の状態をidle, loading, failed, loadedの4つに分ける
  • 初期コンテンツの取得に成功した場合loadedとなりこの時初めてリスト画面が表示され、loadedの状態が維持される
  • 初期コンテンツの取得に失敗した場合はfailedとなり、エラー画面を表示しリトライのみ行えるようにする

改善前後の変化を図示すると以下のようになります。

改善前後の処理フローイメージ

この改善を実現するために、AsyncContentView, AsyncContentLoadableObjectを作成しました。

以下に今回の改善に関連する実装例を示します。(関連する部分のみ抜粋しています)

画面表示の状態はidleから始まり、初期コンテンツの取得が成功すると.loading.loadedの状態に変わり、ここで初めてリスト画面が表示されます。

つまりAsyncContentViewが実装された画面では、同期的に初期コンテンツの取得が成功するまで、ローディングのみが表示されます。その後エラーが発生した場合はリトライの実行のみ行えるエラー画面が表示されます。

一度取得に成功すればそれ以降は取得済みのコンテンツが表示されたままになり、再取得するかどうかはユーザーのアクション次第となります。

struct AsyncContentView<Source: AsyncContentLoadableObject, Content: View>: View {
    @ObservedObject var source: Source
    var content: () -> Content

    var body: some View {
        switch source.loadingState {
        case .idle:
            Color.clear
                .task {
                    await source.load()
                }
        case .loading:
            ProgressView()
                .progressViewStyle(.circular)
                .tint(Color.accentPrimary)
        case .failed:
            NetworkErrorView {
                Task {
                    await source.load()
                }
            }
        case .loaded:
            content()
                .loadingView(source.showLoading)
                .errorAlertView(errorHandler: source.errorHandler)
        }
    }
}
@MainActor
protocol AsyncContentLoadableObject: ObservableObject, Sendable {
    var loadingState: LoadingState { get }

    var errorHandler: ErrorHandler { get }
    var showLoading: Bool { get }

    func load() async
}

実際に質問フィードをもつ画面に適用した例が以下となります。

この改善を行なったことで、ユーザーに見せるコンテンツが正常に取得できている状態なのかどうかが明確になり、コードの可読性が向上しました。

また、同様の画面を作成する際にもAsyncContentViewを利用することで、コードの再利用性も向上しました。

struct HogeView: View {
    @StateObject var viewModel: HogeViewModel

    var body: some View {
        AsyncContentView(source: viewModel) {
            List {
                ForEach(viewModel.contentList, id: \.id) { feedContent in
                    ContentView(feedContent: feedContent)
                }

                if !viewModel.isAllLoaded {
                    ProgressView()
                        .onAppear {
                            viewModel.loadMore()
                        }
                }
            }
            .refreshable {
                await viewModel.refresh()
            }
        }
    }
}
@MainActor
final class HogeViewModel: AsyncContentLoadableObject {
    @Published var loadingState: LoadingState = .idle

    @Published var errorHandler = ErrorHandler()
    @Published var showLoading = false

    func load() async {
        loadingState = .loading

        do {
            try await fetchFeedQuestions()
            loadingState = .loaded
        } catch {
            loadingState = .failed
        }
    }

    func refresh() async {
        showLoading = true

        do {
            try await fetchFeedQuestions()
        } catch let error {
            updateErrorHandler.handle(error)
        }
        showLoading = false
    }

    private func fetchFeedQuestions() async throws {
        let response = try await self.apiService.call(
            from: QuestionRequest.CategoryFeed()
        )
        ...
    }

    func loadMore() {
        showLoading = true

        Task {
            do {
                let response = try await self.apiService.call(
                    from: QuestionRequest.CategoryFeed()
                )
                ...
            } catch let error {
                errorHandler.handle(error)
            }
            showLoading = false
        }
    }
}

まとめ

今回の改善は小さなものではありましたが、新規で画面を作る際にとても役立ちました。 異常系は動作確認も後回しになることが多いため、共通化しておくことで実装漏れが未然に防ぐことができ重要だと感じました。

今後もiOSアプリの品質向上を目指し、設計の見直しを積極的に行っていきたいと思います。

参考

www.swiftbysundell.com