［SwiftUI + MVVM］メンテナンス・機能拡張しやすいコードの書き方の検討（メモ書き）

## この記事の内容
SwiftUI と MVVM を題材に、メンテナンス・機能を拡張しやすいコードの書き方を考える
※ あくまで個人のメモ書き程度と捉えてください

## 開発するアプリ
**機能**
- とある鉄道会社のとある路線の駅を一覧で表示する
- 駅番号・駅名（日本語）・駅名（英語）を表示する

**GitHub**

https://github.com/tanakadaichi1989/SwiftUI_MVVM

**画面数**
- 1つのみ

**画面イメージ**
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/199441/a77de73a-f636-41c8-b54f-c7230d8d14b3.png" width=300px>

## ファイル
|ファイル名|内容|備考|
|---|---|---|
|Station_Kobe.json|駅に関する情報を記録||
|JSONLoader.swift|JSON ファイルからデータを取得・解析|SwiftUI チュートリアルで JSON ファイルからデータを取得・解析するコードを流用した|
|Station.swift|データモデル（駅）を定義||
|Line.swift|路線に関する情報を列挙型で定義|神戸線のラインカラーは青|
|StationViewModel.swift|JSON ファイルから取得したデータを画面に渡す||
|KobeLineApp.swift|アプリを実行すると、まず実行する（エントリーポイント）||
|ContentView.swift|アプリの画面を定義||
|StationNumberView.swift|駅番号に関するビューを定義||
|StationView.swift|駅名に関するビューを定義||

## クラス図
```plantuml
@startuml
class JSONLoader{}
class StationViewModel {}
struct KobeLineApp {}
struct ContentView {}

JSONLoader <-down- StationViewModel
StationViewModel <-down- KobeLineApp
KobeLineApp <-down- ContentView
@enduml
```

**クラス図を観察するとわかること**
矢印 （→） の方向が一方方向であること
- `JSONLoader` は `StationViewModel` のことを知らない
- `StationViewModel` は `KobeLineApp` のことを知らない ... 以下同様

**考えること**
1. 現在は JSON ファイルからデータを取得し、解析している。データの取得先をデータベースに変更する際に発生する問題はあるか
1. 現在は 神戸線のみ JSON ファイルからデータを取得している。仮に、宝塚線・京都線のデータも表示したいとする。その拡張にその変更に耐えられるか

**現時点での反省点**
- アプリの名前は `KobeLineApp` にしない方が良かった。理由：今後、宝塚線・京都線のデータも表示するかもしれないから

## コード
```JSON:Station_Kobe.json
[
    {
        "id": "kobe01",
        "numbering": "01",
        "line": "Kobe",
        "name": "大阪梅田",
        "nameEnglish": "Osaka-umeda",
        "latitude": "34.705326",
        "longitude": "135.498398",
        "isFavorite": true
    },
    {
        "id": "kobe02",
        "numbering": "02",
        "line": "Kobe",
        "name": "中津",
        "nameEnglish": "Nakatsu",
        "latitude": "34.709851",
        "longitude": "135.492499",
        "isFavorite": false
    },
    {
        "id": "kobe03",
        "numbering": "03",
        "line": "Kobe",
        "name": "十三",
        "nameEnglish": "Juso",
        "latitude": "34.72049",
        "longitude": "135.482198",
        "isFavorite": true
    },
    {
        "id": "kobe04",
        "numbering": "04",
        "line": "Kobe",
        "name": "神崎川",
        "nameEnglish": "Kanzakigawa",
        "latitude": "34.732349",
        "longitude": "135.472782",
        "isFavorite": false
    },
    {
        "id": "kobe05",
        "numbering": "05",
        "line": "Kobe",
        "name": "園田",
        "nameEnglish": "Sonoda",
        "latitude": "34.751903",
        "longitude": "135.448145",
        "isFavorite": false
    },
    {
        "id": "kobe06",
        "numbering": "06",
        "line": "Kobe",
        "name": "塚口",
        "nameEnglish": "Tsukaguchi",
        "latitude": "34.752968",
        "longitude": "135.416376",
        "isFavorite": false
    },
    {
        "id": "kobe07",
        "numbering": "07",
        "line": "Kobe",
        "name": "武庫之荘",
        "nameEnglish": "Mukonoso",
        "latitude": "34.751596",
        "longitude": "135.393428",
        "isFavorite": false
    },
    {
        "id": "kobe08",
        "numbering": "08",

        "line": "Kobe",
        "name": "西宮北口",
        "nameEnglish": "Nishinomiya-kitaguchi",
        "latitude": "34.745956",
        "longitude": "135.356597",
        "isFavorite": true
    },
    {
        "id": "kobe09",
        "numbering": "09",
        "line": "Kobe",
        "name": "夙川",
        "nameEnglish": "Shukugawa",
        "latitude": "34.742262",
        "longitude": "135.328128",
        "isFavorite": true
    },
    {
        "id": "kobe10",
        "numbering": "10",

        "line": "Kobe",
        "name": "芦屋川",
        "nameEnglish": "Ashiyagawa",
        "latitude": "34.736568",
        "longitude": "135.300937",
        "isFavorite": false
    },
    {
        "id": "kobe11",
        "numbering": "11",
        "line": "Kobe",
        "name": "岡本",
        "nameEnglish": "Okamoto",
        "latitude": "34.729151",
        "longitude": "135.275827",
        "isFavorite": true
    },
    {
        "id": "kobe12",
        "numbering": "12",
        "line": "Kobe",
        "name": "御影",
        "nameEnglish": "Mikage",
        "latitude": "34.724559",
        "longitude": "135.252254",
        "isFavorite": false
    },
    {
        "id": "kobe13",
        "numbering": "13",
        "line": "Kobe",
        "name": "六甲",
        "nameEnglish": "Rokko",
        "latitude": "34.719652",
        "longitude": "135.23429",
        "isFavorite": false
    },
    {
        "id": "kobe14",
        "numbering": "14",
        "line": "Kobe",
        "name": "王子公園",
        "nameEnglish": "Ojikoen",
        "latitude": "34.710262",
        "longitude": "135.218527",
        "isFavorite": false
    },
    {
        "id": "kobe15",
        "numbering": "15",
        "line": "Kobe",
        "name": "春日野道",
        "nameEnglish": "Kasuganomichi",
        "latitude": "34.703103",
        "longitude": "135.205507",
        "isFavorite": false
    },
    {
        "id": "kobe16",
        "numbering": "16",
        "line": "Kobe",
        "name": "神戸三宮",
        "nameEnglish": "Kobe-sannnomiya",
        "latitude": "34.693143",
        "longitude": "135.192847",
        "isFavorite": true
    }
]
```

```Swift:JSONLoader.swift
import Foundation

class JSONLoader {
    private static var instance: JSONLoader = JSONLoader()
    private init(){}
    public static func getInstance() -> JSONLoader { return JSONLoader.instance }
    
    func load<T: Decodable>(_ filename: String) throws -> T {
        let data: Data

        guard let file = Bundle.main.url(forResource: filename, withExtension: nil)
        else {
            throw JSONLoaderError.cantFindFile
        }

        do {
            data = try Data(contentsOf: file)
        } catch {
            throw JSONLoaderError.cantLoadFile
        }

        do {
            let decoder = JSONDecoder()
            return try decoder.decode(T.self, from: data)
        } catch {
            throw JSONLoaderError.cantParseFile
        }
    }
    
    enum JSONLoaderError:Error {
        case cantFindFile
        case cantLoadFile
        case cantParseFile
    }
}
```

```swift:Station.swift
import Foundation
import SwiftUI

struct Station: Hashable, Codable,Identifiable {
        let id, numbering: String
        let line: Line
        let name, nameEnglish, latitude, longitude: String
        let isFavorite: Bool
    
        enum Line: String, Codable {
            case kobe = "Kobe"
        }
}
```

```swift:Line.swift
import Foundation

enum Line: String {
    case Kobe
    
    var description: String {
        switch(self){
        case .Kobe: return "神戸線"
        }
    }
    
    var color: Color {
        switch(self){
        case .Kobe: return .blue
        }
    }
}
```

```swift:StationViewModel.swift
import Foundation
import SwiftUI

class StationViewModel: ObservableObject {
    private var loader: JSONLoader = JSONLoader.getInstance()
    @Published var stations: [Station] = []
    
    init(){
        do {
            stations = try loader.load("Station_Kobe.json")
        } catch {
            stations = []
        }
    }
}
```

```Swift:KobeLineApp.swift
import SwiftUI

@main
struct KobeLineApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView(viewModel: StationViewModel())
        }
    }
}
```

```Swift:ContentView.swift
import SwiftUI

struct ContentView: View {
    @ObservedObject var viewModel: StationViewModel
    init(viewModel: StationViewModel) {
        self.viewModel = viewModel
    }
    
    var body: some View {
        VStack {
            NavigationView {
                listView // ネストが深くなるため listView に分けた
            }
        }
    }
    
    private var listView: some View {
        List(viewModel.stations){ station in
            HStack {
                StationNumberView(station: station)
                StationView(station: station)
            }
        }
    }
}
```

```swift:StationView.swift
import SwiftUI

struct StationView: View {
    var station: Station
    var body: some View {
        VStack(alignment: .leading) {
            Text(station.name)
                .font(.body)
                .fontWeight(.bold)
            Text(station.nameEnglish)
                .font(.caption)
        }
    }
}
```

```swift:StationNumberView.swift
import SwiftUI

struct StationNumberView: View {
    var station:Station
    var body: some View {
        ZStack{
            Circle()
                .stroke(style: StrokeStyle(lineWidth: 2))
                .frame(width: 30,height: 30)
                .foregroundColor(Line.Kobe.color)
            VStack(spacing: 0) {
                Text("HK")
                    .font(.caption2)
                    .fontWeight(.bold)
                    .foregroundColor(Line.Kobe.color)
                    .padding(0)
                Text(station.numbering)
                    .font(.caption2)
                    .fontWeight(.bold)
                    .foregroundColor(Line.Kobe.color)
                    .padding(0)
            }
        }
    }
}
```

## 参考資料
**SwiftUI チュートリアル**

https://developer.apple.com/tutorials/swiftui


**阪急電鉄**

https://www.hankyu.co.jp/