從前端視角看 SwiftUI
前言
我對 iOS 開發(fā)、手機(jī)開發(fā)、SwiftUI 開發(fā)經(jīng)驗(yàn)有限,若有理解錯(cuò)誤的部分歡迎指正。
從 UI 的角度來看,前端與手機(jī)開發(fā)會(huì)遇到問題是類似的,盡管使用的語言或是開發(fā)手法不盡相同,我們都需要打造一個(gè)易用的使用者介面。既然如此,彼此也會(huì)遇到類似的問題,元件化開發(fā)、狀態(tài)管理、資料流、管理副作用(API 或是IO)等等,對我來說是個(gè)很適合互相學(xué)習(xí)的領(lǐng)域。
從過往的經(jīng)驗(yàn)可以發(fā)現(xiàn),像是 ReSwift[1](Redux 的中心思想)這樣的函式庫,或多或少也借鑒了前端不斷演進(jìn)的開發(fā)手法,不難看出雙方會(huì)遇到的問題其實(shí)有類似的地方。
雖然這個(gè)時(shí)間點(diǎn)提起已經(jīng)有點(diǎn)后話了,但還是想把我入門 SwiftUI 后的感想寫下。
SwiftUI 與 React 的類似之處
我們可以將前端框架歸納為幾個(gè)要素:
- 元件化
- 響應(yīng)式機(jī)制
- 狀態(tài)管理
- 事件監(jiān)聽
- 生命周期
在下面的段落中,我們也會(huì)以這幾個(gè)主題為核心做討論。為了不模糊焦點(diǎn),我會(huì)盡可能只用 React 當(dāng)做舉例,但套用到其他前端框架原理應(yīng)該也相同。
從 class 邁向 struct;從 class 邁向 function
在寫 SwiftUI 的時(shí)候總是讓我想到 React 的發(fā)展史。最初 React 建立元件的方式是透過 JavaScript 的 class 語法,每個(gè) React 的元件都是一個(gè)類別。
class MyComponent extends React.Component {
constructor() {
this.state = {
name: 'kalan'
}
}
componentDidMount() {
console.log('component is mounted')
}
render() {
return <div>my name is {this.state.name}</div>
}
}
透過類別定義元件雖為前端元件化帶來了很大的影響,但也因?yàn)榉爆嵉姆椒ǘx與 this 混淆,在 React16 hooks 出現(xiàn)之后,逐漸提倡使用 function component 與 hooks 的方式來建立元件。
省去了繼承與各種 OO 的花式設(shè)計(jì)模式,建構(gòu)元件的心智負(fù)擔(dān)變得更小了。從 SwiftUI 當(dāng)中我們也可以看到類似的演進(jìn),原本 ViewController 龐大的 class 以及職責(zé),要負(fù)責(zé) view 與 model 的互動(dòng),掌管生命周期,轉(zhuǎn)為更輕量的 struct,讓開發(fā)者可以更專注在 UI 互動(dòng)上,減輕認(rèn)知負(fù)擔(dān)。
元件狀態(tài)管理
React 16 采取了 hooks 來做元件的邏輯復(fù)用與狀態(tài)管理,例如 useState。
const MyComponent = () => {
const [name, setName] = useState({ name: 'kalan' })
useEffect(() => { console.log('component is mounted') }, [])
return <div>my name is {name}</div>
}
在 SwiftUI 當(dāng)中,我們可以透過修飾符 @State 讓 View 也具有類似效果。兩者都具備響應(yīng)式機(jī)制,當(dāng)狀態(tài)變數(shù)發(fā)生改變時(shí),React/Vue 會(huì)偵測改變并反映到畫面當(dāng)中。雖然不知道 SwiftUI 背后的實(shí)作,但背后應(yīng)該也有類似 diff 機(jī)制的東西來達(dá)到響應(yīng)式機(jī)制與最小更新的效果。
然而 SwiftUI 的狀態(tài)管理與 React hooks 仍有差異。在 React 當(dāng)中我們可以將 hook 拆成獨(dú)立的函數(shù),并且在不同的元件當(dāng)中使用,例如:
function useToggle(initialValue) {
const [toggle, set] = useState(initialValue)
const setToggle = useCallback(() => { set((state) => !state) }, [toggle])
useEffect(() => { console.log('toggle is set') }, [toggle])
return [toggle, setToggle]
}
const MyComponent = () => {
const [toggle, setToggle] = useToggle(false)
return <button onClick={() => setToggle()}>Click me</button>
}
const MyToggle = () => {
const [toggle, setToggle] = useToggle(true)
return <button onClick={() => setToggle()}>Toggle, but fancy one</button>
}
在 React 當(dāng)中,我們可以將 toggle 的邏輯拆出,并在不同元件之間使用。由于 useToggle 是一個(gè)純函數(shù),因此內(nèi)部的狀態(tài)也不會(huì)互相影響。
然而在 SwiftUI 當(dāng)中 @State 只能作用在 struct 的 private var 當(dāng)中,不能進(jìn)一步拆出。如果想要將重復(fù)的邏輯抽出,需要另外使用 @Observable 與 @StateObject 這樣的修飾符,另外建立一個(gè)類別來處理。
class ToggleUtil: ObservableObject {
@Published var toggle = false
func setToggle() {
self.toggle = !self.toggle
}
}
struct ContentView: View {
@StateObject var toggleUtil = ToggleUtil()
var body: some View {
Button("Text") {
toggleUtil.setToggle()
}
if toggleUtil.toggle {
Text("Show me!")
}
}
}
在這個(gè)例子當(dāng)中把 toggle 的邏輯拆成一個(gè) class 似乎有點(diǎn)小題大作了,不過仔細(xì)想想像 React 提供的 hook 功能,讓輕量的邏輯共用就算單獨(dú)拆成 hook 也不會(huì)覺得過于冗長,若要封裝更復(fù)雜的邏輯也可以再拆分成更多 hooks,從這點(diǎn)來看 hook 的確是一個(gè)相當(dāng)優(yōu)秀的機(jī)制。后來看到了 SwiftUI-Hooks[2],不知道實(shí)際使用的效果如何。
以 React 來說,在還沒有出現(xiàn) hooks 之前,主要有三個(gè)方式來實(shí)作邏輯共用:
- HOC(Higher Order Component)[3]:將共同邏輯包裝成函數(shù)后返回全新的 class,避免直接修改元件內(nèi)部的實(shí)作。例如早期 react-redux 中的 connect。
- render props[4]:將實(shí)際渲染的元件當(dāng)作屬性(props)傳入,并提供必要的參數(shù)供實(shí)作端使用。
- children function:children 只傳入必要的參數(shù),由實(shí)作端自行決定要渲染的元件。
盡管 hooks 用更優(yōu)雅的方式解決邏輯共用的問題,我認(rèn)為上面的開發(fā)手法演變也很值得參考。
Redux 與 TCA
受到 Redux 的影響,在 Swift 當(dāng)中也有部分開發(fā)者使用了采用了類似手法,甚至也有相對應(yīng)的實(shí)作 ReSwift的說明文。從說明文可以看到主要原因。傳統(tǒng)的 ViewController 職責(zé)曖昧,容易變得肥大導(dǎo)致難以維護(hù),透過 Reducer、Action、Store 訂閱來確保單向資料流,所有的操作都是向 store dispatch 一個(gè)action,而資料的改動(dòng)(mutation)則在 reducer 處理。
而最近的趨勢似乎從 Redux 演變成了 TCA(The Composable Architecture),跟 Redux 的中心思想類似,更容易與 SwiftUI 整合,比較不一樣的地方在于以往涉及 side effect 的操作在 Redux 當(dāng)中會(huì)統(tǒng)一由 middleware 處理,而在 TCA 的架構(gòu)中 reducer 可以回傳一個(gè) Effect,代表接收 action 時(shí)所要執(zhí)行的 IO 操作或是 API 呼叫。
既然采用了類似 redux 的手法,不知道 SwiftUI 是否會(huì)遇到與前端開發(fā)類似的問題,例如 immutability 確保更新可以被感知;透過優(yōu)化 subscribe 機(jī)制確保 store 更新時(shí)只有對應(yīng)的元件會(huì)更新;reducer 與 action 帶來的 boilerplate 問題。
雖然 Redux 在前端仍然具有一定地位,也仍然有許多公司正在導(dǎo)入,然而在前端也越來越多棄用 Redux 的聲音,主要因?yàn)?redux 對 pure function 的追求以及 reducer、action 的重復(fù)性極高,在應(yīng)用沒有到一定復(fù)雜程度之前很難看出帶來的好處,甚至連 Redux 作者本人也開始棄坑 redux 了 4。與此同時(shí),react-redux 仍然有在持續(xù)更新,也推出了 redux-toolkit 來試圖解決導(dǎo)入 redux 時(shí)常見的問題。
取而代之的是更加輕量的狀態(tài)管理機(jī)制,在前端也衍生出了幾個(gè)流派:
- GraphQL → 使用 apollo[5] 或是 relay[6]
- react-query[7]
- react-swr[8]
- recoil[9]
- jotai[10]
全域狀態(tài)管理
在全域狀態(tài)管理上,SwiftUI 也有內(nèi)建機(jī)制叫做 @EnvrionmentObject,其運(yùn)作機(jī)制很像 React 的 context,讓元件可以跨階層存取變數(shù),當(dāng) context 改變時(shí)也會(huì)更新元件。
class User: ObservableObject {
@Published var name = "kalan"
@Published var age = 20
}
struct UserInfo: View {
@EnvironmentObject var user: User
var body: some View {
Text(user.name)
Text(String(user.age))
}
}
struct ContentView: View {
var body: some View {
UserInfo()
}
}
ContentView().envrionmentObject(User())
從上面這個(gè)范例可以發(fā)現(xiàn),我們不需要另外傳入 user 給 UserInfo,透過 @EnvrionmentObject 可以拿到當(dāng)前的 context。轉(zhuǎn)換成 React 的話會(huì)像這樣:
const userContext = createContext({})
const UserInfo = () => {
const { name, age } = useContext(userContext)
return <>
<p>{name}</p>
<p>{age}</p>
</>
}
const App = () => {
<userContext.Provider value={{ user: 'kalan', age: 20 }}>
<UserInfo />
</userContext.Provider>
}
React 的 context 可讓元件跨階層存取變數(shù),當(dāng) context 改變時(shí)也會(huì)更新元件。雖然有效避免了 prop drilling 的問題,然而 context 的存在會(huì)讓測試比較麻煩一些,因?yàn)槭褂?context 時(shí)代表了某種程度的耦合。
響應(yīng)機(jī)制
在 React 當(dāng)中,狀態(tài)或是 props 有變動(dòng)時(shí)都會(huì)觸發(fā)元件更新,透過框架實(shí)作的 diff 機(jī)制比較后反映到畫面上。在 SwfitUI 中也可以看到類似的機(jī)制:
struct MyView: View {
var name: String
@State private var isHidden = false
var body: some View {
Toggle(isOn: $isHidden) {
Text("Hidden")
}
Text("Hello world")
if !isHidden {
Text("Show me \(name)")
}
}
}
一個(gè)典型的 SwiftUI 元件是一個(gè) struct,透過定義 body 變數(shù)來決定 UI。跟 React 相同,他們都只是對 UI 的抽象描述,透過比對資料結(jié)構(gòu)計(jì)算最小差異后,再更新到畫面上。
我還蠻想了解 SwiftUI 背后是怎么計(jì)算 diff 的,希望之后有類似的文章出現(xiàn)
@State 修飾符可用來定義元件內(nèi)部狀態(tài),當(dāng)狀態(tài)改變時(shí)會(huì)更新并反映到畫面中。
在 SwiftUI 當(dāng)中,屬性(MyView 當(dāng)中的 name)可以由外部傳入,跟 React 當(dāng)中的屬性(props)類似。
// 在其他 View 當(dāng)中使用 MyView
struct ContentView: View {
var body: some View {
MyView(name: "kalan")
}
}
用 React 改寫這個(gè)元件的話會(huì)像這樣:
const MyView = ({ name }) => {
const [isHidden, setIsHidden] = useState(false)
return <div>
<button onClick={() => setIsHidden(state => !state)}>hidden</button>
<p>Hello world</p>
{isHidden ? null : `show me ${name}`}
</div>
}
在撰寫 SwiftUI 時(shí)會(huì)發(fā)現(xiàn)這跟以往用 UIKit、UIController 的開發(fā)方式不太一樣。
列表
SwiftUI 與 React 當(dāng)中都可以渲染列表,而撰寫的方式也有雷同之處。在 SwiftUI 當(dāng)中可以這樣寫:
struct TextListView: View {
var body: some View {
List {
ForEach([
"iPhone",
"Android",
"Mac"
], id: \.self) { value in
Text(value)
}
}
}
}
轉(zhuǎn)成 React 大概會(huì)像這樣子:
const TextList = () => {
const list = ['iPhone', 'Android', 'Mac']
return list.map(item => <p key={item}>{item}</p>)
}
在渲染列表時(shí)為了確保效能,減少不必要的比對,React 會(huì)要求開發(fā)者提供 key,而在 SwiftUI 當(dāng)中也有類似的機(jī)制,開發(fā)者必須使用叫做 Identifiable[11] 的 protocol,或是顯式地傳入 id。
Binding
除了將變數(shù)綁定到畫面之外,我們也可以將互動(dòng)綁定到變數(shù)之中。例如在 SwiftUI 當(dāng)中我們可以這樣寫:
struct MyInput: View {
@State private var text = ""
var body: some View {
TextField("Please type something", text: $text)
}
}
在這個(gè)范例當(dāng)中,就算不監(jiān)聽輸入事件,使用 $text 也可以直接改變 text 變數(shù),當(dāng)使用 @State 時(shí)會(huì)加入 property wrapper,會(huì)自動(dòng)加入一個(gè)前綴 $,型別為 Binding[12]。
React 并沒有雙向綁定機(jī)制,必須要顯式監(jiān)聽輸入事件確保單向資料流。不過像 Vue、Svelte 都有雙向綁定機(jī)制,節(jié)省開發(fā)者手動(dòng)監(jiān)聽事件的成本。
Combine 的出現(xiàn)
雖然我對 Combine 還不夠熟悉,但從官方文件與影片看起來,很像RxJS 的 Swift 特化版,提供的 API 與操作符大幅度地簡化了復(fù)雜資料流。這讓我想起了以前研究 RxJS 與 redux-observable 各種花式操作的時(shí)光,真令人懷念。
本質(zhì)上的差異
前面提到那么多,然而網(wǎng)頁與手機(jī)開發(fā)仍然有相當(dāng)大的差異,其中對我來說最顯著的一點(diǎn)是靜態(tài)編譯與動(dòng)態(tài)執(zhí)行。動(dòng)態(tài)執(zhí)行可以說是網(wǎng)頁最大的特色之一。
只要有瀏覽器,JavaScript、HTML、CSS,不管在任何裝置上都可以成功執(zhí)行,網(wǎng)頁不需要事先下載 1xMB ~ 幾百 MB 的內(nèi)容,可以動(dòng)態(tài)執(zhí)行腳本,根據(jù)瀏覽的頁面動(dòng)態(tài)載入內(nèi)容。
由于不需要事先編譯,任何人都可以看到網(wǎng)頁的內(nèi)容與執(zhí)行腳本,加上 HTML 可以 streaming 的特性,可以一邊渲染一邊讀取內(nèi)容。難能可貴的一點(diǎn)是,網(wǎng)頁是去中心化的,只要有伺服器、ip 位址與網(wǎng)域,任何人都可以存取網(wǎng)站內(nèi)容;而 App 如果要上架必須事先通過審查。
不過兩者的生態(tài)圈與開發(fā)手法有很大的不同,仍然建議參考一下彼此的發(fā)展,就算平時(shí)不會(huì)碰也沒關(guān)系,從不同的角度看往往可以發(fā)現(xiàn)不同的事情,也可以培養(yǎng)對技術(shù)的敏銳度。
參考資料
[1] ReSwift: https://github.com/ReSwift/ReSwift
[2] SwiftUI-Hooks: https://github.com/ra1028/SwiftUI-Hooks
[3] HOC(Higher Order Component): https://zh-hant.reactjs.org/docs/higher-order-components.html
[4] render props: https://zh-hant.reactjs.org/docs/render-props.html
[5] apollo: https://github.com/apollographql/apollo-client
[6] relay: https://relay.dev/
[7] react-query: https://react-query.tanstack.com/
[8] react-swr: https://swr.vercel.app/zh-CN
[9] recoil: https://recoiljs.org/
[10] jotai: https://github.com/pmndrs/jotai
[11] Identifiable: https://developer.apple.com/documentation/swift/identifiable
[12] Binding: https://developer.apple.com/documentation/swiftui/binding