/ 今日科技快讯 /
近日特斯拉中国充电团队发文宣布:从4月14日起,特斯拉账户超时费欠费的车辆,将被暂停使用超级充电服务,在特斯拉账户中结清款项后方可恢复,而为保障车主使用,现将超充欠费暂停服务开始时间延缓至4月21日执行。
/ 作者简介 /
明天就是周六啦,提前祝大家周末愉快!
本篇文章来自Youth Lee的投稿,分享了他自己结合Glide写的一个控件,希望对大家有所帮助,同时也感谢作者贡献的精彩文章。
Youth Lee的博客地址:
https://juejin.im/user/599e75646fb9a0247e425b88
/ 前言 /
突然接到需求仿照某车APP做 360度看车 功能。对于这种一句话需求我从来都是拒绝的。Em...说错了,如果我拒绝了就不会有这篇文章了????。
先来看看原版:
再来看看我做的效果,竖版:
横版
/ 设计阶段 /
一句话需求的好处就是,技术可以自己当回产品。让我们根据原版效果图给自己出个需求(总感觉有哪里不对!)。
进来先使用模糊资源,需要自动旋转360度告诉用户:我们的视图是可以转滴。
清晰资源下载完毕后替换模糊资源。
视图跟随手指滑动产生旋转效果
反正是自己出的需求,3个点太多了,需要砍一砍。把 1 跟 2 合并一下,咱没有模糊资源,干脆直接使用清晰资源吧(实际是因为评估下来模糊资源跟清晰资源差别不大,没必要做两次加载)。
技术预研
最关键的是这个 资源 是啥,3D模型吗?
完了!Unity3D没学过,OpenGL也不知道,这可如何是好?
还好我司产品甩给了我36张图,我当即一身轻松,什么嘛,这不就是个帧动画!顺带提一下,某车APP也是使用36张图实现的。360度--每10度换一张图!
传统的帧动画会造成OOM,所以我选择 Glide (https://github.com/bumptech/glide)。图片的缓存问题,还是使用Glide。所以使用Glide就对了!(当然,其他图片框架也很优秀!)
Glide都有了,还需要啥?一个 ImageView 足矣!
啰嗦两句
咳咳...在开始之前我先说一下,我这个方案在 横屏大图 的情况下不是最优的。
通过 adb shell dumpsys activity top 这个命令,可以分析手机当前显示 Activity 的 View Hierarchy。
我分析了主流汽车类APP的 横屏 实现方式,都是通过 WebView 实现的。至于WebView咋实现,这个目前不是我考虑的问题????。竖屏小图嘛,思路跟我这个应该差不多(毕竟无法打入大厂内部刺探源码...)
我自己试了一下,横屏大图的时候,在配置不太好的机型(原谅我无法解释配置不太好...)上偶尔会出现 “掉帧” ,但是人无完人,这点小问题还可接受吧
其实我是没办法解决啊,我猜测是因为图片太多,内存不足时图片加载/释放 以及 原生的渲染性能导致。
/ 具体实现 /
代码都是 Kotlin 实现的,线程切换使用了 RxJava2。语言跟线程切换方式都不是重点, 毕竟都可以换的, Glide才是这套方案的灵魂!
有写得不好的地方还请指出!
36张图下载
先看下载图片的代码,必须是按顺序排好的图片地址,不然展示错乱APP可不负责:
//准备资源
private fun prepareImageSource() {
//io.reactivex.Completable : 我用来封装单张图片的下载操作
val actionList = ArrayList()
//motorImageList是List,元素是36张图的网络地址
motorImageList.forEachIndexed { index, data ->
if (index == 0) //第一张图先展示,用于占位
actionList.add(getFirstImage(data))
else //其他图片先下载
actionList.add(getSingleImage(data))
}
//RxJava2
Completable.merge(actionList)//下载操作合并起来统一处理
.subscribeOn(Schedulers.io())//子线程操作
.unsubscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())//最后回到主线程
.subscribe(object : CompletableObserver {
override fun onComplete() {
loadComplete()//资源下载完成了
}
override fun onError(e: Throwable) {
//这里表示出错了,可以告诉业务这功能凉了,咱也不提供reload机制...
}
override fun onSubscribe(d: Disposable) {
//disposableHelper 为 io.reactivex.disposables.CompositeDisposable
//可以在Activity的onDestroy时取消,这样可以防止异步导致内存泄漏
disposableHelper.addDisposable(d)
}
})
}
getFirstImage(data) 与 getSingleImage(data) 均使用Glide来 加载/下载 图片:
//第一张图直接展示到ImageView占位
private fun getFirstImage(url: String) =
Completable.create {
Glide.with(rotateView)
.load(url)
.diskCacheStrategy(DiskCacheStrategy.DATA)
.into(rotateView) //into操作其实会自动切回主线程!
it.onComplete()
}.subscribeOn(AndroidSchedulers.mainThread())//这个必须在主线程啊
//其他图片走下载逻辑
private fun getSingleImage(url: String) =
Completable.create {
Glide.with(rotateView).asFile()//作为文件存起来
.load(url)
.diskCacheStrategy(DiskCacheStrategy.DATA)
.submit()
it.onComplete()
}.subscribeOn(Schedulers.io())
.unsubscribeOn(Schedulers.io())
Glide中 asFile() 简单介绍下:
/**
* Attempts to always load a {@link File} containing the resource, either using a file path
* obtained from the media store (for local images/videos), or using Glide's disk cache (for
* remote images/videos).
*
* For remote content, prefer {@link #downloadOnly()}.
*
* @return A new request builder for obtaining File paths to content.
*/
@NonNull
@CheckResult
public RequestBuilder asFile() {
return as(File.class).apply(skipMemoryCacheOf(true));
}
注释大意:asFile() 用于本地媒体库或者Glide硬盘缓存加载,远程资源建议使用downloadOnly() 方法,那么我们就来看看 downloadOnly() :
}
/**
* Attempts always load the resource into the cache and return the {@link File} containing the
* cached source data.
*
* This method is designed to work for remote data that is or will be cached using {@link
* com.bumptech.glide.load.engine.DiskCacheStrategy#DATA}. As a result, specifying a {@link
* com.bumptech.glide.load.engine.DiskCacheStrategy} on this request is generally not recommended.
*
* @return A new request builder for downloading content to cache and returning the cache File.
*/
@NonNull
@CheckResult
public RequestBuilder downloadOnly() {
return as(File.class).apply(DOWNLOAD_ONLY_OPTIONS);
}
/**
* A helper method equivalent to calling {@link #downloadOnly()} ()} and then {@link
* RequestBuilder#load(Object)} with the given model.
*
* @return A new request builder for loading a {@link Drawable} using the given model.
*/
@NonNull
@CheckResult
public RequestBuilder download(@Nullable Object model) {
return downloadOnly().load(model);
}
啊哈,还有个 download(@Nullable Object model) 方法,直接取代 asFile().load(url).diskCacheStrategy(DiskCacheStrategy.DATA) 不就行了么,一句话搞定啊!
一般情况下的确是的,但是让我们来看一看 DOWNLOAD_ONLY_OPTIONS :
private static final RequestOptions DOWNLOAD_ONLY_OPTIONS =
diskCacheStrategyOf(DiskCacheStrategy.DATA).priority(Priority.LOW).skipMemoryCache(true);
Em... priority(Priority.LOW) 这个我无法接受,毕竟36张图片下载不能排到最后啊!至于为啥我没使用 Priority.HIGH ,是因为我觉得正常优先级就够了,目前业务情况加不加没啥区别。
diskCacheStrategyOf(DiskCacheStrategy.DATA) 大概如下所说:
DiskCacheStrategy.NONE :表示不缓存任何内容。
DiskCacheStrategy.DATA :表示只缓存原始图片。
DiskCacheStrategy.RESOURCE :表示只缓存转换过后的图片。
DiskCacheStrategy.ALL :表示既缓存原始图片,也缓存转换过后的图片。
DiskCacheStrategy.AUTOMATIC :表示让Glide根据图片资源智能地选择使用哪一种缓存策略(默认选项)。
最后看一下 submit() :
/**
* Returns a future that can be used to do a blocking get on a background thread.
*
* This method defaults to {@link Target#SIZE_ORIGINAL} for the width and the height. However,
* since the width and height will be overridden by values passed to {@link
* RequestOptions#override(int, int)}, this method can be used whenever {@link RequestOptions}
* with override values are applied, or whenever you want to retrieve the image in its original
* size.
*
* @see #submit(int, int)
* @see #into(Target)
*/
@NonNull
public FutureTarget submit() {
return submit(Target.SIZE_ORIGINAL, Target.SIZE_ORIGINAL);
}
submit() 这个需要异步调用,内部调用可以指定宽高的方法 submit(int, int) ,Target.SIZE_ORIGINAL 表示使用资源的原始宽高。值得一提的是这个方法会被 RequestOptions#override(int, int) 覆盖宽高。
好了,整个操作下来图片下载就完成了。我们不需要自己缓存资源到本地,完全使用了Glide的缓存机制。
当然有一点得说下,Glide本身基于 DiskLruCache机制 ,如果用户不经常查看这个图,资源是会被清理了。我认为这种情况可以不用考虑,下次这段操作再下载就完事儿了。
自动旋转
图片准备完毕了,是时候自动旋转一下,告诉用户我们这个是可以滑动展示的!直接上代码:
private var anim: ValueAnimator? = null
private fun loadComplete() {
actionistener?.onSourceReady()//回调业务,资源准备完毕
//android.animation.IntEvaluator
anim = ValueAnimator.ofObject(IntEvaluator(), 1, motorImageList.size)
anim?.duration = 1800
anim?.addUpdateListener {
val value = it.animatedValue as Int
if (currentIndex != value) { //这个value是会重复的
currentIndex = if (value >= motorImageList.size) {//到达上界
0 //因为从1开始的,所以这里用0表示结束
} else {
value
}
Glide.with(rotateView).load(motorImageList[currentIndex]).dontAnimate().placeholder(rotateView.drawable).into(rotateView)
if (currentIndex == 0) {// 0表示结束了
isSourceReady = true //这个内部标记资源加载完毕了
initTimer() //这个下面再说,嘿嘿!
}
}
}
anim?.start()
}
@OnLifecycleEvent(Lifecycle.Event.ON_DESTROY)
fun onDestroy() {
//RxJava 的释放
disposableHelper.dispose()
timerDisposable?.dispose()
//动画记得要释放...
anim?.removeAllUpdateListeners()
anim?.cancel()
}
由于动画这个考验数学功底,我明显不行啊????!所以我就简单搞了搞属性动画,1800毫秒内取一下 1到图片数量(我们APP是36)的数字(其实就是图片List的index),然后使用Glide加载一下图片。
为啥从1开始,因为我们使用了第一张图片占位了(index 为 0),所以就不参与动画计时了。
dontAnimate() 这里使用的本意是禁止图片切换时的动画效果,不过我看源码貌似是禁止Gif的动画,不过写了不嫌多。
placeholder(rotateView.drawable) 这个才是 精髓 啊,使用当前ImageView的图片进行占位,这样视觉效果才会连贯,不然图片切换时会出现闪烁!
滑动旋转
重要的滑动展示来了,先看我们的自定义的 ImageView :
class RotateImageView @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : ImageView(context, attrs, defStyleAttr) {
//RotateController 自定义的控制器,下载逻辑就在它里面完成的
val controller: RotateController = RotateController(this, context)
override fun onTouchEvent(event: MotionEvent?): Boolean {
return controller.onTouchEvent(event)//事件交给控制器处理
}
}
这个看起来比较简单,让我们看下 controller.onTouchEvent(event) :
fun onTouchEvent(event: MotionEvent?): Boolean {
event?.let {
if (it.action == MotionEvent.ACTION_UP || it.action == MotionEvent.ACTION_CANCEL) {
accumulate = 0
}
}
//让“爸爸”View不要打断触摸事件,不然我们的ImageView可能接收不到了
rotateView.parent?.requestDisallowInterceptTouchEvent(true)
//android.view.GestureDetector
return gestureDetector.onTouchEvent(event)
}
在 MotionEvent.ACTION_UP 与 MotionEvent.ACTION_CANCEL 时候把 accumulate 置为0,这个变量下面详细说明。
先让我们看一下资源准备好之后的 initTimer 方法:
private fun initTimer() {
timerDisposable = Observable.interval(40, TimeUnit.MILLISECONDS)
.subscribeOn(Schedulers.io())
.unsubscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribeWith(object : DisposableObserver() {
override fun onNext(time: Long) {
if (accumulate > 0) {
accumulate--
addIndex()
} else if (accumulate < 0) {
accumulate++
reduceIndex()
}
}
override fun onError(e: Throwable) {
//出错了,功能凉了,该咋咋滴吧...
}
override fun onComplete() {}
})
}
这里直接用RxJava开启了 40毫秒 的定时器(其他方式的定时器也行),40毫秒是我试验下来选的一个差不多的值。
当 accumulate 大于0时,我们将 accumulate 减1,并且展示 后一张 图片,看下 addIndex():
private fun addIndex() {
if (isSourceReady) {//资源准备好了,如果没准备好,则不处理
currentIndex++ //当前图片的index,这里加1,准备展示下一张图
if (currentIndex >= motorImageList.size) //如果index大于等于图片总数
currentIndex = 0
//Glide展示图片
Glide.with(rotateView).load(motorImageList[currentIndex]).dontAnimate().placeholder(rotateView.drawable).into(rotateView)
}
}
当 accumulate 小于0时,我们将 accumulate 加1,并且展示 前一张 图片,看下 reduceIndex():
private fun reduceIndex() {
if (isSourceReady) {
currentIndex--
if (currentIndex < 0)
currentIndex = motorImageList.size - 1
Glide.with(rotateView).load(motorImageList[currentIndex]).dontAnimate().placeholder(rotateView.drawable).into(rotateView)
}
}
accumulate 等于0时,不做任何操。这也就是上面在 MotionEvent.ACTION_UP 与 MotionEvent.ACTION_CANCEL 时候把 accumulate 置为0,表示手指离开屏幕,立即停止图片滑动!
所以 accumulate 用来存储还剩几张图需要播放 :
正数:表示向后等待展示的数量
负数:表示向前等待展示的数量
0 :表示保持当前图片不懂
而我们 定时器的作用就是每隔一段时间,去读取 accumulate 的值
只要 accumulate 不为0,就表示一直有 前一帧/后一帧 需要展示。每隔40毫秒就会执行换 前一张/后一张 的图片操作。
accumulate 等于0,就表示一直是当前的图片
那么我们什么时候操作 accumulate 呢?
在android.view.GestureDetector 处理手势的时候:
gestureDetector = GestureDetector(context, object : GestureDetector.OnGestureListener {
override fun onShowPress(e: MotionEvent?) {
//用不到
}
override fun onSingleTapUp(e: MotionEvent?): Boolean {
//单击事件,这个我司业务用来跳转横屏展示
actionistener?.onClick()
return true
}
override fun onDown(e: MotionEvent?): Boolean {
return true
}
override fun onFling(e1: MotionEvent?, e2: MotionEvent?, velocityX: Float, velocityY: Float): Boolean {
L.d(tag, "onFling e1 = ${e1?.action} e2 = ${e2?.action} x = $velocityX y = $velocityY")
//横向滑动在的惯性小于 150 像素就不做操作
if (kotlin.math.abs(velocityX) < 150) return false
if (velocityX > 0) {
accumulate += 5
} else {
accumulate -= 5
}
return true
}
override fun onScroll(e1: MotionEvent?, e2: MotionEvent?, distanceX: Float, distanceY: Float): Boolean {
L.d(tag, "onScroll e1 = ${e1?.action} e2 = ${e2?.action} x = $distanceX y = $distanceY")
when {
kotlin.math.abs(distanceX) < 1f -> { //1像素内的滑动不处理
accumulate = 0
}
kotlin.math.abs(distanceX) < 3f -> {//3像素内的滑动作为
if (distanceX > 0) {
accumulate = -1
} else if (distanceX < 0) {
accumulate = 1
}
distanceX > 0 -> {
if (accumulate < 0) accumulate = 0
accumulate--
}
else -> {
if (accumulate > 0) accumulate = 0
accumulate++
}
}
return true
}
override fun onLongPress(e: MotionEvent?) {}
})
//必须要禁用长按事件,不然无法监听滑动事件
gestureDetector.setIsLongpressEnabled(false)
onScroll 表示手指一直在屏幕上滚动,是处理整个滑动事件的核心逻辑。
纵向滑动不考虑,横向 distanceX 表示的:
* @param distanceX The distance along the X axis that has been scrolled since the last
* call to onScroll. This is NOT the distance between {@code e1}
* and {@code e2}.
简单说:就是两次回调之间滑动的距离。
我们来拆解下 onScroll 监听:
kotlin.math.abs(distanceX) < 1f -> {
accumulate = 0
}
1像素以下 的距离表示手指在屏幕上静止了,此时应停止的动画。这是因为实际操作中,手指虽然停止了,onScroll 还是会产生 1像素以下 回调的。我猜测是手指的细微颤动被检测到了,毕竟人是活体,对吧!
kotlin.math.abs(distanceX) < 3f -> {
if (distanceX > 0) {
accumulate = -1
} else if (distanceX < 0) {
accumulate = 1
}
}
1像素以上 3像素以下 的距离表示手指在慢慢滑动,此时应该根据方向向前/向后展示一帧。
distanceX > 0 -> {
if (accumulate < 0) accumulate = 0
accumulate--
}
else -> {
if (accumulate > 0) accumulate = 0
accumulate++
}
}
}
剩下来的么,代表用户开始释放自己,尽情滑动了!那就按照方向直接加减 accumulate 就对了!
快速滑动的时候,onScroll 回调的很快,accumulate 数值也就累计的很快,这就是为什么要有 1像素于3像素 的判断了,不及时重置 accumulate, 会出现惯性滑动!
最后让我们看看 onFling 方法:
override fun onFling(e1: MotionEvent?, e2: MotionEvent?, velocityX: Float, velocityY: Float): Boolean {
L.d(tag, "onFling e1 = ${e1?.action} e2 = ${e2?.action} x = $velocityX y = $velocityY")
//横向滑动在的惯性小于 150 像素就不做操作
if (kotlin.math.abs(velocityX) < 150) return false
if (velocityX > 0) {
accumulate += 5
} else {
accumulate -= 5
}
return true
}
只要判定为 fling 了,直接来个5帧的加成,做个惯性滑动效果!至于上面 MotionEvent.ACTION_UP 把 accumulate 置为0了不用在意,因为 fling 是在这之后触发的。
fling 的处理就比较简单粗暴了,其实值得细细打磨~
/ 总结 /
一顿操作下来,这个需求也算是完成了。整体还就是个帧动画的思路,不过内存管理,缓存管理就交给Glide了啦!业务的开发量顿时少了很多啊!
对于上面 1像素,3像素啥的,是我个人试验下来的值。 ViewConfiguration.get(context).getScaledTouchSlop() 其实更符合规范一些,不同的屏幕适配也好一些。
另外,除了开始说的性能问题,这个自定义View目前会吃掉所有的点击事件,也就是说纵向的滑动并不会返回父控件处理。以后有时间再优化了...
Demo地址:
https://github.com/YouthLee/RotateImage
推荐阅读:
这本《第三行代码》,让大家久等了!
Dart语言快速入门
Android 10存储适配一一我们项目是这么干的!
欢迎关注我的公众号
学习技术或投稿
长按上图,识别图中二维码即可关注