Skip to main content

flutter-architecture-lessons

Architecture patterns and lessons learned from Flutter's widget system, adapted for Crystal's native-rendering approach. Covers the three-tree model, constraint-based layout, platform adaptation, and key differences from asset_pipeline's PlatformVisitor approach. Guides architectural decisions for expanding the cross-platform component system.

설치로 이동

소스 정보

저장소
amberframework/asset_pipeline
최근 소스 활동
2026년 4월 15일 13:35
감지된 SKILL.md 언어
영어
스타
5
포크
0

설치 방법

기본적으로 소스를 먼저 확인하는 Prompt가 선택됩니다. 직접 명령으로 전환하거나 로컬 사본을 다운로드할 수도 있습니다.

소스 파일 검토

설치 여부를 결정하기 전에 SKILL.md와 SkillsMP에 표시된 보조 파일을 읽어 보세요.

SKILL.md 표시 중

SKILL.md
소스 지침 · 읽기 전용 미리보기
name
flutter-architecture-lessons
description
Architecture patterns and lessons learned from Flutter's widget system, adapted for Crystal's native-rendering approach. Covers the three-tree model, constraint-based layout, platform adaptation, and key differences from asset_pipeline's PlatformVisitor approach. Guides architectural decisions for expanding the cross-platform component system.
user-invocable
true
allowed-tools
Read, Write, Edit, Glob, Grep, Bash, WebSearch, WebFetch
version
1.0.0
# Flutter Architecture Lessons This document captures what the asset_pipeline team has learned from studying Flutter's architecture, what we adopt, what we explicitly reject, and where Flutter's pain points validate our design choices. ## 1. Flutter's Architecture Overview Flutter builds UI with three parallel trees that coexist at runtime: ### The Three Trees **Widget tree** — Immutable, lightweight descriptions of UI. Widgets are plain Dart objects with no identity; they are rebuilt frequently (potentially on every frame). The widget tree is the developer-facing declaration of what the UI should look like. `StatelessWidget.build()` and `StatefulWidget.build()` return widget trees. **Element tree** — Mutable, long-lived objects that Flutter creates once and updates in place. Each widget has a corresponding element. The element tracks the widget's position in the tree, holds references to child elements, and manages the widget lifecycle (mount, rebuild, unmount). The element tree is Flutter's internal book-keeping layer — developers rarely interact with it directly. **RenderObject tree** — Does the actual layout math and painting. Each element that represents a visible component has a corresponding `RenderObject`. RenderObjects implement `performLayout()` (size and position children using BoxConstraints) and `paint()` (draw pixels using Canvas). This tree is what ultimately drives Skia/Impeller to put pixels on screen. The reason Flutter needs three trees: widgets are immutable (easy to create/discard), elements provide stable identity across rebuilds (so Flutter can diff old vs. new widget trees efficiently), and RenderObjects are expensive to construct and carry layout state that must persist across frames. ### Why Three Trees Are Necessary for Flutter Flutter does its own layout and painting. Because it cannot delegate layout to a native engine, it must do layout math itself — hence RenderObjects. Because widgets are immutable and rebuilt constantly, Flutter needs a stable layer (elements) to track what changed. If Flutter used native widgets it could drop to one tree. It doesn't. We do. ### Crystal's Two-Layer Model ``` App code builds UI::View tree (immutable-style declarations) | v UI::PlatformVisitor walks the tree (compile-time selected) | +-- Web::Renderer -> HTML/CSS (no layout math needed; CSS handles it) +-- AppKit::Renderer -> NSView (no layout math; NSStackView handles it) +-- UIKit::Renderer -> UIView (no layout math; UIStackView handles it) +-- Android::Renderer -> Views (no layout math; LinearLayout handles it) ``` We have no Element tree because Crystal's `UI::View` objects are not rebuilt every frame — they are constructed once per build pass and handed to the visitor. We have no RenderObject tree because we delegate all layout math to native engines. Our design is simpler by design, not by accident. --- ## 2. What We Adopt from Flutter ### 2a. Declarative View Tree Flutter's widget tree is a declarative description of UI — it says *what* the UI is, not *how* to draw it. Our `UI::View` tree serves exactly the same purpose. | Flutter | Crystal | |---------|---------| | `Widget.build()` returns a widget tree | `build_view()` returns a `UI::View` tree | | Tree is rebuilt when state changes | Tree is rebuilt when state changes | | Framework diffs old vs. new tree | Visitor re-renders from the tree directly | | Tree is pure description | Tree is pure description | The key mental model is identical: write a function that describes UI as a tree of values, and let the system turn that into visible pixels. Neither Flutter nor our system expects the developer to imperatively mutate a live display list. ### 2b. Composition Over Inheritance Flutter composes small single-purpose widgets into larger ones. `Card` in Flutter is implemented as `Material` wrapping `Padding` wrapping a `Column`. No deep class hierarchy. We should follow this. **Do not** create a `UI::Card` class with 20 properties. **Do** compose existing primitives: ```crystal def card(title : String, body : String, &on_action : -> Nil) : UI::View stack = UI::VStack.new(spacing: 12.0) stack.padding = UI::EdgeInsets.new(top: 16.0, trailing: 16.0, bottom: 16.0, leading: 16.0) stack.background = UI::Color.new(r: 1.0, g: 1.0, b: 1.0) title_label = UI::Label.new(title) title_label.font = UI::Font.new(size: 20.0, weight: :bold) stack << title_label stack << UI::Label.new(body) stack << UI::Button.new("Action", &on_action) stack end ``` This is a factory function, not a class. It composes three existing view types. The visitor already knows how to render all three. No new `visit` method needed, no new platform implementations needed. This is the right default. ### 2c. Constraint-Based Layout Thinking Flutter's constraint system is elegant and worth internalizing even though we never implement it ourselves: > Constraints go down. Sizes go up. Parent sets position. The layout protocol works as follows: a parent passes a `BoxConstraints` (min/max width and height) down to each child. The child decides its size within those bounds and reports it back up. The parent then positions each child. This is a one-pass layout, which is what makes it fast. We do not implement this protocol — native layout engines do it for us. But the mental model maps cleanly: - `UI::VStack(spacing: 16.0, alignment: .leading)` expresses constraints: children are arranged vertically with 16pt gaps, aligned to the leading edge. - The native engine (NSStackView, UIStackView, LinearLayout, CSS flexbox) applies this constraint to measure and position children. - Our `spacing` and `alignment` properties *are* constraints expressed as values; we just let the platform solve them. Understanding Flutter's constraint model helps reason about why our `VStack`/`HStack`/`ZStack` properties are what they are, and it guides the design of new container types. ### 2d. Platform-Adaptive Components Flutter's `.adaptive()` constructors (e.g., `Switch.adaptive`, `Slider.adaptive`, `CircularProgressIndicator.adaptive`) select the platform-appropriate widget at runtime by reading `Theme.of(context).platform`. Our `PlatformVisitor` does this at compile time via `flag?()`. Both approaches solve the same problem — matching UI affordances to the platform the user is running on — but our approach eliminates runtime branching entirely: | Aspect | Flutter `.adaptive()` | Crystal `PlatformVisitor` | |--------|----------------------|--------------------------| | Selection time | Runtime | Compile time | | Mechanism | `Theme.of(context).platform` | `flag?()` macro | | Runtime cost | Branch per render | Zero | | Flexibility | Can change at runtime | Fixed per binary | | Use case | Single binary, multiple platforms | Separate binary per platform | Our approach is more efficient and produces smaller binaries. The tradeoff is that a single binary cannot serve multiple platform targets — which is fine because we cross-compile anyway. When adding new view types, if the behavior differs across platforms, express that difference in the per-platform `visit` implementation, not in a runtime branch in the view class. The visitor pattern is our `.adaptive()`. ### 2e. State Management Patterns Flutter's state management evolution (setState → InheritedWidget → Provider → Riverpod) reflects the difficulty of propagating state changes through a widget tree that is rebuilt constantly. Flutter developers spend significant time choosing and learning state management libraries. Our state model is simpler because we do not rebuild the entire tree on every state change: | Flutter | Crystal | |---------|---------| | `StatefulWidget` + `setState()` | `UI::State(T)` with `on_change` listener | | `InheritedWidget` for propagation | Captured variables in builder block | | Provider/Riverpod for complex apps | `UI::ViewAdapter` + `invalidate!` | | Full subtree rebuild on change | Builder block called, renderer re-renders | ```crystal count = UI::State(Int32).new(0) adapter = UI::ViewAdapter.new do stack = UI::VStack.new(spacing: 16.0) stack << UI::Label.new("Count: #{count.value}") stack << UI::Button.new("Increment") { count.value += 1; nil } stack.as(UI::View) end count.on_change { |_, _| adapter.invalidate! } ``` The builder block captures `count` by reference. When `invalidate!` is called, the block runs again, producing a new view tree with the updated count. No state management library needed. The simplicity is an advantage, not a limitation. --- ## 3. What We Explicitly Do NOT Adopt ### 3a. Custom Drawing Engine Flutter paints every pixel itself using Skia (older releases) or Impeller (current). This means Flutter reimplemented text rendering, scroll physics, animations, input handling, selection handles, cursor blinking, context menus, drag-and-drop, and hundreds of other behaviors that native platforms provide for free. **Flutter's stated advantage:** Pixel-perfect consistency across platforms — a Flutter button looks identical on iOS, Android, and the web. **Flutter's actual disadvantage:** Inconsistency with the platform the user is running on. Users on iOS expect iOS scrolling physics, iOS text selection handles, iOS keyboard behavior. Flutter apps can feel slightly "off" even when beautifully designed. This is why `.adaptive()` constructors exist — they are an acknowledgement that consistency-with-platform matters. **Our advantage:** Native look-and-feel, native accessibility, native scrolling physics, native text selection, native keyboard behavior, native context menus — all for free. `UIScrollView` has momentum scrolling, rubber-band overscroll, and snap behavior that took Apple years to perfect. We get all of it automatically. **Our disadvantage:** Pixel-level visual differences across platforms (a `UI::Button` looks like an `NSButton` on macOS and a `MaterialButton` on Android). This is not a bug; it is what users expect. **Decision:** We will never add a custom drawing layer to replace native rendering. If a view type needs custom appearance, style it with platform APIs (background colors, corner radii, shadows via view properties), not custom painting. ### 3b. Three-Tree Architecture We do not need an Element tree or a RenderObject tree. See Section 1 for the full explanation. Our `UI::View` tree feeds directly into `PlatformVisitor`. No intermediate layers. If performance profiling ever shows that constructing view trees is a bottleneck, the correct fix is to cache or diff view trees at the Crystal level — not to add a Flutter-style element tree. But this is extremely unlikely given that native layout engines are doing the expensive work. ### 3c. Custom Layout Protocol Flutter's `RenderBox.performLayout()` with `BoxConstraints` is necessary because Flutter does layout itself. We do not. NSStackView, UIStackView, LinearLayout, and CSS flexbox are each more capable than any layout engine we would write, and they have been debugged against thousands of edge cases over decades. If a complex layout cannot be expressed with `VStack`/`HStack`/`ZStack` properties, the correct fix is one of: 1. Add a new view type that maps to the appropriate native container (e.g., a grid view). 2. Use platform-specific layout via a platform-native code path (acceptable for complex one-off layouts). 3. Compose multiple nested stacks creatively. Do not add a constraint solver or layout arithmetic to Crystal. Let the platform do it. ### 3d. GlobalKey System Flutter's `GlobalKey` allows cross-tree widget identity — finding a specific widget from anywhere in the tree. It is notoriously complex and has sharp footguns (performance problems, lifecycle bugs). Our equivalent: `view.id = "my-view"`. Native frameworks have their own view identity and lookup systems (`viewWithTag:` on iOS, `view(withIdentifier:)` on macOS, `findViewById` on Android). We expose `id` on `UI::View` for testing and debugging. We do not need a global registry. --- ## 4. Lessons from Flutter's Pain Points ### 4a. Text Input Is Hard to Reimplement Flutter reimplemented text input from scratch. As of 2024–2025, text input in Flutter still has platform-specific edge cases: autocorrect behavior, IME (input method editor) handling for CJK languages, copy/paste integration with the system clipboard, drag-to-select, and smart punctuation all require ongoing maintenance across platforms. **Lesson:** Never custom-implement text input. Always use `UITextField` (iOS), `NSTextField` (macOS), `EditText` (Android), `<input>` (web). These have been battle-tested with every keyboard layout, locale, accessibility tool, and OS version. `UI::TextField` must always map to the native text input widget on every platform. ### 4b. Accessibility Requires Platform Integration Flutter's accessibility layer sits atop its custom rendering tree. It must synthesize an accessibility tree from scratch and keep it synchronized with the visual tree. This is difficult and error-prone. Flutter apps occasionally have screen reader regressions when widgets are rebuilt. **Lesson:** Native components carry accessibility for free. `UILabel` already supports VoiceOver. `NSTextField` already supports VoiceOver and the macOS accessibility APIs. `TextView` already supports TalkBack. Our job is to surface the right semantic information to each native component: - Set `accessibility_label` on `UI::View`. - The platform renderer maps it to `accessibilityLabel` (iOS/macOS), `contentDescription` (Android), or `aria-label` (web). - The platform's accessibility subsystem does the rest. We should never synthesize accessibility trees ourselves. The `accessibility_label` property exists on `UI::View` for exactly this reason. ### 4c. Platform-Specific Behaviors Matter to Users Flutter's approach of simulating platform behaviors (scroll physics, pull-to-refresh, page transitions) in pure Dart is never quite right. iOS users notice when scroll bounce feels slightly off. Android users notice when transitions don't match Material motion specs. **Lesson:** Do not try to unify scrolling behavior, animation curves, or interaction physics. Let each platform be native. `UI::ScrollView` maps to `UIScrollView` on iOS (with momentum scrolling and rubber-band overscroll), `NSScrollView` on macOS (with elastic scrolling and scroll wheel inertia), `ScrollView` on Android (with Material overscroll glow), and `overflow: auto` on web (with browser-native scroll). Each platform gets exactly its own behavior. This is correct. ### 4d. Web Target Is Weak When You Custom-Paint Flutter's web output compiles to either HTML+CSS (which uses DOM elements) or CanvasKit (which renders with Skia to a `<canvas>` element). The CanvasKit path has poor SEO (no semantic HTML), poor accessibility (screen readers cannot read canvas content), and large initial load size (the Skia WASM binary). The HTML path is more web-native but has layout limitations. **Lesson:** Our web renderer produces real, semantic HTML+CSS. A `UI::Label` becomes a `<span>` with appropriate styles. A `UI::VStack` becomes a `<div>` with `display: flex`. Search engines can index this. Screen readers can read it. CSS can style it. The browser's rendering engine does layout. This is the right approach for web. Never add a Canvas-based rendering path for the web renderer. If custom drawing is ever needed on the web, it should go through a separate `UI::Canvas` view type (see the graphics-rendering skill), not replace the HTML renderer. ### 4e. Deep Widget Nesting ("Pyramid of Doom") Flutter code frequently nests 10–15 levels of widgets to compose complex layouts, even for simple screens. This is because everything in Flutter is a widget — including invisible concerns like padding, alignment, and decoration. The result is code that is hard to read and hard to edit. ```dart // Flutter — common pattern (illustrative, not a quote) return Scaffold( body: Center( child: Padding( padding: const EdgeInsets.all(16.0), child: Column( children: [ Container( decoration: BoxDecoration(...), child: Padding( child: Text("Hello"), ), ), ], ), ), ), ); ``` **Lesson:** Crystal's `<<` operator for adding children and the property-based API for padding/background reduces nesting significantly. A `VStack` with padding is one object with a `padding` property, not two nested objects. Preserve this pattern when adding new view types. For future DSL work, consider builder patterns that further reduce nesting: ```crystal # Builder pattern concept (future API) UI::VStack.build(spacing: 16.0) do |stack| stack.label("Title") { |l| l.font = UI::Font.new(size: 20.0, weight: :bold) } stack.label("Body text goes here") stack.button("Action") { do_action } end ``` --- ## 5. Architecture Comparison Table | Aspect | Flutter | asset_pipeline (Crystal) | |--------|---------|--------------------------| | Rendering engine | Custom (Skia / Impeller) | Native (UIKit / AppKit / Views / HTML) | | Layout engine | Custom (RenderBox / BoxConstraints) | Delegated to platform | | Tree model | 3 trees: Widget, Element, RenderObject | 1 tree: UI::View + PlatformVisitor | | Platform dispatch | Runtime (`.adaptive()`, `Theme.platform`) | Compile time (`flag?()` macro) | | Accessibility | Custom synthesized tree | Inherited from native components | | Text input | Custom reimplementation | Native components only | | Scrolling physics | Simulated per-platform in Dart | Native (UIScrollView, NSScrollView, etc.) | | Web rendering | CanvasKit (Skia/WASM) or HTML | Semantic HTML + CSS | | State management | setState / Provider / Riverpod | `UI::State(T)` + `ViewAdapter` | | Code language | Dart | Crystal | | Hot reload | Yes (JIT-compiled Dart) | No (but fast release compilation) | | App size | Larger (bundles Skia + Dart VM) | Smaller (links native frameworks) | | Platform look-and-feel | Simulated (not native) | Genuine native | --- ## 6. Recommended Architecture Patterns Based on Flutter lessons, these patterns are recommended for the asset_pipeline: ### Pattern 1: Component Factory Functions Composition via factory functions is the preferred way to build reusable UI. Do not subclass `UI::View` for composite components; instead write a function that returns a composed view tree. ```crystal def card(title : String, body : String, &on_action : -> Nil) : UI::View stack = UI::VStack.new(spacing: 12.0) stack.padding = UI::EdgeInsets.new(top: 16.0, trailing: 16.0, bottom: 16.0, leading: 16.0) stack.background = UI::Color.new(r: 1.0, g: 1.0, b: 1.0) title_label = UI::Label.new(title) title_label.font = UI::Font.new(size: 20.0, weight: :bold) stack << title_label stack << UI::Label.new(body)
GitHub에서 보기
이 SKILL.md는 매우 커서 SkillsMP가 여기에는 첫 섹션만 미리 보여줍니다. GitHub에서 보기