Sencha Touch Performance Tips and Tricks
How we worked with Sencha Touch 2 - some performance hints and other tips
Our team has been developing with Sencha Touch 2 since its pre-release days late 2011. We have spent a lot of time tweaking components and trying different techniques in order to speed up responsiveness - in an attempt to make it feel as native as possible. With the release of IOS6 on iPhone5 our latest version in development is pretty damn quick.
Why Sencha Touch 2?
In my opinion Sencha Touch 2 is the easy winner (when compared to jQuery Mobile).
Initially spending a few weeks investigating and prototyping with jQuery Mobile it was found to be no where near as production ready as Sencha Touch 2. If you are simply looking to mobile-ify a website, jQuery mobile may be a better alternative due to its quicker learning curve. However, if you are looking to create a more native look and feel web app - I wouldn't recommend anything but Sencha Touch 2 (for now).
The initial downside with the Sencha Touch 2 framework, is it's learning curve - it will take a little while getting used to, but its well worth it. The coding practices it promotes within JavaScript are really promising (hopefully a good step in the right direction in bringing better/well architected structure to the JavaScript language). It took our team about a month or so to really understand the frameworks core concepts and components.
The other great point to make is it's 'just JavaScript', you have full access to the source code, and with the use of the ExtJS 'Override' functionality you can really do just about anything to the core Sencha Touch 2 library, without modifying the library source code.
However, if you're not interested in the challenge and learning something new, maybe Sencha Touch 2 isn't for you..
What sort of applications should you use Sencha Touch 2 for?
- Content based apps
- News listings apps
- Daily deal apps
What sort of applications maybe not to use Sencha Touch 2 for?
- 3D games
- Any other sort of game
- Applications that require high processing power (and potentially a lot of high resolution imagery).
*Not to say the above can't work.. But your are setting yourself up for a challenge.
Final point before we get started
I'm going to assume you have a good understanding of the key concepts of Sencha Touch 2. If you don't I highly suggest having a look at the great documentation at sencha.com:
All docs:
http://docs.sencha.com/touch/2-0/.
Kitchecn sink demo:
http://docs.sencha.com/touch/2-0/touch-build/examples/production/kitchensink/index.html
This article is aimed at developers who have been using Sencha Touch 2 and want to find out some of the approaches we made to improve performance within our upcoming version 2 of our application.
Disclaimer:
A lot of what I mention can be found within the Sencha Touch 2 docs, or by looking directly into the codebase of Sencha Touch 2.
All docs:
http://docs.sencha.com/touch/2-0/.
Kitchecn sink demo:
http://docs.sencha.com/touch/2-0/touch-build/examples/production/kitchensink/index.html
This article is aimed at developers who have been using Sencha Touch 2 and want to find out some of the approaches we made to improve performance within our upcoming version 2 of our application.
Disclaimer:
A lot of what I mention can be found within the Sencha Touch 2 docs, or by looking directly into the codebase of Sencha Touch 2.
Improving performance with views/panels
You basically have 2 approaches with view
Load all of your views/panels into the viewport at application startup:
Advantages
- Your views/panels are in memory and ready to display when triggered by the users input.
- If you have only a few views (around 5-10) you can most likely get away with this approach without many performance issues.
Disadvantages
- As soon as your application grows, you will start suffering massive performance issues, slow button tap responsiveness, slow list scrolling, memory warnings (in Xcode) etc..
- A much slower application bootup time.
Create and load your views/panels into the viewport on demand:
This is the approach we have now adopted (after we initially implemented option 1 above in our initial release version). We are now essentially creating 2 views within our viewport on application boot up (the loading screen, and the home screen). Every other view is created on demand.
Advantages
- You are only loading the views/panels that are required into memory.
- Faster button responsiveness.
- Less in memory which means a more responsive application.
- No more memory warnings from XCode.
Disadvantages
- A little harder to implement.
- It will take an extra bit of time to create and render the view within the app when triggered by the user (simple tests show anywhere from 40ms to about 300ms).
However, you are probably thinking after the user has viewed every view/panel within your application, your application will end up storing all of the views in memory anyway.. Wrong.. Lets take this approach a step further (you can see the below approach used in the kitchen sink example on http://docs.sencha.com/touch/2-0/touch-build/examples/production/kitchensink/index.html).
What you want to do is only keep your most popular views in memory at any one time.
The below code shows how you can manage all of your created views/panels within your viewport, and only keep the most used 5 views in memory, the rest get destroyed, meaning your application is using less memory.
/** * app/views/viewport.js */ Ext.define('Demo.view.Viewport', { extend: 'Ext.Container', xtype: 'demo-viewport', requires: [ // Home Loading 'Demo.view.home.Loading', ], /** * Configuration * @property {object} */ config: { layout: { type: 'card', animation: { type: 'slide', direction: 'left', duration: 250 } }, fullscreen: true, items: [ // Home Loading {xtype: 'home-loading'}, ] } }); /** * app/controller/Base.js * We use a Base Controller to do the logic that is used throughout * the application (eg: back button, memory handling of views etc..) */ Ext.define('Demo.controller.Base', { extend: 'Ext.app.Controller', /** * Configuration * @property {object} */ config: { /** * Refs * @property {object} */ refs: { /** * @ignore * Panels */ viewport: 'demo-viewport', /** * @ignore * Buttons */ back: 'button[action=back]' }, previousPanels: [], /** * Controls * @property {object} */ control: { /** * Generic back to button (if you want one) */ back: { tap: function(el, e){ var viewport = this.getViewport(); var activeItem = viewport.getActiveItem(); var view = activeItem.oldItem; // Unset oldItem as its now been used activeItem.oldItem = null; // Set back animation viewport.getLayout().setAnimation({type: 'slide', direction: 'right'}); // Go back to oldItem viewport.setActiveItem(view); if( e.event ) { // Stop events from bubbling e.stopEvent(); } } } } }, launch: function() { var viewport = this.getViewport(); var baseController = this; viewport.addBeforeListener( 'activeitemchange', function( card, newItem, oldItem ){ // Fix for overlapping panels (explanation later) // allow the panel to change if (oldItem.aboutToBeVisible === true) { newItem.aboutToBeVisible = false; newItem.hide(); return false; } // flag that the new item is now in the process of being // transitioned to newItem.aboutToBeVisible = true; // For the "Back" functionality // Only set oldItem if its null, // If its not null it means that there is an already set oldItem that // should be used instead. if( null == newItem.oldItem ){ newItem.oldItem = oldItem; } } ); viewport.addAfterListener( 'activeitemchange', function( card, newItem, oldItem ){ var index, panels = baseController.getPreviousPanels(), newId = newItem.getItemId(); // The below code ensures that only 5 view panels are kept in memory // This is to speed up the application for( index in panels ){ // If panel already exists in array, remove it if (panels[index].getItemId() == newId) { panels.splice(index, 1); break; } } // Add new item to top of array panels.splice(0, 0, newItem); // remove one panel from the back of the array if( panels.length > 5 ) { var panel = panels.pop(); panel.destroy(); } // The new item is no longer transitioning, so flag it as such newItem.aboutToBeVisible = false; } ); } }); /** * app/controller/Home.js * Example of a controller in your application * */ Ext.define('Demo.controller.Home', { extend: 'Ext.app.Controller', requires: [ 'Demo.view.home.Index' ], /** * Configuration * @property {object} */ config: { /** * Refs * @property {object} */ refs: { viewport: 'demo-viewport',
/**
* Defining all of your views to autoCreate: true will ensure
* Sencha will create them if the dont already exist in memory
*/
homeIndex: { selector: 'home-index', xtype: 'home-index', autoCreate: true } }, /** * Controls * @property {object} */ control: { /** * Home index */ homeIndex: { hide: function(){ }, show: function(){ } } } } });
The above example also shows a generic back button for your application, simple add
action: 'back'
on your button within your view and the rest is taken care for you. However, if you are using the routes in Sencha Touch 2 you will not need this (as the framework already will take care of this for you).
Improving list scrolling and performance
Lists are supposed to scroll smoothly and fast - if they don't, your user will undoubtedly know your application is not native. Below are some points to take into consideration.
Gradients and other CSS3 styles
Avoid gradients/box shadows as much as you can within lists and list items. Our application initially used linear gradients for each list item, scrolling was a little jerky. Replacing this with a flat background color really improved the responsiveness of the scrolling (especially in the iPhone4).
[Screens coming soon.]
High Resolution images in lists
Try to use low resolution images in list items (if your list has images), this will help to increase scrolling speed within your lists. I can identify 2 reasons for this:
- Low resolution images are smaller in file size:
- Requires less bandwidth to download each image.
- Smaller amount of data stored in local memory cache.
- Lower quality images animate faster (less processor intense).
- You can look into staggering the loading of images (so as to make less concurrent web requests). We do this, if you would like a code sample contact me.
Amount of items in list
This is still an unresolved problem with our application. Sencha Touch 2 seems to handle about 30-40 items in a list nicely (each list item has an image). Any more than this the phone begins to struggle. I would suggest looking at the pagination (infinite scroll plugins that are available). Due to our underlying API web service we cannot implement this easily at the moment.
Improving panel transitions and responsiveness
Ensuring when a user taps/clicks a component within your application you want an immediate,or as close to immediate response as possible. The more complicated your application becomes, the more you tend to try to accomplish within the 'show' events of a panel. For examples you may be doing one or more of the following:
- Hiding/showing components
- Populating form data
- Populating dynamic data into panel.
- etc..
If your view/panel is doing this on the 'show' event it can slow down the response/transition of that panel when the user triggers the event. An approach our application is using, is keeping the show events as light weight as possible (usually doing nothing), and creating a delayed function to actually do the complex logic.
This approach allows your application to transition immediately to the next panel, giving a more native feel, and then doing the processing. See below:
/** * app/controller/Portfolio.js * */ Ext.define('Demo.controller.Portfolio', { extend: 'Ext.app.Controller', requires: [ 'Demo.view.portfolio.Index' ], /** * Configuration */ config: { /** * Refs */ refs: { viewport: 'demo-viewport', portfolioIndex: { selector: 'portfolio-index', xtype: 'portfolio-index', autoCreate: true } }, /** * Controls */ control: { /** * Product index events */ index: { show: function(){ var me = this; Ext.create('Ext.util.DelayedTask', function () { me.getIndex().fireEvent('showDelay'); }).delay(500); // 500 should be set to more than your card layout transition time }, showDelay: function(){ /** * More processor intense processing can be done * here, as panel will have been in view */ } } } } });
Screen overlay bug
Sometimes Sencha Touch 2 cannot keep up with user input. I have noticed this happens on larger more complex applications. An example looks similar to below:
I have only noticed this when using a card layout. Basically, this can happen if you click on multiple list items within a single list which both try to set different views as the active item within the card layout. Sencha attempts to show both views and can sometimes result in multiple panels being displayed over one another.
Another way to duplicate is (you may need 2 hands for this), if you have a view/panel with a button (potentially in the header) and a list item. If you repeatably tap both the list item and the button you will most likely be able to trigger this view/panel overlay bug.
Within the Base Controller we listen to the before and after events of the 'activeitemchange' for the viewports card layout. We manually track the when an item has being set as 'active item' within the card layout, by setting:
newItem.aboutToBeVisible = true;
Then once the after listener has been triggered we set:
newItem.aboutToBeVisible = false;
If 2 items are triggered in the before event as event, the secondary one hides itself and returns false.
[Screens coming soon.]
I have only noticed this when using a card layout. Basically, this can happen if you click on multiple list items within a single list which both try to set different views as the active item within the card layout. Sencha attempts to show both views and can sometimes result in multiple panels being displayed over one another.
[Screens coming soon.]
Another way to duplicate is (you may need 2 hands for this), if you have a view/panel with a button (potentially in the header) and a list item. If you repeatably tap both the list item and the button you will most likely be able to trigger this view/panel overlay bug.
Approaches we have used to resolve.
BaseController (preferred):Within the Base Controller we listen to the before and after events of the 'activeitemchange' for the viewports card layout. We manually track the when an item has being set as 'active item' within the card layout, by setting:
newItem.aboutToBeVisible = true;
Then once the after listener has been triggered we set:
newItem.aboutToBeVisible = false;
If 2 items are triggered in the before event as event, the secondary one hides itself and returns false.
/** * app/controller/Base.js * */ Ext.define('Demo.controller.Base', { extend: 'Ext.app.Controller', launch: function() { var viewport = this.getViewport(); var baseController = this;
// Listen to the before event for setting activeItem in viewport. viewport.addBeforeListener( 'activeitemchange', function( card, newItem, oldItem ){ // Fix for overlapping panels
// If a panel is already in process of becoming visible,
// hide this new panel and return false to stop the transition. if (oldItem.aboutToBeVisible === true) { newItem.aboutToBeVisible = false; newItem.hide(); return false; } // Set that the new item is now in the process of being transitioned to newItem.aboutToBeVisible = true; // If not null set oldItem so we can use the back button if( null == newItem.oldItem ){ newItem.oldItem = oldItem; } } );
// Listen to the before event for setting activeItem in viewport. viewport.addAfterListener( 'activeitemchange', function( card, newItem, oldItem ){ var index, panels = baseController.getPreviousPanels(), newId = newItem.getItemId(); // The below code ensures that only 5 view panels are kept in memory // This is to speed up the application for( index in panels ){ // If panel already exists in array, remove it if (panels[index].getItemId() == newId) { panels.splice(index, 1); break; } } // Add new item to top of array panels.splice(0, 0, newItem); // remove one panel from the back of the array if( panels.length > 5 ) { var panel = panels.pop(); panel.destroy(); } // The new item is no longer transitioning, so flag it as such newItem.aboutToBeVisible = false; } ); } });
Buttons:
Another solution to a similar issue in 2.0 of Sencha is tapping a button multiple times can sometimes trigger multiple tap events (Im not sure if this happens in the 2.0.1 yet). This can slow your application if your application is listening for the tap event and doing a complex task. We have implemented a timer on each button (by overriding the button component). What this new functionality does is listen for when a button is tapped, uses a variable to disable any future tap events from bubbling up to the application for the next 2 seconds.
/** * app/override/Button.js */ Ext.define('Demo.override.Button', { override: 'Ext.Button', lastTapped: 0, /** * Update button icon to support masking * @return {String} icon */ updateIcon: function(icon) { var me = this, element = me.iconElement; if (icon) { me.showIconElement(); if( this.getIconMask() ){ element.setStyle('-webkit-mask-image', icon ? 'url(' + icon + ')' : ''); } else { element.setStyle('background-image', icon ? 'url(' + icon + ')' : ''); } me.refreshIconAlign(); me.refreshIconMask(); } else { me.hideIconElement(); me.setIconAlign(false); } }, /** * On slower devices Sencha registers multiple button taps * which ends up causing strange behaviour. Ensure only a single * button tap every 2seconds per button */ onTap: function(e) { var now = Date.now(); if (this.getDisabled()) { return false; } if ( (now - this.lastTapped) > 2000 ) { this.lastTapped = now; this.fireAction('tap', [this, e], 'doTap'); } } });
Native components
Although our application is bundled as an IOS application and listed on the apple store (and hopefully soon Android) we took a specific approach so as to not tie ourselves too heavily to native functionality.
Essentially what this means is our application is coded to work entirely within the browser first. All functionality must work in a browser. We then add the specifics eg:
- Passbook functionality (on IOS6) - only visible on IOS6 devices.
- Local notifications (eg: setting up calendar reminders) - Only available on the iPhone - to any other user (web browser) this functionality is not available/hidden.
- Email composer - only available if within Cordova application.
With this approach you are not locking yourself into a specific platform, more so supporting the web browser first, then progressively adding more support to devices/platforms that support it.
Why? I'm a firm believer that native functionality (that we use Cordova to get access to) will slowly become available from directly within the browser. As this happens you can start removing Cordova plugins that provide connection to the native libraries. Yes its a bit of a gamble, but it cannot hurt to architect your application in this way since you have already chosen a HTML5/JavaScript pathway.
Compile your JavaScript
Always compile your javascript into app-all.js.
Our application has multiple environments, below is an example of 2, development and production index files.
- index-dev.html
<html> <head> <link href="css/application.css" rel="stylesheet" type="text/css"></link> </head> <body> <script> // Define environment var settings = { environment: 'development' }; </script> <script src="app/ConfigurationDev.js"></script> <script src="lib/sencha/sencha-touch-debug.js"></script> <script src="app.js"></script> </body> </html>
- index.html
This is our production environment.
Includes the cordova plugins and the compiled Sencha Touch 2 files:
<head> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no;" /> <meta name="apple-mobile-web-app-capable" content="yes" /> <meta http-equiv="Content-type" content="text/html; charset=utf-8"> <link href="css/application.css" rel="stylesheet" type="text/css" /> </head> <body> <script> console.log = function(){}; </script> <!-- Load up cordova plugins --> <script type="text/javascript" src="lib/cordova/cordova-1.8.1.js"></script> <script type="text/javascript" src="lib/cordova/NotificationEx.js"></script> <script type="text/javascript" src="lib/cordova/LocalNotification.js"></script> <script type="text/javascript" src="lib/cordova/PushNotification.js"></script> <script type="text/javascript" src="lib/cordova/PixAuth.js"></script> <script type="text/javascript" src="lib/cordova/ChildBrowser.js"></script> <script type="text/javascript" src="lib/cordova/SMSComposer.js"></script> <script type="text/javascript" src="lib/cordova/EmailComposer.js"></script> <script type="text/javascript" src="app-all.js"></script> </body> </html>
Configuration setup
Managing configuration is a core component within any application. Below is a sample of how our configuration is setup. We use 4 environments as below:
- Production (default)
- Staging
- Emulator (Used for automated user testing)
- Development (included only within the index-dev.html file)
There are many ways configuration can be implemented, this one for now works well for us.
/** * app/Configuration.js */ var settings = settings || { environment: 'production' }; /** * Production environment */ settings.production = { 'version': '2.0', 'api.user': '', 'api.key': '', 'api.timeout': 30000, 'logger.writer.db': true, 'logger.writer.alert': false, 'logger.writer.console': false, 'logger.writer.remote': true, // Cache 'cache.user.login': 3600, 'cache.configuration.list': 86400, // Our system has 2 performance profiles // High profile - for newer phones (iPhone 4S & 5) 'performance.high.base.transition.time': 250, 'performance.high.scrolltotop.time': 500, 'performance.high.list.acceleration': 15, 'performance.high.list.friction': 0.3, // Low profile - for older phones (iPhone 3S, 4) 'performance.low.base.transition.time': 400, 'performance.low.scrolltotop.time': 750, 'performance.low.list.acceleration': 10, 'performance.low.list.friction': 0.4 }; /** * Staging overrides */ settings.staging = { 'api.user': '', 'api.key': '' }; /** * Development environment * @ignore * * See app/ConfigurationDev.js */ /** * Override configuration with merged config. * This is so there is no possible way settings * from other environments can be accessed unless * through the configuration object */ for( var i in settings.production ) { settings[i] = settings.production[i]; } // This defines which environment will be deployed for( var i in settings[settings.environment] ) { settings[i] = settings[settings.environment][i]; } settings.production = undefined; settings.staging = undefined; settings.development = undefined; settings.emulator = undefined; /** * All configuration within application goes through this object */ Ext.define('Demo.Configuration', { /** * Performance profile high */ PERFORMANCE_HIGH: 'high', /** * Performance profile low */ PERFORMANCE_LOW: 'low', /** * Configuration * @property {object} */ config: { settings: settings, performance: null }, /** * Singleton */ singleton: true, /** * Constructor * @contructor */ constructor: function(config) { if( undefined == config ) { config = {}; } // Default performance to high config.performance = this.PERFORMANCE_HIGH; // If devicePixelRatio is 1, it implies low res display (on iphone) // Drop performance to low for this device if( 1 == window.devicePixelRatio ) { config.performance = this.PERFORMANCE_LOW; } this.initConfig(config); }, /** * Get property * * @param {String} name * @return {String} */ getProperty: function( name ) { return this.getSettings()[name]; }, /** * Set property * Not is use yet * * @param {String} name * @param {String} value */ setProperty: function( name, value ) { this.getSettings()[name] = value; }, /** * Get properties */ getProperties: function() { return this.getSettings(); }, /** * Get environment */ getEnvironment: function() { return this.getSettings().environment; }, /** * Is environment development */ isEnvironmentDevelopment: function() { return ('development' == this.getSettings().environment); }, /** * Is environment staging */ isEnvironmentStaging: function() { return ('staging' == this.getSettings().environment); }, /** * Is environment production */ isEnvironmentProduction: function() { return ('production' == this.getSettings().environment); }, /** * Get a config value based off profile in use */ setPerformanceHigh: function() { // Set performance to high this.setPerformance(this.PERFORMANCE_HIGH); }, /** * Get a config value based off profile in use */ getPerformanceProperty: function(name) { return this.getProperty('performance.' + this.getPerformance() + '.' + name); } });
Design Requests:
The entire product team (designers, developers, product) need to be smart when it comes to HTML mobile development with Sencha Touch 2. Sencha Touch 2 is a great step forward in web application development, but there are many areas you need to focus on. Based off the last year of mobile app development a lot of care needs to be taken into account when designing for Sencha Touch 2. Designers and UX needs to be focused on delivering a fast performing application rather than an application full of CSS3 effects that will slow down performance dramatically.
My advice, start simple, add new features with care. Benchmark and test each change on actual devices to be certain there are no huge performance issues.
Develop in Chrome for fast development - we dramatically increased our development speed by being able to develop directly within chrome. However, constantly test your features in the simulator and the device - otherwise you will find yourself with problems later on in the project.
My advice, start simple, add new features with care. Benchmark and test each change on actual devices to be certain there are no huge performance issues.
Develop in Chrome for fast development - we dramatically increased our development speed by being able to develop directly within chrome. However, constantly test your features in the simulator and the device - otherwise you will find yourself with problems later on in the project.
Working code sample
Here is a very basic working code sample project that uses the code mentioned in this post.
Demo application
Create a virtual host
For development go to
http://virtualhost/index-dev.html
For production go to
http://virtualhost/index.html
I use the above one with Cordova (previously PhoneGap). If there is any interest I will include the Cordova configuration within the code sample.
Hope you learned something from my blog post, any questions let me know..
Dion Beetson
Founder of www.ackwired.com
Linked in: www.linkedin.com/in/dionbeetson
Twitter: www.twitter.com/dionbeetson
Website: www.dionbeetson.com
Demo application
Create a virtual host
For development go to
http://virtualhost/index-dev.html
For production go to
http://virtualhost/index.html
I use the above one with Cordova (previously PhoneGap). If there is any interest I will include the Cordova configuration within the code sample.
Hope you learned something from my blog post, any questions let me know..
Dion Beetson
Founder of www.ackwired.com
Linked in: www.linkedin.com/in/dionbeetson
Twitter: www.twitter.com/dionbeetson
Website: www.dionbeetson.com