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

作成者:カランカラン
💡

質問やフィードバックがありましたら、フォームからお願いします

本文は台湾華語で、ChatGPT で翻訳している記事なので、不確かな部分や間違いがあるかもしれません。ご了承ください

前言

私は iOS 開発、モバイル開発、SwiftUI 開発の経験が限られているため、理解に誤りがあれば指摘していただけると幸いです。

UIの観点から見ると、フロントエンドとモバイル開発が直面する問題は似ています。使用する言語や開発手法は異なるかもしれませんが、使いやすいユーザーインターフェースを作成する必要があります。そのため、双方は似たような問題に直面することがあり、コンポーネント化開発、状態管理、データフロー、副作用の管理(APIやIOなど)は、互いに学び合うのに適した領域だと思います。

過去の経験から、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が登場した後は、関数コンポーネントとhooksを使用してコンポーネントを作成することが徐々に推奨されるようになりました。

継承やさまざまなOOのデザインパターンを省くことで、コンポーネントを構築する際の精神的負担が軽減されました。SwiftUIにおいても同様の進化が見られます。本来のViewControllerの巨大なクラスとその責任を、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のロジックをクラスに分離するのは少し大げさなように思えますが、Reactが提供するhook機能を考えると、軽量なロジックを共用するために別々のhookに分割しても過剰には感じません。より複雑なロジックをカプセル化したい場合は、さらに多くのhooksに分割することも可能です。この点から見ると、hookは非常に優れたメカニズムです。SwiftUIにも同様のメカニズムがあるのかどうかは分かりません。1

Reactの場合、hooksが登場する前は、ロジックの共用を実現するために主に3つの方法がありました:

  • HOC(Higher Order Component)2:共通のロジックを関数としてラップし、新しいクラスを返すことで、コンポーネント内部の実装を直接変更しないようにします。例えば、初期の react-reduxconnect などです。
  • render props3:実際にレンダリングされるコンポーネントをプロパティとして渡し、実装側で使用するために必要なパラメータを提供します。
  • children function:childrenに必要なパラメータだけを渡し、実装側がレンダリングするコンポーネントを決定します。

hooksは、ロジック共用の問題をより優雅に解決しましたが、上記の開発手法の進化も非常に参考になると考えています。

Redux と TCA

Reduxの影響を受けて、Swiftでも一部の開発者が同様の手法を用いています。さらには、対応する実装の説明文も存在します ReSwift。説明文から、伝統的なViewControllerが曖昧な責任を持ち、大きくなりすぎてメンテナンスが難しくなることが主な理由であることがわかります。Reducer、Action、Storeの購読を通じて単方向データフローを確保し、すべての操作はstoreにアクションをdispatchすることで行われ、データの変更(mutation)はreducerで処理されます。

最近のトレンドは、Reduxから TCA(The Composable Architecture)への移行のようです。これはReduxの中心思想に似ており、SwiftUIとの統合が容易です。違いとしては、以前はside effectを伴う操作はRedux内でmiddlewareが統一的に処理していましたが、TCAのアーキテクチャでは、reducerがEffectを返すことができ、アクションを受け取った際に実行するIO操作やAPI呼び出しを示します。

Reduxの手法を採用している以上、SwiftUIもフロントエンド開発と類似の問題に直面するのではないかと思います。たとえば、immutabilityによって更新が感知可能であることを確保することや、subscribeメカニズムを最適化してstoreの更新時に対応するコンポーネントのみが更新されるようにすること、reducerとactionによるボイラープレートの問題などです。

Reduxはフロントエンドの中で一定の地位を持っており、多くの企業が導入していますが、同時にReduxを使用しないという声も増えてきています。主な理由は、pure functionの追求や、reducerとactionの重複性の高さにあります。アプリケーションが一定の複雑さに達する前では、その利点を明確に感じることが難しいのです。実際、Reduxの作者自身もReduxから離れ始めています4。その一方で、react-reduxも引き続き更新され、redux-toolkitをリリースして、Redux導入時に一般的に見られる問題を解決しようとしています。

その代わりに、より軽量な状態管理メカニズムが登場し、フロントエンドでもいくつかの派閥が生まれています:

グローバル状態管理

グローバル状態管理においても、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().environmentObject(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では、状態や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や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というプロトコルを実装する必要があります。または、明示的にidを渡す必要があります。

Binding

変数を画面にバインドするだけでなく、インタラクションを変数にバインドすることもできます。例えば、SwiftUIでは次のように書くことができます:

struct MyInput: View {
  @State private var text = ""
  var body: some View {
    TextField("Please type something", text: $text)
  }
}

この例では、入力イベントを監視しなくても、$textを使用することでtext変数を直接変更できます。@Stateを使用すると、プロパティラッパーが追加され、前に $ が自動的に付加され、型はBindingになります。

Reactには双方向バインディングメカニズムがなく、単方向データフローを確保するために、明示的に入力イベントを監視する必要があります。しかし、VueやSvelteには双方向バインディングメカニズムがあり、開発者が手動でイベントを監視する手間を省いています。

Combine の登場

Combineについてはまだ十分に理解していませんが、公式ドキュメントやビデオを見る限り、RxJSのSwift特化版のように見えます。提供されるAPIやオペレーターは、複雑なデータフローを大幅に簡素化します。これは、以前にRxJSやredux-observableのさまざまな操作を研究していた時を思い出させます。非常に懐かしいです。

本質的な違い

これまで多くのことを述べましたが、ウェブとモバイル開発には依然としてかなりの違いがあります。私にとって最も顕著な点は、静的コンパイルと動的実行です。動的実行は、ウェブの最大の特徴の一つと言えるでしょう。

ブラウザがあれば、JavaScript、HTML、CSSは、どんなデバイスでも成功裏に実行できます。ウェブは事前に1MBから数百MBのコンテンツをダウンロードする必要がなく、スクリプトを動的に実行し、閲覧するページに応じてコンテンツを動的に読み込むことができます。

事前にコンパイルする必要がないため、誰でもウェブの内容と実行スクリプトを見ることができ、HTMLのストリーミング特性により、コンテンツを読み込みながら同時にレンダリングを行うことができます。さらに重要なのは、ウェブは非中央集権的であり、サーバー、IPアドレス、ドメインがあれば、誰でもウェブサイトのコンテンツにアクセスできることです。一方、アプリはリリース前に審査に合格する必要があります。

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

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

この記事が役に立ったと思ったら、下のリンクからコーヒーを奢ってくれると嬉しいです ☕ 私の普通の一日が輝かしいものになります ✨

Buy me a coffee