日韩性视频-久久久蜜桃-www中文字幕-在线中文字幕av-亚洲欧美一区二区三区四区-撸久久-香蕉视频一区-久久无码精品丰满人妻-国产高潮av-激情福利社-日韩av网址大全-国产精品久久999-日本五十路在线-性欧美在线-久久99精品波多结衣一区-男女午夜免费视频-黑人极品ⅴideos精品欧美棵-人人妻人人澡人人爽精品欧美一区-日韩一区在线看-欧美a级在线免费观看

歡迎訪問(wèn) 生活随笔!

生活随笔

當(dāng)前位置: 首頁(yè) > 编程资源 > 编程问答 >内容正文

编程问答

郭霖:手把手教你实现 App 360 度旋转看车效果

發(fā)布時(shí)間:2023/12/16 编程问答 43 豆豆
生活随笔 收集整理的這篇文章主要介紹了 郭霖:手把手教你实现 App 360 度旋转看车效果 小編覺(jué)得挺不錯(cuò)的,現(xiàn)在分享給大家,幫大家做個(gè)參考.

這是郭神號(hào)前陣子的推送,應(yīng)該有不少人還沒(méi)有看過(guò),現(xiàn)在分享給大家,希望對(duì)大家的Android工作和學(xué)習(xí)有所幫助。

/ 作者簡(jiǎn)介 /

本篇文章來(lái)自Youth Lee的投稿,分享了他自己結(jié)合Glide寫的一個(gè)控件,希望對(duì)大家有所幫助,同時(shí)也感謝作者貢獻(xiàn)的精彩文章。

Youth Lee的博客地址:
https://juejin.im/user/599e75646fb9a0247e425b88

前言

突然接到需求仿照某車APP做 360度看車 功能。對(duì)于這種一句話需求我從來(lái)都是拒絕的。Em…說(shuō)錯(cuò)了,如果我拒絕了就不會(huì)有這篇文章了🤣。

先來(lái)看看原版:

再來(lái)看看我做的效果,豎版:

橫版

/ 設(shè)計(jì)階段 /

需求設(shè)計(jì)

一句話需求的好處就是,技術(shù)可以自己當(dāng)回產(chǎn)品。讓我們根據(jù)原版效果圖給自己出個(gè)需求(總感覺(jué)有哪里不對(duì)!)。

  • 進(jìn)來(lái)先使用模糊資源,需要自動(dòng)旋轉(zhuǎn)360度告訴用戶:我們的視圖是可以轉(zhuǎn)滴。
  • 清晰資源下載完畢后替換模糊資源。
  • 視圖跟隨手指滑動(dòng)產(chǎn)生旋轉(zhuǎn)效果
  • 反正是自己出的需求,3個(gè)點(diǎn)太多了,需要砍一砍。把 1 跟 2 合并一下,咱沒(méi)有模糊資源,干脆直接使用清晰資源吧(實(shí)際是因?yàn)樵u(píng)估下來(lái)模糊資源跟清晰資源差別不大,沒(méi)必要做兩次加載)。技術(shù)預(yù)研 最關(guān)鍵的是這個(gè) 資源 是啥,3D模型嗎?

    完了!Unity3D沒(méi)學(xué)過(guò),OpenGL也不知道,這可如何是好?

    還好我司產(chǎn)品甩給了我36張圖,我當(dāng)即一身輕松,什么嘛,這不就是個(gè)幀動(dòng)畫(huà)!

    順帶提一下,某車APP也是使用36張圖實(shí)現(xiàn)的。360度–每10度換一張圖!

    傳統(tǒng)的幀動(dòng)畫(huà)會(huì)造成OOM,所以我選Glide (https://github.com/bumptech/glide)。

    圖片的緩存問(wèn)題,還是使用Glide。所以使用Glide就對(duì)了!(當(dāng)然,其他圖片框架也很優(yōu)秀!)

    Glide都有了,還需要啥?一個(gè) ImageView 足矣!

    啰嗦兩句

    咳咳…在開(kāi)始之前我先說(shuō)一下,我這個(gè)方案在 橫屏大圖 的情況下不是最優(yōu)的。

    通過(guò) **adb shell dumpsys activity top **這個(gè)命令,可以分析手機(jī)當(dāng)前顯示 Activity 的 View Hierarchy。

    我分析了主流汽車類APP的 橫屏 實(shí)現(xiàn)方式,都是通過(guò) WebView 實(shí)現(xiàn)的。至于WebView咋實(shí)現(xiàn),這個(gè)目前不是我考慮的問(wèn)題😅。豎屏小圖嘛,思路跟我這個(gè)應(yīng)該差不多(畢竟無(wú)法打入大廠內(nèi)部刺探源碼…)

    我自己試了一下,橫屏大圖的時(shí)候,在配置不太好的機(jī)型(原諒我無(wú)法解釋配置不太好…)上偶爾會(huì)出現(xiàn) “掉幀” ,但是人無(wú)完人,這點(diǎn)小問(wèn)題還可接受吧

    其實(shí)我是沒(méi)辦法解決啊,我猜測(cè)是因?yàn)閳D片太多,內(nèi)存不足時(shí)圖片加載/釋放 以及 原生的渲染性能導(dǎo)致。

    / 具體實(shí)現(xiàn) /

    代碼都是 Kotlin 實(shí)現(xiàn)的,線程切換使用了 RxJava2語(yǔ)言跟線程切換方式都不是重點(diǎn), 畢竟都可以換的, Glide才是這套方案的靈魂!

    有寫得不好的地方還請(qǐng)指出!

    36張圖下載

    先看下載圖片的代碼,必須是按順序排好的圖片地址,不然展示錯(cuò)亂APP可不負(fù)責(zé):

    //準(zhǔn)備資源 private fun prepareImageSource() {//io.reactivex.Completable : 我用來(lái)封裝單張圖片的下載操作val actionList = ArrayList<Completable>()//motorImageList是List<String>,元素是36張圖的網(wǎng)絡(luò)地址motorImageList.forEachIndexed { index, data ->if (index == 0) //第一張圖先展示,用于占位actionList.add(getFirstImage(data))else //其他圖片先下載actionList.add(getSingleImage(data))}//RxJava2Completable.merge(actionList)//下載操作合并起來(lái)統(tǒng)一處理.subscribeOn(Schedulers.io())//子線程操作.unsubscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread())//最后回到主線程.subscribe(object : CompletableObserver {override fun onComplete() {loadComplete()//資源下載完成了}override fun onError(e: Throwable) {//這里表示出錯(cuò)了,可以告訴業(yè)務(wù)這功能涼了,咱也不提供reload機(jī)制...}override fun onSubscribe(d: Disposable) {//disposableHelper 為 io.reactivex.disposables.CompositeDisposable//可以在Activity的onDestroy時(shí)取消,這樣可以防止異步導(dǎo)致內(nèi)存泄漏disposableHelper.addDisposable(d)}}) }

    getFirstImage(data) 與 getSingleImage(data) 均使用Glide來(lái) 加載/下載 圖片:

    //第一張圖直接展示到ImageView占位private fun getFirstImage(url: String) =Completable.create {Glide.with(rotateView).load(url).diskCacheStrategy(DiskCacheStrategy.DATA).into(rotateView) //into操作其實(shí)會(huì)自動(dòng)切回主線程!it.onComplete()}.subscribeOn(AndroidSchedulers.mainThread())//這個(gè)必須在主線程啊//其他圖片走下載邏輯private fun getSingleImage(url: String) =Completable.create {Glide.with(rotateView).asFile()//作為文件存起來(lái).load(url).diskCacheStrategy(DiskCacheStrategy.DATA).submit()it.onComplete()}.subscribeOn(Schedulers.io()).unsubscribeOn(Schedulers.io())

    Glide中 asFile() 簡(jiǎn)單介紹下:

    /*** 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).** <p>For remote content, prefer {@link #downloadOnly()}.** @return A new request builder for obtaining File paths to content.*/@NonNull@CheckResultpublic RequestBuilder<File> asFile() {return as(File.class).apply(skipMemoryCacheOf(true));}

    注釋大意:asFile() 用于本地媒體庫(kù)或者Glide硬盤緩存加載,遠(yuǎn)程資源建議使用downloadOnly() 方法,那么我們就來(lái)看看 downloadOnly() :

    } /*** Attempts always load the resource into the cache and return the {@link File} containing the* cached source data.** <p>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@CheckResultpublic RequestBuilder<File> 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@CheckResultpublic RequestBuilder<File> download(@Nullable Object model) {return downloadOnly().load(model); }

    啊哈,還有個(gè) download(@Nullable Object model) 方法,直接取代 asFile().load(url).diskCacheStrategy(DiskCacheStrategy.DATA) 不就行了么,一句話搞定啊!

    一般情況下的確是的,但是讓我們來(lái)看一看 DOWNLOAD_ONLY_OPTIONS :

    private static final RequestOptions DOWNLOAD_ONLY_OPTIONS =diskCacheStrategyOf(DiskCacheStrategy.DATA).priority(Priority.LOW).skipMemoryCache(true);

    Em… priority(Priority.LOW) 這個(gè)我無(wú)法接受,畢竟36張圖片下載不能排到最后啊!至于為啥我沒(méi)使用 Priority.HIGH ,是因?yàn)槲矣X(jué)得正常優(yōu)先級(jí)就夠了,目前業(yè)務(wù)情況加不加沒(méi)啥區(qū)別。

    diskCacheStrategyOf(DiskCacheStrategy.DATA) 大概如下所說(shuō):

    DiskCacheStrategy.NONE :表示不緩存任何內(nèi)容。
    DiskCacheStrategy.DATA :表示只緩存原始圖片。
    DiskCacheStrategy.RESOURCE :表示只緩存轉(zhuǎn)換過(guò)后的圖片。
    DiskCacheStrategy.ALL :表示既緩存原始圖片,也緩存轉(zhuǎn)換過(guò)后的圖片。
    DiskCacheStrategy.AUTOMATIC :表示讓Glide根據(jù)圖片資源智能地選擇使用哪一種緩存策略(默認(rèn)選項(xiàng))。

    最后看一下 submit() :

    /*** Returns a future that can be used to do a blocking get on a background thread.** <p>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)*/@NonNullpublic FutureTarget<TranscodeType> submit() {return submit(Target.SIZE_ORIGINAL, Target.SIZE_ORIGINAL);}

    submit() 這個(gè)需要異步調(diào)用,內(nèi)部調(diào)用可以指定寬高的方法 submit(int, int) ,Target.SIZE_ORIGINAL 表示使用資源的原始寬高。值得一提的是這個(gè)方法會(huì)被 RequestOptions#override(int, int) 覆蓋寬高。

    好了,整個(gè)操作下來(lái)圖片下載就完成了。我們不需要自己緩存資源到本地,完全使用了Glide的緩存機(jī)制。

    當(dāng)然有一點(diǎn)得說(shuō)下,Glide本身基于 DiskLruCache機(jī)制 ,如果用戶不經(jīng)常查看這個(gè)圖,資源是會(huì)被清理了。我認(rèn)為這種情況可以不用考慮,下次這段操作再下載就完事兒了。

    #####自動(dòng)旋轉(zhuǎn)

    圖片準(zhǔn)備完畢了,是時(shí)候自動(dòng)旋轉(zhuǎn)一下,告訴用戶我們這個(gè)是可以滑動(dòng)展示的!直接上代碼:

    private var anim: ValueAnimator? = nullprivate fun loadComplete() {actionistener?.onSourceReady()//回調(diào)業(yè)務(wù),資源準(zhǔn)備完畢//android.animation.IntEvaluatoranim = ValueAnimator.ofObject(IntEvaluator(), 1, motorImageList.size)anim?.duration = 1800anim?.addUpdateListener {val value = it.animatedValue as Intif (currentIndex != value) { //這個(gè)value是會(huì)重復(fù)的currentIndex = if (value >= motorImageList.size) {//到達(dá)上界0 //因?yàn)閺?開(kāi)始的,所以這里用0表示結(jié)束} else {value}Glide.with(rotateView).load(motorImageList[currentIndex]).dontAnimate().placeholder(rotateView.drawable).into(rotateView)if (currentIndex == 0) {// 0表示結(jié)束了isSourceReady = true //這個(gè)內(nèi)部標(biāo)記資源加載完畢了initTimer() //這個(gè)下面再說(shuō),嘿嘿!}}}anim?.start()}@OnLifecycleEvent(Lifecycle.Event.ON_DESTROY)fun onDestroy() {//RxJava 的釋放disposableHelper.dispose()timerDisposable?.dispose()//動(dòng)畫(huà)記得要釋放...anim?.removeAllUpdateListeners()anim?.cancel()}

    由于動(dòng)畫(huà)這個(gè)考驗(yàn)數(shù)學(xué)功底,我明顯不行啊😭!所以我就簡(jiǎn)單搞了搞屬性動(dòng)畫(huà),1800毫秒內(nèi)取一下 1到圖片數(shù)量(我們APP是36)的數(shù)字(其實(shí)就是圖片List的index),然后使用Glide加載一下圖片。

    為啥從1開(kāi)始,因?yàn)槲覀兪褂昧说谝粡垐D片占位了(index 為 0),所以就不參與動(dòng)畫(huà)計(jì)時(shí)了。

    dontAnimate() 這里使用的本意是禁止圖片切換時(shí)的動(dòng)畫(huà)效果,不過(guò)我看源碼貌似是禁止Gif的動(dòng)畫(huà),不過(guò)寫了不嫌多。

    placeholder(rotateView.drawable) 這個(gè)才是 精髓 啊,使用當(dāng)前ImageView的圖片進(jìn)行占位,這樣視覺(jué)效果才會(huì)連貫,不然圖片切換時(shí)會(huì)出現(xiàn)閃爍!

    滑動(dòng)旋轉(zhuǎn)

    重要的滑動(dòng)展示來(lái)了,先看我們的自定義的 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)//事件交給控制器處理} }

    這個(gè)看起來(lái)比較簡(jiǎn)單,讓我們看下 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.GestureDetectorreturn gestureDetector.onTouchEvent(event)}

    在 MotionEvent.ACTION_UP 與 MotionEvent.ACTION_CANCEL 時(shí)候把 accumulate 置為0,這個(gè)變量下面詳細(xì)說(shuō)明。

    先讓我們看一下資源準(zhǔn)備好之后的 initTimer 方法:

    private fun initTimer() {timerDisposable = Observable.interval(40, TimeUnit.MILLISECONDS).subscribeOn(Schedulers.io()).unsubscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread()).subscribeWith(object : DisposableObserver<Long?>() {override fun onNext(time: Long) {if (accumulate > 0) {accumulate--addIndex()} else if (accumulate < 0) {accumulate++reduceIndex()}}override fun onError(e: Throwable) {//出錯(cuò)了,功能涼了,該咋咋滴吧...}override fun onComplete() {}})}

    這里直接用RxJava開(kāi)啟了 40毫秒 的定時(shí)器(其他方式的定時(shí)器也行),40毫秒是我試驗(yàn)下來(lái)選的一個(gè)差不多的值。

    當(dāng) accumulate 大于0時(shí),我們將 accumulate 減1,并且展示 后一張 圖片,看下 addIndex():

    private fun addIndex() {if (isSourceReady) {//資源準(zhǔn)備好了,如果沒(méi)準(zhǔn)備好,則不處理currentIndex++ //當(dāng)前圖片的index,這里加1,準(zhǔn)備展示下一張圖if (currentIndex >= motorImageList.size) //如果index大于等于圖片總數(shù)currentIndex = 0//Glide展示圖片Glide.with(rotateView).load(motorImageList[currentIndex]).dontAnimate().placeholder(rotateView.drawable).into(rotateView)} }

    當(dāng) accumulate 小于0時(shí),我們將 accumulate 加1,并且展示 前一張 圖片,看下 reduceIndex():

    private fun reduceIndex() {if (isSourceReady) {currentIndex--if (currentIndex < 0)currentIndex = motorImageList.size - 1Glide.with(rotateView).load(motorImageList[currentIndex]).dontAnimate().placeholder(rotateView.drawable).into(rotateView)}}

    accumulate 等于0時(shí),不做任何操。這也就是上面在 MotionEvent.ACTION_UP 與 MotionEvent.ACTION_CANCEL 時(shí)候把 accumulate 置為0,表示手指離開(kāi)屏幕,立即停止圖片滑動(dòng)!

    所以 accumulate 用來(lái)存儲(chǔ)還剩幾張圖需要播放 :

    正數(shù):表示向后等待展示的數(shù)量
    負(fù)數(shù):表示向前等待展示的數(shù)量
    0 :表示保持當(dāng)前圖片不懂

    而我們 定時(shí)器的作用就是每隔一段時(shí)間,去讀取 accumulate 的值

    只要 accumulate 不為0,就表示一直有 前一幀/后一幀 需要展示。每隔40毫秒就會(huì)執(zhí)行換 前一張/后一張 的圖片操作。
    accumulate 等于0,就表示一直是當(dāng)前的圖片

    那么我們什么時(shí)候操作 accumulate 呢?

    在android.view.GestureDetector 處理手勢(shì)的時(shí)候:

    gestureDetector = GestureDetector(context, object : GestureDetector.OnGestureListener {override fun onShowPress(e: MotionEvent?) {//用不到}override fun onSingleTapUp(e: MotionEvent?): Boolean {//單擊事件,這個(gè)我司業(yè)務(wù)用來(lái)跳轉(zhuǎn)橫屏展示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")//橫向滑動(dòng)在的慣性小于 150 像素就不做操作if (kotlin.math.abs(velocityX) < 150) return falseif (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像素內(nèi)的滑動(dòng)不處理accumulate = 0}kotlin.math.abs(distanceX) < 3f -> {//3像素內(nèi)的滑動(dòng)作為if (distanceX > 0) {accumulate = -1} else if (distanceX < 0) {accumulate = 1}distanceX > 0 -> {if (accumulate < 0) accumulate = 0accumulate--}else -> {if (accumulate > 0) accumulate = 0accumulate++}}return true}override fun onLongPress(e: MotionEvent?) {} })//必須要禁用長(zhǎng)按事件,不然無(wú)法監(jiān)聽(tīng)滑動(dòng)事件 gestureDetector.setIsLongpressEnabled(false)

    onScroll 表示手指一直在屏幕上滾動(dòng),是處理整個(gè)滑動(dòng)事件的核心邏輯。

    縱向滑動(dòng)不考慮,橫向 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}.

    簡(jiǎn)單說(shuō):就是兩次回調(diào)之間滑動(dòng)的距離。

    我們來(lái)拆解下 onScroll 監(jiān)聽(tīng):

    kotlin.math.abs(distanceX) < 1f -> {accumulate = 0 }

    1像素以下 的距離表示手指在屏幕上靜止了,此時(shí)應(yīng)停止的動(dòng)畫(huà)。這是因?yàn)閷?shí)際操作中,手指雖然停止了,onScroll 還是會(huì)產(chǎn)生 1像素以下 回調(diào)的。我猜測(cè)是手指的細(xì)微顫動(dòng)被檢測(cè)到了,畢竟人是活體,對(duì)吧!

    kotlin.math.abs(distanceX) < 3f -> {if (distanceX > 0) {accumulate = -1} else if (distanceX < 0) {accumulate = 1} }

    1像素以上 3像素以下 的距離表示手指在慢慢滑動(dòng),此時(shí)應(yīng)該根據(jù)方向向前/向后展示一幀。

    distanceX > 0 -> {if (accumulate < 0) accumulate = 0accumulate--}else -> {if (accumulate > 0) accumulate = 0accumulate++}} }

    剩下來(lái)的么,代表用戶開(kāi)始釋放自己,盡情滑動(dòng)了!那就按照方向直接加減 accumulate 就對(duì)了!

    快速滑動(dòng)的時(shí)候,onScroll 回調(diào)的很快,accumulate 數(shù)值也就累計(jì)的很快,這就是為什么要有 1像素于3像素 的判斷了,不及時(shí)重置 accumulate, 會(huì)出現(xiàn)慣性滑動(dòng)!

    最后讓我們看看 onFling 方法:

    L.d(tag, "onFling e1 = ${e1?.action} e2 = ${e2?.action} x = $velocityX y = $velocityY")//橫向滑動(dòng)在的慣性小于 150 像素就不做操作if (kotlin.math.abs(velocityX) < 150) return falseif (velocityX > 0) {accumulate += 5} else {accumulate -= 5}return true }

    只要判定為 fling 了,直接來(lái)個(gè)5幀的加成,做個(gè)慣性滑動(dòng)效果!至于上面 MotionEvent.ACTION_UP 把 accumulate 置為0了不用在意,因?yàn)?fling 是在這之后觸發(fā)的。

    fling 的處理就比較簡(jiǎn)單粗暴了,其實(shí)值得細(xì)細(xì)打磨~

    / 總結(jié) /

    一頓操作下來(lái),這個(gè)需求也算是完成了。整體還就是個(gè)幀動(dòng)畫(huà)的思路,不過(guò)內(nèi)存管理,緩存管理就交給Glide了啦!業(yè)務(wù)的開(kāi)發(fā)量頓時(shí)少了很多啊!

    對(duì)于上面 1像素,3像素啥的,是我個(gè)人試驗(yàn)下來(lái)的值。 ViewConfiguration.get(context).getScaledTouchSlop() 其實(shí)更符合規(guī)范一些,不同的屏幕適配也好一些。

    另外,除了開(kāi)始說(shuō)的性能問(wèn)題,這個(gè)自定義View目前會(huì)吃掉所有的點(diǎn)擊事件,也就是說(shuō)縱向的滑動(dòng)并不會(huì)返回父控件處理。以后有時(shí)間再優(yōu)化了…

    Demo地址:https://github.com/YouthLee/RotateImage

    最后

    本文在開(kāi)源項(xiàng)目:https://github.com/xieyuliang/Note-Android中已收錄,里面包含不同方向的自學(xué)編程路線、面試題集合/面經(jīng)、及系列技術(shù)文章等,資源持續(xù)更新中…

    總結(jié)

    以上是生活随笔為你收集整理的郭霖:手把手教你实现 App 360 度旋转看车效果的全部?jī)?nèi)容,希望文章能夠幫你解決所遇到的問(wèn)題。

    如果覺(jué)得生活随笔網(wǎng)站內(nèi)容還不錯(cuò),歡迎將生活随笔推薦給好友。