🐦‍⬛

Perryファーストインプレッション - TypeScriptのままネイティブアプリが作れる新しい選択肢

に公開

こんにちは!テラーノベルでiOS/Android/Webとフロントエンド周りを担当している @kazutoyoです!

TypeScriptをそのままネイティブバイナリにコンパイルできる「Perry」が話題になっていたので、実際に試してみました。
今回はそのファーストインプレッションをお届けします。
https://www.perryts.com/

Perryとは?

Perryは、TypeScriptのコードをそのままネイティブバイナリにコンパイルできる、Rust製のコンパイラです。

TypeScriptで書いたCLIツールを配るときは、npmで公開して npm install -g してもらうのが一般的ですが、この場合は相手側にNode.jsが入っている前提になります。ランタイムごとバンドルして単体バイナリ化する方法(pkg、bun build --compile、deno compile など)もありますが、ランタイムを丸ごと同梱する分どうしてもサイズが大きくなりがちでした。

Perryを使うと、 perry main.ts -o myapp だけで、ランタイムを同梱するのではなく、TypeScriptを直接ネイティブの機械語にコンパイルした単体バイナリが出来上がります。Hello Worldならたった330KB。
Rust や Go で書いたときのような「バイナリ一個配ればOK」という体験を、TypeScriptのまま得られるのが、Perryのいちばん嬉しいところだと思います。

中身としては、パース(ソースコードの解析)にSWC、ネイティブコードの生成にLLVMという、定番のツールを組み合わせて作られています
実行速度もNode.js比で平均2.2倍速い、というのが公式ベンチマークの数字です。

さらに面白いのが、macOS / iOS / Android / Windows / Linux / Web に対応したネイティブUIフレームワーク perry/ui が標準で付いてくるところです。
TypeScriptで書いたコードが、各プラットフォームのネイティブのUIで動きます。

perry/uiと既存のクロスプラットフォームフレームワークとの違い

詳しい使い方は後のセクションで紹介しますが、ここではまず「既存のクロスプラットフォームフレームワークと何が違うのか」を整理しておきます。
公式サイトに分かりやすい比較表が載っています。


https://www.perryts.com/ja

クロスプラットフォームでUIを作るフレームワークと聞くと、Electron、React Native、Flutterあたりを思い浮かべる方が多いと思います。

それぞれ少しずつ立ち位置が違っていて、

  • Electron: Web技術(HTML/CSS/JS)でデスクトップアプリが作れるのが強みですが、アプリごとにChromiumブラウザエンジンとNode.jsランタイムを丸ごと同梱する仕組みなので、どうしてもバイナリサイズやメモリ消費が大きくなりがちです。表示されるUIもWebベースで、各OSネイティブのウィジェットではありません。
  • React Native: 各OSのネイティブウィジェットを使えるのが強み。ただしPerryとは違い、実行時にHermesなどのJSエンジンが必要で、JSI経由でJS側とネイティブ側がやり取りする構成になっています。
  • Flutter: Dartを事前にネイティブコードへコンパイルするので実行は速いですが、UIは各OSのウィジェットではなくSkiaという独自の描画エンジンで全部自前で描いています。なので「そのOSらしさ」は自分で寄せていく必要があります。
  • Ionic: 中身はWebアプリをネイティブアプリの皮で包んでいるような構造で、Electronと近い立ち位置です。

比較表で見ると、「ネイティブコンパイル」「本物のプラットフォームウィジェット」「ランタイムオーバーヘッドなし」の3つをすべて満たしているのはPerryだけ、というのが分かります。

TypeScriptを事前にネイティブバイナリへコンパイルしたうえで、各OSの本物のウィジェット(macOSならAppKit、iOSならUIKit、AndroidならAndroid Views……)を直接呼び出す、という構成になっているためです。

ブラウザエンジンもJSランタイムも挟まらないので、動作も軽快でバイナリサイズも小さく収まります。

もちろんこの比較表はPerry公式のものなので、「自分たちの強みが際立つように切り取られている」という面はあります。

例えばReact NativeもExpo UIを使えば、SwiftUIやJetpack Composeの本物のネイティブコンポーネントをJSから直接呼び出せるようになってきていて、「本物のプラットフォームウィジェット」という項目は必ずしもPerryの専売特許というわけではありません。

Flutterの独自描画もそれはそれで「全プラットフォームで完全にピクセル一致する」というメリットがありますし、Electronはエコシステムの成熟度と開発体験の良さという大きな強みがあります。

とはいえ、 「TypeScriptのままネイティブコンパイルして、各OSの本物のウィジェットを使える」 という組み合わせを実現しているのはPerryだけ、というのは間違いなさそうです。

Perryを試してみる

インストール

公式ドキュメントのInstallationに従ってインストールしていきます。

前提: Cのツールチェーンが必要

PerryはTypeScriptをネイティブバイナリにコンパイルするときに、システムのCツールチェーン(リンカ)を使います。
なので、どのインストール方法を選んでも、あらかじめ以下のいずれかが必要になります。

  • macOS: Xcode Command Line Tools ( xcode-select --install )
  • Linux: gcc または clang(Ubuntu/Debianなら apt install build-essential
  • Windows: Visual Studio Build Tools の「Desktop development with C++」ワークロード

インストール方法

公式では npm経由が推奨 となっていて、対応プラットフォーム(macOS arm64/x64、Linux x64/arm64 glibc+musl、Windows x64)をまとめてカバーできるのもこの方法だけです。

# グローバルインストール
npm install -g @perryts/perry

# または、プロジェクトローカルに入れる場合
npm install @perryts/perry
npx perry src/main.ts -o myapp

インストールの確認

インストールされたか perry --version で確認してみましょう。

 perry --version
perry 0.5.112

Hello Worldしてみる

ドキュメントのHello Worldに従って、早速動かしてみます。

まずhello.tsを作って、お決まりの一行を書きます。

console.log("Hello, Perry!");

あとはコンパイルして

perry hello.ts -o hello

実行します

 ./hello
Hello, Perry!

これだけです。Node.jsもバンドラーも要らず、hello という単体の実行ファイルが出来上がり、そのまま動きます。TypeScriptで書いているのに「Rustでビルドした」ような感覚で、新鮮でした。

試しに file コマンドで中身を見てみると、たしかにネイティブバイナリになっていることが分かります。

  file hello
hello: Mach-O 64-bit executable arm64

もう少し実用的な例

続いて、フィボナッチ数を計算する例も試してみます。

function fibonacci(n: number): number {
  if (n <= 1) return n;
  return fibonacci(n - 1) + fibonacci(n - 2);
}

const start = Date.now();
const result = fibonacci(40);
const elapsed = Date.now() - start;

console.log(`fibonacci(40) = ${result}`);
console.log(`Completed in ${elapsed}ms`);

ビルドと実行をします。

 perry fib.ts -o fib
Collecting modules...
Found 1 module(s): 1 native, 0 JavaScript
Generating code...
Wrote object file: fib_ts.o
Linking (runtime-only)...
Wrote executable: fib
Binary size: 0.7MB

 ./fib
fibonacci(40) = 102334155
Completed in 295ms

また、試しにNode.js(24)とBunでこちらのfib.tsを実行してみました。

❯ node fib.ts
fibonacci(40) = 102334155
Completed in 754ms

❯ bun fib.ts
fibonacci(40) = 102334155
Completed in 486ms

1回のみの計測なのであくまで参考値ですが、Node.js比で2.5倍以上、Bun比で1.5倍程度の速度はでていそうです。

async/awaitもそのまま動く

また、実用的なアプリを作るうえで必要なfetchやasync/awaitもあっさり動きました。

async function fetchPerryStars(): Promise<void> {
  const response = await fetch("https://api.github.com/repos/PerryTS/perry", {
    headers: {
      "User-Agent": "perry-first-impression",
    },
  });
  const data = await response.json();
  console.log(`⭐ Perry has ${data.stargazers_count} stars on GitHub`);
}

await fetchPerryStars();
 perry fetch-test.ts
Collecting modules...
Found 1 module(s): 1 native, 0 JavaScript
Generating code...
Wrote object file: fetch_test_ts.o
Linking (with stdlib)...
Wrote executable: fetch-test
Binary size: 5.1MB
❯ ./fetch-test
⭐ Perry has 867 stars on GitHub

ネイティブUIアプリを作ってみる

ここまではコマンドラインのプログラムを動かしてきましたが、Perryの本領はやはりネイティブUIアプリがそのままTypeScriptで書けるところ。
簡単なカウンターアプリを作ってみます。

import { App, VStack, Text, Button, State } from "perry/ui";

const count = State(0);

App({
  title: "My Counter",
  width: 300,
  height: 200,
  body: VStack(16, [
    Text(`Count: ${count.value}`),
    Button("Increment", () => {
      count.set(count.value + 1);
    }),
    Button("Reset", () => {
      count.set(0);
    }),
  ]),
});

実行するとこのようにネイティブのアプリが起動しました。

スタイルを設定してみる

次のようにテキストやボタンにスタイルを設定してみましょう

import {
  App,
  VStack,
  HStack,
  Text,
  Button,
  Spacer,
  State,
  stackSetAlignment,
} from "perry/ui";

const count = State(0);

// カウント表示のテキスト
const display = Text(`${count.value}`);
display.setFontSize(64);
display.setFontFamily("monospaced");
display.setColor("#007AFF");

// ボタン2つ
const decBtn = Button("−", () => count.set(count.value - 1));
decBtn.setCornerRadius(20);
decBtn.setBackgroundColor("#FF3B30");
decBtn.setControlSize(3); // large

const incBtn = Button("+", () => count.set(count.value + 1));
incBtn.setCornerRadius(20);
incBtn.setBackgroundColor("#34C759");
incBtn.setControlSize(3);

// ボタンを横並びに
const controls = HStack(16, [decBtn, incBtn]);

// 全体を縦に並べて、上下にSpacerを入れて中央寄せに
const container = VStack(24, [
  Spacer(),
  display,
  controls,
  Spacer(),
]);
container.setPadding(40);
stackSetAlignment(container, 9); // CenterX = 水平中央揃え

App({
  title: "Counter",
  width: 400,
  height: 300,
  body: container,
});

実行してみたところ、レイアウトはセンタリングされるなどされていますが、テキストのサイズやボタンの色などが適用されませんでした。

試しにWebでも確認してみましょう。Web用に出力します。

perry counter.ts --target web -o counter.html

テキストのサイズやボタンの setCornerRadius などは効いているようでしたが、背景色やレイアウトなどがうまく反映されていないようでした。

まとめ

ここまでPerryを軽く触ってきました。ファーストインプレッションとして感じたことを、率直にまとめておきます。

良かったところ

TypeScriptでネイティブバイナリが生成される

これが何よりの魅力でした。Hello Worldが数百KBの実行ファイルになり、Node.jsもバンドラーも要らずそのまま動く、というのは、これまでTypeScriptで開発していると味わえなかった感覚です。

RustやGoで感じる「バイナリ1つ配ればOK」の気軽さが、普段書き慣れたTypeScriptのまま得られるのは、CLIツール配布の選択肢として魅力的だと思います。

標準的なNode.jsのAPIを使えたり、npmのエコシステムも全てではないですが対応しているので、普段のNode.jsのスクリプトを書く感覚でネイティブ用のバイナリを作ることが出来そうです。

Zero config で始められる手軽さ

tsconfig.json も package.json も要らず、.ts ファイルだけで始められます。ちょっと試したい、ちょっと書いてビルドしたい、というときの腰の軽さが気持ちよかったです。

新たな選択肢としてのネイティブクロスプラットフォーム

これまでデスクトップアプリを作ろうと思うと、Electron、React Native (for Desktop)、Flutterあたりが候補になっていましたが、Perryはそこに新しい選択肢として加わってきました。

しかもただの選択肢というだけでなく、TypeScriptで書いたコードが本物のネイティブバイナリになるので、Electronのようにブラウザエンジンを同梱する必要もなく、ビルドサイズは小さくパフォーマンスも良好です。

さらに、デスクトップ(macOS / Windows / Linux)だけでなく、モバイル(iOS / Android)やWebまで、同じTypeScriptコードから様々なプラットフォーム向けにネイティブなアプリを吐き出せるというのも大きな魅力です。

TypeScriptという書き慣れた言語のままデスクトップ・モバイルまで幅広くカバーでき、ネイティブのパフォーマンスで動くアプリが作れるというのは面白いと感じました。

気になったところ

ドキュメントがまだそこまで整備されていない

ドキュメントに載っているAPIと実動作がずれている場面が何度かありました。

Stateの .get().value の表記ゆれ、スタイリング系メソッドがプラットフォームによって動いたり動かなかったり、といった具合です。

Active Developmentなプロダクトなので当然といえば当然ですが、本気で使おうとすると、ドキュメントと手元の挙動の差分に向き合う覚悟は必要そうです。

プラットフォームごとに実装状況が違う

今回はmacOSとWebでの出力を確認しましたが、シンプルなアプリケーションでもスタイリングが上手く行かない問題がありました。

対応しているプラットフォームが広い分、同じWidgetで全てのプラットフォームのAPIをカバーするというのも難しい気がしているので、今後どのように対応していくのかなとも思いました。

UIの書き味が「TypeScript的」ではなく「SwiftUI / Flutter 的」

これは好みの話でもありますが、TypeScript開発者にとっては一番引っかかるポイントかもしれません。
Perryのperry/uiはJSXを使わず、関数呼び出しをネストしてUIを組み立てる宣言的スタイルです。

SwiftUIやFlutterに慣れている方なら「あ、あの感じね」とすぐ馴染める書き味ですが、Reactに慣れたTypeScript開発者からすると、JSXで書けないので、新たにFlutterやSwiftUIで実装しているような気分になりました。

perry-reactというプロジェクトで、React/JSXのようにPerryのUIを構築する方法もあるようなのですが、こちらもまだ開発段階のため制限も多いようです。

今後への期待

Perryは、 「TypeScriptのままネイティブコンパイルして、各OSの本物のウィジェットを使える」 という組み合わせを実現している、現時点でほぼ唯一のフレームワークです。

比較表のセクションでも触れた通り、React NativeはJSランタイムが必要で、Flutterは独自描画、Electronはブラウザエンジン同梱、と各々にトレードオフがあるなかで、Perryが目指しているポジションはとてもユニークです。

今はまだ各プラットフォームで実装のバラつきがあり、perry-reactのようなJSX対応もまだこれから、という段階です。UIまわりで本格的にデスクトップ・モバイルアプリを作るのは、もう少し時間がかかりそうな印象でした。

一方で個人的に「これは今からでもハマりそう」と感じたのは、CLIツールなどのコマンドラインアプリ用途です。

UIのスタイリングが不完全でも、console.log や fetch、ファイル操作あたりのコア機能はしっかり動きますし、何よりTypeScriptで書いたものが数百KBの単体バイナリになって配布できるというのは、CLIツールを作るエンジニアにとってかなり嬉しいはずです。

今までならOS別にビルド成果物を分けて配る必要があったようなツールが、Perry一つで対応できるようになるかもしれません。

スタイリングが各プラットフォームで出揃ってきて、React互換レイヤーも成熟してきたら、デスクトップアプリやモバイルアプリの選択肢としても本気で検討できるようになりそうです。

まずはツール系の用途から触ってみて、プロダクトが成熟していく様子を追いかけるのが、今Perryと付き合ういちばん良い距離感かな、と感じました。

「TypeScriptで書いたアプリを、Rust/Go的な気軽さで配布したい」という気持ちが少しでもある方は、ぜひ触ってみてください。

それでは、よいPerryライフを!

テラーノベル テックブログ

Discussion