@zigrivers/scaffold 3.7.0 → 3.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +113 -8
- package/content/knowledge/browser-extension/browser-extension-architecture.md +195 -0
- package/content/knowledge/browser-extension/browser-extension-content-scripts.md +264 -0
- package/content/knowledge/browser-extension/browser-extension-conventions.md +156 -0
- package/content/knowledge/browser-extension/browser-extension-cross-browser.md +229 -0
- package/content/knowledge/browser-extension/browser-extension-dev-environment.md +247 -0
- package/content/knowledge/browser-extension/browser-extension-manifest.md +220 -0
- package/content/knowledge/browser-extension/browser-extension-project-structure.md +183 -0
- package/content/knowledge/browser-extension/browser-extension-requirements.md +107 -0
- package/content/knowledge/browser-extension/browser-extension-security.md +202 -0
- package/content/knowledge/browser-extension/browser-extension-service-workers.md +265 -0
- package/content/knowledge/browser-extension/browser-extension-store-submission.md +155 -0
- package/content/knowledge/browser-extension/browser-extension-testing.md +270 -0
- package/content/knowledge/data-pipeline/data-pipeline-architecture.md +175 -0
- package/content/knowledge/data-pipeline/data-pipeline-batch-patterns.md +263 -0
- package/content/knowledge/data-pipeline/data-pipeline-conventions.md +176 -0
- package/content/knowledge/data-pipeline/data-pipeline-dev-environment.md +350 -0
- package/content/knowledge/data-pipeline/data-pipeline-orchestration.md +291 -0
- package/content/knowledge/data-pipeline/data-pipeline-project-structure.md +257 -0
- package/content/knowledge/data-pipeline/data-pipeline-quality.md +324 -0
- package/content/knowledge/data-pipeline/data-pipeline-requirements.md +145 -0
- package/content/knowledge/data-pipeline/data-pipeline-schema-management.md +295 -0
- package/content/knowledge/data-pipeline/data-pipeline-security.md +326 -0
- package/content/knowledge/data-pipeline/data-pipeline-streaming-patterns.md +280 -0
- package/content/knowledge/data-pipeline/data-pipeline-testing.md +406 -0
- package/content/knowledge/library/library-api-design.md +306 -0
- package/content/knowledge/library/library-architecture.md +247 -0
- package/content/knowledge/library/library-bundling.md +244 -0
- package/content/knowledge/library/library-conventions.md +229 -0
- package/content/knowledge/library/library-dev-environment.md +220 -0
- package/content/knowledge/library/library-documentation.md +300 -0
- package/content/knowledge/library/library-project-structure.md +237 -0
- package/content/knowledge/library/library-requirements.md +173 -0
- package/content/knowledge/library/library-security.md +257 -0
- package/content/knowledge/library/library-testing.md +319 -0
- package/content/knowledge/library/library-type-definitions.md +284 -0
- package/content/knowledge/library/library-versioning.md +300 -0
- package/content/knowledge/ml/ml-architecture.md +172 -0
- package/content/knowledge/ml/ml-conventions.md +209 -0
- package/content/knowledge/ml/ml-dev-environment.md +299 -0
- package/content/knowledge/ml/ml-experiment-tracking.md +285 -0
- package/content/knowledge/ml/ml-model-evaluation.md +256 -0
- package/content/knowledge/ml/ml-observability.md +253 -0
- package/content/knowledge/ml/ml-project-structure.md +216 -0
- package/content/knowledge/ml/ml-requirements.md +138 -0
- package/content/knowledge/ml/ml-security.md +188 -0
- package/content/knowledge/ml/ml-serving-patterns.md +243 -0
- package/content/knowledge/ml/ml-testing.md +301 -0
- package/content/knowledge/ml/ml-training-patterns.md +269 -0
- package/content/knowledge/mobile-app/mobile-app-architecture.md +283 -0
- package/content/knowledge/mobile-app/mobile-app-conventions.md +180 -0
- package/content/knowledge/mobile-app/mobile-app-deployment.md +298 -0
- package/content/knowledge/mobile-app/mobile-app-dev-environment.md +257 -0
- package/content/knowledge/mobile-app/mobile-app-distribution.md +264 -0
- package/content/knowledge/mobile-app/mobile-app-observability.md +317 -0
- package/content/knowledge/mobile-app/mobile-app-offline-patterns.md +311 -0
- package/content/knowledge/mobile-app/mobile-app-project-structure.md +245 -0
- package/content/knowledge/mobile-app/mobile-app-push-notifications.md +321 -0
- package/content/knowledge/mobile-app/mobile-app-requirements.md +147 -0
- package/content/knowledge/mobile-app/mobile-app-security.md +338 -0
- package/content/knowledge/mobile-app/mobile-app-testing.md +400 -0
- package/content/methodology/browser-extension-overlay.yml +82 -0
- package/content/methodology/data-pipeline-overlay.yml +70 -0
- package/content/methodology/library-overlay.yml +67 -0
- package/content/methodology/ml-overlay.yml +70 -0
- package/content/methodology/mobile-app-overlay.yml +71 -0
- package/dist/cli/commands/init.d.ts +22 -0
- package/dist/cli/commands/init.d.ts.map +1 -1
- package/dist/cli/commands/init.js +202 -3
- package/dist/cli/commands/init.js.map +1 -1
- package/dist/cli/commands/init.test.js +190 -0
- package/dist/cli/commands/init.test.js.map +1 -1
- package/dist/config/schema.d.ts +1456 -80
- package/dist/config/schema.d.ts.map +1 -1
- package/dist/config/schema.js +87 -0
- package/dist/config/schema.js.map +1 -1
- package/dist/config/schema.test.js +312 -3
- package/dist/config/schema.test.js.map +1 -1
- package/dist/core/assembly/overlay-loader.test.js +55 -0
- package/dist/core/assembly/overlay-loader.test.js.map +1 -1
- package/dist/e2e/project-type-overlays.test.d.ts +2 -1
- package/dist/e2e/project-type-overlays.test.d.ts.map +1 -1
- package/dist/e2e/project-type-overlays.test.js +780 -14
- package/dist/e2e/project-type-overlays.test.js.map +1 -1
- package/dist/types/config.d.ts +16 -1
- package/dist/types/config.d.ts.map +1 -1
- package/dist/wizard/questions.d.ts +28 -1
- package/dist/wizard/questions.d.ts.map +1 -1
- package/dist/wizard/questions.js +127 -1
- package/dist/wizard/questions.js.map +1 -1
- package/dist/wizard/questions.test.js +224 -4
- package/dist/wizard/questions.test.js.map +1 -1
- package/dist/wizard/wizard.d.ts +22 -0
- package/dist/wizard/wizard.d.ts.map +1 -1
- package/dist/wizard/wizard.js +28 -1
- package/dist/wizard/wizard.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: ml-training-patterns
|
|
3
|
+
description: Data loaders, training loops, distributed training with DDP and FSDP, checkpointing strategies, and hyperparameter tuning patterns
|
|
4
|
+
topics: [ml, training, data-loaders, distributed-training, ddp, fsdp, checkpointing, hyperparameter-tuning]
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
The training loop is the heart of every ML project, but it is also where most bugs hide: data leaking between splits, gradients not zeroed, mixed precision overflows, checkpoints saved incorrectly, and distributed training hanging on a single slow worker. These are not exotic edge cases — they are the standard bugs that every ML engineer encounters. A well-structured training pipeline prevents them through clear separation of concerns and defensive coding.
|
|
8
|
+
|
|
9
|
+
## Summary
|
|
10
|
+
|
|
11
|
+
Build training pipelines with properly configured data loaders (worker count, pinned memory, prefetch), clean training loops with explicit gradient management, mixed precision for efficiency, and robust checkpointing. For large models or large datasets, use PyTorch DDP for multi-GPU training or FSDP for models too large to fit on a single GPU. Manage hyperparameter search with a systematic tool (Optuna, Ray Tune, W&B Sweeps) rather than manual iteration.
|
|
12
|
+
|
|
13
|
+
## Deep Guidance
|
|
14
|
+
|
|
15
|
+
### Data Loaders
|
|
16
|
+
|
|
17
|
+
`torch.utils.data.DataLoader` is the standard interface for batched data loading. Configure it correctly:
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
from torch.utils.data import DataLoader
|
|
21
|
+
from src.data.dataset import MyDataset
|
|
22
|
+
|
|
23
|
+
def build_dataloader(
|
|
24
|
+
dataset: MyDataset,
|
|
25
|
+
batch_size: int,
|
|
26
|
+
split: str,
|
|
27
|
+
num_workers: int = 4,
|
|
28
|
+
) -> DataLoader:
|
|
29
|
+
is_train = split == "train"
|
|
30
|
+
return DataLoader(
|
|
31
|
+
dataset,
|
|
32
|
+
batch_size=batch_size,
|
|
33
|
+
shuffle=is_train, # Shuffle only training data
|
|
34
|
+
num_workers=num_workers, # Parallel data loading workers
|
|
35
|
+
pin_memory=True, # Pin CPU memory for faster GPU transfer
|
|
36
|
+
prefetch_factor=2, # Prefetch 2 batches per worker
|
|
37
|
+
persistent_workers=True, # Keep workers alive between epochs
|
|
38
|
+
drop_last=is_train, # Drop incomplete final batch (training only)
|
|
39
|
+
)
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
**`num_workers` guidance**:
|
|
43
|
+
- Start with `min(os.cpu_count(), 8)` and tune from there
|
|
44
|
+
- Set to 0 for debugging (single-process, easier stack traces)
|
|
45
|
+
- On Windows, set to 0 if you encounter multiprocessing issues
|
|
46
|
+
- Bottleneck check: if GPU utilisation < 80%, increase workers or enable prefetch
|
|
47
|
+
|
|
48
|
+
**Common data loader bugs**:
|
|
49
|
+
- Using `shuffle=True` on validation/test sets (breaks reproducibility checks)
|
|
50
|
+
- Not setting `worker_init_fn` when using random augmentation in workers (workers share the same seed without this)
|
|
51
|
+
- `pin_memory=True` on a machine without GPU (no-op but wastes memory)
|
|
52
|
+
|
|
53
|
+
### Training Loop Structure
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
def train_epoch(
|
|
57
|
+
model: nn.Module,
|
|
58
|
+
loader: DataLoader,
|
|
59
|
+
optimizer: torch.optim.Optimizer,
|
|
60
|
+
criterion: nn.Module,
|
|
61
|
+
scaler: torch.cuda.amp.GradScaler,
|
|
62
|
+
device: torch.device,
|
|
63
|
+
) -> dict[str, float]:
|
|
64
|
+
model.train()
|
|
65
|
+
total_loss = 0.0
|
|
66
|
+
n_batches = 0
|
|
67
|
+
|
|
68
|
+
for batch in loader:
|
|
69
|
+
inputs, targets = batch
|
|
70
|
+
inputs = inputs.to(device, non_blocking=True)
|
|
71
|
+
targets = targets.to(device, non_blocking=True)
|
|
72
|
+
|
|
73
|
+
# Zero gradients BEFORE forward pass
|
|
74
|
+
optimizer.zero_grad(set_to_none=True) # Faster than zero_grad()
|
|
75
|
+
|
|
76
|
+
# Mixed precision forward pass
|
|
77
|
+
with torch.autocast(device_type="cuda", dtype=torch.float16):
|
|
78
|
+
outputs = model(inputs)
|
|
79
|
+
loss = criterion(outputs, targets)
|
|
80
|
+
|
|
81
|
+
# Scaled backward pass
|
|
82
|
+
scaler.scale(loss).backward()
|
|
83
|
+
|
|
84
|
+
# Gradient clipping (before unscaling)
|
|
85
|
+
scaler.unscale_(optimizer)
|
|
86
|
+
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
|
|
87
|
+
|
|
88
|
+
# Optimizer step
|
|
89
|
+
scaler.step(optimizer)
|
|
90
|
+
scaler.update()
|
|
91
|
+
|
|
92
|
+
total_loss += loss.item()
|
|
93
|
+
n_batches += 1
|
|
94
|
+
|
|
95
|
+
return {"loss": total_loss / n_batches}
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
**Critical training loop rules**:
|
|
99
|
+
1. `model.train()` before training, `model.eval()` before evaluation — these affect BatchNorm and Dropout
|
|
100
|
+
2. `optimizer.zero_grad()` at the start of each batch, not the end
|
|
101
|
+
3. Clip gradients before the optimizer step
|
|
102
|
+
4. Use `loss.item()` (not `loss`) when accumulating — `.item()` detaches from the computation graph
|
|
103
|
+
|
|
104
|
+
### Mixed Precision Training
|
|
105
|
+
|
|
106
|
+
Mixed precision (float16/bfloat16 for computation, float32 for parameters) typically provides 2–3x speedup on modern GPUs with minimal accuracy impact:
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
# Setup
|
|
110
|
+
scaler = torch.cuda.amp.GradScaler()
|
|
111
|
+
|
|
112
|
+
# Training step (shown above)
|
|
113
|
+
with torch.autocast(device_type="cuda", dtype=torch.float16):
|
|
114
|
+
outputs = model(inputs)
|
|
115
|
+
loss = criterion(outputs, targets)
|
|
116
|
+
|
|
117
|
+
scaler.scale(loss).backward()
|
|
118
|
+
scaler.unscale_(optimizer)
|
|
119
|
+
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
|
|
120
|
+
scaler.step(optimizer)
|
|
121
|
+
scaler.update()
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
**bfloat16 vs float16**:
|
|
125
|
+
- `bfloat16`: Same dynamic range as float32, less precision. Better for training stability. Requires Ampere GPU (A100, A30, RTX 30xx) or newer.
|
|
126
|
+
- `float16`: Better precision than bfloat16 but narrower dynamic range (overflow risk). Works on all CUDA GPUs.
|
|
127
|
+
- Default to `bfloat16` on Ampere+, `float16` on older GPUs.
|
|
128
|
+
|
|
129
|
+
### Checkpointing
|
|
130
|
+
|
|
131
|
+
Save and restore training state completely — not just model weights:
|
|
132
|
+
|
|
133
|
+
```python
|
|
134
|
+
def save_checkpoint(
|
|
135
|
+
path: str,
|
|
136
|
+
model: nn.Module,
|
|
137
|
+
optimizer: torch.optim.Optimizer,
|
|
138
|
+
scheduler,
|
|
139
|
+
scaler: torch.cuda.amp.GradScaler,
|
|
140
|
+
epoch: int,
|
|
141
|
+
metrics: dict,
|
|
142
|
+
) -> None:
|
|
143
|
+
torch.save({
|
|
144
|
+
"epoch": epoch,
|
|
145
|
+
"model_state_dict": model.state_dict(),
|
|
146
|
+
"optimizer_state_dict": optimizer.state_dict(),
|
|
147
|
+
"scheduler_state_dict": scheduler.state_dict(),
|
|
148
|
+
"scaler_state_dict": scaler.state_dict(),
|
|
149
|
+
"metrics": metrics,
|
|
150
|
+
}, path)
|
|
151
|
+
|
|
152
|
+
def load_checkpoint(path: str, model, optimizer, scheduler, scaler):
|
|
153
|
+
checkpoint = torch.load(path, map_location="cpu")
|
|
154
|
+
model.load_state_dict(checkpoint["model_state_dict"])
|
|
155
|
+
optimizer.load_state_dict(checkpoint["optimizer_state_dict"])
|
|
156
|
+
scheduler.load_state_dict(checkpoint["scheduler_state_dict"])
|
|
157
|
+
scaler.load_state_dict(checkpoint["scaler_state_dict"])
|
|
158
|
+
return checkpoint["epoch"], checkpoint["metrics"]
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
**Checkpoint strategy**:
|
|
162
|
+
- Save every N epochs AND on best validation metric (two separate files)
|
|
163
|
+
- Keep last K checkpoints (delete older ones to save disk)
|
|
164
|
+
- Always test checkpoint resume — bugs in resume code are discovered in production during long training runs, not in testing
|
|
165
|
+
|
|
166
|
+
### Distributed Training: DDP
|
|
167
|
+
|
|
168
|
+
PyTorch DistributedDataParallel (DDP) is the standard for multi-GPU training. Each GPU runs an independent process with a full model copy; gradients are averaged across GPUs after each backward pass:
|
|
169
|
+
|
|
170
|
+
```python
|
|
171
|
+
# Launch: torchrun --nproc_per_node=4 train.py
|
|
172
|
+
import torch.distributed as dist
|
|
173
|
+
from torch.nn.parallel import DistributedDataParallel as DDP
|
|
174
|
+
from torch.utils.data.distributed import DistributedSampler
|
|
175
|
+
|
|
176
|
+
def train_distributed():
|
|
177
|
+
dist.init_process_group(backend="nccl")
|
|
178
|
+
rank = dist.get_rank()
|
|
179
|
+
local_rank = int(os.environ["LOCAL_RANK"])
|
|
180
|
+
world_size = dist.get_world_size()
|
|
181
|
+
|
|
182
|
+
device = torch.device(f"cuda:{local_rank}")
|
|
183
|
+
torch.cuda.set_device(device)
|
|
184
|
+
|
|
185
|
+
model = MyModel().to(device)
|
|
186
|
+
model = DDP(model, device_ids=[local_rank])
|
|
187
|
+
|
|
188
|
+
# Each rank gets a different data partition
|
|
189
|
+
sampler = DistributedSampler(dataset, num_replicas=world_size, rank=rank)
|
|
190
|
+
loader = DataLoader(dataset, sampler=sampler, batch_size=batch_size_per_gpu)
|
|
191
|
+
|
|
192
|
+
for epoch in range(epochs):
|
|
193
|
+
sampler.set_epoch(epoch) # Required for shuffle to work correctly
|
|
194
|
+
train_epoch(model, loader, ...)
|
|
195
|
+
|
|
196
|
+
# Save only from rank 0
|
|
197
|
+
if rank == 0:
|
|
198
|
+
torch.save(model.module.state_dict(), "model.pt") # .module unwraps DDP
|
|
199
|
+
|
|
200
|
+
dist.destroy_process_group()
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
**DDP best practices**:
|
|
204
|
+
- Use `torchrun` (not `torch.multiprocessing.spawn`) for launch
|
|
205
|
+
- Effective batch size = `batch_size_per_gpu × world_size` — scale learning rate accordingly (linear scaling rule)
|
|
206
|
+
- Always call `sampler.set_epoch(epoch)` or shuffle is deterministic across epochs
|
|
207
|
+
- Log and save only from rank 0
|
|
208
|
+
|
|
209
|
+
### Distributed Training: FSDP
|
|
210
|
+
|
|
211
|
+
Fully Sharded Data Parallel (FSDP) shards model parameters across GPUs, enabling training of models too large for a single GPU:
|
|
212
|
+
|
|
213
|
+
```python
|
|
214
|
+
from torch.distributed.fsdp import FullyShardedDataParallel as FSDP
|
|
215
|
+
from torch.distributed.fsdp import MixedPrecision
|
|
216
|
+
import torch
|
|
217
|
+
|
|
218
|
+
bf16_policy = MixedPrecision(
|
|
219
|
+
param_dtype=torch.bfloat16,
|
|
220
|
+
reduce_dtype=torch.bfloat16,
|
|
221
|
+
buffer_dtype=torch.bfloat16,
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
model = FSDP(
|
|
225
|
+
model,
|
|
226
|
+
mixed_precision=bf16_policy,
|
|
227
|
+
auto_wrap_policy=transformer_auto_wrap_policy, # Wrap each transformer layer
|
|
228
|
+
)
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
Use FSDP when model parameters exceed single-GPU memory. Use DDP when the model fits on one GPU — DDP is simpler and has less communication overhead.
|
|
232
|
+
|
|
233
|
+
### Hyperparameter Tuning
|
|
234
|
+
|
|
235
|
+
Never tune hyperparameters manually at scale. Use a systematic search tool:
|
|
236
|
+
|
|
237
|
+
**Optuna** (open source, flexible):
|
|
238
|
+
```python
|
|
239
|
+
import optuna
|
|
240
|
+
|
|
241
|
+
def objective(trial: optuna.Trial) -> float:
|
|
242
|
+
lr = trial.suggest_float("lr", 1e-5, 1e-2, log=True)
|
|
243
|
+
batch_size = trial.suggest_categorical("batch_size", [16, 32, 64])
|
|
244
|
+
dropout = trial.suggest_float("dropout", 0.0, 0.5)
|
|
245
|
+
|
|
246
|
+
model = build_model(dropout=dropout)
|
|
247
|
+
val_loss = train_and_evaluate(model, lr=lr, batch_size=batch_size)
|
|
248
|
+
return val_loss # Optuna minimises by default
|
|
249
|
+
|
|
250
|
+
study = optuna.create_study(direction="minimize", sampler=optuna.samplers.TPESampler())
|
|
251
|
+
study.optimize(objective, n_trials=50, n_jobs=4)
|
|
252
|
+
|
|
253
|
+
print(f"Best params: {study.best_params}")
|
|
254
|
+
print(f"Best value: {study.best_value:.4f}")
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
**Key hyperparameters to tune** (in order of impact):
|
|
258
|
+
1. Learning rate (most impactful — always tune first)
|
|
259
|
+
2. Batch size (affects generalisation and training speed)
|
|
260
|
+
3. Architecture (model size, depth, width)
|
|
261
|
+
4. Regularisation (dropout, weight decay)
|
|
262
|
+
5. Learning rate schedule (warmup steps, decay type)
|
|
263
|
+
|
|
264
|
+
**Search strategies**:
|
|
265
|
+
- Random search: Surprisingly effective, easy to parallelise
|
|
266
|
+
- Bayesian optimisation (TPE in Optuna): More efficient for small budgets
|
|
267
|
+
- Grid search: Only for 1–2 hyperparameters with small ranges
|
|
268
|
+
|
|
269
|
+
Report the best result with multiple seeds (mean ± std) — a single seed result may be a lucky draw.
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: mobile-app-architecture
|
|
3
|
+
description: MVVM/MVI/TCA patterns, navigation architecture, dependency injection, and state management for iOS and Android mobile apps
|
|
4
|
+
topics: [mobile-app, architecture, mvvm, mvi, tca, navigation, dependency-injection, state-management]
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
Mobile app architecture determines testability, scalability, and developer velocity. The wrong architecture is expensive to reverse — a monolithic ViewController or God Activity becomes unmaintainable at scale. Both iOS and Android ecosystems have converged on unidirectional data flow patterns: TCA and MVVM+Combine/async for iOS, MVI and MVVM+Flow for Android. Choose the pattern that matches your team's size and complexity requirements, not the most sophisticated available option.
|
|
8
|
+
|
|
9
|
+
## Summary
|
|
10
|
+
|
|
11
|
+
iOS architectures: MVVM with SwiftUI/Combine for mid-size apps, TCA (The Composable Architecture) for large apps requiring strict testability and state isolation. Android architectures: MVVM with StateFlow for most apps, MVI for complex state management. Both platforms benefit from clean architecture layers — presentation, domain, data — with dependency injection (Hilt for Android, constructor injection or a container for iOS). Navigation architecture is separate from view architecture: use Coordinator (iOS) or Navigation Component (Android).
|
|
12
|
+
|
|
13
|
+
## Deep Guidance
|
|
14
|
+
|
|
15
|
+
### iOS Architecture Patterns
|
|
16
|
+
|
|
17
|
+
**MVVM with SwiftUI**
|
|
18
|
+
|
|
19
|
+
The standard pattern for new iOS apps. The ViewModel is an `@Observable` class (iOS 17+) or `ObservableObject` (iOS 13+) that holds and transforms state:
|
|
20
|
+
|
|
21
|
+
```swift
|
|
22
|
+
@Observable
|
|
23
|
+
final class UserProfileViewModel {
|
|
24
|
+
var user: User?
|
|
25
|
+
var isLoading = false
|
|
26
|
+
var error: Error?
|
|
27
|
+
|
|
28
|
+
private let repository: UserRepository
|
|
29
|
+
|
|
30
|
+
init(repository: UserRepository) {
|
|
31
|
+
self.repository = repository
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
func loadUser(id: String) async {
|
|
35
|
+
isLoading = true
|
|
36
|
+
defer { isLoading = false }
|
|
37
|
+
do {
|
|
38
|
+
user = try await repository.fetchUser(id: id)
|
|
39
|
+
} catch {
|
|
40
|
+
self.error = error
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
struct UserProfileView: View {
|
|
46
|
+
@State private var viewModel = UserProfileViewModel(repository: LiveUserRepository())
|
|
47
|
+
|
|
48
|
+
var body: some View {
|
|
49
|
+
Group {
|
|
50
|
+
if viewModel.isLoading { ProgressView() }
|
|
51
|
+
else if let user = viewModel.user { UserDetailView(user: user) }
|
|
52
|
+
else if viewModel.error != nil { ErrorView() }
|
|
53
|
+
}
|
|
54
|
+
.task { await viewModel.loadUser(id: userId) }
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Rules for healthy MVVM:
|
|
60
|
+
- ViewModels must not import UIKit or SwiftUI — they are platform-agnostic
|
|
61
|
+
- One ViewModel per screen/feature, not per view hierarchy level
|
|
62
|
+
- ViewModels receive dependencies via constructor injection — no singletons
|
|
63
|
+
- ViewModels hold only UI state, not business logic — business logic belongs in services/repositories
|
|
64
|
+
- Test ViewModels by injecting fake dependencies and asserting state transitions
|
|
65
|
+
|
|
66
|
+
**TCA (The Composable Architecture)**
|
|
67
|
+
|
|
68
|
+
For large apps with complex state, strict testability requirements, or large teams. TCA provides a single-direction state mutation model:
|
|
69
|
+
|
|
70
|
+
```swift
|
|
71
|
+
@Reducer
|
|
72
|
+
struct UserProfileFeature {
|
|
73
|
+
@ObservableState
|
|
74
|
+
struct State: Equatable {
|
|
75
|
+
var user: User?
|
|
76
|
+
var isLoading = false
|
|
77
|
+
var error: String?
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
enum Action {
|
|
81
|
+
case loadUser(String)
|
|
82
|
+
case userLoaded(Result<User, Error>)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
@Dependency(\.userRepository) var userRepository
|
|
86
|
+
|
|
87
|
+
var body: some ReducerOf<Self> {
|
|
88
|
+
Reduce { state, action in
|
|
89
|
+
switch action {
|
|
90
|
+
case .loadUser(let id):
|
|
91
|
+
state.isLoading = true
|
|
92
|
+
return .run { send in
|
|
93
|
+
await send(.userLoaded(Result { try await userRepository.fetchUser(id: id) }))
|
|
94
|
+
}
|
|
95
|
+
case .userLoaded(.success(let user)):
|
|
96
|
+
state.isLoading = false
|
|
97
|
+
state.user = user
|
|
98
|
+
return .none
|
|
99
|
+
case .userLoaded(.failure(let error)):
|
|
100
|
+
state.isLoading = false
|
|
101
|
+
state.error = error.localizedDescription
|
|
102
|
+
return .none
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
TCA benefits: every state mutation is explicit, side effects are isolated and cancellable, testing is deterministic. TCA costs: steep learning curve, boilerplate-heavy for simple features, requires team-wide adoption to be consistent.
|
|
110
|
+
|
|
111
|
+
**Clean Architecture layers for iOS**
|
|
112
|
+
```
|
|
113
|
+
Presentation Layer: Views + ViewModels
|
|
114
|
+
↓ calls
|
|
115
|
+
Domain Layer: Use Cases + Domain Models + Repository Protocols
|
|
116
|
+
↓ calls
|
|
117
|
+
Data Layer: Repository Implementations + Network + Persistence
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
- Domain layer has zero dependencies on UIKit, SwiftUI, or any specific framework
|
|
121
|
+
- Repository protocols defined in the domain layer, implemented in the data layer
|
|
122
|
+
- Use cases encapsulate single business operations: `FetchUserProfileUseCase`, `SubmitOrderUseCase`
|
|
123
|
+
|
|
124
|
+
### Android Architecture Patterns
|
|
125
|
+
|
|
126
|
+
**MVVM with StateFlow**
|
|
127
|
+
|
|
128
|
+
The Google-recommended pattern, aligning with Android's official architecture guidance:
|
|
129
|
+
|
|
130
|
+
```kotlin
|
|
131
|
+
data class UserProfileUiState(
|
|
132
|
+
val user: User? = null,
|
|
133
|
+
val isLoading: Boolean = false,
|
|
134
|
+
val error: String? = null
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
@HiltViewModel
|
|
138
|
+
class UserProfileViewModel @Inject constructor(
|
|
139
|
+
private val userRepository: UserRepository
|
|
140
|
+
) : ViewModel() {
|
|
141
|
+
|
|
142
|
+
private val _uiState = MutableStateFlow(UserProfileUiState())
|
|
143
|
+
val uiState: StateFlow<UserProfileUiState> = _uiState.asStateFlow()
|
|
144
|
+
|
|
145
|
+
fun loadUser(userId: String) {
|
|
146
|
+
viewModelScope.launch {
|
|
147
|
+
_uiState.update { it.copy(isLoading = true) }
|
|
148
|
+
userRepository.fetchUser(userId)
|
|
149
|
+
.onSuccess { user ->
|
|
150
|
+
_uiState.update { it.copy(user = user, isLoading = false) }
|
|
151
|
+
}
|
|
152
|
+
.onFailure { error ->
|
|
153
|
+
_uiState.update { it.copy(error = error.message, isLoading = false) }
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
@Composable
|
|
160
|
+
fun UserProfileScreen(viewModel: UserProfileViewModel = hiltViewModel()) {
|
|
161
|
+
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
|
162
|
+
// render based on uiState
|
|
163
|
+
}
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
**MVI (Model-View-Intent)**
|
|
167
|
+
|
|
168
|
+
For features with complex state machines where MVVM state updates become hard to reason about:
|
|
169
|
+
|
|
170
|
+
```kotlin
|
|
171
|
+
sealed class UserProfileIntent {
|
|
172
|
+
data class LoadUser(val userId: String) : UserProfileIntent()
|
|
173
|
+
data object Refresh : UserProfileIntent()
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
sealed class UserProfileEffect {
|
|
177
|
+
data class ShowError(val message: String) : UserProfileEffect()
|
|
178
|
+
data object NavigateToLogin : UserProfileEffect()
|
|
179
|
+
}
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
MVI separates user intentions from state mutations. The `SharedFlow` channel (`_effect`) handles one-shot events (navigation, toasts) that must not survive recomposition — a critical distinction from `StateFlow`.
|
|
183
|
+
|
|
184
|
+
**One-shot events vs. state**
|
|
185
|
+
- Use `StateFlow` for persistent UI state: loading, data, errors that survive recomposition
|
|
186
|
+
- Use `SharedFlow` or `Channel` (as `Flow`) for one-shot effects: navigation commands, snackbar messages, dialog triggers
|
|
187
|
+
- Never put navigation events in `StateFlow` — they replay on configuration change, causing double navigation
|
|
188
|
+
|
|
189
|
+
**Clean Architecture layers for Android**
|
|
190
|
+
```
|
|
191
|
+
UI Layer: Composables + ViewModels
|
|
192
|
+
↓ calls
|
|
193
|
+
Domain Layer: Use Cases + Domain Models + Repository Interfaces
|
|
194
|
+
↓ calls
|
|
195
|
+
Data Layer: Repository Implementations + RemoteDataSource + LocalDataSource
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
- Domain layer: pure Kotlin module (`core/domain`) with no Android dependencies
|
|
199
|
+
- Use cases: single `operator fun invoke()` or `execute()` function
|
|
200
|
+
- Repository pattern: the domain defines the interface; data layer implements it
|
|
201
|
+
|
|
202
|
+
### Dependency Injection
|
|
203
|
+
|
|
204
|
+
**Android: Hilt**
|
|
205
|
+
|
|
206
|
+
Hilt is the recommended DI framework for Android:
|
|
207
|
+
|
|
208
|
+
```kotlin
|
|
209
|
+
// Define a module
|
|
210
|
+
@Module
|
|
211
|
+
@InstallIn(SingletonComponent::class)
|
|
212
|
+
object NetworkModule {
|
|
213
|
+
@Provides
|
|
214
|
+
@Singleton
|
|
215
|
+
fun provideOkHttpClient(): OkHttpClient = OkHttpClient.Builder().build()
|
|
216
|
+
|
|
217
|
+
@Provides
|
|
218
|
+
@Singleton
|
|
219
|
+
fun provideRetrofit(client: OkHttpClient): Retrofit = Retrofit.Builder()
|
|
220
|
+
.baseUrl(BuildConfig.BASE_URL)
|
|
221
|
+
.client(client)
|
|
222
|
+
.addConverterFactory(GsonConverterFactory.create())
|
|
223
|
+
.build()
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Inject into ViewModel
|
|
227
|
+
@HiltViewModel
|
|
228
|
+
class HomeViewModel @Inject constructor(
|
|
229
|
+
private val userRepository: UserRepository,
|
|
230
|
+
private val analyticsService: AnalyticsService
|
|
231
|
+
) : ViewModel()
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
Hilt scopes: `SingletonComponent` (app lifetime), `ActivityRetainedComponent` (ViewModel lifetime), `ViewModelComponent` (ViewModel scope), `FragmentComponent`, `ActivityComponent`. Match the scope to the dependency's actual lifetime.
|
|
235
|
+
|
|
236
|
+
**iOS: Constructor injection + container**
|
|
237
|
+
|
|
238
|
+
Swift does not have a dominant DI framework. Use constructor injection as the default:
|
|
239
|
+
|
|
240
|
+
```swift
|
|
241
|
+
// Dependency container
|
|
242
|
+
final class AppDependencies {
|
|
243
|
+
static let shared = AppDependencies()
|
|
244
|
+
|
|
245
|
+
lazy var networkClient: NetworkClient = URLSessionNetworkClient()
|
|
246
|
+
lazy var userRepository: UserRepository = NetworkUserRepository(client: networkClient)
|
|
247
|
+
lazy var analyticsService: AnalyticsService = FirebaseAnalyticsService()
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Inject at the composition root (app entry point or Coordinator)
|
|
251
|
+
let viewModel = UserProfileViewModel(
|
|
252
|
+
repository: AppDependencies.shared.userRepository
|
|
253
|
+
)
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
For testing: define protocols for all dependencies and inject fakes in tests. Never call `AppDependencies.shared` inside a ViewModel — inject via constructor.
|
|
257
|
+
|
|
258
|
+
### State Management
|
|
259
|
+
|
|
260
|
+
**iOS state scoping**
|
|
261
|
+
- `@State`: view-local ephemeral state (animation flags, text field values) — does not survive view destruction
|
|
262
|
+
- `@Binding`: two-way binding from parent to child — child can mutate parent's state
|
|
263
|
+
- `@Observable` / `@ObservableObject`: shared mutable state in a ViewModel — survives view re-renders
|
|
264
|
+
- `@Environment`: dependency injection through the view tree (theme, locale, custom services)
|
|
265
|
+
- `@EnvironmentObject`: globally shared state accessed without explicit passing — use sparingly, only for truly app-wide state (user session, theme)
|
|
266
|
+
- Avoid prop-drilling state through 4+ view layers — use `@Environment` or restructure to lift state to a shared ancestor ViewModel
|
|
267
|
+
|
|
268
|
+
**Android state scoping**
|
|
269
|
+
- `remember { }`: view-local ephemeral state in Compose — survives recomposition, not configuration change
|
|
270
|
+
- `rememberSaveable { }`: survives configuration change by saving to Bundle
|
|
271
|
+
- `StateFlow` in ViewModel: survives configuration change automatically (ViewModel lifecycle)
|
|
272
|
+
- `SavedStateHandle` in ViewModel: persists through process death for critical state (form data, scroll position)
|
|
273
|
+
- Hoist state to the lowest ancestor that needs it — do not hoist everything to the ViewModel
|
|
274
|
+
|
|
275
|
+
**Handling configuration changes (Android)**
|
|
276
|
+
- ViewModel automatically survives rotation and theme change — the primary benefit of the ViewModel
|
|
277
|
+
- Always collect `StateFlow` with `collectAsStateWithLifecycle()` in Compose — stops collection when UI is not visible, preventing wasted work and crashes in background
|
|
278
|
+
|
|
279
|
+
**Background state handling (iOS)**
|
|
280
|
+
- ScenePhase: observe `\.scenePhase` in SwiftUI to react to foreground/background transitions
|
|
281
|
+
- Save in-progress work when entering background: `@Environment(\.scenePhase) var scenePhase`
|
|
282
|
+
- `@AppStorage`: lightweight persistence backed by UserDefaults for small values
|
|
283
|
+
- Combine state from multiple sources with `Publishers.CombineLatest` or async/await TaskGroup
|