iOS架構(gòu)進(jìn)階(二)

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ù)由三部分組成督禽,

  • 第一部分是通過 RxSwiftRxCocoa ,把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:{})方法來停止activityIndicatorViewrefreshControl兩個(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)
    }
}

講解:

  1. 本質(zhì)上, 這兩個(gè)視圖都是一個(gè)BaseListItemView, 下面稱基類 注意, 它是一個(gè)普通的UIView, 而不是UITableViewCell
  2. Cell直接把基類addSubView
  3. 因?yàn)樽隽藘蓚€(gè)類, 所以update方法不得不復(fù)制了兩次, 其實(shí)是一模一樣的, 這是敗筆
  4. 基類里, 把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ù)邏輯了. 本教程使用了QuickNimble

一個(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ù)的入口
  • 然后就是itexpect的組合

網(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è)問題:

  1. 調(diào)用getMoments方法, 并不是傳一個(gè)mock的入?yún)? 是怎么回事?
  2. 里面.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ǔ)

  1. 建 GitHub 私有 Repo
  2. 生成 GitHubA Access Token
  3. 生成 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

  1. 登錄到 GitHub
  2. 點(diǎn)擊 New repository
  3. 填寫 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è)方法:

  1. create_keychain()冬三,是為了把證書和描述文件存到 keychain 里
  2. match(),顯然是用來生成證書和描述文件的闷旧,type指定了渠道
  3. 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/distributionprofiles, 其中,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)容如下圖所示:


image.png

在圖中有一個(gè)名為 embedded.mobileprovision 的 Provisioning Profile 文件,你可以打開該文件來查看相關(guān)內(nèi)容捣郊,如下圖所示:


image.png

在該 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)建情況。
  1. 連接 Travis CI 與 GitHub
  2. 配置 .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

image.png

如果嚴(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來重寫視圖, 就不再說了.

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末莹痢,一起剝皮案震驚了整個(gè)濱河市,隨后出現(xiàn)的幾起案子墓赴,更是在濱河造成了極大的恐慌竞膳,老刑警劉巖,帶你破解...
    沈念sama閱讀 218,941評(píng)論 6 508
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件诫硕,死亡現(xiàn)場離奇詭異坦辟,居然都是意外死亡,警方通過查閱死者的電腦和手機(jī)章办,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 93,397評(píng)論 3 395
  • 文/潘曉璐 我一進(jìn)店門锉走,熙熙樓的掌柜王于貴愁眉苦臉地迎上來滨彻,“玉大人,你說我怎么就攤上這事挪蹭⊥ざ” “怎么了?”我有些...
    開封第一講書人閱讀 165,345評(píng)論 0 356
  • 文/不壞的土叔 我叫張陵梁厉,是天一觀的道長辜羊。 經(jīng)常有香客問我,道長词顾,這世上最難降的妖魔是什么八秃? 我笑而不...
    開封第一講書人閱讀 58,851評(píng)論 1 295
  • 正文 為了忘掉前任迫横,我火速辦了婚禮嚷节,結(jié)果婚禮上岂津,老公的妹妹穿的比我還像新娘秧耗。我一直安慰自己诸尽,他們只是感情好阁最,可當(dāng)我...
    茶點(diǎn)故事閱讀 67,868評(píng)論 6 392
  • 文/花漫 我一把揭開白布术浪。 她就那樣靜靜地躺著虹菲,像睡著了一般睡雇。 火紅的嫁衣襯著肌膚如雪萌衬。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 51,688評(píng)論 1 305
  • 那天它抱,我揣著相機(jī)與錄音秕豫,去河邊找鬼。 笑死观蓄,一個(gè)胖子當(dāng)著我的面吹牛混移,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播侮穿,決...
    沈念sama閱讀 40,414評(píng)論 3 418
  • 文/蒼蘭香墨 我猛地睜開眼歌径,長吁一口氣:“原來是場噩夢(mèng)啊……” “哼!你這毒婦竟也來了亲茅?” 一聲冷哼從身側(cè)響起回铛,我...
    開封第一講書人閱讀 39,319評(píng)論 0 276
  • 序言:老撾萬榮一對(duì)情侶失蹤,失蹤者是張志新(化名)和其女友劉穎克锣,沒想到半個(gè)月后茵肃,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 45,775評(píng)論 1 315
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡袭祟,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 37,945評(píng)論 3 336
  • 正文 我和宋清朗相戀三年验残,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片巾乳。...
    茶點(diǎn)故事閱讀 40,096評(píng)論 1 350
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡胚膊,死狀恐怖故俐,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情紊婉,我是刑警寧澤,帶...
    沈念sama閱讀 35,789評(píng)論 5 346
  • 正文 年R本政府宣布辑舷,位于F島的核電站喻犁,受9級(jí)特大地震影響,放射性物質(zhì)發(fā)生泄漏何缓。R本人自食惡果不足惜肢础,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,437評(píng)論 3 331
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望碌廓。 院中可真熱鬧传轰,春花似錦、人聲如沸谷婆。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,993評(píng)論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽纪挎。三九已至期贫,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間异袄,已是汗流浹背通砍。 一陣腳步聲響...
    開封第一講書人閱讀 33,107評(píng)論 1 271
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留烤蜕,地道東北人封孙。 一個(gè)月前我還...
    沈念sama閱讀 48,308評(píng)論 3 372
  • 正文 我出身青樓,卻偏偏與公主長得像讽营,于是被迫代替她去往敵國和親虎忌。 傳聞我的和親對(duì)象是個(gè)殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 45,037評(píng)論 2 355

推薦閱讀更多精彩內(nèi)容

  • 原創(chuàng):知識(shí)點(diǎn)總結(jié)性文章創(chuàng)作不易,請(qǐng)珍惜蚀瘸,之后會(huì)持續(xù)更新狡蝶,不斷完善個(gè)人比較喜歡做筆記和寫總結(jié),畢竟好記性不如爛筆頭哈...
    時(shí)光啊混蛋_97boy閱讀 775評(píng)論 0 2
  • 這也是我不想用簡書的原因, 能發(fā)幾篇, 能發(fā)多長, 能發(fā)什么內(nèi)容全控制不了, 還是本地大法好贮勃。 本文是一個(gè)系列課程...
    walkerwzy閱讀 117評(píng)論 0 0
  • 本文由 戴倉薯(也就是我L叭恰) 翻譯,dopcn 校稿寂嘉。首發(fā)于伯樂在線 原文鏈接:https://github.co...
    戴倉薯閱讀 5,155評(píng)論 1 73
  • @[TOC](IOS 逆向開發(fā)(三)應(yīng)用簽名) 1. 數(shù)字簽名 什么是數(shù)字簽名奏瞬? 數(shù)字簽名(digitally s...
    孔雨露閱讀 468評(píng)論 0 5
  • iOS最佳實(shí)踐 譯者注 本文翻譯自 futurice 公司的 iOS Good Practices枫绅,譯文在 Git...
    linxiangyu閱讀 12,989評(píng)論 10 157