How to handle Background App Refresh with HealthKit in React Native
Table of Contents
- Querying sample data with
- Background Delivery in HealthKit
- Background App Refresh
- Event Emitter for sending event to Javascript
- Native module cannot be null
- What is a bridge in React Native?
- Simulating Background Fetch event
- Multiple instances of EventEmitter ?
- Launch due to a background fetch event
- Headless JS
- Bridge is not set
I enjoy React Native when it comes to UI and hot reloading, but there are also many simple things that becomes really hard in React Native. Mostly it is about dealing with native capability and React bridge. This article is about my journey from pulling hairs into gradually understanding native module with Background fetch and HealthKit in React Native for iOS.
The app that I’m working with has HealthKit integration, that means querying for HealthKit data and accumulate them for other researches. One of the feature is to periodically send workout data in the background. There may be some libraries for this task, but I always prefer doing manually as the React Native wrapper should not be that heavy, and I have complete control over the execution. This article is about HealthKit, but the same idea should work for other background needs too.
There will also be a lot of React Native source code spelunking, it is a bit painful to read and debug, but in the end we learn a lot how things actually function. As the time of writing, I’m using react-native 0.57.5
Querying sample data with HKHealthStore
A bit about HealthKit, there are sample types such as workout and step count that we can easily query with HKHealthStore
. HKHealthStore
is the access point for all data managed by HealthKit
, and we can construct HKQuery
and HKSampleQuery
to fine tune the queries.
let predicate = HKQuery.predicateForSamples(
withStart: startDate,
end: endDate,
options: [.strictStartDate, .strictEndDate]
)let query = HKSampleQuery(
sampleType: HKObjectType.workoutType(),
predicate: predicate,
limit: 0,
sortDescriptors: nil,
resultsHandler: { query, samples, error in
guard error == nil else {
return
}
callback(samples)
})store.execute(query)
Background Delivery in HealthKit
Next, there is something called background delivery via the enableBackgroundDelivery
method
Call this method to register your app for background updates. HealthKit wakes your app whenever new samples of the specified type are saved to the store. Your app is called at most once per time period defined by the specified frequency.
We can enable background delivery with a certain frequency and set up observations
store.enableBackgroundDelivery(
for: HKObjectType.workoutType(),
frequency: .daily,
withCompletion: { succeeded, error in
guard error != nil && succeeded else {
return
} // Background delivery is enabled
})
Then observe with HKObserverQuery
let query = HKObserverQuery(
sampleType: HKObjectType.workoutType(),
predicate: nil,
updateHandler: { query, completionHandler, error in
defer {
completionHandler()
} guard error != nil else {
return
} // TODO
})store.execute(query)
Apps can also register to receive updates while in the background by calling the HealthKit store’s
enableBackgroundDelivery(for:frequency:withCompletion:)
method. This method registers your app for background notifications. HealthKit wakes your app whenever new samples of the specified type are saved to the store. Your app is called at most once per time period defined by the frequency you specified when registering.As soon as your app launches, HealthKit calls the update handler for any observer queries that match the newly saved data. If you plan on supporting background delivery, set up all your observer queries in your app delegate’s
application(_:didFinishLaunchingWithOptions:)
method. By setting up the queries inapplication(_:didFinishLaunchingWithOptions:)
, you ensure that the queries are instantiated and ready to use before HealthKit delivers the updates.
According to a thread on Stackoverflow, background delivery seems to work smoothly.
After a full day of testing (iOS 9.2) I can confirm that
HealthKit
background delivery DOES WORK in all of the following application states:background (in background and executing code),
suspended (in background but not executing code),
terminated (force-killed by the user or purged by the system)
Unfortunately, there is no info in documentation about minimum frequencies per data types, but my experience with Fitness types
was as follows:
Active Energy: hourly,
Cycling Distance: immediate,
Flights Climbed: immediate,
NikeFuel: immediate,
Steps: hourly,
Walking + Running Distance: hourly,
Workouts: immediate.
As you can see, we are notified when new data is saved into HealthKit store, but we don’t know what has changed with just the returned HKObserverQuery
. Background delivery may be cool, but for now I will go with traditional background fetch approach to query HealthKit store every now and then.
Background App Refresh
Background App Refresh, or simply Background fetch, has been around since iOS 7, which lets your app run periodically in the background so that it can update its content. Apps that update their content frequently, such as news apps or social media apps, can use this feature to ensure that their content is always up to date. To work with this, first go to Capabilities and enable Background fetch
Then in AppDelegate.m
, yay we working with React Native so there is AppDelegate.m
, check backgroundRefreshStatus
and setMinimumBackgroundFetchInterval
, which you may already guess, specifies the minimum amount of time that must elapse between background fetch operations.
UIBackgroundRefreshStatus status = [UIApplication.sharedApplication backgroundRefreshStatus];
if (status == UIBackgroundRefreshStatusAvailable) {
NSTimeInterval interval = [[Environment sharedInstance] backgroundFetchWakeUpInterval];
[UIApplication.sharedApplication setMinimumBackgroundFetchInterval:interval];
} else {
NSLog(@"Background fetch not available, status: %ld", status);
}
Then implement the callback. When an opportunity arises to download data, the system calls this method to give your app a chance to download any data it needs. Your implementation of this method should download the data, prepare that data for use, and call the block in the completionHandler
parameter.
- (void)application:(UIApplication *)application performFetchWithCompletionHandler:(void (^)(UIBackgroundFetchResult))completionHandler { [[BackgroundFetch sharedInstance] queryHealthKitWithCompletion:completionHandler];
}
I tend to have logic in Swift, and React Native compatible classes in Objective C, because there are many macros like RCT_EXPORT_METHOD
and RCT_EXPORT_MODULE
that would make our lives more miserable if we go with Swift.
BackgroundFetch
is a Swift class used to handle Background fetch. I like to encapsulate related logic into dedicated class so it is more readable. We can organise dependencies graph, but I use singleton
with shared for simplicity. And sometimes singleton makes working with React Native faster.
import Foundation/// Handle backgrounde fetch, query HealthKit data, and use HealthEmitter to send to js
@objc class BackgroundFetch: NSObject {
private static let shared = BackgroundFetch()
@objc let emitter = HealthEmitter()@objc class func sharedInstance() -> BackgroundFetch {
return shared
}@objc func queryHealthKit(completion: @escaping (UIBackgroundFetchResult) -> Void) {
guard HealthService.shared.isGranted else {
completion(.noData)
return
} typealias JSONArray = [[String: Any]] HealthService.shared.readWorkout(callback: { workouts in
let dictionary: [String: Any] = [
"workouts": workouts
] self.emitter.sendData(dictionary)
self.emitter.completionHandler = completion
})
})
}
}
If you have Array
, you can type cast to JSONArray
, here I have Dictionary
so it should be casted into NSDictionary
in sendData
in emitter
.
HealthEmitter
is an Objective C
class that inherits from RCTEventEmitter
. We can try making it in Swift, but since there are some C macros involved, let’s going with Objective C for quick development. And in the end, the Objective C class we write should not contain much logic, it is tended to be interopped with React Native classes.
Event Emitter for sending event to Javascript
Event Emitter is a way to send event from native to Javascript without being invoked directly. You may used Native Modules feature in React Native to call methods from Javascript to native, and get data via a callback. This Event Emitter works like a pub sub, that we add listener in Javascript, and native can trigger events. Here is our HealthEmitter
#import <React/RCTBridgeModule.h>
#import <React/RCTEventEmitter.h>@interface HealthEmitter: RCTEventEmitter <RCTBridgeModule>@property void (^completionHandler)(UIBackgroundFetchResult);/// workouts: NSArray, steps: NSArray
- (void)sendData:(NSDictionary *)json;@end
The reason we store completionHandler
is we want to ensure our work finishes before trigger Background App Refresh.
And here is our HealthEmitter
where it is responsible for sending data to Javascript.
#import "HealthEmitter.h"bool hasListeners = NO;@interface HealthEmitter()
@property NSDictionary *json;
@end@implementation HealthEmitter// Will be called when this module's first listener is added.
- (void)startObserving {
hasListeners = YES;
if (self.json != nil) {
[self sendEventWithName:@"onSendData" body:self.json];
}
}// Will be called when this module's last listener is removed, or on dealloc.
- (void)stopObserving {
hasListeners = NO;
}- (NSArray<NSString *> *)supportedEvents {
return @[@"onSendData"];
}// Not check for hasListeners for now
- (void)sendData:(NSDictionary *)json {
if (hasListeners) {
[self sendEventWithName:@"onSendData" body:json];
} else {
self.json = json;
}
}RCT_EXPORT_METHOD(finish) {
if (self.completionHandler != nil) {
self.completionHandler(UIBackgroundFetchResultNewData);
}
}@end
In theory, implementing EventEmitter
is simple. We just need to declare supportedEvents
and implement the sendData
method. Here we also implement finish
to manually trigger completionHandler
Native module cannot be null
If you get this error, it means that you forget to export the emitter. Go to our HealthEmitter.m
and add somewhere inside @implmentation
and @end
RCT_EXPORT_MODULE(HealthEmitter);
What is a bridge in React Native?
But when we run the app, we will hit an exception
2019-01-16 13:06:25.177414+0100 MyApp[56426:1094928] *** Terminating app due to uncaught exception 'NSInternalInconsistencyException', reason: 'Error when sending event: onSendBooks with body: (
). Bridge is not set. This is probably because you've explicitly synthesized the bridge in HealthEmitter, even though it's inherited from RCTEventEmitter.'
*** First throw call stack:
It complains that our HealthEmitter
is missing a bridge. Luckily for us, React Native is open source so we can dig into the source and see what’s happening. Go to RCTEventEmitter.m
and the method sendEventWithName
- (void)sendEventWithName:(NSString *)eventName body:(id)body
{
RCTAssert(_bridge != nil, @"Error when sending event: %@ with body: %@. "
"Bridge is not set. This is probably because you've "
"explicitly synthesized the bridge in %@, even though it's inherited "
"from RCTEventEmitter.", eventName, body, [self class]);if (RCT_DEBUG && ![[self supportedEvents] containsObject:eventName]) {
RCTLogError(@"`%@` is not a supported event type for %@. Supported events are: `%@`",
eventName, [self class], [[self supportedEvents] componentsJoinedByString:@"`, `"]);
}
if (_listenerCount > 0) {
[_bridge enqueueJSCall:@"RCTDeviceEventEmitter"
method:@"emit"
args:body ? @[eventName, body] : @[eventName]
completion:NULL];
} else {
RCTLogWarn(@"Sending `%@` with no listeners registered.", eventName);
}
}
The exception warning is from the insideRCTAssert
. Our HealthEmitter
has this signature.
@interface HealthEmitter: RCTEventEmitter <RCTBridgeModule>
EventEmitter
is a native module, and it needs to conform to RCTBridgeModule
protocol, which is used to provides the interface needed to register a bridge module.
/*** Async batched bridge used to communicate with the JavaScript application.*/@interface RCTBridge : NSObject <RCTInvalidating>
And when we use the macro RCT_EXPORT_MODULE
, this actually uses RCTRegisterModule
under the hood to register module, and it is said that with this macro in our class implementation to automatically register our module with the bridge when it loads.
RCTBridge
instance loads our bundled js code and execute it inside JavascriptCore
framework in iOS.
If we go back to our AppDelegate.m
to see how RCTRootView
gets initiated, we can see that it creates a bridge under the hood.
jsCodeLocation = [[RCTBundleURLProvider sharedSettings] jsBundleURLForBundleRoot:@"index" fallbackResource:nil];
RCTRootView *rootView = [[RCTRootView alloc] initWithBundleURL:jsCodeLocation
moduleName:@"MyApp"
initialProperties:nil
launchOptions:launchOptions];
Go to RCTRootView.m
and see how the bridge is constructed. As you can see, this method is used when the app uses only 1
RCTRootView
. If we plan to use more RCTRootView
, for example, when we want to have some React Native views inside our existing native app, then we need to create more RCTBridge
for each view.
- (instancetype)initWithBundleURL:(NSURL *)bundleURL
moduleName:(NSString *)moduleName
initialProperties:(NSDictionary *)initialProperties
launchOptions:(NSDictionary *)launchOptions
{
RCTBridge *bridge = [[RCTBridge alloc] initWithBundleURL:bundleURL
moduleProvider:nil
launchOptions:launchOptions];return [self initWithBridge:bridge moduleName:moduleName initialProperties:initialProperties];
}
If you wish to have dependency injection with your own bridge, it’s not that hard to create one.
The bridge initializes any registered RCTBridgeModules automatically
id<RCTBridgeDelegate> moduleInitialiser = [[classThatImplementsRCTBridgeDelegate alloc] init];RCTBridge *bridge = [[RCTBridge alloc] initWithDelegate:moduleInitialiser launchOptions:nil];RCTRootView *rootView = [[RCTRootView alloc]
initWithBridge:bridge
moduleName:kModuleName
initialProperties:nil];
Correct me if I’m wrong, here we ‘re going to reuse that single bridge for our emitter too
[BackgroundFetch sharedInstance].emitter.bridge = rootView.bridge;
Simulating Background Fetch event
Now the exception Bridge is not set
has gone. Let’s test our Background App Refresh when the app is running
, by going to Xcode -> Debug -> Simulate Background Fetch
Before testing, we need to consume our native emitter inside Javascript. Create a class called EmitterHandler.js
, then call EmitterHandler.subscribe
to subscribe to EventEmitter
events.
// @flowimport { NativeEventEmitter, NativeModules } from 'react-native'const HealthEmitter = NativeModules.HealthEmitter
const eventEmitter = new NativeEventEmitter(HealthEmitter)class EmitterHandler {
subscription: any subscribe() { this.subscription = eventEmitter.addListener('onSendData', this.onSendData)
console.log('subscribeEmitter', this.subscription)
} unsubscribe() {
this.subscription.remove()
} onSendData = (event: any) => {
console.log('HealthEmitterHandler.onSendData', event)
const { workouts } = event
this.sendToBackEnd(workouts)
} sendToBackEnd = async (workouts) => {
// send to backend
HealthEmitter.finish()
}
}const emitterHandler = new HealthEmitterHandler()
export default emitterHandler
Note that eventEmitter
is of type NativeEventEmitter
and is used to call addListener
, but we need to call finish
on our HealthEmitter
, which we exposed with RCT_EXPORT_METHOD(finish)
.
We can observe, for example, in App.js
// @flowimport React from 'react'
import { createAppContainer } from 'react-navigation'
import makeRootNavigator from './src/screens/root/RootNavigator'
import EmitterHandler from './src/EmitterHandler'const RootNavigator = makeRootNavigator({})
const AppContainer = createAppContainer(RootNavigator)type Props = {}export default class App extends React.Component<Props> {
componentDidMount() {
EmitterHandler.subscribe()
} render() {
return <AppContainer />
}
}
We can test simulating background fetch event in Simulator. Whenever an event is triggered, we can see that the method application:performFetchWithCompletionHandler
is called, then onSendData
is triggered in Javascript side.
We may hit Access-Control-Allow-Origin
issue. The same-origin policy is a critical security mechanism that restricts how a document or script loaded from one origin can interact with a resource from another origin. A cross-origin request occurs when one domain (for example http://foo.com/) requests a resource from a separate domain (for example http://bar.com/).
Access to fetch at 'http://192.168.0.13:8081/index.delta?platform=ios&dev=true&minify=false' from origin 'http://localhost:8081' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource. If an opaque response serves your needs, set the request's mode to 'no-cors' to fetch the resource with CORS disabled.
If you use ApolloClient like me, then you can customize fetchOptions
const client = new ApolloClient({
uri: 'https://www.myapp.com/api/',
request: async (operation) => {
//
},
onError: (error) => {
//
},
fetchOptions: {
mode: 'no-cors',
}
})
Multiple instances of EventEmitter ?
Go back to our HealthEmitter
, put breakpoints in startObserving
and sendData
, and surprisingly we see different instances. You can check that by looking at the address number in Xcode debugger.
I have no idea, that’s why I declare bool hasListeners = NO;
as a global variable, not inside HealthEmitter
. This workaround should suffice for now.
A bit about starObserving
and stopObserving
These methods will be called when the first observer is added and when the last observer is removed (or when dealloc is called), respectively. These should be overridden in your subclass in order to start/stop sending events.
Observation works when there is listeners, you can verify this by putting breakpoint into RCTEventEmitter.m
RCT_EXPORT_METHOD(addListener:(NSString *)eventName)
{
if (RCT_DEBUG && ![[self supportedEvents] containsObject:eventName]) {
RCTLogError(@"`%@` is not a supported event type for %@. Supported events are: `%@`",
eventName, [self class], [[self supportedEvents] componentsJoinedByString:@"`, `"]);
}
_listenerCount++;
if (_listenerCount == 1) {
[self startObserving];
}
}
Launch due to a background fetch event
Triggering background fetch event while app is running is not enough. In practice our apps should be wake up from terminated state due to background fetch event. To simulate that, we need to tweak Options
in our scheme. Check Launch due to a background fetch event
, now when we hit Run
our app is run but there should be no UI .
This testing does not seem to be feasible in simulator, so it’s best to test in device.
In AppDelegate.m
, application:didFinishLaunchingWithOptions:
and application:performFetchWithCompletionHandler
will be called in order.
But here is where the weird things happen 😱 I don’t know if it relates to React Native cache or that the React console not display correct, but sometimes it does not display Running application
message that it used to show for normal run.
Running application MyApp ({
initialProps = {
};
rootTag = 11;
})
blob:http://192.168.…-d8e43b30f658:26469 Running application "MyApp" with appParams: {"rootTag":11,"initialProps":{}}. __DEV__ === true, development-level warning are ON, performance optimizations are OFF
For a while I was wondering if RCTBundleURLProvider
can’t work in event like waking up due to background fetch, because in this case there is no UI yet, and that UIApplication
state is not active.
There is one thing that happens more frequently, not all the times, but it does happen. That is sometimes the subscribe
method in EmitterHandler
is called after sendData
is triggered in HealthEmitter
. This cause Sending onSendData with no listeners registered
warning as there is no listeners at the moment.
@interface HealthEmitter()
@property NSDictionary *json;
@end
A quick workaround is to store data inside HealthEmitter
, then check that we have both data and hasListeners
set to true.
// Not check for hasListeners for now
- (void)sendData:(NSDictionary *)json {
if (hasListeners) {
[self sendEventWithName:@"onSendData" body:json];
} else {
self.json = json;
}
}
Headless JS
There is also something called Headless, which is a way to run tasks in JavaScript while your app is in the background. It can be used, for example, to sync fresh data, handle push notifications, or play music. The url for that article has headless-js-android
so it is for Android at the moment.
Bridge is not set
We are really close to a working solution, but when running there is the old bridge error again. This is a bit different
Bridge is not set. This is probably because you’ve explicitly synthesized the bridge in HealthEmitter, even though it’s inherited from RCTEventEmitter.’
To solve this and to avoid the multiple HealthEmitter
instances problem, let use singleton to ensure there is only 1 instance around.
@implementation HealthEmitter+ (id)allocWithZone:(NSZone *)zone {
static HealthEmitter *sharedInstance = nil;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
sharedInstance = [super allocWithZone:zone];
});
return sharedInstance;
}@end
A zone is like a region of memory where objects are allocated. This class method is used to returns a new instance of the receiving class.
Now all are compiled, and our periodic background fetch should work as expected. Thanks for following such a long post and journey, hope you learn something useful. The key is to carefully test and be willing to dig into code to find the problems.
If you like this post, consider visiting my other articles and apps 🔥