エムスリーテクノロジーズのiOSアプリ大規模リファクタリング事例 - エムスリーテックブログ

エムスリーテックブログ

エムスリー(m3)のエンジニア・開発メンバーによる技術ブログです

エムスリーテクノロジーズのiOSアプリ大規模リファクタリング事例

【マルチデバイスチーム ブログリレー1日目】

エンジニアリンググループ・マルチデバイスチーム(以下「マルデバ」)の星野です。

私は普段マルチデバイスチームに所属し iOS/Android アプリの開発をしていますが、同時にエムスリーテクノロジーズにも出向という形で在籍し、グループ会社においてもモバイルアプリの開発・支援をしています。

今回は、現在私が進めているグループ会社のモバイルアプリのリファクタリング事例をご紹介し、エムスリーテクノロジーズにおける業務の様子をお伝えできればと思います。

リファクタリングイメージ図

サービスの紹介

今、私が開発に携わっているサービスは介護領域で toB 向けに展開しているサービスです。導入いただいている企業は数千を超え、今なお拡大中です。サービスは主に管理サイト・Android / iOS アプリが存在し、管理サイトは主に介護事業者向けのサービス、Android / iOS アプリは介護業務に従事する方が利用するサービスとなっています。

グループ会社でリリースしているこのサービスは、スマホに慣れていないご高齢な介護従事者の方にも安心・安全・簡単に使っていただけるという大きな特徴があります。この唯一無二の利便性をより多くの企業・介護従事者の方々にお届けし、介護現場が抱える慢性的な人手不足や業務負荷を軽減し、誰もが本来の「介護ケア」に専念できる環境を支えていきたい——。エムスリーテクノロジーズではそのような更なる飛躍を目指すべく、開発スピードの引き上げ・新しい価値をよりスピーディに届ける方法の1つとして、土台・構造の整理 = リファクタリングを進めることなりました。

こちらについてはエムスリーテクノロジーズ取締役 / VPoE 藤原のインタビューにも記載されていますので、あわせてご参照ください。

www.m3t.co.jp

リファクタリング

サービスのリファクタリングでは管理サイト・インフラ・Android / iOS アプリなどさまざまな面のリファクタリングを並行していますが、今回のブログでは iOS アプリのリファクタリングについて紹介いたします。

昨年末頃に私が参画した当時の技術スタックは次のような状況でした。

- アーキテクチャ: MVVM
- レイアウト: Storyboard / Xib ファイル
- ライブラリ管理: CocoaPods
- DI: なし
- テスト: なし
- その他:
  - MVVM ではあるが、V(View, ViewController)もロジックを持つ
  - すべての画面(ViewController) が共通したカスタム親クラスを継承していて、さらに多段階継承している場合もある
  - ゴッドオブジェクト、責務が不明瞭なマネージャクラス多数存在する
  - 通信の完了時の処理がハンドラ形式で書かれている

7-8年ほど前であれば、どの会社でも見かけるような一般的な技術スタック・状況ですが、今後もサービスを飛躍させたいと考えた場合に、未来に向けてのリファクタリングは必要だと判断し、リファクタリングを進めています。

1. CocoaPods から SPM への切り替え

まず最初に行ったのが、ライブラリ管理の CocoaPods から SPM への切り替えです。現在はどのライブラリも SPM 版が用意されている為、大きな問題もなく2-3時間程度で切り替えられました。

2. 画面毎のリファクタリング

その次に実施したのが、カスタム親クラスの排除・DI の導入・MVVM アーキテクチャの整理・モダンなコードへの書き換えを画面毎に進めることでした。アプリのマニュアルや開発関連ドキュメントはありますが、エムスリーテクノロジーズは途中からの参画のため、仕様を頭できちんと理解するにはやはり実際にコードを読んで動かすことが一番です。幸いにも画面毎にできること(機能・仕様)がしっかりと定まっていたため、画面単位でリファクタリングし、リファクタリングが完了したタイミングで適宜リリースする形が取れました。

2-1. 継承の解消

画面毎のリファクタリングの際に共通して最初に行うのが、カスタム親クラス(ここでは仮に BaseViewController と呼びます)の引き剥がしです。すべての画面が共通利用する親クラスは、実装時のお手軽さはありますが、「子クラスによっては不要な機能まで継承することになる」「本来関係ないはずの画面同士が親クラスを通じて結びついてしまう」など、保守運用の観点からはコードの見通しが悪くなるというデメリットの方が大きいため、本当に必要な共通処理は protocol + デフォルト実装に切り出す or 各画面毎に必要な機能を実装していく形で BaseViewController の継承を剥がしていきます。

子画面で必要な機能の解析・移行(protocol / 個別クラスへの実装)については Claude Code に任せるのが手っ取り早いので、ここは Claude Code に簡単な指示だけ出して、あとは出来上がったコードを確認するだけで簡単に進められます。

import UIKit

// 共通の基底クラスによる NG なサンプル
class BaseViewController: UIViewController {
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        // 良かれと思って、アプリがフォアグラウンドに戻った時の通知を設定
        NotificationCenter.default.addObserver(
            self,
            selector: #selector(didReceiveAppWillEnterForegroundNotification),
            name: UIApplication.willEnterForegroundNotification,
            object: nil
        )
    }
    
    @objc private func didReceiveAppWillEnterForegroundNotification() {
        // 画面データの最新化など、共通で行いたい「重い処理」
        print("【Base】アプリ復帰通知を受信:データをリロードします。")
        loadData()
    }
    
    func loadData() {
        // 各画面でオーバーライドされる想定の空メソッド
    }
}

// 1. 通知を受け取ってリロードしたいメイン画面(これは意図通り)
class HomeViewController: BaseViewController {
    override func loadData() {
        print("ホーム画面の最新データをAPIから再取得します。")
    }
}

// 2. ❌ 通知処理が「不要」なはずの、固定文言を出すだけのヘルプ画面
class HelpViewController: BaseViewController {
    // 静的な画面なので、loadDataは必要ないためオーバーライドもしない
    
    // ⚠️ しかし、BaseViewControllerを継承しているせいで…
    // アプリが復帰するたびに、この画面も裏で通知を勝手に受け取ってしまう。
    // 結果として、不要な処理が走ったり、思わぬバグやメモリリークの原因になる。
}

また、一部のクラスで「UIViewController ← 親クラス ← 子クラス ← 孫クラス」という多段階継承された状況で、コード上では孫クラスを使うのに、レイアウトファイル(Storyboard)は子クラスしか存在しない」という特殊な状況の画面が複数ありました。これに関しては、仕方なく先に孫クラス用の Storyboard を新たに用意(複製)し、継承を解消した後、どこからも参照されなくなったタイミングで子クラスの Storyboard とコードを削除する形で継承を引き剥がしました。

2-2. DI の導入

クラスの継承をすっきりさせたところで、次に行うのは DI の導入です。DI については最近マルデバでよく採用している swift-dependencies を利用しました。軽量なコードで DI がかけますので、もしまだ使ったことのない人がいればぜひ試してみてください。エムスリーテックブログでも、一年前に取り上げられてますので、こちらもご参照ください。

www.m3tech.blog

2-3. MVVM の整理

次に MVVM アーキテクチャの整理です。画面毎に機能が異なるため、簡単な画面であれば Claude Code であっさり片付きますが、機能が複雑すぎる画面の場合は「副作用のあるメソッド」が他の「副作用のあるメソッド」を呼んでいたり、さらにそれらがいろいろな箇所から呼ばれていて、どのように整理するのが適切かすぐにわからない場合があります。

この場合は Claude Code に指示を出す前に、まず「何をやっているメソッドなのか」「依存関係はどうなっているのか」を解析してもらって、人間がある程度把握しておく必要があります。そうでないと、いくら指示を出したところで絡まったままのなんとなくの整理にしかならないため、意味のないリファクタリングになってしまいます。解析した内容や問題点を issue としてまとめ、それをもとに Claude Code に指示を出して修正を進めていきます。時間はかかりますが、今後の保守・運用を考えた場合にこの整理はとても重要なポイントなので、地道でも確実に進めていきます。

2-4. async / await への書き換え

通信の完了時の処理については GCD を使ったハンドラ形式の書き方になっていたのですが、このサービスでは通信を直列で順番に処理するシーンが多数あり、「ハンドラによるネスト」がコードリーディングの妨げとなっていました。そのため、あたらに通信クラスを作って「async に切り替える」+「ViewModel を MainActor 化することで、GCD によるスレッド操作を排除する」ことで、コードの可読性を向上させました。

// GCD のハンドラによるネストのサンプル
class ViewModel {
    let networkManager = LegacyNetworkManager()
    var displayRecord: String = ""
    
    func loadDashboardData() {
        // ❌ GCDによる非同期ネスト地獄の始まり
        networkManager.fetchStaffInfo { [weak self] result in
            switch result {
            case .success(let staffId):
                
                // ネスト2階層目:入居者一覧の取得
                self?.networkManager.fetchPatients(for: staffId) { result in
                    switch result {
                    case .success(let patients):
                        guard let firstPatient = patients.first else { return }
                        
                        // ネスト3階層目:最初の入居者のケア記録を取得
                        self?.networkManager.fetchCareRecord(for: firstPatient) { result in
                            switch result {
                            case .success(let record):
                                
                                // ❌ UI更新のためにメインスレッドへ手動で切り替える必要がある(GCD操作)
                                DispatchQueue.main.async {
                                    self?.displayRecord = record
                                    print("【GCD】表示データを更新しました: \(record)")
                                }
                                
                            case .failure(let error):
                                print("ケア記録の取得に失敗: \(error)")
                            }
                        }
                    case .failure(let error):
                        print("入居者一覧の取得に失敗: \(error)")
                    }
                }
            case .failure(let error):
                print("スタッフ情報の取得に失敗: \(error)")
            }
        }
    }
}

// swift concurrency による try await のサンプル
@MainActor
class ViewModel {
    let networkManager = ModernNetworkManager()
    var displayRecord: String = ""
    
    func loadDashboardData() {
        // Taskを立ち上げて非同期処理を開始
        Task {
            do {
                // ✅ 同期処理のように、上から下へ直列に美しく書ける
                let staffId = try await networkManager.fetchStaffInfo()
                let patients = try await networkManager.fetchPatients(for: staffId)
                
                guard let firstPatient = patients.first else { return }
                let record = try await networkManager.fetchCareRecord(for: firstPatient)
                
                // ✅ @MainActor のおかげで、DispatchQueue.main.async を書かずにそのままUI用の状態を更新可能!
                self.displayRecord = record
                print("【async/await】表示データを更新しました: \(record)")
                
            } catch {
                // ✅ エラーハンドリングも一箇所にまとまってクリーン
                print("データの取得中にエラーが発生しました: \(error)")
            }
        }
    }
}

ちなみに今回のアプリのリファクタリングでは、サーバサイドのリファクタリングの都合により「通信の API は既存のものを利用し続ける」形で進めています。サーバサイドは管理サイト(およびそのバックエンド)のリファクタリングを最優先とし、それが完了次第、アプリが利用する API のリファクタリングを行う予定です。

3. AWS Code Commit から GitHub への移行

リファクタリングについては、エムスリーテクノロジーズ側でコードを修正し、コードレビューはエムスリーテクノロジーズとプロダクトオーナーであるグループ会社の開発メンバーが行う体制をとっています。エムスリーテクノロジーズが参画する以前はコード管理に AWS Code Commit を利用していました。Code Commit はコストパフォーマンスやアクセス制御の面による利点がありますが、会社の垣根を超えて開発することを考慮し GitHub に移行しました。

この移行により、普段使い慣れたプラットフォームでコードレビューがしやすくなっただけでなく、今後はテストの自動実行など、CI/CD を活用した開発効率のさらなる改善も期待できるようになっています。このインフラ面の移行に関しては、グループ会社側のメンバーが主導して非常にスピーディに進めていただき、今ではとても快適な開発環境となっています。

今後の予定

現在はある程度リファクタリングが進み、残る大きな課題は「ゴッドオブジェクトの排除」と「Storyboard / Xib ファイルの排除」と「テストの導入」となります。Storyboard / Xib の排除については、以前 Claude Code に試しにやらせてみたところ、膨大な量のコードが出力されてしまったため、ある程度人間が書き換えのサンプルを用意してからそれを参考に進める必要がありそうです。最初の準備に時間はかかるものの、ある程度サンプルが用意できればあとは一瞬で書き換えてくれると期待しています。

ゴッドオブジェクトの排除については、画面間の絡みが出てくるため複雑な課題ではありますが、これまでのリファクタリングで得たドメイン知識をもとに一気に進めていく予定です。

そのため、おそらくそう遠くないうちにリファクタリングが完了する予定で、リファクタリングが完了すればいよいよサービスのグロースが始まります。どのようにアプリを成長させられるか、今からとても楽しみです。

まとめ

今回は、グループ会社の iOS アプリにおけるリファクタリングの取り組みについてご紹介しました。

長年運用されてきたプロダクトには、当時の最適解や積み重ねが存在します。一見レガシーに見えるコードも、ビジネスを支え続けてきた大切な資産です。エムスリーテクノロジーズとしての私たちの役割は、単に「古いから新しくする」のではなく、「次の飛躍に向けた開発スピードを取り戻し、未来のグロースを技術で加速させること」にあります。

We are hiring!

そんなエムスリー・エムスリーテクノロジーズでは、モバイルアプリエンジニアを大大大絶賛募集しています! このブログや、この後に続くマルデバメンバーからのブログを読んで、少しでも興味を持ちましたら、ぜひカジュアル面談などでお話ししましょう!

エムスリーは今年も、DroidKaigi、iOSDC でブース出展予定です。エムスリーのブースを見かけた際はぜひお立ち寄りください。

jobs.m3.com

エムスリーテクノロジーズも採用ページが充実してきましたので、ぜひご覧ください。 https://www.m3t.co.jp/