某车App 360度旋转看车效果实现

/   今日科技快讯   /

近日特斯拉中国充电团队发文宣布:从4月14日起,特斯拉账户超时费欠费的车辆,将被暂停使用超级充电服务,在特斯拉账户中结清款项后方可恢复,而为保障车主使用,现将超充欠费暂停服务开始时间延缓至4月21日执行。

/   作者简介   /

明天就是周六啦,提前祝大家周末愉快!

本篇文章来自Youth Lee的投稿,分享了他自己结合Glide写的一个控件,希望对大家有所帮助,同时也感谢作者贡献的精彩文章。

Youth Lee的博客地址:

https://juejin.im/user/599e75646fb9a0247e425b88

/   前言   /

突然接到需求仿照某车APP做 360度看车 功能。对于这种一句话需求我从来都是拒绝的。Em...说错了,如果我拒绝了就不会有这篇文章了????。

先来看看原版:

某车App 360度旋转看车效果实现_第1张图片

再来看看我做的效果,竖版:

某车App 360度旋转看车效果实现_第2张图片

横版

某车App 360度旋转看车效果实现_第3张图片

/   设计阶段   /

需求设计

一句话需求的好处就是,技术可以自己当回产品。让我们根据原版效果图给自己出个需求(总感觉有哪里不对!)。

  1. 进来先使用模糊资源,需要自动旋转360度告诉用户:我们的视图是可以转滴。

  2. 清晰资源下载完毕后替换模糊资源。

  3. 视图跟随手指滑动产生旋转效果

反正是自己出的需求,3个点太多了,需要砍一砍。把 1 跟 2 合并一下,咱没有模糊资源,干脆直接使用清晰资源吧(实际是因为评估下来模糊资源跟清晰资源差别不大,没必要做两次加载)。

技术预研

最关键的是这个 资源 是啥,3D模型吗?

某车App 360度旋转看车效果实现_第4张图片

完了!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_UPMotionEvent.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存储适配一一我们项目是这么干的!

欢迎关注我的公众号

学习技术或投稿

长按上图,识别图中二维码即可关注

你可能感兴趣的:(某车App 360度旋转看车效果实现)