实战项目 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 中添加文字资源。
相关文章
相关标签/搜索