一次Android相册的开发日志记录 Day2

初步定好方案之后,我们先来实现一个基本的相册app。
一个基本的相册APP总共由3个页面构成:

  • 相册文件夹页面
  • 照片列表页
  • 照片详情页

在页面展示之前,首先我们要能拿到数据,这里我们使用MediaStore来获取用户的媒体信息。
MediaStore记录了用户的各种类型的文件位置,并保存到数据库中名称为external.db

权限的获取

第一步要在Manifest中申请访问媒体数据和操作本地数据的权限。
且操作本地文件的权限,在Android11以后,由于应用沙箱的限制,该权限需要动态申请。

1
2
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />

动态获取权限

1
2
3
4
5
6
7
public static final int PERMISSION_REQ_NO = 1234;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
if (checkSelfPermission(Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
requestPermissions(new String[]{Manifest.permission.READ_EXTERNAL_STORAGE}, PERMISSION_REQ_NO);
return;
}
}

使用MediaStore获取照片信息

实际上依赖的就是我们平常用的比较少的四大组件之一,ContentProvider,这里使用的是ContentResolver来获取一个媒体的Cursor。
通过操作这个Cursor就可以获取照片信息了,看写法可以看出来,实际类似数据库的查询操作。
getContentResolver().query()他有着五个参数,分别为:

  • 操作的URI,这里是ImageUri, MediaStore.Images.Media.EXTERNAL_CONTENT_URI
  • QueryType,查询的数据字段,这里使用一个String数组来记录要查询的字段名,具体可以查询的字段为MediaStore.Images.Media中的常量,有需要可以点进去看看。我这里常用的几个字段如下:
    • MediaStore.Images.Media._ID : ID
    • MediaStore.Images.Media.DATA : 文件路径
    • MediaStore.Images.Media.DISPLAY_NAME : 文件名
    • MediaStore.Images.Media.BUCKET_DISPLAY_NAME : 相册名
    • MediaStore.Images.Media.BUCKET_ID : 相册ID
    • MediaStore.Images.Media.DATE_ADDED : 添加时间
  • Selection,查询条件
  • SelectionArgs,查询条件的参数
  • Order,排序方式

通过query获取到Cursor之后,就可以通过ID获取index索引id去操作游标来查询数据了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
public void getLocalAlbum() {
//sdcard下的多媒体文件
Uri uri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI;

//查询字段
String[] queryType = new String[]{
MediaStore.Images.Media._ID,
MediaStore.Images.Media.DATA,
MediaStore.Images.Media.DISPLAY_NAME,
MediaStore.Images.Media.BUCKET_DISPLAY_NAME,
MediaStore.Images.Media.BUCKET_ID,
MediaStore.Images.Media.DATE_ADDED
};
//查询条件
String selection = MediaStore.Images.Media.DISPLAY_NAME + "= ?";
//排序
String order = MediaStore.Files.FileColumns._ID + " DESC";

Cursor cursor = getContentResolver().query(uri, queryType, selection, null, order);

if (cursor != null) {
//列Index
int idIndex = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID);
int dataIndex = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATA);
int displayNameIndex = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DISPLAY_NAME);
int bucketNameIndex = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.BUCKET_DISPLAY_NAME);
int bucketIdIndex = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.BUCKET_ID);
int dateAddedIndex = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_ADDED);

//循环遍历取出,直到取完
while (cursor.moveToNext()) {
long id = cursor.getLong(idIndex);
//Uri
Uri imageUri = ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id);
String path = cursor.getString(dataIndex);
String displayName = cursor.getString(displayNameIndex);
String bucketName = cursor.getString(bucketNameIndex);
String bucketId = cursor.getString(bucketIdIndex);
Integer dateAdded = cursor.getInt(dateAddedIndex);
}
}
//记得关闭Cursor
cursor.close();
}

这里省略了获取照片数据后,封装和组合的操作,可以将这里的数据封装为对象,就可以在页面间传递和操作了。
这里的数据也是相册可以看到的媒体数据了。不过要注意的是URI传递需要他的对象为实现了Parcelable序列化才可传递。
如果想使用Serializable或者其他方式,可以将Uri.toString()转换为String之后再进行传递,收到后再按需parse即可。


获取了相册信息之后,就可以编写页面了。常规的RecyclerView和基础布局的设计就不在这里赘述了,说点新鲜的。

支持圆角,且宽高比固定的ImageView

参考了很多的相册APP,他们都有个共同的特典,就是每个相册是宽高比固定1:1或者其他比例。在布局中设定了占满宽度并均分之后,不同比例的图片会导致每个ImageView的高度不同,要保证宽高1:1则需要对ImageView的测量进行调整。在onMeasure里对图片进行测量并将宽高调整为相同或需要的比例。同时android:scaleType="centerCrop"保证了图片会横向占满,并自动调整高度。这样显示的效果就很不错了。当按照网格排布时,宽高也是可以自适应的。

1
2
3
4
5
6
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
int width = MeasureSpec.getSize(widthMeasureSpec);
setMeasuredDimension(width, width);
}

为了使得页面看起来美观,我们要对图片做圆角切割。比起直接操作Bitmap会存在潜在的泄露风险,这里使用canvas的clipPath对图形进行裁剪即可。
clipPath会按照指定的路径,对图片进行裁剪遮罩,非常方便。同时可以封装为参数,对圆角的大小在布局中进行自定义的调整。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
@Override
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
super.onLayout(changed, left, top, right, bottom);
width = getWidth();
height = getHeight();
}

@Override
protected void onDraw(Canvas canvas) {
//这里做下判断,只有图片的宽高大于设置的圆角距离的时候才进行裁剪
int maxLeft = Math.max(leftTopRadius, leftBottomRadius);
int maxRight = Math.max(rightTopRadius, rightBottomRadius);
int minWidth = maxLeft + maxRight;
int maxTop = Math.max(leftTopRadius, rightTopRadius);
int maxBottom = Math.max(leftBottomRadius, rightBottomRadius);
int minHeight = maxTop + maxBottom;

if (width >= minWidth && height > minHeight) {
//四个角:右上,右下,左下,左上
mPath.moveTo(leftTopRadius, 0);
mPath.lineTo(width - rightTopRadius, 0);
mPath.quadTo(width, 0, width, rightTopRadius);

mPath.lineTo(width, height - rightBottomRadius);
mPath.quadTo(width, height, width - rightBottomRadius, height);

mPath.lineTo(leftBottomRadius, height);
mPath.quadTo(0, height, 0, height - leftBottomRadius);

mPath.lineTo(0, leftTopRadius);
mPath.quadTo(0, 0, leftTopRadius, 0);

canvas.clipPath(mPath);
}
super.onDraw(canvas);
}

注意:clipPath如果使用硬件加速,在老设备上可能会出现显示不正常的情况。
这时可以开启setLayerType(View.LAYER_TYPE_SOFTWARE, null)来关闭硬件加速。


接下来编写的是点击相册进入的,该相册下的图片列表页。

这里依旧使用GridLayoutManager进行网格布局,讲图片按照一定列数进行排布,但是图片与图片的间隙该如何处理,我想要两侧不留白,但是图片之间还要有缝隙怎么办。这里就可以使用ItemDecoration来设置网格布局的间隔。

ItemDecoration设置间隔

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
public class GridSpacingItemDecoration extends RecyclerView.ItemDecoration {

private int spanCount; //列数
private int spacing; //间隔
private boolean includeEdge; //是否包含边缘

public GridSpacingItemDecoration(int spanCount, int spacing, boolean includeEdge) {
this.spanCount = spanCount;
this.spacing = spacing;
this.includeEdge = includeEdge;
}

@Override
public void getItemOffsets(Rect outRect, View view, RecyclerView parent, RecyclerView.State state) {

//这里是关键,需要根据你有几列来判断
int position = parent.getChildAdapterPosition(view); // item position
int column = position % spanCount; // item column

if (includeEdge) {
outRect.left = spacing - column * spacing / spanCount; // spacing - column * ((1f / spanCount) * spacing)
outRect.right = (column + 1) * spacing / spanCount; // (column + 1) * ((1f / spanCount) * spacing)

if (position < spanCount) { // top edge
outRect.top = spacing;
}
outRect.bottom = spacing; // item bottom
} else {
outRect.left = column * spacing / spanCount; // column * ((1f / spanCount) * spacing)
outRect.right = spacing - (column + 1) * spacing / spanCount; // spacing - (column + 1) * ((1f / spanCount) * spacing)
if (position >= spanCount) {
outRect.top = spacing; // item top
}
}
}
}

使用方法:为RecyclerView设置addItemDecoration即可。

1
2
mBinding.recyclerView.addItemDecoration(
new GridSpacingItemDecoration(4, DisplayUtils.dp2px(getContext(), 2.5f), false));

好了,接下来要编写点击单个图片进入的图片详情页了。

详情页的编写

详情页最主要的就是可以放大缩小的图片View了,这里使用了比较常用的控件PhotoView。
GitHub地址为:https://github.com/Baseflow/PhotoView
按照官方的说明,进行库的添加即可,注意需要引入Jetpack源。

1
implementation 'com.github.Baseflow:PhotoView:2.3.0'

全屏与沉浸模式

仔细观察我们常见的相册应用,可以发现一般相册应用都会提供点击隐藏系统UI的功能,方便用户无干扰的查看图片,这里就需要使用到全屏和沉浸模式了。

首先定义一个主题,将状态栏和导航栏的底部都设置为透明,由于预览页面底色为黑色,所以将状态栏的颜色模式设置为黑。

1
2
3
4
5
6
<style name="Theme.PureGallery.ImageView" parent="Theme.MaterialComponents.DayNight.NoActionBar">
<!-- 其余设置略. -->
<item name="android:windowTranslucentStatus">true</item>
<item name="android:windowTranslucentNavigation">true</item>
<item name="android:windowLightStatusBar" tools:targetApi="m">false</item>
</style>

定义完毕主题之后,在AndroidManifest.xml文件中对Activity设置主题即可。

接下来我们可以看一下关于沉浸模式的说明,这里其实官方手册中描述的十分清楚,
参考地址:https://developer.android.com/training/system-ui/immersive

但是对于这些参数的含义可能还会有点模糊,这里记录一下。并编辑好工具类,可以在需要的时候隐藏系统UI。

  • View.SYSTEM_UI_FLAG_LAYOUT_STABLE:保证布局UI不会因为系统StatusBar显示隐藏发生变化,配合SYSTEM_UI_FLAG_FULLSCREEN使用
  • View.SYSTEM_UI_FLAG_FULLSCREEN:进入全屏模式,隐藏状态栏,高度不变
  • View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN:视图延伸至状态栏区域,状态栏覆盖在视图之上(等同windowTranslucentStatus)
  • View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION:视图延伸至导航栏区域,导航栏覆盖在视图之上(等同windowTranslucentNavigation)
  • View.SYSTEM_UI_FLAG_IMMERSIVE:沉浸模式,配合 View.SYSTEM_UI_FLAG_FULLSCREEN 和 View.SYSTEM_UI_FLAG_HIDE_NAVIGATION 一起设置
  • View.SYSTEM_UI_FLAG_HIDE_NAVIGATION:隐藏导航栏
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public class FullScreenUtils {

public static void hideSystemUI(Activity activity) {
// Enables regular immersive mode.
// For "lean back" mode, remove SYSTEM_UI_FLAG_IMMERSIVE.
// Or for "sticky immersive," replace it with SYSTEM_UI_FLAG_IMMERSIVE_STICKY
View decorView = activity.getWindow().getDecorView();
decorView.setSystemUiVisibility(
View.SYSTEM_UI_FLAG_LAYOUT_STABLE
| View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
| View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
| View.SYSTEM_UI_FLAG_IMMERSIVE
| View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
| View.SYSTEM_UI_FLAG_FULLSCREEN);
}

// Shows the system bars by removing all the flags
// except for the ones that make the content appear under the system bars.
public static void showSystemUI(Activity activity) {
View decorView = activity.getWindow().getDecorView();
decorView.setSystemUiVisibility(
View.SYSTEM_UI_FLAG_LAYOUT_STABLE
| View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
| View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION);
}
}

全面屏黑条问题

本来已经完美实现了要求,但是测试机是小米6,在全面屏的手机上就发现,每次在全屏模式切换下,状态栏都有一个黑条,并且布局会发生抖动。
一瞬间就想到,有可能是全面屏适配的问题,通过查询得知,就是如此。解决方法也很简单,在主题中添加一个选项即可。

1
<item name="android:windowLayoutInDisplayCutoutMode" tools:targetApi="o_mr1">shortEdges</item>

参数中有三个选项,含义分别为:

  • SHORT_EDGES:该窗口始终允许延伸到屏幕短边上的DisplayCutout区域
  • NEVER:窗口不允许和刘海屏重叠
  • DEFAULT:默认情况下,全屏窗口不会使用到刘海区域,非全屏窗口可正常使用刘海区

添加了参数选项后,黑条就消失了,布局也不会发生跳动,按照设计要求正常实现了。

经过上面这么一折腾,一个只有浏览功能的,最基础的相册APP就完成了。那么接下来需要要做什么呢,可以选择接入网络功能,或者完善本地功能。

  1. 本地相册的数据库管理
  2. 相册的创建,删除,修改
  3. 删除照片与移动照片
  4. 页面基础菜单,设置页面,导航条
  5. 分享功能,可以使用系统官方分享组件
  6. 照片的详细信息浏览,分辨率Exif等
  7. 可以考虑一下收藏功能
  8. 调用第三方播放器来播放视频

参考:

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×