UI層架構(gòu)
在 MVVM 模式里菜职,View 依賴于 ViewModel航罗。作為 View 的BaseTableViewController依賴于 ViewModel 層的ListViewModel協(xié)議,這使得BaseTableViewController只依賴于接口而不是具體的類型,從而提高了程序的可擴(kuò)展性奔浅。同時(shí),BaseTableViewController還定義了三個(gè)屬性來顯示 UI 控件:
- tableView屬性用于顯示一個(gè) TableView诗良;
- activityIndicatorView屬性用于顯示俗稱小菊花的加載器汹桦;
- errorLabel用于顯示出錯(cuò)信息的標(biāo)簽控件。
一個(gè)定義示例:
private let tableView: UITableView = configure(.init()) {
$0.translatesAutoresizingMaskIntoConstraints = false
$0.separatorStyle = .none
$0.rowHeight = UITableView.automaticDimension
$0.estimatedRowHeight = 100
$0.contentInsetAdjustmentBehavior = .never
$0.backgroundColor = UIColor.designKit.background
}
其中configure()
方法是封裝的一個(gè)通用方法, 這樣可以使得定義和配置一個(gè)控件的代碼統(tǒng)一起來, 不至于出現(xiàn)這里定義那里配置的隨意性鉴裹。其實(shí)就是簡單地送個(gè)閉包進(jìn)去:
func configure<T: AnyObject>(_ object: T, closure: (T) -> Void) -> T {
closure(object)
return object
}
數(shù)據(jù)綁定
Moments App 使用了 RxSwift 把 ViewModel 層和 View 層進(jìn)行綁定舞骆,綁定的代碼在setupBindings()函數(shù)里,具體如下径荔。
func setupBindings() {
tableView.refreshControl = configure(UIRefreshControl()) {
let refreshControl = $0
$0.rx.controlEvent(.valueChanged)
.filter { refreshControl.isRefreshing }
.bind { [weak self] _ in self?.loadItems() }
.disposed(by: disposeBag)
}
let dataSource = RxTableViewSectionedReloadDataSource<SectionModel<String, ListItemViewModel>>(configureCell: { _, tableView, indexPath, item in
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: type(of: item)), for: indexPath)
(cell as? ListItemCell)?.update(with: item)
return cell
})
viewModel.listItems
.bind(to: tableView.rx.items(dataSource: dataSource))
.disposed(by: disposeBag)
viewModel.hasError
.map { !$0 }
.bind(to: errorLabel.rx.isHidden)
.disposed(by: disposeBag)
}
這個(gè)函數(shù)由三部分組成督禽,
- 第一部分是通過
RxSwift
和RxCocoa
,把UIRefreshControl
控件里的isRefreshing事件和loadItems()函數(shù)綁定起來猖凛。當(dāng)用戶下拉刷新控件的時(shí)候會(huì)調(diào)用loadItems()函數(shù)來刷新列表的數(shù)據(jù)赂蠢。 - 第二部分是把 TableView Cell 控件與 ViewModel 的listItemsSubject 屬性綁定起來绪穆,當(dāng)listItems發(fā)出新的事件時(shí)辨泳,我們會(huì)調(diào)用ListItemCell的update(with viewModel: ListItemViewModel)方法來更新 UI。經(jīng)過了這一綁定玖院,UI 就能隨著 ViewModel 的數(shù)據(jù)變化而自動(dòng)更新菠红。
- 這里需要注意的是要多讀一下
RxCocoa
的api, 了解下列表的綁定, 比如這個(gè)例子里, listItems發(fā)了消息讓table接, 但是table是用一個(gè)datasource
來處理cell的創(chuàng)建和更新的, 因此, 而這個(gè)datasource則是純RxCocoa
自定義的東西, 也是它在真正處理listitems
發(fā)過來的數(shù)據(jù), 也就是說它是個(gè)必要的中轉(zhuǎn)/適配器
- 這里需要注意的是要多讀一下
- 第三部分與第二部分類似,都是把 ViewModel 與 View 層的控件進(jìn)行綁定难菌。在這里试溯,我們把 ViewModel 的hasErrorSubject 屬性綁定到errorLabel.rx.isHidden屬性來控制errorLabel是否可見。
數(shù)據(jù)綁定以后郊酒,我們一起看看loadItems()函數(shù)的實(shí)現(xiàn)遇绞。
func loadItems() {
viewModel.hasError.onNext(false)
viewModel.loadItems()
.observeOn(MainScheduler.instance)
.do(onDispose: { [weak self] in
self?.activityIndicatorView.rx.isAnimating.onNext(false)
self?.tableView.refreshControl?.endRefreshing()
})
.map { false }
.startWith(true)
.distinctUntilChanged()
.bind(to: activityIndicatorView.rx.isAnimating)
.disposed(by: disposeBag)
}
loadItems()本應(yīng)是加載數(shù)據(jù), 并且渲染UI, 但這段代碼我們只看到了把它跟各種狀態(tài)控件綁定了起來, 比如hasError
, activityIndicatorView
, refresControl
等, 只能認(rèn)為, refreshControl
能觸發(fā)tableView的重加載工作, 這里最好去翻翻代碼 --> 好像是loadItems是存數(shù)據(jù)庫了, 而不是接口透傳
假如用戶在調(diào)用 ViewModel 的loadItems()方法的過程中,退出列表頁面燎窘,我們通過.do(onDispose:{})方法來停止activityIndicatorView
和refreshControl
兩個(gè)控件的刷新動(dòng)畫摹闽。
從代碼中你可以看到,盡管我們想更新 UI 層的errorLabel控件褐健,卻沒有直接通過errorLabel.isHidden = true的方式來更新付鹿,而是通過 ViewModel 的hasError屬性來完成。這是因?yàn)槲乙WC View/UI 層都是由 ViewModel 驅(qū)動(dòng)蚜迅,通過單方向的數(shù)據(jù)流來減少 Bug 舵匾,從而提高代碼的可維護(hù)性。
這套UI設(shè)計(jì)了一個(gè)頭部cell和一個(gè)可重復(fù)的cell兩部分, 因此做了兩個(gè)基礎(chǔ)類, 并且按一理的風(fēng)格, 為每個(gè)類都做了一個(gè)protocol
, 顯然只有一個(gè)方法: update(with viewModel: T)
- LiteItemCell protocol 和其實(shí)現(xiàn): BaseTableViewCell
- ListItemView protocol 和其實(shí)現(xiàn): BaseListItemView
比如繞的是, 這個(gè)BaseTableViewCell
其實(shí)還是一個(gè)BaseListItemView
, 總之要記得這是一套過度設(shè)計(jì)的系統(tǒng), 實(shí)際中肯定可以簡化很多層. 作為教學(xué), 可能也復(fù)雜了些, 把需要講解的點(diǎn)和一些實(shí)際應(yīng)用給混在一起了. 我們看看這兩塊的代碼:
// cell 部分
protocol ListItemCell: class {
func update(with viewModel: ListItemViewModel)
}
final class BaseTableViewCell<V: BaseListItemView>: UITableViewCell, ListItemCell {
private let view: V
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
view = .init()
super.init(style: style, reuseIdentifier: reuseIdentifier)
selectionStyle = .none
contentView.addSubview(view)
view.snp.makeConstraints {
$0.edges.equalToSuperview()
}
}
required init?(coder: NSCoder) {
fatalError(L10n.Development.fatalErrorInitCoderNotImplemented)
}
func update(with viewModel: ListItemViewModel) {
view.update(with: viewModel)
}
}
// view部分
protocol ListItemView: class {
func update(with viewModel: ListItemViewModel)
}
class BaseListItemView: UIView, ListItemView {
lazy var disposeBag: DisposeBag = .init()
func update(with viewModel: ListItemViewModel) {
fatalError(L10n.Development.fatalErrorSubclassToImplement)
}
}
講解:
- 本質(zhì)上, 這兩個(gè)視圖都是一個(gè)
BaseListItemView
, 下面稱基類 注意, 它是一個(gè)普通的UIView
, 而不是UITableViewCell
- Cell直接把基類
addSubView
了 - 因?yàn)樽隽藘蓚€(gè)類, 所以
update
方法不得不復(fù)制了兩次, 其實(shí)是一模一樣的, 這是敗筆 - 基類里, 把update方法默認(rèn)
fatal
了, 子類里, 說的是使用傳入的view的update
方法, 所以雖然沒有明確fatal
, 事實(shí)上也是需要自行實(shí)現(xiàn)的.
能動(dòng)態(tài)增加功能的架構(gòu)
規(guī)范的架構(gòu)與框架不僅具有良好的可擴(kuò)展性谁不,例如坐梯,可以靈活地替換網(wǎng)絡(luò)層、數(shù)據(jù)庫甚至 UI 層的實(shí)現(xiàn)刹帕,而且還為開發(fā)者提供了統(tǒng)一的開發(fā)步驟與規(guī)范吵血,方便新功能的快速迭代馏段。以下以增加一個(gè)點(diǎn)贊功能為例, 可以按照以下五個(gè)步驟:
- 增加“添加點(diǎn)贊功能”的功能開關(guān);
- 開發(fā)網(wǎng)絡(luò)層來更新 BFF 的點(diǎn)贊信息践瓷;
- 開發(fā) Repository 層來存儲(chǔ)數(shù)據(jù)院喜;
- 開發(fā) ViewModel 層來準(zhǔn)備 UI 所需的數(shù)據(jù);
- 開發(fā) UI/View 層呈現(xiàn)點(diǎn)贊按鈕和點(diǎn)贊朋友列表晕翠。
增加功能開關(guān)
當(dāng)我們開發(fā)一個(gè)周期比較長的新功能時(shí)喷舀,通常會(huì)使用功能開關(guān)。
如果沒有功能開關(guān)淋肾,當(dāng)開發(fā)周期超過一周以上時(shí)硫麻,我們就不得不把開發(fā)中的功能放在一個(gè)“長命”功能分支下,直到整個(gè)功能完成后才合并到主分支樊卓,這往往會(huì)增加合并分支的難度拿愧。
另一種方法是延遲發(fā)布的時(shí)間,在功能完整開發(fā)出來后才進(jìn)行發(fā)布碌尔。假如有多個(gè)團(tuán)隊(duì)一直在開發(fā)新功能浇辜,那么發(fā)布計(jì)劃就可能一直在延遲
其它步驟不贅述, 有一個(gè)地方需要注意下, 教程用的是GraphQL
, 所有的查詢是客戶端構(gòu)建的, 所以是可以在全鏈路里點(diǎn)贊屬性還沒開發(fā)好的情況下修改需要返回的字段, 而不是像一般的開發(fā), 先把預(yù)期要返回的字段建模進(jìn)去, 等到部署好了自然這個(gè)字段諒有值了:
private static let query = """
query getMomentsDetailsByUserID($userID: ID!, $withLikes: Boolean!) {
getMomentsDetailsByUserID(userID: $userID) {
// other fields
createdDate
isLiked @include(if: $withLikes)
likes @include(if: $withLikes) {
id
avatar
}
}
}
}
"""
注意上面的@include
, 這是語言特征, 就不說了
TDD與單元測試
TDD(Test-Driven Development) 的核心是編寫單元測試。
- 單元測試能方便我們模擬不同的測試場景唾戚,覆蓋不同的邊界條件柳洋,從而提高代碼的質(zhì)量并減少 Bug 的數(shù)量。
- 同時(shí)叹坦,使用 TDD 所開發(fā)的代碼能降低模塊間的耦合度,提高模塊的靈活性和可擴(kuò)展性募书。
在編寫測試代碼時(shí)候绪囱,我們一般遵守 AAA 步驟,所謂AAA 就是 Arrange莹捡、Act 和 Assert鬼吵。
- Arrange:用于搭建測試案例,例如道盏,初始化測試對(duì)象及其依賴而柑。
- Act:表示執(zhí)行測試,例如荷逞,調(diào)用測試對(duì)象的方法媒咳。
- Assert:用于檢驗(yàn)測試的結(jié)果。
基本上都是檢查:
- 必要的步驟(方法)被觸發(fā)
- 關(guān)聯(lián)步驟被觸發(fā)
- 正確地解析了返回值
- 正確地處理了錯(cuò)誤, 空值等
剩下的就是你選的測試框架是怎么處理模擬數(shù)據(jù), 初始化等等的, 最后, 就是依賴注入的架構(gòu), 可以使得測試用例能夠傳入模擬數(shù)據(jù)源, 而如果你是在實(shí)現(xiàn)體內(nèi)寫死的比如網(wǎng)絡(luò)請(qǐng)求, 數(shù)據(jù)庫連接, 單元測試就不能做了, 一做就觸發(fā)了真正的業(yè)務(wù)邏輯了. 本教程使用了Quick
和Nimble
庫
一個(gè)viewmodel的測試用例:
final class MomentsTimelineViewModelTests: QuickSpec {
override func spec() {
describe("MomentsTimelineViewModel") {
var testSubject: MomentsTimelineViewModel!
beforeEach {
testSubject = MomentsTimelineViewModel() // Arrange
}
context("loadItems()") {
beforeEach {
testSubject.loadItems() // Act
}
it("call `momentsRepo.getMoments` with the correct parameters") {
expect(mockMomentsRepo.getMomentsHasBeenCalled).to(beTrue()) // Assert
}
it("check another assertion") { }
}
context("anotherMethod()") { }
}
}
}
- 測試類型中的每一個(gè)公共的方法和屬性都要測試, 上例用的是
context()
方法 - spce和context里都有各自的
beforeEach
, 能看出這是初始化測試數(shù)據(jù)的入口 - 然后就是
it
和expect
的組合
網(wǎng)絡(luò)層
因?yàn)槭褂昧?code>RxSwift, 可以引用RxTesst
庫來簡化測試流程. 首先种远,我們?cè)赿escribe("GetMomentsByUserIDSession")函數(shù)里定義需要初始化的變量涩澡,代碼如下:
var testSubject: GetMomentsByUserIDSession!
var testScheduler: TestScheduler!
var testObserver: TestableObserver<MomentsDetails>!
var mockResponseEvent: Recorded<Event<GetMomentsByUserIDSession.Response>>!
- testSubject是測試的對(duì)象,在這個(gè)例子中是我們需要測試的GetMomentsByUserIDSession坠敷。
- testScheduler的類型是來自 RxTest 的TestScheduler妙同,是一個(gè)用于測試的排程器射富。
- testObserver的類型是 RxTest 的TestableObserver,用來訂閱 Observable 序列里的事件粥帚,并通過接收到的事件來檢查測試的結(jié)果胰耗。
- mockResponseEvent是Recorded類型,也是來自 RxTest芒涡,用于模擬事件的發(fā)送柴灯,例如模擬成功接收到網(wǎng)絡(luò)數(shù)據(jù)事件或者錯(cuò)誤事件。
所需的變量定義完畢以后费尽,可以在beforeEach()方法里面初始化testScheduler和testObserver赠群,具體代碼如下:
beforeEach {
testScheduler = TestScheduler(initialClock: 0)
testObserver = testScheduler.createObserver(MomentsDetails.self)
}
初始化完成, 看一個(gè)用例:
context("getMoments(userID:)") {
context("when response status code 200 with valid response") {
beforeEach {
mockResponseEvent = .next(100, TestData.successResponse)
getMoments(mockEvent: mockResponseEvent)
}
}
}
有兩個(gè)問題:
- 調(diào)用getMoments方法, 并不是傳一個(gè)mock的入?yún)? 是怎么回事?
- 里面
.next
出了一個(gè)successResponse
哪來的?
其實(shí)是私有方法和私有屬性, 所以單元測試不是那么簡單的事, 需要一大堆的準(zhǔn)備工作
func getMoments(mockEvent: Recorded<Event<GetMomentsByUserIDSession.Response>>) {
let testableObservable = testScheduler.createHotObservable([mockEvent])
testSubject = GetMomentsByUserIDSession { _ in testableObservable.asObservable() }
testSubject.getMoments(userID: "0").subscribe(testObserver).disposed(by: disposeBag)
testScheduler.start()
}
private struct TestData {
static let successResponse: GetMomentsByUserIDSession.Response = {
let response = try! JSONDecoder().decode(GetMomentsByUserIDSession.Response.self,
from: TestData.successjson.data(using: .utf8)!)
return response
}()
static let successjson = """
{
"data": { ... } // JSON 數(shù)據(jù), 可以從BFF的正確返回里拷貝一段
}
"""
}
為了mock rx特性的網(wǎng)絡(luò)請(qǐng)求, 上面初始化的test開頭的幾個(gè)屬性就起作用了, 上面都是數(shù)據(jù)準(zhǔn)備, 下面是斷言部分:
it("should complete and map the response correctly") {
let expectedMomentsDetails = TestFixture.momentsDetails
let actualMomentsDetails = testObserver.events.first!.value.element!
expect(actualMomentsDetails).toEventually(equal(expectedMomentsDetails))
}
失敗的用例:
context("when response status code non-200") {
let networkError: APISessionError = .networkError(error: MockError(), statusCode: 500)
beforeEach {
mockResponseEvent = .error(100, networkError, GetMomentsByUserIDSession.Response.self)
getMoments(mockEvent: mockResponseEvent)
}
it("should throw a network error") {
let actualError = testObserver.events.first!.value.error as! APISessionError
expect(actualError).toEventually(equal(networkError))
}
}
Repository層/數(shù)據(jù)層
單元測試只測代碼覆蓋率, 即期望的代碼有沒有執(zhí)行到, 數(shù)據(jù)層這種來源即可能是硬盤, 也可能是數(shù)據(jù)庫, 也可能是網(wǎng)絡(luò)請(qǐng)求的, 在架構(gòu)上就要實(shí)現(xiàn)能依賴注入(DI), 這樣真實(shí)代碼和測試代碼的依賴關(guān)系就解耦了, 測試代碼就可以用mock來代替真實(shí)的依賴了.
private class MockUserDefaultsPersistentDataStore: PersistentDataStoreType {
private(set) var momentsDetails: ReplaySubject<MomentsDetails> = .create(bufferSize: 1)
private(set) var savedMomentsDetails: MomentsDetails?
func save(momentsDetails: MomentsDetails) {
savedMomentsDetails = momentsDetails
}
}
private class MockGetMomentsByUserIDSession: GetMomentsByUserIDSessionType {
private(set) var getMomentsHasbeenCalled = false
private(set) var passedUserID: String = ""
func getMoments(userID: String) -> Observable<MomentsDetails> {
passedUserID = userID
getMomentsHasbeenCalled = true
return Observable.just(TestFixture.momentsDetails)
}
}
看代碼, 其實(shí)就是自己手寫了一個(gè)repository, 只不過是個(gè)簡單版的, 讓代碼能跑過去的. 上面代碼一個(gè)是mock了一個(gè)數(shù)據(jù)庫請(qǐng)求, 或者說文件請(qǐng)求(userdefaults本質(zhì)上是文件), 一個(gè)是mock了一個(gè)網(wǎng)絡(luò)請(qǐng)求.
有了這些 Mock 類型以后,我們就可以把它們注入測試對(duì)象testSubject中:
beforeEach {
mockUserDefaultsPersistentDataStore = MockUserDefaultsPersistentDataStore()
mockGetMomentsByUserIDSession = MockGetMomentsByUserIDSession()
testSubject = MomentsRepo(persistentDataStore: mockUserDefaultsPersistentDataStore, getMomentsByUserIDSession: mockGetMomentsByUserIDSession)
}
上一節(jié)網(wǎng)絡(luò)層的測試, 用testScheduler.createHotObservable()
(即rxTest
)創(chuàng)建了一個(gè)testableObservable, 本節(jié)演示自己寫一個(gè)observable, 我目前不知道需要兩種寫法的必要性, 在我看來, 從網(wǎng)絡(luò)/硬盤/數(shù)據(jù)庫讀取東西都是一回事, 還是先看實(shí)現(xiàn)吧:
class TestObserver<ElementType>: ObserverType {
private var lastEvent: Event<ElementType>?
var lastElement: ElementType? {
return lastEvent?.element
}
var lastError: Error? {
return lastEvent?.error
}
var isCompleted: Bool {
return lastEvent?.isCompleted ?? false
}
func on(_ event: Event<ElementType>) {
lastEvent = event
}
}
取詳情:
context("momentsDetails") {
var testObserver: TestObserver<MomentsDetails>!
beforeEach {
testObserver = TestObserver<MomentsDetails>() // Arrange
testSubject.momentsDetails.subscribe(testObserver).disposed(by: disposeBag) // Act
}
}
// 此時(shí)期望是取不到詳情的:
it("should be `nil` by default") {
expect(testObserver.lastElement).to(beNil()) // Assert
}
// 直到onNext了一個(gè)數(shù)據(jù)出來
context("when persistentDataStore has new data") {
beforeEach {
mockUserDefaultsPersistentDataStore.momentsDetails.onNext(TestFixture.momentsDetails)
}
it("should notify a next event with the new data") {
expect(testObserver.lastElement).toEventually(equal(TestFixture.momentsDetails)) // Assert
}
}
取列表:
context("getMoments(userID:)") {
beforeEach {
testSubject.getMoments(userID: "1").subscribe().disposed(by: disposeBag)
}
it("should call `GetMomentsByUserIDSessionType.getMoments`") {
expect(mockGetMomentsByUserIDSession.getMomentsHasbeenCalled).to(beTrue())
expect(mockGetMomentsByUserIDSession.passedUserID).to(be("1"))
}
it("should save a `MomentsDetails` object") {
expect(mockUserDefaultsPersistentDataStore.savedMomentsDetails).to(equal(TestFixture.momentsDetails))
}
}
ViewModel層
這一層主要把下游數(shù)據(jù)轉(zhuǎn)化成UI需要的數(shù)據(jù),
context("init(userDetails:)") {
context("when all data provided") {
beforeEach {
testSubject = UserProfileListItemViewModel(userDetails: TestFixture.userDetails)
}
it("should initialize the properties correctly") {
expect(testSubject.name).to(equal("Jake Lin"))
expect(testSubject.avatarURL).to(equal(URL(string: "https://avatars-url.com")))
expect(testSubject.backgroundImageURL).to(equal(URL(string: "https://background-image-url.com")))
}
}
context("when `userDetails.avatar` is not a valid URL") {
beforeEach {
testSubject = UserProfileListItemViewModel(userDetails: MomentsDetails.UserDetails(id: "1", name: "name", avatar: "this is not a valid URL", backgroundImage: "https://background-image-url.com"))
}
it("`avatarURL` should be nil") {
expect(testSubject.avatarURL).to(beNil())
}
}
}
因?yàn)樗械霓D(zhuǎn)換邏輯都封裝在UserProfileListItemViewModel的init(userDetails:)方法里面旱幼,所以我們可以通過測試該init()方法來驗(yàn)證數(shù)據(jù)轉(zhuǎn)換的邏輯查描。
統(tǒng)一管理 Certificate 和 Profile
一種思路,用 GitHub 來存儲(chǔ)
- 建 GitHub 私有 Repo
- 生成 GitHubA Access Token
- 生成 App Store Connect API Key
- Users and Access -> Keys -> App Store Connect API
這些api key是連接github和itunes connect必要的憑據(jù), 你可以選擇把它們配置在連接的語句里, 或是配置文件里, 或是環(huán)境變量里.
配置環(huán)境變量的話:
# github的token要base64一下
echo -n your_github_username:your_personal_access_token | base64
export MATCH_GIT_BASIC_AUTHORIZATION=<YOUR BASE64 KEY>
export APP_STORE_CONNECT_API_CONTENT=<App Store Connect API>
配置文件的話, 先創(chuàng)建一個(gè)local.keys
:
APP_STORE_CONNECT_API_CONTENT=<App Store Connect API for an App Manager>
GITHUB_API_TOKEN=<GitHub API token for accessing the private repo for certificates and provisioning profiles>
MATCH_PASSWORD=<Password for certificates for App signing on GitHub private repo>
再寫個(gè)腳本來讀這些Keys, 自動(dòng)寫到環(huán)境變量里(當(dāng)然你也可以選擇在代碼里讀這個(gè)配置文件)
export $(grep -v '^#' ./local.keys | sed 's/#.*//')
export MATCH_GIT_BASIC_AUTHORIZATION=$(echo -n momentsci:$GITHUB_API_TOKEN | base64)
接著在根目錄執(zhí)行以下的命令:
$> source ./scripts/export_env.sh
如果想要開機(jī)自動(dòng)執(zhí)行這個(gè)腳本, 可以在~/.bash_profile
里添加:
source ~/projects/path/scripts/export_env.sh
鑒權(quán)資料拿到后, 就可以用 fastlane match 自動(dòng)生成和管理證書和 Provisioning Profile 了.
local.keys
這個(gè)文件不要傳到項(xiàng)目文件里去(同所有含有敏感信息的文件一樣), 所以要添加到.gitignore
里
創(chuàng)建 GitHub Repo
- 登錄到 GitHub
- 點(diǎn)擊 New repository
- 填寫 Repo name, 選擇 Public
然后使用 fastlane 管理存在 GitHub 的證書和 Provisioning Profile
生成證書和 Provisioning Profile
每個(gè)項(xiàng)目也只需執(zhí)行一次這樣的操作柏卤。
desc "Create all new provisioning profiles managed by fastlane match"
lane :create_new_profiles do
api_key = get_app_store_connect_api_key
keychain_name = "TemporaryKeychain"
keychain_password = "TemporaryKeychainPassword"
create_keychain(
name: keychain_name,
password: keychain_password,
default_keychain: false,
timeout: 3600,
unlock: true,
)
match(
type: "adhoc",
keychain_name: keychain_name,
keychain_password: keychain_password,
storage_mode: "git",
git_url: "https://github.com/JakeLin/moments-codesign",
app_identifier: "com.ibanimatable.moments.internal",
team_id: "6HLFCRTYQU",
api_key: api_key
)
match(
type: "appstore",
keychain_name: keychain_name,
keychain_password: keychain_password,
storage_mode: "git",
git_url: "https://github.com/JakeLin/moments-codesign",
app_identifier: "com.ibanimatable.moments",
team_id: "6HLFCRTYQU",
api_key: api_key
)
end
使用, 執(zhí)行這個(gè)語句即可:
fastlane create_new_profiles
上面的腳本中調(diào)用了兩個(gè)方法:
-
create_keychain()
冬三,是為了把證書和描述文件存到 keychain 里 -
match()
,顯然是用來生成證書和描述文件的闷旧,type
指定了渠道 -
get_app_store_connect_api_key
這句話顯然是取app sotre的apikey, 只是注意一下, 無參數(shù)調(diào)方法似乎都連括號(hào)都不需要
方法定義如下:
desc 'Get App Store Connect API key'
private_lane :get_app_store_connect_api_key do
key_content = ENV["APP_STORE_CONNECT_API_CONTENT"]
api_key = app_store_connect_api_key(
key_id: "D9B979RR69",
issuer_id: "69a6de7b-13fb-47e3-e053-5b8c7c11a4d1",
key_content: "-----BEGIN EC PRIVATE KEY-----\n" + key_content + "\n-----END EC PRIVATE KEY-----",
duration: 1200,
in_house: false
)
api_key
end
從環(huán)境變量里取出apikey, 用app_store_connect_api_key
方法來獲取臨時(shí)的 App Store Connect API Key, 其中长豁,key_id和issuer_id的值都可以在 App Store Connect 的 Keys 配置頁面上找到。這些屬于ID, 不屬于鑒權(quán)信息, 不存在什么保存和脫敏, 直接寫到方法里就行了(除非你要管理多個(gè)賬號(hào))
調(diào)github可能會(huì)碰到用戶的問題, 可以提前設(shè)置一下:
$> git config --global user.email "MomentsCI@lagou.com"
$> git config --global user.name "Moments CI"
當(dāng)create_new_profiles命令成功執(zhí)行以后忙灼,你可以在私有 Repo 上看到兩個(gè)新的文件夾certs/distribution
和profiles
, 其中,certs 文件夾用于保存私鑰(.p12)和證書(.cer)文件钝侠,而 profiles 文件夾則用來保存 adhoc 和 appstore 兩個(gè) Provisioning Profile 文件该园。你也可以在蘋果開發(fā)者網(wǎng)站查看新的證書文件和 Provisioning Profile 文件
下載證書和 Provisioning Profile
一個(gè)項(xiàng)目只需要執(zhí)行一次生成證書和 Provisioning Profile 的操作,其他團(tuán)隊(duì)成員可通過fastlane download_profiles
命令來下載證書和 Provisioning Profile帅韧。該 Lane 的代碼如下:
desc "Download certificates and profiles"
lane :download_profiles do
keychain_name = "TemporaryKeychain"
keychain_password = "TemporaryKeychainPassword"
create_keychain(
name: keychain_name,
password: keychain_password,
default_keychain: false,
timeout: 3600,
unlock: true,
)
match(
type: "adhoc",
readonly: true,
keychain_name: keychain_name,
keychain_password: keychain_password,
storage_mode: "git",
git_url: "https://github.com/JakeLin/moments-codesign",
app_identifier: "com.ibanimatable.moments.internal",
team_id: "6HLFCRTYQU"
)
match(
type: "appstore",
readonly: true,
keychain_name: keychain_name,
keychain_password: keychain_password,
storage_mode: "git",
git_url: "https://github.com/JakeLin/moments-codesign",
app_identifier: "com.ibanimatable.moments",
team_id: "6HLFCRTYQU"
)
end
與生成證書的描述文件的方法好像只差了一個(gè)readonly: true
參數(shù). 我不知道match
里做了什么, 但顯然這里就是告訴你去git里把對(duì)應(yīng)的配置文件下載下來, 再自行一個(gè)create_keychain方法存到本地
新增設(shè)備
當(dāng)我們通過 Ad Hoc 的方式來分發(fā) App 時(shí)里初,必須把需要安裝 App 的設(shè)備 ID 都添加到設(shè)備列表里面,你可以在蘋果開發(fā)者網(wǎng)站的“Certificates, Identifiers & Profiles”的 Devices 下查看所有設(shè)備信息忽舟。但每增加一個(gè)設(shè)備, 都要這么操作, 重新生成, 并所有人下載一次, 我們可以用fastlane來管理, 加一個(gè)add_device
的方法(lane):
desc "Add a new device to provisioning profile"
lane :add_device do |options|
name = options[:name]
udid = options[:udid]
# Add to App Store Connect
api_key = get_app_store_connect_api_key
register_device(
name: name,
udid: udid,
team_id: "6HLFCRTYQU",
api_key: api_key
)
# Update the profiles to Git private repo
match(
type: "adhoc",
force: true,
storage_mode: "git",
git_url: "https://github.com/JakeLin/moments-codesign",
app_identifier: "com.ibanimatable.moments.internal",
team_id: "6HLFCRTYQU",
api_key: api_key
)
end
顯然, 就是一個(gè)register
加一個(gè)match
, 這是第三次用到match了, 它能創(chuàng)建或下載. 這個(gè)時(shí)候只是在遠(yuǎn)程完成了添加, 想要本地同步的團(tuán)隊(duì)成員, 再執(zhí)行一次下載(download_profiles
)就好了
自動(dòng)化構(gòu)建
在上一講我們講述了如何使用 fastlane 來自動(dòng)管理私鑰双妨、證書和 Provisioning Profile 文件。其實(shí)叮阅,我們可以自動(dòng)化幾乎所有的 iOS 任務(wù)刁品,包括編譯、檢查代碼風(fēng)格浩姥、執(zhí)行測試挑随、打包和簽名、發(fā)布到分發(fā)渠道勒叠、上傳到 App Store兜挨、發(fā)送發(fā)布通知等膏孟。自動(dòng)化是衡量一個(gè)團(tuán)隊(duì)成熟度的關(guān)鍵因素,也是推動(dòng)項(xiàng)目工程化實(shí)踐的基石
編譯與執(zhí)行測試
desc "Build development app"
lane :build_dev_app do
puts("Build development app")
gym(scheme: "Moments",
workspace: "Moments.xcworkspace",
export_method: "development",
configuration: "Debug",
xcargs: "-allowProvisioningUpdates")
end
現(xiàn)在用了gym
這個(gè)方法(action
), 注意export_method: "development"
, 這就跟xcode上配置使用自動(dòng)簽名是一個(gè)意思, 可以省去配置證書和描述文件.
用scan
來執(zhí)行測試:
desc "Run unit tests"
lane :tests do
puts("Run the tests")
scan(
scheme: "Moments",
output_directory: "./fastlane/dist",
output_types: "html",
buildlog_path: "./fastlane/dist")
end
打包與簽名
蘋果公司為了給所有的 iOS 用戶提供安全和一致的體驗(yàn)拌汇,便把所有的 App 都放在沙盒(Sandbox)里面運(yùn)行柒桑,這樣能保證 App 運(yùn)行在一個(gè)受限和安全的空間里面。通常情況下噪舀,App 只能訪問沙盒里面的文件系統(tǒng)幕垦。當(dāng) App 需要訪問系統(tǒng)資源的時(shí)候,必須通過權(quán)限管理模塊的授權(quán)傅联。
我們以獲取地理位置信息作為例子來看看權(quán)限管理系統(tǒng)的運(yùn)作方式先改。當(dāng) App 想要獲得后臺(tái)地理位置信息時(shí),
- 權(quán)限管理系統(tǒng)會(huì)檢查 Info.plist 文件是否提供了描述信息蒸走,
- 并檢查用戶是否同意仇奶,
- 最后檢查 Background Modes 的 Entitlement 是否允許 Location updates。
如果這些都通過了比驻,權(quán)限管理系統(tǒng)就允許 App 在后臺(tái)訪問地理位置信息该溯。任何一項(xiàng)不通過,App 都無法在后臺(tái)訪問地理位置信息别惦。
當(dāng) App 需要訪問各種資源的時(shí)候狈茉,iOS 系統(tǒng)會(huì)詢問 App 一些重要的問題來判斷是否能通過權(quán)限檢查。那誰能提供這些信息呢掸掸?答案是 Provisioning Profile
氯庆。可以這么說扰付,Provisioning Profile 能回答下面的幾大“哲學(xué)”問題堤撵。
- 你是誰? Provisioning Profile 具有 Team ID 等信息羽莺,iOS 能知道這個(gè) App 的開發(fā)者是誰实昨。
- 你要干嗎? Provisioning Profile 關(guān)聯(lián)的 Entitlement 能告訴 iOS 系統(tǒng)該 App 需要訪問哪些系統(tǒng)資源盐固。
- 你要去哪里荒给? Provisioning Profile 里的設(shè)備列表能告訴 iOS 系統(tǒng)能否安裝該 App。
- 我能相信你嗎刁卜? 這涉及簽名(Code Sign)的概念志电,通過簽名,就能證明你是這個(gè) App 的簽名主體长酗,并能證明這個(gè) App 里面沒有經(jīng)過非法更改溪北。
前三個(gè)問題, 答案都在描述文件里, 那如果我們把描述文件一換不就行了嗎? 簽名就是起的這個(gè)作用, 保證該應(yīng)用與原證書持有人提交時(shí)的那個(gè)沒有變更. 簽名原理可以去看看別的文章, 總之, 如果你自己簽名, 有如下兩種方式, 首先都是把描述文件下載下來安裝(前面的download_profiles
也行), 然后:
- 方法一: 使用 Xcode 的
Archive
菜單進(jìn)行打包,然后再使用Validate App
功能來簽名 - 方法二: 使用
xcodebuild archive
命令來生成.xcarchive
文件,然后調(diào)用xcodebuild -exportArchive
命令來生成 IPA 文件
我們來看自動(dòng)化方案:
desc 'Creates an archive of the Internal app for testing'
lane :archive_internal do
unlock_keychain(
path: "TemporaryKeychain-db",
password: "TemporaryKeychainPassword")
update_code_signing_settings(
use_automatic_signing: false,
path: "Moments/Moments.xcodeproj",
code_sign_identity: "iPhone Distribution",
bundle_identifier: "com.ibanimatable.moments.internal",
profile_name: "match AdHoc com.ibanimatable.moments.internal")
puts("Create an archive for Internal testing")
gym(scheme: "Moments-Internal",
workspace: "Moments.xcworkspace",
export_method: "ad-hoc",
xcargs: "-allowProvisioningUpdates")
update_code_signing_settings(
use_automatic_signing: true,
path: "Moments/Moments.xcodeproj")
end
我們定義了archive_internalLane
來打包和簽名 Moments App 的 Internal 版本之拨,具體分成以下四步茉继。
- 第一步是解鎖 Keychain。因?yàn)楹灻璧淖C書信息保存在 Keychain 里面蚀乔,所以我們需要解鎖 Keychain 來讓 fastlane 進(jìn)行訪問烁竭。
- 第二步是更新簽名信息。我們使用“iPhone Distribution”作為簽名主體吉挣,并使用“match AdHoc com.ibanimatable.moments.internal”作為 Provisioning Profile派撕,這表示我們使用了 Ad Hoc 的 Provisioning Profile 來分發(fā)該 App。
- 第三步是核心操作睬魂,調(diào)用gymAction 來進(jìn)行打包和簽名终吼。gym幫我們封裝了xcodebuild的實(shí)現(xiàn)細(xì)節(jié),我們只需要調(diào)用一個(gè) Action 就能完成打包和簽名的操作氯哮。這里需要注意际跪,為了生成用于測試的 Internal App,我們需要把export_method參數(shù)賦值為ad-hoc喉钢,這樣我們就能實(shí)現(xiàn)內(nèi)部分發(fā)姆打。
- 第四步是恢復(fù)回自動(dòng)簽名。因?yàn)樵陂_發(fā)環(huán)境中肠虽,我們使用的是自動(dòng)簽名幔戏。為了方便本地開發(fā),在完成打包后税课,我們得把簽名方式進(jìn)行重置闲延。
下面再看一下如何為 App Store 版本的 App 進(jìn)行打包和簽名。
desc 'Creates an archive of the Production app with Appstore distribution'
lane :archive_appstore do
unlock_keychain(
path: "TemporaryKeychain-db",
password: "TemporaryKeychainPassword")
update_code_signing_settings(
use_automatic_signing: false,
path: "Moments/Moments.xcodeproj",
code_sign_identity: "iPhone Distribution",
bundle_identifier: "com.ibanimatable.moments",
profile_name: "match AppStore com.ibanimatable.moments")
puts("Create an archive for AppStore submission")
gym(scheme: "Moments-AppStore",
workspace: "Moments.xcworkspace",
export_method: "app-store",
xcargs: "-allowProvisioningUpdates")
update_code_signing_settings(
use_automatic_signing: true,
path: "Moments/Moments.xcodeproj")
end
archive_appstore的實(shí)現(xiàn)基本上與archive_internal一致伯复。不同的地方是在archive_appstore里面慨代,我們指定的 Provisioning Profile 是 “match AppStore com.ibanimatable.moments
”,而且在調(diào)用gymAction 時(shí)傳遞了app-store
給export_method參數(shù)啸如,表示要生成上傳到 App Store 的 App。
有了archive_internal和archive_appstore以后氮惯,再結(jié)合上一講介紹的download_profiles叮雳,我們就可以十分方便地自動(dòng)化打包和簽名 App 了。命令執(zhí)行完畢以后妇汗,在項(xiàng)目文件夾里面會(huì)出現(xiàn)一個(gè) Moments.ipa 文件帘不。IPA 文件也叫作 iOS App Store Package,該文件是一個(gè)包含了 iOS App 的存檔(archive)文件杨箭。 為了查看 IPA 文件里面的內(nèi)容寞焙,我們可以把后綴名修改成 .zip 文件并進(jìn)行解壓,其內(nèi)容如下圖所示:
在圖中有一個(gè)名為 embedded.mobileprovision 的 Provisioning Profile 文件,你可以打開該文件來查看相關(guān)內(nèi)容捣郊,如下圖所示:
在該 Provisioning Profile 中辽狈,你可以看到用于定義訪問系統(tǒng)資源權(quán)限的 Entitlement 信息、證書信息以及用于安裝的設(shè)備列表信息呛牲。有了這些信息刮萌,iOS 系統(tǒng)就能對(duì) App 進(jìn)行權(quán)限管理。
上傳到發(fā)布渠道
演示一個(gè)發(fā)布到firebase的lane
desc 'Deploy the Internal app to Firebase Distribution'
lane :deploy_internal do
firebase_app_distribution(
app: "1:374168413412:ios:912d89b30767d8e5a038f1",
ipa_path: "Moments.ipa",
groups: "internal-testers",
release_notes: "A new build for the Internal App",
firebase_cli_token: ENV["FIREBASE_API_TOKEN"]
)
end
可見, 這個(gè)action是默認(rèn)存在的, lane也沒封裝什么, 就是包裝了一下參數(shù), 這些參數(shù)都來自于firebase, 有需要的讀一下相關(guān)文檔, 有必要的話也可以把相關(guān)的key存local.keys
里面讀到環(huán)境變量里去
發(fā)布到App Store也一樣, 直接傳參即可:
desc 'Deploy the Production app to TestFlight and App Store'
lane :deploy_appstore do
api_key = get_app_store_connect_api_key
upload_to_app_store(
api_key: api_key,
app_identifier: "com.ibanimatable.moments",
skip_metadata: true,
skip_screenshots: true,
precheck_include_in_app_purchases: false,
)
end
上傳到文件存儲(chǔ), 還是app sotore的標(biāo)的, 就是上一節(jié)生成的ipa
文件, 這里不需要把ipa文件路徑傳參進(jìn)去, 說明是在同目錄里搜索按打包規(guī)則生成的名字, 不能改也不能自定義的
持續(xù)集成
前面我們把一系列任何封裝成了一個(gè)個(gè)命令, 差最后一步, 來調(diào)度這些命令的人, 通過CI(Continuous Integration), 你可以自行采購硬件搭建這樣一個(gè)平臺(tái), 也可以使用虛擬機(jī), 最簡單的起步, 是使用云服務(wù), 這一節(jié)但要Travis CI. (Jekins教程就不在此了)
- Travis CI 使用了“代碼即配置”的方式來配置 CI 管道娘扩,這是最重要的一個(gè)原因着茸。我們可以把 CI 管道的配置信息都寫在一個(gè) YAML 文件里面,并保存在 GitHub 上琐旁。這樣能方便我們把 CI 配置共享到多個(gè)項(xiàng)目涮阔,而且通過 Git 歷史記錄來不斷對(duì)比和優(yōu)化 CI 配置。除此之外灰殴,YAML 文件的配置方式已成為 CI 配置的標(biāo)準(zhǔn)敬特,當(dāng)需要升級(jí)為云端虛擬機(jī) CI 和全手工維護(hù) CI 時(shí),我們可以重用 Travis CI 的 YAML 文件验懊。相比之下擅羞,有些 CI 需要在網(wǎng)頁上進(jìn)行手工配置,而且無法看到修改歷史义图,這使得我們無法通過代碼把配置信息共享到其他項(xiàng)目中去减俏。
- Travis CI 免費(fèi)給開源項(xiàng)目使用。
- Travis CI 整合了 GitHub 和 GitLab 等代碼管理平臺(tái)碱工,只需要一次授權(quán)就能整合 CI 服務(wù)娃承。
- Travis CI 支持多個(gè)不同版本的 Mac OS 和 Xcode,我們可以根據(jù)項(xiàng)目的要求來靈活選擇不同的版本怕篷。例如通過 Travis CI历筝,我們可以方便地測試 Xcode Beta 版的構(gòu)建情況。
- 連接 Travis CI 與 GitHub
- 配置 .travis.yml
language: swift
osx_image: xcode12.2
env:
global:
- CI_BUILD_NUMBER=${TRAVIS_BUILD_NUMBER}
before_install:
- bundle install
- bundle exec pod install
TRAVIS_BUILD_NUMBER
的值由 Travis CI 系統(tǒng)所提供廊谓,它能幫助我們生成一個(gè)自增的 Build Number, 這個(gè)值是怎么寫到app里去的呢? 當(dāng)然是修改.xcconfig
文件:
會(huì)在 increment_build_number.sh 腳本中使用梳猪,如下代碼所示:
VERSION_XCCONFIG="Moments/Moments/Configurations/BaseTarget.xcconfig"
SED_CMD="s/\\(PRODUCT_VERSION_SUFFIX=\\).*/\\1${CI_BUILD_NUMBER}/" # Make sure setting this environment variable before call script.
sed -e ${SED_CMD} -i.bak ${VERSION_XCCONFIG}
rm -f ${VERSION_XCCONFIG}.bak
所有的 CI 管道都配置在jobs下面
jobs:
include:
- stage: "Build"
name: "Build internal app"
script:
- set -o pipefail
- echo "machine github.com login $GITHUB_API_TOKEN" >> ~/.netrc
- bundle exec fastlane download_profiles
- bundle exec fastlane archive_internal
這里用到了fastlane里的命令, 顯然之前配置的local.keys
等用于寫環(huán)境變量的, 也得部署到這個(gè)云服務(wù)里去, 其它stage
示例:
# 單元測試
- stage: "Test"
name: "Test app"
script:
- set -o pipefail
- bundle exec fastlane tests
# 發(fā)測試包
- stage: "Archive, sign and deploy internal app"
name: "Archive Internal app"
if: branch = main
script:
- set -o pipefail
- echo "machine github.com login $GITHUB_API_TOKEN" >> ~/.netrc
- bundle exec fastlane download_profiles
- ./scripts/increment_build_number.sh
- bundle exec fastlane archive_internal
- bundle exec fastlane deploy_internal
# 發(fā)布蘋果市場
- stage: "Archive, sign and deploy production app"
name: "Archive Production app"
if: branch = release
script:
- set -o pipefail
- echo "machine github.com login $GITHUB_API_TOKEN" >> ~/.netrc
- bundle exec fastlane download_profiles
- ./scripts/increment_build_number.sh
- bundle exec fastlane archive_appstore
- bundle exec fastlane deploy_appstore
統(tǒng)計(jì)分析
略
崩潰報(bào)告
感興趣的話了解下, 這是使用firebase的Crashlytics
自動(dòng)化上傳dSYM文件
當(dāng) Xcode 在把源代碼編譯成機(jī)器碼的時(shí)候,編譯器會(huì)生成一堆 Symbol(符號(hào))來存放類型的名字蒸痹、全局變量和方法的名稱等春弥,這些 Symbol 會(huì)把機(jī)器碼對(duì)應(yīng)到各種類型所在的文件和行號(hào)。因此叠荠,我們可以利用這些 Symbol 在 Xcode 里面進(jìn)行 Debug匿沛,或者在崩潰報(bào)告上定位 Bug。默認(rèn)情況下榛鼎,當(dāng)我們生成一個(gè) Debug 版本的 App 時(shí)逃呼,所有的 Debug Symbol 都會(huì)自動(dòng)存放在 App 里面鳖孤。
但是 Release 版本的 App 卻不一樣,為了減小 App 的尺寸抡笼,編譯器并不把 Debug Symbol 存放在 App 里面苏揣,而是生成一些額外的 dSYM 文件(Debug Symbol file)來存放。每個(gè)可執(zhí)行文件蔫缸、Framework 以及 Extension 都通過唯一的 UUID 來配對(duì)相應(yīng)的 dSYM 文件腿准。為了便于定位線上 App 的問題,我們需要保存這些 dSYM 文件拾碌,并上傳到崩潰報(bào)告服務(wù)上去吐葱。
幸運(yùn)的是,fastlane 提供了一個(gè)upload_symbols_to_crashlyticsAction
來幫我們簡化上傳 dSYM 文件的操作校翔。上傳 Internal App dSYM 文件的具體實(shí)現(xiàn)如下:
desc 'Upload symbols to Crashlytics for Internal app'
lane :upload_symbols_to_crashlytics_internal do
upload_symbols_to_crashlytics(
dsym_path: "./Moments.app.dSYM.zip",
gsp_path: "./Moments/Moments/Configurations/Firebase/GoogleService-Info-Internal.plist",
api_token: ENV["FIREBASE_API_TOKEN"]
)
end
在調(diào)用 upload_symbols_to_crashlyticsAction
時(shí)弟跑,我們需要傳遞三個(gè)參數(shù):
- 首先把 dSYM 文件的路徑傳遞給dsym_path參數(shù),
- 然后把 Firebase 的配置文件傳遞給gsp_path參數(shù)防症,
- 最后是把 Firebase API Token 傳遞給api_token參數(shù)
接下來我們?cè)僖黄鹂纯瓷蟼?AppStore 版本 dSYM 文件的具體實(shí)現(xiàn):
desc 'Upload symbols to Crashlytics for Production app'
lane :upload_symbols_to_crashlytics_appstore do
upload_symbols_to_crashlytics(
dsym_path: "./Moments.app.dSYM.zip",
gsp_path: "./Moments/Moments/Configurations/Firebase/GoogleService-Info-AppStore.plist",
api_token: ENV["FIREBASE_API_TOKEN"]
)
end
幾乎是一樣的, 差別就在于firebase的配置文件是不同的. 有了這些 Lane 以后孟辑,我們就可以修改 CI 的配置來自動(dòng)完成上傳 dSYM 文件的操作。下面是 .travis.yml 的配置:
- stage: "Archive, sign and deploy internal app"
name: "Archive Internal app"
if: branch = main
script:
- bundle exec fastlane archive_internal
- bundle exec fastlane upload_symbols_to_crashlytics_internal # 新增的步驟
- bundle exec fastlane deploy_internal
可以看到蔫敲,我們?cè)趕cript下增加了 upload_symbols_to_crashlytics_internal
步驟, 而且是先上傳再deploy饲嗽。
查看崩潰報(bào)告
性能報(bào)告
遠(yuǎn)程開關(guān)
通過遠(yuǎn)程開關(guān),我們就可以在無須發(fā)布新版本的情況下開關(guān) App 的某些功能奈嘿,甚至可以為不同的用戶群體提供不同的功能貌虾。 遠(yuǎn)程功能開關(guān)能幫助我們快速測試新功能,從而保證產(chǎn)品的快速迭代裙犹。該模塊主要由兩部分所組成:Remote Config 模塊和Toggle 模塊尽狠。
Remote Config 也叫作“遠(yuǎn)程配置”,它可以幫助我們把 App 所需的配置信息存儲(chǔ)在服務(wù)端叶圃,讓所有的 App 在啟動(dòng)的時(shí)候讀取相關(guān)的配置信息袄膏,并根據(jù)這些配置信息來調(diào)整 App 的行為。 Remote Config 應(yīng)用廣泛掺冠,可用于遠(yuǎn)程功能開關(guān)沉馆、 A/B 測試和強(qiáng)制更新等功能上。一些基礎(chǔ)功能演示:
protocol RemoteConfigProvider {
func setup()
func fetch()
func getString(by key: RemoteConfigKey) -> String?
func getInt(by key: RemoteConfigKey) -> Int?
func getBool(by key: RemoteConfigKey) -> Bool
}
具體實(shí)現(xiàn)是本地存儲(chǔ)和遠(yuǎn)端拉取的結(jié)合, 這一節(jié)太個(gè)性化, 也略過了. 教程中仍是以firebase為例來講解的, 因?yàn)槭∪チ碎_發(fā)頁面的過程, firebase原生就支持(其實(shí)就等于firebase也提供了幾個(gè)restful服務(wù)供你調(diào)用而已, 值都是你自己在后臺(tái)配的, 自行去使用)
在使用的地方, 使用本地存儲(chǔ)里的值來進(jìn)行判斷即可. 所以拉取遠(yuǎn)程配置一般是在app啟動(dòng), 或當(dāng)前頁面加載的時(shí)機(jī)(如果是當(dāng)前頁面加載, 那么所有的UI更新都得在這個(gè)拉取之后, 最好別這么干)
AB測試
原理跟遠(yuǎn)程開關(guān)一致, 多了個(gè)分組. 需要一個(gè)平臺(tái)能定義分組和關(guān)鍵值, app里去應(yīng)用這些值來寫針對(duì)的代碼, 同樣firebase也是支持的. 定向推送, 隨機(jī)推送等機(jī)制, 也就一起提供給你了, 可見如果愿意使用firebase的話, 幾乎是一站式的. 當(dāng)然如果是公司開發(fā), 這種事由后端同學(xué)做個(gè)平臺(tái)也不是不可以.
App Icon制作
使用SwiftUI替換UIKit
如果嚴(yán)格按ViewModel
數(shù)據(jù)意向流動(dòng)的方式編寫的代碼(如本教程), 這一步重寫一個(gè)視圖層就行了. 關(guān)于SwiftUI, 可以跳轉(zhuǎn)這篇專題文章[[languages.ios.swift]]
SwiftUI 的狀態(tài)管理
iOS17開始已經(jīng)大大簡化了
SwiftUI
的狀態(tài)管理, 使用@Observation
, 參考文章
struct ContentView: View {
@State private var age = 20
var body: some View {
Button("生日啦德崭,現(xiàn)在幾歲: \(age)") {
age += 1
}
}
}
上述例子實(shí)現(xiàn)了本視圖里的狀態(tài)聯(lián)動(dòng), 那么如何跨頁面共享呢? 那就需要使用到 @StateObject
和 @ObservedObject
屬性包裝器了悍及。這兩個(gè)屬性包裝器所定義的屬性都必須遵循ObservableObject協(xié)議。
class UserObservableObject: ObservableObject {
var name = "Jake"
var age = 20 {
willSet {
objectWillChange.send()
}
}
}
你們也看到了send方法, 但是寫法很繁, 每一個(gè)能監(jiān)聽的屬性都要在 willSet
方法里send的話那將是一個(gè)災(zāi)難. 所以有了簡化版本
class UserObservableObject: ObservableObject {
var name = "Jake"
@Published var age = 20
}
同時(shí)要注意, 所有遵循 ObservableObject
協(xié)議的子類型都必須是引用類型接癌,所以我們只能使用類而不是結(jié)構(gòu)體(Struct). 介紹完ObservableObject協(xié)議以后,我們就可以通過下面的例子看看如何使用 @StateObject 和 @ObservedObject 屬性包裝器了扣讼。
struct ChildView: View {
@ObservedObject var user: UserObservableObject
var body: some View {
Button("生日啦缺猛,現(xiàn)在幾歲: \(user.age)") {
user.age += 1
}
}
}
struct ParentView: View {
@StateObject var user: UserObservableObject = .init()
var body: some View {
VStack {
Text("你的名字:\(user.name)")
ChildView(user: user)
}
}
}
@StateObject 和 @ObservedObject 都可以定義用于狀態(tài)共享的屬性,而且這些屬性的類型都必須遵循ObservableObject協(xié)議。不同的地方是 @StateObject 用于生成和管理狀態(tài)屬性的生命周期荔燎,而 @ObservedObject 只能把共享狀態(tài)從外部傳遞進(jìn)來耻姥。例如,在上面的示例代碼中有咨,我們?cè)赑arentView里使用 @StateObject 來定義并初始化user屬性琐簇,然后傳遞給ChildView的user屬性。由于ChildView的user屬性來自外部的ParentView座享,因此定義為 @ObservedObject婉商。
當(dāng)我們需要共享狀態(tài)的時(shí)候,
- 通常在父對(duì)象里定義和初始化一個(gè) @StateObject 屬性渣叛,
- 然后傳遞給子對(duì)象里的 @ObservedObject 屬性丈秩。
如果只有兩層關(guān)系還是很方便的,但假如有好幾層的父子關(guān)系淳衙,逐層傳遞會(huì)變得非常麻煩蘑秽,那有沒有好辦法解決這個(gè)問題呢?
@EnvironmentObject 就是用于解決這個(gè)問題的箫攀。@EnvironmentObject 能幫我們把狀態(tài)共享到整個(gè) App 里面肠牲,下面還是通過一個(gè)例子來看看。
@main
struct MomentsApp: App {
@StateObject var user: UserObservableObject = .init()
var body: some Scene {
WindowGroup {
ParentView()
.environmentObject(user)
}
}
}
struct ChildView: View {
@EnvironmentObject var user: UserObservableObject
var body: some View {
Button("生日啦靴跛,現(xiàn)在幾歲: \(user.age)") {
user.age += 1
}
}
}
struct ParentView: View {
var body: some View {
VStack {
ChildView()
}
}
}
@EnvironmentObject 有點(diǎn)像 Singleton缀雳,我們不能過度使用它,否則會(huì)增加模塊間的耦合度汤求。@ObservedObject 與 @EnvironmentObject 都能幫助我們共享引用類型的屬性俏险,但如何共享值類型的屬性呢?
@Binding 屬性包裝器就能幫我們定義共享值類型的屬性扬绪。
struct ChildView: View {
@Binding var isPresented: Bool
var body: some View {
Button("關(guān)閉") {
isPresented = false
}
}
}
struct ParentView: View {
@State private var showingChildView = false
var body: some View {
VStack {
Text("父 View")
}.sheet(isPresented: $showingChildView) {
ChildView(isPresented: $showingChildView)
}
}
}
可見, 在定義的類里使用, 和往下傳, 是加了$
符號(hào)的, 這使得showingChildView
能被回寫
SwiftUI 的架構(gòu)與實(shí)現(xiàn)
橋接 RxSwift 與 SwiftUI
為了把這些 ViewModel 類型橋接到 SwiftUI 版本的 View 模塊竖独,我們?cè)黾恿藘蓚€(gè)類型:MomentsListObservableObject和IdentifiableListItemViewModel。MomentsListObservableObject負(fù)責(zé)給 SwiftUI 組件發(fā)送更新消息挤牛,下面是它的具體實(shí)現(xiàn):
final class MomentsListObservableObject: ObservableObject {
private let viewModel: MomentsTimelineViewModel
private let disposeBag: DisposeBag = .init()
@Published var listItems: [IdentifiableListItemViewModel] = []
init(userID: String, momentsRepo: MomentsRepoType) {
viewModel = MomentsTimelineViewModel(userID: userID, momentsRepo: momentsRepo)
setupBindings()
}
func loadItems() {
viewModel.loadItems()
.subscribe()
.disposed(by: disposeBag)
}
private func setupBindings() {
viewModel.listItems
.observeOn(MainScheduler.instance)
.subscribe(onNext: { [weak self] items in
guard let self = self else { return }
self.listItems.removeAll()
self.listItems.append(contentsOf: items.flatMap { $0.items }.map { IdentifiableListItemViewModel(viewModel: $0) })
})
.disposed(by: disposeBag)
}
}
寫到這里其實(shí)夠了, 原來的那個(gè)viewmodel是不能直接用的, 還得再包一層. 剩下的是把所有的viewmodel都包一次, 以及用SwiftUI來重寫視圖, 就不再說了.