这篇文章分享个人 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>
复制代码