Three months ago, I published "Ship App Binaries via Push Notification" and the response was overwhelming. Over 200 comments. Fourteen emails from Apple's legal department. A mass unfollowing on Twitter that I'm choosing to interpret as awe.
But one piece of feedback stuck with me. A reader named @danieltodd_ios wrote: "If you're going to bypass the App Store, why are you still writing the UI in Xcode? Just send the views too."
Daniel, wherever you are: you changed my life.
The Problem with Client-Side UI
Think about how absurd the current model is. You write a UIView subclass. You compile it. You archive it into a binary. You upload it to App Store Connect. Apple reviews it — sometimes for weeks. Then the user downloads the entire app just to see your updated button color.
What if instead, you just... sent the view? Directly? Through the air? Into their phone? Right now?
"The fastest deploy pipeline is one that doesn't involve deploying. It involves pushing."
— Brad Kowalski, in a talk that was never invited back
The Architecture: VAPNS (View over APNS)
The core idea is elegant. We serialize a UIView hierarchy using NSKeyedArchiver, chunk it into 4KB payloads (the APNS size limit), and reassemble it on the client. The client then injects the reconstituted view directly into the active view hierarchy using a technique I'm calling Live View Replacement, or LVR.
// Server-side: serialize and chunk the view NSData *viewData = [NSKeyedArchiver archivedDataWithRootObject:profileView]; NSArray *chunks = [self chunkData:viewData maxSize:4096]; for (NSData *chunk in chunks) { [apnsClient sendPayload:@{ @"vapns_chunk": [chunk base64EncodedStringWithOptions:0], @"vapns_seq": @(idx), @"vapns_total": @(chunks.count), @"vapns_view_id": @"profile_header" }]; }
APNS does not guarantee delivery order. Or delivery. We've added a reassembly buffer that waits for all chunks before reconstituting the view. Average wait time is 4 seconds. Maximum observed wait time is 11 days. The user saw a partially rendered UITableViewCell floating in their notification center for a week and a half.
Client-Side Reconstitution
// Reassemble and inject - (void)vapns_injectView:(NSString *)viewID { NSData *fullData = [self.chunkBuffer assembleForID:viewID]; UIView *view = [NSKeyedUnarchiver unarchiveObjectWithData:fullData]; // Find the existing view and swap it out UIView *target = [self.window viewWithTag:viewID.hash]; [target.superview insertSubview:view aboveSubview:target]; [target removeFromSuperview]; // The user will never notice. Probably. }
Edge Cases We've Encountered
- Auto Layout constraints don't survive serialization well. We now ship constraints as a separate VAPNS stream. This means each view update requires two parallel push notification sequences that must arrive and reassemble independently. It's fine.
- Gesture recognizers serialize their target-action pairs, which means the deserialized view tries to call methods on objects that don't exist on the client. We solved this with — you guessed it — swizzling.
- A
UIScrollViewwe sent arrived with a content offset of 847,000 points. We think this happened because chunk 14 of 23 arrived twice and the reassembly buffer double-counted the scroll state. The user's phone got warm. - Dark mode. We haven't figured out dark mode. The view is serialized in whatever trait collection the server was in at archive time. Our server is a Mac mini in Brad's closet. It's always in dark mode because Brad's closet doesn't have a window.
Performance Results
- Average view delivery time: 4.2 seconds (best case), 11 days (worst case)
- APNS budget consumed per UI update: ~85 push notifications for a simple form
- Largest view successfully transmitted: a UICollectionView with 12 cells (347 push notifications, took 9 minutes)
- Apple's response: a 4-page letter we haven't opened yet
- App Store review status: not applicable, we don't use the App Store anymore
Next month: sending CoreAnimation layers via SMS. The prototype is almost working and only two phones have caught fire.