投稿の経緯
最近仕事でよくウィジェットを触っていて、復習とアウトプットを兼ねて天気予報アプリを開発しているので記事にしました。
今回使う天気情報は「Open Weather API」の「One Call API」から取得します。
開発環境
Swift 5.5
Xcode 13.2.1
サンプルプロジェクト
GitHubにPushしています。気になる方はご覧ください。
https://github.com/ken-sasaki-222/WeatherWidget
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
のみを取得します。
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を作成
import Foundation
struct WeatherRequestModel {
var lat: Double
var lng: Double
}
緯度経度を格納するリクエストモデルです。
実装
アーキテクチャ
MVVM + リポジトリパターン
を採用して今回は開発しています。
DataStore
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
を使って非同期処理を書いています。
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
import Foundation
protocol WeatherRepositoryInterface {
func fetchWeathers(requestModel: WeatherRequestModel) async throws -> WeatherResponseModel
}
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
import Foundation
class RepositoryRocator {
static func getWeatherRepository() -> WeatherRepositoryInterface {
WeatherRepository()
}
}
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
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で天気予報アプリ作ってみた~天気情報取得編~について書きました。
現状、緯度経度を直接指定しているので、次回書き換えていきます。
続きが気になる方は↓こちら↓から
ご覧いただきありがとうございました。
こうしたほうがいいや、ここはちょっと違うなど気になる箇所があった場合、ご教示いただけると幸いです。
お知らせ
現在副業でiOSアプリ開発案件を募集しています。
Twitter DMでご依頼お待ちしております!
↓活動リンクはこちら↓
https://linktr.ee/sasaki.ken