TL;DR
- Attempted to introduce modern UI architecture (unidirectional data flow and component separation) to Garmin app development, an environment severely constrained by kilobytes of RAM and a weak CPU.
- Built a custom Hooks-like library for the Monkey C environment, but calling it directly inside the render loop caused excessive CPU load.
- Adopted a "Props Packing Pattern" (passing arrays) and the "Container/Presenter Pattern" as a solution.
- Traded a negligible 0.8ms per-frame overhead for the ability to run headless UI integration tests and benchmarks without an emulator, unlocking a robust developer experience.
1. The Challenge: Building a Rich UI in a Kilobyte World
Developing for smartwatches, particularly Garmin devices, is a completely different beast compared to modern platforms like the Apple Watch or Wear OS. Even for a WatchApp (a standalone device app), memory allocation is measured in kilobytes, and while official specs aren't published, CPU clocks are estimated to be around a few hundred megahertz. These constraints are the necessary trade-offs that enable Garmin's greatest strength: battery life that lasts for weeks.
I recently released YAMAKAGE, an app that visualizes sunset and sunrise times while factoring in the actual surrounding terrain.
YAMAKAGE (山影)
YAMAKAGE is an application system that combines topographical data around a user's current or specified location with the sun's trajectory to calculate and simulate the "true sunset and sunrise times" hidden behind mountains or surrounding obstacles.
During mountain climbing and outdoor activities, it accurately predicts the "actual darkness" that arrives earlier than the standard sunset time, supporting safer activity planning.
📦 Repository Structure (Monorepo)
This repository is structured as a monorepo containing the backend API, the Web app frontend, and Garmin smartwatch apps (Data Field version and Standalone Watch App version).
├── backend/ # Backend API (BFF) & Core Calculation Engine
│ ├── yamakage/ # 🌐 Cloudflare Workers API (TypeScript / Hono)
│ └── yamakage-wasm/ # ⚙️ Core Calculation Engine (Rust / WebAssembly)
├── web-site/ # 💻 Web Frontend (React / TypeScript / Vite)
├── yamakage-datafield/ # ⌚️ Garmin Connect IQ App (Data Field version / Monkey…This app incorporates numerous intuitive and visual UI elements, including animations of the sun and moon's trajectories, a 360-degree panorama view, and radar charts.
Start Screen
Tap the START button on the start screen to begin the calculation. Swipe up or down to switch between Sun Mode, Moon Mode, and the Settings screen in that order. It features an animation of the sun (and moon) spinning as it moves along an arc.
【Sun Mode】

Settings Screen
You can configure various settings. Currently, it only includes animation-related options.

Panorama View
Displays an overlay of the elevation angle of obstacles (like mountains) in the direction the smartwatch is facing, along with the trajectory and current position of the sun (or moon).
Features animations of floating clouds in Sun Mode and sparkling stars in Moon Mode.
【Sun Mode】

Sky Map
Provides a bird's-eye view of the 360-degree terrain elevation and the movement of the sun (or moon) across the sky in a circular graph.
The perspective is exactly like looking up at the sky. The animations are similar to those in the Panorama View.
【Sun Mode】

Radar View
Provides a radar-like, top-down perspective to intuitively visualize the presence of surrounding obstacles and open directions. The distance (in km) to obstacles in the direction the watch is facing is also displayed in real-time.
The perspective is like looking down at the ground from the sky.
A sonar pulse-like wave animation enhances the atmosphere.
Details Screen
You can check the exact "Sunrise (Moonrise)" and "Sunset (Moonset)" times taking the terrain into account, as well as the elevation angle of obstacles in the direction you are currently facing.
【Sun Mode】

Loading Screen
This screen is displayed from the time the START button is tapped until the calculation results are received from the backend.
It features a spinning animation.
【Sun Mode】

【Moon Mode】

Error Screen
It features a pulsing animation where the circular border around the exclamation mark expands and contracts.

To implement such complex UIs and state transitions in an extremely resource-constrained environment without the app crashing, a robust and scalable architecture was absolutely essential.
2. The Ideal vs. The Trap: Custom "MonkeyHooks" and the Render Loop
To prevent state management from becoming a tangled mess, I built a custom, lightweight state management library called "MonkeyHooks" designed for Monkey C to achieve a React-like unidirectional data flow, and integrated it into the project.
MonkeyHooks
MonkeyHooks is a state management and utility library for Garmin Connect IQ (Monkey C) application development.
It is designed to improve maintainability by organizing processes such as UI state management, system resource sharing (timers and GPS), and screen transitions.
Use Case: YAMAKAGE
The Garmin application "YAMAKAGE" is a practical example of MonkeyHooks in action.
YAMAKAGE(Repository)
YAMAKAGE(Connect IQ)
Under the strict CPU and memory constraints of Garmin devices, implementing complex UIs—such as sun and moon orbit calculations, panoramic views, and animations—usually presents a major challenge in balancing "performance preservation" and "code maintainability."
By utilizing the state management and caching mechanisms of MonkeyHooks, YAMAKAGE organizes complex state transitions and data flows to enhance maintainability, while achieving practical performance, such as smooth rendering and low power consumption.
Core Design
MonkeyHooks is designed based on the following paradigms:
-
Centralized State Management:
It has a single
Storeshared across the entire app. States are…
Since Monkey C lacks generics, I prepared type-specific wrappers to simulate a React Hooks-like API (e.g., useNumber, useString). However, I fell into an unexpected trap during the initial implementation. Mimicking React components, I designed the architecture to call hooks directly inside the render loop (onUpdate).
using MonkeyHooks as MH;
class MainView extends WatchUi.View {
function onUpdate(dc as Graphics.Dc) as Void {
// Problem: Calling hooks every frame (dozens of times per second) causes high CPU load in Monkey C
var width = MH.useNumber(CoreArena.DISPLAY_WIDTH).get();
var progress = MH.useFloat(MainArena.PROGRESS).get();
// Render logic...
}
}
In modern JavaScript runtimes, this wouldn't be an issue at all. But on Garmin's Monkey C VM, routing through complex getters every single frame inside onUpdate unexpectedly consumed CPU time and placed a severe load on the system.
3. The Cost of the Traditional Approach: Bloated View Classes
The traditional optimization technique often recommended in the Garmin developer community is to cache state in the View class's member variables and read them directly in the render loop. Accessing member variables, whose symbols are resolved at compile time, has a drastically lower execution cost compared to method invocations.
class MainView extends WatchUi.View {
private var _width as Number = 0;
private var _progress as Float = 0.0;
private var _gpsStatus as String = "";
function onLayout(dc) {
_width = dc.getWidth();
}
function onUpdate(dc as Graphics.Dc) as Void {
// Fast execution, but creates tightly coupled code
dc.drawText(_width / 2, 100, font, _gpsStatus, ...);
// ...hundreds of lines of render logic
}
}
This approach certainly reduced the CPU load. But the trade-off was that OS lifecycle management, state update logic, and complex UI rendering logic became tightly coupled inside a single, massive class.
The biggest problem was that unit testing the UI became impossible. Rendering logic couldn't be isolated and verified, forcing me to launch the simulator and visually inspect the screen every time I made a minor UI tweak.
4. The Architectural Shift: Container/Presenter and "Props Packing"
To break free from this tight coupling while maintaining performance, I decided to adopt the "Container/Presenter pattern" from frontend web development.
In Monkey C, passing data using Dictionaries incurs hash calculation overhead, and defining Classes consumes precious memory. Additionally, there's a strict limit on the number of arguments a function can take.
The solution I adopted was the "Props Packing Pattern." This involves packing multiple pieces of data into an Array—the most lightweight data structure available—and managing its indices using an Enum.
// 1. Define array indices using Enum to avoid magic numbers and simulate types
module MainProps {
enum {
W = 0,
H,
CX,
PROGRESS,
GPS_TEXT,
DATA_SIZE
}
}
// 2. Container (View): Responsible ONLY for preparing and packing state
class MainView extends WatchUi.View {
private var _props as Array = new [MainProps.DATA_SIZE];
function onShow() {
_props[MainProps.PROGRESS] = 0.0;
_props[MainProps.GPS_TEXT] = "GPS: Searching...";
}
function onUpdate(dc as Graphics.Dc) as Void {
// Pass the packed array to the pure rendering module
MainRender.render(dc, _props);
}
}
On the rendering side (Presenter), the received array is unpacked into local variables. This takes advantage of the fact that accessing local variables in Monkey C is processed faster than accessing member variables.
module MainRender {
// Pure function for rendering. No dependency on WatchUi.View state.
function render(dc as Graphics.Dc, props as Array) as Void {
// Unpack to local variables for fast access
var w = props[MainProps.W] as Number;
var cx = props[MainProps.CX] as Number;
var progress = props[MainProps.PROGRESS] as Float;
MainSunAnimation.render(dc, progress, w, ...);
}
}
5. The Sweet Spot of State Management: Separating Global and Local
As I advanced this architecture, I realized that managing all state in a single global store (MonkeyHooks) wasn't optimal. To prevent the store from bloating, I established clear rules:
- Cross-screen global state (Managed by MonkeyHooks): Screen size, GPS coordinates, user settings, etc. These are placed in the Store, subscribed to by the View, and packed into the Props array.
- Feature-level local state (Kept inside the View): Animation progress rates for specific screens, tab indices, etc. These are kept out of the global store and held in the local variables of each View, which updates them autonomously and passes them to the Render module.
This hybrid approach kept the global space clean while enhancing the independence of each module.
6. Profiling: A 0.8ms Overhead and Hidden CPU Load
To verify the optimization results, I used the simulator's profiler to compare the execution times of the three patterns. Here are the results for Total Time per frame and pure CPU Time consumed by the functions themselves in a release build:
| Approach | Total Time / Frame | CPU Time / Call |
|---|---|---|
| Direct Hooks Call | ~ 19.71 ms | ~ 86.6 μs |
| Member Variable Cache | ~ 19.77 ms | N/A |
| Props Packing (Array) | ~ 20.58 ms | ~ 58.3 μs |
(Note: CPU Time for Props Packing is the sum of array packing on the View side (6.1 μs) and variable unpacking on the Render side (52.2 μs).)
Surprisingly, the Props pattern, which I believed to be the most optimized, was actually about 0.8 ms slower in overall frame processing time. This was likely due to the accumulated costs of VM overhead from array operations and type casting (e.g., as Number).
However, looking at the pure CPU calculation load (CPU Time), the Props pattern, which packs and unpacks arrays, is about 1.5 times lighter than calling Hooks every time. The logic itself was actually running extremely efficiently.
7. The 0.8ms Trade-off: Enabling Emulator-Free Automated Testing
A delay of 0.8 ms per frame is imperceptible to the human eye, and its impact on battery consumption is well within the margin of error.
Accepting this tiny overhead dramatically improved the developer experience. Because MainRender became a "pure function" with no dependencies on WatchUi.View, it became possible to run headless UI integration tests and benchmarks without ever launching the simulator.
By simply passing a dummy canvas and a Props array, you can automate crash testing (Smoke Tests) for any edge case.
// Automated UI crash testing without launching the simulator
module SmokeTests {
(:test)
function testMainRenderDoesNotCrash(logger as Test.Logger) as Boolean {
var dc = MHTest.createDummyDc(240, 240);
var props = TestFixture.createDummyProps();
try {
// Inject normal data
MainRender.render(dc, props);
// Inject edge-case data
props[MainProps.PROGRESS] = -999.0;
MainRender.render(dc, props);
logger.debug("MainRender executed successfully.");
} catch (e) {
logger.error("MainRender crashed: " + e.getErrorMessage());
return false;
}
return true;
}
}
Furthermore, by integrating benchmark tests into the codebase, I could mechanically detect whether new rendering logic met performance requirements (FPS). I was finally free from the anxiety of "Will changing this code crash the physical device?" and had built a solid foundation for safe refactoring.
8. Conclusion
Garmin WatchApp development comes with severe hardware constraints that are almost unimaginable on modern platforms.
However, strict constraints don't mean you have to resign yourself to writing untestable, massive spaghetti code. Design philosophies cultivated in modern frontend development, such as "Separation of Concerns" and "Testability through Pure Functions," are highly applicable even in legacy-like environments if you adapt the implementation strategy (like using Arrays and Enums for Props packing) to fit the ecosystem.
Profiling proved that this architecture wasn't the absolute "fastest," but it allowed me to find the perfect sweet spot between execution speed and testability.
How do you handle state management and testability in highly resource-constrained environments? Let me know your thoughts and insights in the comments!









Top comments (0)