How was Aichitalk created. Part 1: engine

    Most recently, we released the first beta version of our online reader, which can be found by reading the book “ Hero of Our Time ” by Mikhail Lermontov . This reader is the result of almost seven months of work, five of which went only to the development of the engine. It would seem that there are already free and open JavaScript engines for reading electronic books on the Internet, and such a long time may raise doubts about the professional suitability of the developer (that is, me). But there is one big and fat "BUT". We set ourselves an overly ambitious and difficult task: we wanted to use the same engine on different devices, including low-power ones, such as an iPhone or an electronic reader.

    What is the difficult task? First of all - in the very low speed of web applications on the iPhone. For example, the mobile Safari, according to my estimates, runs 100 times slower than its desktop counterpart. If the same operation is performed on the desktop for 10 ms and is completely invisible to the user, then on the iPhone it can take more than a second. For comparison: the first version of the engine broke a small chapter into pages in about 15 seconds. Now, six months later, he does the same thing in less than a second and works pretty well in our booq application .

    In this article I will not focus on how to make my reader, but I will share the experience of optimizing a web application for an iPhone. The article will be interesting not only to developers for mobile devices, but also to ordinary web technologists. After all, if your application / site will work quickly on a mobile device, then imagine how fast it will work on the desktop.



    Task


    To begin with, it is worth explaining why we chose web technologies as the basis for the reader. Firstly, it is their prevalence. Now it’s quite difficult to find a device that does not have a built-in browser. Phones, computers, netbooks, tablets, e-books - all of them are able to read HTML, decorate it with CSS and animate through JavaScript. Having the same engine, we can easily create applications for various platforms and devices. Secondly, not a single “classic” reader engine is capable of displaying what a web browser can do. Tables, vector graphics, audio / video content, interactive elements - all this has long been successful in browsers. Imagine that you are reading a scientific book and immediately see a video demonstrating the described process. Well, or read the detective in which you need to complete the puzzle, to open the next chapter :). The possibilities are limited only by the imagination and skills of the developer.

    All this is a beautiful marketing wrapper, but let's go down from heaven to earth and see what the engine should be from a technical point of view:

    • be cross-browser and cross-platform;
    • have a modular structure to make it easier to create versions for various devices;
    • support two reading modes: pagination and scrolling (like a normal web page), as well as quickly switch between them;
    • process chapters of 1 MB + (for comparison: the first volume of Tolstoy’s War and Peace weighs 1.2 MB);
    • have flexible appearance settings (in fact, limited by CSS capabilities).


    As a test site, we used a 2G iPhone with firmware 3.1 and a chapter from Ivan Mironov’s book “Mummified” weighing 500 KB. Such a large chapter is more an exception than a rule, but it sets a good bar for performance, below which you should not fall.

    So, let's start optimization.

    JS code volume


    Immediately I want to upset fans to put a bunch of frameworks on the page and hang them with plugins to solve simple tasks like dragging blocks or selecting elements using the CSS selector: the amount of JS code on the page is of great importance, at least for mobile Safari. For example, parsing and initializing the popular jQuery takes 1400 ms for the original, uncompressed version (155 KB) and 1200 ms for the compressed (76 KB). Despite the fact that the compressed version is 2 times smaller than the original, in terms of functionality they are identical: hence the “small” difference in parsing speed. That is, speed is affected not by the length of variable names, but by the number of functions, objects, methods, and so on. For comparison: on a desktop, parsing takes about 30 ms.

    Ideal option: to keep all JS-code at the very bottom of the page and generally abandon frameworks. Since WebKit itself supports a lot of things, I rendered the standard DOM operations (adding events, searching for elements by selector, etc.) as a separate add-on module, and redefined this layer for the desktop version so that calls are broadcast in jQuery .

    HTML parsing


    The reader itself is focused on the ePub format, in which each chapter of the book is presented as a separate document in XHTML format. The chapter needs to be somehow passed to JavaScript so that it parses it, parses it and starts showing.

    Here it is worth saying a few words about the principle of displaying content on the screen. Let me remind you that the engine must support two reading modes: page by page and "footcloth". Therefore, I decided to frame all the content in two wrappers: the first is a kind of “window”, and the second shifts the content up and down. By choosing the correct window sizes and content offsets, you can create the illusion of a chapter divided into pages:



    Since in any case I need the entire contents of the chapter, and for calculating page sizes I need full-fledged DOM elements, I decided to display the chapter directly in HTML:


      

        

    В департаменте… но лучше не называть, в каком департаменте…


      



    And then I ran into a serious problem: parsing and the accompanying display of the chapter lasted as much as 7 seconds. I assumed that most of the time it takes to render content, so as an experiment I hid the content with display: none:


      


    This time, parsing the page took 800 ms, which is very good: it accelerated by almost 10 times. And since the iPhone has a rather small screen, it was enough to get the first few elements out of the tree and show them so that the user can start reading while the chapter is being calculated.

    In principle, this is already a pretty big victory in terms of performance and it would be possible to do other things, but my intuition told me that it was possible to slightly reduce the parsing time.

    I assumed that when HTML is parsed directly in the body of the document, the browser takes some additional steps so that the elements can appear on the page at the right time. For example, finding and applying appropriate CSS rules. Personally, I don’t need these actions at the moment: I need to transfer the contents of the chapter as a DOM tree directly to JavaScript as quickly as possible. How to make the browser not parse a specific fragment of a document? Right, comment it out:


      

             

    В департаменте… но лучше не называть, в каком департаменте…


        -->
      



    Laugh laugh, but page parsing time was reduced to 350 ms. And a comment is a full-fledged DOM element that can be accessed through JavaScript and get its contents:

    var elems = document.getElementById('content').childNodes;

    for (var i = 0, il = elems.length; i < il; i++) {
      var el = elems[i];
      if (el.nodeType == 8) { //comment
        var div = document.createElement('div');
        div.innerHTML = el.nodeValue;
        // внутри div теперь полноценное DOM-дерево, с которым можно работать
        break;
      }
    }


    The total time for parsing the page and parsing the code into the tree was approximately 550 ms (versus 800 ms in the previous version), which, in my opinion, is very good.

    Page size calculation


    So, I got the contents of the chapter and parse, now you need to break the chapter into pages. During parsing optimization, I realized that my initial version of paging a chapter in page mode as a window and moving content had a number of drawbacks. First, you need to display (draw) the whole chapter, which, as you already understood, takes a lot of time. Secondly, in this situation, I could not display more than one page on the screen: for the second page, I would have to completely duplicate the entire chapter, which, again, would slowly cause an inevitable application crash due to lack of memory on large chapters.

    After about two months of unsuccessful attempts to write a pagination with an acceptable runtime, a pretty good solution was found. In short, what it is.

    In essence, the chapter of the book is a set of paragraphs. Paragraphs can be represented as elements of the first level. Given the speed of rendering HTML content in an iPhone, to display one page as quickly as possible, you need to determine the minimum set of first-level elements that is necessary for its presentation. I have the whole chapter in the form of a list of first-level elements, as well as a list of pages. The page is an object in which serial numbers of the initial and final elements of the first level, window size and offsets are stored. The result was a rather compact and fast design: to display one page, it is enough to clone a set of first-level elements and display them on the screen, indicating the correct offset and size of the window.

    In order to calculate all the pages, you need to know the dimensions of each element of the first level, their internal and external margins, borders, font size and so on. To get all this data, elements must be on the page and styles must be applied to them. For these purposes, I created a special hidden container that inherits all the style descriptions of the page itself, added paragraphs to it and carried out calculations.

    To obtain the necessary characteristics of an element, you need to refer to its CSS properties. As a basis, I took a function css()from jQuery:

    function getCSS(elem, name) {
      if (elem.style[name]) {
        return elem.style[name];
      } else {
        var cs = window.getComputedStyle(elem, "");
        return cs && cs.getPropertyValue(name);
      }
    }


    Since I needed to get quite a few properties at once, this function, judging by the profiler from the Web Inspector (referring to the desktop browser, there are no such debugging tools on the iPhone, which greatly complicates the work), was the slowest. As it turned out, the appeal to getComputedStyle()is very expensive in terms of performance. Therefore, I modified this function so that I could give an array of properties that I need to get, and also removed the check elem.style[name], because in 99% of cases the CSS properties were not exposed to the elements through the object styleand this optimization was more harmful than helping:

    function getCSS(elem, name) {
      var names = (typeof name == 'string') ? [name] : name,
        cs = window.getComputedStyle(elem, ""),
        result = {};
      
      for (var i = 0, il = names.length; i < il; i++) {
        var n = names[i];
        result[n] = cs && cs.getPropertyValue(n);
      }
      
      return (typeof name == 'string') ? result[name] : result;
    }


    After such optimization, the function getCSS()did not even fall into the top three of the slowest functions :).

    The next step: correctly "smudge" the calculation of pages in time. The fact is that while JS is running, the browser interface is completely blocked, and restrictions on the execution time of the script also come into effect. A situation could arise that the screen “freezes” for 20-30 seconds, and then completely falls out with an error about an excess of the timeout for execution. Web Workers is a modern way to get rid of such problems , but Mobile Safari does not support them. Therefore, we will use the proven "grandfather" method that works in all browsers: call each iteration of page counting through setTimeout(). Sample code for this solution:

    function calculatePages(elems, callback) {
      // elems — массив элементов, которые нужно обсчитать
      var cur_elem = 0;
      
      var run = function() {
        createPage(elems[cur_elem]); // делаем обсчёт страницы
        
        cur_elem++;
        if (cur_elem < elems.length)
          setTimeout(run, 1);
        else
          callback(); // обсчёт окончен
      };
      
      run();
    }


    The function works as follows. For example, we need to count 30 elements of the first level. We give an array of these elements to a function calculatePages(), inside which a closure is created in the form of a function run(), which is one iteration of the calculation. When we finished counting, we check to see if there are still more elements in the array. If so, then we setTimeout()call through a new iteration, otherwise we call the callback function, saying that the calculation is over and you can move on.

    This approach has one important aspect - the load on one iteration. In this case, this is how many elements need to be calculated per function call.run(). If, for example, one element is counted in one iteration, then the user interface will be as responsive as possible, but the total calculation time for the entire chapter may increase 2-3 times due to the overhead that occurs when the function starts through setTimeout(). If we count 10 elements in one pass, then the total counting time of the chapter will decrease, but the responsiveness of the interface will also decrease and the risk of not timeout if the paragraphs are very large will increase.

    Therefore, you need to find some middle ground, so that the calculation time is not greatly increased, and not reduce the responsiveness of the interface. I decided not to focus on the number of elements of the first level, but on their volume, which can be obtained through the property innerHTMLortextContent. The value of 5 KB was chosen as the threshold volume by trial and error. Before the call, calculatePages()I divided all the objects into groups: as soon as the total volume of one group became more than 5 KB, it was closed and a new one was created. Accordingly, the calculation of page sizes was carried out not by individual elements, but by groups.

    Delete items


    After one group has been counted, you need to clear the hidden container and free up resources for the next group of elements. The easiest way to clear the contents of a container is to reset the property innerHTML:

    function emptyElement(elem) {
      elem.innerHTML = '';
    }


    However, “the simplest” does not always mean “the fastest” - as measurements showed, this method works much faster:

    function emptyElement(elem) {
      while (elem.firstChild)
        elem.removeChild(elem.firstChild);
    }


    Perhaps for now. This article examined some of the features of parsing and computing large amounts of data on low-power devices. In practice, all the tricks described turned out to be very effective. For example, I tested a chapter with a capacity of more than 1 MB: our reader was able to digest it somewhere in 30-40 seconds, while other (and quite popular) readers from AppStore written in Objective C / C ++ simply fell.

    In the next article, we will consider some factors that affect the time it takes to render one page in an iPhone, as well as some tricks that can significantly reduce this time.

    Sergey Chikuyonok

    Also popular now: