マネーフォワードでAndroidエンジニアをしています前田です。 最近、Android開発をしていて思っていることザックリまとめてみました。
結論
CustomView積極的に使おう!
最近思ってること
先日のdroidkaigiにて、yanzmさんが下記の点を発表されていました。
- Activity/Fragmentに不要な処理書きたくない
- どんどんファットになっていく
- ActivityやFragmentになんでも書けばいいやということが多く、どんどんクラスが大きくなっている
そこで、Viewに依存するものはCustomView作ってしまい、できるだけView内で完結させたいと思うようになってきました。
実際、マネーフォワードのアプリでも、Activity/Fragmentがファットになっており、View内で完結するものはCustomViewを作成し処理を移行しています。
CustomViewにすることのメリットは下記の点だと考えています。
- 各View内で完結する処理はView内に記述することでActivity、Fragmentに処理がほどんどかかれない
- Layoutファイルの見通しが良くなる
- 繰り返し使うようなViewをCustom化することでコピペ処理が減る
実装してみた
全体的な効率UPのために、下記ライブラリを使用しています。
APIリクエストをCustomViewに
APIリクエストはActivity/Fragmentから行いますが、View単体のみでしか使わないものに関してはView内に閉じ込めています。 画面に依存せずCustomViewを画面に配置してあげるだけでAPIをリクエストして、Viewの生成も行ってくれます。
今回の例では、下記を使って実装します。 (APIはtiqavを使用させて頂きました。)
- AsyncTaskLoader
- RecyclerView
AsyncTaskLoader
AsyncTaskLoader内ではAPIの呼び出しを行います。 公式のサンプルとほぼ同じです。 APIの呼び出しには、retrofitを使用しています。
public class TiqavApiLoader extends AsyncTaskLoader<List<Tiqav>> { private List<Tiqav> mTiqavList; public TiqavApiLoader(Context context) { super(context); } @Override public List<Tiqav> loadInBackground() { RestAdapter restAdapter = new RestAdapter.Builder() .setEndpoint("http://api.tiqav.com") .setConverter(new GsonConverter(new Gson())) .build(); TiqavApiService api = restAdapter.create(TiqavApiService.class); return api.getRandom(); } @Override protected void onStartLoading() { if (mTiqavList != null && !mTiqavList.isEmpty()) { deliverResult(mTiqavList); return; } forceLoad(); } @Override public void deliverResult(List<Tiqav> data) { if (!isReset()) { if (mTiqavList != null) { mTiqavList = null; } } mTiqavList = data; if (isStarted()) { super.deliverResult(data); } } @Override protected void onReset() { super.onReset(); onStopLoading(); if (mTiqavList != null) { mTiqavList = null; } } }
RecyclerView
CustomViewです。このクラス内では下記処理を行います。
- RecyclerViewの初期設定
LoaderCallbacks
の実装
特徴的なのはLoaderCallbacks
をView内で実装していることかと思います。
(LoaderでなくてもAPIを非同期で呼んであげても問題は無いと思います。)
public class TiqavRecyclerView extends RecyclerView implements LoaderManager.LoaderCallbacks<List<Tiqav>> { public TiqavRecyclerView(Context context) { this(context, null); } public TiqavRecyclerView(Context context, @Nullable AttributeSet attrs) { this(context, attrs, 0); } public TiqavRecyclerView(Context context, @Nullable AttributeSet attrs, int defStyle) { super(context, attrs, defStyle); init(context); } // View 初期化 private void init(Context context) { // RecylerViewをGridで表示 final GridLayoutManager glm = new GridLayoutManager(context, 4, VERTICAL, false); this.setLayoutManager(glm); } @Override public Loader<List<Tiqav>> onCreateLoader(int id, Bundle args) { return new TiqavApiLoader(getContext()); } @Override public void onLoadFinished(Loader<List<Tiqav>> loader, List<Tiqav> data) { if (data == null) { // TODO: EmptyView. return; } this.setAdapter(new TiqavRecyclerViewAdapter(data)); } @Override public void onLoaderReset(Loader<List<Tiqav>> loader) { } private static class TiqavRecyclerViewAdapter extends Adapter<TiqavViewHolder> { @NonNull private final List<Tiqav> tiqavList; private TiqavRecyclerViewAdapter(@NonNull List<Tiqav> tiqavList) { this.tiqavList = tiqavList; } @Override public TiqavViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { LayoutInflater inflater = LayoutInflater.from(parent.getContext()); final View view = inflater.inflate(R.layout.view_tiqav_image, parent, false); return new TiqavViewHolder(view); } @Override public void onBindViewHolder(TiqavViewHolder holder, int position) { final String imgUrl = tiqavList.get(position).getSourceUrl(); holder.bind(imgUrl); } @Override public int getItemCount() { return tiqavList.size(); } } private static class TiqavViewHolder extends ViewHolder { private ImageView imgView; private TextView errorText; public TiqavViewHolder(View itemView) { super(itemView); imgView = (ImageView) itemView.findViewById(R.id.img); errorText = (TextView) itemView.findViewById(R.id.text); } void bind(String imgUrl) { // 画像取得. Picasso.with(imgView.getContext()) .load(imgUrl) .fit() .into(imgView, new Callback() { @Override public void onSuccess() { errorText.setVisibility(GONE); } @Override public void onError() { imgView.setVisibility(GONE); errorText.setVisibility(VISIBLE); } }); } } }
Activity
ActivityではLoaderManager
にLoaderCallbacks
を実装したCustomViewクラスを渡します。
APIの呼び出し自体はLoaderが行い、コールバック処理もView側で行っているため、ActivityはLoaderManagerからLoaderを呼び出すだけです。
@Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); TiqavRecyclerView tiqavRecyclerView = new TiqavRecyclerView(this); setContentView(tiqavRecyclerView); getSupportLoaderManager().initLoader(tiqavRecyclerView.hashCode(), null, tiqavRecyclerView); }
実際にやってみると、tiqavのAPIから戻ってくるレスポンスの画像のほとんどがnot foundに。。。( ;∀;)
そのため、「画像ないよ」という文字列だらけに…(´;ω;`)ブワッ
よく使うViewをCustomViewに
Layoutファイルの見通し良くするために使います。
今回は階層を表現するときに区切りとして影を落とすことをよくします。
(5.0以降はelevation
で設定できます)
毎回layoutファイルに書いてもいいのですが、ちょっと面倒になってきたのでCustomViewにしてみました。
影はnone, top, bottom, bothに設定できるようにします。
構成はこんな感じです。
- shadow shapeファイル
- res/vaules/attrs.xml
- res/layout/view_shadow_wapper.xml
- ShadowableFrameLayout.java
- Activity
shadow shapeファイル
影用のshapeファイルを作成します。
boarder_shadow.xml(上から下に影を落とす用)
<?xml version="1.0" encoding="utf-8"?> <shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle"> <solid android:height="1dp" android:color="#222222" /> <gradient android:angle="270" android:endColor="#00000000" android:startColor="#33222222" /> </shape>
border_shadow_reverse.xml(下から上に影を落とす用)
<?xml version="1.0" encoding="utf-8"?> <shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle"> <solid android:height="1dp" android:color="#222222" /> <gradient android:angle="270" android:endColor="#33222222" android:startColor="#00000000" /> </shape>
ret/values/attrs.xml
CustomViewにレイアウトファイルから影を設定できるようにdeclare-styleable
を作成します。
<?xml version="1.0" encoding="utf-8"?> <resources> <declare-styleable name="ShadowableFrameLayout"> <attr name="shadow"> <flag name="non" value="0" /> <flag name="top" value="1" /> <flag name="bottom" value="2" /> <flag name="both" value="4" /> </attr> </declare-styleable> </resources>
res/layout/view_shadow_wapper.xml
ShadowableFrameLayout用レイアウトファイルです。
(レイアウトファイル作らないでShadowableFrameLayout内で影部分のView生成しても実現できます。)
<?xml version="1.0" encoding="utf-8"?> <merge xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent"> <View android:id="@+id/shadow_top" android:layout_width="match_parent" android:layout_height="4dp" android:layout_gravity="top|center_horizontal" android:background="@drawable/border_shadow" /> <View android:id="@+id/shadow_bottom" android:layout_width="match_parent" android:layout_height="4dp" android:layout_gravity="bottom|center_horizontal" android:background="@drawable/border_shadow_reverse" /> </merge>
ShadowableFrameLayout.java
CustomViewです。実際に受け取ったflagからどのように影を落とすのか決めています。
public class ShadowableFrameLayout extends FrameLayout { private static final int FLAG_SHADOW_NON = 0; private static final int FLAG_SHADOW_TOP = 1; private static final int FLAG_SHADOW_BOTTOM = 2; private static final int FLAG_SHADOW_BOTH = 4; @IntDef({FLAG_SHADOW_NON, FLAG_SHADOW_TOP, FLAG_SHADOW_BOTTOM, FLAG_SHADOW_BOTH}) public @interface ShadowFlag { } @InjectView(R.id.shadow_top) View mShadowTop; @InjectView(R.id.shadow_bottom) View mShadowBottom; public ShadowableFrameLayout(Context context, AttributeSet attrs) { this(context, attrs, 0); } public ShadowableFrameLayout(Context context, AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); initView(context, attrs, defStyleAttr); } @TargetApi(Build.VERSION_CODES.LOLLIPOP) public ShadowableFrameLayout(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { super(context, attrs, defStyleAttr, defStyleRes); initView(context, attrs, defStyleAttr); } /** * initview. * * @param context * @param attrs * @param defStyleAttr */ private void initView(Context context, AttributeSet attrs, int defStyleAttr) { final TypedArray array = context.obtainStyledAttributes(attrs, R.styleable.ShadowableFrameLayout, defStyleAttr, 0); @ShadowFlag final int flag = array.getInt(R.styleable.ShadowableFrameLayout_shadow, 0); array.recycle(); final View view = LayoutInflater.from(context).inflate(R.layout.view_shadow_wapper, this, true); ButterKnife.inject(this, view); setShadow(flag); } /** * Shadow * * @param flag */ public void setShadow(@ShadowFlag int flag) { switch (flag) { case FLAG_SHADOW_TOP: mShadowTop.setVisibility(VISIBLE); mShadowBottom.setVisibility(GONE); break; case FLAG_SHADOW_BOTTOM: mShadowTop.setVisibility(GONE); mShadowBottom.setVisibility(VISIBLE); break; case FLAG_SHADOW_BOTH: mShadowTop.setVisibility(VISIBLE); mShadowBottom.setVisibility(VISIBLE); break; case FLAG_SHADOW_NON: default: mShadowTop.setVisibility(GONE); mShadowBottom.setVisibility(GONE); break; } } }
使用例
// ... <!--上に影をつける--> <com.moneyforward.mfblogsmple.ui.widget.ShadowableFrameLayout xmlns:shadowable="http://schemas.android.com/apk/res-auto" shadowable:shadow="top" android:layout_width="match_parent" android:layout_height="match_parent"> <TextView // ... /> </com.moneyforward.mfblogsmple.ui.widget.ShadowableFrameLayout> // ...
まとめ
積極的にCustomViewを使うことで、Activity/Fragment内の処理を減らせたり、レイアウトファイルの簡素化を図れるので積極的に使いましょう。
今回使用したサンプルはこちらにおいてあります。
最後に
マネーフォワードでは、積極的に新しい技術や取り組みに挑戦し、ユーザのためになる開発を行いたいエンジニアを募集しています! みなさまのご応募お待ちしております! 【採用サイト】 ■『マネーフォワード採用サイト』 https://recruit.moneyforward.com/ ■『Wantedly』 https://www.wantedly.com/companies/moneyforward 【プロダクト一覧】 ■家計簿アプリ・クラウド家計簿ソフト『マネーフォワード』 https://moneyforward.com/ ■家計簿アプリ・クラウド家計簿ソフト『マネーフォワード』 iPhone,iPad ■家計簿アプリ・クラウド家計簿ソフト『マネーフォワード』 Android ■クラウド型会計ソフト『MFクラウド会計』 https://biz.moneyforward.com/ ■クラウド型請求書管理ソフト『MFクラウド請求書』 https://invoice.moneyforward.com/ ■クラウド型給与計算ソフト『MFクラウド給与』 https://payroll.moneyforward.com/