Android
 Computer >> コンピューター >  >> システム >> Android

SOLID原則とは?モダンなAndroidアプリ開発のための5つの設計原則を徹底解説

Kriptofolioアプリシリーズ - Part 1

ソフトウェアは常に変化し続けるものです。そして、その一つひとつの変更がプロジェクト全体に悪影響を及ぼす可能性があります。そのため、新たな変更を実装する際に発生しうるダメージをいかに防ぐかが非常に重要になります。

「Kriptofolio」(旧「My Crypto Coins」)アプリでは、これから段階的に大量の新しいコードを作成していきます。良い方法で開発を進めるために、まずはモダンなソフトウェアを作るための基礎となる原則を理解する必要があります。それこそが「SOLID原則」です。なんともキャッチーな名前ですね。

シリーズ目次

  • はじめに:2018〜2019年にモダンなAndroidアプリを構築するためのロードマップ
  • Part 1:SOLID原則の紹介(この記事)
  • Part 2:Androidアプリ開発の始め方 — モックアップ・UI・XMLレイアウトの作成
  • Part 3:アーキテクチャについて — さまざまなアーキテクチャパターンの比較と活用方法
  • Part 4:Dagger 2による依存性注入(DI)の実装方法
  • Part 5:Retrofit、OkHttp、Gson、Glide、Coroutinesを使ったRESTful Webサービスの処理

SOLID原則とは

SOLIDは、オブジェクト指向設計における5つの基本原則を定義するための頭字語(ニーモニック)です。以下の5つの原則を指します。

  1. Single Responsibility Principle(単一責任の原則)
  2. Open-Closed Principle(開放閉鎖の原則)
  3. Liskov Substitution Principle(リスコフの置換原則)
  4. Interface Segregation Principle(インターフェース分離の原則)
  5. Dependency Inversion Principle(依存性逆転の原則)

ここからは、それぞれの原則を個別に見ていきます。各原則について、良くない実装例と良い実装例のコードを提示します。これらのサンプルコードは、Kotlin言語を使用したAndroid向けに書かれています。

単一責任の原則(Single Responsibility Principle)

クラスが担う責務は、たった一つであるべきだ。

それぞれのクラスやモジュールは、アプリが提供する機能のうちの一つの部分だけに責任を持つべきです。つまり、ある処理を扱うなら、そのクラスを変更すべき理由も一つだけでなければなりません。もしクラスやモジュールが複数のことを行っているなら、機能を別々のクラスに分割すべきです。

この原則をより深く理解するために、「スイスアーミーナイフ」を例に挙げてみましょう。このナイフは、メインの刃以外にも複数の機能を持つことで有名です。ドライバー、缶切りなど、さまざまな道具が内蔵されています。

「単一機能の例になぜ多機能ナイフを持ち出すのか?」と疑問に思うかもしれません。しかし、少し考えてみてください。このナイフのもう一つの重要な特徴は、ポケットサイズでありながら高い携帯性を備えていることです。いくつかの異なる機能を提供していても、「小さくて持ち運びやすい」という本来の目的には合致しているのです。

プログラミングも同じです。クラスやモジュールを作成するときは、何らかの主要な目的を持たせる必要があります。同時に、シンプルさを追求するあまり機能を過度に細分化しすぎてもいけません。バランスを保つことを忘れないでください。

SOLID原則とは?モダンなAndroidアプリ開発のための5つの設計原則を徹底解説

古典的な例として、RecyclerViewウィジェットのアダプターを作成する際によく使われるonBindViewHolderメソッドが挙げられます。

? 悪いコード例:

class MusicVinylRecordRecyclerViewAdapter(private val vinyls: List<VinylRecord>, private val itemLayout: Int) 
 : RecyclerView.Adapter<MusicVinylRecordRecyclerViewAdapter.ViewHolder>() {
    ...
    override fun onBindViewHolder(holder: ViewHolder, position: Int) {
        val vinyl = vinyls[position]
        holder.itemView.tag = vinyl

        holder.title!!.text = vinyl.title
        holder.author!!.text = vinyl.author
        holder.releaseYear!!.text = vinyl.releaseYear
        holder.country!!.text = vinyl.country
        holder.condition!!.text = vinyl.condition

        /**
         *  ここでこのメソッドは単一責任の原則に違反しています!!!
         *  VinylRecordオブジェクトをビュー表示に適応させることだけが本来の責務なのに、
         *  データ整形まで行ってしまっています。
         *  将来的に変更される理由が複数存在することになり、これは望ましくありません。
         */

        var genreStr = ""
        for (genre in vinyl.genres!!) {
            genreStr += genre + ", "
        }
        genreStr = if (genreStr.isNotEmpty())
            genreStr.substring(0, genreStr.length - 2)
        else
            genreStr

        holder.genre!!.text = genreStr
    }
    ...
}

? 良いコード例:

class MusicVinylRecordRecyclerViewAdapter(private val vinyls: List<VinylRecord>, private val itemLayout: Int) 
 : RecyclerView.Adapter<MusicVinylRecordRecyclerViewAdapter.ViewHolder>() {
    ...
    override fun onBindViewHolder(holder: ViewHolder, position: Int) {
        val vinyl = vinyls[position]
        holder.itemView.tag = vinyl

        holder.title!!.text = vinyl.title
        holder.author!!.text = vinyl.author
        holder.releaseYear!!.text = vinyl.releaseYear
        holder.country!!.text = vinyl.country
        holder.condition!!.text = vinyl.condition
        
        /**
         * データ整形の処理をここで行う代わりに、その責務を他のクラスに移譲します。
         * ここではトップレベル関数 convertArrayListToString を直接呼び出しているだけです。
         * これはKotlinの新しい言語機能です。ただし勘違いしないでください。
         * Kotlinコンパイラーは裏側でJavaクラスを生成し、個々のトップレベル関数は
         * 静的メソッドへと変換されます。結果として各クラスが単一の責任を持つことになります。
         */

        holder.genre!!.text =  convertArrayListToString(vinyl.genres)
    }
    ...
}

単一責任の原則を意識して設計されたコードは、これから説明する他の原則にも自然と近づいていきます。

開放閉鎖の原則(Open-Closed Principle)

ソフトウェアのエンティティは、拡張に対して開かれているべきであり、修正に対して閉じられているべきだ。

この原則は、クラス、モジュール、関数といったソフトウェアの部品を書くとき、拡張には開かれた状態にしつつ、修正に対しては閉じた状態にしておくべきだと述べています。これはどういう意味でしょうか?

たとえば動作するクラスを作ったとします。新しい機能を追加したり変更を加えたりする必要が生じたときに、そのクラス自体を書き換える必要があるのは好ましくありません。代わりに、新しいサブクラスを作成して既存クラスを拡張し、そこに必要な新機能を簡単に追加できるようにすべきです。機能は常に、サブクラスがオーバーライドできるような形でパラメータ化されているべきです。

ユーザーにさまざまな種類のカスタムメッセージを表示するためのFeedbackManagerクラスを作成する例を見てみましょう。

? 悪いコード例:

class MainActivity : AppCompatActivity() {

    lateinit var feedbackManager: FeedbackManager

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        feedbackManager = FeedbackManager(findViewById(android.R.id.content));
    }

    override fun onStart() {
        super.onStart()

        feedbackManager.showToast(CustomToast())
    }
}

class FeedbackManager(var view: View) {

    // 新しい種類のフィードバックメッセージを追加する必要が出たらどうなるでしょう?
    // このマネージャークラスを修正する必要があります。しかし開放閉鎖の原則に従うなら、
    // 古いクラスを書き直すことなく、新しい要件に自動的に適応できるコードを書く必要があります。

    fun showToast(customToast: CustomToast) {
        Toast.makeText(view.context, customToast.welcomeText, customToast.welcomeDuration).show()
    }

    fun showSnackbar(customSnackbar: CustomSnackbar) {
        Snackbar.make(view, customSnackbar.goodbyeText, customSnackbar.goodbyeDuration).show()
    }
}

class CustomToast {

    var welcomeText: String = "Hello, this is toast message!"
    var welcomeDuration: Int = Toast.LENGTH_SHORT
}

class CustomSnackbar {

    var goodbyeText: String = "Goodbye, this is snackbar message.."
    var goodbyeDuration: Int = Toast.LENGTH_LONG
}

? 良いコード例:

class MainActivity : AppCompatActivity() {

    lateinit var feedbackManager: FeedbackManager

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        feedbackManager = FeedbackManager(findViewById(android.R.id.content));
    }

    override fun onStart() {
        super.onStart()

        feedbackManager.showSpecialMessage(CustomToast())
    }
}

class FeedbackManager(var view: View) {

    // 再び同じ状況 — 新しい種類のフィードバックメッセージを追加する必要があります。
    // 古いクラスの実装を変更することなく、新しい要件に適応できるコードを書かなければなりません。
    // ここでの解決策は、インターフェースを使って機能を拡張することに焦点を当てることです。
    // これにより開放閉鎖の原則に従うことができます。

    fun showSpecialMessage(message: Message) {
        message.showMessage(view)
    }
}

interface Message {
    fun showMessage(view: View)
}

class CustomToast: Message {

    var welcomeText: String = "Hello, this is toast message!"
    var welcomeDuration: Int = Toast.LENGTH_SHORT

    override fun showMessage(view: View) {
        Toast.makeText(view.context, welcomeText, welcomeDuration).show()
    }
}

class CustomSnackbar: Message {

    var goodbyeText: String = "Goodbye, this is snackbar message.."
    var goodbyeDuration: Int = Toast.LENGTH_LONG

    override fun showMessage(view: View) {
        Snackbar.make(view, goodbyeText, goodbyeDuration).show()
    }
}

開放閉鎖の原則は、次に説明する2つの原則の目的をまとめたものでもあります。それでは次に進みましょう。

リスコフの置換原則(Liskov Substitution Principle)

プログラム内のオブジェクトは、そのサブタイプのインスタンスと置き換えても、プログラムの正しさが損なわれるべきではない。

この原則は、著名なコンピュータ科学者であるバーバラ・リスコフにちなんで名付けられました。基本的な考え方は、オブジェクトはプログラムの振る舞いを変えることなく、そのサブタイプのインスタンスで置き換えられるべきだというものです。

たとえば、アプリ内にMainClassがあり、それがBaseClassに依存し、さらにSubClassBaseClassを継承しているとします。この原則に従うということは、BaseClassのインスタンスをSubClassのインスタンスに置き換えても、MainClassのコードやアプリ全体が問題なくシームレスに動作することを意味します。

SOLID原則とは?モダンなAndroidアプリ開発のための5つの設計原則を徹底解説

この原則をさらに深く理解するために、Square(正方形)とRectangle(長方形)の継承関係という古典的で分かりやすい例を挙げます。

? 悪いコード例:

class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        val rectangleFirst: Rectangle = Rectangle()
        rectangleFirst.width = 2
        rectangleFirst.height = 3

        textViewRectangleFirst.text = rectangleFirst.area().toString()
        // 最初の長方形の面積は6で、2 x 3 = 6なので正しい結果です。

        // リスコフの置換原則では、サブクラス(Square)は利用者の視点から見て
        // 機能を壊さない形で親クラス(Rectangle)をオーバーライドすべきだと定めています。見てみましょう。
        val rectangleSecond: Rectangle = Square()
        // 利用者はこれが長方形だと思い、通常どおり幅と高さを設定しようとします
        rectangleSecond.width = 2
        rectangleSecond.height = 3

        textViewRectangleSecond.text = rectangleSecond.area().toString()
        // 2番目の長方形の期待される結果も同じく6のはずですが、実際には9になってしまいます。
        // このようにSquareがRectangleを継承するオブジェクト指向のアプローチは間違っています。
    }
}

open class Rectangle {

    open var width: Int = 0
    open var height: Int = 0

    open fun area(): Int {
        return width * height
    }
}

class Square : Rectangle() {

    override var width: Int
        get() = super.width
        set(width) {
            super.width = width
            super.height = width
        }

    override var height: Int
        get() = super.height
        set(height) {
            super.width = height
            super.height = height
        }
}

? 良いコード例:

class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        // RectangleクラスとSquareクラスをリスコフの置換原則を満たすよう
        // より良い形で整理した例です。もう予期しない結果は起こりません。
        val rectangleFirst: Shape = Rectangle(2,3)
        val rectangleSecond: Shape = Square(3)

        textViewRectangleFirst.text = rectangleFirst.area().toString()
        textViewRectangleSecond.text = rectangleSecond.area().toString()
    }
}

class Rectangle(var width: Int, var height: Int) : Shape() {

    override fun area(): Int {
        return width * height
    }
}

class Square(var edge: Int) : Shape() {

    override fun area(): Int {
        return edge * edge
    }
}

abstract class Shape {
    abstract fun area(): Int
}

クラス階層を記述する前には必ず立ち止まって考えましょう。この例が示すように、現実世界のオブジェクトがそのままOOPのクラス階層に対応するとは限りません。別のアプローチを見つける必要があります。

インターフェース分離の原則(Interface Segregation Principle)

一つの汎用インターフェースよりも、クライアント固有の多数のインターフェースの方が優れている。

名前だけ聞くと難しそうですが、原則自体はとても理解しやすいものです。クライアントは、使用しないメソッドへの依存や、不要なインターフェースの実装を強制されてはならない、と述べています。クラスは、できる限り少ないメソッドと属性を持つように設計する必要があります。インターフェースを作成するときは、大きくしすぎないように注意しましょう。代わりに小さなインターフェースに分割し、インターフェースのクライアントが関連するメソッドだけを知っている状態にします。

この原則の概念をつかむために、蝶型ロボットと人型ロボットを使った悪い例と良い例を再び用意しました。

SOLID原則とは?モダンなAndroidアプリ開発のための5つの設計原則を徹底解説

? 悪いコード例:

/**
 * 何らかの未定義のロボットを作ると想像してください。私たちはあらゆる可能な機能を含んだ
 * インターフェースを作成することにしました。
 */
interface Robot {
    fun giveName(newName: String)
    fun reset()
    fun fly()
    fun talk()
}

/**
 * まず、そのインターフェースを実装する蝶型ロボットを作成します。
 */
class ButterflyRobot : Robot {
    var name: String = ""

    override fun giveName(newName: String) {
        name = newName
    }

    override fun reset() {
        // ロボットに対するリセットコマンドを呼び出します。どんなロボットのソフトウェアでも
        // リセットできるべきです。それは理にかなっているので実装します。
        TODO("not implemented")
    }

    override fun fly() {
        // ロボットに対する飛行コマンドを呼び出します。これは蝶型ロボット固有の機能です。
        // もちろん実装します。
        TODO("not implemented")
    }

    override fun talk() {
        // ロボットに対する会話コマンドを呼び出します。
        // 間違い!!! 私たちの蝶型ロボットは話すことはせず、飛ぶだけです!
        // 使わないメソッドの実装を強制されているため、ここでインターフェース分離の原則に違反しています。
        TODO("???")
    }
}

/**
 * 次に、人間と同じような動作ができる人型ロボットを作成します。これも同じインターフェースを実装します。
 */
class HumanoidRobot : Robot {
    var name: String = ""

    override fun giveName(newName: String) {
        name = newName
    }

    override fun reset() {
        // ロボットに対するリセットコマンドを呼び出します。どんなロボットのソフトウェアでも
        // リセットできるべきです。それは理にかなっているので実装します。
        TODO("not implemented")
    }

    override fun fly() {
        // ロボットに対する飛行コマンドを呼び出します。
        // ここが問題です! 人型ロボットを飛ばすつもりは最初からありませんでした。
        // 使わないメソッドの実装を強制されているため、ここでインターフェース分離の原則に違反しています。
        TODO("???")
    }

    override fun talk() {
        // ロボットに対する会話コマンドを呼び出します。これは人型ロボット固有の機能です。
        // もちろん実装します。
        TODO("not implemented")
    }
}

? 良いコード例:

/**
 * 何らかの未定義のロボットを作ると想像してください。すべてのタイプのロボットに共通する機能だけを
 * 含んだ汎用インターフェースを作成すべきです。
 */
interface Robot {
    fun giveName(newName: String)
    fun reset()
}

/**
 * 飛行できる特定のロボットには、独自のインターフェースを定義します。
 */
interface Flyable {
    fun fly()
}

/**
 * 会話できる特定のロボットには、独自のインターフェースを定義します。
 */
interface Talkable {
    fun talk()
}

/**
 * まず、汎用インターフェースと固有のインターフェースを実装する蝶型ロボットを作成します。
 * ご覧のとおり、ロボットに関係のない関数を実装する必要はもうありません!
 */
class ButterflyRobot : Robot, Flyable {
    var name: String = ""

    override fun giveName(newName: String) {
        name = newName
    }

    override fun reset() {
        // ロボットに対するリセットコマンドを呼び出します。どんなロボットのソフトウェアでも
        // リセットできるべきです。それは理にかなっているので実装します。
        TODO("not implemented")
    }

    // ロボットに対する飛行コマンドを呼び出します。これは蝶型ロボット固有の機能です。
    // もちろん実装します。
    override fun fly() {
        TODO("not implemented")
    }
}

/**
 * 次に、人間と同じような動作ができる人型ロボットを作成します。汎用インターフェースと
 * そのタイプに固有のインターフェースを実装します。
 * ご覧のとおり、ロボットに関係のない関数を実装する必要はもうありません!
 */
class HumanoidRobot : Robot, Talkable {
    var name: String = ""

    override fun giveName(newName: String) {
        name = newName
    }

    override fun reset() {
        // ロボットに対するリセットコマンドを呼び出します。どんなロボットのソフトウェアでも
        // リセットできるべきです。それは理にかなっているので実装します。
        TODO("not implemented")
    }

    override fun talk() {
        // ロボットに対する会話コマンドを呼び出します。これは人型ロボット固有の機能です。
        // もちろん実装します。
        TODO("not implemented")
    }
}

依存性逆転の原則(Dependency Inversion Principle)

具象ではなく、抽象に依存すべきだ。

最後の原則は、上位レベルのモジュールは下位レベルのモジュールに依存すべきではないと述べています。両者とも抽象に依存すべきです。抽象は詳細に依存すべきではなく、詳細が抽象に依存すべきです。

この原則の主な狙いは、モジュール間やクラス間に直接的な依存関係を持たせないことにあります。代わりに、抽象(インターフェースなど)に依存するようにしましょう。

もっとシンプルに言えば、あるクラスの中で別のクラスを使用している場合、そのクラスは注入されたクラスに依存することになります。これは原則の趣旨に反するため、避けるべきです。すべてのクラスを疎結合にすることを目指しましょう。

? 悪いコード例:

class Radiator {
    var temperatureCelsius : Int = 0

    fun turnOnHeating(newTemperatureCelsius : Int) {
        temperatureCelsius  = newTemperatureCelsius
        // ラジエーターの暖房をつけるには、このデバイス専用の手順が必要です。
        // ラジエーターには独自の起動手順があります。その手順をここに実装します。
        TODO("not implemented")
    }
}

class AirConditioner {
    var temperatureFahrenheit: Int = 0

    fun turnOnHeating(newTemperatureFahrenheit: Int) {
        temperatureFahrenheit = newTemperatureFahrenheit
        // エアコンの暖房をつけるには、このデバイス専用の手順が必要です。
        // エアコンには独自の技術手順があり、ラジエーターとは異なるため、ここに実装します。
        TODO("not implemented")
    }
}

class SmartHome {

    // スマートホーム制御システムにラジエーター制御を追加しました。
    var radiator: Radiator = Radiator()
    // しかし、後でラジエーターをエアコンに変更することになったらどうなるでしょうか?
    // var airConditioner: AirConditioner = AirConditioner()
    // このSmartHomeクラスはRadiatorクラスに依存しており、依存性逆転の原則に違反しています。

    var recommendedTemperatureCelsius : Int = 20

    fun warmUpRoom() {
        radiator.turnOnHeating(recommendedTemperatureCelsius)
        // 原則を無視すると、こんな重大なミスが起こるかもしれません。
        // ここでは推奨温度を摂氏で渡していますが、エアコンは華氏を想定しています。
        // airConditioner.turnOnHeating(recommendedTemperatureCelsius)
    }
}

? 良いコード例:

// まず抽象化 — インターフェースを作成します。
interface Heating {
    fun turnOnHeating(newTemperatureCelsius : Int)
}

// クラスはHeatingインターフェースを実装します。
class Radiator : Heating {
    var temperatureCelsius : Int = 0

    override fun turnOnHeating(newTemperatureCelsius: Int) {
        temperatureCelsius  = newTemperatureCelsius
        // ここにラジエーター独自の起動手順を実装します。
        TODO("not implemented")
    }
}

// クラスはHeatingインターフェースを実装します。
class AirConditioner : Heating {
    var temperatureFahrenheit: Int = 0

    override fun turnOnHeating(newTemperatureCelsius: Int) {
        temperatureFahrenheit = newTemperatureCelsius * 9/5 + 32
        // エアコン独自の起動手順をここに実装します。
        TODO("not implemented")
    }
}

class SmartHome {

    // スマートホーム制御システムにラジエーター制御を追加しました。
    var radiator: Heating = Radiator()
    // 後でラジエーターをエアコンに変更したくなった場合の答えは、もうあります。
    // クラスは注入された別のクラスではなく、インターフェースに依存するようになりました。
    // var airConditioner: Heating = AirConditioner()

    var recommendedTemperatureCelsius : Int = 20

    fun warmUpRoom() {
        radiator.turnOnHeating(recommendedTemperatureCelsius)
        // 共通のインターフェースに依存しているので、もうミスが起こる心配はありません。
        // airConditioner.turnOnHeating(recommendedTemperatureCelsius)
    }
}

まとめ

これらの原則全体を眺めてみると、互いに補完関係にあることが分かります。SOLID原則に従うことで多くのメリットが得られ、アプリは再利用可能で、保守しやすく、スケーラブルで、テストしやすいものになります。

もちろん、状況によってはすべての原則を完全に守ることが難しい場合もあります。コードを書く際は個々の状況に左右されるからです。しかしそれでも、開発者として少なくともこれらの原則を知っておけば、いつ適用すべきかを判断できるようになります。

リポジトリ

今回はコードを書くのではなく、プロジェクトを学習・計画するパートです。Part 1のブランチコミットへのリンクを掲載しておきます。基本的にはプロジェクトの初期コードである「Hello world」です。

GitHubでソースコードを見る

SOLID原則について上手く説明できたことを願っています。ご意見があれば、下のコメント欄でお気軽にどうぞ。

Ačiū! 最後までお読みいただきありがとうございます! この記事は2018年2月23日に筆者の個人ブログ www.baruckis.com で公開されたものです。

  1. Google Fi(旧Project Fi)とは?仕組み・料金・メリット・デメリットを徹底解説

    モバイル通信市場はすでに飽和状態にあり、それでも「本当にお得なプラン」に出会うのは簡単ではありません。実際、携帯電話キャリアは評判が良くないことが多く、それには理由があります。「無制限」と謳いながら実質的に制限のあるデータプラン、高騰し続けるデータ料金、縮小される容量上限など、ユーザーを悩ませる問題が絶えないからです。データプランに大金を費やすだけでなく、エリアカバー率の高いキャリアを選ぶ手間も発生します。数ある選択肢の中に、さらに加わったのがGoogleの「Project Fi(現Google Fi)」です。 Google Project Fiとは? Project Fiは、Googleが提

  2. LibreOffice 7.1 徹底レビュー ― 不確実性の原理と、削り取られていく希望

    「人は年を重ねるほど皮肉っぽくなる」とよく言われる。だが私はこう考える。それは年齢の問題ではなく、経験の問題なのだと。希望は有限であり、人生を歩み、失望という果実を何度も味わううちに、少しずつ侵食され、削り取られていく。それでも、最後まで死なないのは希望である。 だからこそ、ソフトウェアの世界が轟音を立てて通り過ぎていくのを眺めながら、ただ幸せなユーザーでいたいだけの私の気分は、軽い落胆と終末的な憂鬱の間を行き来することになる。LibreOfficeはこの方程式において大きな役割を占めている。Windowsを使わざるを得ない理由は、オフィス作業とゲームという2つの重要な要素に集約されるからだ。