How to cross the border: cross-platform in a mobile application

    image
    Today, more and more applications are being created for several mobile platforms at once, and applications created initially for one platform are actively ported to others. Theoretically, you can completely write an application “from scratch” for each platform (that is, in fact, only the application idea turns out to be “cross-platform”). But this means that the labor costs for its development and development will grow in proportion to the number of supported platforms. If multi-platform is initially laid down in the application architecture, then these costs (plus, in particular, support costs) can be significantly reduced. You develop a common cross-platform code once - it means you use it on current (and future) platforms. But in this case, several interrelated issues arise immediately:

    • Should there be a border between the common (cross-platform) and native (specific for this platform) code?
    • If so, where and how to draw this border?
    • How to make cross-platform code convenient to use on all platforms, both those that need to be supported now, and those whose support is likely to be needed in the future?

    Of course, the answers to these questions depend on the specific application, the requirements imposed on it and the restrictions imposed, therefore, it seems, it is impossible to find a universal answer. In this article, we will tell you how we searched for answers to these questions during the development of the Parallels Access mobile client for iOS and Android, what architectural decisions were made and what ultimately came about.

    I want to immediately warn that there are a lot of letters in this post, but I did not want to split the topic into pieces. Therefore, be patient.

    Should there be a cross-platform boundary?


    Today, there are many frameworks (for example, Xamarin, Cordova / PhoneGap, Titaniun, Qt, and others), which, in principle, allow you to write code once, and then assemble it under different platforms and receive applications with more (or, in depending on the capabilities of the framework, less) native to the given UI platform and its “look-n-feel”.

    But if it is important for you that the application is perceived and behaves familiar to users on this platform, then it should “play by the rules” established by the Human Interface Guidelines of this platform. And for the perception of the "nativeness" of the application, the user is extremely important "little things" - the type and behavior of UI controls, the type and timing of animations of transitions between UI elements, reactions to gestures, the location of standard controls for this platform, etc. etc. If you are completely writing an application on one of the cross-platform frameworks, then the native behavior of your application will depend on how well it is implemented in this framework. And here, as often happens, "the devil is in the details."

    Where is the "devil" hiding in the framework?
    1. In bugs.No matter how good the framework is, in it, unfortunately, there will inevitably be bugs that will affect the look-n-feel and the behavior of the application to one degree or another.
    2. In speed. The speed with which mobile platforms are developing and changing directly affects the native “look-n-feel” and the speed at which new “chips” appear on a particular platform.

    In any of these cases, you either become dependent on the output speed and quality of the framework updates, or (if you have access to its sources) you will have to fix bugs yourself or add missing features that are urgently needed for you. We fully encountered these problems during the development of our other solution - Parallels Desktop for Mac, which uses the Qt library widely (at one time Parallels Desktop for Mac developed on a common code base with Parallels Workstation for Windows / Linux). At some point in time, we realized that the time and effort spent searching for problems related to bugs or implementation features of platform-specific code in Qt, fixing them, or finding ways to work around them became too large.

    When developing Parallels Access, we decided not to step on the same rake for the second time, so we decided to use native frameworks for each platform for developing UI.

    Where and how to draw the line between cross-platform and native code?


    Parallels Access Interface
    So, we decided that the UI will be written natively, i.e. on Objective-C (later added Swift) for iOS and Java for Android. But besides the UI itself, in Parallels Access, as, probably, in the vast majority of applications, there is a rather large layer of “business logic”. At Parallels Access, he is responsible for such things as user authorization, data exchange with the Parallels Access cloud infrastructure, organizing and, as necessary, restoring encrypted connections to the user's remote computers, receiving a variety of data, as well as video and audio streams from the remote computer, sending commands to launch and activate applications, sending keyboard and mouse actions to a remote computer, and much more. Obviously, this logic is platform independent,

    The choice of writing a cross-platform “core” was simple for us: C ++ plus a subset of Qt modules (QtCore + QtNetwork + QtXml + QtConcurrent). Why is it still Qt? In fact, this library has long become a lot more than just a tool for writing cross-platform UI. The Qt meta-object system provides many extremely convenient things. For example, to get “out of the box” means for thread-safe communication between objects using signals and slots, add dynamic properties to objects, and in a couple of lines of code organize a dynamic factory of objects by line with the class name. In addition, it provides a very convenient cross-platform API for organizing and working with the event loop, streams, network and much more.

    The second reason is historical. We did not want to abandon the use of the thoroughly tested and tested C ++ / Qt Parallels Mobile SDK library, which was created in the process of developing several of our other products and which took several man-years of work.

    How to make cross-platform kernel convenient to use from Objective-C and Java?


    How to use C ++ library from Objective-C and Java? The forehead solution is to make Objective-C ++ wrappers over C ++ classes for use in Objective-C and JNI wrappers for use in Java. But with wrappers there is an obvious problem: if the C ++ library API is actively developing, then wrappers will require constant updating. It is clear that keeping the wrappers up to date manually is a routine, unproductive and inevitably error-prone path. It would be wise to simply generate wrappers and the necessary “boiler plate” code to call methods in C ++ classes and access data. But again, the generator either needs to be written, or you can try using a ready-made one (for example, for Java you could use SWIG. And with the generators, there remains the possibility that the wrapped C ++ API will turn out to be too tough for them and it will take dances with tambourines to generate a correctly working wrapper.

    How to eliminate such a problem in the bud, “by design”? To do this, we asked ourselves, and what, in fact, are the communications between the "platform" code in Objective-C / Java and cross-platform code in C ++? Globally, from a bird's eye view, this is a certain set of data (model objects, command parameters, notification parameters) and the exchange of these data between Objective-C / Java and C ++ using a specific protocol.

    How to describe the data so that its presentation in C ++, Objective-C, Java would be guaranteed possible and mutually convertible? As a solution, one asks to use an even more basic language for describing data structures, and to generate data types that are “native” for each of the three languages ​​from this description (C ++, Objective-C, Java). In addition to generating data types, the ability to efficiently serialize and deserialize them into byte arrays was important to us (below we will explain why). To solve such problems, there are a number of ready-made options, for example:



    We selected Google Protocol Buffers, as at that time (2012) it was slightly superior to its competitors in performance, serialized data more compactly, in addition, it was perfectly documented and provided with examples.
    An example of how the data in the .proto file is described:

    message MouseEvent {
            optional sint32 x = 1;
            optional sint32 y = 2;
            optional sint32 z = 3;
            optional sint32 w = 4;
            repeated Button buttons = 5;
            enum Button {
                    LEFT = 1;
                    RIGHT = 2;
                    MIDDLE = 4;
            }
    }
    


    The generated code, of course, will be much more complicated, because in addition to ordinary getters and setters, it contains methods for serializing and deserializing data, auxiliary methods for determining the presence of fields in the proto-buffer, methods for combining data from two proto-buffers of the same type, etc. These methods will be useful to us in the future, but now it is important for us how the code using the cross-platform “core” and the code in the “core” itself can write and read data. And it looks very simple. Below is an example of code for writing (in Objective-C and Java) and reading data (in C ++) about a mouse event - left-clicking at a point with coordinates (100, 100):

    a) Creating and writing data to object in Objective-C:

    RCMouseEvent *mouseEvent = [[[[[RCMouseEvent builder] setX:100] setY:100] addButtons:RCMouseEvent_ButtonLeft] build];
    int size = [mouseEvent serializedSize];
    void *buffer = malloc(size);
    memcpy(buffer, [[mouseEvent data] bytes], size);
    


    b) Creating and writing data to an object in Java:

    final MouseEvent mouseEvent = MouseEvent.newBuilder().setX(100).setY(100).addButtons(MouseEvent.Button.LEFT).build();
    final byte[] buffer = mouseEvent.toByteArray();
    


    c) reading data in C ++:

    MouseEvent* mouseEvent = new MouseEvent();
    mouseEvent->ParseFromArray(buffer, size);
    int32_t x = mouseEvent->x();
    int32_t y = mouseEvent->y();
    MouseEvent_Button button = mouseEvent->buttons().Get(0);
    


    But how to transfer data written to protobuffers to the C ++ library side and vice versa, given that the code sending requests (Objective-C / Java) to the cross-platform “core” and the code directly processing them (C ++) live in different streams? Using standard synchronization methods for this requires constant attention to where and how synchronization primitives are used, otherwise it is easy to get code with suboptimal performance, dead locks or races that are hard to catch for crashes when reading / writing data is not synchronized. Is it possible to build a communication scheme between Objective-C / Java and C ++ in such a way as to solve this problem by design? Here we again asked the question, and what, in fact, the types of communications we need:

    • Firstly, the API of our cross-platform “core” should provide methods for requesting model objects (for example, get a list of all remote computers registered in this account).
    • Secondly, the kernel API should provide the ability to subscribe to notifications about adding, deleting, and changing properties of objects (for example, about a change in the state of a connection to a remote computer or about the appearance of a new window of an application on a remote computer.)
    • Thirdly, the API must have methods both for executing commands by the “core” itself (for example, establish a connection to a given remote computer using the specified login credentials), and for sending commands to a remote computer (for example, emulate keystrokes on a keyboard on a remote computer when a user types on a mobile device). The result of the command may be changing the properties or deleting the model object (for example, if it was a command to close the last window of the application on the remote computer).

    Those. we get only two characteristic communication patterns:
    1. Request-response from Objective-C / Java to C ++ for requesting / receiving data and for sending commands with the optional completion handler
    2.Notification events from C ++ to Objective-C / Java
    (NB: Processing of audio and video streams is implemented separately and is not considered in this article).

    The implementation of these patterns falls well on the mechanism of asynchronous messages. But, as in the case with the data description, we need an asynchronous queue mechanism that allows you to exchange messages between the three languages ​​(Objective-C, Java and C ++), and, moreover, easily integrates with threads and event loops native to each platform .

    We did not invent a bicycle here, but used the ZeroMQ library. It provides efficient transport for exchanging messages between so-called "nodes", which can be threads within a single process, processes on one computer, processes on multiple computers connected to a network.

    The use of zero-copy algorithms and a lock-free model for exchanging messages in this library makes it a convenient, efficient, and scalable tool for transferring blocks of data between nodes. At the same time, depending on the relative location of the “nodes”, messages can be transmitted via shared memory (for streams within the same process), IPC mechanisms, TCP sockets, etc., and this happens transparently for code using the library: when creating “sockets” through which “nodes” communicate, it’s enough to set “medium of exchange” in one line, and that’s it. In addition to the "low-level" C ++ libzmq library, for ZeroMQ there are a number of high-level bindings for a large number of languages, including C (czmq), Java (jeromq), C #, etc. allowing more compact and efficient use of the patterns provided by ZeroMQ for organizing communications between “nodes”. Having configured the exchange environment, we can, for example, create and transmit ZeroMQ messages from Java (using jeromq) and natively receive and read them on the C ++ side (using czmq).

    ZeroMQ is a transport that implements message dispatching between “nodes” according to a configured communication pattern, but does not impose restrictions on the “payload”. It is here that the fact mentioned above is useful to us, that proto-buffers are not only a means for a generalized description of data structures, but also a mechanism for efficient (both in time and in required memory size) serialization and deserialization of data.



    Thus, using the Google Protocol Buffers + ZeroMQ bundle, we got a language-independent tool for describing data and a thread-safe tool for exchanging data and commands between “platform” and “cross-platform” code.

    Using this bundle:
    • Transparent to developers on Objective-C, Java and C ++. Work with data and operations is conducted entirely in the “native” language
    • It frees developers of client UI code from the need to remember about synchronization when accessing data. Serialized and deserialized objects are different objects, common memory (under certain conditions) is needed only when transferred through ZeroMQ.


    Conclusion


    Summing up, we can say the following: firstly, when studying a problem, you should always “rise” above it and see the general picture of what is and what you want to get as a result - this sometimes helps to simplify the task. Secondly, you should not reinvent the wheel and undeservedly forget all that helped to work effectively in previous solutions.

    And how did you write your cross-platform application - from scratch for each platform and immediately laid in the architecture? Let's discuss the pros and cons in the comments.

    Also popular now: