WidgetKitで天気予報アプリ作ってみた〜天気情報取得編〜

## 投稿の経緯
最近仕事でよくウィジェットを触っていて、復習とアウトプットを兼ねて天気予報アプリを開発しているので記事にしました。
今回使う天気情報は「Open Weather API」の「One Call API」から取得します。

https://openweathermap.org/

## 開発環境
Swift 5.5
Xcode 13.2.1

## サンプルプロジェクト
GitHubにPushしています。気になる方はご覧ください。
https://github.com/ken-sasaki-222/WeatherWidget
![QR_615642.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/643372/8c43013f-9757-f7dd-5946-2684f30f63b7.png)


## APIを叩いてみる
```
{
  "lat": 33.44,
  "lon": -94.04,
  "timezone": "America/Chicago",
  "timezone_offset": -21600,
  "current": {
    "dt": 1618317040,
    "sunrise": 1618282134,
    "sunset": 1618333901,
    "temp": 284.07,
    "feels_like": 282.84,
    "pressure": 1019,
    "humidity": 62,
    "dew_point": 277.08,
    "uvi": 0.89,
    "clouds": 0,
    "visibility": 10000,
    "wind_speed": 6,
    "wind_deg": 300,
    "weather": [
      {
        "id": 500,
        "main": "Rain",
        "description": "light rain",
        "icon": "10d"
      }
    ],
    "rain": {
      "1h": 0.21
    }
  },
    "minutely": [
    {
      "dt": 1618317060,
      "precipitation": 0.205
    },
    ...
  },
    "hourly": [
    {
      "dt": 1618315200,
      "temp": 282.58,
      "feels_like": 280.4,
      "pressure": 1019,
      "humidity": 68,
      "dew_point": 276.98,
      "uvi": 1.4,
      "clouds": 19,
      "visibility": 306,
      "wind_speed": 4.12,
      "wind_deg": 296,
      "wind_gust": 7.33,
      "weather": [
        {
          "id": 801,
          "main": "Clouds",
          "description": "few clouds",
          "icon": "02d"
        }
      ],
      "pop": 0
    },
    ...
  }
    "daily": [
    {
      "dt": 1618308000,
      "sunrise": 1618282134,
      "sunset": 1618333901,
      "moonrise": 1618284960,
      "moonset": 1618339740,
      "moon_phase": 0.04,
      "temp": {
        "day": 279.79,
        "min": 275.09,
        "max": 284.07,
        "night": 275.09,
        "eve": 279.21,
        "morn": 278.49
      },
      "feels_like": {
        "day": 277.59,
        "night": 276.27,
        "eve": 276.49,
        "morn": 276.27
      },
      "pressure": 1020,
      "humidity": 81,
      "dew_point": 276.77,
      "wind_speed": 3.06,
      "wind_deg": 294,
      "weather": [
        {
          "id": 500,
          "main": "Rain",
          "description": "light rain",
          "icon": "10d"
        }
      ],
      "clouds": 56,
      "pop": 0.2,
      "rain": 0.62,
      "uvi": 1.93
    },
    ...
    },
    "alerts": [
    {
      "sender_name": "NWS Tulsa",
      "event": "Heat Advisory",
      "start": 1597341600,
      "end": 1597366800,
      "description": "...HEAT ADVISORY REMAINS IN EFFECT FROM 1 PM THIS AFTERNOON TO\n8 PM CDT THIS EVENING...\n* WHAT...Heat index values of 105 to 109 degrees expected.\n* WHERE...Creek, Okfuskee, Okmulgee, McIntosh, Pittsburg,\nLatimer, Pushmataha, and Choctaw Counties.\n* WHEN...From 1 PM to 8 PM CDT Thursday.\n* IMPACTS...The combination of hot temperatures and high\nhumidity will combine to create a dangerous situation in which\nheat illnesses are possible.",
      "tags": [
        "Extreme temperature value"
        ]
    },
    ...
  ]
```
公式のレスポンスを参考にしていますが、このままでは取得する情報が多く、使わないものもあるので条件を絞ってリクエストを作成したいと思います。条件は以下の通り。

- hourly以外は取得しない
- 温度は摂氏で取得
- 言語は日本対応で取得

#### hourly以外は取得しない
パラメータのexcludeに`current` `minutely` `daily` `alerts`を指定し`hourly`以外は取得しないように設定します。
カンマ区切りのスペースなしである必要があります。

#### 温度は摂氏で取得
温度を摂氏で取得するにはパラメータunitsに`metric`を指定します。

#### 言語は日本対応で取得
パラメータlangに`ja`を指定します。

#### リクエストURLの確認
```
https://api.openweathermap.org/data/2.5/onecall?lat=35.65146&lon=139.63678&units=metric&exclude=current,minutely,daily,alerts&lang=ja&appid={キー}
```
作成したリクエストURLです。緯度経度は東京都のとある地点を使っています。（将来的に現在地から緯度経度を取得します）

## レスポンスをもとにModelを作成
```
{
    "lat": 35.6515,
    "lon": 139.6368,
    "timezone": "Asia/Tokyo",
    "timezone_offset": 32400,
    "hourly": [
        {
            "dt": 1643446800,
            "temp": 7.16,
            "feels_like": 4.87,
            "pressure": 1009,
            "humidity": 50,
            "dew_point": -2.26,
            "uvi": 0,
            "clouds": 75,
            "visibility": 8073,
            "wind_speed": 3.37,
            "wind_deg": 102,
            "wind_gust": 5.11,
            "weather": [
                {
                    "id": 500,
                    "main": "Rain",
                    "description": "小雨",
                    "icon": "10n"
                }
            ],
            "pop": 0.21,
            "rain": {
                "1h": 0.12
            }
        },
        ....
}
```
作成したリクエストを送るとこのようにレスポンスが返ってきます。
今回はhourlyから現在時刻〜48時間分の`dt`、`temp`、`pressure`と、weatherの`main`のみを取得します。

```WeatherResponseModel.swift
import Foundation

struct WeatherResponseModel: Decodable {
    var hourly: [Hourly]
}

struct Hourly: Decodable {
    var dt: Double
    var temp: Double
    var pressure: Double
    var weather: [Weather]
}

struct Weather: Decodable {
    var main: String
}
```

## リクエストModelを作成
```WeatherRequestModel.swift
import Foundation

struct WeatherRequestModel {
    var lat: Double
    var lng: Double
}
```
緯度経度を格納するリクエストモデルです。

## 実装
#### アーキテクチャ
![DIP (1).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/643372/97f53318-b997-1672-6577-3576439e5c04.png)
`MVVM + リポジトリパターン`を採用して今回は開発しています。

#### DataStore
```WeatherDataStore.swift
import Foundation

final class WeatherDataStore {
    private let baseUrl = "https://api.openweathermap.org/data/2.5/onecall"
    private let shared = URLSession.shared
    private let decoder = JSONDecoder()
    
    func fetchWeathers(requestModel: WeatherRequestModel) async throws -> WeatherResponseModel {
        let params = "lat=\(requestModel.lat)&lon=\(requestModel.lng)&units=metric&exclude=current,minutely,daily,alerts&lang=ja&appid=\(API_KEY)"
        let urlString = baseUrl + "?" + params
        guard let url = URL(string: urlString) else {
            throw NSError(domain: "Error fetch weathers.", code: -1)
        }
        let request = URLRequest(url: url)
        let (data, _) = try await shared.data(for: request)
        let response = try decoder.decode(WeatherResponseModel.self, from: data)
        return response
    }
}
```
APIと通信するDataStoreです。今回`async/await`を使って非同期処理を書いています。 

```WeatherWidgetTests.swift
import XCTest

class WeatherWidgetTests: XCTestCase {

    override func setUpWithError() throws {
        // Put setup code here. This method is called before the invocation of each test method in the class.
    }

    override func tearDownWithError() throws {
        // Put teardown code here. This method is called after the invocation of each test method in the class.
    }

    func testWeatherDataStore() async throws {
        let dataStore = WeatherDataStore()
        let request = WeatherRequestModel(lat: 35.65146, lng: 139.63678) 
        let response = try await dataStore.fetchWeathers(requestModel: request)
        
        print("Success fetch weathers.")
        print("Response hourly:", response.hourly)
        
        XCTAssert(response.hourly.count > 0)
    }
}
```
データに依存するので完全なテストではないですが、動作確認レベルでDataStoreのテストを書きました。このテストでAPI通信の挙動を確認できるので、テストが成功して正常に値が取得できているのであればData層の開発は完了です。

#### Repository
```WeatherRepositoryInterface.swift
import Foundation

protocol WeatherRepositoryInterface {
    func fetchWeathers(requestModel: WeatherRequestModel) async throws -> WeatherResponseModel
}
```

```WeatherRepository.swift
import Foundation

class WeatherRepository: WeatherRepositoryInterface {
    private let weatherDataStore = WeatherDataStore()
    
    func fetchWeathers(requestModel: WeatherRequestModel) async throws -> WeatherResponseModel {
        do {
            let response = try await weatherDataStore.fetchWeathers(requestModel: requestModel)
            return response
        }
        catch {
            throw error
        }
    }
}
```
WeatherDataStoreへrequestModelを渡して`do catch`で値を受け取ります。受け取った結果はasync throwsでViewModelに返します。後々開発するWidget ExtensionからもこのRepositoryを経由して天気情報を取得します。

#### ViewModel
```RepositoryRocator.swift
import Foundation

class RepositoryRocator {
    static func getWeatherRepository() -> WeatherRepositoryInterface {
        WeatherRepository()
    }
}
```

```WeatherViewModel.swift
import Foundation

class WeatherViewModel: NSObject {
    private let weatherRepository: WeatherRepositoryInterface
    
    init(weatherRepository: WeatherRepositoryInterface) {
        self.weatherRepository = weatherRepository
        super.init()
    }
    
    override convenience init() {
        self.init(weatherRepository: RepositoryRocator.getWeatherRepository())
    }
    
    func createRequestModel() -> WeatherRequestModel {
        let requestModel = WeatherRequestModel(
            lat: 35.65146,
            lng: 139.63678
        )
        
        return requestModel
    }
    
    func fetchWeathers() async {
        do {
            let response = try await weatherRepository.fetchWeathers(requestModel: createRequestModel())
            print("Success fetch weathers:", response.hourly)
        }
        catch {
            print("Error fetch weathers:", error)
        }
    }
}
```
WeatherRepositoryへアクセスするViewModelです。RepositoryはRepositoryRocatorを経由してRepositoryInterface（抽象）から取得しています。

#### View
```ContentView.swift
import SwiftUI

struct ContentView: View {
    private let weatherVM = WeatherViewModel()
    
    var body: some View {
        Button {
            Task {
                await weatherVM.fetchWeathers()
            }
        } label: {
            Text("天気情報取得")
                .font(.system(size: 18, weight: .medium, design: .default))
                .padding(.horizontal, 80)
                .padding(.vertical, 12)
                .foregroundColor(.white)
                .background(.orange)
                .cornerRadius(100)
        }
    }
}
```
今回はウィジェットの開発がメインなのでアプリのViewはボタンタップで天気情報の取得だけにしておきます。

## レスポンスのログ確認
```
Success fetch weathers: [
    WeatherWidget.Hourly(dt: 1643612400.0, temp: 8.05, pressure: 1013.0, weather: [WeatherWidget.Weather(main: "Clouds")]),
    WeatherWidget.Hourly(dt: 1643616000.0, temp: 8.21, pressure: 1013.0, weather: [WeatherWidget.Weather(main: "Clouds")]), 
    WeatherWidget.Hourly(dt: 1643619600.0, temp: 7.85, pressure: 1014.0, weather: [WeatherWidget.Weather(main: "Clouds")]),
    ...
]
```
48時間分の時間、気温、気圧、weatherが取得できているので取得成功です。

## おわりに
今回は**WidgetKitで天気予報アプリ作ってみた~天気情報取得編~**について書きました。
現状、緯度経度を直接指定しているので、次回書き換えていきます。

続きが気になる方は↓こちら↓から

https://qiita.com/nkekisasa222/items/249240ad36128de05557

ご覧いただきありがとうございました。
こうしたほうがいいや、ここはちょっと違うなど気になる箇所があった場合、ご教示いただけると幸いです。

## お知らせ
現在副業でiOSアプリ開発案件を募集しています。
[Twitter DM](https://twitter.com/ken_sasaki2)でご依頼お待ちしております！
![QR_615427.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/643372/4f8dff15-33fa-c042-44a3-b592f49c959c.png)
↓活動リンクはこちら↓
https://linktr.ee/sasaki.ken