Android 更换头像
运行效果图:
前言
做Android应用开发,通常是有很多的功能组成,今天就来看一下这个用户头像更换的功能该怎么去写。相信很多的小伙伴都写过这个功能,因为作为一个APP来说这是很普遍的功能,基本都会有。只要你的APP有用户模块,就会有用户的个人信息的修改,比如常规的手机号码修改、地址修改、头像修改、昵称修改等。这里面技术含量高一点的就是头像修改了,进入正题吧。
正文
这里我还是新建一个项目来做这个头像修改的功能,这样对于没有接触过这个功能的朋友更友好,这也是我一直以来的写作风格,不要嫌我啰嗦啊。
一、新建项目
创建一个名为ChangeAvatarDemo的项目
项目创建好之后,先想清楚你的这个功能需要什么,换头像常规肯定是上传到后台去,那么你肯定是要有网络权限的,其次如果你的网络请求地址是http开头的话,而在Android9.0及以上版本则要配置http访问许可才行,之后你是否会用到一些第三方框架,比如圆形头像,圆角头像、图片加载、动态权限请求。
二、配置项目
基于这些考虑,首先打开app模块下的build.gradle,在dependencies闭包下添加如下依赖:
//权限请求框架 implementation 'com.tbruyelle.rxpermissions2:rxpermissions:0.9.4@aar' implementation 'io.reactivex.rxjava2:rxandroid:2.0.2' implementation "io.reactivex.rxjava2:rxjava:2.0.0" //热门强大的图片加载器 implementation 'com.github.bumptech.glide:glide:4.11.0' annotationProcessor 'com.github.bumptech.glide:compiler:4.11.0' //Google Material控件,以及迁移到AndroidX下一些控件的依赖 implementation 'com.google.android.material:material:1.2.0'
然后在android闭包下指定JDK版本为1.8
compileOptions { sourceCompatibility = 1.8 targetCompatibility = 1.8 }
添加位置如下图所示:
然后点击右上角Sync进行同步,到这里gradle就配置完成了。
然后打开AndroidManifest.xml,在里面配置如下权限:
<!--网络权限--> <uses-permission android:name="android.permission.INTERNET"/> <!--相机权限--> <uses-permission android:name="android.permission.CAMERA"/> <!-- 读写文件权限 --> <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" /> <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
添加位置如下图所示:
这里还有一个要适配,那就是在Android10.0时增加了作用域存储,因此我这个不用这个作用域存储,所以在你的application标签下增加这样一句话
android:requestLegacyExternalStorage="true"
三、布局、样式改动
首先打开项目中的styles.xml,在里面增加一个样式:
<!-- 圆形图片 --> <style name="circleImageStyle"> <item name="cornerFamily">rounded</item> <item name="cornerSize">50%</item> </style>
这是一个弹窗的布局文件,里面提供你选择拍照、打开相册、取消。而且从命名来看,这是一个底部弹窗。所以需要一个地方去触发这个弹窗从屏幕底部出现。下面打开activity_main.xml,修改代码后如下所示:
<?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" android:gravity="center" android:orientation="vertical" tools:context=".MainActivity"> <!--圆形图片--> <com.google.android.material.imageview.ShapeableImageView android:id="@+id/iv_head" android:layout_width="200dp" android:layout_height="200dp" android:onClick="changeAvatar" android:src="@mipmap/ic_launcher" app:shapeAppearanceOverlay="@style/circleImageStyle" /> </LinearLayout>
这里我用了一个ShapeableImageView,这是material库里面的一个控件,你只要知道它比普通的ImageView要🐮🍺就可以了,想要详细了解的看看Android Material UI控件之ShapeableImageView。
这里我指定了app:shapeAppearanceOverlay="@style/circleImageStyle",也就是说它变成了一个圆形图片控件。
布局就写完了。
四、权限请求
进入到MainActivity,先声明变量
//权限请求 private RxPermissions rxPermissions;
先写一个Toast提示方法。
/** * Toast提示 * * @param msg */ private void showMsg(String msg) { Toast.makeText(this, msg, Toast.LENGTH_SHORT).show(); }
然后可以写一个checkVersion()方法,用于检查当前的Android版本,并且给你提示。
/** * 检查版本 */ private void checkVersion() { //Android6.0及以上版本 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { //如果你是在Fragment中,则把this换成getActivity() rxPermissions = new RxPermissions(this); //权限请求 rxPermissions.request(Manifest.permission.CAMERA, Manifest.permission.WRITE_EXTERNAL_STORAGE) .subscribe(granted -> { if (granted) {//申请成功 showMsg("已获取权限"); } else {//申请失败 showMsg("权限未开启"); } }); } else { //Android6.0以下 showMsg("无需请求动态权限"); } }
然后你要在onCreate()中调用checkVersion()。使用户一进入这个页面就进行检查版本和授权。
不过这里还要防范一个问题,那就是假如用户没有通过权限。再创建一个变量
//是否拥有权限 private boolean hasPermissions = false;
然后赋值
只有权限全部通过授权之后才会是true。
五、底部弹窗显示
如果我没有猜错的话,你的activity_main.xml中还有一个地方报错。
不过不要担心,先增加两个变量
//底部弹窗 private BottomSheetDialog bottomSheetDialog; //弹窗视图 private View bottomView;
然后新增一个changeAvatar()方法,里面的代码如下:
/** * 更换头像 * * @param view */ public void changeAvatar(View view) { bottomSheetDialog = new BottomSheetDialog(this); bottomView = getLayoutInflater().inflate(R.layout.dialog_bottom, null); bottomSheetDialog.setContentView(bottomView); bottomSheetDialog.getWindow().findViewById(R.id.design_bottom_sheet).setBackgroundColor(Color.TRANSPARENT); TextView tvTakePictures = bottomView.findViewById(R.id.tv_take_pictures); TextView tvOpenAlbum = bottomView.findViewById(R.id.tv_open_album); TextView tvCancel = bottomView.findViewById(R.id.tv_cancel); //拍照 tvTakePictures.setOnClickListener(v -> { showMsg("拍照"); bottomSheetDialog.cancel(); }); //打开相册 tvOpenAlbum.setOnClickListener(v -> { showMsg("打开相册"); bottomSheetDialog.cancel(); }); //取消 tvCancel.setOnClickListener(v -> { bottomSheetDialog.cancel(); }); bottomSheetDialog.show(); }
这个方法就是配置弹窗的视图,并且绑定视图中的控件,设置点击事件。现在你再去看你的activity_main.xml布局,就不会报错了。并且如果你现在运行的话,当你点击图片是底部会出现弹窗。然后点击弹窗中的三个控件,或者弹窗外阴影区域都会关闭弹窗。
六、工具类
这里我会添加两个工具类,用来协助我们开发。
在com.llw.changeavatar下新建一个utils包,在这个包下新建一个BitmapUtils类,里面的代码如下:
package com.llw.changeavatar.utils; import android.graphics.Bitmap; import android.graphics.BitmapFactory; import android.graphics.Canvas; import android.graphics.LinearGradient; import android.graphics.Paint; import android.graphics.PorterDuff; import android.graphics.PorterDuffXfermode; import android.graphics.RadialGradient; import android.graphics.RectF; import android.graphics.Shader; import android.util.Base64; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; import java.net.HttpURLConnection; import java.net.MalformedURLException; import java.net.URL; /** * Bitmap工具类 */ public class BitmapUtils { /** * bitmap转为base64 * * @param bitmap * @return */ public static String bitmapToBase64(Bitmap bitmap) { String result = null; ByteArrayOutputStream baos = null; try { if (bitmap != null) { baos = new ByteArrayOutputStream(); bitmap.compress(Bitmap.CompressFormat.JPEG, 100, baos); baos.flush(); baos.close(); byte[] bitmapBytes = baos.toByteArray(); result = Base64.encodeToString(bitmapBytes, Base64.DEFAULT); } } catch (IOException e) { e.printStackTrace(); } finally { try { if (baos != null) { baos.flush(); baos.close(); } } catch (IOException e) { e.printStackTrace(); } } return result; } /** * base64转为bitmap * * @param base64Data * @return */ public static Bitmap base64ToBitmap(String base64Data) { byte[] bytes = Base64.decode(base64Data, Base64.DEFAULT); return BitmapFactory.decodeByteArray(bytes, 0, bytes.length); } /** * url转bitmap * @param url * @return */ public static Bitmap urlToBitmap(final String url){ final Bitmap[] bitmap = {null}; new Thread(() -> { URL imageurl = null; try { imageurl = new URL(url); } catch (MalformedURLException e) { e.printStackTrace(); } try { HttpURLConnection conn = (HttpURLConnection)imageurl.openConnection(); conn.setDoInput(true); conn.connect(); InputStream is = conn.getInputStream(); bitmap[0] = BitmapFactory.decodeStream(is); is.close(); } catch (IOException e) { e.printStackTrace(); } }).start(); return bitmap[0]; } }
然后再新建一个CameraUtils类,代码如下;
package com.llw.changeavatar.utils; import android.annotation.TargetApi; import android.content.ContentUris; import android.content.ContentValues; import android.content.Context; import android.content.Intent; import android.database.Cursor; import android.graphics.Bitmap; import android.graphics.BitmapFactory; import android.graphics.Matrix; import android.media.ExifInterface; import android.net.Uri; import android.os.Build; import android.os.Environment; import android.provider.DocumentsContract; import android.provider.MediaStore; import android.util.Log; import android.widget.ImageView; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.File; import java.io.IOException; /** * 相机、相册工具类 */ public class CameraUtils { /** * 相机Intent * @param context * @param outputImagePath * @return */ public static Intent getTakePhotoIntent(Context context, File outputImagePath) { // 激活相机 Intent intent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE); // 判断存储卡是否可以用,可用进行存储 if (hasSdcard()) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) { // 从文件中创建uri Uri uri = Uri.fromFile(outputImagePath); intent.putExtra(MediaStore.EXTRA_OUTPUT, uri); } else { //兼容android7.0 使用共享文件的形式 ContentValues contentValues = new ContentValues(1); contentValues.put(MediaStore.Images.Media.DATA, outputImagePath.getAbsolutePath()); Uri uri = context.getApplicationContext().getContentResolver().insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues); intent.putExtra(MediaStore.EXTRA_OUTPUT, uri); } } return intent; } /** * 相册Intent * @return */ public static Intent getSelectPhotoIntent() { Intent intent = new Intent("android.intent.action.GET_CONTENT"); intent.setType("image/*"); return intent; } /** * 判断sdcard是否被挂载 */ public static boolean hasSdcard() { return Environment.getExternalStorageState().equals( Environment.MEDIA_MOUNTED); } /** * 4.4及以上系统处理图片的方法 */ @TargetApi(Build.VERSION_CODES.KITKAT) public static String getImageOnKitKatPath(Intent data, Context context) { String imagePath = null; Uri uri = data.getData(); Log.d("uri=intent.getData :", "" + uri); if (DocumentsContract.isDocumentUri(context, uri)) { //数据表里指定的行 String docId = DocumentsContract.getDocumentId(uri); Log.d("getDocumentId(uri) :", "" + docId); Log.d("uri.getAuthority() :", "" + uri.getAuthority()); if ("com.android.providers.media.documents".equals(uri.getAuthority())) { String id = docId.split(":")[1]; String selection = MediaStore.Images.Media._ID + "=" + id; imagePath = getImagePath(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, selection, context); } else if ("com.android.providers.downloads.documents".equals(uri.getAuthority())) { Uri contentUri = ContentUris.withAppendedId(Uri.parse("content://downloads/public_downloads"), Long.valueOf(docId)); imagePath = getImagePath(contentUri, null, context); } } else if ("content".equalsIgnoreCase(uri.getScheme())) { imagePath = getImagePath(uri, null, context); } return imagePath; } /** * 通过uri和selection来获取真实的图片路径,从相册获取图片时要用 */ public static String getImagePath(Uri uri, String selection, Context context) { String path = null; Cursor cursor = context.getContentResolver().query(uri, null, selection, null, null); if (cursor != null) { if (cursor.moveToFirst()) { path = cursor.getString(cursor.getColumnIndex(MediaStore.Images.Media.DATA)); } cursor.close(); } return path; } /** * 更改图片显示角度 * @param filepath * @param orc_bitmap * @param iv */ public static void ImgUpdateDirection(String filepath, Bitmap orc_bitmap, ImageView iv) { //图片旋转的角度 int digree = 0; //根据图片的filepath获取到一个ExifInterface的对象 ExifInterface exif = null; try { exif = new ExifInterface(filepath); if (exif != null) { // 读取图片中相机方向信息 int ori = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_UNDEFINED); // 计算旋转角度 switch (ori) { case ExifInterface.ORIENTATION_ROTATE_90: digree = 90; break; case ExifInterface.ORIENTATION_ROTATE_180: digree = 180; break; case ExifInterface.ORIENTATION_ROTATE_270: digree = 270; break; default: digree = 0; break; } } //如果图片不为0 if (digree != 0) { // 旋转图片 Matrix m = new Matrix(); m.postRotate(digree); orc_bitmap = Bitmap.createBitmap(orc_bitmap, 0, 0, orc_bitmap.getWidth(), orc_bitmap.getHeight(), m, true); } if (orc_bitmap != null) { iv.setImageBitmap(orc_bitmap); } } catch (IOException e) { e.printStackTrace(); exif = null; } } /** * 4.4以下系统处理图片的方法 */ public static String getImageBeforeKitKatPath(Intent data, Context context) { Uri uri = data.getData(); String imagePath = getImagePath(uri, null, context); return imagePath; } /** * 比例压缩 * @param image * @return */ public static Bitmap compression(Bitmap image) { ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); image.compress(Bitmap.CompressFormat.JPEG, 100, outputStream); //判断如果图片大于1M,进行压缩避免在生成图片(BitmapFactory.decodeStream)时溢出 if (outputStream.toByteArray().length / 1024 > 1024) { //重置outputStream即清空outputStream outputStream.reset(); //这里压缩50%,把压缩后的数据存放到baos中 image.compress(Bitmap.CompressFormat.JPEG, 50, outputStream); } ByteArrayInputStream inputStream = new ByteArrayInputStream(outputStream.toByteArray()); BitmapFactory.Options options = new BitmapFactory.Options(); //开始读入图片,此时把options.inJustDecodeBounds 设回true了 options.inJustDecodeBounds = true; Bitmap bitmap = BitmapFactory.decodeStream(inputStream, null, options); options.inJustDecodeBounds = false; int outWidth = options.outWidth; int outHeight = options.outHeight; //现在主流手机比较多是800*480分辨率,所以高和宽我们设置为 float height = 800f;//这里设置高度为800f float width = 480f;//这里设置宽度为480f //缩放比。由于是固定比例缩放,只用高或者宽其中一个数据进行计算即可 int zoomRatio = 1;//be=1表示不缩放 if (outWidth > outHeight && outWidth > width) {//如果宽度大的话根据宽度固定大小缩放 zoomRatio = (int) (options.outWidth / width); } else if (outWidth < outHeight && outHeight > height) {//如果高度高的话根据宽度固定大小缩放 zoomRatio = (int) (options.outHeight / height); } if (zoomRatio <= 0) { zoomRatio = 1; } options.inSampleSize = zoomRatio;//设置缩放比例 options.inPreferredConfig = Bitmap.Config.RGB_565;//降低图片从ARGB888到RGB565 //重新读入图片,注意此时已经把options.inJustDecodeBounds 设回false了 inputStream = new ByteArrayInputStream(outputStream.toByteArray()); //压缩好比例大小后再进行质量压缩 bitmap = BitmapFactory.decodeStream(inputStream, null, options); return bitmap; } }
工具类里面都有注释,我就不多说了。
七、打开相机、相册
声明变量
//存储拍完照后的图片 private File outputImagePath; //启动相机标识 public static final int TAKE_PHOTO = 1; //启动相册标识 public static final int SELECT_PHOTO = 2;
拍照方法:
/** * 拍照 */ private void takePhoto() { if (!hasPermissions) { showMsg("未获取到权限"); checkVersion(); return; } SimpleDateFormat timeStampFormat = new SimpleDateFormat( "yyyy_MM_dd_HH_mm_ss"); String filename = timeStampFormat.format(new Date()); outputImagePath = new File(getExternalCacheDir(), filename + ".jpg"); Intent takePhotoIntent = CameraUtils.getTakePhotoIntent(this, outputImagePath); // 开启一个带有返回值的Activity,请求码为TAKE_PHOTO startActivityForResult(takePhotoIntent, TAKE_PHOTO); }
打开相册方法:
/** * 打开相册 */ private void openAlbum() { if (!hasPermissions) { showMsg("未获取到权限"); checkVersion(); return; } startActivityForResult(CameraUtils.getSelectPhotoIntent(), SELECT_PHOTO); }
方法写完了记得要调用才行,如下图在changeAvatar中调用。
现在你运行,你就会发现会跳转到相机和打开相册。但是你还是要回来的。
八、页面返回显示图片
先声明如下变量
//图片控件 private ShapeableImageView ivHead; //Base64 private String base64Pic; //拍照和相册获取图片的Bitmap private Bitmap orc_bitmap; //Glide请求图片选项配置 private RequestOptions requestOptions = RequestOptions.circleCropTransform() .diskCacheStrategy(DiskCacheStrategy.NONE)//不做磁盘缓存 .skipMemoryCache(true);//不做内存缓存
然后别忘了绑定图片控件
下面重写onActivityResult方法,获取返回的路径。
/** * 返回到Activity * @param requestCode * @param resultCode * @param data */ @Override protected void onActivityResult(int requestCode, int resultCode, Intent data) { super.onActivityResult(requestCode, resultCode, data); switch (requestCode) { //拍照后返回 case TAKE_PHOTO: if (resultCode == RESULT_OK) { //显示图片 displayImage(outputImagePath.getAbsolutePath()); } break; //打开相册后返回 case SELECT_PHOTO: if (resultCode == RESULT_OK) { String imagePath = null; //判断手机系统版本号 if (Build.VERSION.SDK_INT > Build.VERSION_CODES.KITKAT) { //4.4及以上系统使用这个方法处理图片 imagePath = CameraUtils.getImageOnKitKatPath(data, this); } else { imagePath = CameraUtils.getImageBeforeKitKatPath(data, this); } //显示图片 displayImage(imagePath); } break; default: break; } }
这里调用了displayImage方法,代码如下:
/** * 通过图片路径显示图片 */ private void displayImage(String imagePath) { if (!TextUtils.isEmpty(imagePath)) { //显示图片 Glide.with(this).load(imagePath).apply(requestOptions).into(ivHead); //压缩图片 orc_bitmap = CameraUtils.compression(BitmapFactory.decodeFile(imagePath)); //转Base64 base64Pic = BitmapUtils.bitmapToBase64(orc_bitmap); } else { showMsg("图片获取失败"); } }
那么到这里代码就写完了,下面运行一下吧。
九、本地缓存
如果你目前还没有与后台进行交互的话,那要让你的图片持久显示,那么你可以用到缓存。在utils包下新建一个SPUtils类,代码如下:
package com.llw.changeavatar.util; import android.content.Context; import android.content.SharedPreferences; /** * sharepref工具类 */ public class SPUtils { private static final String NAME="config"; public static void putBoolean(String key, boolean value, Context context) { SharedPreferences sp = context.getSharedPreferences(NAME, Context.MODE_PRIVATE); sp.edit().putBoolean(key, value).commit(); } public static boolean getBoolean(String key, boolean defValue, Context context) { SharedPreferences sp = context.getSharedPreferences(NAME, Context.MODE_PRIVATE); return sp.getBoolean(key, defValue); } public static void putString(String key, String value, Context context) { SharedPreferences sp = context.getSharedPreferences(NAME, Context.MODE_PRIVATE); sp.edit().putString(key, value).commit(); } public static String getString(String key, String defValue, Context context) { if(context!=null){ SharedPreferences sp = context.getSharedPreferences(NAME, Context.MODE_PRIVATE); return sp.getString(key, defValue); } return ""; } public static void putInt(String key, int value, Context context) { SharedPreferences sp = context.getSharedPreferences(NAME, Context.MODE_PRIVATE); sp.edit().putInt(key, value).commit(); } public static int getInt(String key, int defValue, Context context) { SharedPreferences sp = context.getSharedPreferences(NAME, Context.MODE_PRIVATE); return sp.getInt(key, defValue); } public static void remove(String key, Context context) { SharedPreferences sp = context.getSharedPreferences(NAME, Context.MODE_PRIVATE); sp.edit().remove(key).commit(); } }
刚才的地址这个类提供了缓存的存取方法,支持三种数据类型,String、Int、boolean。
而刚才的图片路径是String类型的,于是你可以这么写。
在拿到路径之后放入缓本地存中,注意我用的imageUrl作为Key,那么取出缓存也同样需要使用这个key。在什么地方取缓存呢?当然是一进入这个页面就取。就写在onCreate方法中。
//取出缓存 String imageUrl = SPUtils.getString("imageUrl",null,this); if(imageUrl != null){ Glide.with(this).load(imageUrl).apply(requestOptions).into(ivHead); }
这样就实现了本地图片缓存了,运行效果如下图
可以看到,当我杀死程序之后再进入时,它显示的是我之前从相册中选取的图片。这就证明本地缓存成功了,并且可以使用。
十、后台获取
这个由于我无法实际操作,因此我就说一下方式。
实际中大部分的图片都是不会放到缓存里面的,因为会很占空间,第二个是缓存是少量的存储。
首先拿到拍照或者打开相册后的图片路径之后,这个地址当然不是直接发送给后台的,根据我的经验,它们通常需要的是图片的base64,如下图所示:
这里的base64Pic是String类型的,它的数据会比较长,如果你的后台要求使用这种方式的话,那么你记得让他把这个字段的上限放到最大,否则会存储不完成,造成丢失。后台拿到这个base64Pic之后,会上传到一个服务器地址,然后在那里转换成图片,返回一个图片的url地址,通常是网址,这个网址你是后台的本地环境还是测试、正式开发环境,后台的本地环境,则只能在你当前的网络与后台处于同一局域网的情况下才能访问,比如你们连接同一个wifi,而测试、正式环境则不需要在同一局域网也可以访问,这一点你需要注意。
还有一个就是这个base64Pic,就有是只要直接传就可以了,还有的需要你这样拼接一下:
"data:image/jpeg;base64,"+base64Pic
这与你的后台对应的图片转换地址的要求有关系。说道这个网络还有一个地方要配置一下:
首先在你的res下新建一个xml文件夹,在这个文件夹下新建一个network_security_config.xml,里面的代码如下:
<?xml version="1.0" encoding="utf-8"?> <network-security-config> <base-config cleartextTrafficPermitted="true" /> </network-security-config>
然后在AndroidManifest.xml中配置:
这个配置就是为了防一手http请求,因为Android9.0及以后的版本默认是https请求,上面的配置就是允许http请求。
十一、源码
源码地址:ChangeAvatarDemo
总结
文章就结束了,能帮到你那就最好了,山高水长,后会有期~