Removing platform specific code with webpack in your NativeScript application

The NativeScript framework automatically includes source files that are suffixed with the given platform name. This way the clutter and size of your built application is decreased and you do not distribute dead platform specific code that isn’t needed. But often there are case where is it much more simple to write your platform specific code in a single file and just surround it with if statements for the platform, rather then separating it into separate platform specific files. So what happens then? Let’s take an example – bellow you will see the natvigatingTo event handler for a page which has some platform specific code:

import { EventData } from "data/observable";
import { isIOS, isAndroid } from "platform";
import * as frame from "ui/frame";
import { Page } from "ui/page";
import { HelloWorldModel } from "./main-view-model";

export function navigatingTo(args: EventData) {
    let page = <Page>args.object;

    if (isIOS) {
        console.log("test");
        frame.topmost().ios.controller.view.window.backgroundColor = UIColor.colorWithRedGreenBlueAlpha(242 / 255, 242 / 255, 242 / 255, 1);
        UITableViewCell.appearance().selectionStyle = UITableViewCellSelectionStyle.None;
        UITableView.appearance().tableFooterView = UIView.alloc().initWithFrame(CGRectZero); // Hide empty cells
    }

    console.log(`We are running on ${isAndroid ? "ANDROID" : "IOS"}`);

    page.bindingContext = new HelloWorldModel();
}

If we run that though webpack with uglify (and you should always do that for the version of an app that you publish to the stores) we will get this (note that I reformatted code with new lines for readability):

function i(e) {
    var t = e.object;
    o.isIOS && (
        console.log("test"),
        a.topmost().ios.controller.view.window.backgroundColor = UIColor.colorWithRedGreenBlueAlpha(242 / 255, 242 / 255, 242 / 255, 1),
        UITableViewCell.appearance().selectionStyle = 0,
        UITableView.appearance().tableFooterView = UIView.alloc().initWithFrame(CGRectZero)
    ),
        console.log("We are running on " + (o.isAndroid ? "ANDROID" : "IOS")),
        t.bindingContext = new r.HelloWorldModel
}

As you see, although changed a bit, all the platform specific code is there. And no matter if you bundle for iOS or Android the code will be the same. Since you cannot change the platform during runtime some parts of that code are dead and will never execute. So how can we remove those? This can easily be achieved by helping webpack “understand” which parts of the code are dead so it can remove them when it optimizes the bundle.

First thing that we will do is instead of using the isAndroid / isIOS flags from the platform module, we will use flags that we will define in the global object (you will see why later on). In the app’s entry point before starting it we will extend the global object with the flags and their values:

import { isIOS, isAndroid } from "platform";
Object.assign(global, { isIOS, isAndroid });

Now in order to make TypeScript happy and so that we can directly use ‘global.isIOS’ and ‘global.isAndroid’ lets add an ‘app.d.ts’ in our app with the following content:

// Augment the NodeJS global type with our own extensions
declare namespace NodeJS {
    interface Global {
        // Add custom properties here.
        isIOS: boolean;
        isAndroid: boolean;
    }
}

This extends the definition of the global object that is provided with NativeScript’s core modules and we won’t have to cast global to any in order to make our code compile.

Now if we refactor our platform specific code to use the global object instead, the code will look like this:

export function navigatingTo(args: EventData) {
    let page = <Page>args.object;

    if (global.isIOS) {
        console.log("test");
        frame.topmost().ios.controller.view.window.backgroundColor = UIColor.colorWithRedGreenBlueAlpha(242 / 255, 242 / 255, 242 / 255, 1);
        UITableViewCell.appearance().selectionStyle = UITableViewCellSelectionStyle.None;
        UITableView.appearance().tableFooterView = UIView.alloc().initWithFrame(CGRectZero); // Hide empty cells
    }

    console.log(`We are running on ${global.isAndroid ? "ANDROID" : "IOS"}`);

    page.bindingContext = new HelloWorldModel();
}

If we run webpack with uglify now we wont see much of a difference. This is because webpack still does not know which code is dead. So lets help him understand that by changing the webpack.config.js and adding to the webpack.DefinePlugin the same global.isIOS and global.isAndroid flags. This is possible because during bundle time webpack already knows for which platform it is creating the bundle:

// Define useful constants like TNS_WEBPACK
new webpack.DefinePlugin({
    "global.TNS_WEBPACK": "true",
    "global.isAndroid": `${platform === "android"}`,
    "global.isIOS": `${platform === "ios"}`,
    "process": undefined,
}),

When the bundle is created webpack will replace the occurrence of those two flags with the respective value as defined in the config file. By doing this it will render parts of the code dead and the UglifyJS plugin can safely remove them from the final bundle.

One last thing to do – since we will not actually need the flags in the global object when the code is bundled, we can safely change the code in the app entry point:

if (!global.TNS_WEBPACK) {
    Object.assign(global, { isIOS, isAndroid });
}

This will ensure that both bundled and non-bundled builds are working as expected and we will not be polluting the global object unnecessary.

Now if we bundle and uglify the code, for iOS we will get (again code reformatted for readability):

function r(e) {
    var t = e.object;
    console.log("test"),
        o.topmost().ios.controller.view.window.backgroundColor = UIColor.colorWithRedGreenBlueAlpha(242 / 255, 242 / 255, 242 / 255, 1),
        UITableViewCell.appearance().selectionStyle = 0,
        UITableView.appearance().tableFooterView = UIView.alloc().initWithFrame(CGRectZero),
        console.log("We are running on IOS"),
        t.bindingContext = new a.HelloWorldModel
}

And for Android we will get:

function a(t) {
    var e = t.object;
    0;
    console.log("We are running on ANDROID");
    e.bindingContext = new o.HelloWorldModel
}

As you will notice the code for both platforms is greatly optimized now. First the conditional expression we had in the last console.log is changed so there is no unnecessary comparison. Furthermore for Android all the iOS specific code has been removed and replaced with a dummy 0.

5 replies
    • Peter Staev
      Peter Staev says:

      Performance gain greatly depends on how much such conditional statements you have. So it is specific to the code that the user has.

      Reply
  1. Emil Tabakov
    Emil Tabakov says:

    Yeah, I get this. Still, if you can share some data from one of your existing apps – that would be great. We would consider this to be the built-in behaviour if there is a significant improvement in the general case.

    Reply
    • Peter Staev
      Peter Staev says:

      In the app for which this was implemented the bundle.js size went down from 446KB to 443KB for android and from 445KB to 443KB for ios.

      In a synthetic test running 1 million times the above code, changed to assign to an export variable instead of logging to the console, w/o optimization it takes an average of 952ms per run. With the optimization it takes average of 916ms, so around 4%)

      Reply

Leave a Reply

Want to join the discussion?
Feel free to contribute!

Leave a Reply

Your email address will not be published. Required fields are marked *