最近由于工作需要,干了1个多月的iOS开发。虽说与安卓开发之间没有到隔行如隔山的地步,但正因为声明式UI、State管理那一套以及Swift和Kotlin本身的似像非像,刚开始接触特别容易搞混。还有用惯了JetBrains家的IDE以后再用Xcode...感觉十分酸爽。
总之我想把实际动手做的内容总结出来,以便以后打算继续学iOS时回来参考。
这一篇先简单总结一下如何用Form认证
的方式,从应用发送POST请求登录网站,并利用session管理,从WebView
显示登录后网站内容的方法。顺便提一下SwiftUI以及iOS中MVVM架构的简单实现方法。Demo应用以登录GitHub为例。
Initialize |
Input fake credentials |
Sign in failed |
|
|
|
Input correct credentials |
Sign in succeeded |
Sign out |
|
|
|
项目采用MVVM架构。
View层包含两个画面,LoginView
实现户名密码的输入框和登录按钮,GithubView
实现登录成功后,用于浏览GitHub的WebView。
ViewModel层管理LoginView
的状态(State),调用底层方法发送网络请求实施登录,获取响应结果并更新页面显示。
Model层分为Service
和Model
两个文件夹,前者管理通信处理,后者存放枚举类等数据型。
从安卓开发者的角度来看,SwiftUI中的ZStack,VStack,HStack分别类似于Jetpack Compose中的Box,Column和Row。当然,各种Stack皆如其名,符合First-In-Last-Out原则,即最先存入的UI组件会被放置在栈底。
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
|
import SwiftUI
struct LoginView: View {
//.....Properties
var body: some View {
ZStack { // ZStack垂直于画面,由里向外。
// 背景色放置在ZStack最底层。
Color("BackgroundBlack").edgesIgnoringSafeArea(.all)
// VStack 平行于画面,由上至下。
VStack {
// ...LOGO图片
// ...标题文字
VStack {
//嵌套一个VStack方便对这一部分整体进行Padding处理
// 用户名输入区域
LoginTextField(hintString: "GitHub username", inputString: //TODO:
)
Spacer().frame(height: 20)
// 密码输入区域
LoginTextField(hintString: "password"
, inputString: //TODO:
, isSecure: true)
Spacer().frame(height: 50)
// 登录按键
LoginButton(label: "Sign in" ){
// TODO: Trigger login logic
}
}.padding()
// ......
}
}
}
}
|
以上用到的LoginTextField
和LoginButton
都是自定义的UI组件。
iOS的开发框架中似乎没有封装好的ViewModel,不过我们可以很方便地利用ObservableObject
来创建。
创建之前,先来考虑一下哪些数据需要作为State由ViewModel来管理。我认为有以下三项:
- 用户输入的用户名・密码。
需要根据输入内容控制按键是否有效,是否显示警告等。
- 获取数据的进程。
需要根据进行中、成功、失败等状态显示进度条,弹窗等元素。这里创建名为ViewModelState
的枚举类来管理idle/loading/finish/error四种状态。
- 具体的Error信息
方便弹窗显示错误的具体内容。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
class LoginViewModel: ObservableObject {
@Published var state: ViewModelState = .idle
// username and password
@Published var credential = Credential()
@Published var error: MyError?
// Computed property. 值随Published property的变化而变化
var loginDisabled: Bool {
credential.username.isEmpty || credential.password.isEmpty || state == .loading
}
func performLogin() {
self.state = .loading
// TODO: Login logic
// if succeeded: self.state = .finish
// if failed: self.state = .error
}
// ......
}
|
ViewModel中附有@Published
注解的成员变量能够在其值发生更新时通知订阅者。在View中可以通过ObservedObject
或者StateObject
对ViewModel进行订阅。二者的区别是当View重新绘制时,由于StateObject为View所有,因此其数据不会丢失,而ObservedObject独立于View之外,其数据会回到初始状态。如果我们希望用户输入的数据始终被保持,那么应该使用StateObject来实现。现在在View中加入对ViewModel的订阅。
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
|
struct LoginView: View {
//.....Properties
@StateObject private var viewModel = LoginViewModel()
var body: some View {
ZStack {
//......
VStack {
// ......
VStack {
// 用户名输入区域
//✨每次输入字符,viewModel中的credential都会被更新
LoginTextField(hintString: "GitHub username", inputString: $viewModel.credential.username)
Spacer().frame(height: 20)
// 密码输入区域
//✨每次输入字符,viewModel中的credential都会被更新
LoginTextField(hintString: "password", inputString: $viewModel.credential.password, isSecure: true)
Spacer().frame(height: 50)
// 登录按键
LoginButton(label: "Sign in" ){
viewModel.performLogin()
}
//✨实时更新按键的enabled/disabled状态
.disabled(viewModel.loginDisabled)
}.padding()
//......
//✨当viewModel.error更新时显示Alert
}.alert(item: $viewModel.error) { error in
return Alert(title: Text("Error"),
message: Text(String(describing:error)),
dismissButton:.cancel())
}
//✨当viewModel.state变为loading时显示loading动画
if viewModel.state == .loading {
ProgressView()
}
//✨当viewModel.state变为finish时表明登录成功,显示下一个画面
}.onChange(of: viewModel.state) { state in
if state == .finish {
// TODO: move to GithubView
}
}
}
}
|
至此,LoginView和ViewModel中的数据就被绑定起来了。接下去实现登录成功后的画面切换。这里参考了一位YouTuber的教程,不使用NavigationView而实现画面切换的效果。
首先定义一个ObservableObject
类Authentication
,在这个类中定义一个Bool类型的FlagisValidated
,用于标记登录成功与否。同时创建一个允许外界更新这个Flag的方法updateValidation
。
1
2
3
4
5
6
7
8
9
10
11
12
13
|
class Authentication: ObservableObject {
// Track if login succeeded
@Published var isValidated = false
func updateValidation(success: Bool) {
withAnimation {
DispatchQueue.main.async {
self.isValidated = success
}
}
}
}
|
为了使得整个应用的多个画面都可以获取到和更改isValidated
的值,我们可以在应用的入口处创建一个Authentication
的实例并标记为StateObject
。随后根据isValidated
的值显示不同的画面,并将Authentication
实例以EnvironmentObject
的形式注入到各个View中去。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
@main
struct GithubBrowserApp: App {
@StateObject var authentication = Authentication()
var body: some Scene {
WindowGroup {
// Switch first view depending on authentication result
if authentication.isValidated {
// 如果isValidated为真,则已登录,显示GithubView
GithubView().environmentObject(authentication)
} else {
// 反之则未登录,显示LoginView
LoginView().environmentObject(authentication)
}
}
}
}
|
在LoginView
中加入相应的EnvironmentObject
和处理。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
struct LoginView: View {
// Properties
@StateObject private var viewModel = LoginViewModel()
@EnvironmentObject var authentication: Authentication
var body: some View {
ZStack {
//......
//✨当viewModel.state变为finish时表明登录成功,显示下一个画面
}.onChange(of: viewModel.state) { state in
if state == .finish {
authentication.updateValidation(success: true)
}
}
}
}
|
应用的整体架构和画面切换方式确定了,接着就是把网络请求的部分填上。由于实际项目中的服务端不提供OAuth等形式的认证接口,这里避开GitHub提供的接口,而是尝试从登录页面获取Form表单,用发送表单
的形式进行认证。
首先打开“https://github.com/login ”,用浏览器自带的inspector找到Form信息。
通过读取上述Form包含的信息和实际登录时发送的请求报文,理解整个认证流程并不困难。但已经有人询问过相关做法,可以直接参考:How can I use post from requests module to login to github
登录GitHub时,除了用户名密码,在表单的hidden
元素中还发送了一个authenticity_token
,应该是用来标记session的值。因此在正式发送表单前需要先从页面中提取这个token的值。我们先用python写一个轻量的程序来测试登录流程,这里用到了BeautifulSoup
来解析html,获取authenticity_code
。
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
|
import requests
from bs4 import BeautifulSoup
headers = {
'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/97.0.4692.99 Safari/537.36'
}
login_data = {
'commit': 'Sign in',
'login': '', # your username
'password': '' # your password
}
url = 'https://github.com/session'
session = requests.Session()
response = session.get(url, headers=headers) # Fetch the login page html
# Retrieve the authenticity token
soup = BeautifulSoup(response.text, 'html.parser')
login_data['authenticity_token'] = soup.find('input', attrs={'name': 'authenticity_token'})['value']
print(login_data['authenticity_token'])
login_request = requests.Request('POST', url, data=login_data)
# Use prepared for logging the request
prepared = session.prepare_request(login_request)
print(prepared.headers)
print(prepared.method)
print(prepared.body)
response = session.send(prepared, verify=False)
print(response.status_code)
print(response)
# Verify being able to access to any private resource
response = session.get('https://github.com/settings/profile', headers=headers)
with open("resp.txt", "w") as file:
file.write(response.text)
|
只要最终取得的response文本是登录后的页面,即可判断登录成功。
最后只需要把python的逻辑移植到swift中就可以了。我找到了一个可以解析html的swift开源库SwiftSoup,用来完成python中BeautifulSoup
的工作。发送请求则是用URLSession.shared.dataTask(with: URLRequest)
,不使用任何第三方库。
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
|
import Foundation
import SwiftSoup
typealias ResultHandler<T> = (Result<T, MyError>) -> Void
struct LoginManager {
static let urlString = "https://github.com/session"
static let contentType = "application/x-www-form-urlencoded"
/// 获取登录页
static func getLoginPage(handler: @escaping ResultHandler<String>) {
guard let url = URL(string: urlString) else {
handler(.failure(.invalidRequest))
return
}
var requestGetPage = URLRequest(url: url)
requestGetPage.httpMethod = "GET"
URLSession.shared.dataTask(with: requestGetPage) {
data, response, error in
DispatchQueue.main.async {
//.....
if let safeData = data {
// 将登录页html脚本emit出去,以便经由ViewModel传给`postCredential`
// 进行解析,获取authenticity_token并实施发送请求。
let content = String(data: safeData, encoding: .utf8)
handler(.success(content!))
}
}
}.resume()
}
/// 发送登录请求
static func postCredential(_ credentials: Credential, content: String
, handler: @escaping ResultHandler<Void>) {
guard let url = URL(string: urlString) else {
handler(.failure(.invalidRequest))
return
}
var requestPostLogin = URLRequest(url: url)
requestPostLogin.setValue(contentType, forHTTPHeaderField: "Content-Type")
requestPostLogin.httpMethod = "POST"
var authToken: String = ""
// 从登录页脚本中获取authenticity_token
do {
let doc: Document = try SwiftSoup.parseBodyFragment(content)
let body = doc.body()!
let elements: Elements = try body.select("input")
for element in elements {
if try element.attr("name") == "authenticity_token" {
authToken = try element.attr("value")
}
}
} catch let error {
handler(.failure(.other(error)))
}
// 构建请求body
let requestBody = "commit=Sign in&login=\(credentials.username)&password=\(credentials.password)&authenticity_token=\(authToken)".data(using: .utf8)
requestPostLogin.httpBody = requestBody
URLSession.shared.dataTask(with: requestPostLogin) {
data, response, error in
DispatchQueue.main.async {
// ......
if let safeData = data {
let httpResponse = response as! HTTPURLResponse
let dataStr = String(data: safeData, encoding: .utf8)
// 响应状态为200
if httpResponse.statusCode == 200 {
// 分析响应数据,判断login是否成功
do {
let doc: Document = try SwiftSoup.parse(dataStr!)
let body = doc.body()
// 如果出现名为logged-in的class,则为登录成功
if body?.hasClass("logged-in") == true {
handler(.success(()))
} else {
handler(.failure(.error(msg: "Login failed.")))
return
}
} catch let error {
handler(.failure(.other(error)))
return
}
} else {
handler(.failure(.httpError(code: httpResponse.statusCode)))
return
}
}
}
}.resume()
}
}
|
在ViewModel中完成对LoginManager的调用
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
|
class LoginViewModel: ObservableObject {
@Published var state: ViewModelState = .idle
@Published var credential = Credential()
@Published var error: MyError?
// Computed property. 值随Published property的变化而变化
var loginDisabled: Bool {
credential.username.isEmpty || credential.password.isEmpty || state == .loading
}
func performLogin() {
self.state = .loading
URLSession.shared.reset{
print("Start fetching login page...")
LoginManager.getLoginPage {result in
switch result {
case .failure(let error):
self.state = .error
self.error = error
self.credential = Credential()
print(error)
case .success(let content):
self.postLoginData(content: content)
}
}
}
}
private func postLoginData(content: String) {
state = .loading
print("Start posting credentials...")
LoginManager.postCredential(credential, content: content) {result in
switch result {
case .failure(let error):
self.state = .error
self.error = error
self.credential = Credential()
print(error)
case .success():
self.state = .finish
print("Login successful")
}
}
}
}
|
为了登录成功后能够直接在GitHubView中浏览登录后的内容,我们需要创建一个WebView并共享HTTPCookieStorage.shared
中保有的session信息。
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
|
import SwiftUI
import WebKit
struct GithubView: View {
@EnvironmentObject var authentication: Authentication
let url = "https://github.com/session"
var request: URLRequest {
URLRequest(url: URL(string: url)!)
}
var body: some View {
ZStack {
// ......
VStack(alignment: .trailing) {
// ......
WebView(request)
}
}
}
}
struct WebView: UIViewRepresentable {
let request: URLRequest
init(_ request: URLRequest) {
self.request = request
}
func makeUIView(context: Context) -> WKWebView {
return WKWebView()
}
func updateUIView(_ uiView: WKWebView, context: Context) {
uiView.load(request)
}
}
|
仅仅是在新建的WebView中导入URL只能回到GitHub登录页,要想继续使用LoginView完成的登录认证流程,直接浏览登录后的页面,需要把HTTPCookieStorage.shared
中保有的cookies转移到WebView中去。将makeUIView
改成以下内容即可:
1
2
3
4
5
6
7
8
9
10
|
func makeUIView(context: Context) -> WKWebView {
let config = WKWebViewConfiguration()
config.processPool = WKProcessPool()
let cookies = HTTPCookieStorage.shared.cookies ?? [HTTPCookie]()
cookies.forEach({
config.websiteDataStore.httpCookieStore.setCookie($0)
})
return WKWebView(frame:.zero, configuration: config)
}
|
当然我们必须提供一条能够退回登录页面的“Logout”路线,使应用的操作不会进入死胡同。在WebView的上方加入一个简易的Logout按键,重置session以后把Authentication
中的isValidated
改回false即可。
1
2
3
4
5
6
7
8
9
10
11
12
|
VStack(alignment: .trailing) {
Button {
// Log out
URLSession.shared.reset {
authentication.updateValidation(success: false)
}
} label: {
Text("Logout").padding()
}.frame(height: 30)
WebView(request)
}
|
https://github.com/rikucherry1993/GithubBrowser