Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Making Hybrid Apps That Don't Suck

Sponsored · SiteGround - Reliable hosting with speed, security, and support you can count on. →
Avatar for David Muto David Muto
November 13, 2015

Making Hybrid Apps That Don't Suck

Want to make hybrid apps that don't suck? Here you'll find some tips and tricks, as well as some common pitfalls to avoid.

Avatar for David Muto

David Muto

November 13, 2015

Other Decks in Programming

Transcript

  1. 3

  2. SOME INITIAL IDEAS The web is the one and only

    source of truth Web for most things, but native when it counts Keep native code bases as small as possible 4
  3. DOING IT RIGHT Hybrid apps don’t have to suck Don’t

    need to be an expert in native framework Some mobile experience (or willingness to learn) necessary 6
  4. HANDLING MESSAGES (IOS) 8 static NSString *const kEventName = @"eventName";

    static NSString *const kWebMessageHandlerName = @"nativeApp"; @implementation HYBViewController { // implements WKScriptMessageHandler WKWebView *_webView; } ... ... - (void)loadView { [super loadView]; _webView = [[WKWebView alloc] initWithFrame:self.view.frame configuration:[self webViewConfiguration]]; [self.view addSubview:_webview]; } - (WKWebViewConfiguration *)webViewConfiguration { WKUserContentController *controller = [WKUserContentController new]; [controller addScriptMessageHandler:self name:kWebMessageHandlerName]; WKWebViewConfiguration *config = [WKWebViewConfiguration new]; config.userContentController = controller; return config; } @end
  5. HANDLING MESSAGES (IOS) 9 // Your WKScriptMessageHandler class... ... ...

    - (void)userContentController:(WKUserContentController *)userContentController didReceiveScriptMessage:(WKScriptMessage *)message { NSDictionary *options = @{}; if ([message.body isKindOfClass:[NSDictionary class]]) { options = message.body; } NSString *eventName = options[kEventName]; // do something with the event... }
  6. HANDLING MESSAGES (ANDROID) 10 public abstract class HybridFragment extends Fragment

    { private static final String JS_INTERFACE_NAME = "nativeApp"; ... ... @Override public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { ... ... webView = finder.find(R.id.webView); webView.addJavascriptInterface(this, JS_INTERFACE_NAME); webView.getSettings().setDomStorageEnabled(true); webView.getSettings().setDatabaseEnabled(true); if (savedInstanceState != null) { webView.restoreState(savedInstanceState); } return fragment; } @JavascriptInterface public void execute(String eventName, String jsonOptions) { EventIntent intent = new EventIntent(eventName, jsonOptions); // do something with the intent... } }
  7. CALLING FROM JS (COFFEE) 11 nativeExec = (event) -> eventName

    = event.data?.eventName return unless eventName options = event.data?.options window.nativeApp.execute(eventName, options)
  8. SET THE REQUEST VARIANT 14 module SelloController extend ActiveSupport::Concern USER_AGENT_PATTERN

    = /\AYOUR_PATTERN_HERE\z/.freeze SELLO_REQUEST_VARIANT = :sello.freeze included do prepend_before_action(:setup_sello_request) if sello_request? end def sello_request? request.user_agent =~ USER_AGENT_PATTERN end private def setup_sello_request request.variant = SELLO_REQUEST_VARIANT end end
  9. USING THE APP VERSION 15 module SelloController extend ActiveSupport::Concern ...

    ... def sello_version sello_user_agent.version end private ... ... def sello_user_agent @sello_user_agent ||= begin info = USER_AGENT_PATTERN.match(request.user_agent) OpenStruct.new(platform: info[1], version: Gem::Version.new(info[2])) end end end
  10. SUNSETTING OLD VERSIONS 16 module SelloController extend ActiveSupport::Concern MIN_VERSION =

    { "ios" => 0.4, "android" => 0.3 } ... ... private def setup_sello_request return head(:upgrade_required) unless valid_sello_version? request.variant = SELLO_REQUEST_VARIANT end def valid_sello_version? min_version = MIN_VERSION.fetch(sello_user_agent.platform) sello_user_agent.version >= Gem::Version.new(min_version) end ... ... end
  11. ANDROID 20 public class EventHub { ... ... public void

    execute(EventIntent intent, boolean broadcastGlobal) { if (broadcastGlobal) { executeGlobalIntent(intent); return; } executeLocalIntent(intent); } private void executeGlobalIntent(EventIntent intent) { if (intent.getAction().startsWith(GLOBAL_EVENT_PREFIX)) { getEventBus().post(intent); // https://github.com/greenrobot/EventBus } } private void executeLocalIntent(EventIntent intent) { for (EventReceiver receiver : receivers) { if (receiver.getIntentFilter().hasAction(intent.getAction())) { receiver.onReceiveEvent(intent); } } } }
  12. ANDROID 21 public class PagePushReceiver extends EventReceiver { private static

    final String ATTR_URL = "url"; private final Callback callback; ... ... @Override protected void handleEvent(String eventName, JSONObject params) throws JSONException { String url = params.getString(ATTR_URL); callback.pushURL(url); } @Override public IntentFilter getIntentFilter() { return new IntentFilter(Events.PAGE_PUSH); } public interface Callback { void pushURL(String url); } }
  13. IOS 22 - (void)executeEvent:(NSString *)eventName withOptions:(NSDictionary *)options broadcastGlobal:(BOOL)broadcastGlobal { if

    (broadcastGlobal && [eventName hasPrefix:kGlobalEventPrefix]) { [self executeGlobalEvent:eventName options:options]; return; } [self executeLocalEvent:eventName withOptions:options]; } - (void)executeGlobalEvent:(NSString *)eventName withOptions:(NSDictionary *)options { NSDictionary *note = @{ kEventName:eventName, kEventOptions:options ?: @{} }; [_notificationCenter postNotificationName:kGlobalEventNotificationName object: note]; } - (void)executeLocalEvent:(NSString *)eventName withOptions:(NSDictionary *)options { for (HYBEventReceiver *receiver in _receivers) { if ([receiver shouldReceiveEvent:eventName]) { [receiver receiveEvent:eventName withOptions:options ?: @{}]; } } }
  14. IOS 23 #import "HYBPagePushReceiver.h" @implementation HYBPagePushReceiver { __weak id<HYBPagePushReceiverDelegate> _delegate;

    } ... ... - (BOOL)shouldReceiveEvent:(NSString *)eventName { return [eventName isEqualToString:kHYBEventPagePush]; } - (void)receiveEvent:(NSString *)eventName withOptions:(NSDictionary *)options { NSString *url = options[kURL]; BOOL animated = [[options allKeys] containsObject:@"animated"] ? [options[@"animated"] boolValue] : YES; [_delegate eventReceiver:self didPushURL:[NSURL URLWithString:url] animated:animated]; [super receiveEvent:eventName withOptions:options]; } @end
  15. JS (COFFEE) 24 Events = NATIVE_EXEC: "com.domain.NativeExec" EventHub = executeNativeEvent:

    (eventName, options = {}) -> EventHub.executeEvent(Events.NATIVE_EXEC, eventName: eventName, options: options) executeEvent: (eventName, options = {}) -> event = document.createEvent("HTMLEvents") event.initEvent(eventName, false, false) event.data = options document.dispatchEvent(event) subscribe: (eventName, fn) -> document.addEventListener(eventName, fn) unsubscribe: (eventName, fn) -> document.removeEventListener(eventName, fn) nativeExec = (event) -> eventName = event.data?.eventName return unless eventName options = event.data?.options window.nativeApp.execute(eventName, options) EventHub.subscribe(Events.NATIVE_EXEC, nativeExecHandler)
  16. KEEPING IT SIMPLE Make base classes for UIViewControllers/Fragments Receivers can

    be added in subclasses, but most are common Use the controller/fragment as the delegate/callback handler 25
  17. PAGE LOADED EVENT Used to send details about the current

    page to the native device Includes title, bar button items, etc Happens on every page load 29
  18. HTML SETUP 30 <% content_for(:title) do %><%= @product.title %><% end

    %> <% content_for(:page_navigation) do %> <%= bar_button("text", "Edit", action: "push", options: { url: product_url(@product) }) %> <% end %> ... the rest of the view code <!-- RENDERED RESULT --> <head> <title>PRODUCT TITLE HERE</title> <meta name="app-navigation" type="nav-text" action="com.domain.PagePush" value="Edit" content="{ 'url': 'https://domain.com/products/22'}"> </head>
  19. FIRING THE EVENT 31 EventHub.executeNativeEvent Sello.Events.PAGE_LOADED, do(-> details = title:

    document.title navigationItems: [] for navNode in document.head.querySelectorAll("meta[name='app-navigation']") details.navigationItems.push type: navNode.getAttribute("type") value: navNode.getAttribute("value") action: navNode.getAttribute("action") options: JSON.parse(navNode.getAttribute("content")) details)
  20. HANDLING THE EVENT 32 - (void)receiveEvent:(NSString *)eventName withOptions:(NSDictionary *)options {

    [self processTitleFromOptions:options]; [self processGlobalEventsFromOptions:options]; [self processNavigationItemsFromOptions:options]; [self processPullToRefreshFromOptions:options]; [self.eventHub executeWebEvent:kHYBEventPageFinishedLoading withOptions:@{}]; [super receiveEvent:eventName withOptions:options]; [_delegate eventReceiverDidFinishLoading:self]; }
  21. TRIGGERING THE EVENT 34 Components.Modal = ... ... show: value:

    -> EventHub.executeNativeEvent Events.SHOW_MODAL, title: options.title, text: options.text, imageName: options.imageName, buttons: buttons # usage modal = Components.Modal(title: "Are you sure you want to delete this product?") modal.addSubmitButton("YES, DELETE PRODUCT", Components.Modal.ButtonTypes.DANGER, form) modal.addButton("CANCEL", Components.Modal.ButtonTypes.CANCEL) modal.show()
  22. HANDLING THE EVENT 35 public class ModalReceiver extends EventReceiver {

    ... ... @Override protected void handleEvent(String eventName, JSONObject params) throws JSONException { ModalConfig modalConfig = ModalConfig.parse(params); callback.showModalNotification(modalConfig); } }
  23. HANDLING PUSH EVENTS 37 #pragma mark - EventReceiver - (void)receiveEvent:(NSString

    *)eventName withOptions:(NSDictionary *)options { NSString *url = options[kURL]; BOOL animated = [[options allKeys] containsObject:kAnimated] ? [options[kAnimated] boolValue] : YES; [_delegate eventReceiver:self didPushURL:[NSURL URLWithString:url] animated:animated]; [super receiveEvent:eventName withOptions:options]; } #pragma mark - PushReceiverDelegate - (void)eventReceiver:(HYBPagePushReceiver *)receiver didPushURL:(NSURL *)url animated:(BOOL)animated { HYBViewController *controller = [[HYBViewController alloc] initWithURL:url]; [self.navigationController pushViewController:controller animated:animated]; }
  24. HANDLING POP EVENTS 38 #pragma mark - EventReceiver - (void)receiveEvent:(NSString

    *)eventName withOptions:(NSDictionary *)options { BOOL popToRoot = NO; if (options[kPopToRoot]) { popToRoot = [options[kPopToRoot] boolValue]; } [_delegate eventReceiver:self didPopViewToRoot:popToRoot]; [super receiveEvent:eventName withOptions:options]; } #pragma mark - PopReceiverDelegate - (void)eventReceiver:(HYBPagePopReceiver *)receiver didPopViewToRoot:(BOOL)popToRoot { popToRoot ? [self.navigationController popToRootViewControllerAnimated:YES] : [self.navigationController popViewControllerAnimated:YES]; }
  25. HANDLING LOAD EVENTS 39 // PageLoadReceiver.java @Override protected void handleEvent(String

    eventName, JSONObject params) throws JSONException { String url = params.getString(ATTR_URL); callback.loadURL(url); } // PageLoadReceiver.Callback public void loadURL(final String urlString) { runOnUiThread(new Runnable() { @Override public void run() { webView.loadURL(urlString) } }); }
  26. TRIGGERING NAVIGATION 40 <!-- shows the camera on the device

    --> <%= link_to "Camera", "", class: "link--native", data: { "native-event" => "showCamera" } %> <!-- pushes a new viewcontroller/activity on the stack --> <%= link_to "Some Page", page_path, class: "link--native" %> <!-- loads a different URL in this webview --> <%= link_to "Some Page", page_path, class: "link--native", data: { "native-load-event" => true }
  27. TRAPPING CLICKS 41 CLASSES = BASE: "link--native" ATTRS = NATIVE_EVENT:

    "data-native-event" NATIVE_LOAD_EVENT: "data-native-load-event" onClick = (event) -> event.preventDefault() link = $(event.target).thisOrClosest(CLASSES.BASE)[0] if nativeEvent = link.getAttribute(ATTRS.NATIVE_EVENT) EventHub.executeNativeEvent(nativeEvent) else if link.getAttribute(ATTRS.NATIVE_LOAD_EVENT) EventHub.executeNativeEvent(Events.PAGE_LOAD, url: link.href) else EventHub.executeNativeEvent(Events.PAGE_PUSH, url: link.href, animated: true) if link.href false
  28. CLIENT SIDE VALIDATION 44 class ValidationRules isValueSet: (val) -> return

    false unless val? return false if typeof val == "string" && val.length == 0 return true required: (val) -> return "can't be blank" unless @isValueSet(val) numeric: (val) -> return unless @isValueSet(val) return "must be a number" if isNaN(val) integer: (val) -> return unless @isValueSet(val) return numberResult if numberResult = @numeric(val) return "must be an integer" if (+val % 1) != 0 ... ...
  29. CLIENT SIDE VALIDATION 45 DATA_FORM_VALIDATOR = "data-form-validator" ATTR_VALIDATION_RULES = "data-validation-rules"

    class FormValidator constructor: (form, @validationRules = new ValidationRules()) -> @$form = $(form) @$fieldsWithValidations = @$form.find("[#{ATTR_VALIDATION_RULES}]") @errors = {} @_attachEventListeners() @$form.data(DATA_FORM_VALIDATOR, this) isValid: -> @validateField(field) for field in @$fieldsWithValidations Object.keys(@errors).length == 0 ... ...
  30. CLIENT SIDE VALIDATION 46 <%= form_tag signup_setup_path, novalidate: true, "data-form-handler"

    => "SignUp" do %> ... ... <%= email_field_tag "signup[email]", nil, class: "field__input field__input--text", placeholder: "[email protected]", data: { "validation-rules" => "required, email" } %> <%= telephone_field_tag "signup[phone]", nil, class: "field__input field__input--text", placeholder: "(123) 456-7890", data: { "validation-rules" => "required, phoneNumber" } %> <% end %>
  31. HANDLING FORM SUBMISSIONS 47 ATTRS = FORM_HANDLER: "data-form-handler" FormHandler =

    (form) -> self = {}; self.validator = new FormValidator($(form), new ValidationRules) form.addEventListener "submit", (event) -> event?.preventDefault() self.startSubmit() false Object.defineProperty self, "requestOptions", get: -> type: form.getAttribute("method") || "POST" url: form.getAttribute("action") data: self.formData headers: { "Accept": "application/json" } Object.defineProperty self, "shouldSubmit", get: -> # valid? should the form be allowed to submit? ... ...
  32. HANDLING FORM SUBMISSIONS 48 # continued... self.startSubmit = -> return

    unless self.shouldSubmit() self.finishSubmit() self.finishSubmit = (options) -> promise = $.ajax(self.requestOptions) promise.done (response, context, xhr) -> self.onDone(xhr) promise.fail (statusCode, context, xhr) -> self.onFail(xhr) self.onDone = (xhr) -> EventHub.executeNativeEvent Events.PAGE_PUSH, html: xhr.response url: form.getAttribute("action") self.onFail = (xhr) -> # parse response, log error ... self.validator.showErrors(json.errors) if json.errors self
  33. DON’T FORGET TO… Show banners when validation/submission fails Block the

    UI during submission Let the response control the action to be taken on success 49
  34. APP CRASHES These are important! Third party services exist for

    Android/iOS (HockeyApp, TestFlight) Only sent when they open your app again 51
  35. 52

  36. JAVASCRIPT ERRORS 53 originalConsoleLog = console.log logger = (args...) ->

    originalConsoleLog.apply(console, args) # log to the console EventHub.executeNativeEvent Events.LOG, messages: args # send to the device errorHandler = (message, url, lineNumber) -> # send to the device EventHub.executeNativeEvent Events.ERROR, message: message, url: url, lineNumber: lineNumber false console.log = logger window.onerror = errorHandler
  37. HANDLE THOSE ERRORS 54 public class ErrorReceiver extends EventReceiver {

    ... ... @Override protected void handleEvent(String eventName, JSONObject params) throws JSONException { if (eventName.equals(Events.LOG)) { logConsoleMessages(params); } else { logAndShowDialog(params); } } }