【2024年2月版】SwiftUIでiOS/Androidのデュアルプラットフォーム開発ができるSkipを触ってみた
※2024年2月時点の情報です。
概要
先日X(旧Twitter)で以下のポストが話題になっていました。
I’m thrilled to announce the tech preview of Skip: dual-platform app development in Swift.
— Abe White (@aabewhite) 2023年10月26日
You write a modern iOS app in Swift and SwiftUI. Skip’s Xcode plugin generates a native Android Kotlin and Compose version!
Docs: https://t.co/9O7v6HS0CJ
Video: https://t.co/tHNWpj1QlJ pic.twitter.com/ym5HKQe3wB
Skip(Swiftによるデュアルプラットフォームアプリ開発)の技術プレビューを発表できることにわくわくしています。 SwiftとSwiftUIでモダンなiOSアプリを書くと、SkipのXcodeプラグインがネイティブAndroid KotlinおよびComposeバージョンを生成します!
公式のよくある質問を確認すると、「Skip は、Flutter、React Native、Xamarin、Cordova などの他のフレームワークとどう異なりますか?」という質問に「Skip は、iOS と Android という 2 つの主要なモバイル プラットフォーム専用の真のネイティブ アプリ開発に重点を置いています。」という回答があり気になったので、Skipの概要を調べて、iOS/AndroidでWelcome Skipper(SkipにおけるHello World)を表示するところまでやってみました。
Skipについて
SkipプロジェクトはAbe White氏(@aabewhite)とMarc Prud'hommeaux氏(@marcprux)によって2023年に設立されました。彼らは初期のKindleアプリ開発に携わり、Abe氏はその後10 年間をTwitterで過ごし、iOS UI フレームワークの技術責任者を務められました(About)。
Skipとは、「Swift と SwiftUI を Android 用の Kotlin と Compose にトランスパイルする Xcode プラグイン」です。他のクロスプラットフォームと違い、(iOSのみ)配布する際にSkipモジュールを削除することができ、アプリサイズを小さくすることができます。
現在は開発中のため対応しているAPIは少ないですが、順次対応中のようです。
料金
オープンソースプロジェクトの場合は無料、非営利およびインディーデベロッパー(前年のアプリ受益者事業を合わせた収益が25万ドル未満)は$99(現在は開発中のため無料)、Enterpriseは$999(現在は開発中のため$499)です。

現在はTechnology Previewの段階のため、登録すると早期導入者割引の資格が得られるようです。ご興味がある方は早めに登録することをオススメします。↓
今後料金体系が変更される可能性は十分ありますが、個人的にはUnityのように一定規模までは商用でも無料で利用できる方がハードルが下がって嬉しいです。
参考:Unityの料金体系↓
開発に必要なもの
Xcode15、Android Studio 2023、HomebrewがインストールされたmacOS 13以上のPC
開発環境
- MacBook Pro M1 Pro
- macOS Sonoma 14.1.1
- Xcode 15.0.1
- Android Studio Hedgehog | 2023.1.1 Patch 2
Skipをインストール
ターミナルで以下を実行します。
$ brew install skiptools/skip/skip Welcome to Skip 0.8.12!
インストールが完了したら以下を実行して、開発の前提条件が満たされていることを確認します。
$ skip checkup [✓] Skip version 0.8.12 (= 0.8.12) [✓] macOS version 14.1.1 (> 13.5.0) [✓] Swift version 5.9 (= 5.9.0) [✓] Xcode version 15.0.1 (> 15.0.0) [✓] Xcode tools SDKs: 5 [✓] Homebrew version 4.2.8 (> 4.1.0) [✓] Gradle version 8.6 (> 8.3.0) [✓] Java version 21.0.2 (> 17.0.0) [✓] Android Debug Bridge version 1.0.41 (> 1.0.40) [✓] Android Studio version: 2023.1 [✓] Android SDK licenses: 7 [✓] Resolve dependencies (14.63s) [✓] Build hello-skip (18.46s) [✓] Test Swift (12.96s) [✓] Test Kotlin (122.71s) [✓] Archive iOS ipa (21.91s) [✓] Assemble HelloSkip-release.ipa (0.02s) [✓] Verify HelloSkip-release.ipa 24 KB [✓] Assembling Android apk (123.98s) [✓] Verify HelloSkip-release.apk 6.2 MB [✓] Check Swift Package (0.65s) [✓] Skip 0.8.12 checkup (316.39s)pk
これでSkipの開発環境が整いました。(10分ほどで完了しました)
余談(読み飛ばしてOKです)
私は最初、興味本位でAndroid Studio Chipmunk | 2021.2.1 Patch 2を使用している状態で$skip checkupを実行しました。それが直接の理由かはわかりませんが、正常に完了できなかったので、解決までに行ったことを記録しておきます。
まず最初は以下の結果となりました。
[✓] Skip version 0.8.12 (= 0.8.12) [✓] macOS version 14.1.1 (> 13.5.0) [✓] Swift version 5.9 (= 5.9.0) [✓] Xcode version 15.0.1 (> 15.0.0) [✓] Xcode tools SDKs: 5 [✓] Homebrew version 4.2.8 (> 4.1.0) [✓] Gradle version 8.6 (> 8.3.0) [!] Java version 11.0.12 (< 17.0.0) [✓] Android Debug Bridge version 1.0.41 (> 1.0.40) [✓] Android Studio version: 2021.2 [✓] Android SDK licenses: 7 [✓] Resolve dependencies (19.75s) [✓] Build hello-skip (24.07s) [✓] Test Swift (13.22s) [✗] Test Kotlin (79.51s) [✓] Archive iOS ipa (23.73s) [✓] Assemble HelloSkip-release.ipa (0.02s) [✓] Verify HelloSkip-release.ipa 24 KB [✗] Assembling Android apk (43.04s) [✗] Verify HelloSkip-release.apk: /var/folders/0w/ntt1_4g51qnfwh4zhwj83x9c0000gn/T//BD2A3EDE-8C33-457A-9880-28E6DB66FD46/hello-skip/.build/Android/app/outputs/apk/release/app-release-unsigned.apk: The file “app-release-unsigned.apk” couldn’t be opened because there is no such file. [✓] Check Swift Package (0.51s) [✗] Skip 0.8.12 checkup (205.81s) Error: 4 errors
古いAndroid Studio Chipmunk | 2021.2.1 Patch 2を使用していることが原因かもしれないと思い、Android Studio Hedgehog | 2023.1.1 Patch 2にアップデートして再度実行してみました。
アップデート後の結果↓
[✓] Skip version 0.8.12 (= 0.8.12) [✓] macOS version 14.1.1 (> 13.5.0) [✓] Swift version 5.9 (= 5.9.0) [✓] Xcode version 15.0.1 (> 15.0.0) [✓] Xcode tools SDKs: 5 [✓] Homebrew version 4.2.8 (> 4.1.0) [✗] Gradle version: error executing gradle [✗] Java version: error executing java [✓] Android Debug Bridge version 1.0.41 (> 1.0.40) [✓] Android Studio version: 2023.1 [✓] Android SDK licenses: 7 [✓] Resolve dependencies (8.32s) [✓] Build hello-skip (19.06s) [✓] Test Swift (13.16s) [✗] Test Kotlin (0.78s) [✓] Archive iOS ipa (16.93s) [✓] Assemble HelloSkip-release.ipa (0.01s) [✓] Verify HelloSkip-release.ipa 24 KB [✗] Assembling Android apk (0.01s) [✗] Verify HelloSkip-release.apk: /var/folders/0w/ntt1_4g51qnfwh4zhwj83x9c0000gn/T//EAB70A4F-93B6-4CD1-A39F-712850A67345/hello-skip/.build/Android/app/outputs/apk/release/app-release-unsigned.apk: The file “app-release-unsigned.apk” couldn’t be opened because there is no such file. [✓] Check Swift Package (0.41s) [✗] Skip 0.8.12 checkup (59.31s) Error: 6 errors
逆にエラーが増えてしまいました。一番上のGradle version: error executing gradleから解消します。
まずは現在のGradleのバージョンを確認します。
$ gradle -v
以下のエラーが出ました。
ERROR: JAVA_HOME is set to an invalid directory: /Applications/Android Studio.app/Contents/jre/Contents/Home Please set the JAVA_HOME variable in your environment to match the location of your Java installation.
このエラーメッセージは、JAVA_HOME環境変数が無効なディレクトリに設定されていることを示しています。JAVA_HOMEは、Java開発キット(JDK)のインストールパスを指す環境変数です。この問題を解決するには、有効なJDKのインストールパスをJAVA_HOMEに設定する必要があります。
まずはJavaがインストールされているか確認します。
$ java -version openjdk version "21.0.2" 2024-01-16 OpenJDK Runtime Environment Homebrew (build 21.0.2) OpenJDK 64-Bit Server VM Homebrew (build 21.0.2, mixed mode, sharing)
インストールされていることが確認できたので、JAVA_HOMEを設定します。(Javaのインストールパスは$ /usr/libexec/java_homeで取得できます。)
$ export JAVA_HOME=/opt/homebrew/Cellar/openjdk/21.0.2/libexec/openjdk.jdk/Contents/Home
再度Gradleのバージョンを確認します。
$ gradle -v ------------------------------------------------------------ Gradle 8.6 ------------------------------------------------------------ Build time: 2024-02-02 16:47:16 UTC Revision: d55c486870a0dc6f6278f53d21381396d0741c6e Kotlin: 1.9.20 Groovy: 3.0.17 Ant: Apache Ant(TM) version 1.10.13 compiled on January 4 2023 JVM: 21.0.2 (Homebrew 21.0.2) OS: Mac OS X 14.1.1 aarch64
無事確認できたので、再度$ skip checkupを実行すると、なんと解決しました。JAVA_HOMEが設定されていなかったのが原因だったのかもしれないですね。
余談終わり。
アプリプロジェクトを作成
Skipでは以下のコマンドでプロジェクトを作成します。
$ skip init --open-xcode --appid=bundle.id project-name AppName
例えば以下のようにします。適宜置き換えてください。
skip init --open-xcode --appid=com.xyz.HelloSkip hello-skip HelloSkip
コマンド実行後、HelloSkipという名前の単一モジュールを含む新しいSwiftPMパッケージを含むhello-skip/フォルダが作成されます。Xcode によって新しいプロジェクトが開きますが、トランスパイルされたアプリをビルドして起動するには、その前に Android エミュレータが実行されている必要があります。Android Studioを使用し、Virtual Device ManagerからAndroid エミュレータを立ち上げてください。

※自動で開いたXcodeを閉じてしまい、hello-skipディレクトリを再度開こうとしましたが上手くいきませんでした。手動で開く場合はhello-skip/Darwinディレクトリを指定する必要があるようです。
ビルド成功しました。最初のビルドは時間がかかるようですが、私の環境ではそこまで時間はかからずすぐにビルドされました。(最初はiOS17.0.1のiOSシミュレータが選択できずXcode 15.0.1では起動することができなかったので、Xcode15.2をインストールして実行しました。)

まとめ
SwiftUIでiOS/Androidのデュアルプラットフォーム開発ができるSkipについて調べて触ってみました。導入からビルドまで、少し躓きましたが割とスムーズに進めることができました。現在はライセンスキーが不要で、Skipをインストール後すぐに使用できたことも要因かもしれません。
まだまだ実践利用はリスキーですが、現在はTechnology Previewの段階のためこれから状況は変わっていくと思います。ほぼ完全なネイティブコードで完全なiOS/Androidのネイティブアプリを構築できる点が素晴らしいと思っているので、このツールが発展していくととても嬉しいです。
皆さんの感想も聞かせてください。
それでは。
fly.ioのタイムゾーンを変更
画像アップロード時にアスペクト比とサイズをチェックする方法
active storageをインストール
$ rails active_storage:install $ rails db:migrate
model, migrationを作成
$ rails g model Hoge $ rails db:migrate
Modelを作成
# app/models/hoge.rb class Hoge < ApplicationRecord MAX_IMAGE_SIZE = 2 # 2MBまで has_one_attached :image end
Controllerを作成
$ rails g controller Hoges new create
# app/controllers/hoges_controller.rb class HogesController < ApplicationController def new @hoge = Hoge.new end def create @hoge = Hoge.new(hoge_params) if @hoge.save redirect_to '/' else render :new end end private def hoge_params params.require(:hoge).permit(:image) end end
Routesを定義
Rails.application.routes.draw do resources :hoges, only: [:new, :create] end
Viewを作成
# app/views/hoges/new.html.erb <%= form_with model: @hoge, url: hoges_path, data: { turbo: false } do |f| %> <% aspect_width = 1 %> <% aspect_height = 1 %> <div data-controller='image-preview' data-image-preview-max-image-size-value="<%= Hoge:MAX_IMAGE_SIZE %>" data-image-preview-aspect-width-value="<%= aspect_width %>" data-image-preview-aspect-height-value="<%= aspect_height %>"> <%= f.file_field :image, accept: 'image/jpeg,image/png', data: { action: 'change->image-preview#preview', target: 'image-preview.image' } %> </div> <%= f.submit '保存' %> <% end %>
image_preview_controller.jsを使用するため、<div data-controller='image-preview'>で囲みます。また、アスペクト比(data-image-preview-aspect-width-value, data-image-preview-aspect-height-value)や上限サイズ(data-image-preview-max-image-size-value)の値を渡しています。
JSを作成
// app/javascript/controllers/image_preview_controller.js import { Controller } from "@hotwired/stimulus" export default class extends Controller { static values = { maxImageSize: Number, aspectWidth: Number, aspectHeight: Number } static targets = ["image"] preview(event) { // this.imageTarget.files[0].sizeはバイト単位だから、÷1024でキロバイト、さらに÷1024でメガバイトに変換 var size = this.imageTarget.files[0].size / 1024 / 1024; if (size > this.maxImageSizeValue) { alert(`ファイルサイズは${this.maxImageSizeValue}MB以下です。`); // 選択されたファイルを空にする this.imageTarget.value = ''; return } const input = event.target if (input.files && input.files[0]) { const reader = new FileReader() // image.onload内のthisは画像自体のことなので使い分けるため_this変数にする const _this = this; const aspectWidth = this.aspectWidthValue; const aspectHeight = this.aspectHeightValue; reader.onload = (e) => { const image = new Image(); image.onload = function () { // view側からdata-image-preview-aspect-width-valueやdata-image-preview-aspect-height-valueが渡されなかった場合、this.aspectWidthValue・this.aspectHeightValueは0が入る if (aspectWidth != 0 && aspectHeight != 0 && this.width / this.height !== aspectWidth / aspectHeight) { alert(`アスペクト比は${aspectWidth}:${aspectHeight}である必要があります。`); _this.imageTarget.value = ''; return; } } image.src = e.target.result } reader.readAsDataURL(input.files[0]) } } }
これで上限以上のサイズや指定以外のアスペクト比の画像をアップロードしようとした場合にアラートを表示して阻止できます。今回は割愛しましたがgemなどを使ってファイルサイズやアスペクト比をモデル単位でバリデーションすると更に安全になります。
画像アップロードフォームにサムネを表示する
概要
Railsで画像アップロードフォームを作成している時に、アップロードする画像を確認できるように表示する方法を調べました。
準備
Active Storageをインストール
$ rails active_storage:install $ rails db:migrate
ModelとMigrationを作成
$ rails g model Hoge
Model
今回は画像をimageという名前で定義します。
# app/models/hoge.rb class Hoge < ApplicationRecord has_one_attached :image end
Migration
今回は画像アップロードだけなので他のカラムは作成しません。
# db/migrate/20230813122050_create_hoges.rb class CreateHoges < ActiveRecord::Migration[7.0] def change create_table :hoges do |t| t.timestamps end end end
Migrationファイルを適用させます。
$ rails db:migrate
Controller
# app/controllers/hoges_controller.rb class HogesController < ApplicationController def new @hoge = Hoge.new end def create @hoge = Hoge.new(hoge_params) if @hoge.save redirect_to root_path else render :new end end private def hoge_params params.require(:hoge).permit(:image) end end
Routes
Rails.application.routes.draw do root to: 'general#index' resources :hoges, only: [:new, :create] end
View
image_preview_controller.jsというJSファイルを使用するので、file_field要素とサムネ表示部分の要素を<div data-controller='image-preview'>要素で囲みます。
# app/views/hoges/new.html.erb <%= form_with model: @hoge, url: hoges_path, data: { turbo: false } do |f| %> <div data-controller='image-preview'> <% if @hoge.image.attached? %> <%= image_tag @hoge.image, id: 'img_prev' %> <% else %> <img id='img_prev'> <% end %> <br> <%= f.file_field :image, accept: 'image/jpeg,image/png', id: 'img_input', data: { action: 'change->image-preview#preview', target: 'image-preview.image' } %> </div> <%= f.submit '保存' %> <% end %>
JS
ファイル選択時に実行される関数を定義します。
// app/javascript/controllers/image_preview_controller.js import { Controller } from "@hotwired/stimulus" export default class extends Controller { preview(event) { const input = event.target if (input.files && input.files[0]) { const reader = new FileReader() const _this = this; reader.onload = (e) => { const image = new Image(); image.onload = function () { const imagePreview = _this.element.querySelector("#img_prev") imagePreview.src = e.target.result } image.src = e.target.result } reader.readAsDataURL(input.files[0]) } } }
これでファイルアップロードフォームにサムネを表示できるようになります。
Boolean型のカラムがテーブル内で2つ以上trueにならないようにするバリデーションの実装方法
概要
Railsで、enabledカラムがtrueのデータがテーブル内で1つだけという状況を担保したい状況があると思います。そういう時に実装するバリデーションについて書きます。
実装
class Hoge < ApplicationRecord validate :enabled_hoge_uniqueness private def enabled_hoge_uniqueness errors.add(:base, '既に有効なHogeが存在します。') if enabled? && self.class.where(enabled: true).where.not(id:).exists? end end
解説
上記バリデーションを定義することで、Hogeモデルを保存する時にenabledがtrueの場合のみ、「他にenabledカラムがtrueのHogeが存在するかどうか」をチェックしてくれます。
また、.where.not(id:)を使用することで保存しようとしているHogeモデル自身を検索対象から除外できます。これをしないと、元々enabledカラムがtrueのHogeモデルを更新する時にもバリデーションエラーになってしまいます。
Punditをテストする
前提
以下のPolicyをテストします。
# app/policies/hoge_policy.rb class HogePolicy < ApplicationPolicy def show? user.admin? end class Scope < Scope def resolve scope.all end end end
準備
以下を追加します。これでpermitなどのmatchersを使用できるようになります。
# spec/rails_helper.rb require 'pundit/rspec'
FactoryBot
# spec/factories/user.rb FactoryBot.define do factory :user do admin { false } end end
RSpec
# spec/policies/hoge_policy.rb require 'rails_helper' RSpec.describe HogePolicy do let(:record) { Hoge.new } permissions :show? do context 'when user is admin' do let(:user) { create(:user, admin: true) } it 'grants access' do expect(described_class).to permit(user, record) end end context 'when user is not admin' do let(:user) { create(:user) } it 'denies access' do expect(described_class).not_to permit(user, record) end end end end
補足
new?など他のアクションも同様にテストしたい場合は以下のようにカンマで続けます。
permissions :show?, :new? do
参考
GitHub - varvet/pundit: Minimal authorization through OO design and pure Ruby classes
Textウィジェットの引数に長い文字列を渡してもコードを読みやすくする方法
上記のページで知りました。
Textウィジェットの引数に長い文字列を渡す場合、改行しないとファイルが横長になり読みにくいですよね。
Text('ERROR: Parts of the spaceship are on fire. Other parts are overrun by martians. Unclear which are which.');
以下のようにしかできないと思ってましたが、
Text(''' ERROR: Parts of the spaceship are on fire. Other parts are overrun by martians. Unclear which are which. '''); Text('ERROR: Parts of the spaceship are on fire. ' + 'Other parts are overrun by martians. ' + 'Unclear which are which.');
以下のようにできるようです(以前からでしたっけ?)。これでソースコードが見やすくなりますね。
Text( 'ERROR: Parts of the spaceship are on fire. Other ' 'parts are overrun by martians. Unclear which are which.');