Зачем нужен dagger

О чем будет этот курс

Для большинства разработчиков производительность — это последнее, чем они будут заниматься в процессе разработки приложения. О производительности обычно вспоминают, когда она становится реальной проблемой, от которой уже никак не получается отмахнуться.

Но пользователи замечают признаки плохой производительности приложения гораздо лучше, чем новые фичи. Наиболее распространенная причина плохих отзывов на маркете — это плохая производительность и баги

Поэтому очень важно уделять внимание скорости и эффективности работы приложения

В этом курсе мы подробно разберем доступные нам инструменты для поиска проблем производительности. Научимся пользоваться ими и понимать данные, которые они нам предоставляют. Более подробно об этом вы можете прочитать в первом бесплатном уроке.

Какую пользу принесут вам эти знания?

С большой вероятностью на вашем текущем проекте вы сможете стать единоличным гуру производительности, т.к. мало кто из разработчиков всерьез интересуется этой темой.

В ваше резюме можно будет добавлять пункт Application Performance, а на собеседованиях вы будете рассказывать о том, как на текущем проекте вы нашли и пофиксили множество мемори ликов, ускорили старт приложения в два раза и избавились от тормозов при скролле списка. При этом вы сможете подробно описать, какие именно действия вы предпринимали и какие инструменты использовали. Все это будет вам огромным плюсом в глазах будущего работодателя.

Пару раз меня на собеседовании спрашивали, какие я знаю инструменты для выявления проблем производительности. А также просили перечислить правила оптимизации, которых я придерживаюсь при создании приложений.

Ну и в целом, производительность — это интересная тема. Можно увидеть работу приложения изнутри. Например — подробный стек вызова методов с указанием, сколько времени выполнялся каждый метод. Или содержимое памяти приложения — какие там сейчас есть объекты, и кто на кого держит ссылку. Или все сетевые запросы с указанием их содержания, стека вызова и потока.

Корутина

В самом начале документации и статей обычно рассматривается такой простой пример:

launch {
    delay(1000L)
    println("World!")
}

Здесь launch — это билдер корутины, которому передается блок кода. Билдер упакует переданный ему блок кода в корутину и запустит ее. 

Когда мы запускаем корутину, мы можем получить Job, как результат запуска билдера:

val job = launch {
    delay(1000L)
    println("World!")
}

Это дает нам некоторые возможности по управлению корутиной. Например, мы можем сделать выполнение кода отложенным (lazy) и стартовать его позже, когда понадобится. Или в любой момент времени можно будет отменить выполнение. Также можно запускать корутину внутри корутины. Их джобы будут связаны между собой отношениями Parent-Child, что является отдельным механизмом, который влияет на обработку ошибок и отмену корутин.

Подключение к проекту

В build.gradle файл проекта добавьте репозитарий google()

allprojects {
    repositories {
        jcenter()
        google()
    }
    ...
}

В build.gradle файле модуля добавьте dependencies:

dependencies {
    implementation "android.arch.persistence.room:runtime:1.0.0"
    annotationProcessor "android.arch.persistence.room:compiler:1.0.0"
    ...
}

Если у вас студия ниже 3.0 и старые версии Gradle и Android Plugin, то подключение будет выглядеть так:

buildscript {
    repositories {
        jcenter()
        maven { url 'https://maven.google.com' }
    }
    ...
}

и так:

dependencies {
    compile "android.arch.persistence.room:runtime:1.0.0"
    annotationProcessor "android.arch.persistence.room:compiler:1.0.0"
    ...
}

Выявление ошибок

К плюсам даггера относят то, что если у вас есть какая-то ошибка в построении зависимостей, то вы узнаете об этом не в Runtime, а на этапе компиляции. Давайте проверим. Создадим еще один пустой класс Preferences.

public class Preferences {
    
}

И добавим в MainActivity переменную этого типа с аннотацией Inject:

@Inject
Preferences preferences; 

Теперь компонент при инджекте должен создать объект Preferences, но мы не добавили создание этого объекта в модули. И компонент просто не знает откуда его взять. 

Пытаемся скомпилировать. И получаем ошибку:Error:(24, 10) error: Preferences cannot be provided without an @Inject constructor or from an @Provides- or @Produces-annotated method.

Компилятор вполне закономерно жалуется, что не знает, откуда компоненту взять объект Preferences.

Action

При вызове destination нам может понадобиться задать некоторые параметры, например, анимацию, аргументы и т.п. Для этого используется action.

Давайте создадим action, который будет выполнять переход от fragment1 к fragment2

Для destination fragment1 мы создали action, который ведет в destination fragment2.

У action есть различные параметры, которые мы можем настраивать в редакторе графа. Они будут использованы при переходе от destination fragment1 к destination fragment2.

Мы разберем их подробно в следующих уроках. Пока нас снова интересует только значение атрибута ID. Мы можем использовать его при вызове метода navigate, чтобы вызвать action. Давайте сделаем это по нажатию на кнопку Next в Fragment1.

@Override
public void onFragment1NextClick() {
   navController.navigate(R.id.action_fragment1_to_fragment2);
}

Контроллер сделает следующее: 1) возьмет текущий destination (который сейчас отображается в контейнере, т.е. destination fragment1)2) найдет у него action с ID = action_fragment1_to_fragment23) определит, что этот action ведет в destination fragment24) определит, что destination fragment2 — это фрагмент Fragment25) отобразит Fragment2 и при этом применит параметры, которые были заданы в action_fragment1_to_fragment2

Если мы попытаемся вызвать action не находясь в destination, которому этот action принадлежит, то будет ошибка. Т.е. action action_fragment1_to_fragment2 мы можем вызывать только находясь в destination fragment1, потому что при создании action мы рисовали его из destination fragment1.

Из одного destination можно создать несколько action:

Использование одного из нескольких источников

Используемые операторы: concat, first, filter

Это вполне распространенный сценарий, когда нам необходимо получать данные. Мы смотрим сначала в кэш, если там пусто, то смотрим в БД, если и там пусто, то идем на сервер.

У нас есть три репозитория, которые возвращают нам список пользователей. Мы получаем от них три Observable.

Observable<List<User>> cacheUsers = cacheRepository.getUsers();
Observable<List<User>> dbUsers = databaseRepository.getUsers();
Observable<List<User>> networkUsers = networkRepository.getUsers();

Если данные в репозитории есть, то Observable отправит их нам в (onNext) и завершит работу (onCompleted). Если же данных нет, то Observable сразу вызовет onCompleted.

Соединяем три Observable с помощью .

Observable.concat(cacheUsers, dbUsers, networkUsers)
       .first(Collections.emptyList())
       .subscribe(new Consumer<List<User>>() {
           @Override
           public void accept(List<User> users) throws Exception {
               showUsers(users);
           }
       });

Оператор concat будет последовательно получать данные из этих Observable и передавать их дальше. Т.е. сначала пойдут данные из cacheUsers, затем из dbUsers, затем из networkUsers. Если в каком-то из Observable нет данных, он будет просто пропущен.

Может так случиться, что во всех трех Observable будут данные. И мы получим их всех. Чтобы избежать этого и получить только одни данные, мы используем оператор . Он пропустит только первые данные, а затем завершит всю цепочку.

Т.е. если в cacheUsers были данные, мы получим их, а остальное (dbUsers и networkUsers) будет проигнорировано.

Если в cacheUsers данных не было, но они были в dbUsers, то мы получим их, а networkUsers будет проигнорирован.

А если cacheUsers и dbUsers были пусты, то мы получим данные из networkUsers.

Если все три Observable ничего не вернули, то мы получим пустой список, который мы указали, как дефолтное значение, в операторе first.

Лямбда запись:

Observable.concat(cacheUsers, dbUsers, networkUsers)
       .first(Collections.emptyList())
       .subscribe(users -> showUsers(users));

Может быть так, что Observable в случае отсутствия данных выполняет не onCompleted, а onNext с пустым списком. Тогда concat из предыдущего примера вернет нам этот пустой список. Это неправильно. Нам надо игнорировать пустой список и смотреть следующий репозиторий.

В этом случае нам поможет оператор , который не пропустит пустые списки.

Observable.concat(cacheUsers, dbUsers, networkUsers)
       .filter(new Predicate<List<User>>() {
           @Override
           public boolean test(List<User> users) throws Exception {
               return !users.isEmpty();
           }
       })
       .first(Collections.emptyList())
       .subscribe(new Consumer<List<User>>() {
           @Override
           public void accept(List<User> users) throws Exception {
               showUsers(users);
           }
       });

Лямбда запись:

Observable.concat(cacheUsers, dbUsers, networkUsers)
       .filter(users -> !users.isEmpty())
       .first(Collections.emptyList())
       .subscribe(users -> showUsers(users));

Сложная тема

Не раз я слышал мнение, что официальная документация по корутинам сложна и представляет собой примерно такое:

Я, пожалуй, соглашусь с этим мнением. Нас сразу грузят билдерами, скоупами и suspend функциями. Говорят, что их надо использовать так-то и так-то и будет нам счастье. И вроде даже объясняют, что это такое, но особо понятнее не становится. Но даже несмотря на это я рекомендую вам посмотреть эту документацию, чтобы получить хотя бы примерное представление, что такое корутины и зачем они нужны.

В защиту авторов документации я должен сказать, что тема действительно очень сложна для объяснения. Для меня корутины по сложности легко обошли такие непростые темы, как Dagger или Backpressure в RxJava. Я потратил кучу времени, чтобы разобраться, что же такое корутины и как они работают. Читал официальные доки, делал примеры, читал статьи, смотрел видео. И все равно у меня оставалось ощущение, что я не понимаю их до конца сам, а значит не могу объяснить другим. Пришлось прибегнуть к последнему, самому надежному и самому сложному средству — лезть в исходники. Врагу не пожелаю туда соваться, но в итоге я таки пробился через эти дебри и постепенно пазл собрался.

В этом курсе я собираюсь достаточно подробно осветить тему корутин, используя для этого все то, что мне удалось раскопать. Я буду шаг за шагом расписывать отдельные кусочки пазла и периодически собирать эти кусочки в большие куски, объясняя очередную тему. В итоге у вас должна сложиться общая картина. Иногда может показаться, что я слишком ухожу в дебри объяснений, и эта информация не нужна вовсе, и давайте лучше сразу с боевых примеров начнем! Но тут я исхожу из своего опыта. Мне удалось полностью понять примеры только после того, как я раскопал внутренности. И сейчас моя цель — сделать так, чтобы вы, посмотрев на код с корутинами, могли точно сказать, как он себя поведет. А для этого нужно понять корутины изнутри.

Рекомендую не пропускать уроки и идти по ним последовательно, чтобы в последующих уроках все было понятно. А если какой-то урок или раздел можно будет пропустить, я явно напишу об этом.

Подключение даггера к проекту

Создайте новый проект. Чтобы использовать даггер, добавьте в раздел dependencies файла build.gradle вашего модуля:

    compile 'com.google.dagger:dagger:2.7'
    annotationProcessor 'com.google.dagger:dagger-compiler:2.7'

Если не работает, то удалите.

И попробуйте добавить в конец файла build.gradle вашего модуля строки:

// Add plugin https://bitbucket.org/hvisser/android-apt
buildscript {
    repositories {
        mavenCentral()
    }
    dependencies {
        classpath 'com.neenbedankt.gradle.plugins:android-apt:1.8'
    }
}

// Apply plugin
apply plugin: 'com.neenbedankt.android-apt'

// Add Dagger dependencies
dependencies {
    compile 'com.google.dagger:dagger:2.7'
    apt 'com.google.dagger:dagger-compiler:2.7'
}

Если вдруг у вас что-то не работает, то воспользуйтесь этим готовым рабочим проектом.

В качестве объектов, которые мы будем запрашивать от даггера, используем пару классов: DatabaseHelper и NetworkUtils.

public class DatabaseHelper {
  
}
public class NetworkUtils {

}

Их реализация нам сейчас не важна, оставляем их пустыми.

Предположим, что эти объекты будут нужны нам в MainActivity.

public class MainActivity extends Activity {

    DatabaseHelper databaseHelper;
    NetworkUtils networkUtils;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
    }
}

Чтобы получить их с помощью даггера, нам нужно создать модули и компонент.

Создаем модули, которые будут уметь предоставлять требуемые объекты. Именно в модулях мы и пишем весь код по созданию объектов. Это обычные классы, но с парой аннотаций:

@Module
public class NetworkModule {

    @Provides
    NetworkUtils provideNetworkUtils() {
        return new NetworkUtils();
    }

}
@Module
public class StorageModule {

    @Provides
    DatabaseHelper provideDatabaseHelper() {
        return new DatabaseHelper();
    }

}

Аннотацией @Module мы сообщаем даггеру, что этот класс является модулем. А аннотация @Provides указывает, что метод является поставщиком объекта и компонент может использовать его, чтобы получить объект.Технически можно было вполне обойтись и одним модулем. Но логичнее будет разделить объекты на модули по их смыслу и области применения.Модули готовы, теперь создаем компонент. Для этого нам необходимо создать интерфейс

@Component()
public interface AppComponent {

}

Данный интерфейс описывает пустой компонент, который пока ничего не будет уметь. При компиляции проекта, даггер найдет этот интерфейс по аннотации @Component и сгенерирует класс DaggerAppComponent (имя класса = слово Dagger + имя интерфейса), которые реализует этот интерфейс. Это и будет класс компонента.

Все что от нас требуется — наполнить интерфейс методами. Этим мы дадим понять компоненту, какие объекты он должен уметь нам возвращать. А при сборе проекта даггер уже сам их реализует в сгенерированном классе компонента.

Компонент может возвращать нам объекты двумя способами. Первый — это обычные get-методы. Т.е. мы просто вызываем метод, который вернет нам объект. Второй способ интереснее, это inject-методы. В этом случае мы передаем компоненту экземпляр Activity, и компонент сам заполняет там все необходимые поля, создавая необходимые объекты.

Рассмотрим оба способа на примерах.

Повтор при ошибке

Используемые операторы: retryWhen, take, delay, range, zip, just, error, flatMap

У нас есть метод getUsers, который возвращает список пользователей.

Observable<List<User>> getUsers();

Мы можем настроить Observable так, чтобы при ошибке он перезапускался определенное количество раз и через определенное время.

Для этого используется оператор

repository.getUsers()
       .retryWhen(new Function<Observable<Throwable>, ObservableSource<?>>() {
           @Override
           public ObservableSource<?> apply(Observable<Throwable> throwableObservable) throws Exception {
               return throwableObservable.take(3).delay(1, TimeUnit.SECONDS);
           }
       })
       .subscribe(...);

Если из метода getUsers придет ошибка, то этот метод будет перезапущен через одну секунду. Если при перезапуске снова придет ошибка, то он будет еще раз перезапущен через одну секунду. В общем, он будет перезапускаться, пока не вернет успешный результат или количество попыток не достигнет трех.

Временной интервал мы указали в операторе delay, а количество попыток — в take.

Описать принцип работы оператора retryWhen достаточно сложно. В моем курсе RxJava есть отдельный урок по retry операторам и там я все подробно объясняю.

Если вкратце, то throwableObservable, который мы получаем в функции в retryWhen, — это Observable, куда будут приходить ошибки из getUsers. От нас требуется вернуть, как результат работы функции, Observable, который будет использован, как триггер перезапуска метода getUsers.

В нашем примере мы берем throwableObservable, добавляем к нему take и delay и возвращаем как результат работы функции. Соответственно, первые три (оператор take) ошибки из getUsers будут отложенным (оператор delay) сигналом к перезапуску getUsers.

Лямбда запись

repository.getUsers()
       .retryWhen(throwableObservable -> throwableObservable.take(3).delay(1, TimeUnit.SECONDS))
       .subscribe(...);

Но в этой схеме есть недостаток. Когда будет четвертая ошибка, мы не получим ее в onError. Вместо этого придет onComplete. И мы даже не узнаем, что что-то пошло не так.

Это исправляется следующим образом:

repository.getUsers()
       .retryWhen(new Function<Observable<Throwable>, ObservableSource<?>>() {
           @Override
           public ObservableSource<?> apply(Observable<Throwable> throwableObservable) throws Exception {
               return throwableObservable
                       .zipWith(Observable.range(1, RETRY_COUNT), new BiFunction<Throwable, Integer, Observable>() {
                           @Override
                           public Observable apply(Throwable throwable, Integer integer) throws Exception {
                               if (integer < RETRY_COUNT) {
                                   return Observable.just(0L);
                               } else {
                                   return Observable.error(throwable);
                               }
                           }
                       }).flatMap(new Function<Observable, ObservableSource<?>>() {
                           @Override
                           public ObservableSource<?> apply(Observable observable) throws Exception {
                               return observable;
                           }
                       });
           }
       })
       .subscribe(...);

Содержимое функции в retryWhen стало сложнее. Но весь этот код просто прокидывает ошибку из throwableObservable на верхний уровень, когда количество попыток достигает установленного максимума. И в итоге мы получим эту ошибку в onError обработчике в subscribe.

Лямбда запись

repository.getUsers()
       .retryWhen(throwableObservable -> throwableObservable
               .zipWith(Observable.range(1, RETRY_COUNT), (BiFunction<Throwable, Integer, Observable>) (throwable, integer) -> {
                   if (integer < RETRY_COUNT) {
                       return Observable.just(0L);
                   } else {
                       return Observable.error(throwable);
                   }
               }).flatMap(observable -> observable))
       .subscribe(...);

Как получить курс

Первые два урока доступны бесплатно и без регистрации. Прочитав их, вы примете осознанное решение о покупке.

В первом уроке поговорим о том, почему для приложения очень важна производительность, и обсудим общую схему поиска и устранения проблем. Я опишу пару случаев из моей практики и подробно расскажу, о чем будет этот курс.

Во втором уроке рассмотрим самые распространенные приемы и советы по созданию эффективного приложения.

Урок 1. Введение   Урок 2. Советы по производительности

Курс постоянно дополняется. На сегодняшний день он состоит из

Полный курс доступен после регистрации на сайте и оплаты. Стоимость курса — 1200 рублей

С выходом новых уроков стоимость увеличивается. Но читатели, уже купившие курс, автоматически получат доступ к новым урокам. Поэтому, если тема вам интересна, не откладывайте покупку и получайте все следующие уроки бесплатно. Читателю, купившему хотя бы один курс, предоставляется скидка 20% на все остальные курсы.

Доступ к курсу предоставляется навсегда, включая все последующие уроки или обновления.

Практика

Все необходимые для работы объекты созданы. Давайте посмотрим, как использовать их для работы с базой данных.

Database объект — это стартовая точка. Его создание выглядит так:

AppDatabase db =  Room.databaseBuilder(getApplicationContext(),
       AppDatabase.class, "database").build();

Используем Application Context, а также указываем AppDatabase класс и имя файла для базы.

Учитывайте, что при вызове этого кода Room каждый раз будет создавать новый экземпляр AppDatabase. Эти экземпляры очень тяжелые и рекомендуется использовать один экземпляр для всех ваших операций. Поэтому вам необходимо позаботиться о синглтоне для этого объекта. Это можно сделать с помощью Dagger, например.

Если вы не используете Dagger (или другой DI механизм), то можно использовать Application класс для создания и хранения AppDatabase:

public class App extends Application {

    public static App instance;

    private AppDatabase database;

    @Override
    public void onCreate() {
        super.onCreate();
        instance = this;
        database = Room.databaseBuilder(this, AppDatabase.class, "database")
                .build();
    }

    public static App getInstance() {
        return instance;
    }

    public AppDatabase getDatabase() {
        return database;
    }
}

Не забудьте добавить App класс в манифест

В коде получение базы будет выглядеть так:

AppDatabase db = App.getInstance().getDatabase();

Из Database объекта получаем Dao.

EmployeeDao employeeDao = db.employeeDao();

Теперь мы можем работать с Employee объектами. Но эти операции должны выполняться не в UI потоке. Иначе мы получим Exception.

Добавление нового сотрудника в базу будет выглядеть так:

Employee employee = new Employee();
employee.id = 1;
employee.name = "John Smith";
employee.salary = 10000;

employeeDao.insert(employee);

Метод getAll вернет нам всех сотрудников в List<Employee>

List<Employee> employees = employeeDao.getAll();

Получение сотрудника по id:

Employee employee = employeeDao.getById(1);

Обновление данных по сотруднику.

employee.salary = 20000;
employeeDao.update(employee);

Room будет искать в таблице запись по ключевому полю, т.е. по id. Если в объекте employee не заполнено поле id, то по умолчанию в нашем примере оно будет равно нулю и Room просто не найдет такого сотрудника (если, конечно, у вас нет записи с id = 0).

Удаление сотрудника

employeeDao.delete(employee);

Аналогично обновлению, Room будет искать запись по ключевому полю, т.е. по id

Давайте для примера добавим еще один тип объекта — Car.

Описываем Entity объект

@Entity
public class Car {

   @PrimaryKey
   public long id;

   public String model;

   public int year;

}

 Теперь Dao для работы с Car объектом

@Dao
public interface CarDao {

   @Query("SELECT * FROM car")
   List<Car> getAll();

   @Insert
   void insert(Car car);

   @Delete
   void delete(Car car);

}

Будем считать, что нам надо только читать все записи, добавлять новые и удалять старые.

В Database необходимо добавить Car в список entities и новый метод для получения CarDao

@Database(entities = {Employee.class, Car.class}, version = 1)
public abstract class AppDatabase extends RoomDatabase {
   public abstract EmployeeDao employeeDao();
   public abstract CarDao carDao();
}

Т.к. мы добавили новую таблицу, изменилась структура базы данных. И нам необходимо поднять версию базы данных до 2. Но об этом мы подробно поговорим в Уроке 12. А пока можно оставить версию равной 1, удалить старую версию приложения и поставить новую.

Периодический повтор операции

Используемые операторы: repeatWhen, delay

У нас есть метод getUsers, который возвращает список пользователей.

Observable<List<User>> getUsers();

Нам необходимо, чтобы данные загружались с сервера раз в минуту и сохранялись в БД. Для повтора операции мы можем использовать оператор

repository.getUsers()
       .repeatWhen(new Function<Observable<Object>, ObservableSource<?>>() {
           @Override
           public ObservableSource<?> apply(Observable<Object> objectObservable) throws Exception {
               return objectObservable.delay(1, TimeUnit.MINUTES);
           }
       })
       .subscribe(new Consumer<List<User>>() {
           @Override
           public void accept(List<User> users) throws Exception {
               updateUsers(users);
           }
       });

Механизм repeatWhen похож на рассмотренный в предыдущем примере retryWhen. В функции мы получаем objectObservable, который будет постить void, когда из getUsers придет onComplete. Из objectObservable мы можем сделать Observable, элементы которого будут триггером для повторного запуска getUsers. Мы добавляем оператор delay с минутной задержкой. Это значит, что через минуту после каждого onComplete, пришедшего из getUsers, метод getUsers будет перезапущен.

Лямбда запись:

repository.getUsers()
       .repeatWhen(objectObservable -> objectObservable.delay(1, TimeUnit.MINUTES))
       .subscribe(users -> updateUsers(users));

Если вам необходимо количественно ограничить количество повторов, то используйте take для objectObservable.

objectObservable.delay(1, TimeUnit.MINUTES).take(5)

Если вам необходимо остановить повтор при получении каких-либо данных, то добавьте takeUntil

repository.getUsers()
        .repeatWhen(new Function<Observable<Object>, ObservableSource<?>>() {
            @Override
            public ObservableSource<?> apply(Observable<Object> objectObservable) throws Exception {
                return objectObservable.delay(1, TimeUnit.MINUTES);
            }
        })
        .takeUntil(new Predicate<List<User>>() {
            @Override
            public boolean test(@NonNull List<User> users) throws Exception {
                return users.isEmpty();
            }
        })
        .subscribe(new Consumer<List<User>>() {
            @Override
            public void accept(List<User> users) throws Exception {
                updateUsers(users);
            }
        });

 Как только придет пустой список, вся цепочка завершится

Либо в subscribe используйте полноценный DisposableObserver, в onNext проверяйте ваше условие и, если оно выполняется, вызывайте dispose(). 

.subscribe(new DisposableObserver<List<User>>() {
    @Override
    public void onNext(@NonNull List<User> users) {
        if (users.isEmpty()) {
            dispose();
        }
    }

    @Override
    public void onError(@NonNull Throwable e) {

    }

    @Override
    public void onComplete() {
        log("onComplete ");
    }
})

Чтобы остановить всю цепочку извне, просто вызовите dispose для Disposable, полученного из subscribe.

Присоединяйтесь к нам в Telegram:

— в канале StartAndroid публикуются ссылки на новые статьи с сайта startandroid.ru и интересные материалы с хабра, medium.com и т.п.

— в чатах решаем возникающие вопросы и проблемы по различным темам: Android, Kotlin, RxJava, Dagger, Тестирование 

— ну и если просто хочется поговорить с коллегами по разработке, то есть чат Флудильня

— новый чат Performance для обсуждения проблем производительности и для ваших пожеланий по содержанию курса по этой теме 

Обсудить на форуме

Оцените статью
Рейтинг автора
5
Материал подготовил
Андрей Измаилов
Наш эксперт
Написано статей
116
Добавить комментарий