【异步绘制】UIView刷新原理 与 异步绘制
快捷目录
- 壹、 iOS界面刷新机制
- 贰、浅谈UIView的刷新与绘制
- 概述
- 一.UIView 与 CALayer
- 1. UIView 与 CALayer的关系
- 2. CALayer的一些常用属性
- `contents`属性
- `contentGravity`属性
- `contentsScale`属性
- `maskToBounds`属性
- `contentsRect`属性
- 二.View的布局与显示
- 1.图像显示原理
- 2.布局
- `layoutSubviews()`方法
- `setNeedsLayout()`方法
- `layoutIfNeeded()`方法
- 3.显示
- `drawRect:`方法
- `setNeedsDisplay()`方法
- 三.UIView的系统绘制与异步绘制流程
- UIView的绘制流程
- 系统绘制
- 异步绘制
- 什么是异步绘制?
- 异步绘制流程
- 四.总结
- 叁、 iOS列表性能优化之异步绘制
- 一、需求背景
- 1、现状
- 2、需求
- 二、解决方案及亮点
- 1、方案概述
- 2、问题点
- 3、分析过程
- 1)异步绘制时机及减少重复绘制
- 2)队列的并发和择优
- 三、详细设计
- 1、设计图
- 2、代码原理剖析(写在注释)
- 1)设置runloop监听及回调
- 2)创建、获取文本异步绘制队列,并择优选取
- 3)异步绘制
- 4)异步下载缓存图片
- 四、使用示例
- 1)文本异步绘制
- 2)图片异步下载渲染
- 五、成效举证
- 六、核心代码范围
壹、 iOS界面刷新机制
当在操作 UI 时,比如改变了 Frame、更新了 UIView/CALayer 的层次时,或者手动调用了 UIView/CALayer 的 setNeedsLayout/setNeedsDisplay方法后,这个 UIView/CALayer 就被标记为待处理,并被提交到一个全局的容器去。
苹果注册了一个 Observer 监听 BeforeWaiting(即将进入休眠) 和 Exit (即将退出Loop) 事件,回调去执行一个很长的函数:
_ZN2CA11Transaction17observer_callbackEP19__CFRunLoopObservermPv()。这个函数里会遍历所有待处理的 UIView/CAlayer 以执行实际的绘制和调整,并更新 UI 界面。
这个函数内部的调用栈大概是这样的:
_ZN2CA11Transaction17observer_callbackEP19__CFRunLoopObservermPv()QuartzCore:CA::Transaction::observer_callback:CA::Transaction::commit();CA::Context::commit_transaction();CA::Layer::layout_and_display_if_needed();CA::Layer::layout_if_needed();[CALayer layoutSublayers];[UIView layoutSubviews];CA::Layer::display_if_needed();[CALayer display];[UIView drawRect]; //只有初始化frame的时候才会触发,更新界面并不会再次触发。如果想触发,可手动调setNeedsDisplay方法。
关于setNeedsLayout、setNeedsDisplay以及layoutIfNeeded方法的说明:
setNeedsLayout:会触发上面的界面刷新流程,runloop休眠或退出后会触发layoutSubviews方法
setNeedsDisplay:会触发上面的界面刷新流程,runloop休眠或退出后会触发drawRect方法
layoutIfNeeded:如果有需要刷新的标记(frame变化或者约束变化),会触发上面的界面刷新流程,runloop休眠或退出后会触发layoutSubviews方法(如果没有标记,不会调用layoutSubviews)。该方法一般用于Autolayout布局时,及时获取各视图的frame。
贰、浅谈UIView的刷新与绘制
概述
UIView
是我们在做iOS开发时每天都会接触到的类,几乎所有跟页面显示相关的控件也都继承自它。但是关于UIView
的布局、显示、以及绘制原理等方面笔者一直一知半解,只有真正了解了它的原理才能更好的服务我们的开发。并且在市场对iOS开发者要求越来越高的大环境下,对App页面流畅度的优化也是对高级及以上开发者必问的面试题,这就需要我们要对UIView
有更深的认知。
一.UIView 与 CALayer
UIView
:一个视图(UIView)就是在屏幕上显示的一个矩形块(比如图片,文字或者视频),它能够拦截类似于鼠标点击或者触摸手势等用户输入。视图在层级关系中可以互相嵌套,一个视图可以管理它的所有子视图的位置,在iOS当中,所有的视图都从一个叫做UIView的基类派生而来,UIView可以处理触摸事件,可以支持基于Core Graphics绘图,可以做仿射变换(例如旋转或者缩放),或者简单的类似于滑动或者渐变的动画。
CALayer
:CALayer
类在概念上和UIView
类似,同样也是一些被层级关系树管理的矩形块,同样也可以包含一些内容(像图片,文本或者背景色),管理子图层的位置。它们有一些方法和属性用来做动画和变换。和UIView最大的不同是CALayer不处理用户的交互。
CALayer
并不清楚具体的响应链(iOS通过视图层级关系用来传送触摸事件的机制),于是它并不能够响应事件,即使它提供了一些方法来判断一个触点是否在图层的范围之内。
1. UIView 与 CALayer的关系
每一个UIView
都有一个CALayer
实例的图层属性,也就是所谓的backing layer
,视图的职责就是创建并管理这个图层,以确保当子视图在层级关系中添加或者被移除的时候,他们关联的图层也同样对应在层级关系树当中有相同的操作.
两者的关系:实际上这些背后关联的图层(Layer)才是真正用来在屏幕上显示和做动画,UIView仅仅是对它的一个封装,提供了一些iOS类似于处理触摸的具体功能,以及Core Animation底层方法的高级接口。
这里引申出面试常问的一个问题:为什么iOS要基于UIView和CALayer提供两个平行的层级关系呢?为什么不用一个简单的层级来处理所有事情呢?
原因在于要做职责分离(单一职责原则),这样也能避免很多重复代码。在iOS和Mac OS两个平台上,事件和用户交互有很多地方的不同,基于多点触控的用户界面和基于鼠标键盘有着本质的区别,这就是为什么iOS有UIKit
和UIView
,但是Mac OS有AppKit
和NSView
的原因。他们功能上很相似,但是在实现上有着显著的区别。把这种功能的逻辑分开并封装成独立的Core Animation框架,苹果就能够在iOS和Mac OS之间共享代码,使得对苹果自己的OS开发团队和第三方开发者去开发两个平台的应用更加便捷。
2. CALayer的一些常用属性
contents
属性
CALayer
的contents属性可以让我们为layer图层设置一张图片,我们看下它的定义
/* An object providing the contents of the layer, typically a CGImageRef,* but may be something else. (For example, NSImage objects are* supported on Mac OS X 10.6 and later.) Default value is nil.* Animatable. */@property(nullable, strong) id contents;
这个属性的类型被定义为id,意味着它可以是任何类型的对象。在这种情况下,你可以给contents属性赋任何值,你的app都能够编译通过。但是,如果你给contents赋的不是CGImage,那么你得到的图层将是空白的。事实上,你真正要赋值的类型应该是CGImageRef,它是一个指向CGImage结构的指针,UIImage有一个CGImage属性,它返回一个CGImageRef,但是要使用它还需要进行强转:
layer.contents = (__bridge id _Nullable)(image.CGImage);
contentGravity
属性
/* A string defining how the contents of the layer is mapped into its* bounds rect. Options are `center', `top', `bottom', `left',* `right', `topLeft', `topRight', `bottomLeft', `bottomRight',* `resize', `resizeAspect', `resizeAspectFill'. The default value is* `resize'. Note that "bottom" always means "Minimum Y" and "top"* always means "Maximum Y". */@property(copy) CALayerContentsGravity contentsGravity;
如果我们为图层layer
设置contents为一张图片,那么可以使用这个属性来让图片自适应layer的大小,它类似于UIView的contentMode
属性,但是它是一个NSString类型,而不是像对应的UIKit部分,那里面的值是枚举。contentsGravity可选的常量值有以下一些:
kCAGravityCenter
kCAGravityTop
kCAGravityBottom
kCAGravityLeft
kCAGravityRight
kCAGravityTopLeft
kCAGravityTopRight
kCAGravityBottomLeft
kCAGravityBottomRight
kCAGravityResize
kCAGravityResizeAspect
kCAGravityResizeAspectFill
例如,如果要让图片等比例拉伸去自适应layer的大小可以直接这样设置
layer.contentsGravity = kCAGravityResizeAspect;
contentsScale
属性
/* Defines the scale factor applied to the contents of the layer. If* the physical size of the contents is '(w, h)' then the logical size* (i.e. for contentsGravity calculations) is defined as '(w /* contentsScale, h / contentsScale)'. Applies to both images provided* explicitly and content provided via -drawInContext: (i.e. if* contentsScale is two -drawInContext: will draw into a buffer twice* as large as the layer bounds). Defaults to one. Animatable. */@property CGFloat contentsScale
contentsScale
属性定义了contents
设置图片的像素尺寸和视图大小的比例,默认情况下它是一个值为1.0的浮点数。这个属性其实属于支持Retina屏幕机制的一部分,它的值等于当前设备的物理尺寸与逻辑尺寸的比值。如果contentsScale设置为1.0,将会以每个点1个像素绘制图片,如果设置为2.0,则会以每个点2个像素绘制图片。当用代码的方式来处理contents
设置图片的时候,一定要手动的设置图层的contentsScale属性,否则图片在Retina设备上就显示得不正确啦。代码如下:
layer.contentsScale = [UIScreen mainScreen].scale;
maskToBounds
属性
maskToBounds
属性的功能类似于UIView的clipsToBounds
属性,如果设置为YES,则会将超出layer范围的图片进行裁剪.
contentsRect
属性
contentsRect
属性在我们的日常开发中用的不多,它的主要作用是可以让我们显示contents
所设置图片的一个子区域。它是单位坐标取值在0到1之间。默认值是{0, 0, 1, 1},这意味着整个图片默认都是可见的,如果我们指定一个小一点的矩形,比如{0,0,0.5,0.5},那么layer显示的只有图片的左上角,也就是1/4的区域。
实际上给layer的contents赋CGImage的值不是唯一的设置其寄宿图的方法。我们也可以直接用Core Graphics直接绘制。通过继承UIView并实现-drawRect:方法来自定义绘制,如果单独使用
CALayer
那么可以实现其代理(CALayerDelegate)方法- (void)drawLayer:(CALayer *)layer inContext:(CGContextRef)ctx;
在这里面进行自主绘制。实际的方法绘制流程我们在下面进行探讨。
二.View的布局与显示
1.图像显示原理
在开始介绍图像的布局与显示之前,我们有必要先了解下图像的显示原理,也就是我们创建一个显示控件是怎么通过CPU与GPU的运算显示在屏幕上的。这个过程大体分为六个阶段:
绘制
- 布局 :首先一个视图由CPU进行Frame布局,准备视图(view)和图层(layer)的层级关系,以及设置图层属性(位置,背景色,边框)等等。
- 显示:view的显示图层(layer),它的寄宿图片被绘制的阶段。所谓的寄宿图,就是上面我们提到过的layer所显示的内容。它有两种设置形式:一种是直接设置
layer.contents
,赋值一个CGImageRef
;第二种是重写UIView的drawRect:
或CALayerDelegate
的drawLayer:inContext:
方法,实现自定义绘制。注意:如果实现了这两个方法,会额外的消耗CPU的性能。 - 准备:这是Core Animation准备发送数据到渲染服务的阶段。这个阶段主要对视图所用的图片进行解码以及图片的格式转换。PNG或者JPEG压缩之后的图片文件会比同质量的位图小得多。但是在图片绘制到屏幕上之前,必须把它扩展成完整的未解压的尺寸(通常等同于图片宽 x 长 x 4个字节)。为了节省内存,iOS通常直到真正绘制的时候才去解码图片。
- 提交:CPU会将处理视图和图层的层级关系打包,通过IPC(内部处理通信)通道提交给渲染服务,渲染服务由OpenGL ES和GPU组成。
- 生成帧缓存:渲染服务首先将图层数据交给OpenGL ES进行纹理生成和着色,生成前后帧缓存。再根据显示硬件的刷新频率,一般以设备的VSync信号和CADisplayLink为标准,进行前后帧缓存的切换。
- 渲染 :将最终要显示在画面上的后帧缓存交给GPU,进行采集图片和形状,运行变换,应用纹理和混合,最终显示在屏幕上。
注意:当图层被成功打包,发送到渲染服务器之后,CPU仍然要做如下工作:为了显示屏幕上的图层,Core Animation必须对渲染树种的每个可见图层通过OpenGL循环转换成纹理三角板。由于GPU并不知晓Core Animation图层的任何结构,所以必须要由CPU做这些事情。
前四个阶段都在软件层面处理(通过CPU),第五阶段也有CPU参与,只有最后一个完全由GPU执行。而且,你真正能控制只有前两个阶段:布局和显示,Core Animation框架在内部处理剩下的事务,你也控制不了它。所以接下来我们来重点分析布局与显示阶段。
2.布局
布局
:布局就是一个视图在屏幕上的位置与大小。UIView有三个比较重要的布局属性:frame
,bounds
和center
.UIView提供了用来通知系统某个view布局发生变化的方法,也提供了在view布局重新计算后调用的可重写的方法。
layoutSubviews()
方法
layoutSubviews()
:当一个视图“认为”应该重新布局自己的子控件时,它便会自动调用自己的layoutSubviews方法,在该方法中“刷新”子控件的布局.这个方法并没有系统实现,需要我们重新这个方法,在里面实现子控件的重新布局。这个方法很开销很大,因为它会在每个子视图上起作用并且调用它们相应的layoutSubviews
方法.系统会根据当前run loop
的不同状态来触发layoutSubviews
调用的机制,并不需要我们手动调用。以下是他的触发时机:
- 直接修改 view 的大小时会触发
- 调用
addSubview
会触发子视图的layoutSubviews
- 用户在 UIScrollView 上滚动(layoutSubviews 会在
UIScrollView
和它的父view
上被调用) - 用户旋转设备
- 更新视图的 constraints
这些方式都会告知系统view
的位置需要被重新计算,继而会调用layoutSubviews
.当然也可以直接触发layoutSubviews
的方法。
setNeedsLayout()
方法
setNeedsLayout()
方法的调用可以触发layoutSubviews
,调用这个方法代表向系统表示视图的布局需要重新计算。不过调用这个方法只是为当前的视图打了一个脏标记
,告知系统需要在下一次run loop
中重新布局这个视图。也就是调用setNeedsLayout()
后会有一段时间间隔,然后触发layoutSubviews
.当然这个间隔不会对用户造成影响,因为永远不会长到对界面造成卡顿。
layoutIfNeeded()
方法
layoutIfNeeded()
方法的作用是告知系统,当前打了脏标记
的视图需要立即更新,不要等到下一次run loop
到来时在更新,此时该方法会立即触发layoutSubviews
方法。当然但如果你调用了layoutIfNeeded
之后,并且没有任何操作向系统表明需要刷新视图,那么就不会调用layoutsubview
.这个方法在你需要依赖新布局,无法等到下一次 run loop
的时候会比setNeedsLayout
有用。
3.显示
和布局的方法类似,显示也有触发更新的方法,它们由系统在检测到更新时被自动调用,或者我们可以手动调用直接刷新。
drawRect:
方法
在上面我们提到过,如果要设置视图的寄宿图,除了直接设置view.layer.contents
属性,还可以自主进行绘制。绘制的方法就是实现view的drawRect:
方法。这个方法类似于布局的layoutSubviews
方法,它会对当前View的显示进行刷新,不同的是它不会触发后续对视图的子视图方法的调用。跟layoutSubviews
一样,我们不能直接手动调用drawRect:
方法,应该调用间接的触发方法,让系统在 run loop
中的不同结点自动调用。具体的绘制流程我们在本文第三节进行介绍。
setNeedsDisplay()
方法
这个方法类似于布局中的setNeedsLayout
。它会给有内容更新的视图设置一个内部的标记,但在视图重绘之前就会返回。然后在下一个run loop
中,系统会遍历所有已标记的视图,并调用它们的drawRect:
方法。大部分时候,在视图中更新任何 UI 组件都会把相应的视图标记为“dirty”,通过设置视图“内部更新标记”,在下一次run loop
中就会重绘,而不需要显式的调用setNeedsDisplay
.
三.UIView的系统绘制与异步绘制流程
UIView的绘制流程
接下来我们看下UIView
的绘制流程
绘制
- UIView调用setNeedsDisplay,这个方法我们已经介绍过了,它并不会立即开始绘制。
- UIView 调用
setNeedsDisplay
,实际会调用其layer属性的同名方法,此时相当于给layer打上绘制标记。 - 在当前
run loop
将要结束的时候,才会调用CALayer的display方法进入到真正的绘制当中 - 在CALayer的display方法中,会判断
layer
的代理方法displayLayer:
是否被实现,如果代理没有实现这个方法,则进入系统绘制流程,否则进入异步绘制入口。
系统绘制
xitong
-
在系统绘制开始时,在CALayer内部会创建一个绘制上下文,这个上下文可以理解为
CGContextRef
,我们在drawRect:
方法中获取到的currentRef
就是它。 -
然后layer会判断是否有delegate,没有delegate就调用
CALayer
的drawInContext
方法,如果有代理,并且你实现了CALayerDelegate协议中的-drawLayer:inContext:
方法或者UIView中的-drawRect:
方法(其实就是前者的包装方法),那么系统就会调用你实现的这两个方法中的一个。关于这里的代理我的理解是:如果你直接使用的UIView,那么layer的代理就是当前view,你直接实现
-drawRect:
,然后在这个方法里面进行自主绘制; 如果你用的是单独创建的CALayer
,那么你需要设置layer.delegate = self;
当然这里的self就是持有layer的视图或是控制器了,这时你需要实现-drawLayer:inContext:
方法,然后在这个方法里面进行绘制。 -
最后CALayer把位图传给GPU去渲染,也就是将生成的 bitmap 位图赋值给 layer.content 属性。
注意:使用CPU进行绘图的代价昂贵,除非绝对必要,否则你应该避免重绘你的视图。提高绘制性能的秘诀就在于尽量避免去绘制。
异步绘制
什么是异步绘制?
通过上面的介绍我们熟悉了系统绘制流程,系统绘制就是在主线程中进行上下文的创建,控件的自主绘制等,这就导致了主线程频繁的处理UI绘制的工作,如果要绘制的元素过多,过于频繁,就会造成卡顿。而异步绘制就是把复杂的绘制过程放到后台线程中执行,从而减轻主线程负担,来提升UI流畅度。
异步绘制流程
pic
上面很明显的展示了异步绘制过程:
- 从上图看,异步绘制的入口在
layer
的代理方法displayLayer:
,如果要进行异步绘制,我们必须在自定义view中实现这个方法 - 在
displayLayer:
方法中我们开辟子线程 - 在子线程中我们创建绘制上下文,并借助
Core Graphics
相关API完成自主绘制 - 完成绘制后生成Image图片
- 最后回到主线程,把Image图片赋值给layer的contents属性。
当然我们在日常开发中还要考虑线程的管理与绘制时机等问题,使用第三方库YYAsyncLayer
可以让我们把注意力放在具体的绘制上,具体的使用流程可以点这里去查看.
四.总结
我们知道,当我们实现了CALayerDelegate
协议中的-drawLayer:inContext:
方法或者UIView中的-drawRect:
方法,图层就创建了一个绘制上下文,这个上下文需要的大小的内存可从这个算式得出:图层宽X图层高X4字节,宽高的单位均为像素。对于一个在Retina iPad上的全屏图层来说,这个内存量就是 2048X15264字节,相当于12MB内存,图层每次重绘的时候都需要重新抹掉内存然后重新分配。可见使用Core Graphics
利用CPU进行绘制代价是很高的,那么如何进行高效的绘图呢?iOS-Core-Animation-Advanced-Techniques给出了答案,我们在日常开发中完全可以使用Core Animation
的CAShapeLayer
代替Core Graphics
进行图形的绘制,具体的方法这里就不介绍了,感兴趣的可以自行去查看。
参考引用:
iOS-Core-Animation-Advanced-Techniques
YYAsyncLayer
https://juejin.cn/post/6844903567610871816
叁、 iOS列表性能优化之异步绘制
https://juejin.cn/post/6901957495548608525#heading-20
一、需求背景
1、现状
iOS所提供的UIKit框架,其工作基本是在主线程上进行,界面绘制、用户输入响应交互等等。当大量且频繁的绘制任务,以及各种业务逻辑同时放在主线程上完成时,便有可能造成界面卡顿,丢帧现象,即在16.7ms内未能完成1帧的绘制,帧率低于60fps黄金标准。目前常用的UITableView或UICollectionView,在大量复杂文本及图片内容填充后,如果没有优化处理,快速滑动的情况下易出现卡顿,流畅性差问题。
2、需求
不依赖任何第三方pod框架,主要从异步线程绘制、图片异步下载渲染等方面,尽可能优化UITableView的使用,提高滑动流畅性,让帧率稳定在60fps。
(网上有很多优秀的性能优化博客和开源代码,本方案也是基于前人的经验,结合自身的理解和梳理写成demo,关键代码有做注释,很多细节值得推敲和持续优化,不足之处望指正。)
二、解决方案及亮点
1、方案概述
• 异步绘制任务收集与去重;
• 通过单例监听main runloop回调,执行异步绘制任务;
• 支持异步绘制动态文本内容,减轻主线程压力,并缓存高度减少CPU计算;
• 支持异步下载和渲染图片并缓存,仅在可视区域渲染;
• 异步队列并发管理,择优选取执行任务;
• 发现UITableView首次reload会触发3次的系统问题,初始开销增大,待优化;
2、问题点
• 异步绘制时机及减少重复绘制;
• 队列的并发和择优;
3、分析过程
1)异步绘制时机及减少重复绘制
这里简单描述下绘制原理:当UI被添加到界面后,我们改变Frame,或更新 UIView/CALayer层次,或调用setNeedsLayout/setNeedsDisplay方法,均会添加重新绘制任务。这个时候系统会注册一个Observer监听BeforeWaiting(即将进入休眠)和Exit(即将退出Loop)事件,并回调执行当前绘制任务(setNeedsDisplay->display->displayLayer),最终更新界面。
由上可知,我们可以模拟系统绘制任务的收集,在runloop回调中去执行,并重写layer的dispaly方法,开辟子线程进行异步绘制,再返回主线程刷新。
当同个UI多次触发绘制请求时,怎样减少重复绘制,以便减轻并发压力比较重要。本案通过维护一个全局线程安全的原子性状态,在绘制过程中的关键步骤处理前均校验是否要放弃当前多余的绘制任务。
2)队列的并发和择优
一次runloop回调,经常会执行多个绘制任务,这里考虑开辟多个线程去异步执行。首选并行队列可以满足,但为了满足性能效率的同时确保不过多的占用资源和避免线程间竞争等待,更好的方案应该是开辟多个串行队列单线程处理并发任务。
接下来的问题是,异步绘制创建几个串行队列合适?
我们知道一个n核设备,并发执行n个任务,最多创建n个线程时,线程之间将不会互相竞争资源。因此,不建议数量设置超过当前激活的处理器数,并可根据项目界面复杂度以及设备性能适配,适当限制并发开销,文本异步绘制最大队列数设置如下:
#define kMAX_QUEUE_COUNT 6- (NSUInteger)limitQueueCount {if (_limitQueueCount == 0) {// 获取当前系统处于激活状态的处理器数量NSUInteger processorCount = [NSProcessInfo processInfo].activeProcessorCount;// 根据处理器的数量和设置的最大队列数来设定当前队列数组的大小_limitQueueCount = processorCount > 0 ? (processorCount > kMAX_QUEUE_COUNT ? kMAX_QUEUE_COUNT : processorCount) : 1;}return _limitQueueCount;
}
文本的异步绘制串行队列用GCD实现,图片异步下载通过NSOperationQueue实现,两者最大并发数参考SDWebImage图片下载并发数的限制数:6。
如何择优选取执行任务?文本异步队列的选取,可以自定义队列的任务数标记,在队列执行任务前计算+1,当任务执行结束计算-1。这里忽略每次绘制难易度的略微差异,我们便可以判定任务数最少接近于最优队列。图片异步下载任务,交由NSOperationQueue处理并发,我们要处理的是,让同个图片在多次并发下载请求下,仅生成1个NSOperation添加到queue,即去重只下载一次并缓存,且在下载完成后返回主线程同步渲染多个触发该下载请求的控件(本案demo仅用一张图片,所以这种情况必须考虑到)。
三、详细设计
1、设计图
2、代码原理剖析(写在注释)
1)设置runloop监听及回调
/**runloop回调,并发执行异步绘制任务*/
static NSMutableSet<ADTask *> *_taskSet = nil;
static void ADRunLoopCallBack(CFRunLoopObserverRef observer, CFRunLoopActivity activity, void *info) {if (_taskSet.count == 0) return;NSSet *currentSet = _taskSet;_taskSet = [NSMutableSet set];[currentSet enumerateObjectsUsingBlock:^(ADTask *task, BOOL *stop) {[task excute];}];
}/** task调用函数
- (void)excute {((void (*)(id, SEL))[self.target methodForSelector:self.selector])(self.target, self.selector);
}
*/- (void)setupRunLoopObserver {// 创建任务集合_taskSet = [NSMutableSet set];// 获取主线程的runloopCFRunLoopRef runloop = CFRunLoopGetMain();// 创建观察者,监听即将休眠和退出CFRunLoopObserverRef observer = CFRunLoopObserverCreate(CFAllocatorGetDefault(),kCFRunLoopBeforeWaiting | kCFRunLoopExit,true, // 重复0xFFFFFF, // 设置优先级低于CATransaction(2000000)ADRunLoopCallBack, NULL);CFRunLoopAddObserver(runloop, observer, kCFRunLoopCommonModes);CFRelease(observer);
2)创建、获取文本异步绘制队列,并择优选取
- (ADQueue *)ad_getExecuteTaskQueue {// 1、创建对应数量串行队列处理并发任务,并行队列线程数无法控制if (self.queueArr.count < self.limitQueueCount) {ADQueue *q = [[ADQueue alloc] init];q.index = self.queueArr.count;[self.queueArr addObject:q]; q.asyncCount += 1;NSLog(@"queue[%ld]-asyncCount:%ld", (long)q.index, (long)q.asyncCount);return q;}// 2、当队列数已达上限,择优获取异步任务数最少的队列NSUInteger minAsync = [[self.queueArr valueForKeyPath:@"@min.asyncCount"] integerValue];__block ADQueue *q = nil;[self.queueArr enumerateObjectsUsingBlock:^(ADQueue * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {if (obj.asyncCount <= minAsync) {*stop = YES;q = obj;}}];q.asyncCount += 1;NSLog(@"queue[%ld]-excute-count:%ld", (long)q.index, (long)q.asyncCount);return q;
}- (void)ad_finishTask:(ADQueue *)q {q.asyncCount -= 1;if (q.asyncCount < 0) {q.asyncCount = 0;}NSLog(@"queue[%ld]-done-count:%ld", (long)q.index, (long)q.asyncCount);
}
3)异步绘制
/**维护线程安全的绘制状态*/
@property (atomic, assign) ADLayerStatus status;- (void)setNeedsDisplay {// 收到新的绘制请求时,同步正在绘制的线程本次取消self.status = ADLayerStatusCancel;[super setNeedsDisplay];
}- (void)display {// 标记正在绘制self.status = ADLayerStatusDrawing;if ([self.delegate respondsToSelector:@selector(asyncDrawLayer:inContext:canceled:)]) {[self asyncDraw];} else {[super display];}
}- (void)asyncDraw {__block ADQueue *q = [[ADManager shareInstance] ad_getExecuteTaskQueue];__block id<ADLayerDelegate> delegate = (id<ADLayerDelegate>)self.delegate;dispatch_async(q.queue, ^{// 重绘取消if ([self canceled]) {[[ADManager shareInstance] ad_finishTask:q];return;}// 生成上下文contextCGSize size = self.bounds.size;BOOL opaque = self.opaque;CGFloat scale = [UIScreen mainScreen].scale;CGColorRef backgroundColor = (opaque && self.backgroundColor) ? CGColorRetain(self.backgroundColor) : NULL;UIGraphicsBeginImageContextWithOptions(size, opaque, scale);CGContextRef context = UIGraphicsGetCurrentContext();if (opaque && context) {CGContextSaveGState(context); {if (!backgroundColor || CGColorGetAlpha(backgroundColor) < 1) {CGContextSetFillColorWithColor(context, [UIColor whiteColor].CGColor);CGContextAddRect(context, CGRectMake(0, 0, size.width * scale, size.height * scale));CGContextFillPath(context);}if (backgroundColor) {CGContextSetFillColorWithColor(context, backgroundColor);CGContextAddRect(context, CGRectMake(0, 0, size.width * scale, size.height * scale));CGContextFillPath(context);}} CGContextRestoreGState(context);CGColorRelease(backgroundColor);} else { CGColorRelease(backgroundColor);} // 使用context绘制[delegate asyncDrawLayer:self inContext:context canceled:[self canceled]];// 重绘取消if ([self canceled]) {[[ADManager shareInstance] ad_finishTask:q];UIGraphicsEndImageContext();return;}// 获取imageUIImage *image = UIGraphicsGetImageFromCurrentImageContext();UIGraphicsEndImageContext();// 结束任务[[ADManager shareInstance] ad_finishTask:q];// 重绘取消if ([self canceled]) {return;}// 主线程刷新dispatch_async(dispatch_get_main_queue(), ^{self.contents = (__bridge id)(image.CGImage);});});
}
4)异步下载缓存图片
#pragma mark - 处理图片
- (void)ad_setImageWithURL:(NSURL *)url target:(id)target completed:(void (^)(UIImage * _Nullable image, NSError * _Nullable error))completedBlock {if (!url) {if (completedBlock) {NSDictionary *userInfo = @{NSLocalizedFailureReasonErrorKey: NSLocalizedStringFromTable(@"Expected URL to be a image URL", @"AsyncDraw", nil)};NSError *error = [[NSError alloc] initWithDomain:kERROR_DOMAIN code:NSURLErrorBadURL userInfo:userInfo];completedBlock(nil, error);}return;}// 1、缓存中读取NSString *imageKey = url.absoluteString;NSData *imageData = self.imageDataDict[imageKey];if (imageData) {UIImage *image = [UIImage imageWithData:imageData];if (completedBlock) {completedBlock(image, nil);}} else {// 2、沙盒中读取NSString *imagePath = [NSString stringWithFormat:@"%@/Library/Caches/%@", NSHomeDirectory(), url.lastPathComponent];imageData = [NSData dataWithContentsOfFile:imagePath];if (imageData) {UIImage *image = [UIImage imageWithData:imageData];if (completedBlock) {completedBlock(image, nil);}} else {// 3、下载并缓存写入沙盒ADOperation *operation = [self ad_downloadImageWithURL:url toPath:imagePath completed:completedBlock];// 4、添加图片渲染对象[operation addTarget:target];}}
}- (ADOperation *)ad_downloadImageWithURL:(NSURL *)url toPath:(NSString *)imagePath completed:(void (^)(UIImage * _Nullable image, NSError * _Nullable error))completedBlock {NSString *imageKey = url.absoluteString;ADOperation *operation = self.operationDict[imageKey];if (!operation) {operation = [ADOperation blockOperationWithBlock:^{NSLog(@"AsyncDraw image loading~");NSData *newImageData = [NSData dataWithContentsOfURL:url];// 下载失败处理if (!newImageData) {[self.operationDict removeObjectForKey:imageKey];NSDictionary *userInfo = @{NSLocalizedFailureReasonErrorKey: NSLocalizedStringFromTable(@"Failed to load the image", @"AsyncDraw", nil)};NSError *error = [[NSError alloc] initWithDomain:kERROR_DOMAIN code:NSURLErrorUnknown userInfo:userInfo];if (completedBlock) {completedBlock(nil, error);}return;}// 缓存图片数据[self.imageDataDict setValue:newImageData forKey:imageKey];}];// 设置完成回调__block ADOperation *blockOperation = operation;[operation setCompletionBlock:^{NSLog(@"AsyncDraw image load completed~");// 取缓存NSData *newImageData = self.imageDataDict[imageKey];if (!newImageData) {return;}// 返回主线程刷新[[NSOperationQueue mainQueue] addOperationWithBlock:^{UIImage *newImage = [UIImage imageWithData:newImageData];// 遍历渲染同个图片地址的所有控件[blockOperation.targetSet enumerateObjectsUsingBlock:^(id _Nonnull obj, BOOL * _Nonnull stop) {if ([obj isKindOfClass:[UIImageView class]]) {UIImageView *imageView = (UIImageView *)obj;// ADImageView内部判断“超出可视范围,放弃渲染~”imageView.image = newImage;}}];[blockOperation removeAllTargets];}];// 写入沙盒[newImageData writeToFile:imagePath atomically:YES];// 移除任务[self.operationDict removeObjectForKey:imageKey];}];// 加入队列[self.operationQueue addOperation:operation];// 添加opertion[self.operationDict setValue:operation forKey:imageKey];}return operation;
}
四、使用示例
1)文本异步绘制
@implementation ADLabel#pragma mark - Pub MD
- (void)setText:(NSString *)text {_text = text;[[ADManager shareInstance] addTaskWith:self selector:@selector(asyncDraw)];
}
// 绑定异步绘制layer
+ (Class)layerClass {return ADLayer.class;
}#pragma mark - Pri MD
- (void)asyncDraw {[self.layer setNeedsDisplay];
}#pragma mark - ADLayerDelegate
- (void)layerWillDraw:(CALayer *)layer {
}- (void)asyncDrawLayer:(ADLayer *)layer inContext:(CGContextRef __nullable)ctx canceled:(BOOL)canceled {if (canceled) {NSLog(@"异步绘制取消~");return;}UIColor *backgroundColor = _backgroundColor;NSString *text = _text;UIFont *font = _font;UIColor *textColor = _textColor;CGSize size = layer.bounds.size;CGContextSetTextMatrix(ctx, CGAffineTransformIdentity);CGContextTranslateCTM(ctx, 0, size.height);CGContextScaleCTM(ctx, 1, -1);// 绘制区域CGMutablePathRef path = CGPathCreateMutable();CGPathAddRect(path, NULL, CGRectMake(0, 0, size.width, size.height));// 绘制的内容属性字符串NSDictionary *attributes = @{NSFontAttributeName : font,NSForegroundColorAttributeName: textColor,NSBackgroundColorAttributeName : backgroundColor,NSParagraphStyleAttributeName : self.paragraphStyle ?:[NSParagraphStyle new]};NSMutableAttributedString *attrStr = [[NSMutableAttributedString alloc] initWithString:text attributes:attributes];// 使用NSMutableAttributedString创建CTFrameCTFramesetterRef framesetter = CTFramesetterCreateWithAttributedString((CFAttributedStringRef)attrStr);CTFrameRef frame = CTFramesetterCreateFrame(framesetter, CFRangeMake(0, attrStr.length), path, NULL);CFRelease(framesetter);CGPathRelease(path);// 使用CTFrame在CGContextRef上下文上绘制CTFrameDraw(frame, ctx);CFRelease(frame);
}
2)图片异步下载渲染
@implementation ADImageView#pragma mark - Public Methods
- (void)setUrl:(NSString *)url {_url = url;[[ADManager shareInstance] ad_setImageWithURL:[NSURL URLWithString:self.url] target:self completed:^(UIImage * _Nullable image, NSError * _Nullable error) {if (image) {self.image = image;}}];
}
五、成效举证
针对本案制作了AsyncDrawDemo,是一个图文排列布局的UITableView列表,类似新闻列表,TestTableViewCell.m中有异步绘制和图片异步下载渲染开关
#define kAsyncDraw true // 异步开关
//#define kOnlyShowText true // 仅显示文本进行测试
kAsyncDraw开启前后测试对比清单:
• 同样加载1000条数据的列表
• 动态文本缓存高度
• 同一设备:真机iPhone11 iOS13.5.1
• 操作:列表首次加载完成,帧率显示60fps后,快速向上滑动至底部
本案通过YYFPSLabel观察帧率大致均值变化,以及内存/CPU变化截图如下:
1)未开启异步前:
稳定60fps后开始快速滑动至列表底部的前后对比(帧率最低到1fps,滑动过程异常卡顿,cpu未超过40%,内存占用也不多,但非常耗电):
2)开启异步后:
稳定60fps后开始快速滑动至列表底部的前后对比(帧率稳定在60fps,滑动过程非常流畅,cpu最高超过90%,内存占用到达200MB,耗电小)
通过以上对比得出的结论是:未开启“异步绘制和异步下载渲染”,虽然cpu、内存未见异常,但列表滑动卡顿,非常耗电;开启后,虽然内存占用翻倍、cpu也达到过90%,但相对于4G内存和6核CPU的iPhone11来说影响不大,流畅性和耗电得到保障。由此得出结论,UITableView性能优化的关键在于“系统资源充分满足调配的前提下,能异步的尽量异步”,否则主线程压力大引起卡顿,丢帧和耗电在所难免。
补充说明:当打开kOnlyShowText开关,仅显示文本内容进行测试时,在未打开kAsyncDraw开关前快速滑动列表,帧率出现40~50fps,可感知快速滑动下并不流畅。虽然UITableView性能优化主要体现在大图异步下载渲染的优化,文本高度的缓存对于多核CPU设备性能提升效果确实不明显,但文本异步绘制则让性能更上一层。
六、核心代码范围
DEMO地址:https://github.com/stkusegithub/AsyncDraw
代码位于目录 AsyncDrawDemo/AsyncDrawDemo/Core/下
\---AsyncDraw
+---ADManager.h
+---ADManager.m
+---ADLayer.h
+---ADLayer.m
+---ADTask.h
+---ADTask.m
+---ADQueue.h
+---ADQueue.m
+---ADOperation.h
+---ADOperation.m
\---AsyncUI
+---ADLabel.h
+---ADLabel.m
+---ADImageView.h
+---ADImageView.m
-End-
参考链接:
https://blog.csdn.net/chokshen/article/details/108714429
https://www.jianshu.com/p/bd7fdc6722ad
https://jishuin.proginn.com/p/763bfbd80508
相关文章:
【异步绘制】UIView刷新原理 与 异步绘制
快捷目录 壹、 iOS界面刷新机制贰、浅谈UIView的刷新与绘制概述一.UIView 与 CALayer1. UIView 与 CALayer的关系2. CALayer的一些常用属性contents属性contentGravity属性contentsScale属性maskToBounds属性contentsRect属性 二.View的布局与显示1.图像显示原理2.布局layoutSu…...
[ERROR] ocp-server-ce-py_script_start_check-4.2.1 RuntimeError: ‘tenant_name‘
Oceanbase 安装成功后关闭OCP,在重新启动时报错 使用OBD 启动OCP报如下错误 [adminobd ~]$ obd cluster start ocp Get local repositories ok Search plugins ok Open ssh connection ok Load cluster param plugin ok Check before start ocp-server x [ERROR] …...
模拟实验中经常遇到的问题和常用技巧
简介 最近在进行新文章的数值模拟阶段。上一次已经跟读者们分享了模拟实验的大致流程,见:数值模拟流程记录和分享 。 本文是在前提下,汇总了小编在模拟实验中经常遇到的问题和常用技巧。 文章目录 简介1. 隐藏输出结果自动创建文件夹保存多…...
微信小程序(二) ——模版语法1
文章目录 wxml模板语法拼接字符数据绑定 wxml模板语法 拼接字符 <image src"{{test1src}}" mode""/>数据绑定 在data中定义数据,吧数据定义到data对象中在wxml中使用数据不论是绑定内容还是属性都是用 {{}} 语法 动态绑定内容 *声明…...
牛客小白月赛83 解题报告
题目链接: https://ac.nowcoder.com/acm/contest/72041#question A题 解题思路 签到 代码 #include <bits/stdc.h> using namespace std;int main() {int a, b, c, d, e;cin >> a >> b >> c >> d >> e;int A, B, C, D…...
蓝桥杯专题-真题版含答案-【三角螺旋阵】【干支记年法】【异或加密法】【金字塔】
Unity3D特效百例案例项目实战源码Android-Unity实战问题汇总游戏脚本-辅助自动化Android控件全解手册再战Android系列Scratch编程案例软考全系列Unity3D学习专栏蓝桥系列ChatGPT和AIGC 👉关于作者 专注于Android/Unity和各种游戏开发技巧,以及各种资源分…...
鸿蒙篇——初次使用鸿蒙原生编译器DevEcoStudio创建一个鸿蒙原生应用遇到的坑--汇总(持续更新)
前言:欢迎各位鸿蒙初学者、开发者来本帖交流讨论,包含各位遇到的问题、鸿蒙的bug、解决方法等等,我会收集有效的内容更新到本文章中。 背景:2023年12月13日,使用DevEcoStudio 4.0.0.600版本,项目的compileS…...
细胞培养之一二三:哺乳动物细胞培养污染问题和解决方案
一、哺乳动物细胞污染是什么[1]? 污染通常是指在细胞培养基中存在不需要的微生物、不需要的哺乳动物细胞和各种生化或化学物质,从而影响所需哺乳动物细胞的生理和生长。由于微生物在包括人体特定部位在内的环境中无处不在,而且它们的繁殖速度…...
《Linux C编程实战》笔记:文件属性操作函数
获取文件属性 stat函数 在shell下直接使用ls就可以获得文件属性,但是在程序里应该怎么获得呢? #include<sys/types.h> #include <sys/stat.h> #include <unistd.h> int stat(const char *file_name,struct stat *buf); int fstat(i…...
linux中的网络知识
网络 认识基本网络网络划分计算机网络分为LAN、MAN、WAN公网ip和私网ip 传输介质单位换算客户端和服务端 OSI模型osi七层模型TCP/IP:传输控制协议簇HTTP协议简介UDP协议介绍物理地址:mac地址,全球唯一,mac由6段16进制数组成,每段有…...
tp中的调试模式
ThinkPHP有专门为开发过程而设置的调试模式,开启调试模式后,会牺牲一定的执行效率,但带来的方便和除错功能非常值得。 我们强烈建议ThinkPHP开发人员在开发阶段始终开启调试模式(直到正式部署后关闭调试模式)…...
【docker 】基于Dockerfile创建镜像
Dockerfile文档 Dockerfile文档地址 Dockerfile 是一个用来构建镜像的文本文件,文本内容包含了一条条构建镜像所需的指令和说明。 DockerFile 可以说是一种可以被 Docker 程序解释的脚本,DockerFile 是由一条条的命令组成的,每条命令对应 …...
C# 提取PDF中指定文本、图片的坐标
获取PDF文件中文字或图片的坐标可以实现精确定位,这对于快速提取指定区域的元素,以及在PDF中添加注释、标记或自动盖章等操作非常有用。本文将详解如何使用国产PDF库通过C# 提取PDF中指定文本或图片的坐标位置(X, Y轴)。 ✍ 用于…...
CTF网络安全大赛是干什么的?发展史、赛制、赛程介绍,参赛需要学什么?
CTF(Capture The Flag)是一种网络安全竞赛,它模拟了各种信息安全场景,旨在提升参与者的网络安全技能。CTF 赛事通常包含多种类型的挑战,如密码学、逆向工程、网络攻防、Web 安全、二进制利用等。 发展史 CTF 的概念…...
阿里云SMC迁移RedHat/CentOS 5 内核升级
阿里云SMC迁移RedHat/CentOS 5 内核升级 1. 起因 服务器需要迁移上阿里云,有几台服务器用的是Redhat 5.x,在使用SMC进行迁移时出现以下报错. [2023-12-13 09:50:55] [Error] Check System Info Failed, codeS16_111, msgGet OS Info Failed: [error] grub is too old for C…...
无代码开发让合利宝支付与CRM无缝API集成,提升电商用户运营效率
合利宝支付API的高效集成 在当今快速发展的电子商务领域,电商平台正寻求通过高效的支付系统集成来提升用户体验和业务处理效率。合利宝支付,作为中国领先的支付解决方案提供者,为电商平台提供了一个高效的API连接方案。这种方案允许无代码开…...
数据标注公司如何确保数据安全?景联文科技多维度提供保障
数据标注公司通常拥有大量的AI数据和用户数据,保护数据安全是数据标注公司的重要任务。 数据标注公司确保标注数据的安全可以从制度、人员、工具等多个方面入手,建立完善的安全管理体系和审计机制,加强应急预案和备份机制的建立,以…...
(C语言)精确计算程序运行时间的方法
一、先计算每秒多少个计数 typedef __int64 s64;s64 tps; /* timestamp counter per second */s64 get_tps(void) {s64 t0 rdtsc();Sleep(100);return (rdtsc() - t0) * 10; } 这段代码定义了一个函数 get_tps,该函数用于测量处理器的时间戳计数器(RD…...
【Vulnhub 靶场】【VulnCMS: 1】【简单】【20210613】
1、环境介绍 靶场介绍:https://www.vulnhub.com/entry/vulncms-1,710/ 靶场下载:https://download.vulnhub.com/vulncms/VulnCMS.ova 靶场难度:简单 发布日期:2021年06月13日 文件大小:1.4 GB 靶场作者:to…...
普冉(PUYA)单片机开发笔记(10): I2C通信-配置从机
概述 I2C 常用在某些型号的传感器和 MCU 的连接,速率要求不高,距离很短,使用简便。 I2C的通信基础知识请参见《基础通信协议之 IIC详细讲解 - 知乎》。 PY32F003 可以复用出一个 I2C 接口(PA3:SCL,PA2&a…...
Idea maven打包时 报错 illegalArgumentException: Malformed \uxxxx encoding 解决方法
1 改变打包命令重新打包 在maven打包命令上加入 -e -X 2 找到报错类和方法 可以看到是 java.util.Properties#loadConvert类方法中有个throw new IllegalArgumentException( "Malformed \\uxxxx encoding."),在此打断点 3 以Debug方式重新运行maven…...
Qt中槽函数在那个线程执行的探索和思考
信号和槽是Qt的核心机制之一,通过该机制大大简化了开发者的开发难度。信号和槽属于观察者模式(本质上是回调函数的应用)。是函数就需要考虑其是在那个线程中执行,本文讨论的就是槽函数在那个线程中执行的问题。 目录 1. connect…...
C++ 类模板
目录 前言 类模板语法 类模板和函数模板的区别 类模板没有自动类型推导的使用方式 类模板在模板参数列表中可以有默认参数 类模板中成员函数创建时机 类模板对象做函数参数 指定传入的类型 参数模板化 整个类模板化 类模板与继承 类模板成员函数类外实现 类模板分…...
边缘计算系统设计与实践
随着科技的飞速发展,物联网和人工智能两大领域的不断突破,我们看到了一种新型的计算模型——边缘计算的崛起。这种计算模型在处理大规模数据、实现实时响应和降低延迟需求方面,展现出了巨大的潜力。本文将深入探讨边缘计算系统的设计原理和实…...
【Spark精讲】Spark存储原理
目录 类比HDFS的存储架构 Spark的存储架构 存储级别 RDD的持久化机制 RDD缓存的过程 Block淘汰和落盘 类比HDFS的存储架构 HDFS集群有两类节点以管理节点-工作节点模式运行,即一个NameNode(管理节点)和多个DataNode(工作节点)。 Namenode管理文件系统的命名空…...
贪心算法:买卖股票的最佳时机II 跳跃游戏 跳跃游戏II
122.买卖股票的最佳时机II 思路: 想要获得利润,至少要以两天为一个交易单元,因为两天才会有股价差。因此可以将最终利润进行分解,如prices[3] - prices[0] (prices[3] - prices[2]) (prices[2] - prices[1]) (prices[1] - pr…...
音频DAC,ADC,CODEC的选型分析,高性能立体声
想要让模拟信号和数字信号顺利“交往”,就需要一座像“鹊桥”一样的中介,将两种不同的语言转变成统一的语言,消除无语言障碍。这座鹊桥就是转换器芯片,也就是ADC芯片。ADC芯片的全称是Analog-to-Digital Converter, 即模拟数字转换…...
python 连接SQL server 请用pymssql连接,千万别用pyodbc
pymssql官方介绍文档 python 使用 pymssql连接 SQL server 代码示例: 安装pymssql包: pip install pymssql代码: import pymssqldef conn_sqlserver_demo():# 连接字符串示例(根据您的配置进行修改)conn Nonetry:co…...
IntelliJ IDEA 自带HTTP Client接口插件上传文件示例
如何使用IntelliJ IDEA自带的HTTP Client接口插件进行文件上传的示例。在这个示例中,我们将关注Controller代码、HTTP请求文件(xxx.http),以及文件的上传和处理。 Controller代码 首先,让我们看一下处理文件上传的Co…...
C++中的接口有什么用
2023年12月13日,周三上午 今天上午在适配器模式,我发现如果想真正理解适配器模式,就必须学会使用C中的接口,就必须明白为什么要在C中使用接口,所以重新学习了一下C中的接口 目录 C中的接口有什么用用代码说明“实现多…...
网站建设困难吗/软文价格
onenote 入门笔记Microsoft has revamped many of its internal apps to match both the design aesthetic and increased functionality in Windows 10, and what we’ve gotten in the new OneNote is no different. 微软已经对其许多内部应用程序进行了改进,以使…...
帮诈骗团伙做网站属于诈骗吗/如何开发网站平台
声明使用 idea2018.2版本; 在整合ssm时候,发现 dataSource加载不到,并报错;解决办法为: file–>project structure–>modules 在 Project Structrue工程结构的 Modules中,添加 xml配置文件; 点击应…...
wordpress 房产类模板/网店营销策划方案ppt
大家好,我是头条菌。我爱程序员!今天头条菌要推荐的这款接口管理工具:高效、易用、功能强大!看介绍!YApi 是高效、易用、功能强大的 API 管理平台,旨在为开发、产品、测试人员提供更优雅的接口管理服务。可…...
免费推广软件流量精灵/seo网站排名优化服务
1.如下图在gitHub上,地址:https://github.com/foreverjs/forever ,下载foerver压缩包,但缺少node_modules依赖文件夹2.然后在windows平台cmd命令行中使用命令npm install -g forever,安装forever然后再复制forever目录…...
备案网站多少钱/qq代刷网站推广免费
WebWork介绍 WebWork是由OpenSymphony组织开发的,致力于组件化和代码重用的拉出式MVC模式J2EE Web框架。WebWork目前最新版本是2.1,现在的WebWork2.x前身是Rickard Oberg开发的WebWork,但现在WebWork已经被拆分成了Xwork1和WebWork2两个项…...
丰城建设网站/百度识图鉴你所见
我想将销售价格移动到实际价格下进行更改,我设法在产品摘要部分执行此操作,但是找不到任何用于更改的过滤器.请参见下图.我已经尝试过此代码,但没有用,我的代码适用于Summery部分add_filter( woocommerce_variable_sale_price_html, product_variation_price_format, 10, 2 );a…...