вторник, 14 ноября 2017 г.

Пишем код красиво с Rx Android


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

В интернете куча статей о том как писать с помощью Rx, я сам по ним пытался учиться использовать его, много статей было прочитанно, но лишь одна статья меня реально поразила своей логичностью и порядком изложения информации, после которой у меня открылся третий глаз и я начал слышать голоса объясняющие основы Rx. Вот эта статья.Всем кто не знаком с Rx начинать с нее, даже не знание английского не помеха, Google Translate в помощь, статья очень понятно и логично расписывает работу с Rx. Прям ну ооочень хорошо. Остальные статьи как по мне шлак.

Попался мне проект один, который был написан на Rx, я как нубас впал в ступор и начал истерить что это вообще какая-то не понятная хрень и в растроенных чувствах начал разбираться в ней. В итоге понял что эта непонятная хрень довольно таки удобная хрень, дак еще и полезная, так как она обсорбирует в себя кучу ошибок, и разных плюшек типа работы в UI треде или объеденение нескольких списков в один после запроса. 

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

Начнем мы с того что разберем что такое Observable и Subscriber. Это два основных класса которые обеспечивают нас как программистов основными функциями Rx. Observable — у нас класс который хранит в себе данные, а Subscriber — класс передающий данные из Observable. Ну то есть Observable у нас хранит данные, а Subscriber — возвращает все что имеет в себе Observable. Как то так. В общем дальше на примерах будет понятней.

Стандартный вид Observable который все обычно используют это Observable, где Т — это любой объект который может быть возвращаен, пусть то будет Object, String или ArrayList. То есть Observable может вернуть буквально любой вид данных. 

Давайте перейдем к практике. Вот у нас есть какая-то функция которая возвращает нам какой-то ArrayList, нам нужно его получить через Observable и передать в MainActivity. Что нам для этого нужно. Создать какой-то метод котоый будет возвращать Observable<ArrayList> и дальше вернуть этот список в наш метод.

public Observable<ArrayList<String>> getString() {
        return Observable.create(observableEmitter -> {
            ArrayList<String> articlesModels = new ArrayList<>();
            articlesModels.add("one");
            articlesModels.add("two");
            articlesModels.add("three");
            observableEmitter.onNext(articlesModels);
        });
    }

Как видно из кода, мы создали Observable.create, который передает какой-то observableEmitter, это объект самого ObservableEmitter<ArrayList> который возвращает текущее состояние потока, и работает в то же время колбеком для нас. С помощью его мы можем вызывать такие методы как:

observableEmitter.onNext();
observableEmitter.onError();
observableEmitter.onComplete();

Из их названий я так думаю понятно что они занчат. onNext() вызывается когда выполнилась ваша функция, и он возвращает в Subscriber, то что вы передаете в него. onError() соответственно возвращает ошибку которую вы в него передадите, если ничего не передадите, то соответственно он ничего и не выведет. onComplete() вызывается когда все действия завершены и поток закрывается.

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

getString()
                .subscribeOn(Schedulers.io())
                .observeOn(AndroidSchedulers.mainThread())
                .subscribe(new Observer<ArrayList<String>>() {

                    @Override
                    public void onSubscribe(Disposable d) { }

                    @Override
                    public void onNext(ArrayList<String> strings) {
                        for(String str : strings)
                            Log.e("strings", str);
                    }

                    @Override
                    public void onError(Throwable e) {
                        e.printStackTrace();
                    }

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

Это в принципе основной функционал который считается самым популярным в RX. Есть еще методы just, first, last, rand, from, они все нужны, но в редких случаях когда у вас идет работа с локальным списком, когда вы передаете список в Observable и вам нужно сделать какие-то манипуляции с ним внутри Observable. Тогда да, но я тут все это размазывал не из-за этих методов, так что их я оставлю на потом. Возможно в будущем расскажу о них… 

А сейчас я расскажу как я парсил сайт с помощью JSOUP и RxAndroid. Это оказалось довольно просто, даже как-то подозрительно.

Для примера я взял сайт instructables.com. Там довольно простая верстка, и просто можно достать нужные названия классов для парсинга. В примере я всего лишь достану список статей из главной страницы об технологиях.

Начнем мы с того что создадим проект, и в нем у нас будут MainActivity.java и разметка activity_main.xml. Нам нужно будет подключить пару библиотек, и java 8 для работы ретролямбды, что бы не подключать библиотеку. Данный проект писался в Android Studio 3, они уже умеет в ретролямбду без ретролябмды, то есть фишки java 8 доступны без лишних библиотек, это кстати очень удобно, так как раньше это был страшный геморой, пока подключишь ее, свихнешься.

Библиотеки которые нам понадобятся:

app/build.gradle
dependencies {
    implementation fileTree(dir: 'libs', include: ['*.jar'])
    implementation 'com.android.support:appcompat-v7:26.1.0'
    implementation 'com.android.support:recyclerview-v7:26.1.+'

    implementation 'io.reactivex.rxjava2:rxandroid:2.0.1'
    implementation 'io.reactivex.rxjava2:rxjava:2.1.5'

    implementation 'com.jakewharton:butterknife:8.8.1'
    annotationProcessor 'com.jakewharton:butterknife-compiler:8.8.1'

    implementation 'org.jsoup:jsoup:1.11.1'
    implementation 'com.squareup.picasso:picasso:2.5.2'
}

Вроде бы для такого маленького проекта не нужно особо много библиотек, но у меня их довольно много, я буду создавать списки с помощью RecyclerView, буду использовать Rx, буду паристь страницы с помощью JSOUP и буду искать вюхи с помощью ButterKnife, а еще как же без Picasso, мне же надо с помощью чего-то отображать картинки…

А еще нам нужно добавить в тот же app/build.gradle использование java 8. 

android {
    compileOptions {
        sourceCompatibility JavaVersion.VERSION_1_8
        targetCompatibility JavaVersion.VERSION_1_8
    }
}

Таким образом мы говорим студии что у нас можно сокращать код и делать что-то на подобии -> в место длинного и не удобного new View.OnClickListener() { private void onClick }. Но это опять же по желанию, мне нравятся эти сокращения, может кому-то больше нравится длинные но понятные колбеки, без этих непонятных сокращений.

В общем с настройкой gradle мы справились, это уже великолепно. Далее нужно создать интерфейс который будет у нас хранить все методы которые нам нужны для работу. Так и красивей получается и удобней. У нас там будет один метод, но это ведь не важно, за то красиво! 

IRepository.java 
import com.project.dajver.rxandroidecample.api.model.ArticlesModel;
import java.util.ArrayList;
import io.reactivex.Observable;

public interface IRepository {
    Observable<ArrayList<ArticlesModel>> getArticles(String url);
}

В общем создали мы интерфейс, Observable должен будет возвращать нам ArrayList, и это офигенно. Но что же такое ArticlesModel, а это просто модель в которой у нас хранится название и картинка статьи.

ArticlesModel.java
public class ArticlesModel {
    private String name;
    private String imageUrl;

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public String getImageUrl() {
        return imageUrl;
    }

    public void setImageUrl(String imageUrl) {
        this.imageUrl = imageUrl;
    }
}

Вот такая вот структура. Пока что просто? Просто, вот и замечательно, дальше нам нужно создать имплементацию нашего интерфейса Repository. Это очень просто, создаем класс любой, и имплементируем в него наш интерфейс, и там сразу создасться методы которые есть в нашем интерфейсе для дальнейшей реализации.

RepositoryImpl.java
import com.project.dajver.rxandroidecample.api.model.ArticlesModel;

import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.select.Elements;

import java.io.IOException;
import java.util.ArrayList;

import io.reactivex.Observable;

public class RepositoryImpl implements IRepository {

    @Override
    public Observable<ArrayList<ArticlesModel>> getArticles(String urlLink) {
        return Observable.create(observableEmitter -> {
            ArrayList<ArticlesModel> articlesModels = new ArrayList<>();
            Document doc;
            try {
                doc = Jsoup.connect(urlLink).get();

                Elements titleElement = doc.getElementsByClass("title");
                Elements imageElement = doc.getElementsByClass("cover-image");

                for(int i = 0; i < titleElement.size(); i++) {
                    Elements imgsrc = imageElement.get(i).select("img");
                    String style = imgsrc.attr("src");

                    Elements ahref = titleElement.get(i).select("a");
                    String titleText = ahref.text();

                    ArticlesModel model = new ArticlesModel();
                    model.setName(titleText);
                    model.setImageUrl(style);

                    articlesModels.add(model);
                }
                observableEmitter.onNext(articlesModels);
            } catch (IOException e) {
                observableEmitter.onError(e);
            } finally {
                observableEmitter.onComplete();
            }
        });
    }
}

Что же мы тут имеем? А то что у нас создался наш метод getArticles() который у нас возвращает Observable<ArrayList>, и дальше у нас идет реализация Observable.create(), как я писал ранее. Тут у нас идет реализация парсинга сайта по ссылке, которую мы передаем в метод. Дальше Observable.create() у нас Rx заканчивается и начинается JSOUP, с его помощью мы находим нужные нам параметры на странице и парсим их доставая название статей и ссылки на картинки. Распихиваем это все по своим местам в ArticlesModel и сетим эти данные в список. А по окончанию, сетим этот список в observableEmitter.onNext().

Если у нас появятся какие то ошибки, то мы добавили в catch метод onError() который принимает ексепшн и отдает его в сабскрайбера. Ну и в конце в finnlay когда уже все действия у нас заканчиваются мы вызываем onComplete(), что бы убирать прогрессбар на пример, или что-то прятать по окончанию загрузки.

Давайте создадим еще адаптер, что бы в конце статьи уже собрать все в кучу и получить какой-то красивый результат. Адаптер у нас будет стандартный, по этому я на нем не буду задерживать внимания. Обычный RecyclerView.Adapter который отображает текст и картинку.

ArticlesRecyclerAdapter.java
import android.content.Context;
import android.support.v7.widget.RecyclerView;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageView;
import android.widget.TextView;

import com.project.dajver.rxandroidecample.R;
import com.project.dajver.rxandroidecample.api.model.ArticlesModel;
import com.squareup.picasso.Picasso;

import java.util.ArrayList;
import java.util.List;

import butterknife.BindView;
import butterknife.ButterKnife;

public class ArticlesRecyclerAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder>{

    private List<ArticlesModel> newsModels = new ArrayList<>();
    private Context context;

    public ArticlesRecyclerAdapter(Context context, List<ArticlesModel> newsModels) {
        this.context = context;
        this.newsModels = newsModels;
    }

    @Override
    public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
        View view = LayoutInflater.from(parent.getContext()).inflate(R.layout.item_aticle, parent, false);
        return new NewsViewHolder(view);
    }

    @Override
    public void onBindViewHolder(final RecyclerView.ViewHolder holder, final int position) {
        final NewsViewHolder viewHolder = (NewsViewHolder) holder;
        viewHolder.title.setText(newsModels.get(position).getName());
        Picasso.with(context).load(newsModels.get(position).getImageUrl()).into(viewHolder.image);
    }

    @Override
    public int getItemCount() {
        return newsModels.size();
    }

    public class NewsViewHolder extends RecyclerView.ViewHolder {

        @BindView(R.id.title)
        public TextView title;
        @BindView(R.id.image)
        public ImageView image;

        public NewsViewHolder(View itemView) {
            super(itemView);
            ButterKnife.bind(this, itemView);
        }
    }
}

Как я и говорил все тривиально просто. Создали адаптер который принимает в конструкторе ArrayList и дальше сетит данные из списка в вьюхи. 

И не забываем конечно же за разметочку для адаптера.

item_article.xml
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:gravity="center_vertical"
    android:orientation="horizontal"
    android:padding="10dp">

    <ImageView
        android:id="@+id/image"
        android:layout_width="100dp"
        android:layout_height="100dp"
        android:scaleType="fitCenter" />

    <TextView
        android:id="@+id/title"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginLeft="10dp"
        android:text="TextView"
        android:textColor="@android:color/black"
        android:textSize="18sp" />
</LinearLayout>

А что же делать дальше скажете вы? Да все просто, дальше мы открываем наш MainActivity, и прямо в onCreate() пишем вызов этого метода с определенными параметрами, которые я описывал выше. 

MainActivity.java
import android.os.Bundle;
import android.support.v7.app.AppCompatActivity;
import android.support.v7.widget.DividerItemDecoration;
import android.support.v7.widget.LinearLayoutManager;
import android.support.v7.widget.RecyclerView;

import com.project.dajver.rxandroidecample.adapter.ArticlesRecyclerAdapter;
import com.project.dajver.rxandroidecample.api.RepositoryImpl;
import com.project.dajver.rxandroidecample.api.model.ArticlesModel;

import java.util.ArrayList;

import butterknife.BindView;
import butterknife.ButterKnife;
import io.reactivex.Observer;
import io.reactivex.android.schedulers.AndroidSchedulers;
import io.reactivex.disposables.Disposable;
import io.reactivex.schedulers.Schedulers;

public class MainActivity extends AppCompatActivity {

    @BindView(R.id.recyclerView)
    RecyclerView recyclerView;

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

        recyclerView.setLayoutManager(new LinearLayoutManager(this, LinearLayoutManager.VERTICAL, false));
        recyclerView.addItemDecoration(new DividerItemDecoration(this, DividerItemDecoration.VERTICAL));

        new RepositoryImpl().getArticles("https://www.instructables.com/technology/")
                .subscribeOn(Schedulers.io())
                .observeOn(AndroidSchedulers.mainThread())
                .subscribe(new Observer<ArrayList<ArticlesModel>>() {
                    @Override
                    public void onSubscribe(Disposable d) { }

                    @Override
                    public void onNext(ArrayList<ArticlesModel> articlesModels) {
                        ArticlesRecyclerAdapter articlesRecyclerAdapter = new ArticlesRecyclerAdapter(MainActivity.this, articlesModels);
                        recyclerView.setAdapter(articlesRecyclerAdapter);
                    }

                    @Override
                    public void onError(Throwable e) {
                        e.printStackTrace();
                    }

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

Находим RecyclerView на нашей активити, задаем ему параметры, что бы он использовал LinearLayoutManager для отображения, и DividerItemDecoration для разделения айтемов друг от друга. Дальше вызываем наш RepositoryImpl() который вызывает наш метод, передаем в него ссылку откуда парсить наши статьи. 

Метод subscribeOn(Schedulers.io()) — говорит о том что поток в котором будет выполняться функция будет задан Schedulers'ом.
Метод observeOn(AndroidSchedulers.mainThread()) — очевидно говорит что потом мы создаем поверх нашего UI треда. Но как бы мы не хотели повлиять на главный поток, он все равно не будет заблокирован пока действие не закончится, то что нам как раз и нужно, так как JSOUP требует выполнение действий в отдельном потоке.
Ну и конечно же subscribe() который возвращает нам наш Observer<ArrayList>, который мы и хотим получить в итоге. 

Дальше в методе onNext() мы получаем наш ArrayList, и выводим то что у нас в нем есть в адаптер. Если же у нас появтяся какие-то ошибки по ходу действия Observable — все ошибки вернутся в onError().

Осталось только создать RecyclerView в разметке. Кто не знает как вот пример.

activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <android.support.v7.widget.RecyclerView
        android:id="@+id/recyclerView"
        android:layout_width="match_parent"
        android:layout_height="wrap_content" />

</LinearLayout>

И не забываем что у нас приложение все таки работает с интернетом, по этому ему нужно разрешить работу с интернетом в манифесте.

AndroidManifest.xml
    <uses-permission android:name="android.permission.INTERNET" />
И вот после этого всего, у вас по идее должно все скомпилироваться и работать как часы, без падений и ексепшинов. Как часы. 


Исходники:
GitHub

PS:

Rx еще часто используют для комбайна данных из запросов Retrofit на пример. Это довольно интересная тема, так как это очень спасает от костылей которые будут вручную эти данные добавлять и будут в ключат ьв себя кучу ошибок типа смешивания данных и путаницу в айдишниках, а так у нас допустим есть два запроса, как это было сказанно в статье с медиума что я выше дал, для получения фотографии и данных пользователя, и нам нужно эти данные объеденить в одну кучу что бы потом передать куда-нибудь в адаптер. 

Если в кратце. Мы создаем интерфейс для работы с ретрофитом, как я описывал это в статье про работу с Retrofit.

@GET("/user/{id}/photo")
Observable<Photo> getUserPhoto(@Path("id") int id);
@GET("/photo/{id}/metadata")
Observable<Metadata> getPhotoMetadata(@Path("id") int id);

И у нас как бы есть два запроса, которые мы дальше объеденяем в простую конструкцию

Observable.zip(
   service.getUserPhoto(id),
   service.getPhotoMetadata(id),
   (photo, metadata) -> createPhotoWithData(photo, metadata))
   .subscribeOn(Schedulers.io())
   .observeOn(AndroidSchedulers.mainThread())
   .subscribe(photoWithData -> showPhoto(photoWithData));

Где Observable.zip() метод который объеденяет наши два объекта service.getUserPhoto(id) и service.getPhotoMetadata(id). Дальше у нас идет колбек который возвращае (photo, metadata) и мы их объъеденяем в методе createPhotoWithData() который принимает их. Указываем работу в отдельном потоке, но при этом в UI, и дальше выводим то что у нас получилось в итоге в subscribe(), в метод showPhoto() который принимает photoWithData.

В общем если вам так не понятно то объясню на другом примере. Возможно так будет более понятно. Вот у нас есть два метода которые возвращают разные данные в списках.

    private Observable<ArrayList<String>> getUserPhoto() {
        ArrayList<String> strings = new ArrayList<>();
        strings.add("one");
        return Observable.fromArray(strings);
    }

    private Observable<ArrayList<String>> getUserMetadata() {
        ArrayList<String> strings = new ArrayList<>();
        strings.add("two");
        return Observable.fromArray(strings);
    }

Первый у нас возвращает слово one. Второй возвращает слово two. И преобразовываем мы это все в Observable что бы можно было его скормить для парсинга. Все очень просто, а дальше у нас уже знакомая конструкция сбора данных в одну кучу:

Observable.zip(
                getUserPhoto(),
                getUserMetadata(),
                (strings, strings2) -> {
                    ArrayList<String> stringsList = new ArrayList<>();
                    for(int i = 0; i < strings.size(); i++) {
                        stringsList.add(strings.get(i) + " | " + strings2.get(i));
                    }
                    return stringsList;
                })
                .subscribeOn(Schedulers.io())
                .observeOn(AndroidSchedulers.mainThread())
                .subscribe(photoWithData -> {
                    for(String s : photoWithData)
                        Log.e("tag", s);
                });

Передаем в Observable.zip() наши два списка, дальше объеденяем их, выполняем это мы все в отдельном UI потоке, и потом в subscribe() выводим в лог, то что у нас получилось в итоге.

Исходники:
GitHubGist

1 комментарий: