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

article/2025/9/17 9:07:36

这是郭神号前阵子的推送,应该有不少人还没有看过,现在分享给大家,希望对大家的Android工作和学习有所帮助。

/ 作者简介 /

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

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

前言

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

先来看看原版:

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

横版

/ 设计阶段 /

需求设计

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

  1. 进来先使用模糊资源,需要自动旋转360度告诉用户:我们的视图是可以转滴。
  2. 清晰资源下载完毕后替换模糊资源。
  3. 视图跟随手指滑动产生旋转效果

反正是自己出的需求,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<Completable>()//motorImageList是List<String>,元素是36张图的网络地址motorImageList.forEachIndexed { index, data ->if (index == 0) //第一张图先展示,用于占位actionList.add(getFirstImage(data))else //其他图片先下载actionList.add(getSingleImage(data))}//RxJava2Completable.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).** <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() 用于本地媒体库或者Glide硬盘缓存加载,远程资源建议使用downloadOnly() 方法,那么我们就来看看 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);
 }

啊哈,还有个 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.** <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() 这个需要异步调用,内部调用可以指定宽高的方法 submit(int, int) ,Target.SIZE_ORIGINAL 表示使用资源的原始宽高。值得一提的是这个方法会被 RequestOptions#override(int, int) 覆盖宽高。

好了,整个操作下来图片下载就完成了。我们不需要自己缓存资源到本地,完全使用了Glide的缓存机制。

当然有一点得说下,Glide本身基于 DiskLruCache机制 ,如果用户不经常查看这个图,资源是会被清理了。我认为这种情况可以不用考虑,下次这段操作再下载就完事儿了。

#####自动旋转

图片准备完毕了,是时候自动旋转一下,告诉用户我们这个是可以滑动展示的!直接上代码:

private var anim: ValueAnimator? = nullprivate fun loadComplete() {actionistener?.onSourceReady()//回调业务,资源准备完毕//android.animation.IntEvaluatoranim = ValueAnimator.ofObject(IntEvaluator(), 1, motorImageList.size)anim?.duration = 1800anim?.addUpdateListener {val value = it.animatedValue as Intif (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.GestureDetectorreturn 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<Long?>() {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 - 1Glide.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 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像素内的滑动不处理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 = 0accumulate--}else -> {if (accumulate > 0) accumulate = 0accumulate++}}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 = 0accumulate--}else -> {if (accumulate > 0) accumulate = 0accumulate++}}
}

剩下来的么,代表用户开始释放自己,尽情滑动了!那就按照方向直接加减 accumulate 就对了!

快速滑动的时候,onScroll 回调的很快,accumulate 数值也就累计的很快,这就是为什么要有 1像素于3像素 的判断了,不及时重置 accumulate, 会出现惯性滑动!

最后让我们看看 onFling 方法:

     L.d(tag, "onFling e1 = ${e1?.action} e2 = ${e2?.action} x = $velocityX y = $velocityY")//横向滑动在的惯性小于 150 像素就不做操作if (kotlin.math.abs(velocityX) < 150) return falseif (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

最后

本文在开源项目:https://github.com/xieyuliang/Note-Android中已收录,里面包含不同方向的自学编程路线、面试题集合/面经、及系列技术文章等,资源持续更新中…


http://chatgpt.dhexx.cn/article/0FCgtGQP.shtml

相关文章

第一行代码-第二版(郭霖著)笔记(初识Android)

系列文章目录 第一章 第一行代码-第二版&#xff08;郭霖著&#xff09;笔记&#xff08;初识Android&#xff09; 目录 一、Android简介 1.android系统架构 2.Android应用开发特色 二、工具准备 Tips:新建项目的时候是否勾选use legacy android.support libraries 三、…

专访郭霖:成长无止境

留意文末赠书活动 嘉宾 | 郭霖 文 | 张霞 郭霖&#xff0c;Android开发工程师&#xff0c;Android GDE&#xff08;Google认证开发者专家&#xff09;。从事Android开发工作9年&#xff0c;有着丰富的项目实战经验&#xff0c;负责及参与开发过多款移动应用与游戏&#xff0c;开…

解决http响应状态为canceled

最近写登录的页面&#xff0c;发现通过ajax请求后台的时候&#xff0c;监控台返回该请求的状态是canceled。 原因 仅仅是由于之前为了在输入账号时让浏览器进行自动补全&#xff0c;而将原先的div更换为了form,而不巧的是之前的登录事件源使用的是button。 而至于为什么stat…

ajax请求导致status为canceled的原因

在使用layui的form表单提交以后&#xff0c;请求状态总是canceled。后来在form表单的后面添加了一行代码&#xff1a; return false; 就可以了。 文档&#xff1a;https://www.layui.com/doc/modules/form.html#onsubmit 错误&#xff1a; 解决方法&#xff1a; 总结一下&…

ajax请求文件状态为 canceled 的解决办法

ajax请求文件状态为 canceled 的解决办法 场景还原原因分析解决 场景还原 最近做一个表单提交的需求时&#xff0c;遇到了这种情况&#xff0c;输完账号密码后回车提交&#xff0c;报错&#xff0c;f12打开看到是请求的status为canceled了&#xff0c;震惊一秒钟。。。如下图&…

chrome同步或登录报错:Request Canceled

原因 因为某个接口连接失败造成&#xff0c;可以摁快捷键F12或者点击开发者工具。 然后选择network&#xff0c;这里面是该页面所有的收发请求 开始登录&#xff0c;登录的时候要注意network中pending或者报错的接口&#xff0c;然后把域名记录下来 解决方式 安装chrome插…

http发送请求,status显示canceled的原因

原因&#xff1a;onSubmit和submit属性比较陈旧&#xff0c;在提交了数据以后会自动刷新页面&#xff0c;导致信息丢失以及请求中止 解决&#xff1a;在 handler里面写入e.preventDefault();阻止onsubmit执行默认的刷新页面行为。

使用 npm create vue@3 报错 npm ERR! canceled

问题 之前运行都可以成功创建&#xff0c;但今天运行 npm create vue3 的时候报错了&#xff0c;错误信息如下&#xff1a; 解决方法 在网上找了一堆方法都无效。 npm 版本问题&#xff0c;升级到最新版本 npm i -g npm&#xff0c;然后重试 npm create vue3 【x】npm cac…

Go:read一个已经被canceled的http.Request的应答

Go&#xff1a;read一个已经被canceled的http.Request的应答 1.复现 最近发现项目在处理chunk类型的http应答时&#xff0c;出现读数据异常报错&#xff0c;代码示例如下&#xff1a; server package mainimport ("bytes""net/http" )func main() {http…

Idea通过git拉取代码的时候出现Update canceled问题

当在IDEA中通过Git更新代码时&#xff0c;拉取失败&#xff0c;报如下错误 12:31 Update failedInvocation failed Server returned invalid Response.java.lang.RuntimeException: Invocation failed Server returned invalid Response.at git4idea.GitAppUtil.sendXmlRequest…

Xmodem operation was canceled by remote peer问题已解决

1.Xmodem operation was canceled by remote peer. 传输的时候就会出现注意的问题 2.使用df -h命令查看内存状况&#xff0c;可以发现root已经满了。 3.进入根目录&#xff0c;ls显示&#xff0c;使用rm命名将其中的文件删除 4.显示&#xff0c;可以看见内存占用变少。 5.…

vue proxy发出的post请求出现超时导致的canceled

0 问题 vue的proxy代理好了之后&#xff0c;get请求没问题&#xff0c;post请求出现canceled&#xff0c;如下图所示&#xff1a; 解决方案 参考 https://github.com/chimurai/http-proxy-middleware/issues/40 devServer: {host: 0.0.0.0,port: 8085,proxy: {/api: {targ…

IDEA中git拉取代码的时出现Update canceled问题

IDEA中git拉取代码的时出现Update canceled问题 当在IDEA中通过Git更新代码时&#xff0c;拉取失败&#xff0c;报如下错误 解决办法&#xff1a; 勾选上以后&#xff0c;点击 OK 后拉取代码&#xff1b; 然后就成功了

解决Canceled future for execute_request message before replies were done

报错&#xff1a;Canceled future for execute_request message before replies were done The Kernel crashed while execut 解决办法&#xff0c;在代码中添加 import os os.environ["KMP_DUPLICATE_LIB_OK"]"TRUE"就完美解决了

ajax请求取消状态,Ajax请求响应状态status为canceled

需求:业务数据提交成功之后,根据表单 ‘项目阶段’ 字段的值发送邮件; 我的实现逻辑是这样的:在业务数据提交成功后的回掉函数中发起发送邮件的请求,然后关闭表单页面。 $.ajax({url: url, type: post, data: {...}, dataType: json, success: function (result) {$.ajax(…

前端axios请求form-data,status显示canceled

前端axios请求form-data,status显示canceled 起因改进&报错发现&解决问题 PS&#xff1a;前排提示本文略微啰嗦&#xff0c;解决办法在 “发现&解决问题” 部分。 起因 最近在网上跟着学习axios在vue中的使用&#xff0c;包含axios的基本数据请求&#xff0c;实例…

Http响应状态Status为canceled

现象 Ajax发送请求 在浏览器的Network发现 响应状态 变为 cnaceled 解决方案 1.表单提交时用的是自定义的button 调用ajax 和form表单中的属性action冲突&#xff0c; form action与绑定于button上的click事件会同时触发。form action将表单内容以get请求追加至当前url上&…

iOS xcode无故build canceled解决办法

iOS xcode无故build canceled解决办法 简单说下原因和处理方法&#xff0c;后面有发现具体原因再补充 原因: 代码更改确定没有影响到xcodeproj&#xff0c;但查看确发现project.pbxproj文件有变化&#xff0c;导致LaunchImage设定有变化 Bulid Setting 中查看影响到的是Launc…

跨域上传,请求状态canceled

上图展示&#xff1a; 解决方法&#xff1a; 仔细看看前端代码&#xff0c;哪个地方设置了timeout

关于浏览器请求队列和超时表现(canceled)

前端在向服务器 API 发送请求时一般会设置一个超时时间&#xff0c;避免超过期望时间的持续等待。 以 Axios 为例&#xff0c;一般会设置 timeout 请求超时选项。 但是浏览器判断超时并不是这么简单。 搭建环境 express axios 搭建 web 服务。 在项目目录下安装依赖&…