前言
我对 iOS 开发、手机开发、SwiftUI 开发经验有限,若有理解错误的部分欢迎指正。
从 UI 的角度来看,前端与手机开发会遇到问题是类似的,尽管使用的语言或是开发手法不尽相同,我们都需要打造一个易用的使用者介面。既然如此,彼此也会遇到类似的问题,元件化开发、状态管理、资料流、管理副作用(API 或是IO)等等,对我来说是个很适合互相学习的领域。
从过往的经验可以发现,像是 ReSwift[1](Redux 的中心思想)这样的函式库,或多或少也借鉴了前端不断演进的开发手法,不难看出双方会遇到的问题其实有类似的地方。
虽然这个时间点提起已经有点后话了,但还是想把我入门 SwiftUI 后的感想写下。
SwiftUI 与 React 的类似之处
我们可以将前端框架归纳为几个要素:
- 元件化
- 响应式机制
- 状态管理
- 事件监听
- 生命周期
在下面的段落中,我们也会以这几个主题为核心做讨论。为了不模糊焦点,我会尽可能只用 React 当做举例,但套用到其他前端框架原理应该也相同。
从 class 迈向 struct;从 class 迈向 function
在写 SwiftUI 的时候总是让我想到 React 的发展史。最初 React 建立元件的方式是透过 JavaScript 的 class 语法,每个 React 的元件都是一个类别。
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>
}
}
透过类别定义元件虽为前端元件化带来了很大的影响,但也因为繁琐的方法定义与 this 混淆,在 React16 hooks 出现之后,逐渐提倡使用 function component 与 hooks 的方式来建立元件。
省去了继承与各种 OO 的花式设计模式,建构元件的心智负担变得更小了。从 SwiftUI 当中我们也可以看到类似的演进,原本 ViewController 庞大的 class 以及职责,要负责 view 与 model 的互动,掌管生命周期,转为更轻量的 struct,让开发者可以更专注在 UI 互动上,减轻认知负担。
元件状态管理
React 16 采取了 hooks 来做元件的逻辑复用与状态管理,例如 useState。
const MyComponent = () => {
const [name, setName] = useState({ name: 'kalan' })
useEffect(() => { console.log('component is mounted') }, [])
return <div>my name is {name}</div>
}
在 SwiftUI 当中,我们可以透过修饰符 @State 让 View 也具有类似效果。两者都具备响应式机制,当状态变数发生改变时,React/Vue 会侦测改变并反映到画面当中。虽然不知道 SwiftUI 背后的实作,但背后应该也有类似 diff 机制的东西来达到响应式机制与最小更新的效果。
然而 SwiftUI 的状态管理与 React hooks 仍有差异。在 React 当中我们可以将 hook 拆成独立的函数,并且在不同的元件当中使用,例如:
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 当中,我们可以将 toggle 的逻辑拆出,并在不同元件之间使用。由于 useToggle 是一个纯函数,因此内部的状态也不会互相影响。
然而在 SwiftUI 当中 @State 只能作用在 struct 的 private var 当中,不能进一步拆出。如果想要将重复的逻辑抽出,需要另外使用 @Observable 与 @StateObject 这样的修饰符,另外建立一个类别来处理。
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!")
}
}
}
在这个例子当中把 toggle 的逻辑拆成一个 class 似乎有点小题大作了,不过仔细想想像 React 提供的 hook 功能,让轻量的逻辑共用就算单独拆成 hook 也不会觉得过于冗长,若要封装更复杂的逻辑也可以再拆分成更多 hooks,从这点来看 hook 的确是一个相当优秀的机制。后来看到了 SwiftUI-Hooks[2],不知道实际使用的效果如何。
以 React 来说,在还没有出现 hooks 之前,主要有三个方式来实作逻辑共用:
- HOC(Higher Order Component)[3]:将共同逻辑包装成函数后返回全新的 class,避免直接修改元件内部的实作。例如早期 react-redux 中的 connect。
- render props[4]:将实际渲染的元件当作属性(props)传入,并提供必要的参数供实作端使用。
- children function:children 只传入必要的参数,由实作端自行决定要渲染的元件。
尽管 hooks 用更优雅的方式解决逻辑共用的问题,我认为上面的开发手法演变也很值得参考。
Redux 与 TCA
受到 Redux 的影响,在 Swift 当中也有部分开发者使用了采用了类似手法,甚至也有相对应的实作 ReSwift的说明文。从说明文可以看到主要原因。传统的 ViewController 职责暧昧,容易变得肥大导致难以维护,透过 Reducer、Action、Store 订阅来确保单向资料流,所有的操作都是向 store dispatch 一个action,而资料的改动(mutation)则在 reducer 处理。
而最近的趋势似乎从 Redux 演变成了 TCA(The Composable Architecture),跟 Redux 的中心思想类似,更容易与 SwiftUI 整合,比较不一样的地方在于以往涉及 side effect 的操作在 Redux 当中会统一由 middleware 处理,而在 TCA 的架构中 reducer 可以回传一个 Effect,代表接收 action 时所要执行的 IO 操作或是 API 呼叫。
既然采用了类似 redux 的手法,不知道 SwiftUI 是否会遇到与前端开发类似的问题,例如 immutability 确保更新可以被感知;透过优化 subscribe 机制确保 store 更新时只有对应的元件会更新;reducer 与 action 带来的 boilerplate 问题。
虽然 Redux 在前端仍然具有一定地位,也仍然有许多公司正在导入,然而在前端也越来越多弃用 Redux 的声音,主要因为 redux 对 pure function 的追求以及 reducer、action 的重复性极高,在应用没有到一定复杂程度之前很难看出带来的好处,甚至连 Redux 作者本人也开始弃坑 redux 了 4。与此同时,react-redux 仍然有在持续更新,也推出了 redux-toolkit 来试图解决导入 redux 时常见的问题。
取而代之的是更加轻量的状态管理机制,在前端也衍生出了几个流派:
- GraphQL → 使用 apollo[5] 或是 relay[6]
- react-query[7]
- react-swr[8]
- recoil[9]
- jotai[10]
全域状态管理
在全域状态管理上,SwiftUI 也有内建机制叫做 @EnvrionmentObject,其运作机制很像 React 的 context,让元件可以跨阶层存取变数,当 context 改变时也会更新元件。
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())
从上面这个范例可以发现,我们不需要另外传入 user 给 UserInfo,透过 @EnvrionmentObject 可以拿到当前的 context。转换成 React 的话会像这样:
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 可让元件跨阶层存取变数,当 context 改变时也会更新元件。虽然有效避免了 prop drilling 的问题,然而 context 的存在会让测试比较麻烦一些,因为使用 context 时代表了某种程度的耦合。
响应机制
在 React 当中,状态或是 props 有变动时都会触发元件更新,透过框架实作的 diff 机制比较后反映到画面上。在 SwfitUI 中也可以看到类似的机制:
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)")
}
}
}
一个典型的 SwiftUI 元件是一个 struct,透过定义 body 变数来决定 UI。跟 React 相同,他们都只是对 UI 的抽象描述,透过比对资料结构计算最小差异后,再更新到画面上。
我还蛮想了解 SwiftUI 背后是怎么计算 diff 的,希望之后有类似的文章出现
@State 修饰符可用来定义元件内部状态,当状态改变时会更新并反映到画面中。
在 SwiftUI 当中,属性(MyView 当中的 name)可以由外部传入,跟 React 当中的属性(props)类似。
// 在其他 View 当中使用 MyView
struct ContentView: View {
var body: some View {
MyView(name: "kalan")
}
}
用 React 改写这个元件的话会像这样:
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 时会发现这跟以往用 UIKit、UIController 的开发方式不太一样。
列表
SwiftUI 与 React 当中都可以渲染列表,而撰写的方式也有雷同之处。在 SwiftUI 当中可以这样写:
struct TextListView: View {
var body: some View {
List {
ForEach([
"iPhone",
"Android",
"Mac"
], id: \.self) { value in
Text(value)
}
}
}
}
转成 React 大概会像这样子:
const TextList = () => {
const list = ['iPhone', 'Android', 'Mac']
return list.map(item => <p key={item}>{item}</p>)
}
在渲染列表时为了确保效能,减少不必要的比对,React 会要求开发者提供 key,而在 SwiftUI 当中也有类似的机制,开发者必须使用叫做 Identifiable[11] 的 protocol,或是显式地传入 id。
Binding
除了将变数绑定到画面之外,我们也可以将互动绑定到变数之中。例如在 SwiftUI 当中我们可以这样写:
struct MyInput: View {
@State private var text = ""
var body: some View {
TextField("Please type something", text: $text)
}
}
在这个范例当中,就算不监听输入事件,使用 $text 也可以直接改变 text 变数,当使用 @State 时会加入 property wrapper,会自动加入一个前缀 $,型别为 Binding[12]。
React 并没有双向绑定机制,必须要显式监听输入事件确保单向资料流。不过像 Vue、Svelte 都有双向绑定机制,节省开发者手动监听事件的成本。
Combine 的出现
虽然我对 Combine 还不够熟悉,但从官方文件与影片看起来,很像RxJS 的 Swift 特化版,提供的 API 与操作符大幅度地简化了复杂资料流。这让我想起了以前研究 RxJS 与 redux-observable 各种花式操作的时光,真令人怀念。
本质上的差异
前面提到那么多,然而网页与手机开发仍然有相当大的差异,其中对我来说最显著的一点是静态编译与动态执行。动态执行可以说是网页最大的特色之一。
只要有浏览器,JavaScript、HTML、CSS,不管在任何装置上都可以成功执行,网页不需要事先下载 1xMB ~ 几百 MB 的内容,可以动态执行脚本,根据浏览的页面动态载入内容。
由于不需要事先编译,任何人都可以看到网页的内容与执行脚本,加上 HTML 可以 streaming 的特性,可以一边渲染一边读取内容。难能可贵的一点是,网页是去中心化的,只要有伺服器、ip 位址与网域,任何人都可以存取网站内容;而 App 如果要上架必须事先通过审查。
不过两者的生态圈与开发手法有很大的不同,仍然建议参考一下彼此的发展,就算平时不会碰也没关系,从不同的角度看往往可以发现不同的事情,也可以培养对技术的敏锐度。
参考资料
[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