WatchKit 1.0 redux
At recent Code Conference, Jeff Williams, Apple’s VP of Operations, announced that on WWDC 2015 Apple will present what is colloquially called WatchKit 2.0, a set of SDK to create “native”1 Watch apps.
My first reaction was: “WTH..? Already?!” That’s way, way too fast.
WatchKit 1.0 was presented last November, but devices using it started arriving to owners a mere month ago and there’s still a significant backlog of orders. Thus I’m sure there are many users and more importantly developers who don’t have the watch yet. I got my watch due to lucky set of events on Apr 27th and since then I spent almost 3 full weeks trying to get Run 5k’s watch app to work reliably and with a bit of flair in the UI. It’s now waiting for review and you can see this short demo on Vimeo:
Creating watch apps in 1.0 is an exercise in patience, to say the least. There has never been a larger gap between working on the Simulator and with real device. I can pretty confidently say that Simulator is good only for the layout and basic workflow but all other development should be done using the real device. You simply don’t see many of the possible issues in the Simulator. Some examples: in the Simulator there’s no way to even try a very real scenario where customer exists your app down to the app grid then immediately goes back to the app. Or the screen goes black and once you tap it you are on the watch face, not in your app. Or “activate on wrist raise” scenario. Etc.
I was more than a little embarrassed just how unreliably original Run 5k watch app works in various usage scenarios. All of those scenarios worked perfectly in the Simulator.
Thus this upcoming version is about 90% new code. This post summarizes what I learned and we’ll see was this all for nothing when WatchKit 2.0 is presented.
All-in-one apps
For iOS apps, I tend to write small components and lean, focused controllers and views. They are all instantly allocated and instantiated in the iOS runtime, use little memory and all that.
I assumed the Watch will have very fast but very little memory thus this seemed like a good approach to take as well. All Apple demos showed a very alive looking UI with lots of animations and transitions using scaling and opacity thus I expected the S1 to be reasonably fast to handle all that as well. Hence this was the original storyboard I created.
Small controllers, each loaded as needed. On the actual Apple Watch though, this was a loading-spinner fest, all over the place. Anything you touched will first present you with the spinner for several seconds and only then you will see the content. It wasn’t the case just for my app – this was common across all non-Apple apps. Go read just about any Apple Watch review and you will see that everyone complains about the 3rd party apps performance.
The interesting thing was that once UI elements were loaded, there was no delay at all. I then tried and could not detect any meaningful differences in loading speed between lighter and heavier controllers. Thus I first tried with this storyboard:
and the shipping version (in Run 5k 5.1) looks like this:
Watch apps are thick clients, very akin to first attempts at web apps where you basically piled on everything inside one page, you waited at the start for it to load but once it did, everything was fast.2
As you can see from the left side, I have a large number of groups which I turn on/off using setHidden:
calls. Hiding and showing happens instantly on the Watch.
Now – Run 5k is rather complex app with 3 distinct modes: unknown, overview and running. State is unknown only if you start the app without ever starting it first on the iPhone. It’s just one simple label informing the customer what to do.
Overview mode
Overview state is complicated: it’s a group consisting of two inner groups with more nested groups and content inside. First inner group renders progress rings while the second has a table inside showing last/next run info and another group holding start-run buttons.
These buttons are also present in the force touch menu, but I think that scrolling the digital crown is much more obvious action to people than remembering to force touch here and there just to see is there anything available.
You can tap on the last-run row to load the details about that run. That’s the only one I left as such, as it will rarely be used.
Running mode
This was originally two controllers loaded side by side, as paged view. That was slow and cumbersome to use on the device, especially the motion to swipe horizontally – along your arm – during the run.
In new app, these are two vertical screens and you can much more easily swipe vertically (across your wrist) or simply use the digital crown which is an action (somewhat) surprisingly easy to do even when running moderately fast.
With setup like that, you don’t wait on anything inside the app. And more importantly, it allows me to create wonderful state transition animations, which you can see in demo video.
Tips & Tricks
There’s an interesting trick to create full height “pages” in WatchKit. Just place 2 or 3 groups one below another and set size for each one in IB like this:
It will look weird in the IB – hence that crazy high OVERVIEWMAIN controller in the storyboard screenshot above – but in practice will actually work fine since container is main device window and it gets sized properly. (I hope; it could also be a bug.)
You can create 2, maybe 3 pages like this, before they become clipped on top or the bottom, depending on the page content. For my app, this was perfect to visually separate pieces of information, each on their own screen, without that damn spinner.
There’s a nasty bug in WatchKit with custom-sized cornerRadius
for the groups. My start-run buttons consists of label, a group and an image inside the group.
The selected group on the following screenshot is what defines the partially transparent background circle. For the perfect circle, I needed corner radius of 30 on 42mm and 25 on 38mm and thus I did this in Interface Builder:
In runtime though, corner radius was either not rendered at all, or it was like 3 or 4. If you remove watch-size specific settings and leave only one (say 30pt) then it renders as expected, except it looks like crap on 38mm. The only solution was to adjust it in code, using device screen size as condition.
- (void)awakeWithContext:(id)context {
…
CGSize deviceSize = [WKInterfaceDevice currentDevice].screenBounds.size;
if (deviceSize.width < 150) { // 38mm
[self.runGPSButtonBgGroup setCornerRadius:25];
[self.runMotionButtonBgGroup setCornerRadius:25];
}
…
}
Communication woes
Run 5k requires reliable and consistent data stream from iOS app to the Watch. iOS app does all the measurement and Watch is just the remote view as well as remote control for the run: you can start the run from the Watch but also pause / continue and even stop & save the run.
The full circle goes like this:
- you tap the Pause button on the Watch
- which then informs WatchKit Extension through BT
- Extensions uses Darwin notification to ping the main iOS app
- iOS app stops the timer and uses Darwin notifications to inform Extension that run-state is now Paused
- Extension then calls
startAnimating:…
background image over Bluetooth on the Watch
Each step of the way something can break, stall, timeout etc. I originally used great MMWormhole library for extension / app communication but encountered strange problem which Conrad (original author) did not encounter. I needed a quick solution so I forked it and switched from using files to using shared NSUserDefaults.
This solved the locked iPhone problem, but there were numerous other issues along the way.
Must load quickly
WatchKit is very aggressive in kicking either your Watch app or Extension from memory if it’s stalling for any reason. So you need to start quickly, which means that awakeWithContext:
must be very, very light.
Don’t do any significant processing in it nor attempt any synchronous calls to the iOS app. If you must do something here, use GCD and write the rest of the app to handle async result. For Run 5k, I do this:
dispatch_async(dispatch_get_main_queue(), ^{
[self fetchAvailableTrackingOptions];
});
This method uses openParentApplication:reply:
to ask iOS app what tracking method are available (CoreLocation and/or CoreMotion) and then display appropriate start-run buttons when result arrives.
I originally had this as direct call which in random cases took too long and iOS runtime would forcefully kill the WatchKit Extension process since it looked like it was stuck on loading. And when that happens, Watch app stays in the loading-spinner state forever. (Oops, my bad.)
With dispatch_async
I still get my result eventually and while I’m waiting, WKInterfaceGroup
that contains those buttons is made invisible due to setHidden:YES
.
Put the hammer down in willActivate
willActivate
is your best friend. This is where you need to always refresh / reset your entire Watch app UI and do not depend on any local (to the WK Extension) cache you may be using. What I mean by this..
In Run 5k, the most important variable I have is runState
which is either Standby, Running or Paused. This defines what groups are ON or OFF, basically what’s displayed on the Watch. I reference this variable all the time, in all other methods I use. Thus I have a local property in the Extension that keeps the last known value received from the iOS app. This value can change at any time, while I’m interacting with the Watch app or while it’s in background or when it’s completely off. MMWormhole will send me the notification whenever this value changes using this:
[wormhole listenForMessageWithIdentifier:RDMMessageKeyRunState
listener:^(id messageObject) {
RTRunState state = (RTRunState)[(NSNumber *)messageObject integerValue];
[self processRunStateUpdate:state notified:YES];
}
];
This should make sure I always have the latest value, right? Not at all - there is no guarantee that you will actually be getting this notification. Even when you expect it to be the case, say you push new WKInterfaceController
to the stack. Your iOS knowledge tells you that previously active controller is still in memory and humming along in all aspects except UI updates. But in Watch OS that’s not the case and most likely when you swipe back to return to original controller it has ignored all the Darwin notifications MMWormhole may have sent up to that moment.
What Watch OS will also do is always call willActivate
. That method is called each and every time your controller will be shown and it’s your only chance to make sure all your data is valid.
Thus in willActivate
I always, unconditionally, re-read this value:
- (void)willActivate {
…
NSNumber *rs = [RDMCommon readObjectForKey:RDMMessageKeyRunState];
if (rs) {
RTRunState state = (RTRunState)[rs integerValue];
[self processRunStateUpdate:state notified:NO];
}
…
}
I also re-setup MMWormhole’s notification handlers here, every time. You should treat willActivate
as init
in iOS apps - that’s where you gather data, register for notifications etc. Not in awakeWithContext:
, but here.
Stomp the UI elements
This is where I also re-set all the labels, group’s hidden status and background colors/images and whatever I have in the storyboard UI. Since in WatchKit you can’t ask WKInterfaceLabel
what its text
value is, it’s common practice to keep the last known sent value as local variable in the Extension.
Do not depend much on this value outside of one continuous willActivate/didDeactivate cycle. Re-read from iOS app and set that local property then also call setText:
. I wasted many hours debugging why in some random cases I have disparity between the text I see on the Watch and the value I can clearly see it’s different (and correct) in the Extension’s property.
Don’t make the same mistake I did – in willActivate
always stomp over every Group, every Label, every Image - set them up like it’s the first run ever.
But don’t stomp too much
I don’t know if WatchKit has some throttling mechanism or not, where it will ignore you if you try to set some Label’s text too often. But I have definitely seen that if you continuously send the same data repeatedly, it will ignore your calls. Which is very unfortunate if one of your previous calls that was not ignored fails to reach the Watch and update the display.
Hence the notified:
parameter in two code samples above. That parameter is present in all my methods that update the Watch app UI. If it’s YES, then the value is coming from the MMWormhole notification and in that case I will compare the incoming value with my local cache and call setText:
only if different. If it’s notified:NO
then I unconditionally call setText:
since that’s coming from willActivate
.
Here’s one typical method:
- (void)updateCurrentPaceInfoNotified:(BOOL)notified {
NSString *unitName = [RDMCommon readObjectForKey:RDMMessageKeyCurrentUnitName];
if (!unitName) return;
if (notified && [unitName isEqualToString:self.unitName]) return;
self.unitName = unitName;
[self.currentPaceUnitLabel setText:[unitName uppercaseString]];
}
I hope this was helpful. I hope it will remain useful after June 14th.
Lastly one great UX tip for runners: go to Settings on the Watch, tap General then “Activate on wrist raise”, scroll down and choose “Last used app”. Thus when the watch screen goes dark and you tap it (or raise your wrist), it will show you Run 5k again instead of the watch face.