А ваш AngularJS вміє працювати на 3.5 Mb ОЗУ?


На початку весни ABBYY LS спільно з Xerox запустили сервіс для перекладу документів Xerox Easy Translator Service. Родзинкою цього сервісу є додаток, що запускається на БФП Xerox і дозволяє відсканувати необхідну кількість документів, дочекатися перекладу на один з обраних 38 мов, зробити друк перекладу — і все це, не відходячи від МФУ.

Додаток запускається на певній серії МФУ Xerox на основі технології Xerox ConnectKey з сенсорним екраном 800x480 пікселів. Апаратна начинка МФУ залежить від конкретної моделі, наприклад, наша тестова малютка Xerox WorkCentre 3655 має на борту 1Ghz Dual Core процесор і 2Gb оперативної пам'яті. Як не дивно, але МФУ має вбудований webkit-браузер, а наша програма — це звичайний html-додаток, розроблене на AngularJS 1.3.15.

Про самому проекті ми писали в блозі раніше, а ця стаття присвячена одному з захоплюючих етапів проекту, а саме оптимізації AngularJS під роботу на БФП Xerox. Як виявилося на ділі, платформа БФП практично не накладає ніяких серйозних обмежень на розробку програм, і вони працюють практично так само, як і на десктопних webkit-браузерах, за винятком одного " АЛЕ " — html-додатком для виконання JS виділяться всього 3.5 Mb оперативної пам'яті (на даний момент Xerox вже випустили оновлення для своєї платформи, піднявши поріг виділюваної пам'яті до 10 Мб). AngularJS ці 3.5 Mb з'їдав за кілька хвилин роботи в програмі, а збирач сміття вбудованого браузера МФУ не встигав за такий ненажерливістю і просто вибивав наш додаток на головний екран МФУ. До того ж у Xerox немає засобів для аналізу і дебага додатків, запущених на МФУ.

Спочатку здавалося, що зробити нічого не вийде (тим більше не з чуток знаючи ненажерливість сучасних браузерів), але грамотно оцінивши ситуацію, ми все-таки зважилися спробувати приборкати AngularJS і змусити додаток споживати мінімально можливу кількість пам'яті. Почавши з 220kb скомпільованого (мінімізованого, не gzip) JS коду програми, ми закінчили 97kb (AngularJS займає 56kb, все інше – наш код), по максимуму видаливши весь незадіяний код, або видозмінивши його для найменшого споживання пам'яті. Результат – стабільна робота програми протягом кількох десятків хвилин на платформі з 3.5 Mb пам'яті і повний захист від ударів на новій платформі з 10 Mb. Що ж ми зробили?

Http-запити

Основна проблема, з якою ми зіткнулися відразу ж, — це «важкі» http-запити. Їх тяжкість вимірюється не в кількостях або обсяги переданих даних, а в створюваному при кожному запиті новий об'єкт XmlHttpRequest під капотом $http сервісу AngularJS. Офіційна інформація в секції рекомендацій SDK Xerox вказувала на те, що вкрай бажано використовувати у програмі всього один об'єкт XmlHttpRequest і всі запити послідовно виконувати, використовуючи тільки один об'єкт.

Приклади з SDK носили досить простий характер — буквально пару запитів на усі додаток, що, в принципі, ніяк не ускладнює використання одного об'єкта XmlHttpRequest в голому вигляді з застосуванням нативних коллбеков цього об'єкта. У нашому додатку організована досить хитра логіка синхронізації замовлень користувача, oauth авторизація, запити до soap-сервісів МФУ для запуску сканування або друку. До того ж, запити до МФУ виконувалися з використанням коду з SDK Xerox, який створював свій об'єкт XmlHttpRequest, тягнув за собою методи для роботи з xml-відповіддю soap-сервісів і в цілому створював додаткову складність при парсингу цього xml-відповіді і приводив до ситуації написання не Angular-way коду.

Таким чином, ми зіткнулися з дійсно серйозними проблемами: відсутність нормальних прикладів реального використання одного об'єкта XmlHttpRequest, широкого спектру використання запитів і напів-legacy кодом з SDK. Незважаючи на всю складність, вихід із ситуації виявився простий – написати свій $http сервіс, відмовитися від коду з Xerox SDK і написати свої Angular-сервіси для підтримки сканування і друку.

Однією з головних труднощів було ще й те, щоб наш кастомный сервіс повинен мати такий же інтерфейс, як і ангуларовский $http сервіс, щоб зберегти вже працює і протестований код наших контролерів і залежних від $http сервісів. Так як в додатку використовувались лише get і post запити, простий анотації $http.get(...) і $http.post(...), то сам сервіс виглядає ось так:

function ($q) {

var queue = [];

// execute request
function query() {
var request = queue[0];
var defer = request.defer;
xhr.open(request.method, request.url, true);
// set headers
var headers = request.headers;
for (var i in headers) {
xhr.setRequestHeader(i, headers[i]);
}
// load callback
xhr.onreadystatechange = function () {
if (xhr.readyState == 4 && !defer.promise.$$state.status) {
var status = xhr.status;
var data = JSON.parse(xhr.response);
(200 <= status && status < 300 ? defer.resolve : defer.reject)({
data: data,
status: status
});
queue.shift();
if (queue.length) {
query();
}
}
};
// send data
xhr.send(request.data);
}

// add request to queue
function push(method, url, data, headers) {
var defer = $q.defer();
queue.push({
data: typeof data === "string" ? data : JSON.stringify(data),
defer: defer,
headers: headers,
method: method,
url: url
});
if (queue.length == 1) query();
return defer.promise;
}

return {
// get request
get: function (url, data, headers) {
return push("GET", url, data, headers);
},

// post request
post: function (url, data, headers) {
return push("POST", url, data, headers);
}
};
}


Це мінімальний вигляд нашого сервісу, який, використовуючи один об'єкт XmlHttpRequest, здатний виконувати послідовно будь-яку кількість запитів http без загрози агресивного споживання пам'яті МФУ. В кінцевому результаті цей сервіс містить функціональність http interceptor'ів (без можливості внесення змін в кінцевий відповідь запиту, правильніше було б назвати http listeners, використовуємо для логування помилок), скасування черги запитів $http.cancel(), плюс додаткові властивості результуючого об'єкта, які дозволяють зрозуміти, що запит було скасовано користувачем або відвалився по таймауту (30 секунд на запит), наприклад:

$http.get(...).catch(function (response) { if (response.canceled) { ... } });


Наступний етап – обернути виклики soap-сервісів МФУ у відповідні Angular-сервіси. Основна проблема тут полягає в тому, що відповідь від МФУ ми отримуємо у вигляді громіздкою soap'івської xml, а реально необхідні дані займають всього кілька байт. Щоб спростити цей етап, з вихідної xml (яка нам прийшла у вигляді рядка) ми з допомогою регулярного виразу «виймаємо» тільки той тег, який нам цікавий:

var parser = new DOMParser();

function toXml (xml, tag) {
if (tag) {
var node = new RegExp('((<|<)[\\w:]*' + tag + '(>|>|\\s).*\/[\\w:]*' + tag + '(>|>))', 'g').exec(xml);
return node && node.length ? parse(node[1]) : null;
} else {
return parse(xml);
}
}

function parse(xml) {
return parser.parseFromString(xml
.replace(/amp;/g,)
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/<\w+:/g, '<')
.replace(/<\/\w+:/g, '<\/'), 'text/xml').documentElement;
}



В результаті ми отримуємо DOM-дерево, забрати дані з якого вже не становить праці. До того ж, за DOM-дерева можна шукати, що нас цікавлять теги, використовуючи можливості querySelector. Спочатку код з SDK Xerox завжди парсил xml відповідь цілком, а пошук за DOM-дерева виконувався шляхом кастомного обходу дерева до знаходження потрібного елемента (щось на зразок куцого самопісного XPath в JS). Дійсно складно відповісти, який з підходів краще і менше споживає пам'яті і системних ресурсів, але особисто ми чомусь більше довіряємо нативним функцій браузера DomParser.parseFromString, querySelector (querySelectorAll) по роботі з DOM деревом, ніж ручний обхід.

Разом:
Розроблена своя функціональність для виконання http-запитів і простого парсингу xml, у зменшеному вигляді займають 2.3 kb. З програми вилучено весь залежний код SDK Xerox, в минифицированном вигляді займав 17kb.
З AngularJS були видалені сервіси $http і $httpBackend.

Роутинг


Спочатку в проекті використовувався всім відомий ui-router версії 0.2.13. Це дійсно чудове, різнобічне й унікальне у своєму роді рішення для AngularJS. Використовуючи його, ми зробили цілком звичайний роутинг програми, для модальних вікон використовувалися вкладені стану.

Зрозуміло, є менш функціональний і легковажне рішення безпосередньо від самих розробників AngularJS, яке спочатку не підходило у своєму чистому вигляді і вимагало доопрацювань для модальних вікон. Але саме вихідний код цього модуля був активно використаний для розробки власного рішення. В процесі оптимізації програми нами було виявлено, що вся функціональність модуля ui-router нам не потрібна, а саме – у нас не було необхідності в url-роутинге (додаток на МФУ відкривається на весь екран і немає доступу до адресному рядку), вкладених станах, resolve та ін. Все, що нам потрібно від роутінга це:

1. Можливість простого конфігурування станів (екранів і модальних вікон) програми.
2. Супутні директиви та сервіси для кешування і переходів між екранами і (або) модальними вікнами.
3. Коректна підстановка і видалення DOM-дерева html-шаблонів відвідуваних екранів, а також відображення модальних вікон поверх вихідного екрану (аналог вкладених станів ui-router, але нам потрібен всього один рівень вкладеності).

Перший пункт реалізується досить легко:

xerox.provider("$route", function () {
...
var base = "/";
var routes = {};
var start;
var self = this;

// add new route
function add(name, templateUrl, controller, modal) {
routes[name] = {
name: name,
modal: modal,
controller: controller,
templateUrl: base + templateUrl + ".html"
};
return self;
}

// set start state
self.start = function (name) {
start = name;
return self;
};


// add modal
self.modal = function (name, templateUrl, controller) {
return add(name, templateUrl, controller, true);
};

// add state
self.state = function (name, templateUrl, controller) {
return add(name, templateUrl, controller, false);
};

self.$get = [...];

});


На стадії конфігурування:

xerox.config(["$routeProvider", function ($routeProvider) {

$routeProvider

// default state
.(start"settings")

// modals
.modal("login", "login/login", "login")
.modal("logout", "login/logout", "logout")
.modal("processing", "new-order/processing", "processing")

// states
.state("settings", "new-order/settings", "settings")
.state("languages", "new-order/languages", "languages");

}]);


Другий пункт реалізується за допомогою сервісів:

$view

xerox.factory("$view", ["$http", "$locale", "$q", function ($http, $locale, $q) {

var views = {};

return {

// get view
get: function (url) {
var self = this;
if (views[url]) {
return $q.when(views[url]);
} else {
return $http.get(url).then(function (response) {
var template = response.data;
self.put(url, template);
return template;
});
}
},

// put view
put: function (url, text) {
views[url] = text;
}

};
}]);


і $route

return {

// route history
var history = [];

// $route interface
var $route = {
// current route
current: null,

// history back
back: function () {
if ($route.current.modal) {
$rootScope.$broadcast("$routeClose");
} else {
$route.go(history.pop() && history.pop());
}
},

// goto route
go: function (name, params) {
prepare(name, params);
}
};

// prepare and load route
function prepare(name, params) {
var route = routes[name];
$view.get(route.templateUrl).then(function (template) {
route.template = template;
commit(route, params);
});
}

// commit route
function commit(route, params) {
route.params = params || {};
if (!route.modal) {
history.push(route.name);
}
$route.current = route;
$rootScope.$broadcast("$routeChange");
}

// routing start
prepare(start);

return $route;
}];


А також директив xrx-back:

xerox.directive("xrxBack", ["$route", function ($route) {
return {
restrict: "A",
link: function (scope, element) {
element.on(xrxClick, $route.back);
}
};
}]);


xrx-sref:

xerox.directive("xrxSref", ["$route", function ($route) {
return {
restrict: "A",
link: function (scope, element, attr) {
element.on(xrxClick, function () {
$route.go(attr.xrxSref);
});
}
}
}]);


і scriptDirective (для кешування text/ng-template):

xerox.directive("script", ["$view", function ($view) {
return {
restrict: "Е",
terminal: true,
compile: function(element, attr) {
if (attr.type == "text/ng-template") {
$view.put(attr.id element[0].text);
}
}
};
}]);


У сервісі $route ми організуємо додаткову логіку для модальних вікон, а саме: 1) не розміщуємо їх в історію станів і 2) при спробі викликати $route.back при відкритому модальному вікні триггерим подія, що необхідно закрити модальне вікно. На подію підписана директива xrx-view, яка і реалізує пункт 3:

xerox.directive("xrxView", ["$compile", "$controller", "$route", function ($compile, $controller, $route) {
return {
restrict: "A",
link: function (scope, element) {

var stateScope;
var modalScope;
var modalElement;
var targetElement;

// destroy scope
function $destroy(scope) { scope && scope.$destroy(); }

// on route change
scope.$on("$routeChange", function () {
var current = $route.current;
var newScope = scope.$new();

// prepare scopes and DOM element
$destroy(modalScope);
if (current.modal) {
modalScope = newScope;
// find or create modal container
modalElement = element.find(".modals");
if (!modalElement.length) {
modalElement = xrxElement("<div class=modals>");
element.append(modalElement);
}
targetElement = modalElement;
} else {
$destroy(modalScope);
$destroy(stateScope);
modalScope = null;
stateScope = newScope;
targetElement = element;
}

// append controller and inject { $scope, $routeParams }
if (current.controller) {
targetElement.data("$ngControllerController", $controller(current.controller, {
$routeParams: current.params,
$scope: newScope
}));
}

// append Template to DOM and compile
targetElement.html(current.template);
$compile(targetElement.contents())(newScope);
});

// on close modal
scope.$on("$routeClose", function () {
$destroy(modalScope);
modalScope = null;
modalElement.remove();
});

}
};
}]);


На цьому все. Роутинг є максимально легковажним, підтримує роботу як з реальними html-шаблонами, так і їх аналогами з <script type=text/ng-template>...</script>, реалізує необхідну нам логіку модальних вікон. Додатково має схожий з ui-router синтаксис для роботи і конфігурування станів програми.

Разом:
З програми було виключено ui-router розміром 28kb і розроблена своя функціональність в мінімальному вигляді займає всього 1.8 kb.

З AngularJS були видалені наступні сервіси і директиви:
  • ng-controller
  • ng-include
  • scriptDirective (поміщає в $templateCache скрипти c типом text/ng-template)
  • $anchorScroll
  • $location
  • $cacheFactory
  • $templateCache


Локалізація програм

До того моменту, коли ми почали проводити повномасштабну оптимізацію програми, ми вже мали майже повністю локалізоване додаток шістьма мовами – англійською, німецькою, французькою, італійською, іспанською та португальською. Тексти мов зберігалися за типом ключ-значення в JSON, а підставлялися у додатку за допомогою одностороннього биндинга {{::locale.HELLO_HABR}}. У завантаженні локалізації з JSON все досить просто і оптимізувати більше нічого:

angular.element(document).ready(function () {
window.$locale(function () {
angular.bootstrap(document.body, ["xerox"]);
});
});


Всередині функції $locale йде визначення мови інтерфейсу і підвантажується найбільш підходящий мову з JSON з допомогою глобального xhr.

Але ось стадію реалтаймовой локалізації програми можна і потрібно оптимізувати, хоча вона і використовує односторонній биндинг, але все одно це додаткова робота всередині digest циклу при кожному заході на сторінку. До того ж у локалізації є тексти з версткою, які вимагають застосування ng-bind-html, а той у свою чергу спричиняє й ще додаткові перевірки сервісом $sanitize. Рішення далеко не з кращих, але нічого більш зручного зробити, власне, практично і не можна було до того моменту, поки не був розроблений свій роутинг. З появою власного сервісу завантаження і кешування html-шаблонів $view, безсумнівно, прийшла ідея використовувати його для локалізації програми.

Що ж нам для цього довелося зробити? В принципі зовсім небагато:

1. У всіх html шаблони місця, що потребують локалізації, обернути двома квадратними дужками, а-ля, було {{::locale.HELLO_HABR}}, стало — [[HELLO_HABR]]
2. Так як таке поєднання квадратних дужок у додатку унікально, то ми можемо за допомогою регулярного виразу зробити звичайний replace, минаючи стадію готового DOM і в цілому digest цикл, а якщо бути точніше – проводити локалізацію до того, як шаблон буде скомпільовано і вставлений в DOM:

2. xerox.factory("$view", ["$http", "$locale", "$q", function ($http, $locale, $q) {

var views = {};
// locale inject RegExp
var localeRegExp = /\[\[(\w+)\]\]/mg;

// template localization
function localization(template) {
var match;
while (match = localeRegExp.exec(template)) {
template = template.replace(match[0], $locale[match[1]]);
}
return template;
}

return {

...

// put view
put: function (url, text) {
views[url] = localization(text);
}
};
}]);


Таким чином, локалізація спрацьовує одноразово в момент старту Angular-додатка і в пам'яті ми вже зберігаємо локалізовані html-шаблони.

Разом:
Локалізація додатку винесена з циклу digest на стадію завантаження програми.
З AngularJS були видалені наступні сервіси і директиви:
  • ng-bind
  • ng-bind-html
  • ng-bind-template
  • ng-pluralize
  • $$sanitizeUri
  • $sce
  • $sceDelegate


ng-model

Директива ng-model (і інші директиви для роботи з html формами, пов'язані з нею,) – одна з перлин AngularJS, це неймовірний інструмент, в який закохуєшся з першого знайомства. Але мало хто знає, що приховано під капотом ng-model. Це насправді дуже великоваговий код, що відслідковує події на елементі (cut, paste, change, натискання синхронізуючий реальне значення модельки з коротким значенням на екрані, перевіряючий при кожній зміні нашу модельку, що надає інтерфейс-контролер для роботи з моделькою в наших директивах.

На ділі виявилося, що нам всі ці можливості і не потрібні. Наприклад, не потрібна валідація, так як навіть форма авторизації, згідно всім гайдлайнам, виводить помилку в модальному вікні тільки після невдалого серверного запиту. Кастомні чекбокси, селектбоксы і списки теж не вимагають перевірки, а директиви, які їх реалізують, працюють з моделькою в режимі read-write-watch. Тобто директива checkbox виглядає якось так:

xerox.directive("checkbox", function () {
return {
restrict: "Е",
scope: {
xrxModel: "="
},
link: function (scope, element) {
var icon = xrxElement("<div class=checkbox-icon>");
element.prepend(icon);
icon.on(xrxClick, function () {
if (!element.attr(xrxDisabled)) {
scope.$apply(function () {
scope.xrxModel = !scope.xrxModel;
});
}
});
scope.$watch("xrxModel", function (value) {
element[value ? "addClass" : "removeClass"]("checked");
});
}
};
});


Єдине – у нас є формою авторизації, на якій ми використовуємо текстові инпуты. Тому директива клавіатури, як і ng-model, відстежує події cut, change, paste, але в полегшеному вигляді, не запускаючи маховик валідації та інших смаколиків AngularJS при роботі з модельки.

Пару слів про клавіатурі, раз її торкнулися, ось її реальний вигляд:


Вся верстка будується на стороні JS (директива не має html шаблону), з цікавих оптимізацій – подія кліка навішується на загальний контейнер, а не на кожну кнопочку. Тим самим досягається невелика, але все ж економія на обробниках подій, а отже, і займаної додатком пам'яті.

Разом:
З AngularJS були видалені наступні директиви:
ng-model
ng-list
ng-change
pattern
required
minlength
maxlength
ng-value
ng-model-options
ng-options
ng-init
ng-form
input
form
select

Скролл

Чимало масла у вогонь підлили нам і скроллируемые списки:



Оптимізуючи споживання пам'яті, ми відмовилися від ng-repeat (який створює свій scope для кожного елемента), написали своє легковажне рішення і думали, що на цьому все, але от рендеринг списку із 38 мов неабияк пригальмовував на МФУ. Додатково справа погіршувало ще й те, що МФУ не малює в браузері системний скролл і доводиться його малювати власними коштами. Ми перепробували безліч хитрощів, починаючи від використання -webkit-scrollbar і закінчуючи кастомным скролінгом через element.scrollTop або -webkit-transform: translate(x, y) з використанням overflow: hidden. Спроби зрозуміти принцип візуалізації браузера теж не увінчалися успіхом. Або гальмував сам скролл, або перестроювання списку (користувач вибрав інший Source мову і потрібно перебудувати список Target мов, який в собі не містить вибраний Source мова).
Вже майже втративши надію, в один з чергових експериментів, ми помітили, що якщо в списку вставити кілька елементів і міняти тільки їх innerHTML, рендеринг не гальмує, а скролл здійснюється плавно і без затримок. Цим нелегким шляхом у додатку з'явилася директива для скролла, принцип її роботи простий і хитрий одночасно:

1. У контейнер вставляється необхідну кількість елементів, щоб заповнити всю його висоту, наприклад, 7 елементів списку.
2. На основі значення offset'а (відступ від початку масиву даних) і html шаблону змінюються innerHTML наших елементів.
3. Відловлюємо події натискання на стрілочки скролла або «тягання» (mouseDown-mouseMove-mouseUp) повзунка, вираховуємо offset, міняємо позицію бігунка і повертаємося до пункту 2.
Таким чином, створюється відчуття скролла даних, хоча на ділі змінюється тільки внутрішній вміст всіх тих же 7 елементів списку.

Разом:
З AngularJS була видалена директива ng-repeat, так як в ній більше не було жодного сенсу, а всю потрібну нам роботу виконувала нова директива скролла.

Додатково

Додатково над AngularJS був проведений ще ряд шаманств:
  • повністю видалений функціонал анімації ($animate, $$rAF);
  • видалені директиви для роботи з класами та атрибутами (ng-class, ng-class-even та ін);
  • ng-if і ng-switch замінені їх оптимізованими аналогами – встановлюється display: none для html-елементів, що не задовольняють умові, минаючи створення свого scope, як в оригінальних директивах, а ng-show і ng-hide видалені зовсім;
  • видалені $filter і $locale, а всі необхідні дані готуються безпосередньо в наших сервісах на основі невеликих самописних рішень.


Висновки

Сучасна розробка web-додатків йде шляхом включення досить великих сторонніх рішень заради однієї невеликої функції або фічі, тим самим як на дріжджах збільшується ненажерливість програми. Хоча, можливо, ця ненажерливість не настільки помітна на десктопах і лептопах, девайси зі слабкою апаратною начинкою такі додатки перетравлюють з працею, додатково неабияк нагріваючись.

Ми на своєму досвіді переконалися, що, коли настає час необхідної оптимізації, її цілком під силу зробити практично будь-творчій команді розробників. Витрачений час на всі описані оптимізації склало близько 12-15% від загального часу розробки проекту, що, в принципі, більш ніж достатньо, і ми залишилися дуже задоволені досягнутими результатами.

AngularJS дійсно модульний фреймворк, і, хоча він не дозволяє сконфігурувати необхідний набір функцій і завантажити його (як, наприклад, можна зробити з jQuery UI), ми не відчували ніякого дискомфорту при виключенні не потрібних нам директив і сервісів з AngularJS. Саме модульний підхід розробки додатків дозволяє практично безболісно проводити широкомасштабну оптимізацію і рефакторинг.

І все ж хочеться вірити, що така оптимізація виробляються не тільки вимушено, але і в рамках турботи про кінцевих користувачів системи та всієї команди розробки, так і, можливо, просто на хвилі нестримного ентузіазму.

Спасибі фахівцям "СимбирСофт" за активну участь у роботі над проектом.
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.