52
56

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 5 years have passed since last update.

iOSアプリの基本設計を考える:疎結合の概念から構造化、MVVM、RxSwiftまで

Last updated at Posted at 2017-12-07

疎結合であるべき

 MVCやMVVMパターン、オブジェクト指向、デザインパターンの考え方も大事なのですが、自分はよりシンプルな概念でかつ重要と考えているものがあります。それは様々なプログラムコードができるだけ**「疎結合であるべき」**ということです。さすがにサブシステムやフレームワーク、モジュールの規模であれば疎結合は当然と思いますが、もっと小さなプログラムコードの単位でも重要な概念だと思います。

 密結合だと何が悪くて疎結合だとなぜ良いのかと言った議論は百花繚乱かもしれないのでここでは詳しく触れませんが、自分のコードだけで影響範囲が閉じていれば可読性が高く理解が容易になることは間違いないでしょう。(ちょっと話が脱線しますが、短いコードなら可読性が高いと言うのは間違っていると思います。確かに短いと全体が俯瞰しやすいので可読性が高い場合が多いでしょうが、チームの数%だけが理解できるような高度な記述によって短くしたコードはチーム開発には無用だと考えています)

 ただし行き過ぎた疎結合のための努力は可読性を低くする可能性もあります。疎結合にこだわるあまりコードが複雑怪奇になってしまうこともあります。また疎結合・密結合するスコープの境界をどこに引くべきかといった観点も重要です。疎結合がうまくいかない場合、あるいはコードが複雑化する場合はそれらの結合の境界が不適切であることを疑いましょう。

疎結合って?

 では疎結合とはどのような状態を指すのでしょう。
プログラムコード同士がお互いに干渉することが無ければ完全な疎結合でしょうが、それではそもそも一緒に動作することが無いので無意味です。
 お互いが干渉することを前提とすれば、例えば関数呼び出しの種類が少ないことでしょうか?
 スマートに設計されていれば関数の種類は少なく抑えられるはずなので大変に重要な指標だと思いますが、別の観点もあります。それは相手のプログラムコードを関数やメソッドを使って呼び出しているかどうか、YESかNOか、です。

 C言語系のプログラマーであればヘッダーファイルというものをご存知だと思います。C言語系では相手の機能(関数・メソッド)を呼び出すためには相手の関数インターフェイスを記述したヘッダーファイルをインクルードしなければなりません。他と激しく密結合したプログラムコードであれば、おそらく大量のヘッダーファイルをインクルードする羽目になるでしょう。C言語系では他のヘッダーファイルのインクルードをいかに抑えられるかが疎結合性の一つの指標でした。(Swiftでは他のモダンな言語と同様にヘッダーファイルのインクルードが不要になりましたが、疎結合の指標だったと捉えると少しだけデメリットも感じます)

疎結合のために

 相手を呼び出すならインクルードも絶対に必要じゃないか!とおっしゃる貴方、もちろんそうですが呼び出さないでも動作する方法がひとつだけあります。それは**「呼び出されること」です。またまたC言語系の話で申し訳ないのですが、C言語系では呼び出されるだけなら相手のヘッダーファイルをインクルードする必要はありません。逆に自分のヘッダーファイルを相手にインクルードして貰います。
 この状態が
疎結合である**と私は考えます。

 疎結合のメリットは(詳しく書かないと先に述べましたが)、例えば呼び出されるだけならば外部の仕様がどう変わろうと自分(内部)の仕様だけを考えていれば良いわけです。自分はこのようなAPIを持っているから呼び出してね、と。別の言い方をすると、きっちり自分の責務を明確化(要件定義)し、それに沿ったAPIを用意(設計)し、自分以外の誰にも依存せず(疎結合)ブラックボックスで動作する状態が望ましい、と言うことです。
 この考え方はWebサーバのモバイルAPIや、AppleやGoogleのフレームワークAPIと全く同じだと思います。
 
 ただしこの場合は、「呼び出される側」から「呼び出した側」に非同期で通知することが困難になります。相手が何者か知らないので当然のことです。関数呼び出しであれば「戻り値」で返してやれば良いのですが、世の中の多くの事象は非同期に発生します。これらを解決するためにあみ出されたのが、iOSで言えば「Delegate」と「Protocol」「NotificationCenter」「Blocks/Closures」、C言語では「Callback」です。
Javaで言えばInterface。DI(Dependency Injection)も同様の考え方に基づいていると言っていいと思います。
本稿ではこれらを総称して**「非同期コールバック」**と呼ぶことにします。

疎結合と構造化

 しかし、このような「疎結合プログラムコード」ばかりを作ってしまうと、もうひとつの疑問が湧き上がって来ます。それは誰が呼び出すの?ということです。
 その答えは「疎結合プログラムコード」がより変化が少なく基礎となるとなる「疎結合プログラムコード」を呼び出すと言うことになります。「変化が少なく基礎となる」の意味は「より完成されており変化が少ない」と呼んでもいいでしょう。このようなモジュールを「呼び出される側」に配置していくことでより独立性・依存性の階層構造を作り出して行きます。
 このことを構造化プログラミングと呼んだりします。

layer.png

 下位レイヤーは上位レイヤーに依存することはありません。C言語系で言うとヘッダーファイルをインクルードすることはありません。
※ 図の構造化された要素は一般的な例であってシステムによっては異なるかもしれません。

 少し強引かもしれませんがオブジェクト指向プログラミングでメジャーな継承機能は、同じく構造化と考えることもできるかもしれません。

スクリーンショット 2017-12-06 23.19.38.png

 ちなみに継承で親クラスの関数をオーバーライドするといった場合、コンパイラレベルでは「メモリー上の仮想関数テーブルに配置された子クラスの関数を呼び出す(コールバックする)こと」で実現しますのでまさに疎結合のための仕組みと言えなくありません。

MVCパターンとMVVMパターン

 ここでiOSにおけるMVCを考えてみます。iOSにおけるM(Model)とはなんでしょうか?
 通常モバイルアプリには状態遷移を管理しなければ成立しないような大規模なものは少ないですし、サブシステムも無ければ管理すべきハードウェアはほとんどAppleのフレームワークの管理下にあります。したがってModelに相当するコードは皆無になってしまいます。
 厳密なMVCパターンの考え方からは少々逸脱するかもしれませんが、現実的にModelと言えそうなのはAppleのフレームワークであり、インターネット上に構築されたサーバAPIではないでしょうか。ソースコードをXCode上でMVCに分類しようとすると、ModelにはサーバAPIを叩くコードしか入らないでスカスカになってしまった、という経験はありませんか?

 また残りのVCを考えると、VをUIView、CをUIViewControllerとなるのは必然です。なにせ名前がそう主張していますよね。でも仮にUIViewとUIViewControllerを別のグループ(フォルダ)に分離してソースコードを管理するとViewの何とViewControllerの何が繋がっているか分からなくなって混乱しませんか?少なくとも自分はこのような分類をXCode上で見かけると分類した人間を罵りたくなります(笑)
なぜ混乱して罵りたくなるのか。それは分離できないものを分離しているからに他なりません。UIViewとUIViewControllerは実際にはほぼ一体として動作します。名前をさておいてAppleに強制されている実装方法(Interface Builderのことです)を考えるとUIViewControllerは大部分がUser Interfaceだと気付かされます。(このAppleの強制をかいくぐることでUIViewControllerをUser Interfaceでなくすという荒技(笑)もあるかも知れません。これはまた別のお話です。)
 もう少し詳しく言うとタッチパネルを用いたシステムで、ユーザの入力とボタンなどの画面出力を司るモジュールが別々で、疎結合な状態になるなんてあり得ないと思います。
 つまりMVCパターンはスマホアプリの開発に適していないと自分は考えます。(スマホアプリじゃなければもちろん有効なシステムはたくさんあります。特に組込み系では有効だと思います。)

ではMVVMではうまくいくのか?
自分流の解釈ですが、MVVMでは次のように構造化できると考えており、うまくいくと考えています。

| MVVM | 責務、範囲 |
|:----------:|:----------:|:-----------:|
| View | UIViewとUIViewController |
| ViewModel | ViewとModelの媒介 |
| Model |サーバAPIやAppleのフレームワークと、その上に構築された独立性が高いモジュール群|

ポイントは、ViewをUIViewとUIViewControllerのセットで考えること。
ViewModelはViewとModelの媒介者、通訳者として動作することだと思います。
簡単に言えば、Viewは確定しているので、Modelを定義してしまえば残りはすべてViewModelということになります。
MVVMの説明としては乱暴でしょうか(笑)

RxSwiftと疎結合

 上の方で疎結合を実現するには、「Delegate」と「Protocol」「NotificationCenter」「Blocks/Closures」などの非同期のコールバックが必要だと言いました。

前述のMVVMの表を見るとどうでしょうか。
まずModelは「サーバAPIやAppleのフレームワークと、その上に構築された独立性が高いモジュール群」とありますが、基本的にはサーバAPIやAppleのフレームワークがメインであると言っても良いと思います。サーバAPIはそもそも非同期ですし、Appleのフレームワークも他のコードをインクルードするはずは無いので疎結合を保っています。
つまりViewModelは、Model層とインターフェイスするためには従来から関数呼び出し+非同期のコールバックで動作するしか無かったわけです。否応無くそれを強要されていました。

ではViewとViewModelはどうでしょうか。ViewModelにインターフェイスするView側の実態はUIViewControllerです。UIViewおよびUIViewControllerを下位層からの非同期のコールバックで制御した記憶は少なくとも私はありません。

RxSwiftが出現するまでは。

ViewとViewModelが非同期のコールバックで疎結合にできないからこそ、UIViewControllerが必要以上に肥大化していまうケースが多かったのではないでしょうか。
つまりRxSwiftは、疎結合と構造化とViewにとって福音なのです。

RxSwift

ここから少しRxSwiftの実際のコードを見て行きます。
【Swift4】iOSアプリ開発で使える(使いたい)Swiftライブラリーでリストに挙げたReachability.swiftをRxで実装して見ました。

Viewは最初に表示されているFirstViewControllerとネットワークが切断すると表示されるWarningViewControllerの2つのViewControllerで構成されています。

Simulator Screen Shot - iPhone SE - 2017-12-07 at 23.19.50.png

FirstViewControllerはViewModelのObservableをsubscribeしているだけで特にその後は何も動作をしていません。
Reachability.swiftがネットワークの切断を感知するとViewModelから非同期のコールバック、つまりRxのonNextイベントが発行されます。

Swift
import UIKit
import RxSwift

class FirstViewController: StdCoreViewController
{
    lazy private var reachabilityViewModel: ReachabilityViewModel? = { ReachabilityViewModel.shared }()
    
    private var notWorkViewController: WarningViewController?
    
    // RX sequence 一括終了するためのオブジェクト
    var disposeBag: DisposeBag = DisposeBag()
    
    // =============================================================================
    // MARK: - Lifecycle
    // =============================================================================
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        configureReachability()
    }
    
    // =============================================================================
    // MARK: - Reachability
    // =============================================================================
    
    // Reachabilityの設定
    private func configureReachability() -> Void
    {
        // Reachabilityをsubscribeする
        self.reachabilityViewModel?.reachabilityObservable.subscribe { [unowned self] event in
            
            if let reachability = event.element
            {
                if reachability.connection == .none
                {
                    // ネットワークに接続できません
                    self.notWorkViewController = WarningViewController.getInstance(message: "ネットワークに接続できません".localized)
                    self.present(self.notWorkViewController!, animated: true) // 表示
                }
                else
                {
                    // ネットワークに接続しました
                    self.notWorkViewController?.dismiss(animated: true, completion: nil) // 非表示
                    self.notWorkViewController = nil
                    logger.info(reachability.connection.description)
                }
            }
            }
            .disposed(by: disposeBag)
        
        // Reachability検出開始
        self.reachabilityViewModel?.startNotifier(hostname: "google.com")
    }
}
Swift

import UIKit

/// 警告画面
class WarningViewController: StdCoreViewController
{
    // ----------------------
    // MARK: - インスタンス生成
    static func getInstance(message: String) -> WarningViewController
    {
        let storyboardInstance = UIStoryboard(name: "Main", bundle: nil)
        let vc = storyboardInstance.instantiateViewController(withIdentifier: String(describing: self)) as! WarningViewController
        vc.message = message
        return vc
    }

    // ----------------------
    // MARK: - Outlet
    
    @IBOutlet weak private var backgroundView: UIView!
    @IBOutlet weak private var messageLabel: UILabel!
    
    var message: String?
    var color: UIColor?
    
    override func viewDidLoad()
    {
        if let _message = message
        {
            messageLabel.text = _message
        }

        if let _color = color
        {
            backgroundView.backgroundColor = _color
        }
    }
}
}

次にViewModelです。ViewModelではUIKitをimportしていません。これはViewModelがView(User Interface)では無い証です。ただしUIImageのようにUIKitに含まれており、本来はUIのみで使われるはずのクラスがアプリ全体で広域に使用されるようになっているものもあります。いちいちCoreImageでインターフェイスするのが億劫なので自分もUIImageだけは特別扱いしています。

Swift
import Foundation
import RxSwift

// 汎用に使用できるReachability用のViewModel
class ReachabilityViewModel: BaseViewModel
{
    private var reachability: Reachability?
    private let reachabilitySubject = PublishSubject<Reachability>()
    var reachabilityObservable: Observable<Reachability> { return reachabilitySubject }

    // ------------------
    // MARK: - Lifecycle
    
    // シングルトン
    static let shared = ReachabilityViewModel()
    private override init(){super.init()}

    deinit
    {
        stopNotifier()
    }

    private func setupReachability(_ hostName: String?)
    {
        let reachability = hostName == nil ? Reachability() : Reachability(hostname: hostName!)
        self.reachability = reachability
        
        self.reachability?.whenReachable = { reachability in
            logger.info("ネットワーク接続")
            DispatchQueue.main.async{
                self.reachabilitySubject.onNext(self.reachability!)
            }
        }
        
        self.reachability?.whenUnreachable = { reachability in
            logger.error("ネットワーク非接続")
            DispatchQueue.main.async{
                self.reachabilitySubject.onNext(self.reachability!)
            }
        }
    }

    // ------------------
    // MARK: - API
    
    /// 開始
    ///
    /// - Parameter hostname: ネットワーク接続性を確かめる相手先ホスト
    func startNotifier(hostname: String?)
    {
        setupReachability(hostname)
        
        logger.info("--- start notifier")
        do
        {
            try reachability?.startNotifier()
        }
        catch
        {
            reachabilitySubject.onNext(self.reachability!)
            return
        }
    }
    
    /// 停止
    func stopNotifier()
    {
        logger.info("--- stop notifier")
        reachability?.stopNotifier()
        reachability = nil
    }
}

ここに書いたコードがRxSwiftの素晴らしさを表しているかは甚だ疑問ですが(笑)この記事は自分の考えをまとめるいい機会になりました。

ソースコードはこちらにあります。

52
56
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
52
56

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?