たろすの技術メモ

Jot Down the Tech

ソフトウェアエンジニアのメモ書き

【2024年2月版】SwiftUIでiOS/Androidのデュアルプラットフォーム開発ができるSkipを触ってみた

※2024年2月時点の情報です。

概要

先日X(旧Twitter)で以下のポストが話題になっていました。

Skip(Swiftによるデュアルプラットフォームアプリ開発)の技術プレビューを発表できることにわくわくしています。 SwiftとSwiftUIでモダンなiOSアプリを書くと、SkipのXcodeプラグインがネイティブAndroid KotlinおよびComposeバージョンを生成します!

公式のよくある質問を確認すると、「Skip は、Flutter、React Native、Xamarin、Cordova などの他のフレームワークとどう異なりますか?」という質問に「Skip は、iOSAndroid という 2 つの主要なモバイル プラットフォーム専用の真のネイティブ アプリ開発に重点を置いています。」という回答があり気になったので、Skipの概要を調べて、iOS/AndroidでWelcome Skipper(SkipにおけるHello World)を表示するところまでやってみました。

GitHubリポジトリは以下です。

github.com

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は少ないですが、順次対応中のようです。

料金

skip.tools

オープンソースプロジェクトの場合は無料、非営利およびインディーデベロッパー(前年のアプリ受益者事業を合わせた収益が25万ドル未満)は$99(現在は開発中のため無料)、Enterpriseは$999(現在は開発中のため$499)です。

現在はTechnology Previewの段階のため、登録すると早期導入者割引の資格が得られるようです。ご興味がある方は早めに登録することをオススメします。↓

skip.tools

今後料金体系が変更される可能性は十分ありますが、個人的にはUnityのように一定規模までは商用でも無料で利用できる方がハードルが下がって嬉しいです。

参考:Unityの料金体系↓

unity.com

開発に必要なもの

Xcode15、Android Studio 2023、HomebrewがインストールされたmacOS 13以上のPC

開発環境

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のタイムゾーンを変更

概要

先日Railsで開発したアプリをfly.ioにデプロイしたのですが、その際にタイムゾーンがAsia/Tokyo以外になってました(おそらくUTC)。その解決法の備忘録です。

解決

以下の方法で解決できました。

community.fly.io

$ fly launch実行時に生成されるor元々あるDockerfileに以下を追記

ENV TZ="Asia/Tokyo"

画像アップロード時にアスペクト比とサイズをチェックする方法

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モデルを保存する時にenabledtrueの場合のみ、「他にenabledカラムがtrueHogeが存在するかどうか」をチェックしてくれます。

また、.where.not(id:)を使用することで保存しようとしているHogeモデル自身を検索対象から除外できます。これをしないと、元々enabledカラムがtrueHogeモデルを更新する時にもバリデーションエラーになってしまいます。

Punditをテストする

前提

  • Punditの基本的な使い方等については割愛します。
  • rails v7.0.4
  • pundit v2.3.0
  • factory_bot_rails v6.2.0
  • rspec-rails v6.0.1

以下の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

Testing Pundit Policies - My Coding Journey

Textウィジェットの引数に長い文字列を渡してもコードを読みやすくする方法

dart.dev

上記のページで知りました。

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.');