Edited at

[Android] STARBUCKSHOLICというアプリを公開したので使った技術を紹介します

More than 3 years have passed since last update.

ブログに書いたの転載です。


5月末にスタバのカスタムメニュー(シークレットメニュー)を注文できるSTARBUCKSHOLICというアプリをiOS,Androidでリリースしました。(iOSはこちら!)


自分はAndroidの開発を担当しました!ライトなアプリですが使ったライブラリやテクニックを少しでも紹介できればと思っています。


アプリ概要

主に2画面で構成されています。


一覧画面

上部のタブからメニューのカテゴリを選択して一覧として見れる画面です。


一番右のタブはお気に入りとなっていて、リストアイテムの右したのハートアイコンをタップするとお気に入りに登録されリストに追加されます。

各リストアイテムをタップすると詳細画面に遷移します。

ss-list


詳細画面

メニューの画像と注文方法が書いてあります。


タイトル、ベースドリンク、注文方法が載っています。

右上のシェアボタンから各SNS等にシェアできます。また、一覧画面同様に写真右下にあるハートアイコンからお気に入りの登録が出来ます。

ss-recipe


技術的な話


開発環境

下記で開発しました。特に変わったことはないです。


  • Mac OS X

  • Android Studio


使用したライブラリ

サクッと作りたかったのでライブラリ使いまくりです。



  • RxAndroid: Androidでリアクティブプログラミングできるよーなライブラリ。(流行ってるから使ってみたかった)


  • ButterKnife: 神が作ったfindViewByIdをなくしてくれるライブラリ。


  • Timber: 神が作ったログをいい感じに出力してくれるライブラリ


  • Glide: Androidの画像読み込みライブラリ。Google謹製?


  • android-floating-action-buttom: FABを簡単に設置できるライブラリ


  • android-sqlite-asset-helper: AssetsフォルダにあるSQLiteファイルを簡単に扱えるようにしてくれるライブラリ


  • bolts: Parse導入に必要だった。非同期処理を便利にできるようになるライブラリ。(普通に便利だった)

隠す必要もないのでGradleファイルを晒します。


お気に入り登録/解除

お気に入り登録にはちょっと手こずりました。初めは詳細画面のみお気に入りボタンがついていたのですが、一覧画面にも付けたいとのことになり。。。


「詳細画面でお気に入り登録/解除→一覧画面に戻ってくる」という動作のなかで一覧画面でハートの選択を変更しなくていはいけない。また、一覧画面がお気に入りタブの時はそのメニューをリストから削除しないといけないという感じで、あるあるな感じなんですが実装は面倒だという…

簡単じ実装するとonResume()で再描画すればいいのですが、すげーーーーーーーーダサくない??逃げじゃないですか。無駄な処理が毎回走るわけだし。UXも悪くなる可能性大。

なので、お気に入り登録/解除をObserverで通知するような仕組みにしました。Pub/Subでもいいんですがライブラリ入れるほどでもないなと思い自前で作成。


管理とObserver

お気に入り管理用のインターフェース。

public interface FavoriteManager {

public void addFavorite(long id);
public void removeFavorite(long id);
public boolean isFavorite(long id);
public Long[] getFavoriteIdList();
public int getFavoriteCount();

/**
* オブザーバー登録.
*
* @param observer
*/

public void registerObserver(Observer observer);

/**
* オブザーバー解除.
*
* @param observer
*/

public void unregisterObserver(Observer observer);

/**
* オブザーバー.
*/

public interface Observer {
void notify(long id, boolean isRemove);
}
}

お気に入りの実装クラス。


お気に入りアイテムが追加や削除されたらObserverに通知してます。

public class FavoriteManagerImpl implements FavoriteManager {

private static FavoriteManagerImpl sInstance;
private Context mContext;
private ArrayList<Long> favoriteIMenudList;
private ArrayList<Observer> mObservers;

/**
* コンストラクタ.
*
* @param context
*/

private FavoriteManagerImpl(Context context) {
// 初期化
}

/**
* インスタンス化.
*
* @param context
* @return
*/

public static synchronized FavoriteManager getInstance(Context context) {
if (sInstance != null) {
return sFavoriteManagerPreferenceImpl;
}
synchronized (FavoriteManagerPreferenceImpl.class) {
if (sInstance == null) {
sInstance = new FavoriteManagerImpl(context);
}
}
return sFavoriteManagerPreferenceImpl;
}

@Override
public void addFavorite(long id) {
if (favoriteIMenudList.contains(id)) {
return;
}
favoriteIMenudList.add(id);
notifyToObservers(id, false);
}

@Override
public void removeFavorite(long id) {
if (!favoriteIMenudList.contains(id)) {
return;
}
favoriteIMenudList.remove(id);
notifyToObservers(id, true);
}

@Override
public boolean isFavorite(long id) {
return favoriteIMenudList.contains(id);
}

@Override
public Long[] getFavoriteIdList() {
if (favoriteIMenudList.isEmpty()) {
return new Long[0];
}
Long[] copyList = new Long[favoriteIMenudList.size()];
for (int i = 0, size = favoriteIMenudList.size(); i < size; i++) {
copyList[i] = favoriteIMenudList.get(i);
}
return copyList;
}

@Override
public int getFavoriteCount() {
return favoriteIMenudList.size();
}

@Override
public void registerObserver(Observer observer) {
if (observer == null || mObservers.contains(observer)) {
return;
}
mObservers.add(observer);
}

@Override
public void unregisterObserver(Observer observer) {
if (observer == null || !mObservers.contains(observer)) {
return;
}
mObservers.remove(observer);
}

private void notifyToObservers(long menuId, boolean isRemove) {
for (Observer observer : mObservers) {
observer.notify(menuId, isRemove);
}
}

}


Observerの実装クラス

Observerで通知を貰いたいクラスは一覧画面の各リスト、今回だとRecyclerViewを使っているのでRecyclerView(Adapter)です。


リストの中身を管理しているAdapterクラスにObserverを実装します。気をつけないと行けないのはObserverの生存期間(登録期間)です。


RecyclerView.AdapterにはRecyclerViewにattach/detachされた時に呼ばれるメソッドonAttachedToRecyclerView(),onDetachedFromRecyclerViewがあるので、attachされた時にregisterして、detachされたときにunregisterすれば大丈夫です。

ちなみに、「詳細画面が表示されている時にはAdapterがdetachされてるのでは?」という疑問もありましたが実際に実行してみると詳細画面に遷移してもAdapterはattachされた状態で残ってくれてます。Activityが破棄されるときにdetachされるのではと思ってます。


お気に入り以外のリストの場合

お気に入りではない場合、リストアイテムの増減は無いので単純にMenuIdに対応するPositionのリストアイテムを更新して上げれば大丈夫です。


ポジションの取得ですが、リストアイテムの各ViewのTagにMenuを表すObjectをくっつけてあげ、そのObjectからIDに対応するViewを突き止め、RecyclerView#getChildAdapterPosition()というメソッドでポジションを取得します。

更新を伝えるためにRecyclerView.Adapter#notifyItemChanged()を呼んであげます。

public class MenuListAdapter extends RecyclerView.Adapter<MenuListAdapter.MenuListViewHolder> implements FavoriteManager.Observer {

/**
* menuIdからpositionを取得.
*
* @param menuId
* @return
*/

protected int findPositionByMenuId(long menuId) {
final RecyclerView recyclerView = mMenuPanelView.getMenuRecyclerView();
for (int i = 0, size = recyclerView.getChildCount(); i < size; i++) {
final View v = recyclerView.getChildAt(i);
final MenuEntity entity = (MenuEntity) v.getTag();
if (entity != null && entity.menuId == menuId) {
return recyclerView.getChildAdapterPosition(v);
}
}
return NOT_FOUND;
}

@Override
public void notify(long id, boolean isRemove) {
final int updatedPosition = findPositionByMenuId(id);
if (updatedPosition == NOT_FOUND) { // 見つからない場合は無視
return;
}
notifyItemChanged(updatedPosition);
}
}


お気に入りリストの場合

お気に入りリストの場合、MenuIdの追加/削除によってリストから追加/削除してあげないといけません。


追加削除はそれぞれRecyclerView.Adapter#notifyItemInserted(),RecyclerView.Adapter#notifyItemRemoved()というメソッドがあるので、適切なタイミングで呼んであげます。


MenuIdからPositionを取得するのは上の例と同じです。

public class FavoriteMenuListAdapter extends RecyclerView.Adapter<MenuListAdapter.MenuListViewHolder> implements FavoriteManager.Observer {

// お気に入りのメニューリスト
List<> mMenuEntityList;

@Override
public void notify(final long id, final boolean isRemove) {
if (isRemove) {
removeMenu(id);
} else {
insertMenu(id);
}
}

private void removeMenu(long id) {
final int position = findPositionByMenuId(id);
if (position == NOT_FOUND) {
return;
}
mMenuEntityList.remove(position);
notifyItemRemoved(position);
}

private void insertMenu(final long id) {
MenuEntify menu = some.findById(id); // MenuIdに対応するObject取得
mMenuEntityList.add(entity);
notifyItemInserted(mMenuEntityList.size() -1 );
}
}

こんな感じで通知をしてあげるとうまくお気に入りの登録/解除に対応できます。


RecyclerViewは頭がいいので追加削除のアニメーションもいい感じに付けてくれます。


SceneTransitionAnimation

LollipopからActivity→Activityの移動の時に遷移が自然になるようにSceneTransitionAnimationというのが実装されてます。


一覧画面から詳細画面への遷移の時に使ってみました。(DLして確認してみてね☆(ゝω・)vキャピ

すご~く簡単に説明すると、「一覧画面と詳細画面で共有するViewについては同じ名前つけて、startActivityするときに知らせてくれたらいい具合にアニメーションするよ」って感じかなぁ。

一覧画面のリストアイテムのView(一部省略)

<?xml version="1.0" encoding="utf-8"?>

<RelativeLayout
xmlns:android="http://schemas.android.com/apk/res/android">

<co.espal.starbucksholic.ui.widget.SquareProgressImageView
android:id="@+id/menu_img"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:transitionName="image"/>

<android.support.v7.widget.AppCompatTextView
android:id="@+id/menu_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_alignParentBottom="true"
android:layout_below="@+id/menu_img"
android:layout_centerHorizontal="true"/>

<android.support.v7.widget.AppCompatCheckBox
android:id="@+id/favorite_checkbox"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignBottom="@+id/menu_img"
android:layout_alignParentEnd="true"
android:layout_alignParentRight="true"/>
</RelativeLayout>

co.espal.starbucksholic.ui.widget.SquareProgressImageView(正方形で読み込み中はProgress表示したい自作のImageView)の属性にandroid:transitionName="image"というのがあり、これが名前です。これを詳細画面にも引き継ぎたいんです。

詳細画面のレイアウト(だいぶ省略)

<FrameLayout

xmlns:android="http://schemas.android.com/apk/res/android">

<ScrollView
android:id="@+id/scroll_view"
android:layout_width="match_parent"
android:layout_height="match_parent">

<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">

<RelativeLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">

<co.espal.starbucksholic.ui.widget.SquareProgressImageView
android:id="@+id/menu_img"
android:layout_width="match_parent"
android:layout_height="360dp"
android:transitionName="image"/>

<View
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_alignBottom="@+id/menu_title"
android:layout_alignParentLeft="true"
android:layout_alignParentRight="true"
android:layout_alignTop="@+id/menu_title"
android:background="#efebe9"/>

<android.support.v7.widget.AppCompatTextView
android:id="@+id/menu_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_below="@+id/menu_img"
android:layout_toLeftOf="@+id/favorite_button"
android:layout_toStartOf="@+id/favorite_button"/>

</RelativeLayout>

<!-- 省略 -->

</LinearLayout>

</ScrollView>

<include
android:id="@+id/tool_bar"
layout="@layout/view_toolbar_transparent"
android:layout_width="match_parent"
android:layout_height="wrap_content"/>

</FrameLayout>

詳細画面のSquareProgressImageViewにも一覧と同じようにandroid:transitionName="image"というのがあり、これが一覧画面から引き継ぎたいなーといいうViewの名前です。

それで呼び出す時にちょこっと工夫してあげるといい感じに画面遷移してくれます。

// "image"って名前のViewをActivity同士で共有するよ!って事。

final ActivityOptionsCompat options = ActivityOptionsCompat.makeSceneTransitionAnimation(activity, imageView.getImageView(), "image");
final Intent intent = new Intent(this,DetailActivity.class);
ActivityCompat.startActivity(activity, intent, options.toBundle())

これでViewを共通に使った感じで画面遷移してくれます。アニメーションの付け方はまぁ今回はいいでしょう。


注意点

一覧画面の時には必要なサイズ(リストアイテムのサイズ)の画像を指定して読み込んでましたが、詳細に行った時に画像サイズが変わりませんでした。


詳細画面の画像が横幅全てにならず、画面幅3/4ぐらいのサイズになってしまう事象が…

これを回避するために、一覧画面の時に読み込むサイズを画面横幅と同じにしました。(←ここ重要)


これで詳細画面にいったさいにも画面幅ピッタリになってくれました。


まとめ

アプリをリリースしたので長々と色々書いてみました。


開発者さんのみなさんのお役に立てば幸いです!!!是非アプリもDLしてください!!!!こちらから。iOSもあるよ

お気に入り処理はめんどいすよね…