Task Scheduler Evolution



Die iFunny-Anwendung, an der wir arbeiten, ist seit mehr als fünf Jahren im Handel erhältlich. Während dieser Zeit musste das mobile Team viele verschiedene Ansätze und Migrationen zwischen Tools durchlaufen, und vor einem Jahr gab es eine Zeit, von einer selbstgeschriebenen Lösung zu wechseln und in Richtung etwas „Modischeres“ und Verbreiteteres zu schauen. Dieser Artikel ist ein kleiner Überblick darüber, was untersucht wurde, welche Lösungen geprüft wurden und was daraus entstanden ist.

Warum brauchen wir das alles?

Lassen Sie uns sofort entscheiden, was dieser Artikel ist und warum sich dieses Thema für das Android-Entwicklungsteam als wichtig herausgestellt hat:

  1. Es gibt viele Szenarien, in denen Sie Aufgaben außerhalb des Frameworks der aktiven Benutzeroberfläche ausführen müssen.
  2. Das System unterwirft dem Start solcher Aufgaben eine Vielzahl von Einschränkungen.
  3. Es stellte sich als ziemlich schwierig heraus, zwischen vorhandenen Lösungen zu wählen, da jedes Tool seine Vor- und Nachteile hat.

Chronologie der Entwicklung der Ereignisse


Android 0

AlarmManager, Handler, Service


Zunächst wurden ihre Lösungen implementiert, um auf Diensten basierende Hintergrundaufgaben zu starten. Es gab auch einen Mechanismus, der Aufgaben mit dem Lebenszyklus verknüpfte und sie abbrechen und wiederherstellen konnte. Dies passte lange Zeit zum Team, da die Plattform solchen Aufgaben keine Einschränkungen auferlegte.
Google hat empfohlen, dies anhand des folgenden Diagramms zu tun:



Ende 2018 macht es keinen Sinn, dies zu verstehen, es reicht aus, das Ausmaß der Katastrophe einzuschätzen.
Tatsächlich kümmerte es niemanden, wie viel Arbeit im Hintergrund passiert. Anwendungen machten, was sie wollten und wann sie wollten.

Vorteile :
überall verfügbar;
für alle zugänglich.

Nachteile :
Das System schränkt die Arbeit in jeder Hinsicht ein.
keine Starts nach Bedingung;
Die API ist minimal und Sie müssen viel Code schreiben.

Android 5. Lutscher

Jobcheduler


Nach 5 (!) Jahren, näher an 2015, stellte Google fest, dass Aufgaben ineffizient gestartet werden. Benutzer beschwerten sich regelmäßig darüber, dass ihre Telefone knapp wurden, indem sie einfach auf einem Tisch oder in der Tasche lagen.

Mit der Veröffentlichung von Android 5 ist ein Tool wie JobScheduler erschienen. Dies ist ein Mechanismus, mit dessen Hilfe es möglich ist, verschiedene Arbeiten im Hintergrund auszuführen, deren Beginn aufgrund des zentralisierten Systems zum Starten dieser Aufgaben und der Möglichkeit, Bedingungen für diesen Start festzulegen, optimiert und vereinfacht wurde.

Im Code sieht das alles ganz einfach aus: Es wird ein Dienst angekündigt, bei dem die Start- und Endereignisse eintreten.
Aus den Nuancen: Wenn Sie asynchron arbeiten möchten, müssen Sie von onStartJob aus den Stream starten. Die Hauptsache ist, nicht zu vergessen, die jobFinished-Methode am Ende der Arbeit aufzurufen. Andernfalls lässt das System WakeLock nicht los, Ihre Aufgabe wird nicht als erledigt betrachtet und geht verloren.

public class JobSchedulerService extends JobService { @Override public boolean onStartJob(JobParameters params) { doWork(params); return false; } @Override public boolean onStopJob(JobParameters params) { return false; } } 

Von überall in der Anwendung können Sie diese Arbeit initiieren. Aufgaben werden in unserem Prozess ausgeführt, aber auf IPC-Ebene initiiert. Es gibt einen zentralen Mechanismus, der ihre Ausführung steuert und die Anwendung nur zu den dafür erforderlichen Zeitpunkten aktiviert. Sie können auch verschiedene Triggerbedingungen festlegen und Daten über das Bundle übertragen.

 JobInfo task = new JobInfo.Builder(JOB_ID, serviceName) .setRequiredNetworkType(JobInfo.NETWORK_TYPE_UNMETERED) .setRequiresDeviceIdle(true) .setRequiresCharging(true) .build(); JobScheduler scheduler = (JobScheduler) context.getSystemService(JOB_SCHEDULER_SERVICE); scheduler.schedule(task); 

Im Allgemeinen war dies im Vergleich zu nichts bereits etwas. Dieser Mechanismus ist jedoch nur mit API 21 verfügbar, und zum Zeitpunkt der Veröffentlichung von Android 5.0 wäre es seltsam, die Unterstützung aller alten Geräte einzustellen (3 Jahre sind vergangen, und wir unterstützen immer noch vier).

Vorteile :
Die API ist einfach;
Startbedingungen.

Nachteile :
Verfügbar ab API 21
in der Tat nur mit API 23;
leicht einen Fehler zu machen.

Android 5. Lutscher

Gcm Netzwerkmanager


Ein Analogon von JobScheduler - GCM Network Manager wurde ebenfalls vorgestellt. Dies ist eine Bibliothek, die ähnliche Funktionen bietet, aber bereits mit API 9 funktioniert. Richtig, dafür sind Google Play Services erforderlich. Anscheinend wurde die für JobScheduler erforderliche Funktionalität nicht nur über die Android-Version, sondern auch auf GPS-Ebene bereitgestellt. Es sollte beachtet werden, dass die Entwickler des Frameworks ihre Meinung sehr schnell geändert haben und beschlossen haben, ihre Zukunft nicht mit GPS zu verbinden. Danke ihnen dafür.

Alles sieht absolut identisch aus. Gleicher Service:

 public class GcmNetworkManagerService extends GcmTaskService { @Override public int onRunTask(TaskParams taskParams) { doWork(taskParams); return 0; } } 

Der gleiche Aufgabenstart:

 OneoffTask task = new OneoffTask.Builder() .setService(GcmNetworkManagerService.class) .setTag(TAG) .setRequiredNetworkType(JobInfo.NETWORK_TYPE_UNMETERED) .setRequiresCharging(true) .build(); GcmNetworkManager mGcmNetworkManager = GcmNetworkManager.getInstance(this); mGcmNetworkManager.schedule(task); 

Diese Ähnlichkeit der Architektur wurde durch die ererbte Funktionalität und den Wunsch nach einer einfachen Migration zwischen Tools bestimmt.

Vorteile :
API ähnlich wie JobScheduler;
Verfügbar ab API 9.

Nachteile :
Sie müssen über Google Play Services verfügen
leicht einen Fehler zu machen.

Android 5. Lutscher

WakefulBroadcastReceiver


Als nächstes werde ich ein paar Worte über einen der grundlegenden Mechanismen schreiben, die in JobScheduler verwendet werden und Entwicklern direkt zur Verfügung stehen. Dies ist WakeLock und sein WakefulBroadcastReceiver.

Mit WakeLock können Sie verhindern, dass das System in Suspend bleibt, dh das Gerät aktiv bleibt. Dies ist notwendig, wenn wir wichtige Arbeit leisten wollen.
Beim Erstellen von WakeLock können Sie dessen Einstellungen festlegen: Halten Sie die CPU, den Bildschirm oder die Tastatur gedrückt.

 PowerManager pm = (PowerManager) context.getSystemService(Context.POWER_SERVICE) PowerManager.WakeLock wl = pm.newWakeLock(PARTIAL_WAKE_LOCK, "name") wl.acquire(timeout); 

Basierend auf diesem Mechanismus funktioniert der WakefulBroadcastReceiver. Wir starten den Service und halten WakeLock.

 public class SimpleWakefulReceiver extends WakefulBroadcastReceiver { @Override public void onReceive(Context context, Intent intent) { Intent service = new Intent(context, SimpleWakefulService.class); startWakefulService(context, service); } } 

Nachdem der Service die erforderlichen Arbeiten abgeschlossen hat, geben wir ihn mit ähnlichen Methoden frei.

In 4 Versionen wird dieser BroadcastReceiver veraltet und die folgenden Alternativen werden auf developer.android.com beschrieben:

  • JobScheduler;
  • Syncadapter
  • DownloadManager
  • FLAG_KEEP_SCREEN_ON für Window.

Android 6. Marshmallow

DozeMode: Unterwegs schlafen


Dann begann Google, verschiedene Optimierungen für Anwendungen anzuwenden, die auf dem Gerät ausgeführt werden. Was für den Benutzer jedoch eine Optimierung ist, ist eine Einschränkung für den Entwickler.

Der erste Schritt war DozeMode, der das Gerät in den Ruhemodus versetzt, wenn es für eine bestimmte Zeit im Leerlauf liegt. In den ersten Versionen dauerte es eine Stunde, in den nachfolgenden Versionen wurde die Schlafdauer auf 30 Minuten reduziert. In regelmäßigen Abständen wacht das Telefon auf, führt alle anstehenden Aufgaben aus und schläft wieder ein. Das DozeMode-Fenster wird exponentiell erweitert. Alle Übergänge zwischen Modi können über adb verfolgt werden.

Wenn DozeMode auftritt, gelten für die Anwendung die folgenden Einschränkungen:

  • Das System ignoriert alle WakeLock.
  • AlarmManager ist verzögert;
  • JobScheduler funktioniert nicht;
  • SyncAdapter funktioniert nicht.
  • Der Netzwerkzugriff ist begrenzt.

Sie können Ihre Anwendung auch zur Whitelist hinzufügen, damit sie nicht unter die Einschränkungen von DozeMode fällt, aber Samsung hat diese Liste zumindest vollständig ignoriert.

Android 6. Marshmallow

AppStandby: Inaktive Anwendungen


Das System identifiziert inaktive Anwendungen und legt ihnen dieselben Einschränkungen wie in DozeMode auf.
Eine Anwendung wird an die Isolation gesendet, wenn:

  • hat keinen Prozess im Vordergrund;
  • hat keine aktive Benachrichtigung;
  • nicht zur Ausschlussliste hinzugefügt.

Android 7. Nougat

Hintergrundoptimierungen. Svelte


Svelte ist ein Projekt, in dem Google versucht, den RAM-Verbrauch durch Anwendungen und das System selbst zu optimieren.
In Android 7 wurde im Rahmen dieses Projekts entschieden, dass implizite Broadcasts nicht sehr effektiv sind, da sie von einer großen Anzahl von Anwendungen abgehört werden und das System bei Auftreten dieser Ereignisse eine große Menge an Ressourcen verbraucht. Daher wurden die folgenden Arten von Ereignissen für die Deklaration im Manifest verboten:

  • CONNECTIVITY_ACTION;
  • ACTION_NEW_PICTURE;
  • ACTION_NEW_VIDEO.

Android 7. Nougat

FirebaseJobDispatcher


Gleichzeitig wurde eine neue Version des Task-Start-Frameworks veröffentlicht - FirebaseJobDispatcher. Tatsächlich war es der fertige GCM NetworkManager, der etwas aufgeräumt und etwas flexibler gemacht wurde.

Optisch sah alles genau gleich aus. Gleicher Service:

 public class JobSchedulerService extends JobService { @Override public boolean onStartJob(JobParameters params) { doWork(params); return false; } @Override public boolean onStopJob(JobParameters params) { return false; } } 

Der einzige Unterschied zwischen ihm war die Möglichkeit, seinen Treiber zu installieren. Ein Treiber ist die Klasse, die für die Task-Startstrategie verantwortlich war.

Der Start von Aufgaben selbst hat sich im Laufe der Zeit nicht geändert.

 FirebaseJobDispatcher dispatcher = new FirebaseJobDispatcher(new GooglePlayDriver(context)); Job task = dispatcher.newJobBuilder() .setService(FirebaseJobDispatcherService.class) .setTag(TAG) .setConstraints(Constraint.ON_UNMETERED_NETWORK, Constraint.DEVICE_IDLE) .build(); dispatcher.mustSchedule(task); 

Vorteile :
API ähnlich wie JobScheduler;
Verfügbar ab API 9.

Nachteile :
Sie müssen über Google Play Services verfügen
leicht einen Fehler zu machen.

Es war ermutigend, meinen Treiber zu installieren, um GPS loszuwerden. Wir haben sogar gesucht, aber schließlich Folgendes gefunden:





Google weiß das, aber diese Aufgaben bleiben mehrere Jahre offen.

Android 7. Nougat

Android Job von Evernote


Infolgedessen konnte die Community es nicht ertragen, und eine selbst erstellte Lösung erschien in Form einer Bibliothek von Evernote. Es war nicht die einzige, aber es war die Lösung von Evernote, die sich etablieren und „in die Menschen hineinkommen“ konnte.

In architektonischer Hinsicht war diese Bibliothek praktischer als ihre Vorgänger.
Die für das Erstellen von Aufgaben verantwortliche Entität wurde angezeigt. Im Fall von JobScheduler wurden sie durch Reflexion erstellt.

 class SendLogsJobCreator : JobCreator { override fun create(tag: String): Job? { when (tag) { SendLogsJob.TAG -> return SendLogsJob() } return null } } 

Es gibt eine separate Klasse, die die Aufgabe selbst ist. In JobScheduler wurde dies alles in einem Schalter in onStartJob gespeichert.

 class SendLogsJob : Job() { override fun onRunJob(params: Params): Result { return doWork(params) } } 

Der Start von Aufgaben ist identisch, aber zusätzlich zu den geerbten Ereignissen hat Evernote auch eigene hinzugefügt, z. B. das Starten von täglichen Aufgaben, eindeutigen Aufgaben und das Starten innerhalb des Fensters.

 new JobRequest.Builder(JOB_ID) .setRequiresDeviceIdle(true) .setRequiresCharging(true) .setRequiredNetworkType(JobRequest.NetworkType.UNMETERED) .build() .scheduleAsync(); 

Vorteile :
bequeme API;
wird in allen Versionen unterstützt;
Benötigen Sie keine Google Play Services.

Nachteile :
Drittanbieterlösung.

Die Jungs haben ihre Bibliothek aktiv unterstützt. Obwohl es einige kritische Probleme gab, funktionierte es auf allen Versionen und auf allen Geräten. Aus diesem Grund hat sich unser Android-Team letztes Jahr für eine Lösung von Evernote entschieden, da die Bibliotheken von Google eine große Anzahl von Geräten geschnitten haben, die sie nicht unterstützen können.
Im Inneren arbeitete sie im Extremfall an Lösungen von Google - mit AlarmManager.

Android 8. Oreo

Hintergrundausführungsgrenzen


Kehren wir zu unseren Grenzen zurück. Mit dem Aufkommen des neuen Android sind neue Optimierungen gekommen. Die Jungs von Google haben ein anderes Problem gefunden. Diesmal stellte sich heraus, dass das Ganze in Diensten und Sendungen zu sehen war (ja, nichts Neues).

  • startService wenn Anwendungen im Hintergrund
  • implizite Ausstrahlung im Manifest

Erstens war es verboten, Dienste im Hintergrund zu starten. Im "Rahmen des Gesetzes" blieben nur Vordergrundleistungen. Services können jetzt als veraltet bezeichnet werden.
Die zweite Einschränkung ist dieselbe Sendung. Diesmal war es verboten, ALLE impliziten Broadcasts im Manifest zu registrieren. Implizite Sendung ist eine Sendung, die nicht nur für unsere Anwendung bestimmt ist. Beispielsweise gibt es die Aktion ACTION_PACKAGE_REPLACED und die Aktion ACTION_MY_PACKAGE_REPLACED. Der erste ist also implizit.

Jede Sendung kann jedoch weiterhin über Context.registerBroadcast registriert werden.

Android 9. Pie

Arbeitsmanager


Auf diese Optimierung hat noch aufgehört. Vielleicht fingen die Geräte an, schnell und sorgfältig im Hinblick auf den Energieverbrauch zu arbeiten. Vielleicht haben sich die Benutzer weniger darüber beschwert.
In Android 9 näherten sich die Entwickler des Frameworks dem Tool zum Starten von Aufgaben gründlich. Um alle dringenden Probleme zu lösen, wurde in Google I / O eine Bibliothek zum Starten der Hintergrundaufgaben von WorkManager eingeführt.

Google hat kürzlich versucht, seine Vision von der Architektur der Android-Anwendung zu gestalten, und bietet Entwicklern die dafür erforderlichen Tools. Es gab also Architekturkomponenten mit LiveData, ViewModel und Room. WorkManager scheint eine vernünftige Ergänzung zu ihrem Ansatz und Paradigma zu sein.

Wenn wir darüber sprechen, wie der WorkManager im Inneren angeordnet ist, gibt es keinen technologischen Durchbruch. Tatsächlich ist dies ein Wrapper bestehender Lösungen: JobScheduler, FirebaseJobDispatcher und AlarmManager.

createBestAvailableBackgroundScheduler
 static Scheduler createBestAvailableBackgroundScheduler(Context, WorkManager) { if (Build.VERSION.SDK_INT >= MIN_JOB_SCHEDULER_API_LEVEL) { return new SystemJobScheduler(context, workManager); } try { return tryCreateFirebaseJobScheduler(context); } catch (Exception e) { return new SystemAlarmScheduler(context); } } 


Der Auswahlcode ist ziemlich einfach. Es sollte jedoch beachtet werden, dass JobScheduler ab API 21 verfügbar ist, diese jedoch nur mit API 23 verwenden, da die ersten Versionen eher instabil waren.

Wenn die Version niedriger als 23 ist, versuchen wir durch Reflexion, FirebaseJobDispatcher zu finden, andernfalls verwenden wir AlarmManager.

Es ist erwähnenswert, dass der Wrapper ziemlich flexibel herauskam. Diesmal haben die Entwickler alles in separate Einheiten aufgeteilt, und architektonisch sieht es praktisch aus:

  • Arbeiter - Arbeitslogik;
  • WorkRequest - Logik des Taskstarts;
  • WorkRequest.Builder - Parameter;
  • Einschränkungen - Bedingungen;
  • WorkManager - ein Manager, der Aufgaben verwaltet;
  • WorkStatus - Aufgabenstatus.




Die Startbedingungen wurden von JobScheduler übernommen.
Es ist zu beachten, dass der Auslöser zum Ändern des URI nur mit API 23 angezeigt wurde. Darüber hinaus können Sie die Änderung nicht nur eines bestimmten URI, sondern auch aller darin verschachtelten URIs mithilfe des Flags in der Methode abonnieren.

Wenn wir über uns sprechen, wurde in der Alpha-Phase beschlossen, auf WorkManager umzusteigen.
Dafür gibt es mehrere Gründe. Evernote weist einige kritische Fehler auf, die die Entwickler der Bibliothek beim Übergang zu einer Version mit integriertem WorkManager beheben möchten. Und sie selbst sind sich einig, dass die Entscheidung von Google die Vorteile von Evernote zunichte macht. Darüber hinaus passt diese Lösung gut zu unserer Architektur, da wir Architekturkomponenten verwenden.

Darüber hinaus möchte ich anhand eines einfachen Beispiels zeigen, wie wir versuchen, diesen Ansatz zu verwenden. Gleichzeitig ist es nicht sehr wichtig, ob Sie einen WorkManager oder einen JobScheduler haben.



Schauen wir uns ein Beispiel mit einem sehr einfachen Fall an: Klicken Sie auf Neu veröffentlichen oder Ähnliches.

Jetzt versuchen alle Anwendungen, Anfragen an das Netzwerk nicht zu blockieren, da dies den Benutzer nervös macht und ihn warten lässt, obwohl er zu diesem Zeitpunkt innerhalb der Anwendung Einkäufe tätigen oder Anzeigen ansehen kann.

In solchen Fällen ändern sich zuerst die lokalen Daten - der Benutzer sieht sofort das Ergebnis seiner Aktion. Dann gibt es im Hintergrund eine Anfrage an den Server. Wenn dies fehlschlägt, werden die Daten auf ihren Ausgangszustand zurückgesetzt.

Als nächstes werde ich ein Beispiel zeigen, wie es bei uns aussieht.

JobRunner enthält die Logik zum Starten von Aufgaben. Seine Methoden beschreiben die Konfiguration von Aufgaben und übergeben Parameter.

JobRunner.java
 fun likePost(content: IFunnyContent) { val constraints = Constraints.Builder() .setRequiredNetworkType(NetworkType.CONNECTED) .build() val input = Data.Builder() .putString(LikeContentJob.ID, content.id) .build() val request = OneTimeWorkRequest.Builder(LikeContentJob::class.java) .setInputData(input) .setConstraints(constraints) .build() WorkManager.getInstance().enqueue(request) } 


Die Aufgabe selbst im WorkManager lautet wie folgt: Wir nehmen die ID aus den Parametern und rufen die Methode auf dem Server auf, um diesen Inhalt zu mögen.

Wir haben eine Basisklasse, die die folgende Logik enthält:

 abstract class BaseJob : Worker() { final override fun doWork(): Result { val workerInjector = WorkerInjectorProvider.injector() workerInjector.inject(this) return performJob(inputData) } abstract fun performJob(params: Data): Result } 

Erstens können Sie sich ein wenig vom expliziten Wissen von Worker entfernen. Es enthält auch die Abhängigkeitsinjektionslogik über WorkerInjector.

WorkerInjectorImpl.java
 @Singleton public class WorkerInjectorImpl implements WorkerInjector { @Inject public WorkerInjectorImpl() {} @Ovierride public void inject(Worker job) { if (worker instanceof AppCrashedEventSendJob) { Injector.getAppComponent().inject((AppCrashedEventSendJob) job); } else if (worker instanceof CheckNativeCrashesJob) { Injector.getAppComponent().inject((CheckNativeCrashesJob) job); } } } 


Es überträgt einfach Anrufe an Dagger, hilft uns jedoch beim Testen: Wir ersetzen Injektorimplementierungen und implementieren die erforderliche Umgebung in Aufgaben.

 fun void testRegisterPushProvider() { WorkManagerTestInitHelper.initializeTestWorkManager(context) val testDriver = WorkManagerTestInitHelper.getTestDriver() WorkerInjectorProvider.setInjector(TestInjector()) // mock dependencies val id = jobRunner.runPushRegisterJob() testDriver.setAllConstraintsMet(id) Assert.assertTrue(…) } 

 class LikePostInteractor @Inject constructor( val iFunnyContentDao: IFunnyContentDao, val jobRunner: JobRunner) : Interactor { fun execute() { iFunnyContentDao.like(getContent().id) jobRunner.likePost(getContent()) } } 

Interactor ist die Entität, die der ViewController abruft, um die Übergabe des Skripts zu initiieren (in diesem Fall ähnlich). Wir markieren den Inhalt lokal als "hochgeladen" und senden die Aufgabe zur Ausführung. Wenn die Aufgabe fehlschlägt, wird das Gleiche entfernt.

 class IFunnyContentViewModel(val iFunnyContentDao: IFunnyContentDao) : ViewModel() { val likeState = MediatorLiveData<Boolean>() var iFunnyContentId = MutableLiveData<String>() private var iFunnyContentState: LiveData<IFunnyContent> = attachLiveDataToContentId(); init { likeState.addSource(iFunnyContentState) { likeState.postValue(it!!.hasLike) } } } 

Wir verwenden die Architekturkomponenten von Google: ViewModel und LiveData. So sieht unser ViewModel aus. Hier verbinden wir die Aktualisierung des Objekts im DAO mit dem Status like.

IFunnyContentViewController.java
 class IFunnyContentViewController @Inject constructor( private val likePostInteractor: LikePostInteractor, val viewModel: IFunnyContentViewModel) : ViewController { override fun attach(view: View) { viewModel.likeState.observe(lifecycleOwner, { updateLikeView(it!!) }) } fun onLikePost() { likePostInteractor.setContent(getContent()) likePostInteractor.execute() } } 


ViewController abonniert einerseits das Ändern des Status von Ähnlichem und initiiert andererseits die Übergabe des Skripts, das wir benötigen.

Und das ist praktisch der gesamte Code, den wir benötigen. Es bleibt, das Verhalten der Ansicht selbst mit dem Gleichen und der Implementierung Ihres DAO hinzuzufügen; Wenn Sie Room verwenden, registrieren Sie einfach die Felder im Objekt. Es sieht ziemlich einfach und effektiv aus.

Zusammenfassend


JobScheduler, GCM Network Manager, FirebaseJobDispatcher:

  • benutze sie nicht
  • Lies keine Artikel mehr darüber
  • Berichte nicht ansehen
  • Ich denke nicht, welches ich wählen soll.

Android Job von Evernote:

  • Im Inneren verwenden sie den WorkManager.
  • Kritische Fehler verschwimmen zwischen den Lösungen.

WorkManager:

  • API LEVEL 9+;
  • unabhängig von Google Play Services;
  • Verkettung / InputMergers;
  • reaktiver Ansatz;
  • Unterstützung von Google (ich möchte es glauben).

Source: https://habr.com/ru/post/de434680/


All Articles