chole
鸿蒙(HarmonyOS)开发之不申请权限访问相册图片
鸿蒙开发之不申请权限访问相册图片 访问相册图片介绍 在应用开发中,很多场景需要我们需要访问相册中的图片。例如:上传头像、上传银行卡、身份证资料、扫描文件功能、美颜功能等 所以访问相册里的图片成为我们必须要学习和掌握的内容。那如何访问相册图片呢? 在HarmonyOS中,鉴于对用户隐私的高度保护,要方便的完全读取相册与写入相册,需要极其复杂的权限审核。所幸,HarmonyOS也考虑到读取相册对于开发者而言也是一个非常常用的一个功能,因而提供了photoAccessHelper里的PhotoViewPicker来帮助开发者无需获得复杂权限的情况下来读取相册内容。 使用方法 导入相册管理模块 2. 实例化PhotoViewPicker对象(也即图片选择器对象) 3. 调用上述对象的select方法选择图片 这里可以看到调用select方法有两个参数:MIMEType、maxSelectNumberMIMEType即设置可以选哪些类型的媒体文件,可选值有 IMAGE_TYPE:图片类型,也即'image/*' VIDEO_TYPE:视频类型,也即'video/* 'IMAGE_VIDEO_TYPE:所有类型皆可,也即:'*/*' MOVING_PHOTO_IMAGE_TYPE:动态照片类型(实况图),也即'image/movingPhoto'maxSelectNumber就比较好理解了,就是设置可以选择多少数量,若不设置默认为50,最大也只能设置500 select方法是用Promise进行封装的,因此调用后有两种状态,成功进入then,失败进入catch进入then代表读取图片成功,读取到的结果是PhotoSelectResult类型的,这个类型有一个非常重要的属性,即为:photoUris,它是一个数组,里面保存了选择的资源的临时路径,像我们上面的代码,最大只允许选择1张图片,因此取下标0即为选择的图片或视频 用一个小界面测试一下 上面我们已经学了它的基本使用,我们用一个小界面测试一下。界面仅需放置一个Image用来展示选择后的图片,以及用一个按钮进行图片选择,代码如下 最终效果如下:成功将猫林老师的照片展示出来了 总结 如果需要不申请权限的情况下让app读取到系统图库里的图片,需使用photoAccessHelper 使用起来非常简单,仅需实例化PhotoViewPicker对象后再调用select方法即可 下篇猫林老师给大家介绍如何不申请权限的情况下写入照片到图库
chole
鸿蒙(HarmonyOS)开发之PixelMap介绍与实现图片变换
鸿蒙开发之PixelMap介绍与实现图片变换 本文所学技术可以用在哪 很多读者一看这个文章标题,可能根本不知道能干嘛,且不感兴趣。所以咱们先说说,今天写的这个技术有没有用。 首先,猫林老师即将给大家写的《原生AI之文字识别》就得用到这个知识。如果不学,等这篇文章面世时,各位可能有些代码看不懂。 其次,这个技术是实现一切图片处理的基石,比如你的App有个功能需要修改用户头像,而用户上传的图片可能会过大,那我们就需要对图片进行处理,例如裁剪,缩放,那必不可少的要用到这个技术。 最后,有些游戏的实现也依赖了这个技术,例如下图这种拼图游戏,就要用到本文的技术 (如上图所示,这游戏的核心技术就是:将一张完整大图裁剪打散变成多张小图,再通过玩家进行移动摆放拼成原图) P.S:若本文阅读量过万。猫林老师就出一篇文章或视频教大家如何开发这种游戏。(顺便,走过路过亲爱的读者们,麻烦点个关注点个赞点个收藏) 好了,言归正传,咱们本篇内容,正式开始! PixelMap是什么 在回答这个问题之前,大家有必要理解一些基础知识: 任何文件,包括图片和你电脑中的小电影,本质上都是二进制数据。 图片有多种格式,例如png、jpg、gif等 每种图片格式都有其独特优势和用途,例 根据以上信息可得,每种格式的图片,虽然展示的图像可能差别不是很大,但是因为他们的压缩算法和添加的内容有出入,每张图片的二进制表示形式排列规则绝对不一样 既然二进制排列不一样,各有各的规则,那么就意味着打开png格式的图片,要按照png的二进制规则去读取,电脑才能展现,同样打开jpg就要按照jpg的规则去读取去展现。以此类推 按不同的规则去打开对应格式图片的东西,可以叫图片解码器,所以不同格式图片有不同图片解码器。 根据以上结论,不知道有没有同学遇到过有些特定格式的图片系统默认情况下是打不开的。但是你装了某个软件后,他能打开了?现在能不能思考出原因? 没错,就因为系统默认只有一些常见的图片解码器,所以对于不常见或者某些企业自研图片格式就打不开。装完对应软件就能打开了是因为装的这个软件就是它对应的解码器。 好了,以上说了一大坨,回归我们的主题:PixelMap到底是个啥?跟上面说的有啥关系? 上面我们已经知道,不同格式的图片要想能打开就要用不同的规则去加载。那同样的道理,如果现在你要做图片裁剪功能,就意味着不同格式的图片得用不同的裁剪方式。这样就大大不利于开发。 正因此,需要一种统一的方式才好处理图片。而PixelMap正是提供了这种统一的方式,将这些不同格式的图像转换成一个可以直接操作的数据结构。也就相当于PixelMap是一种统一规则的像素结构,它内部根据一系列操作,把不同格式的图片还原成每个像素点的颜色和位置信息,并存在内存中。 所以PixelMap可以简单粗暴的理解为图片还原成像素后的一种数据,有了这种数据后不用再关心这张图片之前是什么格式什么规则。我们只需要对像素数据进行处理即可实现裁剪、缩放等功能! 好了,概念到此结束,以上听懂了是我讲得好。没听懂?那也没关系,不懂也完全不影响我们用。 如何把图片转为PixelMap 我们看看在HarmonyOS Next中如何通过代码来转换。 1. 首先需要导入image模块 2. 获取图片,我们以获取相册里的图片为例(上上篇文章讲过,不会可以去翻看) 3. 先用fileIo文件流打开图片,得到文件描述符(因为解码图片需要用文件描述符),并使用image模块里的createImageSource方法,传入读取到的图片文件描述符,得到解码后的图片,完了后记得关闭io流 4. 把解码后的图片调用createPixelMap方法转成PixelMap格式,异步的,记得加await,以及所在函数加async 经过这五步,我们就得到了一个图片所对应的PixelMap,我们可以用如下代码测试: 说明: 因为PixelMap本身就是表示像素信息的,因此也可以给Image组件显示 如何用PixelMap对图片进行裁剪 PixelMap图片数据。具有crop方法,即可进行裁剪,用法 x与y代表一个以左上角为原点的坐标,设置一个裁剪起点 size代表从上面这个起点开始,裁剪多大的区域 如再上例代码得到PixelMap后我们加一句代码,即 可观察到下图效果 PixelMap其他操作方法 scale translate rotate flip(false, true) flip(true, false) opacity 总结 PixelMap是将图片解码后得到的像素数据,方便对图片进行操作 本篇是其他文章的基石,例如拼图游戏。 最后,都看到这了,给个关注、点赞、收藏不过分吧?
chole
鸿蒙(HarmonyOS)原生AI能力之文本识别
鸿蒙原生AI能力之文本识别 原生智能介绍 在之前开发中,很多场景我们是通过调用云端的智能能力进行开发。例如文本识别、人脸识别等。 原生即指将一些能力直接集成在本地鸿蒙系统中,通过不同层次的AI能力开放,满足开发者的不同场景下的诉求,降低应用开发门槛,帮助开发者快速实现应用智能化 有哪些原生智能能力 基础视觉服务 基础语音服务 端侧模型部署 端侧推理 意图框架 ......... 基础视觉服务 - Core Vision Kit Core Vision Kit(基础视觉服务)是机器视觉相关的基础能力,接下来要导入的类,都在@kit.VisionKit中例如本篇要讲的文字识别即是如此。 文本识别介绍与使用 概念:将图片中的文字给识别出来 使用 textRecognition 实现文本识别 限制:仅能识别5种语言类型简体中文、繁体中文、英文、日文、韩文 使用步骤 1. 导入textRecognition 2. 实例化visionInfo对象,用来准备待识别的图片(需PixelMap类型)3. 实例化TextRecognitionConfiguration对象,设置识别配置(目前仅有是否开启朝向检测一项配置) 4. 调用textRecognition的recognizeText接口传入以上两个对象,开启识别并对识别结果进行处理,得到的是TextRecognitionResult类型结果,这个对象的value属性即为识别结果 这里解释一下这几步 你需要用textRecognition,所以需要先找到它,也即导入,这没什么好说的 你需要用它来帮你识别图片,那你是不是应该把需要识别的图片给它?所以第一个参数就是给他传递一个图片,只不过这个图片只能传PixelMap类型的(这就是为什么上篇我要写PixMap的原因),但是这个图片不能直接传,要包装成VisionInfo类型的对象(虽然目前为止,这个对象只有这一个属性,但保不齐未来会加)然后就是设置一下它识别的相关参数,它目前也只有一个参数,叫isDirectionDetectionSupported,设置是否开启朝向检测,因为有的图片可能是正的,有的图片可能是反的斜的。所以对于反的斜的图片如果这项开启为true,则会检测的更为准确。但是经过猫林老师肉测,其实开不开启扫描反的斜的图片,得到的结果都差不多了。所以可以看自己选择。顺便一提,这个参数可以不传,不传默认是true。然后猫林老师觉得:未来随着API发展,可能会多一些参数也说不准 最后即为调用其进行识别的方法,也即recognizeText开始识别 根据上面所说的,其实上面说的四步,也可以极简改为两步,代码如下 解释:这里就相当于没传第二个参数,它默认值即为true,也即开启朝向检测。 至于如何读取相册图片,以及把图片解码变成PixelMap,不是今天分享的主题,且之前猫林老师有两篇文章分别讲过不会的可以看之前文章,所以这里直接给代码(可看注释) 文本识别展示案例 我们来实现如下图的效果 结合上面说的使用方法,最终文本识别代码如下 总结 今天猫林老师给大家分享了鸿蒙提供的原生AI能力。其实听起来名字很高大上,用起来非常简单。这是因为鸿蒙帮我们做了高度封装,我们无须再关注OCR的相关知识,只需要使用鸿蒙提供的接口即可。所以,华为为了推广鸿蒙,发展鸿蒙生态,真的为开发者想了好多。这样的华为,你爱了吗? 友情提醒:本篇内容只适合用真机测试,模拟器无法出效果。 P.S:根据猫林老师肉测,在API12版本中的Mac模拟器成功出效果。其他版本都不行。所以建议有条件还是上真机。
chole
设计模式 - 组合模式(Composit)
中文名:组合模式英文名:Composit类型:结构型模式班主任评语:组合模式,构建一种树状结构,根节点和叶子节点。根节点可以添加叶子节点,而叶子节点不可再添加子节点。两者都依赖其抽象——节点,符合依赖倒置原则。奖状:Android View的绘制
chole
Android自定义View - Rect
Rect和RectF,矩形,在自定义View中是非常重要的,用来对绘制的内容进行定位,它和Point不同,是由4个坐标点组成的,可以完整描述一个内容的大小和位置。格式化输出String flattenToString();和Point和PointF一样,开发人员又偷懒了,只有Rect中有这个方法,如果是RectF,只能用toString()了。获取宽度int width();获取高度int height();获取中心点x坐标int centerX();获取中心点y坐标int centerY();设置矩形的左上右下void set(int left, int top, int right, int bottom);移动矩形void offset(int dx, int dy);偏移指定个单位的位置。dx如果为正,则向右偏移,为负,则向左偏移。dy如果为正,则向下偏移,为负,则向上偏移。void offsetTo(int newLeft, int newTop);偏移到具体的位置坐标。newLeft表示矩形的左边要偏移到的新位置的x坐标,newTop表示矩形的上边要偏移到的新位置的y坐标。收缩矩形void inset(int left, int top, int right, int bottom);left、top、right、bottom为正则向内收缩n个单位,为负则向外扩张n个单位。比如面积为9的矩形,如果left、top、right和bottom都为1,则inset后的最终面积为1。
chole
Android自定义View - LayoutParams
这一期我们来讲一讲LayoutParams这个玩意儿。Android入门的第一行代码就牵扯到这个东西,然而,你真的理解够了吗?第一层理解<RelativeLayout 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"
tools:context=".MainActivity">
</RelativeLayout>layout_width和layout_height这个是不是最开始学的时候,就要搞清楚的基础知识,match_parent代表填充屏幕,wrap_content代表包裹内容。这些其实是系统控件定义的属性,通过TypedArray进行解析。第二层理解val lp = RelativeLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
lp.addRule(RelativeLayout.ALIGN_PARENT_RIGHT)
lp.addRule(RelativeLayout.CENTER_VERTICAL)
lp.addRule(RelativeLayout.BELOW, viewId)
lp.setMargins(10, 20, 10, 20)使用代码动态布局的时候设置LayoutParams。第三层理解好了,知识是在不断打破旧的认识中进步的,第一层实际还没到LayoutParams,还只是AttributeSet。系统何时将布局中的AttributeSet解析成LayoutParams的呢?@Override
public LayoutParams generateLayoutParams(AttributeSet attrs) {
return new RelativeLayout.LayoutParams(getContext(), attrs);
}
protected LayoutParams generateDefaultLayoutParams() {
return new LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT);
}ViewGroup有个关键的方法,generateLayoutParams()。public LayoutParams(Context c, AttributeSet attrs) {
super(c, attrs);
TypedArray a = c.obtainStyledAttributes(attrs,
com.android.internal.R.styleable.RelativeLayout_Layout);
final int targetSdkVersion = c.getApplicationInfo().targetSdkVersion;
mIsRtlCompatibilityMode = (targetSdkVersion < JELLY_BEAN_MR1 ||
!c.getApplicationInfo().hasRtlSupport());
final int[] rules = mRules;
//noinspection MismatchedReadAndWriteOfArray
final int[] initialRules = mInitialRules;
final int N = a.getIndexCount();
for (int i = 0; i < N; i++) {
int attr = a.getIndex(i);
switch (attr) {
case com.android.internal.R.styleable.RelativeLayout_Layout_layout_alignWithParentIfMissing:
alignWithParent = a.getBoolean(attr, false);
break;
case com.android.internal.R.styleable.RelativeLayout_Layout_layout_toLeftOf:
rules[LEFT_OF] = a.getResourceId(attr, 0);
break;
case com.android.internal.R.styleable.RelativeLayout_Layout_layout_toRightOf:
rules[RIGHT_OF] = a.getResourceId(attr, 0);
break;
case com.android.internal.R.styleable.RelativeLayout_Layout_layout_above:
rules[ABOVE] = a.getResourceId(attr, 0);
break;
case com.android.internal.R.styleable.RelativeLayout_Layout_layout_below:
rules[BELOW] = a.getResourceId(attr, 0);
break;
case com.android.internal.R.styleable.RelativeLayout_Layout_layout_alignBaseline:
rules[ALIGN_BASELINE] = a.getResourceId(attr, 0);
break;
case com.android.internal.R.styleable.RelativeLayout_Layout_layout_alignLeft:
rules[ALIGN_LEFT] = a.getResourceId(attr, 0);
break;
case com.android.internal.R.styleable.RelativeLayout_Layout_layout_alignTop:
rules[ALIGN_TOP] = a.getResourceId(attr, 0);
break;
case com.android.internal.R.styleable.RelativeLayout_Layout_layout_alignRight:
rules[ALIGN_RIGHT] = a.getResourceId(attr, 0);
break;
case com.android.internal.R.styleable.RelativeLayout_Layout_layout_alignBottom:
rules[ALIGN_BOTTOM] = a.getResourceId(attr, 0);
break;
case com.android.internal.R.styleable.RelativeLayout_Layout_layout_alignParentLeft:
rules[ALIGN_PARENT_LEFT] = a.getBoolean(attr, false) ? TRUE : 0;
break;
case com.android.internal.R.styleable.RelativeLayout_Layout_layout_alignParentTop:
rules[ALIGN_PARENT_TOP] = a.getBoolean(attr, false) ? TRUE : 0;
break;
case com.android.internal.R.styleable.RelativeLayout_Layout_layout_alignParentRight:
rules[ALIGN_PARENT_RIGHT] = a.getBoolean(attr, false) ? TRUE : 0;
break;
case com.android.internal.R.styleable.RelativeLayout_Layout_layout_alignParentBottom:
rules[ALIGN_PARENT_BOTTOM] = a.getBoolean(attr, false) ? TRUE : 0;
break;
case com.android.internal.R.styleable.RelativeLayout_Layout_layout_centerInParent:
rules[CENTER_IN_PARENT] = a.getBoolean(attr, false) ? TRUE : 0;
break;
case com.android.internal.R.styleable.RelativeLayout_Layout_layout_centerHorizontal:
rules[CENTER_HORIZONTAL] = a.getBoolean(attr, false) ? TRUE : 0;
break;
case com.android.internal.R.styleable.RelativeLayout_Layout_layout_centerVertical:
rules[CENTER_VERTICAL] = a.getBoolean(attr, false) ? TRUE : 0;
break;
case com.android.internal.R.styleable.RelativeLayout_Layout_layout_toStartOf:
rules[START_OF] = a.getResourceId(attr, 0);
break;
case com.android.internal.R.styleable.RelativeLayout_Layout_layout_toEndOf:
rules[END_OF] = a.getResourceId(attr, 0);
break;
case com.android.internal.R.styleable.RelativeLayout_Layout_layout_alignStart:
rules[ALIGN_START] = a.getResourceId(attr, 0);
break;
case com.android.internal.R.styleable.RelativeLayout_Layout_layout_alignEnd:
rules[ALIGN_END] = a.getResourceId(attr, 0);
break;
case com.android.internal.R.styleable.RelativeLayout_Layout_layout_alignParentStart:
rules[ALIGN_PARENT_START] = a.getBoolean(attr, false) ? TRUE : 0;
break;
case com.android.internal.R.styleable.RelativeLayout_Layout_layout_alignParentEnd:
rules[ALIGN_PARENT_END] = a.getBoolean(attr, false) ? TRUE : 0;
break;
}
}
mRulesChanged = true;
System.arraycopy(rules, LEFT_OF, initialRules, LEFT_OF, VERB_COUNT);
a.recycle();
}这个代码熟悉吧,这就是我们之前讲过的自定义属性啊!没错,xml布局中的属性会先被解析成LayoutParams。那么我问你个问题,你觉得generateLayoutParams()和generateDefaultLayoutParams()的这个LayoutParams是给自己用的呢?还是给它的子控件用的呢?它是给子控件用的。自己的那个直接在构造方法中就从AttributeSet解析出来了。这样你就理解了,为什么RelativeLayout的那些个android:layout_centerVertical="true"
android:layout_alignParentEnd="true"怎么全部定义在子控件里面了。然后ViewGroup的addView()方法中就可以带上这个LayoutParams了。/**
* Adds a child view. If no layout parameters are already set on the child, the
* default parameters for this ViewGroup are set on the child.
*
* <p><strong>Note:</strong> do not invoke this method from
* {@link #draw(android.graphics.Canvas)}, {@link #onDraw(android.graphics.Canvas)},
* {@link #dispatchDraw(android.graphics.Canvas)} or any related method.</p>
*
* @param child the child view to add
* @param index the position at which to add the child
*
* @see #generateDefaultLayoutParams()
*/
public void addView(View child, int index) {
if (child == null) {
throw new IllegalArgumentException("Cannot add a null child view to a ViewGroup");
}
LayoutParams params = child.getLayoutParams();
if (params == null) {
params = generateDefaultLayoutParams();
if (params == null) {
throw new IllegalArgumentException(
"generateDefaultLayoutParams() cannot return null ");
}
}
addView(child, index, params);
}你不重写generateLayoutParams()方法,怎么在添加子控件的时候,让子控件用你的LayoutParams呢?public static class LayoutParams extends ViewGroup.MarginLayoutParams {
}以上是LinearLayout.LayoutParams的摘要,我们自定义ViewGroup的时候,是不是也可以继承个ViewGroup的LayoutParams玩一玩呢?然后重写generateLayoutParams()和generateDefaultLayoutParams()方法。@Override
public LayoutParams generateLayoutParams(AttributeSet attrs) {
return new LinearLayout.LayoutParams(getContext(), attrs);
}这里return你的ViewGroup的LayoutParams,然后在你的ViewGroup的LayoutParams的构造方法中就可以解析自定义属性attrs了。如果忘记了解析方式,我给你个提示,使用context的obtainStyledAttributes()方法。大部分停留在第二层理解,你如果学会了第三层,那么你自定义View又可以玩出新的高度了。
chole
Android自定义View - Bitmap
大家都知道,手机屏幕界面以像素为基准。在我们Android开发中,要绘制一块区域的像素,是以Bitmap位图来承载的。Bitmap就是基于像素的,10x10的Bitmap就对应100个坐标点,也是100个像素,像素就是通常我们所说的px。BitmapConfig我们先来看下BitmapConfig都有哪些值吧。BitmapConfigtotal bytesalpha channelALPHA_81 byte8bitRGB_5652 bytes/ARGB_88884 bytes8bitBitmapFactory.Options在android.graphics.BitmapFactory.Options中,有很多以in和out打头的属性,它们都是什么含义呢?我抽几个最重要的解释一下。inJustDecodeBounds如果它为true,解码的时候则不会返回Bitmap,用在你只需要得到Bitmap的尺寸,对节省内存开销非常有用。inSampleSize示例尺寸,小于1则这个值为1,如果它为3的话,它会将Bitmap宽度和高度都处理成1/3大小。inPreferredConfig色彩模式配置,默认ARGB8888,如果你对透明度不作要求的话,可以设置成RGB565。inScaled设置是否可以被缩放。inDensity表示像素密度。inTargetDensity表示绘制出来的像素密度。inScreenDensity表示屏幕的像素密度。outWidth输出Bitmap的宽度。outHeight输出Bitmap的高度。创建Bitmap//创建一个指定宽高的空的位图
Bitmap createBitmap(int width, int height, Config config);这就是最简单的创建位图的方式。默认像素点的色值全部为0,即黑色。保存到文件boolean compress(CompressFormat format, int quality, OutputStream stream)这个方法需要在子线程调用。CompressFormat有3个枚举值,CompressFormat.JPEG、CompressFormat.PNG和CompressFormat.WEBP。quality的取值范围是0~100,其中100的质量最高。获取像素点的色值int getPixel(int x, int y);修改像素点的色值void setPixel(int x, int y, int color);获取位图的宽度int getWidth();获取位图的高度int getHeight();使用JNI处理像素点的色值Bitmap createBitmap(int[] colors, int offset, int stride, int width, int height, Config config);这种方式用于通过JNI处理像素后,回传色值数组创建Bitmap。因为磁盘IO是非常耗时的,假设一个图像有1000*1000=100万像素,那么你通过Java的for循环调用setPixel()方法,就要调用100万次输入输出流,在性能上和C/C++相比有9倍左右的差距。因为我们使用native代码是把所有像素点一次性处理好,然后一起打包返回给Java层的,效率自然而然就更高了。colors为所有像素点的色值。offset表示从第几个开始拿,索引是从0开始的。stride为步长,即一行应该显示多少个像素点,它是小于等于width的,否则会报错。比如绘制灰色浮雕效果时,是把每一个像素的R、G和B,用下一个像素点的R、G、B减上一个,然后再加上127,让R、G、B都往中间色值靠拢。至于为什么要加127,学过计算机图形学或计算机图像处理学的应该知道中性灰的概念。而如第二行第一个像素点和第一行最后一个像素点的RGB差异不能作为浮雕效果轮廓,所以要去掉边缘像素,这个stride就是用于舍弃边缘像素的。回收资源void recycle();防止内存泄漏。
chole
设计模式 - 策略模式(Strategy)
中文名:策略模式英文名:Strategy类型:型模式班主任评语:策略模式和命令模式是一对孪生兄弟。两者都封装了变化,策略模式将算法整体进行替换,只注重结果,不注重过程。它将算法的替换提升到了类层级。不同的策略实现是可以相互取代的,但该算法将被保留下来,只是用不用的问题。
chole
设计模式 - 中介者模式(Mediator)
中文名:中介者模式英文名:Mediator类型:行为型模式班主任评语:中介者模式,类似于计算机主板的角色。所有计算机外设,比如鼠标、显示屏、键盘、内存条、风扇等,是不是都要插在主板上?如果没有这个中介者的角色,那么交互结构就成了网状结构,坏一个会导致牵一发而动全身。中介者模式,就是改变这种状况的,将网状结构转化为星状结构。
chole
Android自定义View - DoraPullableLayout
描述:下拉刷新和上拉加载复杂度:★★★★☆分组:【Dora大控件组】关系:暂无技术要点:事件分发、视图动画、布局容器的布局照片动图软件包github.com/dora4/dora_…用法val pullableLayout = findViewById<PullableLayout>(R.id.pullableLayout)
pullableLayout.setOnRefreshListener(object : PullableLayout.OnRefreshListener {
override fun onRefresh(layout: PullableLayout) {
pullableLayout.postDelayed(Runnable { pullableLayout.refreshFinish(PullableLayout.SUCCEED) }, 1000)
}
override fun onLoadMore(layout: PullableLayout) {
pullableLayout.postDelayed(Runnable { pullableLayout.loadMoreFinish(PullableLayout.SUCCEED) }, 1000)
}
})
chole
Android自定义View - DoraEmptyLayout
描述:一个用来显示暂无数据、加载中和加载错误的布局容器复杂度:★★☆☆☆分组:【Dora大控件组】关系:暂无技术要点:自定义属性、向ViewGroup中添加控件照片动图软件包github.com/dora4/dora_…用法它只能有且只有一个子控件,这个唯一的子控件作为content。通过调用showEmpty、showError、showLoading、showContent来改变显示,在onEmpty、onError、onLoading、onRefresh中处理回调。emptyLayout = findViewById(R.id.emptyLayout)
emptyLayout
.onEmpty {
Toast.makeText(this@MainActivity, "onEmpty", Toast.LENGTH_SHORT).show()
}
.onError { e ->
val tvError = findViewById<TextView>(R.id.tvError)
tvError.text = e.message
Toast.makeText(this@MainActivity, "onError", Toast.LENGTH_SHORT).show()
}
.onLoading {
((this as ImageView).drawable as AnimationDrawable).start()
Toast.makeText(this@MainActivity, "onLoading", Toast.LENGTH_SHORT).show()
}
.onRefresh {
Toast.makeText(this@MainActivity, "onRefresh", Toast.LENGTH_SHORT).show()
}自定义属性描述dora_emptyLayout配置空数据的布局dora_errorLayout配置加载错误的布局dora_loadingLayout配置加载中的布局
chole
设计模式 - 状态模式(State)
中文名:状态模式英文名:State类型:行为型模式班主任评语:状态模式是一种很实用的设计模式,对于相同的操作,在不同状态下会产生不同的行为。我们只需在环境上下文中改变状态,就能改变对应的行为了。
chole
什么是注解(编译期)——APT
编译期注解的处理技术我们也叫APT,全名Annotation Processing Tool。很多优秀的开源框架使用到的主要技术就是APT,比如GreenDao、ARouter、ButterKnife等。它可以让我们在编译时读取配置信息,直接生成Java代码,然后将生成的Java代码再次打包进行编译。生成代码的过程也用到另外一个框架,javapoet。怎么玩?首先我们要继承AbstractProcessor这个类,重写它的init()和process()方法。也可以使用getSupportedAnnotationTypes()方法来代替@SupportedAnnotationTypes注解,用来指定这个注解处理器用来处理哪些注解。ProcessingEnvironment和RoundEnvironment可以用来获取一些处理环境和周边环境信息。import com.google.auto.service.AutoService;
import com.lwh.flavors.annotation.handler.AnnotationHandler;
import com.lwh.flavors.annotation.handler.DifferenceHandler;
import com.lwh.flavors.annotation.handler.WrapperHandler;
import com.lwh.flavors.writer.JavaWriter;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import javax.annotation.processing.AbstractProcessor;
import javax.annotation.processing.ProcessingEnvironment;
import javax.annotation.processing.Processor;
import javax.annotation.processing.RoundEnvironment;
import javax.annotation.processing.SupportedAnnotationTypes;
import javax.annotation.processing.SupportedSourceVersion;
import javax.lang.model.SourceVersion;
import javax.lang.model.element.Element;
import javax.lang.model.element.TypeElement;
@AutoService(Processor.class)
@SupportedAnnotationTypes(
{
"com.lwh.flavors.annotation.Difference",
"com.lwh.flavors.annotation.Wrapper"
})
@SupportedSourceVersion(SourceVersion.RELEASE_7)
public class DecorateProcessor extends AbstractProcessor {
private List<AnnotationHandler> mHandlers = new ArrayList<>();
private JavaWriter mWriter;
private Map<String, List<Element>> mElementsMap = new HashMap<>();
@Override
public synchronized void init(ProcessingEnvironment processingEnv) {
super.init(processingEnv);
registerHandler(new DifferenceHandler());
registerHandler(new WrapperHandler());
mWriter = new JavaWriter(processingEnv);
}
protected void registerHandler(AnnotationHandler handler) {
mHandlers.add(handler);
}
@Override
public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
for (AnnotationHandler handler : mHandlers) {
handler.attachProcessingEnvironment(processingEnv);
mElementsMap.putAll(handler.handleAnnotation(roundEnv));
}
mWriter.generate(mElementsMap);
return true; //处理完成了,return true就好
}
}最终真正的处理肯定是通过AnnotationHandler,我们继承AnnotationHandler来做出具体的处理。通过调用roundEnv.getElementsAnnotatedWith()方法来获取项目中所有配置了该编译期注解的元素Element。比如TypeElement就是配置了该注解的类的一些元素信息,这些信息是编译层面的,跟运行期的对象没有关系。import com.lwh.flavors.annotation.Difference;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import javax.annotation.processing.ProcessingEnvironment;
import javax.annotation.processing.RoundEnvironment;
import javax.lang.model.element.Element;
import javax.lang.model.element.TypeElement;
public class DifferenceHandler implements AnnotationHandler {
private ProcessingEnvironment processingEnv;
@Override
public void attachProcessingEnvironment(ProcessingEnvironment env) {
this.processingEnv = env;
}
@Override
public Map<String, List<Element>> handleAnnotation(RoundEnvironment env) {
Map<String, List<Element>> annotationMap = new HashMap<>();
Set<? extends Element> elementSet = env.getElementsAnnotatedWith(Difference.class);
for (Element element : elementSet) {
TypeElement typeElement = (TypeElement) element;
String packageName = getPackageName(processingEnv, typeElement);
String className = packageName + "." + typeElement.getSimpleName().toString();
List<Element> cacheElements = annotationMap.get(className);
if (cacheElements == null) {
cacheElements = new ArrayList<>();
annotationMap.put(className, cacheElements);
}
cacheElements.add(typeElement);
}
return annotationMap;
}
private String getPackageName(ProcessingEnvironment env, Element element) {
return env.getElementUtils().getPackageOf(element).getQualifiedName().toString();
}
}然后我们就是要使用javapoet这个框架来帮我们写代码了。import com.lwh.flavors.MultiFlavors;
import com.lwh.flavors.annotation.Difference;
import com.lwh.flavors.annotation.Flavor;
import com.lwh.flavors.interfaces.DecoratorFactory;
import com.lwh.flavors.interfaces.IDifference;
import com.lwh.flavors.annotation.DifferenceInterface;
import com.lwh.flavors.annotation.Wrapper;
import com.squareup.javapoet.ClassName;
import com.squareup.javapoet.JavaFile;
import com.squareup.javapoet.MethodSpec;
import com.squareup.javapoet.ParameterizedTypeName;
import com.squareup.javapoet.TypeSpec;
import com.squareup.javapoet.TypeVariableName;
import com.squareup.javapoet.WildcardTypeName;
import java.io.IOException;
import java.lang.reflect.Constructor;
import java.util.List;
import java.util.Map;
import javax.annotation.processing.Filer;
import javax.annotation.processing.Messager;
import javax.annotation.processing.ProcessingEnvironment;
import javax.lang.model.element.AnnotationMirror;
import javax.lang.model.element.Element;
import javax.lang.model.element.Modifier;
import javax.lang.model.element.TypeElement;
import javax.lang.model.type.DeclaredType;
import javax.lang.model.type.TypeMirror;
import javax.lang.model.util.Types;
public class JavaWriter implements AbstractWriter {
private ProcessingEnvironment mProcessingEnv;
private Messager mMessager;
private Filer mFiler;
public JavaWriter(ProcessingEnvironment env) {
this.mProcessingEnv = env;
this.mMessager = env.getMessager();
this.mFiler = mProcessingEnv.getFiler();
}
@Override
public void generate(Map<String, List<Element>> map) {
for (Map.Entry<String, List<Element>> entry : map.entrySet()) {
List<Element> elements = entry.getValue();
for (Element element : elements) {
Difference difference = element.getAnnotation(Difference.class);
Wrapper wrapper = element.getAnnotation(Wrapper.class);
if (difference != null) {
handleAnnotation(difference, element);
}
if (wrapper != null) {
handleAnnotation(wrapper, element);
}
}
}
}
private void handleAnnotation(Difference difference, Element element) {
String proxyName = difference.proxyName();
TypeElement typeElement = (TypeElement) element;
TypeVariableName c = TypeVariableName.get("C", IDifference.class);
TypeVariableName d = TypeVariableName.get("D", IDifference.class);
MethodSpec.Builder newDecoratorMtdBuilder = MethodSpec.methodBuilder("newDecorator");
newDecoratorMtdBuilder.addModifiers(Modifier.PUBLIC);
newDecoratorMtdBuilder.addTypeVariable(c);
newDecoratorMtdBuilder.addTypeVariable(d);
newDecoratorMtdBuilder.addParameter(c, "component");
newDecoratorMtdBuilder.addParameter(ParameterizedTypeName.get(ClassName.get(Class.class), c), "componentClazz");
MethodSpec.Builder getDecoratorClassMtdBuilder = MethodSpec.methodBuilder("getDecoratorClass");
getDecoratorClassMtdBuilder.addModifiers(Modifier.PUBLIC)
.returns(ParameterizedTypeName.get(ClassName.get(Class.class), WildcardTypeName.subtypeOf(IDifference.class)));
boolean needReturnNull = false;
List<? extends AnnotationMirror> annotationMirrors = typeElement.getAnnotationMirrors();
for (AnnotationMirror annotationMirror : annotationMirrors) {
DeclaredType annotationType = annotationMirror.getAnnotationType();
Element ae = annotationType.asElement();
Flavor flavor = ae.getAnnotation(Flavor.class);
String s = ae.getSimpleName().toString();
if (flavor == null) {
continue;
}
if (proxyName.equalsIgnoreCase(s)) {
List<? extends TypeMirror> interfaces = typeElement.getInterfaces();
for (TypeMirror typeMirror : interfaces) {
Types types = mProcessingEnv.getTypeUtils();
Element e = types.asElement(typeMirror);
DifferenceInterface differenceInterface = e.getAnnotation(DifferenceInterface.class);
if (differenceInterface != null) {
newDecoratorMtdBuilder.addCode("try {\n $T constructor = getDecoratorClass().getConstructor(componentClazz);\n", Constructor.class);
newDecoratorMtdBuilder.addStatement(" constructor.setAccessible(true)");
newDecoratorMtdBuilder.addStatement(" return (D) constructor.newInstance(component)");
newDecoratorMtdBuilder.addCode("} catch($T e) {\n e.printStackTrace();\n}\n", Exception.class);
getDecoratorClassMtdBuilder.addStatement("return $T.class", ClassName
.bestGuess(differenceInterface.packageName()+"."+differenceInterface.moduleName() + s));
needReturnNull = true;
}
}
}
}
if (!needReturnNull) {
getDecoratorClassMtdBuilder.addStatement("return null");
}
newDecoratorMtdBuilder.addStatement("return null");
newDecoratorMtdBuilder.returns(d);
String packageName = MultiFlavors.getPackageName(mProcessingEnv, element);
String className = typeElement.getSimpleName().toString();
className += "$Factory";
TypeSpec typeSpec = TypeSpec.classBuilder(className)
.addSuperinterface(DecoratorFactory.class)
.addModifiers(Modifier.PUBLIC)
.addMethod(newDecoratorMtdBuilder.build())
.addMethod(getDecoratorClassMtdBuilder.build())
.build();
JavaFile javaFile = JavaFile.builder(packageName, typeSpec)
.addFileComment("These codes are generated by Dora automatically. Do not modify!")
.build();
try {
javaFile.writeTo(mProcessingEnv.getFiler());
} catch (IOException e) {
e.printStackTrace();
}
}
private void handleAnnotation(Wrapper wrapper, Element element) {
String flavorName = wrapper.flavorName();
Types types = mProcessingEnv.getTypeUtils();
TypeElement typeElement = (TypeElement) element;
List<? extends AnnotationMirror> annotationMirrors = typeElement.getAnnotationMirrors();
for (AnnotationMirror annotationMirror : annotationMirrors) {
DeclaredType annotationType = annotationMirror.getAnnotationType();
Element ae = annotationType.asElement();
Flavor flavor = ae.getAnnotation(Flavor.class);
if (flavor == null) {
continue;
}
List<? extends TypeMirror> interfaces = typeElement.getInterfaces();
for (TypeMirror typeMirror:interfaces) {
Element interfaceElement = types.asElement(typeMirror);
DifferenceInterface differenceInterface = interfaceElement.getAnnotation(DifferenceInterface.class);
if (differenceInterface != null) {
String packageName = differenceInterface.packageName();
String moduleName = differenceInterface.moduleName();
String s = ae.getSimpleName().toString();
if (s.equalsIgnoreCase(flavorName)) {
MethodSpec methodSpec = MethodSpec.constructorBuilder()
.addModifiers(Modifier.PUBLIC)
.addParameter(ClassName.bestGuess(interfaceElement.toString()), "base")
.addStatement("super(base)")
.build();
TypeSpec typeSpec = TypeSpec.classBuilder(moduleName + s)
.addModifiers(Modifier.PUBLIC)
.superclass(ClassName.bestGuess(typeElement.toString()))
.addMethod(methodSpec)
.build();
JavaFile javaFile = JavaFile.builder(packageName, typeSpec)
.addFileComment("These codes are generated by Dora automatically. Do not modify!")
.build();
try {
javaFile.writeTo(mProcessingEnv.getFiler());
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
}
}
}这些Mirror就是真正的源代码级别的镜像信息,TypeMirror、AnnotationMirror。javapoet通过MethodSpec和TypeSpec来构建方法、类这样的代码,最后通过JavaFile这个类的writeTo(filer)方法写入文件,filer知道将代码生成在哪个地方,Filer的对象可能会报红线,但是是假报错,咱们无视就好。前面无法平息因该框架太妙的激动的心情,忘了最重要一点,就是环境搭建,在最后补上。apply plugin: 'java'
dependencies {
implementation 'com.google.auto.service:auto-service:1.0-rc2'
implementation 'com.squareup:javapoet:1.9.0'
sourceCompatibility = "1.7"
targetCompatibility = "1.7"
}这个注解处理器的项目需要使用annotationProcessor来依赖,kotlin项目使用kapt。
chole
Android自定义View - Canvas
巧妇难为无米之炊。同样的,没有画板,或者叫画布,我们也没法开发丰富多彩的自定义View。绘制内容// 绘制颜色
void drawColor(int color);// 绘制一个点和很多点
void drawPoint(float x, float y, Paint paint);
void drawPoints(float[] pts, Paint paint);pts必须为2的倍数,数组每两个连续的值构成一个x和y坐标的点。// 绘制一条直线和很多直线
void drawLine(float startX, float startY, float stopX, float stopY, Paint paint);
void drawLines(float[] pts, Paint paint);pts必须为4的倍数,数组每4个连续的值构成一个(x1,y1,x2,y2)连成的一条直线。// 绘制矩形和圆角矩形
void drawRect(float left, float top, float right, float bottom, Paint paint);
void drawRoundRect(float left, float top, float right, float bottom, float rx, float ry, Paint paint);// 绘制圆形和椭圆
void drawCircle(float cx, float cy, float radius, Paint paint);
void drawOval(float left, float top, float right, float bottom, Paint paint);cx和cy代表圆心的x和y坐标,radius代表圆的半径。left、top、right、bottom为椭圆外切矩形的边界值。// 绘制扇形
void drawArc(float left, float top, float right, float bottom, float startAngle, float sweepAngle, boolean useCenter, Paint paint);left、top、right、bottom为扇形所在的椭圆外切矩形的边界值。startAngle为起始的角度,由一个点向→为0度,逆时针递增。sweepAngle为扇形扫过的角度。useCenter为true表示开始的点,扇形扫过的边和结束的点连成的封闭图形,为false,则为圆形和起始点连接,包括扇形的圆弧所构成的图形。// 绘制位图
void drawBitmap(Bitmap bitmap, float left, float top, Paint paint);
void drawBitmap(Bitmap bitmap, Rect src, Rect dst, Paint paint);src一般传入空,表示不裁剪原位图,dst为绘制到画布上的矩形区域。// 绘制文字
void drawText(String text, float x, float y, Paint paint);// 绘制路径
void drawPath(Path path, Paint paint);画布裁剪//通过矩形切割画板
canvas.clipRect(); //通过路径切割画板
canvas.clipPath();裁剪保留传入的矩形区域的图形,即矩形之外的区域舍弃。画布拼接Region.Op.DIFFERENCE //第一次不同于第二次的部分显示出来
Region.Op.REPLACE //显示第二次的
Region.Op.REVERSE_DIFFERENCE //第二次不同于第一次的部分显示出来
Region.Op.INTERSECT //交集显示 Region.Op.UNION //并集显示
Region.Op.XOR //补集显示,就是全集的减去交集部分画布保存和还原状态// 画布保存
canvas.save();
// 画布还原
canvas.restore();
// 画布平移
canvas.translate(float dx, float dy);
// 画布缩放
canvas.scale(float sx, float sy);
// 画布旋转
canvas.rotate(float degrees);save保存画布的状态,然后进行一些动画变换,restore还原到执行动画之前的状态,这些方法是一组。
chole
Android使用BaseItemProvider实现多布局的Adapter
在Android中,你可以使用BaseQuickAdapter实现列表的显示,也可以使用BaseMultiItemQuickAdapter实现多类型类别的显示。当你想做到类型布局的复用时,那么你应该使用BaseItemProvider。首先你要定义一个Entry类,比如聊天消息。然后每一种消息类型,你可以继承BaseItemProvider,例如文字类型的、语音类型的、图片类型的、名片类型的、转账类型的、红包类型的、文件类型的等。然后你可以在这些Provider中依赖聊天消息适配器的基类,这里面就有单聊适配器和群聊适配器,通常这两种聊天房间的界面大同小异,所以你可以很好的扩展新的消息类型在单聊和群聊的显示。Adapter继承自BaseProviderMultiAdapter。在构造方法中调用很多次addItemProvider()方法来添加消息item的类型。然后重写BaseProviderMultiAdapter的getItemType()方法来将Entry中的消息类型字段返回,判断该聊天消息使用何种Provider,因为BaseItemProvider的子类也要重写getItemViewType(),只不过它是指定具体的一种类型。
chole
Android自定义View - DoraRadioGroup
描述:一个支持多行的RadioGroup,修复官方RadioGroup的BUG复杂度:★★☆☆☆分组:【系统控件优化】关系:DoraButton技术要点:ViewGroup添加View过程、LayoutParams解析过程照片动图软件包github.com/dora4/dora_…用法val radioGroup = findViewById<DoraRadioGroup>(R.id.radioGroup)
radioGroup.check(R.id.rb_default_checked)
radioGroup.setOnCheckedChangeListener(object : DoraRadioGroup.OnCheckedChangeListener {
override fun onCheckedChanged(group: DoraRadioGroup, checkedId: Int) {
Log.e("MainActivity", "checkedId=$checkedId")
}
})
chole
Android自定义View - 与自定义View的邂逅
在大前端开发中,不管是前端、安卓或是苹果,自定义View绝对是一个进阶的方向了。很多人认为有那么多开源的代码,为什么我还要自己写?其实你这么想也不错,不学自定义View其实问题也不大,毕竟公司一般来说是有人会的。系统学习自定义View完全出于个人爱好,开发最主要的是对业务要熟悉,自定义View只是起锦上添花的作用,切忌本末倒置。当然,如果你对自定义View开发或是UI效果感兴趣,想学习的话,不妨可以看看我循序渐进的教程,这套教程将在后期不定期更新出来。很多时候,对自定义View不怎么熟悉的人,看别人写的自定义View,感觉非常高大上,且似曾相识,好像在梦里见过一样。但要自己写,就是写不出来。其实,见多了,并不代表你有思路,更不能说明你就会自己写。这是由于你对自定义View的知识体系在脑海中的印象,还是零零散散,星星点点的,并没有形成一套完整的开发套路。自定义View其实很简单,你得把很多关键的类系统的学习一遍,然后多练习,熟能生巧,多学习别人的思路。更深层次的,你需要去读Android的源代码。学习大量的源代码的思路,你才有可能信手拈来,将这些关键的类组合起来为我所用。开发自定义View是需要一些知识准备的,比如基本的计算机图形学知识,以及一些数学知识。我最早入行选择Android也是被当时所谓的Android智能手机那清晰的画面所吸引,否则,我可能就主攻后端开发了。在你决定深入研究自定义View之前,你一定要对艺术感兴趣,然后学习基本的工具的使用方法。那么,你应该先买支好画笔,再买张好的画布,从Canvas和Paint开始你的艺术创作之旅吧。
chole
设计模式 - 享元模式(Flyweight)
中文名:享元模式英文名:Flyweight类型:结构型模式班主任评语:享元模式,在大量创建同一个类对象的时候,对不变的属性复用,而只修改变化的属性,从而降低内存使用率。比如春节机票,出发地、目的地、航班号、起飞时间和降落时间不变的,只有价格随购买时间变化,那么出票的时候只需要修改机票对象的价格属性,而无需重新创建对象。
chole
Android自定义View - DoraAnimator
描述:一个Android动画框架复杂度:★★☆☆☆分组:【Dora大控件组】关系:暂无技术要点:Path的使用、二三阶贝瑟尔曲线、属性动画照片动图软件包github.com/dora4/dora_…用法动画可以进行叠加,且所有节点都是以原控件为参考。val tv = findViewById<TextView>(R.id.tv)
(LineTo(100f, 0f) + LineTo(0f, 100f)).startAnimation(tv, 3000)
(RotateAction(90f) + RotateAction(180f)).startAnimation(tv, 3000)
(AlphaAction(0.5f) + AlphaAction(1f)).startAnimation(tv, 3000)
(ScaleAction(2f, 2f) + ScaleAction(1f, 1f)).startAnimation(tv, 3000)
chole
Android自定义View - 三角函数
欢迎来到高数课堂,敲黑板,今天我们来讲解什么是三角函数?为什么三角函数是重点如果你要在Android中进行一些坐标的计算,少不了三角函数。通常情况下,会绘制一些非垂直或水平于坐标轴的直线。这个时候,你要计算这条直线的长度,就要用到这方面的知识了。特别是在手势处理中,通常也需要使用到三角函数。勾股定理在一个直角三角形中,勾三股四弦五,这句话是在说,如果短的直角边为3cm,长的直角边为4cm,那么斜边一定为5cm。那么为什么一定会是这样呢?因为在直角三角形中,有个公式a²+b²=c²,a和b都为直角边,而c是斜边。代入可得3x3+4x4=9+16=25,而25又是等于5x5的。正弦、余弦、正切讲解这个东西之前,你要先搞清楚角度和弧度的关系。我们知道,一个圆形的圆周角是360°,也就是说,我们可以将圆形分成360等分,每一份是1°。同样的,一个圆形也可以按弧度进行等分,一个半圆的弧度是一个π,没错,那么一个整圆的弧度就是2π啦。2π就等于360°。我们经常用到的角度有,30°、45°、60°、90°。那么我们可以记住这几个常用角度的弧度,它们分别是1/6π、1/4π、1/3π、1/2π。一个锐角的对边与斜边的比值叫作正弦(Sin)。 一个锐角的邻边与斜边的比值叫作余弦(Cos)。 一个锐角的对边与邻边的比值叫作正切(Tan)。在Android中我们通常使用弧度来进行计算Math.sin(Math.PI / 6)
Math.cos(Math.PI / 4)
Math.tan(Math.PI / 3)正弦和余弦函数图像我们可以看到,正弦函数由下向上穿过坐标原点,且呈周期性变化,我们一般只用到作用域为(0,π/2)的正弦函数,也就是值域通常只会在(0,1),不会有负数。余弦函数经过(0,1)点,同样我们也只会用到作用域为(0,π/2)的。正切函数图像正切函数的x取值不能为π/2,也就是90°。kπ+π/2都是取不到的。这里仅作了解,因为我们一般也只用到作用域为(0,π/2)的。这里补充下区间的写法,()小括号表示最小最大值都取不到,只能取无限接近该值的值。[]中括号是可以取到边界值的。也可以( ]和[ )这样组合使用。
chole
Android自定义View - Matrix
Matrix这个东西不常用,来自计算机图形学,用来进行一些矩阵变换。MSCALE控制着缩放,MSKEW控制着扭曲,MTRANS控制着平移,MPERSP在3D中才有用,控制着着透视。安卓矩阵set、pre(前乘、右乘)和post(后乘,左乘)开头的方法matrix.setTranslate()//直接设置平移
matrix.preTranslate()//右乘一个位移矩阵
matrix.postTranslate()//左乘一个位移矩阵那么什么是左乘呢?M’ = T(dx, dy) * MM是原矩阵,T(dx, dy)是矩阵变换,在左边就是左乘(post),在右边就是右乘(pre),说了等于没说。直接举个例子吧。连续preTranslate()前乘,看起来像按代码调用顺序平移。而如果连续postTranslate()后乘,则看起来像先执行最后面一行代码的平移。这么说起来,pre右乘更像按我们代码顺序执行的。矩阵的使用如下:canvas.setMatrix(matrix);让画布执行矩阵变换。canvas.drawBitmap(bitmap, matrix, paint);绘制一个带矩阵变换的位图,比如要绘制一个扭曲后的图像。
chole
Android自定义View - 学习路线大纲
1.与自定义View的邂逅2.Canvas3.Paint4.三角函数5.Point6.Rect7.Color8.Bitmap9.Drawable10.Path11.Shader12.自定义属性13.Theme和Style14.View15.GestureDetector16.Scroller17.ItemTouchHelper18.Matrix19.ViewGroup20.LayoutParams21.动画22.事件分发23.MaterialDesign
chole
Android自定义View - ItemTouchHelper
ItemTouchHelper是一个工具类,方便我们对RecyclerView的item进行拖拽,做侧滑删除用它是不是就很方便了,连Scroller都不用了,有时真的是思路为王啊。继承ItemTouchHelper.Callbackimport android.graphics.Canvas
import android.widget.TextView
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.RecyclerView
class DoraDragItemCallback(private val adapter: DoraDragItemAdapter) : ItemTouchHelper.Callback() {
override fun getMovementFlags(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder
): Int {
val canDrag = adapter.canDrag(viewHolder.adapterPosition)
val canLeftSwipe = adapter.canLeftSwipe(viewHolder.adapterPosition)
val canRightSwipe = adapter.canRightSwipe(viewHolder.adapterPosition)
val dragFlags = ItemTouchHelper.UP or ItemTouchHelper.DOWN
val swipeFlags = (if(canLeftSwipe) ItemTouchHelper.START else 0) or
(if(canRightSwipe) ItemTouchHelper.END else 0)
return makeMovementFlags(if (canDrag) dragFlags else 0, swipeFlags)
}
override fun onMove(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder,
target: RecyclerView.ViewHolder
): Boolean {
adapter.onItemMove(viewHolder.adapterPosition, target.adapterPosition)
return true
}
override fun getMoveThreshold(viewHolder: RecyclerView.ViewHolder): Float {
return 0f
}
override fun canDropOver(recyclerView: RecyclerView, current: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder): Boolean {
if (target.adapterPosition == 0) {
return false
}
return super.canDropOver(recyclerView, current, target)
}
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
adapter.onItemRemove(viewHolder.adapterPosition)
}
override fun onChildDraw(
c: Canvas,
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder,
dX: Float,
dY: Float,
actionState: Int,
isCurrentlyActive: Boolean
) {
super.onChildDraw(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive)
if (isCurrentlyActive) {
if (actionState == ItemTouchHelper.ACTION_STATE_DRAG) {
onDragStart(viewHolder)
}
} else {
onDragEnd(viewHolder)
}
}
private fun onDragStart(viewHolder: RecyclerView.ViewHolder) {
val textView = viewHolder.itemView.findViewById<TextView>(android.R.id.text1)
// 放大20%
textView.scaleX = 1.2f
textView.scaleY = 1.2f
}
private fun onDragEnd(viewHolder: RecyclerView.ViewHolder) {
val textView = viewHolder.itemView.findViewById<TextView>(android.R.id.text1)
// 还原
textView.scaleX = 1f
textView.scaleY = 1f
}
}getMovementFlags:告诉框架该item能否上下拉,能否左右滑动getMoveThreshold:item上下滑动多少才算拖动onMove:item被上下移动重新排序后回调canDropOver:告诉框架被拉起来上下调整位置的item能否落到该位置上onSwiped:item被左右滑动删除后回调onChildDraw:告诉框架怎么画item的子控件,在这里可以让子控件发生一些变化使用ItemTouchHelperval adapter = DoraDragItemAdapter()
val touchHelper = ItemTouchHelper(DoraDragItemCallback(adapter))
touchHelper.attachToRecyclerView(recyclerView)将ItemTouchHelper依附到RecyclerView上。
chole
我的又一个神奇的框架——Skins换肤框架
为什么会有换肤的需求app的换肤,可以降低app用户的审美疲劳。再好的UI设计,一直不变的话,也会对用户体验大打折扣,即使表面上不说,但心里或多或少会有些难受。所以app的界面要适当的改版啊,要不然可难受死用户了,特别是UI设计还相对较丑的。换肤是什么换肤是将app的背景色、文字颜色以及资源图片,一键进行全部切换的过程。这里就包括了图片资源和颜色资源。Skins怎么使用Skins就是一个解决这样一种换肤需求的框架。// 添加以下代码到项目根目录下的build.gradle
allprojects {
repositories {
maven { url "https://jitpack.io" }
}
}
// 添加以下代码到app模块的build.gradle
dependencies {
// skins依赖了dora框架,所以你也要implementation dora
implementation("com.github.dora4:dora:1.1.12")
implementation 'com.github.dora4:dview-skins:1.4'
}我以更换皮肤颜色为例,打开res/colors.xml。<!-- 需要换肤的颜色 -->
<color name="skin_theme_color">@color/cyan</color>
<color name="skin_theme_color_red">#d23c3e</color>
<color name="skin_theme_color_orange">#ff8400</color>
<color name="skin_theme_color_black">#161616</color>
<color name="skin_theme_color_green">#009944</color>
<color name="skin_theme_color_blue">#0284e9</color>
<color name="skin_theme_color_cyan">@color/cyan</color>
<color name="skin_theme_color_purple">#8c00d6</color>将所有需要换肤的颜色,添加skin_前缀和_skinname后缀,不加后缀的就是默认皮肤。 然后在启动页应用预设的皮肤类型。在布局layout文件中使用默认皮肤的资源名称,像这里就是R.color.skin_theme_color,框架会自动帮你替换。要想让框架自动帮你替换,你需要让所有要换肤的Activity继承BaseSkinActivity。private fun applySkin() {
val manager = PreferencesManager(this)
when (manager.getSkinType()) {
0 -> {
}
1 -> {
SkinManager.changeSkin("cyan")
}
2 -> {
SkinManager.changeSkin("orange")
}
3 -> {
SkinManager.changeSkin("black")
}
4 -> {
SkinManager.changeSkin("green")
}
5 -> {
SkinManager.changeSkin("red")
}
6 -> {
SkinManager.changeSkin("blue")
}
7 -> {
SkinManager.changeSkin("purple")
}
}
}另外还有一个情况是在代码中使用换肤,那么跟布局文件中定义是有一些区别的。val skinThemeColor = SkinManager.getLoader().getColor("skin_theme_color")这个skinThemeColor拿到的就是当前皮肤下的真正的skin_theme_color颜色,比如R.color.skin_theme_color_orange的颜色值“#ff8400”或R.id.skin_theme_color_blue的颜色值“#0284e9”。 SkinLoader还提供了更简洁设置View颜色的方法。override fun setImageDrawable(imageView: ImageView, resName: String) {
val drawable = getDrawable(resName) ?: return
imageView.setImageDrawable(drawable)
}
override fun setBackgroundDrawable(view: View, resName: String) {
val drawable = getDrawable(resName) ?: return
view.background = drawable
}
override fun setBackgroundColor(view: View, resName: String) {
val color = getColor(resName)
view.setBackgroundColor(color)
}框架原理解析先看BaseSkinActivity的源码。package dora.skin.base
import android.content.Context
import android.os.Bundle
import android.util.AttributeSet
import android.view.InflateException
import android.view.LayoutInflater
import android.view.View
import androidx.collection.ArrayMap
import androidx.core.view.LayoutInflaterCompat
import androidx.core.view.LayoutInflaterFactory
import androidx.databinding.ViewDataBinding
import dora.BaseActivity
import dora.skin.SkinManager
import dora.skin.attr.SkinAttr
import dora.skin.attr.SkinAttrSupport
import dora.skin.attr.SkinView
import dora.skin.listener.ISkinChangeListener
import dora.util.LogUtils
import dora.util.ReflectionUtils
import java.lang.reflect.Constructor
import java.lang.reflect.Method
import java.util.*
abstract class BaseSkinActivity<T : ViewDataBinding> : BaseActivity<T>(),
ISkinChangeListener, LayoutInflaterFactory {
private val constructorArgs = arrayOfNulls<Any>(2)
override fun onCreateView(parent: View?, name: String, context: Context, attrs: AttributeSet): View? {
if (createViewMethod == null) {
val methodOnCreateView = ReflectionUtils.findMethod(delegate.javaClass, false,
"createView", *createViewSignature)
createViewMethod = methodOnCreateView
}
var view: View? = ReflectionUtils.invokeMethod(delegate, createViewMethod, parent, name,
context, attrs) as View?
if (view == null) {
view = createViewFromTag(context, name, attrs)
}
val skinAttrList = SkinAttrSupport.getSkinAttrs(attrs, context)
if (skinAttrList.isEmpty()) {
return view
}
injectSkin(view, skinAttrList)
return view
}
private fun injectSkin(view: View?, skinAttrList: MutableList<SkinAttr>) {
if (skinAttrList.isNotEmpty()) {
var skinViews = SkinManager.getSkinViews(this)
if (skinViews == null) {
skinViews = arrayListOf()
}
skinViews.add(SkinView(view, skinAttrList))
SkinManager.addSkinView(this, skinViews)
if (SkinManager.needChangeSkin()) {
SkinManager.apply(this)
}
}
}
private fun createViewFromTag(context: Context, viewName: String, attrs: AttributeSet): View? {
var name = viewName
if (name == "view") {
name = attrs.getAttributeValue(null, "class")
}
return try {
constructorArgs[0] = context
constructorArgs[1] = attrs
if (-1 == name.indexOf('.')) {
// try the android.widget prefix first...
createView(context, name, "android.widget.")
} else {
createView(context, name, null)
}
} catch (e: Exception) {
// We do not want to catch these, lets return null and let the actual LayoutInflater
null
} finally {
// Don't retain references on context.
constructorArgs[0] = null
constructorArgs[1] = null
}
}
@Throws(InflateException::class)
private fun createView(context: Context, name: String, prefix: String?): View? {
var constructor = constructorMap[name]
return try {
if (constructor == null) {
// Class not found in the cache, see if it's real, and try to add it
val clazz = context.classLoader.loadClass(
if (prefix != null) prefix + name else name).asSubclass(View::class.java)
constructor = clazz.getConstructor(*constructorSignature)
constructorMap[name] = constructor
}
constructor!!.isAccessible = true
constructor.newInstance(*constructorArgs)
} catch (e: Exception) {
// We do not want to catch these, lets return null and let the actual LayoutInflater
null
}
}
override fun onCreate(savedInstanceState: Bundle?) {
val layoutInflater = LayoutInflater.from(this)
LayoutInflaterCompat.setFactory(layoutInflater, this)
super.onCreate(savedInstanceState)
SkinManager.addListener(this)
}
override fun onDestroy() {
super.onDestroy()
SkinManager.removeListener(this)
}
override fun onSkinChanged(suffix: String) {
SkinManager.apply(this)
}
companion object {
val constructorSignature = arrayOf(Context::class.java, AttributeSet::class.java)
private val constructorMap: MutableMap<String, Constructor<out View>> = ArrayMap()
private var createViewMethod: Method? = null
val createViewSignature = arrayOf(View::class.java, String::class.java,
Context::class.java, AttributeSet::class.java)
}
}我们可以看到BaseSkinActivity继承自dora.BaseActivity,所以dora框架是必须要依赖的。有人说,那我不用dora框架的功能,可不可以不依赖dora框架?我的回答是,不建议。Skins对Dora生命周期注入特性采用的是,依赖即配置。package dora.lifecycle.application
import android.app.Application
import android.content.Context
import dora.skin.SkinManager
class SkinsAppLifecycle : ApplicationLifecycleCallbacks {
override fun attachBaseContext(base: Context) {
}
override fun onCreate(application: Application) {
SkinManager.init(application)
}
override fun onTerminate(application: Application) {
}
}所以你无需手动配置<meta-data android:name="dora.lifecycle.config.SkinsGlobalConfig" android:value="GlobalConfig"/>,Skins已经自动帮你配置好了。那么我顺便问个问题,BaseSkinActivity中最关键的一行代码是哪行?LayoutInflaterCompat.setFactory(layoutInflater, this)这行代码是整个换肤流程最关键的一行代码。我们来干预一下所有Activity onCreateView时的布局加载过程。我们在SkinAttrSupport.getSkinAttrs中自己解析了AttributeSet。 /**
* 从xml的属性集合中获取皮肤相关的属性。
*/
fun getSkinAttrs(attrs: AttributeSet, context: Context): MutableList<SkinAttr> {
val skinAttrs: MutableList<SkinAttr> = ArrayList()
var skinAttr: SkinAttr
for (i in 0 until attrs.attributeCount) {
val attrName = attrs.getAttributeName(i)
val attrValue = attrs.getAttributeValue(i)
val attrType = getSupportAttrType(attrName) ?: continue
if (attrValue.startsWith("@")) {
val ref = attrValue.substring(1)
if (TextUtils.isEqualTo(ref, "null")) {
// 跳过@null
continue
}
val id = ref.toInt()
// 获取资源id的实体名称
val entryName = context.resources.getResourceEntryName(id)
if (entryName.startsWith(SkinConfig.ATTR_PREFIX)) {
skinAttr = SkinAttr(attrType, entryName)
skinAttrs.add(skinAttr)
}
}
}
return skinAttrs
}我们只干预skin_开头的资源的加载过程,所以解析得到我们需要的属性,最后得到SkinAttr的列表返回。package dora.skin.attr
import android.view.View
import android.widget.ImageView
import android.widget.TextView
import dora.skin.SkinLoader
import dora.skin.SkinManager
enum class SkinAttrType(var attrType: String) {
/**
* 背景属性。
*/
BACKGROUND("background") {
override fun apply(view: View, resName: String) {
val drawable = loader.getDrawable(resName)
if (drawable != null) {
view.setBackgroundDrawable(drawable)
} else {
val color = loader.getColor(resName)
view.setBackgroundColor(color)
}
}
},
/**
* 字体颜色。
*/
TEXT_COLOR("textColor") {
override fun apply(view: View, resName: String) {
val colorStateList = loader.getColorStateList(resName) ?: return
(view as TextView).setTextColor(colorStateList)
}
},
/**
* 图片资源。
*/
SRC("src") {
override fun apply(view: View, resName: String) {
if (view is ImageView) {
val drawable = loader.getDrawable(resName) ?: return
view.setImageDrawable(drawable)
}
}
};
abstract fun apply(view: View, resName: String)
/**
* 获取资源管理器。
*/
val loader: SkinLoader
get() = SkinManager.getLoader()
}当前skins框架只定义了几种主要的换肤属性,你理解原理后,也可以自己进行扩展,比如RadioButton的button属性等。开源项目传送门如果你要深入理解完整的换肤流程,请阅读skins的源代码,[github.com/dora4/dview…] 。
chole
Android自定义View - DoraRotateCoverView
描述:一个无缝衔接的旋转封面复杂度:★☆☆☆☆分组:【Dora大控件组】关系:暂无技术要点:基本绘图、ObjectAnimator照片动图软件包 https://github.com/dora4/dora_rotate_cover_view/blob/main/art/dora_rotate_cover_view.apk 用法val rotateCoverView = findViewById<DoraRotateCoverView>(R.id.rotateCoverView)
// 设置转一圈的时间为10秒钟
rotateCoverView.setRotateDuration(10000)
// 智能启动
rotateCoverView.start(R.drawable.cover, true)类API描述DoraRotateCoverViewstart()开始动画运行,如果isSmart传true,则自行处理start和resume,非第一次启动则调用resumeDoraRotateCoverViewstop()终止动画运行DoraRotateCoverViewpause()暂停动画运行DoraRotateCoverViewresume()继续动画运行DoraRotateCoverViewsetRotateDuration()设置旋转一圈所需的时间DoraRotateCoverViewborderColor边框的颜色DoraRotateCoverViewborderWidth边框的宽度
chole
浅谈6大设计原则
开闭原则(Open-Closed Principle,OCP)对扩展开放,对修改关闭。单一职责原则(Simple Responsibility Pinciple,SRP)一个类只做与它有关的事情。依赖倒置原则(Dependence Inversion Principle,DIP)高层模块不应该依赖底层模块,两者都应该依赖其抽象。接口隔离原则(Interface Segregation Principle,ISP)接口要尽可能小。最小知识原则(Least Knowledge Principle,LKP)不需要外部可以访问的就不要暴露出去。里氏替换原则(Liskov Substitution Principle,LSP)所有使用父类的地方,用其子类也不会改变其原有行为。
chole
Android自定义View - DoraCurveChart
描述:图表引擎之折线统计图复杂度:★★★★☆分组:【图表引擎】关系:dora_bar_chart技术要点:基本绘图、Path的使用、贝瑟尔曲线、属性动画照片动图软件包github.com/dora4/dora_…用法类API描述DoraCurveEntryvalue一个点的值DoraCurveEntryshowValue是否显示点的值DoraCurveDataSetlineWidth一个图例注记代表的数据集的折线的宽度DoraCurveDataSetlineColor一个图例注记代表的数据集的折线的颜色DoraCurveDataSetlabel注记的文本DoraCurveDataSetmode线的模式,LINEAR为折线,CURVE为曲线DoraChartViewsetData()设置图表的数据DoraChartViewsetLegend()设置图表的图例和注记DoraAxisaxisLineColor坐标轴线的颜色DoraAxisaxisLineWidth坐标轴线的宽度DoraAxisdrawGridLine是否绘制网格线DoraLegendiconLabelGap图例和注记的间距DoraLegendlegendItemGap图例之间的间距DoraLegendiconSize图例的大小,所有图例大小一致DoraLegendentries所有的图例和注记的数据DoraLegendtype图例的类型,SQUARE代表方形,CIRCLE代表圆形LegendEntrylabel注记的文本,等同于CurveDataSet的labelLegendEntrylabelColor注记的颜色LegendEntryiconColor图例的颜色
chole
Android Studio插件开发 - Dora SDK的IDE插件
Android Studio是一种常用的集成开发环境(IDE),用于开发Android应用程序。它提供了许多功能和工具,可以帮助开发人员更轻松地构建和调试Android应用程序。如果你想开发Android Studio插件,以下是一些基本步骤:确保你已经安装了最新版本的Android Studio。你可以从官方网站(developer.android.com/studio)下载并安…了解Android Studio插件的基本结构和原理。Android Studio插件是基于IntelliJ平台构建的,因此你可以通过学习IntelliJ插件开发来了解Android Studio插件的开发。创建一个新的插件项目。在Android Studio中,选择"File" -> "New" -> "New Project",然后选择"IntelliJ Platform Plugin"作为项目类型。定义你的插件。你可以通过插件描述文件(plugin.xml)来指定插件的名称、版本、依赖项等信息。实现插件的功能。根据你的需求,你可以使用Java或Kotlin编写插件的代码。你可以使用IntelliJ平台提供的API来访问和操作Android Studio的功能和组件。编译和运行插件。在Android Studio中,选择"Run" -> "Run 'plugin_name'"来编译和运行你的插件。这将启动一个新的实例,并加载你的插件。测试和调试插件。你可以使用Android Studio的调试功能来调试你的插件代码。在开发过程中,确保测试你的插件在各种情况下的行为和兼容性。打包和发布插件。一旦你完成了插件的开发和测试,你可以将插件打包为一个JAR文件,并上传到Android Studio的插件市场(plugins.jetbrains.com/androidstud…这只是一个简单的概述,帮助你入门Android Studio插件开发。要深入了解插件开发的详细内容,你可以查阅Android Studio和IntelliJ平台的官方文档,并参考一些示例代码和教程。祝你成功开发自己的Android Studio插件!Dora SDK的Android Studio插件介绍Dora SDK github.com/dora4/dora ,是由Dora开发的,没错,就是我,一款高效开发Android App的基础架构。而Dora Android Studio Plugin则提供了快捷使用Dora SDK的功能,即以图形化界面的方式,创建继承自dora.BaseActivity的Activity类和继承自dora.BaseFragment的Fragment类。这样就方便了大家高效地使用Dora SDK开发很多很多的界面啦!依赖Dora SDK的Android Studio插件使用步骤其实也很简单,总共就下载和安装两步。使用Dora SDK的Android Studio插件创建模板代码创建模板代码也是只有简简单单的两步。通过插件源码编译适合自己Android Studio版本使用的插件包敲黑板,重点来了。通常情况下,我们使用的Android Studio版本是不一致的,除非我们心灵相通,比较默契,是吧!首先你要查看你当前使用的Android Studio的版本。把AI后面的这一串数字复制下来。IDEA有很多分支变体版本,AI就代表我们的Android Studio。然后修改gradle的配置,compileKotlin {
kotlinOptions.jvmTarget = 你的jdk版本
}
compileTestKotlin {
kotlinOptions.jvmTarget = 你的jdk版本
}
// See https://github.com/JetBrains/gradle-intellij-plugin/
intellij {
plugins = ['Kotlin', 'android']
version.set("你的Android Studio版本,如213.7172.25.2113.9123335")
// Android Studio的代号是AI
type.set("AI")
}JDK的版本在[File] - [Project Structure]中配置。我们还要配置一下我们项目的类型为Gradle项目。点击[Edit Configrations],选择Gradle,点OK,然后就可以编译插件了。编译后的插件生成目录为dora-studio-plugin/build/libs/,然后照着本文前面所提到的本地安装插件的步骤就可以了。插件源码讲解最后,我觉得还是有必要简单讲解下插件的源码。目录结构大概是这样的。插件代码在kotlin目录下,资源则在resources目录下。我们先从resources/META-INF/plugin.xml看起,里面配置了插件的一些基本信息,重要的是这个入口。<extensions defaultExtensionNs="com.android">
<!-- Add your extensions here -->
<tools.idea.wizard.template.wizardTemplateProvider
implementation="com.dorachat.templates.recipes.DoraTemplateWizardProvider"/>
</extensions>这里配置了一个模板向导提供者。package com.dorachat.templates.recipes
import com.android.tools.idea.wizard.template.WizardTemplateProvider
class DoraTemplateWizardProvider: WizardTemplateProvider() {
override fun getTemplates() = listOf(MVVMActivityTemplate, MVVMFragmentTemplate)
}因为我们总共有两套模板,所以这里列出这两套模板的类名。以MVVMActivityTemplate为例。package com.dorachat.templates.recipes
import com.android.tools.idea.wizard.template.*
import com.android.tools.idea.wizard.template.impl.activities.common.MIN_API
import java.io.File
object MVVMActivityTemplate : Template {
override val category: Category
get() = Category.Activity
override val constraints: Collection<TemplateConstraint>
get() = emptyList() //AndroidX, kotlin
override val description: String
get() = "创建一个dora.MVVMActivity,来自https://github.com/dora4/dora"
override val documentationUrl: String?
get() = null
override val formFactor: FormFactor
get() = FormFactor.Mobile
override val minSdk: Int
get() = MIN_API
override val name: String
get() = "MVVM Activity"
override val recipe: Recipe
get() = {
mvvmActivityRecipe(
it as ModuleTemplateData,
activityClassInputParameter.value,
activityTitleInputParameter.value,
layoutNameInputParameter.value,
packageName.value
)
}
override val uiContexts: Collection<WizardUiContext>
get() = listOf(WizardUiContext.ActivityGallery, WizardUiContext.MenuEntry, WizardUiContext.NewProject, WizardUiContext.NewModule)
override val useGenericInstrumentedTests: Boolean
get() = false
override val useGenericLocalTests: Boolean
get() = false
override val widgets: Collection<Widget<*>>
get() = listOf(
TextFieldWidget(activityTitleInputParameter),
TextFieldWidget(activityClassInputParameter),
TextFieldWidget(layoutNameInputParameter),
PackageNameWidget(packageName),
LanguageWidget()
)
override fun thumb(): Thumb {
return Thumb { findResource(this.javaClass, File("template_mvvm_activity.png")) }
}
val activityClassInputParameter = stringParameter {
name = "Activity Name"
default = "MainActivity"
help = "The name of the activity class to create"
constraints = listOf(Constraint.CLASS, Constraint.UNIQUE, Constraint.NONEMPTY)
suggest = { layoutToActivity(layoutNameInputParameter.value) }
}
var layoutNameInputParameter: StringParameter = stringParameter {
name = "Layout Name"
default = "activity_main"
help = "The name of the layout to create for the activity"
constraints = listOf(Constraint.LAYOUT, Constraint.UNIQUE, Constraint.NONEMPTY)
suggest = { activityToLayout(activityClassInputParameter.value) }
}
val activityTitleInputParameter = stringParameter {
name = "Title"
default = "Main"
help = "The name of the activity. For launcher activities, the application title"
visible = { false }
constraints = listOf(Constraint.NONEMPTY)
suggest = { buildClassNameWithoutSuffix(activityClassInputParameter.value, "Activity") }
}
val packageName = defaultPackageNameParameter
}这里是不是配置了一些创建模板的信息?包括编译的最低sdk版本,有哪些输入框等等。这些输入控件需要指定输入类型的参数,如stringParameter。suggest表示输入的建议,简单的说,就是让别的输入框的内容跟当前输入的内容联动。还有一个重点就是这个recipe,它决定了这个模板代码怎么去生成。package com.dorachat.templates.recipes
import com.android.tools.idea.wizard.template.*
import com.android.tools.idea.wizard.template.impl.activities.common.generateManifest
import com.dorachat.templates.recipes.app_package.res.layout.mvvmActivityXml
import com.dorachat.templates.recipes.app_package.res.layout.mvvmFragmentXml
import com.dorachat.templates.recipes.app_package.src.mvvmActivity
import com.dorachat.templates.recipes.app_package.src.mvvmActivityKt
import com.dorachat.templates.recipes.app_package.src.mvvmFragment
import com.dorachat.templates.recipes.app_package.src.mvvmFragmentKt
import java.lang.StringBuilder
fun RecipeExecutor.mvvmActivityRecipe(
moduleData: ModuleTemplateData,
activityClass: String,
activityTitle: String,
layoutName: String,
packageName: String
) {
val (projectData, srcOut, resOut) = moduleData
generateManifest(
moduleData = moduleData,
activityClass = activityClass,
// activityTitle = activityTitle,
packageName = packageName,
isLauncher = false,
hasNoActionBar = false,
generateActivityTitle = false,
)
if (projectData.language.equals(Language.Kotlin)) {
save(mvvmActivityKt(projectData.applicationPackage ?: packageName, packageName, activityClass,
buildBindingName(layoutName), layoutName), srcOut.resolve("${activityClass}.${projectData.language.extension}"))
}
if (projectData.language.equals(Language.Java)) {
save(mvvmActivity(projectData.applicationPackage ?: packageName, packageName, activityClass,
buildBindingName(layoutName), layoutName), srcOut.resolve("${activityClass}.${projectData.language.extension}"))
}
save(mvvmActivityXml(packageName, activityClass), resOut.resolve("layout/${layoutName}.xml"))
open(resOut.resolve("layout/${layoutName}.xml"))
}如果要考虑比较周全,肯定是Java和Kotlin语言开发的项目代码都要能生成,然后创建Activity类的同时也把对应的布局xml文件也创建了,并且打开给使用者看一下,便于直接添加到git版本控制。总结有兴趣的同学可以参阅插件项目的源代码,github.com/dora4/dora-… 和Dora SDK的源代码github.com/dora4/dora 。另外,我的开发套件三驾马车分别是dora、dcache和dview,你也可以称为“三剑客”。这是使用Android Studio插件开发代码生成模板的一个案例,插件开发还可以完全自定义功能,本文篇幅有限,就不细说了,大概是通过Action作菜单按钮和Java Swing作为图形界面,开发任意Java能实现的内容。
chole
什么是反射
在Java中,有一种动态加载执行的技术,叫反射。简单来说,就是在你程序运行的时候,再附加加载一些类去执行。要对一个类使用反射,必须先得到该类的字节码。获取字节码class对象类名.class对象.getClass()Class.forName("该类所在的带包名路径")比如Person.class,person.getClass(),Class.forName("com.example.Person"),注意使用Class.forName的方式要try-catch一个ClassNotFoundException。获取类的属性和方法getDeclaredField()getField()getDeclaredMethod()getMethod()getDeclaredConstructor()getConstructor()getDeclared开头的是获取该类中直接声明的属性或方法,不包括该类的父类,而没有Declared的是获取该类包括继承来的所有属性或方法。属性值get和setfield.set(obj, value) field.get(obj) obj为该类的一个对象方法调用method.invoke(obj, params...) constructor.invoke(obj, params...)获取类的第一个泛型类型private fun getGenericType(obj: Any): Class<*>? {
return if (obj.javaClass.genericSuperclass is ParameterizedType &&
(obj.javaClass.genericSuperclass as ParameterizedType).actualTypeArguments.isNotEmpty()) {
(obj.javaClass.genericSuperclass as ParameterizedType).actualTypeArguments[0] as Class<*>
} else (obj.javaClass.genericSuperclass as ParameterizedType).actualTypeArguments[0] as Class<*>
}
chole
如何仿一个抖音极速版领现金的进度条动画?
效果演示不仅仅是实现效果,要封装,就封装好看完了演示的效果,你是否在思考,代码应该怎么实现?先不着急写代码,先想想哪些地方是要可以动态配置的。首先第一个,进度条的形状是不是要可以换?然后进度条的背景色和填充的颜色,以及动画的时长是不是也要可以配置?没错,起始位置是不是也要可以换?最好还要让速度可以一会快一会慢对吧,画笔的笔帽是不是还可以选择平的或圆的?带着这些问题,我们再开始写代码。代码实现我们写一个自定义View,把可以动态配置的地方想好后,就可以定义自定义属性了。<?xml version="1.0" encoding="utf-8"?>
<resources>
<declare-styleable name="DoraProgressView">
<attr name="dview_progressType">
<enum name="line" value="0"/>
<enum name="semicircle" value="1"/>
<enum name="semicircleReverse" value="2"/>
<enum name="circle" value="3"/>
<enum name="circleReverse" value="4"/>
</attr>
<attr name="dview_progressOrigin">
<enum name="left" value="0"/>
<enum name="top" value="1"/>
<enum name="right" value="2"/>
<enum name="bottom" value="3"/>
</attr>
<attr format="dimension|reference" name="dview_progressWidth"/>
<attr format="color|reference" name="dview_progressBgColor"/>
<attr format="color|reference" name="dview_progressHoverColor"/>
<attr format="integer" name="dview_animationTime"/>
<attr name="dview_paintCap">
<enum name="flat" value="0"/>
<enum name="round" value="1"/>
</attr>
</declare-styleable>
</resources>然后我们不管三七二十一,先把自定义属性解析出来。private fun initAttrs(context: Context, attrs: AttributeSet?, defStyleAttr: Int) {
val a = context.obtainStyledAttributes(
attrs,
R.styleable.DoraProgressView,
defStyleAttr,
0
)
when (a.getInt(R.styleable.DoraProgressView_dview_progressType, PROGRESS_TYPE_LINE)) {
0 -> progressType = PROGRESS_TYPE_LINE
1 -> progressType = PROGRESS_TYPE_SEMICIRCLE
2 -> progressType = PROGRESS_TYPE_SEMICIRCLE_REVERSE
3 -> progressType = PROGRESS_TYPE_CIRCLE
4 -> progressType = PROGRESS_TYPE_CIRCLE_REVERSE
}
when (a.getInt(R.styleable.DoraProgressView_dview_progressOrigin, PROGRESS_ORIGIN_LEFT)) {
0 -> progressOrigin = PROGRESS_ORIGIN_LEFT
1 -> progressOrigin = PROGRESS_ORIGIN_TOP
2 -> progressOrigin = PROGRESS_ORIGIN_RIGHT
3 -> progressOrigin = PROGRESS_ORIGIN_BOTTOM
}
when(a.getInt(R.styleable.DoraProgressView_dview_paintCap, 0)) {
0 -> paintCap = Paint.Cap.SQUARE
1 -> paintCap = Paint.Cap.ROUND
}
progressWidth = a.getDimension(R.styleable.DoraProgressView_dview_progressWidth, 30f)
progressBgColor =
a.getColor(R.styleable.DoraProgressView_dview_progressBgColor, Color.GRAY)
progressHoverColor =
a.getColor(R.styleable.DoraProgressView_dview_progressHoverColor, Color.BLUE)
animationTime = a.getInt(R.styleable.DoraProgressView_dview_animationTime, 1000)
a.recycle()
}解析完自定义属性,切勿忘了释放TypedArray。接下来我们考虑下一步,测量。半圆是不是不要那么大的画板对吧,我们在测量的时候就要充分考虑进去。override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
progressBgPaint.strokeWidth = progressWidth
progressHoverPaint.strokeWidth = progressWidth
if (progressType == PROGRESS_TYPE_LINE) {
// 线
var left = 0f
var top = 0f
var right = measuredWidth.toFloat()
var bottom = measuredHeight.toFloat()
val isHorizontal = when(progressOrigin) {
PROGRESS_ORIGIN_LEFT, PROGRESS_ORIGIN_RIGHT -> true
else -> false
}
if (isHorizontal) {
top = (measuredHeight - progressWidth) / 2
bottom = (measuredHeight + progressWidth) / 2
progressBgRect[left + progressWidth / 2, top, right - progressWidth / 2] = bottom
} else {
left = (measuredWidth - progressWidth) / 2
right = (measuredWidth + progressWidth) / 2
progressBgRect[left, top + progressWidth / 2, right] = bottom - progressWidth / 2
}
} else if (progressType == PROGRESS_TYPE_CIRCLE || progressType == PROGRESS_TYPE_CIRCLE_REVERSE) {
// 圆
var left = 0f
val top = 0f
var right = measuredWidth
var bottom = measuredHeight
progressBgRect[left + progressWidth / 2, top + progressWidth / 2, right - progressWidth / 2] =
bottom - progressWidth / 2
} else {
// 半圆
val isHorizontal = when(progressOrigin) {
PROGRESS_ORIGIN_LEFT, PROGRESS_ORIGIN_RIGHT -> true
else -> false
}
val min = measuredWidth.coerceAtMost(measuredHeight)
var left = 0f
var top = 0f
var right = 0f
var bottom = 0f
if (isHorizontal) {
if (measuredWidth >= min) {
left = ((measuredWidth - min) / 2).toFloat()
right = left + min
}
if (measuredHeight >= min) {
bottom = top + min
}
progressBgRect[left + progressWidth / 2, top + progressWidth / 2, right - progressWidth / 2] =
bottom - progressWidth / 2
setMeasuredDimension(
MeasureSpec.makeMeasureSpec(
(right - left).toInt(),
MeasureSpec.EXACTLY
),
MeasureSpec.makeMeasureSpec(
(bottom - top + progressWidth).toInt() / 2,
MeasureSpec.EXACTLY
)
)
} else {
if (measuredWidth >= min) {
right = left + min
}
if (measuredHeight >= min) {
top = ((measuredHeight - min) / 2).toFloat()
bottom = top + min
}
progressBgRect[left + progressWidth / 2, top + progressWidth / 2, right - progressWidth / 2] =
bottom - progressWidth / 2
setMeasuredDimension(
MeasureSpec.makeMeasureSpec(
(right - left + progressWidth).toInt() / 2,
MeasureSpec.EXACTLY
),
MeasureSpec.makeMeasureSpec(
(bottom - top).toInt(),
MeasureSpec.EXACTLY
)
)
}
}
}View的onMeasure()方法是不是默认调用了一个super.onMeasure(widthMeasureSpec, heightMeasureSpec)它最终会调用setMeasuredDimension()方法来确定最终测量的结果吧。如果我们对默认的测量不满意,我们可以自己改,最后也调用setMeasuredDimension()方法把测量结果确认。半圆,如果是水平的情况下,我们的宽度就只要一半,相反如果是垂直的半圆,我们高度就只要一半。最后我们画还是照常画,只不过在最后把画到外面的部分移动到画板上显示出来。接下来就是我们最重要的绘图环节了。override fun onDraw(canvas: Canvas) {
if (progressType == PROGRESS_TYPE_LINE) {
val isHorizontal = when(progressOrigin) {
PROGRESS_ORIGIN_LEFT, PROGRESS_ORIGIN_RIGHT -> true
else -> false
}
if (isHorizontal) {
canvas.drawLine(
progressBgRect.left,
measuredHeight / 2f,
progressBgRect.right,
measuredHeight / 2f,
progressBgPaint)
} else {
canvas.drawLine(measuredWidth / 2f,
progressBgRect.top,
measuredWidth / 2f,
progressBgRect.bottom, progressBgPaint)
}
if (percentRate > 0) {
when (progressOrigin) {
PROGRESS_ORIGIN_LEFT -> {
canvas.drawLine(
progressBgRect.left,
measuredHeight / 2f,
(progressBgRect.right) * percentRate,
measuredHeight / 2f,
progressHoverPaint
)
}
PROGRESS_ORIGIN_TOP -> {
canvas.drawLine(measuredWidth / 2f,
progressBgRect.top,
measuredWidth / 2f,
(progressBgRect.bottom) * percentRate,
progressHoverPaint)
}
PROGRESS_ORIGIN_RIGHT -> {
canvas.drawLine(
progressWidth / 2 + (progressBgRect.right) * (1 - percentRate),
measuredHeight / 2f,
progressBgRect.right,
measuredHeight / 2f,
progressHoverPaint
)
}
PROGRESS_ORIGIN_BOTTOM -> {
canvas.drawLine(measuredWidth / 2f,
progressWidth / 2 + (progressBgRect.bottom) * (1 - percentRate),
measuredWidth / 2f,
progressBgRect.bottom,
progressHoverPaint)
}
}
}
} else if (progressType == PROGRESS_TYPE_SEMICIRCLE) {
if (progressOrigin == PROGRESS_ORIGIN_LEFT) {
// PI ~ 2PI
canvas.drawArc(progressBgRect, 180f, 180f, false, progressBgPaint)
canvas.drawArc(
progressBgRect,
180f,
angle.toFloat(),
false,
progressHoverPaint
)
} else if (progressOrigin == PROGRESS_ORIGIN_TOP) {
canvas.translate(-progressBgRect.width() / 2, 0f)
// 3/2PI ~ 2PI, 0 ~ PI/2
canvas.drawArc(progressBgRect, 270f, 180f, false, progressBgPaint)
canvas.drawArc(
progressBgRect,
270f,
angle.toFloat(),
false,
progressHoverPaint
)
} else if (progressOrigin == PROGRESS_ORIGIN_RIGHT) {
canvas.translate(0f, -progressBgRect.height() / 2)
// 2PI ~ PI
canvas.drawArc(progressBgRect, 0f, 180f, false, progressBgPaint)
canvas.drawArc(
progressBgRect,
0f,
angle.toFloat(),
false,
progressHoverPaint
)
} else if (progressOrigin == PROGRESS_ORIGIN_BOTTOM) {
// PI/2 ~ 3/2PI
canvas.drawArc(progressBgRect, 90f, 180f, false, progressBgPaint)
canvas.drawArc(
progressBgRect,
90f,
angle.toFloat(),
false,
progressHoverPaint
)
}
} else if (progressType == PROGRESS_TYPE_SEMICIRCLE_REVERSE) {
if (progressOrigin == PROGRESS_ORIGIN_LEFT) {
canvas.translate(0f, -progressBgRect.height() / 2)
// PI ~ 2PI
canvas.drawArc(progressBgRect, 180f, -180f, false, progressBgPaint)
canvas.drawArc(
progressBgRect,
180f,
-angle.toFloat(),
false,
progressHoverPaint
)
} else if (progressOrigin == PROGRESS_ORIGIN_TOP) {
// 3/2PI ~ PI/2
canvas.drawArc(progressBgRect, 270f, -180f, false, progressBgPaint)
canvas.drawArc(
progressBgRect,
270f,
-angle.toFloat(),
false,
progressHoverPaint
)
} else if (progressOrigin == PROGRESS_ORIGIN_RIGHT) {
// 2PI ~ PI
canvas.drawArc(progressBgRect, 0f, -180f, false, progressBgPaint)
canvas.drawArc(
progressBgRect,
0f,
-angle.toFloat(),
false,
progressHoverPaint
)
} else if (progressOrigin == PROGRESS_ORIGIN_BOTTOM) {
canvas.translate(-progressBgRect.width() / 2, 0f)
// PI/2 ~ 2PI, 2PI ~ 3/2PI
canvas.drawArc(progressBgRect, 90f, -180f, false, progressBgPaint)
canvas.drawArc(
progressBgRect,
90f,
-angle.toFloat(),
false,
progressHoverPaint
)
}
} else if (progressType == PROGRESS_TYPE_CIRCLE) {
val deltaAngle = if (progressOrigin == PROGRESS_ORIGIN_TOP) {
90f
} else if (progressOrigin == PROGRESS_ORIGIN_RIGHT) {
180f
} else if (progressOrigin == PROGRESS_ORIGIN_BOTTOM) {
270f
} else {
0f
}
canvas.drawArc(progressBgRect, 0f, 360f, false, progressBgPaint)
canvas.drawArc(
progressBgRect,
180f + deltaAngle,
angle.toFloat(),
false,
progressHoverPaint
)
} else if (progressType == PROGRESS_TYPE_CIRCLE_REVERSE) {
val deltaAngle = if (progressOrigin == PROGRESS_ORIGIN_TOP) {
90f
} else if (progressOrigin == PROGRESS_ORIGIN_RIGHT) {
180f
} else if (progressOrigin == PROGRESS_ORIGIN_BOTTOM) {
270f
} else {
0f
}
canvas.drawArc(progressBgRect, 0f, 360f, false, progressBgPaint)
canvas.drawArc(
progressBgRect,
180f + deltaAngle,
-angle.toFloat(),
false,
progressHoverPaint
)
}
}绘图除了需要Android的基础绘图知识外,还需要一定的数学计算的功底,比如基本的几何图形的点的计算你要清楚。怎么让绘制的角度变化起来呢?这个问题问的好。这个就牵扯出我们动画的一个关键类,TypeEvaluator,这个接口可以让我们只需要指定边界值,就可以根据动画执行的时长,来动态计算出当前的渐变值。private inner class AnimationEvaluator : TypeEvaluator<Float> {
override fun evaluate(fraction: Float, startValue: Float, endValue: Float): Float {
return if (endValue > startValue) {
startValue + fraction * (endValue - startValue)
} else {
startValue - fraction * (startValue - endValue)
}
}
}百分比渐变的固定写法,是不是应该记个笔记,方便以后CP?那么现在我们条件都成熟了,只需要将初始角度的百分比改变一下,我们写一个改变角度百分比的方法。fun setPercentRate(rate: Float) {
if (animator == null) {
animator = ValueAnimator.ofObject(
AnimationEvaluator(),
percentRate,
rate
)
}
animator?.addUpdateListener { animation: ValueAnimator ->
val value = animation.animatedValue as Float
angle =
if (progressType == PROGRESS_TYPE_CIRCLE || progressType == PROGRESS_TYPE_CIRCLE_REVERSE) {
(value * 360).toInt()
} else if (progressType == PROGRESS_TYPE_SEMICIRCLE || progressType == PROGRESS_TYPE_SEMICIRCLE_REVERSE) {
(value * 180).toInt()
} else {
0 // 线不需要求角度
}
percentRate = value
invalidate()
}
animator?.interpolator = LinearInterpolator()
animator?.setDuration(animationTime.toLong())?.start()
animator?.addListener(object : Animator.AnimatorListener {
override fun onAnimationStart(animation: Animator) {}
override fun onAnimationEnd(animation: Animator) {
percentRate = rate
listener?.onComplete()
}
override fun onAnimationCancel(animation: Animator) {}
override fun onAnimationRepeat(animation: Animator) {}
})
}这里牵扯到了Animator。有start就一定不要忘了异常中断的情况,我们可以写一个reset的方法来中断动画执行,恢复到初始状态。fun reset() {
percentRate = 0f
animator?.cancel()
}如果你不reset,想连续执行动画,则两次调用的时间间隔一定要大于动画时长,否则就应该先取消动画。涉及到的Android绘图知识点我们归纳一下完成这个自定义View需要具备的知识点。基本图形的绘制,这里主要是扇形测量和画板的平移变换自定义属性的定义和解析Animator和动画估值器TypeEvaluator的使用思路和灵感来自于系统化的基础知识这个控件其实并不难,主要就是动态配置一些参数,然后在计算上稍微复杂一些,需要一些数学的功底。那么你为什么没有思路呢?你没有思路最可能的原因主要有以下几个可能。自定义View的基础绘图API不熟悉动画估值器使用不熟悉对自定义View的基本流程不熟悉看的自定义View的源码不够多自定义View基础知识没有系统学习,导致是一些零零碎碎的知识片段数学功底不扎实我觉得往往不是你不会,这些基础知识点你可能都看到过很多次,但是一到自己写就没有思路了。思路和灵感来自于大量源码的阅读和大量的实践。大前提就是你得先把自定义View的这些知识点系统学习一下,先保证都见过,然后才是将它们融会贯通,用的时候信手拈来。
chole
设计模式 - 原型模式(Prototype)
中文名:原型模式英文名:Prototype类型:创建型模式班主任评语:原型模式,是一种复用数据的思想。我们来看一下浅拷贝和深拷贝的定义。浅拷贝:只复制指向某个对象的指针,而不复制对象本身,该对象复制了原对象的指针,相当于是新建了一个对象,新旧对象还是共用一个内存块。深拷贝:新建一个一模一样的对象,该对象与原对象不共享内存,修改新对象也不会影响原对象。那么使用Cloneable接口的就是深拷贝了,而直接赋值给另一个变量的则为浅拷贝。
chole
Android代码实现新年贺卡动画
今天,我们自己用android程序实现一个兔年的新年贺卡。下面就是见证美好的时刻,上效果。好,我们来使用Android动画的知识,来实现这样一个动画效果吧。需要使用到的知识点架构设计、Android视图动画、TypeEvaluator、Path、组合模式、代理模式。思路分析我们回顾动画的种类,补间动画、帧动画、属性动画以及Android View自带的视图动画。我们今天自己基于属性动画来打造一个山寨版的Android视图动画吧。我们可以从平移动画、缩放动画、旋转动画和透明度动画中抽象出一个基类Action类。我是不会告诉你这个类的命名我是抄的cocos2d的。然后我们扩展Action类,实现这四种动画,再作用在View上。这样就可以让View按我们的动画框架播放动画了。代码实现/**
* 组合的action可以直接交给view执行。
*/
interface Action<A : Action<A>> {
fun add(action: A): A
fun getAnimator(): Animator<A>
fun startAnimation(view: View, duration: Long)
}抽象一个Action接口,Action还可以添加Action,这里是组合模式的结构。import android.view.View
import dora.widget.animator.AlphaAnimator
import dora.widget.animator.Animator
class AlphaAction(val alpha: Float) : Action<AlphaAction> {
private var animator = AlphaAnimator()
override fun add(action: AlphaAction): AlphaAction {
animator.add(action)
return this
}
override fun startAnimation(view: View, duration: Long) {
animator.startAnimation(view, duration)
}
override fun getAnimator(): Animator<AlphaAction> {
return animator
}
operator fun plus(action: AlphaAction) = add(action)
init {
animator.add(this)
}
}我们以透明度动画为例,在Animator中实现属性动画的逻辑,然后聚合到Action类的实现,通过代理的方式调用我们的动画实现。这里我们重写了+号操作符,这样可以支持两个对象进行相加,这个是Kotlin模仿C++的语法。import android.view.View
import dora.widget.action.Action
import java.util.*
abstract class Animator<A : Action<A>>: Action<A> {
protected lateinit var targetView: View
protected var actionTree: MutableList<A> = ArrayList()
override fun add(action: A): A {
actionTree.add(action)
return actionTree[actionTree.size - 1]
}
override fun startAnimation(view: View, duration: Long) {
targetView = view
}
override fun getAnimator(): Animator<A> {
return this
}
}在Animator中,将所有的Action放到一个List集合中保存起来,当我们调用startAnimation()方法,则可以将传入的View拿到,并执行动画。class AlphaAnimator : Animator<AlphaAction>() {
override fun startAnimation(view: View, duration: Long) {
super.startAnimation(view, duration)
actionTree.add(0, AlphaAction(1.0f))
val animator = ObjectAnimator.ofObject(
this, ALPHA, AlphaEvaluator(),
*actionTree.toTypedArray()
)
animator.duration = duration
animator.start()
}
fun setAlpha(action: AlphaAction) {
val alpha = action.alpha
targetView.alpha = alpha
}
private class AlphaEvaluator : TypeEvaluator<AlphaAction> {
override fun evaluate(
fraction: Float,
startValue: AlphaAction,
endValue: AlphaAction
): AlphaAction {
val action: AlphaAction
val startAlpha = startValue.alpha
val endAlpha = endValue.alpha
action = if (endAlpha > startAlpha) {
AlphaAction(startAlpha + fraction * (endAlpha - startAlpha))
} else {
AlphaAction(startAlpha - fraction * (startAlpha - endAlpha))
}
return action
}
}
companion object {
private const val ALPHA = "alpha"
}
override fun getAnimator(): Animator<AlphaAction> {
return this
}
}比如AlphaAnimator的实现,我们这里最关键的一行代码就是使用了ObjectAnimator,用它来监听该对象属性的变化。比如这里我们监听alpha属性实际上是监听的setAlpha方法。动画变化的中间值则是通过TypeEvaluator估值器来进行计算估值的。在startAnimation()方法被调用的时候,我们默认在最前面添加了一个默认值。actionTree.add(0, AlphaAction(1.0f))我这里只是抛砖引玉,你可以做得更好,比如将初始状态不要写死,让子类去指定或在使用的时候动态指定,这样就会更加的灵活。abstract class PathAction internal constructor(
val x: Float,
val y: Float
) : Action<PathAction> {
private var animator = PathAnimator()
override fun add(action: PathAction): PathAction {
animator.add(action)
return this
}
override fun startAnimation(view: View, duration: Long) {
animator.startAnimation(view, duration)
}
override fun getAnimator(): Animator<PathAction> {
return animator
}
operator fun plus(action: PathAction) = add(action)
init {
animator.add(this)
}
}移动的动画也是类似的逻辑,我们基于Path实现移动动画。class PathAnimator : Animator<PathAction>() {
private val PATH = "path"
override fun startAnimation(view: View, duration: Long) {
super.startAnimation(view, duration)
actionTree.add(0, MoveTo(0f, 0f))
val animator = ObjectAnimator.ofObject(
this, PATH, PathEvaluator(),
*actionTree.toTypedArray()
)
animator.duration = duration
animator.start()
}
fun setPath(action: MoveTo) {
val x = action.x
val y = action.y
targetView.translationX = x
targetView.translationY = y
}
private inner class PathEvaluator : TypeEvaluator<PathAction> {
override fun evaluate(fraction: Float, startValue: PathAction, endValue: PathAction): PathAction {
var x = 0f
var y = 0f
if (endValue is MoveTo) {
x = endValue.x
y = endValue.y
}
if (endValue is LineTo) {
x = startValue.x + fraction * (endValue.x - startValue.x)
y = startValue.y + fraction * (endValue.y - startValue.y)
}
val ratio = 1 - fraction
if (endValue is QuadTo) {
x = Math.pow(ratio.toDouble(), 2.0)
.toFloat() * startValue.x + (2 * fraction * ratio
* (endValue).inflectionX) + (Math.pow(
endValue.x.toDouble(),
2.0
)
.toFloat()
* Math.pow(fraction.toDouble(), 2.0).toFloat())
y = Math.pow(ratio.toDouble(), 2.0)
.toFloat() * startValue.y + (2 * fraction * ratio
* (endValue).inflectionY) + (Math.pow(
endValue.y.toDouble(),
2.0
)
.toFloat()
* Math.pow(fraction.toDouble(), 2.0).toFloat())
}
if (endValue is CubicTo) {
x = Math.pow(ratio.toDouble(), 3.0).toFloat() * startValue.x + (3 * Math.pow(
ratio.toDouble(),
2.0
).toFloat() * fraction
* (endValue).inflectionX1) + (3 * ratio *
Math.pow(fraction.toDouble(), 2.0).toFloat()
* (endValue).inflectionX2) + Math.pow(fraction.toDouble(), 3.0)
.toFloat() * endValue.x
y = Math.pow(ratio.toDouble(), 3.0).toFloat() * startValue.y + (3 * Math.pow(
ratio.toDouble(),
2.0
).toFloat() * fraction
* (endValue).inflectionY1) + (3 * ratio *
Math.pow(fraction.toDouble(), 2.0).toFloat()
* (endValue).inflectionY2) + Math.pow(fraction.toDouble(), 3.0)
.toFloat() * endValue.y
}
return MoveTo(x, y)
}
}
override fun getAnimator(): Animator<PathAction> {
return this
}
}曲线运动则牵扯到一些贝瑟尔曲线的知识。比如二阶的贝瑟尔曲线class QuadTo(val inflectionX: Float, val inflectionY: Float, x: Float, y: Float) :
PathAction(x, y)和三阶的贝瑟尔曲线class CubicTo(
val inflectionX1: Float,
val inflectionX2: Float,
val inflectionY1: Float,
val inflectionY2: Float,
x: Float,
y: Float
) : PathAction(x, y)直线运动则是定义了MoveTo和LineTo两个类。class MoveTo(x: Float, y: Float) : PathAction(x, y)class LineTo(x: Float, y: Float) : PathAction(x, y)调用动画框架API我们贺卡的动画就是使用了以下的写法,同一类Action可以通过+号操作符进行合并,我们可以同时调用这四类Action进行动画效果的叠加,这样可以让动画效果更加丰富。(AlphaAction(0.2f) + AlphaAction(1f)).startAnimation(ivRabbit, 2000)
(MoveTo(-500f, 100f)
+ LineTo(-400f, 80f)
+ LineTo(-300f, 50f)
+ LineTo(-200f, 100f)
+ LineTo(-100f, 80f)
+ LineTo(0f, 100f)
+ LineTo(100f, 80f)
+ LineTo(200f, 50f)
+ LineTo(300f, 100f)
+ LineTo(400f, 80f)
)
.startAnimation(ivRabbit, 2000)
(RotateAction(0f) + RotateAction(180f)+ RotateAction(360f)) .startAnimation(ivRabbit, 4000)
ScaleAction(2f, 2f).startAnimation(ivRabbit, 8000)
Handler().postDelayed({
MoveTo(0f, 0f).startAnimation(ivRabbit, 500)
}, 8000)兴趣是最好的老师,本文篇幅有限,我们可以通过Android的代码在Android手机上实现各种各样炫酷的效果。跟着哆啦一起玩转Android自定义View吧。
chole
设计模式 - 简单工厂模式(Simple Factory)
中文名:简单工厂模式英文名:Simple Factory类型:创建型模式班主任评语:简单工厂模式,把对象的创建过程封装到工厂内部。这是一种非常简单的设计模式,也是非常扯的一种设计模式。主要就是将对象创建过程放到工厂中统一进行管理。
chole
Android自定义View - DoraProgressButton
描述:一个带进度的按钮复杂度:★★★☆☆分组:【进度条】关系:dora_circular_progress_bar、dora_sporty_progress_bar技术要点:基本绘图、属性动画、Xfermode照片动图软件包github.com/dora4/dora_…用法自定义属性描述dora_showBorder是否显示边框dora_borderWidth边框的宽度dora_borderColor边框的颜色dora_cornerRadius按钮的圆角dora_hoverTextColor按钮进度的文字颜色dora_backgroundColor按钮的背景色dora_hoverColor按钮进度的颜色dora_pausedText暂停状态显示的文字dora_finishedText完成状态显示的文字dora_autoReset进度达到100%是否重置到初始状态,通常使用在“秒杀”场景
chole
Android自定义View - DoraDialog
描述:底部弹出的对话框,可自定义对话框的视图复杂度:★★★☆☆分组:【Dora大控件组】关系:暂无技术要点:View视图层级DecorView、视图动画、ViewGroup添加View照片动图软件包github.com/dora4/dora_…用法val tvShowDialog = findViewById<TextView>(R.id.tvShowDialog)
tvShowDialog.setOnClickListener {
DoraDialog.Builder(this)
.create(DoraDialogWindow(R.layout.dialog_sample))
.onAttach(object : DoraDialog.OnAttachListener {
override fun onAttached(window: ADialogWindow) {
Toast.makeText(this@MainActivity, "onAttached", Toast.LENGTH_SHORT).show()
}
})
.toggle()
}类API描述DoraDialogshow对话框显示DoraDialogtoggle对话框显示状态则隐藏,隐藏状态则显示ADialogWindowdestroy隐藏或取消对话框
chole
Android自定义View - DoraFlashView
DoraFlashView描述:一个高亮的炫光控件复杂度:★☆☆☆☆分组:【Dora大控件组】关系:暂无技术要点:基本绘图、LinearGradient、Matrix照片动图软件包 https://github.com/dora4/dora_flash_view/blob/main/art/dora_flash_view.apk 用法自定义属性描述dora_rotateAngle倾斜角度,取值范围为(-90,90)区间,顺时针为正,逆时针为负dora_deltaX每次向右偏移的量,决定移动速度dora_gradientSize高亮区域的宽度dora_highlightColor高亮区域的发散颜色dora_type枚举值,normal为清晰,blur为模糊
chole
设计模式 - 代理模式(Proxy)
中文名:代理模式英文名:Proxy类型:结构型模式班主任评语:代理模式,它是被应用最为广泛的一种设计模式。所谓代理,就是不亲自去做某件事。在生活中也大量存在这样的案例,比如使用洗衣机洗衣、让同事代为买辣条等。代理模式和装饰器模式的区别在于,它们的用途不一样。装饰器模式主要是为了扩展和增强功能,而代理模式则是对访问的控制,即我在什么情况下需要调用这个方法。奖状:AppDelegate、Retrofit
chole
Android&Flutter混合开发
为什么要有混合开发我们知道,Flutter是可以做跨平台开发的,即一份Flutter的Dart代码,可以编译到多个平台上运行。这么做的好处就是,在不降低多少性能的情况下,尽最大可能的节省开发的时间成本,直接将开发的时间总成本缩短了快一半,而且测试也比较轻松,通常情况下,在一端能正常运行,在另外的端也不会有太大问题,不会出现由于开发水平不一致导致的,版本功能没有对齐的尴尬局面。然而完全的跨平台开发,在某些对性能要求极高的场景表现则不尽人意,特别是Android的低端机上。那么有没有一种方案,既能保留跨平台开发高效的优点,又能在特定的功能上充分发挥原生的优势呢?如果没有,我就不会在这里扯淡了。这个完美的解决方案就是混合开发。什么是混合开发混合开发简单来说就是,既有Flutter的Dart代码,又有平台相关的原生代码,如Kotlin、Swift。在改动比较小,且追求极致体验的主要功能模块,采用原生开发,搭建一个所谓的“壳”。然后在一些经常变动的界面,如应用的首页、活动页面,以及简单的UI界面,如设置界面、关于界面,使用Flutter进行开发。这样做的好处就是可以降低错误率,提高开发效率,缩减开发周期,提升产品竞争力和快速抢占市场的能力。混合开发长啥样用Flutter进行混合开发的app,性能不比原生差多少。而且在比较难的领域,只需要攻克Flutter端的代码,就可以。如Web3,不是说它有多难,主要是比较前沿,会的人相对来说较少。开始集成Flutter模块到Android端由于本人水平有限,我这里只介绍Android端的混合开发。我这里默认你已经掌握了Flutter的跨平台开发。如果没有这个基础的同学,可以看我Flutter专栏的其他文章。1.创建项目 你要先创建一个android app原生工程。然后在原生工程的里面再创建一个Flutter的模块项目。特别注意的是,Project type从Application改为Module,直接以模块的方式进行依赖和编译。Platforms至少选择Android,调试可能会用到Web,更方便。2,Flutter模块的gradle配置 在settings.gradle中加入以下代码。setBinding(new Binding([gradle: this]))
evaluate(new File( settingsDir,'flutter_lib/.android/include_flutter.groovy'))
include ':flutter_lib'然后加入以下代码。dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.PREFER_SETTINGS)
repositories {
google()
mavenCentral()
maven {
allowInsecureProtocol = true
url "http://download.flutter.io"
}
}
}注意将FAIL_ON_PROJECT_REPOS改为PREFER_SETTINGS。 如果为Gradle8.x,加入namespace,8.x之前使用applicationId。在flutter_lib->.android->Flutter->build.gradle中加入以下代码。 android {
namespace 'com.example.flutter_lib'
}命名空间可以自己定义。3.在app模块中依赖该flutter模块,跟依赖普通的android模块一样。 dependencies {
implementation project(':flutter')
}4.在app模块的AndroidManifest.xml清单文件中注册FlutterActivity。 <application>
<activity
android:name="io.flutter.embedding.android.FlutterActivity"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize" />
</application>如果你有使用我的1.1版本以上的dora框架[github.com/dora4/dora] ,则这一步略过。5.打开Flutter插件的入口Activity。 val intent = FlutterActivity
.withNewEngine()
.initialRoute("home")
.build(this)
startActivity(intent) 后续你还可以将插件的UI风格设计得跟原生的壳一样,然后封装好常用的原生的功能,使用Flutter的MethodChannel进行通信,提供给Flutter层进行调用。如打开原生的WebView,弹出原生的对话框,获取系统状态栏的高度等。另外Flutter模块项目,就不要依赖一些跨平台的库了,如deviceinfo,在Android这边没法获取到iOS那边的设备信息。
chole
设计模式 - 访问者模式(Visitor)
中文名:访问者模式英文名:Visitor类型:行为型模式班主任评语:访问者模式,在不破坏原有数据结构的基础上,使用访问者,对数据以不同的维度进行访问。且不同的访问者将可以得到独立的数据结果集。
chole
Android自定义View - DoraMenuPanel
描述:菜单面板,简单的列表将不再需要RecyclerView复杂度:★★☆☆☆分组:【Dora大控件组】关系:暂无技术要点:代码布局View、View滑动处理照片动图软件包 https://github.com/dora4/dora_menu_panel/blob/main/art/dora_menu_panel.apk 用法val menuPanel = findViewById<TipsMenuPanel>(R.id.menuPanel)
menuPanel
.addMenu(NormalMenuPanelItem(name = "普通条目1"))
.addMenu(NormalMenuPanelItem(name = "普通条目2"))
.addMenu(NormalMenuPanelItem(name = "普通条目3"))
.addMenu(NormalMenuPanelItem(name = "普通条目4"))
.addMenu(InputMenuPanelItem(name = "输入条目", hint = "输入文本"))
.addMenuGroup(MenuPanelItemGroup(title = "这是第一个分组的标题",
marginTop = 10,
items = arrayListOf(NormalMenuPanelItem(name = "分组条目1"),
NormalMenuPanelItem(name = "分组条目2"))))
.addMenuGroup(MenuPanelItemGroup(title = "这是第二个分组的标题",
titleSpan = MenuPanelItemRoot.Span(20, 20),
items = arrayListOf(NormalMenuPanelItem(name = "分组条目3"),
NormalMenuPanelItem(name = "分组条目4"))))
.addMenu(NormalMenuPanelItem(marginTop = 20, name = "添加菜单"))
menuPanel.setTips("总共有${menuPanel.itemCount}个菜单")
menuPanel.setOnPanelMenuClickListener(object : MenuPanel.OnPanelMenuClickListener {
override fun onMenuClick(position: Int, view: View, name: String) {
if (name == "添加菜单") { // 强烈推荐使用name判断功能而不是position
menuPanel.addMenu(NormalMenuPanelItem())
menuPanel.setTips("总共有${menuPanel.itemCount}个菜单")
} else {
Toast.makeText(this@MainActivity, "点击了$name", Toast.LENGTH_SHORT).show()
}
}
})
menuPanel.setOnPanelScrollListener(object : MenuPanel.OnPanelScrollListener {
override fun onScrollToTop() {
Toast.makeText(this@MainActivity, "滑动到顶部", Toast.LENGTH_SHORT).show()
}
override fun onScrollToBottom() {
Toast.makeText(this@MainActivity, "滑动到底部", Toast.LENGTH_SHORT).show()
}
})类API描述MenuPaneladdMenu()添加一个菜单MenuPaneladdMenuGroup()添加一个菜单组MenuPanelgetItem()获取菜单的基本信息,然后可以进行数据修改MenuPanelremoveItem()移除一个菜单MenuPanelremoveItemRange()移除连续的菜单MenuPanelremoveItemFrom从某个位置移除该位置后面的菜单MenuPanelremoveItemTo从开始移除到某个位置的菜单TipsMenuPanelsetTips()设置底部提示信息
chole
Retrofit+Flow网络请求与Android网络请求的演变
Retrofit网络请求我想大家都不陌生,今天我就来梳理一下技术是如何一步一步进步,逼格是如何一步一步变高的。Retrofit使用方式演变萌新刚开始接触Retrofit的时候是从okhttp和volley以及android系统源码里面那个HttpPost与HttpGet切换过来的。public interface AuthService {
@POST("v1/login")
@FormUrlEncoded
ResponseBody login(@Field("username") String username, @Field("password") String password);
}那个时候市面上主流还是用的Java,也不知道从哪天开始,突然发现Retrofit这种代理接口的方式用着很爽。于是用着用着就上瘾了,甚至都不知道Retrofit是使用的动态代理的方式。这种方式是通过responseBody.body().string()拿到json字符串,然后再自己通过json解析库解析出数据的。小白然后有一天到处看博客或技术文章,于是就发现了Retrofit的返回值原来不仅仅可以是ResponseBody,还可以是T。public interface AuthService {
@POST("v2/login")
@FormUrlEncoded
LoginResponse login(@Field("username") String username, @Field("password") String password);
}这时已经意识到可以json解析的过程交给retrofit框架。implementation(‘com.squareup.retrofit2:converter-gson:2.8.1’)加了个gson转换器的依赖,对吧?新手后来,为了满足对更高逼格的追求,返回值直接跟OkHttp的Call结合,然后使用enqueue的方式进行请求,于是就变成了Call<T>。public interface AuthService {
@POST("v3/login")
@FormUrlEncoded
Call<LoginResponse> login(@Field("username") String username, @Field("password") String password);
}初级再后来,发现市面上RxJava的热度突然飙升,于是乎,就开始研究起了RxJava,这时候,功力开始有所长进。public interface AuthService {
@POST("v4/login")
@FormUrlEncoded
Observable<LoginResponse> login(@Field("username") String username, @Field("password") String password);
}这时你可能就需要依赖这几个库了,版本号偏高暂且不去计较,也有可能用的是rxjava第一代。implementation ‘com.squareup.retrofit2:adapter-rxjava2:2.8.1’
implementation ‘io.reactivex.rxjava2:rxjava:2.0.1’
implementation ‘io.reactivex.rxjava2:rxandroid:2.0.1’中级随着Kotlin的兴起,市面上对网络请求的写法也是大相径庭,网络框架也开始演变出自己的风格,甚至有些公司自己封装网络请求库,没有什么问题啊,反正主要思路就是动态代理。百花齐放的时代来临。interface AuthService {
@POST("v5/login")
fun login(@Body body: RequestBody): Observable<BaseResponse<LoginUser>>
}为了追求更加新颖的写法,将@Field换成了@Body,返回值模型增加了公共的code、msg等。高级一阶由于经验逐渐变得丰富,你开始使用Kotlin的协程,因为你对更牛逼技术的追求一直没有停止过。interface AuthService {
@POST("v6/login")
suspend fun login(@Body body: RequestBody): BaseResponse<LoginUser>
}这个时候retrofit的写法就已经进入到了第6代,你问为什么是第6代?这个不是重点,我编的。你直接将API接口中定义的函数变成了suspend函数,方便在协程作用域发起。同时你去掉了Observable这个RxJava的产物,返回值又回到了最初的状态。你不禁感慨,从哪里来,到哪里去。返璞归真了!高级二阶你以为到这就结束了?随着Flow的问世,网络请求就进入到了第七世代。Flow是基于协程的产物,可以不用挂起函数了。而且Flow具备RxJava的优良特性,可以对数据流进行变换,也可以监听函数执行的生命周期。这样就方便添加显示加载中对话框和隐藏加载中对话框,以及加载进度了。interface AuthService {
@POST("v7/login")
fun login(@Body body: RequestBody): Flow<BaseResponse<LoginUser>>
}dcache框架如何支持协程和Flow我的dcache框架1.x的稳定版本,不支持flow。implementation("com.github.dora4:dcache-android:1.8.5")你需要使用2.0.12及以上版本,对flow请求有很好的支持。implementation("com.github.dora4:dcache-android:2.0.12")接下来我们简单阅读下DoraHttp.kt的源代码。/**
* 将一个普通的api接口包装成Flow返回值的接口。
*/
suspend fun <T> flowResult(requestBlock: suspend () -> T,
loadingBlock: ((Boolean) -> Unit)? = null,
errorBlock: ((String) -> Unit)? = null,
) : Flow<T> {
return flow {
// 设置超时时间为10秒
val response = withTimeout(10 * 1000) {
requestBlock()
}
emit(response)
}
.flowOn(Dispatchers.IO)
.onStart {
loadingBlock?.invoke(true)
}
.catch { e ->
errorBlock?.invoke(e.toString())
}
.onCompletion {
loadingBlock?.invoke(false)
}
}这个函数建议在net作用域内执行,net协程作用域的定义请参见DoraHttp.kt的详细源代码,github.com/dora4/dcach… 。高阶函数的block参数定义中,如果加suspend关键字,则可以传入suspend块,也可以传入普通的方法块。如果不加suspend关键字,则只能传入普通方法块。这个函数对应第6代的写法,可以翻看前面的内容。Flow<T>最终调用collect {} 来处理业务逻辑。
/**
* 直接发起Flow请求,如果你使用框架内部的[dora.http.retrofit.RetrofitManager]的话,需要开启
* [dora.http.retrofit.RetrofitManager]的flow配置选项[dora.http.retrofit.RetrofitManager.Config.useFlow]
* 为true。
*/
suspend fun <T> flowRequest(requestBlock: () -> Flow<T>,
successBlock: ((T) -> Unit),
failureBlock: ((String) -> Unit)? = null,
loadingBlock: ((Boolean) -> Unit)? = null
) {
requestBlock()
.flowOn(Dispatchers.IO)
.onStart {
loadingBlock?.invoke(true)
}
.catch { e ->
failureBlock?.invoke(e.toString())
}
.onCompletion {
loadingBlock?.invoke(false)
}.collect {
successBlock(it)
}
}这个源码对应第7代的写法。
chole
Android自定义View - GestureDetector
GestureDetectorimport android.content.Context;
import android.util.AttributeSet;
import android.util.Log;
import android.view.GestureDetector;
import android.view.MotionEvent;
import android.view.View;
import androidx.annotation.Nullable;
public class MyView extends View implements GestureDetector.OnGestureListener {
private GestureDetector mDetector;
public MyView(Context context) {
super(context);
}
public MyView(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
}
@Override
protected void onFinishInflate() {
super.onFinishInflate();
mDetector = new GestureDetector(getContext(), this);
}
@Override
public boolean onTouchEvent(MotionEvent event) {
mDetector.onTouchEvent(event);
return super.onTouchEvent(event);
}
@Override
public boolean onDown(MotionEvent e) {
Log.d("MyView", "onDown");
return false;
}
@Override
public void onShowPress(MotionEvent e) {
Log.d("MyView", "onShowPress");
}
@Override
public boolean onSingleTapUp(MotionEvent e) {
Log.d("MyView", "onSingleTapUp");
return false;
}
@Override
public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
Log.d("MyView", "onScroll");
return false;
}
@Override
public void onLongPress(MotionEvent e) {
Log.d("MyView", "onLongPress");
}
@Override
public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
Log.d("MyView", "onFling");
return false;
}
}使用GestureDetector可以监听很多类型的手势,比如长按控件,会打印以下结果。下面解释一下这几个回调是什么时候回调的。onDown:手指按下onShowPress:按下后停留,没有松开onSingleTapUp:点击手指松开,如果没有回调onScroll和onLongPress的话,就会回调这个onScroll:滑动onLongPress:长按控件onFling:快速滑动,用力拽OnDoubleTapListeneronSingleTapConfirmed:单击事件被确认,300ms后没有第二次按下onDoubleTap:双击事件onDoubleTapEvent:双击后的输入事件,比如双击后拖拽OnContextClickListeneronContextClick:外接键盘鼠标右键
chole
Android自定义View - 事件分发
事件传递和事件分发其实就是一个东西,叫法不一致罢了,你不用被名称所迷惑。有的人管这个叫事件传递机制,有的人则叫它事件分发机制。为了避免混淆,我这里统一称为事件分发。事件分发在自定义View开发中属于重点也是难点,多少人遇到瓶颈倒在这里了,所以完全有必要拿出来讲解一下。事件分发流程首先一个事件先从Activity的dispatchTouchEvent()方法开始。public boolean dispatchTouchEvent(MotionEvent ev) {
if (ev.getAction() == MotionEvent.ACTION_DOWN) {
onUserInteraction();
}
if (getWindow().superDispatchTouchEvent(ev)) {
return true;
}
return onTouchEvent(ev);
}先调用PhoneWindow的superDispatchTouchEvent()方法,然后PhoneWindow的superDispatchTouchEvent调用的是DecorView的superDispatchTouchEvent。@Override
public boolean superDispatchTouchEvent(MotionEvent event) {
return mDecor.superDispatchTouchEvent(event);
}我们再看DecorView的源代码。public boolean superDispatchTouchEvent(MotionEvent event) {
return super.dispatchTouchEvent(event);
}在DecorView的superDispatchTouchEvent()方法中调用了dispatchTouchEvent()方法。DecorView就是我们Activity真正的根布局了,它继承自FrameLayout。我们再看下DecorView的dispatchTouchEvent()方法。@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
final Window.Callback cb = mWindow.getCallback();
return cb != null && !mWindow.isDestroyed() && mFeatureId < 0
? cb.dispatchTouchEvent(ev) : super.dispatchTouchEvent(ev);
}因为它调了super.dispatchTouchEvent()方法。我们再来看ViewGroup的dispatchTouchEvent()方法大概都写了些啥。@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
if (mInputEventConsistencyVerifier != null) {
mInputEventConsistencyVerifier.onTouchEvent(ev, 1);
}
// If the event targets the accessibility focused view and this is it, start
// normal event dispatch. Maybe a descendant is what will handle the click.
if (ev.isTargetAccessibilityFocus() && isAccessibilityFocusedViewOrHost()) {
ev.setTargetAccessibilityFocus(false);
}
boolean handled = false;
if (onFilterTouchEventForSecurity(ev)) {
final int action = ev.getAction();
final int actionMasked = action & MotionEvent.ACTION_MASK;
// Handle an initial down.
if (actionMasked == MotionEvent.ACTION_DOWN) {
// Throw away all previous state when starting a new touch gesture.
// The framework may have dropped the up or cancel event for the previous gesture
// due to an app switch, ANR, or some other state change.
cancelAndClearTouchTargets(ev);
resetTouchState();
}
// Check for interception.
final boolean intercepted;
if (actionMasked == MotionEvent.ACTION_DOWN
|| mFirstTouchTarget != null) {
final boolean disallowIntercept = (mGroupFlags & FLAG_DISALLOW_INTERCEPT) != 0;
if (!disallowIntercept) {
intercepted = onInterceptTouchEvent(ev);
ev.setAction(action); // restore action in case it was changed
} else {
intercepted = false;
}
} else {
// There are no touch targets and this action is not an initial down
// so this view group continues to intercept touches.
intercepted = true;
}
// If intercepted, start normal event dispatch. Also if there is already
// a view that is handling the gesture, do normal event dispatch.
if (intercepted || mFirstTouchTarget != null) {
ev.setTargetAccessibilityFocus(false);
}
// Check for cancelation.
final boolean canceled = resetCancelNextUpFlag(this)
|| actionMasked == MotionEvent.ACTION_CANCEL;
// Update list of touch targets for pointer down, if needed.
final boolean isMouseEvent = ev.getSource() == InputDevice.SOURCE_MOUSE;
final boolean split = (mGroupFlags & FLAG_SPLIT_MOTION_EVENTS) != 0
&& !isMouseEvent;
TouchTarget newTouchTarget = null;
boolean alreadyDispatchedToNewTouchTarget = false;
if (!canceled && !intercepted) {
// If the event is targeting accessibility focus we give it to the
// view that has accessibility focus and if it does not handle it
// we clear the flag and dispatch the event to all children as usual.
// We are looking up the accessibility focused host to avoid keeping
// state since these events are very rare.
View childWithAccessibilityFocus = ev.isTargetAccessibilityFocus()
? findChildWithAccessibilityFocus() : null;
if (actionMasked == MotionEvent.ACTION_DOWN
|| (split && actionMasked == MotionEvent.ACTION_POINTER_DOWN)
|| actionMasked == MotionEvent.ACTION_HOVER_MOVE) {
final int actionIndex = ev.getActionIndex(); // always 0 for down
final int idBitsToAssign = split ? 1 << ev.getPointerId(actionIndex)
: TouchTarget.ALL_POINTER_IDS;
// Clean up earlier touch targets for this pointer id in case they
// have become out of sync.
removePointersFromTouchTargets(idBitsToAssign);
final int childrenCount = mChildrenCount;
if (newTouchTarget == null && childrenCount != 0) {
final float x =
isMouseEvent ? ev.getXCursorPosition() : ev.getX(actionIndex);
final float y =
isMouseEvent ? ev.getYCursorPosition() : ev.getY(actionIndex);
// Find a child that can receive the event.
// Scan children from front to back.
final ArrayList<View> preorderedList = buildTouchDispatchChildList();
final boolean customOrder = preorderedList == null
&& isChildrenDrawingOrderEnabled();
final View[] children = mChildren;
for (int i = childrenCount - 1; i >= 0; i--) {
final int childIndex = getAndVerifyPreorderedIndex(
childrenCount, i, customOrder);
final View child = getAndVerifyPreorderedView(
preorderedList, children, childIndex);
// If there is a view that has accessibility focus we want it
// to get the event first and if not handled we will perform a
// normal dispatch. We may do a double iteration but this is
// safer given the timeframe.
if (childWithAccessibilityFocus != null) {
if (childWithAccessibilityFocus != child) {
continue;
}
childWithAccessibilityFocus = null;
i = childrenCount;
}
if (!child.canReceivePointerEvents()
|| !isTransformedTouchPointInView(x, y, child, null)) {
ev.setTargetAccessibilityFocus(false);
continue;
}
newTouchTarget = getTouchTarget(child);
if (newTouchTarget != null) {
// Child is already receiving touch within its bounds.
// Give it the new pointer in addition to the ones it is handling.
newTouchTarget.pointerIdBits |= idBitsToAssign;
break;
}
resetCancelNextUpFlag(child);
if (dispatchTransformedTouchEvent(ev, false, child, idBitsToAssign)) {
// Child wants to receive touch within its bounds.
mLastTouchDownTime = ev.getDownTime();
if (preorderedList != null) {
// childIndex points into presorted list, find original index
for (int j = 0; j < childrenCount; j++) {
if (children[childIndex] == mChildren[j]) {
mLastTouchDownIndex = j;
break;
}
}
} else {
mLastTouchDownIndex = childIndex;
}
mLastTouchDownX = ev.getX();
mLastTouchDownY = ev.getY();
newTouchTarget = addTouchTarget(child, idBitsToAssign);
alreadyDispatchedToNewTouchTarget = true;
break;
}
// The accessibility focus didn't handle the event, so clear
// the flag and do a normal dispatch to all children.
ev.setTargetAccessibilityFocus(false);
}
if (preorderedList != null) preorderedList.clear();
}
if (newTouchTarget == null && mFirstTouchTarget != null) {
// Did not find a child to receive the event.
// Assign the pointer to the least recently added target.
newTouchTarget = mFirstTouchTarget;
while (newTouchTarget.next != null) {
newTouchTarget = newTouchTarget.next;
}
newTouchTarget.pointerIdBits |= idBitsToAssign;
}
}
}
// Dispatch to touch targets.
if (mFirstTouchTarget == null) {
// No touch targets so treat this as an ordinary view.
handled = dispatchTransformedTouchEvent(ev, canceled, null,
TouchTarget.ALL_POINTER_IDS);
} else {
// Dispatch to touch targets, excluding the new touch target if we already
// dispatched to it. Cancel touch targets if necessary.
TouchTarget predecessor = null;
TouchTarget target = mFirstTouchTarget;
while (target != null) {
final TouchTarget next = target.next;
if (alreadyDispatchedToNewTouchTarget && target == newTouchTarget) {
handled = true;
} else {
final boolean cancelChild = resetCancelNextUpFlag(target.child)
|| intercepted;
if (dispatchTransformedTouchEvent(ev, cancelChild,
target.child, target.pointerIdBits)) {
handled = true;
}
if (cancelChild) {
if (predecessor == null) {
mFirstTouchTarget = next;
} else {
predecessor.next = next;
}
target.recycle();
target = next;
continue;
}
}
predecessor = target;
target = next;
}
}
// Update list of touch targets for pointer up or cancel, if needed.
if (canceled
|| actionMasked == MotionEvent.ACTION_UP
|| actionMasked == MotionEvent.ACTION_HOVER_MOVE) {
resetTouchState();
} else if (split && actionMasked == MotionEvent.ACTION_POINTER_UP) {
final int actionIndex = ev.getActionIndex();
final int idBitsToRemove = 1 << ev.getPointerId(actionIndex);
removePointersFromTouchTargets(idBitsToRemove);
}
}
if (!handled && mInputEventConsistencyVerifier != null) {
mInputEventConsistencyVerifier.onUnhandledEvent(ev, 1);
}
return handled;
}这里有个关键的变量mFirstTouchTarget,它的类型是TouchTarget。没有确定处理事件的控件之前,mFirstTouchTarget为空。那么就会调dispatchTransformedTouchEvent()方法来找消费该事件的控件层级。所以MotionEvent.ACTION_DOWN事件在没确定mFirstTouchTarget之前是一路传递下去的。if (actionMasked == MotionEvent.ACTION_DOWN
|| mFirstTouchTarget != null) {
final boolean disallowIntercept = (mGroupFlags & FLAG_DISALLOW_INTERCEPT) != 0;
if (!disallowIntercept) {
intercepted = onInterceptTouchEvent(ev);
ev.setAction(action); // restore action in case it was changed
} else {
intercepted = false;
}
} else {
// There are no touch targets and this action is not an initial down
// so this view group continues to intercept touches.
intercepted = true;
}那么如果是按下事件,或者mFirstTouchTarget已经确认,会先问onInterceptTouchEvent()方法,要不要拦截下来这个事件。当然子控件如果调用了public void requestDisallowInterceptTouchEvent(boolean disallowIntercept);这个方法,你就没法拦截了。毕竟要征求子控件的意见。子控件通过调用getParent().requestDisallowInterceptTouchEvent()方法来要求得到这个事件。我们看下ViewGroup的onInterceptTouchEvent()方法。public boolean onInterceptTouchEvent(MotionEvent ev) {
if (ev.isFromSource(InputDevice.SOURCE_MOUSE)
&& ev.getAction() == MotionEvent.ACTION_DOWN
&& ev.isButtonPressed(MotionEvent.BUTTON_PRIMARY)
&& isOnScrollbarThumb(ev.getX(), ev.getY())) {
return true;
}
return false;
}除了外接鼠标的左键被按下这种情况,默认都是不拦截。如果ViewGroup拦截了这个事件,会发生什么呢?首先会调它自己的dispatchTransformedTouchEvent()方法。if (mFirstTouchTarget == null) {
// No touch targets so treat this as an ordinary view.
handled = dispatchTransformedTouchEvent(ev, canceled, null,
TouchTarget.ALL_POINTER_IDS);
}注意child传入的null。这样就会直接调super.dispatchTouchEvent(event)方法,也就是View的,而View的dispatchTouchEvent()方法会直接调onTouchEvent()方法。因为ViewGroup本身继承自View,那么就直接会回调ViewGroup的onTouchEvent()方法了,这样你就只能在当前拦截事件的ViewGroup的onTouchEvent()方法return true来消费事件了。private boolean dispatchTransformedTouchEvent(MotionEvent event, boolean cancel,
View child, int desiredPointerIdBits) {
final boolean handled;
// Canceling motions is a special case. We don't need to perform any transformations
// or filtering. The important part is the action, not the contents.
final int oldAction = event.getAction();
if (cancel || oldAction == MotionEvent.ACTION_CANCEL) {
event.setAction(MotionEvent.ACTION_CANCEL);
if (child == null) {
handled = super.dispatchTouchEvent(event);
} else {
handled = child.dispatchTouchEvent(event);
}
event.setAction(oldAction);
return handled;
}
// Calculate the number of pointers to deliver.
final int oldPointerIdBits = event.getPointerIdBits();
final int newPointerIdBits = oldPointerIdBits & desiredPointerIdBits;
// If for some reason we ended up in an inconsistent state where it looks like we
// might produce a motion event with no pointers in it, then drop the event.
if (newPointerIdBits == 0) {
return false;
}
// If the number of pointers is the same and we don't need to perform any fancy
// irreversible transformations, then we can reuse the motion event for this
// dispatch as long as we are careful to revert any changes we make.
// Otherwise we need to make a copy.
final MotionEvent transformedEvent;
if (newPointerIdBits == oldPointerIdBits) {
if (child == null || child.hasIdentityMatrix()) {
if (child == null) {
handled = super.dispatchTouchEvent(event);
} else {
final float offsetX = mScrollX - child.mLeft;
final float offsetY = mScrollY - child.mTop;
event.offsetLocation(offsetX, offsetY);
handled = child.dispatchTouchEvent(event);
event.offsetLocation(-offsetX, -offsetY);
}
return handled;
}
transformedEvent = MotionEvent.obtain(event);
} else {
transformedEvent = event.split(newPointerIdBits);
}
// Perform any necessary transformations and dispatch.
if (child == null) {
handled = super.dispatchTouchEvent(transformedEvent);
} else {
final float offsetX = mScrollX - child.mLeft;
final float offsetY = mScrollY - child.mTop;
transformedEvent.offsetLocation(offsetX, offsetY);
if (! child.hasIdentityMatrix()) {
transformedEvent.transform(child.getInverseMatrix());
}
handled = child.dispatchTouchEvent(transformedEvent);
}
// Done.
transformedEvent.recycle();
return handled;
}dispatchTransformedTouchEvent()这个方法,主要用来将事件传递给子控件。如果有子控件,就分发给子控件的dispatchTouchEvent()方法,否则就调View的dispatchTouchEvent()方法,我们看一下View的dispatchTouchEvent()方法。public boolean dispatchTouchEvent(MotionEvent event) {
// If the event should be handled by accessibility focus first.
if (event.isTargetAccessibilityFocus()) {
// We don't have focus or no virtual descendant has it, do not handle the event.
if (!isAccessibilityFocusedViewOrHost()) {
return false;
}
// We have focus and got the event, then use normal event dispatch.
event.setTargetAccessibilityFocus(false);
}
boolean result = false;
if (mInputEventConsistencyVerifier != null) {
mInputEventConsistencyVerifier.onTouchEvent(event, 0);
}
final int actionMasked = event.getActionMasked();
if (actionMasked == MotionEvent.ACTION_DOWN) {
// Defensive cleanup for new gesture
stopNestedScroll();
}
if (onFilterTouchEventForSecurity(event)) {
if ((mViewFlags & ENABLED_MASK) == ENABLED && handleScrollBarDragging(event)) {
result = true;
}
//noinspection SimplifiableIfStatement
ListenerInfo li = mListenerInfo;
if (li != null && li.mOnTouchListener != null
&& (mViewFlags & ENABLED_MASK) == ENABLED
&& li.mOnTouchListener.onTouch(this, event)) {
result = true;
}
if (!result && onTouchEvent(event)) {
result = true;
}
}
if (!result && mInputEventConsistencyVerifier != null) {
mInputEventConsistencyVerifier.onUnhandledEvent(event, 0);
}
// Clean up after nested scrolls if this is the end of a gesture;
// also cancel it if we tried an ACTION_DOWN but we didn't want the rest
// of the gesture.
if (actionMasked == MotionEvent.ACTION_UP ||
actionMasked == MotionEvent.ACTION_CANCEL ||
(actionMasked == MotionEvent.ACTION_DOWN && !result)) {
stopNestedScroll();
}
return result;
}View的dispatchTouchEvent()方法第一次调用由于result为false就会进onTouchEvent()方法,可想而知,它一直找到最里面的那个View就直接调onTouchEvent()方法了,如果还没有找到,那么事件就流失掉了。如果找到了return true的onTouchEvent()方法,它自己的dispatchTouchEvent()方法也会return true赋值给handled变量。我们回到dispatchTransformedTouchEvent()方法这个位置看一下。if (child == null) {
handled = super.dispatchTouchEvent(event);
} else {
final float offsetX = mScrollX - child.mLeft;
final float offsetY = mScrollY - child.mTop;
event.offsetLocation(offsetX, offsetY);
handled = child.dispatchTouchEvent(event);
event.offsetLocation(-offsetX, -offsetY);
}
return handled;然后回到ViewGroup的dispatchTouchEvent()方法。if (dispatchTransformedTouchEvent(ev, false, child, idBitsToAssign)) {
// Child wants to receive touch within its bounds.
mLastTouchDownTime = ev.getDownTime();
if (preorderedList != null) {
// childIndex points into presorted list, find original index
for (int j = 0; j < childrenCount; j++) {
if (children[childIndex] == mChildren[j]) {
mLastTouchDownIndex = j;
break;
}
}
} else {
mLastTouchDownIndex = childIndex;
}
mLastTouchDownX = ev.getX();
mLastTouchDownY = ev.getY();
newTouchTarget = addTouchTarget(child, idBitsToAssign);
alreadyDispatchedToNewTouchTarget = true;
break;
}dispatchTransformedTouchEvent()返回true后,调用addTouchTarget()方法给mFirstTouchTarget赋值。那么我们再看下给mFirstTouchTarget赋值的方法。private TouchTarget addTouchTarget(@NonNull View child, int pointerIdBits) {
final TouchTarget target = TouchTarget.obtain(child, pointerIdBits);
target.next = mFirstTouchTarget;
mFirstTouchTarget = target;
return target;
}哪个控件消费了事件,也就是dispatchTouchEvent()方法返回了true时,就会给mFirstTouchTarget赋值,这样就确认了mFirstTouchTarget。// Dispatch to touch targets.
if (mFirstTouchTarget == null) {
// No touch targets so treat this as an ordinary view.
handled = dispatchTransformedTouchEvent(ev, canceled, null,
TouchTarget.ALL_POINTER_IDS);
} else {
// Dispatch to touch targets, excluding the new touch target if we already
// dispatched to it. Cancel touch targets if necessary.
TouchTarget predecessor = null;
TouchTarget target = mFirstTouchTarget;
while (target != null) {
final TouchTarget next = target.next;
if (alreadyDispatchedToNewTouchTarget && target == newTouchTarget) {
handled = true;
} else {
final boolean cancelChild = resetCancelNextUpFlag(target.child)
|| intercepted;
if (dispatchTransformedTouchEvent(ev, cancelChild,
target.child, target.pointerIdBits)) {
handled = true;
}
if (cancelChild) {
if (predecessor == null) {
mFirstTouchTarget = next;
} else {
predecessor.next = next;
}
target.recycle();
target = next;
continue;
}
}
predecessor = target;
target = next;
}
}注意看,当确定了mFirstTouchTarget后,再次调dispatchTransformedTouchEvent()方法,就传入了child了。也就是说,事件就一路直接到了mFirstTouchTarget的child指定的控件了。然后后面的MotionEvent.ACTION_MOVE和MotionEvent.ACTION_UP事件都会给到这个控件。总结我们开发自定义View的时候,通常是重写onTouchEvent()方法并return true来确定哪一层来消费这个事件。传递过程中,都能收到MotionEvent.ACTION_DOWN事件,而只有确认mFirstTouchTarget后的那个控件,才能收到全部的事件。
chole
设计模式 - 命令模式(Command)
中文名:命令模式英文名:Command类型:行为型模式班主任评语:命令模式和策略模式是一对孪生兄弟。命令模式将某一种操作封装到具体的Command类中,它比较注重的是执行的过程。通过传入不同的参数来选择不同的命令执行。命令的执行是可以撤销的,在最终执行之前撤销都是有效的。
chole
Android项目实战 —— 手把手教你实现一款本地音乐播放器Dora Music
今天带大家实现一款基于Dora SDK的Android本地音乐播放器app,本项目也作为Dora SDK的实践项目或使用教程。使用到开源库有[github.com/dora4/dora] 、[github.com/dora4/dcach…] 等。先声明一点,本项目主要作为框架的使用教程,界面风格不喜勿喷。效果演示实现功能基本播放功能,包括播放、暂停、缓冲、后台播放等播放模式切换均衡器和重低音增强耳机拔出暂停音频焦点处理,和其他音乐播放器互斥摇一摇切换歌曲更换皮肤知识产权框架搭建我们要开发一款Android App,首先要搭建基础框架,比如使用MVP还是MVVM架构?使用什么网络库?使用什么ORM库?很显然,作为Dora SDK的使用教程,肯定是要依赖Dora SDK的。 // Dora全家桶
implementation("com.github.dora4:dcache-android:1.7.9")
implementation("com.github.dora4:dora:1.1.9")
implementation("com.github.dora4:dora-arouter-support:1.1")
implementation("com.github.dora4:dora-apollo-support:1.1")
implementation("com.github.dora4:dora-pgyer-support:1.0")
// implementation 'com.github.dora4:dora-eventbus-support:1.1'
implementation("com.github.dora4:dview-toggle-button:1.0")
implementation("com.github.dora4:dview-alert-dialog:1.0")
implementation("com.github.dora4:dview-loading-dialog:1.2")
implementation("com.github.dora4:dview-colors:1.0")
implementation("com.github.dora4:dview-skins:1.4")
implementation("com.github.dora4:dview-bottom-dialog:1.1")
// implementation 'com.github.dora4:dview-avatar:1.4'
implementation("com.github.dora4:dview-titlebar:1.9")列表功能使用BRVAHimplementation("io.github.cymchad:BaseRecyclerViewAdapterHelper:3.0.6")运行时权限申请使用XXPermissionsimplementation("com.github.getActivity:XXPermissions:18.2")图片加载使用Glideimplementation("com.github.bumptech.glide:glide:4.11.0")主要依赖的就是这些库。应用入口MusicApp类编写package site.doramusic.app
import dora.BaseApplication
import dora.db.Orm
import dora.db.OrmConfig
import dora.http.log.FormatLogInterceptor
import dora.http.retrofit.RetrofitManager
import site.doramusic.app.base.conf.AppConfig
import site.doramusic.app.db.Album
import site.doramusic.app.db.Artist
import site.doramusic.app.db.Folder
import site.doramusic.app.db.Music
import site.doramusic.app.http.service.CommonService
import site.doramusic.app.http.service.MusicService
import site.doramusic.app.http.service.UserService
import site.doramusic.app.media.MediaManager
class MusicApp : BaseApplication(), AppConfig {
/**
* 全局的音乐播放控制管理器。
*/
var mediaManager: MediaManager? = null
private set
companion object {
/**
* 全局Application单例。
*/
var instance: MusicApp? = null
private set
}
override fun onCreate() {
super.onCreate()
instance = this
init()
}
private fun init() {
initHttp() // 初始化网络框架
initDb() // 初始化SQLite数据库的表
initMedia() // 初始化媒体管理器
}
private fun initMedia() {
mediaManager = MediaManager(this)
}
private fun initHttp() {
RetrofitManager.initConfig {
okhttp {
interceptors().add(FormatLogInterceptor())
build()
}
mappingBaseUrl(MusicService::class.java, AppConfig.URL_APP_SERVER)
mappingBaseUrl(UserService::class.java, AppConfig.URL_APP_SERVER)
mappingBaseUrl(CommonService::class.java, AppConfig.URL_CHAT_SERVER)
}
}
private fun initDb() {
Orm.init(this, OrmConfig.Builder()
.database(AppConfig.DB_NAME)
.version(AppConfig.DB_VERSION)
.tables(Music::class.java, Artist::class.java,
Album::class.java, Folder::class.java)
.build())
}
}网络和ORM库都是来自于dcache-android库。首先初始化4张表,music、artist、album、folder,用来保存一些音乐信息。初始化网络库的时候添加一个FormatLogInterceptor日志拦截器,方便格式化输出网络请求日志。在Application中保存一个MediaManager单例,用来全局控制音乐的播放、暂停等。MediaManager与整体媒体框架我们使用MediaManager来统一管理媒体。由于要支持app后台运行时也能继续播放,所以我们考虑使用Service,而我们这不是一个简简单单的服务,而是要实时控制和反馈数据的。对于这样的一种场景,我们考虑将服务运行在单独的进程,并使用AIDL在主进程进行跨进程调用。
/**
* 通过它调用AIDL远程服务接口。
*/
class MediaManager(internal val context: Context) : IMediaService.Stub(), AppConfig {
private var mediaService: IMediaService? = null
private val serviceConnection: ServiceConnection
private var onCompletionListener: MusicControl.OnConnectCompletionListener? = null
init {
this.serviceConnection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName, service: IBinder) {
mediaService = asInterface(service)
if (mediaService != null) {
//音频服务启动的标志
LogUtils.i("MediaManager:connected")
onCompletionListener!!.onConnectCompletion(mediaService)
}
}
override fun onServiceDisconnected(name: ComponentName) {
//音频服务断开的标志
LogUtils.i("MediaManager:disconnected")
}
}
}
fun setOnCompletionListener(l: MusicControl.OnConnectCompletionListener) {
onCompletionListener = l
}
fun connectService() {
val intent = Intent(AppConfig.MEDIA_SERVICE)
intent.setClass(context, MediaService::class.java)
context.bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE)
}
fun disconnectService() {
context.unbindService(serviceConnection)
context.stopService(Intent(AppConfig.MEDIA_SERVICE))
}
override fun play(pos: Int): Boolean {
try {
return mediaService?.play(pos) ?: false
} catch (e: RemoteException) {
e.printStackTrace()
}
return false
}
override fun playById(id: Int): Boolean {
try {
return mediaService?.playById(id) ?: false
} catch (e: RemoteException) {
e.printStackTrace()
}
return false
}
override fun playByPath(path: String) {
try {
mediaService?.playByPath(path)
} catch (e: RemoteException) {
e.printStackTrace()
}
}
override fun playByUrl(music: Music, url: String) {
try {
mediaService?.playByUrl(music, url)
} catch (e: RemoteException) {
e.printStackTrace()
}
}
override fun replay(): Boolean {
try {
return mediaService?.replay() ?: false
} catch (e: RemoteException) {
e.printStackTrace()
}
return false
}
override fun pause(): Boolean {
try {
return mediaService?.pause() ?: false
} catch (e: RemoteException) {
e.printStackTrace()
}
return false
}
override fun prev(): Boolean {
try {
return mediaService?.prev() ?: false
} catch (e: RemoteException) {
e.printStackTrace()
}
return false
}
override fun next(): Boolean {
try {
return mediaService?.next() ?: false
} catch (e: RemoteException) {
e.printStackTrace()
}
return false
}
override fun stop() {
try {
mediaService?.stop() ?: false
} catch (e: RemoteException) {
e.printStackTrace()
}
}
override fun duration(): Int {
try {
return mediaService?.duration() ?: 0
} catch (e: RemoteException) {
e.printStackTrace()
}
return 0
}
override fun setCurMusic(music: Music) {
try {
mediaService?.setCurMusic(music) ?: false
} catch (e: RemoteException) {
e.printStackTrace()
}
}
override fun position(): Int {
try {
return mediaService?.position() ?: 0
} catch (e: RemoteException) {
e.printStackTrace()
}
return 0
}
override fun pendingProgress(): Int {
try {
return mediaService?.pendingProgress() ?: 0
} catch (e: RemoteException) {
e.printStackTrace()
}
return 0
}
override fun seekTo(progress: Int): Boolean {
try {
return mediaService?.seekTo(progress) ?: false
} catch (e: RemoteException) {
e.printStackTrace()
}
return false
}
override fun refreshPlaylist(playlist: MutableList<Music>?) {
try {
mediaService?.refreshPlaylist(playlist)
} catch (e: RemoteException) {
e.printStackTrace()
}
}
override fun setBassBoost(strength: Int) {
try {
mediaService?.setBassBoost(strength)
} catch (e: RemoteException) {
e.printStackTrace()
}
}
override fun setEqualizer(bandLevels: IntArray) {
try {
mediaService?.setEqualizer(bandLevels)
} catch (e: RemoteException) {
e.printStackTrace()
}
}
override fun getEqualizerFreq(): IntArray? {
try {
return mediaService?.equalizerFreq
} catch (e: RemoteException) {
e.printStackTrace()
}
return null
}
override fun getPlayState(): Int {
try {
return mediaService?.playState ?: 0
} catch (e: RemoteException) {
e.printStackTrace()
}
return 0
}
override fun getPlayMode(): Int {
try {
return mediaService?.playMode ?: 0
} catch (e: RemoteException) {
e.printStackTrace()
}
return 0
}
override fun setPlayMode(mode: Int) {
try {
mediaService?.playMode = mode
} catch (e: RemoteException) {
e.printStackTrace()
}
}
override fun getCurMusicId(): Int {
try {
return mediaService?.curMusicId ?: -1
} catch (e: Exception) {
e.printStackTrace()
}
return -1
}
override fun loadCurMusic(music: Music): Boolean {
try {
return mediaService?.loadCurMusic(music) ?: false
} catch (e: Exception) {
e.printStackTrace()
}
return false
}
override fun getCurMusic(): Music? {
try {
return mediaService?.curMusic
} catch (e: RemoteException) {
e.printStackTrace()
}
return null
}
override fun getPlaylist(): MutableList<Music>? {
try {
return mediaService?.playlist
} catch (e: Exception) {
e.printStackTrace()
}
return null
}
override fun updateNotification(bitmap: Bitmap, title: String, name: String) {
try {
mediaService?.updateNotification(bitmap, title, name)
} catch (e: RemoteException) {
e.printStackTrace()
}
}
override fun cancelNotification() {
try {
mediaService?.cancelNotification()
} catch (e: RemoteException) {
e.printStackTrace()
}
}
}我们将服务配置在单独的进程,需要在AndroidManifest.xml中给service标签指定android:process,也就是进程标识,这样就分出了区别于应用主进程的一个新的进程。<service
android:name=".media.MediaService"
android:process=":doramedia"
android:exported="true"
android:label="DoraMusic Media">
<intent-filter>
<action android:name="site.doramusic.app.service.ACTION_MEDIA_SERVICE" />
</intent-filter>
</service>与媒体信息相关表的定义Music歌曲表package site.doramusic.app.db;
import android.os.Bundle;
import android.os.Parcel;
import android.os.Parcelable;
import androidx.annotation.NonNull;
import dora.db.constraint.AssignType;
import dora.db.constraint.PrimaryKey;
import dora.db.migration.OrmMigration;
import dora.db.table.Column;
import dora.db.table.Ignore;
import dora.db.table.OrmTable;
import dora.db.table.PrimaryKeyEntry;
import dora.db.table.Table;
import site.doramusic.app.sort.Sort;
/**
* 歌曲表。
*/
@Table("music")
public class Music implements OrmTable, Parcelable, Sort {
public static final String COLUMN_ID = "_id";
public static final String COLUMN_SONG_ID = "song_id";
public static final String COLUMN_ALBUM_ID = "album_id";
public static final String COLUMN_DURATION = "duration";
public static final String COLUMN_MUSIC_NAME = "music_name";
public static final String COLUMN_ARTIST = "artist";
public static final String COLUMN_DATA = "data";
public static final String COLUMN_FOLDER = "folder";
public static final String COLUMN_MUSIC_NAME_KEY = "music_name_key";
public static final String COLUMN_ARTIST_KEY = "artist_key";
public static final String COLUMN_FAVORITE = "favorite";
public static final String COLUMN_LAST_PLAY_TIME = "last_play_time";
/**
* 数据库中的_id
*/
@Column(COLUMN_ID)
@PrimaryKey(AssignType.AUTO_INCREMENT)
public int id;
@Column(COLUMN_SONG_ID)
public int songId = -1;
@Column(COLUMN_ALBUM_ID)
public int albumId = -1;
@Column(COLUMN_DURATION)
public int duration;
@Column(COLUMN_MUSIC_NAME)
public String musicName;
@Column(COLUMN_ARTIST)
public String artist;
@Column(COLUMN_DATA)
public String data;
@Column(COLUMN_FOLDER)
public String folder;
@Column(COLUMN_MUSIC_NAME_KEY)
public String musicNameKey;
@Column(COLUMN_ARTIST_KEY)
public String artistKey;
@Column(COLUMN_FAVORITE)
public int favorite;
@Column(COLUMN_LAST_PLAY_TIME)
public long lastPlayTime;
@Ignore
private String sortLetter;
@Ignore
private Type type;
/**
* 封面路径,在线歌曲用。
*/
@Ignore
private String coverPath;
@NonNull
@Override
public OrmMigration[] getMigrations() {
return new OrmMigration[0];
}
public enum Type {
LOCAL, ONLINE
}
public Music() {
}
@Override
public int describeContents() {
return 0;
}
@Override
public void writeToParcel(Parcel dest, int flags) {
Bundle bundle = new Bundle();
bundle.putInt(COLUMN_ID, id);
bundle.putInt(COLUMN_SONG_ID, songId);
bundle.putInt(COLUMN_ALBUM_ID, albumId);
bundle.putInt(COLUMN_DURATION, duration);
bundle.putString(COLUMN_MUSIC_NAME, musicName);
bundle.putString(COLUMN_ARTIST, artist);
bundle.putString(COLUMN_DATA, data);
bundle.putString(COLUMN_FOLDER, folder);
bundle.putString(COLUMN_MUSIC_NAME_KEY, musicNameKey);
bundle.putString(COLUMN_ARTIST_KEY, artistKey);
bundle.putInt(COLUMN_FAVORITE, favorite);
bundle.putLong(COLUMN_LAST_PLAY_TIME, lastPlayTime);
dest.writeBundle(bundle);
}
public static final Creator<Music> CREATOR = new Creator<Music>() {
@Override
public Music createFromParcel(Parcel source) {
Music music = new Music();
Bundle bundle = source.readBundle(getClass().getClassLoader());
music.id = bundle.getInt(COLUMN_ID);
music.songId = bundle.getInt(COLUMN_SONG_ID);
music.albumId = bundle.getInt(COLUMN_ALBUM_ID);
music.duration = bundle.getInt(COLUMN_DURATION);
music.musicName = bundle.getString(COLUMN_MUSIC_NAME);
music.artist = bundle.getString(COLUMN_ARTIST);
music.data = bundle.getString(COLUMN_DATA);
music.folder = bundle.getString(COLUMN_FOLDER);
music.musicNameKey = bundle.getString(COLUMN_MUSIC_NAME_KEY);
music.artistKey = bundle.getString(COLUMN_ARTIST_KEY);
music.favorite = bundle.getInt(COLUMN_FAVORITE);
music.lastPlayTime = bundle.getLong(COLUMN_LAST_PLAY_TIME);
return music;
}
@Override
public Music[] newArray(int size) {
return new Music[size];
}
};
@NonNull
@Override
public String toString() {
return "DoraMusic{" +
"id=" + id +
", songId=" + songId +
", albumId=" + albumId +
", duration=" + duration +
", musicName='" + musicName + ''' +
", artist='" + artist + ''' +
", data='" + data + ''' +
", folder='" + folder + ''' +
", musicNameKey='" + musicNameKey + ''' +
", artistKey='" + artistKey + ''' +
", favorite=" + favorite +
", lastPlayTime=" + lastPlayTime +
'}';
}
@NonNull
@Override
public PrimaryKeyEntry getPrimaryKey() {
return new PrimaryKeyEntry(COLUMN_ID, id);
}
@Override
public boolean isUpgradeRecreated() {
return false;
}
@Override
public String getSortLetter() {
return sortLetter;
}
@Override
public void setSortLetter(String sortLetter) {
this.sortLetter = sortLetter;
}
public void setType(Type type) {
this.type = type;
}
public Type getType() {
return type;
}
public void setCoverPath(String coverPath) {
this.coverPath = coverPath;
}
public String getCoverPath() {
return coverPath;
}
@Override
public int compareTo(Sort sort) {
return getSortLetter().compareTo(sort.getSortLetter());
}
}Artist歌手表package site.doramusic.app.db;
import android.os.Bundle;
import android.os.Parcel;
import android.os.Parcelable;
import androidx.annotation.NonNull;
import dora.db.constraint.AssignType;
import dora.db.constraint.PrimaryKey;
import dora.db.migration.OrmMigration;
import dora.db.table.Column;
import dora.db.table.Ignore;
import dora.db.table.OrmTable;
import dora.db.table.PrimaryKeyEntry;
import dora.db.table.Table;
import site.doramusic.app.sort.Sort;
/**
* 歌手表。
*/
@Table("artist")
public class Artist implements OrmTable, Parcelable, Sort {
public static final String COLUMN_ID = "_id";
public static final String COLUMN_ARTIST_NAME = "artist_name";
public static final String COLUMN_NUMBER_OF_TRACKS = "number_of_tracks";
@Ignore
private String sortLetter;
@Column(COLUMN_ID)
@PrimaryKey(AssignType.AUTO_INCREMENT)
public int id;
@Column(COLUMN_ARTIST_NAME)
public String name;
/**
* 曲目数。
*/
@Column(COLUMN_NUMBER_OF_TRACKS)
public int number_of_tracks;
@Override
public int describeContents() {
return 0;
}
public Artist() {
}
@Override
public void writeToParcel(Parcel dest, int flags) {
Bundle bundle = new Bundle();
bundle.putInt(COLUMN_ID, id);
bundle.putString(COLUMN_ARTIST_NAME, name);
bundle.putInt(COLUMN_NUMBER_OF_TRACKS, number_of_tracks);
dest.writeBundle(bundle);
}
public static final Creator<Artist> CREATOR = new Creator<Artist>() {
@Override
public Artist createFromParcel(Parcel source) {
Bundle bundle = source.readBundle(getClass().getClassLoader());
Artist artist = new Artist();
artist.id = bundle.getInt(COLUMN_ID);
artist.name = bundle.getString(COLUMN_ARTIST_NAME);
artist.number_of_tracks = bundle.getInt(COLUMN_NUMBER_OF_TRACKS);
return artist;
}
@Override
public Artist[] newArray(int size) {
return new Artist[size];
}
};
@NonNull
@Override
public PrimaryKeyEntry getPrimaryKey() {
return new PrimaryKeyEntry(COLUMN_ID, id);
}
@Override
public boolean isUpgradeRecreated() {
return false;
}
@Override
public String getSortLetter() {
return sortLetter;
}
@Override
public void setSortLetter(String sortLetter) {
this.sortLetter = sortLetter;
}
public int compareTo(Sort sort) {
return getSortLetter().compareTo(sort.getSortLetter());
}
@NonNull
@Override
public OrmMigration[] getMigrations() {
return new OrmMigration[0];
}
}Album专辑表package site.doramusic.app.db;
import android.os.Bundle;
import android.os.Parcel;
import android.os.Parcelable;
import androidx.annotation.NonNull;
import dora.db.constraint.AssignType;
import dora.db.constraint.PrimaryKey;
import dora.db.migration.OrmMigration;
import dora.db.table.Column;
import dora.db.table.Ignore;
import dora.db.table.OrmTable;
import dora.db.table.PrimaryKeyEntry;
import dora.db.table.Table;
import site.doramusic.app.sort.Sort;
/**
* 专辑表。
*/
@Table("album")
public class Album implements OrmTable, Parcelable, Sort {
public static final String COLUMN_ID = "_id";
public static final String COLUMN_ALBUM_NAME = "album_name";
public static final String COLUMN_ALBUM_ID = "album_id";
public static final String COLUMN_NUMBER_OF_SONGS = "number_of_songs";
public static final String COLUMN_ALBUM_COVER_PATH = "album_cover_path";
@Column(COLUMN_ID)
@PrimaryKey(AssignType.AUTO_INCREMENT)
public int id;
@Ignore
private String sortLetter;
//专辑名称
@Column(COLUMN_ALBUM_NAME)
public String album_name;
//专辑在数据库中的id
@Column(COLUMN_ALBUM_ID)
public int album_id = -1;
//专辑的歌曲数目
@Column(COLUMN_NUMBER_OF_SONGS)
public int number_of_songs = 0;
//专辑封面图片路径
@Column(COLUMN_ALBUM_COVER_PATH)
public String album_cover_path;
@Override
public int describeContents() {
return 0;
}
public Album() {
}
@Override
public void writeToParcel(Parcel dest, int flags) {
Bundle bundle = new Bundle();
bundle.putInt(COLUMN_ID, id);
bundle.putString(COLUMN_ALBUM_NAME, album_name);
bundle.putString(COLUMN_ALBUM_COVER_PATH, album_cover_path);
bundle.putInt(COLUMN_NUMBER_OF_SONGS, number_of_songs);
bundle.putInt(COLUMN_ALBUM_ID, album_id);
dest.writeBundle(bundle);
}
public static final Creator<Album> CREATOR = new Creator<Album>() {
@Override
public Album createFromParcel(Parcel source) {
Album album = new Album();
Bundle bundle = source.readBundle(getClass().getClassLoader());
album.id = bundle.getInt(COLUMN_ID);
album.album_name = bundle.getString(COLUMN_ALBUM_NAME);
album.album_cover_path = bundle.getString(COLUMN_ALBUM_COVER_PATH);
album.number_of_songs = bundle.getInt(COLUMN_NUMBER_OF_SONGS);
album.album_id = bundle.getInt(COLUMN_ALBUM_ID);
return album;
}
@Override
public Album[] newArray(int size) {
return new Album[size];
}
};
@NonNull
@Override
public PrimaryKeyEntry getPrimaryKey() {
return new PrimaryKeyEntry(COLUMN_ID, id);
}
@Override
public boolean isUpgradeRecreated() {
return false;
}
@Override
public String getSortLetter() {
return sortLetter;
}
@Override
public void setSortLetter(String sortLetter) {
this.sortLetter = sortLetter;
}
@Override
public int compareTo(Sort sort) {
return getSortLetter().compareTo(sort.getSortLetter());
}
@NonNull
@Override
public OrmMigration[] getMigrations() {
return new OrmMigration[0];
}
}Folder文件夹表package site.doramusic.app.db;
import android.os.Bundle;
import android.os.Parcel;
import android.os.Parcelable;
import androidx.annotation.NonNull;
import dora.db.constraint.AssignType;
import dora.db.constraint.NotNull;
import dora.db.constraint.PrimaryKey;
import dora.db.constraint.Unique;
import dora.db.migration.OrmMigration;
import dora.db.table.Column;
import dora.db.table.Ignore;
import dora.db.table.OrmTable;
import dora.db.table.PrimaryKeyEntry;
import dora.db.table.Table;
import site.doramusic.app.sort.Sort;
/**
* 文件夹表。
*/
@Table("folder")
public class Folder implements OrmTable, Parcelable, Sort {
public static final String COLUMN_ID = "_id";
public static final String COLUMN_FOLDER_NAME = "folder_name";
public static final String COLUMN_FOLDER_PATH = "folder_path";
@Ignore
private String sortLetter;
@Column(COLUMN_ID)
@PrimaryKey(AssignType.AUTO_INCREMENT)
public int id;
@Column(COLUMN_FOLDER_NAME)
public String name;
@Unique
@NotNull
@Column(COLUMN_FOLDER_PATH)
public String path;
@Override
public int describeContents() {
return 0;
}
@Override
public void writeToParcel(Parcel dest, int flags) {
Bundle bundle = new Bundle();
bundle.putInt(COLUMN_ID, id);
bundle.putString(COLUMN_FOLDER_NAME, name);
bundle.putString(COLUMN_FOLDER_PATH, path);
dest.writeBundle(bundle);
}
public Folder() {
}
public static Creator<Folder> CREATOR = new Creator<Folder>() {
@Override
public Folder createFromParcel(Parcel source) {
Folder folder = new Folder();
Bundle bundle = source.readBundle(getClass().getClassLoader());
folder.id = bundle.getInt(COLUMN_ID);
folder.name = bundle.getString(COLUMN_FOLDER_NAME);
folder.path = bundle.getString(COLUMN_FOLDER_PATH);
return folder;
}
@Override
public Folder[] newArray(int size) {
return new Folder[size];
}
};
@NonNull
@Override
public PrimaryKeyEntry getPrimaryKey() {
return new PrimaryKeyEntry(COLUMN_ID, id);
}
@Override
public boolean isUpgradeRecreated() {
return false;
}
@Override
public String getSortLetter() {
return sortLetter;
}
@Override
public void setSortLetter(String sortLetter) {
this.sortLetter = sortLetter;
}
@Override
public int compareTo(Sort sort) {
return getSortLetter().compareTo(sort.getSortLetter());
}
@NonNull
@Override
public OrmMigration[] getMigrations() {
return new OrmMigration[0];
}
}这4张表的类主要演示dcache-android库的orm功能。我们可以看到@Table和@Column可以给表和列重命名,当然,不一定就会使用默认的表和列名规则。不是表字段的属性加上@Ignore。也可以通过@Unique配置唯一约束,通过@NotNull配置非空约束,使用@PrimaryKey配置主键约束。MusicScanner本地歌曲扫描package site.doramusic.app.media
import android.annotation.SuppressLint
import android.content.Context
import android.database.Cursor
import android.provider.MediaStore
import dora.db.Orm
import dora.db.Transaction
import dora.db.dao.DaoFactory
import dora.db.table.TableManager
import dora.util.PinyinUtils
import dora.util.TextUtils
import site.doramusic.app.base.conf.AppConfig
import site.doramusic.app.db.Album
import site.doramusic.app.db.Artist
import site.doramusic.app.db.Folder
import site.doramusic.app.db.Music
import site.doramusic.app.util.MusicUtils
import site.doramusic.app.util.PreferencesManager
import java.io.File
import java.util.*
import kotlin.collections.ArrayList
/**
* 媒体扫描器,用来扫描手机中的歌曲文件。
*/
@SuppressLint("Range")
object MusicScanner : AppConfig {
private val proj_music = arrayOf(
MediaStore.Audio.Media._ID, MediaStore.Audio.Media.TITLE,
MediaStore.Audio.Media.DATA, MediaStore.Audio.Media.ALBUM_ID,
MediaStore.Audio.Media.ARTIST, MediaStore.Audio.Media.ARTIST_ID,
MediaStore.Audio.Media.DURATION)
private val proj_album = arrayOf(MediaStore.Audio.Albums.ALBUM,
MediaStore.Audio.Albums.NUMBER_OF_SONGS, MediaStore.Audio.Albums._ID,
MediaStore.Audio.Albums.ALBUM_ART)
private val proj_artist = arrayOf(
MediaStore.Audio.Artists.ARTIST,
MediaStore.Audio.Artists.NUMBER_OF_TRACKS)
private val proj_folder = arrayOf(MediaStore.Files.FileColumns.DATA)
private val musicDao = DaoFactory.getDao(Music::class.java)
private val artistDao = DaoFactory.getDao(Artist::class.java)
private val albumDao = DaoFactory.getDao(Album::class.java)
private val folderDao = DaoFactory.getDao(Folder::class.java)
private fun recreateTables() {
TableManager.recreateTable(Music::class.java)
TableManager.recreateTable(Artist::class.java)
TableManager.recreateTable(Album::class.java)
TableManager.recreateTable(Folder::class.java)
}
@JvmStatic
fun scan(context: Context): List<Music> {
recreateTables()
var musics = arrayListOf<Music>()
Transaction.execute(Music::class.java) {
musics = queryMusic(context, AppConfig.ROUTE_START_FROM_LOCAL) as ArrayList<Music>
it.insert(musics)
}
if (musics.size > 0) {
// 歌曲都没有就没有必要查询歌曲信息了
Transaction.execute {
val artists = queryArtist(context)
artistDao.insert(artists)
val albums = queryAlbum(context)
albumDao.insert(albums)
val folders = queryFolder(context)
folderDao.insert(folders)
}
}
return musics
}
@JvmStatic
fun queryMusic(context: Context, from: Int): List<Music> {
return queryMusic(context, null, null, from)
}
@JvmStatic
fun queryMusic(context: Context,
selections: String?, selection: String?, from: Int): List<Music> {
val sp = PreferencesManager(context)
val uri = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
val cr = context.contentResolver
val select = StringBuffer(" 1=1 ")
// 查询语句:检索出.mp3为后缀名,时长大于1分钟,文件大小大于1MB的媒体文件
if (sp.getFilterSize()) {
select.append(" and ${MediaStore.Audio.Media.SIZE} > " +
"${AppConfig.SCANNER_FILTER_SIZE}")
}
if (sp.getFilterTime()) {
select.append(" and ${MediaStore.Audio.Media.DURATION} > " +
"${AppConfig.SCANNER_FILTER_DURATION}")
}
if (TextUtils.isNotEmpty(selections)) {
select.append(selections)
}
return when (from) {
AppConfig.ROUTE_START_FROM_LOCAL -> if (musicDao.count() > 0) {
musicDao.selectAll()
} else {
getMusicList(cr.query(uri, proj_music,
select.toString(), null,
MediaStore.Audio.Media.ARTIST_KEY))
}
AppConfig.ROUTE_START_FROM_ARTIST -> if (musicDao.count() > 0) {
queryMusic(selection,
AppConfig.ROUTE_START_FROM_ARTIST)
} else {
getMusicList(cr.query(uri, proj_music,
select.toString(), null,
MediaStore.Audio.Media.ARTIST_KEY))
}
AppConfig.ROUTE_START_FROM_ALBUM -> {
if (musicDao.count() > 0) {
return queryMusic(selection,
AppConfig.ROUTE_START_FROM_ALBUM)
}
if (musicDao.count() > 0) {
return queryMusic(selection, AppConfig.ROUTE_START_FROM_FOLDER)
}
if (musicDao.count() > 0) {
return queryMusic(selection, AppConfig.ROUTE_START_FROM_FAVORITE)
}
if (musicDao.count() > 0) {
queryMusic(selection, AppConfig.ROUTE_START_FROM_LATEST)
} else arrayListOf()
}
AppConfig.ROUTE_START_FROM_FOLDER -> {
if (musicDao.count() > 0) {
return queryMusic(selection, AppConfig.ROUTE_START_FROM_FOLDER)
}
if (musicDao.count() > 0) {
return queryMusic(selection, AppConfig.ROUTE_START_FROM_FAVORITE)
}
if (musicDao.count() > 0) {
queryMusic(selection, AppConfig.ROUTE_START_FROM_LATEST)
} else arrayListOf()
}
AppConfig.ROUTE_START_FROM_FAVORITE -> {
if (musicDao.count() > 0) {
return queryMusic(selection, AppConfig.ROUTE_START_FROM_FAVORITE)
}
if (musicDao.count() > 0) {
queryMusic(selection, AppConfig.ROUTE_START_FROM_LATEST)
} else arrayListOf()
}
AppConfig.ROUTE_START_FROM_LATEST -> {
if (musicDao.count() > 0) {
queryMusic(selection, AppConfig.ROUTE_START_FROM_LATEST)
} else arrayListOf()
}
else -> arrayListOf()
}
}
@JvmStatic
fun queryMusic(selection: String?, type: Int): List<Music> {
val db = Orm.getDB()
var sql = ""
when (type) {
AppConfig.ROUTE_START_FROM_ARTIST -> {
sql = "select * from music where ${Music.COLUMN_ARTIST} = ?"
}
AppConfig.ROUTE_START_FROM_ALBUM -> {
sql = "select * from music where ${Music.COLUMN_ALBUM_ID} = ?"
}
AppConfig.ROUTE_START_FROM_FOLDER -> {
sql = "select * from music where ${Music.COLUMN_FOLDER} = ?"
}
AppConfig.ROUTE_START_FROM_FAVORITE -> {
sql = "select * from music where ${Music.COLUMN_FAVORITE} = ?"
// } else if (type == ROUTE_START_FROM_DOWNLOAD) {
// sql = "select * from music where download = ?";
}
AppConfig.ROUTE_START_FROM_LATEST -> {
sql = "select * from music where ${Music.COLUMN_LAST_PLAY_TIME} > ? order by " +
"${Music.COLUMN_LAST_PLAY_TIME} desc limit 100"
}
}
return parseCursor(db.rawQuery(sql, arrayOf(selection)))
}
private fun parseCursor(cursor: Cursor): List<Music> {
val list: MutableList<Music> = ArrayList()
while (cursor.moveToNext()) {
val music = Music()
music.id = cursor.getInt(cursor.getColumnIndex(Music.COLUMN_ID))
music.songId = cursor.getInt(cursor.getColumnIndex(Music.COLUMN_SONG_ID))
music.albumId = cursor.getInt(cursor.getColumnIndex(Music.COLUMN_ALBUM_ID))
music.duration = cursor.getInt(cursor.getColumnIndex(Music.COLUMN_DURATION))
music.musicName = cursor.getString(cursor.getColumnIndex(
Music.COLUMN_MUSIC_NAME))
music.artist = cursor.getString(cursor.getColumnIndex(Music.COLUMN_ARTIST))
music.data = cursor.getString(cursor.getColumnIndex(Music.COLUMN_DATA))
music.folder = cursor.getString(cursor.getColumnIndex(Music.COLUMN_FOLDER))
music.musicNameKey = cursor.getString(cursor.getColumnIndex(
Music.COLUMN_MUSIC_NAME_KEY))
music.artistKey = cursor.getString(cursor.getColumnIndex(
Music.COLUMN_ARTIST_KEY))
music.favorite = cursor.getInt(cursor.getColumnIndex(Music.COLUMN_FAVORITE))
music.lastPlayTime = cursor.getLong(cursor.getColumnIndex(
Music.COLUMN_LAST_PLAY_TIME))
list.add(music)
}
cursor.close()
return list
}
/**
* 获取包含音频文件的文件夹信息。
*
* @param context
* @return
*/
@JvmStatic
fun queryFolder(context: Context): List<Folder> {
val sp = PreferencesManager(context)
val uri = MediaStore.Files.getContentUri("external")
val cr = context.contentResolver
val selection = StringBuilder(MediaStore.Files.FileColumns.MEDIA_TYPE
+ " = " + MediaStore.Files.FileColumns.MEDIA_TYPE_AUDIO + " and " + "("
+ MediaStore.Files.FileColumns.DATA + " like '%.mp3' or "
+ MediaStore.Files.FileColumns.DATA + " like '%.flac' or "
+ MediaStore.Files.FileColumns.DATA + " like '%.wav' or "
+ MediaStore.Files.FileColumns.DATA + " like '%.ape' or "
+ MediaStore.Files.FileColumns.DATA + " like '%.m4a' or "
+ MediaStore.Files.FileColumns.DATA + " like '%.aac')")
// 查询语句:检索出.mp3为后缀名,时长大于1分钟,文件大小大于1MB的媒体文件
if (sp.getFilterSize()) {
selection.append(" and " + MediaStore.Audio.Media.SIZE + " > " + AppConfig.SCANNER_FILTER_SIZE)
}
if (sp.getFilterTime()) {
selection.append(" and " + MediaStore.Audio.Media.DURATION + " > " + AppConfig.SCANNER_FILTER_DURATION)
}
// selection.append(") group by ( " + MediaStore.Files.FileColumns.PARENT)
return if (folderDao.count() > 0) {
folderDao.selectAll()
} else {
getFolderList(cr.query(uri, proj_folder, selection.toString(), null, null))
}
}
/**
* 获取歌手信息。
*
* @param context
* @return
*/
@JvmStatic
fun queryArtist(context: Context): List<Artist> {
val uri = MediaStore.Audio.Artists.EXTERNAL_CONTENT_URI
val cr = context.contentResolver
return if (artistDao.count() > 0) {
artistDao.selectAll()
} else {
getArtistList(cr.query(uri, proj_artist,
null, null, MediaStore.Audio.Artists.NUMBER_OF_TRACKS
+ " desc"))
}
}
/**
* 获取专辑信息。
*
* @param context
* @return
*/
@JvmStatic
fun queryAlbum(context: Context): List<Album> {
val sp = PreferencesManager(context)
val uri = MediaStore.Audio.Albums.EXTERNAL_CONTENT_URI
val cr = context.contentResolver
val where = StringBuilder(MediaStore.Audio.Albums._ID
+ " in (select distinct " + MediaStore.Audio.Media.ALBUM_ID
+ " from audio_meta where (1=1 ")
if (sp.getFilterSize()) {
where.append(" and " + MediaStore.Audio.Media.SIZE + " > " + AppConfig.SCANNER_FILTER_SIZE)
}
if (sp.getFilterTime()) {
where.append(" and " + MediaStore.Audio.Media.DURATION + " > " + AppConfig.SCANNER_FILTER_DURATION)
}
where.append("))")
return if (albumDao.count() > 0) {
albumDao.selectAll()
} else { // Media.ALBUM_KEY 按专辑名称排序
// FIXME: Android11的Invalid token select问题
getAlbumList(cr.query(uri, proj_album,
null, null, MediaStore.Audio.Media.ALBUM_KEY))
}
}
private fun getMusicList(cursor: Cursor?): List<Music> {
val list: MutableList<Music> = ArrayList()
if (cursor == null) {
return list
}
while (cursor.moveToNext()) {
val music = Music()
val filePath = cursor.getString(cursor
.getColumnIndex(MediaStore.Audio.Media.DATA))
music.songId = cursor.getInt(cursor
.getColumnIndex(MediaStore.Audio.Media._ID))
music.albumId = cursor.getInt(cursor
.getColumnIndex(MediaStore.Audio.Media.ALBUM_ID))
val duration = cursor.getInt(cursor
.getColumnIndex(MediaStore.Audio.Media.DURATION))
if (duration > 0) {
music.duration = duration
} else {
try {
music.duration = MusicUtils.getDuration(filePath)
} catch (e: RuntimeException) {
continue
}
}
music.musicName = cursor.getString(cursor
.getColumnIndex(MediaStore.Audio.Media.TITLE))
music.artist = cursor.getString(cursor
.getColumnIndex(MediaStore.Audio.Media.ARTIST))
music.data = filePath
val folderPath = filePath.substring(0,
filePath.lastIndexOf(File.separator))
music.folder = folderPath
music.musicNameKey = PinyinUtils.getPinyinFromSentence(music.musicName)
music.artistKey = PinyinUtils.getPinyinFromSentence(music.artist)
list.add(music)
}
cursor.close()
return list
}
private fun getAlbumList(cursor: Cursor?): List<Album> {
val list: MutableList<Album> = ArrayList()
if (cursor == null) {
return list
}
while (cursor.moveToNext()) {
val album = Album()
album.album_name = cursor.getString(
cursor.getColumnIndex(MediaStore.Audio.Albums.ALBUM))
album.album_id = cursor.getInt(cursor.getColumnIndex(MediaStore.Audio.Albums._ID))
album.number_of_songs = cursor.getInt(cursor
.getColumnIndex(MediaStore.Audio.Albums.NUMBER_OF_SONGS))
album.album_cover_path = cursor.getString(cursor
.getColumnIndex(MediaStore.Audio.Albums.ALBUM_ART))
list.add(album)
}
cursor.close()
return list
}
private fun getArtistList(cursor: Cursor?): List<Artist> {
val list: MutableList<Artist> = ArrayList()
if (cursor == null) {
return list
}
while (cursor.moveToNext()) {
val artist = Artist()
artist.name = cursor.getString(cursor
.getColumnIndex(MediaStore.Audio.Artists.ARTIST))
artist.number_of_tracks = cursor.getInt(cursor
.getColumnIndex(MediaStore.Audio.Artists.NUMBER_OF_TRACKS))
list.add(artist)
}
cursor.close()
return list
}
private fun getFolderList(cursor: Cursor?): List<Folder> {
val list: MutableList<Folder> = ArrayList()
if (cursor == null) {
return list
}
while (cursor.moveToNext()) {
val folder = Folder()
val filePath = cursor.getString(
cursor.getColumnIndex(MediaStore.Files.FileColumns.DATA))
folder.path = filePath.substring(0,
filePath.lastIndexOf(File.separator))
folder.name = folder.path.substring(folder.path
.lastIndexOf(File.separator) + 1)
list.add(folder)
}
cursor.close()
return list
}
}我们可以看到,使用DaoFactory.getDao拿到dao对象就可以以ORM的方式操作数据库中的表了。MusicControl媒体控制的具体实现package site.doramusic.app.media;
import android.content.Context;
import android.content.Intent;
import android.media.AudioAttributes;
import android.media.AudioManager;
import android.media.MediaPlayer;
import android.media.audiofx.BassBoost;
import android.media.audiofx.Equalizer;
import android.os.Build;
import android.os.PowerManager;
import com.lsxiao.apollo.core.Apollo;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Random;
import dora.db.builder.WhereBuilder;
import dora.db.dao.DaoFactory;
import dora.db.dao.OrmDao;
import dora.util.LogUtils;
import dora.util.TextUtils;
import dora.util.ToastUtils;
import site.doramusic.app.base.conf.ApolloEvent;
import site.doramusic.app.base.conf.AppConfig;
import site.doramusic.app.db.Music;
import site.doramusic.app.util.PreferencesManager;
/**
* 音乐播放流程控制。
*/
public class MusicControl implements MediaPlayer.OnCompletionListener, AppConfig {
private final Random mRandom;
private int mPlayMode;
private final MediaPlayerProxy mMediaPlayer;
private final List<Music> mPlaylist;
private final Context mContext;
private int mCurPlayIndex;
private int mPlayState;
private int mPendingProgress;
private final int mCurMusicId;
private Music mCurMusic;
private boolean mPlaying;
private final AudioManager mAudioManager;
private final OrmDao<Music> mDao;
private final PreferencesManager mPrefsManager;
public MusicControl(Context context) {
this.mContext = context;
this.mAudioManager = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE);
this.mPrefsManager = new PreferencesManager(context);
this.mPlayMode = MPM_LIST_LOOP_PLAY; //默认列表循环
this.mPlayState = MPS_NO_FILE; //默认没有音频文件播放
this.mCurPlayIndex = -1;
this.mCurMusicId = -1;
this.mPlaylist = new ArrayList<>();
this.mDao = DaoFactory.INSTANCE.getDao(Music.class);
this.mMediaPlayer = new MediaPlayerProxy();
this.mMediaPlayer.setNeedCacheAudio(true);
this.mMediaPlayer.setWakeMode(context, PowerManager.PARTIAL_WAKE_LOCK); //播放音频的时候加锁,防止CPU休眠
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
AudioAttributes attrs = new AudioAttributes.Builder()
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
.build();
this.mMediaPlayer.setAudioAttributes(attrs);
} else {
this.mMediaPlayer.setAudioStreamType(AudioManager.STREAM_MUSIC);
}
this.mMediaPlayer.setOnCompletionListener(this);
this.mRandom = new Random();
this.mRandom.setSeed(System.currentTimeMillis());
}
/**
* 设置重低音参数。
*
* @param strength
*/
public void setBassBoost(int strength) {
int audioSessionId = mMediaPlayer.getAudioSessionId();
BassBoost bassBoost = new BassBoost(0, audioSessionId);
BassBoost.Settings settings = new BassBoost.Settings();
settings.strength = (short) strength;
bassBoost.setProperties(settings);
bassBoost.setEnabled(true);
bassBoost.setParameterListener(new BassBoost.OnParameterChangeListener() {
@Override
public void onParameterChange(BassBoost effect, int status, int param, short value) {
LogUtils.i("重低音参数改变");
}
});
}
/**
* 获取均衡器支持的频率。
*
* @return
*/
public int[] getEqualizerFreq() {
int audioSessionId = mMediaPlayer.getAudioSessionId();
Equalizer equalizer = new Equalizer(0, audioSessionId);
short bands = equalizer.getNumberOfBands();
int[] freqs = new int[bands];
for (short i = 0; i < bands; i++) {
int centerFreq = equalizer.getCenterFreq(i) / 1000;
freqs[i] = centerFreq;
}
return freqs;
}
/**
* 设置均衡器。
*
* @param bandLevels
*/
public void setEqualizer(int[] bandLevels) {
int audioSessionId = mMediaPlayer.getAudioSessionId();
Equalizer equalizer = new Equalizer(1, audioSessionId);
// 获取均衡控制器支持最小值和最大值
short minEQLevel = equalizer.getBandLevelRange()[0];//第一个下标为最低的限度范围
short maxEQLevel = equalizer.getBandLevelRange()[1]; // 第二个下标为最高的限度范围
int distanceEQLevel = maxEQLevel - minEQLevel;
int singleEQLevel = distanceEQLevel / 25;
for (short i = 0; i < bandLevels.length; i++) {
equalizer.setBandLevel(i, (short) (singleEQLevel * bandLevels[i]));
}
equalizer.setEnabled(true);
equalizer.setParameterListener(new Equalizer.OnParameterChangeListener() {
@Override
public void onParameterChange(Equalizer effect, int status, int param1, int param2, int value) {
LogUtils.i("均衡器参数改变:" + status + "," + param1 + "," + param2 + "," + value);
}
});
}
/**
* 保存收藏。
*
* @param music
*/
private void saveFavorite(Music music) {
music.favorite = 1;
mDao.update(WhereBuilder.Companion.create().addWhereEqualTo("_id", music.id), music);
}
/**
* 保存最近播放。
*
* @param music
*/
private void saveLatest(Music music) {
//更新本地缓存歌曲
music.lastPlayTime = System.currentTimeMillis();
mDao.update(WhereBuilder.Companion.create().addWhereEqualTo("_id", music.id), music);
}
/**
* 设置播放。
*
* @param playState
*/
public void setPlaying(int playState) {
switch (playState) {
case MPS_PLAYING:
mPlaying = true;
break;
default:
mPlaying = false;
}
}
/**
* 设置当前播放的歌曲。
*
* @param music
* @return
*/
public boolean loadCurMusic(Music music) {
if (prepare(seekPosById(mPlaylist, music.songId))) {
this.mCurMusic = music;
return true;
}
return false;
}
/**
* 修改当前播放歌曲的信息。
*
* @param music
* @return
*/
public void setCurMusic(Music music) {
this.mPlaylist.set(mCurPlayIndex, music);
this.mCurMusic = music;
}
/**
* 缓冲准备。
*
* @param pos
* @return
*/
public boolean prepare(int pos) {
mCurPlayIndex = pos;
mPendingProgress = 0;
mMediaPlayer.reset();
if (mPrefsManager.getBassBoost()) {
setBassBoost(1000);
} else {
setBassBoost(1);
}
if (!mPrefsManager.getEqualizerDecibels().equals("")) {
int[] equalizerFreq = getEqualizerFreq();
int[] decibels = new int[equalizerFreq.length];
String[] values = mPrefsManager.getEqualizerDecibels().split(",");
for (int i = 0; i < decibels.length; i++) {
decibels[i] = Integer.valueOf(values[i]);
}
setEqualizer(decibels);
}
String path = mPlaylist.get(pos).data;
if (TextUtils.isNotEmpty(path)) {
try {
mMediaPlayer.setDataSource(path);
mMediaPlayer.prepare();
mPlayState = MPS_PREPARE;
} catch (Exception e) {
mPlayState = MPS_INVALID;
if (pos < mPlaylist.size()) {
pos++;
playById(mPlaylist.get(pos).songId);
}
return false;
}
} else {
ToastUtils.showShort(mContext, "歌曲路径为空");
}
mCurMusic = mPlaylist.get(mCurPlayIndex);
sendMusicPlayBroadcast();
return true;
}
/**
* 根据歌曲的id来播放。
*
* @param id
* @return
*/
public boolean playById(int id) {
if (requestFocus()) {
int position = seekPosById(mPlaylist, id);
mCurPlayIndex = position;
if (mCurMusicId == id) {
if (!mMediaPlayer.isPlaying()) {
mMediaPlayer.start();
mPlayState = MPS_PLAYING;
sendMusicPlayBroadcast();
mCurMusic = mPlaylist.get(mCurPlayIndex);
saveLatest(mCurMusic);
} else {
pause();
}
return true;
}
if (!prepare(position)) {
return false;
}
return replay();
} else {
return false;
}
}
/**
* 根据URL播放歌曲。
*
* @param music
* @param url
*/
public void playByUrl(Music music, String url) {
if (requestFocus()) {
try {
mMediaPlayer.setAudioCachePath(music.data);
mMediaPlayer.setOnCachedProgressUpdateListener(new MediaPlayerProxy.OnCachedProgressUpdateListener() {
@Override
public void updateCachedProgress(int progress) {
mPendingProgress = progress;
}
});
String localProxyUrl = mMediaPlayer.getLocalURLAndSetRemoteSocketAddress(url);
mPlaylist.add(mCurPlayIndex, music); //插入到当前播放位置
mCurMusic = music;
mMediaPlayer.startProxy();
mMediaPlayer.reset();
mMediaPlayer.setDataSource(localProxyUrl);
mMediaPlayer.prepareAsync();
mMediaPlayer.start();
mPlayState = MPS_PLAYING;
sendMusicPlayBroadcast();
} catch (Exception e) {
e.printStackTrace();
}
}
}
/**
* 根据本地文件路径播放歌曲。
*
* @param path
*/
public void play(String path) {
if (requestFocus()) {
try {
mMediaPlayer.stop();
mMediaPlayer.reset();
mMediaPlayer.setDataSource(path);
mMediaPlayer.prepare();
mMediaPlayer.setOnPreparedListener(new MediaPlayer.OnPreparedListener() {
@Override
public void onPrepared(MediaPlayer mp) {
mMediaPlayer.start();
sendMusicPlayBroadcast();
}
});
} catch (IOException e) {
e.printStackTrace();
}
}
}
/**
* 停止播放歌曲。
*/
public void stop() {
if (mMediaPlayer.isPlaying()) {
mMediaPlayer.stop();
}
}
AudioManager.OnAudioFocusChangeListener audioFocusListener = new AudioManager.OnAudioFocusChangeListener() {
public void onAudioFocusChange(int focusChange) {
if (focusChange == AudioManager.AUDIOFOCUS_LOSS_TRANSIENT) {
// Pause playback
pause();
} else if (focusChange == AudioManager.AUDIOFOCUS_GAIN) {
// Resume playback
replay();
} else if (focusChange == AudioManager.AUDIOFOCUS_LOSS) {
mAudioManager.abandonAudioFocus(audioFocusListener);
pause();
}
}
};
/**
* 请求音频焦点。
*
* @return
*/
private boolean requestFocus() {
// Request audio focus for playback
int result = mAudioManager.requestAudioFocus(audioFocusListener,
// Use the music stream.
AudioManager.STREAM_MUSIC,
// Request permanent focus.
AudioManager.AUDIOFOCUS_GAIN);
return result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED;
}
/**
* 根据位置播放列表中的歌曲。
*
* @param pos
* @return
*/
public boolean play(int pos) {
if (requestFocus()) {
if (mCurPlayIndex == pos) {
if (!mMediaPlayer.isPlaying()) {
mMediaPlayer.start();
mPlayState = MPS_PLAYING;
sendMusicPlayBroadcast();
mCurMusic = mPlaylist.get(mCurPlayIndex);
saveLatest(mCurMusic);
} else {
pause();
}
return true;
}
if (!prepare(pos)) {
return false;
}
return replay();
} else {
return false;
}
}
/**
* 获取当前播放歌曲的索引。
*
* @return
*/
public int getCurPlayIndex() {
return mCurPlayIndex;
}
/**
* 保证索引在播放列表索引范围内。
*
* @param index
* @return
*/
private int reviseIndex(int index) {
if (index < 0) {
index = mPlaylist.size() - 1;
}
if (index >= mPlaylist.size()) {
index = 0;
}
return index;
}
/**
* 获取当前歌曲播放的位置。
*
* @return
*/
public int position() {
if (mPlayState == MPS_PLAYING || mPlayState == MPS_PAUSE) {
return mMediaPlayer.getCurrentPosition();
}
return 0;
}
/**
* 获取当前歌曲的时长。
*
* @return 毫秒
*/
public int duration() {
if (mPlayState == MPS_INVALID || mPlayState == MPS_NO_FILE) {
return 0;
}
return mMediaPlayer.getDuration();
}
/**
* 跳到指定进度播放歌曲。
*
* @param progress
* @return
*/
public boolean seekTo(int progress) {
if (mPlayState == MPS_INVALID || mPlayState == MPS_NO_FILE) {
return false;
}
int pro = reviseSeekValue(progress);
int time = mMediaPlayer.getDuration();
int curTime = (int) ((float) pro / 100 * time);
mMediaPlayer.seekTo(curTime);
return true;
}
/**
* 获取歌曲的播放模式。
*
* @return
*/
public int getPlayMode() {
return mPlayMode;
}
/**
* 设置歌曲的播放模式。
*
* @param mode
*/
public void setPlayMode(int mode) {
this.mPlayMode = mode;
}
/**
* 清空播放列表。
*/
public void clear() {
mMediaPlayer.stop();
mMediaPlayer.reset();
}
/**
* 在线缓冲进度。
*
* @return
*/
public int pendingProgress() {
return mPendingProgress;
}
public interface OnConnectCompletionListener {
void onConnectCompletion(IMediaService service);
}
/**
* 获取当前正在播放的歌曲。
*
* @return
*/
public Music getCurMusic() {
return mCurMusic;
}
/**
* 检测当前歌曲是否正在播放中。
*
* @return
*/
public boolean isPlaying() {
return mPlaying;
}
/**
* 暂停当前歌曲的播放。
*
* @return
*/
public boolean pause() {
if (mPlayState != MPS_PLAYING) {
return false;
}
mMediaPlayer.pause();
mPlayState = MPS_PAUSE;
mCurMusic = mPlaylist.get(mCurPlayIndex);
sendMusicPlayBroadcast();
return true;
}
/**
* 播放上一首。
*
* @return
*/
public boolean prev() {
switch (mPlayMode) {
case AppConfig.MPM_LIST_LOOP_PLAY: //列表循环
return moveLeft();
case AppConfig.MPM_ORDER_PLAY: //顺序播放
if (mCurPlayIndex != 0) {
return moveLeft();
} else {
return prepare(mCurPlayIndex);
}
case AppConfig.MPM_RANDOM_PLAY: //随机播放
int index = getRandomIndex();
if (index != -1) {
mCurPlayIndex = index;
} else {
mCurPlayIndex = 0;
}
if (prepare(mCurPlayIndex)) {
return replay();
}
return false;
case AppConfig.MPM_SINGLE_LOOP_PLAY: //单曲循环
prepare(mCurPlayIndex);
return replay();
default:
return false;
}
}
/**
* 播放下一首。
*
* @return
*/
public boolean next() {
switch (mPlayMode) {
case MPM_LIST_LOOP_PLAY: //列表循环
return moveRight();
case MPM_ORDER_PLAY: //顺序播放
if (mCurPlayIndex != mPlaylist.size() - 1) {
return moveRight();
} else {
return prepare(mCurPlayIndex);
}
case MPM_RANDOM_PLAY: //随机播放
int index = getRandomIndex();
if (index != -1) {
mCurPlayIndex = index;
} else {
mCurPlayIndex = 0;
}
if (prepare(mCurPlayIndex)) {
return replay();
}
return false;
case MPM_SINGLE_LOOP_PLAY: //单曲循环
prepare(mCurPlayIndex);
return replay();
default:
return false;
}
}
@Override
public void onCompletion(MediaPlayer mp) {
next();
}
/**
* 随机播放模式下获取播放索引。
*
* @return
*/
private int getRandomIndex() {
int size = mPlaylist.size();
if (size == 0) {
return -1;
}
return Math.abs(mRandom.nextInt() % size);
}
/**
* 修正缓冲播放的进度在合理的范围内。
*
* @param progress
* @return
*/
private int reviseSeekValue(int progress) {
if (progress < 0) {
progress = 0;
} else if (progress > 100) {
progress = 100;
}
return progress;
}
/**
* 刷新播放列表的歌曲。
*
* @param playlist
*/
public void refreshPlaylist(List<Music> playlist) {
mPlaylist.clear();
mPlaylist.addAll(playlist);
if (mPlaylist.size() == 0) {
mPlayState = MPS_NO_FILE;
mCurPlayIndex = -1;
return;
}
}
/**
* 在当前播放模式下播放上一首。
*
* @return
*/
public boolean moveLeft() {
if (mPlayState == MPS_NO_FILE) {
return false;
}
mCurPlayIndex--;
mCurPlayIndex = reviseIndex(mCurPlayIndex);
if (!prepare(mCurPlayIndex)) {
return false;
}
return replay();
}
/**
* 在当前播放模式下播放下一首。
*
* @return
*/
public boolean moveRight() {
if (mPlayState == MPS_NO_FILE) {
return false;
}
mCurPlayIndex++;
mCurPlayIndex = reviseIndex(mCurPlayIndex);
if (!prepare(mCurPlayIndex)) {
return false;
}
return replay();
}
/**
* 重头开始播放当前歌曲。
*
* @return
*/
public boolean replay() {
if (requestFocus()) {
if (mPlayState == MPS_INVALID || mPlayState == MPS_NO_FILE) {
return false;
}
mMediaPlayer.start();
mPlayState = MPS_PLAYING;
sendMusicPlayBroadcast();
mCurMusic = mPlaylist.get(mCurPlayIndex);
saveLatest(mCurMusic);
return true;
} else {
return false;
}
}
/**
* 发送音乐播放/暂停的广播。
*/
private void sendMusicPlayBroadcast() {
setPlaying(mPlayState);
Intent intent = new Intent(ACTION_PLAY);
intent.putExtra("play_state", mPlayState);
mContext.sendBroadcast(intent);
Apollo.emit(ApolloEvent.REFRESH_LOCAL_NUMS);
}
/**
* 获取当前的播放状态。
*
* @return
*/
public int getPlayState() {
return mPlayState;
}
/**
* 获取播放列表。
*
* @return
*/
public List<Music> getPlaylist() {
return mPlaylist;
}
/**
* 退出媒体播放。
*/
public void exit() {
mMediaPlayer.stop();
mMediaPlayer.release();
mCurPlayIndex = -1;
mPlaylist.clear();
}
/**
* 根据歌曲的ID,寻找出歌曲在当前播放列表中的位置。
*
* @param playlist
* @param id
* @return
*/
public int seekPosById(List<Music> playlist, int id) {
if (id == -1) {
return -1;
}
int result = -1;
if (playlist != null) {
for (int i = 0; i < playlist.size(); i++) {
if (id == playlist.get(i).songId) {
result = i;
break;
}
}
}
return result;
}
}前面我们提到使用AIDL进行跨进程访问。那么整体调用顺序是,MediaManager->MediaService->MusicControl。MediaManager调用层,相当于一个外包装或者说是门面。MediaService中间层,用于后台访问。MusicControl实现层。ShakeDetector摇一摇切歌package site.doramusic.app.shake
import android.content.Context
import android.hardware.Sensor
import android.hardware.SensorEvent
import android.hardware.SensorEventListener
import android.hardware.SensorManager
import android.os.Handler
import site.doramusic.app.util.PreferencesManager
/**
* 摇一摇切歌。
*/
class ShakeDetector(context: Context) : SensorEventListener {
private val sensorManager: SensorManager?
private var onShakeListener: OnShakeListener? = null
private val prefsManager: PreferencesManager
private var lowX: Float = 0.toFloat()
private var lowY: Float = 0.toFloat()
private var lowZ: Float = 0.toFloat()
private var shaking: Boolean = false
private val shakeHandler: Handler by lazy {
Handler()
}
companion object {
private const val FILTERING_VALUE = 0.1f
}
init {
// 获取传感器管理服务
sensorManager = context.getSystemService(Context.SENSOR_SERVICE) as SensorManager
prefsManager = PreferencesManager(context)
}
private val r: Runnable = Runnable {
shaking = false
}
override fun onSensorChanged(event: SensorEvent) {
if (prefsManager.getShakeChangeMusic() && event.sensor.type == Sensor.TYPE_ACCELEROMETER) {
if (!shaking) {
shakeHandler.removeCallbacks(r)
val x = event.values[SensorManager.DATA_X]
val y = event.values[SensorManager.DATA_Y]
val z = event.values[SensorManager.DATA_Z]
lowX = x * FILTERING_VALUE + lowX * (1.0f - FILTERING_VALUE)
lowY = y * FILTERING_VALUE + lowY * (1.0f - FILTERING_VALUE)
lowZ = z * FILTERING_VALUE + lowZ * (1.0f - FILTERING_VALUE)
val highX = x - lowX
val highY = y - lowY
val highZ = z - lowZ
if (highX >= 10 || highY >= 10 || highZ >= 10) {
shaking = true
onShakeListener?.onShake()
shakeHandler.postDelayed(r, 2000)
}
}
}
}
override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) {
//传感器精度改变
}
/**
* 启动摇晃检测--注册监听器。
*/
fun start() {
sensorManager?.registerListener(this,
sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER),
SensorManager.SENSOR_DELAY_NORMAL)
}
/**
* 停止摇晃检测--取消监听器。
*/
fun stop() {
sensorManager?.unregisterListener(this)
}
/**
* 当摇晃事件发生时,接收通知。
*/
interface OnShakeListener {
/**
* 当手机晃动时被调用。
*/
fun onShake()
}
fun setOnShakeListener(l: OnShakeListener) {
this.onShakeListener = l
}
}摇一摇功能的实现原理很简单,就是使用了Android的重力传感器,当x,y,z轴的加速度超过了预先设定的阈值,就会触发摇一摇功能,我们这里是调用MediaManager播放下一首歌。因为MediaManager管理着整个可以播放的音乐列表,所以随时都可以触发摇一摇功能,当然在设置中关掉了摇一摇功能除外。拔出耳机或断开蓝牙耳机连接暂停播放音乐package site.doramusic.app.receiver
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothHeadset
import android.bluetooth.BluetoothProfile
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.media.AudioManager
import android.os.Handler
import site.doramusic.app.MusicApp
import site.doramusic.app.R
import site.doramusic.app.media.SimpleAudioPlayer
/**
* 耳机拨出监听。
*/
class EarphoneReceiver : BroadcastReceiver() {
private lateinit var player: SimpleAudioPlayer
override fun onReceive(context: Context, intent: Intent) {
val action = intent.action
if (action == AudioManager.ACTION_AUDIO_BECOMING_NOISY) {
changeSpeakerphoneOn(context, true)
// 只监听拔出耳机使用这个意图
// 耳机拔出时,暂停音乐播放
Handler().postDelayed({
player = SimpleAudioPlayer(context)
player.playByRawId(R.raw.earphone)
}, 1000)
pauseMusic()
} else if (Intent.ACTION_HEADSET_PLUG == action) {
// if (intent.hasExtra("state")) {
// int state = intent.getIntExtra("state", -1);
// if (state == 1) {
// //插入耳机
// } else if (state == 0) {
// //拔出耳机
// pauseMusic();
// }
// }
} else if (BluetoothHeadset.ACTION_CONNECTION_STATE_CHANGED == action) {
val adapter = BluetoothAdapter.getDefaultAdapter()
if (BluetoothProfile.STATE_DISCONNECTED == adapter.getProfileConnectionState(BluetoothProfile.A2DP) ||
BluetoothProfile.STATE_DISCONNECTED == adapter.getProfileConnectionState(BluetoothProfile.HEADSET) ||
BluetoothProfile.STATE_DISCONNECTED == adapter.getProfileConnectionState(BluetoothProfile.HEALTH) ||
BluetoothProfile.STATE_DISCONNECTED == adapter.getProfileConnectionState(BluetoothProfile.GATT)) {
changeSpeakerphoneOn(context, true)
//蓝牙耳机失去连接
Handler().postDelayed({
player = SimpleAudioPlayer(context)
player.playByRawId(R.raw.bluetooth)
}, 1000)
pauseMusic()
} else if (BluetoothProfile.STATE_CONNECTED == adapter.getProfileConnectionState(BluetoothProfile.HEADSET) ||
BluetoothProfile.STATE_CONNECTED == adapter.getProfileConnectionState(BluetoothProfile.HEADSET) ||
BluetoothProfile.STATE_CONNECTED == adapter.getProfileConnectionState(BluetoothProfile.HEALTH) ||
BluetoothProfile.STATE_CONNECTED == adapter.getProfileConnectionState(BluetoothProfile.GATT)) {
//蓝牙耳机已连接
}
}
}
private fun pauseMusic() {
MusicApp.instance!!.mediaManager!!.pause()
}
/**
* 切换播放模式。
*
* @param connected
*/
private fun changeSpeakerphoneOn(context: Context, connected: Boolean) {
val am = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
am.isSpeakerphoneOn = connected
}
}我们通过监听系统广播来实现这样的功能。MusicTimer全局音乐播放界面刷新package site.doramusic.app.util;
import android.os.Handler;
import android.os.Message;
import java.util.Timer;
import java.util.TimerTask;
public class MusicTimer {
public final static int REFRESH_PROGRESS_EVENT = 0x100;
private static final int INTERVAL_TIME = 500;
private Handler[] mHandler;
private Timer mTimer;
private TimerTask mTimerTask;
private int what;
private boolean mTimerStart = false;
public MusicTimer(Handler... handler) {
this.mHandler = handler;
this.what = REFRESH_PROGRESS_EVENT;
mTimer = new Timer();
}
public void startTimer() {
if (mHandler == null || mTimerStart) {
return;
}
mTimerTask = new MusicTimerTask();
mTimer.schedule(mTimerTask, INTERVAL_TIME, INTERVAL_TIME);
mTimerStart = true;
}
public void stopTimer() {
if (!mTimerStart) {
return;
}
mTimerStart = false;
if (mTimerTask != null) {
mTimerTask.cancel();
mTimerTask = null;
}
}
class MusicTimerTask extends TimerTask {
@Override
public void run() {
if (mHandler != null) {
for (Handler handler : mHandler) {
Message msg = handler.obtainMessage(what);
msg.sendToTarget();
}
}
}
}
}我们所有需要刷新进度条的地方都要用到这个类,一般设置为0.5秒刷新一次,既不过度刷新,又要保证歌曲时间播放进度较准确。BaseActivity写法体验package site.doramusic.app.ui.activity
import android.graphics.drawable.ColorDrawable
import android.os.Bundle
import android.view.ViewGroup
import android.widget.RelativeLayout
import androidx.appcompat.widget.AppCompatImageView
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.DefaultItemAnimator
import androidx.recyclerview.widget.LinearLayoutManager
import com.alibaba.android.arouter.facade.annotation.Route
import dora.skin.SkinManager
import dora.skin.base.BaseSkinActivity
import dora.util.DensityUtils
import dora.util.StatusBarUtils
import dora.widget.DoraTitleBar
import site.doramusic.app.R
import site.doramusic.app.annotation.TimeTrace
import site.doramusic.app.base.conf.ARoutePath
import site.doramusic.app.databinding.ActivityChoiceColorBinding
import site.doramusic.app.ui.adapter.ChoiceColorAdapter
import site.doramusic.app.util.PreferencesManager
/**
* 换肤界面,选择颜色。
*/
@Route(path = ARoutePath.ACTIVITY_CHOICE_COLOR)
class ChoiceColorActivity : BaseSkinActivity<ActivityChoiceColorBinding>() {
private lateinit var colorDrawable: ColorDrawable
private var choiceColorAdapter: ChoiceColorAdapter? = null
private var colorDatas: MutableList<ColorData>? = null
private lateinit var prefsManager: PreferencesManager
data class ColorData(val backgroundResId: Int, val backgroundColor: Int)
override fun getLayoutId(): Int {
return R.layout.activity_choice_color
}
override fun onSetStatusBar() {
super.onSetStatusBar()
StatusBarUtils.setTransparencyStatusBar(this)
}
override fun initData(savedInstanceState: Bundle?) {
mBinding.statusbarChoiceColor.layoutParams = RelativeLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT,
StatusBarUtils.getStatusBarHeight())
SkinManager.getLoader().setBackgroundColor(mBinding.statusbarChoiceColor, "skin_theme_color")
val imageView = AppCompatImageView(this)
val dp24 = DensityUtils.dp2px(24f)
imageView.layoutParams = RelativeLayout.LayoutParams(dp24, dp24)
imageView.setImageResource(R.drawable.ic_save)
mBinding.titlebarChoiceColor.addMenuButton(imageView)
mBinding.titlebarChoiceColor.setOnIconClickListener(object : DoraTitleBar.OnIconClickListener {
override fun onIconBackClick(icon: AppCompatImageView) {
}
override fun onIconMenuClick(position: Int, icon: AppCompatImageView) {
if (position == 0) {
changeSkin()
}
}
})
prefsManager = PreferencesManager(this)
colorDatas = mutableListOf(
ColorData(R.drawable.cyan_bg,
resources.getColor(R.color.skin_theme_color_cyan)),
ColorData(R.drawable.orange_bg,
resources.getColor(R.color.skin_theme_color_orange)),
ColorData(R.drawable.black_bg,
resources.getColor(R.color.skin_theme_color_black)),
ColorData(R.drawable.green_bg,
resources.getColor(R.color.skin_theme_color_green)),
ColorData(R.drawable.red_bg,
resources.getColor(R.color.skin_theme_color_red)),
ColorData(R.drawable.blue_bg,
resources.getColor(R.color.skin_theme_color_blue)),
ColorData(R.drawable.purple_bg,
resources.getColor(R.color.skin_theme_color_purple)))
choiceColorAdapter = ChoiceColorAdapter()
choiceColorAdapter!!.setList(colorDatas!!)
mBinding.rvChoiceColor.layoutManager = LinearLayoutManager(this,
LinearLayoutManager.HORIZONTAL, false)
// mBinding.rvChoiceColor.addItemDecoration(DividerItemDecoration(this, DividerItemDecoration.HORIZONTAL))
mBinding.rvChoiceColor.itemAnimator = DefaultItemAnimator()
mBinding.rvChoiceColor.adapter = choiceColorAdapter
choiceColorAdapter!!.selectedPosition = if (prefsManager.getSkinType() == 0) 0 else prefsManager.getSkinType() - 1
colorDrawable = ColorDrawable(ContextCompat.getColor(this, R.color.colorPrimary))
mBinding.ivChoiceColorPreview.background = colorDrawable
choiceColorAdapter!!.setOnItemClickListener { adapter, view, position ->
val color = colorDatas!![position].backgroundColor
colorDrawable.color = color
choiceColorAdapter!!.selectedPosition = position
choiceColorAdapter!!.notifyDataSetChanged()
}
}
/**
* 测试AOP。
*/
@TimeTrace
private fun changeSkin() {
when (choiceColorAdapter!!.selectedPosition) {
0 -> {
prefsManager.saveSkinType(1)
SkinManager.changeSkin("cyan")
}
1 -> {
prefsManager.saveSkinType(2)
SkinManager.changeSkin("orange")
}
2 -> {
prefsManager.saveSkinType(3)
SkinManager.changeSkin("black")
}
3 -> {
prefsManager.saveSkinType(4)
SkinManager.changeSkin("green")
}
4 -> {
prefsManager.saveSkinType(5)
SkinManager.changeSkin("red")
}
5 -> {
prefsManager.saveSkinType(6)
SkinManager.changeSkin("blue")
}
6 -> {
prefsManager.saveSkinType(7)
SkinManager.changeSkin("purple")
}
}
SkinManager.getLoader().setBackgroundColor(mBinding.statusbarChoiceColor, "skin_theme_color")
finish()
}
}以换肤界面为例,另外换肤可以看我这篇文章juejin.cn/post/725848… ,我这里就不多说了。使用dora.BaseActivity和dora.BaseFragment,可以统一数据加载都在initData中。开源项目地址github.com/dora4/DoraM…
chole
Android自定义View - 自定义属性
在Android自定义View中,自定义属性是一个重点。那么它有什么用呢?它被用来封装控件,兼容变化。我们开发好的自定义控件,通常不能完全满足别人的需求,比如颜色、文字大小不符合使用者的需求,这个时候我们就需要用到自定义属性了。定义attrs.xml<?xml version="1.0" encoding="utf-8"?>
<resources>
<declare-styleable name="MyView">
<attr name="dora_background" format="color|reference"/>
</declare-styleable>
</resources>通过定义declare-styleable节点来定义属性,name是自定义属性的名字,format是该属性的数据类型,reference为引用类型,比如可以指定@string/和@color/这样的值。我们一起来看看都有哪些format的类型吧。string:字符串 dimension:尺寸 color:颜色 integer:整数 float:小数 fraction:分数 flags:位运算 enum:枚举 boolean:布尔值 reference:引用代码中的读取方式代码中读取自定义属性,都是通过TypedArray。TypedArray ta = context.obtainStyledAttributes();
ta.recycle();重要的事情说三遍,一定要回收,一定要回收,一定要回收。读取字符串ta.getString();读取尺寸ta.getDimension();//返回float类型的尺寸
ta.getDimensionPixelOffset();//返回int类型的尺寸,舍弃小数位,通常使用在有正负且对称的点的距离的长度
ta.getDimensionPixelSize();//返回int类型的尺寸,四舍五入,通常使用在不考虑位置偏移等因素的,如仅仅是控件中某个元素的宽度和高度读取颜色ta.getColor();
ta.getColorStateList();读取整数ta.getInteger();读取小数ta.getFloat();读取分数ta.getFraction();分为指定10%和10%p两种情况,其中有base和pbase两个参数,base对应的就是对应的10%,而pbase就是对应的10%p。这两个参数不会同时生效。base和pbase都是读取时的放大倍数,且一般都是设置为1。如果你设置10%,base为10,那么得到的结果就是1左右。而如果你设置10%p,pbase为5,那么得到的结果为0.5左右。多种组合<attr name="property" format="flags">
<flag name="center" value="0" />
<flag name="centerVertical" value="1" />
<flag name="centerHorizontal" value="2" />
<flag name="left" value="3" />
<flag name="top" value="4" />
<flag name="right" value="5" />
<flag name="bottom" value="6" />
</attr>以上为flags类型的定义方式。通过|来分隔多个传入的值。ta.getInt();枚举<attr name="fruit" format="enum">
<enum name="apple" value="0" />
<enum name="banana" value="1" />
<enum name="watermelon" value="2" />
<enum name="orange" value="3" />
<enum name="pineapple" value="4" />
</attr>以上为enum类型的定义方式。ta.getInt();布尔值ta.getBoolean();引用ta.getResourceId();
ta.getDrawable();
ta.getTextArray();根据具体情况选择方法。
chole
设计模式 - 责任链模式(Chain of Responsibility)
中文名:责任链模式英文名:Chain of Responsibility类型:行为型模式班主任评语:责任链模式就像是一个审批流,逐级审批。每一个节点完成操作后,会给到它的下一个节点。它的好处就在于流程清晰。当操作按一定流程进行后,上一个节点其实是可以决定要不要交给下一个节点的,这样就可以做到拦截机制。奖状:Android View的事件分发、Okhttp的拦截器
chole
设计模式 - 单例模式(Singleton)
中文名:单例模式英文名:Singleton类型:创建型模式班主任评语:单例模式,是一种应用非常广泛的一种设计模式,它避免了重复创建大量对象,对对象进行复用,这样可以大大节省内存的使用。它的缺点就是要注意多线程环境下对改变量访问的时候要加锁。
chole
什么是注解(运行期)
注解就是配置在类、方法或属性上的额外的信息,运行期注解可以在反射中被读取到。注解定义public @interface 注解类名我们还可以给注解配置元注解信息,元注解就是配置在注解上的注解。通过给我们定义的注解配置元注解,来定义注解的用途。比如@Target和@Retention就是两个常用的元注解。@Target可以指定注解配置的地方,例如@Target(ElementType.TYPE)。取值说明ElementType.TYPE应用于类、接口(包括注解类型)、枚举ElementType.FIELD应用于属性(包括枚举中的常量)ElementType.METHOD应用于方法ElementType.PARAMETER应用于方法的形参ElementType.CONSTRUCTOR应用于构造函数ElementType.LOCAL_VARIABLE应用于局部变量ElementType.ANNOTATION_TYPE应用于注解@Retention可以指定注解的类型,例如@Retention(RetentionPolicy.RUNTIME)。取值说明RetentionPolicy.SOURCE编译时被丢弃,只标记源码,用于源码阅读RetentionPolicy.CLASS编译期注解RetentionPolicy.RUNTIME运行期注解注解使用将注解声明在元素的左边运行期注解读取运行期注解的读取基于反射。调用该类字节码class对象的getAnnotation()方法就可以拿到该注解了。然后我们就可以调用注解上面的方法获取我们想要的配置信息了。注意注解默认有一个value属性,它是可以直接调用的。
chole
Android自定义View - DoraDragItem
描述:一个可以拖动排序的条目控制器复杂度:★★☆☆☆分组:【Dora大控件组】关系:暂无技术要点:ItemTouchHelper照片动图软件包github.com/dora4/dora_…用法val recyclerView = findViewById<RecyclerView>(R.id.recyclerView)
recyclerView.layoutManager = LinearLayoutManager(this, LinearLayoutManager.VERTICAL, false)
val adapter = DoraDragItemAdapter()
val touchHelper = ItemTouchHelper(DoraDragItemCallback(adapter))
touchHelper.attachToRecyclerView(recyclerView)