应用卡顿是让人头疼的问题,不像闪退一样直观明了,可以直接通过异常信号或调用栈分析得到,常常让人无处下手。好的用户体验需要我们把细节做到位,画面掉帧会导致卡顿感,造成不好的印象。卡顿是如何造成的,下面详细讲述。

认识卡顿

一些概念

  • FPS:Frames Per Second,表示每秒渲染的帧数,通过用于衡量画面的流畅度,数值越高则表示画面越流畅。
  • CPU:负责对象的创建和销毁、对象属性的调整、布局计算、文本的计算和排版、图片的格式转换和解码、图像的绘制(Core Graphics)。
  • GPU: 负责纹理的渲染(将数据渲染到屏幕)。
  • 垂直同步技术: 让CPU和GPU在收到vSync信号后再开始准备数据,防止撕裂感和跳帧,通俗来讲就是保证每秒输出的帧数不高于屏幕显示的帧数。
  • 双缓冲技术:iOS是双缓冲机制,前帧缓存和后帧缓存,cpu计算完GPU渲染后放入缓冲区中,当gpu下一帧已经渲染完放入缓冲区,且视频控制器已经读完前帧,GPU会等待vSync(垂直同步信号)信号发出后,瞬间切换前后帧缓存,并让cpu开始准备下一帧数据,安卓4.0后采用三重缓冲,多了一个后帧缓冲,可降低连续丢帧的可能性,但会占用更多的CPU和GPU 。

图像显示原理

首先从过去的 CRT 显示器原理说起。CRT 的电子枪按照上面方式,从上到下一行行扫描,扫描完成后显示器就呈现一帧画面,随后电子枪回到初始位置继续下一次扫描。为了把显示器的显示过程和系统的视频控制器进行同步,显示器(或者其他硬件)会用硬件时钟产生一系列的定时信号。当电子枪换到新的一行,准备进行扫描时,显示器会发出一个水平同步信号(horizonal synchronization),简称 HSync;而当一帧画面绘制完成后,电子枪回复到原位,准备画下一帧前,显示器会发出一个垂直同步信号(vertical synchronization),简称 VSync。显示器通常以固定频率进行刷新,这个刷新率就是 VSync 信号产生的频率。尽管现在的设备大都是液晶显示屏了,但原理仍然没有变。

通常来说,计算机系统中 CPU、GPU、显示器是以上面这种方式协同工作的。CPU 计算好显示内容提交到 GPU,GPU 渲染完成后将渲染结果放入帧缓冲区,随后视频控制器会按照 VSync 信号逐行读取帧缓冲区的数据,经过可能的数模转换传递给显示器显示。

在最简单的情况下,帧缓冲区只有一个,这时帧缓冲区的读取和刷新都都会有比较大的效率问题。为了解决效率问题,显示系统通常会引入两个缓冲区,即双缓冲机制。在这种情况下,GPU 会预先渲染好一帧放入一个缓冲区内,让视频控制器读取,当下一帧渲染好后,GPU 会直接把视频控制器的指针指向第二个缓冲器。如此一来效率会有很大的提升。

双缓冲虽然能解决效率问题,但会引入一个新的问题。当视频控制器还未读取完成时,即屏幕内容刚显示一半时,GPU 将新的一帧内容提交到帧缓冲区并把两个缓冲区进行交换后,视频控制器就会把新的一帧数据的下半段显示到屏幕上,造成画面撕裂现象,如下图:

为了解决这个问题,GPU 通常有一个机制叫做垂直同步(简写也是 V-Sync),当开启垂直同步后,GPU 会等待显示器的 VSync 信号发出后,才进行新的一帧渲染和缓冲区更新。这样能解决画面撕裂现象,也增加了画面流畅度,但需要消费更多的计算资源,也会带来部分延迟。

那么目前主流的移动设备是什么情况呢?从网上查到的资料可以知道,iOS 设备会始终使用双缓存,并开启垂直同步。而安卓设备直到 4.1 版本,Google 才开始引入这种机制,目前安卓系统是三缓存+垂直同步。

卡顿原因

正常情况下,iOS 手机默认显示刷新频率是 60hz,所以 GPU 渲染只要达到 60fps 就不会产生卡顿。

在 VSync 信号到来后,系统图形服务会通过 CADisplayLink 等机制通知 App,App 主线程开始在 CPU 中计算显示内容,比如视图的创建、布局计算、图片解码、文本绘制等。随后 CPU 会将计算好的内容提交到 GPU 去,由 GPU 进行变换、合成、渲染。随后 GPU 会把渲染结果提交到帧缓冲区去,等待下一次 VSync 信号到来时显示到屏幕上。由于垂直同步的机制,如果在一个 VSync 时间内,CPU 或者 GPU 没有完成内容提交,则那一帧就会被丢弃,等待下一次机会再显示,而这时显示屏会保留之前的内容不变。这就是界面卡顿的原因。

以 60fps 为例,vSync 会每 16.67ms 渲染一次,如在16.67ms内没有准备好下一帧数据就会使画面停留在上一帧,这就造成了卡顿。例如图中第3帧渲染完成之前一直显示的是第2帧的内容。

从上面的图中可以看到,CPU 和 GPU 不论哪个阻碍了显示流程,都会造成掉帧现象。所以开发时,也需要分别对 CPU 和 GPU 压力进行评估和优化。这样我们就清楚了,只要能使 CPU 的计算和 GPU 的渲染能在规定时间内完成,就可以完美解决卡顿。所以我们的目标:尽量减小CPU和GPU的资源消耗,使其有足够的资源完成计算和渲染工作。

优化卡顿

CPU

对象创建

对象的创建会分配内存、调整属性、甚至还有读取文件等操作,比较消耗 CPU 资源。尽量用轻量的对象代替重量的对象,可以对性能有所优化。比如 CALayer 比 UIView 要轻量许多,那么不需要响应触摸事件的控件,用 CALayer 显示会更加合适。如果对象不涉及 UI 操作,则尽量放到后台线程去创建,但可惜的是包含有 CALayer 的控件,都只能在主线程创建和操作。通过 Storyboard 创建视图对象时,其资源消耗会比直接通过代码创建对象要大非常多,在性能敏感的界面里,Storyboard 并不是一个好的技术选择。

尽量推迟对象创建的时间,并把对象的创建分散到多个任务中去。尽管这实现起来比较麻烦,并且带来的优势并不多,但如果有能力做,还是要尽量尝试一下。如果对象可以复用,并且复用的代价比释放、创建新对象要小,那么这类对象应当尽量放到一个缓存池里复用。

对象调整

对象的调整也经常是消耗 CPU 资源的地方。这里特别说一下 CALayer:CALayer 内部并没有属性,当调用属性方法时,它内部是通过运行时 resolveInstanceMethod 为对象临时添加一个方法,并把对应属性值保存到内部的一个 Dictionary 里,同时还会通知 delegate、创建动画等等,非常消耗资源。UIView 的关于显示相关的属性(比如 frame/bounds/transform)等实际上都是 CALayer 属性映射来的,所以对 UIView 的这些属性进行调整时,消耗的资源要远大于一般的属性。对此你在应用中,应该尽量减少不必要的属性修改。

当视图层次调整时,UIView、CALayer 之间会出现很多方法调用与通知,所以在优化性能时,应该尽量避免调整视图层次、添加和移除视图。

对象销毁

对象的销毁虽然消耗资源不多,但累积起来也是不容忽视的。通常当容器类持有大量对象时,其销毁时的资源消耗就非常明显。同样的,如果对象可以放到后台线程去释放,那就挪到后台线程去。这里有个小 Tip:把对象捕获到 block 中,然后扔到后台队列去随便发送个消息以避免编译器警告,就可以让对象在后台线程销毁了。

NSArray *tmp = self.array;
self.array = nil;
dispatch_async(queue, ^{[tmp class];
});

布局计算

视图布局的计算是 App 中最为常见的消耗 CPU 资源的地方。如果能在后台线程提前计算好视图布局、并且对视图布局进行缓存,那么这个地方基本就不会产生性能问题了。

不论通过何种技术对视图进行布局,其最终都会落到对 UIView.frame/bounds/center 等属性的调整上。上面也说过,对这些属性的调整非常消耗资源,所以尽量提前计算好布局,在需要时一次性调整好对应属性,而不要多次、频繁的计算和调整这些属性。

Autolayout

Autolayout 是苹果本身提倡的技术,在大部分情况下也能很好的提升开发效率,但是 Autolayout 对于复杂视图来说常常会产生严重的性能问题。随着视图数量的增长,Autolayout 带来的 CPU 消耗会呈指数级上升。具体数据可以看这个文章:http://pilky.me/36/。 如果你不想手动调整 frame 等属性,你可以用一些工具方法替代(比如常见的 left/right/top/bottom/width/height 快捷属性),或者使用 ComponentKit、AsyncDisplayKit 等框架。

文本计算

如果一个界面中包含大量文本(比如微博微信朋友圈等),文本的宽高计算会占用很大一部分资源,并且不可避免。如果你对文本显示没有特殊要求,可以参考下 UILabel 内部的实现方式:用 [NSAttributedString boundingRectWithSize:options:context:] 来计算文本宽高,用 -[NSAttributedString drawWithRect:options:context:] 来绘制文本。尽管这两个方法性能不错,但仍旧需要放到后台线程进行以避免阻塞主线程。

如果你用 CoreText 绘制文本,那就可以先生成 CoreText 排版对象,然后自己计算了,并且 CoreText 对象还能保留以供稍后绘制使用。

文本渲染

屏幕上能看到的所有文本内容控件,包括 UIWebView,在底层都是通过 CoreText 排版、绘制为 Bitmap 显示的。常见的文本控件 (UILabel、UITextView 等),其排版和绘制都是在主线程进行的,当显示大量文本时,CPU 的压力会非常大。对此解决方案只有一个,那就是自定义文本控件,用 TextKit 或最底层的 CoreText 对文本异步绘制。尽管这实现起来非常麻烦,但其带来的优势也非常大,CoreText 对象创建好后,能直接获取文本的宽高等信息,避免了多次计算(调整 UILabel 大小时算一遍、UILabel 绘制时内部再算一遍);CoreText 对象占用内存较少,可以缓存下来以备稍后多次渲染。

图片的解码

当你用 UIImage 或 CGImageSource 的那几个方法创建图片时,图片数据并不会立刻解码。图片设置到 UIImageView 或者 CALayer.contents 中去,并且 CALayer 被提交到 GPU 前,CGImage 中的数据才会得到解码。这一步是发生在主线程的,并且不可避免。如果想要绕开这个机制,常见的做法是在后台线程先把图片绘制到 CGBitmapContext 中,然后从 Bitmap 直接创建图片。目前常见的网络图片库都自带这个功能。

 图像的绘制

图像的绘制通常是指用那些以 CG 开头的方法把图像绘制到画布中,然后从画布创建图片并显示这样一个过程。这个最常见的地方就是 [UIView drawRect:] 里面了。由于 CoreGraphic 方法通常都是线程安全的,所以图像的绘制可以很容易的放到后台线程进行。一个简单异步绘制的过程大致如下(实际情况会比这个复杂得多,但原理基本一致):

- (void)display {dispatch_async(backgroundQueue, ^{CGContextRef ctx = CGBitmapContextCreate(...);// draw in context...CGImageRef img = CGBitmapContextCreateImage(ctx);CFRelease(ctx);dispatch_async(mainQueue, ^{layer.contents = img;});});
}

总结:

  • 尽量用轻量级的对象,比如用不到事件处理的地方使用CALayer取代UIView
  • 尽量提前计算好布局(例如cell行高)
  • 不要频繁地调用和调整UIView的相关属性,比如frame、bounds、transform等属性,尽量减少不必要的调用和修改(UIView的显示属性实际都是CALayer的映射,而CALayer本身是没有这些属性的,都是初次调用属性时通过resolveInstanceMethod添加并创建Dictionry保存的,耗费资源)
  • Autolayout会比直接设置frame消耗更多的CPU资源,当视图数量增长时会呈指数级增长
  • 图片的size最好刚好跟UIImageView的size保持一致,减少图片显示时的处理计算
  • 控制一下线程的最大并发数量
  • 尽量把耗时的操作放到子线程
  • 文本处理(尺寸计算、绘制、CoreText和YYText)
    1. 计算文本宽高boundingRectWithSize:options:context: 和文本绘制drawWithRect:options:context:放在子线程操作
    2. 使用CoreText自定义文本空间,在对象创建过程中可以缓存宽高等信息,避免像UILabel/UITextView需要多次计算(调整和绘制都要计算一次),且CoreText直接使用了CoreGraphics占用内存小,效率高。(YYText)
  • 图片处理(解码、绘制) 图片都需要先解码成bitmap才能渲染到UI上,iOS创建UIImage,不会立刻进行解码,只有等到显示前才会在主线程进行解码,固可以使用Core Graphics中的CGBitmapContextCreate相关操作提前在子线程中进行强制解压缩获得位图 (YYImage/SDWebImage/kingfisher的对比)
SDWebImage的使用:CGImageRef imageRef = image.CGImage;// device color spaceCGColorSpaceRef colorspaceRef = SDCGColorSpaceGetDeviceRGB();BOOL hasAlpha = SDCGImageRefContainsAlpha(imageRef);// iOS display alpha info (BRGA8888/BGRX8888)CGBitmapInfo bitmapInfo = kCGBitmapByteOrder32Host;bitmapInfo |= hasAlpha ? kCGImageAlphaPremultipliedFirst : kCGImageAlphaNoneSkipFirst;size_t width = CGImageGetWidth(imageRef);size_t height = CGImageGetHeight(imageRef);// kCGImageAlphaNone is not supported in CGBitmapContextCreate.// Since the original image here has no alpha info, use kCGImageAlphaNoneSkipLast// to create bitmap graphics contexts without alpha info.CGContextRef context = CGBitmapContextCreate(NULL,width,height,kBitsPerComponent,0,colorspaceRef,bitmapInfo);if (context == NULL) {return image;}// Draw the image into the context and retrieve the new bitmap image without alphaCGContextDrawImage(context, CGRectMake(0, 0, width, height), imageRef);CGImageRef imageRefWithoutAlpha = CGBitmapContextCreateImage(context);UIImage *imageWithoutAlpha = [[UIImage alloc] initWithCGImage:imageRefWithoutAlpha scale:image.scale orientation:image.imageOrientation];CGContextRelease(context);CGImageRelease(imageRefWithoutAlpha);return imageWithoutAlpha;

GPU

相对于 CPU 来说,GPU 能干的事情比较单一:接收提交的纹理(Texture)和顶点描述(三角形),应用变换(transform)、混合并渲染,然后输出到屏幕上。通常你所能看到的内容,主要也就是纹理(图片)和形状(三角模拟的矢量图形)两类。

纹理的渲染

所有的 Bitmap,包括图片、文本、栅格化的内容,最终都要由内存提交到显存,绑定为 GPU Texture。不论是提交到显存的过程,还是 GPU 调整和渲染 Texture 的过程,都要消耗不少 GPU 资源。当在较短时间显示大量图片时(比如 TableView 存在非常多的图片并且快速滑动时),CPU 占用率很低,GPU 占用非常高,界面仍然会掉帧。避免这种情况的方法只能是尽量减少在短时间内大量图片的显示,尽可能将多张图片合成为一张进行显示。

当图片过大,超过 GPU 的最大纹理尺寸时,图片需要先由 CPU 进行预处理,这对 CPU 和 GPU 都会带来额外的资源消耗。目前来说,iPhone 4S 以上机型,纹理尺寸上限都是 4096×4096,更详细的资料可以看这里:iosres.com。所以,尽量不要让图片和视图的大小超过这个值。

视图的混合 (Composing)

当多个视图(或者说 CALayer)重叠在一起显示时,GPU 会首先把他们混合到一起。如果视图结构过于复杂,混合的过程也会消耗很多 GPU 资源。为了减轻这种情况的 GPU 消耗,应用应当尽量减少视图数量和层次,并在不透明的视图里标明 opaque 属性以避免无用的 Alpha 通道合成。当然,这也可以用上面的方法,把多个视图预先渲染为一张图片来显示。

  • 尽量避免短时间内大量图片的显示,尽可能将多张图片合成一张进行显示

  • GPU能处理的最大纹理尺寸是4096x4096,一旦超过这个尺寸,就会占用CPU资源进行处理,所以纹理尽量不要超过这个尺寸

  • GPU会将多个视图混合在一起再去显示,混合的过程会消耗CPU资源,尽量减少视图数量和层次

  • 减少透明的视图(alpha<1),不透明的就设置opaque为YES,GPU就不会去进行alpha的通道合成

  • 尽量避免出现离屏渲染

图形的生成。

CALayer 的 border、圆角、阴影、遮罩(mask),CASharpLayer 的矢量图形显示,通常会触发离屏渲染(offscreen rendering),而离屏渲染通常发生在 GPU 中。当一个列表视图中出现大量圆角的 CALayer,并且快速滑动时,可以观察到 GPU 资源已经占满,而 CPU 资源消耗很少。这时界面仍然能正常滑动,但平均帧数会降到很低。为了避免这种情况,可以尝试开启 CALayer.shouldRasterize 属性,但这会把原本离屏渲染的操作转嫁到 CPU 上去。对于只需要圆角的某些场合,也可以用一张已经绘制好的圆角图片覆盖到原本视图上面来模拟相同的视觉效果。最彻底的解决办法,就是把需要显示的图形在后台线程绘制为图片,避免使用圆角、阴影、遮罩等属性。

离屏渲染

这里特别说下离屏渲染,对 GPU 的资源消耗极大。 在OpenGL中,GPU有2种渲染方式,分别是屏幕渲染(On-Screen Rendering)离屏渲染(Off-Screen Rendering),区别在于渲染操作是在当前用于显示的屏幕缓冲区进行还是新开辟一个缓冲区进行渲染,渲染完成后再在当前显示的屏幕展示。

离屏渲染消耗性能的原因,在于需要创建新的缓冲区,并且在渲染的整个过程中,需要多次切换上下文环境,先是从当前屏幕(On-Screen)切换到离屏(Off-Screen);等到离屏渲染结束以后,将离屏缓冲区的渲染结果显示到屏幕上,又需要将上下文环境从离屏切换到当前屏幕,造成了资源的及大小消耗。

一些会触发离屏渲染的操作:

  • 光栅化,layer.shouldRasterize = YES

  • 遮罩,layer.mask

  • 圆角,同时设置layer.masksToBounds = YES、layer.cornerRadius大于0 考虑通过CoreGraphics绘制裁剪圆角,或者叫美工提供圆角图片

  • 阴影,layer.shadowXXX 如果设置了layer.shadowPath就不会产生离屏渲染

画圆角避免离屏渲染

CAShapeLayer与UIBezierPath(贝塞尔曲线)配合画圆角

- (void)drawCornerPicture{UIImageView *imageView = [[UIImageView alloc] initWithFrame:CGRectMake(200, 400, 200, 200)];imageView.image = [UIImage imageNamed:@"1"];// 开启图片上下文// UIGraphicsBeginImageContext(imageView.bounds.size);// 一般使用下面的方法UIGraphicsBeginImageContextWithOptions(imageView.bounds.size, NO, 0);// 绘制贝塞尔曲线UIBezierPath *bezierPath = [UIBezierPath bezierPathWithRoundedRect:imageView.bounds cornerRadius:100];// 按绘制的贝塞尔曲线剪切[bezierPath addClip];// 画图[imageView drawRect:imageView.bounds];// 获取上下文中的图片imageView.image = UIGraphicsGetImageFromCurrentImageContext();// 关闭图片上下文UIGraphicsEndImageContext();[self.view addSubview:imageView];
}

使用 Core Graphics 绘制圆角

- (void)circleImage{UIImageView *imageView = [[UIImageView alloc] initWithFrame:CGRectMake(200, 400, 200, 200)];imageView.image = [UIImage imageNamed:@"001.jpeg"];// NO代表透明UIGraphicsBeginImageContextWithOptions(imageView.bounds.size, NO, 0.0);// 获得上下文CGContextRef ctx = UIGraphicsGetCurrentContext();// 添加一个圆CGRect rect = CGRectMake(0, 0, imageView.bounds.size.width, imageView.bounds.size.height);CGContextAddEllipseInRect(ctx, rect);// 裁剪CGContextClip(ctx);// 将图片画上去
//    [imageView drawRect:rect];[imageView.image drawInRect:rect];imageView.image = UIGraphicsGetImageFromCurrentImageContext();// 关闭上下文UIGraphicsEndImageContext();[self.view addSubview:imageView];
}

查看离屏渲染,模拟器可以选中“Debug - Color Off-screen Rendered”开启调试,真机可以用Instruments检测,“Instruments - Core Animation - Debug Options - Color Offscreen-Rendered Yellow”开启调试,开启后,有离屏渲染的图层会变成高亮的黄色。

卡顿监控

Xcode 自带 Instruments

在开发阶段,可以直接使用 Instrument 来检测性能问题, Time Profiler 查看与 CPU 相关的耗时操作,Core Animation 查看与 GPU 相关的渲染操作。

FPS(CADisplayLink)

正常情况下,App的FPS只要保持在50~60之间,用户就不会感到界面卡顿。通过向主线程添加CADisplayLink我们可以接收到每次屏幕刷新的回调,从而统计出每秒屏幕刷新次数。这种方案最常见,例如 YYFPSLabel,且只用了CADisplayLink,实现成本较低,但由于只能在CPU空闲时才去回调,无法精确采集到卡顿时调用栈信息,可以在开发阶段作为辅助手段使用。

#import "YYFPSLabel.h"
//#import <YYKit/YYKit.h>
#import "YYText.h"
#import "YYWeakProxy.h"#define kSize CGSizeMake(55, 20)@implementation YYFPSLabel {CADisplayLink *_link;NSUInteger _count;NSTimeInterval _lastTime;UIFont *_font;UIFont *_subFont;NSTimeInterval _llll;
}- (instancetype)initWithFrame:(CGRect)frame {if (frame.size.width == 0 && frame.size.height == 0) {frame.size = kSize;}self = [super initWithFrame:frame];self.layer.cornerRadius = 5;self.clipsToBounds = YES;self.textAlignment = NSTextAlignmentCenter;self.userInteractionEnabled = NO;self.backgroundColor = [UIColor colorWithWhite:0.000 alpha:0.700];_font = [UIFont fontWithName:@"Menlo" size:14];if (_font) {_subFont = [UIFont fontWithName:@"Menlo" size:4];} else {_font = [UIFont fontWithName:@"Courier" size:14];_subFont = [UIFont fontWithName:@"Courier" size:4];}// 创建CADisplayLink并添加到主线程的RunLoop中_link = [CADisplayLink displayLinkWithTarget:[YYWeakProxy proxyWithTarget:self] selector:@selector(tick:)];[_link addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSRunLoopCommonModes];return self;
}- (void)dealloc {[_link invalidate];
}- (CGSize)sizeThatFits:(CGSize)size {return kSize;
}//刷新回调时去计算fps
- (void)tick:(CADisplayLink *)link {if (_lastTime == 0) {_lastTime = link.timestamp;return;}_count++;NSTimeInterval delta = link.timestamp - _lastTime;if (delta < 1) return;_lastTime = link.timestamp;float fps = _count / delta;_count = 0;CGFloat progress = fps / 60.0;UIColor *color = [UIColor colorWithHue:0.27 * (progress - 0.2) saturation:1 brightness:0.9 alpha:1];NSMutableAttributedString *text = [[NSMutableAttributedString alloc] initWithString:[NSString stringWithFormat:@"%d FPS",(int)round(fps)]];[text yy_setColor:color range:NSMakeRange(0, text.length - 3)];[text yy_setColor:[UIColor whiteColor] range:NSMakeRange(text.length - 3, 3)];text.yy_font = _font;[text yy_setFont:_subFont range:NSMakeRange(text.length - 4, 1)];self.attributedText = text;
}@end

RunLoop 监听

// 1.进入loop
__CFRunLoopRun(runloop, currentMode, seconds, returnAfterSourceHandled)// 2.RunLoop 即将触发 Timer 回调。
__CFRunLoopDoObservers(runloop, currentMode, kCFRunLoopBeforeTimers);
// 3.RunLoop 即将触发 Source0 (非port) 回调。
__CFRunLoopDoObservers(runloop, currentMode, kCFRunLoopBeforeSources);
// 4.RunLoop 触发 Source0 (非port) 回调。
sourceHandledThisLoop = __CFRunLoopDoSources0(runloop, currentMode, stopAfterHandle)
// 5.执行被加入的block等Source1事件
__CFRunLoopDoBlocks(runloop, currentMode);// 6.RunLoop 的线程即将进入休眠(sleep)。
__CFRunLoopDoObservers(runloop, currentMode, kCFRunLoopBeforeWaiting);// 7.调用 mach_msg 等待接受 mach_port 的消息。线程将进入休眠, 直到被下面某一个事件唤醒。
__CFRunLoopServiceMachPort(waitSet, &msg, sizeof(msg_buffer), &livePort)// 进入休眠// 8.RunLoop 的线程刚刚被唤醒了。
__CFRunLoopDoObservers(runloop, currentMode, kCFRunLoopAfterWaiting// 9.1.如果一个 Timer 到时间了,触发这个Timer的回调
__CFRunLoopDoTimers(runloop, currentMode, mach_absolute_time())// 9.2.如果有dispatch到main_queue的block,执行bloc__CFRUNLOOP_IS_SERVICING_THE_MAIN_DISPATCH_QUEUE__(msg);// 9.3.如果一个 Source1 (基于port) 发出事件了,处理这个事件
__CFRunLoopDoSource1(runloop, currentMode, source1, msg);// 10.RunLoop 即将退出
__CFRunLoopDoObservers(rl, currentMode, kCFRunLoopExit);

source0 处理的是 app 内部事件,包括 UI 事件,每次处理的开始和结束的耗时决定了当前页面刷新是否正常,即 kCFRunLoopBeforeSources 和 kCFRunLoopAfterWaiting 之间。因此创建一个子线程去监听主线程状态变化,通过dispatch_semaphore 在主线程进入上面两个状态时发送信号量,子线程设置超时时间循环等待信号量,若超过时间后还未接收到主线程发出的信号量则可判断为卡顿,此时可以保存当前调用栈信息作为后续分析依据,线上卡顿监控多采用这种方式,代码实现如下。

#pragma mark - 注册RunLoop观察者//在主线程注册RunLoop观察者
- (void)registerMainRunLoopObserver
{//监听每个步凑的回调CFRunLoopObserverContext context = {0, (__bridge void*)self, NULL, NULL};self.runLoopObserver = CFRunLoopObserverCreate(kCFAllocatorDefault,kCFRunLoopAllActivities,YES,0,&runLoopObserverCallBack,&context);CFRunLoopAddObserver(CFRunLoopGetMain(), self.runLoopObserver, kCFRunLoopCommonModes);
}//观察者方法
static void runLoopObserverCallBack(CFRunLoopObserverRef observer, CFRunLoopActivity activity, void *info)
{self.runLoopActivity = activity;//触发信号,说明开始执行下一个步骤。if (self.semaphore != nil){dispatch_semaphore_signal(self.semaphore);}
}#pragma mark - RunLoop状态监测//创建一个子线程去监听主线程RunLoop状态
- (void)createRunLoopStatusMonitor
{//创建信号self.semaphore = dispatch_semaphore_create(0);if (self.semaphore == nil){return;}//创建一个子线程,监测Runloop状态时长dispatch_async(dispatch_get_global_queue(0, 0), ^{while (YES){//如果观察者已经移除,则停止进行状态监测if (self.runLoopObserver == nil){self.runLoopActivity = 0;self.semaphore = nil;return;}//信号量等待。状态不等于0,说明状态等待超时//方案一->设置单次超时时间为500毫秒long status = dispatch_semaphore_wait(self.semaphore, dispatch_time(DISPATCH_TIME_NOW, 500 * NSEC_PER_MSEC));if (status != 0){if (self.runLoopActivity == kCFRunLoopBeforeSources || self.runLoopActivity == kCFRunLoopAfterWaiting){...//发生超过500毫秒的卡顿,此时去记录调用栈信息}}/*//方案二->连续5次卡顿50ms上报long status = dispatch_semaphore_wait(semaphore, dispatch_time(DISPATCH_TIME_NOW, 50*NSEC_PER_MSEC));if (status != 0){if (!observer){timeoutCount = 0;semaphore = 0;activity = 0;return;}if (activity==kCFRunLoopBeforeSources || activity==kCFRunLoopAfterWaiting){if (++timeoutCount < 5)continue;//保存调用栈信息}}timeoutCount = 0;*/}});
}

子线程Ping

根据卡顿发生时,主线程无响应的原理,创建一个子线程循环去Ping主线程,Ping之前先设卡顿置标志为True,再派发到主线程执行设置标志为False,最后子线程在设定的阀值时间内休眠结束后判断标志来判断主线程有无响应。该方法的监控准确性和性能损耗与ping频率成正比。
代码部分来源于 ANREye

private class AppPingThread: Thread {private let semaphore = DispatchSemaphore(value: 0)//判断主线程是否卡顿的标识private var isMainThreadBlock = falseprivate var threshold: Double = 0.4fileprivate var handler: (() -> Void)?func start(threshold:Double, handler: @escaping AppPingThreadCallBack) {self.handler = handlerself.threshold = thresholdself.start()}override func main() {while self.isCancelled == false {self.isMainThreadBlock = true//主线程去重置标识DispatchQueue.main.async {self.isMainThreadBlock = falseself.semaphore.signal()}Thread.sleep(forTimeInterval: self.threshold)//若标识未重置成功则说明再设置的阀值时间内主线程未响应,此时去做响应处理if self.isMainThreadBlock  {//采集卡顿调用栈信息self.handler?()}_ = self.semaphore.wait(timeout: DispatchTime.distantFuture)}}}

AsyncDisplayKit

AsyncDisplayKit 是 Facebook 开源的一个用于保持 iOS 界面流畅的库,我从中学到了很多东西,所以下面我会花较大的篇幅来对其进行介绍和分析。

ASDK 的由来

ASDK 的作者是 Scott Goodson (Linkedin),
他曾经在苹果工作,负责 iOS 的一些内置应用的开发,比如股票、计算器、地图、钟表、设置、Safari 等,当然他也参与了 UIKit framework 的开发。后来他加入 Facebook 后,负责 Paper 的开发,创建并开源了 AsyncDisplayKit。目前他在 Pinterest 和 Instagram 负责 iOS 开发和用户体验的提升等工作。

ASDK 自 2014 年 6 月开源,10 月发布 1.0 版。目前 ASDK 即将要发布 2.0 版。
V2.0 增加了更多布局相关的代码,ComponentKit 团队为此贡献很多。
现在 Github 的 master 分支上的版本是 V1.9.1,已经包含了 V2.0 的全部内容。

ASDK 的资料

想要了解 ASDK 的原理和细节,最好从下面几个视频开始:
2014.10.15 NSLondon – Scott Goodson – Behind AsyncDisplayKit
2015.03.02 MCE 2015 – Scott Goodson – Effortless Responsiveness with AsyncDisplayKit
2015.10.25 AsyncDisplayKit 2.0: Intelligent User Interfaces – NSSpain 2015
前两个视频内容大同小异,都是介绍 ASDK 的基本原理,附带介绍 POP 等其他项目。
后一个视频增加了 ASDK 2.0 的新特性的介绍。

除此之外,还可以到 Github Issues 里看一下 ASDK 相关的讨论,下面是几个比较重要的内容:
关于 Runloop Dispatch
关于 ComponentKit 和 ASDK 的区别
为什么不支持 Storyboard 和 Autolayout
如何评测界面的流畅度

之后,还可以到 Google Groups 来查看和讨论更多内容:
https://groups.google.com/forum/#!forum/asyncdisplaykit

ASDK 的基本原理

ASDK 认为,阻塞主线程的任务,主要分为上面这三大类。文本和布局的计算、渲染、解码、绘制都可以通过各种方式异步执行,但 UIKit 和 Core Animation 相关操作必需在主线程进行。ASDK 的目标,就是尽量把这些任务从主线程挪走,而挪不走的,就尽量优化性能。

为了达成这一目标,ASDK 尝试对 UIKit 组件进行封装:

这是常见的 UIView 和 CALayer 的关系:View 持有 Layer 用于显示,View 中大部分显示属性实际是从 Layer 映射而来;Layer 的 delegate 在这里是 View,当其属性改变、动画产生时,View 能够得到通知。UIView 和 CALayer 不是线程安全的,并且只能在主线程创建、访问和销毁。

ASDK 为此创建了 ASDisplayNode 类,包装了常见的视图属性(比如 frame/bounds/alpha/transform/backgroundColor/superNode/subNodes 等),然后它用 UIView->CALayer 相同的方式,实现了 ASNode->UIView 这样一个关系。

当不需要响应触摸事件时,ASDisplayNode 可以被设置为 layer backed,即 ASDisplayNode 充当了原来 UIView 的功能,节省了更多资源。

与 UIView 和 CALayer 不同,ASDisplayNode 是线程安全的,它可以在后台线程创建和修改。Node 刚创建时,并不会在内部新建 UIView 和 CALayer,直到第一次在主线程访问 view 或 layer 属性时,它才会在内部生成对应的对象。当它的属性(比如frame/transform)改变后,它并不会立刻同步到其持有的 view 或 layer 去,而是把被改变的属性保存到内部的一个中间变量,稍后在需要时,再通过某个机制一次性设置到内部的 view 或 layer。

通过模拟和封装 UIView/CALayer,开发者可以把代码中的 UIView 替换为 ASNode,很大的降低了开发和学习成本,同时能获得 ASDK 底层大量的性能优化。为了方便使用, ASDK 把大量常用控件都封装成了 ASNode 的子类,比如 Button、Control、Cell、Image、ImageView、Text、TableView、CollectionView 等。利用这些控件,开发者可以尽量避免直接使用 UIKit 相关控件,以获得更完整的性能提升。

ASDK 的图层预合成

有时一个 layer 会包含很多 sub-layer,而这些 sub-layer 并不需要响应触摸事件,也不需要进行动画和位置调整。ASDK 为此实现了一个被称为 pre-composing 的技术,可以把这些 sub-layer 合成渲染为一张图片。开发时,ASNode 已经替代了 UIView 和 CALayer;直接使用各种 Node 控件并设置为 layer backed 后,ASNode 甚至可以通过预合成来避免创建内部的 UIView 和 CALayer。

通过这种方式,把一个大的层级,通过一个大的绘制方法绘制到一张图上,性能会获得很大提升。CPU 避免了创建 UIKit 对象的资源消耗,GPU 避免了多张 texture 合成和渲染的消耗,更少的 bitmap 也意味着更少的内存占用。

ASDK 异步并发操作

自 iPhone 4S 起,iDevice 已经都是双核 CPU 了,现在的 iPad 甚至已经更新到 3 核了。充分利用多核的优势、并发执行任务对保持界面流畅有很大作用。ASDK 把布局计算、文本排版、图片/文本/图形渲染等操作都封装成较小的任务,并利用 GCD 异步并发执行。如果开发者使用了 ASNode 相关的控件,那么这些并发操作会自动在后台进行,无需进行过多配置

Runloop 任务分发

Runloop work distribution 是 ASDK 比较核心的一个技术,ASDK 的介绍视频和文档中都没有详细展开介绍,所以这里我会多做一些分析。如果你对 Runloop 还不太了解,可以看一下我之前的文章 深入理解RunLoop,里面对 ASDK 也有所提及。

iOS 的显示系统是由 VSync 信号驱动的,VSync 信号由硬件时钟生成,每秒钟发出 60 次(这个值取决设备硬件,比如 iPhone 真机上通常是 59.97)。iOS 图形服务接收到 VSync 信号后,会通过 IPC 通知到 App 内。App 的 Runloop 在启动后会注册对应的 CFRunLoopSource 通过 mach_port 接收传过来的时钟信号通知,随后 Source 的回调会驱动整个 App 的动画与显示。

Core Animation 在 RunLoop 中注册了一个 Observer,监听了 BeforeWaiting 和 Exit 事件。这个 Observer 的优先级是 2000000,低于常见的其他 Observer。当一个触摸事件到来时,RunLoop 被唤醒,App 中的代码会执行一些操作,比如创建和调整视图层级、设置 UIView 的 frame、修改 CALayer 的透明度、为视图添加一个动画;这些操作最终都会被 CALayer 捕获,并通过 CATransaction 提交到一个中间状态去(CATransaction 的文档略有提到这些内容,但并不完整)。当上面所有操作结束后,RunLoop 即将进入休眠(或者退出)时,关注该事件的 Observer 都会得到通知。这时 CA 注册的那个 Observer 就会在回调中,把所有的中间状态合并提交到 GPU 去显示;如果此处有动画,CA 会通过 DisplayLink 等机制多次触发相关流程。

ASDK 在此处模拟了 Core Animation 的这个机制:所有针对 ASNode 的修改和提交,总有些任务是必需放入主线程执行的。当出现这种任务时,ASNode 会把任务用 ASAsyncTransaction(Group) 封装并提交到一个全局的容器去。ASDK 也在 RunLoop 中注册了一个 Observer,监视的事件和 CA 一样,但优先级比 CA 要低。当 RunLoop 进入休眠前、CA 处理完事件后,ASDK 就会执行该 loop 内提交的所有任务。具体代码见这个文件:ASAsyncTransactionGroup。

通过这种机制,ASDK 可以在合适的机会把异步、并发的操作同步到主线程去,并且能获得不错的性能。

ASDK 中还有封装很多高级的功能,比如滑动列表的预加载、V2.0添加的新的布局模式等。ASDK 是一个很庞大的库,它本身并不推荐你把整个 App 全部都改为 ASDK 驱动,把最需要提升交互性能的地方用 ASDK 进行优化就足够了。

预排版

当获取到 API JSON 数据后,我会把每条 Cell 需要的数据都在后台线程计算并封装为一个布局对象 CellLayout。CellLayout 包含所有文本的 CoreText 排版结果、Cell 内部每个控件的高度、Cell 的整体高度。每个 CellLayout 的内存占用并不多,所以当生成后,可以全部缓存到内存,以供稍后使用。这样,TableView 在请求各个高度函数时,不会消耗任何多余计算量;当把 CellLayout 设置到 Cell 内部时,Cell 内部也不用再计算布局了。

对于通常的 TableView 来说,提前在后台计算好布局结果是非常重要的一个性能优化点。为了达到最高性能,你可能需要牺牲一些开发速度,不要用 Autolayout 等技术,少用 UILabel 等文本控件。但如果你对性能的要求并不那么高,可以尝试用 TableView 的预估高度的功能,并把每个 Cell 高度缓存下来。这里有个来自百度知道团队的开源项目可以很方便的帮你实现这一点:FDTemplateLayoutCell。

预渲染

微博的头像在某次改版中换成了圆形,所以我也跟进了一下。当头像下载下来后,我会在后台线程将头像预先渲染为圆形并单独保存到一个 ImageCache 中去。

对于 TableView 来说,Cell 内容的离屏渲染会带来较大的 GPU 消耗。在 Twitter Demo 中,我为了图省事儿用到了不少 layer 的圆角属性,你可以在低性能的设备(比如 iPad 3)上快速滑动一下这个列表,能感受到虽然列表并没有较大的卡顿,但是整体的平均帧数降了下来。用 Instument 查看时能够看到 GPU 已经满负荷运转,而 CPU 却比较清闲。为了避免离屏渲染,你应当尽量避免使用 layer 的 border、corner、shadow、mask 等技术,而尽量在后台线程预先绘制好对应内容。

异步绘制

我只在显示文本的控件上用到了异步绘制的功能,但效果很不错。我参考 ASDK 的原理,实现了一个简单的异步绘制控件。这块代码我单独提取出来,放到了这里:YYAsyncLayer。YYAsyncLayer 是 CALayer 的子类,当它需要显示内容(比如调用了 [layer setNeedDisplay])时,它会向 delegate,也就是 UIView 请求一个异步绘制的任务。在异步绘制时,Layer 会传递一个 BOOL(^isCancelled)() 这样的 block,绘制代码可以随时调用该 block 判断绘制任务是否已经被取消。

当 TableView 快速滑动时,会有大量异步绘制任务提交到后台线程去执行。但是有时滑动速度过快时,绘制任务还没有完成就可能已经被取消了。如果这时仍然继续绘制,就会造成大量的 CPU 资源浪费,甚至阻塞线程并造成后续的绘制任务迟迟无法完成。我的做法是尽量快速、提前判断当前绘制任务是否已经被取消;在绘制每一行文本前,我都会调用 isCancelled() 来进行判断,保证被取消的任务能及时退出,不至于影响后续操作。

目前有些第三方微博客户端(比如 VVebo、墨客等),使用了一种方式来避免高速滑动时 Cell 的绘制过程,相关实现见这个项目:VVeboTableViewDemo。它的原理是,当滑动时,松开手指后,立刻计算出滑动停止时 Cell 的位置,并预先绘制那个位置附近的几个 Cell,而忽略当前滑动中的 Cell。这个方法比较有技巧性,并且对于滑动性能来说提升也很大,唯一的缺点就是快速滑动中会出现大量空白内容。如果你不想实现比较麻烦的异步绘制但又想保证滑动的流畅性,这个技巧是个不错的选择。

全局并发控制

当我用 concurrent queue 来执行大量绘制任务时,偶尔会遇到这种问题:

大量的任务提交到后台队列时,某些任务会因为某些原因(此处是 CGFont 锁)被锁住导致线程休眠,或者被阻塞,concurrent queue 随后会创建新的线程来执行其他任务。当这种情况变多时,或者 App 中使用了大量 concurrent queue 来执行较多任务时,App 在同一时刻就会存在几十个线程同时运行、创建、销毁。CPU 是用时间片轮转来实现线程并发的,尽管 concurrent queue 能控制线程的优先级,但当大量线程同时创建运行销毁时,这些操作仍然会挤占掉主线程的 CPU 资源。ASDK 有个 Feed 列表的 Demo:SocialAppLayout,当列表内 Cell 过多,并且非常快速的滑动时,界面仍然会出现少量卡顿,我谨慎的猜测可能与这个问题有关。

使用 concurrent queue 时不可避免会遇到这种问题,但使用 serial queue 又不能充分利用多核 CPU 的资源。我写了一个简单的工具 YYDispatchQueuePool,为不同优先级创建和 CPU 数量相同的 serial queue,每次从 pool 中获取 queue 时,会轮询返回其中一个 queue。我把 App 内所有异步操作,包括图像解码、对象释放、异步绘制等,都按优先级不同放入了全局的 serial queue 中执行,这样尽量避免了过多线程导致的性能问题。

更高效的异步图片加载

SDWebImage 在这个 Demo 里仍然会产生少量性能问题,并且有些地方不能满足我的需求,所以我自己实现了一个性能更高的图片加载库。在显示简单的单张图片时,利用 UIView.layer.contents 就足够了,没必要使用 UIImageView 带来额外的资源消耗,为此我在 CALayer 上添加了 setImageWithURL 等方法。除此之外,我还把图片解码等操作通过 YYDispatchQueuePool 进行管理,控制了 App 总线程数量。

其他可以改进的地方

上面这些优化做完后,微博 Demo 已经非常流畅了,但在我的设想中,仍然有一些进一步优化的技巧,但限于时间和精力我并没有实现,下面简单列一下:

列表中有不少视觉元素并不需要触摸事件,这些元素可以用 ASDK 的图层合成技术预先绘制为一张图。

再进一步减少每个 Cell 内图层的数量,用 CALayer 替换掉 UIView。

目前每个 Cell 的类型都是相同的,但显示的内容却各部一样,比如有的 Cell 有图片,有的 Cell 里是卡片。把 Cell 按类型划分,进一步减少 Cell 内不必要的视图对象和操作,应该能有一些效果。

把需要放到主线程执行的任务划分为足够小的块,并通过 Runloop 来进行调度,在每个 Loop 里判断下一次 VSync 的时间,并在下次 VSync 到来前,把当前未执行完的任务延迟到下一个机会去。这个只是我的一个设想,并不一定能实现或起作用。

如何评测界面的流畅度

最后还是要提一下,“过早的优化是万恶之源”,在需求未定,性能问题不明显时,没必要尝试做优化,而要尽量正确的实现功能。做性能优化时,也最好是走修改代码 -> Profile -> 修改代码这样一个流程,优先解决最值得优化的地方。

如果你需要一个明确的 FPS 指示器,可以尝试一下 KMCGeigerCounter。对于 CPU 的卡顿,它可以通过内置的 CADisplayLink 检测出来;对于 GPU 带来的卡顿,它用了一个 1×1 的 SKView 来进行监视。这个项目有两个小问题:SKView 虽然能监视到 GPU 的卡顿,但引入 SKView 本身就会对 CPU/GPU 带来额外的一点的资源消耗;这个项目在 iOS 9 下有一些兼容问题,需要稍作调整。

我自己也写了个简单的 FPS 指示器:FPSLabel 只有几十行代码,仅用到了 CADisplayLink 来监视 CPU 的卡顿问题。虽然不如上面这个工具完善,但日常使用没有太大问题。

最后,用 Instuments 的 GPU Driver 预设,能够实时查看到 CPU 和 GPU 的资源消耗。在这个预设内,你能查看到几乎所有与显示有关的数据,比如 Texture 数量、CA 提交的频率、GPU 消耗等,在定位界面卡顿的问题时,这是最好的工具。

iOS开发~卡顿优化相关推荐

  1. iOS性能优化——卡顿优化

    作为开发者都想成为性能优化这方面的高手,因为所有App开发都离不开优化这个重要的环节,每个开发者开发出的产品都不可能一次做到最优,所以在产品上线前就需要不断的测试和优化,等到产品上线时避免一些闪退.卡 ...

  2. Android 性能优化(62)---存检测、卡顿优化、耗电优化、APK瘦身——详解篇

    Android 性能优化,内存检测.卡顿优化.耗电优化.APK瘦身--详解篇 自2008年智能时代开始,Android操作系统一路高歌,10年智能机发展之路,如今 Android 9.0 代号P  都 ...

  3. 深入探索Android卡顿优化(下)

    前言 成为一名优秀的Android开发,需要一份完备的知识体系,在这里,让我们一起成长为自己所想的那样~. 在上篇文章中,笔者带领大家学习了卡顿优化分析方法与工具.自动化卡顿检测方案及优化这两块内容. ...

  4. 深入探索Android卡顿优化

    由于卡顿优化这一主题包含的内容太多,为了更详细地进行讲解,因此,笔者将它分为了上.下两篇.本篇,即为<深入探索Android卡顿优化>的上篇. 本篇包含的主要内容如下所示: 卡顿优化分析方 ...

  5. Android性能优化系列篇(三):崩溃优化+卡顿优化

    前言 汇总了一下众多大佬的性能优化文章,知识点,主要包含: UI优化/启动优化/崩溃优化/卡顿优化/安全性优化/弱网优化/APP深度优化等等等~ 本篇是第三篇:崩溃优化+卡顿优化 [非商业用途,如有侵 ...

  6. Android卡顿优化分析

    本篇包含的主要内容如下所示: 1.卡顿优化分析方法与工具 2.自动化卡顿检测方案及优化 在某个 App 的时候,有时我们会看到某个 App 运行起来,即出现了卡现象,如何去定义发生了卡现象呢?马上来了 ...

  7. scum开服务器延迟高怎么办,人渣SCUM卡顿优化方法 人渣SCUM卡顿怎么办

    人渣SCUM卡顿优化方法 人渣SCUM卡顿怎么办 2018-09-05 09:32:15来源:SCUM吧/fabulis编辑:野狐禅评论(0) <人渣SCUM>卡顿不流畅怎么优化呢?不少玩 ...

  8. Android 卡顿优化之 Skipped * frames 掉帧的计算

    Android 卡顿优化之 Skipped * frames 掉帧的计算 有时候看日志的时候,可能会在日志中看到类似下文的打印: Skipped 30 frames! The application ...

  9. 饥荒服务器怎么显示帧数,进化游戏卡顿优化方法 附最高帧数120帧设置方法

    作为一款次时代游戏,进化卡顿问题是可以想象的,特别是如果你选择操作怪物的话,有时候进食或者走路都会出现莫名其妙的卡顿,下面小编就给大家各位A卡和N卡玩家提供一个优化卡顿问题的方法,希望能够缓解大家的卡 ...

最新文章

  1. Linux0.11 创建进程的过程分析--fork函数的使用
  2. Python多线程学习
  3. Perlin Noise algorithms(备忘)
  4. PHP制作订货,PHP生成订单号的两种方法
  5. java邮件附件名称乱码_Javamail 中附件中文名字乱码祥解 (转)
  6. python内存回收垃圾有哪些_[Python之路] 内存管理垃圾回收
  7. flash事件冒泡的理解
  8. Android 音频系统:从 AudioTrack 到 AudioFlinger(全)
  9. ubuntu 10.04 下驱动程序的hello word
  10. Unix/Linux shell脚本编程学习--Shell Script II
  11. 访问php页面显示源码解决方法
  12. java组件及事件处理(13)--ActionListener监听算平方数
  13. 让学前端不再害怕英语单词(四)
  14. Html网页设计-羽毛球网站设计
  15. idea各种中文乱码问题
  16. 某些软件中,输入法无法输入中文
  17. ESXi 6.7 ubuntu GPU直连踩坑记
  18. 镜像底层原理详解和基于Docker file创建镜像
  19. icon font阿里巴巴矢量图引进文件 css
  20. LSS(Lift, Splat, Shoot)测试

热门文章

  1. IMX6UL 功能评估测试 视频
  2. 2020年欧洲UX和设计会议已更新
  3. 硬盘“文件或目录损坏且无法读取”或“参数错误”的解决办法
  4. 金蝶KIS 引出数据WPS EXCEL 空白 所见即所得引出空白 电脑上安装了WPS 不想卸载 引出的时候用Office也是空表
  5. javafx 报错javafx.fxml.LoadException:和Caused by: java.lang.NullPointerException
  6. 免费开源一套充电桩Saas系统 含源码 V2.3.1
  7. 538. 把二叉搜索树转换为累加树(java)
  8. Proe使用 tips
  9. NetCore中带图片的word转html (NPOI.Word2Html)
  10. v24.03 鸿蒙内核源码分析(进程概念) | 如何更好的理解进程 | 百篇博客分析HarmonyOS源码