スパイダープラス Tech Blog

建設SaaS「スパイダープラス」のエンジニアとデザイナーのブログ

Rubyのクラスメソッドの仕組みを理解する

はじめに

初めまして、スパイダープラスでWebエンジニアをしているizkです。
普段は、S+Reportというプロダクトでバックエンドを中心にRubyPHPなどを書いています。

さて、Rubyでコーディングしていると、モジュールをクラスにincludeしてメソッドを呼び出す場面はよくあると思います。

ある日、同じようにモジュールをincludeしてメソッドを呼び出そうとしたところ、undefined methodエラーが発生しました。
原因を調べてみると、メソッドを呼び出そうとした場所がクラスメソッド内だったためでした。includeextendに書き換えると、無事にメソッドを呼び出せるようになりました。

この時の私は「インスタンスメソッドにはinclude、クラスメソッドにはextend」くらいの認識でコードを書いていたため、なぜそうなるのか仕組みを調べてみることにしました。

本記事の対象読者

本記事のゴール

Rubyのオブジェクトモデル(クラス、モジュール、特異クラス)を整理しつつ、クラスメソッドの呼び出しの仕組みを理解すること

続きを読む

スパイダープラスTechBlog 2025年の締め~目標を大きく超えたテックブログ運用方法~

こんにちは。プラットフォーム開発部 SREチームのKです。
本記事では、テックブログ運営に関わったメンバーの視点で2025年の取り組みを振り返り、投稿ペースの維持と目標超過のPVを実現できた仕組みを整理します。
テックブログ通算90本目🎉2025年を締めくくる記事でもあります。


2025年の運用成果

続きを読む

AIに仕事をさせるためのお膳立てとRustという強力なガードレール

こんにちは。EMの本田です。

AIで開発していますか? 世間では「AIで開発生産性が〇〇倍!」という景気の良い話が飛び交っていますが、現場のエンジニアとしては「いきなりそこを目指すと、逆に管理コストで疲弊しそうだな」と感じることも多いのではないでしょうか。

私のチームでもAI活用を進めていますが、「生産性の定量化」までは今のところ落とし込んでいません。

まずは、「人間がビジネスのコア(価値・設計)に集中するために、それ以外をAIとツールに任せる」というスタンスで、地に足のついた状態を目指しました。

今回は、RustとClaude Codeを中心に、私のチームで実践しているAI活用方法について紹介します。

AIに夢を見すぎない:役割分担の再定義

まず前提として、「すべてのコンテキストをAIに伝えることはできない」ということを受け入れるところから始めました。

仕様判断やアーキテクチャの核心部分をAIに丸投げするのは、現時点では時期尚早だと考えています。まず最初にAIに期待すべきは「自律的な思考」よりも「ルールの徹底」や「定型作業の遂行」です。

  • 人間がやること: ビジネスロジックの設計、仕様の決定、最終的な品質責任。
  • AIがやること: ボイラープレートの記述、Git操作(Issue/PR作成等)、一次レビュー。
  • ツールがやること: フォーマット整形、静的解析、セキュリティチェック(CI/CD)。

失敗談:コンテキストの詰め込みすぎ問題

当初は「機能実装からGitのコミット、プッシュまで全部一つの流れでAIにやってもらおう」と考えたことがありました。しかし、これはうまくいかないことがよくありました。

実装の詳細、プロジェクトのルール、Gitの操作手順...これらすべてを一つのコンテキスト(チャットセッション)で処理しようとすると、コンテキストウィンドウが不足したり、AIの注意力が散漫になって出力品質が安定しなかったのです。

そこで私は「作業単位でコンテキストを区切ることができる」方針で環境設計をしました。

  • 設計は設計のセッション。
  • 実装は実装のセッション。
  • コミットやPR作成は別のセッション(あるいはカスタムコマンド)。

結果として、各工程での成果物が明確になり、人間がチェック(レビュー)を入れるタイミングも作りやすくなりました。「認知負荷を作業単位にとどめておける」というのは、人間にとっても管理しやすいメリットがありました。

Claude Code × Git:定型作業の半自動化

現在、Issue作成、ブランチ作成、コミット、PR作成といったGit操作は、基本的にAI(Claude Code)を経由して行っています。

ただし、毎回プロンプトを手打ちするのではなく、プロジェクトのルールを反映した カスタムコマンドを用意しています。

create-issue, create-branch, create-commit, create-pr といったコマンドを用意し、必要なパラメータ(例: 機能名、変更内容)を渡すだけで、AIがルールに従って適切なIssueやPRを作成してくれます。

さらに、以下のようなルールを事前にドキュメント化し、AIが参照するようにカスタムコマンドの中で指示しています。

  • Issue/PRテンプレート: フォーマットを統一。
  • 命名規則: ブランチ名やコミットメッセージの一貫性をAIに強制させる。

人間は方向性を指示するだけで、AIがルールに従って定型作業をこなします。

AIを戦力化する「自動フィードバック」

ここで重要になるのが「AIが出力したコードを誰がチェックするか」という問題です。

AIは疲れを知らず、爆速でコードを生成しますが、その品質は完璧ではありません。インデントがズレたり、存在しない関数を呼んだり、プロジェクトの禁止事項を無視したりします。 これらをすべて人間がレビューして指摘していたら、AIを使う前よりも時間がかかってしまいます。

私たちが目指したのは、「人間が見る前に、システムがAIにダメ出しをする環境」です。

  • 構文・フォーマット: 汚いコードは自動で整形されるか、エラーになる。
  • 整合性: 動かないコード(型エラー等)は弾かれる。
  • 設計: 不適切な依存関係は禁止される。
  • 品質: 複雑すぎるコードは禁止される。
  • セキュリティ: 脆弱性のあるライブラリは禁止される。怪しいソースから取ってきているライブラリは禁止される。
  • ライセンス: 許可されていないライセンスの使用は禁止される。

この「ガードレール」が整備されていると、AIはエラーメッセージを読み取って自律的に修正を行うことができます。 「AIが書き、システムが指摘し、AIが直す」。このループを人間抜きで回せる環境こそが、AI開発において最も生産性が高く、ストレスのない状態だと言えます。

その実現になぜ「Rust」が最適なのか

この「システムによる厳格なフィードバック環境」を構築する上で、Rustは私たちのチームにとって非常にコストパフォーマンスが良い選択肢でした。もちろん、TypeScriptでも厳格な型チェックやESLint設定で近いことは実現できますが、Rustはそれが「デフォルト」である点が大きな違いです。

コンパイルエラー」という最強のフィードバック

PythonJavaScriptのような動的型付け言語では、AIが「なんとなく動く嘘のコード」を書いても、実行するまで気づかないことがあります。 しかしRustなら、型システムや所有権のルールに違反していれば即座にコンパイルエラーになります。

AIにとって、コンパイラからの明確なエラーメッセージは「最高の修正指示書」です。 人間が「ここ間違ってるよ」と教える代わりに、「コンパイルを通せ」と指示するだけで、AIは試行錯誤して正しいコードに到達できます。

公式ツールによる「迷いのない」環境構築

Rustは、ビルド(cargo build)、テスト(cargo test)、フォーマット(cargo fmt)、ドキュメント(cargo doc)、Linter(cargo clippy)といった開発に必要なツールがすべて公式で標準化されています。

JS/TS等でありがちな「Linterは何を使う?」「設定ファイルはどう書く?」といった環境構築の試行錯誤が一切ありません。 rustup でインストールさえすれば、人間もAIもすぐに開発に入れます。 AIに対して「標準のフォーマッターをかけて」と指示すれば、文脈を説明しなくても100%正解の挙動をしてくれるのは、プロンプトの簡略化にも繋がります。

明示的な構文:AIが「読みやすく書きやすい」

Rustは型、ライフタイム、可変性(mut)、エラーハンドリング(Result)などが構文上で明示されます。 暗黙の挙動が少ないため、AIはコードの意図を正確に読み取りやすく、生成時も「何を書くべきか」が明確です。

例えば「この関数は失敗する可能性がある」という情報が戻り値の型(Result<T, E>)に表れているため、AIはエラーハンドリングを忘れずに書いてくれます。

コンパイルの厳密性

AIが「なんとなく動く嘘のコード」を書いても、Rustの厳格な型システムと所有権ルールが即座にコンパイルエラーとして弾いてくれます。 「コンパイルが通るように直して」と指示するだけで、ある程度の品質が保証される点は、動的型付け言語にはない安心感です。

アーキテクチャによる「物理的」なガードレール

Rustの強力な型システムに加え、アーキテクチャ構成そのものもAI運用のためのガードレールとして活用しています。具体的には、クリーンアーキテクチャを実践し、各レイヤーをRustのワークスペース機能を使った別クレート(モノレポ構成)に分割しています。

依存関係の「物理的な」強制

単一のディレクトリ内でフォルダ分けするだけでは、AI(や人間)がついうっかり上位レイヤーから下位レイヤー(例:ドメイン層からインフラ層)をimportしてしまうミスを防げません。

しかし、レイヤーごとにクレート(ライブラリ)を分けてしまえば、Cargo.toml に依存関係を記述しない限りコードを利用できません。

.
├── Cargo.toml (workspace)
├── crates
│   ├── kernel      # ドメイン層。依存なし
│   ├── app         # アプリケーション層。kernelにのみ依存
│   ├── xxx-adapter # インフラ層。app,kernelに依存。
│   └── driver      # 最も外側の層。すべての層に依存。

このように構成することで、「ドメイン層がDB操作(インフラ層)に依存する」といったアーキテクチャ違反は、論理的以前に物理的に(コンパイルレベルで)不可能になります。 AIが間違った依存関係のコードを書いても、ビルドが通らないため、AIは自律的に「あ、この構成は許されないんだ」と気づき、正しいインターフェース経由の実装に修正します。

AIとの共通言語としての「定石」

クリーンアーキテクチャは世界中で広く知られたパターンであり、AI(LLM)の学習データにも大量に含まれています。

独自のオレオレ・アーキテクチャだと、AIに毎回コンテキスト説明が必要になりますが、クリーンアーキテクチャであれば、比較的雑な指示でも、AIは意図をある程度正確に汲み取ってくれます。

「AIにとって設計しやすい(予測しやすい)構造」を採用することは、結果としてプロンプトエンジニアリングのコストを下げることにも繋がります。

PostToolUseフックによるフォーマット強制

Claude Codeの設定で .claude/settings.jsonPostToolUse フックを仕込んでおくと、AIがファイルを編集した直後に自動コマンドを実行できます。 Rustの場合、ここでフォーマッターを走らせるのが効果的です。以下は自動フォーマットの設定例です。

  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write|MultiEdit",
        "hooks": [
          {
            "type": "command",
            "command": "file_path=$(jq -r '.tool_input.file_path'); if [ -n \"$file_path\" ] && [ -f \"$file_path\" ] && echo \"$file_path\" | grep -q '\\.rs$'; then cargo fmt -- \"$file_path\" 2>&1 || true; fi"
          }
        ]
      }
    ]
  }

これにより、AIが書いたコードは常にプロジェクトのスタイルガイドに沿った状態に保たれます。

CI/CDによる品質ゲート:人間が楽をするための厳格化

「AIにとって開発しやすい環境 = 人間にとっても開発しやすい環境」です。 人間がやる必要性の薄いチェックは、CI/CDで徹底的に自動化しています。

品質ゲート

以下のコマンド群が通らない限り、マージはできません。

  • cargo check (コンパイル)
  • cargo fmt --check (フォーマット確認)
  • cargo clippy -- -D warnings (Linterによる静的解析)
    • 単純なLintだけでなく、コードの複雑さを制限するルールを厳しめに設定しています。
      • clippy::cognitive_complexity: 認知的複雑度が一定以上のコードを禁止。
      • clippy::too_many_lines: 1つの関数が長すぎる場合に警告。
    • AIは文句も言わずに数百行の巨大な関数や、深いネスト構造を生成することができます。そのため、「人間が理解できる粒度(行数・複雑さ)に分割すること」をLintでAIに強制させています。これに引っかかると、AIは自らリファクタリングを行うようになりました。
  • cargo test / cargo test --release (テスト)
  • cargo doc (ドキュメント生成確認)
  • cargo build --release (リリースビルド確認)
# clippy.toml の設定例
cognitive-complexity-threshold = 7 # 複雑度が7を超えたら警告
too-many-lines-threshold = 50      # 50行を超えたら警告

以下は実際に使用しているGitHub Actionsの設定例です。

# .github/workflows/ci.yml
name: CI

on:
  pull_request:
    branches:
      - main
      - production
    paths:
      # Rustのソースコードとビルド設定ファイルが変更された場合のみCIを実行
      - 'crates/**'
      - 'Cargo.toml'
      - 'Cargo.lock'
      - 'rust-toolchain.toml'
      - 'clippy.toml'
      - '.github/workflows/ci.yml'
  workflow_dispatch:

env:
  RUST_BACKTRACE: 1

jobs:
  # Fast compilation check
  check:
    name: Check
    runs-on: ubuntu-latest
    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Setup Rust toolchain
        uses: actions-rust-lang/setup-rust-toolchain@v1

      - name: Setup Rust cache
        uses: swatinem/rust-cache@v2
        with:
          shared-key: "ci-check"
          cache-on-failure: true

      - name: Run cargo check
        run: cargo check --workspace --all-targets --verbose

  # Code formatting check
  fmt:
    name: Format
    runs-on: ubuntu-latest
    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Setup Rust toolchain
        uses: actions-rust-lang/setup-rust-toolchain@v1

      - name: Check formatting
        run: cargo fmt --all --check

  # Linting with clippy
  clippy:
    name: Clippy
    runs-on: ubuntu-latest
    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Setup Rust toolchain
        uses: actions-rust-lang/setup-rust-toolchain@v1

      - name: Setup Rust cache
        uses: swatinem/rust-cache@v2
        with:
          shared-key: "ci-clippy"
          cache-on-failure: true

      - name: Run clippy
        run: cargo clippy --workspace --all-targets -- -D warnings

  # Unit and integration tests (debug mode)
  test:
    name: Test (Debug)
    runs-on: ubuntu-latest
    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Setup Rust toolchain
        uses: actions-rust-lang/setup-rust-toolchain@v1

      - name: Setup Rust cache
        uses: swatinem/rust-cache@v2
        with:
          shared-key: "ci-test"
          cache-on-failure: true

      - name: Run tests
        run: cargo test --workspace --verbose

  # Unit and integration tests (release mode)
  test-release:
    name: Test (Release)
    runs-on: ubuntu-latest
    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Setup Rust toolchain
        uses: actions-rust-lang/setup-rust-toolchain@v1

      - name: Setup Rust cache
        uses: swatinem/rust-cache@v2
        with:
          shared-key: "ci-test-release"
          cache-on-failure: true

      - name: Run tests (release)
        run: cargo test --workspace --release --verbose

  # Documentation generation
  doc:
    name: Documentation
    runs-on: ubuntu-latest
    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Setup Rust toolchain
        uses: actions-rust-lang/setup-rust-toolchain@v1

      - name: Setup Rust cache
        uses: swatinem/rust-cache@v2
        with:
          shared-key: "ci-doc"
          cache-on-failure: true

      - name: Generate documentation
        run: cargo doc --workspace --no-deps --document-private-items --verbose
        env:
          RUSTDOCFLAGS: "-D warnings"

  # Release build verification
  build-release:
    name: Build (Release)
    runs-on: ubuntu-latest
    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Setup Rust toolchain
        uses: actions-rust-lang/setup-rust-toolchain@v1

      - name: Setup Rust cache
        uses: swatinem/rust-cache@v2
        with:
          shared-key: "ci-build-release"
          cache-on-failure: true

      - name: Build release
        run: cargo build --workspace --release --verbose

  # Summary job - all checks must pass
  # このジョブの目的:
  # 1. GitHub Branch Protectionで必須チェックとして設定する場合、
  #    個別のジョブ全てを設定する代わりに、このジョブ1つだけを設定すればよい
  # 2. PRのステータスチェックで「全てのCIが成功したか」を一目で確認できる
  # 3. ジョブの追加/削除時にBranch Protectionの設定を変更する必要がない
  ci-success:
    name: CI Success
    runs-on: ubuntu-latest
    needs:
      - check
      - fmt
      - clippy
      - test
      - test-release
      - doc
      - build-release
    if: always()
    steps:
      - name: Check all jobs succeeded
        run: |
          if [ "${{ contains(needs.*.result, 'failure') }}" == "true" ]; then
            echo "One or more CI jobs failed"
            exit 1
          elif [ "${{ contains(needs.*.result, 'cancelled') }}" == "true" ]; then
            echo "One or more CI jobs were cancelled"
            exit 1
          else
            echo "All CI jobs succeeded"
          fi

セキュリティチェック (cargo-deny)

依存関係(クレート)の管理も自動化しています。cargo-deny を使用し、4つの観点でスキャンを行います。

チェック 内容
advisories RustSec Advisory DBを使った脆弱性検出
bans 禁止クレートの検出、複数バージョン警告
licenses 許可ライセンスのみ使用か確認(MIT, Apache-2.0, BSD等)
sources crates.io以外からの怪しい依存関係を禁止

以下はGitHub Actionsでの設定例です。週次での定期実行も設定しています。

# .github/workflows/security.yml
name: Security

on:
  pull_request:
    branches:
      - main
      - production
    paths:
      # 依存関係やセキュリティ設定が変更された場合のみセキュリティチェックを実行
      - 'crates/**'
      - 'Cargo.toml'
      - 'Cargo.lock'
      - 'deny.toml'
      - '.github/workflows/security.yml'
  schedule:
    # Run weekly on Sunday at 00:00 UTC (09:00 JST)
    - cron: '0 0 * * 0'
  workflow_dispatch:

jobs:
  # Comprehensive dependency and security checks using cargo-deny
  deny:
    name: Cargo Deny
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        check:
          - advisories
          - bans licenses sources
    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Setup Rust toolchain
        uses: actions-rust-lang/setup-rust-toolchain@v1

      - name: Install cargo-deny
        run: cargo install cargo-deny --locked

      - name: Run cargo deny (${{ matrix.check }})
        run: cargo deny check ${{ matrix.check }}

  # Summary job
  # このジョブの目的:
  # 1. Matrix jobの結果を集約して単一のステータスチェックにする
  # 2. GitHub Branch Protectionで必須チェックとして設定する場合に便利
  # 3. PRのステータスチェックで「全てのセキュリティチェックが成功したか」を一目で確認できる
  security-success:
    name: Security Success
    runs-on: ubuntu-latest
    needs:
      - deny
    if: always()
    steps:
      - name: Check all security jobs succeeded
        run: |
          if [ "${{ contains(needs.*.result, 'failure') }}" == "true" ]; then
            echo "One or more security checks failed"
            exit 1
          elif [ "${{ contains(needs.*.result, 'cancelled') }}" == "true" ]; then
            echo "One or more security checks were cancelled"
            exit 1
          else
            echo "All security checks passed"
          fi

レビュープロセスの変革:Lint指摘からの卒業

これらのお膳立てをした結果、コードレビューの風景が大きく変わりました。

Before:

  • 「この変数名は命名規則に従っていません」
  • 「不要なimportが残っています」
  • 「このコードは使われてなさそうなので不要でしょうか?」
  • 「この関数、ドキュメントコメントがないので書いてほしいです」
  • 「こことあそこで一貫性が取れていないように見えます。どちらかに統一していただけないでしょうか。」

以前はこうした細かいLintやコーディングスタイルの指摘に、人間の貴重なリソースを使っているシーンがありました。

After:

  • 一次レビューはAIが実施: AIエージェントが自動でコードを巡回し、指摘を行う。
  • 対応案の提示: 指摘に対して「どう修正するか」の案もAIが出し、人間はそれを承認・選択するだけ。
  • CIによるブロック: スタイル違反やLintはそもそもCIで落ちる。ルールを厳しくしても人間への負荷がかからない。

これによって、人間のところにレビューが回ってくる頃には、ほとんどが純粋な「設計の議論」や「ビジネスロジックの妥当性」だけに集中できる状態になっています。

「これ、AIが直してくれるからいいや」と割り切れる部分が増えたことで、心理的な負担も大きく減りました。

まとめ

  1. ルールを決める: AIに守らせるための厳格なルール(型、Lint、テンプレート)を人間が決める。
  2. 作業を区切る: コンテキストを混ぜず、Issue作成、実装、PR作成を作業単位でAIに依頼する。
  3. コアに集中する: 浮いた時間で、エンジニアは本来やるべき「価値の創造」に向き合う。

Rustは単に「安全で速い言語」であるだけでなく、「AIの手綱を握り続けるための最適なパートナー」でもありました。 コンパイラが常に横でAIのコードを監査してくれる安心感があるからこそ、私たちはコアに集中できる時間が増えた気がします。

AI活用は今のところ銀の弾丸ではありません。どこから手をつけていくか迷っている方は、以下の順で試してみてはいかがでしょうか。

  • まずはコミットメッセージの作成だけAIに任せてみる
  • Issue/PRテンプレートを整備し、AIが参照できるようにする
  • Linterの設定を厳しくして、AIが自動修正するループを作る

最初から「全自動化」を考えるのがしんどい場合は、まずは人間が指示を出しAIが手を動かす「半自動化」をしやすい環境を整えることから始めてみることをおすすめします。

ところで、スパイダープラスでは仲間を募集しています。 少しでも興味が出てきたなという方はお気軽にご連絡ください。

レガシーコードの仕様整理にAIをフル活用してみた ― Claude Codeとsubagentで生産性爆上げした話

皆さんこんにちは〜スパイダープラスの舘です!!

もう年の暮れが近くなってきていますが、みなさんは年末の整理や掃除は進んでいますか? 今回は、プライベートだとそこら辺は苦手で放置気味になってしまっている僕がSPIDER+の仕様・ドキュメント整備をやった際にAIを活用して 「これはよかった」「効果的だった」と思ったことを、備忘録として皆さんに共有できたらなと思っています。

きっかけ ― なぜAIに頼ることにしたのか

SPIDER+はSwift/Objective-Cが混在する、それなりに規模の大きいコードベースです。 機能ごとの仕様ドキュメントを整備したいと思っていたものの、コードを読み解きながらまとめていくのはなかなか骨の折れる作業…。

「これ、AIに手伝ってもらえないかな?」と思って試してみたのが Claude Code でした。

続きを読む

Universal Links によるAppコンテンツへのアクセスが可能になりました

はじめに

こんにちは、スパイダープラス開発メンバーのちょっくんです。 みなさんはディープリンクという手法をご存知でしょうか? ウェブリンクからアプリを起動して特定の画面を表示させる、アレです。 ディープリンクにはいくつか手法があり、SPIDER+では長らくカスタムURLスキームを採用していました。しかし、この手法ではGMailでリンクを受け取った場合に、アプリを起動できなくなっていることがわかりました。 そこでiOS9以上で推奨されるUniversal Linksを採用したことについて紹介します。

続きを読む

AIレビューで負担を半減した方法:GitHub Copilotの活用事例

1. レビュー体制の限界とAIレビューへの期待

スパイダープラスでWeb開発を担当している高森です。

今回は、直近の開発フローの課題であった「コードレビュー工数の増大」を解決するために、AIレビューを本格導入し、開発体制をどう変革したかについてお話しします。

これまでの開発体制と課題

スパイダープラスのWeb開発チームでは、コードの品質を担保するため、Pull Request (以下PR) のレビューを必ず2名体制で行っていました。

続きを読む

クロスプラットフォームアプリ開発の新星「Swift SDK for Android」試してみた

はじめに

こんにちは!ピヨコです。

今回は2025年10月末に発表された「Swift SDK for Android」を使ってSwift製のライブラリをAndroidアプリで試していきたいと思います。
www.swift.org

今回はSwift.orgが提供しているサンプルを利用します。
github.com

旧来のスマホアプリ開発において、SwiftではiOSアプリしか開発できませんでした。
そのため、iOSAndroidクロスプラットフォームを実現するためには、iOS/AndroidのそれぞれのNativeアプリを個別で開発する、もしくはFlutterやReact Nativeといったフレームワークを採用する必要がありました。
今回のSDKの発表によって、既存のSwiftで動作しているiOSアプリがあればAndroidアプリとしてもSwiftの資産を利用できる可能性が高まります。
現在はプレビュー版ですので、今後のアップデートや情報の追加に期待したいところです。

※記事公開時点ではプレビュー版のSDKです。今後のSDKのアップデートによって手順など変更の可能性があります。必ず最新の情報を確認してください。

「Swift SDK for Android」とは?

「Swift SDK for Android」はOSSのコミュニティであるSwift.orgのAndroid workgroupによって開発されたSDKです。

「Swift SDK for Android」の仕組みとしては、「Swift Java」を利用しています。
「Swift Java」は、SwiftのプログラムからJavaのコードやライブラリを呼び出したり、逆にJavaからSwiftのコードを呼び出したりできるようにするためのライブラリとツールセットです。
github.com

大きな特徴としては、WindowsLinuxからも「Swift SDK for Android」を利用して開発可能ということです。
ただし、iOSアプリとして動作確認やストア公開するためには引き続きmacOSXcodeが必要になります。

また、現時点ではSwiftとJavaの相互変換のみで、UIKitやSwiftUIなどのFrameworkには対応できていません。
つまり、Swiftで書かれたロジック部分はJavaに変換しての活用が可能ですが、Viewについては従来通りそれぞれのプラットフォームでコードを持つ必要があります。

記事の内容

  • Swift SDK for Android利用のための環境構築
  • サンプルのアプリを動かしてみる
  • 自作のSwift製のライブラリを作ってAndroidアプリで動かしてみる

説明しないこと

実行環境

  • macOS Tahoe 26.0.1
    • 空き容量が5GB以上必要
    • ターミナルはzshを利用
  • Swift 6.3-dev (main-snapshot-2025-10-17)
  • Swift SDK for Android (DEVELOPMENT-SNAPSHOT-2025-10-17-a)
  • Java Development Kit (JDK) 25.0.1
  • Xcode 26.1.1
  • Android Studio 2025.2.1 Patch 1
    • 2025年11月時点で最新版ではないと正常に動作しないようです

導入

Getting Started with the Swift SDK for Androidに従って進めていきます。
2025-10-17のスナップショットが最新のようだったので、バージョンの「2025-10-16」を「2025-10-17」に置き換えて導入を行なっています。

手順はこの4ステップで進めていきます。

  1. Swiftlyの導入
  2. Host Toolchainの導入
  3. Swift SDK for Androidの導入
  4. Android NDKの導入
  5. Java Development Kit (JDK)の導入

Swiftlyの導入

Swiftly経由で特定バージョンのSwiftを利用する必要があるため、Swiftlyを導入します。
Xcodeが入っている場合はすでにSwiftがインストールされていますが、バージョンコントロールに必要なので必ず導入してください。

Install SwiftでOSごとの導入方法が書かれているので参考にしてください。

今回はMacなのでターミナルからこちらのコマンドを実行し、完了したらターミナルを再起動します。

% curl -O https://download.swift.org/swiftly/darwin/swiftly.pkg && \
  installer -pkg swiftly.pkg -target CurrentUserHomeDirectory && \
  ~/.swiftly/bin/swiftly init --quiet-shell-followup && \
  . "${SWIFTLY_HOME_DIR:-$HOME/.swiftly}/env.sh" && \
  hash -r

Host Toolchainの導入

SDKに対応したバージョンのSwiftを導入するため、以下を順番に実行します。

% swiftly install main-snapshot-2025-10-17
% swiftly use main-snapshot-2025-10-17
% swiftly run swift --version

最後にこのような表示が出ればOKです。

Apple Swift version 6.3-dev (LLVM d8e7cc748ee6e7f, Swift a07ea37d0054945)

Swift SDK for Androidの導入

次にSDKを導入するため、以下を順番に実行します。

% swift sdk install https://download.swift.org/development/android-sdk/swift-DEVELOPMENT-SNAPSHOT-2025-10-17-a/swift-DEVELOPMENT-SNAPSHOT-2025-10-17-a_android-0.1.artifactbundle.tar.gz --checksum ba57d4663be0ebba2a22d8377a282a37d2d801aa25e5c5ed4b8d9b7769ae6782
% swiftly run swift sdk list

最後にこのような表示が出ればOKです。

swift-DEVELOPMENT-SNAPSHOT-2025-10-17-a-android-0.1

Android NDKの導入

次にAndroid NDKを導入します。Android NDKは、AndroidでCやC++のコードを利用するためのツールセットです。
以下を順番に実行します。

% mkdir ~/android-ndk
% cd ~/android-ndk
% curl -fSLO https://dl.google.com/android/repository/android-ndk-r27d-$(uname -s).zip
% unzip -q android-ndk-r27d-*.zip
% export ANDROID_NDK_HOME=$PWD/android-ndk-r27d
% cd ~/Library/org.swift.swiftpm || cd ~/.swiftpm
% ./swift-sdks/swift-DEVELOPMENT-SNAPSHOT-2025-10-17-a-android-0.1.artifactbundle/swift-android/scripts/setup-android-sdk.sh

最後にこのような表示が出ればOKです。

setup-android-sdk.sh: success: ndk-sysroot linked to Android NDK at /Users/hoge/android-ndk/android-ndk-r27d/toolchains/llvm/prebuilt

Java Development Kit (JDK)の導入

最後にJDKを導入します。すでにJDKを導入済みの場合はバージョンや環境変数JAVA_HOMEの設定を確認してください。
将来的には不要になるとのことですが、現時点ではJDK 25が推奨とのことです。
以下を順番に実行します。今回はbrewで導入しますが、miseやsdkmanなど普段お使いのものがあればそちらで構いません。

% brew install openjdk
% sudo ln -sfn /opt/homebrew/opt/openjdk/libexec/openjdk.jdk /Library/Java/JavaVirtualMachines/openjdk.jdk
% echo 'export PATH="/opt/homebrew/opt/openjdk/bin:$PATH"' >> ~/.zshrc
% source ~/.zshrc
% java --version

このような表示が出ればJDKの導入はOKです。ここで表示されたバージョンを控えておき、続けてJAVA_HOMEを設定します。

openjdk 25.0.1 2025-10-21
OpenJDK Runtime Environment Homebrew (build 25.0.1)
OpenJDK 64-Bit Server VM Homebrew (build 25.0.1, mixed mode, sharing)

JAVA_HOMEのバージョンの25.0.1のところは適宜上記で控えたバージョンに差し替えて実行してください。

% echo 'export JAVA_HOME=$(/usr/libexec/java_home -v 25.0.1)' >> ~/.zshrc
% source ~/.zshrc

サンプルを動かす

さて、実際に公開されているサンプルを動かしてみたいと思います。
https://github.com/swiftlang/swift-android-examples/

hello-swift-java/の配下には、Androidアプリとアプリに導入するSwiftのライブラリがそれぞれ配置されています。

swift-android-example
├── hello-swift-java
│   ├── hashing-app   // Androidアプリの実装
│   ├── hashing-lib   // Swiftのライブラリ
│   ├── README.md
│   └── resources     // READMEの画像

Swift製ライブラリをAndroidプロジェクトから利用可能にする

サンプルのソースをローカルにクローンもしくはダウンロードしたら、README.mdの「Publish swift-java packages locally」の通りに進めていきます。
JDK 25の導入とJAVA_HOMEの環境変数の設定は終わっているのでその次から進めます。

まずはhashing-libの依存関係のあるPackageを取得しておきます。

% cd hashing-lib
% swift package resolve

取得したswift-javaAndroidプロジェクトから利用できるようにローカルのMavenRepositoryに公開します。

% ./.build/checkouts/swift-java/gradlew --project-dir .build/checkouts/swift-java :SwiftKitCore:publishToMavenLocal

バージョンを導入時のものに合わせる

完了したら、swift-android-exampleAndroid Studioで開きます。

このままだとSyncが失敗するため、バージョン関連を適宜変更します。

まず、Settings > Build, Execution, Deployment > Build Tools > Gradle からGradle JDKJAVA_HOMEに変えておきます。

次にhello-swift-java/hashing-lib/build.gradleswift-android.gradle.ktsのSwiftバージョンとSDKのバージョンをそれぞれ今回導入したものに変えておきます。
バージョン番号等は導入したものに適宜読み替えてください。

diff --git a/hello-swift-java/hashing-lib/build.gradle b/hello-swift-java/hashing-lib/build.gradle
index f0170c9..b0fc4f6 100644
--- a/hello-swift-java/hashing-lib/build.gradle
+++ b/hello-swift-java/hashing-lib/build.gradle
@@ -99,8 +99,8 @@ def swiftRuntimeLibs = [
     "swiftSynchronization"
 ]
 
-def sdkName = "swift-DEVELOPMENT-SNAPSHOT-2025-10-16-a-android-0.1.artifactbundle"
-def swiftVersion = "main-snapshot-2025-10-16"
+def sdkName = "swift-DEVELOPMENT-SNAPSHOT-2025-10-17-a-android-0.1.artifactbundle"
+def swiftVersion = "main-snapshot-2025-10-17"
 def minSdk = android.defaultConfig.minSdkVersion.apiLevel
 /**
  * Android ABIs and their Swift triple mappings
diff --git a/swift-android.gradle.kts b/swift-android.gradle.kts
index aef7792..de12330 100644
--- a/swift-android.gradle.kts
+++ b/swift-android.gradle.kts
@@ -14,8 +14,8 @@ data class SwiftConfig(
     var releaseExtraBuildFlags: List<String> = emptyList(),
     var swiftlyPath: String? = null, // Optional custom swiftly path
     var swiftSDKPath: String? = null, // Optional custom Swift SDK path
-    var swiftVersion: String = "main-snapshot-2025-10-16", // Swift version
-    var androidSdkVersion: String = "DEVELOPMENT-SNAPSHOT-2025-10-16-a-android-0.1" // SDK version
+    var swiftVersion: String = "main-snapshot-2025-10-17", // Swift version
+    var androidSdkVersion: String = "DEVELOPMENT-SNAPSHOT-2025-10-17-a-android-0.1" // SDK version
 )

Androidアプリを動かす

Syncが完了したら、実際にBuild、Runしていきます。
Configurationは「hello-swift-java-hashing-app」を指定します。  

無事にアプリを動かすことができました🎉

Swift製のライブラリを作ってAndroidアプリで動かしてみる

hashing-libを参考に新しくライブラリを作ってみます。
inputに対して「こんにちは!{input}さん!」のような文字列を返す簡単な内容です。

Swiftライブラリの作成

まずはSwiftPackageを作成します。

hello-swift-java
├── hello-lib
│   ├── build.gradle
│   ├── gradle.properties
│   ├── Package.swift
│   ├── Sources
│   │   └── SwiftHello
│   │       ├── swift-java.config
│   │       └── SwiftHello.swift
│   └── Tests
│       └── SwiftHelloTests

Package.swiftはhash-libのものからPackageの設定を書き換えます。
全て掲載すると長いので変更部分だけ抜粋します。

Package.swift

...
...

let package = Package(
  name: "SwiftHello",
  platforms: [.macOS(.v15), .iOS(.v16)],
  products: [
    .library(
      name: "SwiftHello",
      type: .dynamic,
      targets: ["SwiftHello"])
  ],
  dependencies: [
    .package(url: "https://github.com/swiftlang/swift-java", branch: "main"),
    .package(url: "https://github.com/apple/swift-crypto.git", "1.0.0"..<"4.0.0"),
  ],
  targets: [
    .target(
      name: "SwiftHello",
      dependencies: [
        .product(name: "Crypto", package: "swift-crypto"),
        .product(name: "SwiftJava", package: "swift-java"),
        .product(name: "CSwiftJavaJNI", package: "swift-java"),
        .product(name: "SwiftJavaRuntimeSupport", package: "swift-java"),
      ],
      swiftSettings: [
        .swiftLanguageMode(.v5),
        .unsafeFlags(["-I\(javaIncludePath)", "-I\(javaPlatformIncludePath)"], .when(platforms: [.macOS, .linux, .windows]))
      ],
      plugins: [
        .plugin(name: "JExtractSwiftPlugin", package: "swift-java")
      ]
    ),
    .testTarget(
      name: "SwiftHelloTests",
      dependencies: ["SwiftHello"]
    ),
  ]
)

build.gradleはhash-libのものからnamespaceとソースのディレクトリのパスを書き換えます。
全て掲載すると長いのでこちらも変更部分だけ抜粋します。

build.gradle

...
...

android {
    namespace "com.example.hellolib"
    compileSdkVersion 34

    defaultConfig {
        minSdkVersion 28
    }

    compileOptions {
        sourceCompatibility = JavaVersion.VERSION_17
        targetCompatibility = JavaVersion.VERSION_17
    }
}

...
...

def buildSwiftAll = tasks.register("buildSwiftAll") {
    group = "build"
    description = "Builds the Swift code for all Android ABIs."

    // If the package description changes, we should execute jextract again, maybe we added jextract to new targets
    inputs.file(new File(projectDir, "Package.swift"))
    inputs.dir(new File(layout.projectDirectory.asFile, "Sources/SwiftHello".toString()))

    outputs.dir(layout.buildDirectory.dir("../.build/plugins/outputs/${layout.projectDirectory.asFile.getName().toLowerCase()}"))

    File baseSwiftPluginOutputsDir = layout.buildDirectory.dir("../.build/plugins/outputs/").get().asFile
    if (!baseSwiftPluginOutputsDir.exists()) {
        baseSwiftPluginOutputsDir.mkdirs()
    }
    Files.walk(layout.buildDirectory.dir("../.build/plugins/outputs/").get().asFile.toPath()).each {
        // Add any Java sources generated by the plugin to our sourceSet
        if (it.endsWith("JExtractSwiftPlugin/src/generated/java")) {
            outputs.dir(it)
        }
    }
}

ライブラリの実装です。
実装後はswift build -vでビルドが通るか確認しておきます。

Sources/SwiftHello/SwiftHello.swift  

#if canImport(FoundationEssentials)
import FoundationEssentials
#else
import Foundation
#endif

public func hello(_ input: String) -> String {
    return "こんにちは!\(input)さん!"
}

Javaから参照するパッケージ名の設定です。
hash-libのものからjavaPackageだけ書き換えます。

swift-java.config

{
  "javaPackage": "com.example.swifthello",
  "mode": "jni"
}

Swiftライブラリのビルド

一通り準備ができたら、ライブラリをAndroidアプリで使えるようビルドします。
サンプルソースのルートのsettings.gradle.ktsAndroidアプリで参照するPackage名とパスを追加します。

diff --git a/settings.gradle.kts b/settings.gradle.kts
index 0be2778..e758263 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -28,6 +28,8 @@ include(":hello-swift-java-hashing-lib")
 project(":hello-swift-java-hashing-lib").projectDir = file("hello-swift-java/hashing-lib")
 include(":hello-swift-java-hashing-app")
 project(":hello-swift-java-hashing-app").projectDir = file("hello-swift-java/hashing-app")
+include(":hello-swift-java-hello-lib")
+project(":hello-swift-java-hello-lib").projectDir = file("hello-swift-java/hello-lib")

サンプルソースのルート、にgradlewがあるのでそちらからビルドを実行します。

% cd swift-android-example
% ./gradlew :hello-swift-java-hello-lib:assembleRelease

Androidアプリからの実行

サンプルのアプリのbuild.gradle.ktsにライブラリの依存関係を追加してSyncします。

diff --git a/hello-swift-java/hashing-app/build.gradle.kts b/hello-swift-java/hashing-app/build.gradle.kts
index e087f09..87f86fc 100644
--- a/hello-swift-java/hashing-app/build.gradle.kts
+++ b/hello-swift-java/hashing-app/build.gradle.kts
@@ -50,6 +50,7 @@ dependencies {
     implementation(libs.androidx.material3)
     implementation("org.swift.swiftkit:swiftkit-core:1.0-SNAPSHOT")
     implementation(project(":hello-swift-java-hashing-lib"))
+    implementation(project(":hello-swift-java-hello-lib"))
     testImplementation(libs.junit)
     androidTestImplementation(libs.androidx.junit)
     androidTestImplementation(libs.androidx.espresso.core)

Syncが完了したら、Androidアプリ側のソースを書き換えます。
挙動を試したいだけなので、既存のhash値を表示する部分をそのまま書き換えてます。

...
...
import com.example.swifthello.SwiftHello

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent {
            HashingAppTheme {
                Surface (
                    modifier = Modifier.fillMaxSize().padding(top = 64.dp),
                    color = MaterialTheme.colorScheme.background
                ) {
                    HelloScreen()
                }
            }
        }
    }
}

@Composable
fun HelloScreen() {
    val input = remember { mutableStateOf("") }
    val helloResult = remember { mutableStateOf("") }

    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(32.dp),
        verticalArrangement = Arrangement.spacedBy(12.dp)
    ) {
        TextField(
            value = input.value,
            onValueChange = { input.value = it },
            label = { Text("Enter name") },
            modifier = Modifier.fillMaxWidth()
        )

        Button(
            colors = ButtonDefaults.buttonColors(
                containerColor = Color(0xFFF05138),
                contentColor = Color.White
            ),
            onClick = {
                helloResult.value = SwiftHello.hello(input.value)
            }
        ) {
            Text("Hello")
        }

        if (helloResult.value.isNotEmpty()) {
            Text(
                text = helloResult.value,
                style = MaterialTheme.typography.bodyMedium
            )
        }
    }
}

BuildしてRunします。
新しいSwiftライブラリでも無事にアプリを動かすことができました🎉

まとめ

今回は10月末に発表された「Swift SDK for Android」を実際に触ってみました。

現在はプレビュー版なのでやや環境構築の手間が多い印象でしたが、今後の開発で改善されるとのことです。
また、検証中にSwift→Javaの変換が上手くいかないことがありましたが、UIKitやSwiftUIも未対応のことから今後の改善に期待です。
(余談:DateFormatterが変換できず、Swiftライブラリを書き直してます。)

今回簡単な検証をしただけですが、今後の開発の進展によってはクロスプラットフォームアプリの開発のフレームワーク選択に大きな影響を及ぼすだろうことが予想できます。今後のアップデートや情報の追加が楽しみです。

スパイダープラスでは、エンジニアを募集しています。興味のある方はぜひ、カジュアル面談からでもお気軽にご応募ください!