Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions Examples/DailyPulse/iosApp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ iosApp/

| KMP/iOS design | Example |
| --- | --- |
| Macro-declared SKIE `StateFlow` | `ArticleSKIEExample.swift` and `OwnershipExamples.swift` |
| Demand-driven SKIE `StateFlow` | `ArticleSKIEExample.swift` |
| Explicit eager SKIE fields | `OwnershipExamples.swift` |
| `StateObject`, `ObservedObject`, environment ownership | `OwnershipExamples.swift` |
| Writable Kotlin property as SwiftUI `Binding` | `OwnershipExamples.swift` |
| KMP-NativeCoroutines `NativeFlow` | `NativeCoroutinesExample.swift` |
Expand Down Expand Up @@ -52,5 +53,8 @@ variants. These previews use immutable Swift fixtures and do not initialize
Koin, allocate Kotlin ViewModels, start coroutines, collect flows, or perform
network requests.

There is no generated Swift source or build-tool plugin. Every ViewModel's
typed observation plan is declared locally with `@KMPObservable`.
There is no generated Swift source or build-tool plugin. `ArticleSKIEExample`
uses argument-free `@KMPObservable` and starts its `articleState` collector on
the first `$article.articleState` read. `OwnershipExamples` lists fields
explicitly to demonstrate eager observation. Both modes are compile-time typed
and use the same ownership wrappers.
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,9 @@ import SwiftUI
import shared
import KMPObservableBridgeSKIE

@KMPObservable(
ArticleViewModel.self,
fields: \.articleState
)
// No field list is required. A collector starts when `$article.articleState`
// is first read and is shared by every wrapper observing this ViewModel.
@KMPObservable
extension ArticleViewModel: @retroactive KMPStaticallyObservable {}

struct ArticleInjectorExampleView: View {
Expand All @@ -18,7 +17,7 @@ struct ArticleInjectorExampleView: View {
var body: some View {
NavigationView {
ArticleContentView(viewModel: article)
.navigationTitle("Macro SKIE")
.navigationTitle("Demand-Driven SKIE")
}
}
}
Expand All @@ -31,10 +30,15 @@ private struct ArticleContentView: View {
}

var body: some View {
// Projected access gives the bridge a compile-time key path. It reads
// the authoritative current value directly from Kotlin and lazily
// activates observation for this field—without runtime reflection.
let state = $article.articleState

ArticleListContent(
isLoading: article.articleState.isLoading,
error: article.articleState.error,
articles: article.articleState.articles.map(ArticleRowModel.init)
isLoading: state.isLoading,
error: state.error,
articles: state.articles.map(ArticleRowModel.init)
)
}
}
Expand Down Expand Up @@ -177,7 +181,7 @@ struct ArticleSKIEExampleView_Previews: PreviewProvider {
error: nil,
articles: articles
)
.navigationTitle("Macro SKIE")
.navigationTitle("Demand-Driven SKIE")
}
.previewDisplayName("Articles")

Expand Down
69 changes: 64 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,9 +105,25 @@ import KMPObservableBridgeSKIE
extension SkieSwiftStateFlow: @retroactive KMPValueProperty {}
```

### 3. Declare observable fields
### 3. Enable observation

Place the declaration beside the feature that owns the ViewModel:
For the smallest setup, attach the macro without arguments:

```swift
@KMPObservable
extension ProfileViewModel: @retroactive KMPStaticallyObservable {}
```

Fields are discovered on demand when they are read through the projected
store, for example `$profile.profileState`. Swift supplies the typed key path
to the dynamic-member subscript at compile time; the bridge does not use
reflection or inspect the imported Kotlin declaration. The first read starts
one shared collector for that field, and unread fields allocate no collector.
For `Equatable` StateFlow values, duplicate suppression is seeded from the
exact synchronous value returned to the first body evaluation, so SKIE's
initial replay does not cause a redundant render pass.

For eager observation, list the fields explicitly:

```swift
@KMPObservable(
Expand All @@ -120,9 +136,52 @@ extension ProfileViewModel: @retroactive KMPStaticallyObservable {}
The fields are ordinary Swift key paths. Renaming a Kotlin export or selecting
an incompatible property fails at compile time.

Swift macros cannot inspect members of imported Kotlin classes, so fields must
be listed explicitly. This avoids runtime reflection and build-generated Swift
files.
Swift macros cannot enumerate members of imported Kotlin classes. The
argument-free form solves that limitation through compile-time key paths at
each projected read; the explicit form remains available when observation must
begin before a field is read. Both forms avoid runtime reflection and
build-generated Swift files.

### Demand-driven iOS example

```swift
import SwiftUI
import shared
import KMPObservableBridgeSKIE

@KMPObservable
extension ArticleViewModel: @retroactive KMPStaticallyObservable {}

struct ArticleScreen: View {
@KMPStateObject private var viewModel = ArticleViewModel()

var body: some View {
// The first read creates the typed key path and starts one shared
// collector. Kotlin remains the only current-value storage.
let state = $viewModel.articleState

List(state.articles, id: \.title) { article in
Text(article.title)
}
.overlay {
if state.isLoading {
ProgressView()
}
}
}
}
```

Use projected access for demand-driven StateFlow values:

```swift
let state = $viewModel.articleState // observed current value
```

The leading `$` selects the bridge's projected store; it does not create a
`Binding` for a read-only StateFlow. Writable exported Swift properties still
use the same projected store to produce a native `Binding`, such as
`TextField("Search", text: $viewModel.searchText)`.

### 4. Use native ownership

Expand Down
41 changes: 41 additions & 0 deletions Sources/KMPObservableBridge/Adapters/KMPAsyncSequenceState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,47 @@ public extension KMPState {
asyncSequence(keyPath, changes: { $0 })
}

/// Observes a current-value sequence after seeding duplicate suppression
/// from the value synchronously read by the projected store.
///
/// SKIE StateFlow replays its current value to every new collector. Without
/// this seed, the first projected read would schedule a redundant SwiftUI
/// invalidation for a value that the same body evaluation already used.
internal static func demandEquatable<Sequence>(
_ keyPath: KeyPath<ViewModel, Sequence>,
initialValue: Sequence.Element
) -> Self where
Sequence: AsyncSequence & KMPValueProperty,
Sequence.Element == Sequence.Value,
Sequence.Element: Equatable
{
Self(dependency: .field(keyPath)) { viewModel, notify, reportError in
let source = viewModel[keyPath: keyPath]
let task = Task { @MainActor in
var previous = initialValue

do {
for try await element in source {
try Task.checkCancellation()
guard previous != element else {
continue
}
previous = element
notify()
}
} catch is CancellationError {
// Expected lifecycle termination.
} catch {
reportError(error)
}
}

return KMPObservation {
task.cancel()
}
}
}

/// Invalidates only when a selected value changes.
static func asyncSequence<
Sequence: AsyncSequence,
Expand Down
6 changes: 5 additions & 1 deletion Sources/KMPObservableBridge/Core/KMPObservationSource.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
/// same Kotlin state concurrently.
@MainActor
enum KMPObservationSource<ViewModel: AnyObject> {
case demandDriven
case staticPlan(KMPObservationPlan<ViewModel>)
case keyed(
@MainActor (
Expand All @@ -19,7 +20,10 @@ enum KMPObservationSource<ViewModel: AnyObject> {
func kmpStaticObservationSource<ViewModel: KMPStaticallyObservable>(
for _: ViewModel.Type
) -> KMPObservationSource<ViewModel> {
.keyed { model, notifyDependency, reportError in
guard ViewModel.kmpObservationStrategy == .explicit else {
return .demandDriven
}
return .keyed { model, notifyDependency, reportError in
ViewModel.kmpStartObservation(
on: model,
notifyDependency: notifyDependency,
Expand Down
18 changes: 16 additions & 2 deletions Sources/KMPObservableBridge/Macros/KMPMacros.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,19 @@
/// Generates the static observation conformance for one imported KMP
/// ViewModel.
/// Enables demand-driven observation for an imported KMP ViewModel.
///
/// A collector starts lazily the first time a projected StateFlow property is
/// read and is shared by all stores observing the same model and key path.
///
/// ```swift
/// @KMPObservable
/// extension ProfileViewModel: @retroactive KMPStaticallyObservable {}
/// ```
@attached(member, names: named(kmpObservationStrategy), named(kmpObservationPlan), named(kmpStartObservation))
public macro KMPObservable() = #externalMacro(
module: "KMPObservableBridgeMacros",
type: "KMPObservableMacro"
)

/// Generates an eager static observation plan for an imported KMP ViewModel.
///
/// List the SKIE `StateFlow` properties that drive SwiftUI. The macro expands
/// the concise key paths into statically typed observation routes:
Expand Down
Loading