這篇文章分享我的 Android 開發(入門)課程 的最後一個實戰專案:貨物清單應用。這個專案託管在我的 GitHub 上,具體是 InventoryApp Repository,專案介紹已詳細寫在 README 上,歡迎大家 star 和 fork。
這個實戰專案的主要目的是練習在 Android 中使用 SQLite 資料庫。與 實戰專案 9: 習慣記錄應用 直接在 Activity 中運算元據庫的做法不同,InventoryApp 採用了更符合 Android 設計規範的框架,即
- 資料庫端
(1)使用Contract
類定義資料庫相關的常量,如 Content URI 及其 MIME 型別、資料庫的表格名稱以及各列名稱。
(2)使用自定義SQLiteOpenHelper
類管理資料庫,如新建資料庫表格、升級資料庫架構。
(3)使用自定義ContentProvider
類實現資料庫的 CRUD 操作,其中包括對資料庫更新和插入資料時的資料校驗。 - UI 端
通過ContentResolver
對資料庫實現插入、更新、刪除資料的互動,而讀取資料通過CursorLoader
在後臺執行緒實現。
由此可見,InventoryApp 的資料庫框架與課程中介紹的相同,所以這部分內容不再贅述,詳情可參考相關的學習筆記,如《課程 3: Content Providers 簡介》。值得一提的是,InventoryApp 的資料庫需要儲存圖片,但是沒有將圖片資料直接存入資料庫(如將圖片轉換為 byte[] 以 BLOB 原樣存入資料庫),而是儲存了圖片的 URI,這樣極大地降低了資料庫的體積,同時也減輕了應用處理資料的負擔。
除此之外,InventoryApp 還使用了很多其它有意思的 Android 元件,這篇文章按例分享給大家,希望對大家有幫助,歡迎互相交流。為了精簡篇幅,文中的程式碼有刪減,請以 GitHub 中的程式碼為準。
關鍵詞:RecyclerView & CursorLoader、Glide、Runtime Permissions、DialogFragment、通過相機應用拍攝照片以及在相簿中選取圖片、FileProvider、AsyncTask、Intent to Email with Attachment、InputFilter、RegEx、禁止裝置螢幕旋轉、Drawable Resources、FloatingActionButton
RecyclerView 從 CursorLoader 接收資料以填充列表
雖然課程中介紹的 ListView 和 GridView 能夠輕鬆地與 CursorLoader 配合顯示列表,但是 RecyclerView 作為 ListView 的升級版,它是一個更靈活的 Android 元件,尤其是在列表的子項需要載入的資料量較大或者子項的資料需要頻繁更新的時候,RecyclerView 更適合這種應用場景。例如在 實戰專案 7&8 : 從 Web API 獲取資料 中,BookListing App 實現了可擴充套件 CardView 效果的 RecyclerView 列表,如下圖所示。
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 類提供,具體的應用流程是
- 首先在
onCreateViewHolder
中根據子項佈局建立一個自定義 ViewHolder 物件。 - 然後將自定義 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);
}
}
...
}
複製程式碼
- 首先定義 InventoryAdapter 的建構函式,輸入引數分別為 Context 和 Cursor 物件,其中 Cursor 包含了列表需要顯示的內容,它定義為一個全域性變數,使其能由
getItemCount
等方法利用。當初始化或重置介面卡時,Cursor 可傳入 null 表示列表無資料顯示,介面卡不會出錯。 - 然後實現自定義 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 中的實際應用中,還有幾個需要注意的點。
一、Glide
對於 Android 來說,在列表中顯示多張圖片是一項既耗時又耗效能的工作,是否需要而又如何將讀取圖片資源、根據檢視大小裁剪圖片等工作放入後臺執行緒,這是 InventoryApp 在開發過程中踩過的大坑。在查閱 這篇 Android Developers 文件 後,才瞭解到絕大多數情況下,Glide 庫 都能僅用一行程式碼就完美地實現圖片抓取、解碼、顯示,它甚至支援 GIF 動圖以及視訊快照。
在 InventoryApp 中,使用了 Glide 目前最新的 v4 版本(已穩定,v3 版本已不維護)的 Generated API ,主要原因是需要利用 Glide 的 多重變換 設定圖片 centerCrop 的裁剪模式以及四周圓角 (RoundedCorners)。Glide 的文件非常豐富,上手非常簡單,所以這裡不再贅述。
二、swapCursor
由於在 InventoryApp 中 RecyclerView 需要從 CursorLoader 接收資料,在 onLoadFinished
和 onLoaderReset
需要呼叫介面卡的 swapCursor
方法,而 RecyclerView 沒有提供類似 ListView 的相應方法,所以需要在介面卡中自己實現。
In InventoryAdapter.java
public void swapCursor(Cursor cursor) {
mCursor = cursor;
notifyDataSetChanged();
}
複製程式碼
在這裡,swapCursor
方法的輸入引數為一個 Cursor 物件;在方法內,更新介面卡內的 Cursor 全域性變數,完成後通知介面卡列表的資料集發生了變化。
三、列表子項的點選事件監聽器
在 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;
}
複製程式碼
- 首先呼叫 MyViewHolder 的
getAdapterPosition()
方法獲取當前子項的位置。 - 然後呼叫 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);
}
複製程式碼
- 首先定義一個介面 (interface),名為 OnItemClickListener,裡面放置一個
onItemClick
方法,表示 Activity 或 Fragment 在例項化這個介面時必須實現該方法。 - 然後將 OnItemClickListener 介面定義為一個全域性變數,使其在介面卡內可被其它方法應用。
- 最後定義一個
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);
}
});
}
複製程式碼
四、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;
}
}
複製程式碼
- 在輔助方法 hasPermissionExternalStorage 中,首先判斷應用是否已獲得 WRITE_EXTERNAL_STORAGE 許可權,若是則返回 true。
- 如果應用尚未獲得需要的許可權,那麼首先通過 ActivityCompat 的
shouldShowRequestPermissionRationale
方法判斷是否需要向使用者顯示請求該許可權的理由,若不需要則直接通過 ActivityCompat 的requestPermissions
方法請求許可權,其中輸入引數依次為
(1)activity: 請求許可權的當前 Activity,在這裡即 DetailActivity。
(2)permissions: 需要請求的許可權列表,作為一個字串列表物件傳入,不能為空。
(3)requestCode: 該許可權請求的唯一識別符號,通常定義為一個全域性的整數常量,它在接收許可權請求的結果時會用到。 - 如果使用者之前拒絕過許可權請求,那麼
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);
}
}
複製程式碼
- 首先通過許可權請求的唯一識別符號區分不同請求,如果不是期望的請求,那麼就呼叫超級類保持預設行為。
- 針對特定的許可權請求,進一步判斷使用者是否同意該請求,若是則進行下面的工作;若使用者拒絕則顯示一個相關的 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();
}
...
}
複製程式碼
- 首先通過 LayoutInflater 根據對話方塊的佈局檔案生成一個 View 物件。
- 然後通過 AlertDialog.Builder 配置對話方塊,主要是將上面生成的 View 物件設定為對話方塊的佈局。
- 最後呼叫 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);
}
複製程式碼
- 首先定義一個介面 (interface),名為 ImageChooserDialogListener,裡面放置兩個方法,分別作為兩個選項的點選事件的響應方法。Activity 在使用 ImageChooserDialogFragment 時必須實現介面內的兩個方法。
- 然後將 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();
}
});
...
}
複製程式碼
- 首先根據由佈局檔案生成的 View 物件找到兩個選項的檢視,分別為“相機”和“相簿”。
- 相機檢視的點選事件監聽器呼叫 ImageChooserDialogListener 的
onDialogCameraClick
方法,在 DetailActivity 中響應點選事件,隨後通過dismiss()
方法關閉對話方塊。 - 類似地,相簿檢視的點選事件監聽器呼叫 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);
}
}
}
}
複製程式碼
- 在相簿中選取圖片的 Intent 比較簡單,URI 設為 Intent.ACTION_GET_CONTENT,MIME 型別設為 image/*,最後通過
startActivityForResult
方法啟動帶有回傳資料的 Intent,其中輸入引數為
(1)intent: 上面配置好的 Intent 物件,在這裡即 selectPictureIntent。
(2)requestCode: Intent 的唯一識別符號,通常定義為一個全域性的整數常量,它在接收 Intent 的回傳資料時會用到。 - 通過相機應用拍攝照片的 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。 - 在通過相機應用拍攝照片的 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;
}
複製程式碼
- 在通過相機應用拍攝照片的 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;
}
}
}
複製程式碼
- 首先判斷 Intent 請求是否成功,若是再根據不同 Intent 的唯一識別符號分別進行處理。
- 對於通過相機應用拍攝照片的 Intent,因為資料庫僅儲存圖片的 URI,而不是儲存圖片資料本身,所以在這裡,根據之前新建圖片檔案時獲取的目錄路徑獲得一個 file URI,並賦給全域性變數 mLatestItemImageUri;最後利用 Glide 顯示圖片。
- 對於在相簿中選取圖片的 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;
}
}
}
複製程式碼
- 從 Intent 獲取的 Content URI 傳入自定義 AsyncTask 類 copyImageFileTask 的
doInBackground
方法,在後臺執行緒中完成複製檔案的工作。 - 首先判斷 URI 是否為空,若為空則提前返回 null。
- 然後呼叫輔助方法新建一個 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 */
);
}
複製程式碼
- 接下來從上述 Content URI 讀取資料並存入一個 InputStream 物件,同時根據上述 File 物件新建一個 OutputStream 物件,然後通過 byte[] 快取將 InputStream 的資料寫入 OutputStream,完成複製後關閉兩個物件,防止記憶體洩漏。
- 最後呼叫 Uri 的
fromFile
方法,根據完成複製的 File 物件返回一個 file URI。然後在onPostExecute
方法中,如果由doInBackground
方法傳入的 URI 不為 null 的話,那麼將 URI 賦給 mLatestItemImageUri。
至此,通過相機應用拍攝照片以及在相簿中選取圖片的功能就實現了,不過還有一個非常明顯的優化項,那就是每一次使用者通過相機應用拍攝照片或在相簿中選取圖片時,應用都會新建一個圖片檔案,如果使用者連續使用相機應用拍攝照片,或者連續在相簿中選取圖片,這會產生多個圖片檔案,但最終應用只採用了最後一張圖片,甚至如果使用者此時放棄編輯,之前操作產生的多個檔案都作廢了,徒增裝置和應用的佔用記憶體。
因此,應用要能夠刪除無用的檔案,分為三種情況處理。
一、在相機應用中途取消拍攝照片
對於通過相機應用拍攝照片的操作,只要使用者點選了 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。
二、重複通過相機應用拍攝照片或重複在相簿中選取圖片
使用者連續使用相機應用拍攝照片,或者連續在相簿中選取圖片,這會產生多個圖片檔案,但最終應用只採用了最後一張圖片,對此的策略是在更換新圖片之前刪除舊圖片。
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.");
}
}
}
複製程式碼
- 因為使用者通過相機應用拍攝的照片或從相簿選取的圖片的 URI 都儲存在全域性變數 mLatestItemImageUri 中,而且 mLatestItemImageUri 的值僅在使用者新增圖片時改變,所以 mLatestItemImageUri 可以作為使用者之前是否已新增過圖片的標識。
- 在
onActivityResult
方法內,在判斷 Intent 請求成功後,首先呼叫輔助方法刪除舊圖片。在輔助方法deleteFile
內,首先判斷 mLatestItemImageUri 是否為 null,若不為空,說明此時存在舊圖片;然後根據這個 file URI 的目錄路徑建立一個 File 物件進行刪除檔案的操作,成功後 Log 一條 verbose 訊息。
三、使用者放棄編輯
使用者通過相機應用拍攝照片或從相簿選取圖片之後,沒有儲存就點選 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);
}
複製程式碼
- 頭兩行程式碼保證了只有郵箱應用能夠響應這個 Intent 請求。
- 向 Intent 新增 EXTRA_STREAM 資料作為郵件的附件,傳入圖片檔案的 file URI 即可。注意如果這裡傳入的是 Content URI,郵箱應用可能由於許可權等問題無法獲取指定的檔案。
- 在 StringBuilder 中
append
新增 System.getProperty("line.separator") 資源使字串換行,它在所有平臺都適用。 - 向 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;
}
}
複製程式碼
- 由於自定義 InputFilter 類 DigitsInputFilter 只在 DetailActivity 中用到,所以它作為內部類實現,在 DigitsInputFilter 類內有一個關鍵的全域性變數 mPattern,用於決定使用者輸入是否符合要求。
- 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)不允許以小數點 (.) 開頭。 - 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>
複製程式碼
- 將 android:screenOrientation 設為 sensorPortrait,使螢幕方向始終保持感測器的垂直方向(正向或反向),它在使用者禁用感測器的情況下仍有效。
- 向 android:configChanges 新增 orientation 和 screenSize 引數,表示 Activity 在螢幕旋轉以及尺寸變化時不會重啟,而是保持執行,並呼叫 onConfigurationChanged() 方法。在這裡 DetailActivity 並沒有 override
onConfigurationChanged()
方法,也就是說螢幕旋轉以及尺寸變化時,DetailActivity 保持執行,不作任何反應。 - 通常情況下,在執行時發生配置變化時,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 Drawable。Drawable 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>
複製程式碼
- CoordinatorLayout 作為根目錄,不要忘記新增 app 名稱空間。
- 在 FloatingActionButton 內新增 app:layout_anchor 屬性,並以需要錨定的檢視 ID 作為引數;隨後新增 app:layout_anchorGravity 屬性,設定錨定位置,在這裡設為右下角,一般還會新增 16dp 的外邊距 margin。
- 值得注意的是,FloatingActionButton 是 ImageButton 的子類,所以預設情況下無法在 FloatingActionButton 中新增文字資源。