Docker Composeを使ってPalWorldのサーバを建てる

最近はPalWorldにどっぷりハマっています。
マルチプレイをする場合は、24時間起動し続けたいわけでなければ無理してサーバを建てるメリットはないんですが、 それでも建てたいかつ、比較的環境を汚したくない人向けの簡単な記事です。

ポート開放や正式な建て方に関しては公式や他のブログを参考にしてください。
あくまで弊宅ではKubernetesクラスタ上でPalWorldを動作させているため、 このやり方はデータのチェックや動作確認で利用していることが多いです。

TL;DR

  • PalWorldSettings.iniとGameUserSettings.iniを用意する
  • 記事のDockerfileとcompose.yamlを参考にして用意する

ディレクトリ構造

最終的なディレクトリ構造は以下のような形になります。

.
├── compose.yaml
├── Dockerfile
├── GameUserSettings.ini
├── PalWorldSettings.ini
└── SaveGames

【任意】SaveGamesを用意する

既にあるセーブデータがあり再利用したい場合は、どうにかしてSaveGamesに以下のような構造でデータを持ってきます。

SaveGames
└── 0
    └── A091215A35CD4039A7860DEE387F2838
        ├── LevelMeta.sav
        ├── Level.sav
        └── Players
            ├── abcedf00000000000.sav
            └── fdecba00000000000.sav

PalWorldSettings.iniを用意する

PalWorldSettings.iniはジェネレーターなどが有志で公開されているので、それを参考に用意してください。

補足

ジェネレーターや他のブログを見ればわかりますが、現状反映されない項目が多くあります。
ですが、誰かがホストとしてゲームを実行している場合はより多くの設定が反映されるため、 現時点ではサーバを用意するメリットは基本的に薄いケースがあります。

GameUserSettings.iniを用意する

こちらは他のやり方だとあまり用意するケースはないかと思いますが、 このファイルには起動時に利用するセーブデータの指定を行うことができます。

コンテナの再作成や既にあるサーバのデータを再利用したいケースのために用意しています。

[/Script/Pal.PalGameLocalSettings]
AudioSettings=(Master=0.500000,BGM=1.000000,SE=1.000000,PalVoice=1.000000,HumanVoice=1.000000,Ambient=1.000000,UI=1.000000)
GraphicsLevel=None
DefaultGraphicsLevel=None
bRunedBenchMark=False
bHasAppliedUserSetting=False
DedicatedServerName=A091215A35CD4039A7860DEE387F2838
AntiAliasingType=AAM_TSR
DLSSMode=Performance
GraphicsCommonQuality=0

A091215A35CD4039A7860DEE387F2838 に関しては文字列に書き換えます。
デフォルトでは16進数で命名されていますが、正直なんでも良いです。
既にあるセーブデータを流用する場合は、SaveGames/0配下のディレクトリ名に合わせて指定してください。

SaveGames
└── 0
    └── B091215A35CD4039A7860DEE387F2121

この場合A091215A35CD4039A7860DEE387F2838B091215A35CD4039A7860DEE387F2121に書き換えます。

Dockerfileを用意する

以下を参考にDockerfileを作成します。

FROM ubuntu:22.04

RUN apt update -qq \
    && apt install -y vim ca-certificates curl lib32gcc-s1 xdg-user-dirs \
    && apt clean \
    && rm -rf /var/lib/apt/lists/*

RUN useradd -m steam
USER steam

# FYI: https://developer.valvesoftware.com/wiki/SteamCMD#Linux
ENV STEAM_ROOT_PATH /home/steam/Steam
ENV STEAMCMD /home/steam/Steam/steamcmd.sh
RUN mkdir -p $STEAM_ROOT_PATH
WORKDIR $STEAM_ROOT_PATH
RUN curl -sqL "https://steamcdn-a.akamaihd.net/client/installer/steamcmd_linux.tar.gz" | tar zxvf - \
    && $STEAMCMD +quit
RUN mkdir -p $HOME/.steam/sdk64/ \
    && $STEAMCMD +login anonymous +app_update 1007 +quit \
    && cp $STEAM_ROOT_PATH/steamapps/common/Steamworks\ SDK\ Redist/linux64/steamclient.so $HOME/.steam/sdk64/

# # FYI: https://tech.palworldgame.com/dedicated-server-guide#linux
RUN $STEAMCMD +login anonymous +app_update 2394010 validate +quit
WORKDIR $STEAM_ROOT_PATH/steamapps/common/PalServer
RUN mkdir -p Pal/Saved/SaveGames
CMD ["./PalServer.sh"]

copmose.yamlを用意する

以下を参考にcompose.yamlを作成します。

services:
  palworld:
    build: .
    image: palworld
    restart: always
    ports:
      - 32111:8211/udp
    volumes:
      - ./PalWorldSettings.ini:/home/steam/Steam/steamapps/common/PalServer/Pal/Saved/Config/LinuxServer/PalWorldSettings.ini
      - ./GameUserSettings.ini:/home/steam/Steam/steamapps/common/PalServer/Pal/Saved/Config/LinuxServer/GameUserSettings.ini
      - ./SaveGames:/home/steam/Steam/steamapps/common/PalServer/Pal/Saved/SaveGames

ちょっと長いですが、PalWorldSettings.ini、GameUserSettings.ini、SaveGamesをコンテナにマウントしています。

コンテナを起動する

ここまでくれば後は起動は簡単です。

docker compose up -d

と実行し、しばらく待てば接続できるようになります。
停止させたい場合は、

docker compose stop

と入力するだけです。

おまけ

WSL 2で動かしてるけど接続できない

Docker Desktop for Windowsではなく、WSL 2に直接インストールしたDocker Engineの場合、 WSL 2がUDPのポートフォワードに対応していないためアクセスすることができません。

大人しくDocker Desktop for Windowsをインストールしておきましょう。

サーバのアップデートが必要

コンテナを停止した後にコンテナイメージを再作成すればOKです。

# 停止
docker compose stop

# イメージの再作成
docker compose build --no-cache

# 起動
docker compose up -d

Helm ChartでデプロイしているWordPressにads.txtを配置する

元々広告を設置しているWordPressのサイトがあったんですが、本格的にk8sクラスタへ移行するにあたってads.txtを配置する必要がでてきました。
今回はその備忘録になります。

TL;DR

  • BitnamiのWordPressのChartを使っている場合の話に限る
  • このイメージは /opt/bitnami/wordpressWordPressディレクト
  • ここに配置するためにextraVolumesextraVolumeMountsをvaluesとして定義すればOK

ads.txtを配置する

1. ads.txtを準備する

Google Adsenseを使っているので、Google Adsenseからads.txtの中身の情報を取得しておきます。

2. ConfigMap or Secretでads.txtを用意する

以下を参考にしながら、ads.txtの中身を用意します。
正直公開情報になるのでConfigMapで十分だとは思います。

apiVersion: v1
kind: ConfigMap
metadata:
  name: ads-txt-file
data:
  ads.txt: |
    # ここにads.txtの内容を記述します
    # 例:
    google.com, pub-0000000000000000, DIRECT, 123456789

後はこれがデプロイされるようにkustomization.yamlなどを変更しておきます。

3. values.yamlにextraVolumesとextraVolumeMountsを定義する

BitnamiのChartでは、extraVolumesextraVolumeMountsというキーが定義されており、これを定義することで先ほど書いたConfigMapをマウントしてくれるようになります。

extraVolumes:
  - name: ads-txt
    configMap:
      name: ads-txt-file

extraVolumeMounts:
  - name: ads-txt
    mountPath: /opt/bitnami/wordpress/ads.txt
    subPath: ads.txt

これで大まかな作業としては完了で、こちらもデプロイすれば反映自体は完了で、実際にアクセスしたWordPressのサイトでads.txtが確認できるかと思います。

個人開発したアプリをストア申請するまでの手順とハマりどころ

最近個人用にアプリを作っていたのですが、ついでにアプリストアに申請した時のハマりどころがあったのでメモとして残しておきます。

TL;DR

  • 基本的に https://zenn.dev/moutend/articles/feebf0120dce6e6426fa に従って進めるでOK
  • 5.5 inchのシミュレーターがApple Silicionだと動かせない
    • 別のシミュレーターで撮ったスクショをサイズ変形して申請して回避
  • 個人名でアプリが公開される

大まかな手順

色んな方が書いてくれていますが、以下の記事が比較的網羅的かつハマりどころは少なかった印象があります。

zenn.dev

ハマったところ

5.5 inchのスクショをどう作るか

アプリのスクショはシミュレーターを使って撮るのが一般的かと思いますが、 iPhone向けのスクショは、6.5 inchと5.5 inchが必須になっています。

ですが、Apple SiliconなMacでは5.5 inchのシミュレーターを動かすことができませんでした。
なので今回は、6.5 inchのスクショをサイズ変形して登録しました。

個人アカウントで開発者登録するとアカウントに登録している名前が公表される

今回は諦めましたが、個人アカウントで開発者登録するとアプリで表示される開発チーム名?が個人名になります。
気になる人は屋号付きで開業届を出して、DUNSナンバーの申請をして法人登録した方が良さそうです。

既に準備を始めているので、もしアプリが公開できたらすぐに移管していそうです。

サポートページ・プライバシーポリシーのページ作成が面倒

これは完全に俺個人の感想ですが、この手のページのコンテンツをそれなりに考えるのが面倒くさいです。
個人情報の収集や課金コンテンツなどはないので、法的にそこまでやらなくても問題ないかもしれませんが、なんとなくプレッシャーとして感じます。

LLMを使ってテンプレートを作ってもらいましたが、他の方々がテンプレートや事例を公開してくれているので、 それを参考にすると良いかもしれません。

GitHub PagesとGoogle Formでアプリ申請に必要なページを最小限の労力で作成する

最近個人用にアプリを作っていたのですが、ついでにアプリストアリリースする気持ちになったので、そこで必要になるページをGitHub Pages+Google Formで作成した話です。

執筆時点ではまだ申請中なので、申請が却下されたり許可が通ったらしたら追記します。 申請通ったので参考にしても問題はないはず。

TL; DR

  • アプリ申請では、サポートページとプライバシーポリシーについてのページが必要となる
  • サポートページとプライバシーポリシーはdocsディレクトリにマークダウンファイルとして保存する
  • そこからの問い合わせ先としてGoogle Formを用意する

bookil.zuki.dev

ストア申請に必要なページ

アプリ申請に必要なページは

  • サポートのページ
  • プライバシーポリシーのページ

の2つが必要になります。

以下の記事を参考にしながら進めさせてもらい、実際に必要なことも確認しました。

zenn.dev

今回は最小限の労力でいきたかったので、GitHub Pagesですぐに使えるJekyllと、問い合わせフォーム用にGoogle Formを用意して進めることにしました。

GitHub PagesとJekyll

GitHub PagesはGitHubで利用できる無料のWebページのホスティングサービスです。
このGitHub Pagesでは、GitHub Actionsなどを自分で書かなくてもJekyllを使ってサイトをすぐに公開することができるため、他の静的サイトジェネレータより手軽に始めることができます。

とりあえずはデザインにこだわる必要も現時点ではないため、雑に作るために採用しています。

docs.github.com

Markdownファイルを用意する

Jekyllでページを生成してもらうために、アプリを管理しているプライベートリポジトリにdocsというディレクトリを作成し、そこに以下のような構成でファイルを作成しました。

docs
├── _config.yml # Jekyllの設定ファイル 
├── how_to_use.md # 使い方ページ
├── index.md # ルートページ
├── privacy_policy.md # プライバシーポリシーページ
└── support.md # サポートページ

各ファイルの内容に関しては、実際のサイトを見てください。

bookil.zuki.dev

_config.ymlはJekyll用の設定ファイルになり、今回は以下のような最小限の内容に留めています。

title: Bookil
theme: minima

header_pages:
- how_to_use.md
- support.md
- privacy_policy.md

これをコミット&プッシュしておいて、ファイルの準備は完了です。

ちなみにファイルのポリシーに関しては、LLMを利用してテンプレートを生成してもらって、書き換えたりセクションを追加しています。

GitHub Pagesの設定をする

GitHub Pagesの設定を行うんですが、かなり楽でBuild and deploymentBranchの設定を行うだけになります。

  • Branch ... main
  • Direcotry ... /docs

これ以外の設定は不要で、すぐにビルドが実行されてGitHub Pagesが公開されます。
カスタムドメインを設定する場合は、公式の案内に従って設定してください。

これでほぼ完了ではあるんですが、問い合わせ先がないとサポートページとプライバシーポリシーのページは完成とはいえないのでフォームを作ります。

Google Formで問い合わせフォームを作成する

実際のフォームは以下のようになりますが、これをGoogle Formでサクッと作成します。

forms.gle

特筆する点としては、

  • メールアドレスを取得しない(Googleアカウント無しでも利用可)
  • Spreadsheetsとの連携を有効にして見返したり、集計しやすくする

という点ぐらいで、それ以外は何か細かい要件はないかと思います。

これで問い合わせフォームの作成が終わったら、サポートページやプライバシポリシーのページにリンクを貼り、コミット&プッシュをするとGitHub Pagesにすぐに反映して確認できるかと思います。

これで申請に必要なページを最小限の労力で作れたかと思います。

iOSアプリでファイル取得処理にRepositoryパターンを活用してPreviewする

年末にやっていた個人アプリ開発でアプリでのTipsの1つです。
他に良いやり方もあるかもしれませんが、Swift触り始めて4日目ぐらいの感想になります。

TL;DR

  • Previewでドキュメントディレクトリにアクセスするとエラーが出る、もしくはクラッシュして2度と起動しなくなる
  • ファイルの取得処理はRepositoryに任せることで差し替えしやすくする
  • Previewではテスト用に実装されたRepositoryに差し替えることでエラーも出ずに確認できる

なぜ取得処理をRepositoryパターンとして実装しようとしたのか?

Repositoryパターンでも問題はないんですが、取得処理をViewから剥がすことが重要でした。
SwiftUIではとても便利なPreviewという機能があるんですが、アプリのドキュメントディレクトリにあるファイルを取得して表示するようなViewなどの場合、 このアクセスがきっかけでエラーで表示できないことや、Previewがクラッシュして同じ端末では2度と起動できないという現象に遭遇しました。

これらを防ぐために取得処理自体を別の層に移し、Previewでは実際にファイルアクセスをしないようなダミーの実装クラスを用意したかったのが経緯になります。

ファイルの取得処理をRepositoryパターンで実装する

今回はサンプルとしてドキュメントディレクトリに保存されているPDFファイルを一覧で表示するViewを題材にします。

表示後のイメージは以下のような形です。

ファイルの取得処理を実装する

はじめから実装の話になりますが、まずはファイルの取得処理のためのProtocolを定義します。

protocol FileRepository {
    func fetchDocuments() -> [PDFDocument]
}

URLではなくPDFDocumentを返しているのは、その方がView側での使い勝手が良いためと、
ファイルアクセスが入るとPreviewでエラーもしくはクラッシュする可能性が高まるためです。

そして、次にアプリとして動作させるための本実装をします。
特筆することはありませんが、PDF拡張子であるファイルのURLを元にPDFDocumentを生成しています。

class FileRepositoryImpl: FileRepository {
    private let fileManager = FileManager.default

    func fetchDocuments() -> [PDFDocument] {
        guard let path = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first else { return [] }
        
        do {
            let urls = try fileManager.contentsOfDirectory(at: path, includingPropertiesForKeys: nil)
            return urls
                .filter { $0.pathExtension == "pdf" }
                .map { PDFDocument(url: $0)! }
        } catch {
            print("Error while enumerating files \(path.path): \(error.localizedDescription)")
            return []
        }
    }
}

Viewを実装する

テスト用の実装の前に先にViewを作ります。

struct ContentView: View {
    var fileRepository: FileRepository = FileRepositoryImpl()
    private var documents: [PDFDocument] {
        fileRepository.fetchDocuments()
    }
    private var columns: [GridItem] {
        Array(repeating: .init(.flexible()), count: 2)
    }

    var body: some View {
        ScrollView {
            LazyVGrid(columns: columns, spacing: 20) {
                ForEach(documents, id: \.documentURL?.lastPathComponent) { document in
                    VStack {
                        Image(uiImage: generateThumbnail(document: document)!)
                            .resizable()
                            .aspectRatio(contentMode: .fit)
                            .cornerRadius(10)
                        
                        Text(document.documentURL!.lastPathComponent)
                            .font(.headline)
                            .frame(minHeight: 2 * UIFont.systemFontSize * 1.5, alignment: .topLeading)
                            .lineLimit(2)
                    }
                    .shadow(radius: 5)
                    .padding()
                }
            }
        }
    }
    
    private func generateThumbnail(document: PDFDocument) -> UIImage? {
        let scale = 1.0
        guard let page = document.page(at: 0) else { return nil }
        let pageSize = page.bounds(for: .mediaBox)
        let thumbnailSize = CGSize(width: pageSize.width * scale, height: pageSize.height * scale)
        return page.thumbnail(of: thumbnailSize, for: .mediaBox)
    }
}

サンプルとしては雑な実装となっていますが、以下のようにfileRepositoryをプロパティとして定義し、 ContentViewの初期化時に上書きできるような定義をしておきます。

    // デフォルトではFileRepositoryImpleを使うが
    // ContentView(fileRepository: Dummy())のような宣言ができるようにする
    var fileRepository: FileRepository = FileRepositoryImpl()

PreviewのためのRepositoryを実装する

さて下準備はできたのでPreviewのための実装をします。
前提条件としては以下をおさらいしておきます。

  • Previewでドキュメントディレクトリにアクセスするとエラーもしくはクラッシュするのでアクセスさせない
  • FileRepositoryではPDFDocumentの配列として返す
  • documentURLのlastPathComponentにアクセスしている
  • サムネイルを生成して表示しているので何かが表示されているデータになっているとPreviewで確認しやすい

これができるように実装を進めていきます。

class PreviewFileRepositoryImpl: FileRepository {
    class DummyPDFDocument: PDFDocument {
        private let url: URL
        init(url: URL, data: Data) {
            self.url = url
            super.init(data: data)!
        }
        
        override var documentURL: URL? { return url }
    }
    
    private let words: [String] = [
        "Spring", "Summer", "Autumn", "Winter",
        "January", "February", "March", "April", "May", "June",
        "July", "August", "September", "October", "November", "December",
        "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday",
        "Day", "Night", "Morning", "Evening", "Noon", "Midnight",
        "Sun", "Moon", "Star", "Sky", "Cloud"
    ]
    
    func fetchDocuments() -> [PDFDocument] {
        return words.map { makePDF(name: $0)! }
    }
    
    func makePDF(name: String) -> DummyPDFDocument? {
        let data = NSMutableData()
        let pdfRect = CGRect(x: 0, y: 0, width: 612, height: 792)
        UIGraphicsBeginPDFContextToData(data, pdfRect, nil)

        UIGraphicsBeginPDFPage() // 描画開始
        if UIGraphicsGetCurrentContext() != nil {
            let style = NSMutableParagraphStyle()
            style.alignment = .center
            let attributes: [NSAttributedString.Key: Any] = [
                .font: UIFont.systemFont(ofSize: 64),
                .paragraphStyle: style
            ]
            
            let size = name.size(withAttributes: attributes)
            let rect = CGRect(
                x: (pdfRect.width - size.width) / 2,
                y: (pdfRect.height - size.height) / 2,
                width: size.width,
                height: size.height
            )

            name.draw(
                in: rect,
                withAttributes: attributes
            )
        }
        UIGraphicsEndPDFContext() // 描画終了
        
        let url = URL(fileURLWithPath: "/path/to/\(name).pdf")
        return DummyPDFDocument(url: url, data: data as Data)
    }
}

さてまずはDummyPDFDocumentについてです。
PDFDocument(data: data) だけでもPDFデータは生成できるのですが、この定義の場合documentURLnilになってしまいます。
ここに依存している処理がViewでは存在しているため、DummyPDFDocumentクラスを用意して、初期化時に渡されたURLを返すようにoverrideしておきます。
こうすることで、URLやパスの取得処理が走る場合には対応できるケースが多いです。

    class DummyPDFDocument: PDFDocument {
        private let url: URL
        init(url: URL, data: Data) {
            self.url = url
            super.init(data: data)!
        }
        
        override var documentURL: URL? { return url }
    }

次にファイルの取得処理についてです。
ここでは、事前に定義されているwordsごとにDummyPDFDocumentを生成して返すような実装になっています。

    func fetchDocuments() -> [PDFDocument] {
        return words.map { makePDF(name: $0)! }
    }
    
    func makePDF(name: String) -> DummyPDFDocument? {
        let data = NSMutableData()
        let pdfRect = CGRect(x: 0, y: 0, width: 612, height: 792)
        UIGraphicsBeginPDFContextToData(data, pdfRect, nil)

        UIGraphicsBeginPDFPage() // 描画開始
        if UIGraphicsGetCurrentContext() != nil {
            let style = NSMutableParagraphStyle()
            style.alignment = .center
            let attributes: [NSAttributedString.Key: Any] = [
                .font: UIFont.systemFont(ofSize: 64),
                .paragraphStyle: style
            ]
            
            let size = name.size(withAttributes: attributes)
            let rect = CGRect(
                x: (pdfRect.width - size.width) / 2,
                y: (pdfRect.height - size.height) / 2,
                width: size.width,
                height: size.height
            )

            name.draw(
                in: rect,
                withAttributes: attributes
            )
        }
        UIGraphicsEndPDFContext() // 描画終了
        
        let url = URL(fileURLWithPath: "/path/to/\(name).pdf")
        return DummyPDFDocument(url: url, data: data as Data)
    }

makePDFは、DummyPDFDocumentを作成しますが、サムネイルとして表示するためのページを作成したいので、UIGraphicsを使って気合いで描画しています。

Previewする

最後にPreviewですが、先程実装したPreview用のFileRepositoryを初期化時に渡してあげることでうまく動作することが確認できるかと思います。

#Preview {
    ContentView(fileRepository: PreviewFileRepositoryImpl())
}

こうすることで最初に提示したイメージ通りにPreviewが表示されることが確認できました。

まだ触って5日目程度で書いた記事なので、別の良い実装があれば気軽にコメントいただければと。

2023年買ってよかったもの

2023年に買ってよかったものを書いておきます。
2023年も色々細かい買い物してたんですが、かなり印象に残ってるのは1つしかないので、それを書いておきます。

Cleer Arc 2

イヤホンは外耳炎になりがちなのでヘッドホンを使っていたんですが、これに切り替えてからは外出先でも家でも気軽に使えるようになって、めちゃくちゃはかどっています。
耳をふさがないので外音も通常通り聞こえますし、運動中でも重宝しています。

www.cleeraudio.jp

SwiftDataで動的なキーワード検索を実装する

最近は自分向けのPDFリーダーアプリを開発しています。
このアプリではSwiftDataを使って読書履歴をモデル化して管理しているのですが、これを踏まえてちょっと自分のメモがてらにTODOアプリを例にしたメモを残しておきます。

Swiftを触り始めて3日目ぐらいなので、書き間違えや嘘などがあるかもしれません。
その場合はコメントなどで教えてもらえると助かります。

TL;DR

  • SwiftDataの@Queryで動的な条件は付与できない
  • Computed Propertiesを使って@Queryで定義したフィルターを利用する

基本的な使い方

まず今回のサンプルとなるタスクを一覧表示するViewと、Modelを定義します。
以下のような仕様で作成しておきます。

  • Taskモデル
    • nameをプロパティとして持つ
  • TodoView
    • Taskを一覧表示する
    • 画面上部にはキーワード検索のためのTextFieldを設定
import SwiftUI
import SwiftData

@Model
final class Task {
    var name: String
    
    init(name: String) {
        self.name = name
    }
}

struct TodoView: View {
    @Environment(\.modelContext) private var context
    @State private var keyword: String = ""
    @Query(sort: \Task.name) private var tasks: [Task]

    var body: some View {
        VStack {
            HStack {
                TextField("検索...", text: $keyword)
                    .padding()
                    .background(primaryColor)
                    .cornerRadius(5)
            }
            .buttonStyle(PlainButtonStyle())
            .padding(.horizontal)
            
            List(tasks) { task in
                Text(task.name)
            }
        }
    }
}

// Preview
struct TodoView_Previews: PreviewProvider {
    static var words: [String] = [
        "Summery", "Country", "Perk", "Relief", "Virus",
        "Hunter", "Photocopy", "Liberal", "Sugar", "Practice",
    ]

    static var container: some ModelContainer {
        let configuration = ModelConfiguration(isStoredInMemoryOnly: true)
        let container = try! ModelContainer(for: Task.self, configurations: configuration)
        
        // プレビュー用データを作成
        words.forEach { word in
            let task = Task(name: word)
            container.mainContext.insert(task)
        }
        
        return container
    }
    
    static var previews: some View {
        TodoView()
            .modelContainer(container)
    }
}

この段階では、検索用のTextFieldがありますが絞り込むロジックを書いていません。
そのため絞り込むためのロジックを書いていきます。

キーワードでタスクを絞り込む 

キーワード検索を絞り込むためにはどういったやり方があるでしょうか?
最初は@Queryのfilterに絞り込むためのロジックを書けばいけると思っていたのですが、ここではkeyword自体を参照することができませんでした。

// エラー
@Query(filter: #Predicate<Task> { $0.name.contains(keyword) }, sort: \Task.name) private var tasks: [Task]

そして小一時間ほど公式ドキュメントを眺めたりしながら試行錯誤した結果、Computed Propertiesなプロパティを定義することで実現することができました。

// 成功
@Query(sort: \Task.name) private var tasks: [Task]
private var filteredTasks: [Task] {
    guard !keyword.isEmpty else { return tasks }
    return tasks.filter { $0.name.contains(keyword) }
}
  • キーワードが空文字列の場合はtasksをそのまま返す
  • それ以外の場合、タスク名にキーワードが含まれている文字列を返す

というプロパティを定義することで動的なキーワード検索を比較的楽に実現することができました。

もし同じように悩んでいる方がいれば参考にしてみてください。