cockscomblog?

cockscomb on hatena blog

huggingface/transformersでストリーミング

AIにテキストを生成させるサービスでは、テキスト生成の速度が遅いとユーザー体験がよくない。ChatGPTは、生成されたテキストが一度に表示されるのではなく、少しずつ表示されるUIになっている。このことで、テキスト生成の完了まで待たずに結果を確かめられる。

テキスト生成の結果を少しずつ表示するためには、生成途中のテキストがストリーミングで返される必要がある。OpenAIのAPIにはstreamオプションがあり、有効にするとserver-sent eventsで結果を返してくれる。

huggingface/transformersのStreamer

OpenAI APIではなく、自分たちでhuggingface/transformersを動かしている場合、ストリーミングは困難だった。transformersにはストリーミングをサポートするAPIがなかったためである。

ところが、最近になってこれをサポートする実装が入ってきた。Hugging FaceのJoão Ganteさんが精力的に取り組んでいるようだ。

ただし今日(2023年4月10日)時点ではまだリリースされておらず、おそらくv4.28.0以降でリリースされるものと思われる。今回はソースからtransformersをインストールすることで、ストリーミングを試してみる。

transformersのストリーミング機能は、ドキュメントのUtilities for Generationで紹介されており、TextStreamerあるいはTextIteratorStreamermodel.generateに渡す形で利用する。TextStreamerは標準出力に書き出すもので、TextIteratorStreamerイテレータとして利用するものだ。

サーバーからストリーミングする

実際にFastAPIを使ったサーバーでTextIteratorStreamerを使う。まずは次のように、非同期ジェネレータとして関数generateを定義する。

import asyncio
from threading import Thread
from typing import AsyncIterator

from transformers import (
    AutoTokenizer,
    AutoModelForCausalLM,
    TextIteratorStreamer,
)

tokenizer = AutoTokenizer.from_pretrained("rinna/japanese-gpt2-medium", use_fast=False)
tokenizer.do_lower_case = True

model = AutoModelForCausalLM.from_pretrained("rinna/japanese-gpt2-medium")


async def generate(text: str) -> AsyncIterator[str]:
    inputs = tokenizer(text, add_special_tokens=False, return_tensors="pt")

    streamer = TextIteratorStreamer(tokenizer)
    generation_kwargs = dict(
        **inputs,
        streamer=streamer,
        max_new_tokens=100,
        do_sample=True,
        top_k=50,
        top_p=0.95,
        temperature=0.9,
        pad_token_id=tokenizer.pad_token_id,
        bos_token_id=tokenizer.bos_token_id,
        eos_token_id=tokenizer.eos_token_id,
        bad_words_ids=[[tokenizer.bos_token_id]],
        num_return_sequences=1,
    )
    thread = Thread(target=model.generate, kwargs=generation_kwargs)
    thread.start()

    for output in streamer:
        if not output:
            continue
        await asyncio.sleep(0)
        yield output

TextIteratorStreamermodel.generateに渡している。ドキュメントに倣ってmodel.generateは別スレッドで実行し、TextIteratorStreamerイテレータとして回してyieldしている。TextIteratorStreamerは生成されたテキストを内部である程度バッファリングして、きりのよいところで返してくれる。ややワークアラウンドのようにも見えるが、ループの中でasyncio.sleepを行って、他の処理をブロッキングしないようにする。

FastAPIのエンドポイントからは次のようにStreamingResponseを返す。

from fastapi import FastAPI
from fastapi.responses import StreamingResponse
from pydantic import BaseModel

from server.generator import generate

app = FastAPI()


class GenerateInput(BaseModel):
    text: str


@app.post("/generate")
async def generate_post(generate_input: GenerateInput):
    return StreamingResponse(generate(generate_input.text), media_type="text/plain")

StreamingResponseは非同期イテレータを受け取るので、これで十分機能する。この実装では単純にtext/plainのレスポンスがストリームで返るので、クライアントが特別に対応していない場合でも互換性がある。より構造化されたレスポンスを返したい場合は、server-sent eventsとして返す方がよさそうだ。

フロントエンドでストリーミングされたデータを表示する。

フロントエンドでもストリーミングで表示するには少し工夫が要る。次のように非同期ジェネレータを書く。

async function* generate(text: string): AsyncGenerator<string> {
  const res = await fetch('https://example.com/generate', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ text }),
  })
  const reader = res.body?.getReader()
  if (!reader) {
    return
  }
  const decoder = new TextDecoder()
  while (true) {
    const { value, done } = await reader.read()
    if (done) {
      break
    }
    yield decoder.decode(value)
  }
}

普通ならawait res.text()とするところで、本文の読み込みストリームを取得する。そこから逐次的に値を読み取って、TextDecoderでデコードしつつyieldしていく。

Reactなら、簡略化した例だが、次のように使う。

'use client'

import React from 'react'

async function* generate(text: string): AsyncGenerator<string> {
  (省略)
}

export function Generator() {
  const [input, setInput] = React.useState('')
  const onChange: React.ChangeEventHandler<HTMLInputElement> = (event) => {
    setInput(event.currentTarget.value)
  }
  const [output, setOutput] = React.useState('')
  const onSubmit: React.FormEventHandler<HTMLFormElement> = async (event) => {
    event.preventDefault()
    setOutput('')
    for await (const chunk of generate(input)) {
      setOutput((prev) => prev + chunk)
    }
  }
  return (
    <div>
      <form onSubmit={onSubmit}>
        <input onChange={onChange} />
        <button type="submit">
          Generate
        </button>
      </form>
      <div>{output}</div>
    </div>
  )
}

for await...ofで非同期ジェネレータから情報を読み出している。

Time to First Byte (TTFB)

ストリーミングの様子は、Webインスペクタのネットワークタブでも確かめられる。ストリーミングしないと「待機中」のまま長く待たされるが、ストリーミングすると「待機中」はすぐに終わり、「ダウンロード」が続く。つまりTTFBが改善されると言い換えられる。

ストリーミングしない場合は待機中が続く

ストリーミングすると待機中が短くなる

いかがでしたか

huggingface/transformersのStreamerによって、簡単にテキスト生成結果をストリーミングできることがわかった。まだリリースされていない機能で、リリースまでにAPIが変わるかもしれないが、リリースされるのが楽しみだ。

XCFrameworkをSwift PackageとしてGitHubでリリースする

以前、XCFrameworkをバイナリターゲットとしてSwift Packageに埋め込んだライブラリを作った。

これを容易に利用するためには、ビルド済みのXCFrameworkをどこかにアップロードして、それを参照するSwift Packageを公開するとよい。XCFrameworkはGitHubリリースに成果物として置いておくことにする。

ということで、前回は概ねこれを手作業でやったのだけど、手作業したくないのでGitHub Actionsにした。

リリースが自動化されているソフトウェアは信頼性が高い。

GitHub Actionsでバイナリターゲットを含むSwift Packageをリリース

自動化にあたって手順を書き出してみると、次のようになる。ややこしい。

  1. XCFrameworkをビルドしてアーカイブする
  2. XCFrameworkのチェックサムを計算
  3. バージョン番号を決める
  4. Package.swiftを書き換える
  5. Package.swiftをコミット
  6. タグを打つ
  7. GitHubでリリースを作成

実際のActionはこうなる。

順を追ってやっていく。

XCFrameworkをビルドしてアーカイブする

これは普通にやったらいい。今回はgomobileを使っているのでちょっと変な感じではある。Makefileに書いてある。

.PHONY: install-tools
install-tools:
    go install golang.org/x/mobile/cmd/gomobile@latest
    gomobile init

.PHONY: xcframework zip
xcframework: install-tools
    gomobile bind -target=ios,iossimulator,macos,maccatalyst -iosversion 14 -prefix GOJQ -o Frameworks/GOJQBinding.xcframework github.com/cockscomb/swift-gojq/binding

zip: xcframework
    zip -X -r Frameworks/GOJQBinding.xcframework.zip Frameworks/GOJQBinding.xcframework/

XCFrameworkのチェックサムを計算

Swift Packageのバイナリターゲットにはチェックサムが必要なので計算する。swift package compute-checksum するだけ。

- name: Compute checksum
  id: checksum
  run: |
    echo "checksum=$(swift package compute-checksum Frameworks/GOJQBinding.xcframework.zip)" >> $GITHUB_OUTPUT

バージョン番号を決める

mathieudutour/github-tag-action を使った。major とか minor とか patch とかで一つ前のバージョン番号から決めることにして、workflow_dispatchinput で選べる感じにしている。

on:
  workflow_dispatch:
    inputs:
      bump:
        description: 'Bump version'
        required: true
        default: 'patch'
        type: choice
        options:
        - major
        - minor
        - patch
- name: Calculate next version
  id: next_version
  uses: mathieudutour/github-tag-action@v6.1
  with:
    github_token: ${{ secrets.GITHUB_TOKEN }}
    default_bump: ${{ github.event.inputs.bump }}
    tag_prefix: ''
    dry_run: true

バージョンを先に決めておかないとリリースのURLが決まらないので、先にバージョンを決める。dry_run があって便利。

Package.swiftを書き換える

ここがいちばんトリッキー。リリースのURLとチェックサムPackage.swift に書き込まないといけない。Package.swift のテンプレートを用意してレンダリングするか、すでにある Package.swift を書き換えるかだが、テンプレートを管理するのが面倒なので後者を選ぶ。

丁寧にSwift Package Pluginを作ってある。Swift Syntaxで Package.swift を書き換える。

Swift SyntaxによるPackage.swiftの書き換え

Swift Syntaxでは、SyntaxRewriter を継承して適当な visit(_:) メソッドを実装すると、ASTを辿って必要な箇所を書き換えられる。Sources/swift-package-checksum-rewriter/BinaryTargetSourceRewriter.swift として実装している。書き換え前後で余計な差分が出にくいように Trivia というやつで改行や空白を丁寧に調整している。(それもあってSwiftSyntaxBuilderがあまり思うように使えていない。)

これを executableTarget にしておいて、Swift Package Pluginからは実行ファイルとして呼び出す。だからプラグインとしての実装は次の通りで、ただのラッパーになった。

import Foundation
import PackagePlugin

@main
struct RewriteChecksumCommandPlugin: CommandPlugin {
    func performCommand(context: PluginContext, arguments: [String]) async throws {
        let tool = try context.tool(named: "swift-package-checksum-rewriter")
        let process = try Process.run(URL(fileURLWithPath: tool.path.string), arguments: arguments)
        process.waitUntilExit()
    }
}

使い方

これを使うには Package.swift に依存を追加する。

     dependencies: [
        .package(url: "https://github.com/cockscomb/swift-package-checksum-rewriter", from: "0.1.0"),
    ],

あとは swift package コマンドから呼び出すだけ。--allow-writing-to-package-directory するのがコツ。

- name: Rewrite Package.swift
  run: |
    swift package \
      --allow-writing-to-package-directory \
      rewrite-package-binary-target \
      --url=https://github.com/cockscomb/swift-gojq/releases/download/${{ steps.next_version.outputs.new_tag }}/GOJQBinding.xcframework.zip \
      --checksum=${{ steps.checksum.outputs.checksum }} \
      Package.swift \
      GOJQBinding

ここが一番面倒だった。

Package.swiftをコミット

ここはどうやってもいい。今回は stefanzweifel/git-auto-commit-action を使った。簡単。

- uses: stefanzweifel/git-auto-commit-action@v4
  with:
    commit_message: Bump version to ${{ steps.next_version.outputs.new_tag }}

タグを打つ

さっきは dry_run したけど今回が本番。

- name: Bump version
  id: bump_version
  uses: mathieudutour/github-tag-action@v6.1
  with:
    github_token: ${{ secrets.GITHUB_TOKEN }}
    default_bump: ${{ github.event.inputs.bump }}
    tag_prefix: ''

GitHubでリリースを作成

actions/upload-release-assetアーカイブされているので、案内に従って softprops/action-gh-release を使う。これも簡単。

- name: Release
  uses: softprops/action-gh-release@v1
  with:
    tag_name: ${{ steps.next_version.outputs.new_tag }}
    body: ${{ steps.next_version.outputs.changelog }}
    files: Frameworks/GOJQBinding.xcframework.zip

完成

結構ややこしい感じだけど、これでバイナリターゲットを含むSwift Packageを自動的にリリースできるようになった。

ちょっとやったらできるだろうと思って作業し始めたけど、Package.swiftの書き換えがややこしくて思ったより大変だった。

React Server ComponentsとGraphQLは競合するか

Next.jsのapp directoryについて話していて、GraphQLを使う場面ではServer Componentsの魅力がいくらか落ちるよな、と思った。裏を返せば、Server Componentsが活用されるような時代ではGraphQLの重要度が下がるかもしれない。

現にServer ComponentsのRFCの「Credits and Prior Artを見ると次のように書いてある。

  • Relay’s data-driven dependencies, which allow the server to dynamically choose which Client Component to use.
  • GraphQL, for demonstrating one approach to avoiding multiple round trips when loading data for client-side apps.

GraphQLやRelayは「Prior Art」だ。

GraphQLが強力な理由に、データのグラフがそのまま扱えることや、フラグメントによってコンポーネントが必要とするデータを宣言的に表現できることがある。これらによって解決された課題は、Server Componentsでも解決できる。Server Componentsはサーバーで構築されるから、Web APIを複数回にわたって呼び出す際のRTTが十分に短くなるし、それぞれのコンポーネントで必要なデータを個別に取得してもそれなりにいい感じになる。

ということで、Server Componentsを前提とすると、GraphQLによって得られていた利益(の一部)が他の手段でも叶えられることになる。またはGraphQLを前提とすると、Server Componentsによって得られるはずの利益が多少は目減りする。

もちろんServer Componentsによって達成されることは他にもあるし、GraphQLにも他にメリットがある。

GraphQLは引き続き有力

GraphQLの重要なメリットに、オーバーフェッチングを防ぐことがある。またエコシステムの発展のおかげで、ツーリングも優れている。ネイティブアプリからも利用でき、ひとつのAPIで多様なクライアントをサポートできる。GraphQLはWeb APIを構築する際に、引き続き有力な選択肢である。

またGraphQLの側も@defer@streamディレクティブによって、Next.jsのStreamingと同じ課題を解決しようとしている。ふたつの技術がある意味では競合するという例でもある。

しかし、RelayやApollo Clientの強力なキャッシュ機構を今のServer Componentsと組み合わせてうまく動かすのはややこしいだろう。と言いつつ、先のページのFAQに「Does this replace Apollo, Relay, or other GraphQL clients for React?というのがあり、そこでは「No」という回答に加えて、次のようにある。

For example, internally we use Relay and GraphQL in conjunction with Server Components.

つまり詳細はわからないがFacebookでは何らかうまくやっているようである。ただし直感的には、urqlとか、ややライトウェイトなクライアントが取り回しやすい、みたいなことが起きるんじゃないかという気はする。もちろんRelayやApollo Clientの側もさらに進歩して、適応していくだろう。

Server Componentsの時代

ということでServer Componentsの時代では、これまでのクライアントサイドレンダリング(+1ページ目はサーバーサイドレンダリング)のような、クライアント側でやっていくスタイルから変化し、GraphQLの意味付けも変わっていく予感がある。より単純なクライアントが好まれるようになったり、そもそもGraphQLではなくRESTあるいはRPCスタイルのWeb APIが流行るかもしれない。

1Password CLIで.env.localを作る

1PasswordにはVS Code拡張があって、.env ファイルなどにハードコードされたシークレットを1Passwordに保存して、secret reference というURL形式に置き換えてくれる。

ちなみにコミュニティ製のJetBrains IDE用の拡張もある。

これを使って .env.local.template を作る。

TWITTER_CONSUMER_KEY='op://Private/Twitter/API Key/consumer_key'
TWITTER_CONSUMER_SECRET='op://Private/Twitter/API Key/consumer_secret'

op:// のところがsecret reference。

.gitignore ファイルでは .env.local は無視しているけど、.env.local.templateリポジトリに入れてしまう。

# .env
.env.local
.env.*.local

あとは、1PasswordのVS Code拡張で「Preview with secrets」すると、secret referenceが解決されたプレビューが得られるので、これを .env.local として保存したら良い。けどちょっと面倒なので、1Password CLIでやってしまう方が便利。

$ op inject --in-file .env.local.template --out-file .env.local

1Passwordをチームで使っていると便利ですね。

ショートカット.appでjq

jq

jqJSONをいい感じにクエリできるやつで、広く使われている。

$ echo '{"foo": "bar"}' | jq '.foo'
"bar"

例えばGitHub Actionsのランナーにもデフォルトで入っている。

あるいはGitHubCLIツール gh にも --jq オプションがあって、統合されている。

つまりソフトウェアエンジニアにとっては、jqはJSONを触るときのデファクトスタンダードツールと言える。

ショートカット.app

iOS/iPadOS/macOSには、ショートカット.appがある。特に説明は不要と思う。

ショートカットでなんらかのJSON APIを利用したい場合、「URLの内容を取得」アクションでデータを取得して、「辞書の値を取得」(あるいは「リストから項目を取得」)アクションで情報を取り出す。

これでいいといえばいい。が、やはりjqを使いたい。

Swiftからgojqを使う

id:itchyny さんが作っている、Goによるjqの実装、gojqというのがある。

これを使う。

Goにはgomobileというのがあって、Goでモバイルアプリを作るやつという印象だが、実際にはGoでライブラリを作ることもできる。

ということで、gojqを呼び出すためのグルーコードを書いていく。

package binding

import (
    "github.com/itchyny/gojq"
    _ "golang.org/x/mobile/bind"
)

type Query struct {
    query *gojq.Query
}

func NewQuery(src string) (*Query, error) {
    query, err := gojq.Parse(src)
    if err != nil {
        return nil, err
    }
    return &Query{query: query}, nil
}

func (q *Query) Run(input []byte) (*Iterator, error) {
    ...
}

そしてうまいことオプションをつけて gomobile コマンドを実行する。

$ gomobile bind \
    -target=ios,iossimulator,macos,maccatalyst \
    -iosversion 14 \
    -prefix GOJQ \
    -o Frameworks/GOJQBinding.xcframework \
    github.com/cockscomb/swift-gojq/binding

-targetiOSmacOSを指定する。-prefix も設定できる。これで、XCFramework形式で出力できる。

これは次のようなインターフェースを持っていて、Objective-Cのヘッダが作られる(ここではSwiftから見た様子の一部を抜粋している)。

import Foundation

open class GOJQBindingIterator : NSObject, goSeqRefInterface {
    public init()

    open func next() throws -> Data
}

open class GOJQBindingQuery : NSObject, goSeqRefInterface {
    public init?(_ src: String?)

    open func run(_ input: Data?) throws -> GOJQBindingIterator
}

public func GOJQBindingNewQuery(_ src: String?, _ error: NSErrorPointer) -> GOJQBindingQuery?

Goの string がSwiftの String になったり、同じように []byteData になったり、基本的な変換はもちろん行われる。さらに errorNSError になるし、Goの構造体が NSObject を継承したクラスに変換されたりしている。よくできている。(Swiftでところどころ Optional になるのは仕方ない。)

ここまでやってくれると、もうちょっとSwiftから使いやすいようにラッパを書くのも簡単だ。

import GOJQBinding

enum QueryError: Error {
   case unknown
}

public struct Query {
    private let binding: GOJQBindingQuery

    public init(_ query: String) throws {
        var error: NSError?
        guard let binding = GOJQBindingNewQuery(query, &error) else {
            throw error ?? QueryError.unknown
        }
        self.binding = binding
    }

    public func run(_ input: Data) throws -> AsyncThrowingStream<Data, any Error> {
        ...
    }
}

あとはXCFrameworkを binaryTarget に加えたSwift Packageを作る。

// swift-tools-version: 5.7

import PackageDescription

let package = Package(
    name: "SwiftGoJq",
    platforms: [ .macOS(.v13), .macCatalyst(.v14), .iOS(.v14) ],
    products: [
        .library(name: “SwiftGoJq”, targets: ["SwiftGoJq"]),
    ],
    dependencies: [],
    targets: [
        .binaryTarget(
            name: "GOJQBinding",
            url: "https://github.com/cockscomb/swift-gojq/releases/download/0.1.0/GOJQBinding.xcframework.zip",
            checksum: "1c45710de17fb7020dcfc75105344729725c5e3875e7058e98790e5f4e178162"),
        .target(
            name: "SwiftGoJq",
            dependencies: [
                "GOJQBinding",
            ]),
    ]
)

GitHubに置いておいたのでどうぞご利用ください。

ショートカット

これでようやく本題の、ショートカットのアクションにしていく。

iOS 16/macOS VenturaからApp Intentsフレームワークというのが追加されている。これはアプリの機能をシステムに公開するための新しいしくみだ。これを使う。

次のように AppIntent プロトコルを実装したコードをアプリに含めておくと、システムが自動的に認識して、ショートカットから呼び出せるアクションにしてくれる。XxxManager に登録、みたいなことはいっさい不要だ。

import AppIntents

import AsyncAlgorithms
import SwiftGoJq

struct JQIntent: AppIntent {
    static var title: LocalizedStringResource = "jq"

    @Parameter(title: "JSON") var input: String

    @Parameter(title: "Query") var query: String

    static var parameterSummary: some ParameterSummary {
        Summary("\(\.$input) | jq '\(\.$query)'")
    }

    func perform() async throws -> some IntentResult {
        let jq = try Query(query)
        let results = try jq.run(input)
        let array = try await Array(results)
        return .result(value: array)
    }
}

@Parameter のついたプロパティが入力で、perform メソッドが実行され、結果を出力できる。async throws なので非同期的な処理も容易だ。

また parameterSummary によってショートカット.app中での表示をコントロールできる。ここでは、入力 | jq 'クエリ' という感じで、シェルっぽくしてみた。かわいいでしょう。

実際の表示は次のとおりで、入力がうまく表示されている。

いかがでしたか

iOS 16で追加されたApp Intentsフレームワークのおかげで、とても簡単にショートカット用のアクションを提供できた。App IntentsやiOS 16のことがもっと知りたくなってきたと思う。

iOS 16について学ぶのに何かいいリソースはないかな〜。

お〜っと、2022年12月24日発売の「WEB+DB PRESS Vol.132」に、ちょうどiOS 16の特集「iOS 16最前線」が載っているぞ!!

ということで、こちらの特集をはてなの同僚 id:yutailang0119 / id:kouki_dan と一緒に書きました。App Intentsについても紹介していますので、どうぞお買い求めください。もちろん他の記事もどれもおもしろいので、年末年始のお供にぴったりです。

こちらからは以上です。ハッピーホリデー!


以上、「potatotips #80 iOS/Android開発Tips共有会」での発表を再録しました。当日は主催者および参加者のみなさんのおかげで、とても楽しく過ごせました。またどのLTもたいへんおもしろく拝見しました。ありがとうございました。またよろしくお願いします。

macOSのコンテナ開発環境におけるVirtualization frameworkの採用

Docker Desktop for Mac

Docker Desktop for Macでは、仮想マシン上のLinuxでDockerを動かしている。仮想マシンにはhyperkitやQEMUが使われていた。が4.14.0からVirtualization frameworkがデフォルトで使われる。

Set Virtualization framework as the default hypervisor for macOS >= 12.5.

Virtualization frameworkmacOS内蔵の仕組みで、macOS 11で導入されてから、徐々に機能が拡張されている。Virtualization frameworkは高レベルなAPIで、より低レベルなAPIとしてmacOS 10.10から搭載されているHypervisor frameworkがあり、おそらくVirtualization frameworkもこれを利用している。(hyperkitやQEMUも利用している。)

Virtualization frameworkを使うと、Docker Desktopの設定でvirtiofsを有効にできる。現時点ではベータ扱いになっている。4.15.0でGAになった。おそらくVirtualizatoin frameworkのVZVirtioFileSystemDeviceConfigurationを利用しているのだろう。

virtiofsを有効にすると、ホストのmacOSとゲストのLinuxの間でのファイル共有が高速化される。ファイルの共有の遅さは、Docker Desktop for Macの積年の課題だった。

さて、Docker Desktopのロードマップを見ると、今後はApple Siliconでx86_64アーキテクチャのバイナリを実行する際にRosetta 2を利用することが検討されている検討されていたが、4.16.0でベータリリースされた

macOS 13では、Virtualization framework上のLinuxでもRosetta 2が利用できる機能が追加されており、これを利用するつもりだろう。

Rosetta 2はAOT変換(とJIT変換)によって、Apple Silicon上でx86_64のバイナリをかなり高速に実行させられることが知られている。現在、Apple Siliconでx86_64のDocker Imageを実行する際にはエミュレーションが行われており、ネイティブよりかなり遅い。Rosetta 2が利用できれば、一定の高速化が期待できる。

ということで、Docker Desktop for MacはVirtualizaton frameworkへの移行によって、高速化を達成しつつある。

Lima

ところで、Docker Desktop以外のソリューションはどうしているのかというと、Rancher DesktopにせよFinchにせよ、macOSLinuxを動かすというところではLimaを使っている。

LimaはQEMUを使っているが、最近ちょうどVirtualization frameworkのサポートが追加されつつあり、同時にvirtiofsも利用可能になる。

それだけでなく、Apple SiliconではRosetta 2によるx86_64バイナリの実行がサポートされようとしており、最新のベータにもこれが含まれている。

ということで、Lima 0.14に大注目だ。

まとめ

macOSでのコンテナ開発環境は、macOS上でどうやってLinuxを動かすかというところから始まっていて、最近ではmacOSに搭載されたVirtualization frameworkの採用が広がっている。そしてvirtiofsとRosetta 2によって、開発体験が改善されつつある。Docker DesktopもLimaを採用する他のソリューションも同様に進歩していて、目が離せない。

筆者は現在、勤め先がDockerの有料プランを契約しているため、それを使っています。

SwiftUIでSFSafariViewControllerを使う手法の探求

SwiftUIからSFSafariViewControllerを使いたい場面は多い。

SafariView

SFSafariViewControllerはビューコントローラーだから、UIViewControllerRepresentableを使ってSwiftUIのビューにしてしまうのが簡単か。

import SwiftUI
import SafariServices

struct SafariView: UIViewControllerRepresentable {
    typealias UIViewControllerType = SFSafariViewController

    typealias Configuration = SFSafariViewController.Configuration

    private let url: URL
    private let configuration: Configuration?

    init(url: URL, configuration: Configuration? = nil) {
        self.url = url
        self.configuration = configuration
    }

    func makeUIViewController(context: Context) -> SFSafariViewController {
        let safariViewController: SFSafariViewController
        if let configuration {
            safariViewController = SFSafariViewController(url: url, configuration: configuration)
        } else {
            safariViewController = SFSafariViewController(url: url)
        }
        return safariViewController
    }

    func updateUIViewController(_ uiViewController: SFSafariViewController, context: Context) {
    }
}

誰でもこういうコードを書いたことがあるんじゃないか。

あとは何らかのきっかけ‌fullScreenCover(isPresented:onDismiss:content:))なんかを使って、このSafariViewを表示してやればいい。 UIViewControllerpresent(_:animated:completion:)とは少し違うけど、他にいい方法を知らない。

じゃあ「何らかのきっかけ」ってなんだろう、というところが本題。

OpenURLAction

SwiftUIでは、TextのイニシャライザにAttributedStringを渡せば、文字列中にリンクを埋め込める。 あるいはそのものずばりLinkビューというのがあって、URLへのリンクを表現できる。 こういうのをクリックしたときもSFSafariViewControllerを表示したい。

実はLinkドキュメンテーションにいい例があった。

Link("Visit Our Site", destination: URL(string: "https://www.example.com")!)
    .environment(\.openURL, OpenURLAction { url in
        print("Open \(url)")
        return .handled
    })

LinkText内のリンクをクリックすると、EnvironmentからOpenURLActionが呼び出される。 そしてこれは上書き可能になっている。 OpenURLActionを上書きして、SFSafariViewControllerを開くようにフックしてやればよいらしい。

渡ってくるURLを状態として持って、URLが存在するときSafariViewを開く、という風にしたい。 何とか再利用性のあるコードにしたいので、ちょっと考える。

ViewModifier

ここで、ViewModifierを使えばモディファイア内に状態を持てることを思い出す。 ちょっと書いてみるとこういう感じになる。

import SwiftUI

struct OpenURLInSafariViewModifier: ViewModifier {
    @State private var url: URL? = nil
    private var isPresented: Binding<Bool> {
        Binding {
            url != nil
        } set: { newValue in
            if newValue == false {
                url = nil
            }
        }
    }

    private let configuration: SafariView.Configuration?

    init(configuration: SafariView.Configuration?) {
        self.configuration = configuration
    }

    func body(content: Content) -> some View {
        content
            .environment(\.openURL, OpenURLAction { url in
                switch url.scheme {
                case "https"?, "http"?:
                    self.url = url
                    return .handled
                default:
                    return .systemAction(url)
                }
            })
            .fullScreenCover(isPresented: isPresented) {
                if let url {
                    SafariView(url: url, configuration: configuration)
                        .edgesIgnoringSafeArea(.all)
                }
            }

    }
}

extension View {
   func openURLInSafariView(configuration: SafariView.Configuration? = nil) -> some View {
        return modifier(OpenURLInSafariViewModifier(configuration: configuration))
    }
}

いっけんよさそう。

SafariServicesUI

こういう感じで使う。

import SwiftUI

struct ContentView: View {
    var body: some View {
        Link("Open in SFSafariViewController", destination: URL(string: "https://developer.apple.com")!)
            .openURLInSafariView()
    }
}
import SwiftUI

struct ContentView: View {
    var body: some View {
        Text("Open in SFSafariViewController with [Attributed String](https://developer.apple.com)")
            .openURLInSafariView()
    }
}

状態が隠蔽されていて、シンプルだし、SwiftUIっぽいインターフェースだと思う。

ということでライブラリっぽくSwift Packageにしてみた。

どうでしょうか。