初步定好方案之后,我们先来实现一个基本的相册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 ; } }
实际上依赖的就是我们平常用的比较少的四大组件之一,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 () { 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 ) { 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 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.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); int column = position % spanCount; if (includeEdge) { outRect.left = spacing - column * spacing / spanCount; outRect.right = (column + 1 ) * spacing / spanCount; if (position < spanCount) { outRect.top = spacing; } outRect.bottom = spacing; } else { outRect.left = column * spacing / spanCount; outRect.right = spacing - (column + 1 ) * spacing / spanCount; if (position >= spanCount) { outRect.top = spacing; } } } }
使用方法:为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) { 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); } 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就完成了。那么接下来需要要做什么呢,可以选择接入网络功能,或者完善本地功能。
本地相册的数据库管理
相册的创建,删除,修改
删除照片与移动照片
页面基础菜单,设置页面,导航条
分享功能,可以使用系统官方分享组件
照片的详细信息浏览,分辨率Exif等
可以考虑一下收藏功能
调用第三方播放器来播放视频
参考: