實戰項目 10: 貨物清單應用

這篇文章分享個人 Android 開發(入門)課程 的最後一個實戰項目:貨物清單應用。這個項目託管在個人 GitHub 上,具體是 InventoryApp Repository,項目介紹已詳細寫在 README 上,歡迎你們 star 和 fork。html

這個實戰項目的主要目的是練習在 Android 中使用 SQLite 數據庫。與 實戰項目 9: 習慣記錄應用 直接在 Activity 中操做數據庫的作法不一樣,InventoryApp 採用了更符合 Android 設計規範的框架,即java

  • 數據庫端
    (1)使用 Contract 類定義數據庫相關的常量,如 Content URI 及其 MIME 類型、數據庫的表格名稱以及各列名稱。
    (2)使用自定義 SQLiteOpenHelper 類管理數據庫,如新建數據庫表格、升級數據庫架構。
    (3)使用自定義 ContentProvider 類實現數據庫的 CRUD 操做,其中包括對數據庫更新和插入數據時的數據校驗。
  • UI 端
    經過 ContentResolver 對數據庫實現插入、更新、刪除數據的交互,而讀取數據經過 CursorLoader 在後臺線程實現。

因而可知,InventoryApp 的數據庫框架與課程中介紹的相同,因此這部份內容再也不贅述,詳情可參考相關的學習筆記,如《課程 3: Content Providers 簡介》。值得一提的是,InventoryApp 的數據庫須要存儲圖片,可是沒有將圖片數據直接存入數據庫(如將圖片轉換爲 byte[] 以 BLOB 原樣存入數據庫),而是存儲了圖片的 URI,這樣極大地下降了數據庫的體積,同時也減輕了應用處理數據的負擔。node

除此以外,InventoryApp 還使用了不少其它有意思的 Android 組件,這篇文章按例分享給你們,但願對你們有幫助,歡迎互相交流。爲了精簡篇幅,文中的代碼有刪減,請以 GitHub 中的代碼爲準。android

關鍵詞:RecyclerView & CursorLoader、Glide、Runtime Permissions、DialogFragment、經過相機應用拍攝照片以及在相冊中選取圖片、FileProvider、AsyncTask、Intent to Email with Attachment、InputFilter、RegEx、禁止設備屏幕旋轉、Drawable Resources、FloatingActionButtongit

RecyclerView 從 CursorLoader 接收數據以填充列表

雖然課程中介紹的 ListView 和 GridView 可以輕鬆地與 CursorLoader 配合顯示列表,可是 RecyclerView 做爲 ListView 的升級版,它是一個更靈活的 Android 組件,尤爲是在列表的子項須要加載的數據量較大或者子項的數據須要頻繁更新的時候,RecyclerView 更適合這種應用場景。例如在 實戰項目 7&8 : 從 Web API 獲取數據 中,BookListing App 實現了可擴展 CardView 效果的 RecyclerView 列表,以下圖所示。github

RecyclerView 的使用教程能夠參考 這個 Android Developers 文檔。在 InventoryApp 中,首先在 CatalogActivity 中建立一個 RecyclerView 對象,並進行初始化設置,在這裏主要是經過 setLayoutManager 將列表的佈局模式設置爲兩列的、交錯分佈的垂直列表。其中,這種交錯網格佈局 (StaggeredGridLayout) 也是 InventoryApp 使用 RecyclerView 的一個緣由;GridView 默認狀況下只能顯示對齊的網格,當子項之間的尺寸(寬或高)不一樣時,會以最大的那個對齊,這樣就會產生沒必要要的空隙。正則表達式

In CatalogActivity.java數據庫

@Override
protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_catalog);

    RecyclerView recyclerView = findViewById(R.id.list);

    recyclerView.setLayoutManager(new StaggeredGridLayoutManager(2, StaggeredGridLayoutManager.VERTICAL));

    mAdapter = new InventoryAdapter(this, null);
    recyclerView.setAdapter(mAdapter);

    ...
}
複製代碼

固然,RecyclerView 一樣採用適配器模式向列表填充數據,並且業務邏輯與 CursorAdapter 相似:首先經過 onCreateViewHolder 建立新的子項視圖,隨後經過 onBindViewHolder 將數據填充到視圖中;視圖回收時則直接經過 onBindViewHolder 將數據填充到回收的視圖中。不一樣的是,RecyclerView 列表的子項佈局須要由自定義 RecyclerView.ViewHolder 類提供,具體的應用流程是api

  1. 首先在 onCreateViewHolder 中根據子項佈局建立一個自定義 ViewHolder 對象。
  2. 而後將自定義 ViewHolder 對象傳遞至 onBindViewHolder 對相應位置的子項進行數據填充。

所以,在 InventoryApp 中的 RecyclerView 適配器自定義爲 InventoryAdapter,注意類名後的 extends 參數爲 RecyclerView.Adapter,其泛型參數爲 VH,即自定義的 RecyclerView.ViewHolder,在這裏做爲適配器的內部類實現。緩存

In InventoryAdapter.java

public class InventoryAdapter extends RecyclerView.Adapter<InventoryAdapter.MyViewHolder> {

    private Cursor mCursor;
    private Context mContext;

    public InventoryAdapter(Context context, Cursor cursor) {
        mContext = context;
        mCursor = cursor;
    }

    @Override
    public int getItemCount() {
        if (mCursor == null) {
            return 0;
        } else {
            return mCursor.getCount();
        }
    }

    public class MyViewHolder extends RecyclerView.ViewHolder {
        private ImageView imageView;
        private TextView nameTextView, priceTextView, quantityTextView;
        private FloatingActionButton fab;

        private MyViewHolder(View view) {
            super(view);

            imageView = view.findViewById(R.id.item_image);
            nameTextView = view.findViewById(R.id.item_name);
            priceTextView = view.findViewById(R.id.item_price);
            quantityTextView = view.findViewById(R.id.item_quantity);
            fab = view.findViewById(R.id.fab_sell);
        }
    }

    ...
}
複製代碼
  1. 首先定義 InventoryAdapter 的構造函數,輸入參數分別爲 Context 和 Cursor 對象,其中 Cursor 包含了列表須要顯示的內容,它定義爲一個全局變量,使其能由 getItemCount 等方法利用。當初始化或重置適配器時,Cursor 可傳入 null 表示列表無數據顯示,適配器不會出錯。
  2. 而後實現自定義 RecyclerView.ViewHolder 類,名爲 MyViewHolder,其構造函數根據傳入的 View 對象(一般是根據 Layout 生成)找到須要填充數據的視圖,注意這些視圖須要聲明爲內部類 MyViewHolder 的全局變量;另外在構造函數內不要忘記調用超級類,輸入參數爲傳入的 View 對象。

有了上述基礎,InventoryAdapter 就能夠根據自定義 ViewHolder 對象實現列表的數據填充了。首先在 onCreateViewHolder 中經過 LayoutInflater 根據列表子項的佈局文件生成一個 View 對象,而後建立一個 MyViewHolder 對象,輸入參數即生成的 View 對象,最後返回該 MyViewHolder 對象。

In InventoryAdapter.java

@NonNull
@Override
public InventoryAdapter.MyViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
    View itemView = LayoutInflater.from(parent.getContext()).inflate(R.layout.list_item, parent, false);
    MyViewHolder myViewHolder = new MyViewHolder(itemView);

    return myViewHolder;
}
複製代碼

而後在 onBindViewHolder 中根據傳入的 MyViewHolder 對象以及 Cursor 進行數據填充。注意在進行任何操做以前,須要將 Cursor 的位置移到當前位置上。

In InventoryAdapter.java

@Override
public void onBindViewHolder(@NonNull final InventoryAdapter.MyViewHolder holder, int position) {
    if (mCursor.moveToPosition(position)) {

        ...

        GlideApp.with(mContext).load(imageUriString)
                .transforms(new CenterCrop(), new RoundedCorners(
                        (int) mContext.getResources().getDimension(R.dimen.background_corner_radius)))
                .into(holder.imageView);

        ...
    }
}
複製代碼

至此,RecyclerView 的適配器基本框架就已經實現了。不過在 InventoryApp 中的實際應用中,還有幾個須要注意的點。

1、Glide

對於 Android 來講,在列表中顯示多張圖片是一項既耗時又耗性能的工做,是否須要而又如何將讀取圖片資源、根據視圖大小裁剪圖片等工做放入後臺線程,這是 InventoryApp 在開發過程當中踩過的大坑。在查閱 這篇 Android Developers 文檔 後,才瞭解到絕大多數狀況下,Glide 庫 都能僅用一行代碼就完美地實現圖片抓取、解碼、顯示,它甚至支持 GIF 動圖以及視頻快照。

在 InventoryApp 中,使用了 Glide 目前最新的 v4 版本(已穩定,v3 版本已不維護)的 Generated API ,主要緣由是須要利用 Glide 的 多重變換 設置圖片 centerCrop 的裁剪模式以及四周圓角 (RoundedCorners)。Glide 的文檔很是豐富,上手很是簡單,因此這裏再也不贅述。

2、swapCursor

因爲在 InventoryApp 中 RecyclerView 須要從 CursorLoader 接收數據,在 onLoadFinishedonLoaderReset 須要調用適配器的 swapCursor 方法,而 RecyclerView 沒有提供相似 ListView 的相應方法,因此須要在適配器中本身實現。

In InventoryAdapter.java

public void swapCursor(Cursor cursor) {
    mCursor = cursor;
    notifyDataSetChanged();
}
複製代碼

在這裏,swapCursor 方法的輸入參數爲一個 Cursor 對象;在方法內,更新適配器內的 Cursor 全局變量,完成後通知適配器列表的數據集發生了變化。

3、列表子項的點擊事件監聽器

onCreateViewHolder 中生成的 View 對象表示每個列表子項,對其設置 OnClickListener 就能夠響應列表子項的點擊事件。

In InventoryAdapter.java

@NonNull
@Override
public InventoryAdapter.MyViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
    View itemView = LayoutInflater.from(parent.getContext()).inflate(R.layout.list_item, parent, false);
    final MyViewHolder myViewHolder = new MyViewHolder(itemView);
    // Setup each item listener here.
    itemView.setOnClickListener(new View.OnClickListener() {
        @Override
        public void onClick(View view) {
            int position = myViewHolder.getAdapterPosition();
            if (mOnItemClickListener != null) {
                // Send the click event back to the host activity.
                mOnItemClickListener.onItemClick(view, position, getItemId(position));
            }
        }
    });

    return myViewHolder;
}

public long getItemId(int position) {
    if (mCursor != null) {
        if (mCursor.moveToPosition(position)) {
            int idColumnIndex = mCursor.getColumnIndex(InventoryEntry._ID);
            return mCursor.getLong(idColumnIndex);
        }
    }

    return 0;
}
複製代碼
  1. 首先調用 MyViewHolder 的 getAdapterPosition() 方法獲取當前子項的位置。
  2. 而後調用 OnItemClickListener 的 onItemClick 方法,表示在使用 RecyclerView 的 CatalogActivity 中對列表子項的點擊事件進行響應,輸入參數包括當前子項的位置及其在數據庫中的 ID,其中 ID 經過 getItemId 方法查詢 Cursor 的相應鍵得到。

在 InventoryApp 中,RecyclerView 列表的每個子項的被點擊時的動做是由 CatalogActivity 跳轉到 DetailActivity 中,這裏要用到 Intent 組件,因此在 CatalogActivity 中響應列表子項的點擊事件比較合理。不過 RecyclerView.Adapter 沒有默認的子項點擊事件監聽器,因此這裏須要本身實現。

In InventoryAdapter.java

private OnItemClickListener mOnItemClickListener;

public void setOnItemClickListener(OnItemClickListener onItemClickListener) {
    mOnItemClickListener = onItemClickListener;
}

public interface OnItemClickListener {
    void onItemClick(View view, int position, long id);
}
複製代碼
  1. 首先定義一個接口 (interface),名爲 OnItemClickListener,裏面放置一個 onItemClick 方法,表示 Activity 或 Fragment 在實例化這個接口時必須實現該方法。
  2. 而後將 OnItemClickListener 接口定義爲一個全局變量,使其在適配器內可被其它方法應用。
  3. 最後定義一個 setOnItemClickListener 方法,將 OnItemClickListener 接口的實例化對象做爲輸入參數,而且在方法內將傳入的 OnItemClickListener 對象賦給上述的全局變量,在這裏即把 Activity 或 Fragment 實現的 OnItemClickListener 接口的實例化對象傳入適配器。

這種代碼結構體現了典型的 Java 繼承特性。在 CatalogActivity 中實現 RecyclerView 列表子項的點擊事件響應代碼以下,可見 RecyclerView 的適配器調用 setOnItemClickListener 方法,傳入一個新的 OnItemClickListener 對象,並在其中實現 onItemClick 方法。代碼結構與 ListView 的 AdapterView.OnItemClickListener 相同。

In CatalogActivity.java

@Override
protected void onCreate(Bundle savedInstanceState) {

    ...

    mAdapter.setOnItemClickListener(new InventoryAdapter.OnItemClickListener() {
        @Override
        public void onItemClick(View view, int position, long id) {
            Intent intent = new Intent(CatalogActivity.this, DetailActivity.class);

            Uri currentItemUri = ContentUris.withAppendedId(InventoryEntry.CONTENT_URI, id);
            intent.setData(currentItemUri);

            startActivity(intent);
        }
    });
}
複製代碼

4、Empty View

爲 RecyclerView 列表添加一個空視圖是提高用戶體驗的必要之舉,因爲 RecyclerView 從 CursorLoader 接收數據,因此能夠利用 CursorLoader 在加載數據完畢後的 onLoadFinished 方法中判斷列表的狀態,若是列表爲空,則顯示空視圖;若是列表中有數據,則消除空視圖。

In CatalogActivity.java

@Override
public void onLoadFinished(Loader<Cursor> loader, Cursor data) {
    mAdapter.swapCursor(data);

    View emptyView = findViewById(R.id.empty_view);
    if (mAdapter.getItemCount() == 0) {
        emptyView.setVisibility(View.VISIBLE);
    } else {
        emptyView.setVisibility(View.GONE);
    }
}
複製代碼

運行時權限請求

在 InventoryApp 中包含讀寫圖片文件的操做,這涉及了 Android 危險權限,因此應用須要請求 STORAGE 這一個權限組,以得到讀寫外部存儲器中的文件的權限。關於 Android 權限的更多介紹可參考《課程 2: HTTP 網絡》

所以,首先在 AndroidManifest 中添加 參數,放在頂級元素 下面。在這裏,只添加了一條 WRITE_EXTERNAL_STORAGE 參數,而沒有添加 READ_EXTERNAL_STORAGE 參數。這是由於二者屬於同一個權限組,應用得到前者的寫權限時會自動獲取後者的讀權限。

In AndroidManifest.xml

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.example.android.inventoryapp">

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

    <application ...>
        ...
    </application>
</manifest>
複製代碼

Note:
從 Android 4.4 KitKat (API level 19) 開始,應用經過 getExternalFilesDir(String)getExternalCacheDir() 讀寫應用自身目錄下(僅應用自己可見)的文件時,不須要請求 STORAGE 權限組。

至此,對於運行在 Android 5.1 (API level 22) 或如下的設備,InventoryApp 在安裝時 (Install Time),就會彈出對話框,顯示應用請求的 STORAGE 權限組,用戶必須贊成該權限請求,不然沒法安裝應用。而對於運行在 Android 6.0 (API level 23) 或以上的設備,須要在 InventoryApp 運行時 (Runtime),彈出對話框請求 STORAGE 權限組;若是應用沒有相關的代碼處理運行時權限請求,那麼默認不具備該權限。

所以,應用須要在恰當的時機向用戶請求權限。因爲 InventoryApp 所需的 STORAGE 權限組僅在進行圖片相關的操做時涉及到,因此在 DetailActivity 中處理圖片的惟一入口處設置 OnClickListener 來處理運行時權限請求。

In DetailActivity.java

@Override
protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_detail);

    ...

    View imageContainer = findViewById(R.id.item_image_container);
    imageContainer.setOnClickListener(new View.OnClickListener() {
        @Override
        public void onClick(View v) {
            // Check permission before anything happens.
            if (hasPermissionExternalStorage()) {
                // Permission has already been granted, then start the dialog fragment.
                startImageChooserDialogFragment();
            }
        }
    });
}
複製代碼

當圖片編輯框被點擊時,監聽器內會調用一個輔助方法,判斷是否已得到所需的權限,如果則返回 true,才進行下面的工做。值得注意的是,InventoryApp 在每一次圖片編輯框被點擊時都必須檢查是否已得到所需的權限,由於從 Android 6.0 Marshmallow (API level 23) 開始,用戶可隨時撤回給予應用的權限。

In DetailActivity.java

private boolean hasPermissionExternalStorage() {
    if (ContextCompat.checkSelfPermission(getApplicationContext(),
            Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
        // Permission is NOT granted.
        if (ActivityCompat.shouldShowRequestPermissionRationale(DetailActivity.this,
                Manifest.permission.WRITE_EXTERNAL_STORAGE)) {
            // Show an explanation with snack bar to user if needed.
            Snackbar snackbar = Snackbar.make(findViewById(R.id.editor_container),
                    R.string.permission_required, Snackbar.LENGTH_LONG);
            // Prompt user a OK button to request permission.
            snackbar.setAction(android.R.string.ok, new View.OnClickListener() {
                @Override
                public void onClick(View v) {
                    // Request the permission.
                    ActivityCompat.requestPermissions(DetailActivity.this,
                            new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE},
                            PERMISSION_REQUEST_EXTERNAL_STORAGE);
                }
            });
            snackbar.show();
        } else {
            // Request the permission directly, if it doesn't need to explain. ActivityCompat.requestPermissions(DetailActivity.this, new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE}, PERMISSION_REQUEST_EXTERNAL_STORAGE); } return false; } else { // Permission has already been granted, then return true. return true; } } 複製代碼
  1. 在輔助方法 hasPermissionExternalStorage 中,首先判斷應用是否已得到 WRITE_EXTERNAL_STORAGE 權限,如果則返回 true。
  2. 若是應用還沒有得到須要的權限,那麼首先經過 ActivityCompat 的 shouldShowRequestPermissionRationale 方法判斷是否須要向用戶顯示請求該權限的理由,若不須要則直接經過 ActivityCompat 的 requestPermissions 方法請求權限,其中輸入參數依次爲
    (1)activity: 請求權限的當前 Activity,在這裏即 DetailActivity。
    (2)permissions: 須要請求的權限列表,做爲一個字符串列表對象傳入,不能爲空。
    (3)requestCode: 該權限請求的惟一標識符,一般定義爲一個全局的整數常量,它在接收權限請求的結果時會用到。
  3. 若是用戶以前拒絕過權限請求,那麼 shouldShowRequestPermissionRationale 方法會返回 true,表示須要向用戶顯示請求該權限的理由,並異步處理權限請求。在這裏,經過彈出一個 Snackbar 顯示請求該權限的理由,並提供一個 OK 按鈕,用戶點擊後會經過 ActivityCompat 的 requestPermissions 方法請求權限,此時應用會彈出一個標準的(應用沒法配置或改變)對話框供用戶選擇是否贊成該權限請求。

應用發起權限請求後,用戶的選擇會經過 onRequestPermissionsResult 方法獲取,在這裏響應不一樣的請求結果。

In DetailActivity.java

@Override
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions,
                                       @NonNull int[] grantResults) {
    if (requestCode == PERMISSION_REQUEST_EXTERNAL_STORAGE) {
        if (grantResults.length == 1 && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
            // For the first time, permission was granted, then start the dialog fragment.
            startImageChooserDialogFragment();
        } else {
            // Prompt to user that permission request was denied.
            Toast.makeText(this, R.string.toast_permission_denied, Toast.LENGTH_SHORT)
                    .show();
        }
    } else {
        super.onRequestPermissionsResult(requestCode, permissions, grantResults);
    }
}
複製代碼
  1. 首先經過權限請求的惟一標識符區分不一樣請求,若是不是指望的請求,那麼就調用超級類保持默認行爲。
  2. 針對特定的權限請求,進一步判斷用戶是否贊成該請求,如果則進行下面的工做;若用戶拒絕則顯示一個相關的 Toast 消息。

至此,運行時權限請求基本上就完成了,處理流程以下圖所示。更多信息可參考 這個 Android Developers 文檔

Note:
InventoryApp 也使用了相機應用拍攝照片,可是這裏不須要請求訪問相機的權限,由於 InventoryApp 並不是直接操控攝像頭硬件模塊,而是經過 Intent 利用相機應用來獲取圖片資源,這也是使用 Intent 的一個優點。

DialogFragment

在 InventoryApp 中,應用得到讀寫外部存儲器文件的權限後,用戶點擊 DetailActivity 中的圖片編輯框時,會調用一個輔助方法,彈出一個標籤爲 imageChooser 的自定義對話框,提供了兩個選項。

In DetailActivity.java

private void startImageChooserDialogFragment() {
    DialogFragment fragment = new ImageChooserDialogFragment();
    fragment.show(getFragmentManager(), "imageChooser");
}
複製代碼

上述對話框自定義爲 ImageChooserDialogFragment,放在單獨的 Java 文件中,屬於 DialogFragment 的子類。首先在 onCreateDialog 方法中,建立並返回一個 Dialog 對象。

In ImageChooserDialogFragment.java

public class ImageChooserDialogFragment extends DialogFragment {

    @Override
    public Dialog onCreateDialog(Bundle savedInstanceState) {
        LayoutInflater inflater = getActivity().getLayoutInflater();
        View view = inflater.inflate(R.layout.dialog_image_chooser, null);

        AlertDialog.Builder builder = new AlertDialog.Builder(getActivity());
        builder.setView(view);

        return builder.create();
    }

    ...
}
複製代碼
  1. 首先經過 LayoutInflater 根據對話框的佈局文件生成一個 View 對象。
  2. 而後經過 AlertDialog.Builder 配置對話框,主要是將上面生成的 View 對象設置爲對話框的佈局。
  3. 最後調用 AlertDialog.Builder 對象的 create() 方法,返回一個 Dialog 對象。

因爲 ImageChooserDialogFragment 的兩個選項的點擊事件都須要使用 Intent 組件,因此與上述 RecyclerView.Adapter 的列表子項點擊事件監聽器相同,這裏也要在調用 ImageChooserDialogFragment 的 DetailActivity 中響應其中兩個選項的點擊事件。相似地,在 ImageChooserDialogFragment 中定義點擊事件的接口,以及相關的變量與方法。

In ImageChooserDialogFragment.java

private ImageChooserDialogListener mListener;

@Override
public void onAttach(Activity activity) {
    super.onAttach(activity);
    try {
        mListener = (ImageChooserDialogListener) activity;
    } catch (ClassCastException e) {
        throw new ClassCastException(activity.toString()
                + " must implement ImageChooserDialogListener.");
    }
}

public interface ImageChooserDialogListener {
    void onDialogCameraClick(DialogFragment dialog);
    void onDialogGalleryClick(DialogFragment dialog);
}
複製代碼
  1. 首先定義一個接口 (interface),名爲 ImageChooserDialogListener,裏面放置兩個方法,分別做爲兩個選項的點擊事件的響應方法。Activity 在使用 ImageChooserDialogFragment 時必須實現接口內的兩個方法。
  2. 而後將 ImageChooserDialogListener 接口定義爲一個全局變量,使其能在 onAttach 方法內根據 Activity 初始化,並在其它地方應用,例如在 onCreateDialog 中設置兩個選項的點擊事件監聽器,分別調用 ImageChooserDialogListener 的兩個方法,表示在 DetailActivity 中對點擊事件進行響應。

In ImageChooserDialogFragment.java

@Override
public Dialog onCreateDialog(Bundle savedInstanceState) {
    LayoutInflater inflater = getActivity().getLayoutInflater();
    View view = inflater.inflate(R.layout.dialog_image_chooser, null);

    View cameraView = view.findViewById(R.id.action_camera);
    View galleryView = view.findViewById(R.id.action_gallery);

    cameraView.setOnClickListener(new View.OnClickListener() {
        @Override
        public void onClick(View v) {
            // Send the camera click event back to the host activity.
            mListener.onDialogCameraClick(ImageChooserDialogFragment.this);
            // Dismiss the dialog fragment.
            dismiss();
        }
    });

    galleryView.setOnClickListener(new View.OnClickListener() {
        @Override
        public void onClick(View v) {
            // Send the gallery click event back to the host activity.
            mListener.onDialogGalleryClick(ImageChooserDialogFragment.this);
            // Dismiss the dialog fragment.
            dismiss();
        }
    });

    ...
}
複製代碼
  1. 首先根據由佈局文件生成的 View 對象找到兩個選項的視圖,分別爲「相機」和「相冊」。
  2. 相機視圖的點擊事件監聽器調用 ImageChooserDialogListener 的 onDialogCameraClick 方法,在 DetailActivity 中響應點擊事件,隨後經過 dismiss() 方法關閉對話框。
  3. 相似地,相冊視圖的點擊事件監聽器調用 ImageChooserDialogListener 的 onDialogGalleryClick 方法,在 DetailActivity 中響應點擊事件,隨後經過 dismiss() 方法關閉對話框。

關於 Dialog 的更多信息可參考 這個 Android Developers 文檔

經過相機應用拍攝照片以及在相冊中選取圖片

在調用 ImageChooserDialogFragment 的 DetailActivity 中響應其中兩個選項的點擊事件,即實現 ImageChooserDialogListener 接口內的兩個方法,這裏完成了經過相機應用拍攝照片以及在相冊中選取圖片的功能。

In DetailActivity.java

public class DetailActivity extends AppCompatActivity
        implements ImageChooserDialogFragment.ImageChooserDialogListener {

    public static final String FILE_PROVIDER_AUTHORITY = "com.example.android.fileprovider.camera";

    private static final int REQUEST_IMAGE_CAPTURE = 0;
    private static final int REQUEST_IMAGE_SELECT = 1;

    @Override
    public void onDialogGalleryClick(DialogFragment dialog) {
        Intent selectPictureIntent = new Intent();
        selectPictureIntent.setAction(Intent.ACTION_GET_CONTENT);
        selectPictureIntent.setType("image/*");
        if (selectPictureIntent.resolveActivity(getPackageManager()) != null) {
            startActivityForResult(selectPictureIntent, REQUEST_IMAGE_SELECT);
        }
    }

    @Override
    public void onDialogCameraClick(DialogFragment dialog) {
        Intent takePictureIntent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE);
        if (takePictureIntent.resolveActivity(getPackageManager()) != null) {
            File imageFile = null;
            try {
                imageFile = createCameraImageFile();
            } catch (IOException e) {
                Log.e(LOG_TAG, "Error creating the File " + e);
            }

            if (imageFile != null) {
                Uri imageURI = FileProvider.getUriForFile(this,
                        FILE_PROVIDER_AUTHORITY, imageFile);
                takePictureIntent.putExtra(MediaStore.EXTRA_OUTPUT, imageURI);
                startActivityForResult(takePictureIntent, REQUEST_IMAGE_CAPTURE);
            }
        }
    }
}
複製代碼
  1. 在相冊中選取圖片的 Intent 比較簡單,URI 設爲 Intent.ACTION_GET_CONTENT,MIME 類型設爲 image/*,最後經過 startActivityForResult 方法啓動帶有回傳數據的 Intent,其中輸入參數爲
    (1)intent: 上面配置好的 Intent 對象,在這裏即 selectPictureIntent。
    (2)requestCode: Intent 的惟一標識符,一般定義爲一個全局的整數常量,它在接收 Intent 的回傳數據時會用到。
  2. 經過相機應用拍攝照片的 Intent 則相對複雜,主要的工做是建立一個文件,用於存儲相機應用拍攝的照片。完整的步驟以下,更多信息可參考 這個 Android Developers 文檔
    (1)首先設置 Intent 的 URI 爲 MediaStore.ACTION_IMAGE_CAPTURE。
    (2)而後經過輔助方法建立一個 File 對象,這裏須要捕捉可能由建立文件產生的 IOException 異常。
    (3)若是成功建立 File 對象,那麼就經過 FileProvider 的 getUriForFile 方法獲取該文件的 URI,並做爲 EXTRA_OUTPUT 數據傳入 Intent,在這裏就指定了相機應用拍攝的照片的存儲位置。
    (4)最後經過 startActivityForResult 方法啓動帶有回傳數據的 Intent,其中惟一標識符爲 REQUEST_IMAGE_CAPTURE。
  3. 在經過相機應用拍攝照片的 Intent 中,調用了一個輔助方法來建立 File 對象,代碼以下,邏輯並不複雜。
    (1)首先經過 SimpleDateFormat 得到一個固定格式的時間戳,再加上先後綴就構成了一個抗衝突 (collision-resistant) 的文件名。
    (2)而後經過 Environment 的 getExternalStoragePublicDirectory 方法,以及 Environment.DIRECTORY_PICTURES 輸入參數,獲取一個公共的圖片目錄。這樣用戶經過相機應用拍攝的照片就能被全部應用訪問,這是符合 Android 設計規範的。
    (3)最後經過 File 的 createTempFile 方法建立並返回一個 File 對象,其中輸入參數包括上述定義的文件名以及存儲目錄。
    (4)另外經過 File 對象的 getAbsolutePath() 方法獲取新建的圖片文件的目錄路徑,它在接收 Intent 的回傳數據時會用到。

In DetailActivity.java

private String mCurrentPhotoPath;

    private File createCameraImageFile() throws IOException {
        String timeStamp = new SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault())
                .format(new Date());
        String imageFileName = "JPEG_" + timeStamp + "_";

        File storageDirectory = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES);
        File imageFile = File.createTempFile(
                imageFileName,      /* prefix    */
                ".jpg",             /* suffix    */
                storageDirectory    /* directory */
        );

        mCurrentPhotoPath = imageFile.getAbsolutePath();

        return imageFile;
    }
複製代碼
  1. 在經過相機應用拍攝照片的 Intent 中,經過 FileProvider 的 getUriForFile 方法獲取了圖片文件的 URI,其中輸入參數爲
    (1)context: 當前的應用環境,在這裏即 this 表示當前的 DetailActivity。
    (2)authority: FileProvider 的主機名,必須與 AndroidManifest 中的一致。
    (3)file: 須要獲取 URI 的 File 對象,在這裏即上面生成的圖片文件 imageFile。

顯然,這裏使用了 Android 提供的 FileProvider,須要在 AndroidManifest 中聲明。

In AndroidManifest.xml

<application>

   ...

   <provider
        android:name="android.support.v4.content.FileProvider"
        android:authorities="com.example.android.fileprovider.camera"
        android:exported="false"
        android:grantUriPermissions="true">
        <meta-data
            android:name="android.support.FILE_PROVIDER_PATHS"
            android:resource="@xml/file_paths" />
    </provider>
</application>
複製代碼

其中元數據指定了文件的目錄,定義在 xml/file_paths 目錄下。

In res/xml/file_paths.xml

<paths>
    <!-- Declare the path to the public Pictures directory. -->
    <external-path name="item_images" path="." />
</paths>
複製代碼

因爲圖片文件放在公共目錄下,因此 FileProvider 指定的文件目錄與應用內部的不一樣,具體可參考 這個 stack overflow 帖子

經過相機應用拍攝照片以及在相冊中選取圖片的兩個 Intent 都是帶有回傳數據的,所以經過 override onActivityResult 方法獲取 Intent 的回傳數據。

In DetailActivity.java

private Uri mLatestItemImageUri = null;

@Override
protected void onActivityResult(int requestCode, int resultCode, Intent intent) {
    if (resultCode == RESULT_OK) {
        switch (requestCode) {
            case REQUEST_IMAGE_CAPTURE:
                mLatestItemImageUri = Uri.fromFile(new File(mCurrentPhotoPath));

                GlideApp.with(this).load(mLatestItemImageUri)
                        .transforms(new CenterCrop(), new RoundedCorners(
                                (int) getResources().getDimension(R.dimen.background_corner_radius)))
                        .into(mImageView);
                break;
            case REQUEST_IMAGE_SELECT:
                Uri contentUri = intent.getData();

                GlideApp.with(this).load(contentUri)
                        .transforms(new CenterCrop(), new RoundedCorners(
                                (int) getResources().getDimension(R.dimen.background_corner_radius)))
                        .into(mImageView);

                new copyImageFileTask().execute(contentUri);
                break;
        }
    }
}
複製代碼
  1. 首先判斷 Intent 請求是否成功,如果再根據不一樣 Intent 的惟一標識符分別進行處理。
  2. 對於經過相機應用拍攝照片的 Intent,由於數據庫僅存儲圖片的 URI,而不是存儲圖片數據自己,因此在這裏,根據以前新建圖片文件時獲取的目錄路徑得到一個 file URI,並賦給全局變量 mLatestItemImageUri;最後利用 Glide 顯示圖片。
  3. 對於在相冊中選取圖片的 Intent,經過 getData() 方法得到用戶選擇的圖片文件的 Content URI,隨後利用 Glide 顯示圖片。值得注意的是,這裏沒有直接把從 Intent 獲取的 Content URI 賦給 mLatestItemImageUri,而是經過一個 AsyncTask 在後臺線程將用戶選擇的圖片文件複製到應用內部目錄的文件中,再將複製的文件的 file URI 賦給 mLatestItemImageUri。

In DetailActivity.java

private class copyImageFileTask extends AsyncTask<Uri, Void, Uri> {
    @Override
    protected Uri doInBackground(Uri... uris) {
        if (uris[0] == null) {
            return null;
        }

        try {
            File file = createCopyImageFile();

            InputStream input = getContentResolver().openInputStream(uris[0]);
            OutputStream output = new FileOutputStream(file);
            byte[] buffer = new byte[4 * 1024];
            int bytesRead;
            while ((bytesRead = input.read(buffer)) > 0) {
                output.write(buffer, 0, bytesRead);
            }

            input.close();
            output.close();

            return Uri.fromFile(file);
        } catch (IOException e) {
            Log.e(LOG_TAG, "Error creating the File " + e);
        }

        return null;
    }

    @Override
    protected void onPostExecute(Uri uri) {
        if (uri != null) {
            mLatestItemImageUri = uri;
        }
    }
}
複製代碼
  1. 從 Intent 獲取的 Content URI 傳入自定義 AsyncTask 類 copyImageFileTask 的 doInBackground 方法,在後臺線程中完成複製文件的工做。
  2. 首先判斷 URI 是否爲空,若爲空則提早返回 null。
  3. 而後調用輔助方法新建一個 File 對象,用於存儲複製的圖片文件。與上述相機應用拍攝照片使用的輔助方法的邏輯相似,這裏的一樣先是生成一個抗衝突的文件名,再獲取一個存儲目錄,最後經過 File 的 createTempFile 方法建立並返回一個 File 對象。
    不一樣的是,由於這裏是從相冊選擇圖片的場景,若是把圖片複製到公共目錄下會對用戶形成困擾,因此這裏經過 getExternalFilesDir 方法以及 Environment.DIRECTORY_PICTURES 輸入參數獲取應用內部的目錄,使複製的圖片文件對其它應用不可見。另外,這裏不須要獲取複製文件的目錄路徑,因此沒有用到 FileProvider。

In DetailActivity.java

private File createCopyImageFile() throws IOException {
    String timeStamp = new SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault())
            .format(new Date());
    String imageFileName = "JPEG_" + timeStamp + "_";

    File storageDirectory = getExternalFilesDir(Environment.DIRECTORY_PICTURES);

    return File.createTempFile(
            imageFileName,      /* prefix    */
            ".jpg",             /* suffix    */
            storageDirectory    /* directory */
    );
}
複製代碼
  1. 接下來從上述 Content URI 讀取數據並存入一個 InputStream 對象,同時根據上述 File 對象新建一個 OutputStream 對象,而後經過 byte[] 緩存將 InputStream 的數據寫入 OutputStream,完成複製後關閉兩個對象,防止內存泄漏。
  2. 最後調用 Uri 的 fromFile 方法,根據完成複製的 File 對象返回一個 file URI。而後在 onPostExecute 方法中,若是由 doInBackground 方法傳入的 URI 不爲 null 的話,那麼將 URI 賦給 mLatestItemImageUri。

至此,經過相機應用拍攝照片以及在相冊中選取圖片的功能就實現了,不過還有一個很是明顯的優化項,那就是每一次用戶經過相機應用拍攝照片或在相冊中選取圖片時,應用都會新建一個圖片文件,若是用戶連續使用相機應用拍攝照片,或者連續在相冊中選取圖片,這會產生多個圖片文件,但最終應用只採用了最後一張圖片,甚至若是用戶此時放棄編輯,以前操做產生的多個文件都做廢了,徒增設備和應用的佔用內存。

所以,應用要可以刪除無用的文件,分爲三種狀況處理。

1、在相機應用中途取消拍攝照片

對於經過相機應用拍攝照片的操做,只要用戶點擊了 ImageChooserDialogFragment 的相機選項,無論 Intent 請求是否成功,應用都會新建一個文件,因此須要在 onActivityResult 中添加 Intent 請求不成功時的執行代碼,例如用戶點擊了對話框的相機選項,跳轉到相機應用,但沒有成功拍攝照片就回到 InventoryApp,此時就須要刪除這個操做新建的圖片文件。

In DetailActivity.java

@Override
protected void onActivityResult(int requestCode, int resultCode, Intent intent) {
    if (resultCode == RESULT_OK) {
        switch (requestCode) {
            case REQUEST_IMAGE_CAPTURE:
                ...

                mCurrentPhotoPath = null;
                break;
            case REQUEST_IMAGE_SELECT:
                ...
        }
    } else if (mCurrentPhotoPath != null) {
        File file = new File(mCurrentPhotoPath);
        if (file.delete()) {
            Toast.makeText(this, android.R.string.cancel, Toast.LENGTH_SHORT).show();
        }
    }
}
複製代碼

須要注意的是,在相冊中選取圖片的操做也會觸發 onActivityResult,例如用戶首先經過相機應用拍攝了一張照片,隨後又點擊了對話框的相冊選項,跳轉到相冊,但沒有選擇圖片就回到 InventoryApp;因爲刪除動做是根據 mCurrentPhotoPath 是否爲 null 來觸發的,若是上次經過相機應用拍攝照片返回的數據處理完畢後沒有清空 mCurrentPhotoPath 的話,就會誤刪用戶以前經過相機應用拍攝的照片。所以,在經過相機應用拍攝照片的 case 條目內,處理完返回數據後,要將 mCurrentPhotoPath 設爲 null。

2、重複經過相機應用拍攝照片或重複在相冊中選取圖片

用戶連續使用相機應用拍攝照片,或者連續在相冊中選取圖片,這會產生多個圖片文件,但最終應用只採用了最後一張圖片,對此的策略是在更換新圖片以前刪除舊圖片。

In DetailActivity.java

@Override
protected void onActivityResult(int requestCode, int resultCode, Intent intent) {
    if (resultCode == RESULT_OK) {
        deleteFile();

        ...
    }
}

private void deleteFile() {
    if (mLatestItemImageUri != null) {
        File file = new File(mLatestItemImageUri.getPath());
        if (file.delete()) {
            Log.v(LOG_TAG, "Previous file deleted.");
        }
    }
}
複製代碼
  1. 由於用戶經過相機應用拍攝的照片或從相冊選取的圖片的 URI 都存儲在全局變量 mLatestItemImageUri 中,並且 mLatestItemImageUri 的值僅在用戶添加圖片時改變,因此 mLatestItemImageUri 能夠做爲用戶以前是否已添加過圖片的標識。
  2. onActivityResult 方法內,在判斷 Intent 請求成功後,首先調用輔助方法刪除舊圖片。在輔助方法 deleteFile 內,首先判斷 mLatestItemImageUri 是否爲 null,若不爲空,說明此時存在舊圖片;而後根據這個 file URI 的目錄路徑建立一個 File 對象進行刪除文件的操做,成功後 Log 一條 verbose 消息。

3、用戶放棄編輯

用戶經過相機應用拍攝照片或從相冊選取圖片以後,沒有保存就點擊 BACK 或 UP 按鈕放棄編輯,這會致使新建的圖片文件無用,因此對策是在 BACK 或 UP 按鈕的點擊事件監聽器中調用輔助方法 deleteFile 刪除舊圖片。

Intent to Email with Attachment

在 DetailActivity 的編輯模式下,菜單欄有一個訂購按鈕能夠 Intent 到郵箱應用,而且帶有當前貨物的信息,包括將圖片文件放入郵件的附件。

In DetailActivity.java

Intent intent = new Intent(Intent.ACTION_SENDTO);
intent.setData(Uri.parse("mailto:"));

String subject = "Order " + mCurrentItemName;

intent.putExtra(Intent.EXTRA_SUBJECT, subject);

StringBuilder text = new StringBuilder(getString(R.string.intent_email_text, mCurrentItemName));
text.append(System.getProperty("line.separator"));
intent.putExtra(Intent.EXTRA_STREAM, Uri.parse(mCurrentItemImage));

intent.putExtra(Intent.EXTRA_TEXT, text.toString());

if (intent.resolveActivity(getPackageManager()) != null) {
    startActivity(intent);
}
複製代碼
  1. 頭兩行代碼保證了只有郵箱應用可以響應這個 Intent 請求。
  2. 向 Intent 添加 EXTRA_STREAM 數據做爲郵件的附件,傳入圖片文件的 file URI 便可。注意若是這裏傳入的是 Content URI,郵箱應用可能因爲權限等問題沒法獲取指定的文件。
  3. 在 StringBuilder 中 append 添加 System.getProperty("line.separator") 資源使字符串換行,它在全部平臺都適用。
  4. 向 Intent 添加其它 EXTRA 數據可參考 這篇 Android Developers 文檔

InputFilter

實戰項目 9: 習慣記錄應用 相似,InventoryApp 中的價格 EditText 的輸入限制也是由一個自定義 InputFilter 類實現的。

private class DigitsInputFilter implements InputFilter {

    private Pattern mPattern;

    private DigitsInputFilter(int digitsBeforeDecimalPoint, int digitsAfterDecimalPoint) {
        mPattern = Pattern.compile(getString(R.string.price_pattern,
                digitsBeforeDecimalPoint - 1, digitsAfterDecimalPoint));
    }

    @Override
    public CharSequence filter(CharSequence source, int start, int end,
                               Spanned dest, int dstart, int dend) {

        String inputString = dest.toString().substring(0, dstart)
                + source.toString().substring(start, end)
                + dest.toString().substring(dend, dest.toString().length());

        Matcher matcher = mPattern.matcher(inputString);

        if (!matcher.matches()) {
            return "";
        }
        return null;
    }
}
複製代碼
  1. 因爲自定義 InputFilter 類 DigitsInputFilter 只在 DetailActivity 中用到,因此它做爲內部類實現,在 DigitsInputFilter 類內有一個關鍵的全局變量 mPattern,用於決定用戶輸入是否符合要求。
  2. DigitsInputFilter 的構造函數傳入兩個輸入限制參數,分別是小數點前的數字位數以及小數點後的數字位數。它們會做爲輸入 Pattern 的一部分,用於決定 EditText 的輸入限制。在 InventoryApp 中,DigitsInputFilter 專門用於價格 EditText,在調用時傳入的兩個參數分別是 10 和 2,表示小數點前最多可輸入十位數字,小數點後則最多爲兩位。在這裏,Pattern 經過正則表達式 (RegEx) 編譯而成,InventoryApp 中使用的價格正則表達式爲 ^(0|[1-9][0-9]{0,9}+)((\\.\\d{0,2})?),它容許的輸入格式可分爲如下幾種狀況
    (1)以 0 開頭,接下來僅接受小數點 (.) 輸入,不容許更多的 0 或 1~9 數字輸入;小數點後容許最多兩位 0~9 數字輸入。
    (2)以 1~9 開頭,接下來可輸入小數點 (.) 或最多九位 0~9 數字輸入;小數點後容許最多兩位 0~9 數字輸入。
    (3)不容許以小數點 (.) 開頭。
  3. Override filter method 定義實現輸入限制的代碼,每當用戶輸入一個字符都會觸發該方法。在這裏,首先獲取 EditText 中現有的全部字符,而後調用全局變量 Pattern 的 matcher 方法得到一個 Matcher 對象,最後經過 Matcher 對象的 matches() 方法判斷當前輸入是否符合 Pattern。如果則返回 null 表示容許輸入,若非則返回 "" 用空字符代替輸入,表示過濾輸入。

禁止設備屏幕旋轉

在 InventoryApp 中,存在一種狀況,即用戶原本以垂直方向手持設備,可是在向貨物添加圖片時,用戶把設備橫放在相機應用拍攝照片,這會致使 InventoryApp 的 DetailActivity 在後臺被銷燬,用戶拍完照片回來時應用就奔潰了。所以,InventoryApp 的 DetailActivity 須要禁止設備屏幕旋轉,在 AndroidManifest 中設置相關參數。

In AndroidManifest.xml

<activity
    android:name=".DetailActivity"
    android:screenOrientation="sensorPortrait"
    android:configChanges="keyboardHidden|orientation|screenSize"
    android:parentActivityName=".CatalogActivity"
    android:theme="@style/AppTheme"
    android:windowSoftInputMode="stateHidden">
    <!-- Parent activity meta-data to support 4.0 and lower -->
    <meta-data
        android:name="android.support.PARENT_ACTIVITY"
        android:value=".CatalogActivity" />
</activity>
複製代碼
  1. 將 android:screenOrientation 設爲 sensorPortrait,使屏幕方向始終保持傳感器的垂直方向(正向或反向),它在用戶禁用傳感器的狀況下仍有效。
  2. 向 android:configChanges 添加 orientation 和 screenSize 參數,表示 Activity 在屏幕旋轉以及尺寸變化時不會重啓,而是保持運行,並調用  onConfigurationChanged() 方法。在這裏 DetailActivity 並無 override onConfigurationChanged() 方法,也就是說屏幕旋轉以及尺寸變化時,DetailActivity 保持運行,不做任何反應。
  3. 一般狀況下,在運行時發生配置變化時,Activity 會重啓,而 android:configChanges 屬性中的參數就指定了其中一些配置變化由 Activity 在 onConfigurationChanged() 方法中自行處理,不須要 Activity 重啓。例如 keyboardHidden 參數表明了鍵盤可用性狀態的配置變化,把它放入 android:configChanges 屬性中就可以起到首次進入 Activity 時禁止自動彈出輸入法的效果。更多信息能夠參考 這個 Android Developers 文檔

Drawable Resources

在 Android 中 Drawable 資源除了由 png、jpg、gif 等文件提供的圖片文件以外,還有許多直接由 xml 文件提供的資源。例如在 InventoryApp 中,background_border.xml 提供了 CatalogActivity 的列表子項以及 DetailActivity 的圖片的邊框背景,它屬於 Shape Drawable;image_chooser_item_color_list.xml 則提供了添加圖片對話框中的選項在不一樣點按狀態下的顏色,它屬於 State List DrawableDrawable Resources 的文檔很是詳盡,邏輯也不復雜,因此在此再也不贅述。

FloatingActionButton

FloatingActionButton 的位置能夠錨定 (anchor) 到某一個視圖上,如上圖所示,銷售按鈕錨定在貨物圖片的右下角,經過如下代碼能夠實現。

In list_item.xml

<android.support.design.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"

    ...>

    <LinearLayout .../>

    <android.support.design.widget.FloatingActionButton
        ...

        android:layout_margin="@dimen/activity_spacing"
        android:src="@drawable/ic_sell_white_24dp"
        app:layout_anchor="@id/item_image"
        app:layout_anchorGravity="bottom|right|end" />
</android.support.design.widget.CoordinatorLayout>
複製代碼
  1. CoordinatorLayout 做爲根目錄,不要忘記添加 app 命名空間。
  2. 在 FloatingActionButton 內添加 app:layout_anchor 屬性,並以須要錨定的視圖 ID 做爲參數;隨後添加 app:layout_anchorGravity 屬性,設置錨定位置,在這裏設爲右下角,通常還會添加 16dp 的外邊距 margin。
  3. 值得注意的是,FloatingActionButton 是 ImageButton 的子類,因此默認狀況下沒法在 FloatingActionButton 中添加文字資源。
相關文章
相關標籤/搜索