RORK LABEN
IOS27 — iOS 27 のパブリックベータが7月に公開されました。正式提供は9月ごろの見込みで、AI を組み込んだ Siri とアプリ起動の高速化が主な変更です(iPhone 11 以降)REQ — iOS 27 自体は iPhone 11 以降に届きますが、AI 機能の多くは iPhone 15 Pro 以降が要件です。最低対応バージョンと AI 前提機能の可否は別の判断になりますBETA — 本番端末にベータを入れずに確かめるなら、Mac のクラウド環境と Xcode 26 の Simulator が現実的です。初期の互換確認はこれで足りますTWOCLICK — Rork Max は web 上で Xcode を置き換えることを狙う Swift アプリビルダーで、自然言語からの記述・実機テスト・2クリックの App Store 公開を一続きにしていますREACH — Rork Max のローンチ投稿は X で800万表示を超え、2週間で年間収益が倍になったと報告されていますEXPO — 通常の Rork は React Native (Expo) で iOS・Android・web を横断する構成を維持しています。Rork Max が純 Swift を出すのに対し、一つのコードベースで広げる道ですIOS27 — iOS 27 のパブリックベータが7月に公開されました。正式提供は9月ごろの見込みで、AI を組み込んだ Siri とアプリ起動の高速化が主な変更です(iPhone 11 以降)REQ — iOS 27 自体は iPhone 11 以降に届きますが、AI 機能の多くは iPhone 15 Pro 以降が要件です。最低対応バージョンと AI 前提機能の可否は別の判断になりますBETA — 本番端末にベータを入れずに確かめるなら、Mac のクラウド環境と Xcode 26 の Simulator が現実的です。初期の互換確認はこれで足りますTWOCLICK — Rork Max は web 上で Xcode を置き換えることを狙う Swift アプリビルダーで、自然言語からの記述・実機テスト・2クリックの App Store 公開を一続きにしていますREACH — Rork Max のローンチ投稿は X で800万表示を超え、2週間で年間収益が倍になったと報告されていますEXPO — 通常の Rork は React Native (Expo) で iOS・Android・web を横断する構成を維持しています。Rork Max が純 Swift を出すのに対し、一つのコードベースで広げる道です
記事一覧/アプリ開発
アプリ開発/2026-07-31上級

生成した壁紙カタログのハッシュが毎回変わる — 非決定の出どころを5つ切り分ける

同じ画像フォルダから同じ catalog.json が出てこない。opendir の列挙順、JSON.stringify のキー並べ替え、照合順序、Unicode 正規化、非同期の完了順。1,225ファイルで一つずつ切り分けて、出力を1種類に固定するまでの実測記録です。

ビルド再現性Node.js2カタログ生成CI5Unicode

プレミアム記事

git diff が 1,984 行。

画像は1枚も追加していません。壁紙アプリのカタログを作り直す npm run catalog を、直前のコミットと同じ状態でもう一度走らせただけです。11,041 行の catalog.json のうち、およそ 18% の行が動いていました。

このスクリプトは、アセットフォルダを舐めて catalog.json を吐くだけの短いものです。壁紙系を個人開発で複数本まわしていると、画像の追加は日常作業になります。だからこそ、生成物が正しいことは人の目ではなく機械で担保したいところでした。CI では「生成物が最新か」を git diff --exit-code で見張るつもりでした。ところが毎回差分が出るので、ゲートは常に赤。結局チェックを外し、生成物のレビューは目視に戻っていました。

差分が出る理由をタイムスタンプだと決めつけて、その行を消しました。直りませんでした。そこから、非決定の出どころを一つずつ切り離して測っていくことになります。

計測環境は Node v22.22.3 / Ubuntu 22.04(Linux 6.8)。検証用に 1,225 ファイル(連番 ID 360件・カテゴリ接頭辞つき 865件・うち日本語名 25件)のフォルダを ext4 と tmpfs の3か所に用意しました。以下の数値はすべてこの環境で実際に走らせて取ったものです。

差分の中身を先に見る

やり直しの前に、何が動いているのかを見ます。

$ node scripts/gen-catalog.mjs assets/ public/catalog.json
$ cp public/catalog.json /tmp/a1.json
$ node scripts/gen-catalog.mjs assets/ public/catalog.json
$ diff /tmp/a1.json public/catalog.json | wc -l
1984
$ wc -l /tmp/a1.json
11041 /tmp/a1.json

差分は 2 種類に分かれていました。generatedAt の 1 行と、items の中身がブロックごと入れ替わっている大量の行です。

後者が厄介でした。値は変わっていません。同じオブジェクトが別の位置に出ているだけです。JSON の意味としては等価で、アプリの動作にも影響しません。だからこそ放置されていたわけですが、差分ゲートを効かせたい以上、バイト列まで一致させる必要があります。

生成スクリプトはこういう形をしていました。

// scripts/gen-catalog.mjs(当時)
import fsp from "node:fs/promises";
import path from "node:path";
 
const SRC = process.argv[2];
const OUT = process.argv[3];
 
const catalog = {};
const tagSet = new Set();
const names = [];
 
// メモリを気にして opendir でストリーミング
const dir = await fsp.opendir(SRC);
for await (const ent of dir) {
  if (ent.isFile() && ent.name.endsWith(".jpg")) names.push(ent.name);
}
 
await Promise.all(names.map(async (name) => {
  const st = await fsp.stat(path.join(SRC, name));
  const id = name.replace(/\.jpg$/, "");
  const cat = id.includes("_") ? id.split("_")[0] : "legacy";
  tagSet.add(cat);
  catalog[id] = {
    id, file: name, category: cat,
    bytes: st.size, width: 1290, height: 2796,
    aspect: 1290 / 2796,
  };
}));
 
const doc = {
  generatedAt: new Date().toISOString(),
  count: Object.keys(catalog).length,
  tags: [...tagSet].sort((a, b) => a.localeCompare(b)),
  items: catalog,
};
await fsp.writeFile(OUT, JSON.stringify(doc, null, 2));

30行ほどです。この中に、非決定の原因が5つ入っていました。

タイムスタンプを消しても直らない

最初に generatedAt を落としました。これで終わりだと思っていました。

同じディレクトリに対して 20 回連続で走らせ、出力の SHA-256 を集合に入れて数えます。

スクリプトの状態20回の出力ハッシュ
そのまま20 種類
generatedAt を削除20 種類

1 種類も減りませんでした。タイムスタンプは、目に見えていただけの原因です。

残っていたのは Promise.all の書き方でした。names.map(async ...) の中で配列に push していると、追加される順番は「その非同期処理が終わった順」になります。stat の完了順は毎回わずかにばらつくので、そこから先の並びがすべて変わります。

200件の ID で 20 回ずつ試した結果です。

集め方20回の出力
完了時に配列へ push20 種類
Promise.all の戻り値を使う1 種類

Promise.all は戻り値の配列を入力順で並べ直してくれます。値を返す形に書き換えるだけで、この原因は消えます。

// 完了順に依存する書き方
const items = [];
await Promise.all(names.map(async (n) => { const st = await fsp.stat(n); items.push(mk(n, st)); }));
 
// 入力順が保たれる書き方
const items = await Promise.all(names.map(async (n) => { const st = await fsp.stat(n); return mk(n, st); }));

ここまでで、同じディレクトリを繰り返し処理する限りは出力が 1 種類に落ち着きました。ところが CI では、まだ差分が出ていました。

ここまでお読みいただきありがとうございます。

この記事の続きを読む

この先には、実装コードやベンチマーク結果など、実務でお役に立てる内容をご用意しています。このサイトは広告を掲載しておらず、サーバーや開発にかかる費用はメンバーの皆様のご支援で成り立っています。もしお役に立てていましたら、ご支援いただけますと大変ありがたいです。

この記事で得られること
readdir は整列済み・opendir は未整列という差の実測。同じ1,225ファイルでもファイルシステムが変われば opendir の順序は同位置一致 2/1225 まで崩れます
非決定を1つずつ外していく切り分け表。タイムスタンプを消しても20回中20種類のままだった段階と、その先に何が残っていたか
決定的な生成スクリプトの全文と、CI で git diff --exit-code を差分ゲートとして機能させるための正規化ガード
Stripe による安全な決済 · いつでもキャンセル可能

この記事を購入する

この先の内容をすべてお読みいただけます。一度のご購入で、いつでも何度でもアクセスできます。このサイトは広告を掲載しておらず、皆さまのご支援がサーバー費用などの運営を支えています。

または
メンバーシップなら全記事が読み放題 →
シェア

お読みいただきありがとうございます

Rork Lab は広告なしで運営しており、サーバー費用などの運営コストはメンバーシップのご支援で賄っています。実装コード・ベンチマーク・本番設計パターンなど、実務でお役立ていただける記事を毎日更新しています。もし読んでよかったと感じていただけましたら、ぜひご覧ください。

  • コピー&ペーストで使える実装コード付き
  • 毎日新しい上級ガイドを追加
  • ¥580/月 または ¥1,480 の永久アクセス
メンバーシップを見る →

関連記事

アプリ開発2026-07-29
壁紙カタログに同じ絵が二度並んでいた — 知覚ハッシュの閾値を324枚で実測して決める
画像カタログの重複検出を、SHA-256・dHash・色相署名・ブロック署名の4通りで実測しました。dHash 単独では適合率0.9%。平坦な画像でハッシュが潰れる現象と、閾値ではなく正解ラベルの方が間違っていた顛末を、再現できるコードと数値で共有します。
アプリ開発2026-06-25
言語を増やすたびに未翻訳が本番へ漏れる — Rork(Expo) アプリの文言カタログ運用と抜け検出の設計
Rork で生成した Expo アプリを多言語化したあと、言語を足すたびに未翻訳が本番へ漏れる問題を仕組みで止めるための設計を共有します。文言カタログの一元化、CI でのキー抜け・余り検出、フォールバック連鎖、複数形、擬似ロケールでのレイアウト検証まで実装込みで解説します。
アプリ開発2026-07-18
1行も書かずに App Store 審査を一周する — ステッカーパックを申請の予行演習に使う
iMessage ステッカーパックはコードなしで App Store に提出できる数少ない枠です。収益にならない制約と引き換えに、申請工程を丸ごと一度通せます。画像仕様の落とし穴と、予行演習としての使い方をまとめました。
📚RECOMMENDED BOOKS
大規模言語モデル入門
山田育矢
LLM開発
生成AIプロンプトエンジニアリング入門
我妻幸長
プロンプト
Claude CodeによるAI駆動開発入門
平川知秀
AI駆動開発
※ アフィリエイトリンクを含みます
もっと見る →