First of all, we will need a working OpenUI5 app with more than one view. For example, you can use the official OpenUI5 tutorial "Navigation and Routing". If you do, remember, that mocked data sources will not work offline, so you will need to disable the mock server by commenting out the line mockserver.start();
in index.html
.
To make the app work offline, we will convert it to a PWA now. This means, we are going to add a ServiceWorker, that will intecept server requests and use cached responses while offline (see my introduction to making SAPUI5 apps work offline for a comparison of different approaches). Additionally we will also need to add some changes to our index.html
and the manifest.json
to make browsers recognize our app as a PWA.
Now let's take a closer look at what our app actually loads from the server:
- index.html
- OpenUI5 libraries, CSS and fonts
- Possibly Images and other media
- Views, controllers and other component resources like manifest.json, component.js, translation files (*.properties)
- Possibly external libraries used in your app (e.g. charting libraries or other jQuery plugins)
- Remote data
We will need to make the ServiceWorker cache all of these resources, so they are available offline. Unfortunately, it's not that easy because the ServiceWorker can only intercept asynchronous requests while OpenUI5 uses synchronouse XHR requests in a lot of cases. Let's go through all the steps one-after-another.
Prerequisities
Progressive Web Apps use some of the latest browser technologies, so there are some important requirements for PWAs to run properly. However, if these requirements are not met, the PWA will gracefully degrade to a regular web app, which means it will still work, but only online. Here is what is needed for full functionality:
- HTTPS-Access to the web server, where the app is hosted (localhost will work for development too)
- OpenUI5 version 1.54 or later (1.52 should work too, but might have limitations)
- A modern browser like Chrome or Firefox. Try my PWA-Checker to find out, if a specific browser will work.
Creating a ServiceWorker
The simplest way to create a decent service worker is using the woderful workbox toolkit from Google. Just go through the getting-started-tutorial to add a ServiceWorker to your app and register it in the index.html
. The ServiceWorker file must be placed in the root folder (next to index.html
).
Now open the app in Google Chrome and check the console for workbox output.
Adding routes to the ServiceWorker
Now we need to tell our ServiceWorker, what to cache and which strategy to use. Place this code in your ServiceWorker after importScripts()
. If your app requires any images or other assets, just register another route.
// index.html and JavaScript files workbox.routing.registerRoute( new RegExp('(index\.html|.*\.js)'), // Fetch from the network, but fall back to cache workbox.strategies.networkFirst() ); // CSS, fonts, i18n workbox.routing.registerRoute( /(.*\.css|.*\.properties|.*\.woff2)/, // Use cache but update in the background ASAP workbox.strategies.staleWhileRevalidate({ // Use a custom cache name cacheName: 'asset-cache', }) );
Note, that we do not need routes to our views and controllers because we are going to pack them all into a preload-container in one of the next steps.
Just telling the ServiceWorker to cache required files is not enough. Since the ServiceWorker can only intercept asynchronous requests, we will need to make OpenUI5 load everything asynchronously. By default, UI5 uses synchronous XHR requests for almost everything.
Loading UI5 libraries asynchronously
Check your bootstrap and init scripts in index.html to make sure, OpenUI5 loads all libraries and the component asynchronously. Here is an example:
<script id="sap-ui-bootstrap" src="https://openui5.hana.ondemand.com/resources/sap-ui-core.js" data-sap-ui-libs="sap.m" data-sap-ui-theme="sap_belize" data-sap-ui-compatVersion="edge" data-sap-ui-preload="async"'> </script> <script> sap.ui.getCore().attachInit(function () { sap.ui.require([ "sap/m/Shell", "sap/ui/core/ComponentContainer" ], function (Shell, ComponentContainer) { new Shell({ app : new ComponentContainer({ height : "100%", component : sap.ui.component({ name : "your.Component", manifest : true, async: true }) }) }).placeAt("content"); }) }); </script>
Setting the bootstrap option data-sap-ui-preload
to "async" makes libraries load asynchronously, but requires all init scripts to be wrapped in sap.ui.getCore().attachInit()
. Also make sure to list all used libraries under data-sap-ui-libs
because the async preload will only work for them.
Also note the component options async
and manifest
. This results in an async request for component preloads (see below).
Caching your component: views, controllers, etc.
Despite the async-setting in the component, UI5 still uses sync XHR requests to load views and controllers. The only way around this, is to pack them all into one big file called Component-preload.js
. Luckily, SAP provides tools to simplify this task. The file can be either generated via SAP WebIDE or by running a special grunt task. We will use the latter:
- Install Node.js, npm and Grunt if you have not already done so.
- Create the file
package.json
with the corresponding content below in the folder with yourindex.html
. - Create the file
Gruntfile.js
with the corresponding contents below in the folder with yourindex.html
. - Run
npm install
from command line within the folder with your index.html. - Run
grunt
from command line within the folder with yourindex.html
.
Note: this method requires async-routing to be enabled in your app. It's best practice anyway, but better doublecheck your routing configuration in the manifest.json
and make sure the option sap.ui5 > routing > config > async
is set to true
.
package.json
This file tells npm to install grunt and the required tasks locally to your project.
{ "name": "ui5-pwa-tutorial", "version": "0.1.0", "devDependencies": { "grunt": "~1.0.0", "grunt-contrib-clean": "~1.1.0", "grunt-openui5": "~0.14.0" } }
Gruntfile.js
This file tells grunt to run two tasks (clean
and openui5_preload
) and sets parameters for each of them. The first one will remove the old preload file if it exists, while the latter will generate a new Component-preload.js
file which will contain our Component.js
, manifest.json
and all other .js
, .xml
and .properties
files. This preload-file gets loaded asynchronously and matches the general JS-route in our ServiceWorker, so it will now get cached just like all the library files.
module.exports = function(grunt) { grunt.initConfig({ dir:{ webapp: 'YourAppName', dist: 'dist' }, clean: { preload: ["Component-preload.js"] }, openui5_preload: { component: { options: { compress: false, resources: { cwd: "path/from/Gruntfile.js/to/folder/with/component.js", prefix: "your/component", src: [ "Component.js", "**/*.js", "**/*.fragment.xml", "**/*.view.xml", "**/*.properties", "manifest.json", "!Component-preload.js", "!test/**" ] }, dest: "path/from/Gruntfile.js/to/folder/with/component.js" }, components: "your/component" } } }); grunt.loadNpmTasks("grunt-contrib-clean"); grunt.loadNpmTasks("grunt-openui5"); grunt.registerTask('build', ['clean', 'openui5_preload']); grunt.registerTask('default', ['build']); };
Caching external libraries
If your app uses non-UI5 libraries, that are registered as modules (e.g. via jQuery.sap.registerModulePath()
, jQuery.sap.includeStyleSheet()
, etc.), you will need to preload them too. The easiest way to do this, is to include them in the component-preload.js
: just append every file to the JS object there using the component name (i.e. the string representing your lib in the first argument of sap.ui.define()
) for key and the JSON-escaped file contents for value - see this article for more details.
External libs, that are imported via <script>
tag in your index.html
can be cached by the Service Worker directly, so just add corresponding routes to it and you are done. However, according to UI5 bast practices, it is not recommended to import any custom scripts or CSS in the index.html. The main reason is, that they will not get loaded if the app is run from the Fiori Launchpad. Of course, our Service Worker would not get loaded in this case either, but being progressive, the app should still work - just without the special features of the ServiceWorker like offline capability.
So see for yourself, which way to import third-party libraries. In the end, both approaches can be made offline compatible.
Precache
Precaching allows the ServiceWorker to cache resources, that are not explicitly required for the first page of an app, thus enabling offline navigation to other pages. In the case of OpenUI5, however, there is only one page and all the assets are loaded right away thanks to the component preload, so there is no real need for precaching. You will only need it if you require some resources, that are not included in the Component-preload.js
. In this case, refer to the workbox documenation.
Going offline!
Now your app will continue to work if the client goes offline. To test it, you can either turn off your local development server or, if the app is hosted on a remote server, disconnect your client. Your app will still work - even if you close and reopen your browser window.
Caching data
If your app loads data asynchronously, you will probably need to store that data offline too. This can be quite tricky - especially for complex or large data sets. I will return to this topic in one of my future articles.
OpenUI5 does not offer any special tools to cache server data on the client, so the whole thing is not anyhow specific to UI5. Basically there are two different approaches you may choose from:
- Cache data requests, so the user will be able to see previously displayed data if the connection is lost.
- Create a local replica of the data and make the app work with it while syncing it regularly with the server.
Which one to choose, largely depends on your requirements. Obviously, data replication, is a lot more complicated as you will have to deal with sync errors, locking, etc. This approach also requires a lot of changes to the logic within the app. Caching requests is much simpler, but does not take the user completely offline as no actions altering the data will have an immediate effect.
The amount of required data should be take into account too: replicating large data sets is not only complicated and slow, it could also eat up too much space on the client. Remember, that IndexedDB is not really persistant storage - it may get purged by the OS at any time.
Making it a real mobile app
To make browsers recognize our app as a PWA, we still need a few final stitches. Add the following lines to your manifest.json right after the _version
property and don't forget to run grunt again to update Component-preload.js
. For more information about the app manifest, refer to Google's PWA checklist.
"short_name": "YourApp", "name": "Your App Title", "icons": [ { "src": "/images/app-icon-192.png", "type": "image/png", "sizes": "192x192" }, { "src": "/images/app-icon-512.png", "type": "image/png", "sizes": "512x512" } ], "start_url": "/path_to_your_app/", "scope": "/path_to_your_app/", "background_color": "#3367D6", "theme_color": "#3367D6", "display": "standalone",
Now users will get automatically promted to install your app on mobile devices. If they do, it will behave just like a native mobile app!