PR
個人開発 React NativeSQLiteコンテンツ配信ハングル個人開発推し韓マル開発ノート部分フォント

アプリのコンテンツをアップデートなしで届ける配信パイプライン 元ファイル → DB → 暗号化 → 部分フォント → サーバー → アプリ

サーバーとスマートフォンをつなぐデータのイメージ 個人開発

ハングル学習アプリ「推し韓マル」では、基本の単語 5,500 語あまり・例文・リアルな会話・流行語・「今日の韓国語」を、毎月アプリのアップデートなしで届けています。その配信の流れをまとめます。

スポンサーリンク

全体の流れ

content-src/**/*.jsonl  (人が直す元ファイル · git)
   │  build_content.py   検証 → SQLite (同じ元 = 同じ結果 · content_version = ハッシュ)
   │  check_examples.py  例文の整合の点検 (報告)
   │  build_fonts.mjs    使う文字だけを残した部分フォント
   │  pack_assets.mjs    アプリに入れる版を暗号化
   ▼
アプリのビルドに同梱 (content.bin)          ← アプリを最初に起動したときに使うコンテンツ
   │  release_content.mjs  圧縮 + 暗号化 + 往復検査 + manifest
   │  deploy_content.sh    サーバーに上げる (順番を保証 · 削除しない)
   ▼
アプリ: manifest を確認 → 新しい配信ならダウンロード → 次の起動で入れ替え + 案内シート

1. 元ファイルは JSONL、ビルドは決定的に

元ファイルは、1 行 = 1 レコードの JSONL です。diff が行単位で見え、人が直しても衝突が小さく済みます。

ビルドのスクリプトの原則は、次のとおりです。

  • 検証:キー・型・必須の値・許される値・id の重複・つながり先があるか・id の頭の文字と type の整合・日付の形式・つながりの種類ごとの from/to の type
  • 同じ元なら同じ結果:並び順を固定し、ビルドの時刻を記録しない。content_version = 元ファイル+スキーマのバージョンのハッシュ
  • 一時ファイルに作ってから入れ替える:失敗しても、もとの DB がそのまま残る
[完了] assets/content/content.db · content_version=6aafa6e2dbe43d91 · schema=2 · 2452KB
  items=11551 {'EXPRESSION': 28, 'SENTENCE': 5914, 'TREND': 28, 'WORD': 5581} · links=6123

content_version が決定的なので、「中身が本当に変わったか」がハッシュの比較 1 回で分かります。アプリ・サーバー・配信ファイルの名前が、すべてこの値を使います。

2. 例文の整合の自動点検:単語 ↔ 例文のずれを見つける

単語帳のコンテンツでよくある事故は、「単語の意味を直したのに、例文は古い意味のまま」です。規則をひとつ決めて、機械に見させます。

例文の韓国語にはその単語が、例文の日本語にはその意味の表記(活用形を含む)が入っていなければならない。

日本語の意味は活用するので、「語幹」を取り出して比べます。

KANJI = re.compile(r'[\u4e00-\u9fff々]+')
VERB_END = 'うくすつぬふむるぐぶい'

def ja_keys(gloss: str) -> list[str]:
    g = re.sub(r'(.*?)|\(.*?\)', '', gloss).replace('〜', '').strip()
    m = KANJI.search(g)
    if m:
        return [m.group(0)]                 # 漢字があれば漢字の部分 (活用と無関係)
    if g == 'する':
        return ['し', 'す', 'さ', 'せ']
    if g.endswith('する'):
        return [g[:-2]]
    if g.endswith('ない') and len(g) > 2:   # わからない → わから · わか
        keys = [g[:-2]]
        if len(g) - 3 >= 2:
            keys.append(g[:-3])
        return keys
    if g.endswith('だ') and len(g) >= 2:
        return [g[:-1]]
    if len(g) >= 2 and g[-1] in VERB_END:   # 最後の 1 文字を落とす
        return [g[:-1]]
    return [g or gloss]

韓国語の側は、動詞・形容詞の不規則な活用(했어요・돼요・써요・몰라요)が多いので照合を省き、日本語の側で照合します。結果は頻度順の表に保存し、頻度 400 位以内のずれは 0 を保っています。

つまずいたこと:この点検は、ビルドした DB を読みます。元ファイルを直した直後にビルドせずに回して、「まだずれが 1 件」と出て迷ったことがあります。順番は必ず、ビルド → 点検です。

3. 部分フォント:5.2 MB → 800 KB

日本語と韓国語のフォントは大きいです。アプリに実際に出てくる文字だけを残します。

const chars = {
  ja: uniqueChars(BASE, range(0x3040, 0x30ff), range(0x31f0, 0x31ff),   // 記号 + かな全部
                  jaContent, uiStrings, praise, legalChars()),           // コンテンツ + UI の文字列
  ko: uniqueChars(BASE, range(0x3131, 0x318e), koContent, praise),       // 記号 + 互換字母 + コンテンツ
};
for (const t of TARGETS) {
  const subset = await subsetFont(readFileSync(t.src), chars[t.lang], { targetFormat: 'sfnt' });
  writeFileSync(path.join(OUT_DIR, t.out), subset);
}
writeFileSync(path.join(OUT_DIR, 'subset-chars.json'), JSON.stringify(chars, null, 2) + '\n');
assets/fonts/NotoSansJP-Regular.ttf  814KB (元 5.2MB)
assets/fonts/NotoSansKR-Regular.ttf  277KB (元 5.9MB)
[完了] 部分フォント 5 個 合計 2.66MB · 文字数 ja=2457 ko=1492
  • 含める文字の一覧(subset-chars.json)も一緒に残します。Jest のテストが「UI の文字列のすべての文字がフォントにあるか」を検査し、豆腐(□)のまま出すのを防ぎます
  • コンテンツが変わればフォントも変わります。だから、フォントの生成をコンテンツのビルドの 1 段階にまとめました
  • 会社の情報のように、フォントの一覧に原文が残ると困る文字は、並べ替えて重複を除いた 1 文字ずつの形でだけ入れます

スポンサーリンク

モバイルアプリ開発の知識地図:一冊でiOS/Android/Flutterの開発技術が学べる本

モバイルアプリ開発の知識地図:一冊でiOS/Android/Flutterの開発技術が学べる本

楽天で見る

スポンサーリンク

モバイルアプリ開発の知識地図 / 國師誠也

モバイルアプリ開発の知識地図 / 國師誠也

Yahoo!ショッピングで見る

4. 同梱・配信するコンテンツの保護(考え方だけ)

平文の DB をアプリのバンドルやサーバーにそのまま置くと、誰でも取り出せてしまいます。このプロジェクトは、次の原則に従っています(細かい方式・キー・形式は公開しません)。

  • アプリのバンドルとサーバーには、圧縮+認証付き暗号(AEAD)でまとめたファイルだけを置く。アプリはメモリの中でだけ開き、機器に平文のファイルを残さない
  • 認証付き暗号なので、1 ビットでも変わると開けなくなる。破損や差し替えの検知を別に作る必要がない
  • 同じ元なら同じファイルになるようにする。ビルドのたびにバイナリが変わって git の diff が汚れることがない
  • 画のデータには、目に見えない透かしを入れる。複製が出てきたら、同じ鍵で計算し直して出どころを証明できる
  • 作った直後に往復の検査:アプリと同じ順番で開き直し、元とバイト単位で比べる。失敗したらファイルを残さない

参考:アプリに入る鍵は、結局はアプリから取り出せます。この保護の目的は、「URL さえ分かれば誰でも持っていける」無防備な状態をなくし、大量の複製を高くつかせることにあります。

5. サーバーへの配信:順番が安全を作る

# 上げる順番: .htaccess → 暗号化したコンテンツ (.bin) → manifest.json を最後に
# --delete は使わない (前の配信のファイルを残す · 古い版のアプリ · ダウンロード中の利用者を守る)
  • manifest を最後に上げる。manifest が指すファイルがまだない瞬間を、アプリに見せないためです
  • 古いファイルを消さない。古い版のアプリや、ダウンロードの途中の利用者が壊れません
  • サーバーのフォルダは読み取り(GET・HEAD)だけを許し、フォルダの一覧とスクリプトの実行を止めています
  • 本番の配信のあと、公開の URL で確かめます:manifest 200・配信番号の一致・コンテンツ 200・DELETE の拒否・フォルダ一覧の拒否・.htaccess の拒否
  • 配信先のパスを、スクリプトが検査します(パスのガード)。別のサイトのフォルダに上げてしまう事故を防ぎます

6. アプリの側:manifest を信じず、検査する

アプリの更新の判断は、DB・ネットワーク・暗号のライブラリから切り離した純粋関数にして、単体テストしています。(下は検査の項目の一部を縮めて写したものです。)

export function parseRemoteManifest(value: unknown): RemoteManifest | null {
  if (!value || typeof value !== 'object') return null;
  const v = value as Record<string, unknown>;
  const ok =
    Number.isInteger(v.release) && (v.release as number) >= 2026000000 && (v.release as number) <= 2099123199 &&
    typeof v.contentVersion === 'string' && /^[0-9a-f]{8,64}$/.test(v.contentVersion) &&
    Number.isInteger(v.schemaVersion) &&
    v.file === `${v.release as number}-${v.contentVersion}.bin` &&      // ファイル名まで突き合わせる
    Number.isInteger(v.size) && (v.size as number) > 24 &&
    typeof v.title === 'string' && v.title.trim() !== '';
  return ok ? (v as unknown as RemoteManifest) : null;                  // ひとつでも違えば受け取らない
}

/** 起動時に使うコンテンツ: 受け取っておいたものが同梱版・使用中より新しければ昇格 (→ 案内シート)。 */
export function chooseContent(bundled, active, pending) {
  const usable = (r) => r !== null && r.schemaVersion === bundled.schemaVersion && r.release > bundled.release;
  if (usable(pending) && (!usable(active) || pending.release > active.release)) return { use: pending, promoted: true };
  return { use: usable(active) ? active : null, promoted: false };
}
  • 配信の番号は YYYYMMDDNN の整数です。比べやすく、日付の表示もついでにできます
  • スキーマが違えば使わない。アプリのアップデートで DB の構造が変わっても、古い構造の配信版を開いて落ちることがありません
  • アプリのアップデートで同梱版のほうが新しくなったら、同梱版を使う。受け取っておいた古いコンテンツが、新しいアプリの同梱版を上書きしません
  • 新しいコンテンツはダウンロードしておくだけで、次の起動のときに入れ替えます。使っている途中で DB が変わりません

おわりに

  • 元ファイルは人が直しやすい JSONL、作るものは決定的なビルドで
  • 「変わったか」はハッシュで、「合っているか」は検査とテストで判断する
  • 配信は、順番(manifest を最後に)と保存(消さない)で安全にする
  • アプリは、サーバーの応答を信じずに検査する

スポンサーリンク

Coding Assistant対応 iPhoneアプリ開発集中講座 Xcode 26/iOS 26

Coding Assistant対応 iPhoneアプリ開発集中講座 Xcode 26/iOS 26

楽天で見る

スポンサーリンク

iPhoneアプリ開発集中講座 iOS 26 Xcode 26 AIアシスタント(Coding Assistant)も解説

iPhoneアプリ開発集中講座 iOS 26 Xcode 26 AIアシスタント(Coding Assistant)も解説

Yahoo!ショッピングで見る

この記事について

自社のハングル学習アプリ「推し韓マル」の、毎月のコンテンツ配信の仕組みの記録です。コードとログは実際のソースとビルドから抜き出し、コメントを読みやすく縮めています。動作を変える修正はしていません。コンテンツの保護については、有料のコンテンツの保護に直結するため、鍵の名前や値・方式・形式を伏せ、考え方だけを書いています。

コメント

タイトルとURLをコピーしました