Coding01

Coding 点滴

0%

学习 iOS Widgets 开发之天气小组件(一)

对标 Widget

主要对标天气的 Widget 如下图所示:

7561606730867_.pic

参考内容主要有:

  1. 所在位置:裕华区
  2. 当前温度:1 摄氏度,实况温度,默认单位:摄氏度
  3. 实况风向和实况风力等级:东北风 1 级,
  4. 实况相对湿度,百分比数值
  5. 实况天气状况的文字描述,包括阴晴雨雪等天气状态的描述
  6. 实况观测时间
  7. 实时空气质量指数和实时空气质量指数级别
  8. 未来三天的天气预报

整个 widget 主要分成左右结构,左边结构采用上中下三层布局,右边结构采用列表形式展示未来几天天气预报内容。

天气 API

本文结合国内外一些好的天气接口提供商,最后选择使用和风天气开发平台

主要是其提供的「开发者方案」完全符合我们这种目前处于自学和少用户量的开发者,特别的友好,并且对于「认证个人开发者」提供的 API 日访问量 (16700 次/天) 足够使用了,而且如果是集成官方 SDK,访问量是无限制的。

注:本文是开发 Widget,和风天气提供的 iOS SDK 是基于 OC 的,个人想还是纯 Swift 开发,且不想 APP 包过大,所以本文当前采用 API 接口形式。

结合上面的对标 Widget,主要需要 API 包括:

  1. 天气预报和实况接口:

商业版 https://api.qweather.com/v7/weather/now?{请求参数}
开发版 https://devapi.qweather.com/v7/weather/now?{请求参数}

返回数据如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// 北京实况天气 
// 商业版 https://api.qweather.com/v7/weather/now?location=101010100&key=xxx
// 开发版 https://devapi.qweather.com/v7/weather/now?location=101010100&key=xxx
// 请将示例请求URL中的KEY更换成你自己的KEY

{
"code": "200",
"updateTime": "2020-06-30T22:00+08:00",
"fxLink": "http://hfx.link/2ax1",
"now": {
"obsTime": "2020-06-30T21:40+08:00",
"temp": "24",
"feelsLike": "26",
"icon": "101",
"text": "多云",
"wind360": "123",
"windDir": "东南风",
"windScale": "1",
"windSpeed": "3",
"humidity": "72",
"precip": "0.0",
"pressure": "1003",
"vis": "16",
"cloud": "10",
"dew": "21"
},
"refer": {
"sources": [
"Weather China"
],
"license": [
"commercial license"
]
}
}
  1. 3 天预报接口:

商业版 https://api.qweather.com/v7/weather/3d?{请求参数}
开发版 https://devapi.qweather.com/v7/weather/3d?{请求参数}

返回数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
// 北京3天预报 
// 商业版 https://api.qweather.com/v7/weather/3d?location=101010100&key=xxx
// 开发版 https://devapi.qweather.com/v7/weather/3d?location=101010100&key=xxx
// 请将示例请求URL中的KEY更换成你自己的KEY

{
"code": "200",
"updateTime": "2020-06-14T16:57+08:00",
"fxLink": "https://www.qweather.com/weather/beijing-101010100.html",
"daily": [
{
"fxDate": "2020-06-14",
"sunrise": "04:45",
"sunset": "19:44",
"moonrise": "01:05",
"moonset": "12:53",
"tempMax": "35",
"tempMin": "22",
"iconDay": "100",
"textDay": "晴",
"iconNight": "150",
"textNight": "晴",
"wind360Day": "358",
"windDirDay": "北风",
"windScaleDay": "1-2",
"windSpeedDay": "8",
"wind360Night": "234",
"windDirNight": "西南风",
"windScaleNight": "1-2",
"windSpeedNight": "6",
"humidity": "22",
"precip": "0.0",
"pressure": "1001",
"vis": "25",
"uvIndex": "11"
},
{
"fxDate": "2020-06-15",
"sunrise": "04:45",
"sunset": "19:45",
"moonrise": "01:29",
"moonset": "13:51",
"tempMax": "36",
"tempMin": "22",
"iconDay": "100",
"textDay": "晴",
"iconNight": "150",
"textNight": "晴",
"wind360Day": "6",
"windDirDay": "北风",
"windScaleDay": "1-2",
"windSpeedDay": "2",
"wind360Night": "220",
"windDirNight": "西南风",
"windScaleNight": "1-2",
"windSpeedNight": "5",
"humidity": "30",
"precip": "0.0",
"pressure": "999",
"vis": "25",
"uvIndex": "11"
},
{
"fxDate": "2020-06-16",
"sunrise": "04:45",
"sunset": "19:45",
"moonrise": "01:52",
"moonset": "14:49",
"tempMax": "35",
"tempMin": "24",
"iconDay": "100",
"textDay": "晴",
"iconNight": "150",
"textNight": "晴",
"wind360Day": "235",
"windDirDay": "西南风",
"windScaleDay": "3-4",
"windSpeedDay": "18",
"wind360Night": "206",
"windDirNight": "西南风",
"windScaleNight": "1-2",
"windSpeedNight": "5",
"humidity": "30",
"precip": "0.0",
"pressure": "994",
"vis": "25",
"uvIndex": "11"
}
],
"refer": {
"sources": [
"Weather China"
],
"license": [
"commercial license"
]
}
}
  1. 空气质量接口:

全国 3240 市县区及 1500 个监测站点的空气质量AQI接口,支持空气质量AQI数据,空气质量实况数据、空气质量未来7天预报。通过灵活的接口请求参数,你可以一次获取以上数据,也可以分别获取其中你需要的数据。

商业版 https://api.qweather.com/v7/air/now?{请求参数}
开发版 https://devapi.qweather.com/v7/air/now?{请求参数}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
// 北京空气质量实况 
// 商业版:https://api.qweather.com/v7/air/now?location=101010100&key=xxx
// 开发版:https://devapi.qweather.com/v7/air/now?location=101010100&key=xxx
// 请将示例请求URL中的KEY更换成你自己的KEY

{
"code": "200",
"updateTime": "2020-06-21T11:44+08:00",
"fxLink": "https://www.qweather.com/air/beijing-101010100.html",
"now": {
"pubTime": "2020-06-21T11:00+08:00",
"aqi": "82",
"category": "良",
"primary": "O3",
"pm10": "82",
"pm2p5": "39",
"no2": "16",
"so2": "5",
"co": "0.7",
"o3": "185"
},
"station": [
{
"pubTime": "2020-06-21T11:00+08:00",
"stationName": "万寿西宫",
"stationId": "CNA1001",
"aqi": "64",
"level": "2",
"category": "良",
"primary": "PM10",
"pm10": "77",
"pm2p5": "36",
"no2": "17",
"so2": "3",
"co": "0.5",
"o3": "166"
},
{
"pubTime": "2020-06-21T11:00+08:00",
"stationName": "定陵",
"stationId": "CNA1002",
"aqi": "103",
"level": "3",
"category": "轻度污染",
"primary": "O3",
"pm10": "95",
"pm2p5": "35",
"no2": "15",
"so2": "3",
"co": "0.7",
"o3": "206"
},
{
"pubTime": "2020-06-21T11:00+08:00",
"stationName": "东四",
"stationId": "CNA1003",
"aqi": "102",
"level": "3",
"category": "轻度污染",
"primary": "O3",
"pm10": "67",
"pm2p5": "36",
"no2": "12",
"so2": "5",
"co": "0.6",
"o3": "203"
},
{
"pubTime": "2020-06-21T11:00+08:00",
"stationName": "天坛",
"stationId": "CNA1004",
"aqi": "69",
"level": "2",
"category": "良",
"primary": "O3",
"pm10": "76",
"pm2p5": "38",
"no2": "12",
"so2": "5",
"co": "0.7",
"o3": "175"
},
{
"pubTime": "2020-06-21T11:00+08:00",
"stationName": "农展馆",
"stationId": "CNA1005",
"aqi": "73",
"level": "2",
"category": "良",
"primary": "O3",
"pm10": "77",
"pm2p5": "35",
"no2": "13",
"so2": "4",
"co": "0.6",
"o3": "178"
},
{
"pubTime": "2020-06-21T11:00+08:00",
"stationName": "官园",
"stationId": "CNA1006",
"aqi": "83",
"level": "2",
"category": "良",
"primary": "O3",
"pm10": "77",
"pm2p5": "39",
"no2": "15",
"so2": "5",
"co": "0.5",
"o3": "186"
},
{
"pubTime": "2020-06-21T11:00+08:00",
"stationName": "海淀区万柳",
"stationId": "CNA1007",
"aqi": "97",
"level": "2",
"category": "良",
"primary": "O3",
"pm10": "74",
"pm2p5": "36",
"no2": "17",
"so2": "6",
"co": "0.7",
"o3": "197"
},
{
"pubTime": "2020-06-21T11:00+08:00",
"stationName": "顺义新城",
"stationId": "CNA1008",
"aqi": "101",
"level": "3",
"category": "轻度污染",
"primary": "O3",
"pm10": "67",
"pm2p5": "32",
"no2": "18",
"so2": "5",
"co": "0.5",
"o3": "202"
},
{
"pubTime": "2020-06-21T11:00+08:00",
"stationName": "怀柔镇",
"stationId": "CNA1009",
"aqi": "99",
"level": "2",
"category": "良",
"primary": "O3",
"pm10": "102",
"pm2p5": "53",
"no2": "26",
"so2": "4",
"co": "0.7",
"o3": "199"
},
{
"pubTime": "2020-06-21T11:00+08:00",
"stationName": "昌平镇",
"stationId": "CNA1010",
"aqi": "78",
"level": "2",
"category": "良",
"primary": "NA",
"pm10": "106",
"pm2p5": "57",
"no2": "17",
"so2": "3",
"co": "0.7",
"o3": "163"
},
{
"pubTime": "2020-06-21T11:00+08:00",
"stationName": "奥体中心",
"stationId": "CNA1011",
"aqi": "67",
"level": "2",
"category": "良",
"primary": "PM10",
"pm10": "84",
"pm2p5": "30",
"no2": "15",
"so2": "3",
"co": "0.5",
"o3": "164"
},
{
"pubTime": "2020-06-21T11:00+08:00",
"stationName": "古城",
"stationId": "CNA1012",
"aqi": "67",
"level": "2",
"category": "良",
"primary": "O3",
"pm10": "72",
"pm2p5": "33",
"no2": "13",
"so2": "5",
"co": "0.6",
"o3": "173"
}
],
"refer": {
"sources": [
"cnemc"
],
"license": [
"commercial license"
]
}
}

结合开发者调用接口的频次和 Widget 更新频次,本文每 1 小时请求一次接口,这样也可以防止接口滥用的问题。

这里用到自己的云服务器搭建 Laravel 接口,封装第三方请求,将数据整合成 Widget 需要的数据格式,这里就不再描述了,比较简单

接口返回数据大致如下:

Widget Model

根据需要,我们先定义以下几个字段,用于展示使用,未来再根据需要扩展。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
struct Weather: Convertible, Hashable {
var updateTime: String = ""
var weatherNow: WeatherNow = WeatherNow()
var weatherDailies: [WeatherDaily] = []
}

struct WeatherNow: Convertible, Hashable {
var temp: Int = 2 // 目前温度
var text: String = "阴" // 描述
var icon: String = "104" // 图标
var windDir: String = "西北风" //
var windScale: Int = 2 // 级
var humidity: Int = 63 // 湿度
var category: String = "轻度污染" // aqi
var aqi: Int = 114
}

struct WeatherDaily: Convertible, Hashable {
var fxDate: String = "2020-12-01"
var tempMin: Int = -1
var tempMax: Int = 4
var iconDay: String = "400"
var textDay: String = "小雪"
var iconNight: String = "400"
var textNight: String = "小雪"
}

Model 主要内嵌实时天气和最近三天的天气预报。看着比对标的少一天。

网络请求获取数据也比较简单:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class WeatherLoader {
let apiBaseUrl = "https://api.com/weather"

public func fetch(parameters: [String: String], completion: @escaping (Result<Weather, Error>) -> Void) {
AF.request(apiBaseUrl, method: .get, parameters: parameters)
.validate().responseJSON { response in
switch response.result {
case .success(_):
let weather = response.data?.kj.model(Weather.self)
completion(.success(weather!))
case .failure(let error):
completion(.failure(error))
}
}
}
}

ViewModel

这里结合 Combine 框架,保证获取数据后能够传到 View 层。

Combine Framework
Customize handling of asynchronous events by combining event-processing operators.

转自:苹果官方文档Combine

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import Combine

class WeatherViewModel: ObservableObject {

let willChange = PassthroughSubject<Void, Never>()
private var weatherLoader: WeatherLoader = WeatherLoader()

@Published var isVisible: Bool = false

var weather: Weather = Weather() {
willSet {
willChange.send()
}
}

func getWeather() {
let params = ["location": "114.48,38.03"]

weatherLoader.fetch(parameters: params) {result in
if case .success(let weather) = result {
self.weather = weather
self.isVisible = true
} else {
self.weather = Weather()
}
}
}
}

我们以「石家庄」为例,获取当天的天气数据。

Widget

整个 Widget 代码基本按照之前的文章重复复制粘贴 (在未来某一个时间重构),这里主要有两个地方需要注意:

一开始在布局时,采用 VStack,导致一直存在边际的问题,如下:

后来发现改成 GeometryReader 布局,完美解决。

代码主要如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
import SwiftUI

struct WeatherCard: View {
var weather: Weather

var body: some View {
GeometryReader { geo in
VStack(alignment: .leading, spacing: 0) {
// 头部坐标和更新时间
HStack(alignment: .center, spacing: 0) {
HStack(alignment: .center, spacing: 4, content: {
Image(systemName: "location.fill")
.font(.system(size: 11))
.foregroundColor(.font_foreground)
Text(weather.location)
.font(.system(size: 11, weight: Font.Weight.bold, design: Font.Design.default))
.foregroundColor(.font_foreground)
})

Spacer()

HStack(alignment: .center, spacing: 4, content: {
Text(weather.updateTime)
.font(.system(size: 11, weight: Font.Weight.bold, design: Font.Design.default))
.foregroundColor(.font_foreground)
Image(systemName: "arrow.2.circlepath")
.font(.system(size: 11, weight: Font.Weight.bold, design: Font.Design.default))
.foregroundColor(.font_foreground)
})
}.frame(height: 44)

Divider()
.foregroundColor(.divider_color)
.frame(height: 1)

HStack(alignment: .center, spacing: 0) {
// 当前温度
Text("\(weather.weatherNow.temp)ºC")
.font(.system(size: 45, weight: Font.Weight.bold, design: Font.Design.default))
.foregroundColor(.font_weather_temp_foreground)

Spacer().frame(width: 10)

VStack(alignment: HorizontalAlignment.leading) {
Text("\(weather.weatherNow.text)")
.font(.system(size: 14, weight: Font.Weight.bold, design: Font.Design.default))
.foregroundColor(.main_color_1)

if (weather.weatherDailies.count > 1) {
// Spacer().frame(height:9)

Text("\(weather.weatherDailies.first!.tempMin)ºC~\(weather.weatherDailies.first!.tempMax)ºC")
.font(.system(size: 11, weight: Font.Weight.bold, design: Font.Design.default))
.foregroundColor(.font_foreground)
}
}

Spacer()

Image(systemName: weather.weatherNow.icon2systemIcon())
.font(.system(size: 45))
.foregroundColor(.weather_icon)
}.frame(height: geo.frame(in: .local).height - 51)

Spacer()
}.padding(EdgeInsets.init(top: 0, leading: 20, bottom: 0, trailing: 20))
.background(Color.box_background)
}
}
}

和风天气开放平台给了图标图片,但我觉得如果能直接使用系统自带的最好,这里推荐使用SwiftUI SF Symbols

好了,接下来就是看看效果的时候:

基本上和设计师设计的原始搞一模一样,还原度极高:

7631606886181_.pic_hd

下一步

下一步主要结合 Accessing Location Information in Widgets ,获取 Location 信息,调用接口,获取天气信息。

Welcome to my other publishing channels