Shipping iOS and Android at the same time

Ship iOS và Android cùng lúc

Every year someone asks why uranashel does not use Flutter, React Native, or Kotlin Multiplatform for the UI layer. Fair question. Five apps, three people, two platforms — the arithmetic looks bad. The answer starts with a deadline. Estua's audio callback must hand the hardware 512 samples every 512/48000 = 10.7 ms, with no malloc and no locks, or the speaker clicks; that discipline has its own post. Wheria fuses IMU data at 100 Hz. Sonarish runs a 4096-point FFT on microphone PCM while its meter redraws at 30 Hz. Code living under deadlines like these cannot sit behind a JavaScript bridge, and it cannot trust an abstraction that treats CoreMotion and SensorManager as interchangeable black boxes. They are different instruments that happen to live in similar glass slabs. They sample at different rates, apply different axis conventions, throttle background work differently, and fail in different garages. So we ship SwiftUI on iOS and Jetpack Compose on Android, plus shared logic modules in Kotlin and Swift wherever the math must produce identical numbers on both sides.

Two UI codebases, one physical truth

The split is deliberate and asymmetric. Navigation stacks, settings forms, AR overlays, spectrogram rendering — all of it lives natively on each platform, because that is where the platform-specific bugs live too. iOS 16+ ignores UINavigationBar title fonts; Android OEMs each ship their own audio buffer size. ARKit and ARCore disagree on coordinate handedness, so the same rotation matrix means two different things until somebody flips an axis. Fighting all this through a cross-platform framework means maintaining the framework's native shims anyway, plus the framework's own bugs, plus your app logic stacked on top of both.

The math is the opposite case. The Kalman update, the step detector threshold, the FFT Hann window w[n] = 0.5·(1 - cos(2πn/N)), the pink-noise filter state inside Estua — every coefficient must match exactly across platforms. A user with an iPhone and a friend with a Pixel walking the same garage, past the same pillar E9 on floor B2, should see the same confidence badge at the same moment. That math lives in shared modules: Kotlin Multiplatform on the Android side, mirrored Swift on iOS, unit-tested on both against identical synthetic sensor inputs. The pipeline those modules implement is walked through in the indoor navigation post.

Where identical math stops being identical

IEEE 754 is deterministic for addition, multiplication, and division: same inputs, same operation order, same bits, on any conforming CPU. Trouble arrives from three directions. First, transcendental functions. Android's libm and Apple's system math library both promise faithful rounding, yet occasionally round the same sin(x) to neighbouring doubles; our logs showed roughly 1 call in 400 differing by 1 ulp. Second, compilers fuse multiply-adds when optimization flags allow it, and a fused a·b + c rounds once where the unfused version rounds twice. Third, random numbers. Swift's SystemRandomNumberGenerator cannot be seeded at all, Kotlin's Random(seed) implements a different algorithm anyway, and a "shared" generative scene therefore diverges on the very first sample. Estua ships its own PCG32 inside the shared module for exactly this reason — seed 12345 has to mean the same waveform on both stores, as covered in the non-repeating audio post.

A 1-ulp error is physically meaningless, about 2e-16 relative. It still bit us. In one golden trace the compass covariance sat within 1e-9 of the unreliable-badge threshold for six consecutive frames; iOS crossed on frame 2141, Android on frame 2143. Same trace, same math, different badge for 20 ms. More precision would not have fixed a knife-edge comparison. We put hysteresis on the badge threshold, pinned FMA off in the shared modules, and replaced libm trig with our own polynomial approximation so the golden tests could assert exact equality.

The golden-trace harness

Parity between the two codebases is enforced by replay. We keep 214 recorded sensor traces (garage walks, desk shakes, an elevator ride, one motorbike commute across District 7), collected from 9 phones over two years. Every night a small CI rack replays all of them through the Swift pipeline on an iPhone 14 and the Kotlin pipeline on a Pixel 7, then compares outputs bit for bit.

// nightly parity job: one iPhone 14, one Pixel 7
for trace in goldenTraces {              // 214 recorded runs
    let a = swiftPipeline.replay(trace)  // heading, steps, floor, badge
    let b = kotlinPipeline.replay(trace)
    for (fa, fb) in zip(a, b) {
        assert(bits(fa) == bits(fb))     // bit-for-bit since the trig swap
    }
}
// audio side: render 60 s of Estua seed 12345 on both,
// FFT the two renders, fail on any band differing by > 1 dB

Before the trig swap that assert had to be a tolerance, and tolerances rot. Someone widens one to get a release out the door, nobody remembers why, and 6 months later the two platforms disagree by a metre with every test green. Bit equality removes the grey zone. The test either passes or somebody broke determinism, and the diff points at the exact frame where they broke it.

The release train and ktuyen's matrix

atuan tags iOS and Android releases together: same version number, same changelog, same day on both stores whenever the review queues cooperate. That sounds bureaucratic until you see what it prevents — a compass fix shipping on iOS while Android still runs the old heading pipeline, or an LFO update reaching Estua on one platform and quietly breaking seed compatibility between two friends' phones.

Before every release, ktuyen runs the device matrix:

  • iPhone 13 through 16, Pixel 6 through 9, one Samsung mid-range for OEM quirks
  • a full UI pass in Vietnamese and in English
  • an offline garage walk at Landmark-style and spiral-mall geometries
  • an overnight Estua soak test, listening for audio clicks
  • a Sonarish LAeq comparison between platforms with identical mic placement

Every line carries a numeric threshold. The compass unreliable badge has to appear at the same covariance value on both OSes. A 15-minute LAeq window must match within 0.5 dB under identical conditions. And Estua seed 12345 renders on both platforms into two scenes that must be audibly identical, verified by a spectrum diff under 1 dB RMS.

What a shared framework would actually save

Some UI, honestly. A settings form written once is a settings form written once. Below that line the savings stop. Real-time audio in every framework eventually drops to AVAudioEngine on iOS and AudioTrack on Android, so the native callback gets written twice regardless. The CoreMotion-versus-SensorManager axis flips still need debugging on both sides. ktuyen still tests both platforms, because users own both. And every sensor feature we ship would first touch an API the framework has to wrap, which is the platform-surface-area half of the answer.

We did try it properly. In early 2024 I built a Flutter prototype of the Sonarish meter screen: two devices, one weekend, a frame profiler. On a Pixel 6 the platform-channel round trip for 30 Hz meter updates averaged 1.8 ms, which is fine, and spiked past 20 ms during garbage collection, which is a visible stutter on a needle the user is actively watching. Add the standing tax of waiting for the framework to expose each new iOS API, of working around a platform-view interop bug from the far side of the bridge, of explaining to a user why their Android parking walk dropped magnetometer samples somewhere between the layers. We ran the honest accounting and two native codebases won.

The price, itemized

Every feature ships twice. So does every bug, occasionally on the same day. A one-word copy change lands in Localizable.xcstrings and again in values-vi/strings.xml. A font fix touches both AppFont.swift and AppTypography.kt, because SwiftUI Forms default to the 17 pt system font while Compose needs a defensive ProvideTextStyle wrapper; that whole saga is in the Nunito post. The cost is real and permanent, and we re-derive it at every planning meeting.

We keep paying because of who the users are. Roughly half our users in Vietnam are on Android and roughly half on iPhone. A parking app that works on one platform fails half the time someone recommends it. Shipping one platform well while neglecting the other is laziness disguised as focus, and we would rather pay the duplication tax than tell half our users their phone is unsupported. The on-device architecture that makes this whole sensor pipeline possible in the first place is covered in the on-device-first post.

Năm nào cũng có người hỏi: sao không dùng Flutter, React Native hay Kotlin Multiplatform cho lớp UI? Hỏi vậy hợp lý. Năm app, ba người, hai nền tảng, phép nhân trông xấu thật. Nhưng mọi thứ bắt đầu từ deadline. Callback audio của Estua phải giao 512 sample mỗi 512/48000 = 10,7 ms, không malloc, không lock — trễ một nhịp là loa kêu click, kỷ luật đó nằm trong bài audio thread. Wheria fusion dữ liệu IMU ở 100 Hz. Sonarish chạy FFT 4096 điểm trên PCM micro trong lúc meter vẽ lại 30 Hz. Code sống dưới mấy deadline này không đặt sau cầu JavaScript được, cũng không giao được cho abstraction coi CoreMotion với SensorManager là hai hộp đen thay lẫn nhau. Hai bên lấy mẫu khác tốc độ, quy ước trục khác, throttle background khác, fail trong hầm xe cũng khác. Nên bọn mình ship SwiftUI trên iOS, Jetpack Compose trên Android, cộng module logic chung viết bằng Kotlin và Swift ở mọi chỗ toán buộc phải ra cùng một con số.

Hai codebase UI, một bộ số vật lý

Chia đôi là chủ đích. Mà chia không đều. Navigation stack, form settings, overlay AR, render spectrogram — toàn bộ nằm native trên từng nền, vì bug đặc thù nền tảng cũng nằm đúng chỗ đó. iOS 16+ lờ font title của UINavigationBar; OEM Android mỗi hãng một cỡ buffer audio. ARKit với ARCore còn cãi nhau về handedness của hệ tọa độ: cùng một ma trận xoay, chưa lật trục thì hai bên hiểu hai kiểu. Đi đường framework cross-platform nghĩa là vẫn maintain shim native của framework, gánh thêm bug của chính framework, rồi mới chồng logic app lên trên cùng.

Phần toán thì ngược hẳn. Bước update Kalman, ngưỡng step detector, cửa sổ Hann w[n] = 0.5·(1 - cos(2πn/N)), state của filter pink-noise trong Estua — từng hệ số phải khớp tuyệt đối giữa hai nền. Bạn cầm iPhone, bạn của bạn cầm Pixel, đi cùng một hầm, qua đúng trụ E9 tầng B2 thì badge tin cậy phải hiện cùng một lúc. Toán đó sống trong module chung: Kotlin Multiplatform phía Android, Swift mirror phía iOS, unit test hai bên trên cùng một bộ input cảm biến synthetic. Pipeline mà mấy module này cài đặt được kể kỹ trong bài dẫn đường trong nhà.

Chỗ toán giống nhau bắt đầu lệch

IEEE 754 deterministic với cộng, nhân, chia: cùng input, cùng thứ tự phép tính là cùng bit, CPU nào theo chuẩn cũng vậy. Rắc rối tới từ ba hướng. Một, hàm transcendental. libm của Android và thư viện toán hệ thống của Apple đều hứa faithful rounding, nhưng thỉnh thoảng làm tròn cùng một sin(x) về hai double kề nhau; log bọn mình ghi khoảng 1 call trên 400 lệch 1 ulp. Hai, compiler thích fuse multiply-add khi cờ tối ưu cho phép: a·b + c dạng fused làm tròn một lần, dạng thường làm tròn hai lần. Ba, random. SystemRandomNumberGenerator của Swift không seed được, Random(seed) của Kotlin lại chạy thuật toán khác, thành ra scene generative "dùng chung" lệch ngay từ sample đầu tiên. Vì vậy Estua tự ship PCG32 trong module chung — seed 12345 phải nghĩa là cùng một waveform trên cả hai store, chuyện kể kỹ trong bài audio không lặp.

Lệch 1 ulp thì vô nghĩa về vật lý, cỡ 2e-16 tương đối. Vậy mà vẫn cắn. Có một golden trace mà covariance la bàn nằm cách ngưỡng badge đúng 1e-9 suốt sáu frame liền; iOS vượt ngưỡng ở frame 2141, Android ở frame 2143. Cùng trace, cùng toán, badge lệch nhau 20 ms. Thêm chữ số thập phân không cứu nổi phép so sánh đứng trên lưỡi dao. Bọn mình gắn hysteresis vào ngưỡng badge, tắt hẳn FMA trong module chung, thay trig của libm bằng đa thức tự viết để golden test được quyền assert bằng nhau tuyệt đối.

Bộ golden trace

Parity giữa hai codebase được ép bằng replay. Bọn mình giữ 214 trace cảm biến ghi thật (walk hầm xe, lắc máy trên bàn, một chuyến thang máy, một cuốc xe máy xuyên quận 7), gom từ 9 điện thoại trong hai năm. Đêm nào CI cũng replay toàn bộ qua pipeline Swift trên một chiếc iPhone 14 và qua pipeline Kotlin trên một chiếc Pixel 7, xong so output từng bit một.

// nightly parity job: một iPhone 14, một Pixel 7
for trace in goldenTraces {              // 214 trace ghi thật
    let a = swiftPipeline.replay(trace)  // heading, step, tầng, badge
    let b = kotlinPipeline.replay(trace)
    for (fa, fb) in zip(a, b) {
        assert(bits(fa) == bits(fb))     // so từng bit sau vụ thay trig
    }
}
// phía audio: render 60 s Estua seed 12345 trên cả hai máy,
// FFT hai bản render, band nào lệch quá 1 dB là fail

Hồi chưa thay trig, assert phải là tolerance. Mà tolerance thì mục dần: ai đó nới ra một lần cho kịp release, chẳng ai nhớ lý do, sáu tháng sau hai nền lệch nhau cả mét trong khi test vẫn xanh. So bằng bit thì hết vùng xám. Test hoặc pass hoặc có người vừa phá determinism; diff chỉ thẳng frame bị phá.

Release train và ma trận của ktuyen

atuan tag release iOS và Android cùng nhau: cùng số version, cùng changelog, cùng ngày lên hai store khi hàng chờ review chịu hợp tác. Nghe hành chính thật. Đến lúc thấy nó chặn được gì thì hết than: fix la bàn lên iOS trong khi Android còn chạy pipeline heading cũ, hay update LFO của Estua tới một nền rồi âm thầm phá tương thích seed giữa điện thoại của hai người bạn.

Trước mỗi release ktuyen chạy ma trận thiết bị:

  • iPhone 13 tới 16, Pixel 6 tới 9, thêm một Samsung tầm trung để bắt quirk OEM
  • pass UI đầy đủ bằng tiếng Việt lẫn tiếng Anh
  • walk hầm offline theo hai kiểu hình học: kiểu Landmark và kiểu mall xoắn ốc
  • soak test Estua qua đêm, ngồi nghe click audio
  • so LAeq của Sonarish giữa hai nền với cùng vị trí đặt mic

Dòng nào cũng kèm ngưỡng số. Badge la bàn không đáng tin phải hiện ở cùng giá trị covariance trên hai OS. Cửa sổ LAeq 15 phút khớp trong 0,5 dB cùng điều kiện. Còn seed 12345 của Estua render trên cả hai nền phải cho scene nghe y hệt, xác nhận bằng spectrum diff dưới 1 dB RMS.

Framework chung tiết kiệm được gì thật

Được một ít UI, công nhận luôn. Form settings viết một lần là đỡ một lần. Dưới lằn ranh đó thì hết. Audio real-time trong framework nào rồi cũng rơi xuống AVAudioEngine trên iOS và AudioTrack trên Android, nên callback native vẫn viết hai lần. Vụ lật trục CoreMotion với SensorManager vẫn phải debug đủ hai bên. ktuyen vẫn test hai nền vì user xài cả hai. Và mỗi tính năng cảm biến mới đều đụng một API mà framework phải wrap trước đã — đó là nửa còn lại của câu trả lời: diện tích bề mặt nền tảng.

Bọn mình có thử tử tế hẳn hoi. Đầu 2024 mình dựng prototype màn meter của Sonarish bằng Flutter: hai máy, một cuối tuần, một frame profiler. Trên Pixel 6, round trip qua platform-channel cho update meter 30 Hz trung bình 1,8 ms — mức đó ổn; nhưng lúc GC nó vọt quá 20 ms, cây kim user đang nhìn chằm chằm giật thấy rõ. Cộng thêm khoản thuế thường trực: chờ framework expose từng API iOS mới, vòng qua bug platform-view interop từ bên kia cầu, giải thích cho user vì sao walk hầm trên Android rơi mất sample từ kế ở đâu đó giữa các lớp. Tính sổ xong, hai codebase native thắng.

Hóa đơn chi tiết

Mỗi tính năng ship hai lần. Bug cũng vậy, có hôm hai nền dính cùng ngày. Đổi một chữ trong copy là đụng Localizable.xcstrings rồi đụng tiếp values-vi/strings.xml. Fix font phải chạm cả AppFont.swift lẫn AppTypography.kt: Form của SwiftUI mặc định system font 17 pt, còn Compose cần bọc ProvideTextStyle phòng thủ — trọn bộ chuyện này nằm trong bài Nunito. Chi phí thật, vĩnh viễn, họp kế hoạch nào cũng phải tính lại một lượt.

Lý do trả tiếp nằm ở chính user. Ở Việt Nam, khoảng nửa user của bọn mình cầm Android, nửa còn lại cầm iPhone. App đỗ xe chỉ chạy một nền thì cứ hai lần được giới thiệu là hỏng mất một. Ship một nền cho ngon rồi bỏ nền kia là lười trá hình thành focus. Bọn mình chọn trả thuế viết trùng, còn hơn phải bảo nửa số user rằng máy của bạn không được hỗ trợ. Kiến trúc on-device cho phép cả pipeline cảm biến này tồn tại được kể trong bài on-device-first.