pointfreeco

Stars and mentions

Last 30 days, Oct 5 – Nov 3

GitHub stars57
mentions10
02468Oct 5Oct 19Nov 3
UTC days · repositories pointfreeco owns4 repositories

Package downloads

No package download history available yet.

Top repositories

pointfreeco’s repositories ranked by new forks, as on trending.

Loading

Contributes to

Repositories outside pointfreeco’s account where pointfreeco opened or reviewed pull requests in the past 365 days.

Who noticed pointfreeco

Everyone who starred or forked pointfreeco’s repositories since the archive began in 2011.

Notable accounts

Ordered by the stars their own repositories have received.

Biggest fans

Accounts that starred the most of these repositories.

Breakout weeks

Each repository’s best week of new stars, with the release published around it.

Mentions

10posts
Nov 40 posts
  1. Building Second Skin in public: an app for your tattoos 🌹 Hi! 👋🏻 If you've read either of my last two posts, you've probably seen me mention Second Skin and then quickly move on to something else... well, this time it finally gets a post all to itself. I've decided I'm going to build it in public. So every once in a while i'll do a little write up to say how far i've gotten, which bits I got wrong along the way and what are some things i've learned. This first one is really just an introduction which also aims to show you what i've done so far 🚀. 🌹 So, what is Second Skin? Second Skin is an iOS app for keeping track of your tattoos. The tagline is "Your tattoos, from idea to healed." and the app is split into four tabs that pretty much follow that journey: - Collection. The pieces you already have, either as a photo grid or dotted around a body map. - Ideas. Moodboards for the tattoos you're thinking about, with references saved from Instagram, Pinterest, Safari or your own photos. Also the aim in the future here is to include foundation models to let the app suggest ideas to you based on the things you've got previously, got saved etc. - Bookings. Your upcoming sessions, and then a quick "How did it go?" once they've happened, so things like time in the chair, cost and a pain score out of 10 are scored. - Stats. Hours in the chair, how much of your skin is covered, your go-to artist, your most painful session and a few more. This should hopefully just be a fun way to keep track and should help when you get the dreaded How many have you got? question. Running through all of it is aftercare. When you finish a session you can start an aftercare routine (usually the one your artist gave you) and the app will remind you of each step every day until it's healed. This is mainly just to help remind you as the app in no way will be giving medical information. !The Second Skin Welcome screen: a fan of three tattoo photos, the title "Second Skin", the tagline "Your tattoos, from idea to healed.", three features (Keep every piece, Plan what's next, Look after it) and a Get started button The Welcome screen, with three of my own pieces. 🌹 🖋️ Why build it? I'm a bit of a tattoo enthusiast myself, I started really just appreciating tattooing for what it is. I think that all started for me as I've always been into Hardcore & Metal music and tattooing is quite prominent in those scenes. This led to me being interested in traditional tattooing and then has led to me having quite a lot of tattoos myself. Every piece you see in-app as promotion will likely just be one of my own tattos. Over the years getting tattoo'd i found that it was often hard to keep track of appointments, usually had to just add the booking into my own calendar and hope i'd remember. I then had to remember days before the session to do things like buy aftercare, buy food etc and prep for the session. I then had a bit of a hard time collating ideas I wanted to get in the longer term and also keeping track of pictures of the pieces I had. All of this led to me wishing there was something simple and beautiful in place to do all of this. 🏷️ Why "Second Skin"? "Second skin" is actually the name for the protective film that goes over a fresh tattoo. I liked that the name also is resonant with tattoos being your second skin and since the app is about collection of your tattoos it makes sense that the app really is your second skin. 🔒 Private by design Tattoos can be pretty personal, so this was one of the first things I decided on: no sign-up, no ads, no tracking and no servers. Everything lives on your devices and syncs through your own iCloud account using CloudKit's private database. There are no Second Skin servers for your data to end up on, which also means I never see any of it. If you're signed out of iCloud it all just stays on your device. The aim is for the App Store privacy label to say "Data Not Collected", and for that to actually be true. I also think this is incredibly important as for a lot of us, our tattoos really are a thing we've done for ourselves, so I think storing this type of information should be the same. !The three privacy promises from the About you screen: Private to you (your details and collection live on your devices and in your own iCloud), We never see it (there are no Second Skin servers, nothing is sent to us) and No account, no tracking (no sign-up, no ads, no analytics on your photos The privacy promises you see when you first set up the app. 🎨 The design Before writing any code I sat down and tried to be inspired by some of my favourite apps. I used the help of AI to just collate a mood board of all different designs the apps I really love have used. This then let me mock up a few different ideas for how Second Skin will look. In the end the clean native look won for me. I think if the app is going to hold lot's of your tattoos, lot's of information and potentially lot's of colourful things, I would prefer that the app itself is quite clean and stripped back so you can see exactly what you are looking for. Thefore.... 1. Photos lead. Your tattoos are the hero, so the app is built around big rounded photo cards and grids. 2. Liquid Glass for controls only. The tab bar, toolbar buttons and floating controls get glass, and anything sitting on top of a photo gets a plain frosted material instead, as the HIG suggests. 3. Colour in small doses. The surfaces are neutral, with one rose accent (#D9485F) for primary actions and whatever's coming up next. 4. Standard iOS type sizes only. No custom font sizes, so Dynamic Type just works. 🧱 The tech For anyone curious, here's what it's being built with: - SwiftUI, targeting iOS 27 with Liquid Glass, running on iPhone, iPhone Duo and iPad from v1. Supporting the bigger screens from the start keeps my layouts honest. - SQLiteData from Point-Free for the database and CloudKit sync, instead of SwiftData. - Swift Dependencies for swapping in fakes for things like the clock, notifications and photos. - SnapshotTesting for image tests of every screen. SQLiteData was probably the biggest decision so far. SwiftData would have been the obvious pick, but SQLiteData gives me live queries that work inside my @Observable screen models (not just in views), plain SQL for all the stats sums and counts, and CloudKit sharing. That last one means one day you could share an idea with your artist without me needing to run a backend. The trade-off is writing my migrations by hand in SQL, which to be fair has been a nice refresher. > 💡 Tip: CloudKit only lets you add to your schema once it's in production, you can't rename or delete fields. If you're syncing with CloudKit, it's worth spending a bit more time on your data model up front before anything ships. 🤝 How I'm building it In my last post I mentioned the one rule I set for this project: I write the code. Claude explains things, reviews my work and writes the parts I hand over to it, which is usually the repetitive stuff I don't learn much from. For example, I wrote the first few database tables myself to learn the pattern and then handed the rest over once I had it down. The rest of the process is pretty much how I'd want a team to work: - A branch and pull request for every change. With a template, no more than 15 files, then squash and merge. - Requirements before code. Before I start a ticket, Claude writes up a list of requirements, kind of like a product manager would. This honestly just helps me stay on track when i'm building things. - Decision records in the repo. Things like "why iPad from v1" or "why the design system grows from screens" get written down in the same PR as the code. - A review before every PR. Claude runs a code review on my changes before I raise them. - A handoff at the end of every session. A skill I wrote saves where I got to in Obsidian, so the next session can pick straight back up. It's probably a lot of process for a side project, but it means I can put it down for a week and come back without wondering what on earth I was doing. It's also honestly my first time using AI in a side project. I think for me, using AI is helping because this is a side project. I'm working as an iOS Engineer for 8.5 hours during the day before I even sit down to work on this project. If i didn't at least try and lean into AI to help streamline the process I probably wouldn't reach the MVP stage. 📍 Where it's at I started on 15 October and as of writing there are 18 merged pull requests. So far that's: - The design, the architecture plan and a release plan for v1. - The whole database schema, plus sample data built from photos of my own tattoos. - The app shell, with all four tabs. - Onboarding: a Welcome screen and an "About you" screen for your name, photo and currency. No account needed. - A debug menu (shake the device in a debug build) to reset onboarding, wipe the data or load the sample data. !Three screens in dark mode: Welcome, About you filled in with the name Andrew, and the empty Collection tab asking you to add your first piece Where things are right now, in dark mode: Welcome, About you and an empty Collection. v1 is going to include all four tabs, aftercare, reminders and the share extension for saving ideas. Things like on-device AI suggestions for ideas, search and widgets will come later. There's also a little Easter egg planned for v1, but I'm going to keep that one quiet for now. 🤫 🔜 What's next? Next up is the Collection tab: the photo grid, adding a piece and the body map. That means building a proper photo pipeline, which should make for a good post of its own. Along the way I'm also planning to write about: - Choosing SQLiteData over SwiftData, and what it's like day to day - Designing an app with AI as a sounding board (and the app icon saga) - Building for iPhone, iPhone Duo and iPad from day one - What it's like making a rule that you write the code when AI could just do it If you've got tattoos, I'd love to know how you keep track of yours right now and what you'd want from an app like this. And if there's a part of the build you'd like me to go deeper on, let me know in the comments. 👇🏻

  2. @rachpradhan I looked at the flightly ipa and saw pointfreeco/swift-composable-architecture which they use very similarly they have features split out and drive them from deterministic cores that the Swift code renders from. if Zig can go direct to iOS and now need swift to be the intermediary then why not.

    2 likes · 71 views
  3. @ashravikumar have your agent look at Flightly. I found out about pointfreeco/swift-composable-architecture that way. Flightly is built super interestingly in that it's a very pure/reducer style derivation of state into the components. so the UI is super light and easy

    2 likes · 52 views
  4. @polymorph3us kind of but a different concept - pointfreeco/swift-composable-architecture

    1 like · 9 views
  5. Package.resolved の originHash は何のためにあるのか Renovate が依存を上げた PR をマージしたあと、tuist install を流すと Package.resolved の 1 行だけが書き換わるようになりました。 Package.resolved { - "originHash" : "b51d86e0d60dc9c07d7da15f1bb995c680b75f60f09f0ee586aea8f14852849d", - "originHash" : "9ea100c33d0673a7cbf638a61115d34ce142720e95d1761c390aa31ae6ec796b", "pins" : [ この originHash が何のための値なのかを、SwiftPM のソースを読み、実際に計測して確かめました。分かったことは次の 4 つです。 originHash は「この Package.resolved がどの Package.swift に対して作られたか」の目印で、改ざん検知の値ではない 一致していると、SwiftPM は依存のバージョンを選び直す計算を省ける。差が出るのは checkout が足りないとき(clone 直後や .build をキャッシュしない CI)で、今回の環境(pin 60 個)では約 20 秒だった SwiftPM 自身は、pin が変わらない限り originHash を書き直さない。だから古いまま残りやすい 実際に解決しないまま originHash だけを今の値にするのは危ない。Tuist は一致した hash を信じて推移的な依存の要求を確かめず、要求を満たさない pin をそのまま使う(再現して確かめた) 後半では、依存を自動で上げる Renovate と Dependabot で、この値の扱いがどう違うかも比べます。調べる途中で見立てを 3 回間違えたので、その話も最後に書きます。 ! SwiftPM のコードは 2026 年 10 月時点の main(7137197)、計測は macOS・Xcode 27.2(Swift 6.4.2)で行いました。 originHash はルートの manifest の SHA-256 originHash は、Package.resolved の書式のバージョン 3 で入った値です。SwiftPM は、ルートの Package.swift の tools-version が 5.10 以上のときにこの書式で書き出します。 中身は、SwiftPM の computeResolvedFileOriginHash を読むと分かります。 Sources/Workspace/Workspace+Dependencies.swift private func computeResolvedFileOriginHash(root: PackageGraphRootInput) throws -> String { var content = try root.packages.reduce(into: "") { partial, element in let path = try ManifestLoader.findManifest( packagePath: element, fileSystem: self.fileSystem, currentToolsVersion: self.currentToolsVersion ) try partial.append(self.fileSystem.readFileContents(path)) } content += root.dependencies.reduce(into: "") { partial, element in partial += element.locationString } return content.sha256Checksum } ルートパッケージの manifest のファイル内容をそのままつなげ、ルートに追加の依存があればその URL を足して、SHA-256 をとっています。ふつうのパッケージでは追加の依存は無いので、Package.swift のファイル内容の SHA-256 そのものになります。手元でも確かめられます。 $ grep originHash Package.resolved "originHash" : "9ea100c33d0673a7cbf638a61115d34ce142720e95d1761c390aa31ae6ec796b", $ shasum -a 256 Package.swift 9ea100c33d0673a7cbf638a61115d34ce142720e95d1761c390aa31ae6ec796b Package.swift ここから 3 つのことが言えます。 pin(各依存のバージョンとコミット)は計算に入っていない。Package.resolved の中身が書き換えられても、originHash では気づけない コメントを 1 文字足しただけでも値が変わる Package@swift-6.0.swift のような Swift のバージョンごとの manifest があると、使っている toolchain が選んだファイルの hash になる 一致すると、SwiftPM はバージョンの選び直しを省く originHash を入れた SwiftPM の PR(swiftlang/swift-package-manager#6698、2023 年 8 月)には、狙いがこう書かれています。 when a package.resolved file exists the dependencies resolution should stick to it, unless requested otherwise 実装では、swift package resolve やビルドが使う標準の解決(コード上は .bestEffort)の中で、この hash を比べて分岐しています(抜粋して簡略化しています)。 let currentHash = try self.computeResolvedFileOriginHash(root: root) guard storedHash == currentHash else { // 'Package.resolved' origin hash does do not match manifest dependencies. resolving and updating accordingly return try await resolveAndUpdateResolvedFile(forceResolution: false) } // 'Package.resolved' origin hash matches manifest dependencies, attempting resolution based on this file let (manifests, precomputationResult) = try await self.tryResolveBasedOnResolvedVersionsFile(...) コメントにしたメッセージは、--very-verbose を付けると実際にログに出ます(「does do not」は原文のままです)。 originHash 通る経路 やること 一致 tryResolveBasedOnResolvedVersionsFile pin のコミットで checkout し、pin が今の要求を満たすかを確かめる 不一致 resolveAndUpdateResolvedFile 足りない checkout があれば resolver を走らせる(今の pin を優先して選ぶ)。揃っていれば pin が要求を満たすかを確かめるだけ どちらの経路でも、pin が今の要求を満たすかは確かめられ(precomputeResolution か resolver)、満たさなければ解決し直します。今の pin が要求を満たしていれば、どちらの経路でもその pin がそのまま使われます。違うのは、そこにたどり着くまでの手間です。後で書く計測でも、hash が一致していてもずれていても、最終的な pin は同じでした。 なお、Package.resolved を正として扱う --force-resolved-versions と、swift package update は originHash を読みません。update は値を計算して書き込むだけです。 差が出るのは checkout が足りないとき 手間の違いがどれくらいかを、仕事で開発しているアプリの依存で測りました。Package.swift に直接書いている依存が 25 個、推移的な依存も含めた pin が 60 個あり、すべて git のリポジトリです。 まず、.build に checkout がある状態です。5 回ずつ流しました。 originHash swift package resolve 一致 0.46〜0.51 秒 古い 0.43〜0.51 秒 差はありませんでした。前の節の分岐のとおり、checkout が揃っていれば、hash がずれていても resolver は走らないからです。checkout が揃った手元の開発では、originHash が古くても遅くなりません。 次に、.build を空にした状態です。git のリポジトリは SwiftPM のキャッシュ(--cache-path)に入っているので、clone の時間はほぼ含みません。順番を入れ替えて 2 回ずつ流しました。 originHash 1 巡目 2 巡目 ログの「Computing version for」 一致 66 秒 29 秒 0 件 古い 87 秒 53 秒 60 件 一致していると、SwiftPM は pin のコミットでそのまま作業コピーを作ります。古いと resolver が走り、60 パッケージすべてについて、git のタグの一覧と、候補になるバージョンの Package.swift を読みます。ログの「Computing version for」がその作業で、これが 2 回とも約 20 秒の差になりました。clone 直後の手元や、.build をキャッシュしない CI は、毎回この分だけ遅くなります。 Tuist ではもっと差が開きます。Tuist の依存解決(SwifterPM)は、hash が一致すると swift package resolve を起動せず、自前のキャッシュから戻すだけで済ませるからです。.build が空の状態で 1 回ずつ測ると、一致で 7.4 秒、古いと 134 秒でした。 ! ネットワークから clone し直す場合は測っていません。その場合は clone の時間が大きく、差の割合は小さくなると考えています。 計測の手順と条件 コマンドは swift package --package-path <dir> --scratch-path <dir>/.build --cache-path <cache> --disable-scm-to-registry-transformation resolve で、時間は /usr/bin/time -p で測りました。 hash を一致させるときは、Package.resolved の originHash を shasum -a 256 Package.swift の値に書き換えました。古い hash は、Package.swift を変える前の値です。pin はどちらも同じです。 checkout がある状態は、同じ .build で続けて 5 回ずつ流しました。 checkout が無い状態は、毎回新しいディレクトリを作り、--scratch-path を空にしました。--cache-path は共通なので、git のリポジトリはキャッシュから取ります。1 巡目は一致→古い、2 巡目は古い→一致の順です。 Tuist の数字は、Tuist の依存解決部分を swifterpm コマンドとしてビルドし、空の --scratch-path で 1 回ずつ測りました。 回数が少なく、同じ条件でも 29 秒と 66 秒のように大きくぶれています。絶対値はマシンや負荷で変わるので、2 巡とも約 20 秒の差が出た、という点だけを読み取ってください。 SwiftPM は pin が変わらないと originHash を書き直さない これだけ効く値なのに、古いまま残りやすい理由は、SwiftPM の保存処理にあります。saveResolvedFile は、保存済みの pin と今の pin を比べて、同じなら何もせずに抜けます(抜粋です。比べている部分は日本語のコメントに置き換えました)。 Sources/Workspace/Workspace+ResolvedPackages.swift // try to load the Package.resolved store from disk so we can compare for any changes // this is needed as we want to avoid re-writing the resolved files unless absolutely necessary var needsUpdate = false if let persistedResolvedPackagesStore = try? self.resolvedPackagesStore.load() { // pin の数と、各 pin の状態を比べる(originHash は比べない) } // exist early is there is nothing to do if !needsUpdate { return } 比べているのは pin だけなので、Package.swift を変えても pin が変わらなければ、originHash は前の値のまま残ります。小さなパッケージで再現できます。 mkdir -p repro/Sources/Repro && cd repro cat > Package.swift <<'EOF' // swift-tools-version: 6.0 import PackageDescription let package = Package( name: "Repro", dependencies: [ .package(url: "apple/swift-algorithms", from: "1.1.0"), ], targets: [ .target(name: "Repro", dependencies: [.product(name: "Algorithms", package: "swift-algorithms")]), ] ) EOF echo 'import Algorithms' > Sources/Repro/Repro.swift swift package resolve grep originHash Package.resolved # shasum -a 256 Package.swift と同じ値 要求だけを変える(今の pin 1.2.1 はどちらでも満たす) sed -i '' 's/from: "1.1.0"/from: "1.2.0"/' Package.swift swift package resolve --very-verbose 2>&1 | grep "origin hash" debug: 'Package.resolved' origin hash does do not match manifest dependencies. resolving and updating accordingly grep originHash Package.resolved # 前の値のまま。何回流しても変わらない swift package update でも、pin が変わらなければ書き直しません。同じ不満は SwiftPM の Issue(#7644「V3 originHash should update more eagerly」、2024 年 6 月)にも出ていて、まだ直っていません。 依存を自動で上げるツールを使っていると、この状態にすぐなります。Renovate の Swift 対応は、Package.swift の要求を上げるときに Package.resolved の該当する pin も書き換えます。ただし originHash には触りません。このことは renovatebot/renovate#41534 に既知の制約として書かれています。マージした後に SwiftPM が解決しても、pin はすでに新しいので、originHash は古いまま残ります。 Dependabot は事情が違います。Dependabot の Swift 対応は、書き換えた Package.swift を作業用のディレクトリに置き、swift package resolve <依存> --version <バージョン> を実際に走らせて、その結果の Package.resolved を commit します(lockfile_updater.rb)。originHash も SwiftPM が計算するので、正しい値になります。以前 Dependabot を使っていたときの commit を見返すと、Package.swift を書き換えた PR でも、originHash は Package.swift の SHA-256 と一致していました。 冒頭の 1 行の diff は、ここに Tuist が絡んで出ていました。Tuist は tuist install で解決するたびに、自分で計算した originHash でファイルを書き直します。SwiftPM と違って値を最新に保つので、Renovate の PR の後に古い値が見つかると、書き直して diff を出すわけです。 解決しないまま originHash だけを直してはいけない この値がただの SHA-256 なら、Renovate が Package.swift を書き換えるついでに計算すればよさそうに見えます。私も最初はそう考えて、Renovate に提案しました(後で取り下げています)。ところが、これは Tuist を使っていると危ない直し方です。 Tuist の依存解決は、hash が一致すると swift package resolve を起動せずに pin をそのまま使います。この速い経路で確かめているのはミラーとローカルパッケージの条件だけで、推移的な依存の要求は確かめません。 一方、Renovate が書き換えるのは、上げた依存の pin だけです。たとえば GoogleSignIn-iOS を 10.0.0 に上げたとします。10.0.0 は AppAuth-iOS に from: "3.0.0" を要求しますが、Package.resolved には AppAuth-iOS 2.1.0 の pin が残ります。実際に私たちのリポジトリでも、この状態の PR がマージされていました。 この状態で originHash だけを今の値にするとどうなるかを、小さな Tuist のプロジェクトで試しました。GoogleSignIn-iOS を from: "9.2.0" で入れた状態から、Renovate と同じように Package.swift の要求と GoogleSignIn-iOS の pin だけを 10.0.0 に書き換えました。そのうえで、Tuist 4.212.0 の tuist install を流しています。 originHash tuist install checkout された AppAuth-iOS GTMAppAuth 古いまま 29 秒、成功 3.0.0 6.0.0 今の値に書き換え 3 秒、成功 2.1.0 5.0.0 古いままだと、Tuist は SwiftPM に解決を任せ、SwiftPM が AppAuth-iOS を 3.0.0 に直しました。今の値にしてあると、Tuist は速い経路で済ませました。checkout されたのは、GoogleSignIn-iOS 10.0.0 の要求(from: "3.0.0")を満たさない AppAuth-iOS 2.1.0 で、エラーも出ませんでした。古い originHash が、ちょうど「確かめ直せ」という合図になっていたわけです。 SwiftPM 自身は、どちらの経路でも pin を確かめます。同じ Package.resolved を素の swift package resolve で解決すると、hash が一致していても(ログは「origin hash matches」)、AppAuth-iOS は 3.0.0 に直りました。 再現の手順 Tuist.swift と、Tuist/Package.swift に .package(url: "google/GoogleSignIn-iOS", from: "9.2.0") だけを書いたプロジェクトで tuist install する。GoogleSignIn-iOS 9.2.0、AppAuth-iOS 2.1.0、GTMAppAuth 5.0.0 が pin される 別のプロジェクトで from: "10.0.0" にして tuist install し、10.0.0 系の依存をキャッシュに入れておく 1 の Package.resolved の GoogleSignIn-iOS の pin だけを、2 のもの(10.0.0)に書き換える。Package.swift は from: "10.0.0" にする originHash を 1 の値のまま残したものと、shasum -a 256 Tuist/Package.swift の値に書き換えたものを用意し、それぞれ空の Tuist/.build で tuist install する 結果の Package.resolved と Tuist/.build/workspace-state.json で、checkout されたバージョンを確かめる 同じ更新を Dependabot がすると、SwiftPM が解決するので AppAuth-iOS も 3.0.0 に上がり、originHash も正しい値になります。pin と originHash を一緒に、実際の解決結果で書き換えるので、この問題は起きません。 ほかにも、自動で上げる PR がそれぞれ originHash を書き換えると、どの PR も Package.resolved の 2 行目を別の値に変えることになります。そのため、1 本マージするたびに残りの PR が conflict します。 originHash を変えてよいのは、その Package.swift で実際に解決して、pin と一緒に書き込むときだけです。 どう付き合うか 使っている道具ごとに、現実的な扱い方が違います。 SwiftPM と Xcode だけを使っている場合は、古い originHash でも結果は変わらず、checkout が無いときに遅くなるだけです。ただ、今の SwiftPM には、pin を変えずに originHash だけを更新するコマンドがありません。Package.resolved を消して作り直せば hash は新しくなりますが、範囲内の新しいバージョンに pin が上がることがあります。 Tuist を使っている場合は、tuist install が書き直した 1 行の diff を、そのまま commit するのがよいと考えています。見た目だけの差分に見えますが、これを入れておくと、clone 直後や CI の install が速い経路に戻ります。 Renovate と Dependabot の違い 依存を自動で上げているなら、どちらのツールかで扱いが変わります。 Renovate Dependabot 方式 SwiftPM を動かさず、pin の文字列を書き換える Linux の上で SwiftPM を実際に動かす originHash 古いまま残る SwiftPM が計算した正しい値になる 推移的な依存の pin 取り残される SwiftPM が揃える Linux でだけ使う依存 入らない 入る PR 同士の conflict originHash の行ではぶつからない Package.swift を変える PR 同士が originHash の行でぶつかる ※ Package.swift を持つパッケージを更新する場合の比較です。Xcode プロジェクトの Package.resolved については後で書きます。 Dependabot の方が Package.resolved を正しく保てますが、代わりに Linux で解決した結果になります。Dependabot は Ubuntu の上の Swift 6.3.1 で解決します(Dockerfile)。Linux で解決するので、Apple のプラットフォームでは使わない依存まで pin に入ることがあります。 私たちのアプリでは OpenCombine がそうでした。依存している combine-schedulers は、Darwin 以外でだけ OpenCombine を使う trait を既定で有効にしています(Package@swift-6.1.swift)。macOS で解決すると OpenCombine は pin に入りませんが、Dependabot の PR には入ってきて、手で消していました。この食い違いを避けるなら、使わない OpenCombine をあえてルートの依存に書いておく手もあります。macOS で解決しても pin に入るので、Linux の結果と揃います(target を持たない manifest で確かめました)。 Dependabot の Swift も、手元の Xcode より古くなりがちです。依存に Package@swift-6.4.swift のような新しい Swift 向けの manifest が増えると、Linux と手元で読むファイルが変わり、結果がずれる可能性があります。 なお、Package.swift を持たない Xcode プロジェクトの Package.resolved を書き換える経路(xcode_lockfile_updater.rb)は、SwiftPM を動かさず、originHash を扱うコードもありません。こちらは Renovate と同じく、pin だけを書き換えます。 どちらのツールでも、いちばんきれいなのは、その PR の中で macOS で実際に解決して、結果を commit することです。たとえば Renovate のブランチで macOS の CI に tuist install を流させ、書き換わった Package.resolved を bot が PR に commit・push します。こうすると originHash だけでなく、取り残された推移的な依存の pin も一緒に直り、Linux でだけ入る依存も混ざりません。ただし swift package resolve は pin が変わらなければ originHash を書き直さないので、Tuist を使わない場合は、この方法でも hash が古いまま残ることがあります。 SwiftPM 側の修正 根本を直すため、SwiftPM に PR(swiftlang/swift-package-manager#10718)を出しました。pin が要求を満たすと確かめた後で、originHash がずれていたら hash だけを書き直す変更です。入れば Xcode でも originHash が最新に保たれるようになります。ただし代わりに、Package.swift を変えた後に 1 行の diff が出るようになり、Package.swift を触ったブランチ同士はこの行でぶつかりやすくなります。checkout が無いときの解決の速さと conflict のどちらを取るか、というトレードオフです。 見立てを 3 回間違えた話 最初は「Tuist が余計な diff を出している」と思いました。調べると、Tuist の値は SwiftPM の定義どおりの SHA-256 で、古い値を残していたのは Renovate の側でした。 次に「Renovate が originHash を計算し直せばいい」と考えました。これは前の節のとおり、Tuist では壊れた pin を通してしまうので取り下げました。 最後に「SwiftPM と Xcode では古い originHash でも遅くならない。書き直して diff を出す Tuist の方を SwiftPM に合わせればいい」と考えて、Tuist に PR(tuist/tuist#14475)を出しました。根拠は checkout がある状態の計測だけでした。checkout が無い状態を測ってみると、Tuist の書き直しこそが速さを保っていたと分かり、PR は閉じました。 近道が効くのは、ふだんの手元ではなく clone 直後や CI です。性能を確かめるときは、効いてほしい状態で測るべきだった、というのがいちばんの反省です。 originHash は「近道を使ってよいか」の目印 originHash は、Package.resolved が今の Package.swift 用かどうかを SwiftPM に伝える目印です。値が正しければ、clone 直後や CI で依存のバージョンの選び直しを省けます。ただ、SwiftPM 自身は pin が変わるまで更新しないので、古くなりがちです(#7644)。 古い値に気づいたら、実際に解決した結果と一緒に commit する。解決しないまま値だけを書き換えない。この 2 つを守れば、diff に紛れ込むこの 1 行が、クリーンな環境での解決を速くしてくれます。依存を自動で上げるなら、Dependabot は実際に解決して書き換えるので前者に当たります。ただし Linux で解決した結果になる点には気をつけてください。

  6. @fromjrtosr curious if tools like: - airbnb/epoxy-ios - pointfreeco/swift-navigation - pointfreeco/swift-perception still have values when implementing features with uikit

    1 like · 16 views
  7. The Composable Architecture: A library for building applications in a consistent and understandable way (15k stars) What it does All text below is quoted directly from the project README. >The Composable Architecture (TCA, for short) is a library for building applications in a consistent and understandable way, with composition, testing, and ergonomics in mind. It can be used in SwiftUI, UIKit, and more, and on any Apple platform (iOS, macOS, iPadOS, visionOS, tvOS, and watchOS). What is the Composable Architecture?: - State management: How to manage the state of your application using simple value types, and share state across many screens so that mutations in one screen can be immediately observed in another screen. - Composition: How to break down large features into smaller components that can be extracted to their own, isolated modules and be easily glued back together to form the feature. - Side effects: How to let certain parts of the application talk to the outside world in the most testable and understandable way possible. - Testing: How to not only test a feature built in the architecture, but also write integration tests for features that have been composed of many parts, and write end-to-end tests to understand how side effects influence your application. This allows you to make strong guarantees that your business logic is running in the way you expect. - Ergonomics: How to accomplish all of the above in a simple API with as few concepts and moving parts as possible. >The benefits of doing this are that you will instantly unlock testability of your feature, and you will be able to break large, complex features into smaller domains that can be glued together. Repository pointfreeco/swift-composable-architecture Repository stats |Metric|Value| |:-|:-| |Stars|14,963| |Forks|1,677| |Last release|1.26.2 (2026-08-28)| |Languages|Swift 99.9%, Makefile 0.1%| |Contributors|257| |AI contributor accounts (Claude / Codex / Cursor / Copilot)|None| |AI co-authored commits|None| Main contributor |Field|Value| |:-|:-| |Name|Stephen Celis| |GitHub|@stephencelis| |Commits to this repo|934 (top contributor)| |Bio|Working on @pointfreeco: pointfree.co| |Company / Location|@pointfreeco / California| |Website|stephencelis.com| |Followers|6172| |Public repos|131| |On GitHub since|2008-02-22|

    16 points
  8. Why oh why did I *assume* that SwiftData would support iCloud sharing? It does not. However, sqlite-data pointfreeco/sqlite-data *does*, and so I feel incredibly lucky that the folks at @pointfreeco have put it together and seen fit to share. Thank you @mbrandonw and […]

  9. Swift 6 で Redux と Redux Saga を作り直した 以前、Redux Saga for Swift という記事を書きました。 qiita.com その記事で、ReSwift[^reswift] の上で動く Redux Saga の自作ライブラリ ReSwift-Saga[^reswift-saga] を紹介しました。そのライブラリは機能も少なく、エラー処理やテストも今後の課題として残したままでした。やり残しを抱えたまま、気づけば Swift 6 の時代です。 Swift 6 では、非同期処理まわりが大きく強化されました。async / await と構造化並行性で、非同期の処理を上から順に書けて、キャンセルも親から子へ伝わります。さらに strict concurrency によって、スレッドをまたいで受け渡す値が Sendable かどうかをコンパイラが検査するようになりました。 Saga がしていることを並べてみると、Action を待ち、処理をして、結果を Action で返し、ときには処理をキャンセルしたり並行に動かしたりします。受け渡すのは値としての Action と State です。どれも Swift 6 が得意とする領域です。 というわけで、Redux 本体も含めて Swift 6 向けにまるごと作り直したのが swift-redux-saga[^swift-redux-saga] です。企画と設計は私、設計補助と主な実装は Claude Code、品質管理と実装少々は Codex の共作した個人の趣味ライブラリです。Swift 6 ならいいものが作れそうと思って、AI で実現させました。今は 0.2.1 を公開していて、Swift Package Manager で導入できます。 mitsuharu/swift-redux-saga この記事では、Redux と Redux Saga のおさらいをしてから、なぜ今 Swift で Redux なのか、他のライブラリはどういう状況なのかを整理し、swift-redux-saga の使い方を紹介します。ライブラリの内部実装の話はしませんが、興味あれば、ソースコードを見てください。 Redux そして Redux Saga へ 簡単に Redux というアーキテクチャについて説明します。 Redux Redux[^redux] は、JavaScript で生まれた状態管理のための予測可能な状態コンテナです。アプリの状態を 1 か所にまとめ、状態の変更を決まった経路に限定することで、状態がいつ、なぜ変わったのかを追えるようにします。 登場するのは次の部品です。 - State - アプリの状態を表す値です - 画面に表示するデータはすべてここから読みます - Action - 「ボタンが押された」「データが届いた」のような出来事を表す値です - Reducer - 今の State と Action から次の State を作る関数です - 副作用(通信など)は持ちません - Store - State を 1 つだけ持ち、受け取った Action を Reducer に通して State を更新します View は State を読んで表示し、ユーザーの操作を Action として Store に送ります(dispatch)。State を書き換えられるのは Reducer だけなので、State の変化はすべて Action の列で説明できます。データが一方向にしか流れないことが、Redux のいちばん大事な性質です。開発において重要な考えである Single source of truth(信頼できる唯一の情報源)が実現されます。 Redux Toolkit 素の Redux で書くと、Action の型の定数、Action を作る関数、Reducer の switch 文と、同じ機能のためのコードがあちこちに分かれ、量も増えがちでした。この手間を減らすために Redux 公式が出しているのが Redux Toolkit[^rtk](RTK)で、今は Redux のコードを書くときの公式の推奨になっています。 中心になるのは createSlice です。1 つの機能の State の初期値と、Action ごとの State の更新処理をまとめて書くと、Reducer と Action を作る関数がいっしょに生成されます。ほかにも、State から表示用の値を計算して結果をキャッシュする createSelector、ID で引ける一覧を扱う createEntityAdapter などがそろっています。swift-redux-saga の Redux 本体は、この Redux Toolkit を手本にしたものです。 Redux Saga Redux はシンプルである一方、通信のような副作用をどこに書くのかは決めていません。Reducer は純粋な関数でなければならないので通信はできませんし、View で通信してから結果を dispatch すると、ロジックが View に散らばります。 そこで登場するのが Redux Saga です。Saga はアプリの中で副作用を個別に実行する独立したスレッドのようなイメージです。Saga は Store の外側で Action を待ち受け、Action が届いたら通信などの処理をして、結果を新しい Action として Store に戻します。View は Action を送るだけ、Reducer は State を作るだけ、Saga は副作用だけ、と役割がきれいに分かれます。 一般的に副作用の実装はアーキテクチャにロックインされてしまいます。しかしながら、Redux Saga は副作用を関数で定義するので、他のアーキテクチャへの移植性は比較的高いというメリットもあります。 !Action が Middleware と Reducer を通って Store の State になり、View が State を表示する。Saga は Middleware から Action を受け取り、API などを呼んで、結果の Action を put で戻す 実線が Redux の通常の流れで、Action は一直線に Reducer まで進みます。点線が Saga の流れで、Saga は通常の流れの外側で Action を受け取り、処理の結果を流れの先頭に戻します。 Saga の中では、take(Action を待つ)、put(Action を発行する)、call(関数を呼ぶ)、fork(別の Saga を並行して動かす)といった命令を組み合わせて処理を書き、これらの命令は Effect と呼ばれています。よく使う組み合わせはヘルパーとして用意されていて、たとえば takeLatest は、Action が届くたびに処理を始め、前の処理が終わっていなければキャンセルします。検索欄の入力のように、古い結果が要らなくなる処理を短く書けます。 なぜ今 Swift で Redux なのか Swift で Redux を使う試みは以前からあり、ReSwift はその代表です。それでも今あらためて作り直したのは、Swift の言語とフレームワークの側が、ここ数年で Redux と相性のよい形に変わってきたためです。 Swift Concurrency redux-saga は JavaScript のジェネレーター関数(function* と yield)を使って、非同期の処理を上から順に書けるようにしています。Swift にジェネレーターはありませんが、async / await があります。yield take(...) を try await ctx.take(...) と書けば、ほぼ同じ感覚で読めるはずです。 ここで、Swift では AsyncStream の continuation.yield(_:) のように yield という名前は出てきます。しかし、これは非同期のシーケンスに値を流すメソッドで、関数を途中で止めて値を返すジェネレーターの yield とは別物です。 さらに、Swift の構造化並行性では、親のタスクをキャンセルすると子のタスクにもキャンセルが伝わります。fork した Saga を親ごと止める、takeLatest で古い処理を止める、といった redux-saga のキャンセルの考え方を、言語の仕組みのまま実現できます。 Swift 6 の strict concurrency Swift 6[^swift6] からは、スレッドをまたいで受け渡す値が安全かどうか(Sendable かどうか)をコンパイラが検査します。共有された可変の状態を持つ設計ほど、この検査を通すのに苦労します。 一方、Redux の State は不変の値として受け渡され、変更は Reducer の中だけで起き、Action もただの値です。Swift の struct と enum で書けば、State と Action は自然に Sendable になります。Redux の「値と純粋関数で状態を扱う」という考え方は、Swift 6 が求める書き方と同じ方向を向いています。 SwiftUI と Observation SwiftUI は、React と同じく「状態から画面を作る」宣言的な UI です。そして iOS 17 から使える Observation(@Observable)で、View が読んだプロパティが変わったときだけ再描画されるようになりました。 ReSwift の頃は、StoreSubscriber に準拠して購読し、newState(state:) で受け取り、同じ値なら無視する設定をして……と手間がかかりました。今は View の中で store.count と書くだけで済みます。UIKit でも、iOS 26 からは Observation による自動追跡が既定で有効になりました。 他のライブラリの開発状況 以下は 2026 年 10 月時点で、各リポジトリを確認した内容です。 ReSwift ReSwift は、Swift で Redux を使うライブラリとして長く使われてきました。ただ、最新のリリースは 2023 年 1 月の 6.1.1 で、最後のコミットは 2024 年 4 月の README の更新です。Package.swift の swift-tools-version は 5.7 のままで、ソースに Sendable は出てきません。Swift 6 言語モードのアプリから使うと、Store や Action の受け渡しが安全であることを、使う側で保証しなければなりません。非同期処理の拡張である ReSwift-Thunk[^reswift-thunk] も、最新のリリースは 2020 年 10 月の 2.0.1 です。 私の旧ライブラリ ReSwift-Saga は ReSwift の上で動いていたので、ReSwift ごと置き換えることにしました。 The Composable Architecture The Composable Architecture[^tca](TCA)は、Point-Free が開発している、Redux の考え方を取り入れたアーキテクチャです。開発は活発で、2026 年 8 月に 1.26.2 が出ています。 TCA は状態管理だけでなく、依存性の注入、画面遷移、テストまでを一体で提供するフレームワークで、swift-dependencies や swift-case-paths など Point-Free の多くのパッケージに依存しています。完成度は高いのですが、アプリの書き方全体を TCA に合わせることになります。副作用も TCA 独自の Effect で書くため、redux-saga とは書き方がだいぶ違うものになります。 ReactorKit ReactorKit[^reactorkit] も、Action を受けて State を作る単方向の設計を取るライブラリです。2026 年もコミットは続いていますが、最新のリリースは 2022 年 1 月の 3.2.0 で、RxSwift に依存しています。Swift Concurrency と Observation を前提にした今の SwiftUI の書き方とは、組み合わせにくい面があります。 JavaScript の redux-saga 本家の redux-saga[^redux-saga-repo] も今もメンテナンスされていて、2026 年 7 月に 1.5.1 が出ています。ただし、Redux 公式のドキュメント[^side-effects]は、データの取得には Saga ではなく RTK Query を、Action への反応には listener middleware を勧めていて、Saga は listener で足りないときに使うもの、という位置づけです。一方で、Saga は「バックグラウンドスレッドのような振る舞いや、debounce やキャンセルを必要とする、とても複雑な非同期のワークフロー」に向いている、とも書かれています。 私もこの評価には納得していて、通信して結果を表示するだけの処理に Saga は大げさだと思います。それでも、ログインの間だけ同期を動かす、入力が止まってから検索する、保存の順番を守る、といった時間の絡む処理は、モバイルアプリでは日常的に出てくるものです。こうした処理を読みやすく書くために、Swift にも Saga があってよいと考えています。 Swift 6 対応の Redux と Saga はなかった Swift 6 にきちんと対応した軽い Redux と、redux-saga と同じ書き方で副作用を書ける仕組みの両方を満たすライブラリは、見つかりませんでした。それなら自分で作るしかありません。 swift-redux-saga を使おう swift-redux-saga は、Swift 6 言語モードで警告なしにビルドできる Redux と Redux Saga の Swift Package です。Redux 本体は前に紹介した Redux Toolkit を参考にしていて、createSlice や createSelector に当たる機能もあります。マクロ用の swift-syntax を除いて外部のライブラリには依存していません。動作環境は Swift 6.2(Xcode 26)以降、iOS 17 以降です。 導入は Package.swift か、Xcode の File > Add Package Dependencies から行います。 dependencies: [ .package(url: "https://github.com/mitsuharu/swift-redux-saga.git", from: "0.2.1") ] Redux と Saga を使うなら ReduxSaga を、SwiftUI の補助も使うなら ReduxSwiftUI をターゲットに追加します。Saga だけを他の状態管理と組み合わせても使えます。 アーキテクチャにロックインされないように作る swift-redux-saga を作るときにいちばん意識したのは、アプリがこのライブラリにロックインされないことです。状態管理のライブラリは、一度アプリ全体に広げると簡単には外せません。メンテナンスが止まったり、Swift の大きな変更に追いつけなくなったりすると、アプリごと身動きが取れなくなります。ReSwift の上に ReSwift-Saga を載せていた私自身が、Swift 6 を前にしてまさにその状況になりました。旧ライブラリの記事でもアーキテクチャとの接点を小さくする方針を書きましたが、今回はそれを設計の前提に据えました。 そのために、通信や保存などのビジネスロジック(UseCase や Repository)は、このライブラリを import せずに書けるようにしました。Saga は「Action を受けて、ビジネスロジックを呼び、結果を Action で返す」だけの薄い層にとどめます。Saga から呼ぶ call は、Action を受け取る関数に限らず、どんな async 関数でも呼べます。ビジネスロジックが必要とする依存は、Saga を作るときに引数で渡す形です。こうしておけば、ビジネスロジックの側は Action も State も知らずに済みます。 サンプルアプリは、この方針に沿って 3 つのターゲットに分けています。 Domain ライブラリに依存しない(Model / Repository / UseCase) AppFeature Domain + swift-redux-saga(State / Action / Reducer / Saga) App SwiftUI や UIKit の画面と、Store の組み立て ライブラリを入れ替えることになっても、書き直すのは AppFeature の State・Action・Reducer・Saga だけで、Domain はそのまま使えます。AppFeature は UI のフレームワークにも依存しないので、Saga や Reducer のテストはアプリを起動せずに実行できます。小さなアプリなら 1 つのターゲットにまとめてもかまいません。それでも分けておけば、ロジックが View に紛れ込みにくくなるという利点もあります。 ライブラリ自体も、使う範囲を選べるように分けています。Saga は Redux 本体に依存しておらず、Saga のプロダクトだけを追加すれば、他の状態管理と組み合わせて使えます。画面側も、Store を直接読んでもよいし、MVVM の ViewModel を挟んでもよく、どちらかに決め打ちしていません。 カウンターを作る まずは定番の、ボタンでカウントを増やし、「読み込み」で値をサーバーから取ってくるカウンターです。 State と Action と Reducer は、Slice という入れ物にまとめます。Redux Toolkit の createSlice に当たるものです。 import Redux enum Counter: Slice { struct State: Sendable, Equatable { var count = 0 var isLoading = false } enum Action: Sendable, Equatable { case increment case fetch case fetched(Int) } static let initialState = State() static func reduce(into state: inout State, action: Action) { switch action { case .increment: state.count += 1 case .fetch: state.isLoading = true case .fetched(let value): state.isLoading = false state.count = value } } } JavaScript の Reducer は新しい State を返しますが、ここでは inout で受け取った State を直接書き換えます。Swift の struct は値型なので、書き換えても呼び出し元の State が勝手に変わることはなく、Reducer は純粋な関数のままです。Action を enum にしておけば、switch で扱い忘れた case がコンパイルエラーになるのも嬉しいところです。 ReSwift を使ったことがある方は、ここで少し書き方が違うと感じるかもしれません。swift-redux-saga の Redux 本体は ReSwift ではなく Redux Toolkit を手本にしているためです。ReSwift では、Action は Action プロトコルに準拠した struct で、Reducer は Optional の State を受け取って新しい State を返す関数でした。 // ReSwift の書き方 struct IncrementCounter: Action {} func counterReducer(action: Action, state: CounterState?) -> CounterState { var state = state ?? CounterState() switch action { case _ as IncrementCounter: state.count += 1 default: break } return state } swift-redux-saga では、Action を enum にし、機能ごとの State・Action・Reducer を Slice にまとめます。初期値は initialState として別に持つので、Reducer が Optional の State を扱うこともありません。複数の Slice は Reducer.slice でアプリ全体の Reducer にまとめます。Redux Toolkit の createSlice を使ったことがあれば、こちらのほうが見慣れた形だと思います。State の変化の受け取り方も違い、ReSwift の StoreSubscriber の代わりに、後で紹介する Observation で読むだけです。ReSwift からの書き換え方は、移行ガイドに対応表を載せています。 次に Saga を書きます。前の節で書いたとおり、通信の処理そのものはライブラリを import しない普通の Swift のコードとして用意し、Saga を作るときに渡します。 // ライブラリを import しない struct CounterRepository: Sendable { let load: @Sendable () async throws -> Int } extension CounterRepository { // 本番の実装(fetchCountFromServer はアプリの通信処理)。テストでは別の実装を渡す。 static let live = CounterRepository { try await fetchCountFromServer() } } import Saga struct CounterSagas: Sendable { let repository: CounterRepository var root: Saga<Counter.State, Counter.Action> { Saga("counter") { ctx in ctx.takeLatest(.action(.fetch)) { ctx, _ in do { let value = try await ctx.call(repository.load) await ctx.put(.fetched(value)) } catch { // 失敗も Action にして返す } } } } } Effect は ctx のメソッドとして呼び、redux-saga で yield を書く場所に await を書きます。この Saga は .fetch が届くたびに通信し、結果を .fetched として Store に戻すものです。通信中にもう一度 .fetch が届けば、takeLatest が前の通信をキャンセルします。redux-saga の takeLatest(FETCH, fetchCount) とほぼ同じ見た目ですね。 Store は、Saga を Store につなぐ SagaMiddleware を渡して作り、作った後で Saga を起動します。 import ReduxSaga @MainActor func makeStore() -> Store<Counter.State, Counter.Action> { let sagaMiddleware = SagaMiddleware<Counter.State, Counter.Action>() let store = Store( initialState: Counter.initialState, reducer: Counter.reducer, middleware: [sagaMiddleware] ) sagaMiddleware.run(CounterSagas(repository: .live).root) return store } SwiftUI の View は、State のプロパティを読み、Action を dispatch するだけです。 import ReduxSwiftUI struct CounterView: View { @Environment(Store<Counter.State, Counter.Action>.self) private var store var body: some View { VStack { Text("\(store.count)") Button("+1") { store.dispatch(.increment) } Button("読み込み") { store.dispatch(.fetch) } .disabled(store.isLoading) } } } store.count を読んだ View は、count が変わったときだけ再描画されます。購読の登録や解除は書きません。ReSwift の StoreSubscriber を書いていた頃と比べると、ずいぶん短くなりました。 ログインと ToDo を組み合わせる リポジトリの Examples/ には、ログインと ToDo の 2 つの機能を持つサンプルアプリが、SwiftUI 版と UIKit 版の両方で入っています。ここからはその中身を紹介します。 ログインの Saga は次のとおりです。ログインのボタンが押されたら通信し、成功すれば .loggedIn、失敗すれば .failed を返します。 public var root: Saga<AuthFeature.State, AuthFeature.Action> { Saga("auth") { ctx in ctx.takeLeading(.case(\.loginTapped)) { ctx, name in do { guard let user = try await ctx.call(useCase.login, name) else { await ctx.put(.failed("Enter your name.")) return } await ctx.put(.loggedIn(user)) } catch is CancellationError { } catch { await ctx.put(.failed(error.localizedDescription)) } } } } takeLeading は、処理中に届いた同じ Action を無視するヘルパーで、ボタンの連打対策に使っています。.case(\.loginTapped) は、loginTapped(name:) という case のときだけ反応して、関連値の name を取り出して渡す、という意味です。enum の case から値を取り出すコードは、@Slice や @ActionCases というマクロで生成しています。 アプリ全体の State は、機能ごとの State をプロパティとして並べただけの struct です。ログインの Saga はログインの State と Action の型のまま書き、親につなぐときに型を付け替えます。そのため、機能ごとのコードはアプリ全体の型を知らずに書けます。 Saga がいちばん役に立つのは、機能をまたぐ流れを書くときです。サンプルでは、ToDo の同期をログインしている間だけ動かしています。ルートの Saga を簡略化すると、次のようになります。 while true { _ = try await ctx.take(.case(\.auth?.loggedIn)) let session = ctx.fork(todoSagas.root, state: \.todo, action: \.todo, embed: AppAction.todo) _ = try await ctx.take(.case(\.auth?.loggedOut)) ctx.cancel(session) } はい、無限ループです。ログインを待ち、ToDo の Saga を動かし、ログアウトを待って止める、という流れがそのままコードになっています。ctx.cancel(session) で止めると、ToDo の Saga が実行中だった読み込みや保存もまとめてキャンセルされます。同じことを ViewModel のフラグや Combine の購読で書くと、どこで何を止めるかのコードがあちこちに散らばりがちでした。 ToDo の Saga では、検索語の入力が 300 ミリ秒止まってから反映する debounce や、保存の順番を守るために Action を一度溜めておく actionChannel も使っています。Effect の一覧は、redux-saga との対応表も含めて Saga の Effect にまとめています。 SwiftUI では useSelector のように読む React Redux を使ったことがある方なら、useSelector と useDispatch で、読みたい値と送る関数だけを宣言する書き方に馴染みがあると思います。swift-redux-saga では、@SelectState と @DispatchAction というプロパティラッパーで同じように書けます。次のコードはサンプルのログイン画面です。 struct LoginView: View { @SelectState(\RootFeature.State.auth.isLoggingIn) private var isLoggingIn @SelectState(\RootFeature.State.auth.errorMessage) private var errorMessage @DispatchAction private var dispatch: (RootFeature.Action) -> Void @State private var name = "" var body: some View { Form { TextField("Name", text: $name) Button("Log in") { dispatch(.auth(.loginTapped(name: name))) } .disabled(isLoggingIn) if let errorMessage { Text(errorMessage).foregroundStyle(.red) } } } } View は Store の型を知らず、読む値と送る Action だけを知っています。@SelectState は読んだ値が変わったときだけ再描画するので、ToDo の一覧が更新されてもこの画面は描き直されません。入力中の名前は画面の中でしか使わないので、View の @State に置きました。 画面の状態をすべて Store に入れる必要はありません。サンプルでは、設定画面のように Store の値を表示して操作するだけの画面は Store を直接使い、入力中の文字列など画面特有の状態がある ToDo の画面は MVVM を経由しています。ViewModel は Store の値の写しを持たず、計算プロパティで Store を読むので、データは Store の 1 か所にだけあります。 テストは結果を確かめる redux-saga では、ジェネレーターから Effect を 1 つずつ取り出して、期待どおりの Effect かを確かめるテストがよく書かれます。残念ながら Swift にジェネレーターはないので、この書き方はできません。代わりに swift-redux-saga では、Saga を実際に動かし、発行された Action と State の変化を確かめます。サンプルのテストから、ログインすると ToDo が読み込まれることを確かめる部分を抜き出します。 @Test func todosAreLoadedOnlyAfterLoggingIn() async throws { let store = makeStore() try await store.send(.auth(.loginTapped(name: "me"))) { $0.auth.isLoggingIn = true } try store.receive(.auth(.loggedIn(User(name: "me")))) { $0.auth.isLoggingIn = false $0.auth.user = User(name: "me") } try store.receive(.todo(.refresh)) { $0.todo.isLoading = true } try store.receive(.todo(.loaded([milk], generation: 0))) { $0.todo.isLoading = false $0.todo.todos.ids = [milk.id] $0.todo.todos.entities = [milk.id: milk] } try await store.finish() } send で Action を送り、receive で Saga が発行した Action を順に確かめます。後ろのクロージャには、期待する State の変化を書きます。TestStore はすべての Saga が次の Effect で止まるまで待ってから確かめるので、sleep で待つ必要がなく、マシンの速さでテストの結果が変わりません。debounce のような時間の絡む処理は、TestClock で時間を進めて確かめます。 結果を確かめるテストは、Saga の中の書き方を変えても、振る舞いが同じなら壊れません。Redux 公式のドキュメントは、Saga のテストが実装の細部に依存しやすいことを欠点に挙げていますが、この書き方ならその心配は小さくなります。旧ライブラリでやり残していたテストも、これでようやく書けるようになりました。 評価と考察 サンプルアプリを作ってみていちばんよかったのは、時間の絡む処理を読めるコードで書けたことです。ログイン中だけ同期を動かす処理も、入力が止まってから検索する処理も、Saga を開けば流れが上から順に書いてあります。ViewModel に書いていた頃のように、フラグやタスクの参照をいくつも持ち回る必要はありません。キャンセルが親から子へ伝わるので、止め忘れも起きにくくなりました。View の処理も Action を送るだけになり、とてもシンプルになって満足しています。 Swift らしく書けるのも気に入っています。Action を enum にすれば Reducer の扱い忘れはコンパイルエラーになり、State と Action は Sendable な値なので Swift 6 の検査にも素直に通ります。yield を await に置き換えただけの見た目なので、redux-saga を知っている人なら対応表を見ればすぐに読めますし、知らない人には普通の async 関数に見えるはずです。 ロックインを避ける構成も、実際に使ってみて安心感がありました。サンプルの Domain は swift-redux-saga を一度も import していない(import しているのは Foundation だけ)ので、このライブラリをやめる日が来ても、捨てるコードの範囲がはっきりしています。 ただ、どのアプリにも勧められるわけではありません。Action を enum で定義し、Reducer と Saga に分けて書くので、画面が 1 つで通信も 1 つというアプリでは、コードが増えるだけです。Redux と Saga の考え方を知らない人には、慣れるまでの学習コストもかかります。 Xcode 26 の既定の設定にも注意が要ります。Xcode 26 で作ったアプリのターゲットでは default MainActor isolation が有効になっていて、型や関数が暗黙にメインアクターに属します。Saga はメインアクターの外で動くので、State や Action、Saga には nonisolated を付けて宣言しなければなりません。付け忘れたときのエラーメッセージから原因を読み取りにくいので、README に例を載せました。State と Saga を UI とは別のターゲットに置けば、そのターゲットではこの設定を無効にでき、nonisolated を書かずに済みます。 TCA と比べると、swift-redux-saga は状態管理と副作用だけに絞った小さなライブラリです。画面遷移や依存性の注入は、アプリで好きな方法を選べます。逆に、そこまでまとめて面倒を見てほしいなら TCA の方が向いています。 旧記事でも書いたとおり、このアーキテクチャはニッチだと自認しています。それでも、Redux ベースのアーキテクチャに興味がある方や、時間の絡む処理の書き方に悩んでいる方には、試す価値があると思います。 まとめ 前回の記事では、ReSwift の上に Saga の考え方を取り入れるところまでで精一杯でした。今回は Swift Concurrency、Swift 6 の strict concurrency、Observation がそろったので、Redux 本体から作り直しました。put や takeLatest、エラー処理、テストまで用意して、ようやく redux-saga と呼べる形になりました。 はじめての方は、カウンターのアプリを作りながら順に説明するはじめかたから読んでみてください。ReSwift や ReSwift-Saga を使っている方向けには、移行ガイドも用意しています。感想や不具合の報告は、GitHub の Issue でお待ちしています。 Swift で書く Redux Saga に、iOS アプリ開発者の方が少しでも興味を持っていただけたら嬉しいです。 [^redux-saga]: redux-saga [^reswift]: ReSwift [^reswift-saga]: ReSwift-Saga(旧ライブラリ。現在は非推奨) [^swift-redux-saga]: swift-redux-saga [^redux]: Redux [^swift6]: Announcing Swift 6 [^reswift-thunk]: ReSwift-Thunk [^tca]: The Composable Architecture [^tca2]: Beta preview: ComposableArchitecture 2.0 [^reactorkit]: ReactorKit [^redux-saga-repo]: redux-saga(GitHub) [^side-effects]: Redux: Side Effects Approaches [^rtk]: Redux Toolkit

  10. 最近准备开新坑了,需要选型一个能方便icloud同步也方便debug的数据库,这个大家有用过吗?体验如何?pointfreeco/sqlite-data

    3 likes · 91 views
Trending awards, every completed day and week
Gold
01st place finishes
Silver
02nd place finishes
Bronze
13rd place finishes
Awarded repositories
1Owned by pointfreeco now

Repositories

Ordered by gold, then silver, then bronze.

  1. swift-composable-architectureBest: 3rd by stars, 451 stars001

Every award

Top-three finishes of these repositories in completed trending days and weeks, newest first.

  1. #3 by starsDaily trending
    451 stars
    swift-composable-architecture

A repository earns an award for finishing first, second or third in a completed trending day or week, ranked by new stars, new forks or pull request authors. Awards follow a repository through renames and transfers and count toward its current owner. Weeks run Monday to Sunday, UTC.