From 47d1a80f368f85a8c7bfb71bf273b644598270a3 Mon Sep 17 00:00:00 2001 From: sonms Date: Tue, 8 Sep 2026 13:58:29 +0900 Subject: [PATCH 01/21] [Add] Modifier.Node codegen MVP scaffold (KSP) - modifier-node-annotations: @ModifierNodeFactory, @Invalidates, InvalidationScope - modifier-node-processor: KSP + KotlinPoet generator for ModifierNodeElement (create/update/equals/hashCode/inspectableProperties) and public extension fun; invalidation hierarchy folding (Measure > Placement > Draw); var/scope validation - modifier-node-sample: isolated dogfood module with two examples (debugTint draw-only, fixedSquare draw+layout) - ARCHITECTURE.md: scope, non-goals, honest limitations, decision gate Experimental branch; not for master merge until the decision gate passes. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_013UGir6UrorM7Kx7iyYv1ir --- ARCHITECTURE.md | 201 +++++++++++ build.gradle.kts | 2 + gradle/libs.versions.toml | 7 + modifier-node-annotations/build.gradle.kts | 17 + .../modifiernode/annotations/Annotations.kt | 49 +++ modifier-node-processor/build.gradle.kts | 14 + .../processor/ModifierNodeProcessor.kt | 321 ++++++++++++++++++ .../ModifierNodeProcessorProvider.kt | 10 + ...ols.ksp.processing.SymbolProcessorProvider | 1 + modifier-node-sample/build.gradle.kts | 32 ++ .../src/main/AndroidManifest.xml | 1 + .../sonms/modifiernode/sample/SampleNodes.kt | 52 +++ settings.gradle.kts | 3 + 13 files changed, 710 insertions(+) create mode 100644 ARCHITECTURE.md create mode 100644 modifier-node-annotations/build.gradle.kts create mode 100644 modifier-node-annotations/src/main/kotlin/com/sonms/modifiernode/annotations/Annotations.kt create mode 100644 modifier-node-processor/build.gradle.kts create mode 100644 modifier-node-processor/src/main/kotlin/com/sonms/modifiernode/processor/ModifierNodeProcessor.kt create mode 100644 modifier-node-processor/src/main/kotlin/com/sonms/modifiernode/processor/ModifierNodeProcessorProvider.kt create mode 100644 modifier-node-processor/src/main/resources/META-INF/services/com.google.devtools.ksp.processing.SymbolProcessorProvider create mode 100644 modifier-node-sample/build.gradle.kts create mode 100644 modifier-node-sample/src/main/AndroidManifest.xml create mode 100644 modifier-node-sample/src/main/kotlin/com/sonms/modifiernode/sample/SampleNodes.kt diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..87ea221 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,201 @@ +# ARCHITECTURE — Modifier.Node codegen (실험 브랜치) + +> 브랜치: `feat/modifier-node-codegen` +> 상태: MVP 검증 단계. 아래 "결정 게이트"를 통과하기 전까지 `master` 병합 없음. + +--- + +## 1. 문제 + +Compose에서 `Modifier` 확장 함수를 만들 때 지금까지 `Modifier.composed { }` 를 써 왔다. +`composed` 의 구조적 문제: + +- 반환 Modifier가 `equals`/`hashCode` 를 구현하지 않음 → modifier 재사용 비교가 항상 실패 → 불필요한 재구성/무효화 +- 매 재구성마다 람다 re-materialize +- composition 밖에서 사용 불가, `Modifier.Companion` 정적 팩토리로 만들 수 없음 +- AndroidX가 `Modifier.Node` 로 전면 이관 중이며 `composed` 는 deprecate 예고 상태 + +`Modifier.Node` 로 옮기면 위 문제가 해결되지만 **보일러플레이트**가 생긴다: +`ModifierNodeElement` 서브클래스 + `Modifier.Node` 서브클래스 + +`equals` / `hashCode` / `update` / `InspectorInfo` 수동 작성. + +--- + +## 2. 이 프로젝트가 하는 것 + +`@ModifierNodeFactory` 어노테이션이 붙은 **`Modifier.Node` 서브클래스**로부터 +`ModifierNodeElement` 레이어와 공개 확장 함수를 KSP로 생성한다. + +> 설계 변경 (초안 대비): 별도 `NodeBase` 를 생성하고 사용자가 상속하는 대신, +> **사용자가 concrete Node 클래스를 직접 작성하고 거기에 어노테이션을 단다.** +> 파라미터의 단일 원천이 Node의 주 생성자(`var` 프로퍼티 파라미터)가 되어 +> 이름 규약/동기화 문제가 사라진다. `create` 는 생성자 호출, `update` 는 `var` 재대입. + +```kotlin +@ModifierNodeFactory(name = "shimmer") +internal class ShimmerNode( + @Invalidates(Draw) var color: Color, + @Invalidates(Measure) var thickness: Dp, + var durationMillis: Int, +) : Modifier.Node(), DrawModifierNode { + override fun ContentDrawScope.draw() { /* color, thickness 사용 */ } +} +``` + +생성물 (`ShimmerElement.kt` 한 파일): + +- `internal fun Modifier.shimmer(color, thickness, durationMillis): Modifier = this.then(ShimmerElement(...))` +- `internal class ShimmerElement : ModifierNodeElement()` — `create` / `update` / `equals` / `hashCode` / `inspectableProperties` + +사용자가 직접 작성하는 것: + +- Node 클래스의 그리기/측정/포인터 **행동 코드만** + +--- + +## 3. 만드는 이유 (가치 명제) + +InspectorInfo/`update` 자동화만 놓고 보면 절약량은 파일당 10~20줄이며 +data class Element 로도 대충 흉내 낼 수 있다. **그 자체는 프로젝트의 이유가 아니다.** + +실제 이유는 두 가지다: + +### 3.1 컴파일 타임에 검증되는 invalidation 계약 + +- `@Invalidates(Measure)` 인데 노드가 `LayoutModifierNode` 를 구현하지 않으면 KSP 에러 +- 파라미터가 바뀌었을 때 어떤 invalidate가 호출되는지 생성 코드 + 테스트 헬퍼로 고정 +- 손으로 짤 때 흔한 두 실수를 원천 차단: + - 과잉: 무엇이 바뀌든 `invalidateMeasurement()` + - 누락: 필드 대입만 하고 invalidate 안 함 + +생성되는 `update` 형태: + +```kotlin +override fun update(node: ShimmerNode) { + var redraw = false; var remeasure = false + if (node.color != color) { node.color = color; redraw = true } + if (node.thickness != thickness) { node.thickness = thickness; remeasure = true } + if (node.durationMillis != durationMillis) node.durationMillis = durationMillis + if (remeasure) node.invalidateMeasurement() // 계층상 redraw 포함 + else if (redraw) node.invalidateDraw() +} +``` + +invalidation 계층: `Measure` > `Placement` > `Draw` (상위 하나만 호출), +`Semantics` / `ParentData` 는 직교(별도 호출). + +무지정 파라미터 기본 정책: **안전 우선** — 노드가 구현한 모든 capability를 invalidate. +`@Invalidates(...)` 로 좁히고, `@Invalidates(None)` 으로 완전히 뺀다 +(create 시점에만 쓰거나 `observeReads` 로 반응적으로 읽는 값). + +### 3.2 바이너리 호환성 (라이브러리 저자용) + +- Element/Node 클래스를 `internal` 로 감추고 `Modifier.foo(...)` 함수만 공개 + → 내부 리팩터가 ABI를 깨지 않음, `.api` diff가 예측 가능 +- (opt-in) `@ModifierNodeFactory(binaryCompat = true)`: + Kotlin default argument 대신 명시적 오버로드 체인 생성 + → 파라미터 추가 = 심볼 추가, 기존 심볼 유지 + +--- + +## 4. 비목표 (Non-goals) + +- **`composed` → `Node` 자동 변환기.** `composed` 블록은 임의의 `@Composable` 코드를 + 담을 수 있어(서브 컴포지션, 복잡한 `remember` 키, effect 의존성) 범용 기계 변환이 불가능하다. +- **노드 행동 코드 생성.** 그리기/측정/포인터 로직은 사용자가 직접 쓴다. + codegen은 Element 레이어 + 필드 선언까지만. +- **런타임 람다 DSL** (`Modifier.node { onDraw { } }` 류). 람다 캡처 → `equals` 불안정 → + `composed` 문제 재발. +- **범용 OSS로의 확장.** 아래 "포지셔닝" 참고. + +--- + +## 5. 포지셔닝 / 솔직한 한계 + +- **니치가 좁다.** `Modifier.Node` 를 직접 작성하는 사람은 라이브러리 저자 / 디자인시스템 + 팀 정도이며, 그중 이 codegen을 dependency로 받을 사람은 더 적다. + → 이 프로젝트는 **Velvet-Compose 내부 인프라 + `Modifier.Node` 학습 + 포트폴리오**가 목적이다. + 공개는 하되 "많은 사용자" 목표는 없다. +- **절약량이 크지 않다.** trivial한 부분(InspectorInfo/update 대입)만 보면 파일당 10~20줄. + 가치는 3.1 / 3.2 에 있고 거기서 안 나오면 접는다. +- **`Modifier.Node` API 안정성.** 코어(`ModifierNodeElement`, `Modifier.Node`, + `DrawModifierNode`, `LayoutModifierNode`, `invalidateDraw/Measurement/Placement`)는 + 현행 Compose에서 stable. MVP 착수 시 실제 사용 API 목록을 뽑아 각각 + stable/experimental 태그를 확인하고, experimental이 섞이면 그 부분만 opt-in으로 격리한다. +- **1인 유지보수.** SLA 없음. 이슈 생기면 대응, API 호환성 보장 안 함. README에 명시. + +--- + +## 6. MVP 스코프 + +- `@ModifierNodeFactory(name)` (Node 클래스에 부착) + `@Invalidates(vararg Scope)` (생성자 파라미터에 부착) + (`Scope = Measure | Placement | Draw | Semantics | ParentData | None`) +- KSP 프로세서: + - `Element` 생성: `create` / `update` / `equals` / `hashCode` / `inspectableProperties` + - 공개 확장 함수 생성 + - 검증: `@Invalidates` scope ↔ 노드가 구현한 인터페이스 정합성, `var` 파라미터 강제 +- 지원 노드 인터페이스 **2개만**: `DrawModifierNode`, `LayoutModifierNode` +- invalidation 계층 접기 로직 (`Measure` > `Placement` > `Draw`) +- 테스트 헬퍼 1개: "파라미터 P를 바꾸면 `invalidateDraw` 만 호출되고 `invalidateMeasurement` 는 + 호출되지 않는다" 를 검증 ← **아직 미작성** +- 모듈: `:modifier-node-annotations`, `:modifier-node-processor`, `:modifier-node-sample` +- **Dogfooding: `:modifier-node-sample` 안에서 `fadingEdge` 재작성.** + ratingbar / wheelpicker 컴포넌트 본체에 이 codegen dependency를 연결하지 않는다 + (의존성이 실험 모듈에 몰리는 것 방지). `fadingEdge` 는 sample 모듈 격리 예제로만 사용. + ※ `fadingEdge` 는 `graphicsLayer { compositingStrategy = Offscreen }` 를 필요로 하므로 + 순수 `DrawModifierNode` 로는 완전 재현 불가 — GraphicsLayer 위임이 필요하고 이는 + 결정 게이트에서 "노드 행동 코드는 codegen이 안 도와준다" 를 실증하는 좋은 케이스. + +### 진행 상태 (2026-09-08) + +- [x] 모듈 3개 스캐폴딩, 카탈로그/settings 배선 +- [x] 어노테이션 정의 +- [x] 프로세서: Element + 확장 함수 생성, invalidation 계층 접기, `var`/scope 검증 +- [x] `:modifier-node-sample` 예제 2개(`debugTint` draw 전용, `fixedSquare` draw+layout) 빌드/코드젠 확인 +- [ ] invalidation 계약 테스트 헬퍼 +- [ ] `fadingEdge` 재작성 +- [ ] 기본값(default argument) 처리 — 현재 미지원, 호출부에서 전 인자 전달 필요 + +### 한계 (MVP 구현상) + +- 생성 함수에 default argument 재현 불가 (KSP가 기본값 표현식을 못 읽음). 전 인자 필수. +- 제네릭 Node, 다중 타입 파라미터 미검증. +- `equals`/`hashCode` 는 참조형 파라미터의 `equals` 안정성에 의존 (람다/불안정 타입 넣으면 그대로 깨짐 — 이는 사용자 책임). + +### 범위 밖 (MVP 이후) + +- 노드 인터페이스 확장 (`PointerInputModifierNode`, `SemanticsModifierNode`, `GlobalPositionAwareModifierNode` ...) +- `binaryCompat = true` 오버로드 체인 +- Element/Node `internal` 은닉 옵션 +- IDE inspection / quick-fix + +--- + +## 7. 결정 게이트 + +MVP 완료 후 아래를 근거로 **계속 / 중단**을 판단한다. + +**계속 조건 (아래를 모두 만족):** + +1. 재작성한 `fadingEdge` 가 원본보다 읽기 쉽거나 최소한 동등하다 (보일러플레이트가 + 실제로 사라졌고, 노드 행동 코드는 그대로 명확하다). +2. `@Invalidates` invalidation 계약이 **손으로 짤 때보다 명확한 이점**을 준다: + - scope ↔ 인터페이스 불일치를 실제로 컴파일 타임에 잡아낸다 + - 테스트 헬퍼로 "이 파라미터는 redraw만" 을 고정할 수 있고, 그게 회귀 방지에 유용하다고 느껴진다 +3. KSP 프로세서 구현/디버깅 비용이 감당 가능한 수준이었다 (며칠 단위가 아니라 시간 단위). + +**중단 판단 (아래 중 하나라도):** + +- 재작성 결과가 원본과 비슷하거나 더 장황하다. +- invalidation 계약이 "있으면 좋지만 없어도 그만" 수준이고, 검증 에러가 실전에서 안 걸린다. +- KSP 쪽에서 예상 못 한 제약(타입 해석, 제네릭 노드, cross-module 가시성)으로 스코프가 계속 샌다. + +중단 시: `Modifier.Node` / KSP 를 저비용으로 학습한 것으로 간주하고 브랜치를 아카이브한다. + +--- + +## 8. 참고 + +- 프로젝트: Kotlin 2.0.21, AGP 8.13.2, Compose BOM 2026.04.01 +- KSP 버전은 Kotlin 2.0.21 에 정렬 (`2.0.21-1.0.x`) +- 첫 dogfooding 대상: `wheelpicker/src/main/java/com/sonms/wheelpicker/extension/ModifierExt.kt` diff --git a/build.gradle.kts b/build.gradle.kts index 0024322..61401fe 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -5,4 +5,6 @@ plugins { alias(libs.plugins.kotlin.compose) apply false alias(libs.plugins.android.library) apply false alias(libs.plugins.dokka) apply false + alias(libs.plugins.kotlin.jvm) apply false + alias(libs.plugins.ksp) apply false } \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index adc8aa2..6c36e5b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -13,6 +13,8 @@ vanniktech = "0.36.0" dokka = "2.2.0" appcompat = "1.7.1" material = "1.13.0" +ksp = "2.0.21-1.0.28" +kotlinpoet = "1.18.1" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -33,11 +35,16 @@ androidx-compose-material3 = { group = "androidx.compose.material3", name = "mat kotlinx-collections-immutable = { group = "org.jetbrains.kotlinx", name = "kotlinx-collections-immutable", version.ref = "kotlinxCollectionsImmutable" } androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } material = { group = "com.google.android.material", name = "material", version.ref = "material" } +ksp-symbol-processing-api = { group = "com.google.devtools.ksp", name = "symbol-processing-api", version.ref = "ksp" } +kotlinpoet = { group = "com.squareup", name = "kotlinpoet", version.ref = "kotlinpoet" } +kotlinpoet-ksp = { group = "com.squareup", name = "kotlinpoet-ksp", version.ref = "kotlinpoet" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } android-library = { id = "com.android.library", version.ref = "agp" } +kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } +ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } vanniktech = { id = "com.vanniktech.maven.publish", version.ref = "vanniktech" } dokka = { id = "org.jetbrains.dokka", version.ref = "dokka" } \ No newline at end of file diff --git a/modifier-node-annotations/build.gradle.kts b/modifier-node-annotations/build.gradle.kts new file mode 100644 index 0000000..c9211df --- /dev/null +++ b/modifier-node-annotations/build.gradle.kts @@ -0,0 +1,17 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + alias(libs.plugins.kotlin.jvm) +} + +// Consumed by Android library modules that target JVM 11. +java { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 +} + +kotlin { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_11) + } +} diff --git a/modifier-node-annotations/src/main/kotlin/com/sonms/modifiernode/annotations/Annotations.kt b/modifier-node-annotations/src/main/kotlin/com/sonms/modifiernode/annotations/Annotations.kt new file mode 100644 index 0000000..dfedabc --- /dev/null +++ b/modifier-node-annotations/src/main/kotlin/com/sonms/modifiernode/annotations/Annotations.kt @@ -0,0 +1,49 @@ +package com.sonms.modifiernode.annotations + +/** + * `Modifier.Node` 팩토리 생성 대상 표시. + * + * 이 어노테이션이 붙은 `Modifier.Node` 서브클래스로부터 KSP가 다음을 생성한다. + * - `Element : ModifierNodeElement` (create / update / equals / hashCode / inspectableProperties) + * - `fun Modifier.(...): Modifier` 공개 확장 함수 + * + * 생성 규칙: + * - 파라미터 원천은 **주 생성자**다. 모든 추적 대상 파라미터는 `var` 프로퍼티 파라미터여야 한다. + * - 파라미터별 무효화 범위는 [Invalidates] 로 지정한다. 미지정 시 노드가 구현한 모든 범위를 무효화한다(안전 기본값). + * + * MVP 한계: + * - 기본값(default argument)은 생성 함수에 재현되지 않는다. 호출부에서 모든 인자를 전달해야 한다. + * - 지원 노드 인터페이스는 `DrawModifierNode`, `LayoutModifierNode` 뿐이다. + */ +@Target(AnnotationTarget.CLASS) +@Retention(AnnotationRetention.SOURCE) +annotation class ModifierNodeFactory( + /** 생성될 확장 함수 이름. 비우면 `` 에서 `Node` 접미사를 떼고 첫 글자를 소문자로 바꿔 유도한다. */ + val name: String = "", +) + +/** + * 이 파라미터가 바뀌었을 때 트리거할 무효화 범위. + * + * 여러 범위를 지정할 수 있다. 예: `@Invalidates(Draw, Semantics)`. + * [InvalidationScope.None] 은 어떤 무효화도 하지 않음을 뜻한다(생성 시점에만 쓰이거나 + * `observeReads` 로 반응적으로 읽는 값). + */ +@Target(AnnotationTarget.VALUE_PARAMETER) +@Retention(AnnotationRetention.SOURCE) +annotation class Invalidates( + vararg val scopes: InvalidationScope, +) + +/** + * 무효화 범위. 계층: [Measure] > [Placement] > [Draw] (상위 하나만 호출). + * [Semantics], [ParentData] 는 직교(별도 호출). [None] 은 무효화 없음. + */ +enum class InvalidationScope { + Measure, + Placement, + Draw, + Semantics, + ParentData, + None, +} diff --git a/modifier-node-processor/build.gradle.kts b/modifier-node-processor/build.gradle.kts new file mode 100644 index 0000000..e3d8a3e --- /dev/null +++ b/modifier-node-processor/build.gradle.kts @@ -0,0 +1,14 @@ +plugins { + alias(libs.plugins.kotlin.jvm) +} + +kotlin { + jvmToolchain(17) +} + +dependencies { + implementation(project(":modifier-node-annotations")) + implementation(libs.ksp.symbol.processing.api) + implementation(libs.kotlinpoet) + implementation(libs.kotlinpoet.ksp) +} diff --git a/modifier-node-processor/src/main/kotlin/com/sonms/modifiernode/processor/ModifierNodeProcessor.kt b/modifier-node-processor/src/main/kotlin/com/sonms/modifiernode/processor/ModifierNodeProcessor.kt new file mode 100644 index 0000000..d7d7240 --- /dev/null +++ b/modifier-node-processor/src/main/kotlin/com/sonms/modifiernode/processor/ModifierNodeProcessor.kt @@ -0,0 +1,321 @@ +package com.sonms.modifiernode.processor + +import com.google.devtools.ksp.getAllSuperTypes +import com.google.devtools.ksp.processing.CodeGenerator +import com.google.devtools.ksp.processing.KSPLogger +import com.google.devtools.ksp.processing.Resolver +import com.google.devtools.ksp.processing.SymbolProcessor +import com.google.devtools.ksp.symbol.KSAnnotated +import com.google.devtools.ksp.symbol.KSAnnotation +import com.google.devtools.ksp.symbol.KSClassDeclaration +import com.google.devtools.ksp.symbol.KSType +import com.google.devtools.ksp.symbol.KSValueParameter +import com.google.devtools.ksp.validate +import com.squareup.kotlinpoet.ClassName +import com.squareup.kotlinpoet.FileSpec +import com.squareup.kotlinpoet.FunSpec +import com.squareup.kotlinpoet.KModifier +import com.squareup.kotlinpoet.MemberName +import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy +import com.squareup.kotlinpoet.PropertySpec +import com.squareup.kotlinpoet.TypeSpec +import com.squareup.kotlinpoet.ksp.toTypeName +import com.squareup.kotlinpoet.ksp.writeTo + +private const val FACTORY_ANNOTATION = "com.sonms.modifiernode.annotations.ModifierNodeFactory" + +private val MODIFIER = ClassName("androidx.compose.ui", "Modifier") +private val MODIFIER_NODE_ELEMENT = ClassName("androidx.compose.ui.node", "ModifierNodeElement") +private val INSPECTOR_INFO = ClassName("androidx.compose.ui.platform", "InspectorInfo") +private val INVALIDATE_DRAW = MemberName("androidx.compose.ui.node", "invalidateDraw") +private val INVALIDATE_MEASUREMENT = MemberName("androidx.compose.ui.node", "invalidateMeasurement") +private val INVALIDATE_PLACEMENT = MemberName("androidx.compose.ui.node", "invalidatePlacement") + +private const val DRAW_MODIFIER_NODE = "androidx.compose.ui.node.DrawModifierNode" +private const val LAYOUT_MODIFIER_NODE = "androidx.compose.ui.node.LayoutModifierNode" + +/** 무효화 범위. 처리기 내부 표현. */ +private enum class Scope { Measure, Placement, Draw, Semantics, ParentData, None } + +class ModifierNodeProcessor( + private val codeGenerator: CodeGenerator, + private val logger: KSPLogger, +) : SymbolProcessor { + + override fun process(resolver: Resolver): List { + val symbols = resolver.getSymbolsWithAnnotation(FACTORY_ANNOTATION).toList() + val deferred = symbols.filterNot { it.validate() } + + symbols.filter { it.validate() } + .filterIsInstance() + .forEach { runCatching { generate(it) }.onFailure { e -> logger.error("codegen failed: ${e.message}", it) } } + + return deferred + } + + private fun generate(node: KSClassDeclaration) { + val pkg = node.packageName.asString() + val nodeName = node.simpleName.asString() + val nodeClass = ClassName(pkg, nodeName) + + val factoryAnn = node.annotations.first { it.shortName.asString() == "ModifierNodeFactory" } + val explicitName = (factoryAnn.argValue("name") as? String).orEmpty() + val funName = explicitName.ifBlank { + nodeName.removeSuffix("Node").replaceFirstChar { it.lowercase() } + } + val elementName = "${nodeName.removeSuffix("Node")}Element" + val elementClass = ClassName(pkg, elementName) + + val visibility = if (node.isPublic()) KModifier.PUBLIC else KModifier.INTERNAL + + val ctor = node.primaryConstructor + if (ctor == null) { + logger.error("@ModifierNodeFactory requires a primary constructor", node) + return + } + + val supported = node.supportedScopes() + if (supported.isEmpty()) { + logger.error( + "@ModifierNodeFactory node must implement DrawModifierNode and/or LayoutModifierNode (MVP)", + node, + ) + return + } + + val params = ctor.parameters.map { p -> resolveParam(p, node, supported) } + + // --- 확장 함수: fun Modifier.(...): Modifier = this.then(Element(...)) --- + val extFun = FunSpec.builder(funName) + .addModifiers(visibility) + .receiver(MODIFIER) + .returns(MODIFIER) + .apply { params.forEach { addParameter(it.name, it.type) } } + .addStatement( + "return this.then(%T(%L))", + elementClass, + params.joinToString(", ") { it.name }, + ) + .build() + + // --- Element 클래스 --- + val elementType = TypeSpec.classBuilder(elementClass) + .addModifiers(visibility) + .superclass(MODIFIER_NODE_ELEMENT.parameterizedBy(nodeClass)) + .primaryConstructor( + FunSpec.constructorBuilder() + .apply { params.forEach { addParameter(it.name, it.type) } } + .build(), + ) + .apply { + params.forEach { + addProperty( + PropertySpec.builder(it.name, it.type, KModifier.PRIVATE) + .initializer(it.name) + .build(), + ) + } + } + .addFunction(createFun(nodeClass, params)) + .addFunction(updateFun(nodeClass, params)) + .addFunction(inspectableFun(funName, params)) + .addFunction(equalsFun(elementClass, params)) + .addFunction(hashCodeFun(params)) + .build() + + FileSpec.builder(pkg, elementName) + .addFunction(extFun) + .addType(elementType) + .build() + .writeTo(codeGenerator, aggregating = false, originatingKSFiles = listOfNotNull(node.containingFile)) + } + + private data class Param( + val name: String, + val type: com.squareup.kotlinpoet.TypeName, + val scopes: Set, + ) + + private fun resolveParam(p: KSValueParameter, node: KSClassDeclaration, supported: Set): Param { + val name = p.name!!.asString() + if (!p.isVar) { + logger.error("Parameter '$name' must be a 'var' property parameter", p) + } + val invAnn = p.annotations.firstOrNull { it.shortName.asString() == "Invalidates" } + val scopes: Set = when { + invAnn == null -> supported // 안전 기본값 + else -> { + val declared = invAnn.enumArgNames("scopes").mapNotNull { runCatching { Scope.valueOf(it) }.getOrNull() }.toSet() + when { + Scope.None in declared -> emptySet() + else -> { + declared.forEach { s -> + val ok = when (s) { + Scope.Draw -> Scope.Draw in supported + Scope.Measure, Scope.Placement -> Scope.Measure in supported + Scope.Semantics, Scope.ParentData -> + false.also { logger.error("@Invalidates($s) not supported yet (MVP)", p) } + Scope.None -> true + } + if (!ok && s != Scope.Semantics && s != Scope.ParentData) { + logger.error( + "@Invalidates($s) on '$name' but ${node.simpleName.asString()} does not implement the matching node interface", + p, + ) + } + } + declared + } + } + } + } + return Param(name, p.type.toTypeName(), scopes) + } + + private fun createFun(nodeClass: ClassName, params: List) = FunSpec.builder("create") + .addModifiers(KModifier.OVERRIDE) + .returns(nodeClass) + .addStatement("return %T(%L)", nodeClass, params.joinToString(", ") { it.name }) + .build() + + private fun updateFun(nodeClass: ClassName, params: List): FunSpec { + val b = FunSpec.builder("update") + .addModifiers(KModifier.OVERRIDE) + .addParameter("node", nodeClass) + + val anyDraw = params.any { Scope.Draw in it.scopes } + val anyMeasure = params.any { Scope.Measure in it.scopes } + val anyPlacement = params.any { Scope.Placement in it.scopes && Scope.Measure !in it.scopes } + + if (anyDraw) b.addStatement("var redraw = false") + if (anyMeasure) b.addStatement("var remeasure = false") + if (anyPlacement) b.addStatement("var replace = false") + + params.forEach { p -> + if (p.scopes.isEmpty()) { + b.addStatement("if (node.%N != %N) node.%N = %N", p.name, p.name, p.name, p.name) + } else { + b.beginControlFlow("if (node.%N != %N)", p.name, p.name) + b.addStatement("node.%N = %N", p.name, p.name) + if (Scope.Draw in p.scopes) b.addStatement("redraw = true") + if (Scope.Measure in p.scopes) b.addStatement("remeasure = true") + else if (Scope.Placement in p.scopes) b.addStatement("replace = true") + b.endControlFlow() + } + } + + // 계층: Measure > Placement > Draw. 상위 하나만 호출. + when { + anyMeasure && (anyPlacement || anyDraw) -> { + b.beginControlFlow("if (remeasure)") + b.addStatement("node.%M()", INVALIDATE_MEASUREMENT) + if (anyPlacement) { + b.nextControlFlow("else if (replace)") + b.addStatement("node.%M()", INVALIDATE_PLACEMENT) + } + if (anyDraw) { + b.nextControlFlow("else if (redraw)") + b.addStatement("node.%M()", INVALIDATE_DRAW) + } + b.endControlFlow() + } + anyMeasure -> { + b.beginControlFlow("if (remeasure)") + b.addStatement("node.%M()", INVALIDATE_MEASUREMENT) + b.endControlFlow() + } + anyPlacement && anyDraw -> { + b.beginControlFlow("if (replace)") + b.addStatement("node.%M()", INVALIDATE_PLACEMENT) + b.nextControlFlow("else if (redraw)") + b.addStatement("node.%M()", INVALIDATE_DRAW) + b.endControlFlow() + } + anyPlacement -> { + b.beginControlFlow("if (replace)") + b.addStatement("node.%M()", INVALIDATE_PLACEMENT) + b.endControlFlow() + } + anyDraw -> { + b.beginControlFlow("if (redraw)") + b.addStatement("node.%M()", INVALIDATE_DRAW) + b.endControlFlow() + } + } + return b.build() + } + + private fun inspectableFun(name: String, params: List) = FunSpec.builder("inspectableProperties") + .addModifiers(KModifier.OVERRIDE) + .receiver(INSPECTOR_INFO) + .addStatement("name = %S", name) + .apply { params.forEach { addStatement("properties[%S] = %N", it.name, it.name) } } + .build() + + private fun equalsFun(elementClass: ClassName, params: List): FunSpec { + val b = FunSpec.builder("equals") + .addModifiers(KModifier.OVERRIDE) + .addParameter("other", ANY_NULLABLE) + .returns(Boolean::class) + .addStatement("if (this === other) return true") + .addStatement("if (other !is %T) return false", elementClass) + params.forEach { b.addStatement("if (%N != other.%N) return false", it.name, it.name) } + b.addStatement("return true") + return b.build() + } + + private fun hashCodeFun(params: List): FunSpec { + val b = FunSpec.builder("hashCode") + .addModifiers(KModifier.OVERRIDE) + .returns(Int::class) + if (params.isEmpty()) { + b.addStatement("return %T::class.hashCode()", MODIFIER) + return b.build() + } + b.addStatement("var result = %N.hashCode()", params.first().name) + params.drop(1).forEach { b.addStatement("result = 31 * result + %N.hashCode()", it.name) } + b.addStatement("return result") + return b.build() + } + + private fun KSClassDeclaration.supportedScopes(): Set { + val supers = (listOf(this) + getAllSuperTypes().mapNotNull { it.declaration as? KSClassDeclaration }) + .mapNotNull { it.qualifiedName?.asString() } + .toSet() + return buildSet { + if (DRAW_MODIFIER_NODE in supers) add(Scope.Draw) + if (LAYOUT_MODIFIER_NODE in supers) { + add(Scope.Measure) + add(Scope.Placement) + } + } + } + + private fun KSClassDeclaration.isPublic(): Boolean = + modifiers.none { it.name == "INTERNAL" || it.name == "PRIVATE" || it.name == "PROTECTED" } + + companion object { + private val ANY_NULLABLE = ClassName("kotlin", "Any").copy(nullable = true) + } +} + +/** 이름 있는 인자 값 조회. */ +private fun KSAnnotation.argValue(name: String): Any? = + arguments.firstOrNull { it.name?.asString() == name }?.value + +/** enum vararg 인자를 엔트리 simpleName 리스트로. */ +private fun KSAnnotation.enumArgNames(name: String): List { + val raw = argValue(name) ?: return emptyList() + val list = when (raw) { + is List<*> -> raw + is Array<*> -> raw.toList() + else -> listOf(raw) + } + return list.mapNotNull { entry -> + when (entry) { + is KSType -> entry.declaration.simpleName.asString() + is KSClassDeclaration -> entry.simpleName.asString() + else -> entry?.toString()?.substringAfterLast('.') + } + } +} diff --git a/modifier-node-processor/src/main/kotlin/com/sonms/modifiernode/processor/ModifierNodeProcessorProvider.kt b/modifier-node-processor/src/main/kotlin/com/sonms/modifiernode/processor/ModifierNodeProcessorProvider.kt new file mode 100644 index 0000000..b813095 --- /dev/null +++ b/modifier-node-processor/src/main/kotlin/com/sonms/modifiernode/processor/ModifierNodeProcessorProvider.kt @@ -0,0 +1,10 @@ +package com.sonms.modifiernode.processor + +import com.google.devtools.ksp.processing.SymbolProcessor +import com.google.devtools.ksp.processing.SymbolProcessorEnvironment +import com.google.devtools.ksp.processing.SymbolProcessorProvider + +class ModifierNodeProcessorProvider : SymbolProcessorProvider { + override fun create(environment: SymbolProcessorEnvironment): SymbolProcessor = + ModifierNodeProcessor(environment.codeGenerator, environment.logger) +} diff --git a/modifier-node-processor/src/main/resources/META-INF/services/com.google.devtools.ksp.processing.SymbolProcessorProvider b/modifier-node-processor/src/main/resources/META-INF/services/com.google.devtools.ksp.processing.SymbolProcessorProvider new file mode 100644 index 0000000..cf9f1f5 --- /dev/null +++ b/modifier-node-processor/src/main/resources/META-INF/services/com.google.devtools.ksp.processing.SymbolProcessorProvider @@ -0,0 +1 @@ +com.sonms.modifiernode.processor.ModifierNodeProcessorProvider diff --git a/modifier-node-sample/build.gradle.kts b/modifier-node-sample/build.gradle.kts new file mode 100644 index 0000000..a229409 --- /dev/null +++ b/modifier-node-sample/build.gradle.kts @@ -0,0 +1,32 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.ksp) +} + +android { + namespace = "com.sonms.modifiernode.sample" + compileSdk = 36 + + defaultConfig { + minSdk = 24 + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + kotlinOptions { + jvmTarget = "11" + } +} + +dependencies { + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.foundation) + + implementation(project(":modifier-node-annotations")) + ksp(project(":modifier-node-processor")) +} diff --git a/modifier-node-sample/src/main/AndroidManifest.xml b/modifier-node-sample/src/main/AndroidManifest.xml new file mode 100644 index 0000000..94cbbcf --- /dev/null +++ b/modifier-node-sample/src/main/AndroidManifest.xml @@ -0,0 +1 @@ + diff --git a/modifier-node-sample/src/main/kotlin/com/sonms/modifiernode/sample/SampleNodes.kt b/modifier-node-sample/src/main/kotlin/com/sonms/modifiernode/sample/SampleNodes.kt new file mode 100644 index 0000000..5d24a3c --- /dev/null +++ b/modifier-node-sample/src/main/kotlin/com/sonms/modifiernode/sample/SampleNodes.kt @@ -0,0 +1,52 @@ +package com.sonms.modifiernode.sample + +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.ContentDrawScope +import androidx.compose.ui.layout.Measurable +import androidx.compose.ui.layout.MeasureResult +import androidx.compose.ui.layout.MeasureScope +import androidx.compose.ui.node.DrawModifierNode +import androidx.compose.ui.node.LayoutModifierNode +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.Dp +import com.sonms.modifiernode.annotations.Invalidates +import com.sonms.modifiernode.annotations.InvalidationScope.Draw +import com.sonms.modifiernode.annotations.InvalidationScope.Measure +import com.sonms.modifiernode.annotations.ModifierNodeFactory + +/** + * 예제 1 — draw 전용 노드. + * 생성물: `DebugTintElement`, `fun Modifier.debugTint(color: Color): Modifier` + */ +@ModifierNodeFactory(name = "debugTint") +internal class DebugTintNode( + @Invalidates(Draw) var color: Color, +) : Modifier.Node(), DrawModifierNode { + override fun ContentDrawScope.draw() { + drawContent() + drawRect(color) + } +} + +/** + * 예제 2 — draw + layout 노드. update 의 remeasure/redraw 분기를 검증하기 위한 케이스. + * 생성물: `FixedSquareElement`, `fun Modifier.fixedSquare(side: Dp, overlay: Color): Modifier` + */ +@ModifierNodeFactory(name = "fixedSquare") +internal class FixedSquareNode( + @Invalidates(Measure) var side: Dp, + @Invalidates(Draw) var overlay: Color, +) : Modifier.Node(), DrawModifierNode, LayoutModifierNode { + + override fun MeasureScope.measure(measurable: Measurable, constraints: Constraints): MeasureResult { + val px = side.roundToPx() + val placeable = measurable.measure(Constraints.fixed(px, px)) + return layout(px, px) { placeable.place(0, 0) } + } + + override fun ContentDrawScope.draw() { + drawContent() + drawRect(overlay) + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index efe9548..dadf91f 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -23,3 +23,6 @@ rootProject.name = "VelvetCompose" include(":app") include(":wheelpicker") include(":ratingbar") +include(":modifier-node-annotations") +include(":modifier-node-processor") +include(":modifier-node-sample") From ba19d607c090db55bab0f177d7cb6f073d630959 Mon Sep 17 00:00:00 2001 From: sonms Date: Tue, 8 Sep 2026 14:00:42 +0900 Subject: [PATCH 02/21] [Add] Dogfood fadingEdge as Modifier.Node; record findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FadingEdgeNode: graphicsLayer+drawWithCache chain rewritten as a raw DrawModifierNode with manual GraphicsLayer lifecycle - ARCHITECTURE.md §9: LOC comparison + findings - fadingEdge never used composed; lowering a composition-free chain to a raw node is a net loss (lost drawWithCache caching, manual layer lifecycle) - codegen value is only in cases that would otherwise use composed - conditional application (enabled=false early return) not expressible today - decision-gate condition 1 unmet, but target was mis-chosen; needs a real composed-style case next Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_013UGir6UrorM7Kx7iyYv1ir --- ARCHITECTURE.md | 41 +++++++++- .../sonms/modifiernode/sample/FadingEdge.kt | 80 +++++++++++++++++++ 2 files changed, 120 insertions(+), 1 deletion(-) create mode 100644 modifier-node-sample/src/main/kotlin/com/sonms/modifiernode/sample/FadingEdge.kt diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 87ea221..6cbe785 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -152,8 +152,8 @@ invalidation 계층: `Measure` > `Placement` > `Draw` (상위 하나만 호출), - [x] 어노테이션 정의 - [x] 프로세서: Element + 확장 함수 생성, invalidation 계층 접기, `var`/scope 검증 - [x] `:modifier-node-sample` 예제 2개(`debugTint` draw 전용, `fixedSquare` draw+layout) 빌드/코드젠 확인 +- [x] `fadingEdge` 재작성 (§9 참고) - [ ] invalidation 계약 테스트 헬퍼 -- [ ] `fadingEdge` 재작성 - [ ] 기본값(default argument) 처리 — 현재 미지원, 호출부에서 전 인자 전달 필요 ### 한계 (MVP 구현상) @@ -199,3 +199,42 @@ MVP 완료 후 아래를 근거로 **계속 / 중단**을 판단한다. - 프로젝트: Kotlin 2.0.21, AGP 8.13.2, Compose BOM 2026.04.01 - KSP 버전은 Kotlin 2.0.21 에 정렬 (`2.0.21-1.0.x`) - 첫 dogfooding 대상: `wheelpicker/src/main/java/com/sonms/wheelpicker/extension/ModifierExt.kt` + +--- + +## 9. Dogfooding 결과 — `fadingEdge` + +`modifier-node-sample/.../FadingEdge.kt` (`FadingEdgeNode`) + 생성된 `FadingEdgeElement`. + +### LOC + +| | 원본 | Node 판 | +|---|---|---| +| 손으로 쓴 코드 | 함수 1개, 로직 ~30줄 | `FadingEdgeNode` ~55줄 (lifecycle 포함) | +| 생성 코드 | 0 | `FadingEdgeElement` ~65줄 (무료) | +| **총 손 코드** | **~30줄** | **~55줄** | + +손으로 쓴 코드가 오히려 늘었다. + +### 핵심 발견 (결정 게이트에 반영) + +1. **`fadingEdge` 는 애초에 `composed` 를 안 썼다.** 원본은 + `graphicsLayer { }.drawWithCache { }` 체인 — 둘 다 이미 `Modifier.Node` 기반이고 + `equals` 도 제대로 되는 modifier다. 이걸 raw 노드로 내리면: + - `GraphicsLayer` lifecycle 을 수동 관리 (`onAttach`/`onDetach`) + - `drawWithCache` 의 브러시 캐싱을 잃음 (record 블록에서 매번 재계산) + - → **순 손해.** +2. 즉 이 codegen 의 가치는 **"원래라면 `composed` 를 썼을 상황"** 에서만 나온다: + CompositionLocal 읽기, `InteractionSource`, 코루틴 기반 애니메이션을 modifier 안에서 다룰 때. + composition-free modifier 체인을 raw 노드로 재작성하는 건 안티패턴. +3. **조건부 적용 표현 불가.** 원본의 `enabled=false` → early return (modifier 미적용). + 현재 codegen 은 `this.then(Element(...))` 를 무조건 생성 → 노드가 파라미터를 보고 + no-op 하는 우회만 가능, disabled 상태에서도 노드/레이어 attach. + → 확장 함수 본문에 `if` 를 넣는 옵션(`@ModifierNodeFactory(skipWhen = ...)` 류)이 필요. + +### 판정 관련 + +- 계속 조건 1 ("`fadingEdge` 가 원본보다 읽기 쉽거나 동등") → **불충족.** 단 이는 + 대상 선정 실수(§9-1). `composed` 를 실제로 쓰는 케이스로 다시 검증해야 공정하다. +- 다음 dogfood 후보: CompositionLocal / InteractionSource 를 읽는 modifier 를 + `:modifier-node-sample` 에 새로 하나 만들어 비교. diff --git a/modifier-node-sample/src/main/kotlin/com/sonms/modifiernode/sample/FadingEdge.kt b/modifier-node-sample/src/main/kotlin/com/sonms/modifiernode/sample/FadingEdge.kt new file mode 100644 index 0000000..7bd6760 --- /dev/null +++ b/modifier-node-sample/src/main/kotlin/com/sonms/modifiernode/sample/FadingEdge.kt @@ -0,0 +1,80 @@ +package com.sonms.modifiernode.sample + +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.ContentDrawScope +import androidx.compose.ui.graphics.layer.GraphicsLayer +import androidx.compose.ui.graphics.layer.drawLayer +import androidx.compose.ui.node.DrawModifierNode +import androidx.compose.ui.node.requireGraphicsContext +import com.sonms.modifiernode.annotations.Invalidates +import com.sonms.modifiernode.annotations.InvalidationScope.Draw +import com.sonms.modifiernode.annotations.ModifierNodeFactory + +/** + * Dogfooding — `wheelpicker` 의 `Modifier.fadingEdge` 를 `Modifier.Node` 로 재작성. + * + * 원본은 `graphicsLayer { compositingStrategy = Offscreen }.drawWithCache { ... }` 조합이었다. + * Node 판에서는 오프스크린 버퍼를 직접 [GraphicsLayer] 로 관리한다: + * - [onAttach] / [onDetach] 에서 레이어 생성/반납 (composition 밖 lifecycle) + * - [draw] 에서 컨텐츠를 레이어에 record 하고 같은 버퍼에 DstIn 마스크를 얹는다 + * + * 결정 게이트 메모: + * - `enabled=false` 는 원본에서 "modifier 미적용(early return)" 이었다. 현재 codegen 은 + * `this.then(Element(...))` 를 무조건 생성하므로 **조건부 적용을 표현할 수 없다**. + * 여기서는 노드가 파라미터를 보고 no-op 하는 방식으로 우회했고, 그 대가로 disabled + * 상태에서도 노드/레이어가 attach 된다. + * - 그리기 로직(브러시 계산, record, DstIn, drawLayer)은 전부 손으로 작성했다. + * codegen 이 걷어낸 건 Element/equals/hashCode/update/inspector 뿐이다. + */ +@ModifierNodeFactory(name = "fadingEdge") +internal class FadingEdgeNode( + @Invalidates(Draw) var isVertical: Boolean, + @Invalidates(Draw) var fraction: Float, + @Invalidates(Draw) var enabled: Boolean, +) : Modifier.Node(), DrawModifierNode { + + private var layer: GraphicsLayer? = null + + override fun onAttach() { + layer = requireGraphicsContext().createGraphicsLayer() + } + + override fun onDetach() { + layer?.let { requireGraphicsContext().releaseGraphicsLayer(it) } + layer = null + } + + override fun ContentDrawScope.draw() { + val gl = layer + if (!enabled || gl == null) { + drawContent() + return + } + + val f = fraction + val maskBrush = if (isVertical) { + Brush.verticalGradient( + 0f to Color.Transparent, + f to Color.Black, + (1f - f) to Color.Black, + 1f to Color.Transparent, + ) + } else { + Brush.horizontalGradient( + 0f to Color.Transparent, + f to Color.Black, + (1f - f) to Color.Black, + 1f to Color.Transparent, + ) + } + + gl.record { + this@draw.drawContent() + drawRect(brush = maskBrush, blendMode = BlendMode.DstIn) + } + drawLayer(gl) + } +} From cead66c16ccaa8f40cb552f45ef4d79b9c8b8302 Mon Sep 17 00:00:00 2001 From: sonms Date: Tue, 8 Sep 2026 14:04:48 +0900 Subject: [PATCH 03/21] [Add] before/after comparison: composed vs Node+codegen (2 cases) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Case A (accentOverlay): CompositionLocal read + draw — codegen decisive (7 -> 10 hand-written lines vs 7 -> 55 without codegen) - Case B (pressScale): InteractionSource + animation — codegen insufficient alone (10 -> 40 lines); surfaces missing "onUpdate hook" limitation - COMPARISON.md with verdicts; decision-gate condition 1 met when judged on Case A rather than the mis-chosen fadingEdge Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_013UGir6UrorM7Kx7iyYv1ir --- modifier-node-sample/COMPARISON.md | 140 ++++++++++++++++++ .../modifiernode/sample/compare/LocalTint.kt | 61 ++++++++ .../modifiernode/sample/compare/PressScale.kt | 112 ++++++++++++++ 3 files changed, 313 insertions(+) create mode 100644 modifier-node-sample/COMPARISON.md create mode 100644 modifier-node-sample/src/main/kotlin/com/sonms/modifiernode/sample/compare/LocalTint.kt create mode 100644 modifier-node-sample/src/main/kotlin/com/sonms/modifiernode/sample/compare/PressScale.kt diff --git a/modifier-node-sample/COMPARISON.md b/modifier-node-sample/COMPARISON.md new file mode 100644 index 0000000..f4c12a0 --- /dev/null +++ b/modifier-node-sample/COMPARISON.md @@ -0,0 +1,140 @@ +# composed vs Modifier.Node(+codegen) — before/after + +두 케이스로 비교한다. 소스: `src/main/kotlin/.../compare/`. + +- **Case A — `accentOverlay`**: CompositionLocal 하나 읽어서 draw. (전형적 `composed` 사용) +- **Case B — `pressScale`**: `InteractionSource` 구독 + 애니메이션. (무거운 `composed` 사용) + +손으로 쓴 코드 줄 수만 센다(주석 제외, 대략치). 생성 코드는 0으로 친다. + +| | composed | Node(손) | codegen 없이 Node(손) | 생성물 | +|---|---:|---:|---:|---:| +| Case A | ~7 | **~10** | ~55 | ~46 | +| Case B | ~10 | **~40** | ~85 | ~53 | + +--- + +## Case A — `accentOverlay` + +### BEFORE + +```kotlin +fun Modifier.accentOverlayComposed(alpha: Float): Modifier = composed { + val color = LocalAccentColor.current + drawWithContent { + drawContent() + drawRect(color.copy(alpha = alpha)) + } +} +``` + +숨은 비용: 반환 Modifier `equals` 불안정 → 부모 재구성 시 하위 재구성, `composed` 람다 매번 +re-materialize, composition 밖 사용 불가, layout inspector 에 이름 안 뜸. + +### AFTER + +```kotlin +@ModifierNodeFactory(name = "accentOverlay") +internal class AccentOverlayNode( + @Invalidates(Draw) var alpha: Float, +) : Modifier.Node(), DrawModifierNode, CompositionLocalConsumerModifierNode { + override fun ContentDrawScope.draw() { + val color = currentValueOf(LocalAccentColor) // draw 에서 읽으면 관찰됨 → 로컬 변경 시 자동 redraw + drawContent() + drawRect(color.copy(alpha = alpha)) + } +} +``` + +생성: `fun Modifier.accentOverlay(alpha)` + `AccentOverlayElement`(create/update/equals/hashCode/inspector). + +### 판정 A — **명확한 이득** + +- 손 코드 7 → 10줄. 늘어난 3줄은 전부 "실제 정보"(무슨 노드인지 선언 + `var` 선언). +- codegen 없으면 7 → ~55줄. 이 55줄이 싫어서 다들 `composed` 에 머문다. **codegen 이 정확히 그 벽을 없앤다.** +- 덤: 안정 equals, inspector 이름, composition 밖 사용 가능, `@Invalidates(Draw)` ↔ `DrawModifierNode` 정합성 컴파일 체크. + +--- + +## Case B — `pressScale` + +### BEFORE + +```kotlin +fun Modifier.pressScaleComposed( + interactionSource: InteractionSource, + pressedScale: Float, +): Modifier = composed { + val pressed by interactionSource.collectIsPressedAsState() + val scale by animateFloatAsState(if (pressed) pressedScale else 1f, label = "pressScale") + graphicsLayer { scaleX = scale; scaleY = scale } +} +``` + +`composed` 가 공짜로 해주는 것: 구독 lifecycle, 애니메이션 상태 remember, +`interactionSource` 키가 바뀌면 재구독. + +### AFTER + +```kotlin +@ModifierNodeFactory(name = "pressScale") +internal class PressScaleNode( + @Invalidates(Draw) var pressedScale: Float, + @Invalidates(None) var interactionSource: InteractionSource, +) : Modifier.Node(), DrawModifierNode { + + private val scaleAnim = Animatable(1f) + private var collectJob: Job? = null + private var boundSource: InteractionSource? = null + + override fun onAttach() = rebind() + override fun onDetach() { collectJob?.cancel(); collectJob = null; boundSource = null } + + private fun rebind() { + if (boundSource === interactionSource) return + collectJob?.cancel() + boundSource = interactionSource + collectJob = coroutineScope.launch { + val presses = ArrayList() + interactionSource.interactions.collect { i -> + when (i) { + is PressInteraction.Press -> presses.add(i) + is PressInteraction.Release -> presses.remove(i.press) + is PressInteraction.Cancel -> presses.remove(i.press) + } + launch { scaleAnim.animateTo(if (presses.isNotEmpty()) pressedScale else 1f) } + } + } + } + + override fun ContentDrawScope.draw() { + rebind() // ← codegen update 가 대입만 하므로 수동 재구독 확인 + val s = scaleAnim.value // snapshot 상태 → 프레임마다 자동 redraw + scale(s, s, center) { this@draw.drawContent() } + } +} +``` + +### 판정 B — **codegen 만으로는 부족** + +- 손 코드 10 → 40줄. codegen 이 Element(~50줄)를 없앴지만, interaction+animation 의 + **노드 행동 코드 자체가 `composed` 보다 훨씬 장황**하다. +- **새 한계 발견:** `interactionSource` 파라미터가 바뀔 때 재구독하려면 훅이 필요한데, + codegen 의 `update` 는 `node.x = x` 대입만 한다. 여기서는 `draw()` 진입 시 `rebind()` + 수동 호출로 우회 — 지저분하다. → `update` 후 `node.onUpdated()` 를 부르거나, + `@Invalidates` 에 "재바인드" 개념(예: `@OnChange`)이 필요. +- `Animatable` / press 카운팅 보일러플레이트는 codegen 범위 밖. 별도 노드 유틸이 있어야 함. + +--- + +## 종합 + +| 모디파이어 부류 | codegen 효과 | +|---|---| +| **값(파라미터/CompositionLocal) 읽고 draw/measure 에서 반응** — 커스텀 모디파이어의 다수 | **결정적.** 7→10 vs 7→55. `composed` 를 벗어나게 해줌 | +| **interaction / coroutine / animation** | Element 제거는 도움되지만 균형을 못 뒤집음. `onUpdate` 훅 + 노드 유틸 필요 | +| 모든 부류 공통 | 안정 equals·inspector·`@Invalidates` 정합성 체크·(예정) ABI 표면 축소 | + +**결정 게이트 조건 1** ("재작성이 원본보다 낫거나 동등"): `fadingEdge`(§ARCHITECTURE 9)로는 불충족이었으나 +그건 애초에 `composed` 케이스가 아니었음. **Case A 로 재평가 시 충족.** Case B 는 codegen 의 +경계를 명확히 보여줌 — 라이브러리화하면 `onUpdate` 훅을 v1.1 스코프에 넣어야 한다. diff --git a/modifier-node-sample/src/main/kotlin/com/sonms/modifiernode/sample/compare/LocalTint.kt b/modifier-node-sample/src/main/kotlin/com/sonms/modifiernode/sample/compare/LocalTint.kt new file mode 100644 index 0000000..ea75ed7 --- /dev/null +++ b/modifier-node-sample/src/main/kotlin/com/sonms/modifiernode/sample/compare/LocalTint.kt @@ -0,0 +1,61 @@ +package com.sonms.modifiernode.sample.compare + +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.ContentDrawScope +import androidx.compose.ui.node.CompositionLocalConsumerModifierNode +import androidx.compose.ui.node.DrawModifierNode +import androidx.compose.ui.node.currentValueOf +import com.sonms.modifiernode.annotations.Invalidates +import com.sonms.modifiernode.annotations.InvalidationScope.Draw +import com.sonms.modifiernode.annotations.ModifierNodeFactory + +/** 비교용 CompositionLocal. */ +val LocalAccentColor = compositionLocalOf { Color.Magenta } + +// ───────────────────────────────────────────────────────────────────────────── +// BEFORE — composed { } 로 CompositionLocal 을 읽는 전형적 패턴 +// ───────────────────────────────────────────────────────────────────────────── + +/** + * `LocalAccentColor` 를 읽어 반투명 오버레이를 얹는다. + * + * 문제: + * - 반환 Modifier 가 `equals`/`hashCode` 미구현 → 부모가 재구성될 때마다 modifier 체인 + * 비교 실패 → 하위 재구성/무효화 + * - `composed` 람다가 매 재구성마다 re-materialize + * - composition 밖에서 사용 불가 + */ +fun Modifier.accentOverlayComposed(alpha: Float): Modifier = composed { + val color = LocalAccentColor.current + drawWithContent { + drawContent() + drawRect(color.copy(alpha = alpha)) + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// AFTER — Modifier.Node + codegen +// ───────────────────────────────────────────────────────────────────────────── + +/** + * 손으로 쓰는 건 노드 행동 코드뿐. `currentValueOf(LocalAccentColor)` 를 `draw()` 에서 + * 읽으면 snapshot 관찰 대상이라 로컬 값이 바뀌면 자동 redraw. + * + * 생성물(`AccentOverlayElement.kt`): + * - `fun Modifier.accentOverlay(alpha: Float): Modifier` + * - `AccentOverlayElement` : create/update/equals/hashCode/inspectableProperties + */ +@ModifierNodeFactory(name = "accentOverlay") +internal class AccentOverlayNode( + @Invalidates(Draw) var alpha: Float, +) : Modifier.Node(), DrawModifierNode, CompositionLocalConsumerModifierNode { + override fun ContentDrawScope.draw() { + val color = currentValueOf(LocalAccentColor) + drawContent() + drawRect(color.copy(alpha = alpha)) + } +} diff --git a/modifier-node-sample/src/main/kotlin/com/sonms/modifiernode/sample/compare/PressScale.kt b/modifier-node-sample/src/main/kotlin/com/sonms/modifiernode/sample/compare/PressScale.kt new file mode 100644 index 0000000..8c931bb --- /dev/null +++ b/modifier-node-sample/src/main/kotlin/com/sonms/modifiernode/sample/compare/PressScale.kt @@ -0,0 +1,112 @@ +package com.sonms.modifiernode.sample.compare + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.interaction.InteractionSource +import androidx.compose.foundation.interaction.PressInteraction +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.graphics.drawscope.ContentDrawScope +import androidx.compose.ui.graphics.drawscope.scale +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.node.DrawModifierNode +import com.sonms.modifiernode.annotations.Invalidates +import com.sonms.modifiernode.annotations.InvalidationScope.Draw +import com.sonms.modifiernode.annotations.InvalidationScope.None +import com.sonms.modifiernode.annotations.ModifierNodeFactory +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch + +// ───────────────────────────────────────────────────────────────────────────── +// BEFORE — composed { }: InteractionSource 구독 + 애니메이션 +// ───────────────────────────────────────────────────────────────────────────── + +/** + * press 되는 동안 축소 스케일 애니메이션. + * + * `composed` 가 여기서 해주는 것: + * - `collectIsPressedAsState()` — InteractionSource 구독을 composition lifecycle 에 묶음 + * - `animateFloatAsState` — 애니메이션 상태를 remember + * - `interactionSource` 가 바뀌면 `collectIsPressedAsState` 가 알아서 재구독 (remember 키) + * + * 대가: equals 불안정, 매 재구성 re-materialize, composition 밖 사용 불가. + */ +fun Modifier.pressScaleComposed( + interactionSource: InteractionSource, + pressedScale: Float, +): Modifier = composed { + val pressed by interactionSource.collectIsPressedAsState() + val scale by animateFloatAsState( + targetValue = if (pressed) pressedScale else 1f, + label = "pressScale", + ) + graphicsLayer { + scaleX = scale + scaleY = scale + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// AFTER — Modifier.Node + codegen +// ───────────────────────────────────────────────────────────────────────────── + +/** + * codegen 이 걷어내는 것: Element/equals/hashCode/update/inspector. + * + * 손으로 남는 것 — 그리고 이게 §COMPARISON 의 핵심: + * - `coroutineScope` 에서 `interactionSource.interactions` 수집 (press 카운팅) + * - `Animatable` 을 직접 들고 `launch { animateTo() }` + * - `onDetach` 에서 job 정리 + * - ⚠️ `interactionSource` 파라미터가 바뀌면 재구독이 필요한데, 현재 codegen 의 + * `update` 는 `node.interactionSource = interactionSource` 대입만 한다. + * "파라미터 변경 시 노드 커스텀 로직 실행" 훅이 없다 → 여기서는 수동 처리. + */ +@ModifierNodeFactory(name = "pressScale") +internal class PressScaleNode( + @Invalidates(Draw) var pressedScale: Float, + @Invalidates(None) var interactionSource: InteractionSource, +) : Modifier.Node(), DrawModifierNode { + + private val scaleAnim = Animatable(1f) + private var collectJob: Job? = null + private var boundSource: InteractionSource? = null + + override fun onAttach() { + rebind() + } + + override fun onDetach() { + collectJob?.cancel() + collectJob = null + boundSource = null + } + + /** codegen 의 update 가 대입만 하므로, 재구독은 draw 진입 시 수동 확인. */ + private fun rebind() { + if (boundSource === interactionSource) return + collectJob?.cancel() + boundSource = interactionSource + collectJob = coroutineScope.launch { + val presses = ArrayList() + interactionSource.interactions.collect { interaction -> + when (interaction) { + is PressInteraction.Press -> presses.add(interaction) + is PressInteraction.Release -> presses.remove(interaction.press) + is PressInteraction.Cancel -> presses.remove(interaction.press) + } + val target = if (presses.isNotEmpty()) pressedScale else 1f + launch { scaleAnim.animateTo(target) } + } + } + } + + override fun ContentDrawScope.draw() { + rebind() + val s = scaleAnim.value // Animatable.value 는 snapshot 상태 → 프레임마다 자동 redraw + scale(s, s, center) { + this@draw.drawContent() + } + } +} From ebf70260183728d2c9c65f4162b8607d0f53b61c Mon Sep 17 00:00:00 2001 From: sonms Date: Tue, 8 Sep 2026 14:14:50 +0900 Subject: [PATCH 04/21] [Add] Contract tests + finding: parameter-level invalidation scope is not viable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GeneratedElementTest (pure JUnit): equals/hashCode reuse contract, create/update field sync — the contract codegen actually guarantees - InvalidationContractTest (Robolectric): equals-skip = unchanged params never call update() - Verified from androidx.compose.ui 1.12.0-beta02 bytecode: NodeChain.updateNode() unconditionally calls autoInvalidateUpdatedNode() after update(); the only suppressor Modifier.Node.shouldAutoInvalidate is on the Node (codegen can't inject) and @Deprecated in 1.12 - => generated per-param invalidateDraw/Measurement calls have no runtime effect; @Invalidates survives only as a compile-time lint - ARCHITECTURE.md §10 (finding) + §11 (conclusion: A stop / B rescope to boilerplate+ABI / C broaden to Node ergonomics) Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_013UGir6UrorM7Kx7iyYv1ir --- ARCHITECTURE.md | 93 ++++++++++++++++++- gradle/libs.versions.toml | 2 + .../processor/ModifierNodeProcessor.kt | 7 ++ modifier-node-sample/COMPARISON.md | 13 +++ modifier-node-sample/build.gradle.kts | 11 +++ .../sample/GeneratedElementTest.kt | 63 +++++++++++++ .../sample/InvalidationContractTest.kt | 91 ++++++++++++++++++ 7 files changed, 276 insertions(+), 4 deletions(-) create mode 100644 modifier-node-sample/src/test/kotlin/com/sonms/modifiernode/sample/GeneratedElementTest.kt create mode 100644 modifier-node-sample/src/test/kotlin/com/sonms/modifiernode/sample/InvalidationContractTest.kt diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 6cbe785..3af09b4 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -153,8 +153,11 @@ invalidation 계층: `Measure` > `Placement` > `Draw` (상위 하나만 호출), - [x] 프로세서: Element + 확장 함수 생성, invalidation 계층 접기, `var`/scope 검증 - [x] `:modifier-node-sample` 예제 2개(`debugTint` draw 전용, `fixedSquare` draw+layout) 빌드/코드젠 확인 - [x] `fadingEdge` 재작성 (§9 참고) -- [ ] invalidation 계약 테스트 헬퍼 +- [x] `composed` vs Node 비교 2케이스 (§10, `COMPARISON.md`) +- [x] 계약 테스트 — `GeneratedElementTest`(equals/hashCode/create/update), `InvalidationContractTest`(equals 스킵) +- [x] **invalidation 스코프 실효성 조사 → 불가 판정 (§10)** - [ ] 기본값(default argument) 처리 — 현재 미지원, 호출부에서 전 인자 전달 필요 +- [ ] **§11 A/B/C 갈래 선택** ← 다음 결정 ### 한계 (MVP 구현상) @@ -235,6 +238,88 @@ MVP 완료 후 아래를 근거로 **계속 / 중단**을 판단한다. ### 판정 관련 - 계속 조건 1 ("`fadingEdge` 가 원본보다 읽기 쉽거나 동등") → **불충족.** 단 이는 - 대상 선정 실수(§9-1). `composed` 를 실제로 쓰는 케이스로 다시 검증해야 공정하다. -- 다음 dogfood 후보: CompositionLocal / InteractionSource 를 읽는 modifier 를 - `:modifier-node-sample` 에 새로 하나 만들어 비교. + 대상 선정 실수(§9-1). `composed` 를 실제로 쓰는 케이스로 다시 검증 → §10, `COMPARISON.md`. + +--- + +## 10. `composed` 케이스 재검증 + invalidation 스코프 사망 선고 + +`modifier-node-sample/COMPARISON.md` 에 2케이스 before/after. 요약: + +| 모디파이어 부류 | codegen 효과 | +|---|---| +| 값(파라미터/CompositionLocal) 읽고 draw/measure 에서 반응 | **결정적** — 손 코드 7→10 vs codegen 없이 7→55 | +| interaction / coroutine / animation | Element 제거는 도움되나 균형 못 뒤집음. `onUpdate` 훅 + 노드 유틸 필요 | + +### 치명적 발견 — §3.1 (파라미터 단위 invalidation 스코프) 이 불가능 + +`androidx.compose.ui` **1.12.0-beta02 (BOM 2026.04.01) 바이트코드 직접 확인**: + +- `NodeChain.updateNode()` 는 `element.update(node)` 직후 **무조건** + `NodeKindKt.autoInvalidateUpdatedNode(node)` 를 호출한다 (attached 이면 즉시, 아니면 attach 대기). +- `autoInvalidateNodeSelf` 는 노드가 구현한 **모든** capability(measure/draw/semantics/ + parentData/focus...)를 무효화한다. +- 유일한 억제 수단 `Modifier.Node.shouldAutoInvalidate` 는: + 1. **Element 가 아니라 Node 쪽** 프로퍼티 → `@ModifierNodeFactory` 가 사용자 Node 클래스에 + 주입 불가 (KSP 는 기존 소스 수정 못 함) + 2. Compose 1.12 에서 **`@Deprecated`** (`getShouldAutoInvalidate$annotations` = deprecated) +- Robolectric 테스트로도 재현: `@Invalidates(Draw)` 파라미터를 바꿔도 remeasure 발생. + +→ 생성된 `update()` 의 `if (redraw) invalidateDraw() else ...` 는 **런타임 효과가 없다**. + Compose 가 이미 전부 무효화한다. `@Invalidates` 는 **컴파일 타임 검증** + (`@Invalidates(Measure)` ↔ `LayoutModifierNode` 정합성) 용도로만 남는다. + +### 실제로 남는 가치 + +1. `equals`/`hashCode`/`create`/`update`/`inspectableProperties` 보일러플레이트 제거 + — modifier 당 ~30~55줄. `GeneratedElementTest` 로 계약 고정. +2. **equals 스킵** — 파라미터 불변 시 `update()` 자체가 안 불림. `Modifier.Node` 성능의 핵심이며 + codegen 이 정확한 `equals` 를 보장 → 사용자가 실수로 깨뜨릴 여지 제거. `InvalidationContractTest` 로 고정. +3. `@Invalidates` scope ↔ 노드 인터페이스 **컴파일 타임 검증** (런타임 효과는 없음) +4. (미구현) ABI 표면 축소 — Element/Node `internal`, 함수만 공개 + +### 결정 게이트 재평가 + +| 조건 | 결과 | +|---|---| +| 1. 재작성이 원본보다 낫거나 동등 | **부분 충족** — Case A(값 읽기)는 명백히 이득, Case B(interaction)는 아님, `fadingEdge`(composition-free)는 손해 | +| 2. invalidation 계약이 손으로 짤 때보다 명확한 이점 | **불충족** — 런타임 효과 없음 (§10). 컴파일 검증만 남고 이는 "있으면 좋지만 없어도 그만" | +| 3. KSP 구현 비용이 시간 단위 | **충족** — 스캐폴딩+3케이스+테스트가 반나절 | + +**조건 2 불충족 = 중단 신호.** 헤드라인 차별점이 API 부재로 무너졌다. +남는 건 보일러플레이트 제거 + equals 안정성 보장 + (예정) ABI 축소 — 사용자가 처음부터 +"10~20줄, 없어도 그만" 이라 평가한 티어다. + +--- + +## 11. 결론 + +**"파라미터 단위 invalidation + ABI" 라는 원래 라이브러리 명제로는 라이브러리화 부적합.** +핵심(§3.1)이 Compose API 부재로 불가능하고, 남는 가치는 사용자 스스로 "없어도 그만" 으로 +평가한 수준이다. + +세 갈래: + +### A. 중단 (권장 후보 1) +`Modifier.Node` + KSP 를 반나절에 학습했고, "Compose 가 autoInvalidate 를 무조건 한다" 는 +비자명한 사실을 바이트코드로 확인했다. 브랜치를 아카이브하고 이 지식만 챙긴다. +`master` 병합 없음. + +### B. 축소 재정의 (권장 후보 2) +명제를 **"`Modifier.Node` 보일러플레이트 + ABI 친화 공개 API 생성기"** 로 낮춘다. +- 마케팅에서 invalidation 성능 이야기를 **완전히 뺀다** +- `@Invalidates` 는 컴파일 타임 lint 로만 유지 (scope↔인터페이스 정합성) +- 추가 작업: Element/Node `internal` 은닉 + 함수만 공개 (ABI), `@ModifierNodeFactory(skipWhen=)` + (조건부 적용, §9-3), `onUpdate` 훅 (§10 Case B) +- 대상: 자기 자신 + 소수 라이브러리 저자. "재미없지만 실재하는" 도구. +- 이미 만든 인프라가 그대로 쓰이므로 유지 비용 낮음. + +### C. 목표 전환 (더 큰 작업) +"Modifier.Node 를 쉽게" 로 스코프를 넓힌다 — 노드 내부 애니메이션/InteractionSource 헬퍼, +delegation 프리셋, 테스트 유틸. codegen 은 부품 하나로 격하. 초안 논의의 "방향 B" 로 회귀. +작업량 크고 니치는 그대로. + +### 남길 것 (어느 갈래든) +- `ARCHITECTURE.md` §10 — "Compose 는 `update()` 후 무조건 autoInvalidate 한다, + `shouldAutoInvalidate` 는 deprecated" — 재확인 비용을 아끼는 사실. +- `COMPARISON.md` — `composed` vs Node 의 부류별 손익표. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6c36e5b..0e59b37 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -15,6 +15,7 @@ appcompat = "1.7.1" material = "1.13.0" ksp = "2.0.21-1.0.28" kotlinpoet = "1.18.1" +robolectric = "4.13" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -38,6 +39,7 @@ material = { group = "com.google.android.material", name = "material", version.r ksp-symbol-processing-api = { group = "com.google.devtools.ksp", name = "symbol-processing-api", version.ref = "ksp" } kotlinpoet = { group = "com.squareup", name = "kotlinpoet", version.ref = "kotlinpoet" } kotlinpoet-ksp = { group = "com.squareup", name = "kotlinpoet-ksp", version.ref = "kotlinpoet" } +robolectric = { group = "org.robolectric", name = "robolectric", version.ref = "robolectric" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } diff --git a/modifier-node-processor/src/main/kotlin/com/sonms/modifiernode/processor/ModifierNodeProcessor.kt b/modifier-node-processor/src/main/kotlin/com/sonms/modifiernode/processor/ModifierNodeProcessor.kt index d7d7240..fc00bca 100644 --- a/modifier-node-processor/src/main/kotlin/com/sonms/modifiernode/processor/ModifierNodeProcessor.kt +++ b/modifier-node-processor/src/main/kotlin/com/sonms/modifiernode/processor/ModifierNodeProcessor.kt @@ -85,6 +85,13 @@ class ModifierNodeProcessor( val params = ctor.parameters.map { p -> resolveParam(p, node, supported) } + // 주의: Compose 는 update() 직후 autoInvalidateUpdatedNode() 로 노드의 모든 capability 를 + // 무조건 무효화한다(NodeChain.updateNode). 유일한 억제 수단인 Modifier.Node.shouldAutoInvalidate + // 는 (1) Element 가 아니라 Node 쪽 프로퍼티라 codegen 이 주입 못 하고 (2) Compose 1.12 에서 + // @Deprecated 다. 따라서 생성된 update() 의 세분화된 invalidate 호출은 사실상 "이중" 이며 + // 런타임 최적화 효과가 없다. @Invalidates 는 현재 컴파일 타임 검증 용도로만 의미가 있다. + // 자세한 내용: ARCHITECTURE.md §10. + // --- 확장 함수: fun Modifier.(...): Modifier = this.then(Element(...)) --- val extFun = FunSpec.builder(funName) .addModifiers(visibility) diff --git a/modifier-node-sample/COMPARISON.md b/modifier-node-sample/COMPARISON.md index f4c12a0..9d7510b 100644 --- a/modifier-node-sample/COMPARISON.md +++ b/modifier-node-sample/COMPARISON.md @@ -138,3 +138,16 @@ internal class PressScaleNode( **결정 게이트 조건 1** ("재작성이 원본보다 낫거나 동등"): `fadingEdge`(§ARCHITECTURE 9)로는 불충족이었으나 그건 애초에 `composed` 케이스가 아니었음. **Case A 로 재평가 시 충족.** Case B 는 codegen 의 경계를 명확히 보여줌 — 라이브러리화하면 `onUpdate` 훅을 v1.1 스코프에 넣어야 한다. + +--- + +## ⚠️ 그 뒤 발견 — invalidation 스코프는 작동하지 않는다 + +`ARCHITECTURE.md` §10 참고. `NodeChain.updateNode` 바이트코드상 `update()` 직후 +`autoInvalidateUpdatedNode()` 가 무조건 실행되어 노드의 모든 capability 를 무효화한다. +억제 수단 `Modifier.Node.shouldAutoInvalidate` 는 Element 가 아니라 Node 쪽이고 Compose 1.12 +에서 deprecated. **따라서 위 "종합" 표의 "모든 부류 공통: `@Invalidates` 정합성 체크" 는 +컴파일 타임 lint 로만 유효하고 런타임 성능 효과는 없다.** + +codegen 이 실제로 주는 것은 (1) 보일러플레이트 제거 (2) equals 스킵을 보장하는 정확한 +`equals`/`hashCode`. → 라이브러리 명제 재검토는 `ARCHITECTURE.md` §11. diff --git a/modifier-node-sample/build.gradle.kts b/modifier-node-sample/build.gradle.kts index a229409..eee4096 100644 --- a/modifier-node-sample/build.gradle.kts +++ b/modifier-node-sample/build.gradle.kts @@ -20,6 +20,9 @@ android { kotlinOptions { jvmTarget = "11" } + testOptions { + unitTests.isIncludeAndroidResources = true + } } dependencies { @@ -29,4 +32,12 @@ dependencies { implementation(project(":modifier-node-annotations")) ksp(project(":modifier-node-processor")) + kspTest(project(":modifier-node-processor")) + + testImplementation(libs.junit) + testImplementation(libs.robolectric) + testImplementation(platform(libs.androidx.compose.bom)) + testImplementation(libs.androidx.compose.ui.test.junit4) + testImplementation(libs.androidx.compose.ui.tooling) + debugImplementation(libs.androidx.compose.ui.test.manifest) } diff --git a/modifier-node-sample/src/test/kotlin/com/sonms/modifiernode/sample/GeneratedElementTest.kt b/modifier-node-sample/src/test/kotlin/com/sonms/modifiernode/sample/GeneratedElementTest.kt new file mode 100644 index 0000000..ab17104 --- /dev/null +++ b/modifier-node-sample/src/test/kotlin/com/sonms/modifiernode/sample/GeneratedElementTest.kt @@ -0,0 +1,63 @@ +package com.sonms.modifiernode.sample + +import androidx.compose.ui.graphics.Color +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * codegen 이 실제로 보장하는 계약을 고정한다 — Compose 런타임 불필요. + * + * 1. **재사용(equals/hashCode) 계약**: 같은 인자 → equal, 한 인자라도 다르면 non-equal. + * 이게 `composed` 대비 진짜 이득이다. Element 가 equal 이면 Compose 는 `update()` 를 + * 아예 호출하지 않는다. + * 2. **update() 필드 동기화**: 바뀐 파라미터가 노드 필드에 반영된다. + * + * ⚠️ 여기서 검증하지 않는 것: "파라미터 단위 invalidation 스코프". 현행 Compose 에서 + * `NodeChain.updateNode` 가 `update()` 직후 모든 capability 를 무조건 무효화하므로 + * 생성된 `if (redraw) invalidateDraw()` 는 런타임 효과가 없다 (ARCHITECTURE.md §10). + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class GeneratedElementTest { + + @Test + fun equal_args_produce_equal_elements() { + assertEquals(DebugTintElement(Color.Red), DebugTintElement(Color.Red)) + assertEquals( + DebugTintElement(Color.Red).hashCode(), + DebugTintElement(Color.Red).hashCode(), + ) + } + + @Test + fun differing_args_produce_non_equal_elements() { + assertNotEquals(DebugTintElement(Color.Red), DebugTintElement(Color.Blue)) + } + + @Test + fun multi_param_equality_is_per_field() { + assertEquals(FixedSquareElement(mkDp(8), Color.Red), FixedSquareElement(mkDp(8), Color.Red)) + assertNotEquals(FixedSquareElement(mkDp(8), Color.Red), FixedSquareElement(mkDp(9), Color.Red)) + assertNotEquals(FixedSquareElement(mkDp(8), Color.Red), FixedSquareElement(mkDp(8), Color.Blue)) + } + + @Test + fun update_syncs_changed_field_to_node() { + val node = DebugTintNode(Color.Red) + // update() 는 detached 노드에서 invalidateDraw() 가 no-op 이라 안전. + DebugTintElement(Color.Blue).update(node) + assertEquals(Color.Blue, node.color) + } + + @Test + fun create_seeds_node_from_element_args() { + val node = DebugTintElement(Color.Green).create() + assertEquals(Color.Green, node.color) + } + + private fun mkDp(v: Int) = androidx.compose.ui.unit.Dp(v.toFloat()) +} diff --git a/modifier-node-sample/src/test/kotlin/com/sonms/modifiernode/sample/InvalidationContractTest.kt b/modifier-node-sample/src/test/kotlin/com/sonms/modifiernode/sample/InvalidationContractTest.kt new file mode 100644 index 0000000..0214b81 --- /dev/null +++ b/modifier-node-sample/src/test/kotlin/com/sonms/modifiernode/sample/InvalidationContractTest.kt @@ -0,0 +1,91 @@ +package com.sonms.modifiernode.sample + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.drawscope.ContentDrawScope +import androidx.compose.ui.layout.Measurable +import androidx.compose.ui.layout.MeasureResult +import androidx.compose.ui.layout.MeasureScope +import androidx.compose.ui.node.DrawModifierNode +import androidx.compose.ui.node.LayoutModifierNode +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.dp +import com.sonms.modifiernode.annotations.Invalidates +import com.sonms.modifiernode.annotations.InvalidationScope.Draw +import com.sonms.modifiernode.annotations.InvalidationScope.Measure +import com.sonms.modifiernode.annotations.ModifierNodeFactory +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** 노드가 자기 measure/draw 호출 횟수를 기록. */ +object ProbeCounters { + var measures = 0 + var draws = 0 + fun reset() { + measures = 0 + draws = 0 + } +} + +@ModifierNodeFactory(name = "probe") +internal class ProbeNode( + @Invalidates(Draw) var drawKey: Int, + @Invalidates(Measure) var measureKey: Int, +) : Modifier.Node(), DrawModifierNode, LayoutModifierNode { + + override fun MeasureScope.measure(measurable: Measurable, constraints: Constraints): MeasureResult { + ProbeCounters.measures++ + val placeable = measurable.measure(constraints) + return layout(placeable.width, placeable.height) { placeable.place(0, 0) } + } + + override fun ContentDrawScope.draw() { + ProbeCounters.draws++ + drawContent() + } +} + +/** + * codegen 이 주는 유일한 런타임 최적화 = **equals 스킵**. + * 파라미터가 안 바뀌면 `Element.equals` == true → Compose 가 `update()` 를 아예 안 부름 + * → 측정/그리기 무효화 없음. + * + * (파라미터 단위 invalidation 스코프는 작동하지 않는다 — ARCHITECTURE.md §10. + * `NodeChain.updateNode` 바이트코드상 `update()` 직후 `autoInvalidateUpdatedNode()` 무조건 호출.) + */ +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [34]) +class InvalidationContractTest { + + @get:Rule + val rule = createComposeRule() + + @Test + fun unchanged_params_skip_update_entirely() { + val drawKey = mutableIntStateOf(0) + val tick = mutableIntStateOf(0) + + rule.setContent { + tick.intValue // 재구성 트리거용, modifier 파라미터엔 안 들어감 + Box(Modifier.size(24.dp).probe(drawKey.intValue, 0)) + } + rule.waitForIdle() + ProbeCounters.reset() + + // 상위만 재구성, probe 파라미터는 동일 → Element equal → update 미호출 + tick.intValue = 1 + rule.waitForIdle() + + assertEquals("equal Element must not trigger remeasure", 0, ProbeCounters.measures) + assertEquals("equal Element must not trigger redraw", 0, ProbeCounters.draws) + } +} From 63afbec2191d64c1efd2f3f39ddfac3a357547a3 Mon Sep 17 00:00:00 2001 From: sonms Date: Tue, 8 Sep 2026 15:09:04 +0900 Subject: [PATCH 05/21] =?UTF-8?q?[Fix]=20Correct=20=C2=A710:=20parameter-l?= =?UTF-8?q?evel=20invalidation=20DOES=20work=20via=20shouldAutoInvalidate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previous commit claimed the invalidation-scope feature was architecturally impossible. That was a bytecode misread: the Deprecated:true attribute on the synthetic getShouldAutoInvalidate$annotations() method is a Kotlin codegen artifact (getNode$annotations() has it too), not a deprecation of the property. From androidx.compose.ui 1.12.0-beta02 sources: - NodeKind.autoInvalidateNodeSelf: `if (phase == Updated && !node.shouldAutoInvalidate) return` - Modifier.Node.shouldAutoInvalidate is a documented, open, non-deprecated opt-out - Compose's own SimpleGraphicsLayerModifier / PainterNode / FocusTargetNode use it - AutoInvalidateProbeTest: controlled experiment proving a shouldAutoInvalidate=false node does NOT remeasure on a draw-only param change (and still remeasures on a measure param via update()) - processor: when any @Invalidates is present, require the Node to declare `override val shouldAutoInvalidate` (compile error with fix-it otherwise) - all sample nodes updated with the one-line override - ARCHITECTURE §10 rewritten (incl. why the first conclusion was wrong) + §10.5 on the realistically-narrow perf benefit; §11 decision gate revised Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_013UGir6UrorM7Kx7iyYv1ir --- ARCHITECTURE.md | 145 ++++++++++------ .../processor/ModifierNodeProcessor.kt | 28 ++- modifier-node-sample/COMPARISON.md | 21 ++- .../sonms/modifiernode/sample/FadingEdge.kt | 2 + .../sonms/modifiernode/sample/SampleNodes.kt | 4 + .../modifiernode/sample/compare/LocalTint.kt | 2 + .../modifiernode/sample/compare/PressScale.kt | 2 + .../sample/AutoInvalidateProbeTest.kt | 159 ++++++++++++++++++ .../sample/InvalidationContractTest.kt | 8 +- 9 files changed, 298 insertions(+), 73 deletions(-) create mode 100644 modifier-node-sample/src/test/kotlin/com/sonms/modifiernode/sample/AutoInvalidateProbeTest.kt diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 3af09b4..793439d 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -154,8 +154,9 @@ invalidation 계층: `Measure` > `Placement` > `Draw` (상위 하나만 호출), - [x] `:modifier-node-sample` 예제 2개(`debugTint` draw 전용, `fixedSquare` draw+layout) 빌드/코드젠 확인 - [x] `fadingEdge` 재작성 (§9 참고) - [x] `composed` vs Node 비교 2케이스 (§10, `COMPARISON.md`) -- [x] 계약 테스트 — `GeneratedElementTest`(equals/hashCode/create/update), `InvalidationContractTest`(equals 스킵) -- [x] **invalidation 스코프 실효성 조사 → 불가 판정 (§10)** +- [x] 계약 테스트 — `GeneratedElementTest`, `InvalidationContractTest`(equals 스킵), `AutoInvalidateProbeTest`(opt-out 동작) +- [x] **invalidation 스코프 실효성 조사 → 작동 확인 (§10). 첫 "불가" 결론은 오독, 정정함** +- [x] 프로세서: `@Invalidates` 사용 시 `shouldAutoInvalidate` override 강제 - [ ] 기본값(default argument) 처리 — 현재 미지원, 호출부에서 전 인자 전달 필요 - [ ] **§11 A/B/C 갈래 선택** ← 다음 결정 @@ -242,7 +243,7 @@ MVP 완료 후 아래를 근거로 **계속 / 중단**을 판단한다. --- -## 10. `composed` 케이스 재검증 + invalidation 스코프 사망 선고 +## 10. `composed` 케이스 재검증 + invalidation 스코프 실효성 `modifier-node-sample/COMPARISON.md` 에 2케이스 before/after. 요약: @@ -251,75 +252,107 @@ MVP 완료 후 아래를 근거로 **계속 / 중단**을 판단한다. | 값(파라미터/CompositionLocal) 읽고 draw/measure 에서 반응 | **결정적** — 손 코드 7→10 vs codegen 없이 7→55 | | interaction / coroutine / animation | Element 제거는 도움되나 균형 못 뒤집음. `onUpdate` 훅 + 노드 유틸 필요 | -### 치명적 발견 — §3.1 (파라미터 단위 invalidation 스코프) 이 불가능 +### 10.1 autoInvalidate 조사 — 처음 결론이 틀렸다 (정정) + +`androidx.compose.ui` **1.12.0-beta02 소스 직접 확인** (`ui-android-...-sources.jar`): + +- `NodeChain.updateNode()` → `autoInvalidateUpdatedNode(node)` → `NodeKind.autoInvalidateNodeSelf`: + ``` + if (phase == Updated && !node.shouldAutoInvalidate) return // NodeKind.kt:337 + ``` + 즉 **opt-out 이 있다.** +- `Modifier.Node.shouldAutoInvalidate` (Modifier.kt:224-240): `open val ... get() = true`. + KDoc 이 직접 안내: *"You may choose to set this to `false` if your modifier has + auto-invalidatable properties that do not frequently require invalidation to improve + performance ... you must call the appropriate invalidate functions manually when the + modifier is updated."* → **이게 바로 파라미터 단위 invalidation 이다.** +- **`@Deprecated` 아니다.** 앞 커밋에서 "deprecated" 라 한 건 바이트코드의 + `getShouldAutoInvalidate$annotations()` 합성 메서드에 붙은 `Deprecated: true` 속성을 오독한 것. + 같은 속성이 `getNode$annotations()` 에도 붙어 있는데 `node` 가 deprecated 일 리 없다 — + Kotlin 컴파일러의 `$annotations` 합성 메서드 처리 방식일 뿐. override 시 경고도 안 뜬다. +- **Compose 자체가 이 패턴을 쓴다**: `SimpleGraphicsLayerModifier`(= `Modifier.graphicsLayer{}`), + `PainterNode`(= `Modifier.paint`), `BlockGraphicsLayerModifier`, `FocusTargetNode`. + `SimpleGraphicsLayerModifier` 주석: *"We can skip remeasuring as we only need to rerun the + placement block. we request it manually in the update block."* + +### 10.2 통제 실험 — `AutoInvalidateProbeTest` + +동일 하네스, draw+layout 노드 2개, `drawKey`(draw 전용 파라미터)만 변경: + +| 노드 | `shouldAutoInvalidate` | drawKey 변경 시 remeasure | +|---|---|---| +| `AutoOnNode` | 기본(true) | **발생** (autoInvalidate) | +| `AutoOffNode` | `false` | **없음** ✅ | +| `AutoOffNode`, measureKey 변경 | `false` | 발생 (update() 의 `invalidateMeasurement()` 정상) | -`androidx.compose.ui` **1.12.0-beta02 (BOM 2026.04.01) 바이트코드 직접 확인**: +→ **파라미터 단위 invalidation 스코프는 작동한다.** 단 Node 가 `shouldAutoInvalidate = false` + 를 선언해야 한다. -- `NodeChain.updateNode()` 는 `element.update(node)` 직후 **무조건** - `NodeKindKt.autoInvalidateUpdatedNode(node)` 를 호출한다 (attached 이면 즉시, 아니면 attach 대기). -- `autoInvalidateNodeSelf` 는 노드가 구현한 **모든** capability(measure/draw/semantics/ - parentData/focus...)를 무효화한다. -- 유일한 억제 수단 `Modifier.Node.shouldAutoInvalidate` 는: - 1. **Element 가 아니라 Node 쪽** 프로퍼티 → `@ModifierNodeFactory` 가 사용자 Node 클래스에 - 주입 불가 (KSP 는 기존 소스 수정 못 함) - 2. Compose 1.12 에서 **`@Deprecated`** (`getShouldAutoInvalidate$annotations` = deprecated) -- Robolectric 테스트로도 재현: `@Invalidates(Draw)` 파라미터를 바꿔도 remeasure 발생. +### 10.3 남는 제약 — codegen 이 Node 에 그 한 줄을 못 넣는다 -→ 생성된 `update()` 의 `if (redraw) invalidateDraw() else ...` 는 **런타임 효과가 없다**. - Compose 가 이미 전부 무효화한다. `@Invalidates` 는 **컴파일 타임 검증** - (`@Invalidates(Measure)` ↔ `LayoutModifierNode` 정합성) 용도로만 남는다. +`@ModifierNodeFactory` 는 사용자가 쓴 concrete Node 클래스를 수정할 수 없다(KSP 한계). +**해결: `@Invalidates` 를 하나라도 쓰면 KSP 가 다음 선언을 요구한다(없으면 컴파일 에러).** +```kotlin +override val shouldAutoInvalidate: Boolean get() = false +``` +Compose 엔지니어가 `SimpleGraphicsLayerModifier` 에 손으로 쓰는 바로 그 줄이다. +codegen 은 정밀한 `update()` (계층 접기 포함)를 생성한다. `ModifierNodeProcessor` 구현됨. -### 실제로 남는 가치 +### 10.4 실제 가치 (수정판) -1. `equals`/`hashCode`/`create`/`update`/`inspectableProperties` 보일러플레이트 제거 +1. **파라미터 단위 invalidation** — `@Invalidates` + 강제된 `shouldAutoInvalidate=false` + + 생성된 `update()`. `AutoInvalidateProbeTest` 로 실효 확인. Compose 내부 hot-path 패턴과 동일. +2. `equals`/`hashCode`/`create`/`update`/`inspectableProperties` 보일러플레이트 제거 — modifier 당 ~30~55줄. `GeneratedElementTest` 로 계약 고정. -2. **equals 스킵** — 파라미터 불변 시 `update()` 자체가 안 불림. `Modifier.Node` 성능의 핵심이며 - codegen 이 정확한 `equals` 를 보장 → 사용자가 실수로 깨뜨릴 여지 제거. `InvalidationContractTest` 로 고정. -3. `@Invalidates` scope ↔ 노드 인터페이스 **컴파일 타임 검증** (런타임 효과는 없음) -4. (미구현) ABI 표면 축소 — Element/Node `internal`, 함수만 공개 +3. **equals 스킵** — 파라미터 불변 시 `update()` 자체가 안 불림. codegen 이 정확한 `equals` 보장. +4. `@Invalidates` scope ↔ 노드 인터페이스 컴파일 타임 검증. +5. (미구현) ABI 표면 축소. -### 결정 게이트 재평가 - -| 조건 | 결과 | -|---|---| -| 1. 재작성이 원본보다 낫거나 동등 | **부분 충족** — Case A(값 읽기)는 명백히 이득, Case B(interaction)는 아님, `fadingEdge`(composition-free)는 손해 | -| 2. invalidation 계약이 손으로 짤 때보다 명확한 이점 | **불충족** — 런타임 효과 없음 (§10). 컴파일 검증만 남고 이는 "있으면 좋지만 없어도 그만" | -| 3. KSP 구현 비용이 시간 단위 | **충족** — 스캐폴딩+3케이스+테스트가 반나절 | +### 10.5 성능 효과의 현실적 크기 -**조건 2 불충족 = 중단 신호.** 헤드라인 차별점이 API 부재로 무너졌다. -남는 건 보일러플레이트 제거 + equals 안정성 보장 + (예정) ABI 축소 — 사용자가 처음부터 -"10~20줄, 없어도 그만" 이라 평가한 티어다. +파라미터 단위 invalidation 은 **micro-optimization** 이다. Compose 조차 전체 modifier 중 +극소수(graphicsLayer, paint, focus)에만 적용했다. equals 스킵(파라미터 불변 시 update 스킵)이 +`Modifier.Node` 성능의 대부분이고, 그건 `@Invalidates` 없이도 codegen 이 준다. +`@Invalidates` 의 이득은 "파라미터가 자주 바뀌는데 그 변경이 draw 만 요구하는" 좁은 경우에 한정. --- ## 11. 결론 -**"파라미터 단위 invalidation + ABI" 라는 원래 라이브러리 명제로는 라이브러리화 부적합.** -핵심(§3.1)이 Compose API 부재로 불가능하고, 남는 가치는 사용자 스스로 "없어도 그만" 으로 -평가한 수준이다. +§10 정정 후: **원래 명제(파라미터 단위 invalidation + ABI)는 기술적으로 성립한다.** +단 두 가지 냉정한 조건: + +- 파라미터 단위 invalidation 의 실제 성능 이득은 좁다 (§10.5). 주된 가치는 여전히 + "보일러플레이트 제거 + equals 안정성 보장" 이다. +- 니치는 그대로 좁다 (§5). `Modifier.Node` 직접 작성자 + 그중 codegen 수용자. + +### 결정 게이트 재평가 (수정판) + +| 조건 | 결과 | +|---|---| +| 1. 재작성이 원본보다 낫거나 동등 | **부분 충족** — Case A(값 읽기) 이득, Case B(interaction) 아님, composition-free 는 손해 | +| 2. invalidation 계약이 손보다 명확한 이점 | **조건부 충족** — 작동하고(§10.2), KSP 가 `shouldAutoInvalidate` 강제(§10.3), 계약을 테스트로 고정. 단 이득 범위가 좁다(§10.5) | +| 3. KSP 구현 비용이 시간 단위 | **충족** — 전체 반나절 | -세 갈래: +### 갈래 -### A. 중단 (권장 후보 1) -`Modifier.Node` + KSP 를 반나절에 학습했고, "Compose 가 autoInvalidate 를 무조건 한다" 는 -비자명한 사실을 바이트코드로 확인했다. 브랜치를 아카이브하고 이 지식만 챙긴다. -`master` 병합 없음. +**B. 정직한 재정의 (권장).** +명제: *"`Modifier.Node` 보일러플레이트 제거 + equals 안정성 보장 + (opt-in) 파라미터 단위 +invalidation"*. 성능은 부차적 셀링포인트로만, 과장 없이. 이미 만든 인프라가 거의 그대로 완성형. +남은 작업: +- ABI: Element/Node `internal` 은닉 + 함수만 공개 +- `@ModifierNodeFactory(skipWhen=)` — 조건부 적용 (§9-3) +- `onUpdate` 훅 — interaction/re-subscribe (§10 Case B) +- default argument 지원 검토 +- README 에 §5 한계 + §10.5 성능 현실 명시 -### B. 축소 재정의 (권장 후보 2) -명제를 **"`Modifier.Node` 보일러플레이트 + ABI 친화 공개 API 생성기"** 로 낮춘다. -- 마케팅에서 invalidation 성능 이야기를 **완전히 뺀다** -- `@Invalidates` 는 컴파일 타임 lint 로만 유지 (scope↔인터페이스 정합성) -- 추가 작업: Element/Node `internal` 은닉 + 함수만 공개 (ABI), `@ModifierNodeFactory(skipWhen=)` - (조건부 적용, §9-3), `onUpdate` 훅 (§10 Case B) -- 대상: 자기 자신 + 소수 라이브러리 저자. "재미없지만 실재하는" 도구. -- 이미 만든 인프라가 그대로 쓰이므로 유지 비용 낮음. +**A. 중단.** 니치가 좁고 파라미터 단위 이득이 미미하다고 보면 여전히 유효한 선택. +지식(§10)은 문서로 남긴다. -### C. 목표 전환 (더 큰 작업) -"Modifier.Node 를 쉽게" 로 스코프를 넓힌다 — 노드 내부 애니메이션/InteractionSource 헬퍼, -delegation 프리셋, 테스트 유틸. codegen 은 부품 하나로 격하. 초안 논의의 "방향 B" 로 회귀. -작업량 크고 니치는 그대로. +**C. 목표 전환.** "Modifier.Node 를 쉽게" 로 확장 (노드 유틸/delegation/테스트). 작업량 큼. ### 남길 것 (어느 갈래든) -- `ARCHITECTURE.md` §10 — "Compose 는 `update()` 후 무조건 autoInvalidate 한다, - `shouldAutoInvalidate` 는 deprecated" — 재확인 비용을 아끼는 사실. -- `COMPARISON.md` — `composed` vs Node 의 부류별 손익표. +- `ARCHITECTURE.md` §10 — autoInvalidate 메커니즘 + `shouldAutoInvalidate` opt-out 사실관계 + (그리고 처음에 틀렸던 이유). 재확인 비용을 아낀다. +- `COMPARISON.md` — `composed` vs Node 부류별 손익표. +- `AutoInvalidateProbeTest` — opt-out 동작 회귀 방지. diff --git a/modifier-node-processor/src/main/kotlin/com/sonms/modifiernode/processor/ModifierNodeProcessor.kt b/modifier-node-processor/src/main/kotlin/com/sonms/modifiernode/processor/ModifierNodeProcessor.kt index fc00bca..7ba0cc1 100644 --- a/modifier-node-processor/src/main/kotlin/com/sonms/modifiernode/processor/ModifierNodeProcessor.kt +++ b/modifier-node-processor/src/main/kotlin/com/sonms/modifiernode/processor/ModifierNodeProcessor.kt @@ -1,6 +1,7 @@ package com.sonms.modifiernode.processor import com.google.devtools.ksp.getAllSuperTypes +import com.google.devtools.ksp.getDeclaredProperties import com.google.devtools.ksp.processing.CodeGenerator import com.google.devtools.ksp.processing.KSPLogger import com.google.devtools.ksp.processing.Resolver @@ -85,12 +86,23 @@ class ModifierNodeProcessor( val params = ctor.parameters.map { p -> resolveParam(p, node, supported) } - // 주의: Compose 는 update() 직후 autoInvalidateUpdatedNode() 로 노드의 모든 capability 를 - // 무조건 무효화한다(NodeChain.updateNode). 유일한 억제 수단인 Modifier.Node.shouldAutoInvalidate - // 는 (1) Element 가 아니라 Node 쪽 프로퍼티라 codegen 이 주입 못 하고 (2) Compose 1.12 에서 - // @Deprecated 다. 따라서 생성된 update() 의 세분화된 invalidate 호출은 사실상 "이중" 이며 - // 런타임 최적화 효과가 없다. @Invalidates 는 현재 컴파일 타임 검증 용도로만 의미가 있다. - // 자세한 내용: ARCHITECTURE.md §10. + // Compose 는 update() 직후 autoInvalidateUpdatedNode() 로 노드의 모든 capability 를 + // 무효화한다(NodeChain.updateNode → NodeKind.autoInvalidateNodeSelf). 세분화된 update() + // 가 실효를 가지려면 Node 가 `shouldAutoInvalidate = false` 를 선언해야 한다. + // (지원되는 문서화된 API — Compose 의 graphicsLayer/paint 모디파이어가 동일 패턴 사용. + // 자세한 내용: ARCHITECTURE.md §10.) + // codegen 은 Node 클래스를 수정 못 하므로, @Invalidates 를 쓴 경우 이 선언을 요구한다. + val hasExplicitScopes = ctor.parameters.any { p -> + p.annotations.any { it.shortName.asString() == "Invalidates" } + } + if (hasExplicitScopes && !nodeDeclaresShouldAutoInvalidate(node)) { + logger.error( + "@Invalidates 를 쓰려면 Node 에 다음 한 줄이 필요하다 (그래야 세분화가 실효를 가짐):\n" + + " override val shouldAutoInvalidate: Boolean get() = false\n" + + "미선언 시 Compose 가 update() 후 모든 capability 를 무효화하여 @Invalidates 가 무의미해진다.", + node, + ) + } // --- 확장 함수: fun Modifier.(...): Modifier = this.then(Element(...)) --- val extFun = FunSpec.builder(funName) @@ -301,6 +313,10 @@ class ModifierNodeProcessor( private fun KSClassDeclaration.isPublic(): Boolean = modifiers.none { it.name == "INTERNAL" || it.name == "PRIVATE" || it.name == "PROTECTED" } + /** Node 클래스가 자체적으로 `shouldAutoInvalidate` 를 override 선언했는지 (값 false 여부는 신뢰). */ + private fun nodeDeclaresShouldAutoInvalidate(node: KSClassDeclaration): Boolean = + node.getDeclaredProperties().any { it.simpleName.asString() == "shouldAutoInvalidate" } + companion object { private val ANY_NULLABLE = ClassName("kotlin", "Any").copy(nullable = true) } diff --git a/modifier-node-sample/COMPARISON.md b/modifier-node-sample/COMPARISON.md index 9d7510b..cfb807c 100644 --- a/modifier-node-sample/COMPARISON.md +++ b/modifier-node-sample/COMPARISON.md @@ -141,13 +141,18 @@ internal class PressScaleNode( --- -## ⚠️ 그 뒤 발견 — invalidation 스코프는 작동하지 않는다 +## ⚠️ 그 뒤 발견 — invalidation 스코프는 **작동한다** (앞선 정정) -`ARCHITECTURE.md` §10 참고. `NodeChain.updateNode` 바이트코드상 `update()` 직후 -`autoInvalidateUpdatedNode()` 가 무조건 실행되어 노드의 모든 capability 를 무효화한다. -억제 수단 `Modifier.Node.shouldAutoInvalidate` 는 Element 가 아니라 Node 쪽이고 Compose 1.12 -에서 deprecated. **따라서 위 "종합" 표의 "모든 부류 공통: `@Invalidates` 정합성 체크" 는 -컴파일 타임 lint 로만 유효하고 런타임 성능 효과는 없다.** +`ARCHITECTURE.md` §10 참고. 한때 "작동 안 함" 이라 적었으나 **오독이었다**: -codegen 이 실제로 주는 것은 (1) 보일러플레이트 제거 (2) equals 스킵을 보장하는 정확한 -`equals`/`hashCode`. → 라이브러리 명제 재검토는 `ARCHITECTURE.md` §11. +- `NodeKind.autoInvalidateNodeSelf` 에 opt-out 이 있다: + `if (phase == Updated && !node.shouldAutoInvalidate) return` +- `Modifier.Node.shouldAutoInvalidate` 는 deprecated 아님. Compose 의 `graphicsLayer`/`paint` + 모디파이어가 이 패턴을 쓴다. +- 통제 실험(`AutoInvalidateProbeTest`): `shouldAutoInvalidate = false` 노드는 draw 전용 + 파라미터 변경 시 remeasure 안 함. + +제약: codegen 이 Node 클래스에 그 한 줄을 못 넣으므로, `@Invalidates` 사용 시 KSP 가 +`override val shouldAutoInvalidate get() = false` 를 요구한다(없으면 컴파일 에러). + +단 성능 이득 범위는 좁다 (§10.5). 주된 가치는 여전히 보일러플레이트 제거 + equals 안정성. diff --git a/modifier-node-sample/src/main/kotlin/com/sonms/modifiernode/sample/FadingEdge.kt b/modifier-node-sample/src/main/kotlin/com/sonms/modifiernode/sample/FadingEdge.kt index 7bd6760..f91d03f 100644 --- a/modifier-node-sample/src/main/kotlin/com/sonms/modifiernode/sample/FadingEdge.kt +++ b/modifier-node-sample/src/main/kotlin/com/sonms/modifiernode/sample/FadingEdge.kt @@ -36,6 +36,8 @@ internal class FadingEdgeNode( @Invalidates(Draw) var enabled: Boolean, ) : Modifier.Node(), DrawModifierNode { + override val shouldAutoInvalidate: Boolean get() = false + private var layer: GraphicsLayer? = null override fun onAttach() { diff --git a/modifier-node-sample/src/main/kotlin/com/sonms/modifiernode/sample/SampleNodes.kt b/modifier-node-sample/src/main/kotlin/com/sonms/modifiernode/sample/SampleNodes.kt index 5d24a3c..5b8bce0 100644 --- a/modifier-node-sample/src/main/kotlin/com/sonms/modifiernode/sample/SampleNodes.kt +++ b/modifier-node-sample/src/main/kotlin/com/sonms/modifiernode/sample/SampleNodes.kt @@ -23,6 +23,8 @@ import com.sonms.modifiernode.annotations.ModifierNodeFactory internal class DebugTintNode( @Invalidates(Draw) var color: Color, ) : Modifier.Node(), DrawModifierNode { + override val shouldAutoInvalidate: Boolean get() = false + override fun ContentDrawScope.draw() { drawContent() drawRect(color) @@ -39,6 +41,8 @@ internal class FixedSquareNode( @Invalidates(Draw) var overlay: Color, ) : Modifier.Node(), DrawModifierNode, LayoutModifierNode { + override val shouldAutoInvalidate: Boolean get() = false + override fun MeasureScope.measure(measurable: Measurable, constraints: Constraints): MeasureResult { val px = side.roundToPx() val placeable = measurable.measure(Constraints.fixed(px, px)) diff --git a/modifier-node-sample/src/main/kotlin/com/sonms/modifiernode/sample/compare/LocalTint.kt b/modifier-node-sample/src/main/kotlin/com/sonms/modifiernode/sample/compare/LocalTint.kt index ea75ed7..1dbf99e 100644 --- a/modifier-node-sample/src/main/kotlin/com/sonms/modifiernode/sample/compare/LocalTint.kt +++ b/modifier-node-sample/src/main/kotlin/com/sonms/modifiernode/sample/compare/LocalTint.kt @@ -53,6 +53,8 @@ fun Modifier.accentOverlayComposed(alpha: Float): Modifier = composed { internal class AccentOverlayNode( @Invalidates(Draw) var alpha: Float, ) : Modifier.Node(), DrawModifierNode, CompositionLocalConsumerModifierNode { + override val shouldAutoInvalidate: Boolean get() = false + override fun ContentDrawScope.draw() { val color = currentValueOf(LocalAccentColor) drawContent() diff --git a/modifier-node-sample/src/main/kotlin/com/sonms/modifiernode/sample/compare/PressScale.kt b/modifier-node-sample/src/main/kotlin/com/sonms/modifiernode/sample/compare/PressScale.kt index 8c931bb..a86e257 100644 --- a/modifier-node-sample/src/main/kotlin/com/sonms/modifiernode/sample/compare/PressScale.kt +++ b/modifier-node-sample/src/main/kotlin/com/sonms/modifiernode/sample/compare/PressScale.kt @@ -69,6 +69,8 @@ internal class PressScaleNode( @Invalidates(None) var interactionSource: InteractionSource, ) : Modifier.Node(), DrawModifierNode { + override val shouldAutoInvalidate: Boolean get() = false + private val scaleAnim = Animatable(1f) private var collectJob: Job? = null private var boundSource: InteractionSource? = null diff --git a/modifier-node-sample/src/test/kotlin/com/sonms/modifiernode/sample/AutoInvalidateProbeTest.kt b/modifier-node-sample/src/test/kotlin/com/sonms/modifiernode/sample/AutoInvalidateProbeTest.kt new file mode 100644 index 0000000..0298525 --- /dev/null +++ b/modifier-node-sample/src/test/kotlin/com/sonms/modifiernode/sample/AutoInvalidateProbeTest.kt @@ -0,0 +1,159 @@ +package com.sonms.modifiernode.sample + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.drawscope.ContentDrawScope +import androidx.compose.ui.layout.Measurable +import androidx.compose.ui.layout.MeasureResult +import androidx.compose.ui.layout.MeasureScope +import androidx.compose.ui.node.DrawModifierNode +import androidx.compose.ui.node.LayoutModifierNode +import androidx.compose.ui.node.ModifierNodeElement +import androidx.compose.ui.node.invalidateDraw +import androidx.compose.ui.node.invalidateMeasurement +import androidx.compose.ui.platform.InspectorInfo +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.dp +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** + * 통제 실험: "파라미터 단위 invalidation" 이 실제로 효과를 내는가? + * + * 동일 하네스에서 두 노드를 비교한다. + * - [AutoOnNode] : shouldAutoInvalidate = 기본(true). update() 후 Compose 가 measure+draw 전부 무효화. + * - [AutoOffNode] : shouldAutoInvalidate = false. update() 가 부른 것만 무효화되어야 함. + * + * drawKey 만 바꿨을 때: + * - AutoOn → measures >= 1 (autoInvalidate 가 remeasure 시킴) + * - AutoOff → measures == 0 (draw 만) ← 이게 나오면 §10 "불가" 결론은 틀렸다 + * + * 카운터는 노드 자신의 measure()/draw() 안에서 증가 → wrapper 레벨 리메저와 무관. + */ +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [34]) +class AutoInvalidateProbeTest { + + @get:Rule + val rule = createComposeRule() + + private class Counters { + var measures = 0 + var draws = 0 + } + + private val onC = Counters() + private val offC = Counters() + + // ── autoInvalidate ON (default) ────────────────────────────────────────── + private inner class AutoOnNode(var drawKey: Int, var measureKey: Int) : + Modifier.Node(), DrawModifierNode, LayoutModifierNode { + override fun MeasureScope.measure(m: Measurable, c: Constraints): MeasureResult { + onC.measures++ + val p = m.measure(c) + return layout(p.width, p.height) { p.place(0, 0) } + } + override fun ContentDrawScope.draw() { onC.draws++; drawContent() } + } + + private inner class AutoOnElement(val drawKey: Int, val measureKey: Int) : + ModifierNodeElement() { + override fun create() = AutoOnNode(drawKey, measureKey) + override fun update(node: AutoOnNode) { + val d = node.drawKey != drawKey + val m = node.measureKey != measureKey + node.drawKey = drawKey + node.measureKey = measureKey + if (m) node.invalidateMeasurement() else if (d) node.invalidateDraw() + } + override fun InspectorInfo.inspectableProperties() { name = "autoOn" } + override fun equals(other: Any?) = + other is AutoOnElement && other.drawKey == drawKey && other.measureKey == measureKey + override fun hashCode() = drawKey * 31 + measureKey + } + + // ── autoInvalidate OFF ─────────────────────────────────────────────────── + private inner class AutoOffNode(var drawKey: Int, var measureKey: Int) : + Modifier.Node(), DrawModifierNode, LayoutModifierNode { + @Suppress("DEPRECATION", "OVERRIDE_DEPRECATION") + override val shouldAutoInvalidate: Boolean get() = false + override fun MeasureScope.measure(m: Measurable, c: Constraints): MeasureResult { + offC.measures++ + val p = m.measure(c) + return layout(p.width, p.height) { p.place(0, 0) } + } + override fun ContentDrawScope.draw() { offC.draws++; drawContent() } + } + + private inner class AutoOffElement(val drawKey: Int, val measureKey: Int) : + ModifierNodeElement() { + override fun create() = AutoOffNode(drawKey, measureKey) + override fun update(node: AutoOffNode) { + val d = node.drawKey != drawKey + val m = node.measureKey != measureKey + node.drawKey = drawKey + node.measureKey = measureKey + if (m) node.invalidateMeasurement() else if (d) node.invalidateDraw() + } + override fun InspectorInfo.inspectableProperties() { name = "autoOff" } + override fun equals(other: Any?) = + other is AutoOffElement && other.drawKey == drawKey && other.measureKey == measureKey + override fun hashCode() = drawKey * 31 + measureKey + } + + /** @param mutate 0 = drawKey 변경, 1 = measureKey 변경 */ + private fun run(makeOn: Boolean, mutate: Int): Counters { + val dk = mutableIntStateOf(0) + val mk = mutableIntStateOf(0) + val counters = if (makeOn) onC else offC + + rule.setContent { + val d = dk.intValue + val m = mk.intValue + val mod: Modifier = if (makeOn) { + Modifier.size(24.dp).then(AutoOnElement(d, m)) + } else { + Modifier.size(24.dp).then(AutoOffElement(d, m)) + } + Box(mod) + } + rule.waitForIdle() + counters.measures = 0 + counters.draws = 0 + + if (mutate == 0) dk.intValue = 1 else mk.intValue = 1 + rule.waitForIdle() + return counters + } + + /** 기준선: autoInvalidate ON → draw 파라미터만 바꿔도 remeasure. */ + @Test + fun autoOn_drawParam_change_remeasures() { + val c = run(makeOn = true, mutate = 0) + assertTrue("autoInvalidate ON: draw param change remeasures (baseline)", c.measures >= 1) + } + + /** 핵심 A: autoInvalidate OFF → draw 파라미터 변경이 remeasure 를 유발하지 않는다. */ + @Test + fun autoOff_drawParam_change_does_NOT_remeasure() { + val c = run(makeOn = false, mutate = 0) + assertEquals("autoInvalidate OFF: draw param change must NOT remeasure", 0, c.measures) + } + + /** 핵심 B: autoInvalidate OFF → measure 파라미터 변경 시 update() 의 invalidateMeasurement() 는 작동한다. */ + @Test + fun autoOff_measureParam_change_DOES_remeasure_via_update() { + val c = run(makeOn = false, mutate = 1) + assertTrue("autoInvalidate OFF: manual invalidateMeasurement() in update() must work", c.measures >= 1) + } +} diff --git a/modifier-node-sample/src/test/kotlin/com/sonms/modifiernode/sample/InvalidationContractTest.kt b/modifier-node-sample/src/test/kotlin/com/sonms/modifiernode/sample/InvalidationContractTest.kt index 0214b81..a6d57ef 100644 --- a/modifier-node-sample/src/test/kotlin/com/sonms/modifiernode/sample/InvalidationContractTest.kt +++ b/modifier-node-sample/src/test/kotlin/com/sonms/modifiernode/sample/InvalidationContractTest.kt @@ -41,6 +41,8 @@ internal class ProbeNode( @Invalidates(Measure) var measureKey: Int, ) : Modifier.Node(), DrawModifierNode, LayoutModifierNode { + override val shouldAutoInvalidate: Boolean get() = false + override fun MeasureScope.measure(measurable: Measurable, constraints: Constraints): MeasureResult { ProbeCounters.measures++ val placeable = measurable.measure(constraints) @@ -54,12 +56,12 @@ internal class ProbeNode( } /** - * codegen 이 주는 유일한 런타임 최적화 = **equals 스킵**. + * codegen 이 주는 최적화 중 하나 = **equals 스킵**. * 파라미터가 안 바뀌면 `Element.equals` == true → Compose 가 `update()` 를 아예 안 부름 * → 측정/그리기 무효화 없음. * - * (파라미터 단위 invalidation 스코프는 작동하지 않는다 — ARCHITECTURE.md §10. - * `NodeChain.updateNode` 바이트코드상 `update()` 직후 `autoInvalidateUpdatedNode()` 무조건 호출.) + * (파라미터 단위 invalidation 스코프는 `shouldAutoInvalidate = false` 를 통해 작동한다 — + * `AutoInvalidateProbeTest` + ARCHITECTURE.md §10.) */ @RunWith(RobolectricTestRunner::class) @GraphicsMode(GraphicsMode.Mode.NATIVE) From 89a8bd1826421629268c9019b09d9dcf1ebc3269 Mon Sep 17 00:00:00 2001 From: sonms Date: Tue, 8 Sep 2026 16:17:15 +0900 Subject: [PATCH 06/21] [Add] ABI hiding: generated Element always internal, function visibility opt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - @ModifierNodeFactory(visibility = GeneratedVisibility.Public | Internal), default Public — controls the generated Modifier.() extension function - generated Element is now always internal regardless of node visibility, so only the extension function is part of the binary API - node validation: private node => error (generated file can't reference it); public node => warning (it's in your ABI, make it internal) - GeneratedElementTest.generated_element_is_internal (needs kotlin-reflect) - ARCHITECTURE §11: B confirmed; ABI task done, skipWhen next Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_013UGir6UrorM7Kx7iyYv1ir --- ARCHITECTURE.md | 26 +++++++----- gradle/libs.versions.toml | 1 + .../modifiernode/annotations/Annotations.kt | 8 ++++ .../processor/ModifierNodeProcessor.kt | 40 +++++++++++++++++-- modifier-node-sample/build.gradle.kts | 1 + .../sample/GeneratedElementTest.kt | 8 ++++ 6 files changed, 71 insertions(+), 13 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 793439d..e913cb3 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -336,20 +336,26 @@ codegen 은 정밀한 `update()` (계층 접기 포함)를 생성한다. `Modifi ### 갈래 -**B. 정직한 재정의 (권장).** +**→ B 확정 (2026-09-08).** + +**B. 정직한 재정의.** 명제: *"`Modifier.Node` 보일러플레이트 제거 + equals 안정성 보장 + (opt-in) 파라미터 단위 invalidation"*. 성능은 부차적 셀링포인트로만, 과장 없이. 이미 만든 인프라가 거의 그대로 완성형. -남은 작업: -- ABI: Element/Node `internal` 은닉 + 함수만 공개 -- `@ModifierNodeFactory(skipWhen=)` — 조건부 적용 (§9-3) -- `onUpdate` 훅 — interaction/re-subscribe (§10 Case B) -- default argument 지원 검토 -- README 에 §5 한계 + §10.5 성능 현실 명시 -**A. 중단.** 니치가 좁고 파라미터 단위 이득이 미미하다고 보면 여전히 유효한 선택. -지식(§10)은 문서로 남긴다. +남은 작업 (순서): +1. **ABI: Element `internal` 강제 + 공개 함수 visibility 제어** ✅ 완료 + - `Element` 는 노드 가시성과 무관하게 항상 `internal` (`generated_element_is_internal` 테스트) + - `@ModifierNodeFactory(visibility = GeneratedVisibility.Public | Internal)`, 기본 `Public` + - Node 가 `private` 면 에러(생성 파일에서 참조 불가), `public` 이면 "ABI 노출" 경고 + - `public fun Modifier.foo()` 본문이 `internal` Element 를 참조 — Kotlin 허용(시그니처만 노출 검사) +2. `@ModifierNodeFactory(skipWhen=)` — 조건부 적용 (§9-3) ← 다음 +3. `onUpdate` 훅 — interaction/re-subscribe (§10 Case B) +4. default argument 지원 검토 +5. README 에 §5 한계 + §10.5 성능 현실 명시 + +**A. 중단.** (보류) 니치가 좁고 파라미터 단위 이득이 미미하다고 보면 유효했던 선택. -**C. 목표 전환.** "Modifier.Node 를 쉽게" 로 확장 (노드 유틸/delegation/테스트). 작업량 큼. +**C. 목표 전환.** (보류) "Modifier.Node 를 쉽게" 로 확장 (노드 유틸/delegation/테스트). 작업량 큼. ### 남길 것 (어느 갈래든) - `ARCHITECTURE.md` §10 — autoInvalidate 메커니즘 + `shouldAutoInvalidate` opt-out 사실관계 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0e59b37..5c54aa3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -40,6 +40,7 @@ ksp-symbol-processing-api = { group = "com.google.devtools.ksp", name = "symbol- kotlinpoet = { group = "com.squareup", name = "kotlinpoet", version.ref = "kotlinpoet" } kotlinpoet-ksp = { group = "com.squareup", name = "kotlinpoet-ksp", version.ref = "kotlinpoet" } robolectric = { group = "org.robolectric", name = "robolectric", version.ref = "robolectric" } +kotlin-reflect = { group = "org.jetbrains.kotlin", name = "kotlin-reflect", version.ref = "kotlin" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } diff --git a/modifier-node-annotations/src/main/kotlin/com/sonms/modifiernode/annotations/Annotations.kt b/modifier-node-annotations/src/main/kotlin/com/sonms/modifiernode/annotations/Annotations.kt index dfedabc..d7b00aa 100644 --- a/modifier-node-annotations/src/main/kotlin/com/sonms/modifiernode/annotations/Annotations.kt +++ b/modifier-node-annotations/src/main/kotlin/com/sonms/modifiernode/annotations/Annotations.kt @@ -20,8 +20,16 @@ package com.sonms.modifiernode.annotations annotation class ModifierNodeFactory( /** 생성될 확장 함수 이름. 비우면 `` 에서 `Node` 접미사를 떼고 첫 글자를 소문자로 바꿔 유도한다. */ val name: String = "", + /** + * 생성될 `Modifier.(...)` 확장 함수의 가시성. + * 생성된 `Element` 는 이 값과 무관하게 항상 `internal` 이다 (ABI 표면에서 제외). + */ + val visibility: GeneratedVisibility = GeneratedVisibility.Public, ) +/** [ModifierNodeFactory.visibility] 값. */ +enum class GeneratedVisibility { Public, Internal } + /** * 이 파라미터가 바뀌었을 때 트리거할 무효화 범위. * diff --git a/modifier-node-processor/src/main/kotlin/com/sonms/modifiernode/processor/ModifierNodeProcessor.kt b/modifier-node-processor/src/main/kotlin/com/sonms/modifiernode/processor/ModifierNodeProcessor.kt index 7ba0cc1..f5336ec 100644 --- a/modifier-node-processor/src/main/kotlin/com/sonms/modifiernode/processor/ModifierNodeProcessor.kt +++ b/modifier-node-processor/src/main/kotlin/com/sonms/modifiernode/processor/ModifierNodeProcessor.kt @@ -67,7 +67,30 @@ class ModifierNodeProcessor( val elementName = "${nodeName.removeSuffix("Node")}Element" val elementClass = ClassName(pkg, elementName) - val visibility = if (node.isPublic()) KModifier.PUBLIC else KModifier.INTERNAL + // 확장 함수 가시성: @ModifierNodeFactory(visibility=...), 기본 Public. + val funVisibility = when (factoryAnn.enumArgName("visibility")) { + "Internal" -> KModifier.INTERNAL + else -> KModifier.PUBLIC + } + // Element 는 노드 가시성과 무관하게 항상 internal (ABI 표면에서 제외). + val elementVisibility = KModifier.INTERNAL + + // Node 가시성 검증. + when { + node.isPrivate() -> { + logger.error( + "@ModifierNodeFactory node must not be private — the generated $elementName " + + "(separate file) cannot reference it. Use 'internal'.", + node, + ) + return + } + node.isPublic() -> logger.warn( + "@ModifierNodeFactory node '$nodeName' is public → it is part of your binary API. " + + "Make it 'internal' so only Modifier.$funName(...) is exposed.", + node, + ) + } val ctor = node.primaryConstructor if (ctor == null) { @@ -106,7 +129,7 @@ class ModifierNodeProcessor( // --- 확장 함수: fun Modifier.(...): Modifier = this.then(Element(...)) --- val extFun = FunSpec.builder(funName) - .addModifiers(visibility) + .addModifiers(funVisibility) .receiver(MODIFIER) .returns(MODIFIER) .apply { params.forEach { addParameter(it.name, it.type) } } @@ -119,7 +142,7 @@ class ModifierNodeProcessor( // --- Element 클래스 --- val elementType = TypeSpec.classBuilder(elementClass) - .addModifiers(visibility) + .addModifiers(elementVisibility) .superclass(MODIFIER_NODE_ELEMENT.parameterizedBy(nodeClass)) .primaryConstructor( FunSpec.constructorBuilder() @@ -313,6 +336,9 @@ class ModifierNodeProcessor( private fun KSClassDeclaration.isPublic(): Boolean = modifiers.none { it.name == "INTERNAL" || it.name == "PRIVATE" || it.name == "PROTECTED" } + private fun KSClassDeclaration.isPrivate(): Boolean = + modifiers.any { it.name == "PRIVATE" } + /** Node 클래스가 자체적으로 `shouldAutoInvalidate` 를 override 선언했는지 (값 false 여부는 신뢰). */ private fun nodeDeclaresShouldAutoInvalidate(node: KSClassDeclaration): Boolean = node.getDeclaredProperties().any { it.simpleName.asString() == "shouldAutoInvalidate" } @@ -326,6 +352,14 @@ class ModifierNodeProcessor( private fun KSAnnotation.argValue(name: String): Any? = arguments.firstOrNull { it.name?.asString() == name }?.value +/** 단일 enum 인자를 엔트리 simpleName 으로 (없으면 null). */ +private fun KSAnnotation.enumArgName(name: String): String? = when (val raw = argValue(name)) { + is KSType -> raw.declaration.simpleName.asString() + is KSClassDeclaration -> raw.simpleName.asString() + null -> null + else -> raw.toString().substringAfterLast('.') +} + /** enum vararg 인자를 엔트리 simpleName 리스트로. */ private fun KSAnnotation.enumArgNames(name: String): List { val raw = argValue(name) ?: return emptyList() diff --git a/modifier-node-sample/build.gradle.kts b/modifier-node-sample/build.gradle.kts index eee4096..fa7c4e7 100644 --- a/modifier-node-sample/build.gradle.kts +++ b/modifier-node-sample/build.gradle.kts @@ -36,6 +36,7 @@ dependencies { testImplementation(libs.junit) testImplementation(libs.robolectric) + testImplementation(libs.kotlin.reflect) testImplementation(platform(libs.androidx.compose.bom)) testImplementation(libs.androidx.compose.ui.test.junit4) testImplementation(libs.androidx.compose.ui.tooling) diff --git a/modifier-node-sample/src/test/kotlin/com/sonms/modifiernode/sample/GeneratedElementTest.kt b/modifier-node-sample/src/test/kotlin/com/sonms/modifiernode/sample/GeneratedElementTest.kt index ab17104..39fe3db 100644 --- a/modifier-node-sample/src/test/kotlin/com/sonms/modifiernode/sample/GeneratedElementTest.kt +++ b/modifier-node-sample/src/test/kotlin/com/sonms/modifiernode/sample/GeneratedElementTest.kt @@ -7,6 +7,7 @@ import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config +import kotlin.reflect.KVisibility /** * codegen 이 실제로 보장하는 계약을 고정한다 — Compose 런타임 불필요. @@ -59,5 +60,12 @@ class GeneratedElementTest { assertEquals(Color.Green, node.color) } + /** ABI: 생성된 Element 는 노드 가시성과 무관하게 internal (공개 API 표면에서 제외). */ + @Test + fun generated_element_is_internal() { + assertEquals(KVisibility.INTERNAL, DebugTintElement::class.visibility) + assertEquals(KVisibility.INTERNAL, FixedSquareElement::class.visibility) + } + private fun mkDp(v: Int) = androidx.compose.ui.unit.Dp(v.toFloat()) } From 840140987d91fa5892cae04fee52f84fee06617d Mon Sep 17 00:00:00 2001 From: sonms Date: Tue, 8 Sep 2026 16:24:46 +0900 Subject: [PATCH 07/21] [Add] Conditional application via @SkipWhenFalse / @SkipWhenTrue markers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - marker annotations on a Boolean parameter; processor prepends `if (!param) return this` (multiple markers OR-joined) to the generated Modifier.() function, so a disabled modifier never attaches a node - validation: marker requires a Boolean param; not both markers on one param - fadingEdge dogfood updated to `@SkipWhenFalse var enabled` (replaces the in-node !enabled branch) - skipWhenFalse_gates_application test: Modifier.skipProbe(on=false) === Modifier - ARCHITECTURE §11: item 2 done, onUpdate hook next Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_013UGir6UrorM7Kx7iyYv1ir --- ARCHITECTURE.md | 8 +++- .../modifiernode/annotations/Annotations.kt | 19 ++++++++++ .../processor/ModifierNodeProcessor.kt | 37 ++++++++++++++++++- .../sonms/modifiernode/sample/FadingEdge.kt | 16 ++++---- .../sample/InvalidationContractTest.kt | 19 ++++++++++ 5 files changed, 87 insertions(+), 12 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index e913cb3..c4b94ff 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -348,8 +348,12 @@ invalidation"*. 성능은 부차적 셀링포인트로만, 과장 없이. 이미 - `@ModifierNodeFactory(visibility = GeneratedVisibility.Public | Internal)`, 기본 `Public` - Node 가 `private` 면 에러(생성 파일에서 참조 불가), `public` 이면 "ABI 노출" 경고 - `public fun Modifier.foo()` 본문이 `internal` Element 를 참조 — Kotlin 허용(시그니처만 노출 검사) -2. `@ModifierNodeFactory(skipWhen=)` — 조건부 적용 (§9-3) ← 다음 -3. `onUpdate` 훅 — interaction/re-subscribe (§10 Case B) +2. **조건부 적용 — 마커 어노테이션 `@SkipWhenFalse` / `@SkipWhenTrue`** ✅ 완료 + - `Boolean` 파라미터에 부착 → 생성 함수 앞에 `if (!param) return this` (여럿이면 OR) + - skip 시 노드 미attach. "사라지는 애니메이션" 은 불가 → 그 경우 마커 없이 노드가 no-op + - `skipWhenFalse_gates_application` 테스트 (`Modifier.skipProbe(on=false) === Modifier`) + - `fadingEdge` dogfood 를 `@SkipWhenFalse enabled` 로 갱신 +3. `onUpdate` 훅 — interaction/re-subscribe (§10 Case B) ← 다음 4. default argument 지원 검토 5. README 에 §5 한계 + §10.5 성능 현실 명시 diff --git a/modifier-node-annotations/src/main/kotlin/com/sonms/modifiernode/annotations/Annotations.kt b/modifier-node-annotations/src/main/kotlin/com/sonms/modifiernode/annotations/Annotations.kt index d7b00aa..a0f58e9 100644 --- a/modifier-node-annotations/src/main/kotlin/com/sonms/modifiernode/annotations/Annotations.kt +++ b/modifier-node-annotations/src/main/kotlin/com/sonms/modifiernode/annotations/Annotations.kt @@ -43,6 +43,25 @@ annotation class Invalidates( vararg val scopes: InvalidationScope, ) +/** + * 이 `Boolean` 파라미터가 `false` 면 modifier 를 적용하지 않는다. + * + * 생성된 `Modifier.(...)` 함수 맨 앞에 `if (!param) return this` 가 삽입되어, + * 조건 불충족 시 노드가 아예 attach 되지 않는다. + * + * 여러 `@SkipWhen*` 파라미터가 있으면 하나라도 skip 조건이면 skip 한다(OR). + * 주의: skip 시 modifier 가 완전히 제거되므로 "사라지는 애니메이션" 은 불가능하다. + * 그런 경우엔 마커 없이 노드가 파라미터를 읽어 no-op 하도록 구현하라. + */ +@Target(AnnotationTarget.VALUE_PARAMETER) +@Retention(AnnotationRetention.SOURCE) +annotation class SkipWhenFalse + +/** [SkipWhenFalse] 의 반대. 이 `Boolean` 파라미터가 `true` 면 modifier 를 적용하지 않는다. */ +@Target(AnnotationTarget.VALUE_PARAMETER) +@Retention(AnnotationRetention.SOURCE) +annotation class SkipWhenTrue + /** * 무효화 범위. 계층: [Measure] > [Placement] > [Draw] (상위 하나만 호출). * [Semantics], [ParentData] 는 직교(별도 호출). [None] 은 무효화 없음. diff --git a/modifier-node-processor/src/main/kotlin/com/sonms/modifiernode/processor/ModifierNodeProcessor.kt b/modifier-node-processor/src/main/kotlin/com/sonms/modifiernode/processor/ModifierNodeProcessor.kt index f5336ec..182f76a 100644 --- a/modifier-node-processor/src/main/kotlin/com/sonms/modifiernode/processor/ModifierNodeProcessor.kt +++ b/modifier-node-processor/src/main/kotlin/com/sonms/modifiernode/processor/ModifierNodeProcessor.kt @@ -38,6 +38,9 @@ private const val LAYOUT_MODIFIER_NODE = "androidx.compose.ui.node.LayoutModifie /** 무효화 범위. 처리기 내부 표현. */ private enum class Scope { Measure, Placement, Draw, Semantics, ParentData, None } +/** @SkipWhenFalse / @SkipWhenTrue 처리기 내부 표현. */ +private enum class SkipKind { WhenFalse, WhenTrue } + class ModifierNodeProcessor( private val codeGenerator: CodeGenerator, private val logger: KSPLogger, @@ -127,12 +130,26 @@ class ModifierNodeProcessor( ) } - // --- 확장 함수: fun Modifier.(...): Modifier = this.then(Element(...)) --- + // @SkipWhenFalse / @SkipWhenTrue → 확장 함수 앞에 가드 삽입. + val skipConditions = params.mapNotNull { p -> + when (p.skip) { + SkipKind.WhenFalse -> "!${p.name}" + SkipKind.WhenTrue -> p.name + null -> null + } + } + + // --- 확장 함수: fun Modifier.(...): Modifier { if () return this; return this.then(...) } --- val extFun = FunSpec.builder(funName) .addModifiers(funVisibility) .receiver(MODIFIER) .returns(MODIFIER) .apply { params.forEach { addParameter(it.name, it.type) } } + .apply { + if (skipConditions.isNotEmpty()) { + addStatement("if (%L) return this", skipConditions.joinToString(" || ")) + } + } .addStatement( "return this.then(%T(%L))", elementClass, @@ -176,6 +193,7 @@ class ModifierNodeProcessor( val name: String, val type: com.squareup.kotlinpoet.TypeName, val scopes: Set, + val skip: SkipKind?, ) private fun resolveParam(p: KSValueParameter, node: KSClassDeclaration, supported: Set): Param { @@ -183,6 +201,21 @@ class ModifierNodeProcessor( if (!p.isVar) { logger.error("Parameter '$name' must be a 'var' property parameter", p) } + + val skipFalse = p.annotations.any { it.shortName.asString() == "SkipWhenFalse" } + val skipTrue = p.annotations.any { it.shortName.asString() == "SkipWhenTrue" } + val isBoolean = p.type.resolve().declaration.qualifiedName?.asString() == "kotlin.Boolean" + if ((skipFalse || skipTrue) && !isBoolean) { + logger.error("@SkipWhen* requires a Boolean parameter; '$name' is not Boolean", p) + } + if (skipFalse && skipTrue) { + logger.error("'$name' has both @SkipWhenFalse and @SkipWhenTrue", p) + } + val skip = when { + skipFalse -> SkipKind.WhenFalse + skipTrue -> SkipKind.WhenTrue + else -> null + } val invAnn = p.annotations.firstOrNull { it.shortName.asString() == "Invalidates" } val scopes: Set = when { invAnn == null -> supported // 안전 기본값 @@ -211,7 +244,7 @@ class ModifierNodeProcessor( } } } - return Param(name, p.type.toTypeName(), scopes) + return Param(name, p.type.toTypeName(), scopes, skip) } private fun createFun(nodeClass: ClassName, params: List) = FunSpec.builder("create") diff --git a/modifier-node-sample/src/main/kotlin/com/sonms/modifiernode/sample/FadingEdge.kt b/modifier-node-sample/src/main/kotlin/com/sonms/modifiernode/sample/FadingEdge.kt index f91d03f..d6a69f2 100644 --- a/modifier-node-sample/src/main/kotlin/com/sonms/modifiernode/sample/FadingEdge.kt +++ b/modifier-node-sample/src/main/kotlin/com/sonms/modifiernode/sample/FadingEdge.kt @@ -12,6 +12,7 @@ import androidx.compose.ui.node.requireGraphicsContext import com.sonms.modifiernode.annotations.Invalidates import com.sonms.modifiernode.annotations.InvalidationScope.Draw import com.sonms.modifiernode.annotations.ModifierNodeFactory +import com.sonms.modifiernode.annotations.SkipWhenFalse /** * Dogfooding — `wheelpicker` 의 `Modifier.fadingEdge` 를 `Modifier.Node` 로 재작성. @@ -21,19 +22,17 @@ import com.sonms.modifiernode.annotations.ModifierNodeFactory * - [onAttach] / [onDetach] 에서 레이어 생성/반납 (composition 밖 lifecycle) * - [draw] 에서 컨텐츠를 레이어에 record 하고 같은 버퍼에 DstIn 마스크를 얹는다 * - * 결정 게이트 메모: - * - `enabled=false` 는 원본에서 "modifier 미적용(early return)" 이었다. 현재 codegen 은 - * `this.then(Element(...))` 를 무조건 생성하므로 **조건부 적용을 표현할 수 없다**. - * 여기서는 노드가 파라미터를 보고 no-op 하는 방식으로 우회했고, 그 대가로 disabled - * 상태에서도 노드/레이어가 attach 된다. + * 메모: + * - `enabled` 은 `@SkipWhenFalse` — 원본의 `if (!enabled) return this` 를 codegen 이 + * 생성 함수 앞에 넣어준다. `enabled=false` 면 노드/레이어가 아예 attach 되지 않는다. * - 그리기 로직(브러시 계산, record, DstIn, drawLayer)은 전부 손으로 작성했다. - * codegen 이 걷어낸 건 Element/equals/hashCode/update/inspector 뿐이다. + * codegen 이 걷어낸 건 Element/equals/hashCode/update/inspector + skip 가드 뿐이다. */ @ModifierNodeFactory(name = "fadingEdge") internal class FadingEdgeNode( @Invalidates(Draw) var isVertical: Boolean, @Invalidates(Draw) var fraction: Float, - @Invalidates(Draw) var enabled: Boolean, + @SkipWhenFalse var enabled: Boolean, ) : Modifier.Node(), DrawModifierNode { override val shouldAutoInvalidate: Boolean get() = false @@ -50,8 +49,9 @@ internal class FadingEdgeNode( } override fun ContentDrawScope.draw() { + // enabled 는 @SkipWhenFalse — 여기 도달했다면 항상 true (노드 미attach 로 걸러짐). val gl = layer - if (!enabled || gl == null) { + if (gl == null) { drawContent() return } diff --git a/modifier-node-sample/src/test/kotlin/com/sonms/modifiernode/sample/InvalidationContractTest.kt b/modifier-node-sample/src/test/kotlin/com/sonms/modifiernode/sample/InvalidationContractTest.kt index a6d57ef..55d40b0 100644 --- a/modifier-node-sample/src/test/kotlin/com/sonms/modifiernode/sample/InvalidationContractTest.kt +++ b/modifier-node-sample/src/test/kotlin/com/sonms/modifiernode/sample/InvalidationContractTest.kt @@ -17,7 +17,10 @@ import com.sonms.modifiernode.annotations.Invalidates import com.sonms.modifiernode.annotations.InvalidationScope.Draw import com.sonms.modifiernode.annotations.InvalidationScope.Measure import com.sonms.modifiernode.annotations.ModifierNodeFactory +import com.sonms.modifiernode.annotations.SkipWhenFalse import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotSame +import org.junit.Assert.assertSame import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -55,6 +58,15 @@ internal class ProbeNode( } } +@ModifierNodeFactory(name = "skipProbe") +internal class SkipProbeNode( + @SkipWhenFalse var on: Boolean, + @Invalidates(Draw) var tag: Int, +) : Modifier.Node(), DrawModifierNode { + override val shouldAutoInvalidate: Boolean get() = false + override fun ContentDrawScope.draw() = drawContent() +} + /** * codegen 이 주는 최적화 중 하나 = **equals 스킵**. * 파라미터가 안 바뀌면 `Element.equals` == true → Compose 가 `update()` 를 아예 안 부름 @@ -90,4 +102,11 @@ class InvalidationContractTest { assertEquals("equal Element must not trigger remeasure", 0, ProbeCounters.measures) assertEquals("equal Element must not trigger redraw", 0, ProbeCounters.draws) } + + /** @SkipWhenFalse: on=false 면 생성 함수가 리시버를 그대로 반환(노드 미적용). */ + @Test + fun skipWhenFalse_gates_application() { + assertSame("on=false → 리시버 그대로", Modifier, Modifier.skipProbe(on = false, tag = 0)) + assertNotSame("on=true → Element 추가됨", Modifier, Modifier.skipProbe(on = true, tag = 0)) + } } From d65dc4d31b8d353873321b796ef0500ecfde183c Mon Sep 17 00:00:00 2001 From: sonms Date: Tue, 8 Sep 2026 16:29:14 +0900 Subject: [PATCH 08/21] [Docs] Trim comments to wheelpicker style (bilingual, essentials only) - annotations: concise bilingual KDoc, drop the MVP-limitations essay - sample modifiers: one-line bilingual KDoc; move the composed-vs-Node rationale to COMPARISON.md, drop ASCII separators and investigation notes - tests: short "what this proves" comments, drop the misleading @Suppress("DEPRECATION") on shouldAutoInvalidate (it is not deprecated) - processor: shorten the shouldAutoInvalidate error/comment Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_013UGir6UrorM7Kx7iyYv1ir --- .../modifiernode/annotations/Annotations.kt | 74 ++++++++----------- .../processor/ModifierNodeProcessor.kt | 12 +-- .../sonms/modifiernode/sample/FadingEdge.kt | 16 +--- .../sonms/modifiernode/sample/SampleNodes.kt | 8 +- .../modifiernode/sample/compare/LocalTint.kt | 28 +------ .../modifiernode/sample/compare/PressScale.kt | 33 ++------- .../sample/AutoInvalidateProbeTest.kt | 30 +++----- .../sample/GeneratedElementTest.kt | 19 +---- .../sample/InvalidationContractTest.kt | 21 ++---- 9 files changed, 69 insertions(+), 172 deletions(-) diff --git a/modifier-node-annotations/src/main/kotlin/com/sonms/modifiernode/annotations/Annotations.kt b/modifier-node-annotations/src/main/kotlin/com/sonms/modifiernode/annotations/Annotations.kt index a0f58e9..7c78e5f 100644 --- a/modifier-node-annotations/src/main/kotlin/com/sonms/modifiernode/annotations/Annotations.kt +++ b/modifier-node-annotations/src/main/kotlin/com/sonms/modifiernode/annotations/Annotations.kt @@ -1,76 +1,62 @@ package com.sonms.modifiernode.annotations /** - * `Modifier.Node` 팩토리 생성 대상 표시. + * 이 `Modifier.Node` 서브클래스로부터 `ModifierNodeElement` 와 `Modifier` 확장 함수를 생성한다. + * Generates a `ModifierNodeElement` and a `Modifier` extension function from this `Modifier.Node` subclass. * - * 이 어노테이션이 붙은 `Modifier.Node` 서브클래스로부터 KSP가 다음을 생성한다. - * - `Element : ModifierNodeElement` (create / update / equals / hashCode / inspectableProperties) - * - `fun Modifier.(...): Modifier` 공개 확장 함수 + * 파라미터는 주 생성자에서 읽으며, 각각 `var` 프로퍼티 파라미터여야 한다. + * Parameters are read from the primary constructor and must each be a `var` property parameter. * - * 생성 규칙: - * - 파라미터 원천은 **주 생성자**다. 모든 추적 대상 파라미터는 `var` 프로퍼티 파라미터여야 한다. - * - 파라미터별 무효화 범위는 [Invalidates] 로 지정한다. 미지정 시 노드가 구현한 모든 범위를 무효화한다(안전 기본값). - * - * MVP 한계: - * - 기본값(default argument)은 생성 함수에 재현되지 않는다. 호출부에서 모든 인자를 전달해야 한다. - * - 지원 노드 인터페이스는 `DrawModifierNode`, `LayoutModifierNode` 뿐이다. + * @param name 생성될 확장 함수 이름. 비우면 클래스명에서 `Node` 접미사를 떼어 유도한다. + * Name of the generated extension function. Derived from the class name (minus `Node`) if blank. + * @param visibility 생성될 확장 함수의 가시성. 생성된 Element 는 항상 `internal` 이다. + * Visibility of the generated extension function. The generated Element is always `internal`. */ @Target(AnnotationTarget.CLASS) @Retention(AnnotationRetention.SOURCE) annotation class ModifierNodeFactory( - /** 생성될 확장 함수 이름. 비우면 `` 에서 `Node` 접미사를 떼고 첫 글자를 소문자로 바꿔 유도한다. */ val name: String = "", - /** - * 생성될 `Modifier.(...)` 확장 함수의 가시성. - * 생성된 `Element` 는 이 값과 무관하게 항상 `internal` 이다 (ABI 표면에서 제외). - */ val visibility: GeneratedVisibility = GeneratedVisibility.Public, ) -/** [ModifierNodeFactory.visibility] 값. */ +/** [ModifierNodeFactory.visibility] 값. Value for [ModifierNodeFactory.visibility]. */ enum class GeneratedVisibility { Public, Internal } /** - * 이 파라미터가 바뀌었을 때 트리거할 무효화 범위. + * 이 파라미터가 바뀌었을 때 무효화할 범위. 미지정 시 노드가 구현한 모든 범위를 무효화한다. + * Invalidation scopes to trigger when this parameter changes. Defaults to every scope the node implements. * - * 여러 범위를 지정할 수 있다. 예: `@Invalidates(Draw, Semantics)`. - * [InvalidationScope.None] 은 어떤 무효화도 하지 않음을 뜻한다(생성 시점에만 쓰이거나 - * `observeReads` 로 반응적으로 읽는 값). + * `@Invalidates` 를 쓰려면 노드가 `override val shouldAutoInvalidate get() = false` 를 선언해야 한다. + * Using `@Invalidates` requires the node to declare `override val shouldAutoInvalidate get() = false`. */ @Target(AnnotationTarget.VALUE_PARAMETER) @Retention(AnnotationRetention.SOURCE) -annotation class Invalidates( - vararg val scopes: InvalidationScope, -) +annotation class Invalidates(vararg val scopes: InvalidationScope) /** - * 이 `Boolean` 파라미터가 `false` 면 modifier 를 적용하지 않는다. + * 무효화 범위. 계층은 [Measure] > [Placement] > [Draw] (상위 하나만 호출). + * Invalidation scope. Hierarchy is [Measure] > [Placement] > [Draw] (only the highest one fires). * - * 생성된 `Modifier.(...)` 함수 맨 앞에 `if (!param) return this` 가 삽입되어, - * 조건 불충족 시 노드가 아예 attach 되지 않는다. + * [None] 은 무효화 없음. 생성 시점에만 쓰거나 `observeReads` 로 읽는 값에 사용한다. + * [None] means no invalidation — for values used only on create, or read via `observeReads`. + */ +enum class InvalidationScope { Measure, Placement, Draw, Semantics, ParentData, None } + +/** + * 이 `Boolean` 파라미터가 `false` 면 modifier 를 적용하지 않는다 (노드 미attach). + * The modifier is not applied when this `Boolean` parameter is `false` (no node attached). * - * 여러 `@SkipWhen*` 파라미터가 있으면 하나라도 skip 조건이면 skip 한다(OR). - * 주의: skip 시 modifier 가 완전히 제거되므로 "사라지는 애니메이션" 은 불가능하다. - * 그런 경우엔 마커 없이 노드가 파라미터를 읽어 no-op 하도록 구현하라. + * 생성 함수 앞에 `if (!param) return this` 가 삽입된다. 여러 개면 OR 로 묶인다. + * Prepends `if (!param) return this` to the generated function. Multiple markers are OR-joined. */ @Target(AnnotationTarget.VALUE_PARAMETER) @Retention(AnnotationRetention.SOURCE) annotation class SkipWhenFalse -/** [SkipWhenFalse] 의 반대. 이 `Boolean` 파라미터가 `true` 면 modifier 를 적용하지 않는다. */ +/** + * [SkipWhenFalse] 의 반대 — 이 `Boolean` 파라미터가 `true` 면 modifier 를 적용하지 않는다. + * Opposite of [SkipWhenFalse] — the modifier is not applied when this `Boolean` parameter is `true`. + */ @Target(AnnotationTarget.VALUE_PARAMETER) @Retention(AnnotationRetention.SOURCE) annotation class SkipWhenTrue - -/** - * 무효화 범위. 계층: [Measure] > [Placement] > [Draw] (상위 하나만 호출). - * [Semantics], [ParentData] 는 직교(별도 호출). [None] 은 무효화 없음. - */ -enum class InvalidationScope { - Measure, - Placement, - Draw, - Semantics, - ParentData, - None, -} diff --git a/modifier-node-processor/src/main/kotlin/com/sonms/modifiernode/processor/ModifierNodeProcessor.kt b/modifier-node-processor/src/main/kotlin/com/sonms/modifiernode/processor/ModifierNodeProcessor.kt index 182f76a..5e42639 100644 --- a/modifier-node-processor/src/main/kotlin/com/sonms/modifiernode/processor/ModifierNodeProcessor.kt +++ b/modifier-node-processor/src/main/kotlin/com/sonms/modifiernode/processor/ModifierNodeProcessor.kt @@ -112,20 +112,16 @@ class ModifierNodeProcessor( val params = ctor.parameters.map { p -> resolveParam(p, node, supported) } - // Compose 는 update() 직후 autoInvalidateUpdatedNode() 로 노드의 모든 capability 를 - // 무효화한다(NodeChain.updateNode → NodeKind.autoInvalidateNodeSelf). 세분화된 update() - // 가 실효를 가지려면 Node 가 `shouldAutoInvalidate = false` 를 선언해야 한다. - // (지원되는 문서화된 API — Compose 의 graphicsLayer/paint 모디파이어가 동일 패턴 사용. - // 자세한 내용: ARCHITECTURE.md §10.) - // codegen 은 Node 클래스를 수정 못 하므로, @Invalidates 를 쓴 경우 이 선언을 요구한다. + // @Invalidates 는 Node 가 shouldAutoInvalidate=false 를 선언해야 실효가 있다 (ARCHITECTURE.md §10). + // codegen 은 Node 클래스를 수정 못 하므로 여기서 요구한다. val hasExplicitScopes = ctor.parameters.any { p -> p.annotations.any { it.shortName.asString() == "Invalidates" } } if (hasExplicitScopes && !nodeDeclaresShouldAutoInvalidate(node)) { logger.error( - "@Invalidates 를 쓰려면 Node 에 다음 한 줄이 필요하다 (그래야 세분화가 실효를 가짐):\n" + + "@Invalidates requires the node to declare:\n" + " override val shouldAutoInvalidate: Boolean get() = false\n" + - "미선언 시 Compose 가 update() 후 모든 capability 를 무효화하여 @Invalidates 가 무의미해진다.", + "Otherwise Compose auto-invalidates every capability after update() and @Invalidates is a no-op.", node, ) } diff --git a/modifier-node-sample/src/main/kotlin/com/sonms/modifiernode/sample/FadingEdge.kt b/modifier-node-sample/src/main/kotlin/com/sonms/modifiernode/sample/FadingEdge.kt index d6a69f2..d9529a0 100644 --- a/modifier-node-sample/src/main/kotlin/com/sonms/modifiernode/sample/FadingEdge.kt +++ b/modifier-node-sample/src/main/kotlin/com/sonms/modifiernode/sample/FadingEdge.kt @@ -15,18 +15,11 @@ import com.sonms.modifiernode.annotations.ModifierNodeFactory import com.sonms.modifiernode.annotations.SkipWhenFalse /** - * Dogfooding — `wheelpicker` 의 `Modifier.fadingEdge` 를 `Modifier.Node` 로 재작성. + * 가장자리를 서서히 투명하게 만드는 페이드 엣지 효과. + * Fading edge effect that fades the edges to transparent. * - * 원본은 `graphicsLayer { compositingStrategy = Offscreen }.drawWithCache { ... }` 조합이었다. - * Node 판에서는 오프스크린 버퍼를 직접 [GraphicsLayer] 로 관리한다: - * - [onAttach] / [onDetach] 에서 레이어 생성/반납 (composition 밖 lifecycle) - * - [draw] 에서 컨텐츠를 레이어에 record 하고 같은 버퍼에 DstIn 마스크를 얹는다 - * - * 메모: - * - `enabled` 은 `@SkipWhenFalse` — 원본의 `if (!enabled) return this` 를 codegen 이 - * 생성 함수 앞에 넣어준다. `enabled=false` 면 노드/레이어가 아예 attach 되지 않는다. - * - 그리기 로직(브러시 계산, record, DstIn, drawLayer)은 전부 손으로 작성했다. - * codegen 이 걷어낸 건 Element/equals/hashCode/update/inspector + skip 가드 뿐이다. + * 오프스크린 [GraphicsLayer] 에 컨텐츠를 그린 뒤 같은 버퍼에 `DstIn` 그라디언트 마스크를 얹는다. + * Draws the content into an offscreen [GraphicsLayer], then applies a `DstIn` gradient mask to it. */ @ModifierNodeFactory(name = "fadingEdge") internal class FadingEdgeNode( @@ -49,7 +42,6 @@ internal class FadingEdgeNode( } override fun ContentDrawScope.draw() { - // enabled 는 @SkipWhenFalse — 여기 도달했다면 항상 true (노드 미attach 로 걸러짐). val gl = layer if (gl == null) { drawContent() diff --git a/modifier-node-sample/src/main/kotlin/com/sonms/modifiernode/sample/SampleNodes.kt b/modifier-node-sample/src/main/kotlin/com/sonms/modifiernode/sample/SampleNodes.kt index 5b8bce0..2688b28 100644 --- a/modifier-node-sample/src/main/kotlin/com/sonms/modifiernode/sample/SampleNodes.kt +++ b/modifier-node-sample/src/main/kotlin/com/sonms/modifiernode/sample/SampleNodes.kt @@ -16,8 +16,8 @@ import com.sonms.modifiernode.annotations.InvalidationScope.Measure import com.sonms.modifiernode.annotations.ModifierNodeFactory /** - * 예제 1 — draw 전용 노드. - * 생성물: `DebugTintElement`, `fun Modifier.debugTint(color: Color): Modifier` + * 컨텐츠 위에 색을 덧칠하는 draw 전용 노드. + * Draw-only node that tints the content with a color. */ @ModifierNodeFactory(name = "debugTint") internal class DebugTintNode( @@ -32,8 +32,8 @@ internal class DebugTintNode( } /** - * 예제 2 — draw + layout 노드. update 의 remeasure/redraw 분기를 검증하기 위한 케이스. - * 생성물: `FixedSquareElement`, `fun Modifier.fixedSquare(side: Dp, overlay: Color): Modifier` + * 정사각형 크기로 강제하고 그 위에 색을 덧칠하는 draw + layout 노드. + * Draw + layout node that forces a square size and tints over it. */ @ModifierNodeFactory(name = "fixedSquare") internal class FixedSquareNode( diff --git a/modifier-node-sample/src/main/kotlin/com/sonms/modifiernode/sample/compare/LocalTint.kt b/modifier-node-sample/src/main/kotlin/com/sonms/modifiernode/sample/compare/LocalTint.kt index 1dbf99e..f97151d 100644 --- a/modifier-node-sample/src/main/kotlin/com/sonms/modifiernode/sample/compare/LocalTint.kt +++ b/modifier-node-sample/src/main/kotlin/com/sonms/modifiernode/sample/compare/LocalTint.kt @@ -13,22 +13,9 @@ import com.sonms.modifiernode.annotations.Invalidates import com.sonms.modifiernode.annotations.InvalidationScope.Draw import com.sonms.modifiernode.annotations.ModifierNodeFactory -/** 비교용 CompositionLocal. */ val LocalAccentColor = compositionLocalOf { Color.Magenta } -// ───────────────────────────────────────────────────────────────────────────── -// BEFORE — composed { } 로 CompositionLocal 을 읽는 전형적 패턴 -// ───────────────────────────────────────────────────────────────────────────── - -/** - * `LocalAccentColor` 를 읽어 반투명 오버레이를 얹는다. - * - * 문제: - * - 반환 Modifier 가 `equals`/`hashCode` 미구현 → 부모가 재구성될 때마다 modifier 체인 - * 비교 실패 → 하위 재구성/무효화 - * - `composed` 람다가 매 재구성마다 re-materialize - * - composition 밖에서 사용 불가 - */ +/** BEFORE — `composed { }` 로 CompositionLocal 을 읽는다. `COMPARISON.md` Case A 참고. */ fun Modifier.accentOverlayComposed(alpha: Float): Modifier = composed { val color = LocalAccentColor.current drawWithContent { @@ -37,18 +24,7 @@ fun Modifier.accentOverlayComposed(alpha: Float): Modifier = composed { } } -// ───────────────────────────────────────────────────────────────────────────── -// AFTER — Modifier.Node + codegen -// ───────────────────────────────────────────────────────────────────────────── - -/** - * 손으로 쓰는 건 노드 행동 코드뿐. `currentValueOf(LocalAccentColor)` 를 `draw()` 에서 - * 읽으면 snapshot 관찰 대상이라 로컬 값이 바뀌면 자동 redraw. - * - * 생성물(`AccentOverlayElement.kt`): - * - `fun Modifier.accentOverlay(alpha: Float): Modifier` - * - `AccentOverlayElement` : create/update/equals/hashCode/inspectableProperties - */ +/** AFTER — 같은 효과를 `Modifier.Node` 로. `draw()` 안의 `currentValueOf` 는 관찰되므로 로컬 변경 시 자동 redraw. */ @ModifierNodeFactory(name = "accentOverlay") internal class AccentOverlayNode( @Invalidates(Draw) var alpha: Float, diff --git a/modifier-node-sample/src/main/kotlin/com/sonms/modifiernode/sample/compare/PressScale.kt b/modifier-node-sample/src/main/kotlin/com/sonms/modifiernode/sample/compare/PressScale.kt index a86e257..e4fc3ae 100644 --- a/modifier-node-sample/src/main/kotlin/com/sonms/modifiernode/sample/compare/PressScale.kt +++ b/modifier-node-sample/src/main/kotlin/com/sonms/modifiernode/sample/compare/PressScale.kt @@ -19,20 +19,7 @@ import com.sonms.modifiernode.annotations.ModifierNodeFactory import kotlinx.coroutines.Job import kotlinx.coroutines.launch -// ───────────────────────────────────────────────────────────────────────────── -// BEFORE — composed { }: InteractionSource 구독 + 애니메이션 -// ───────────────────────────────────────────────────────────────────────────── - -/** - * press 되는 동안 축소 스케일 애니메이션. - * - * `composed` 가 여기서 해주는 것: - * - `collectIsPressedAsState()` — InteractionSource 구독을 composition lifecycle 에 묶음 - * - `animateFloatAsState` — 애니메이션 상태를 remember - * - `interactionSource` 가 바뀌면 `collectIsPressedAsState` 가 알아서 재구독 (remember 키) - * - * 대가: equals 불안정, 매 재구성 re-materialize, composition 밖 사용 불가. - */ +/** BEFORE — `composed { }` 로 InteractionSource 구독 + 애니메이션. `COMPARISON.md` Case B 참고. */ fun Modifier.pressScaleComposed( interactionSource: InteractionSource, pressedScale: Float, @@ -48,20 +35,11 @@ fun Modifier.pressScaleComposed( } } -// ───────────────────────────────────────────────────────────────────────────── -// AFTER — Modifier.Node + codegen -// ───────────────────────────────────────────────────────────────────────────── - /** - * codegen 이 걷어내는 것: Element/equals/hashCode/update/inspector. + * AFTER — 같은 효과를 `Modifier.Node` 로. * - * 손으로 남는 것 — 그리고 이게 §COMPARISON 의 핵심: - * - `coroutineScope` 에서 `interactionSource.interactions` 수집 (press 카운팅) - * - `Animatable` 을 직접 들고 `launch { animateTo() }` - * - `onDetach` 에서 job 정리 - * - ⚠️ `interactionSource` 파라미터가 바뀌면 재구독이 필요한데, 현재 codegen 의 - * `update` 는 `node.interactionSource = interactionSource` 대입만 한다. - * "파라미터 변경 시 노드 커스텀 로직 실행" 훅이 없다 → 여기서는 수동 처리. + * `interactionSource` 가 바뀌면 재구독이 필요한데 생성된 `update()` 는 필드 대입만 한다 → + * [draw] 진입 시 [rebind] 로 수동 확인 (§10 Case B, `onUpdate` 훅 도입 전까지의 우회). */ @ModifierNodeFactory(name = "pressScale") internal class PressScaleNode( @@ -85,7 +63,6 @@ internal class PressScaleNode( boundSource = null } - /** codegen 의 update 가 대입만 하므로, 재구독은 draw 진입 시 수동 확인. */ private fun rebind() { if (boundSource === interactionSource) return collectJob?.cancel() @@ -106,7 +83,7 @@ internal class PressScaleNode( override fun ContentDrawScope.draw() { rebind() - val s = scaleAnim.value // Animatable.value 는 snapshot 상태 → 프레임마다 자동 redraw + val s = scaleAnim.value // snapshot 상태 → 애니메이션 프레임마다 자동 redraw scale(s, s, center) { this@draw.drawContent() } diff --git a/modifier-node-sample/src/test/kotlin/com/sonms/modifiernode/sample/AutoInvalidateProbeTest.kt b/modifier-node-sample/src/test/kotlin/com/sonms/modifiernode/sample/AutoInvalidateProbeTest.kt index 0298525..4dcb564 100644 --- a/modifier-node-sample/src/test/kotlin/com/sonms/modifiernode/sample/AutoInvalidateProbeTest.kt +++ b/modifier-node-sample/src/test/kotlin/com/sonms/modifiernode/sample/AutoInvalidateProbeTest.kt @@ -27,17 +27,8 @@ import org.robolectric.annotation.Config import org.robolectric.annotation.GraphicsMode /** - * 통제 실험: "파라미터 단위 invalidation" 이 실제로 효과를 내는가? - * - * 동일 하네스에서 두 노드를 비교한다. - * - [AutoOnNode] : shouldAutoInvalidate = 기본(true). update() 후 Compose 가 measure+draw 전부 무효화. - * - [AutoOffNode] : shouldAutoInvalidate = false. update() 가 부른 것만 무효화되어야 함. - * - * drawKey 만 바꿨을 때: - * - AutoOn → measures >= 1 (autoInvalidate 가 remeasure 시킴) - * - AutoOff → measures == 0 (draw 만) ← 이게 나오면 §10 "불가" 결론은 틀렸다 - * - * 카운터는 노드 자신의 measure()/draw() 안에서 증가 → wrapper 레벨 리메저와 무관. + * `shouldAutoInvalidate = false` 노드는 `update()` 가 부른 것만 무효화한다 (ARCHITECTURE.md §10). + * 동일 하네스에서 auto ON/OFF 두 노드를 비교하고, 카운터는 노드 자신의 measure()/draw() 에서 증가시킨다. */ @RunWith(RobolectricTestRunner::class) @GraphicsMode(GraphicsMode.Mode.NATIVE) @@ -55,7 +46,7 @@ class AutoInvalidateProbeTest { private val onC = Counters() private val offC = Counters() - // ── autoInvalidate ON (default) ────────────────────────────────────────── + // autoInvalidate ON (기본) private inner class AutoOnNode(var drawKey: Int, var measureKey: Int) : Modifier.Node(), DrawModifierNode, LayoutModifierNode { override fun MeasureScope.measure(m: Measurable, c: Constraints): MeasureResult { @@ -82,10 +73,9 @@ class AutoInvalidateProbeTest { override fun hashCode() = drawKey * 31 + measureKey } - // ── autoInvalidate OFF ─────────────────────────────────────────────────── + // autoInvalidate OFF private inner class AutoOffNode(var drawKey: Int, var measureKey: Int) : Modifier.Node(), DrawModifierNode, LayoutModifierNode { - @Suppress("DEPRECATION", "OVERRIDE_DEPRECATION") override val shouldAutoInvalidate: Boolean get() = false override fun MeasureScope.measure(m: Measurable, c: Constraints): MeasureResult { offC.measures++ @@ -136,24 +126,24 @@ class AutoInvalidateProbeTest { return counters } - /** 기준선: autoInvalidate ON → draw 파라미터만 바꿔도 remeasure. */ + /** 기준선: auto ON 이면 draw 파라미터만 바꿔도 remeasure. */ @Test fun autoOn_drawParam_change_remeasures() { val c = run(makeOn = true, mutate = 0) - assertTrue("autoInvalidate ON: draw param change remeasures (baseline)", c.measures >= 1) + assertTrue(c.measures >= 1) } - /** 핵심 A: autoInvalidate OFF → draw 파라미터 변경이 remeasure 를 유발하지 않는다. */ + /** auto OFF: draw 파라미터 변경은 remeasure 를 유발하지 않는다. */ @Test fun autoOff_drawParam_change_does_NOT_remeasure() { val c = run(makeOn = false, mutate = 0) - assertEquals("autoInvalidate OFF: draw param change must NOT remeasure", 0, c.measures) + assertEquals(0, c.measures) } - /** 핵심 B: autoInvalidate OFF → measure 파라미터 변경 시 update() 의 invalidateMeasurement() 는 작동한다. */ + /** auto OFF: measure 파라미터 변경 시 update() 의 invalidateMeasurement() 는 정상 작동. */ @Test fun autoOff_measureParam_change_DOES_remeasure_via_update() { val c = run(makeOn = false, mutate = 1) - assertTrue("autoInvalidate OFF: manual invalidateMeasurement() in update() must work", c.measures >= 1) + assertTrue(c.measures >= 1) } } diff --git a/modifier-node-sample/src/test/kotlin/com/sonms/modifiernode/sample/GeneratedElementTest.kt b/modifier-node-sample/src/test/kotlin/com/sonms/modifiernode/sample/GeneratedElementTest.kt index 39fe3db..3f603dc 100644 --- a/modifier-node-sample/src/test/kotlin/com/sonms/modifiernode/sample/GeneratedElementTest.kt +++ b/modifier-node-sample/src/test/kotlin/com/sonms/modifiernode/sample/GeneratedElementTest.kt @@ -9,18 +9,7 @@ import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config import kotlin.reflect.KVisibility -/** - * codegen 이 실제로 보장하는 계약을 고정한다 — Compose 런타임 불필요. - * - * 1. **재사용(equals/hashCode) 계약**: 같은 인자 → equal, 한 인자라도 다르면 non-equal. - * 이게 `composed` 대비 진짜 이득이다. Element 가 equal 이면 Compose 는 `update()` 를 - * 아예 호출하지 않는다. - * 2. **update() 필드 동기화**: 바뀐 파라미터가 노드 필드에 반영된다. - * - * ⚠️ 여기서 검증하지 않는 것: "파라미터 단위 invalidation 스코프". 현행 Compose 에서 - * `NodeChain.updateNode` 가 `update()` 직후 모든 capability 를 무조건 무효화하므로 - * 생성된 `if (redraw) invalidateDraw()` 는 런타임 효과가 없다 (ARCHITECTURE.md §10). - */ +/** 생성된 Element 의 equals/hashCode/create/update 계약과 가시성. Compose 런타임 불필요. */ @RunWith(RobolectricTestRunner::class) @Config(sdk = [34]) class GeneratedElementTest { @@ -49,8 +38,8 @@ class GeneratedElementTest { @Test fun update_syncs_changed_field_to_node() { val node = DebugTintNode(Color.Red) - // update() 는 detached 노드에서 invalidateDraw() 가 no-op 이라 안전. - DebugTintElement(Color.Blue).update(node) + DebugTintElement(Color.Blue).update(node) // detached 노드에서 invalidateDraw() 는 no-op + assertEquals(Color.Blue, node.color) } @@ -60,7 +49,7 @@ class GeneratedElementTest { assertEquals(Color.Green, node.color) } - /** ABI: 생성된 Element 는 노드 가시성과 무관하게 internal (공개 API 표면에서 제외). */ + /** ABI: 생성된 Element 는 노드 가시성과 무관하게 internal. */ @Test fun generated_element_is_internal() { assertEquals(KVisibility.INTERNAL, DebugTintElement::class.visibility) diff --git a/modifier-node-sample/src/test/kotlin/com/sonms/modifiernode/sample/InvalidationContractTest.kt b/modifier-node-sample/src/test/kotlin/com/sonms/modifiernode/sample/InvalidationContractTest.kt index 55d40b0..fe4902b 100644 --- a/modifier-node-sample/src/test/kotlin/com/sonms/modifiernode/sample/InvalidationContractTest.kt +++ b/modifier-node-sample/src/test/kotlin/com/sonms/modifiernode/sample/InvalidationContractTest.kt @@ -67,14 +67,7 @@ internal class SkipProbeNode( override fun ContentDrawScope.draw() = drawContent() } -/** - * codegen 이 주는 최적화 중 하나 = **equals 스킵**. - * 파라미터가 안 바뀌면 `Element.equals` == true → Compose 가 `update()` 를 아예 안 부름 - * → 측정/그리기 무효화 없음. - * - * (파라미터 단위 invalidation 스코프는 `shouldAutoInvalidate = false` 를 통해 작동한다 — - * `AutoInvalidateProbeTest` + ARCHITECTURE.md §10.) - */ +/** equals 스킵과 `@SkipWhenFalse` 가드. Element 가 equal 이면 Compose 는 `update()` 를 부르지 않는다. */ @RunWith(RobolectricTestRunner::class) @GraphicsMode(GraphicsMode.Mode.NATIVE) @Config(sdk = [34]) @@ -89,24 +82,22 @@ class InvalidationContractTest { val tick = mutableIntStateOf(0) rule.setContent { - tick.intValue // 재구성 트리거용, modifier 파라미터엔 안 들어감 + tick.intValue // 상위 재구성만 유발, probe 파라미터는 고정 Box(Modifier.size(24.dp).probe(drawKey.intValue, 0)) } rule.waitForIdle() ProbeCounters.reset() - // 상위만 재구성, probe 파라미터는 동일 → Element equal → update 미호출 tick.intValue = 1 rule.waitForIdle() - assertEquals("equal Element must not trigger remeasure", 0, ProbeCounters.measures) - assertEquals("equal Element must not trigger redraw", 0, ProbeCounters.draws) + assertEquals(0, ProbeCounters.measures) + assertEquals(0, ProbeCounters.draws) } - /** @SkipWhenFalse: on=false 면 생성 함수가 리시버를 그대로 반환(노드 미적용). */ @Test fun skipWhenFalse_gates_application() { - assertSame("on=false → 리시버 그대로", Modifier, Modifier.skipProbe(on = false, tag = 0)) - assertNotSame("on=true → Element 추가됨", Modifier, Modifier.skipProbe(on = true, tag = 0)) + assertSame(Modifier, Modifier.skipProbe(on = false, tag = 0)) + assertNotSame(Modifier, Modifier.skipProbe(on = true, tag = 0)) } } From dac970cc21b5faf79c4786d66a5d87f01950c388 Mon Sep 17 00:00:00 2001 From: sonms Date: Tue, 8 Sep 2026 16:34:28 +0900 Subject: [PATCH 09/21] =?UTF-8?q?[Add]=20@OnChange=20marker=20=E2=80=94=20?= =?UTF-8?q?call=20onChanged()=20when=20a=20parameter=20changes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - @OnChange on a value parameter; generated update() calls the node's fun onChanged() after assigning the new value, only when it changed - KSP validates the node declares fun onChanged() (no args) - PressScaleNode rewritten with @OnChange: drops the boundSource dedup field and the manual rebind() call in draw() (Case B hand-written ~40 -> ~35 lines) - onChange_fires_only_when_param_changes test - COMPARISON.md Case B + ARCHITECTURE §10/§11 updated Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_013UGir6UrorM7Kx7iyYv1ir --- ARCHITECTURE.md | 12 ++++-- .../modifiernode/annotations/Annotations.kt | 16 ++++++++ .../processor/ModifierNodeProcessor.kt | 23 ++++++++++-- modifier-node-sample/COMPARISON.md | 37 ++++++++----------- .../modifiernode/sample/compare/PressScale.kt | 22 ++++------- .../sample/InvalidationContractTest.kt | 30 +++++++++++++++ 6 files changed, 98 insertions(+), 42 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index c4b94ff..8465676 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -250,7 +250,7 @@ MVP 완료 후 아래를 근거로 **계속 / 중단**을 판단한다. | 모디파이어 부류 | codegen 효과 | |---|---| | 값(파라미터/CompositionLocal) 읽고 draw/measure 에서 반응 | **결정적** — 손 코드 7→10 vs codegen 없이 7→55 | -| interaction / coroutine / animation | Element 제거는 도움되나 균형 못 뒤집음. `onUpdate` 훅 + 노드 유틸 필요 | +| interaction / coroutine / animation | `@OnChange` 로 재구독 격차 축소(10→35). 남은 장황함은 진짜 노드 코드 | ### 10.1 autoInvalidate 조사 — 처음 결론이 틀렸다 (정정) @@ -330,7 +330,7 @@ codegen 은 정밀한 `update()` (계층 접기 포함)를 생성한다. `Modifi | 조건 | 결과 | |---|---| -| 1. 재작성이 원본보다 낫거나 동등 | **부분 충족** — Case A(값 읽기) 이득, Case B(interaction) 아님, composition-free 는 손해 | +| 1. 재작성이 원본보다 낫거나 동등 | **대체로 충족** — Case A(값 읽기) 명백한 이득, Case B(interaction)도 `@OnChange` 이후 근접, composition-free 는 여전히 손해 | | 2. invalidation 계약이 손보다 명확한 이점 | **조건부 충족** — 작동하고(§10.2), KSP 가 `shouldAutoInvalidate` 강제(§10.3), 계약을 테스트로 고정. 단 이득 범위가 좁다(§10.5) | | 3. KSP 구현 비용이 시간 단위 | **충족** — 전체 반나절 | @@ -353,8 +353,12 @@ invalidation"*. 성능은 부차적 셀링포인트로만, 과장 없이. 이미 - skip 시 노드 미attach. "사라지는 애니메이션" 은 불가 → 그 경우 마커 없이 노드가 no-op - `skipWhenFalse_gates_application` 테스트 (`Modifier.skipProbe(on=false) === Modifier`) - `fadingEdge` dogfood 를 `@SkipWhenFalse enabled` 로 갱신 -3. `onUpdate` 훅 — interaction/re-subscribe (§10 Case B) ← 다음 -4. default argument 지원 검토 +3. **`@OnChange` 마커 — 파라미터 변경 시 `onChanged()` 호출** ✅ 완료 + - 생성된 `update()` 가 필드 갱신 후 노드의 `onChanged()` 호출 (변경 시에만) + - KSP 가 `fun onChanged()` (무인자) 선언을 요구 + - `PressScaleNode` 를 이걸로 갱신 → `boundSource` dedup 필드 + `draw()` 의 `rebind()` 제거 + - `onChange_fires_only_when_param_changes` 테스트 +4. default argument 지원 검토 ← 다음 5. README 에 §5 한계 + §10.5 성능 현실 명시 **A. 중단.** (보류) 니치가 좁고 파라미터 단위 이득이 미미하다고 보면 유효했던 선택. diff --git a/modifier-node-annotations/src/main/kotlin/com/sonms/modifiernode/annotations/Annotations.kt b/modifier-node-annotations/src/main/kotlin/com/sonms/modifiernode/annotations/Annotations.kt index 7c78e5f..db7ed8f 100644 --- a/modifier-node-annotations/src/main/kotlin/com/sonms/modifiernode/annotations/Annotations.kt +++ b/modifier-node-annotations/src/main/kotlin/com/sonms/modifiernode/annotations/Annotations.kt @@ -60,3 +60,19 @@ annotation class SkipWhenFalse @Target(AnnotationTarget.VALUE_PARAMETER) @Retention(AnnotationRetention.SOURCE) annotation class SkipWhenTrue + +/** + * 이 파라미터가 바뀌면 생성된 `update()` 가 노드 필드 갱신 후 `onChanged()` 를 호출한다. + * When this parameter changes, the generated `update()` calls `onChanged()` after updating the node field. + * + * 노드는 인자 없이 `Unit` 을 반환하는 `fun onChanged()` 를 선언해야 한다. + * The node must declare `fun onChanged()` taking no arguments and returning `Unit`. + * 예: `interactionSource` → `fun onInteractionSourceChanged()`. + * e.g. `interactionSource` → `fun onInteractionSourceChanged()`. + * + * 콜백 시점에는 노드 필드가 이미 새 값이다. 코루틴 재구독, effect 재시작 등에 쓴다. + * The node field already holds the new value when the callback runs. Use it to re-subscribe a coroutine, restart an effect, etc. + */ +@Target(AnnotationTarget.VALUE_PARAMETER) +@Retention(AnnotationRetention.SOURCE) +annotation class OnChange diff --git a/modifier-node-processor/src/main/kotlin/com/sonms/modifiernode/processor/ModifierNodeProcessor.kt b/modifier-node-processor/src/main/kotlin/com/sonms/modifiernode/processor/ModifierNodeProcessor.kt index 5e42639..8b38ce0 100644 --- a/modifier-node-processor/src/main/kotlin/com/sonms/modifiernode/processor/ModifierNodeProcessor.kt +++ b/modifier-node-processor/src/main/kotlin/com/sonms/modifiernode/processor/ModifierNodeProcessor.kt @@ -190,7 +190,11 @@ class ModifierNodeProcessor( val type: com.squareup.kotlinpoet.TypeName, val scopes: Set, val skip: SkipKind?, - ) + val onChange: Boolean, + ) { + /** `@OnChange` 콜백 이름. e.g. `interactionSource` → `onInteractionSourceChanged`. */ + val changedCallback: String get() = "on${name.replaceFirstChar { it.uppercase() }}Changed" + } private fun resolveParam(p: KSValueParameter, node: KSClassDeclaration, supported: Set): Param { val name = p.name!!.asString() @@ -212,6 +216,18 @@ class ModifierNodeProcessor( skipTrue -> SkipKind.WhenTrue else -> null } + + val onChange = p.annotations.any { it.shortName.asString() == "OnChange" } + if (onChange) { + val cb = "on${name.replaceFirstChar { it.uppercase() }}Changed" + val declared = node.getAllFunctions().any { fn -> + fn.simpleName.asString() == cb && fn.parameters.isEmpty() + } + if (!declared) { + logger.error("@OnChange on '$name' requires the node to declare 'fun $cb()'", p) + } + } + val invAnn = p.annotations.firstOrNull { it.shortName.asString() == "Invalidates" } val scopes: Set = when { invAnn == null -> supported // 안전 기본값 @@ -240,7 +256,7 @@ class ModifierNodeProcessor( } } } - return Param(name, p.type.toTypeName(), scopes, skip) + return Param(name, p.type.toTypeName(), scopes, skip, onChange) } private fun createFun(nodeClass: ClassName, params: List) = FunSpec.builder("create") @@ -263,7 +279,7 @@ class ModifierNodeProcessor( if (anyPlacement) b.addStatement("var replace = false") params.forEach { p -> - if (p.scopes.isEmpty()) { + if (p.scopes.isEmpty() && !p.onChange) { b.addStatement("if (node.%N != %N) node.%N = %N", p.name, p.name, p.name, p.name) } else { b.beginControlFlow("if (node.%N != %N)", p.name, p.name) @@ -271,6 +287,7 @@ class ModifierNodeProcessor( if (Scope.Draw in p.scopes) b.addStatement("redraw = true") if (Scope.Measure in p.scopes) b.addStatement("remeasure = true") else if (Scope.Placement in p.scopes) b.addStatement("replace = true") + if (p.onChange) b.addStatement("node.%N()", p.changedCallback) b.endControlFlow() } } diff --git a/modifier-node-sample/COMPARISON.md b/modifier-node-sample/COMPARISON.md index cfb807c..bb94299 100644 --- a/modifier-node-sample/COMPARISON.md +++ b/modifier-node-sample/COMPARISON.md @@ -10,7 +10,7 @@ | | composed | Node(손) | codegen 없이 Node(손) | 생성물 | |---|---:|---:|---:|---:| | Case A | ~7 | **~10** | ~55 | ~46 | -| Case B | ~10 | **~40** | ~85 | ~53 | +| Case B | ~10 | **~35** | ~85 | ~53 | --- @@ -80,20 +80,18 @@ fun Modifier.pressScaleComposed( @ModifierNodeFactory(name = "pressScale") internal class PressScaleNode( @Invalidates(Draw) var pressedScale: Float, - @Invalidates(None) var interactionSource: InteractionSource, + @Invalidates(None) @OnChange var interactionSource: InteractionSource, ) : Modifier.Node(), DrawModifierNode { private val scaleAnim = Animatable(1f) private var collectJob: Job? = null - private var boundSource: InteractionSource? = null - override fun onAttach() = rebind() - override fun onDetach() { collectJob?.cancel(); collectJob = null; boundSource = null } + override fun onAttach() = subscribe() + override fun onDetach() { collectJob?.cancel(); collectJob = null } + fun onInteractionSourceChanged() = subscribe() // 생성된 update() 가 호출 - private fun rebind() { - if (boundSource === interactionSource) return + private fun subscribe() { collectJob?.cancel() - boundSource = interactionSource collectJob = coroutineScope.launch { val presses = ArrayList() interactionSource.interactions.collect { i -> @@ -108,22 +106,19 @@ internal class PressScaleNode( } override fun ContentDrawScope.draw() { - rebind() // ← codegen update 가 대입만 하므로 수동 재구독 확인 val s = scaleAnim.value // snapshot 상태 → 프레임마다 자동 redraw scale(s, s, center) { this@draw.drawContent() } } } ``` -### 판정 B — **codegen 만으로는 부족** +### 판정 B — codegen + `@OnChange` 로 격차 축소 -- 손 코드 10 → 40줄. codegen 이 Element(~50줄)를 없앴지만, interaction+animation 의 - **노드 행동 코드 자체가 `composed` 보다 훨씬 장황**하다. -- **새 한계 발견:** `interactionSource` 파라미터가 바뀔 때 재구독하려면 훅이 필요한데, - codegen 의 `update` 는 `node.x = x` 대입만 한다. 여기서는 `draw()` 진입 시 `rebind()` - 수동 호출로 우회 — 지저분하다. → `update` 후 `node.onUpdated()` 를 부르거나, - `@Invalidates` 에 "재바인드" 개념(예: `@OnChange`)이 필요. -- `Animatable` / press 카운팅 보일러플레이트는 codegen 범위 밖. 별도 노드 유틸이 있어야 함. +- 손 코드 10 → ~35줄. Element(~50줄) + 재구독 dedup(`boundSource` 필드, `draw()` 의 `rebind()`) + 이 사라졌다. 남은 장황함은 `Animatable` / press 카운팅 — 진짜 노드 행동 코드다. +- `@OnChange var interactionSource` → 생성된 `update()` 가 필드 갱신 후 `onInteractionSourceChanged()` + 를 호출한다. `composed` 의 `remember(key)` 재구독에 대응. +- `Animatable` / press 카운팅 보일러플레이트는 여전히 codegen 범위 밖 (노드 유틸이 있으면 더 줄겠지만 별개). --- @@ -132,12 +127,12 @@ internal class PressScaleNode( | 모디파이어 부류 | codegen 효과 | |---|---| | **값(파라미터/CompositionLocal) 읽고 draw/measure 에서 반응** — 커스텀 모디파이어의 다수 | **결정적.** 7→10 vs 7→55. `composed` 를 벗어나게 해줌 | -| **interaction / coroutine / animation** | Element 제거는 도움되지만 균형을 못 뒤집음. `onUpdate` 훅 + 노드 유틸 필요 | -| 모든 부류 공통 | 안정 equals·inspector·`@Invalidates` 정합성 체크·(예정) ABI 표면 축소 | +| **interaction / coroutine / animation** | `@OnChange` 로 재구독 격차 축소(10→35). 남은 장황함(`Animatable`/카운팅)은 진짜 노드 코드 | +| 모든 부류 공통 | 안정 equals·inspector·`@Invalidates` 정합성 체크·ABI 표면 축소·`@SkipWhen*` 가드 | **결정 게이트 조건 1** ("재작성이 원본보다 낫거나 동등"): `fadingEdge`(§ARCHITECTURE 9)로는 불충족이었으나 -그건 애초에 `composed` 케이스가 아니었음. **Case A 로 재평가 시 충족.** Case B 는 codegen 의 -경계를 명확히 보여줌 — 라이브러리화하면 `onUpdate` 훅을 v1.1 스코프에 넣어야 한다. +그건 애초에 `composed` 케이스가 아니었음. **Case A 로 재평가 시 충족.** Case B 도 `@OnChange` 이후엔 +근접(값 읽기 케이스만큼 결정적이진 않음). --- diff --git a/modifier-node-sample/src/main/kotlin/com/sonms/modifiernode/sample/compare/PressScale.kt b/modifier-node-sample/src/main/kotlin/com/sonms/modifiernode/sample/compare/PressScale.kt index e4fc3ae..321fba2 100644 --- a/modifier-node-sample/src/main/kotlin/com/sonms/modifiernode/sample/compare/PressScale.kt +++ b/modifier-node-sample/src/main/kotlin/com/sonms/modifiernode/sample/compare/PressScale.kt @@ -16,6 +16,7 @@ import com.sonms.modifiernode.annotations.Invalidates import com.sonms.modifiernode.annotations.InvalidationScope.Draw import com.sonms.modifiernode.annotations.InvalidationScope.None import com.sonms.modifiernode.annotations.ModifierNodeFactory +import com.sonms.modifiernode.annotations.OnChange import kotlinx.coroutines.Job import kotlinx.coroutines.launch @@ -36,37 +37,31 @@ fun Modifier.pressScaleComposed( } /** - * AFTER — 같은 효과를 `Modifier.Node` 로. - * - * `interactionSource` 가 바뀌면 재구독이 필요한데 생성된 `update()` 는 필드 대입만 한다 → - * [draw] 진입 시 [rebind] 로 수동 확인 (§10 Case B, `onUpdate` 훅 도입 전까지의 우회). + * AFTER — 같은 효과를 `Modifier.Node` 로. `interactionSource` 는 `@OnChange` — 바뀌면 + * 생성된 `update()` 가 [onInteractionSourceChanged] 를 불러 재구독한다. */ @ModifierNodeFactory(name = "pressScale") internal class PressScaleNode( @Invalidates(Draw) var pressedScale: Float, - @Invalidates(None) var interactionSource: InteractionSource, + @Invalidates(None) @OnChange var interactionSource: InteractionSource, ) : Modifier.Node(), DrawModifierNode { override val shouldAutoInvalidate: Boolean get() = false private val scaleAnim = Animatable(1f) private var collectJob: Job? = null - private var boundSource: InteractionSource? = null - override fun onAttach() { - rebind() - } + override fun onAttach() = subscribe() override fun onDetach() { collectJob?.cancel() collectJob = null - boundSource = null } - private fun rebind() { - if (boundSource === interactionSource) return + fun onInteractionSourceChanged() = subscribe() + + private fun subscribe() { collectJob?.cancel() - boundSource = interactionSource collectJob = coroutineScope.launch { val presses = ArrayList() interactionSource.interactions.collect { interaction -> @@ -82,7 +77,6 @@ internal class PressScaleNode( } override fun ContentDrawScope.draw() { - rebind() val s = scaleAnim.value // snapshot 상태 → 애니메이션 프레임마다 자동 redraw scale(s, s, center) { this@draw.drawContent() diff --git a/modifier-node-sample/src/test/kotlin/com/sonms/modifiernode/sample/InvalidationContractTest.kt b/modifier-node-sample/src/test/kotlin/com/sonms/modifiernode/sample/InvalidationContractTest.kt index fe4902b..9102282 100644 --- a/modifier-node-sample/src/test/kotlin/com/sonms/modifiernode/sample/InvalidationContractTest.kt +++ b/modifier-node-sample/src/test/kotlin/com/sonms/modifiernode/sample/InvalidationContractTest.kt @@ -17,6 +17,7 @@ import com.sonms.modifiernode.annotations.Invalidates import com.sonms.modifiernode.annotations.InvalidationScope.Draw import com.sonms.modifiernode.annotations.InvalidationScope.Measure import com.sonms.modifiernode.annotations.ModifierNodeFactory +import com.sonms.modifiernode.annotations.OnChange import com.sonms.modifiernode.annotations.SkipWhenFalse import org.junit.Assert.assertEquals import org.junit.Assert.assertNotSame @@ -67,6 +68,19 @@ internal class SkipProbeNode( override fun ContentDrawScope.draw() = drawContent() } +object OnChangeCount { + var value = 0 +} + +@ModifierNodeFactory(name = "onChangeProbe") +internal class OnChangeProbeNode( + @OnChange @Invalidates(Draw) var key: Int, +) : Modifier.Node(), DrawModifierNode { + override val shouldAutoInvalidate: Boolean get() = false + fun onKeyChanged() { OnChangeCount.value++ } + override fun ContentDrawScope.draw() = drawContent() +} + /** equals 스킵과 `@SkipWhenFalse` 가드. Element 가 equal 이면 Compose 는 `update()` 를 부르지 않는다. */ @RunWith(RobolectricTestRunner::class) @GraphicsMode(GraphicsMode.Mode.NATIVE) @@ -100,4 +114,20 @@ class InvalidationContractTest { assertSame(Modifier, Modifier.skipProbe(on = false, tag = 0)) assertNotSame(Modifier, Modifier.skipProbe(on = true, tag = 0)) } + + @Test + fun onChange_fires_only_when_param_changes() { + val key = mutableIntStateOf(0) + rule.setContent { Box(Modifier.size(24.dp).onChangeProbe(key.intValue)) } + rule.waitForIdle() + OnChangeCount.value = 0 + + key.intValue = 1 + rule.waitForIdle() + assertEquals(1, OnChangeCount.value) + + key.intValue = 1 // 같은 값 → update() 미호출 + rule.waitForIdle() + assertEquals(1, OnChangeCount.value) + } } From 477c2d982112c5840825ee69bcf29692c59b682a Mon Sep 17 00:00:00 2001 From: sonms Date: Tue, 8 Sep 2026 16:48:00 +0900 Subject: [PATCH 10/21] =?UTF-8?q?[Fix]=20Review=20pass=20=E2=80=94=20corre?= =?UTF-8?q?ctness,=20safety,=20doc=20consistency?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FadingEdgeNode.draw(): use the DrawScope-scoped GraphicsLayer.record(size) overload, not the GraphicsLayer member — the member form throws at runtime when drawContent() is recorded (see LayoutNodeDrawScope error message) - FadingEdgeNode: @Invalidates(None) on the @SkipWhenFalse `enabled` gate - processor: skip codegen for a node when a param has a hard error (non-var, bad marker, missing @OnChange callback) — prevents cascading errors in the generated file - processor: warn on function-type params (reference-identity equals defeats reuse); guard against a blank derived function name - annotations module: explicitApi() + explicit `public` modifiers - ARCHITECTURE.md: sync stale sections (§3.2 visibility not binaryCompat, §6 progress points to §11, remove done TODOs); add §12 @Default review All 12 tests green; debug + release AAR build clean, no warnings from our code. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_013UGir6UrorM7Kx7iyYv1ir --- ARCHITECTURE.md | 57 +++++++++++-------- modifier-node-annotations/build.gradle.kts | 1 + .../modifiernode/annotations/Annotations.kt | 18 +++--- .../processor/ModifierNodeProcessor.kt | 39 +++++++++++-- .../sonms/modifiernode/sample/FadingEdge.kt | 7 ++- 5 files changed, 81 insertions(+), 41 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 8465676..a0c74ae 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1,7 +1,9 @@ # ARCHITECTURE — Modifier.Node codegen (실험 브랜치) > 브랜치: `feat/modifier-node-codegen` -> 상태: MVP 검증 단계. 아래 "결정 게이트"를 통과하기 전까지 `master` 병합 없음. +> 상태: **갈래 B 확정** (§11). MVP + ABI/skipWhen/onChange 구현 완료, README·default 인자 남음. +> `master` 병합은 남은 작업 정리 후. +> 현재 진행 상태의 정본은 §11 "남은 작업" 이다 (아래 §6 진행 상태는 초기 기록). --- @@ -90,11 +92,9 @@ invalidation 계층: `Measure` > `Placement` > `Draw` (상위 하나만 호출), ### 3.2 바이너리 호환성 (라이브러리 저자용) -- Element/Node 클래스를 `internal` 로 감추고 `Modifier.foo(...)` 함수만 공개 - → 내부 리팩터가 ABI를 깨지 않음, `.api` diff가 예측 가능 -- (opt-in) `@ModifierNodeFactory(binaryCompat = true)`: - Kotlin default argument 대신 명시적 오버로드 체인 생성 - → 파라미터 추가 = 심볼 추가, 기존 심볼 유지 +- 생성된 Element 는 항상 `internal`, 공개되는 건 `Modifier.foo(...)` 함수뿐 + (`@ModifierNodeFactory(visibility = ...)` 로 함수 가시성 제어). → 내부 리팩터가 ABI를 깨지 않음. +- (범위 밖) default argument 대신 오버로드 체인 생성 — 파라미터 추가 = 심볼 추가. --- @@ -136,8 +136,6 @@ invalidation 계층: `Measure` > `Placement` > `Draw` (상위 하나만 호출), - 검증: `@Invalidates` scope ↔ 노드가 구현한 인터페이스 정합성, `var` 파라미터 강제 - 지원 노드 인터페이스 **2개만**: `DrawModifierNode`, `LayoutModifierNode` - invalidation 계층 접기 로직 (`Measure` > `Placement` > `Draw`) -- 테스트 헬퍼 1개: "파라미터 P를 바꾸면 `invalidateDraw` 만 호출되고 `invalidateMeasurement` 는 - 호출되지 않는다" 를 검증 ← **아직 미작성** - 모듈: `:modifier-node-annotations`, `:modifier-node-processor`, `:modifier-node-sample` - **Dogfooding: `:modifier-node-sample` 안에서 `fadingEdge` 재작성.** ratingbar / wheelpicker 컴포넌트 본체에 이 codegen dependency를 연결하지 않는다 @@ -146,32 +144,29 @@ invalidation 계층: `Measure` > `Placement` > `Draw` (상위 하나만 호출), 순수 `DrawModifierNode` 로는 완전 재현 불가 — GraphicsLayer 위임이 필요하고 이는 결정 게이트에서 "노드 행동 코드는 codegen이 안 도와준다" 를 실증하는 좋은 케이스. -### 진행 상태 (2026-09-08) +### 초기 진행 기록 (2026-09-08) — 현재 상태는 §11 참고 -- [x] 모듈 3개 스캐폴딩, 카탈로그/settings 배선 -- [x] 어노테이션 정의 -- [x] 프로세서: Element + 확장 함수 생성, invalidation 계층 접기, `var`/scope 검증 -- [x] `:modifier-node-sample` 예제 2개(`debugTint` draw 전용, `fixedSquare` draw+layout) 빌드/코드젠 확인 -- [x] `fadingEdge` 재작성 (§9 참고) -- [x] `composed` vs Node 비교 2케이스 (§10, `COMPARISON.md`) -- [x] 계약 테스트 — `GeneratedElementTest`, `InvalidationContractTest`(equals 스킵), `AutoInvalidateProbeTest`(opt-out 동작) -- [x] **invalidation 스코프 실효성 조사 → 작동 확인 (§10). 첫 "불가" 결론은 오독, 정정함** -- [x] 프로세서: `@Invalidates` 사용 시 `shouldAutoInvalidate` override 강제 -- [ ] 기본값(default argument) 처리 — 현재 미지원, 호출부에서 전 인자 전달 필요 -- [ ] **§11 A/B/C 갈래 선택** ← 다음 결정 +- [x] 모듈 3개 스캐폴딩, 어노테이션, 프로세서(Element + 함수 생성, invalidation 계층 접기, 검증) +- [x] 샘플 예제 + `fadingEdge` 재작성 (§9), `composed` vs Node 비교 (§10, `COMPARISON.md`) +- [x] 계약 테스트 — `GeneratedElementTest`, `InvalidationContractTest`, `AutoInvalidateProbeTest` +- [x] invalidation 스코프 실효성 조사 → 작동 확인 (§10). 첫 "불가" 결론은 오독, 정정함 +- [x] 갈래 B 확정 → 이후 작업은 §11 ### 한계 (MVP 구현상) - 생성 함수에 default argument 재현 불가 (KSP가 기본값 표현식을 못 읽음). 전 인자 필수. + → `@Default(String)` 마커 검토 완료(§12), 미구현. - 제네릭 Node, 다중 타입 파라미터 미검증. -- `equals`/`hashCode` 는 참조형 파라미터의 `equals` 안정성에 의존 (람다/불안정 타입 넣으면 그대로 깨짐 — 이는 사용자 책임). +- `equals`/`hashCode` 는 참조형 파라미터의 `equals` 안정성에 의존. 함수 타입 파라미터는 + 프로세서가 경고한다(참조 동일성 → 재사용 깨짐). 그 밖의 불안정 타입은 사용자 책임. +- 하드 에러(non-var, 잘못된 마커, `@OnChange` 콜백 누락) 시 해당 노드의 생성물을 만들지 않는다(2차 에러 방지). ### 범위 밖 (MVP 이후) - 노드 인터페이스 확장 (`PointerInputModifierNode`, `SemanticsModifierNode`, `GlobalPositionAwareModifierNode` ...) - `binaryCompat = true` 오버로드 체인 -- Element/Node `internal` 은닉 옵션 - IDE inspection / quick-fix +- 프로세서 자체 단위 테스트 (현재는 `:modifier-node-sample` 생성물 + Robolectric 로 간접 검증) --- @@ -358,8 +353,8 @@ invalidation"*. 성능은 부차적 셀링포인트로만, 과장 없이. 이미 - KSP 가 `fun onChanged()` (무인자) 선언을 요구 - `PressScaleNode` 를 이걸로 갱신 → `boundSource` dedup 필드 + `draw()` 의 `rebind()` 제거 - `onChange_fires_only_when_param_changes` 테스트 -4. default argument 지원 검토 ← 다음 -5. README 에 §5 한계 + §10.5 성능 현실 명시 +4. default argument 지원 — `@Default(String)` 마커 검토 완료(§12), 구현 보류 +5. README 에 §5 한계 + §10.5 성능 현실 명시 ← 다음 **A. 중단.** (보류) 니치가 좁고 파라미터 단위 이득이 미미하다고 보면 유효했던 선택. @@ -370,3 +365,17 @@ invalidation"*. 성능은 부차적 셀링포인트로만, 과장 없이. 이미 (그리고 처음에 틀렸던 이유). 재확인 비용을 아낀다. - `COMPARISON.md` — `composed` vs Node 부류별 손익표. - `AutoInvalidateProbeTest` — opt-out 동작 회귀 방지. + +--- + +## 12. `@Default(String)` 검토 (구현 보류) + +```kotlin +@Default("1000") var durationMillis: Int → fun Modifier.x(durationMillis: Int = 1000, ...) +``` + +- **구현 ~10줄**: 어노테이션 문자열을 `ParameterSpec.defaultValue("%L", expr)` 로 verbatim 방출. 생성 함수에만, Element 생성자는 그대로. +- **되는 것**: 리터럴(`1000`/`0f`/`true`/`null`/`""`), 파라미터 타입의 멤버(`Color.Unspecified`, `StepSize.ONE`). +- **안 되는 것**: 생성 파일에 import 안 된 심볼. `4.dp` 도 실패(자주 씀). → FQN 강제거나 유닛 allowlist(`.dp/.sp/.em` 자동 import) 필요. +- **리스크**: 오타 → 생성 파일에서 컴파일 에러(어노테이션 위치 아님). 중간 위치 `@Default` → 뒤 파라미터 named 강제(경고만, 리오더 안 함). +- **판정**: "default 없음"보다 낫고 오버로드 체인보다 단순. 권장 형태 = `@Default(String)` + non-blank 검증 + `.dp/.sp/.em` allowlist + 중간위치 경고 (~20줄). 우선순위는 README 뒤. diff --git a/modifier-node-annotations/build.gradle.kts b/modifier-node-annotations/build.gradle.kts index c9211df..bad5aef 100644 --- a/modifier-node-annotations/build.gradle.kts +++ b/modifier-node-annotations/build.gradle.kts @@ -11,6 +11,7 @@ java { } kotlin { + explicitApi() compilerOptions { jvmTarget.set(JvmTarget.JVM_11) } diff --git a/modifier-node-annotations/src/main/kotlin/com/sonms/modifiernode/annotations/Annotations.kt b/modifier-node-annotations/src/main/kotlin/com/sonms/modifiernode/annotations/Annotations.kt index db7ed8f..63ee237 100644 --- a/modifier-node-annotations/src/main/kotlin/com/sonms/modifiernode/annotations/Annotations.kt +++ b/modifier-node-annotations/src/main/kotlin/com/sonms/modifiernode/annotations/Annotations.kt @@ -14,13 +14,13 @@ package com.sonms.modifiernode.annotations */ @Target(AnnotationTarget.CLASS) @Retention(AnnotationRetention.SOURCE) -annotation class ModifierNodeFactory( - val name: String = "", - val visibility: GeneratedVisibility = GeneratedVisibility.Public, +public annotation class ModifierNodeFactory( + public val name: String = "", + public val visibility: GeneratedVisibility = GeneratedVisibility.Public, ) /** [ModifierNodeFactory.visibility] 값. Value for [ModifierNodeFactory.visibility]. */ -enum class GeneratedVisibility { Public, Internal } +public enum class GeneratedVisibility { Public, Internal } /** * 이 파라미터가 바뀌었을 때 무효화할 범위. 미지정 시 노드가 구현한 모든 범위를 무효화한다. @@ -31,7 +31,7 @@ enum class GeneratedVisibility { Public, Internal } */ @Target(AnnotationTarget.VALUE_PARAMETER) @Retention(AnnotationRetention.SOURCE) -annotation class Invalidates(vararg val scopes: InvalidationScope) +public annotation class Invalidates(public vararg val scopes: InvalidationScope) /** * 무효화 범위. 계층은 [Measure] > [Placement] > [Draw] (상위 하나만 호출). @@ -40,7 +40,7 @@ annotation class Invalidates(vararg val scopes: InvalidationScope) * [None] 은 무효화 없음. 생성 시점에만 쓰거나 `observeReads` 로 읽는 값에 사용한다. * [None] means no invalidation — for values used only on create, or read via `observeReads`. */ -enum class InvalidationScope { Measure, Placement, Draw, Semantics, ParentData, None } +public enum class InvalidationScope { Measure, Placement, Draw, Semantics, ParentData, None } /** * 이 `Boolean` 파라미터가 `false` 면 modifier 를 적용하지 않는다 (노드 미attach). @@ -51,7 +51,7 @@ enum class InvalidationScope { Measure, Placement, Draw, Semantics, ParentData, */ @Target(AnnotationTarget.VALUE_PARAMETER) @Retention(AnnotationRetention.SOURCE) -annotation class SkipWhenFalse +public annotation class SkipWhenFalse /** * [SkipWhenFalse] 의 반대 — 이 `Boolean` 파라미터가 `true` 면 modifier 를 적용하지 않는다. @@ -59,7 +59,7 @@ annotation class SkipWhenFalse */ @Target(AnnotationTarget.VALUE_PARAMETER) @Retention(AnnotationRetention.SOURCE) -annotation class SkipWhenTrue +public annotation class SkipWhenTrue /** * 이 파라미터가 바뀌면 생성된 `update()` 가 노드 필드 갱신 후 `onChanged()` 를 호출한다. @@ -75,4 +75,4 @@ annotation class SkipWhenTrue */ @Target(AnnotationTarget.VALUE_PARAMETER) @Retention(AnnotationRetention.SOURCE) -annotation class OnChange +public annotation class OnChange diff --git a/modifier-node-processor/src/main/kotlin/com/sonms/modifiernode/processor/ModifierNodeProcessor.kt b/modifier-node-processor/src/main/kotlin/com/sonms/modifiernode/processor/ModifierNodeProcessor.kt index 8b38ce0..98b827d 100644 --- a/modifier-node-processor/src/main/kotlin/com/sonms/modifiernode/processor/ModifierNodeProcessor.kt +++ b/modifier-node-processor/src/main/kotlin/com/sonms/modifiernode/processor/ModifierNodeProcessor.kt @@ -64,10 +64,13 @@ class ModifierNodeProcessor( val factoryAnn = node.annotations.first { it.shortName.asString() == "ModifierNodeFactory" } val explicitName = (factoryAnn.argValue("name") as? String).orEmpty() - val funName = explicitName.ifBlank { - nodeName.removeSuffix("Node").replaceFirstChar { it.lowercase() } + val base = nodeName.removeSuffix("Node") + val funName = explicitName.ifBlank { base.replaceFirstChar { it.lowercase() } } + if (funName.isBlank()) { + logger.error("Cannot derive a function name from '$nodeName' — pass @ModifierNodeFactory(name = \"...\")", node) + return } - val elementName = "${nodeName.removeSuffix("Node")}Element" + val elementName = "${base.ifBlank { nodeName }}Element" val elementClass = ClassName(pkg, elementName) // 확장 함수 가시성: @ModifierNodeFactory(visibility=...), 기본 Public. @@ -110,7 +113,14 @@ class ModifierNodeProcessor( return } - val params = ctor.parameters.map { p -> resolveParam(p, node, supported) } + var paramsValid = true + val params = ctor.parameters.map { p -> + val (param, valid) = resolveParam(p, node, supported) + if (!valid) paramsValid = false + param + } + // 하드 에러(non-var, 잘못된 마커 등)가 있으면 생성물을 만들지 않는다 — 2차 에러 방지. + if (!paramsValid) return // @Invalidates 는 Node 가 shouldAutoInvalidate=false 를 선언해야 실효가 있다 (ARCHITECTURE.md §10). // codegen 은 Node 클래스를 수정 못 하므로 여기서 요구한다. @@ -196,10 +206,24 @@ class ModifierNodeProcessor( val changedCallback: String get() = "on${name.replaceFirstChar { it.uppercase() }}Changed" } - private fun resolveParam(p: KSValueParameter, node: KSClassDeclaration, supported: Set): Param { + /** @return 파라미터 모델과, 하드 에러가 없어 생성 가능한지 여부. */ + private fun resolveParam(p: KSValueParameter, node: KSClassDeclaration, supported: Set): Pair { val name = p.name!!.asString() + var valid = true + if (!p.isVar) { logger.error("Parameter '$name' must be a 'var' property parameter", p) + valid = false + } + + // 함수 타입은 equals 가 참조 동일성 → composed 와 같은 재구성 함정. 경고만. + val typeFqn = p.type.resolve().declaration.qualifiedName?.asString().orEmpty() + if (typeFqn.startsWith("kotlin.Function") || typeFqn.startsWith("kotlin.coroutines.SuspendFunction")) { + logger.warn( + "Parameter '$name' is a function type — the generated equals() uses reference identity, " + + "so a fresh lambda each recomposition defeats element reuse. Hoist it or wrap in a stable holder.", + p, + ) } val skipFalse = p.annotations.any { it.shortName.asString() == "SkipWhenFalse" } @@ -207,9 +231,11 @@ class ModifierNodeProcessor( val isBoolean = p.type.resolve().declaration.qualifiedName?.asString() == "kotlin.Boolean" if ((skipFalse || skipTrue) && !isBoolean) { logger.error("@SkipWhen* requires a Boolean parameter; '$name' is not Boolean", p) + valid = false } if (skipFalse && skipTrue) { logger.error("'$name' has both @SkipWhenFalse and @SkipWhenTrue", p) + valid = false } val skip = when { skipFalse -> SkipKind.WhenFalse @@ -225,6 +251,7 @@ class ModifierNodeProcessor( } if (!declared) { logger.error("@OnChange on '$name' requires the node to declare 'fun $cb()'", p) + valid = false } } @@ -256,7 +283,7 @@ class ModifierNodeProcessor( } } } - return Param(name, p.type.toTypeName(), scopes, skip, onChange) + return Param(name, p.type.toTypeName(), scopes, skip, onChange) to valid } private fun createFun(nodeClass: ClassName, params: List) = FunSpec.builder("create") diff --git a/modifier-node-sample/src/main/kotlin/com/sonms/modifiernode/sample/FadingEdge.kt b/modifier-node-sample/src/main/kotlin/com/sonms/modifiernode/sample/FadingEdge.kt index d9529a0..5c184ae 100644 --- a/modifier-node-sample/src/main/kotlin/com/sonms/modifiernode/sample/FadingEdge.kt +++ b/modifier-node-sample/src/main/kotlin/com/sonms/modifiernode/sample/FadingEdge.kt @@ -9,8 +9,10 @@ import androidx.compose.ui.graphics.layer.GraphicsLayer import androidx.compose.ui.graphics.layer.drawLayer import androidx.compose.ui.node.DrawModifierNode import androidx.compose.ui.node.requireGraphicsContext +import androidx.compose.ui.unit.toIntSize import com.sonms.modifiernode.annotations.Invalidates import com.sonms.modifiernode.annotations.InvalidationScope.Draw +import com.sonms.modifiernode.annotations.InvalidationScope.None import com.sonms.modifiernode.annotations.ModifierNodeFactory import com.sonms.modifiernode.annotations.SkipWhenFalse @@ -25,7 +27,7 @@ import com.sonms.modifiernode.annotations.SkipWhenFalse internal class FadingEdgeNode( @Invalidates(Draw) var isVertical: Boolean, @Invalidates(Draw) var fraction: Float, - @SkipWhenFalse var enabled: Boolean, + @SkipWhenFalse @Invalidates(None) var enabled: Boolean, ) : Modifier.Node(), DrawModifierNode { override val shouldAutoInvalidate: Boolean get() = false @@ -65,7 +67,8 @@ internal class FadingEdgeNode( ) } - gl.record { + // DrawScope 스코프의 record — drawContent() 를 레이어에 올바르게 기록한다. + gl.record(size.toIntSize()) { this@draw.drawContent() drawRect(brush = maskBrush, blendMode = BlendMode.DstIn) } From 717243447e568dd5e0f53c66c6caa3962ed50555 Mon Sep 17 00:00:00 2001 From: sonms Date: Tue, 8 Sep 2026 16:52:30 +0900 Subject: [PATCH 11/21] =?UTF-8?q?[Docs]=20Add=20modifier-node-annotations/?= =?UTF-8?q?README.md;=20ARCHITECTURE=20=C2=A711=20sync?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - README: positioning (niche, solo maintenance, no SLA), setup, usage, annotation reference table, shouldAutoInvalidate requirement, honest performance expectations (§10.5), limitations - ARCHITECTURE §11: README done, @Default explicitly deferred Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_013UGir6UrorM7Kx7iyYv1ir --- ARCHITECTURE.md | 15 +++-- modifier-node-annotations/README.md | 101 ++++++++++++++++++++++++++++ 2 files changed, 111 insertions(+), 5 deletions(-) create mode 100644 modifier-node-annotations/README.md diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index a0c74ae..4b7ed02 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1,9 +1,9 @@ # ARCHITECTURE — Modifier.Node codegen (실험 브랜치) > 브랜치: `feat/modifier-node-codegen` -> 상태: **갈래 B 확정** (§11). MVP + ABI/skipWhen/onChange 구현 완료, README·default 인자 남음. -> `master` 병합은 남은 작업 정리 후. -> 현재 진행 상태의 정본은 §11 "남은 작업" 이다 (아래 §6 진행 상태는 초기 기록). +> 상태: **갈래 B 확정** (§11). MVP + ABI + `@SkipWhen*` + `@OnChange` + README 완료. +> `@Default` 는 검토만(§12), 구현 보류. `master` 병합은 남은 정리(§11) 후. +> 진행 상태 정본은 §11 (§6 진행 상태는 초기 기록). --- @@ -353,8 +353,13 @@ invalidation"*. 성능은 부차적 셀링포인트로만, 과장 없이. 이미 - KSP 가 `fun onChanged()` (무인자) 선언을 요구 - `PressScaleNode` 를 이걸로 갱신 → `boundSource` dedup 필드 + `draw()` 의 `rebind()` 제거 - `onChange_fires_only_when_param_changes` 테스트 -4. default argument 지원 — `@Default(String)` 마커 검토 완료(§12), 구현 보류 -5. README 에 §5 한계 + §10.5 성능 현실 명시 ← 다음 +4. **README** — `modifier-node-annotations/README.md` ✅ 완료 + - 포지셔닝(§5) · 어노테이션 레퍼런스 · `shouldAutoInvalidate` 요구 · 성능 현실(§10.5) · 한계 +5. default argument 지원 — `@Default(String)` 마커 검토 완료(§12), **구현 보류** + +### 남은 것 +- (보류) `@Default` 구현 — 사용자 요청 시 +- `master` 병합 전 정리: `:modifier-node-sample` 을 ship 할지 trim 할지, 커밋 squash 여부 **A. 중단.** (보류) 니치가 좁고 파라미터 단위 이득이 미미하다고 보면 유효했던 선택. diff --git a/modifier-node-annotations/README.md b/modifier-node-annotations/README.md new file mode 100644 index 0000000..5ca1429 --- /dev/null +++ b/modifier-node-annotations/README.md @@ -0,0 +1,101 @@ +# modifier-node-codegen + +`@ModifierNodeFactory` 를 붙인 `Modifier.Node` 서브클래스로부터 `ModifierNodeElement` 와 +`Modifier` 확장 함수를 KSP 로 생성한다. `composed { }` 를 벗어나 `Modifier.Node` 를 쓸 때 +반복되는 보일러플레이트(Element / `equals` / `hashCode` / `update` / `inspectableProperties`)를 없앤다. + +> **포지셔닝.** Velvet-Compose 내부 인프라 + `Modifier.Node` 학습용 실험 프로젝트다. +> `Modifier.Node` 를 직접 작성하는 사람(라이브러리 저자 등)만 대상이고, 니치가 좁다. +> 1인 유지보수 · SLA 없음 · API 호환성 보장 없음. 설계 배경과 판단 근거는 +> [`ARCHITECTURE.md`](../ARCHITECTURE.md), `composed` 와의 비교는 +> [`COMPARISON.md`](../modifier-node-sample/COMPARISON.md) 참고. + +## Setup + +```kotlin +// build.gradle.kts +plugins { + alias(libs.plugins.ksp) +} +dependencies { + implementation(project(":modifier-node-annotations")) + ksp(project(":modifier-node-processor")) +} +``` + +## 사용법 + +`Modifier.Node` 서브클래스를 평소대로 작성하고 `@ModifierNodeFactory` 를 붙인다. +파라미터는 **주 생성자의 `var` 프로퍼티 파라미터**여야 한다. + +```kotlin +@ModifierNodeFactory(name = "debugTint") +internal class DebugTintNode( + @Invalidates(Draw) var color: Color, +) : Modifier.Node(), DrawModifierNode { + override val shouldAutoInvalidate: Boolean get() = false // @Invalidates 를 쓰면 필수 + + override fun ContentDrawScope.draw() { + drawContent() + drawRect(color) + } +} +``` + +생성물 (`DebugTintElement.kt`): + +```kotlin +public fun Modifier.debugTint(color: Color): Modifier = this.then(DebugTintElement(color)) + +internal class DebugTintElement( + private val color: Color, +) : ModifierNodeElement() { + override fun create() = DebugTintNode(color) + override fun update(node: DebugTintNode) { + if (node.color != color) { node.color = color; node.invalidateDraw() } + } + override fun InspectorInfo.inspectableProperties() { name = "debugTint"; properties["color"] = color } + override fun equals(other: Any?): Boolean { /* per-field */ } + override fun hashCode(): Int { /* per-field */ } +} +``` + +Node 클래스의 그리기/측정/포인터 **행동 코드만** 직접 쓰면 된다. + +## 어노테이션 + +| 어노테이션 | 대상 | 하는 일 | +|---|---|---| +| `@ModifierNodeFactory(name, visibility)` | Node 클래스 | 생성 트리거. `name` 비우면 클래스명(`-Node`)에서 유도. `visibility` 는 생성 함수의 가시성(기본 `Public`), Element 는 항상 `internal` | +| `@Invalidates(vararg InvalidationScope)` | `var` 파라미터 | 이 파라미터가 바뀔 때 무효화할 범위. 미지정 시 노드가 구현한 모든 범위. `None` 은 무효화 없음 | +| `@SkipWhenFalse` / `@SkipWhenTrue` | `Boolean` 파라미터 | 조건 불충족 시 생성 함수가 `return this` — 노드 미attach. 여럿이면 OR | +| `@OnChange` | `var` 파라미터 | 값이 바뀌면 생성 `update()` 가 노드의 `fun onChanged()` 호출(재구독·effect 재시작용). 해당 함수 선언 필수 | + +### `shouldAutoInvalidate = false` + +Compose 는 `update()` 직후 노드의 모든 capability 를 자동 무효화한다. `@Invalidates` 의 +파라미터 단위 제어가 실제로 먹으려면 Node 가 다음을 선언해야 한다(`@Invalidates` 사용 시 KSP 가 강제): + +```kotlin +override val shouldAutoInvalidate: Boolean get() = false +``` + +지원되는 문서화된 API 이며, Compose 자체 `graphicsLayer` / `paint` 모디파이어가 같은 패턴을 쓴다. + +## 성능에 대한 현실적 기대 + +`Modifier.Node` 성능의 대부분은 **equals 스킵**(파라미터 불변 시 `update()` 자체가 안 불림)이고, +이건 `@Invalidates` 없이도 codegen 이 정확한 `equals` 를 보장해서 얻는다. `@Invalidates` 의 +파라미터 단위 invalidation 은 micro-optimization 이다 — Compose 조차 극소수 모디파이어에만 적용했고, +"파라미터가 자주 바뀌는데 그 변경이 draw 만 요구하는" 좁은 경우에만 이득이 있다. + +즉 이 도구의 실질 가치는 **보일러플레이트 제거 + equals 안정성 보장 + ABI 표면 축소**다. + +## 한계 + +- 생성 함수에 default argument 를 재현하지 못한다 (KSP 가 기본값 표현식을 못 읽음). 호출부에서 전 인자 전달. +- 지원 노드 인터페이스는 `DrawModifierNode`, `LayoutModifierNode` 뿐. +- 제네릭 Node, 다중 타입 파라미터 미검증. +- `equals` 는 참조형 파라미터의 `equals` 안정성에 의존한다. 함수 타입 파라미터는 경고하지만 + 그 밖의 불안정 타입은 사용자 책임. +- 프로세서 자체 단위 테스트가 없다 (`:modifier-node-sample` 생성물 + Robolectric 로 간접 검증). From a3bf914b9fd2b88a02dd208de31d9fb21c3b64d6 Mon Sep 17 00:00:00 2001 From: sonms Date: Tue, 8 Sep 2026 17:10:51 +0900 Subject: [PATCH 12/21] [Refactor] Tidy modifier-node-sample: one modifier per file, cover @SkipWhenTrue - split SampleNodes.kt into DebugTint.kt + FixedSquare.kt (one modifier each, wheelpicker style); rename compare/LocalTint.kt -> compare/AccentOverlay.kt to match the modifier name - SkipProbeNode gains a @SkipWhenTrue param; skip_markers_gate_application now covers both markers and the OR-join (previously @SkipWhenTrue was never exercised) - drop unused testImplementation(compose.ui.tooling) - add modifier-node-sample/README.md (module purpose + file/test map) 12 tests green, clean debug + release build. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_013UGir6UrorM7Kx7iyYv1ir --- modifier-node-sample/README.md | 21 ++++++++++++++++ modifier-node-sample/build.gradle.kts | 1 - .../sonms/modifiernode/sample/DebugTint.kt | 25 +++++++++++++++++++ .../sample/{SampleNodes.kt => FixedSquare.kt} | 19 +++----------- .../{LocalTint.kt => AccentOverlay.kt} | 0 .../sample/InvalidationContractTest.kt | 12 ++++++--- 6 files changed, 58 insertions(+), 20 deletions(-) create mode 100644 modifier-node-sample/README.md create mode 100644 modifier-node-sample/src/main/kotlin/com/sonms/modifiernode/sample/DebugTint.kt rename modifier-node-sample/src/main/kotlin/com/sonms/modifiernode/sample/{SampleNodes.kt => FixedSquare.kt} (78%) rename modifier-node-sample/src/main/kotlin/com/sonms/modifiernode/sample/compare/{LocalTint.kt => AccentOverlay.kt} (100%) diff --git a/modifier-node-sample/README.md b/modifier-node-sample/README.md new file mode 100644 index 0000000..ae1f7a9 --- /dev/null +++ b/modifier-node-sample/README.md @@ -0,0 +1,21 @@ +# modifier-node-sample + +`modifier-node-codegen` 의 예제 + 통합 테스트 모듈. **배포하지 않는다.** +This is the examples + integration test module for `modifier-node-codegen`. **Not published.** + +## 구성 + +| 파일 | 내용 | +|---|---| +| `DebugTint.kt` | 가장 단순한 예 — draw 전용 노드 | +| `FixedSquare.kt` | draw + layout 노드 — `update()` 계층 접기 | +| `FadingEdge.kt` | dogfood. `graphicsLayer` 오프스크린 + `DstIn` 마스크. composition-free 체인을 raw 노드로 내렸을 때의 손익을 보여주는 케이스 (`../ARCHITECTURE.md` §9) | +| `compare/AccentOverlay.kt` | `composed` vs Node — CompositionLocal 읽기 (Case A) | +| `compare/PressScale.kt` | `composed` vs Node — InteractionSource + 애니메이션 (Case B) | +| `COMPARISON.md` | 위 두 케이스의 before/after 분석 | + +## 테스트 + +- `GeneratedElementTest` — equals/hashCode/create/update 계약, Element `internal` 가시성 (순수 JUnit) +- `InvalidationContractTest` — equals 스킵, `@SkipWhen*` 가드, `@OnChange` 콜백 (Robolectric) +- `AutoInvalidateProbeTest` — `shouldAutoInvalidate = false` opt-out 동작 통제 실험 (Robolectric) diff --git a/modifier-node-sample/build.gradle.kts b/modifier-node-sample/build.gradle.kts index fa7c4e7..a914358 100644 --- a/modifier-node-sample/build.gradle.kts +++ b/modifier-node-sample/build.gradle.kts @@ -39,6 +39,5 @@ dependencies { testImplementation(libs.kotlin.reflect) testImplementation(platform(libs.androidx.compose.bom)) testImplementation(libs.androidx.compose.ui.test.junit4) - testImplementation(libs.androidx.compose.ui.tooling) debugImplementation(libs.androidx.compose.ui.test.manifest) } diff --git a/modifier-node-sample/src/main/kotlin/com/sonms/modifiernode/sample/DebugTint.kt b/modifier-node-sample/src/main/kotlin/com/sonms/modifiernode/sample/DebugTint.kt new file mode 100644 index 0000000..21ebc9f --- /dev/null +++ b/modifier-node-sample/src/main/kotlin/com/sonms/modifiernode/sample/DebugTint.kt @@ -0,0 +1,25 @@ +package com.sonms.modifiernode.sample + +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.ContentDrawScope +import androidx.compose.ui.node.DrawModifierNode +import com.sonms.modifiernode.annotations.Invalidates +import com.sonms.modifiernode.annotations.InvalidationScope.Draw +import com.sonms.modifiernode.annotations.ModifierNodeFactory + +/** + * 컨텐츠 위에 색을 덧칠하는 draw 전용 노드. 가장 단순한 생성 예. + * Draw-only node that tints the content — the simplest codegen example. + */ +@ModifierNodeFactory(name = "debugTint") +internal class DebugTintNode( + @Invalidates(Draw) var color: Color, +) : Modifier.Node(), DrawModifierNode { + override val shouldAutoInvalidate: Boolean get() = false + + override fun ContentDrawScope.draw() { + drawContent() + drawRect(color) + } +} diff --git a/modifier-node-sample/src/main/kotlin/com/sonms/modifiernode/sample/SampleNodes.kt b/modifier-node-sample/src/main/kotlin/com/sonms/modifiernode/sample/FixedSquare.kt similarity index 78% rename from modifier-node-sample/src/main/kotlin/com/sonms/modifiernode/sample/SampleNodes.kt rename to modifier-node-sample/src/main/kotlin/com/sonms/modifiernode/sample/FixedSquare.kt index 2688b28..b79f0c5 100644 --- a/modifier-node-sample/src/main/kotlin/com/sonms/modifiernode/sample/SampleNodes.kt +++ b/modifier-node-sample/src/main/kotlin/com/sonms/modifiernode/sample/FixedSquare.kt @@ -15,25 +15,12 @@ import com.sonms.modifiernode.annotations.InvalidationScope.Draw import com.sonms.modifiernode.annotations.InvalidationScope.Measure import com.sonms.modifiernode.annotations.ModifierNodeFactory -/** - * 컨텐츠 위에 색을 덧칠하는 draw 전용 노드. - * Draw-only node that tints the content with a color. - */ -@ModifierNodeFactory(name = "debugTint") -internal class DebugTintNode( - @Invalidates(Draw) var color: Color, -) : Modifier.Node(), DrawModifierNode { - override val shouldAutoInvalidate: Boolean get() = false - - override fun ContentDrawScope.draw() { - drawContent() - drawRect(color) - } -} - /** * 정사각형 크기로 강제하고 그 위에 색을 덧칠하는 draw + layout 노드. + * `side` 변경은 remeasure, `overlay` 변경은 redraw 만 — 생성된 `update()` 의 계층 접기 예. + * * Draw + layout node that forces a square size and tints over it. + * Changing `side` remeasures; changing `overlay` only redraws — shows the generated `update()` folding. */ @ModifierNodeFactory(name = "fixedSquare") internal class FixedSquareNode( diff --git a/modifier-node-sample/src/main/kotlin/com/sonms/modifiernode/sample/compare/LocalTint.kt b/modifier-node-sample/src/main/kotlin/com/sonms/modifiernode/sample/compare/AccentOverlay.kt similarity index 100% rename from modifier-node-sample/src/main/kotlin/com/sonms/modifiernode/sample/compare/LocalTint.kt rename to modifier-node-sample/src/main/kotlin/com/sonms/modifiernode/sample/compare/AccentOverlay.kt diff --git a/modifier-node-sample/src/test/kotlin/com/sonms/modifiernode/sample/InvalidationContractTest.kt b/modifier-node-sample/src/test/kotlin/com/sonms/modifiernode/sample/InvalidationContractTest.kt index 9102282..a890a85 100644 --- a/modifier-node-sample/src/test/kotlin/com/sonms/modifiernode/sample/InvalidationContractTest.kt +++ b/modifier-node-sample/src/test/kotlin/com/sonms/modifiernode/sample/InvalidationContractTest.kt @@ -19,6 +19,7 @@ import com.sonms.modifiernode.annotations.InvalidationScope.Measure import com.sonms.modifiernode.annotations.ModifierNodeFactory import com.sonms.modifiernode.annotations.OnChange import com.sonms.modifiernode.annotations.SkipWhenFalse +import com.sonms.modifiernode.annotations.SkipWhenTrue import org.junit.Assert.assertEquals import org.junit.Assert.assertNotSame import org.junit.Assert.assertSame @@ -62,6 +63,7 @@ internal class ProbeNode( @ModifierNodeFactory(name = "skipProbe") internal class SkipProbeNode( @SkipWhenFalse var on: Boolean, + @SkipWhenTrue var hidden: Boolean, @Invalidates(Draw) var tag: Int, ) : Modifier.Node(), DrawModifierNode { override val shouldAutoInvalidate: Boolean get() = false @@ -110,9 +112,13 @@ class InvalidationContractTest { } @Test - fun skipWhenFalse_gates_application() { - assertSame(Modifier, Modifier.skipProbe(on = false, tag = 0)) - assertNotSame(Modifier, Modifier.skipProbe(on = true, tag = 0)) + fun skip_markers_gate_application() { + // 적용: on=true 이고 hidden=false + assertNotSame(Modifier, Modifier.skipProbe(on = true, hidden = false, tag = 0)) + // @SkipWhenFalse: on=false → skip + assertSame(Modifier, Modifier.skipProbe(on = false, hidden = false, tag = 0)) + // @SkipWhenTrue: hidden=true → skip + assertSame(Modifier, Modifier.skipProbe(on = true, hidden = true, tag = 0)) } @Test From 8c2af11292b4d51b50d43f26131ad203dc39b6c8 Mon Sep 17 00:00:00 2001 From: sonms Date: Tue, 8 Sep 2026 17:11:02 +0900 Subject: [PATCH 13/21] =?UTF-8?q?[Docs]=20ARCHITECTURE=20=C2=A711:=20mark?= =?UTF-8?q?=20sample=20cleanup=20done?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_013UGir6UrorM7Kx7iyYv1ir --- ARCHITECTURE.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 4b7ed02..3438e0e 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -359,7 +359,8 @@ invalidation"*. 성능은 부차적 셀링포인트로만, 과장 없이. 이미 ### 남은 것 - (보류) `@Default` 구현 — 사용자 요청 시 -- `master` 병합 전 정리: `:modifier-node-sample` 을 ship 할지 trim 할지, 커밋 squash 여부 +- `:modifier-node-sample` 정리 ✅ (한 파일 1 modifier, `@SkipWhenTrue` 커버, `README.md`, 미배포 명시) +- `master` 병합 전: 커밋 squash 여부 **A. 중단.** (보류) 니치가 좁고 파라미터 단위 이득이 미미하다고 보면 유효했던 선택. From 86e146b5c57b708d4009536ead87fd082303afa4 Mon Sep 17 00:00:00 2001 From: sonms Date: Tue, 8 Sep 2026 17:17:40 +0900 Subject: [PATCH 14/21] [Docs] Rewrite ARCHITECTURE.md as a coherent current-state doc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The doc had grown into an investigation journal with contradictions layered on (e.g. §9 "조건부 적용 표현 불가" after @SkipWhen* shipped; two competing decision gates; §10.4 "ABI 미구현" after it shipped). - restructured: 문제 → 하는 것 → 어노테이션 → shouldAutoInvalidate 사실 → 가치/한계 → 비목표 → 비교요약 → 구현상태 → 판단근거 → 부록(@Default, 조사이력) - single accurate value statement: main value = boilerplate removal + equals stability + ABI; parameter-level invalidation is a narrow micro-opt (was contradicted between §3 and §10.5) - one decision gate, not two; investigation drama moved to a short 부록 B - preserved the load-bearing facts: NodeKind.kt:337 opt-out, the $annotations Deprecated artifact, Compose's own usage - 388 → 234 lines; fixed §-refs in COMPARISON.md and sample README No code changed. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_013UGir6UrorM7Kx7iyYv1ir --- ARCHITECTURE.md | 447 ++++++++++------------------- modifier-node-sample/COMPARISON.md | 26 +- modifier-node-sample/README.md | 2 +- 3 files changed, 153 insertions(+), 322 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 3438e0e..8ee02a4 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1,387 +1,234 @@ -# ARCHITECTURE — Modifier.Node codegen (실험 브랜치) +# ARCHITECTURE — modifier-node-codegen -> 브랜치: `feat/modifier-node-codegen` -> 상태: **갈래 B 확정** (§11). MVP + ABI + `@SkipWhen*` + `@OnChange` + README 완료. -> `@Default` 는 검토만(§12), 구현 보류. `master` 병합은 남은 정리(§11) 후. -> 진행 상태 정본은 §11 (§6 진행 상태는 초기 기록). +> 브랜치: `feat/modifier-node-codegen` (실험). `master` 미병합. +> 스택: Kotlin 2.0.21 · AGP 8.13.2 · Compose BOM 2026.04.01 (ui 1.12.0-beta02) · KSP 2.0.21-1.0.28 · KotlinPoet 1.18.1 +> 모듈: `:modifier-node-annotations` · `:modifier-node-processor` · `:modifier-node-sample`(예제+테스트, 미배포) + +사용법은 [`modifier-node-annotations/README.md`](modifier-node-annotations/README.md), +`composed` 와의 비교는 [`modifier-node-sample/COMPARISON.md`](modifier-node-sample/COMPARISON.md). --- ## 1. 문제 -Compose에서 `Modifier` 확장 함수를 만들 때 지금까지 `Modifier.composed { }` 를 써 왔다. -`composed` 의 구조적 문제: +`Modifier` 확장 함수를 만들 때 흔히 `Modifier.composed { }` 를 쓴다. `composed` 는: -- 반환 Modifier가 `equals`/`hashCode` 를 구현하지 않음 → modifier 재사용 비교가 항상 실패 → 불필요한 재구성/무효화 +- 반환 Modifier 가 `equals`/`hashCode` 미구현 → modifier 체인 재사용 비교 실패 → 불필요한 재구성/무효화 - 매 재구성마다 람다 re-materialize -- composition 밖에서 사용 불가, `Modifier.Companion` 정적 팩토리로 만들 수 없음 -- AndroidX가 `Modifier.Node` 로 전면 이관 중이며 `composed` 는 deprecate 예고 상태 +- composition 밖에서 사용 불가 -`Modifier.Node` 로 옮기면 위 문제가 해결되지만 **보일러플레이트**가 생긴다: -`ModifierNodeElement` 서브클래스 + `Modifier.Node` 서브클래스 + -`equals` / `hashCode` / `update` / `InspectorInfo` 수동 작성. +AndroidX 권장은 `Modifier.Node` 이며 신규 코드에서 `composed` 사용을 지양한다. +`Modifier.Node` 로 옮기면 위 문제는 사라지지만 **보일러플레이트**가 생긴다: +`ModifierNodeElement` 서브클래스 + `equals`/`hashCode`/`update`/`inspectableProperties` 수동 작성. --- ## 2. 이 프로젝트가 하는 것 -`@ModifierNodeFactory` 어노테이션이 붙은 **`Modifier.Node` 서브클래스**로부터 -`ModifierNodeElement` 레이어와 공개 확장 함수를 KSP로 생성한다. +`@ModifierNodeFactory` 를 붙인 **concrete `Modifier.Node` 서브클래스**로부터 KSP 가 +`ModifierNodeElement` 와 `Modifier` 확장 함수를 생성한다. -> 설계 변경 (초안 대비): 별도 `NodeBase` 를 생성하고 사용자가 상속하는 대신, -> **사용자가 concrete Node 클래스를 직접 작성하고 거기에 어노테이션을 단다.** -> 파라미터의 단일 원천이 Node의 주 생성자(`var` 프로퍼티 파라미터)가 되어 -> 이름 규약/동기화 문제가 사라진다. `create` 는 생성자 호출, `update` 는 `var` 재대입. +파라미터의 단일 원천은 Node 주 생성자의 `var` 프로퍼티 파라미터다. +(초안의 별도 `NodeBase` 상속 방식은 이름 규약/동기화 문제 때문에 버림.) +`create()` 는 생성자 호출, `update()` 는 `var` 재대입 + 무효화. ```kotlin -@ModifierNodeFactory(name = "shimmer") -internal class ShimmerNode( +@ModifierNodeFactory(name = "debugTint") +internal class DebugTintNode( @Invalidates(Draw) var color: Color, - @Invalidates(Measure) var thickness: Dp, - var durationMillis: Int, ) : Modifier.Node(), DrawModifierNode { - override fun ContentDrawScope.draw() { /* color, thickness 사용 */ } + override val shouldAutoInvalidate: Boolean get() = false // §5 + + override fun ContentDrawScope.draw() { drawContent(); drawRect(color) } } ``` -생성물 (`ShimmerElement.kt` 한 파일): - -- `internal fun Modifier.shimmer(color, thickness, durationMillis): Modifier = this.then(ShimmerElement(...))` -- `internal class ShimmerElement : ModifierNodeElement()` — `create` / `update` / `equals` / `hashCode` / `inspectableProperties` +생성물 (`DebugTintElement.kt` 한 파일): -사용자가 직접 작성하는 것: +- `public fun Modifier.debugTint(color: Color): Modifier = this.then(DebugTintElement(color))` +- `internal class DebugTintElement : ModifierNodeElement()` + — `create` / `update` / `equals` / `hashCode` / `inspectableProperties` -- Node 클래스의 그리기/측정/포인터 **행동 코드만** +사용자는 Node 의 **행동 코드(그리기/측정/포인터)만** 쓴다. --- -## 3. 만드는 이유 (가치 명제) - -InspectorInfo/`update` 자동화만 놓고 보면 절약량은 파일당 10~20줄이며 -data class Element 로도 대충 흉내 낼 수 있다. **그 자체는 프로젝트의 이유가 아니다.** - -실제 이유는 두 가지다: +## 3. 어노테이션 -### 3.1 컴파일 타임에 검증되는 invalidation 계약 +| 어노테이션 | 대상 | 하는 일 | +|---|---|---| +| `@ModifierNodeFactory(name, visibility)` | Node 클래스 | 생성 트리거. `name` 비우면 클래스명(`-Node`)에서 유도. `visibility` 는 생성 **함수**의 가시성(기본 `Public`) — 생성 Element 는 항상 `internal` | +| `@Invalidates(vararg InvalidationScope)` | `var` 파라미터 | 변경 시 무효화 범위. 미지정 → 노드가 구현한 모든 범위. `None` → 무효화 없음. **사용 시 Node 에 `shouldAutoInvalidate = false` 강제**(§5) | +| `@SkipWhenFalse` / `@SkipWhenTrue` | `Boolean` 파라미터 | 조건 시 생성 함수가 `return this` — 노드 미attach. 여럿이면 OR (`if (!a \|\| b) return this`) | +| `@OnChange` | `var` 파라미터 | 값 변경 시 생성 `update()` 가 노드의 `fun onChanged()` 호출(재구독·effect 재시작용). 해당 함수 선언 필수 | -- `@Invalidates(Measure)` 인데 노드가 `LayoutModifierNode` 를 구현하지 않으면 KSP 에러 -- 파라미터가 바뀌었을 때 어떤 invalidate가 호출되는지 생성 코드 + 테스트 헬퍼로 고정 -- 손으로 짤 때 흔한 두 실수를 원천 차단: - - 과잉: 무엇이 바뀌든 `invalidateMeasurement()` - - 누락: 필드 대입만 하고 invalidate 안 함 +`InvalidationScope` 계층: `Measure` > `Placement` > `Draw` (상위 하나만 호출). +`Semantics` / `ParentData` 는 MVP 미지원, 지정 시 에러. -생성되는 `update` 형태: +생성되는 `update()` (계층 접기 예): ```kotlin -override fun update(node: ShimmerNode) { +override fun update(node: FixedSquareNode) { var redraw = false; var remeasure = false - if (node.color != color) { node.color = color; redraw = true } - if (node.thickness != thickness) { node.thickness = thickness; remeasure = true } - if (node.durationMillis != durationMillis) node.durationMillis = durationMillis - if (remeasure) node.invalidateMeasurement() // 계층상 redraw 포함 - else if (redraw) node.invalidateDraw() + if (node.side != side) { node.side = side; remeasure = true } + if (node.overlay != overlay) { node.overlay = overlay; redraw = true } + if (remeasure) node.invalidateMeasurement() else if (redraw) node.invalidateDraw() } ``` -invalidation 계층: `Measure` > `Placement` > `Draw` (상위 하나만 호출), -`Semantics` / `ParentData` 는 직교(별도 호출). - -무지정 파라미터 기본 정책: **안전 우선** — 노드가 구현한 모든 capability를 invalidate. -`@Invalidates(...)` 로 좁히고, `@Invalidates(None)` 으로 완전히 뺀다 -(create 시점에만 쓰거나 `observeReads` 로 반응적으로 읽는 값). - -### 3.2 바이너리 호환성 (라이브러리 저자용) - -- 생성된 Element 는 항상 `internal`, 공개되는 건 `Modifier.foo(...)` 함수뿐 - (`@ModifierNodeFactory(visibility = ...)` 로 함수 가시성 제어). → 내부 리팩터가 ABI를 깨지 않음. -- (범위 밖) default argument 대신 오버로드 체인 생성 — 파라미터 추가 = 심볼 추가. - --- -## 4. 비목표 (Non-goals) +## 4. `shouldAutoInvalidate` — `@Invalidates` 가 한 줄을 요구하는 이유 -- **`composed` → `Node` 자동 변환기.** `composed` 블록은 임의의 `@Composable` 코드를 - 담을 수 있어(서브 컴포지션, 복잡한 `remember` 키, effect 의존성) 범용 기계 변환이 불가능하다. -- **노드 행동 코드 생성.** 그리기/측정/포인터 로직은 사용자가 직접 쓴다. - codegen은 Element 레이어 + 필드 선언까지만. -- **런타임 람다 DSL** (`Modifier.node { onDraw { } }` 류). 람다 캡처 → `equals` 불안정 → - `composed` 문제 재발. -- **범용 OSS로의 확장.** 아래 "포지셔닝" 참고. +> 재조사 비용을 아끼기 위한 사실 기록. `androidx.compose.ui` 1.12.0-beta02 소스 직접 확인. ---- - -## 5. 포지셔닝 / 솔직한 한계 - -- **니치가 좁다.** `Modifier.Node` 를 직접 작성하는 사람은 라이브러리 저자 / 디자인시스템 - 팀 정도이며, 그중 이 codegen을 dependency로 받을 사람은 더 적다. - → 이 프로젝트는 **Velvet-Compose 내부 인프라 + `Modifier.Node` 학습 + 포트폴리오**가 목적이다. - 공개는 하되 "많은 사용자" 목표는 없다. -- **절약량이 크지 않다.** trivial한 부분(InspectorInfo/update 대입)만 보면 파일당 10~20줄. - 가치는 3.1 / 3.2 에 있고 거기서 안 나오면 접는다. -- **`Modifier.Node` API 안정성.** 코어(`ModifierNodeElement`, `Modifier.Node`, - `DrawModifierNode`, `LayoutModifierNode`, `invalidateDraw/Measurement/Placement`)는 - 현행 Compose에서 stable. MVP 착수 시 실제 사용 API 목록을 뽑아 각각 - stable/experimental 태그를 확인하고, experimental이 섞이면 그 부분만 opt-in으로 격리한다. -- **1인 유지보수.** SLA 없음. 이슈 생기면 대응, API 호환성 보장 안 함. README에 명시. +Compose 는 `NodeChain.updateNode()` → `autoInvalidateUpdatedNode()` 에서 `update()` 직후 +노드의 **모든** capability 를 무효화한다. 유일한 opt-out: ---- +```kotlin +// NodeKind.kt (autoInvalidateNodeSelf) +if (phase == Updated && !node.shouldAutoInvalidate) return +``` -## 6. MVP 스코프 - -- `@ModifierNodeFactory(name)` (Node 클래스에 부착) + `@Invalidates(vararg Scope)` (생성자 파라미터에 부착) - (`Scope = Measure | Placement | Draw | Semantics | ParentData | None`) -- KSP 프로세서: - - `Element` 생성: `create` / `update` / `equals` / `hashCode` / `inspectableProperties` - - 공개 확장 함수 생성 - - 검증: `@Invalidates` scope ↔ 노드가 구현한 인터페이스 정합성, `var` 파라미터 강제 -- 지원 노드 인터페이스 **2개만**: `DrawModifierNode`, `LayoutModifierNode` -- invalidation 계층 접기 로직 (`Measure` > `Placement` > `Draw`) -- 모듈: `:modifier-node-annotations`, `:modifier-node-processor`, `:modifier-node-sample` -- **Dogfooding: `:modifier-node-sample` 안에서 `fadingEdge` 재작성.** - ratingbar / wheelpicker 컴포넌트 본체에 이 codegen dependency를 연결하지 않는다 - (의존성이 실험 모듈에 몰리는 것 방지). `fadingEdge` 는 sample 모듈 격리 예제로만 사용. - ※ `fadingEdge` 는 `graphicsLayer { compositingStrategy = Offscreen }` 를 필요로 하므로 - 순수 `DrawModifierNode` 로는 완전 재현 불가 — GraphicsLayer 위임이 필요하고 이는 - 결정 게이트에서 "노드 행동 코드는 codegen이 안 도와준다" 를 실증하는 좋은 케이스. - -### 초기 진행 기록 (2026-09-08) — 현재 상태는 §11 참고 - -- [x] 모듈 3개 스캐폴딩, 어노테이션, 프로세서(Element + 함수 생성, invalidation 계층 접기, 검증) -- [x] 샘플 예제 + `fadingEdge` 재작성 (§9), `composed` vs Node 비교 (§10, `COMPARISON.md`) -- [x] 계약 테스트 — `GeneratedElementTest`, `InvalidationContractTest`, `AutoInvalidateProbeTest` -- [x] invalidation 스코프 실효성 조사 → 작동 확인 (§10). 첫 "불가" 결론은 오독, 정정함 -- [x] 갈래 B 확정 → 이후 작업은 §11 - -### 한계 (MVP 구현상) - -- 생성 함수에 default argument 재현 불가 (KSP가 기본값 표현식을 못 읽음). 전 인자 필수. - → `@Default(String)` 마커 검토 완료(§12), 미구현. -- 제네릭 Node, 다중 타입 파라미터 미검증. -- `equals`/`hashCode` 는 참조형 파라미터의 `equals` 안정성에 의존. 함수 타입 파라미터는 - 프로세서가 경고한다(참조 동일성 → 재사용 깨짐). 그 밖의 불안정 타입은 사용자 책임. -- 하드 에러(non-var, 잘못된 마커, `@OnChange` 콜백 누락) 시 해당 노드의 생성물을 만들지 않는다(2차 에러 방지). +- `Modifier.Node.shouldAutoInvalidate` (Modifier.kt): `open val ... get() = true`. **`@Deprecated` 아니다.** + (바이트코드의 `getShouldAutoInvalidate$annotations()` 합성 메서드에 `Deprecated: true` 가 붙어 있으나 + 이는 Kotlin 컴파일러 아티팩트 — `getNode$annotations()` 도 동일. override 시 경고 없음.) +- KDoc 이 직접 안내: *"You may choose to set this to `false` … you must call the appropriate + invalidate functions manually when the modifier is updated."* +- Compose 자체 사용처: `SimpleGraphicsLayerModifier`(= `Modifier.graphicsLayer{}`), + `PainterNode`(= `Modifier.paint`), `FocusTargetNode`. -### 범위 밖 (MVP 이후) +즉 `@Invalidates` 의 파라미터 단위 제어가 실효를 가지려면 Node 가 +`override val shouldAutoInvalidate: Boolean get() = false` 를 선언해야 한다. +codegen 은 Node 클래스를 수정 못 하므로 `@Invalidates` 사용 시 이 선언을 요구한다(없으면 KSP 에러). -- 노드 인터페이스 확장 (`PointerInputModifierNode`, `SemanticsModifierNode`, `GlobalPositionAwareModifierNode` ...) -- `binaryCompat = true` 오버로드 체인 -- IDE inspection / quick-fix -- 프로세서 자체 단위 테스트 (현재는 `:modifier-node-sample` 생성물 + Robolectric 로 간접 검증) +`AutoInvalidateProbeTest` 로 동작 고정: `shouldAutoInvalidate=false` 노드는 draw 전용 파라미터 +변경 시 remeasure 하지 않고, measure 파라미터 변경 시엔 `update()` 의 `invalidateMeasurement()` 로 remeasure. --- -## 7. 결정 게이트 +## 5. 가치와 한계 -MVP 완료 후 아래를 근거로 **계속 / 중단**을 판단한다. +### 실질 가치 -**계속 조건 (아래를 모두 만족):** +1. **보일러플레이트 제거** — `equals`/`hashCode`/`create`/`update`/`inspectableProperties`, + modifier 당 ~30~55줄. `GeneratedElementTest` 로 계약 고정. +2. **equals 안정성 보장** — 파라미터 불변 시 `update()` 자체가 안 불림(`Modifier.Node` 성능의 대부분). + codegen 이 정확한 `equals` 를 생성하므로 사용자가 실수로 깨뜨릴 여지가 없다. +3. **ABI 표면 축소** — Element 는 항상 `internal`, 공개는 `Modifier.foo()` 함수뿐. +4. `@Invalidates` scope ↔ 노드 인터페이스 **컴파일 타임 검증**. +5. `@SkipWhen*` / `@OnChange` — `composed` 의 조건부 적용 / `remember(key)` 재구독을 마커로 대체. -1. 재작성한 `fadingEdge` 가 원본보다 읽기 쉽거나 최소한 동등하다 (보일러플레이트가 - 실제로 사라졌고, 노드 행동 코드는 그대로 명확하다). -2. `@Invalidates` invalidation 계약이 **손으로 짤 때보다 명확한 이점**을 준다: - - scope ↔ 인터페이스 불일치를 실제로 컴파일 타임에 잡아낸다 - - 테스트 헬퍼로 "이 파라미터는 redraw만" 을 고정할 수 있고, 그게 회귀 방지에 유용하다고 느껴진다 -3. KSP 프로세서 구현/디버깅 비용이 감당 가능한 수준이었다 (며칠 단위가 아니라 시간 단위). +### 파라미터 단위 invalidation 의 성능 이득은 좁다 -**중단 판단 (아래 중 하나라도):** +micro-optimization 이다. Compose 조차 극소수 modifier(graphicsLayer/paint/focus)에만 적용했다. +`Modifier.Node` 성능의 대부분은 equals 스킵이고 그건 `@Invalidates` 없이도 얻는다. +`@Invalidates` 의 이득은 "파라미터가 자주 바뀌는데 그 변경이 draw 만 요구하는" 경우에 한정. +**주된 가치는 1·2·3 이다.** -- 재작성 결과가 원본과 비슷하거나 더 장황하다. -- invalidation 계약이 "있으면 좋지만 없어도 그만" 수준이고, 검증 에러가 실전에서 안 걸린다. -- KSP 쪽에서 예상 못 한 제약(타입 해석, 제네릭 노드, cross-module 가시성)으로 스코프가 계속 샌다. +### 한계 -중단 시: `Modifier.Node` / KSP 를 저비용으로 학습한 것으로 간주하고 브랜치를 아카이브한다. - ---- +- 생성 함수에 default argument 재현 불가 (KSP 가 기본값 표현식을 못 읽음). 호출부에서 전 인자 전달. + → `@Default(String)` 검토 완료(부록 A), 구현 보류. +- 지원 노드 인터페이스 2개: `DrawModifierNode`, `LayoutModifierNode`. +- 제네릭 Node, 다중 타입 파라미터 미검증. +- `equals` 는 참조형 파라미터의 `equals` 안정성에 의존. 함수 타입은 프로세서가 경고, + 그 밖의 불안정 타입은 사용자 책임. +- 하드 에러(non-var, 잘못된 마커, `@OnChange` 콜백 누락) 시 해당 노드 생성물을 안 만든다(2차 에러 방지). +- 프로세서 자체 단위 테스트 없음 — `:modifier-node-sample` 생성물 + Robolectric 로 간접 검증. -## 8. 참고 +### 포지셔닝 -- 프로젝트: Kotlin 2.0.21, AGP 8.13.2, Compose BOM 2026.04.01 -- KSP 버전은 Kotlin 2.0.21 에 정렬 (`2.0.21-1.0.x`) -- 첫 dogfooding 대상: `wheelpicker/src/main/java/com/sonms/wheelpicker/extension/ModifierExt.kt` +`Modifier.Node` 를 직접 작성하는 사람(라이브러리 저자 등)만 대상 — 니치가 좁다. +Velvet-Compose 내부 인프라 + `Modifier.Node` 학습 목적. 1인 유지보수, SLA·API 호환성 보장 없음. --- -## 9. Dogfooding 결과 — `fadingEdge` - -`modifier-node-sample/.../FadingEdge.kt` (`FadingEdgeNode`) + 생성된 `FadingEdgeElement`. - -### LOC - -| | 원본 | Node 판 | -|---|---|---| -| 손으로 쓴 코드 | 함수 1개, 로직 ~30줄 | `FadingEdgeNode` ~55줄 (lifecycle 포함) | -| 생성 코드 | 0 | `FadingEdgeElement` ~65줄 (무료) | -| **총 손 코드** | **~30줄** | **~55줄** | - -손으로 쓴 코드가 오히려 늘었다. - -### 핵심 발견 (결정 게이트에 반영) - -1. **`fadingEdge` 는 애초에 `composed` 를 안 썼다.** 원본은 - `graphicsLayer { }.drawWithCache { }` 체인 — 둘 다 이미 `Modifier.Node` 기반이고 - `equals` 도 제대로 되는 modifier다. 이걸 raw 노드로 내리면: - - `GraphicsLayer` lifecycle 을 수동 관리 (`onAttach`/`onDetach`) - - `drawWithCache` 의 브러시 캐싱을 잃음 (record 블록에서 매번 재계산) - - → **순 손해.** -2. 즉 이 codegen 의 가치는 **"원래라면 `composed` 를 썼을 상황"** 에서만 나온다: - CompositionLocal 읽기, `InteractionSource`, 코루틴 기반 애니메이션을 modifier 안에서 다룰 때. - composition-free modifier 체인을 raw 노드로 재작성하는 건 안티패턴. -3. **조건부 적용 표현 불가.** 원본의 `enabled=false` → early return (modifier 미적용). - 현재 codegen 은 `this.then(Element(...))` 를 무조건 생성 → 노드가 파라미터를 보고 - no-op 하는 우회만 가능, disabled 상태에서도 노드/레이어 attach. - → 확장 함수 본문에 `if` 를 넣는 옵션(`@ModifierNodeFactory(skipWhen = ...)` 류)이 필요. +## 6. 비목표 -### 판정 관련 - -- 계속 조건 1 ("`fadingEdge` 가 원본보다 읽기 쉽거나 동등") → **불충족.** 단 이는 - 대상 선정 실수(§9-1). `composed` 를 실제로 쓰는 케이스로 다시 검증 → §10, `COMPARISON.md`. +- `composed` → `Node` **자동 변환기.** `composed` 블록은 임의의 `@Composable` 코드(서브 컴포지션, + 복잡한 `remember` 키, effect 의존성)를 담을 수 있어 범용 기계 변환 불가. +- **노드 행동 코드 생성.** 그리기/측정/포인터 로직은 사용자가 쓴다. +- **런타임 람다 DSL** (`Modifier.node { onDraw { } }` 류). 람다 캡처 → `equals` 불안정 → `composed` 문제 재발. +- **범용 OSS 확장.** --- -## 10. `composed` 케이스 재검증 + invalidation 스코프 실효성 +## 7. `composed` vs Node 비교 (요약) -`modifier-node-sample/COMPARISON.md` 에 2케이스 before/after. 요약: +세부는 `COMPARISON.md`. 손으로 쓴 코드 줄 수: -| 모디파이어 부류 | codegen 효과 | -|---|---| -| 값(파라미터/CompositionLocal) 읽고 draw/measure 에서 반응 | **결정적** — 손 코드 7→10 vs codegen 없이 7→55 | -| interaction / coroutine / animation | `@OnChange` 로 재구독 격차 축소(10→35). 남은 장황함은 진짜 노드 코드 | - -### 10.1 autoInvalidate 조사 — 처음 결론이 틀렸다 (정정) - -`androidx.compose.ui` **1.12.0-beta02 소스 직접 확인** (`ui-android-...-sources.jar`): - -- `NodeChain.updateNode()` → `autoInvalidateUpdatedNode(node)` → `NodeKind.autoInvalidateNodeSelf`: - ``` - if (phase == Updated && !node.shouldAutoInvalidate) return // NodeKind.kt:337 - ``` - 즉 **opt-out 이 있다.** -- `Modifier.Node.shouldAutoInvalidate` (Modifier.kt:224-240): `open val ... get() = true`. - KDoc 이 직접 안내: *"You may choose to set this to `false` if your modifier has - auto-invalidatable properties that do not frequently require invalidation to improve - performance ... you must call the appropriate invalidate functions manually when the - modifier is updated."* → **이게 바로 파라미터 단위 invalidation 이다.** -- **`@Deprecated` 아니다.** 앞 커밋에서 "deprecated" 라 한 건 바이트코드의 - `getShouldAutoInvalidate$annotations()` 합성 메서드에 붙은 `Deprecated: true` 속성을 오독한 것. - 같은 속성이 `getNode$annotations()` 에도 붙어 있는데 `node` 가 deprecated 일 리 없다 — - Kotlin 컴파일러의 `$annotations` 합성 메서드 처리 방식일 뿐. override 시 경고도 안 뜬다. -- **Compose 자체가 이 패턴을 쓴다**: `SimpleGraphicsLayerModifier`(= `Modifier.graphicsLayer{}`), - `PainterNode`(= `Modifier.paint`), `BlockGraphicsLayerModifier`, `FocusTargetNode`. - `SimpleGraphicsLayerModifier` 주석: *"We can skip remeasuring as we only need to rerun the - placement block. we request it manually in the update block."* - -### 10.2 통제 실험 — `AutoInvalidateProbeTest` - -동일 하네스, draw+layout 노드 2개, `drawKey`(draw 전용 파라미터)만 변경: - -| 노드 | `shouldAutoInvalidate` | drawKey 변경 시 remeasure | -|---|---|---| -| `AutoOnNode` | 기본(true) | **발생** (autoInvalidate) | -| `AutoOffNode` | `false` | **없음** ✅ | -| `AutoOffNode`, measureKey 변경 | `false` | 발생 (update() 의 `invalidateMeasurement()` 정상) | +| 부류 | composed | Node+codegen | codegen 없이 | +|---|---:|---:|---:| +| 값(파라미터/CompositionLocal) 읽고 draw/measure 반응 | ~7 | ~10 | ~55 | +| interaction / coroutine / animation | ~10 | ~35 | ~85 | +| composition-free 체인(`graphicsLayer` 등) | — | 손해 | 손해 | -→ **파라미터 단위 invalidation 스코프는 작동한다.** 단 Node 가 `shouldAutoInvalidate = false` - 를 선언해야 한다. +- **값 읽기 부류**: 결정적. codegen 이 보일러플레이트 벽을 없애 `composed` 를 벗어나게 해줌. +- **interaction 부류**: `@OnChange` 로 격차 축소. 남는 장황함(`Animatable`/카운팅)은 진짜 노드 코드. +- **composition-free 체인**: `graphicsLayer{}.drawWithCache{}` 처럼 이미 `Modifier.Node` 기반이고 + `equals` 도 되는 체인을 raw 노드로 내리면 레이어 lifecycle 수동 관리 + 캐싱 상실 = 순 손해. + `fadingEdge` dogfood 가 이 케이스 (`FadingEdge.kt`). -### 10.3 남는 제약 — codegen 이 Node 에 그 한 줄을 못 넣는다 +--- -`@ModifierNodeFactory` 는 사용자가 쓴 concrete Node 클래스를 수정할 수 없다(KSP 한계). -**해결: `@Invalidates` 를 하나라도 쓰면 KSP 가 다음 선언을 요구한다(없으면 컴파일 에러).** -```kotlin -override val shouldAutoInvalidate: Boolean get() = false -``` -Compose 엔지니어가 `SimpleGraphicsLayerModifier` 에 손으로 쓰는 바로 그 줄이다. -codegen 은 정밀한 `update()` (계층 접기 포함)를 생성한다. `ModifierNodeProcessor` 구현됨. +## 8. 구현 상태 -### 10.4 실제 가치 (수정판) +**갈래 B 확정** (§9). 아래 완료: -1. **파라미터 단위 invalidation** — `@Invalidates` + 강제된 `shouldAutoInvalidate=false` + - 생성된 `update()`. `AutoInvalidateProbeTest` 로 실효 확인. Compose 내부 hot-path 패턴과 동일. -2. `equals`/`hashCode`/`create`/`update`/`inspectableProperties` 보일러플레이트 제거 - — modifier 당 ~30~55줄. `GeneratedElementTest` 로 계약 고정. -3. **equals 스킵** — 파라미터 불변 시 `update()` 자체가 안 불림. codegen 이 정확한 `equals` 보장. -4. `@Invalidates` scope ↔ 노드 인터페이스 컴파일 타임 검증. -5. (미구현) ABI 표면 축소. +- [x] KSP 프로세서: Element + 확장 함수 생성, invalidation 계층 접기, 검증 +- [x] `@ModifierNodeFactory(visibility)` + Element `internal` 강제 (`generated_element_is_internal`) +- [x] `@Invalidates` + `shouldAutoInvalidate` 강제 (§4, `AutoInvalidateProbeTest`) +- [x] `@SkipWhenFalse` / `@SkipWhenTrue` (`skip_markers_gate_application`) +- [x] `@OnChange` (`onChange_fires_only_when_param_changes`) +- [x] 예제 + dogfood(`fadingEdge`) + `composed` 비교 2케이스 +- [x] `modifier-node-annotations/README.md`, `:modifier-node-sample` 정리 -### 10.5 성능 효과의 현실적 크기 +**보류/미완:** -파라미터 단위 invalidation 은 **micro-optimization** 이다. Compose 조차 전체 modifier 중 -극소수(graphicsLayer, paint, focus)에만 적용했다. equals 스킵(파라미터 불변 시 update 스킵)이 -`Modifier.Node` 성능의 대부분이고, 그건 `@Invalidates` 없이도 codegen 이 준다. -`@Invalidates` 의 이득은 "파라미터가 자주 바뀌는데 그 변경이 draw 만 요구하는" 좁은 경우에 한정. +- `@Default` — 검토만(부록 A), 구현 보류 +- `master` 병합 전: 커밋 squash 여부 +- (범위 밖) 노드 인터페이스 확장, default 대신 오버로드 체인, IDE inspection, 프로세서 단위 테스트 --- -## 11. 결론 +## 9. 판단 근거 (갈래 B) -§10 정정 후: **원래 명제(파라미터 단위 invalidation + ABI)는 기술적으로 성립한다.** -단 두 가지 냉정한 조건: +명제: **"`Modifier.Node` 보일러플레이트 제거 + equals 안정성 보장 + (opt-in) 파라미터 단위 invalidation"**. +성능은 부차 셀링포인트로만, 과장 없이 (§5). -- 파라미터 단위 invalidation 의 실제 성능 이득은 좁다 (§10.5). 주된 가치는 여전히 - "보일러플레이트 제거 + equals 안정성 보장" 이다. -- 니치는 그대로 좁다 (§5). `Modifier.Node` 직접 작성자 + 그중 codegen 수용자. +착수 전 세운 판단 기준과 결과: -### 결정 게이트 재평가 (수정판) - -| 조건 | 결과 | +| 기준 | 결과 | |---|---| -| 1. 재작성이 원본보다 낫거나 동등 | **대체로 충족** — Case A(값 읽기) 명백한 이득, Case B(interaction)도 `@OnChange` 이후 근접, composition-free 는 여전히 손해 | -| 2. invalidation 계약이 손보다 명확한 이점 | **조건부 충족** — 작동하고(§10.2), KSP 가 `shouldAutoInvalidate` 강제(§10.3), 계약을 테스트로 고정. 단 이득 범위가 좁다(§10.5) | -| 3. KSP 구현 비용이 시간 단위 | **충족** — 전체 반나절 | - -### 갈래 - -**→ B 확정 (2026-09-08).** - -**B. 정직한 재정의.** -명제: *"`Modifier.Node` 보일러플레이트 제거 + equals 안정성 보장 + (opt-in) 파라미터 단위 -invalidation"*. 성능은 부차적 셀링포인트로만, 과장 없이. 이미 만든 인프라가 거의 그대로 완성형. - -남은 작업 (순서): -1. **ABI: Element `internal` 강제 + 공개 함수 visibility 제어** ✅ 완료 - - `Element` 는 노드 가시성과 무관하게 항상 `internal` (`generated_element_is_internal` 테스트) - - `@ModifierNodeFactory(visibility = GeneratedVisibility.Public | Internal)`, 기본 `Public` - - Node 가 `private` 면 에러(생성 파일에서 참조 불가), `public` 이면 "ABI 노출" 경고 - - `public fun Modifier.foo()` 본문이 `internal` Element 를 참조 — Kotlin 허용(시그니처만 노출 검사) -2. **조건부 적용 — 마커 어노테이션 `@SkipWhenFalse` / `@SkipWhenTrue`** ✅ 완료 - - `Boolean` 파라미터에 부착 → 생성 함수 앞에 `if (!param) return this` (여럿이면 OR) - - skip 시 노드 미attach. "사라지는 애니메이션" 은 불가 → 그 경우 마커 없이 노드가 no-op - - `skipWhenFalse_gates_application` 테스트 (`Modifier.skipProbe(on=false) === Modifier`) - - `fadingEdge` dogfood 를 `@SkipWhenFalse enabled` 로 갱신 -3. **`@OnChange` 마커 — 파라미터 변경 시 `onChanged()` 호출** ✅ 완료 - - 생성된 `update()` 가 필드 갱신 후 노드의 `onChanged()` 호출 (변경 시에만) - - KSP 가 `fun onChanged()` (무인자) 선언을 요구 - - `PressScaleNode` 를 이걸로 갱신 → `boundSource` dedup 필드 + `draw()` 의 `rebind()` 제거 - - `onChange_fires_only_when_param_changes` 테스트 -4. **README** — `modifier-node-annotations/README.md` ✅ 완료 - - 포지셔닝(§5) · 어노테이션 레퍼런스 · `shouldAutoInvalidate` 요구 · 성능 현실(§10.5) · 한계 -5. default argument 지원 — `@Default(String)` 마커 검토 완료(§12), **구현 보류** - -### 남은 것 -- (보류) `@Default` 구현 — 사용자 요청 시 -- `:modifier-node-sample` 정리 ✅ (한 파일 1 modifier, `@SkipWhenTrue` 커버, `README.md`, 미배포 명시) -- `master` 병합 전: 커밋 squash 여부 - -**A. 중단.** (보류) 니치가 좁고 파라미터 단위 이득이 미미하다고 보면 유효했던 선택. - -**C. 목표 전환.** (보류) "Modifier.Node 를 쉽게" 로 확장 (노드 유틸/delegation/테스트). 작업량 큼. +| 재작성이 원본보다 낫거나 동등 | 대체로 충족 — 값 읽기 명백한 이득, interaction 은 `@OnChange` 이후 근접, composition-free 는 손해(§7) | +| invalidation 계약이 손보다 명확한 이점 | 조건부 충족 — 작동하고(§4) KSP 가 강제, 테스트로 고정. 단 성능 이득 범위가 좁다(§5) | +| KSP 구현 비용이 시간 단위 | 충족 — 전체 반나절 | -### 남길 것 (어느 갈래든) -- `ARCHITECTURE.md` §10 — autoInvalidate 메커니즘 + `shouldAutoInvalidate` opt-out 사실관계 - (그리고 처음에 틀렸던 이유). 재확인 비용을 아낀다. -- `COMPARISON.md` — `composed` vs Node 부류별 손익표. -- `AutoInvalidateProbeTest` — opt-out 동작 회귀 방지. +대안: **A 중단**(니치·이득이 미미하다고 보면 유효했음), **C 목표 전환**("Modifier.Node 를 쉽게" — 노드 +유틸/delegation/테스트, 작업량 큼). 둘 다 미채택. --- -## 12. `@Default(String)` 검토 (구현 보류) +## 부록 A — `@Default(String)` 검토 (구현 보류) ```kotlin @Default("1000") var durationMillis: Int → fun Modifier.x(durationMillis: Int = 1000, ...) ``` -- **구현 ~10줄**: 어노테이션 문자열을 `ParameterSpec.defaultValue("%L", expr)` 로 verbatim 방출. 생성 함수에만, Element 생성자는 그대로. -- **되는 것**: 리터럴(`1000`/`0f`/`true`/`null`/`""`), 파라미터 타입의 멤버(`Color.Unspecified`, `StepSize.ONE`). -- **안 되는 것**: 생성 파일에 import 안 된 심볼. `4.dp` 도 실패(자주 씀). → FQN 강제거나 유닛 allowlist(`.dp/.sp/.em` 자동 import) 필요. -- **리스크**: 오타 → 생성 파일에서 컴파일 에러(어노테이션 위치 아님). 중간 위치 `@Default` → 뒤 파라미터 named 강제(경고만, 리오더 안 함). -- **판정**: "default 없음"보다 낫고 오버로드 체인보다 단순. 권장 형태 = `@Default(String)` + non-blank 검증 + `.dp/.sp/.em` allowlist + 중간위치 경고 (~20줄). 우선순위는 README 뒤. +- **구현 ~10줄**: 어노테이션 문자열을 `ParameterSpec.defaultValue("%L", expr)` 로 verbatim 방출. + 생성 함수에만, Element 생성자는 그대로. +- **되는 것**: 리터럴(`1000`/`0f`/`true`/`null`/`""`), 파라미터 타입의 멤버(`Color.Unspecified` 등). +- **안 되는 것**: 생성 파일에 import 안 된 심볼. `4.dp` 도 실패(자주 씀). + → FQN 강제거나 유닛 allowlist(`.dp/.sp/.em` 자동 import) 필요. +- **리스크**: 오타 → 생성 파일에서 컴파일 에러(어노테이션 위치 아님). + 중간 위치 `@Default` → 뒤 파라미터 named 강제(경고만, 리오더 안 함). +- **판정**: "default 없음"보다 낫고 오버로드 체인보다 단순. + 권장 형태 = `@Default(String)` + non-blank 검증 + `.dp/.sp/.em` allowlist + 중간위치 경고 (~20줄). + +## 부록 B — 조사 이력 + +`@Invalidates` 의 파라미터 단위 invalidation 을 두고 한 차례 **"현행 Compose 에서 불가능"** 이라 +잘못 결론냈다가 정정했다. 원인: `shouldAutoInvalidate` 를 `@Deprecated` 로 오독(§4 참고). +소스를 직접 확인해 opt-out 이 정상 API 이고 Compose 자체가 쓰는 패턴임을 확인 → §4 로 정리. +교훈: deprecation 여부는 바이트코드 attribute 가 아니라 소스/`@Deprecated` 어노테이션으로 확인. diff --git a/modifier-node-sample/COMPARISON.md b/modifier-node-sample/COMPARISON.md index bb94299..7ebcb0f 100644 --- a/modifier-node-sample/COMPARISON.md +++ b/modifier-node-sample/COMPARISON.md @@ -130,24 +130,8 @@ internal class PressScaleNode( | **interaction / coroutine / animation** | `@OnChange` 로 재구독 격차 축소(10→35). 남은 장황함(`Animatable`/카운팅)은 진짜 노드 코드 | | 모든 부류 공통 | 안정 equals·inspector·`@Invalidates` 정합성 체크·ABI 표면 축소·`@SkipWhen*` 가드 | -**결정 게이트 조건 1** ("재작성이 원본보다 낫거나 동등"): `fadingEdge`(§ARCHITECTURE 9)로는 불충족이었으나 -그건 애초에 `composed` 케이스가 아니었음. **Case A 로 재평가 시 충족.** Case B 도 `@OnChange` 이후엔 -근접(값 읽기 케이스만큼 결정적이진 않음). - ---- - -## ⚠️ 그 뒤 발견 — invalidation 스코프는 **작동한다** (앞선 정정) - -`ARCHITECTURE.md` §10 참고. 한때 "작동 안 함" 이라 적었으나 **오독이었다**: - -- `NodeKind.autoInvalidateNodeSelf` 에 opt-out 이 있다: - `if (phase == Updated && !node.shouldAutoInvalidate) return` -- `Modifier.Node.shouldAutoInvalidate` 는 deprecated 아님. Compose 의 `graphicsLayer`/`paint` - 모디파이어가 이 패턴을 쓴다. -- 통제 실험(`AutoInvalidateProbeTest`): `shouldAutoInvalidate = false` 노드는 draw 전용 - 파라미터 변경 시 remeasure 안 함. - -제약: codegen 이 Node 클래스에 그 한 줄을 못 넣으므로, `@Invalidates` 사용 시 KSP 가 -`override val shouldAutoInvalidate get() = false` 를 요구한다(없으면 컴파일 에러). - -단 성능 이득 범위는 좁다 (§10.5). 주된 가치는 여전히 보일러플레이트 제거 + equals 안정성. +- **값 읽기 부류**: 결정적. `fadingEdge`(composition-free 체인)는 `composed` 케이스가 아니었고 + raw 노드로 내려서 손해 — `../ARCHITECTURE.md` §7. +- **interaction 부류**: `@OnChange` 이후 근접(값 읽기만큼 결정적이진 않음). +- 파라미터 단위 invalidation 이 실제로 작동하는지(그리고 성능 이득이 왜 좁은지)는 + `../ARCHITECTURE.md` §4·§5. diff --git a/modifier-node-sample/README.md b/modifier-node-sample/README.md index ae1f7a9..caf504d 100644 --- a/modifier-node-sample/README.md +++ b/modifier-node-sample/README.md @@ -9,7 +9,7 @@ This is the examples + integration test module for `modifier-node-codegen`. **No |---|---| | `DebugTint.kt` | 가장 단순한 예 — draw 전용 노드 | | `FixedSquare.kt` | draw + layout 노드 — `update()` 계층 접기 | -| `FadingEdge.kt` | dogfood. `graphicsLayer` 오프스크린 + `DstIn` 마스크. composition-free 체인을 raw 노드로 내렸을 때의 손익을 보여주는 케이스 (`../ARCHITECTURE.md` §9) | +| `FadingEdge.kt` | dogfood. `graphicsLayer` 오프스크린 + `DstIn` 마스크. composition-free 체인을 raw 노드로 내렸을 때의 손익을 보여주는 케이스 (`../ARCHITECTURE.md` §7) | | `compare/AccentOverlay.kt` | `composed` vs Node — CompositionLocal 읽기 (Case A) | | `compare/PressScale.kt` | `composed` vs Node — InteractionSource + 애니메이션 (Case B) | | `COMPARISON.md` | 위 두 케이스의 before/after 분석 | From db5b2256e057850f955c0450cddf183c2b01aff6 Mon Sep 17 00:00:00 2001 From: sonms Date: Tue, 8 Sep 2026 17:29:15 +0900 Subject: [PATCH 15/21] =?UTF-8?q?[Docs]=20Drop=20"=EC=8B=A4=ED=97=98"=20fr?= =?UTF-8?q?aming=20=E2=80=94=20it's=20a=20feature=20branch=20on=20the=20B?= =?UTF-8?q?=20track,=20not=20a=20throwaway?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_013UGir6UrorM7Kx7iyYv1ir --- ARCHITECTURE.md | 2 +- modifier-node-annotations/README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 8ee02a4..11e77d0 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1,6 +1,6 @@ # ARCHITECTURE — modifier-node-codegen -> 브랜치: `feat/modifier-node-codegen` (실험). `master` 미병합. +> 브랜치: `feat/modifier-node-codegen` — `master` 미병합. 갈래 B 진행 중 (§8·§9). > 스택: Kotlin 2.0.21 · AGP 8.13.2 · Compose BOM 2026.04.01 (ui 1.12.0-beta02) · KSP 2.0.21-1.0.28 · KotlinPoet 1.18.1 > 모듈: `:modifier-node-annotations` · `:modifier-node-processor` · `:modifier-node-sample`(예제+테스트, 미배포) diff --git a/modifier-node-annotations/README.md b/modifier-node-annotations/README.md index 5ca1429..e0bc1a8 100644 --- a/modifier-node-annotations/README.md +++ b/modifier-node-annotations/README.md @@ -4,7 +4,7 @@ `Modifier` 확장 함수를 KSP 로 생성한다. `composed { }` 를 벗어나 `Modifier.Node` 를 쓸 때 반복되는 보일러플레이트(Element / `equals` / `hashCode` / `update` / `inspectableProperties`)를 없앤다. -> **포지셔닝.** Velvet-Compose 내부 인프라 + `Modifier.Node` 학습용 실험 프로젝트다. +> **포지셔닝.** Velvet-Compose 내부 인프라 + `Modifier.Node` 학습 목적의 도구다. > `Modifier.Node` 를 직접 작성하는 사람(라이브러리 저자 등)만 대상이고, 니치가 좁다. > 1인 유지보수 · SLA 없음 · API 호환성 보장 없음. 설계 배경과 판단 근거는 > [`ARCHITECTURE.md`](../ARCHITECTURE.md), `composed` 와의 비교는 From febe4ccb41f86695a4fcbc46a1bf2f415e0fe636 Mon Sep 17 00:00:00 2001 From: sonms Date: Tue, 8 Sep 2026 17:49:02 +0900 Subject: [PATCH 16/21] [Update] Include modifier-node modules in Gradle project config and fix doc formatting - Add `modifier-node-annotations`, `modifier-node-processor`, and `modifier-node-sample` to `.idea/gradle.xml` - Fix code block language tag in `ARCHITECTURE.md` --- .idea/gradle.xml | 3 +++ ARCHITECTURE.md | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/.idea/gradle.xml b/.idea/gradle.xml index c92a982..23d6a2c 100644 --- a/.idea/gradle.xml +++ b/.idea/gradle.xml @@ -11,6 +11,9 @@ diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 11e77d0..11a7c0d 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -212,7 +212,7 @@ Velvet-Compose 내부 인프라 + `Modifier.Node` 학습 목적. 1인 유지보 ## 부록 A — `@Default(String)` 검토 (구현 보류) -```kotlin +``` @Default("1000") var durationMillis: Int → fun Modifier.x(durationMillis: Int = 1000, ...) ``` From 17cee5d335f20efae1eff0e64048477ef1053c0b Mon Sep 17 00:00:00 2001 From: sonms Date: Tue, 8 Sep 2026 17:56:02 +0900 Subject: [PATCH 17/21] [Update] Lower Java language and bytecode target levels to 11 in IntelliJ config - Lower project language level and bytecode target level from Java 21 to Java 11 in `.idea/misc.xml` and `.idea/compiler.xml` - Set Gradle JVM explicitly to Java 17 in `.idea/gradle.xml` --- .idea/compiler.xml | 2 +- .idea/gradle.xml | 2 +- .idea/misc.xml | 3 ++- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/.idea/compiler.xml b/.idea/compiler.xml index b86273d..fb7f4a8 100644 --- a/.idea/compiler.xml +++ b/.idea/compiler.xml @@ -1,6 +1,6 @@ - + \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml index 23d6a2c..437f0fd 100644 --- a/.idea/gradle.xml +++ b/.idea/gradle.xml @@ -6,7 +6,7 @@