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

這也是我不想用簡書的原因, 能發(fā)幾篇, 能發(fā)多長, 能發(fā)什么內(nèi)容全控制不了, 還是本地大法好孙援。 本文是一個系列課程的學(xué)習(xí)筆記, 全文23646字, 我試了下度液, 至少得切成兩篇來發(fā)布.

Ruby工具鏈

目前流行的第三方工具 CocoaPodsfastlane 都是使用 Ruby 來開發(fā)的和措。特別是 Ruby 有非常成熟的依賴庫管理工具 RubyGemsBundler,其中 Bundler 可以幫我們有效地管理 CocoaPodsfastlane 的版本悠夯。

\tt Ruby \begin{cases} \small 工具 \begin{cases} \tt CodoaPods \\[2ex] \tt fastlane \end{cases} \\ \small 版本管理 \begin{cases} \tt RVM \\[2ex] \tt rbenv \end{cases} \\ \small 包管理 \begin{cases} \tt Bundler \\[2ex] \tt RubyGems \end{cases} \\ \end{cases}

  • 推薦使用 rbenv是晨,因?yàn)樗褂?shims 文件夾來分離各個 Ruby 版本,相對于 RVM 更加輕裝而方便使用健蕊。
  • 配置
# PATH
export PATH="$HOME/.rbenv/bin:$PATH" 
eval "$(rbenv init -)"

# install version
$ cd $(PROJECT_DIR)  # 這意思是每個項(xiàng)目裝一個菱阵?
$ rbenv install 2.7.1
$ rbenv local 2.7.1
  • 以上生成一個.ruby-version文件,里面只有一個版本號缩功。

RubyGems 和 Bundler

  • 在 Ruby 的世界晴及,包叫作 Gem,我們可以通過gem install命令來安裝嫡锌。
  • 但是 RubyGems 在管理 Gem 版本的時候有些缺陷虑稼,就有人開發(fā)了 Bundler
  • Bundler本身也是一個包琳钉,所以要通過gem install bundler來安裝
  • bundler init來初始化
  • 內(nèi)容是你在CocoaPods里面熟悉的語法(即ruby)
source "https://rubygems.org"
gem "cocoapods", "1.10.0"
gem "fastlane", "2.166.0"

整合起來:

# Install ruby using rbenv
ruby_version=`cat .ruby-version`
if [[ ! -d "$HOME/.rbenv/versions/$ruby_version" ]]; then
  rbenv install $ruby_version;
fi
# Install bunlder
gem install bundler
# Install all gems
bundle install
# Install all pods
bundle exec pod install
  1. 安裝正確的 ruby 版本
  2. 用 ruby gem 安裝 bundle
  3. 用 bundle 安裝 cocoapods
  4. 用 cocoapods 安裝項(xiàng)目依賴

CocoaPods

source 'https://cdn.cocoapods.org/' # 公共庫
source 'https://my-git-server.com/internal-podspecs' # 私有庫

project './Moments/Moments.xcodeproj' # 項(xiàng)目文件
workspace './Moments.xcworkspace'  # 針對項(xiàng)目文件生成工作空間,包含了下載回來的依賴蛛倦,還會生成一個 Pods.xcodeproj 項(xiàng)目歌懒,都是平級

platform :ios, '14.0' # 最低支持iOS版本
use_frameworks! # 把依賴庫打包成靜態(tài)庫還是動態(tài)庫

# 組織依賴的一種方案
# 比如根據(jù)是否是開發(fā)中才需要使用的
# 還有根據(jù)用戶名來組織的,這樣每個開發(fā)維護(hù)自己的依賴溯壶,在開發(fā)階段不會沖突(在依賴很多的時候及皂,每個開發(fā)只開發(fā)其中部分模板,其它模板會需要設(shè)成無端獲取且改,這樣每個人的需求是不一樣的
def dev_pods
  pod 'SwiftLint', '0.40.3', configurations: ['Debug']
  pod 'SwiftGen', '6.4.0', configurations: ['Debug'] # 只在debug的構(gòu)建下使用
end

# 最終是在target里面組裝的
target 'Moments' do
  dev_pods
  core_pods
  # other pods...
end

# 使用本地路徑
pod 'DesignKit', :path => './Frameworks/DesignKit', :inhibit_warnings => false

版本比較

  • = 只安裝這個版本
  • > 0.1表示大于 0.1 的任何版本验烧,這樣可以包含 0.2 或者 1.0;
  • >= 0.1表示大于或等于 0.1 的任何版本又跛;
  • < 0.1表示少于 0.1 的任何版本碍拆;
  • <= 0.1表示少于或等于 0.1 的任何版本;
  • ~> 0.1.2表示大于 0.1.2 而且最高支持 0.1.* 的版本效扫,但不包含 0.2 版本倔监。

這幾個操作符相里面,~>(Squiggy arrow)操作符更為常用菌仁,既保持了小更新浩习,也沒有跨大版本,避免了api的變更

Podfile.lock文件

DEPENDENCIES:
  - Alamofire (= 5.2.0)
  - Firebase/Analytics (= 7.0.0)
PODFILE CHECKSUM: 400d19dbc4f5050f438797c5c6459ca0ef74a777
  • 所有依賴庫的版本號都參與了PODFILE CHECKSUM的計(jì)算
  • 所以如果要嚴(yán)格保證每一個小版本都團(tuán)隊(duì)內(nèi)一致的話济丘,這個文件可以提交到git
    • 這樣只要文件一沖突谱秽,必然知道要么是依賴數(shù)量對不上,要么是版本對不上

Workspace

  • 通過 Workspace摹迷,我們可以把相關(guān)聯(lián)的多個 Xcode 子項(xiàng)目組合起來方便開發(fā)疟赊。
    • 原生項(xiàng)目,加上Cocoapods生成的Pods.xcodeproj 峡碉,至少就有兩個項(xiàng)目了近哟,所以需要一個Workspace來管理
  • CocoaPods 還會修改 Xcode 項(xiàng)目中的 Build Phases 以此來檢測 Podfile.lock 和 Manifest.lock 文件的一致性,并把Pods_<項(xiàng)目名稱>.framework動態(tài)庫嵌入我們的主項(xiàng)目中去鲫寄。

Pod 版本更新

  • CocoaPods 已經(jīng)為我們提供了pod outdated命令吉执,我們可以用它一次查看所有 Pod 的最新版本,而無須到 GitHub 上逐一尋找地来。
  • 千萬不要使用pod update戳玫,因?yàn)閜od update會自動把開發(fā)者機(jī)器上所有 Pod 的版本自動更新了。

作者推薦的是每用pod outdated發(fā)現(xiàn)一個更新未斑,都要閱讀更新文檔咕宿,確定有沒有需要改代碼的地方,以及所有用到相關(guān)api的地方復(fù)測一遍,然后再把本地修改并確認(rèn)好的podfile文件和lock文件上傳到遠(yuǎn)端府阀,供團(tuán)隊(duì)其他成員同步缆镣。(所以他認(rèn)為不加驗(yàn)證地直接update是危險(xiǎn)的)

多環(huán)境支持

Xcode 構(gòu)建基礎(chǔ)概念

  • 一般在構(gòu)建一個 iOS App 的時候,需要用到 Xcode Project肌似,Xcode Target费就,Build Settings,Build Configuration 和 Xcode Scheme 等構(gòu)建配置川队。
  • Xcode Project用于組織源代碼文件和資源文件。
    • 一個 Project 可以包含多個 Target
    • 例如當(dāng)我們新建一個 Xcode Project 的時候睬澡,它會自動生成 App 的主 Target固额,Unit Test Target 和 UI Test Target。
  • Xcode Target用來定義如何構(gòu)建出一個產(chǎn)品(例如 App煞聪, Extension 或者 Framework
    • Target 可以指定需要編譯的源代碼文件和需要打包的資源文件斗躏,以及構(gòu)建過程中的步驟。
    • 那么 Target 所指定的設(shè)置哪里來的呢昔脯?來自 Build Settings啄糙。
  • Build Setting保存了構(gòu)建過程中需要用到的信息,它以一個個變量(鍵值對)的形式而存在云稚,例如所支持的設(shè)備平臺隧饼,或者支持操作系統(tǒng)的最低版本等。

推薦使用 Build ConfigurationXcode Scheme 來管理多環(huán)境静陈,進(jìn)而構(gòu)建出不同環(huán)境版本的 App燕雁。

Build Configuration

image.png
  • Build Configuration就是一組 Build Setting。 我們可以通過 Build Configuration 來分組和管理不同組合的 Build Setting 集合鲸拥,然后傳遞給 Xcode 構(gòu)建系統(tǒng)進(jìn)行編譯拐格。
  • 當(dāng)我們在 Xcode 上新建一個項(xiàng)目的時候,Xcode 會自動生成兩個 Configuration:Debug和Release刑赶。
  • 怎樣在構(gòu)建過程中選擇不同的configuration呢捏浊?Xcode Scheme
  • Xcode Scheme用于定義一個完整的構(gòu)建過程,其包括指定哪些 Target 需要進(jìn)行構(gòu)建撞叨,構(gòu)建過程中使用了哪個 Build Configuration 金踪,以及需要執(zhí)行哪些測試案例等等。
  • 在項(xiàng)目新建的時候只有一個 Scheme谒所,但可以為同一個項(xiàng)目建立多個 Scheme热康。
  • 為了方便管理,我們通常的做法是劣领,一個 Scheme 對應(yīng)一個 Configuration姐军。有了這三個 Scheme 以后,我們就可以很方便地構(gòu)建出 Moments α(開發(fā)環(huán)境),Moments β(測試環(huán)境)和 Moments(生產(chǎn)環(huán)境)三個功能差異的 App奕锌。


    image.png

    image.png
  • 要實(shí)現(xiàn)上面三個環(huán)境打包出不同的名字著觉,從這個路徑,就可以為每個configuration設(shè)置不同的product name
  • 這里也同樣可以知道如果搜索環(huán)境變量惊暴,如"$(PRODUCT_NAME)"
  • 但其實(shí)顯示名用的是info.plist文件里的bundle display name字段(默認(rèn)與bundle name指向的都是product name),這個字段目前沒看到可以為不同的configuration而設(shè)置

以上為不同環(huán)境打不同包的做法辽话,雖然直觀肄鸽,但是不適用在開發(fā)/測試階段可以切換環(huán)境的方案,所以要自己團(tuán)隊(duì)溝通選擇什么路線油啤。

xcconfig配置文件

一般修改 Build Setting 的辦法是在 Xcode 的 Build Settings 界面上進(jìn)行典徘,這樣做有一些不好的地方

  • 首先是手工修改很容易出錯,例如有時候很難看出來修改的 Setting 到底是 Project 級別的還是 Target 級別的益咬。
    其次逮诲,最關(guān)鍵的是每次修改完畢以后都會修改了 xcodeproj 項(xiàng)目文檔 (如下圖所示),導(dǎo)致 Git 歷史很難查看和對比幽告。


    image.png
  • Xcode 為我們提供了一個統(tǒng)一管理這些 Build Setting 的便利方法梅鹦,那就是使用 xcconfig 配置文件來管理。
  • xcconfig也叫作 Build configuration file(構(gòu)建配置文件)冗锁,我們可以使用它來為 Project 或 Target 定義一組 Build Setting齐唆。
  • 它是一個純文本文件,我們可以使用 Xcode 以外的其他文本編輯器來修改蒿讥,而且可以保存到 Git 進(jìn)行統(tǒng)一管理蝶念。
  • 格式就是鍵值對

當(dāng)我們使用 xcconfig 時,Xcode 構(gòu)建系統(tǒng)會按照下面的優(yōu)先級來計(jì)算出 Build Setting 的最后生效值:

  • Platform Defaults (平臺默認(rèn)值)

  • Xcode Project xcconfig File(Project 級別的 xcconfig 文件)

  • Xcode Project File Build Settings(Project 級別的手工配置的 Build Setting)

  • Target xcconfig File (Target 級別的 xcconfig 文件)

  • Target Build Settings(Target 級別的手工配置的 Build Setting)

  • 所以為了避免互相沖突(覆蓋)芋绸,在添加值時參考這種用法

FRAMEWORK_SEARCH_PATHS = $(inherited) ./Moments/Pods #或 $(PROJECT_DIR) 這是引入別的Build Setting
  • 重用媒殉,引用其它xcconfig文件
#include "path/to/OtherFile.xcconfig"

一個應(yīng)用實(shí)踐(先不說是否過度設(shè)計(jì)):


image.png

我們把所有 xcconfig 文件分成三大類:Shared、 Project 和 Targets摔敛。

其中 Shared 文件夾用于保存分享到整個 App 的 Build Setting廷蓉,例如 Swift 的版本號、App 所支持的 iOS 版本號等各種共享的基礎(chǔ)信息马昙。

下面是 SDKAndDeviceSupport.xcconfig 文件里面所包含的信息:

TARGETED_DEVICE_FAMILY = 1
IPHONEOS_DEPLOYMENT_TARGET = 14.0
  • TARGETED_DEVICE_FAMILY表示支持的設(shè)備桃犬,1表示 iPhone。
  • IPHONEOS_DEPLOYMENT_TARGET表示支持 iOS 的最低版本行楞。

Project 文件夾用于保存 Xcode Project 級別的 Build Setting攒暇,其中 BaseProject.xcconfig 會引入 Shared 文件夾下所有的 xcconfig 配置文件,如下所示:

#include "CompilerAndLanguage.xcconfig"
#include "SDKAndDeviceSupport.xcconfig"
#include "BaseConfigurations.xcconfig"

然后我們會根據(jù)三個不同的環(huán)境分別建了三個xcconfig 配置文件子房,如下:

DebugProject.xcconfig 文件

#include "BaseProject.xcconfig"
SWIFT_ACTIVE_COMPILATION_CONDITIONS = $(inherited) DEBUG

InternalProject.xcconfig 文件

#include "BaseProject.xcconfig"
SWIFT_ACTIVE_COMPILATION_CONDITIONS = $(inherited) INTERNAL

AppStoreProject.xcconfig 文件

#include "BaseProject.xcconfig"
SWIFT_ACTIVE_COMPILATION_CONDITIONS = $(inherited) PRODUCTION
  • 它們的共同點(diǎn)是都引入了用于共享的 BaseProject.xcconfig 文件形用,然后分別定義了 Swift 編譯條件配置SWIFT_ACTIVE_COMPILATION_CONDITIONS就轧。
  • 其中(inherited)表示繼承原有的配置,(inherited)后面的DEBUG或者INTERNAL表示在原有配置的基礎(chǔ)上后面添加了一個新條件田度。

有了這些編譯條件妒御,我們就可以在代碼中這樣使用:

#if DEBUG
    print("Debug Environment")
#endif

該段代碼只在開發(fā)環(huán)境執(zhí)行,因?yàn)橹挥虚_發(fā)環(huán)境的SWIFT_ACTIVE_COMPILATION_CONDITIONS才有DEBUG的定義镇饺。

Targets 文件夾用于保存 Xcode Target 級別的 Build Setting乎莉,也是由一個 BaseTarget.xcconfig 文件來共享所有 Target 都需要使用的信息。

PRODUCT_BUNDLE_NAME = Moments

這里的PRODUCT_BUNDLE_NAME是 App 的名字奸笤。
下面是三個不同環(huán)境的 Target xcconfig 文件惋啃。

DebugTarget.xcconfig

#include "../Pods/Target Support Files/Pods-Moments/Pods-Moments.debug.xcconfig"
#include "BaseTarget.xcconfig"
PRODUCT_BUNDLE_NAME = $(inherited) α
PRODUCT_BUNDLE_IDENTIFIER = com.ibanimatable.moments.development

InternalTarget.xcconfig

#include "../Pods/Target Support Files/Pods-Moments/Pods-Moments.internal.xcconfig"
#include "BaseTarget.xcconfig"
PRODUCT_BUNDLE_NAME = $(inherited) β
PRODUCT_BUNDLE_IDENTIFIER = com.ibanimatable.moments.internal

AppStoreTarget.xcconfig

#include "../Pods/Target Support Files/Pods-Moments/Pods-Moments.appstore.xcconfig"
#include "BaseTarget.xcconfig"
PRODUCT_BUNDLE_NAME = $(inherited)
PRODUCT_BUNDLE_IDENTIFIER = com.ibanimatable.moments
  • 它們都需要引入 CocoaPods 所生成的 xcconfig 和共享的 BaseTarget.xcconfig 文件,
  • 然后根據(jù)需要改寫 App 的名字监右。
  • 順便還為每個configuration設(shè)置了獨(dú)立的bundleid
    一旦有了這些 xcconfig 配置文件肥橙,今后我們就可以在 Xcode 的 Project Info 頁面里的 Configurations 上引用它們。


    image.png

可以在 build Settings 頁面來查看具體的生效值(all + level), 5列就對應(yīng)的5個優(yōu)先級(越往左越高秸侣,所以resolved就代表解析值的意思)


image.png

總結(jié)下


image.png

最后

  1. 如果選擇用xcconfig來文本化配置,那么千萬不要再在UI上來修改(會覆蓋)
  2. 圖標(biāo)宠互,URLScheme也可以通過 Build Configuration 來做味榛。
  • 首先要在 Asset Catalog 加上新的 Icon set,例如命名為 AppIcon.beta (可以準(zhǔn)備不同的圖標(biāo))
  • 然后在不同的 xcconfig 里面配置 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon.beta 等值予跌。

SwiftLint

安裝 SwiftLint 的方式有很多種搏色,例如使用 Homebrew,Mint券册,下載 SwiftLint.pkg 安裝包等等频轿。但我只推薦 CocoaPods 這一種方法,因?yàn)橥ㄟ^ CocoaPods 可以有效地管理 SwiftLint 的版本烁焙,從而保證團(tuán)隊(duì)內(nèi)各個成員都能使用一模一樣的 SwiftLint 及其編碼規(guī)范航邢。在Podfile文件里添加如下

pod 'SwiftLint', '= 0.41.0', configurations: ['Debug']
image.png
  • .swiftlint.yml主要用于啟動和關(guān)閉 SwiftLint 所提供的規(guī)則,以及自定義配置與規(guī)則骄蝇。
  • SwiftLint 提供了disabled_rules,opt_in_rules和only_rules三種規(guī)則設(shè)置方法膳殷。
    • disabled_rules能幫我們關(guān)閉默認(rèn)生效的規(guī)則,
    • opt_in_rules可以啟動默認(rèn)關(guān)閉的規(guī)則九火。
    • 但我不推薦你使用它們 赚窃,而是用only_rules來定義每條生效的規(guī)則。(這樣不怕不同版本的默認(rèn)規(guī)則有區(qū)別)

demo:

only_rules:
  - array_init
  - attributes
  - block_based_kvo
  - class_delegate_protocol
  - closing_brace

自定義配置:

line_length: 110
file_length:
  warning: 500
  error: 1200

自定義規(guī)則:

custom_rules:
  no_hardcoded_strings:
    regex: "([A-Za-z]+)"
    match_kinds: string
    message: "Please do not hardcode strings and add them to the appropriate `Localizable.strings` file; a build script compiles all strings into strongly typed resources available through `Generated/Strings.swift`, e.g. `L10n.accessCamera"
    severity: warning

該規(guī)則no_hardcoded_strings會通過正則表達(dá)式來檢查字符串是否進(jìn)行了硬編碼岔激。

排除掃描路徑:

excluded:
  - Pods

Fastlane

image.png

Git

  • 建立一個模板文件pull_request_template.md勒极。當(dāng)我們提交 PR 的時候,GitHub 會自動讀取并準(zhǔn)備好描述文檔的模板虑鼎,我們只需要填寫相關(guān)內(nèi)容即可辱匿。
## Summary

- Github issue/doc: _link_
- Card: _link_

_A clear and concise description of this PR. e.g. Adding Link button to moments screen_

## Details
### Description
_Long description of this PR._
  - _Why are we doing this?_
  - _Any other related context_

### Screengrabs (if applicable)

_Optional but highly recommended._

| Before | After |
| - | - |
| _before_ | _after_ |

## Quality Analysis

- [ ] Unit tests that cover all added and changed code
- [ ] Tested on Simulator
- [ ] Tested on iPhone
- [ ] Tested on iPad (if applicable)

**Testing steps:**

0. _Step 1_
0. _Step 2_
0. _..._

## Checklist

* [ ] Has feature toggling been considered?
* [ ] Has tested both dark mode and light mode if there is any UI change?
* [ ] Has tested Dynamic Type if there is any UI change?
* [ ] Has tested language support for multiple locales if there is any UI change?
* [ ] Have new test cases been unit tested? 
* [ ] Have run `bundle exec fastlane prepare_pr`?
* [ ] Need to labelled the PR? (If applicable: e.g. added new dependencies etc.)
image.png

統(tǒng)一的設(shè)計(jì)規(guī)范

間距

  • 只保留幾個幾個命名間距键痛,如下面是開源設(shè)計(jì)規(guī)范 Backpack 所定義的間距,其包含了 iOS掀鹅、 Android 和 Web 三個平臺散休。


    image.png
// in DesignKit
public struct Spacing {
    public static let twoExtraSmall: CGFloat = 4
    public static let extraSmall: CGFloat = 8
    public static let small: CGFloat = 12
    public static let medium: CGFloat = 18
    public static let large: CGFloat = 24
    public static let extraLarge: CGFloat = 32
    public static let twoExtraLarge: CGFloat = 40
    public static let threeExtraLarge: CGFloat = 48
}

// use
import DesignKit

private let likesStakeView: UIStackView = configure(.init()) {
    $0.spacing = Spacing.twoExtraSmall
    $0.directionalLayoutMargins = NSDirectionalEdgeInsets(
      top: Spacing.twoExtraSmall, 
      leading: Spacing.twoExtraSmall, 
      bottom: Spacing.twoExtraSmall, 
      trailing: Spacing.twoExtraSmall)
  }

字體

  • iOS 的 App 一般都使用 iOS 系統(tǒng)所自帶的字體系列。這樣更能符合用戶的閱讀習(xí)慣乐尊。在自帶的字體系列的基礎(chǔ)上戚丸,通過把字號大小和字體粗細(xì)組合起來定義一些字體類型。

顏色

  • 為了給用戶提供顏色一致的體驗(yàn)扔嵌,在 App 設(shè)計(jì)中限府,我們一般采用統(tǒng)一的調(diào)色板(Color palette)來完成。
  • 如果你所在團(tuán)隊(duì)沒有專門的設(shè)計(jì)師來定義這些顏色痢缎,也可以使用 iOS 提供的動態(tài)系統(tǒng)顏色(Dynamic System Colors)胁勺,它為我們定義了同時支持淺色和深色模式的各種顏色。
  • 在定義語義化顏色時要特別注意顏色之間的對比度独旷,例如使用了Text Primary Color的文本在使用Background Color的背景下能容易閱讀署穗,而使用灰色的背景再使用黑色的文本會難以閱讀。

圖標(biāo)

  • 如果沒有特殊要求嵌洼,我推薦直接使用蘋果公司提供的案疲。具體來說,在 iOS 系統(tǒng)內(nèi)置的 SF Symbols 為我們提供了 3150 個一致的麻养、可定制的圖標(biāo)
  • SF Symbols 里絕大部分的圖標(biāo)都有輪廓和填充兩個版本褐啡,我們可以使用填充的圖標(biāo)表示選中狀態(tài)。

常用組件

  • 一些會重復(fù)出現(xiàn)的UI組件鳖昌,可以納入設(shè)計(jì)規(guī)范备畦,避免場景不同了設(shè)計(jì)也變了。
  • 但這是個使用中逐漸發(fā)現(xiàn)并歸納的過程许昨,也要避免一開始就貪多直接定義一堆不用的組件

所以設(shè)計(jì)規(guī)范需要開發(fā)團(tuán)隊(duì)與設(shè)計(jì)師一起參進(jìn)進(jìn)來懂盐,提前約定。

組件庫實(shí)例

  • 在實(shí)際操作中车要,我們一般先創(chuàng)建內(nèi)部庫允粤,如果今后有必要,可以再升級為私有庫乃至開源庫翼岁。
  • 我們通過 CocoaPods 創(chuàng)建和管理這個內(nèi)部庫类垫,有兩種方法(命令/手動)
  1. pod lib create [pod name], 如DesignKit
    會生成如下文件:
DesignKit
  Example/   
  README.md
  DesignKit.podspec
  LICENSE           
  _Pods.xcodeproj
  • 會自動包含一個工程和一個示例項(xiàng)目琅坡,
  • podspec文件關(guān)注幾個設(shè)置悉患,主要是版本和路徑相關(guān)
    image.png
  1. 手動創(chuàng)建文件
DesignKit        
  DesignKit.podspec
  assets/
  src/
  LICENSE        
  • pod spec lint命令檢測spec文件正確性
  • 使用路徑指定內(nèi)部庫
pod 'DesignKit', :path => './Frameworks/DesignKit', :inhibit_warnings => false
  • pod install后:
    image.png

功能開關(guān)組件

  • 如果一個大功能,直接做一個分支管理開發(fā)過程既漫長榆俺,也無法細(xì)粒度地維護(hù)售躁,可以把它拆成很多小功能坞淮,依次提交
  • 但是會有一個問題,幾個小功能提交后碰到一次發(fā)布的話陪捷,會把主體未開發(fā)完的小功能也發(fā)布了上去回窘,因此可以考慮對功能進(jìn)行“開關(guān)”
    • 先做個開關(guān),直到開發(fā)完畢再移除它市袖,或者在所有代碼合并完畢后啡直,從主分支再拉一個remove-toggle這樣的分支,專門用來移除它苍碟。
    • 當(dāng)然酒觅,把小功能往一個主功能分支提也是個思路,非本節(jié)內(nèi)容

\begin{cases} 編譯時開關(guān) \\ 本地開關(guān) \\ 遠(yuǎn)程開關(guān) \end{cases}

實(shí)現(xiàn)思路:


image.png
  • 做一個開關(guān)協(xié)議微峰,最終實(shí)現(xiàn)三種開關(guān)舷丹,每種開關(guān)根據(jù)自己的特征開放接口
  • 做一個開關(guān)的行為類,主要實(shí)現(xiàn)判斷和更新
    • 比如使用本地編譯標(biāo)識的話蜓肆,"更新"方法就不需要實(shí)現(xiàn)了
struct BuildTargetTogglesDataStore: TogglesDataStoreType {
    static let shared: BuildTargetTogglesDataStore = .init()
    private let buildTarget: BuildTargetToggle
    private init() {
        #if DEBUG
        buildTarget = .debug
        #endif
        #if INTERNAL
        buildTarget = .internal
        #endif
        #if PRODUCTION
        buildTarget = .production
        #endif
    }
    func isToggleOn(_ toggle: ToggleType) -> Bool {
        guard let toggle = toggle as? BuildTargetToggle else {
            return false
        }
        return toggle == buildTarget
    }
    func update(toggle: ToggleType, value: Bool) { }
}

這里是直接讀編譯變量颜凯,前面說過這些是你設(shè)置的,來自 InternalProject.xcconfig 文件:

SWIFT_ACTIVE_COMPILATION_CONDITIONS = $(inherited) INTERNAL

在任何地方仗扬,都使用這些toggle的datastore來先判斷再執(zhí)行:

extension UIWindow {
    override open func motionEnded(_ motion: UIEvent.EventSubtype, with event: UIEvent?) {
        if BuildTargetTogglesDataStore.shared.isToggleOn(BuildTargetToggle.debug)
            || BuildTargetTogglesDataStore.shared.isToggleOn(BuildTargetToggle.internal) {
            let router: AppRouting = AppRouter()
            if motion == .motionShake {
                router.route(to: URL(string: "\(UniversalLinks.baseURL)InternalMenu"), from: rootViewController, using: .present)
            }
        }
    }
}

所有功能開關(guān)都需要及時清理装获,只要不是付費(fèi)隱藏內(nèi)容之類的設(shè)計(jì),多余的判斷都是沒必要的厉颤。

使用累進(jìn)合并master而不是一個巨大的功能分支的好處:

  • 有了開關(guān)就可以把未完成的功能都合并到 master,不斷快速迭代凡简。master 也可以隨時發(fā)布逼友。
  • 只有內(nèi)部測試人員在打開開關(guān)的時候才能看到開發(fā)中的功能。
  • 如果沒有功能開關(guān)秤涩,功能分支可能變得很大很長帜乞,合并時可能會有很多沖突,
  • 而且由于不在 master 里面筐眷,不好通過 CI 自動化黎烈,測試人員也不容易得到測試包。

SwiftGen

作用是用常量的方式來使用資源字符串(如圖片匀谣,多語言code等)

pod 'SwiftGen', '= 6.4.0', configurations: ['Debug']
image.png

這里要注意照棋,由于我們自己的源代碼會使用到 SwiftGen 所生成的代碼陈惰,因此必須把 Run SwiftGen 步驟放在 Compile Source 步驟之前唯绍。

strings:
  inputs:
    - Moments/Resources/en.lproj
  outputs:
    - templateName: structured-swift5
      output: Moments/Generated/Strings.swift
  1. 表示這是個strings的任務(wù)
  2. 來源是xxx/en.lproj (因?yàn)槎嗾Z言code是一樣的,所以指定一個就好了)
  3. 后面就是模板文件和輸出位置优妙,模板文件是需要自己多留意一下的宝恶。

生成類似的代碼:

internal enum L10n {
  internal enum InternalMenu {
    /// Area 51
    internal static let area51 = L10n.tr("Localizable", "internalMenu.area51")
    /// Avatars
    internal static let generalInfo = L10n.tr("Localizable", "internalMenu.generalInfo")
  }
}
  1. 使用了a.b這樣的用點(diǎn)分隔的code符隙,好處理可以歸類
  2. swiftgen能支持這種用法趴捅,并且把它當(dāng)成了命名空間,直接做成了嵌套的枚舉

使用:

let title = L10n.InternalMenu.area51
let infoSection = InternalMenuSection(
    title: L10n.InternalMenu.generalInfo,
    items: [InternalMenuDescriptionItemViewModel(title: appVersion)]
)

不再需要硬編碼字符串了

類似的霹疫,有R.swift

動態(tài)字體和輔助功能

使用iOS自帶的UIFont.UITextStyle

label.font = UIFont.preferredFont(forTextStyle: .body)
label.adjustsFontForContentSizeCategory = true
image.png

第三方字體要與UITextStyle建立關(guān)聯(lián):

guard let customFont = UIFont(name: "CustomFont", size: UIFont.labelFontSize) else {
    fatalError("Failed to load the "CustomFont" font. Make sure the font file is included in the project and the font name is spelled correctly."
    )
}
label.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: customFont)
label.adjustsFontForContentSizeCategory = true

可見拱绑,相比直接使用UIFont,改而使用了UIFontMetrics

深色模式和語義色(Semantic colors)

為了簡化深色模式的適配過程丽蝎,蘋果公司提供了具有語義的系統(tǒng)色(System colors)和動態(tài)系統(tǒng)色(Dynamic system colors)供我們使用猎拨。


image.png

上圖是蘋果開發(fā)者網(wǎng)站提供的一個 iOS 系統(tǒng)色,有藍(lán)色征峦、綠色迟几、靛藍(lán)、橙色栏笆、黃色等类腮,它們在淺色模式和深色模式下會使用到不同的顏色值。


image.png

上圖顯示是 iOS 系統(tǒng)提供的動態(tài)系統(tǒng)色的定義蛉加。它們都是通過用途來定義各種顏色的名稱蚜枢。例如 Label 用于主標(biāo)簽文字的顏色,而 Secondary label用于副標(biāo)簽文字的顏色针饥,使用它們就能自動支持不同的外觀模式了厂抽。

移動端系統(tǒng)架構(gòu)的設(shè)計(jì)與實(shí)現(xiàn)

  • BFF(Backend for Frontend),即為前端服務(wù)的后端丁眼,不要求前端做一個個的原子請求筷凤,還要處理業(yè)務(wù)邏輯或異步返回的先后順序,后端自行分發(fā)服務(wù)和組織順序苞七,最終整合數(shù)據(jù)統(tǒng)一返給前端藐守。
  • 不同微服務(wù)(或不同的數(shù)據(jù)源,不同的第三方)可能有自己的連接方式蹂风,app就需要準(zhǔn)備所有的SDK卢厂,而BFF只需要對接一個服務(wù)(這一個服務(wù)也要注意盡量為前端封裝業(yè)務(wù)邏輯)
  • 多端開發(fā)行為一致,避免了不同的服務(wù)調(diào)用順序和傳參的差別造成體驗(yàn)不一致或出現(xiàn)不同的問題
  • 只需要公開一個服務(wù)到公網(wǎng)

GraphQL

和 REST API惠啄,gRPC 以及 SOAP 相比慎恒, GraphQL 架構(gòu)有以下幾大優(yōu)點(diǎn)。

  • GraphQL 允許客戶端按自身的需要通過 Query 來請求不同數(shù)據(jù)集撵渡,而不像 REST API 和gRPC 那樣每次都是返回全部數(shù)據(jù)融柬,這樣能有效減輕網(wǎng)絡(luò)負(fù)載。
  • GraphQL能減輕為各客戶端開發(fā)單獨(dú) Endpoint 的工作量趋距。比如當(dāng)我們開發(fā) App Clip 的時候丹鸿,App Clip 可以在 Query 中以指定子數(shù)據(jù)集的方式來使用和主 App 相同的 Query,而無須重新開發(fā)新 Endpoint棚品。
  • GraphQL 服務(wù)能根據(jù)客戶端的 Query 來按需請求數(shù)據(jù)源靠欢,避免無必要的數(shù)據(jù)請求廊敌,減輕服務(wù)端的負(fù)載。

Apollo Server

Apollo Server 是基于 Node.js 的 GraphQL 服務(wù)器门怪,目前非常流行骡澈。使用它,可以很方便地結(jié)合 Express 等 Web 服務(wù)掷空,而且還可以部署到亞馬遜 Lambda肋殴,微軟 Azure Functions 等 Serverless 服務(wù)上。

限制:

GraphQL 通常使用 HTTP POST 請求坦弟,但有些 CDN (content delivery network护锤,內(nèi)容分發(fā)網(wǎng)絡(luò))對 POST 緩存支持不好,當(dāng)我們把 GraphQL 的請求換成 GET 時酿傍,整個 Query 會變成 JSON-encoded 字符串并放在 Query String 里面進(jìn)行發(fā)送烙懦。此時,要特別注意該 Query String 的長度不要超過 CDN 所支持的長度限制(比如 Akamai 支持最長的 URL 是 8892 字節(jié))赤炒,否則請求將會失敗氯析。

MVVM

image.png

其實(shí)就是由ViewController做所有臟活變成了由ViewModel做所有臟活


image.png

響應(yīng)式編程與RxSwift

  • 所謂響應(yīng)式編程,就是使用異步數(shù)據(jù)流(Asynchronous data streams)進(jìn)行編程。
  • 在傳統(tǒng)的指令式編程語言里莺褒,代碼不僅要告訴程序做什么掩缓,還要告訴程序什么時候做。而在響應(yīng)式編程里遵岩,我們只需要處理各個事件你辣,程序會自動響應(yīng)狀態(tài)的更新。
  • 而且尘执,這些事件可以單獨(dú)封裝绢记,能有效提高代碼復(fù)用性并簡化錯誤處理的邏輯。
  • Android 平臺的 Architecture Components 提供了支持響應(yīng)式編程的 LiveData正卧, SwiftUI 也配套了 Combine 框架
  • 除了RxSwift, 目前比較流行的響應(yīng)式編程框架還有 ReactiveKitReactiveSwift 和 Combine
  • RxSwift 遵循了 ReactiveX 的 API 標(biāo)準(zhǔn)跪解,由于 ReactiveX 提供了多種語言的實(shí)現(xiàn)炉旷,學(xué)會 RxSwift 能有效把知識遷移到其他平臺。


    image.png
  1. 當(dāng)用戶打開朋友圈頁面叉讥,App 會使用后臺排程器向 BFF 發(fā)起一個網(wǎng)絡(luò)請求窘行,
  2. Networking 模塊把返回結(jié)果通過Observable 序列發(fā)送給 Repository 模塊。
  3. Repository 模塊訂閱接收后图仓,把數(shù)據(jù)發(fā)送到Subject里面罐盔,
  4. 然后經(jīng)過map 操作符轉(zhuǎn)換,原先的 Model 類型轉(zhuǎn)換成了 ViewModel 類型救崔。
  5. ViewModel 模塊訂閱經(jīng)過操作符轉(zhuǎn)換的數(shù)據(jù)惶看,發(fā)送給下一個Subject捏顺,
  6. 之后,這個數(shù)據(jù)被 ViewController 訂閱纬黎,并通過主排程器更新了 UI幅骄。

整個過程中,Repository 模塊本今、 ViewModel模塊拆座、ViewController 都是訂閱者,分別接收來自前一層的信息冠息。就這樣挪凑,當(dāng) App 得到網(wǎng)絡(luò)返回?cái)?shù)據(jù)時,就能自動更新每一層的狀態(tài)信息逛艰,也能實(shí)時更新 UI 顯示躏碳。

異步數(shù)據(jù)序列 Observable

  • 為了保證程序狀態(tài)的同步,我們需要把各種異步事件都發(fā)送到異步數(shù)據(jù)流里瓮孙,供響應(yīng)式編程發(fā)揮作用唐断。
  • 在 RxSwfit 中,異步數(shù)據(jù)流稱為 Observable 序列杭抠,它表示可觀察的異步數(shù)據(jù)序列脸甘,也可以理解為消息發(fā)送序列。
  • 在實(shí)際應(yīng)用中偏灿,我們通常使用 Observable 序列作為入口丹诀,把外部事件連接到響應(yīng)式編程框架里面。

那么怎樣創(chuàng)建 Observable 序列呢翁垂?為方便我們生成 Observable 序列铆遭, RxSwfit 的Observable類型提供了如下幾個工廠方法:

  1. just方法,用于生成只有一個事件的 Observable 序列沿猜;
  2. of方法枚荣,生成包含多個事件的 Observable 序列;
  3. from方法啼肩,和of方法一樣橄妆,from方法也能生成包含多個事件的 Observable 序列,但它只接受數(shù)組為入口參數(shù)祈坠。
let observable1: Observable<Int> = Observable.just(1) // 序列包含 1
let observable2: Observable<Int> = Observable.of(1, 2, 3) // 序列包含 1, 2, 3 
let observable3: Observable<Int> = Observable.from([1, 2, 3]) // 序列包含 1, 2, 3
let observable4: Observable<[Int]> = Observable.of([1, 2, 3]) // 序列包含 [1, 2, 3]

// demo
let peopleObservable = Observable.of(
  Person(name: "Jake", income: 10), 
  Person(name: "Ken", income: 20)
  )

可以理解為from會解包害碾,但of不會

訂閱者

在響應(yīng)式編程模式里,訂閱者是一個重要的角色赦拘。在 RxSwift 中慌随,訂閱者可以調(diào)用Observable對象的subscribe方法來訂閱。

let observable = Observable.of(1, 2, 3)
observable.subscribe { event in
    print(event)
}

// event的定義是:
public enum Event<Element> {
    /// Next element is produced.
    case next(Element)
    /// Sequence terminated with an error.
    case error(Swift.Error)
    /// Sequence completed successfully.
    case completed
}

所以會得到輸出:

next(1)
next(2)
next(3)
completed

由于之前講過的offrom等方法都不能發(fā)出errorcompleted事件 ,在這里我就使用了create方法來創(chuàng)建 Observable 序列阁猜。

Observable<Int>.create { observer in
    observer.onNext(1)
    // 發(fā)送completed的例子
    observer.onCompleted()
    observer.onNext(2)
    // 發(fā)送error事件的例子
    observer.onError(MyError.anError)
    observer.onNext(3)
    return Disposables.create()
}.subscribe { event in
    print(event)
}
  • subscribe方法返回的類型為Disposable的對象丸逸,我們可以通過調(diào)用該對象的dispose方法來取消訂閱。
  • 取消訂閱不是讓消息源發(fā)送complete蹦漠,僅僅是改變訂閱者自己的行為
let disposable = Observable.of(1, 2).subscribe { element in
    print(element) // next event
} onError: { error in
    print(error)
} onCompleted: {
    print("Completed")
} onDisposed: {
    print("Disposed")
}
disposable.dispose()

加入delay

let disposableWithDelay = Observable.of(1, 2)
  .delay(.seconds(2), scheduler: MainScheduler.instance)
  .subscribe { element in
      print(element) // next event
  }
  ...
  disposableWithDelay.dispose()
  • 延遲的是“發(fā)送通知”的時間
  • 因?yàn)樯鲜龃a立即調(diào)用了dispose方法椭员,所以在發(fā)送通知前已經(jīng)取消訂閱了,因?yàn)椴粫蛴∪魏芜^程中的事件笛园,只會直接打印dispose
  • 所以異步事件里用同步寫法是做不到在監(jiān)聽事件本身結(jié)束后才取消訂閱的隘击,
  • RxSwift 為我們提供了DisposeBag類型,方便存放和管理各個Disposable對象研铆。
    • 我的理解就是由信息源來解除訂閱者的訂閱埋同。
let disposeBag: DisposeBag = .init()
Observable.just(1).subscribe { event in
    print(event)
}.disposed(by: disposeBag)
Observable.of("a", "b").subscribe { event in
    print(event)
}.disposed(by: disposeBag)

可見,是disposed方法多了個參數(shù)

上面subscrib有時候是Element有時候是Event棵红,且都是位置參數(shù)0凶赁,自己試試區(qū)別。(直接subscribe就是Event, 用onNext等參數(shù)取出來就是Element)

以上是如何生成逆甜、訂閱和退訂 Observable 序列虱肄。

事件中轉(zhuǎn) Subject

  • 使用Observable的工廠方法所生成的對象都是“只讀”,一旦生成交煞,就無法添加新的事件咏窿。
  • 但很多時候,我們需要往 Observable 序列增加事件素征,比如要把用戶點(diǎn)擊 UI 的事件添加到 Observable 中集嵌,或者把底層模塊的事件加工并添加到上層模塊的序列中。
  • RxSwift 為我們提供的 Subject 及其onNext方法可以完成這項(xiàng)操作御毅。
  • Subject作為一種特殊的 Observable 序列根欧,它既能接收又能發(fā)送,我們一般用它來做事件的中轉(zhuǎn)端蛆。
    • 用例: 比如凤粗,當(dāng) Repository 模塊從 Networking 模塊中接收到事件時,會把該事件轉(zhuǎn)送到自身的 Subject 來通知 ViewModel今豆,從而保證 ViewModel 的狀態(tài)同步嫌拣。
  • 常見的 Subject 一般有 PublishSubjectBehaviorSubject 和 ``ReplaySubject`晚凿。它們的區(qū)別在于訂閱者能否收到訂閱前的事件:
    • PublishSubject:如果你想訂閱者只收到訂閱后的事件,可以使用 PublishSubject瘦馍。
    • BehaviorSubject:如果你想訂閱者在訂閱時能收到訂閱前最后一條事件歼秽,可以使用 BehaviorSubject。
    • ReplaySubject:如果你想訂閱者在訂閱的時候能收到訂閱前的 N 條事件情组,那么可以使用 ReplaySubject燥筷。
  • 而在訂閱以后箩祥,它們的行為都是一致的,當(dāng) Subject 發(fā)出error或者completed事件以后肆氓,訂閱者將無法接收到新的事件
  • 就后面的實(shí)例袍祖,是subscribe(Subject),即把subject傳進(jìn)subscribe里來實(shí)現(xiàn)中轉(zhuǎn)

操作符

操作符(Operator)是 RxSwift 另外一個重要的概念谢揪,它能幫助訂閱者在接收事件之前把 Observable 序列中的事件進(jìn)行過濾蕉陋、轉(zhuǎn)換或者合并。

  • 轉(zhuǎn)換:map
  • 過濾:filter, distinctUntilChanged
  • 合并:startWith拨扶,concat凳鬓,mergecombineLatestzip

除了上面提到過的常用操作符患民,RxSwift 還為我們提供了 50 多個操作符缩举,那怎樣才能學(xué)會它們呢?我推薦你到 rxmarbles.com 或者到 App Store 下載 RxMarbles App匹颤,然后打開各個操作符并修改里面的參數(shù)仅孩,通過輸入的事件和執(zhí)行的結(jié)果來理解這些操作的作用

排程器

  • 保持程序狀態(tài)自動更新之所以困難,很大原因在于處理并發(fā)的異步事件是一件繁瑣的事情印蓖。
  • 為了方便處理來自不同線程的并發(fā)異步事件辽慕,RxSwift 為我們提供了排程器。
    • 它可以幫我們把繁重的任務(wù)調(diào)度到后臺排程器完成另伍,
    • 并能指定其運(yùn)行方式(如是串行還是并發(fā))鼻百,
    • 也能保證 UI 的任務(wù)都在主線程上執(zhí)行。
  • 根據(jù)串行或者并發(fā)來歸類摆尝,我們可以把排程器分成兩大類串行的排程器并發(fā)的排程器温艇。
    • 串行的排程器包括 CurrentThreadScheduler、MainScheduler堕汞、SerialDispatchQueueScheduler勺爱。
      • CurrentThreadScheduler可以把任務(wù)安排在當(dāng)前的線程上執(zhí)行,這是默認(rèn)的排程器讯检。
      • MainScheduler是把任務(wù)調(diào)度到主線程MainThread里并馬上執(zhí)行琐鲁,它主要用于執(zhí)行 UI 相關(guān)的任務(wù)
      • SerialDispatchQueueScheduler則會把任務(wù)放在dispatch_queue_t里面并串行執(zhí)行。
    • 并發(fā)的排程器包括 ConcurrentDispatchQueueScheduler 和 OperationQueueScheduler人灼。
      • ConcurrentDispatchQueueScheduler把任務(wù)安排到dispatch_queue_t里面围段,且以并發(fā)的方式執(zhí)行。
        • 該排程器一般用于執(zhí)行后臺任務(wù)投放,例如網(wǎng)絡(luò)訪問和數(shù)據(jù)緩存等等奈泪。
        • 在創(chuàng)建的時候,我們可以指定DispatchQueue的類型,例如使用ConcurrentDispatchQueueScheduler(qos: .background)來指定使用后臺線程執(zhí)行任務(wù)涝桅。
      • OperationQueueScheduler是把任務(wù)放在NSOperationQueue里面拜姿,以并發(fā)的方式執(zhí)行。
        • 這個排程器一般用于執(zhí)行繁重的后臺任務(wù)冯遂,并通過設(shè)置maxConcurrentOperationCount來控制所執(zhí)行并發(fā)任務(wù)的最大數(shù)量蕊肥。
        • 它可以用于下載大文件。
Observable.of(1, 2, 3, 4)
    // 發(fā)布事件要執(zhí)行的代碼放到后臺
    .subscribeOn(ConcurrentDispatchQueueScheduler(qos: .background))
    .dumpObservable()
    .map { "\(getThreadName()): \($0)" }
    // 訂閱事件要執(zhí)行的代碼放到主線程
    .observeOn(MainScheduler.instance)
    .dumpObserver()
    .disposed(by: disposeBag)

這是常用的模式蛤肌,比如網(wǎng)絡(luò)請求在后臺壁却,拿到數(shù)據(jù)后更新UI肯定是在主線程

后臺+異步,不能保證執(zhí)行順序寻定,比如幾個網(wǎng)絡(luò)請求儒洛,你并不能保證一定最先處理哪一個的返回

經(jīng)驗(yàn):

  1. 當(dāng)我們拿到需求的時候,先把任務(wù)進(jìn)行分解狼速,找出哪個部分是事件發(fā)布者琅锻,哪部分是事件訂閱者,例如一個新功能頁面向胡,網(wǎng)絡(luò)請求部分一般是事件發(fā)布者恼蓬,當(dāng)?shù)玫骄W(wǎng)絡(luò)請求的返回結(jié)果時會發(fā)出事件,而 UI 部分一般為事件訂閱者僵芹,通過訂閱事件來保持 UI 的自動更新处硬。
  2. 找到事件發(fā)布者以后,要分析事件發(fā)布的頻率與間隔拇派。如果只是發(fā)布一次荷辕,可以使用Obervable;如果需要多次發(fā)布件豌,可以使用Subject疮方;如果需要緩存之前多個事件,可以使用 ReplaySubject茧彤。
  3. 當(dāng)我們有了事件發(fā)布者和訂閱者以后骡显,接著可以分析發(fā)送和訂閱事件的類型差異,選擇合適的操作符來進(jìn)行轉(zhuǎn)換曾掂。我們可以先使用本講中提到的常用操作符惫谤,如果它們還不能解決你的問題,可以查看 RxMarbles 來尋找合適的操作符珠洗。
  4. 最后溜歪,我們可以根據(jù)事件發(fā)布者和訂閱者所執(zhí)行的任務(wù)性質(zhì),通過排程器進(jìn)行調(diào)度许蓖。例如把網(wǎng)絡(luò)請求和數(shù)據(jù)緩存任務(wù)都安排在后臺排程器蝴猪,而 UI 更新任務(wù)放在主排程器富岳。

網(wǎng)絡(luò)層架構(gòu)

為了存取服務(wù)器上的數(shù)據(jù),并與其他用戶進(jìn)行通信拯腮,幾乎所有的 iOS App 都會訪問后臺 API 。目前流行的后臺 API 設(shè)計(jì)有幾種方案: RESTful蚁飒、gRPC动壤、GraphQL 和 WebSocket。其中淮逻,gRPC 使用 Protobuf 進(jìn)行數(shù)據(jù)傳輸琼懊, GraphQL 和 RESTful 往往使用 JSON 進(jìn)行傳輸。
為了把訪問后臺 API 的網(wǎng)絡(luò)傳輸細(xì)節(jié)給屏蔽掉爬早,并為上層模塊提供統(tǒng)一的訪問接口哼丈,我們在架構(gòu) App 的時候,往往會把網(wǎng)絡(luò)訪問封裝成一個獨(dú)立的 Networking 模塊筛严。

image.png

底層 HTTP 網(wǎng)絡(luò)通信模塊

該模塊把所有 HTTP 請求封裝起來醉旦,核心是APISession協(xié)議。下面是它的定義桨啃。

protocol APISession {
   associatedtype ReponseType: Codable
   func post(_ path: String, parameters: Parameters?, headers: HTTPHeaders) -> Observable<ReponseType>
}
  1. 只提供了一個POST方法
  2. 統(tǒng)一用ResponseType來做接收對象(當(dāng)泛型用)
  • 實(shí)現(xiàn)協(xié)議的時候可以重定義這個ResponseType:
  • typealias ReponseType = Response
  • 但一般接口的最外層可能都是固定的车胡,比如code, message, data,所以它可以不必是個泛型/協(xié)議而是一個具體的類型
  • 不過從后面的代碼可知照瘾,它對應(yīng)的是data的類型(大部分網(wǎng)絡(luò)請求的封裝也都是這么處理的)

用一個擴(kuò)展來提供post的默認(rèn)實(shí)現(xiàn):

extension APISession {
   func post(_ path: String, headers: HTTPHeaders = [:], parameters: Parameters? = nil) -> Observable<ReponseType> {
       return request(path, method: .post, headers: headers, parameters: parameters, encoding: JSONEncoding.default)
   }
}

private func request(_ path: String, method: HTTPMethod, headers: HTTPHeaders, parameters: Parameters?, encoding: ParameterEncoding) -> Observable<ReponseType> {
       let url = baseUrl.appendingPathComponent(path)
       let allHeaders = HTTPHeaders(defaultHeaders.dictionary.merging(headers.dictionary) { $1 })
       return Observable.create { observer -> Disposable in
           let queue = DispatchQueue(label: "moments.app.api", qos: .background, attributes: .concurrent)
           let request = AF.request(url, method: method, parameters: parameters, encoding: encoding, headers: allHeaders, interceptor: nil, requestModifier: nil)
               .validate()
               .responseJSON(queue: queue) { response in
                   // 處理返回的 JSON 數(shù)據(jù)
               }
           return Disposables.create {
               request.cancel()
           }
       }
   }
  1. 我們首先使用Observable.create()方法來創(chuàng)建一個 Observable 序列并返回給調(diào)用者
  2. 然后在create()方法的封包里使用 Alamofire 的request()方法發(fā)起網(wǎng)絡(luò)請求匈棘。
  3. 為了不阻擋 UI 的響應(yīng),我們把該請求安排到后臺隊(duì)列中執(zhí)行析命。
  4. 當(dāng)我們得到返回的 JSON 以后主卫,會使用下面的代碼進(jìn)行處理。
switch response.result {
case .success:
    guard let data = response.data else {
        // if no error provided by Alamofire return .noData error instead.
        observer.onError(response.error ?? APISessionError.noData)
        return
    }
    do {
        let model = try JSONDecoder().decode(ReponseType.self, from: data)
        observer.onNext(model)
        observer.onCompleted()
    } catch {
        observer.onError(error)
    }
case .failure(let error):
    if let statusCode = response.response?.statusCode {
        observer.onError(APISessionError.networkError(error: error, statusCode: statusCode))
    } else {
        observer.onError(error)
    }
}

很簡單鹃愤,就是判斷有沒有data鍵(取決于后端)簇搅,有的話就轉(zhuǎn)化成泛型指定的類別,廣播出去并關(guān)閉數(shù)據(jù)流昼浦;沒有就報(bào)錯馍资,廣播出去;請求出現(xiàn)了異彻卦耄或果斷轉(zhuǎn)對象那出現(xiàn)了異常也廣播出去鸟蟹。

上面的例子把接口加了一層協(xié)議,這是將網(wǎng)絡(luò)層并進(jìn)行了抽象使兔,方便以后切換請求方式(比如以后想從GraphQL變成RESTful)建钥,類似的有以前切換數(shù)據(jù)庫,這屬于面向接口(協(xié)議)編程的內(nèi)容虐沥,初創(chuàng)團(tuán)隊(duì)想這么想會有過度設(shè)計(jì)的嫌疑(雖然確實(shí)能解決項(xiàng)目初始的時候底層技術(shù)棧還沒確定的問題)熊经。

比如如下泽艘,一個接口,一個實(shí)現(xiàn):

protocol GetMomentsByUserIDSessionType {
   func getMoments(userID: String) -> Observable<MomentsDetails>
}

func getMoments(userID: String) -> Observable<MomentsDetails> {
    let session = Session(userID: userID)
    return sessionHandler(session).map { 
        $0.data.getMomentsDetailsByUserID }
}

// sessionHandler是init時候通過閉包送進(jìn)來的
init(togglesDataStore: TogglesDataStoreType = InternalTogglesDataStore.shared, sessionHandler: @escaping (Session) -> Observable<Response> = {
    $0.post($0.path, headers: $0.headers, parameters: $0.parameters)
}) {
    self.togglesDataStore = togglesDataStore
    self.sessionHandler = sessionHandler
}
  • 其中$0表示入口參數(shù)Session的對象镐依,
  • 由于Session遵循了APISession協(xié)議匹涮,它可以直接調(diào)用APISession的擴(kuò)展方法post來發(fā)起 HTTP POST 請求,并獲取類型為Response的返回值槐壳。

不要被getMomentsDetailsByUserID迷惑了然低,它不是個方法,只是一個鍵:

{
 "data": {
   "getMomentsDetailsByUserID": {
     // MomentsDetails object
     "userDetails": {...},
      "moments": [...]
   }
 }
}

相應(yīng)的數(shù)據(jù)結(jié)構(gòu)則定義為:

struct Response: Codable {
    let data: Data
    struct Data: Codable {
        let getMomentsDetailsByUserID: MomentsDetails
    }
}
  • 可見抽取的是json里面的特定的鍵务唐,說明后面還會有處理雳攘,不會原樣映射。
  • map干的就是把detail從json的data里取出來掛到Response上

接著我們看看Session結(jié)構(gòu)體的具體實(shí)現(xiàn)枫笛。 該結(jié)構(gòu)體負(fù)責(zé)準(zhǔn)備 GraphQL 請求的數(shù)據(jù)吨灭,這些數(shù)據(jù)包括 URL 路徑、HTTP 頭和參數(shù)刑巧。URL 路徑比較簡單喧兄,是一個值為/graphql的常量。HTTP 頭也是一個默認(rèn)的HTTPHeaders對象啊楚。最重要的數(shù)據(jù)是類型為Parameters的parameters屬性繁莹。

init(userID: String) {
    let variables: [AnyHashable: Encodable] = ["userID": userID]
    parameters = ["query": Self.query,
                  "variables": variables]
}

可見把參數(shù)封了一層,包到了variables里特幔,之所以要包一層咨演,因?yàn)檫€要送一個query。注意蚯斯,本節(jié)說的都是針對特定后端的薄风,比如這里講的是GraphQL,所有的 GraphQL 的請求都需要發(fā)送 Query:

private static let query = """
    query getMomentsDetailsByUserID($userID: ID!) {
      getMomentsDetailsByUserID(userID: $userID) {
        userDetails {
          id
          name
          avatar
          backgroundImage
        }
        moments {
          id
          userDetails {
            name
            avatar
          }
          type
          title
          photos
          createdDate
        }
      }
    }
"""
}
  1. 這不是給swift看的拍嵌,所以身份只是一個字符串遭赂。
  • 可以理解為寫SQL,也是字符串
  1. 不要被反復(fù)使用的getMomentsDetailsByUserID迷惑了横辆,名字可以變的撇他,不需要內(nèi)外層保持一致,但需要與調(diào)用者保證一致
  2. 這里表示這個接口返出來的json是由getMomentsDetailsByUserID引導(dǎo)的狈蚤,包含了兩個鍵(userDetailsmoments)與前面的果斷對照看一下
  3. query里的變量來自variables變量困肩,所以params需要兩個參數(shù)
  4. 本質(zhì)上是把SQL串和查詢參數(shù)通過HTTP來請求,所以沒什么新鮮內(nèi)容

以上query會產(chǎn)生如下返回:

{
 "userDetails": {
   "id": "0",
   "name": "Jake Lin",
   "avatar": "https://avatar-url",
   "backgroundImage": "https://background-image-url"
 },
 "moments": [
   {
     "id": "0",
     "userDetails": {
       "name": "Taylor Swift",
       "avatar": "https://another-avatar-url"
     },
     "type": "PHOTOS",
     "title": null,
     "photos": [
       "https://photo-url"
     ],
     "createdDate": "1615899003"
   }
 ]
}

接下來要做的脆侮,上面定義Response模型的時候做過一次了锌畸,那里提取出了MomentsDetails模型,在這里定義靖避。
其實(shí)就是對著GraghQL的schema抄一遍潭枣,關(guān)鍵字基本都差不多

type MomentsDetails {
 userDetails: UserDetails!
 moments: [Moment!]!
}

type Moment {
 id: ID!
 userDetails: UserDetails!
 type: MomentType!
 title: String
 url: String
 photos: [String!]!
 createdDate: String!
}

type UserDetails {
  id: ID!
  name: String!
  avatar: String!
  backgroundImage: String!
}

enum MomentType {
 URL
 PHOTOS
}

// 有了上面的 GraphQL Schema比默,加上 JSON 數(shù)據(jù)結(jié)構(gòu),我們可以完成MomentsDetails的映射盆犁。
 struct MomentsDetails: Codable {
   let userDetails: UserDetails
   let moments: [Moment]
}
  • 具體做法是把 GraphQL 中的type映射成struct命咐,然后每個屬性都使用let來定義成常量。
  • 在 GraphQL 中,!符合表示非空類型,而 Swift 中恰好相反寡喝,標(biāo)記的是s可空(?符號)。
struct UserDetails: Codable {
    let id: String
    let name: String
    let avatar: String
    let backgroundImage: String
}

// 接著我們看看Moment類型定義。
struct Moment: Codable {
    let id: String
    let userDetails: MomentUserDetails
    let type: MomentType
    let title: String?
    let url: String?
    let photos: [String]
    let createdDate: String
    struct MomentUserDetails: Codable {
        let name: String
        let avatar: String
    }

  // 注意這里情龄,枚舉映射為枚舉,這里是內(nèi)聯(lián)的
  // GraphQL 會通過字符串來傳輸enum
  enum MomentType: String, Codable {
      case url = "URL"
      case photos = "PHOTOS"
  }
}

Codable是兩個協(xié)議(編碼和解碼)的簡寫:

public typealias Codable = Decodable & Encodable

\begin{array}{l} \small\text{JSON數(shù)據(jù)} \to \small\text{JSONDecoder} \to \small\text{Swift Model} \\ \small\text{Swift Model} \to \small\text{JSONEncoder} \to \small\text{JSON數(shù)據(jù)} \end{array}

  • 在 Swift 4 之前目木,我們需要使用JSONSerialization來反序列化 JSON 數(shù)據(jù)蜕企,然后把每一個屬性單獨(dú)轉(zhuǎn)換成所需的類型。
  • 后來出現(xiàn) SwiftyJSON 等庫路呜,幫我們減輕了一部分 JSON 轉(zhuǎn)型工作迷捧,但還是需要大量手工編碼來完成映射。
  • 1Swift 4 以后胀葱,出現(xiàn)了Codable協(xié)議漠秋,我們只需要把所定義的 Model 類型遵守該協(xié)議,Swift 在調(diào)用JSONDecoderdecode方法時就能自動完成轉(zhuǎn)型抵屿。這樣既能減少編寫代碼的數(shù)量庆锦,還能獲得原生的性能。
let model = try JSONDecoder().decode(ReponseType.self, from: data)
  • 加上try語句才會讓decode方法返nil轧葛。

數(shù)據(jù)層架構(gòu):倉庫模式

Repository 模式

所謂 Repository 模式搂抒,就是為數(shù)據(jù)訪問提供抽象的接口,數(shù)據(jù)使用者在讀寫數(shù)據(jù)時尿扯,只調(diào)用相關(guān)的接口函數(shù)求晶,并不關(guān)心數(shù)據(jù)到底存放在網(wǎng)絡(luò)還是本地,也不用關(guān)心本地?cái)?shù)據(jù)庫的具體實(shí)現(xiàn)衷笋。使用 Repository 模式有以下幾大優(yōu)勢:

  • Repository 模塊作為唯一數(shù)據(jù)源統(tǒng)一管理所有數(shù)據(jù)芳杏,能有效保證整個 App 數(shù)據(jù)的一致性;
  • Repository 模塊封裝了所有數(shù)據(jù)訪問的細(xì)節(jié)辟宗,可提高程序的可擴(kuò)展性和靈活性爵赵,例如,在不改變接口的情況下泊脐,把本地存儲替換成其他的數(shù)據(jù)庫亚再;
  • 結(jié)合 RxSwift 的 Subject, Repository 模塊能自動更新 App 的數(shù)據(jù)與狀態(tài)晨抡。


    image.png
  • ViewModel 模塊是 Repository 模塊的上層數(shù)據(jù)使用者氛悬,在朋友圈功能里面,MomentsTimelineViewModel和MomentListItemViewModel都通過MomentsRepoType的momentsDetailsSubject 來訂閱數(shù)據(jù)的更新则剃。
  • Repository 模塊分成兩大部分: RepoDataStore
    • 其中 Repo 負(fù)責(zé)統(tǒng)一管理數(shù)據(jù)(如訪問網(wǎng)絡(luò)的數(shù)據(jù)如捅、讀寫本地?cái)?shù)據(jù))棍现,并通過 Subject 來為訂閱者分發(fā)新的數(shù)據(jù)。
      • Repo 由MomentsRepoType協(xié)議和遵循該協(xié)議的MomentsRepo結(jié)構(gòu)體所組成镜遣。MomentsRepoType協(xié)議用于定義接口己肮,而MomentsRepo封裝具體的實(shí)現(xiàn),
      • 當(dāng)MomentsRepo需要讀取和更新 BFF 的數(shù)據(jù)時悲关,會調(diào)用 Networking 模塊的組件谎僻。而當(dāng)MomentsRepo需要讀取和更新本地?cái)?shù)據(jù)時,會使用到 DataStore寓辱。
    • DataStore 負(fù)責(zé)本地?cái)?shù)據(jù)的存儲艘绍,它由PersistentDataStoreType協(xié)議和UserDefaultsPersistentDataStore結(jié)構(gòu)體所組成。其中秫筏,PersistentDataStoreType協(xié)議用于定義本地?cái)?shù)據(jù)讀寫的接口诱鞠。而UserDefaultsPersistentDataStore結(jié)構(gòu)體是其中一種實(shí)現(xiàn)。
    • 從名字可以看到这敬,該實(shí)現(xiàn)使用了 iOS 系統(tǒng)所提供的 UserDefaults 來存儲數(shù)據(jù)航夺。
    • 假如我們需要支持 Core Data,那么可以提供另外一個結(jié)構(gòu)體來遵循PersistentDataStoreType協(xié)議崔涂,比如把該結(jié)構(gòu)體命名為CoreDataPersistentDataStore阳掐,并使用它來封裝所有 Core Data 的訪問細(xì)節(jié)。有了 DataStore 的接口冷蚂,我們可以很方便地替換不同的本地?cái)?shù)據(jù)庫锚烦。

Repository模式實(shí)現(xiàn)

先看一下 DataStore 的接口,一個存帝雇,一個取:

protocol PersistentDataStoreType {
    var momentsDetails: ReplaySubject<MomentsDetails> { get }
    func save(momentsDetails: MomentsDetails)
}

UserDefaults版本的實(shí)現(xiàn):

struct UserDefaultsPersistentDataStore: PersistentDataStoreType {
    static let shared: UserDefaultsPersistentDataStore = .init() // 這樣就單例了涮俄?
    private(set) var momentsDetails: ReplaySubject<MomentsDetails> = .create(bufferSize: 1)
    private let disposeBage: DisposeBag = .init()
    private let defaults = UserDefaults.standard
    private let momentsDetailsKey = String(describing: MomentsDetails.self)
    private init() {
        defaults.rx
            .observe(Data.self, momentsDetailsKey)
            .compactMap { $0 }
            .compactMap { try? JSONDecoder().decode(MomentsDetails.self, from: $0) }
            .subscribe(momentsDetails) 
            // momentsDetails是一個ReplaySubject,所以它也是個中轉(zhuǎn)尸闸,等待別人的訂閱
            // 它又是個屬性彻亲,所以別人可以通過這個datastore找到它(來訂閱)
            .disposed(by: disposeBage)
    }
    func save(momentsDetails: MomentsDetails) {
        if let encodedData = try? JSONEncoder().encode(momentsDetails) {
            defaults.set(encodedData, forKey: momentsDetailsKey)
        }
    }
}
  • 因?yàn)?code>UserDefaultsPersistentDataStore遵循了PersistentDataStoreType協(xié)議,因此需要實(shí)現(xiàn)momentsDetails屬性和save()方法吮廉。

  • 其中momentsDetails屬性為 RxSwfitReplaySubject類型苞尝。它負(fù)責(zé)把數(shù)據(jù)的更新事件發(fā)送給訂閱者。

  • 在init()方法中宦芦,我們通過了 Key 來訂閱 UserDefaults 里的數(shù)據(jù)更新宙址,

    • 一旦與該 Key 相關(guān)聯(lián)的數(shù)據(jù)發(fā)生了變化,我們就使用JSONDecoder來把更新的數(shù)據(jù)解碼成MomentsDetails類型调卑,
    • 然后發(fā)送給momentsDetailsSubject 屬性抡砂。
    • 這樣momentsDetails屬性就可以把數(shù)據(jù)事件中轉(zhuǎn)給外部的訂閱者了大咱。

    MomentsRepoType

  protocol MomentsRepoType {
    var momentsDetails: ReplaySubject<MomentsDetails> { get }
    func getMoments(userID: String) -> Observable<Void>
    func updateLike(isLiked: Bool, momentID: String, fromUserID userID: String) -> Observable<Void>
}

用一個MomentsRepo來實(shí)現(xiàn)它,可以想見注益,這個momentsDetails必然來自于上面的DataStoremomentsDetails屬性:

init(persistentDataStore: PersistentDataStoreType,
             getMomentsByUserIDSession: GetMomentsByUserIDSessionType,
             updateMomentLikeSession: UpdateMomentLikeSessionType) {
    self.persistentDataStore = persistentDataStore
    self.getMomentsByUserIDSession = getMomentsByUserIDSession
    self.updateMomentLikeSession = updateMomentLikeSession
    persistentDataStore
        .momentsDetails // 在這里被訂閱
        .subscribe(momentsDetails) // 繼續(xù)分發(fā)
        .disposed(by: disposeBag)
}
// 業(yè)務(wù)邏輯的實(shí)現(xiàn)很明確碴巾,監(jiān)聽網(wǎng)絡(luò)回調(diào),存到datastore里
// 上面的代碼里丑搔,已經(jīng)把datastore里監(jiān)聽的特定數(shù)據(jù)通過repo廣播出去了
func getMoments(userID: String) -> Observable<Void> {
    return getMomentsByUserIDSession
        .getMoments(userID: userID)
        .do(onNext: { persistentDataStore.save(momentsDetails: $0) })
        .map { _ in () }
        .catchErrorJustReturn(())
}
func updateLike(isLiked: Bool, momentID: String, fromUserID userID: String) -> Observable<Void> {
    return updateMomentLikeSession
        .updateLike(isLiked, momentID: momentID, fromUserID: userID)
        .do(onNext: { persistentDataStore.save(momentsDetails: $0) })
        .map { _ in () }
        .catchErrorJustReturn(())
}

應(yīng)用:

momentsRepo.momentsDetails.subscribe(onNext: {
    // 接收并處理朋友圈數(shù)據(jù)更新
}).disposed(by: disposeBag)

RxSwift Subject

可以看到厦瓢,在 Repository 模塊里面,大量使用了 RxSwiftSubject 來中轉(zhuǎn)數(shù)據(jù)事件啤月。 回顧一下煮仇,在 RxSwift 里面,常見的 SubjectPublishSubject谎仲、BehaviorSubjectReplaySubject浙垫。

PublishSubject

PublishSuject 用于發(fā)布(Publish)事件,它的特點(diǎn)是訂閱者只能接收訂閱后的事件:

let publishSubject = PublishSubject<Int>()
publishSubject.onNext(1)
let observer1 = publishSubject.subscribe { event in
    print("observer1: \(event)")
}
observer1.disposed(by: disposeBag)
publishSubject.onNext(2)
let observer2 = publishSubject.subscribe { event in
    print("observer2: \(event)")
}
observer2.disposed(by: disposeBag)
publishSubject.onNext(3)
publishSubject.onCompleted()
publishSubject.onNext(4)

輸出:

observer1: next(2)
observer1: next(3)
observer2: next(3)
observer1: completed
observer2: completed
  • 首先强重,next(1)誰都收不到,因?yàn)樗l(fā)布的時候沒人訂閱
  • 其次贸人,next(4)誰也收不到间景,因?yàn)榘l(fā)布者發(fā)布了completed通知
  • 剩下的就是看訂閱時機(jī)了

順便回顧下disposeBag,相比訂閱者主動dispose艺智,他們并不知道什么時機(jī)可以去執(zhí)行倘要,所以有了disposeBag,由發(fā)布者通知訂閱者可以dispose

這只是一個形象的說法十拣,事實(shí)上如果是這樣的話封拧,發(fā)送complete通知已經(jīng)能達(dá)到效果了,總之由bag來管理就可以不需要手動dispose

這個訂閱時機(jī)之所以很重要夭问,是因?yàn)槿绻惆褦?shù)據(jù)變成訂閱機(jī)制泽西,那么你在產(chǎn)生數(shù)據(jù)的時候如果訂閱都還沒實(shí)例化好,那么就接收不到這些數(shù)據(jù)了(它不像是回調(diào)和promise)缰趋。

BehaviorSubject

BehaviorSubject 用于緩存一個事件捧杉,當(dāng)訂閱者訂閱 BehaviorSubject 時,會馬上收到該 Subject 里面最后一個事件秘血。

把上一節(jié)的代碼改成 BehaviorSubject

let behaviorSubject = BehaviorSubject<Int>(value: 1) // 注意這里味抖,有個初始值

輸出會變?yōu)椋?/p>

observer1: next(1) // 訂閱者1訂閱前的一條消息
observer1: next(2)
observer2: next(2) // 訂閱者2訂閱前的一條消息
observer1: next(3)
observer2: next(3)
observer1: completed
observer2: completed

ReplaySubject

BehaviorSubject 只能緩存一個事件,當(dāng)我們需要緩存 N 個事件時灰粮,就可以使用 ReplaySubject(所以這個方法顯然跟了一個參數(shù)N)仔涩。

let replaySubject = ReplaySubject<Int>.create(bufferSize: 2)
  • 我們的demo里最多就錯過兩個消息,所以你設(shè)為2的話粘舟,已經(jīng)可以打印出所有事件了熔脂。
  • 所以如果是網(wǎng)絡(luò)請求類的佩研,用一個BehaviorSubject一般是不會漏掉請求結(jié)果的,因?yàn)橐粋€請求自然只有一次響應(yīng)

回顧下上面的repository寫法里:

private(set) var momentsDetails: ReplaySubject<MomentsDetails> = .create(bufferSize: 1)
  • 可見锤悄,即使用了 ReplaySubject韧骗,我們也只緩存了一個事件,當(dāng) BehaviorSubject 用了零聚。但是
  • BehaviorSubject 需要一個初始值來初始化(占位值袍暴?),如果我沒辦法提供隶症,只能把存放的類型定義為 Optional (可空)類型政模,
  • 使用 ReplaySubject卻不需要(為什么replay下就不需要用optional了?)
  • 這就是實(shí)例代碼里即使只需要緩存一個事件蚂会,也選擇使用 ReplaySubject的原因

除了上面的三個 Subject 以外淋样,RxSwift 還為我們提供了兩個特殊的 Subject:PublishRelayBehaviorRelay,它們的名字和 BehaviorSubjectReplaySubject 非常類似胁住,區(qū)別是 Relay 只中繼next事件趁猴,我們并不能往 Relay 里發(fā)送completederror事件。

下面是一些在項(xiàng)目場景中使用 Subject 的經(jīng)驗(yàn)彪见,希望對你有幫助儡司。

  1. 如果需要把 Subject 傳遞給其他類型發(fā)送消息,例如在朋友圈時間軸列表頁面把 Subject 傳遞給各個朋友圈子組件余指,然后接收來自子組件的事件捕犬。 這種情況我們一般會傳遞 PublishSubject,因?yàn)樵趥鬟f前在主頁面(如例子中的朋友圈時間軸頁面)已經(jīng)訂閱了該 PublishSubject酵镜,子組件所發(fā)送事件碉碉,主頁面都能收到。
  2. BehaviorSubject 可用于狀態(tài)管理淮韭,例如管理頁面的加載狀態(tài)垢粮,開始時可以把 BehaviorSubject 初始化為加載狀態(tài),一旦得到返回?cái)?shù)據(jù)就可以轉(zhuǎn)換為成功狀態(tài)靠粪。
  3. 因?yàn)?BehaviorSubject 必須賦予初始值足丢,但有些情況下,我們并沒有初始化庇配,如果使用 BehaviorSubject 必須把其存放的類型定義為 Optional 類型斩跌。為了避免使用 Optional,我們可以使用 bufferSize 為 1 的 ReplaySubject 來代替 BehaviorSubject捞慌。
  4. Subject 和 Relay 都能用于中轉(zhuǎn)事件耀鸦,當(dāng)中轉(zhuǎn)的事件中沒有completed或error時,我們都選擇 Relay。

View Model 架構(gòu):如何準(zhǔn)備 UI 層的數(shù)據(jù)袖订?

  • 為了讓 UI 能正確顯示氮帐,我們需要把 Model 數(shù)據(jù)進(jìn)行轉(zhuǎn)換。
    • 例如洛姑,當(dāng)我們顯示圖片的時候上沐,需要把字符串類型的 URL 轉(zhuǎn)換成 iOS 所支持 URL 類型;當(dāng)顯示時間信息時楞艾,需要把 UTC 時間值轉(zhuǎn)換成設(shè)備所在的時區(qū)参咙。
  • 如果我們把所有類型轉(zhuǎn)換的邏輯都放在 UI/View 層里面,作為 View 層的 View Controller 往往會變得越來越臃腫硫眯。
  • 我們使用 MVVM 模式來轉(zhuǎn)化蕴侧,這里使用了RxSwift的Operator(操作符)
    image.png

ViewModel 模式的實(shí)現(xiàn)

首先看一下ListViewModel協(xié)議的定義。

  • 一個網(wǎng)絡(luò)請求的方法
  • 網(wǎng)絡(luò)請求結(jié)果(列表)两入,用一個behaviorSubject序列來廣播出去
  • 監(jiān)聽list映射了一個是否為空的判斷出來
  • 一個是否出錯的序列
  • 一個網(wǎng)絡(luò)請求的方法
protocol ListViewModel {
    var hasContent: Observable<Bool> { get }
    var hasError: BehaviorSubject<Bool> { get }
    func trackScreenviews()
    func loadItems() -> Observable<Void>
    var listItems: BehaviorSubject<[SectionModel<String, ListItemViewModel>]> { get }
}

注意, 這里把loadItemslistItems分開了, 盲猜下, 一個是行為, 一個是組織數(shù)據(jù), 具體得到代碼里找一下使用方法. 如果不這么做, 我們可能直接observe loadItems()的結(jié)果作為列表數(shù)據(jù)

extension ListViewModel {
    var hasContent: Observable<Bool> {
        return listItems
            .map(\.isEmpty) // 映射為了bool
            .distinctUntilChanged() // 只有值發(fā)生改變時才發(fā)送新事件
            .asObservable()
    }
}
  • 上面提供了一個hasContent的默認(rèn)實(shí)現(xiàn)
  • 這個方法使用mapdistinctUntilChanged操作符來把listItems轉(zhuǎn)換成 Bool 類型的hasContent净宵。
    • 也就是說它其實(shí)就是轉(zhuǎn)化的另一個序列(而這個序列是behaviorSubject,即數(shù)據(jù)來源另一個序列裹纳,還會把變化廣播出去)
    • 所以hasContent應(yīng)該也成了一個behaviorSubject

下面是列表的內(nèi)容(即listViewModel的內(nèi)容)

protocol ListItemViewModel {
    static var reuseIdentifier: String { get }
}
extension ListItemViewModel {
    static var reuseIdentifier: String {
        String(describing: self)
    }
}

這里連UITableViewCell所需要的idendifier也接管了择葡,真正做了了前端只管用(即期望是一個listItem對應(yīng)一個cell)

上述就是ListViewModel協(xié)議的定義,接下來看它的實(shí)現(xiàn)結(jié)構(gòu)體MomentsTimelineViewModel剃氧。

由于MomentsTimelineViewModel遵循了ListViewModel協(xié)議敏储,因此需要實(shí)現(xiàn)了該協(xié)議中listItemshasError屬性以及loadItems()trackScreenviews()方法。

我們首先看一下loadItems()方法的實(shí)現(xiàn)她我。

func loadItems() -> Observable<Void> {
    return momentsRepo.getMoments(userID: userID)
}

當(dāng) ViewModel 需要讀取數(shù)據(jù)的時候虹曙,會調(diào)用 Repository 模塊的組件迫横,在朋友圈功能中番舆,我們調(diào)用了MomentsRepoType的getMoments()方法來讀取數(shù)據(jù)。
接著看看trackScreenviews()方法的實(shí)現(xiàn)矾踱。在該方法里面恨狈,我們調(diào)用了TrackingRepoType的trackScreenviews()方法來發(fā)送用戶的行為數(shù)據(jù),具體實(shí)現(xiàn)如下呛讲。

func trackScreenviews() {
    trackingRepo.trackScreenviews(ScreenviewsTrackingEvent(screenName: L10n.Tracking.momentsScreen, screenClass: String(describing: self)))
 }

ViewModel 模塊的一個核心功能禾怠,是把 Model 數(shù)據(jù)轉(zhuǎn)換為用于 UI 呈現(xiàn)所需的 ViewModel 數(shù)據(jù),我通過下面代碼看它是怎樣轉(zhuǎn)換的贝搁。

func setupBindings() {
 momentsRepo.momentsDetails
     .map {
         [UserProfileListItemViewModel(userDetails: $0.userDetails)]
             + $0.moments.map { MomentListItemViewModel(moment: $0) }
     }
     .subscribe(onNext: {
         listItems.onNext([SectionModel(model: "", items: $0)])
     }, onError: { _ in
         hasError.onNext(true)
     })
     .disposed(by: disposeBag)
}

從代碼中你可以發(fā)現(xiàn)吗氏,我們訂閱了momentsRepo的momentsDetails屬性,接收來自 Model 的數(shù)據(jù)更新雷逆。因?yàn)樵搶傩缘念愋褪荕omentsDetails弦讽,而 View 層用所需的數(shù)據(jù)類型為ListItemViewModel。我們通過 map 操作符來進(jìn)行類型轉(zhuǎn)換,在轉(zhuǎn)換成功后往产,調(diào)用listItems的onNext()方法把準(zhǔn)備好的 ViewModel 數(shù)據(jù)發(fā)送給 UI被碗。如果發(fā)生錯誤,就通過hasError屬性發(fā)送出錯信息仿村。

  • 在 map 操作符的轉(zhuǎn)換過程中锐朴,我們分別使用了UserProfileListItemViewModelMomentListItemViewModel結(jié)構(gòu)體來轉(zhuǎn)換用戶簡介信息和朋友圈條目信息。
  • 這兩個結(jié)構(gòu)體都遵循了ListItemViewModel協(xié)議蔼囊。
  • map的實(shí)現(xiàn)很詭異焚志,直接一個+號,原來是因?yàn)樗鼈儗?shí)現(xiàn)的是同一個協(xié)議压真,等同于同類型娩嚼,干脆就把用戶信息變成信息流里的第一條數(shù)據(jù)(也就是說取的時候需要自行區(qū)分)

接下來是它們的實(shí)現(xiàn),首先看一下UserProfileListItemViewModel滴肿。

struct UserProfileListItemViewModel: ListItemViewModel {
    let name: String
    let avatarURL: URL?
    let backgroundImageURL: URL?
    init(userDetails: MomentsDetails.UserDetails) {
        name = userDetails.name
        avatarURL = URL(string: userDetails.avatar)
        backgroundImageURL = URL(string: userDetails.backgroundImage)
    }
}

MomentListItemViewModel:

init(moment: MomentsDetails.Moment, now: Date = Date(), relativeDateTimeFormatter: RelativeDateTimeFormatterType = RelativeDateTimeFormatter()) {
    userAvatarURL = URL(string: moment.userDetails.avatar)
    userName = moment.userDetails.name
    title = moment.title
    if let firstPhoto = moment.photos.first {
      photoURL = URL(string: firstPhoto)
    } else {
      photoURL = nil
    }
    // 以下不過實(shí)現(xiàn)是展示"5分鐘前"之類的需求
    var formatter = relativeDateTimeFormatter
    formatter.unitsStyle = .full
    if let timeInterval = TimeInterval(moment.createdDate) {
      let createdDate = Date(timeIntervalSince1970: timeInterval)
      postDateDescription = formatter.localiaedString(for: createdDate, relativeTo: now)
    } else {
      postDateDescription = nil
    }

關(guān)于RxSwift的操作符岳悟,可跳轉(zhuǎn)這個鏈接[[transfer, filter etc.|languages.ios.rxSwift#map]]

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市泼差,隨后出現(xiàn)的幾起案子贵少,更是在濱河造成了極大的恐慌,老刑警劉巖堆缘,帶你破解...
    沈念sama閱讀 206,839評論 6 482
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件滔灶,死亡現(xiàn)場離奇詭異,居然都是意外死亡吼肥,警方通過查閱死者的電腦和手機(jī)录平,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 88,543評論 2 382
  • 文/潘曉璐 我一進(jìn)店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來缀皱,“玉大人斗这,你說我怎么就攤上這事∑《罚” “怎么了表箭?”我有些...
    開封第一講書人閱讀 153,116評論 0 344
  • 文/不壞的土叔 我叫張陵,是天一觀的道長钮莲。 經(jīng)常有香客問我免钻,道長,這世上最難降的妖魔是什么崔拥? 我笑而不...
    開封第一講書人閱讀 55,371評論 1 279
  • 正文 為了忘掉前任极舔,我火速辦了婚禮,結(jié)果婚禮上链瓦,老公的妹妹穿的比我還像新娘拆魏。我一直安慰自己,他們只是感情好,可當(dāng)我...
    茶點(diǎn)故事閱讀 64,384評論 5 374
  • 文/花漫 我一把揭開白布稽揭。 她就那樣靜靜地躺著俺附,像睡著了一般。 火紅的嫁衣襯著肌膚如雪溪掀。 梳的紋絲不亂的頭發(fā)上事镣,一...
    開封第一講書人閱讀 49,111評論 1 285
  • 那天,我揣著相機(jī)與錄音揪胃,去河邊找鬼璃哟。 笑死,一個胖子當(dāng)著我的面吹牛喊递,可吹牛的內(nèi)容都是我干的随闪。 我是一名探鬼主播,決...
    沈念sama閱讀 38,416評論 3 400
  • 文/蒼蘭香墨 我猛地睜開眼骚勘,長吁一口氣:“原來是場噩夢啊……” “哼铐伴!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起俏讹,我...
    開封第一講書人閱讀 37,053評論 0 259
  • 序言:老撾萬榮一對情侶失蹤当宴,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后泽疆,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體户矢,經(jīng)...
    沈念sama閱讀 43,558評論 1 300
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 36,007評論 2 325
  • 正文 我和宋清朗相戀三年殉疼,在試婚紗的時候發(fā)現(xiàn)自己被綠了梯浪。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 38,117評論 1 334
  • 序言:一個原本活蹦亂跳的男人離奇死亡瓢娜,死狀恐怖挂洛,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情恋腕,我是刑警寧澤抹锄,帶...
    沈念sama閱讀 33,756評論 4 324
  • 正文 年R本政府宣布逆瑞,位于F島的核電站荠藤,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏获高。R本人自食惡果不足惜哈肖,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 39,324評論 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望念秧。 院中可真熱鬧淤井,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,315評論 0 19
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至漩绵,卻和暖如春贱案,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背止吐。 一陣腳步聲響...
    開封第一講書人閱讀 31,539評論 1 262
  • 我被黑心中介騙來泰國打工宝踪, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留,地道東北人碍扔。 一個月前我還...
    沈念sama閱讀 45,578評論 2 355
  • 正文 我出身青樓瘩燥,卻偏偏與公主長得像,于是被迫代替她去往敵國和親不同。 傳聞我的和親對象是個殘疾皇子厉膀,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 42,877評論 2 345

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