历时五天用 SwiftUI 做了一款 APP,阿里工程师如何做的?
?
作者|姜沂(傾寒)?
出品|阿里巴巴新零售淘系技術(shù)部
導(dǎo)讀:自 2014 年蘋果發(fā)布會發(fā)布 Swift 之后, Swift 經(jīng)過多年迭代,終于達到了 ABI 穩(wěn)定版本,也意味著 Swift 做為穩(wěn)定的得語言,值得用在大型 APP, 用來生產(chǎn)環(huán)境中。
2019 年 WWDC , 又發(fā)布了引起無數(shù) Apple 平臺開發(fā)者歡呼的框架 SwiftUI, 據(jù)非官方消息,SwiftUI 框架孵化于 4 年前,作為蘋果全平臺的 UI 系統(tǒng)的未來,數(shù)十名核心開發(fā)者,不準向其他同事和外部披露任何關(guān)于此項目的任何信息,于今年釋出 Beta 版本后,從方方面面都透出出這是目前最強的移動端聲明式 編程框架,沒有之一(個人覺得)。在此實戰(zhàn)之前作者已經(jīng)編寫了兩篇相關(guān)的文章。
1、SwiftUI初體驗?(點擊閱讀)
2、系列文章深度解讀|SwiftUI 背后那些事兒?(點擊閱讀)
注: 項目代號為企業(yè)內(nèi)部私有,這里使用 SOT 代指,意為 “Swift on Taobao”。
背景
為了研究 SwiftUI 在業(yè)務(wù)落地的可能性,我們一直持續(xù)關(guān)注著 SwiftUI 的發(fā)展,但編程這種工作,向來是閱讀千編,不如實戰(zhàn)一次來的深刻,剛好我們有一個業(yè)務(wù)場景非常適合,那就是觀察穩(wěn)定性大盤。
整個淘系也有一個用來觀察穩(wěn)定性數(shù)據(jù)的應(yīng)用,通常來說數(shù)據(jù)大盤是比較適合在 PC 瀏覽器中展示的,我們也在 PC 中使用了多年,但是淘寶 APP 是一個重運營類的 APP, 經(jīng)常會有一些活動在節(jié)假日投放。
但此時值班人員或者相關(guān)人員可能在外,有時候可能并未攜帶電腦,這時候觀察穩(wěn)定性情況就非常窘迫,我們迫切需要一款可以隨身攜帶的APP,用于在緊急時刻觀察穩(wěn)定性問題。
項目耗時
這里先給出時間結(jié)論,
整個 SOT APP 耗時 1.3 人力,共 10 個工作日,整個 Swift 代碼 約 2800 行。
由于這是一款必須工作在內(nèi)網(wǎng)下的 APP, 接入內(nèi)網(wǎng)鑒權(quán)沒有太多經(jīng)驗,花費不少時間。
整體下來大約有 5天左右的工作量花在調(diào)試接口,內(nèi)網(wǎng)鑒權(quán),原型設(shè)計部分,真正花在 SwiftUI 的部分約有 5 天,不得不說效率驚人。
項目設(shè)計
原型設(shè)計
做一款 APP 的最核心的部分是設(shè)計 APP 的功能,熟悉 SOT 的同學(xué),應(yīng)該知道一般觀察穩(wěn)定性主要是觀察數(shù)據(jù)大盤,聚合列表,分析聚合詳情,崩潰分析等比較重要的模塊。
落地 SwiftUI 的計劃預(yù)計 兩周,所以 SOT 一期只做做核心常用的部分。功能有了,那么設(shè)計怎么辦呢?
不要慫,作為 9102 年的程序員,不會做 UI 怎么可以?由于 Mac 平臺的 設(shè)計軟件 如 Keynote 和 Sketch 操作方式,基本和 StoryBoard (只會用代碼寫UI的同學(xué)要回去重新學(xué)習(xí)下 StoryBoard 了 -)操作非常接近,花了一天時間簡單設(shè)計了下界面。
這里刻意模仿 App Store的圓角和陰影設(shè)計,至于為什么?原因就是負責(zé)的設(shè)計會讓 UI 代碼編寫變的更有挑戰(zhàn)性,如果只是用系統(tǒng)原生的樣式,那么碰見的難題就會大大減少,這樣的實戰(zhàn)到了實際的項目中,碰見的問題還會很多。
事實證明負責(zé)的 UI 設(shè)計對理解 SwiftUI 非常有價值,單單一個圓角,就花去了 6 個小時開發(fā)時間。
數(shù)據(jù)流管理
SwiftUI 是一個典型的單向數(shù)據(jù)流得聲明式 UI 編程框架, 在 SwiftUI 中 View 只是一個頁面的描述部分,SwiftUI 提供了多個數(shù)據(jù)流管理對象。
@State @Binding @Obserabled ,通過改變這些數(shù)據(jù)流的值,SwiftUI 系統(tǒng)可以理解重新構(gòu)建 View Tree, 并根據(jù)內(nèi)部變化的范圍,有一層類似 Virtual Dom 的 ViewTree, 由于 View 都是結(jié)構(gòu)體,SwiftUI 每次構(gòu)建這個 View Tree 都極快,這使得性能有很強的保障。
在實踐中也發(fā)現(xiàn)了一些Bug,但由于目前 SwiftUI 還在高速變化,這些 Bug 都會在將來的版本中修復(fù),這里就不過多解釋了。
State
State 是 SwiftUI 中最常用的 代理屬性,通過對代理屬性的修改,SwiftUI 內(nèi)部會自動的重新計算 View的 Body部分,構(gòu)建 出View Tree。
注意 State 只能在當(dāng)前 View 的 body 體里面修改,所以 State 的適用場景就是只影響當(dāng)前 View 內(nèi)部的變化的操作。
舉個實際的例子就是類似下載網(wǎng)絡(luò)圖片的部分,調(diào)用方通常提供一個 URL 和 Placeholder Image,在 SwiftUI 中使用 State 即可,因為此時的網(wǎng)絡(luò)圖變化只影響當(dāng)前 View。
如 APP 選擇界面中,圖片資源都來源自網(wǎng)絡(luò)。
示例代碼如下 :
struct NetworkImage: SwiftUI.View {var urlPath: String? var placeHodlerImage: UIImage init(url path: String?, placeHolder: String) { self.urlPath = path self.placeHodlerImage = UIImage(named: placeHolder)!.withRenderingMode(.alwaysOriginal)}@State var downLoadedImage: UIImage? = nil var body: some SwiftUI.View { Image(uiImage: downLoadedImage ?? placeHodlerImage).resizable().aspectRatio(contentMode: .fill).onAppear(perform: download)} func download() { if let _ = downLoadedImage { return} _ = urlPath.flatMap(URL.init(string:)).map { ImageDownloader.default.downloadImage(with: $0) { result in switch result { case .success(let value): self.downLoadedImage = value.image.withRenderingMode(.alwaysOriginal) case .failure(let error):log.debug(error)}}}} }Binding
在傳統(tǒng)的命令式編程中,GUI 程序中最復(fù)雜的部分莫過于狀態(tài)管理,尤其是多數(shù)據(jù)同步,一個數(shù)據(jù)存在于不同的 UI 組成部分,UI 各個部分的變化理論上都有同步,狀態(tài)量的變多加上異步的操作,會使程序的可讀性直線下降,并且伴隨著而來的就是 Bug ,并且不敢重構(gòu)。
SwiftUI 給我們的理念就是 Single source of truth, 簡單來說就是單一數(shù)據(jù)源,單一數(shù)據(jù)源是個很早就有的名詞/方法,但是很多系統(tǒng)并沒有給出很好的解決辦法,比如習(xí)慣 FRP 的同學(xué)可能用 RX/RAC 里面的 Singnal 去描述,但是 FRP 晦澀的概念,又使其在項目中的接入成本大大提高。
SwiftUI 給我們的解決辦法就是 @Binding 。作者之前嘗試自己實現(xiàn)一個 Binding,實現(xiàn)起來就是一個簡單的閉包,通過閉包捕獲 Source of truth 的數(shù)據(jù),同時 SwiftUI 會幫我們自動刷新需要同步的界面。使我們的數(shù)據(jù)同步變的的非常簡單。
實際例子如,系統(tǒng)提供的 Control(可操作的View) 的構(gòu)造器基本都需要 @Binding 屬性,可以自動的同步來自 API 調(diào)用方的數(shù)據(jù)源。
這里舉個例子如 項目中的版本選擇和日期選擇功能,我們需要講控件選擇的值同步給數(shù)據(jù)源。
?
?
struct DateVersionPanel : View { @Binding var version: String @State var input = "" @Binding var date: Date var title: String@State private var showVersionPicker = false @State private var showDatePicker = falsevar dateFormatter: DateFormatter { let formatter = DateFormatter()formatter.dateFormat = "yyyy-MM-dd" return formatter} private func showDate() {showDatePicker = true} var body: some View {HStack(alignment: .center) {Text(title).font(.system(size: 14))HStack(alignment: .center) {TextField(version.isEmpty ? "不區(qū)分版本" : version, text: $input, onEditingChanged: { (changed) inlog.debug("TextFieldonEditing: \(changed)")}) {log.debug("TextFielduserName: \(self.version)")self.version = self.input}.font(.system(size: 9)).padding(.leading, 20).frame(width: 100, height: 20)NavigationLink(destination: VersionSelectView(version: $version)) {Image("down_arrow").frame(width: 24, height: 14).aspectRatio(contentMode: .fill)}.offset(x: -20)}.frame(width: 100, height: 25).border(Color.grayText, width: 0.5).padding(.leading, 40)NavigationLink(destination: CalendarView(date: self.$date)) {HStack {Text(dateFormatter.string(from: date) ).font(.system(size: 9)).padding(.leading, 10)Image("down_arrow").padding(.trailing, 10)}.frame(width: 100, height: 25).border(Color.grayText, width: 0.5).padding(.leading, 40)}}.padding(.bottom, 10)} }ObservableObject
ObservableObject 在 Xcode11 Beta 4 之前叫 ObjectBinding , 這個類型是一個協(xié)議,要求我們實現(xiàn)一個來自 Combine 框架的 Subject Subject 是一個和命令式編程世界交互的橋梁,是一個特殊的 Publisher,SwiftUI 內(nèi)部會自動的訂閱這個 Subject,在 Subject 發(fā)送變化時 SwiftUI 會自動刷新數(shù)據(jù)。
ObservableObject 適用于多個 UI 組成部分同步數(shù)據(jù),ObservableObject 取代了,Cocoa 框架基本編程風(fēng)格 MVC 中控制器的角色,暫時項目中就叫他 ViewModel 吧。
@Published 是 Xcode11 beta5 之后新增的代理屬性,此屬性如果用在 ObservableObject 內(nèi),如果屬性發(fā)送了變化,會自動觸發(fā) ObservableObject 的 objectWillChanged 的Subject變化,自動刷新頁面。
同時由于 Combine 框架的支持,多個條件聯(lián)動變成了一個簡單的事情,在 SOT APP 項目中,就非常適合,比如數(shù)據(jù)大盤,有將近10幾個數(shù)據(jù)狀態(tài),任何一個觸發(fā),都會導(dǎo)致數(shù)據(jù)刷新。
?
class HomeViewModel: ObservableObject {@Published var isCorrectionOn = true@Published var isForce = false@Published var crashType = CrashType.crash@Published var pecision = Pecision.fifith@Published var quota = Quota.count@Published var currentDate = Date()@Published var currentVersion = ""@Published var comDate = Date().lastDay@Published var comVersion = ""@Published var refresh = true@Published var metric: Metric? = nil@Published var trends: [TrendItem] = []@Published var summary: Summary? = nilvar api = SOTAPI()// MARK: - Life Cyclevar cancels = [AnyCancellable]()init() {var cancel = $refresh.combineLatest($isForce, $isCorrectionOn).combineLatest($crashType, $pecision, $quota).combineLatest($currentDate, $currentVersion).combineLatest($comVersion, $comDate).debounce(for: 0.5, scheduler: RunLoop.main).sink {[weak self](_) inself?.requestMetric()self?.requestTrends()}cancels.append(cancel)cancel = $refresh.sink{[weak self](_) inself?.requestSummary()}cancels.append(cancel)}func requestMetric() {}func requestTrends() {}func requestSummary() {} }Work with UIKit
由于 SwiftUI 是一個封閉的系統(tǒng),有時候一些控件還不夠豐富,為了滿足開發(fā)所用,還需要和一些已有的 UIKit的 UIView 混合編程,一方面可以減少遷移的負擔(dān),一方面可以增加 SwiftUI 的能力。
在 SOT 項目中,由于日期選擇是一個專業(yè)的庫,這里采用了第三方庫,就涉及到于 UIKit 交互, SwiftUI 提供了一套非常簡單清晰的標準,可以用在多個平臺上交互,并提供一致的表現(xiàn)力。
需要注意的是 UIViewRepresentable 的遵守者,是一個 View 容器,此容器會被創(chuàng)建多次,如果內(nèi)部有數(shù)據(jù)源需要通知,需要創(chuàng)建相應(yīng)的 Coordinator 將當(dāng)前的容器當(dāng)做 View 傳遞進去,由于 View 是結(jié)構(gòu)體。
此時創(chuàng)建的是一個拷貝副本,所以 Coordinator 修改的部分,最好只是 ObservableObject Binding
?
struct CalendarView : UIViewRepresentable {@Environment(\.presentationMode) var presentationMode@Binding var date: Dateinit(date: Binding<Date>) { self._date = date}func makeUIView(context: UIViewRepresentableContext<CalendarView>) -> UIView { let view = UIView(frame: UIScreen.main.bounds)view.backgroundColor = .backgroundThemelet height: CGFloat = 300.0 let width = view.frame.size.width let frame = CGRect(x: 0.0, y: 0.0, width: width, height: height) let calendar = FSCalendar(frame: frame)calendar.locale = Locale.init(identifier: "ZH-CN")calendar.delegate = context.coordinatorcontext.coordinator.fsCalendar = calendarcalendar.backgroundColor = UIColor.whiteview.addSubview(calendar)return view}func makeCoordinator() -> CalendarView.Coordinator { Coordinator(self)}func updateUIView(_ uiView: UIView, context: UIViewRepresentableContext<CalendarView>) {log.debug("Date")context.coordinator.fsCalendar?.select(date)}func dismiss() {presentationMode.wrappedValue.dismiss()}class Coordinator: NSObject, FSCalendarDelegate { var control: CalendarView var date: Date var fsCalendar: FSCalendar? init(_ control: CalendarView) { self.control = control self.date = control.date} func calendar(_ calendar: FSCalendar, didSelect date: Date, at monthPosition: FSCalendarMonthPosition) { self.control.date = date}} }架構(gòu)
Combine
在此項目中使用了最基本的 Combine 操作,由于項目一期主要是為了探索 SwiftUI ,所以并未對架構(gòu)模式做精細的設(shè)計,可以觀察到,ViewModel,內(nèi)部還是有訂閱,發(fā)送網(wǎng)絡(luò)請求,最后同步數(shù)據(jù)的操作,這種編碼方式,還是典型的命令式編程風(fēng)格,此部分會在項目二期逐漸探索中修改為響應(yīng)式風(fēng)格。
Redux/Flux
SwiftUI 是一個單向數(shù)據(jù)流框架,在此之前,大前端已經(jīng)有 React, Flutter , Reactive Native,等比較流行的框架。在這些單向數(shù)據(jù)流得框架下,Redux 作為一種比較流行的狀態(tài)管理的架構(gòu)風(fēng)格,已經(jīng)經(jīng)過多方面的驗證,SwiftUI 對于Redux也是比較適用的。
Redux 的基本思想核步驟是:
1、整個頁面甚至 APP 是一個巨大的狀態(tài)機,有一個狀態(tài)存儲 Store ,在某個時刻處于某種狀態(tài)。
2、狀態(tài)在頁面表達中是一個簡單的樹型結(jié)構(gòu),在 SwiftUI,對應(yīng)的 就是 View Tree。
3、View 操作不能直接修改狀態(tài),只能通過發(fā)送 Action, 間接改變 Store。
4、Reducer 通過 Action 加上 oldState 獲取 newSatete。簡單來說就是 State = f(action+oldState)。
附上一份 阮一峰的Redux入門教程的示例圖:
這套風(fēng)格在前端大型項目中已經(jīng)了驗證,可以比較清晰的表達用戶事件交互和狀態(tài)管理。
目前由于 SwiftUI 中 ViewCtonroller的消失,加上方便的 ObserableObject 和 EmviromentObject 。
SOT 項目一期暫未采用,在二期項目中會探索合適的架構(gòu)設(shè)計。
項目總結(jié)
此項目在短短的 10 個工作日內(nèi)就能完成,不得不說 SwiftUI 的開發(fā)效率真的驚人,雖然目前還有一些 Bug ,但是相信在未來,SwiftUI 會是 Apple 平臺 UI 布局的解決辦法,關(guān)于 SwiftUI 如何在淘系落地業(yè)務(wù),還在持續(xù)探索中。
目前此項目已在集團內(nèi)部開源。
原文鏈接
本文為云棲社區(qū)原創(chuàng)內(nèi)容,未經(jīng)允許不得轉(zhuǎn)載。
總結(jié)
以上是生活随笔為你收集整理的历时五天用 SwiftUI 做了一款 APP,阿里工程师如何做的?的全部內(nèi)容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 使用EMR Spark Relation
- 下一篇: 大促背后的流量利器|手淘push升级 比