iOS架构进阶(二)

UI层架构

在 MVVM 模式里,View 依赖于 ViewModel。作为 View 的BaseTableViewController依赖于 ViewModel 层的ListViewModel协议,这使得BaseTableViewController只依赖于接口而不是具体的类型,从而提高了程序的可扩展性。同时,BaseTableViewController还定义了三个属性来显示 UI 控件:

  • tableView属性用于显示一个 TableView;
  • activityIndicatorView属性用于显示俗称小菊花的加载器;
  • errorLabel用于显示出错信息的标签控件。

一个定义示例:

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()方法是封装的一个通用方法, 这样可以使得定义和配置一个控件的代码统一起来, 不至于出现这里定义那里配置的随意性。其实就是简单地送个闭包进去:

func configure<T: AnyObject>(_ object: T, closure: (T) -> Void) -> T {
    closure(object)
    return object
}

数据绑定

Moments App 使用了 RxSwift 把 ViewModel 层和 View 层进行绑定,绑定的代码在setupBindings()函数里,具体如下。

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)
}

这个函数由三部分组成,

  • 第一部分是通过 RxSwiftRxCocoa ,把UIRefreshControl控件里的isRefreshing事件和loadItems()函数绑定起来。当用户下拉刷新控件的时候会调用loadItems()函数来刷新列表的数据。
  • 第二部分是把 TableView Cell 控件与 ViewModel 的listItemsSubject 属性绑定起来,当listItems发出新的事件时,我们会调用ListItemCell的update(with viewModel: ListItemViewModel)方法来更新 UI。经过了这一绑定,UI 就能随着 ViewModel 的数据变化而自动更新。
    • 这里需要注意的是要多读一下RxCocoa的api, 了解下列表的绑定, 比如这个例子里, listItems发了消息让table接, 但是table是用一个datasource来处理cell的创建和更新的, 因此, 而这个datasource则是纯RxCocoa自定义的东西, 也是它在真正处理listitems发过来的数据, 也就是说它是个必要的中转/适配器
  • 第三部分与第二部分类似,都是把 ViewModel 与 View 层的控件进行绑定。在这里,我们把 ViewModel 的hasErrorSubject 属性绑定到errorLabel.rx.isHidden属性来控制errorLabel是否可见。

数据绑定以后,我们一起看看loadItems()函数的实现。

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()本应是加载数据, 并且渲染UI, 但这段代码我们只看到了把它跟各种状态控件绑定了起来, 比如hasError, activityIndicatorView, refresControl等, 只能认为, refreshControl能触发tableView的重加载工作, 这里最好去翻翻代码 --> 好像是loadItems是存数据库了, 而不是接口透传

假如用户在调用 ViewModel 的loadItems()方法的过程中,退出列表页面,我们通过.do(onDispose:{})方法来停止activityIndicatorViewrefreshControl两个控件的刷新动画。

从代码中你可以看到,尽管我们想更新 UI 层的errorLabel控件,却没有直接通过errorLabel.isHidden = true的方式来更新,而是通过 ViewModel 的hasError属性来完成。这是因为我要保证 View/UI 层都是由 ViewModel 驱动,通过单方向的数据流来减少 Bug ,从而提高代码的可维护性。

这套UI设计了一个头部cell和一个可重复的cell两部分, 因此做了两个基础类, 并且按一理的风格, 为每个类都做了一个protocol, 显然只有一个方法: update(with viewModel: T)

  • LiteItemCell protocol 和其实现: BaseTableViewCell
  • ListItemView protocol 和其实现: BaseListItemView
    比如绕的是, 这个BaseTableViewCell其实还是一个BaseListItemView, 总之要记得这是一套过度设计的系统, 实际中肯定可以简化很多层. 作为教学, 可能也复杂了些, 把需要讲解的点和一些实际应用给混在一起了. 我们看看这两块的代码:
// 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. 本质上, 这两个视图都是一个BaseListItemView, 下面称基类 注意, 它是一个普通的UIView, 而不是UITableViewCell
  2. Cell直接把基类addSubView
  3. 因为做了两个类, 所以update方法不得不复制了两次, 其实是一模一样的, 这是败笔
  4. 基类里, 把update方法默认fatal了, 子类里, 说的是使用传入的view的update方法, 所以虽然没有明确fatal, 事实上也是需要自行实现的.

能动态增加功能的架构

规范的架构与框架不仅具有良好的可扩展性,例如,可以灵活地替换网络层、数据库甚至 UI 层的实现,而且还为开发者提供了统一的开发步骤与规范,方便新功能的快速迭代。以下以增加一个点赞功能为例, 可以按照以下五个步骤:

  • 增加“添加点赞功能”的功能开关;
  • 开发网络层来更新 BFF 的点赞信息;
  • 开发 Repository 层来存储数据;
  • 开发 ViewModel 层来准备 UI 所需的数据;
  • 开发 UI/View 层呈现点赞按钮和点赞朋友列表。

增加功能开关

当我们开发一个周期比较长的新功能时,通常会使用功能开关。
如果没有功能开关,当开发周期超过一周以上时,我们就不得不把开发中的功能放在一个“长命”功能分支下,直到整个功能完成后才合并到主分支,这往往会增加合并分支的难度。
另一种方法是延迟发布的时间,在功能完整开发出来后才进行发布。假如有多个团队一直在开发新功能,那么发布计划就可能一直在延迟

其它步骤不赘述, 有一个地方需要注意下, 教程用的是GraphQL, 所有的查询是客户端构建的, 所以是可以在全链路里点赞属性还没开发好的情况下修改需要返回的字段, 而不是像一般的开发, 先把预期要返回的字段建模进去, 等到部署好了自然这个字段谅有值了:

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) 的核心是编写单元测试。

  • 单元测试能方便我们模拟不同的测试场景,覆盖不同的边界条件,从而提高代码的质量并减少 Bug 的数量。
  • 同时,使用 TDD 所开发的代码能降低模块间的耦合度,提高模块的灵活性和可扩展性。

在编写测试代码时候,我们一般遵守 AAA 步骤,所谓AAA 就是 Arrange、Act 和 Assert。

  • Arrange:用于搭建测试案例,例如,初始化测试对象及其依赖。
  • Act:表示执行测试,例如,调用测试对象的方法。
  • Assert:用于检验测试的结果。

基本上都是检查:

  • 必要的步骤(方法)被触发
  • 关联步骤被触发
  • 正确地解析了返回值
  • 正确地处理了错误, 空值等

剩下的就是你选的测试框架是怎么处理模拟数据, 初始化等等的, 最后, 就是依赖注入的架构, 可以使得测试用例能够传入模拟数据源, 而如果你是在实现体内写死的比如网络请求, 数据库连接, 单元测试就不能做了, 一做就触发了真正的业务逻辑了. 本教程使用了QuickNimble

一个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()") { }
        }
    }
}
  • 测试类型中的每一个公共的方法和属性都要测试, 上例用的是context()方法
  • spce和context里都有各自的beforeEach, 能看出这是初始化测试数据的入口
  • 然后就是itexpect的组合

网络层

因为使用了RxSwift, 可以引用RxTesst库来简化测试流程. 首先,我们在describe("GetMomentsByUserIDSession")函数里定义需要初始化的变量,代码如下:

var testSubject: GetMomentsByUserIDSession!
var testScheduler: TestScheduler!
var testObserver: TestableObserver<MomentsDetails>!
var mockResponseEvent: Recorded<Event<GetMomentsByUserIDSession.Response>>!
  • testSubject是测试的对象,在这个例子中是我们需要测试的GetMomentsByUserIDSession。
  • testScheduler的类型是来自 RxTest 的TestScheduler,是一个用于测试的排程器。
  • testObserver的类型是 RxTest 的TestableObserver,用来订阅 Observable 序列里的事件,并通过接收到的事件来检查测试的结果。
  • mockResponseEvent是Recorded类型,也是来自 RxTest,用于模拟事件的发送,例如模拟成功接收到网络数据事件或者错误事件。

所需的变量定义完毕以后,可以在beforeEach()方法里面初始化testScheduler和testObserver,具体代码如下:

beforeEach {
    testScheduler = TestScheduler(initialClock: 0)
    testObserver = testScheduler.createObserver(MomentsDetails.self)
}

初始化完成, 看一个用例:

context("getMoments(userID:)") {
    context("when response status code 200 with valid response") {
        beforeEach {
            mockResponseEvent = .next(100, TestData.successResponse)
            getMoments(mockEvent: mockResponseEvent)
        }
    }
}

有两个问题:

  1. 调用getMoments方法, 并不是传一个mock的入参, 是怎么回事?
  2. 里面.next出了一个successResponse哪来的?
    其实是私有方法和私有属性, 所以单元测试不是那么简单的事, 需要一大堆的准备工作
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 数据, 可以从BFF的正确返回里拷贝一段
    }
    """
}

为了mock rx特性的网络请求, 上面初始化的test开头的几个属性就起作用了, 上面都是数据准备, 下面是断言部分:

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层/数据层

单元测试只测代码覆盖率, 即期望的代码有没有执行到, 数据层这种来源即可能是硬盘, 也可能是数据库, 也可能是网络请求的, 在架构上就要实现能依赖注入(DI), 这样真实代码和测试代码的依赖关系就解耦了, 测试代码就可以用mock来代替真实的依赖了.

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)
    }
}

看代码, 其实就是自己手写了一个repository, 只不过是个简单版的, 让代码能跑过去的. 上面代码一个是mock了一个数据库请求, 或者说文件请求(userdefaults本质上是文件), 一个是mock了一个网络请求.

有了这些 Mock 类型以后,我们就可以把它们注入测试对象testSubject中:

beforeEach {
    mockUserDefaultsPersistentDataStore = MockUserDefaultsPersistentDataStore()
    mockGetMomentsByUserIDSession = MockGetMomentsByUserIDSession()
    testSubject = MomentsRepo(persistentDataStore: mockUserDefaultsPersistentDataStore, getMomentsByUserIDSession: mockGetMomentsByUserIDSession)
}

上一节网络层的测试, 用testScheduler.createHotObservable()(即rxTest)创建了一个testableObservable, 本节演示自己写一个observable, 我目前不知道需要两种写法的必要性, 在我看来, 从网络/硬盘/数据库读取东西都是一回事, 还是先看实现吧:

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
    }
}

// 此时期望是取不到详情的:
it("should be `nil` by default") {
    expect(testObserver.lastElement).to(beNil()) // Assert
}

// 直到onNext了一个数据出来
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层

这一层主要把下游数据转化成UI需要的数据,

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())
    }
}
}

因为所有的转换逻辑都封装在UserProfileListItemViewModel的init(userDetails:)方法里面,所以我们可以通过测试该init()方法来验证数据转换的逻辑。

统一管理 Certificate 和 Profile

一种思路,用 GitHub 来存储

  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必要的凭据, 你可以选择把它们配置在连接的语句里, 或是配置文件里, 或是环境变量里.

配置环境变量的话:

# 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>

配置文件的话, 先创建一个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>

再写个脚本来读这些Keys, 自动写到环境变量里(当然你也可以选择在代码里读这个配置文件)

export $(grep -v '^#' ./local.keys | sed 's/#.*//')
export MATCH_GIT_BASIC_AUTHORIZATION=$(echo -n momentsci:$GITHUB_API_TOKEN | base64)

接着在根目录执行以下的命令:

$> source ./scripts/export_env.sh

如果想要开机自动执行这个脚本, 可以在~/.bash_profile里添加:

source ~/projects/path/scripts/export_env.sh

鉴权资料拿到后, 就可以用 fastlane match 自动生成和管理证书和 Provisioning Profile 了.

local.keys这个文件不要传到项目文件里去(同所有含有敏感信息的文件一样), 所以要添加到.gitignore

创建 GitHub Repo

  1. 登录到 GitHub
  2. 点击 New repository
  3. 填写 Repo name, 选择 Public

然后使用 fastlane 管理存在 GitHub 的证书和 Provisioning Profile

生成证书和 Provisioning Profile

每个项目也只需执行一次这样的操作。

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

使用, 执行这个语句即可:

fastlane create_new_profiles

上面的脚本中调用了两个方法:

  1. create_keychain(),是为了把证书和描述文件存到 keychain 里
  2. match(),显然是用来生成证书和描述文件的,type指定了渠道
  3. get_app_store_connect_api_key这句话显然是取app sotre的apikey, 只是注意一下, 无参数调方法似乎都连括号都不需要

方法定义如下:

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

从环境变量里取出apikey, 用app_store_connect_api_key方法来获取临时的 App Store Connect API Key, 其中,key_id和issuer_id的值都可以在 App Store Connect 的 Keys 配置页面上找到。这些属于ID, 不属于鉴权信息, 不存在什么保存和脱敏, 直接写到方法里就行了(除非你要管理多个账号)

调github可能会碰到用户的问题, 可以提前设置一下:

$> git config --global user.email "MomentsCI@lagou.com"
$> git config --global user.name "Moments CI"

当create_new_profiles命令成功执行以后,你可以在私有 Repo 上看到两个新的文件夹certs/distributionprofiles, 其中,certs 文件夹用于保存私钥(.p12)和证书(.cer)文件,而 profiles 文件夹则用来保存 adhoc 和 appstore 两个 Provisioning Profile 文件。你也可以在苹果开发者网站查看新的证书文件和 Provisioning Profile 文件

下载证书和 Provisioning Profile

一个项目只需要执行一次生成证书和 Provisioning Profile 的操作,其他团队成员可通过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

与生成证书的描述文件的方法好像只差了一个readonly: true参数. 我不知道match里做了什么, 但显然这里就是告诉你去git里把对应的配置文件下载下来, 再自行一个create_keychain方法存到本地

新增设备

当我们通过 Ad Hoc 的方式来分发 App 时,必须把需要安装 App 的设备 ID 都添加到设备列表里面,你可以在苹果开发者网站的“Certificates, Identifiers & Profiles”的 Devices 下查看所有设备信息。但每增加一个设备, 都要这么操作, 重新生成, 并所有人下载一次, 我们可以用fastlane来管理, 加一个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

显然, 就是一个register加一个match, 这是第三次用到match了, 它能创建或下载. 这个时候只是在远程完成了添加, 想要本地同步的团队成员, 再执行一次下载(download_profiles)就好了

自动化构建

在上一讲我们讲述了如何使用 fastlane 来自动管理私钥、证书和 Provisioning Profile 文件。其实,我们可以自动化几乎所有的 iOS 任务,包括编译、检查代码风格、执行测试、打包和签名、发布到分发渠道、上传到 App Store、发送发布通知等。自动化是衡量一个团队成熟度的关键因素,也是推动项目工程化实践的基石

编译与执行测试

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

现在用了gym这个方法(action), 注意export_method: "development", 这就跟xcode上配置使用自动签名是一个意思, 可以省去配置证书和描述文件.

scan来执行测试:

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 用户提供安全和一致的体验,便把所有的 App 都放在沙盒(Sandbox)里面运行,这样能保证 App 运行在一个受限和安全的空间里面。通常情况下,App 只能访问沙盒里面的文件系统。当 App 需要访问系统资源的时候,必须通过权限管理模块的授权。

我们以获取地理位置信息作为例子来看看权限管理系统的运作方式。当 App 想要获得后台地理位置信息时,

  • 权限管理系统会检查 Info.plist 文件是否提供了描述信息,
  • 并检查用户是否同意,
  • 最后检查 Background Modes 的 Entitlement 是否允许 Location updates。

如果这些都通过了,权限管理系统就允许 App 在后台访问地理位置信息。任何一项不通过,App 都无法在后台访问地理位置信息。

当 App 需要访问各种资源的时候,iOS 系统会询问 App 一些重要的问题来判断是否能通过权限检查。那谁能提供这些信息呢?答案是 Provisioning Profile。可以这么说,Provisioning Profile 能回答下面的几大“哲学”问题。

  • 你是谁? Provisioning Profile 具有 Team ID 等信息,iOS 能知道这个 App 的开发者是谁。
  • 你要干吗? Provisioning Profile 关联的 Entitlement 能告诉 iOS 系统该 App 需要访问哪些系统资源。
  • 你要去哪里? Provisioning Profile 里的设备列表能告诉 iOS 系统能否安装该 App。
  • 我能相信你吗? 这涉及签名(Code Sign)的概念,通过签名,就能证明你是这个 App 的签名主体,并能证明这个 App 里面没有经过非法更改。

前三个问题, 答案都在描述文件里, 那如果我们把描述文件一换不就行了吗? 签名就是起的这个作用, 保证该应用与原证书持有人提交时的那个没有变更. 签名原理可以去看看别的文章, 总之, 如果你自己签名, 有如下两种方式, 首先都是把描述文件下载下来安装(前面的download_profiles也行), 然后:

  • 方法一: 使用 Xcode 的 Archive 菜单进行打包,然后再使用 Validate App 功能来签名
  • 方法二: 使用xcodebuild archive命令来生成 .xcarchive 文件,然后调用xcodebuild -exportArchive命令来生成 IPA 文件

我们来看自动化方案:

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。因为签名所需的证书信息保存在 Keychain 里面,所以我们需要解锁 Keychain 来让 fastlane 进行访问。
  • 第二步是更新签名信息。我们使用“iPhone Distribution”作为签名主体,并使用“match AdHoc com.ibanimatable.moments.internal”作为 Provisioning Profile,这表示我们使用了 Ad Hoc 的 Provisioning Profile 来分发该 App。
  • 第三步是核心操作,调用gymAction 来进行打包和签名。gym帮我们封装了xcodebuild的实现细节,我们只需要调用一个 Action 就能完成打包和签名的操作。这里需要注意,为了生成用于测试的 Internal App,我们需要把export_method参数赋值为ad-hoc,这样我们就能实现内部分发。
  • 第四步是恢复回自动签名。因为在开发环境中,我们使用的是自动签名。为了方便本地开发,在完成打包后,我们得把签名方式进行重置。

下面再看一下如何为 App Store 版本的 App 进行打包和签名。

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的实现基本上与archive_internal一致。不同的地方是在archive_appstore里面,我们指定的 Provisioning Profile 是 “match AppStore com.ibanimatable.moments”,而且在调用gymAction 时传递了app-store给export_method参数,表示要生成上传到 App Store 的 App。

有了archive_internal和archive_appstore以后,再结合上一讲介绍的download_profiles,我们就可以十分方便地自动化打包和签名 App 了。命令执行完毕以后,在项目文件夹里面会出现一个 Moments.ipa 文件。IPA 文件也叫作 iOS App Store Package,该文件是一个包含了 iOS App 的存档(archive)文件。 为了查看 IPA 文件里面的内容,我们可以把后缀名修改成 .zip 文件并进行解压,其内容如下图所示:


image.png

在图中有一个名为 embedded.mobileprovision 的 Provisioning Profile 文件,你可以打开该文件来查看相关内容,如下图所示:


image.png

在该 Provisioning Profile 中,你可以看到用于定义访问系统资源权限的 Entitlement 信息、证书信息以及用于安装的设备列表信息。有了这些信息,iOS 系统就能对 App 进行权限管理。

上传到发布渠道

演示一个发布到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

可见, 这个action是默认存在的, lane也没封装什么, 就是包装了一下参数, 这些参数都来自于firebase, 有需要的读一下相关文档, 有必要的话也可以把相关的key存local.keys里面读到环境变量里去

发布到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

上传到文件存储, 还是app sotore的标的, 就是上一节生成的ipa文件, 这里不需要把ipa文件路径传参进去, 说明是在同目录里搜索按打包规则生成的名字, 不能改也不能自定义的

持续集成

前面我们把一系列任何封装成了一个个命令, 差最后一步, 来调度这些命令的人, 通过CI(Continuous Integration), 你可以自行采购硬件搭建这样一个平台, 也可以使用虚拟机, 最简单的起步, 是使用云服务, 这一节但要Travis CI. (Jekins教程就不在此了)

  • Travis CI 使用了“代码即配置”的方式来配置 CI 管道,这是最重要的一个原因。我们可以把 CI 管道的配置信息都写在一个 YAML 文件里面,并保存在 GitHub 上。这样能方便我们把 CI 配置共享到多个项目,而且通过 Git 历史记录来不断对比和优化 CI 配置。除此之外,YAML 文件的配置方式已成为 CI 配置的标准,当需要升级为云端虚拟机 CI 和全手工维护 CI 时,我们可以重用 Travis CI 的 YAML 文件。相比之下,有些 CI 需要在网页上进行手工配置,而且无法看到修改历史,这使得我们无法通过代码把配置信息共享到其他项目中去。
  • Travis CI 免费给开源项目使用。
  • Travis CI 整合了 GitHub 和 GitLab 等代码管理平台,只需要一次授权就能整合 CI 服务。
  • Travis CI 支持多个不同版本的 Mac OS 和 Xcode,我们可以根据项目的要求来灵活选择不同的版本。例如通过 Travis CI,我们可以方便地测试 Xcode Beta 版的构建情况。
  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 系统所提供,它能帮助我们生成一个自增的 Build Number, 这个值是怎么写到app里去的呢? 当然是修改.xcconfig文件:

会在 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等用于写环境变量的, 也得部署到这个云服务里去, 其它stage示例:

# 单元测试
- stage: "Test"
  name: "Test app"
  script:
    - set -o pipefail
    - bundle exec fastlane tests

# 发测试包
- 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

# 发布苹果市场
- 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

统计分析

崩溃报告

感兴趣的话了解下, 这是使用firebase的Crashlytics

自动化上传dSYM文件

当 Xcode 在把源代码编译成机器码的时候,编译器会生成一堆 Symbol(符号)来存放类型的名字、全局变量和方法的名称等,这些 Symbol 会把机器码对应到各种类型所在的文件和行号。因此,我们可以利用这些 Symbol 在 Xcode 里面进行 Debug,或者在崩溃报告上定位 Bug。默认情况下,当我们生成一个 Debug 版本的 App 时,所有的 Debug Symbol 都会自动存放在 App 里面。
但是 Release 版本的 App 却不一样,为了减小 App 的尺寸,编译器并不把 Debug Symbol 存放在 App 里面,而是生成一些额外的 dSYM 文件(Debug Symbol file)来存放。每个可执行文件、Framework 以及 Extension 都通过唯一的 UUID 来配对相应的 dSYM 文件。为了便于定位线上 App 的问题,我们需要保存这些 dSYM 文件,并上传到崩溃报告服务上去。
幸运的是,fastlane 提供了一个upload_symbols_to_crashlyticsAction 来帮我们简化上传 dSYM 文件的操作。上传 Internal App dSYM 文件的具体实现如下:

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

在调用 upload_symbols_to_crashlyticsAction 时,我们需要传递三个参数:

  • 首先把 dSYM 文件的路径传递给dsym_path参数,
  • 然后把 Firebase 的配置文件传递给gsp_path参数,
  • 最后是把 Firebase API Token 传递给api_token参数

接下来我们再一起看看上传 AppStore 版本 dSYM 文件的具体实现:

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 的配置来自动完成上传 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

可以看到,我们在script下增加了 upload_symbols_to_crashlytics_internal 步骤, 而且是先上传再deploy。

查看崩溃报告

性能报告

远程开关

通过远程开关,我们就可以在无须发布新版本的情况下开关 App 的某些功能,甚至可以为不同的用户群体提供不同的功能。 远程功能开关能帮助我们快速测试新功能,从而保证产品的快速迭代。该模块主要由两部分所组成:Remote Config 模块和Toggle 模块。

Remote Config 也叫作“远程配置”,它可以帮助我们把 App 所需的配置信息存储在服务端,让所有的 App 在启动的时候读取相关的配置信息,并根据这些配置信息来调整 App 的行为。 Remote Config 应用广泛,可用于远程功能开关、 A/B 测试和强制更新等功能上。一些基础功能演示:

protocol RemoteConfigProvider {
    func setup()
    func fetch()
    func getString(by key: RemoteConfigKey) -> String?
    func getInt(by key: RemoteConfigKey) -> Int?
    func getBool(by key: RemoteConfigKey) -> Bool
}

具体实现是本地存储和远端拉取的结合, 这一节太个性化, 也略过了. 教程中仍是以firebase为例来讲解的, 因为省去了开发页面的过程, firebase原生就支持(其实就等于firebase也提供了几个restful服务供你调用而已, 值都是你自己在后台配的, 自行去使用)

在使用的地方, 使用本地存储里的值来进行判断即可. 所以拉取远程配置一般是在app启动, 或当前页面加载的时机(如果是当前页面加载, 那么所有的UI更新都得在这个拉取之后, 最好别这么干)

AB测试

原理跟远程开关一致, 多了个分组. 需要一个平台能定义分组和关键值, app里去应用这些值来写针对的代码, 同样firebase也是支持的. 定向推送, 随机推送等机制, 也就一起提供给你了, 可见如果愿意使用firebase的话, 几乎是一站式的. 当然如果是公司开发, 这种事由后端同学做个平台也不是不可以.

App Icon制作

使用SwiftUI替换UIKit

image.png

如果严格按ViewModel数据意向流动的方式编写的代码(如本教程), 这一步重写一个视图层就行了. 关于SwiftUI, 可以跳转这篇专题文章[[languages.ios.swift]]

SwiftUI 的状态管理

iOS17开始已经大大简化了SwiftUI的状态管理, 使用@Observation, 参考文章

struct ContentView: View {
    @State private var age = 20
    var body: some View {
        Button("生日啦,现在几岁: \(age)") {
            age += 1
        }
    }
}

上述例子实现了本视图里的状态联动, 那么如何跨页面共享呢? 那就需要使用到 @StateObject@ObservedObject 属性包装器了。这两个属性包装器所定义的属性都必须遵循ObservableObject协议。

class UserObservableObject: ObservableObject {
    var name = "Jake"
    var age = 20 {
        willSet {
            objectWillChange.send()
        }
    }
}

你们也看到了send方法, 但是写法很繁, 每一个能监听的属性都要在 willSet 方法里send的话那将是一个灾难. 所以有了简化版本

class UserObservableObject: ObservableObject {
    var name = "Jake"
    @Published var age = 20
}

同时要注意, 所有遵循 ObservableObject 协议的子类型都必须是引用类型,所以我们只能使用类而不是结构体(Struct). 介绍完ObservableObject协议以后,我们就可以通过下面的例子看看如何使用 @StateObject 和 @ObservedObject 属性包装器了。

struct ChildView: View {
    @ObservedObject var user: UserObservableObject
    var body: some View {
        Button("生日啦,现在几岁: \(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 都可以定义用于状态共享的属性,而且这些属性的类型都必须遵循ObservableObject协议。不同的地方是 @StateObject 用于生成和管理状态属性的生命周期,而 @ObservedObject 只能把共享状态从外部传递进来。例如,在上面的示例代码中,我们在ParentView里使用 @StateObject 来定义并初始化user属性,然后传递给ChildView的user属性。由于ChildView的user属性来自外部的ParentView,因此定义为 @ObservedObject。

当我们需要共享状态的时候,

  • 通常在父对象里定义和初始化一个 @StateObject 属性,
  • 然后传递给子对象里的 @ObservedObject 属性。

如果只有两层关系还是很方便的,但假如有好几层的父子关系,逐层传递会变得非常麻烦,那有没有好办法解决这个问题呢?
@EnvironmentObject 就是用于解决这个问题的。@EnvironmentObject 能帮我们把状态共享到整个 App 里面,下面还是通过一个例子来看看。

@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("生日啦,现在几岁: \(user.age)") {
            user.age += 1
        }
    }
}
struct ParentView: View {
    var body: some View {
        VStack {
            ChildView()
        }
    }
}

@EnvironmentObject 有点像 Singleton,我们不能过度使用它,否则会增加模块间的耦合度。@ObservedObject 与 @EnvironmentObject 都能帮助我们共享引用类型的属性,但如何共享值类型的属性呢?

@Binding 属性包装器就能帮我们定义共享值类型的属性。

struct ChildView: View {
   @Binding var isPresented: Bool
   var body: some View {
       Button("关闭") {
           isPresented = false
       }
   }
}
struct ParentView: View {
   @State private var showingChildView = false
   var body: some View {
       VStack {
           Text("父 View")
       }.sheet(isPresented: $showingChildView) {
           ChildView(isPresented: $showingChildView)
       }
   }
}

可见, 在定义的类里使用, 和往下传, 是加了$符号的, 这使得showingChildView能被回写

SwiftUI 的架构与实现

桥接 RxSwift 与 SwiftUI

为了把这些 ViewModel 类型桥接到 SwiftUI 版本的 View 模块,我们增加了两个类型:MomentsListObservableObject和IdentifiableListItemViewModel。MomentsListObservableObject负责给 SwiftUI 组件发送更新消息,下面是它的具体实现:

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)
    }
}

写到这里其实够了, 原来的那个viewmodel是不能直接用的, 还得再包一层. 剩下的是把所有的viewmodel都包一次, 以及用SwiftUI来重写视图, 就不再说了.

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 213,928评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,192评论 3 387
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 159,468评论 0 349
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,186评论 1 286
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,295评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,374评论 1 292
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,403评论 3 412
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,186评论 0 269
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,610评论 1 306
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,906评论 2 328
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,075评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,755评论 4 337
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,393评论 3 320
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,079评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,313评论 1 267
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,934评论 2 365
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,963评论 2 351

推荐阅读更多精彩内容

  • 原创:知识点总结性文章创作不易,请珍惜,之后会持续更新,不断完善个人比较喜欢做笔记和写总结,毕竟好记性不如烂笔头哈...
    时光啊混蛋_97boy阅读 769评论 0 2
  • 这也是我不想用简书的原因, 能发几篇, 能发多长, 能发什么内容全控制不了, 还是本地大法好。 本文是一个系列课程...
    walkerwzy阅读 106评论 0 0
  • 本文由 戴仓薯(也就是我!) 翻译,dopcn 校稿。首发于伯乐在线 原文链接:https://github.co...
    戴仓薯阅读 5,139评论 1 73
  • @[TOC](IOS 逆向开发(三)应用签名) 1. 数字签名 什么是数字签名? 数字签名(digitally s...
    孔雨露阅读 458评论 0 5
  • iOS最佳实践 译者注 本文翻译自 futurice 公司的 iOS Good Practices,译文在 Git...
    linxiangyu阅读 12,973评论 10 157