iT邦幫忙

2018 iT 邦幫忙鐵人賽
DAY 5
0
Software Development

Android Architecture系列 第 5

連線至GitHub API取得資料

今天要建立基本的功能來取代之前純粹demo用途的內容,前面提過我們要建立縮水版的GitHubBrowser,功能為:

  1. 使用者輸入關鍵字
  2. 用Retrofit連線至GitHub API搜尋符合關鍵字的repository
  3. 將結果顯示於畫面上的RecyclerView

由於Retrofit和RecyclerView都是之前講過的東西,所以大部分會看程式帶過而不做個別的說明,今天目的只是跑一次要做的步驟,順便看看MVVM架構下新增功能的過程。

API

加入Retrofit dependencies:

implementation "com.squareup.retrofit2:retrofit:2.3.0"
implementation "com.squareup.retrofit2:converter-gson:2.3.0"

我們要用的是GitHub API搜尋repository功能,其回傳的json內容長這樣:

{
  "total_count": 40,
  "incomplete_results": false,
  "items": [
    {
      "id": 3081286,
      "name": "Tetris",
      "full_name": "dtrupenn/Tetris",
      "owner": {
        "login": "dtrupenn",
        "id": 872147,
        "avatar_url": "https://secure.gravatar.com/avatar/e7956084e75f239de85d3a31bc172ace?d=https://a248.e.akamai.net/assets.github.com%2Fimages%2Fgravatars%2Fgravatar-user-420.png",
        "gravatar_id": "",
        "url": "https://api.github.com/users/dtrupenn",
        "received_events_url": "https://api.github.com/users/dtrupenn/received_events",
        "type": "User"
      },
      "private": false,
      "html_url": "https://github.com/dtrupenn/Tetris",
      "description": "A C implementation of Tetris using Pennsim through LC4",
      "fork": false,
      "url": "https://api.github.com/repos/dtrupenn/Tetris",
      "created_at": "2012-01-01T00:31:50Z",
      "updated_at": "2013-01-05T17:58:47Z",
      "pushed_at": "2012-01-01T00:37:02Z",
      "homepage": "",
      "size": 524,
      "stargazers_count": 1,
      "watchers_count": 1,
      "language": "Assembly",
      "forks_count": 0,
      "open_issues_count": 0,
      "master_branch": "master",
      "default_branch": "master",
      "score": 10.309712
    }
  ]
}

依照上面的Json建立POJO,首先是整個Response的內容

public class RepoSearchResponse {
    @SerializedName("total_count")
    private int total;
    @SerializedName("items")
    private List<Repo> items;

    public int getTotal() {
        return total;
    }

    public void setTotal(int total) {
        this.total = total;
    }

    public List<Repo> getItems() {
        return items;
    }

    public void setItems(List<Repo> items) {
        this.items = items;
    }
}

接著是其中的items

public class Repo {

    public final int id;

    @SerializedName("name")
    @NonNull
    public final String name;

    @SerializedName("full_name")
    public final String fullName;

    @SerializedName("description")
    public final String description;

    @SerializedName("stargazers_count")
    public final int stars;

    @SerializedName("owner")
    @NonNull
    public final Owner owner;

    ...Constructor
}

最後是Repo中的帳號資料Owner

public class Owner {
    
    @SerializedName("login")
    @NonNull
    public final String login;

    @SerializedName("avatar_url")
    public final String avatarUrl;

    @SerializedName("url")
    public final String url;

    ...Constructor and override equals
}

Retrofit client

interface只要一個GET

public interface GithubService {
    @GET("search/repositories")
    Call<RepoSearchResponse> searchRepos(@Query("q") String query);
}

Retrofit client:

public class RetrofitManager {

    private static RetrofitManager mInstance = new RetrofitManager();

    private GithubService githubService;

    private RetrofitManager() {

        Retrofit retrofit = new Retrofit.Builder()
                .baseUrl("https://api.github.com/")
                .addConverterFactory(GsonConverterFactory.create())
                .build();

        githubService = retrofit.create(GithubService.class);
    }

    public GithubService getAPI() {
        return mInstance.githubService;
    }
}

在AndroidManifest加入網路權限:

<uses-permission android:name="android.permission.INTERNET" />

網路功能的建置完成了,接著處理一下Model和ViewModel的運作。

Model

DataModel中用retrofit呼叫API,並跟之前一樣用interface回傳結果:

public class DataModel {

    private GithubService githubService = RetrofitManager.getAPI();

    public void searchRepo(String query, final onDataReadyCallback callback) {
        githubService.searchRepos(query)
                .enqueue(new Callback<RepoSearchResponse>() {
                    @Override
                    public void onResponse(@NonNull Call<RepoSearchResponse> call, @NonNull Response<RepoSearchResponse> response) {
                        callback.onDataReady(response.body().getItems());
                    }

                    @Override
                    public void onFailure(@NonNull Call<RepoSearchResponse> call, @NonNull Throwable t) {
                        // TODO: error handle
                    }
                });
    }

    public interface onDataReadyCallback {
        void onDataReady(List<Repo> data);
    }
}

其中API連線失敗的onFailure之後會用wrapper打包response來處理,目前就先略過。

ViewModel

MainViewModel我們改名成RepoViewModel,LiveData也跟著改名一下:

public class RepoViewModel extends ViewModel {

    public final ObservableBoolean isLoading = new ObservableBoolean(false);

    private final MutableLiveData<List<Repo>> repos = new MutableLiveData<>();

    ...

    LiveData<List<Repo>> getRepos() {
        return repos;
    }

    void searchRepo(String query) {

        isLoading.set(true);

        dataModel.searchRepo(query, new DataModel.onDataReadyCallback() {
            @Override
            public void onDataReady(List<Repo> data) {
                repos.setValue(data);
                isLoading.set(false);
            }
        });
    }
}

到這邊就處理完Model和ViewModel了,在MVVM架構下各角色職責分明,當功能要修改時可以每一層分別處理,個人覺得比MVC在Activity內的各處改好很多。

View

需要一個TextInputLayout讓使用者輸入關鍵字,以及RecyclerView搭配CardView顯示搜尋結果,先加入dependencies:

implementation "com.android.support:design:27.0.2"
implementation "com.android.support:recyclerview-v7:27.0.2"
implementation "com.android.support:cardview-v7:27.0.2"

用Fragment來顯示UI,首先是layout檔,項目大致如上所述
repo_fragment.xml

<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools">

    <data>

        <import type="android.view.View" />

        <variable
            name="viewModel"
            type="ivankuo.com.itbon2018.ui.RepoViewModel" />

    </data>

    <android.support.constraint.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:focusableInTouchMode="true">

        <android.support.design.widget.TextInputLayout
            android:id="@+id/textInputLayout"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            app:layout_constraintBottom_toTopOf="@+id/recyclerView"
            app:layout_constraintEnd_toStartOf="@+id/btnSearch"
            app:layout_constraintStart_toStartOf="parent">

            <android.support.design.widget.TextInputEditText
                android:id="@+id/edtQuery"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:hint="search repositories" />

        </android.support.design.widget.TextInputLayout>

        <Button
            android:id="@+id/btnSearch"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="GO"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintTop_toTopOf="parent" />

        <android.support.v7.widget.RecyclerView
            android:id="@+id/recyclerView"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintLeft_toLeftOf="parent"
            app:layout_constraintTop_toBottomOf="@+id/textInputLayout"
            tools:listitem="@layout/repo_item" />

        <ProgressBar
            android:id="@+id/progressBar"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:visibility="@{viewModel.isLoading ? View.VISIBLE : View.GONE}"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintLeft_toLeftOf="parent"
            app:layout_constraintRight_toRightOf="parent"
            app:layout_constraintTop_toTopOf="parent" />

    </android.support.constraint.ConstraintLayout>
</layout>

(才發現我的package name當初打錯了耶!應該要com.ivanxxx)

Fragment本身,一些設置元件的部分就不貼了節省版面

public class RepoFragment extends Fragment {

    public static final String TAG = "Repo";

    private RepoFragmentBinding binding;

    private GithubViewModelFactory factory = new GithubViewModelFactory();
    private RepoViewModel viewModel;

    private RepoAdapter repoAdapter = new RepoAdapter(new ArrayList<Repo>());

    ...

    @Nullable
    @Override
    public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
        binding = RepoFragmentBinding.inflate(inflater, container, false);

        // Setup EditText OnKeyListener
        ...
        
        binding.btnSearch.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View view) {
                doSearch();
            }
        });

        binding.recyclerView.setLayoutManager(new LinearLayoutManager(getContext(),
                LinearLayoutManager.VERTICAL, false));
        binding.recyclerView.setAdapter(repoAdapter);

        return binding.getRoot();
    }

    @Override
    public void onActivityCreated(@Nullable Bundle savedInstanceState) {
        super.onActivityCreated(savedInstanceState);
        viewModel = ViewModelProviders.of(this, factory).get(RepoViewModel.class);
        binding.setViewModel(viewModel);
        viewModel.getRepos().observe(this, new Observer<List<Repo>>() {
            @Override
            public void onChanged(@Nullable List<Repo> repos) {
                repoAdapter.swapItems(repos);
            }
        });
    }

    private void doSearch() {
        String query = binding.edtQuery.getText().toString();
        if (TextUtils.isEmpty(query)) {
            repoAdapter.clearItems();
            return;
        }
        viewModel.searchRepo(query);
        dismissKeyboard();
    }

    ...
}

在Fragment中使用Data Binding的話可以在onCreateView使用inflate,並return binding.getRoot()

顯示列表的RepoAdapter,明天會用Data Binding修過,先能動就好:

public class RepoAdapter extends RecyclerView.Adapter<RepoAdapter.RepoViewHolder> {

    private List<Repo> items;

    RepoAdapter(List<Repo> items) {
        this.items = items;
    }

    class RepoViewHolder extends RecyclerView.ViewHolder{

        private RepoItemBinding binding;

        RepoViewHolder(RepoItemBinding binding) {
            super(binding.getRoot());
            this.binding = binding;
        }
    }

    @Override
    public RepoViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
        LayoutInflater layoutInflater = LayoutInflater.from(parent.getContext());
        RepoItemBinding binding = RepoItemBinding.inflate(layoutInflater, parent, false);
        return new RepoViewHolder(binding);
    }

    @Override
    public void onBindViewHolder(RepoViewHolder holder, int position) {
        Repo repo = items.get(position);
        Glide.with(holder.itemView.getContext())
                .load(repo.owner.avatarUrl)
                .into(holder.binding.ownerAvatar);
        holder.binding.name.setText(repo.fullName);
        holder.binding.desc.setText(repo.description);
        holder.binding.stars.setText(""+repo.stars);
    }

    @Override
    public int getItemCount() {
        return items.size();
    }
    
    void clearItems() {
        int size = this.items.size();
        this.items.clear();
        notifyItemRangeRemoved(0, size);
    }

    void swapItems(List<Repo> newItems) {
        DiffUtil.DiffResult result = DiffUtil.calculateDiff(new RepoDiffCallback(this.items, newItems));
        this.items.clear();
        this.items.addAll(newItems);
        result.dispatchUpdatesTo(this);
    }
    
    ...
}

列表的item layout,同樣明天會用Data Binding修過:

<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools">

    <android.support.v7.widget.CardView
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        app:cardUseCompatPadding="true">

        <android.support.constraint.ConstraintLayout
            android:layout_width="match_parent"
            android:layout_height="wrap_content">

            <ImageView
                android:id="@+id/ownerAvatar"
                android:layout_width="48dp"
                android:layout_height="48dp"
                android:layout_marginStart="8dp"
                android:scaleType="fitCenter"
                app:layout_constraintStart_toStartOf="parent"
                app:layout_constraintTop_toTopOf="parent" />

            <TextView
                android:id="@+id/name"
                android:layout_width="0dp"
                android:layout_height="wrap_content"
                android:layout_marginStart="@dimen/default_margin"
                android:layout_marginTop="16dp"
                app:layout_constraintEnd_toStartOf="@+id/stars"
                app:layout_constraintStart_toEndOf="@+id/ownerAvatar"
                app:layout_constraintTop_toTopOf="parent"
                tools:text="repository name" />

            <TextView
                android:id="@+id/desc"
                android:layout_width="0dp"
                android:layout_height="wrap_content"
                android:layout_marginStart="@dimen/default_margin"
                android:layout_marginTop="8dp"
                app:layout_constraintStart_toStartOf="parent"
                app:layout_constraintTop_toBottomOf="@+id/ownerAvatar"
                tools:layout_editor_absoluteX="@dimen/default_margin"
                tools:text="this is some description more description more description" />

            <TextView
                android:id="@+id/stars"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_marginEnd="@dimen/default_margin"
                android:layout_marginTop="@dimen/default_margin"
                android:drawableEnd="@android:drawable/btn_star"
                android:gravity="center"
                app:layout_constraintEnd_toEndOf="parent"
                app:layout_constraintTop_toTopOf="parent"
                tools:text="5" />

        </android.support.constraint.ConstraintLayout>
    </android.support.v7.widget.CardView>
</layout>

最後是MainActivity,改成只有顯示Fragment的功能
main_activity.xml

<?xml version="1.0" encoding="utf-8"?>
<FrameLayout 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"
    tools:ignore="MergeRootFrame">

    <FrameLayout
        android:id="@+id/container"
        android:layout_width="match_parent"
        android:layout_height="match_parent" />
</FrameLayout>
public class MainActivity extends AppCompatActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.main_activity);
        String tag = RepoFragment.TAG;
        if (getSupportFragmentManager().findFragmentByTag(tag) == null) {
            RepoFragment fragment = RepoFragment.newInstance();
            getSupportFragmentManager().beginTransaction()
                    .add(R.id.container, fragment, tag)
                    .commit();
        }
    }
}

OK完成,最後的package會長這樣:
https://ithelp.ithome.com.tw/upload/images/20171225/20103849Gos5zuOjKM.jpg


今天的內容沒有什麼新東西,只是把功能建置過程跑一遍,不過花費的時間比預期還多所以蠻趕的,可能有疏漏的地方我們之後再陸續修改。

GitHub source code:
https://github.com/IvanBean/ITBon2018/tree/day05-api


上一篇
Architecture Components - LiveData
下一篇
Data Binding with RecyclerView & Custom setter
系列文
Android Architecture30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言