· 12分で読了

フロントエンドの視点から見る SwiftUI

この記事は中国語から自動翻訳されたものです。翻訳によりニュアンスが失われている場合があります。

はじめに

僕の iOS 開発、モバイル開発、SwiftUI 開発の経験は限られているため、理解に誤りがあればぜひ指摘してほしい。

UI の観点から見ると、フロントエンドとモバイル開発が直面する課題は似ている。使われる言語や開発手法は違っても、使いやすいユーザーインターフェースを作り上げる必要がある点では同じだ。そうであるなら、コンポーネント指向開発、状態管理、データフロー、副作用(API や I/O)の管理など、お互いに似たような問題に直面する。僕にとって、この二つは互いに学び合うのに非常に適した領域だ。

過去の経験から見ても、ReSwift(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 の混乱などの問題もあった。React 16 で hooks が登場して以降、徐々に function component と hooks を用いたコンポーネント作成が推奨されるようになった。

継承や様々なオブジェクト指向の凝ったデザインパターンが不要になり、コンポーネント構築の認知的負荷がずっと小さくなった。SwiftUI においても同様の進化が見て取れる。かつての ViewController が持っていた巨大なクラスと責務——View と Model の仲介やライフサイクルの管理——から、より軽量な struct へと移行し、開発者が UI のインタラクションに集中できるようになり、認知負荷が軽減されている。

コンポーネントの状態管理

React 16 では、useState などの hooks を採用してコンポーネントのロジック再利用と状態管理を行うようになった。

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 のロジックをクラスに切り出すのは少し大げさに思えるかもしれないが、React が提供する hook 機能をよく考えてみると、軽量なロジック共有であれば単独の hook に切り出しても冗長に感じず、より複雑なロジックをカプセル化したい場合もさらに多くの hooks へと分割できる。その点から見ると、hook は実に優れた仕組みだと言える。SwiftUI にも似たような仕組みがあるのだろうか。1

React について言えば、hooks が登場する前は、主に3つの方法でロジックの共有を実現していた:

  • HOC(Higher Order Component)2:共通ロジックを関数でラップして新しいクラスを返すことで、コンポーネント内部の実装を直接変更することを避ける。例えば初期の react-redux における connect。
  • render props3:実際にレンダリングするコンポーネントを属性(props)として渡し、必要なパラメータを実装側に提供する。
  • children function:children に必要なパラメータだけを渡し、実装側でレンダリングするコンポーネントを決定する。

hooks はロジック共有の課題をよりエレガントな方法で解決したが、上記のような開発手法の変遷も大いに参考になると思う。

Redux と TCA

Redux の影響を受けて、Swift でも一部の開発者が同様の手法を採用しており、それに対応する実装である ReSwift の説明文もある。その説明文から主な理由が読み取れる。従来の ViewController は責務が曖昧で肥大化しやすく、メンテナンスが困難になりがちだった。Reducer、Action、Store の購読を通じて単方向データフローを担保し、すべての操作は store に対して action を dispatch し、データの変更(mutation)は reducer で処理する。

そして最近のトレンドは Redux から TCA(The Composable Architecture) へと進化しているようだ。Redux の基本思想と似ており、SwiftUI と統合しやすくなっている。Redux と少し異なる点は、従来の Redux では副作用を伴う操作を一括して middleware で処理していたのに対し、TCA のアーキテクチャでは reducer が Effect を返すことができ、action を受け取ったときに実行すべき I/O 操作や API 呼び出しを表現する点だ。

Redux に似た手法を採用している以上、SwiftUI でもフロントエンド開発と似たような課題に直面するのではないだろうか。例えば、更新を確実に検知するための immutability の担保、store 更新時に対応するコンポーネントだけを更新させるための subscribe メカニズムの最適化、reducer と action による boilerplate の問題などだ。

フロントエンドにおいて Redux は依然として一定の地位を占めており、導入を進めている企業もまだ多くあるが、一方で Redux を脱却する声もますます増えている。主な原因は、Redux の pure function への徹底的なこだわりや、reducer と action の重複コードが非常に多く、アプリケーションがある程度の複雑さに達するまでは恩恵が見えにくいことにある。Redux の作者本人でさえ Redux から手を引き始めているほどだ4。その一方で、react-redux は現在もアップデートが続けられており、Redux 導入時によくある問題を解決するために redux-toolkit もリリースされた。

それに取って代わったのが、より軽量な状態管理の仕組みであり、フロントエンドでもいくつかの派閥が生まれている:

グローバル状態管理

グローバルな状態管理において、SwiftUI には @EnvironmentObject という組み込みの仕組みがある。その動作メカニズムは 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())

上の例を見るとわかるように、UserInfo に別途 user を渡す必要はなく、@EnvironmentObject を通じて現在の 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 では、state や props に変更があるとコンポーネントの更新がトリガーされ、フレームワークが実装する diff メカニズムによって比較された後、画面に反映される。SwiftUI でも同様の仕組みが見られる:

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 や UIViewController による開発スタイルとは全く異なることに気づかされる。

リスト表示

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 というプロトコルを実装するか、明示的に 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 となる。

React には双方向バインディングの仕組みはなく、単方向データフローを担保するために入力イベントを明示的にリッスンする必要がある。一方、Vue や Svelte などには双方向バインディングの仕組みが備わっており、開発者が手動でイベントをリッスンする手間を省いてくれる。

Combine の登場

僕はまだ Combine にそれほど精通していないが、公式ドキュメントや動画を見る限り、RxJS の Swift 特化版のように見える。提供されている API やオペレータは、複雑なデータフローを大幅に簡素化してくれる。かつて RxJS や redux-observable の様々なテクニックを研究していた日々を思い出し、実に懐かしい気持ちになる。

本質的な違い

ここまで多くの共通点を挙げてきたが、Web とモバイル開発には依然として極めて大きな違いがある。僕にとって最も顕著な違いは、静的コンパイルか動的実行かという点だ。動的実行は Web の最大の特徴の一つと言える。

ブラウザさえあれば、JavaScript、HTML、CSS はどんなデバイス上でも問題なく実行できる。Web は事前に十数 MB〜数百 MB のコンテンツをダウンロードする必要がなく、スクリプトを動的に実行し、閲覧しているページに応じて動的にコンテンツをロードできる。

事前コンパイルが不要であるため、誰でも Web のコンテンツや実行スクリプトを見ることができるし、HTML のストリーミング特性を活かしてレンダリングしながらコンテンツを読み込むことも可能だ。そして何より価値があるのは、Web が非中央集権的(分散型)であるという点だ。サーバー、IP アドレス、ドメインさえあれば、誰でも Web サイトのコンテンツにアクセスできる。これに対してアプリは、リリースする前に必ず審査を通過しなければならない。

とはいえ、両者のエコシステムや開発手法は大きく異なるものの、互いの動向を参考にすることは大いにお勧めしたい。普段触れることがなくても構わない。異なる視点から見ることで新たな発見があることも多いし、技術に対する感性を養うことにもつながるからだ。

Footnotes

  1. 後に SwiftUI-Hooks を見かけたが、実際の使用感がどうなのかは気になるところだ。 ↩

  2. https://zh-hant.reactjs.org/docs/higher-order-components.html ↩

  3. https://zh-hant.reactjs.org/docs/render-props.html ↩

  4. https://youtu.be/XEt09iK8IXs ↩

関連記事

他のトピックを探索