Dynamiczne moduły w Androidzie - #1 - jak zacząć?

29.10.2019 | Adam Nowicki

Wstęp

Google daje programistom możliwość generowania plików app bundle, żeby klient mógł pobrać z Google Play APK zoptymalizowane pod swój telefon. Dzięki temu nie musimy budować, podpisywać i wrzucać do sklepu kilku paczek, by użytkownik pobierał wszystko, a tylko to, czego rzeczywiście potrzebuje.

Dodatkową funkcjonalnością jest dynamiczne „dociąganie”  modułów aplikacji. Załóżmy, że mamy aplikację, której plik .apk zoptymalizowany pod dane urządzenie zajmuje 60MB i całkiem dobrze się sprawuje. Potrzebujemy jednak dodać do niej nową funkcjonalność opartą o zewnętrzne SDK, które np. pozwala używać kamery i przetwarza przechwycone obrazy. SDK waży 50MB i nie możemy wymóc na dostawcy zmniejszenia rozmiaru paczki. 

Jednak skanowanie obrazów nie jest główną funkcjonalnością naszej aplikacji. Może to aplikacja do zarządzania swoim kontem bankowym, w której dodatkiem jest skanowanie paragonu lub dowodu osobistego. Wykorzystując ideę dynamicznego ładowania modułu nie zmuszamy klientów do pobierania z Google Play od razu 110MB danych, a jedynie 60, których rzeczywiście potrzebuje. Dopiero podczas pierwszego użycia nowej funkcjonalności użytkownik zostanie grzecznie zapytany czy chce „dociągnąć” dane. 

Mechanizm od strony programistycznej jest dosyć prosty. Problemem jest testowanie - aby przejść pełną ścieżkę trzeba po każdej poprawce wgrywać plik do testów wewnętrznych w Google Play. Lokalnie możemy budować app bundle i użyć do instalacji bundletool lub skorzystać bezpośrednio z funkcji Run w Android Studio. Jednak w takich przypadkach nasz moduł będzie już w paczce i niczego nie trzeba będzie dociągać.

W dzisiejszym wpisie pokażę jak stworzyć dynamiczny moduł. W kolejnych chciałbym przybliżyć problemy związane z wykorzystaniem daggera, androidx Navigation Component oraz wykorzystania providerów w AndroidManifest.xml.

Nowy projekt

Tworzymy nowy projekt z Empty Activity. Jako Minimum API wybieramy na razie 5.0, bo tylko od tego API działa pobieranie dynamicznego modułu. Chcemy żeby nasza aplikacja miała dwie wersje - developerską i produkcyjną. Dodajemy więc flavour server:

Kolejnym krokiem jest dodanie dynamicznego modułu.

File -> New module -> Dynamic Feature Module

Nazwa wyświetlana podczas pobierania modułu ze sklepu: Feature .

Zaglądając w AndroidManifest modułu feature Android Studio wygenerowało nam odpowiedni wpis:

który mówi, że moduł będzie można pobrać na życzenie klienta oraz, że nie jest to moduł instant (inna ciekawa opcja używania aplikacji). Jeśli opcja fusing jest włączona to w systemie Android poniżej 5.0 moduł zostanie automatycznie dodany do APK. W przeciwnym wypadku nie będzie w ogóle możliwości używania feature.

Napotykamy na pierwszy problem: aktualnie przy próbie uruchomienia aplikacji w Android Studio (3.4.2) dostaniemy niewiele mówiące errory typu:

ERROR: Unable to resolve dependency for ':feature@debug/compileClasspath': Could not resolve project :app.

Problem w tym, że nasz dynamiczny moduł musi mieć takie same flavoury jak moduł app. Jeśli przekopiujemy powyższe linie do build.gradle z naszego dynamicznego modułu to wszystko powinno być OK.

Konfiguracja

Zajmijmy się pobieraniem naszego modułu. Potrzebujemy pobrać moduł, jednak nie chcemy robić tego osobiście. Zatrudnimy więc managera :) Klasa SplitInstallManager robi dokładnie to, czego potrzebujemy.

Żeby oddzielić logikę pobierania od widoku stworzymy osobną klasę:

Managera definiujemy jako pole w klasie, bo przyda nam się jeszcze później. Oprócz niego mamy metodę do pobierania modułu. Żeby była ona uniwersalna, przyjmuje moduleName i dwa callbacki. Jeden zwracający Activity, z którego wołana jest prośba o pobranie, drugi wywołany zostanie, gdy moduł zostanie pobrany lub gdy pobrany był już wcześniej.

Oprócz tego chcielibyśmy wiedzieć jaki jest postęp pobierania żeby pokazać użytkownikowi progressBar. Istnieje specjalny listener. Zobaczmy, jak wygląda:

Nasz manager musi go zarejestrować:

Listenera trzeba wyrejestrować przy przejściu w tło i ponownie zarejestrować przy powrocie. Musimy też zapisać potrzebne callbacki i nazwę modułu.

Jak pewnie zauważyliście, listener ma kilka statusów. Kolejność jest taka, że najpierw pobieramy moduł i - dla uproszczenia - logujemy w konsoli używając Timbera progress. Po udanej instalacji pobranego modułu wywołujemy callback i wracamy do Activity.

Jeśli jednak nasz moduł zajmuje więcej niż 10MB pamięci, powinniśmy użytkownika zapytać, czy rzeczywiście chce teraz go pobierać. Może ma słaby zasięg, niestabilne łącze lub mało pozostałych do użycia danych w swoim pakiecie. Rezultat otrzymamy w onActivityResult. Dlatego definiujemy też odpowiedni REQUEST_CODE. Jeśli użytkownik się zgodzi, manager kontynuuje pobieranie.

Po wszystkich opisanych zabiegach nasza klasa wygląda następująco:

Wróćmy do naszego activity. Najpierw kod:

Aby kodu z naszego dynamicznego modułu można było używać od razu, bez restartu aplikacji, trzeba wywołać

SplitCompat.install(this)

zarówno w Activity jaki w głównej klasie, dziedziczącej po Application.

W onCreate tworzymy obiekt FeatureModuleProxy, a po kliknieciu przycisku wywołujemy pobieranie. W onResume onPause, jak mówiliśmy wcześniej, rejestrujemy i wyrejestrowujemy listener. W dalszych częściach artykułu pokażę jak zrobić to ładniej.

Pozostaje nam już tylko wywołanie odpowiedniej funkcji z dynamicznego modułu po jego udanym pobraniu. Jako, że moduł app nie wie o istnieniu modułu feature i nie ma bezpośredniego dostępu do jego klas, musimy użyć refleksji.

Aby to zrobić deklarujemy interfejs Feature w module app.

W module feature ​dostarczamy jego implementację:

Teraz, w module app, w MainActivity możemy stworzyć obiekt klasy FeatureImpl i ustawić pobrany z niego tekst na textView:

Budowanie

Aby zbudować bundle'a możemy wyklikać odpowiednie opcje z menu Android Studio, lub użyć terminala. Skupmy się na tej drugiej opcji. Wywołujemy:

./gradlew clean bundleRelease

w wyniku czego powstaje plik .aab, który możemy wrzucić do Sklepu Play lub użyć lokalnie bundletool'a dostępnego tutaj. Plik znajduje się w katalogu app/build/outputs/bundle. Oczywiście nie będę tu pokazywać jak wysłać paczkę do Sklepu, zobaczycie za to jak użyć wspomnianego narzędzia.

java -jar bundletool-all-0.10.2.jar build-apks --bundle=app.aab --output=app.apks --ks=release.keystore --ks-pass=pass:xxxxxx --ks-key-alias=xxxxxxkey --key-pass=pass:xxxxxx

To polecenie zbuduje plik .apks, który można wrzucić na telefon:

java -jar bundletool-all-0.10.2.jar install-apks --apks=app.apks

W ten sposób jednak nie uda się użyć dynamicznego modułu, ponieważ lokalnie nie ma mechanizmu pobierania, a plików .apk jest tak naprawdę kilka.

Aby plik .apk był jeden, zawierający wszystkie nasze moduły musimy dodać do naszego polecenia budującego plik .apks

--mode=universal

Rozpakujemy więc obie powstałe paczki. Zawartość pierwszej wygląda mniej więcej tak:

Podczas gdy drugiej:

Spoglądając dodatkowo na rozmiary paczek widać, że w drugim przypadku w universal.apk powinno być wszystko, co możemy zaoferować użytkownikowi. W pierwszym przypadku natomiast to Sklep Play decyduje, co powinno zostać pobrane na telefon, na podstawie jego języka, rozdzielczości czy procesora.

Po uruchomieniu aplikacji i kliknięciu buttona możemy zobaczyć tekst "HelloFeature" co oznacza, że wszystko działa poprawnie, przynajmniej lokalnie :)

Podsumowanie

Teraz nie pozostaje nic innego jak wrzucić aplikację do sklepu i przetestować samo pobieranie modułu. Udanej pracy!

W kolejnej części tego artykułu pokażę Wam jak używac daggera pomiędzy modułami.