Użytkownicy portalu Pracuj.pl mają dostęp do zróżnicowanych funkcjonalności i narzędzi. Poprzez serwis mogą korzystać m.in. z Kreatora CV, raportów wynagrodzeń i kalkulatora wynagrodzeń. Dla urządzeń mobilnych z systemami Android i iOS napisaliśmy dwie aplikacje: Pracuj.pl – Oferty pracy (tutaj iOS) oraz Kalkulator wynagrodzeń (tutaj iOS).
Autor tekstu: Patryk Nadrowski
Starszy Programista iOS w Grupie Pracuj.
Jesienią 2017 r. udostępniliśmy nową wersję Kalkulatora wynagrodzeń. Wcześniej kody dla poszczególnych platform (iOS, Android, WWW) były od siebie całkowicie niezależne. Aplikacje bazowały na płynących od Apple’a oraz Google’a zaleceniach dotyczących pisania aplikacji dla ich systemów.
Co było źle?
Negatywny efekt tego rozwiązania widoczny był zwłaszcza na początku każdego roku kalendarzowego, kiedy to aktualizowane są stawki podatkowe. Zmiany trzeba było wprowadzać niezależnie na wszystkich trzech platformach. W takiej sytuacji zwiększało się też ryzyko popełnienia błędu i stworzenia rozbieżności.
Mając na uwadze powyższe kwestie oraz fakt, że planowaliśmy zmodernizowanie interfejsu użytkownika po stronie aplikacji mobilnej oraz serwisu WWW, postanowiliśmy napisać aplikację od nowa. Decyzja ta miała przede wszystkim na celu:
- stworzenie modułu do wyliczania stawek i algorytmu podatkowego wspólnego dla wszystkich platform,
- współdzielenie kodu odpowiadającego za logikę biznesową pomiędzy aplikacjami na iOS oraz na Androidzie,
- ogólne ulepszenie systemu i poprawę jakości jego funkcjonowania,
- odświeżenie interfejsu zgodnie z trendami mobile.
Jaki język i dlaczego C++?
Idea współdzielenia kodu wydaje się ciekawa. Logikę biznesową kodujemy raz i używamy jej na wielu platformach. Znacznie skraca to czas potrzebny na realizację zadań. W takiej sytuacji łatwiej też dostrzec błędy i wprowadzić odpowiednie poprawki.
Odrzuciliśmy React Native i Xamarin. Zdecydowaliśmy się na C++, Objective-C i Java.
W przypadku aplikacji mobilnych najpopularniejsze rozwiązania odnośnie do pisania współdzielonego kodu bazują na językach JavaScript oraz C#. W głównej mierze podyktowane jest to faktem, że wiele osób chętnie wykorzystuje gotowe frameworki takie jak React Native, Xamarin itp.
Nam zależało jednak na ograniczeniu warstw abstrakcji występujących pomiędzy bibliotekami oferowanymi przez producentów obydwu platform i korzystaniu z pełni ich możliwości. Dlatego też odrzuciliśmy możliwość bazowania na tego typu frameworkach.
W przypadku naszej aplikacji zdecydowaliśmy się na język C++. Warstwa UI powstaje natomiast w językach Objective-C++ (iOS) oraz JAVA (Android).
W odróżnieniu od C# czy JavaScript C++ jest oficjalnie wspierany na obydwu platformach przy pisaniu natywnych aplikacji. Możliwość ręcznego zarządzania pamięcią umożliwia nam pisanie efektywniejszego kodu (co prawda piszemy aplikację mobilną, a nie grę, ale wydajność wykonywanego kodu jest dla nas istotna).
Dodatkowym jego atutem jest też fakt, że stwarza zdecydowanie większe możliwości przy projektowaniu architektury danego systemu. Zamiast architektury nastawionej na obiektowość (object oriented design) można bez przeszkód korzystać z podejścia, w którym najważniejszym elementem są przetwarzane dane, a dokładnie ich reprezentacja w pamięci (data oriented design).
Istotnym elementem tego języka są z naszej perspektywy również szablony. Typy generyczne, dostępne chociażby w języku C#, są daleko w tyle pod względem możliwości. Nie można ich traktować jako odpowiednika szablonów.
Nie bez znaczenia pozostawał też fakt, że część naszego zespołu posiadała duże doświadczenie w posługiwaniu się tym językiem. W przypadku zespołów bez takich osób należy liczyć się z tym, że C++ jest zdecydowanie bardziej rozbudowany i wymaga większego nakładu czasu przeznaczanego na jego naukę niż C# czy JavaScript. Często jest to odbierane jako czynnik negatywny.
Gdybym miał krótko podsumować wybór języka – zdecydowała po prostu większa elastyczność C++ niż w przypadku konkurencyjnych rozwiązań.
Aplikacja mobilna – serwer
Patrząc na architekturę aplikacji, możemy zaobserwować, że składa się ona z dwóch części. Jest to rozwiązanie zbliżone do architektury klient–serwer.
Część pierwsza (w dalszej części artykułu określana jako serwerowa) napisana jest w całości w języku C++14. Możemy tu wyróżnić podział na pojedyncze moduły aplikacji, z których każdy odpowiada jednemu ekranowi aplikacji. Mogą się one między sobą bezpośrednio komunikować i wymieniać informacje.
W modułach zawarta jest cała logika biznesowa aplikacji. To tutaj zapadają decyzje o tym, czy chcemy przełączyć się na następny moduł, wysłać zapytanie do serwera w odpowiedniej kolejności itd.
Każdy moduł przechowywany jest w zarządcy modułów za pomocą inteligentnych wskaźników (w tym przypadku z std::shared_ptr). Krótko mówiąc: jest to zwykły stos.
Warstwa klienta przetrzymuje tylko uchwyt (std::weak_ptr) do danego modułu. Po wykonaniu asynchronicznej operacji moduł komunikuje się z klientem za pośrednictwem callbacka.
W tym przypadku nie korzystamy z typowych interfejsów, czyli klasy z abstrakcyjnymi metodami, lecz wykorzystujemy pojedyncze obiekty typu std::function. Dzięki temu w przypadku testów nie musimy tworzyć wielu klas z różnymi wariantami metod – po prostu podmieniamy pojedynczy obiekt std::function, co redukuje ilość pisanego kodu.
Obecnie część ta posiada wsparcie dla systemów iOS, Android, Linux oraz macOS. Nie planujemy wsparcia dla platformy Windows, ponieważ wymagałoby to od nas dużych zmian w skryptach budujących, a nie widzimy w tym wartości biznesowej.
Fakt oddzielenia logiki biznesowej od warstwy UI ułatwia nam również pisanie testów jednostkowych. Dzięki odrzuceniu warstwy UI – zależnej przecież od platformy – separujemy się od wywoływania kodu zbędnego w praktyce testowania jednostkowego. Stwarza nam to również możliwość wykorzystania własnego, prostego, bazującego na asercjach narzędzia do wykonywania testów zamiast rozbudowanego frameworka do testów interfejsu użytkownika.
Aplikacja mobilna – klient
Drugi element aplikacji (w dalszej części artykułu określany jako część kliencka) dedykowany jest interfejsowi użytkownika i – w zależności od systemu operacyjnego – napisany został w innym języku.
W przypadku iOS-a wybór padł na Objective-C. Swift obecnie jest niestety krokiem wstecz w stosunku do Objective-C, jeśli mówimy o integracji z językiem C++ – musielibyśmy wrócić do pisania mostków.
W przypadku Androida zdecydowaliśmy się wykorzystać język Java. Wybór ten pociągnął za sobą konieczność napisania mostków w JNI. Z reguły mostek taki składa się z funkcji typu setCallbacks, w której ustawiamy wszystkie callbacki potrzebne do komunikacji warstwy UI z modułem. Dodatkowo zawarte są tam pozostałe, bardzo krótkie funkcje opakowujące publiczne metody dostępne w module.
Poniżej można zobaczyć przykładowy fragment modułu jednego z naszych produktów:
class OnboardingLocation : public Module { public: class Callbacks : public Module::Callbacks { public: Callbacks() = default; Callbacks(const Callbacks& pOther) = delete; Callbacks& operator=(const Callbacks& pOther) = delete; void setFetchRecommendedLocation(std::function<void(std::string lpLocation)> pCallback); void setGetLocations(std::function<void(std::vector<std::string> lpLocations)> pCallback); void setOpenOnboardingJobType(std::function<void(std::shared_ptr<hms::ext::ModuleShared> lpModule)> pCallback); void setValidation(std::function<void(bool lpValid)> pCallback); private: // private fields }; void openOnboardingJobType(); void close(); void fetchRecommendedLocation(); void getLocations(std::string pInput); void setActiveLocation(size_t pIndex); protected: OnboardingLocation(); virtual ~OnboardingLocation(); private: // private fields };
Hermes
Część serwerowa aplikacji wykorzystuje na szeroką skalę bibliotekę Hermes naszego autorstwa. Hermes odpowiada za kilka podstawowych zadań stawianych przed większością aplikacji mobilnych. Opublikowaliśmy kod tej biblioteki na GitHubie, zachęcam do zerknięcia: https://github.com/GrupaPracuj/hermes.
Hermes składa się z kilku modułów:
Network Manager
Odpowiada za obsługę zapytań sieciowych ze wsparciem dla grupowania ich pomiędzy poszczególnymi API. Dla każdej takiej grupy możemy zdefiniować tryb odtwarzania nieudanych zapytań, co jest szczególnie istotne np. przy odświeżaniu wygasłych tokenów.
Dostępna jest również funkcja pozwalająca w optymalny sposób wykonywać wiele podobnych zapytań naraz, np. pobranie kilku elementów graficznych.
Redukcji wykonywania zbędnych zapytań służy mechanizm cache dla odebranych danych.
Do samego przetwarzania zapytań „pod maską” korzystamy z biblioteki libcurl, która jest jedną z najpopularniejszych na rynku służących do wykonywania tego typu operacji.
Task Manager
Deleguje zadania na odpowiednie wątki wchodzące w skład zdefiniowanych przez programistę puli.
Odpowiada on również za wykonywanie zadań z opóźnieniem czy za wymuszanie przetwarzania danego zadania na wątku głównym.
Data Manager
Podstawowym zadaniem tego modułu jest po prostu przechowywanie poszczególnych obiektów z danymi.
Dodatkowo oferuje on możliwość szyfrowania w bardzo prosty sposób danych zapisywanych na dysku, a następnie – rzecz jasna – ich odczytywania oraz deszyfrowania.
Rozszerzenia
Oprócz tego Hermes oferuje szereg innych rozszerzeń. Jednym z nich jest wspomniany w sekcji opisującej część serwerową naszej aplikacji komponent nazwany modułem. Moduł to klasa zawierająca metody do przetwarzania logiki biznesowej oraz callbacki, dzięki którym odpowiedź jest przekazywana do warstwy UI.
Warto także wymienić serializer oraz deserializer, które znacząco upraszczają proces przetwarzania wysyłanych oraz otrzymywanych danych w formacie JSON (nic nie stoi na przeszkodzie, aby w prosty sposób dodać również obsługę innych formatów, np. XML).
W formie rozszerzeń oferowanych w bibliotece Hermes przygotowaliśmy również zestaw metod pomocniczych do pisania mostków w JNI oraz integrację wbudowanego Data Managera z androidowymi assetami.
Hermes wykorzystywany jest również z powodzeniem w aplikacji mobilnej Pracuj.pl dla systemów iOS oraz Android.
LUA
Istotną częścią aplikacji jest też skrypt napisany w języku LUA. Odpowiedzialny jest on za wyliczanie stawek podatkowych.
Skrypt ten jest wykorzystywany zarówno przez aplikację mobilną, jak i przez serwis WWW, dzięki czemu aktualizacja stawek jest prosta, a użytkownik ma gwarancję, że korzystając z naszej aplikacji na dowolnej platformie, otrzyma zawsze ten sam wynik. Wcześniej niestety nie mogliśmy tego zapewnić.
Obsługa skryptu LUA jest wpięta w część serwerową naszej aplikacji. Klient otrzymuje już gotowe do prezentacji kwoty.
Specyfika działania skryptów sprawia, że element ten może być niezależną częścią pliku wykonywalnego aplikacji, dzięki czemu możliwa jest jego aktualizacja niezależnie od reszty aplikacji.
Z takiego też rozwiązania skorzystaliśmy w naszym projekcie. Nawet osoby, które nie pobiorą najnowszej wersji aplikacji z App Store czy Google Play, będą posiadały aktualną wersję skryptu odpowiadającego za stawki podatkowe (proces aktualizacji wymaga chwilowego połączenia z Internetem).
Oczywiście oprócz języka LUA na rynku dostępnych jest wiele podobnych rozwiązań, choćby Angel Script czy Squirrel. Za wyborem języka LUA przemawia fakt, że przez wiele lat swojego istnienia zyskał on miano bardzo stabilnego rozwiązania, które z powodzeniem jest wykorzystywane niemal we wszystkich grach AAA.
Organizacja pracy
Aplikację mobilną rozwijało czterech programistów: dwóch dedykowanych dla platformy iOS oraz dwóch dla platformy Android.
Dwie osoby spośród tej czwórki mają również duże doświadczenie w pisaniu oprogramowania cross-platform w języku C++, co bardzo pomogło w wybranym przez nas procesie. Odpowiadali oni w głównej mierze za rozwój części serwerowej oraz biblioteki Hermes. Część kliencka aplikacji była z kolei pisana przez osoby dedykowane dla danych platform.
Wnioski
Przygotowanie aplikacji należących do Grupy Pracuj z wykorzystaniem wymienionych rozwiązań pozwoliło znacznie zredukować ilość kodu aplikacji – około 40% kodu jest wspólne. Udało nam się także zminimalizować niemal do zera ryzyko rozjazdu działania aplikacji na poszczególnych platformach od strony biznesowej.
Jak wynika ze statystyk, ponad połowa aplikacji dla danego systemu operacyjnego zawarta jest we wspólnej dla wszystkich systemów części serwerowej. Oczywiście pozytywnie wpływa to na czas potrzebny na napisanie aplikacji. Zysk jest tym większy, im większą liczbę platform planujemy wspierać.
Napisanie dedykowanego dla danej platformy kodu w przypadku części klienckiej umożliwia stworzenie interfejsu użytkownika wyglądającego w sposób naturalny dla danego systemu operacyjnego i zachowującego wszystkie jego funkcjonalności.
W zasadzie jedynym minusem, który w naszym przypadku dotyczył tylko Androida, była konieczność napisania mostków w JNI. Na pierwszy rzut oka może wydawać się to mało przyjazne rozwiązanie. Jednak w miarę szybko staje się całkiem intuicyjne.
Podsumowując…
Jesteśmy zadowoleni z podjętych decyzji i podjęlibyśmy je ponownie. Ta droga pozwoliła zrealizować projekt w terminie i budżecie, co nie zawsze się zdarza. :)
Zachęcamy do pobrania i przetestowania aplikacji (Android, iOS).
Warto również zerknąć na omawianą bibliotekę Hermes – można tam znaleźć dużo ciekawego kodu!