这篇文章分享我的 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 类提供,具体的应用流程是

  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;}@Overridepublic 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 中的实际应用中,还有几个需要注意的点。

一、Glide

对于 Android 来说,在列表中显示多张图片是一项既耗时又耗性能的工作,是否需要而又如何将读取图片资源、根据视图大小裁剪图片等工作放入后台线程,这是 InventoryApp 在开发过程中踩过的大坑。在查阅 这篇 Android Developers 文档 后,才了解到绝大多数情况下,Glide 库 都能仅用一行代码就完美地实现图片抓取、解码、显示,它甚至支持 GIF 动图以及视频快照。

在 InventoryApp 中,使用了 Glide 目前最新的 v4 版本(已稳定,v3 版本已不维护)的 Generated API ,主要原因是需要利用 Glide 的 多重变换 设置图片 centerCrop 的裁剪模式以及四周圆角 (RoundedCorners)。Glide 的文档非常丰富,上手非常简单,所以这里不再赘述。

二、swapCursor

由于在 InventoryApp 中 RecyclerView 需要从 CursorLoader 接收数据,在 onLoadFinishedonLoaderReset 需要调用适配器的 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() {@Overridepublic 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() {@Overridepublic 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() {@Overridepublic 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() {@Overridepublic 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 {@Overridepublic 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() {@Overridepublic 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() {@Overridepublic 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 AppCompatActivityimplements 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;@Overridepublic 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);}}@Overridepublic 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>...<providerandroid:name="android.support.v4.content.FileProvider"android:authorities="com.example.android.fileprovider.camera"android:exported="false"android:grantUriPermissions="true"><meta-dataandroid: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> {@Overrideprotected 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;}@Overrideprotected 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。

至此,通过相机应用拍摄照片以及在相册中选取图片的功能就实现了,不过还有一个非常明显的优化项,那就是每一次用户通过相机应用拍摄照片或在相册中选取图片时,应用都会新建一个图片文件,如果用户连续使用相机应用拍摄照片,或者连续在相册中选取图片,这会产生多个图片文件,但最终应用只采用了最后一张图片,甚至如果用户此时放弃编辑,之前操作产生的多个文件都作废了,徒增设备和应用的占用内存。

因此,应用要能够删除无用的文件,分为三种情况处理。

一、在相机应用中途取消拍摄照片

对于通过相机应用拍摄照片的操作,只要用户点击了 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.");}}
}
复制代码
  1. 因为用户通过相机应用拍摄的照片或从相册选取的图片的 URI 都存储在全局变量 mLatestItemImageUri 中,而且 mLatestItemImageUri 的值仅在用户添加图片时改变,所以 mLatestItemImageUri 可以作为用户之前是否已添加过图片的标识。
  2. 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);
}
复制代码
  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));}@Overridepublic 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

<activityandroid: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-dataandroid: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 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>
复制代码
  1. CoordinatorLayout 作为根目录,不要忘记添加 app 命名空间。
  2. 在 FloatingActionButton 内添加 app:layout_anchor 属性,并以需要锚定的视图 ID 作为参数;随后添加 app:layout_anchorGravity 属性,设置锚定位置,在这里设为右下角,一般还会添加 16dp 的外边距 margin。
  3. 值得注意的是,FloatingActionButton 是 ImageButton 的子类,所以默认情况下无法在 FloatingActionButton 中添加文字资源。

实战项目 10: 货物清单应用相关推荐

  1. Python实战项目-10文件存储/支付宝支付/支付成功回调接口

    每天进步一点点,关注我哦,每天分享测试技术文章 文件存储 视频文件存储在某个位置,如果放在自己服务器上 放在项目的media文件夹 服务器上线后,用户既要访问接口,又需要看视频,都是使用一个域名和端口 ...

  2. 【课题报告】OpenCV 抠图项目实战(10)PyQt5 使用

    Python 小白的课题报告-OpenCV 抠图项目实战(10)PyQt5 使用 本系列是 Python 小白的课题作业<基于OpenCV 的图像分割和抠图>. 需要说明的是,本系列并不能 ...

  3. 10个Python爬虫实战项目

    Python爬虫是指使用Python语言编写程序,自动化地从互联网上获取数据并进行处理和分析的技术.Python爬虫是一项复杂而且实用的技术,需要掌握多个方面的基础知识,并具备较强的编程能力和实际操作 ...

  4. PADS-VX入门到精通实战项目讲解(上)—LOGIC部分-覃小刚-专题视频课程

    PADS-VX入门到精通实战项目讲解(上)-LOGIC部分-9259人已学习 课程介绍         本课程介绍PADS-VX软件应用,软件的主要三部份分别为Logic.Layout. Router ...

  5. 超硬核Java学习路线图+学习资源+实战项目汇总,看完以后不用再问我怎么学Java了!

    之前写过很多次关于Java学习指南.Java技术路线图的文章.但是总还是有小伙伴来问我,Java怎么学,项目怎么做,资源怎么找,真是让人头秃. 于是这次黄小斜决定来一波狠的,把所有这些内容都整理起来, ...

  6. 2023年最新最全uniCloud入门学习,零基础入门到实战项目 uni-admin打造uniapp网页后端 微信支付宝抖音小程序后端 unicloud数据后台快速打造uniapp小程序项目

    今天开始带着大家一起零基础学习uniCloud,在下面的课程中我们就简称uniCloud为cloud吧.我这里从零基础开始教大家,后面可以带大家简单的做一个实战项目.所以不用担心自己没有基础,跟着石头 ...

  7. 实战项目001-像素时钟-基于ESP8266

    废话少说,先上图 观看视频:https://www.bilibili.com/video/BV1ct4y1q7tK/ 以下为制作资料: 1.项目简介 该项目参考HACK实验室的矩阵时钟,和国外开源时钟 ...

  8. 再次升级,985博士整理的71个OpenCV实战项目教程开放下载!

    点击上方"小白学视觉",选择加"星标"或"置顶" 重磅干货,第一时间送达 近期小白学视觉公众号推出了多篇Python+OpenCV实战项目的 ...

  9. Web 前端,易学难精,没有拿手的实战项目,怎么办?

    今年受经济下行与疫情叠加的影响,毕业生有800多万,就业形势十分严峻,但即便如此,Web 前端人才在软件开发行业的就业市场中依旧供小于求,目前,全国总缺口每年大约为近百万人. 行业对前端需求量持续增加 ...

最新文章

  1. ant 编译java 项目_使用ant编译打包、部署简单的javaweb项目 --01
  2. gnokii 中文安装使用文档
  3. Making the impossible: 3 nodes intercontinental replication--转载
  4. 网络协议 8 - TCP协议(上):性恶就要套路深
  5. ubuntu下安装subversion客户端:
  6. mysql 游标的用途及使用方法
  7. 怎么安装paraview_ParaViewWeb基于本地服务的环境部署
  8. android ntp服务器配置
  9. openoffic+java+spring 多线程 转换doc,ppt,xls - html/pdf
  10. Android本地数据存储:Shared Preferences安全风险浅析
  11. 好123主页篡改修复方法
  12. 考心理健康教育教师资格证-初出茅庐
  13. 将语雀文档迁移到飞书
  14. 声呐技术学习---(1)
  15. 使用ROS melodic下 控制真实UR5机器人 手把手教程
  16. Spark SQL: Relational Data Processing in Spark
  17. 软件测试过程中的文档内容
  18. MacBookPro的系统偏好设置(包含触控板设置)
  19. 软件测试管理工具alm,以知识为核心的ALM之测试管理篇
  20. HTML之 <p></p>标签 笔记

热门文章

  1. C++中为何构造函数不可是虚函数,而析构函数可以?
  2. 二维方向图matlab程序,二维点源阵方向图,阵因子matlab
  3. PCB设计用什么软件好?
  4. Kubernetes使用集群联邦实现多集群管理
  5. 关于CENTOS6.X下修改网卡名称的方法
  6. 华三 交换 IRF V7
  7. 彩云国物语片头曲_はじまりの風
  8. ubuntu12.04下android开发环境搭建两个注意事项
  9. 王子与公主的另类结局
  10. iPhone开发之网络编程 AsyncSocket