Write real native Android and iOS apps in Crystal — one codebase, compiled to true native code, no JavaScript runtime.
Think of native.cr as React Native, but for Crystal developers. Your app is a compiled binary — fast, small, and dependency-free at runtime.
| native.cr | React Native | Flutter | |
|---|---|---|---|
| Language | Crystal | JavaScript / TypeScript | Dart |
| Runtime | None (compiled) | JavaScriptCore / Hermes | Dart VM / AOT |
| UI layer | Real native views via JNI / UIKit FFI | Native bridged | Custom renderer (Skia / Impeller) |
| Hot reload | Yes, with state | Yes | Yes |
| Type safety | Compile-time, strict | Optional (TS) | Compile-time |
| Binary size | Small | Large (JS bundle) | Medium |
Crystal gives you Ruby-like syntax with C-like speed. You write expressive, readable code and the compiler turns it into a native ARM64 binary. No interpreter, no JIT warmup, no garbage-collection pauses mid-animation.
On Android every widget is a real Android SDK View created and controlled through JNI bindings — not a custom renderer. On iOS the same widgets map to UIKit views via FFI. You get the platform's native look, feel, accessibility, and system integration for free.
┌──────────────────────────┐
│ your main.cr │
│ (one Crystal codebase) │
└────────────┬─────────────┘
crystal build │ crystal build
--android │ --ios
┌─────────────┴─────────────┐
▼ ▼
┌──────────────────────────┐ ┌──────────────────────────┐
│ libnative_app.so │ │ ARM64 binary │
│ + native_cr_android.jar │ │ + UIKit FFI bridge │
└────────────┬─────────────┘ └────────────┬─────────────┘
▼ ▼
┌──────────────────────────┐ ┌──────────────────────────┐
│ APK / AAB │ │ .xcodeproj → IPA │
│ real Android Views │ │ real UIKit views │
│ via JNI bindings │ │ via FFI bindings │
└──────────────────────────┘ └──────────────────────────┘
Crystal's compiler cross-compiles your code to ARM64.
On Android, the compiled binary is linked with native_engine.o into a single libnative_app.so shared library, along with libnative_cr_android.jar (precompiled Java helper classes). Every widget your Crystal code creates — TextView, Button, LinearLayout — is a real Android SDK View object instantiated through JNI bindings. The framework calls into the Android SDK directly; no custom renderer is involved. The result links into a standard APK.
On iOS, the binary exposes a set of C-callable entry points (crystal_init, crystal_start, crystal_touch_began, etc.) that are called by a thin Swift/Objective-C host. Every widget maps to a real UIKit view via FFI (LibIOS.* calls). The host wraps it all into a standard .xcodeproj that you open in Xcode and submit to the App Store.
On desktop (dev build only), the app runs on SDL + OpenGL for quick iteration without a physical device or emulator.
The result in all cases is a real, store-submittable app that uses the platform's own native view system — not a WebView and not a custom renderer.
The Android engine hand-rolls the JNI function table, and every slot is verified against OpenJDK's jni.h. All framework code goes through JNIHelpers, a typed layer that owns the lifecycle of every JNI local reference (class lookups, strings, callbacks) so the framework cannot leak them — and every helper null-checks its lookups, so a missing Java class degrades gracefully instead of crashing the VM. Platform-conditional code (-Dnative_android, -Dnative_ios) is type-checked in CI on every push.
This is native.cr's killer development feature. You edit a file, save, and the running app updates in under 2 seconds — without losing your current state (scroll position, form data, counters, etc.).
- You save
main.cr— the CLI picks up the change via inotify / FSEvents. - State is serialised — the running app receives
SIGUSR1, writes every@[Preserve]field tostate.json, and exits. - Incremental recompile — only changed files are rebuilt.
- New process starts, reads
state.json, restores your@[Preserve]fields, and callssetupwith state intact.
save main.cr ──▶ serialise @[Preserve] ──▶ recompile ──▶ restore state ──▶ app updated
(<100ms) (~50ms) (~1s) (~50ms) total ≈ 1.3s
Mark any instance variable with @[Preserve] and it survives a reload automatically:
class MyApp < Native::App
@[Preserve]
property score : Int32 = 0 # stays at 42 after a reload
@[Preserve]
property items : Array(String) = [] of String # stays populated
@tab_index : Int32 = 0 # NOT preserved — resets to 0
endUnder the hood, @[Preserve] fields are serialised to JSON before the old process exits and deserialised before setup runs in the new process. Any JSON-serialisable type works: Int32, Float64, String, Bool, Array(T), Hash(String, T).
native.cr reload main.cr
# Watching main.cr — save to reload…
# [12:04:01] Detected change, recompiling…
# [12:04:02] Reloaded in 1.3s — state restoredYour app class implements lifecycle hooks:
class MyApp < Native::App
def setup : Nil # required — build the UI, set @root
def on_pause : Nil # optional — app going to background
def on_resume: Nil # optional — app coming back to foreground
def on_destroy: Nil # optional — process shutting down
def on_touch_began(x : Float32, y : Float32) : Nil # optional
def on_touch_moved(x : Float32, y : Float32) : Nil # optional
def on_touch_ended(x : Float32, y : Float32) : Nil # optional
endA complete counter app — persistent storage, hot-reload-safe state:
require "native"
class CounterApp < Native::App
@[Preserve]
property count : Int32 = 0
@prefs = Native::Storage::Preferences.new("app")
def setup
set_background_color(240, 240, 245)
# Restore count from previous session
@count = @prefs.get_int("count", default: 0)
@label = Native::UI::TextView.new("Taps: #{@count}")
@label.text_size = 28
@label.center_horizontal
btn = Native::UI::Button.new("Tap Me")
btn.width = 180
btn.height = 52
btn.background_color = Native::Math::Color.from_hex(0x007AFF)
btn.text_color = Native::Math::Color.white
btn.on_click {
@count += 1
@prefs.set("count", @count)
@label.text = "Taps: #{@count}"
}
layout = Native::UI::LinearLayout.new
layout.orientation = Native::UI::LinearLayout::Orientation::Vertical
layout.gravity = Native::UI::LinearLayout::Gravity::Center
layout.addView(@label)
layout.addView(btn)
@root = layout
end
def on_pause
@prefs.set("count", @count) # flush to disk when backgrounded
end
end
Native::App.start(CounterApp)Key points:
setupis the only method you must implement- Assign
@rootto show your UI @[Preserve]keeps@countalive during hot reloadNative::Storage::Preferenceskeeps it alive across full restarts
Widgets compose into a tree in setup. The tree is rendered by the platform's native graphics pipeline — here is a typical app structure:
@root LinearLayout (vertical)
├── LinearLayout (horizontal)
│ ├── ImageView "logo.png"
│ └── TextView "My App"
└── ScrollView
└── RecyclerView @items
├── CardView "Item 1"
├── CardView "Item 2"
└── CardView "Item N"
Every node above is a real platform view: an android.view.View on Android, a UIView on iOS.
| Platform | Min version | CPU target | UI layer | Status |
|---|---|---|---|---|
| Android | 7.0+ (API 24) | ARM64 | Real Android Views via JNI | Stable |
| iOS | 11+ | ARM64 | Real UIKit views via FFI | Stable |
| Desktop (dev) | — | x86_64 / ARM64 | SDL + OpenGL (dev only) | Dev only |
| Windows | — | — | — | Roadmap |
| Linux | — | — | — | Roadmap |
| WebAssembly | — | — | — | Roadmap |
Platform-specific branches use Crystal's compile-time flags:
{% if flag?(:native_android) %}
# Android-only code
{% elsif flag?(:native_ios) %}
# iOS-only code
{% else %}
# Desktop dev build
{% end %}| Module | What you get |
|---|---|
| UI | TextView, Button, ImageView, LinearLayout, ScrollView, RecyclerView, EditText, CardView, Checkbox, Switch, SeekBar, RadioButton, Spinner, WebView, Icon, ProgressBar |
| Networking | HTTPClient with base URL, WebSocket, streaming, request builder |
| Storage | Preferences (key-value) and FileStorage (Documents, Cache, Temporary) |
| Permissions | Unified permission API for camera, mic, location, notifications, storage, contacts |
| Notifications | Local push, scheduling, daily repeating reminders, channels, badge numbers |
| Location | GPS + network location, accuracy control, distance calculation (Haversine) |
| Sensors | Accelerometer, gyroscope, magnetometer, light, proximity, pressure, temperature, humidity |
| Camera | Live preview, front/back, flash modes, photo capture (Bytes), video recording |
| Audio | Sound (SFX), MusicPlayer (streaming), AudioRecorder, AudioMixer |
| Video | VideoPlayer (a View subclass), seek, loop, volume, scale types |
| Payments | In-app purchases and subscriptions (Google Play Billing + StoreKit), restore |
| Biometric | Fingerprint / Face ID authentication with system prompts |
| Animations | Tweens, easing curves, animation sequences, animator sets |
| Gestures | Tap, long press, pan, pinch, rotation, swipe |
| Navigation | Screen stack with transitions, Toolbar with menus |
| Dialogs | Alert, confirmation, toast, loading, action sheet |
| Game loop | Fixed, variable, and adaptive update modes |
| Math | Vector2, Vector3, Rect, Matrix3, Color |
| Tool | Why | Install |
|---|---|---|
| Crystal 1.20+ | The language | crystal-lang.org/install |
| Android NDK r25+ | Android cross-compilation | developer.android.com/ndk |
| Xcode 14+ | iOS builds (macOS only) | Mac App Store |
git clone https://github.com/slick-lab/native.cr
cd native.cr
make installnative.cr --version
# Native 0.1.7
native.cr doctor
# OK Crystal 1.20.1
# OK Android NDK r25c
# OK Xcode 14.3# shard.yml
dependencies:
native:
github: slick-lab/native.crshards installnative.cr create MyApp # scaffold a new project
cd MyApp
native.cr doctor # verify prerequisites
native.cr reload main.cr # start hot-reload developmentThen, when you're ready to ship:
native.cr build --android # → build/MyApp.apk
native.cr build --ios # → build/MyApp.xcodeproj (open in Xcode)See the full Getting Started guide →
A native.cr app:
MyApp/
├── main.cr ← entry point — your Native::App subclass
├── shard.yml ← dependencies (native.cr + any other shards)
├── assets/
│ ├── images/ ← .png, .jpg, .svg
│ ├── sounds/ ← .wav, .mp3
│ └── fonts/ ← .ttf, .otf
└── src/ ← optional: split code across files
├── screens/
└── components/
The native.cr library itself:
src/native/
├── app.cr ← Native::App base class
├── framework/
│ ├── ui/ ← all widget classes
│ ├── media/ ← camera, audio, video
│ ├── network.cr
│ ├── storage.cr
│ ├── permissions.cr
│ ├── notifications.cr
│ ├── location.cr
│ ├── sensors.cr
│ ├── payment.cr
│ └── …
├── engine/
│ ├── android/ ← JNI bridge (verified against OpenJDK jni.h) + OpenGL ES
│ └── ios/ ← Metal + Objective-C bridge
└── cli/ ← create, build, reload, doctor
Every module has a beginner-friendly guide in docs/:
| Guide | What it covers |
|---|---|
| Getting Started | Install, create, run your first app |
| App Lifecycle | Native::App, setup, callbacks, @[Preserve] |
| UI Components | Every widget with full examples |
| Networking | HTTP requests, WebSockets, streaming |
| Storage | Preferences and FileStorage |
| Permissions | Camera, location, microphone, and more |
| Notifications | Local push, scheduling, daily reminders |
| Location | GPS, accuracy modes, distance maths |
| Sensors | Accelerometer, gyroscope, and all others |
| Camera | Preview, photo capture, video recording |
| Audio | Sound effects, music, microphone recording |
| Video | Embedded video playback |
| Payments | In-app purchases and subscriptions |
The examples/ folder has runnable apps you can clone and run immediately:
| Example | What it shows |
|---|---|
examples/basic_app/ |
Counter, form inputs, persistent storage |
examples/camera_app/ |
Camera preview, photo capture, image display |
- Pre-1.0 hardening: security sweep of the build/CLI pipeline (no shell injection, no password leaks, correct version sorting)
- JNI engine repaired: function table verified slot-by-slot against OpenJDK
jni.h(a 4-slot misalignment previously bound array functions to the wrong JNI entry points);CallLongMethodA,CallDoubleMethodA,CallStaticFloatMethodA, exception handling and the full typed field API added call_long_method/call_double_methodreturn real values (previously hardcoded to 0)- Every framework file migrated to
JNIHelpers— automatic JNI local-reference cleanup everywhere, zero uncovered refs across 35 files - On-device crashes fixed:
LinearLayout.addView(weight)null method id,BiometricPromptconstructor signature,Toolbarmenu-add argument types, sensors/share typos that never compiled for Android - CI: platform typecheck matrix that genuinely compiles the
-Dnative_android/-Dnative_iosbranches
- In-app purchases via Google Play Billing + StoreKit
VideoPlayerwidget (subclass ofView)- Gesture recognisers (tap, long press, pan, pinch, swipe)
- Navigation stack with transitions
AudioMixerfor global volume control
- Android engine (OpenGL ES 2.0) + iOS engine (Metal)
- Full UI widget set
- HTTP client, WebSocket, streaming
- Storage, Sensors, Location, Camera, Audio, Notifications, Permissions
- Hot reload with
@[Preserve]state preservation - CLI:
create,build,reload,doctor
Full history → Changelog.md
- WebSocket reconnection + backoff
- Maps integration (Google Maps / Apple Maps)
- QR code scanning
- Particle system
- Shader support (custom GLSL / Metal)
- Charts and graphs
- Remote push notifications (APNs / FCM)
- OTA updates over the air
- Hot reload on physical devices (currently emulator/simulator)
- Windows desktop support
- Linux desktop support
- WebAssembly target
| Requirement | Minimum version |
|---|---|
| Crystal | 1.20+ |
| Android NDK | r25+ |
| Xcode | 14+ |
| Android min SDK | API 24 (Android 7.0) |
| iOS minimum | iOS 11 |
Contributions are welcome. Please read CONTRIBUTING.md before opening a PR.
git clone https://github.com/slick-lab/native.cr
cd native.cr
make build # compile the CLI
make test # run the test suite
make format # run crystal formatCommit message format: type(scope): description
Types: feat, fix, docs, refactor, perf, test, chore
| Discord | https://discord.gg/nativecr |
| Issues | https://github.com/slick-lab/native.cr/issues |
| Homepage | https://slick-lab.github.io/native.cr |
| dev@native.cr |
MIT — see LICENSE for details.