实战项目 10: 货物清单应用

这篇文章分享我的 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;
    }

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

一、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() {
        @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);
        }
    });
}

四、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 中添加 <uses-permission> 参数,放在顶级元素 <manifest> 下面。在这里,只添加了一条 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。

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

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

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

对于通过相机应用拍摄照片的操作,只要用户点击了 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));
    }

    @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 中添加文字资源。
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,732评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,496评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,264评论 0 338
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,807评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,806评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,675评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,029评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,683评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 41,704评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,666评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,773评论 1 332
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,413评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,016评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,978评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,204评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,083评论 2 350
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,503评论 2 343

推荐阅读更多精彩内容