Money Forward Developers Blog

株式会社マネーフォワード公式開発者向けブログです。技術や開発手法、イベント登壇などを発信します。サービスに関するご質問は、各サービス窓口までご連絡ください。

20230215130734

最近AndroidのViewに思うこと(CustomView編)

マネーフォワードで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ではLoaderManagerLoaderCallbacksを実装した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で設定できます)

shadow.png

毎回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/