這篇文章分享個人 Android 開發(入門)課程 的最後一個實戰項目:貨物清單應用。這個項目託管在個人 GitHub 上,具體是 InventoryApp Repository,項目介紹已詳細寫在 README 上,歡迎你們 star 和 fork。html
這個實戰項目的主要目的是練習在 Android 中使用 SQLite 數據庫。與 實戰項目 9: 習慣記錄應用 直接在 Activity 中操做數據庫的作法不一樣,InventoryApp 採用了更符合 Android 設計規範的框架,即java
Contract
類定義數據庫相關的常量,如 Content URI 及其 MIME 類型、數據庫的表格名稱以及各列名稱。SQLiteOpenHelper
類管理數據庫,如新建數據庫表格、升級數據庫架構。ContentProvider
類實現數據庫的 CRUD 操做,其中包括對數據庫更新和插入數據時的數據校驗。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
雖然課程中介紹的 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
onCreateViewHolder
中根據子項佈局建立一個自定義 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);
}
}
...
}
複製代碼
getItemCount
等方法利用。當初始化或重置適配器時,Cursor 可傳入 null 表示列表無數據顯示,適配器不會出錯。有了上述基礎,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 接收數據,在 onLoadFinished
和 onLoaderReset
須要調用適配器的 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;
}
複製代碼
getAdapterPosition()
方法獲取當前子項的位置。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);
}
複製代碼
onItemClick
方法,表示 Activity 或 Fragment 在實例化這個接口時必須實現該方法。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; } } 複製代碼
shouldShowRequestPermissionRationale
方法判斷是否須要向用戶顯示請求該權限的理由,若不須要則直接經過 ActivityCompat 的 requestPermissions
方法請求權限,其中輸入參數依次爲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);
}
}
複製代碼
至此,運行時權限請求基本上就完成了,處理流程以下圖所示。更多信息可參考 這個 Android Developers 文檔。
Note:
InventoryApp 也使用了相機應用拍攝照片,可是這裏不須要請求訪問相機的權限,由於 InventoryApp 並不是直接操控攝像頭硬件模塊,而是經過 Intent 利用相機應用來獲取圖片資源,這也是使用 Intent 的一個優點。
在 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();
}
...
}
複製代碼
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);
}
複製代碼
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();
}
});
...
}
複製代碼
onDialogCameraClick
方法,在 DetailActivity 中響應點擊事件,隨後經過 dismiss()
方法關閉對話框。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);
}
}
}
}
複製代碼
startActivityForResult
方法啓動帶有回傳數據的 Intent,其中輸入參數爲getUriForFile
方法獲取該文件的 URI,並做爲 EXTRA_OUTPUT 數據傳入 Intent,在這裏就指定了相機應用拍攝的照片的存儲位置。startActivityForResult
方法啓動帶有回傳數據的 Intent,其中惟一標識符爲 REQUEST_IMAGE_CAPTURE。getExternalStoragePublicDirectory
方法,以及 Environment.DIRECTORY_PICTURES 輸入參數,獲取一個公共的圖片目錄。這樣用戶經過相機應用拍攝的照片就能被全部應用訪問,這是符合 Android 設計規範的。createTempFile
方法建立並返回一個 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;
}
複製代碼
getUriForFile
方法獲取了圖片文件的 URI,其中輸入參數爲顯然,這裏使用了 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;
}
}
}
複製代碼
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;
}
}
}
複製代碼
doInBackground
方法,在後臺線程中完成複製文件的工做。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 */
);
}
複製代碼
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.");
}
}
}
複製代碼
onActivityResult
方法內,在判斷 Intent 請求成功後,首先調用輔助方法刪除舊圖片。在輔助方法 deleteFile
內,首先判斷 mLatestItemImageUri 是否爲 null,若不爲空,說明此時存在舊圖片;而後根據這個 file URI 的目錄路徑建立一個 File 對象進行刪除文件的操做,成功後 Log 一條 verbose 消息。3、用戶放棄編輯
用戶經過相機應用拍攝照片或從相冊選取圖片以後,沒有保存就點擊 BACK 或 UP 按鈕放棄編輯,這會致使新建的圖片文件無用,因此對策是在 BACK 或 UP 按鈕的點擊事件監聽器中調用輔助方法 deleteFile
刪除舊圖片。
在 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);
}
複製代碼
append
添加 System.getProperty("line.separator") 資源使字符串換行,它在全部平臺都適用。與 實戰項目 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;
}
}
複製代碼
^(0|[1-9][0-9]{0,9}+)((\\.\\d{0,2})?)
,它容許的輸入格式可分爲如下幾種狀況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>
複製代碼
onConfigurationChanged()
方法,也就是說屏幕旋轉以及尺寸變化時,DetailActivity 保持運行,不做任何反應。onConfigurationChanged()
方法中自行處理,不須要 Activity 重啓。例如 keyboardHidden 參數表明了鍵盤可用性狀態的配置變化,把它放入 android:configChanges 屬性中就可以起到首次進入 Activity 時禁止自動彈出輸入法的效果。更多信息能夠參考 這個 Android Developers 文檔。在 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 的位置能夠錨定 (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>
複製代碼