Eureka Engineering
Published in

Eureka Engineering

EmacsでSwiftUIのPreviewを表示する

XcodeではSwiftUIをプレビューすることができますが、それと近いことを別の場所で行うためには何ができるでしょうか。
XcodeはSwiftUIのプレビューを表示することによって高い開発効率を提供していますが、それをXcodeではない環境で実現する方法と、その活用方法について書きます。

はじめに

私は学生時代からEmacsがお気に入りのエディタで、過去に複数の自作の拡張機能の開発を行ってきました。

Emacsが面白いのはCLIとGUIの両方で動きつつ、自分の思い通りのエディタに変更できるところだと思います。他のエディタでもこの目的を達成することはできますが、当時の自分にとって複数の点で痒い所に手が届くエディタであったため、今も気に入って使用しています。

自分の思い通りにエディタの挙動を変更したり、拡張したりできることは素晴らしいことです。例えば、テキストファイルの閲覧中にある単語の意味がわからない時に、多くの人はDictionary.appであったり、Googleで検索したりします。しかし、その単語がエディタ上でウィンドウを切り替えることなく調べられてしまえば目的は達成できますし、実はそれくらいなら数行のコードで実現できます。

しかし、今ではメインの開発環境がXcodeに移ったこともあり、Emacsは完全なメモ帳とTODOリストになってしまいました。以前はとにかく長いinit.elを書いていましたが、今では利用目的が変わったこともあって、この程度になっています。

私のEmacsの起動画面

Emacsを触ることは1日に1時間もないほどになってしまいましたが、Xcodeではなく自分の好きな環境でエディタでコードを書ける環境を少しだけ構築してみようと思います。

EmacsとXcode

コミュニティによって開発された拡張機能を使えば、EmacsでSwiftを快適に書くことができます。例えば、swift-modeやlsp-modeを用いることでシンタックスハイライトや自動補完が適用されます。

https://github.com/swift-emacs/swift-mode

しかし、デバッガやXcodeのビルドシステムとの接続やシミュレータアプリの起動周りなど、EmacsでiOS開発をしようとすると設定はもちろん、日々の開発でもXcodeを使うよりも設定に時間がかかってしまいます。重要な機能のほとんどはEmacs側から呼び出そうとすると難しいものも多いでしょう。UI frameworkがUIKitからSwiftUIに変わったとはいえ、状況はあまり変わっていません。それほどに現実的ではないですし、近年ではXcode以外の環境でiOS開発にチャレンジしている人はかなり減ったと感じます。XcodeにVim modeが入るなど、Xcode側がエディタとしての機能を充実させていることも理由のひとつだと思われます。

とはいえ、XcodeのようなIDEのヘヴィな環境と比べ、ライトなエディタなでプレイグラウンドとしての活用は十分考えられるのではないかと思います。Xcodeよりもさらに自由に拡張できる特性を活かせば、使い所はあるのではないかと考えます。

EmacsでSwiftUIのプレビューを作る

EmacsでSwiftUIのコードをプレイグラウンド的に書ける環境を作ります。SwiftUIでコードを書く際にプレビューはかなり強力なので、Xcodeではない外部エディタでコードを書く際にもプレビューの表示はマストで欲しい機能です。ただし、プレビューの画像を出力するAPIは存在しないようなので、実現するには自前で画像を生成する手法を取るのが手っ取り早そうです。

SwiftUIはiOS、macOSやtvOSで動作するため、単純に目的を実行するのであれば、macOSのランタイムで実行することでプレビュー画像の生成を実装できます。しかし、実装によってはSwiftUIはランタイムによって挙動が異なる場合があり、macOSとiOSの見た目に差分がある、または同じiOSでもバージョンによって見た目に差分ができてしまいます。そのため、今回はiOSのシミュレータ上でSwiftUIのViewを描画し、プレビューの画像を生成します。

そのため、まずは以下の図のようなシェルスクリプトを実装しておきます。部分的に説明をすると、simct をCLIから呼び出すことによって、シミュレータにアプリケーションをインストールし、アプリケーション経由でmain.swiftを呼び出します。そこからはmain.swiftの自由なので、任意の出力をさせることが可能です。

main.swiftにprintのみを書いて実行させると以下のようになります。

余計なログも出力されていますが、”Hello from main.swift”の文字列が確認できると思います。

githubはこちらです。
https://github.com/takumatt/run-on-simulator

それでは下準備が終わったので、本題のEmacsのプレビューの実装を進めます。先ほど用意した run-on-simulator.sh を用いて、画像を生成し、その画像ファイルをwatchしてEmacs側の画像を更新します。

今回はEmacsで編集中のファイルを加工してPreviewProviderの対象となるstructを探し出し、プレビュー画像を生成する対象となるViewを作るために、PreviewProviderExtractor.swift と PreviewCodeBuilder.swift の2つを経由してひとつのmain.swiftに仕上げます。

ソースコードはこちらです。
https://github.com/takumatt/swiftui-preview-generator

実行するとこのような形になります。左のウィンドウに表示されているのがSwiftのコードで編集して保存をすると自動で右側のウィンドウのプレビュー画像が更新されます。

Emacsでのプレビュー表示

Emacs側の制御を行うEmacsLispのコードはこちらに貼っておきます。編集中のファイルをウォッチしてshellに渡して、出力された画像をウォッチしていて、あとは変数とモードの定義なので大した量ではありません。

(require 'filenotify)

(define-minor-mode sui-preview-mode
"SwiftUI Preview."
:init-value nil
:lighter "SUI-preview")

(defvar sui-preview-buffer
"The buffer that displays preview images.")

(defvar sui-run-on-simulator-dir
"The directory where run-on-simulator.sh placed.")

(defvar sui-preview-target-file
"Target file.")

(defvar sui-preview-image-path "Generated image path string.")

;; simulator
(defvar sui-simulator-udid)
(defvar sui-simulator-target)

(defun sui-preview--watch-swift-file()
"Start watching the swift file."
(file-notify-add-watch sui-preview-target-file
'(change)
(lambda (descriptor)
(sui-preview--generate-preview))))

(defun sui-preview--watch-preview-image()
"Re-render the preview image."
(file-notify-add-watch sui-preview-image-path
'(change)
(lambda (descriptor)
(sui-preview--set-image sui-preview-buffer))))


(defun sui-preview--start-watch()
;; TODO: watch error file
(sui-preview--watch-swift-file)
(sui-preview--watch-preview-image))

(defun sui-preview-mode()
(interactive)
(setq sui-preview-buffer (sui-preview--create-preview-buffer))
(setq sui-preview-target-file (buffer-file-name))
(sui-preview--set-image sui-preview-buffer)
(sui-preview--split-window sui-preview-buffer)
(sui-preview--start-watch))

(defun sui-preview--generate-preview()
(let ((default-directory sui-run-on-simulator-dir))
(with-temp-buffer
(shell-command (concat "sh preview-generator.sh"
" " sui-simulator-udid
" " sui-simulator-target
" " sui-preview-target-file)
t))))

(defun sui-preview--create-preview-buffer()
"Create a buffer for previews and returns the buffer."
(get-buffer-create "swift-ui-preview"))

(defun sui-preview--set-image(buf)
(let ((current-buf (current-buffer)))
(switch-to-buffer buf)
(erase-buffer)
(clear-image-cache) ;; The image doesn't change without this.
(insert (current-time-string))
(insert-image (create-image (expand-file-name sui-preview-image-path)))
(switch-to-buffer current-buf)))

(defun sui-preview--split-window(buf)
(pop-to-buffer buf)
(other-window -1))

お気づきの方もいるかと思いますが、現段階のlimitationとして、ライブラリのリンクができません。今はmain.swiftに全てを詰め込むしかないため、プレビュー生成時にエラーとなることが起こりえます。

別の活用方法

任意のシミュレータで任意のSwiftコードが実行できることになったことによって、異なるiOSバージョンでの実行確認を簡単に確認することができます。以下のような簡単なshを用意しました。

sh preview-generator.sh $UDID_iOS13 x86_64-apple-ios13-simulator
sh preview-generator.sh $UDID_iOS14 arm64-apple-ios14-simulator
sh preview-generator.sh $UDID_iOS16 arm64-apple-ios16-simulator

試しに、ランタイムによって挙動が変わるSwiftUIのCapsuleの挙動を比較してみます。ソースコードは以下の通りです。ランタイムのバージョンを表示することによって、実際に指定したバージョンのiOSで動作しているか確認しています。

struct CapsuleView: View {
var body: some View {
VStack {
Text("\(UIDevice.current.systemVersion)")

Capsule(style: .continuous)
.fill(.blue)
.frame(width: 200, height: 100)
}
}
}
左から順にiOS 13.7, 14.5, 16.1で実行した際の図

iOSのバージョンによってそれぞれ異なる見た目になることが確認できました。今後はこのスクリプトを用いることで、比較的簡単にiOS側の細かな挙動の確認することができそうです。

おわりに

粗雑な作りではありますが、無事プレビューの表示は達成できました。

今後の課題としてはライブラリとのリンクなどが考えられますが、プレビューに必要なファイルをまとめるに当たって、実行時間が伸びていくことが予想されます。そのため、このままの実行時間でスケールさせることは難しいと考えられ、解決にはXcodeでのSwiftUIのプレビューで行われているようなDynamicLibraryの生成とその更新が必要になります。

そのような方向で発展させていくにはかなり難易度が高いため、一旦ここで作ったツールは先ほどの章で述べたように、iOSごとの挙動をサクッと確認できるツールとして使っていこうと思います。現時点ではかなりインタフェースも荒削りなので、そのあたりは多少使えるレベルまで持っていけるように整備したいと思います。

エウレカでは、ビジネス成長を技術面から支え、加速させることに興味関心があるiOSエンジニアを募集しています。

--

--

Learn about Eureka’s engineering efforts, product developments and more.

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store