drawRect & 内存 -> 深究

本文深入探讨了在 iOS 开发中随意覆盖 drawRect 方法可能导致的内存暴增问题,通过分析一个具体案例,揭示了内存消耗的真正原因,并提出了解决方案。重点介绍了 CALayer 的作用、drawRect 方法背后的机制以及如何使用 CAShapeLayer 进行优化,以确保 App 性能稳定。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

转载自:http://bihongbo.com/2016/01/03/memoryGhostdrawRect/

在平日的开发中,随意覆盖drawRect方法,稍有不慎就会让你的程序内存暴增。下面我们来看一个例子。

去年的某天午后,北京的雾霾依旧像现在这样醇厚,我的同事辉哥像往常一样与我楼下约烟。我见辉哥表情凝重,便询问究竟。辉哥做了一个画板功能,但是苦于内存问题一直得不到解决。画板功能很简单,就是记录手指触摸的轨迹然后绘制在屏幕上。下面我们来看一张效果图:

效果图效果图

如图我们看到左侧内存的状况随着手指的绘制逐渐恶化。另外细心的同学可以观察到,点击图中蓝色矩形按钮之后,便会弹出画板,而这时并没有进行任何的手指绘制,内存就突变为114MB,然后每当手指绘制开始时,内存立即增加到300MB左右稳定下来。对于正常的iOS App来讲,这么大的内存消耗是不能容忍的。

下面分析一下原因:
可能的原因有两个,一是在手指绘制的过程中创建的大量点对象没有及时释放或者其他资源没有及时释放。
二是系统在绘制的过程中开始大量消耗内存。

第一个原因,手指绘制的过程中创建的大量点对象没有及时释放或者其他资源没有及时释放。这一点我们暂时排除以节省时间,因为这个画板功能工程是用ARC写的,并且我们已经做过代码检查和使用Instruments工具来检测内存使用情况,这里并没有所谓的对象没有及时释放的问题存在。

第二个原因,系统在绘制的过程中开始大量消耗内存。首先我们曾经注意到一个诡异并且不寻常的事情就是,当黄色的画板刚刚弹出的时候内存就瞬间从18MB暴增至114MB。这一点更加说明第一个原因不是问题所在,因为这时手指还没有进行任何绘制,也就是说不存在任何点与线的对象,那么内存怎么会暴增呢?

这时我们要考虑这个画板功能是如何实现的,画板分为两步,第一步记录用户手指的轨迹,这一步会生成大量点的对象(已排除嫌疑)。第二步绘制到视图或者图层上,我们平常使用频繁的绘图方式基本上是Quarz2D的那套C语言框架,而绘制代码所在的地点在哪呢?我们今天的主角终于上场了--drawRect

下面我们来看一段画板功能绘制的代码:

- (void)drawRect:(CGRect)rect
{
    if (!self.paths.count) return; CGContextRef ctx = UIGraphicsGetCurrentContext(); for (BHBPaintPath *path in self.paths) { CGContextSaveGState(ctx); [[UIColor blackColor] set]; [path stroke]; //关键的一步绘制 CGContextRestoreGState(ctx); } } 

去掉绘图上下文栈和其余判断边界的代码,我们只是在当前view上绘制了n条黑色的线。看起来普普通通的绘图方式,怎么会导致内存的剧增呢?我们现在说罪魁祸首是drawRect证据并不充分。我们回想画板刚弹出时的内存状况,接下来我们注释掉drawRect所有的代码。运行的效果图如下:

效果图1效果图1

效果立竿见影,注释掉drawRect之后,内存立刻恢复正常,我们终于抓到了消耗内存的恶鬼,问题就出在对drawRect方法的覆盖。那么抓到了犯人,本文是否应该完结了?非也非也,我们虽说知道了内存暴增的原因,但是我们并没有深入的去分析drawRect为什么对内存的影响这么大,而且我们也没有给出问题的解决方案。请接着往下看。

那么现在我们分析一下drawRect导致内存暴增的真正原因:

重写drawRect为何会导致内存大量上涨?

要想搞明白这个问题,我们需要撸一撸在iOS程序上图形显示的原理。在iOS系统中所有显示的视图都是从基类UIView继承而来的,同时UIView负责接收用户交互。但是实际上你所看到的视图内容,包括图形等,都是由UIView的一个实例图层属性来绘制和渲染的,那就是CALayer

CALayer类的概念与UIView非常类似,它也具有树形的层级关系,并且可以包含图片文本、背景色等。它与UIView最大的不同在于它不能响应用户交互,可以说它根本就不知道响应链的存在,它的API虽然提供了“某点是否在图层范围内的方法”,但是它并不具有响应的能力。

在每一个UIView实例当中,都有一个默认的支持图层,UIView负责创建并且管理这个图层。实际上这个CALayer图层才是真正用来在屏幕上显示的UIView仅仅是对它的一层封装,实现了CALayerdelegate,提供了处理事件交互的具体功能,还有动画底层方法的高级API。

可以说CALayerUIView的内部实现细节。

脑补了这么多,它与今天的主题drawRect有何关系呢?别着急,我们既然已经确定CALayer才是最终显示到屏幕上的,只要顺藤摸瓜,即可分析清楚。CALayer其实也只是iOS当中一个普通的类,它也并不能直接渲染到屏幕上,因为屏幕上你所看到的东西,其实都是一张张图片。而为什么我们能看到CALayer的内容呢,是因为CALayer内部有一个contents属性。contents默认可以传一个id类型的对象,但是只有你传CGImage的时候,它才能够正常显示在屏幕上。所以最终我们的图形渲染落点落在contents身上如图。

效果图2效果图2

contents也被称为寄宿图,除了给它赋值CGImage之外,我们也可以直接对它进行绘制,绘制的方法正是这次问题的关键,通过继承UIView并实现-drawRect:方法即可自定义绘制。-drawRect: 方法没有默认的实现,因为对UIView来说,寄宿图并不是必须的,UIView不关心绘制的内容。如果UIView检测到-drawRect:方法被调用了,它就会为视图分配一个寄宿图,这个寄宿图的像素尺寸等于视图大小乘以contentsScale(这个属性与屏幕分辨率有关,我们的画板程序在不同模拟器下呈现的内存用量不同也是因为它)的值。

那么回到我们的画板程序,当画板从屏幕上出现的时候,因为重写了-drawRect:方法,-drawRect :方法就会自动调用。生成一张寄宿图后,方法里面的代码利用Core Graphics去绘制n条黑色的线,然后内容就会缓存起来,等待下次你调用-setNeedsDisplay时再进行更新。

画板视图的-drawRect:方法的背后实际上都是底层的CALayer进行了重绘和保存中间产生的图片,CALayerdelegate属性默认实现了CALayerDelegate协议,当它需要内容信息的时候会调用协议中的方法来拿。当画板视图重绘时,因为它的支持图层CALayer的代理就是画板视图本身,所以支持图层会请求画板视图给它一个寄宿图来显示,它此刻会调用:

(void)displayLayer:(CALayer *)layer;

如果画板视图实现了这个方法,就可以拿到layer来直接设置contents寄宿图,如果这个方法没有实现,支持图层CALayer会尝试调用:

- (void)drawLayer:(CALayer *)layer inContext:(CGContextRef)ctx; 

这个方法调用之前,CALayer创建了一个合适尺寸的空寄宿图(尺寸由boundscontentsScale决定)和一个Core Graphics的绘制上下文环境,为绘制寄宿图做准备,它作为ctx参数传入。在这一步生成的空寄宿图内存是相当巨大的,它就是本次内存问题的关键,一旦你实现了CALayerDelegate协议中的-drawLayer:inContext:方法或者UIView中的-drawRect:方法(其实就是前者的包装方法),图层就创建了一个绘制上下文,这个上下文需要的内存可从这个公式得出:图层宽*图层高*4字节,宽高的单位均为像素。而我们的画板程序因为要支持像猿题库一样两指挪动的效果,我们开辟的画板大小为:

_myDrawer = [[BHBMyDrawer alloc] initWithFrame:CGRectMake(0, 0, SCREEN_SIZE.width*5, SCREEN_SIZE.height*2)];

我们的画板程序的画板视图它在iPhone6s plus机器上的上下文内存量就是1920*2*1080*5*4字节相当于79MB内存,图层每次重绘的时候都需要重新抹掉内存然后重新分配。它就是我们画板程序内存暴增的真正原因。

最终我们将内存暴增的原因找出来了,那么我们有没有合理的解决方案呢?

我认为最合理的办法处理类似于画板这样画线条的需求直接用专有图层CAShapeLayer。让我们看看它是什么:

CAShapeLayer是一个通过矢量图形而不是bitmap来绘制的图层子类。用CGPath来定义想要绘制的图形,CAShapeLayer会自动渲染。它可以完美替代我们的直接使用Core Graphics绘制layer,对比之下使用CAShapeLayer有以下优点:

  • 渲染快速。CAShapeLayer使用了硬件加速,绘制同一图形会比用Core Graphics快很多。
  • 高效使用内存。一个CAShapeLayer不需要像普通CALayer一样创建一个寄宿图形,所以无论有多大,都不会占用太多的内存。
  • 不会被图层边界剪裁掉。
  • 不会出现像素化。

所以最终我们的画板程序使用CAShapeLayer来实现线条的绘制,性能非常稳定,效果图如下:

效果图3效果图3

总结一下绘制性能优化原则:

  • 1.绘制图形性能的优化最好的办法就是不去绘制。
  • 2.利用专有图层代替绘图需求。
  • 3.不得不用到绘图尽量缩小视图面积,并且尽量降低重绘频率。
  • 4.异步绘制,推测内容,提前在其他线程绘制图片,在主线程中直接设置图片。

本文最后一个效果图为仿写猿题库练题画板功能,demo请在github搜索BHBDrawBoarderDemo。或者直接戳这里

补充:

答疑

我的这篇拙作是因为同事辉哥的离职让我想起来去把当初和他解决的比较有意思的问题梳理总结一下,写的并不甚完美,通过公众号的回复和微博的回复私信等等,我收集了读者大大们的问题,对这些问题我会以我的能力一一尝试解答,如果有错误的地方还请不吝指出。

1.内存没有问题啊,为什么我们的代码内存不会暴增?

首先检讨一下,我在原文当中第一个内存暴增例子可能过于简单不够详尽。其实内存的增长与绘图视图的大小和屏幕分辨率有直接关系,你可以尝试将视图范围扩大,并且使用iPhone 6 Plus或者Ipad等高分辨率机型进行测试。由于我们的绘图程序支持两只手指拖动绘图版(很多人忽略了这个功能,导致很多内容存在误解,建议大家用真机玩一下这个功能),实际的绘图区域要比屏幕大出很多倍,所以内存增长特别明显,这也是我们对drawRect使用不好的例子,如果你使用的相当谨慎,内存是不会有很严重的问题(也许只有几MB),但是我们有能力去优化它,就应该尽量让它运行的更好!另外我在github上的BHBDrawBorderDemo仓库上传了一个比较明显的暴增例子,使用drawRectCAShapeLayer的代码都有,demo很简单就是弹出一个视图,在这个视图上画2个矩形如下图,你可以在调试的时候非常方便的切换,代码戳这里

效果图0效果图0

另外下面这张图是我自己在不同机型两种方案的对比结果(对比的对象只是屏幕大小的视图,我们的绘图程序会达到300M的内存是因为视图的大小是屏幕的好多倍),你可以亲自拿我的Demo试试。
效果图1效果图1

2.drawRect里面加上super调用行不行?

这个问题我实在不想解释,但是又怕有人误解,只好提一下。

同学,苹果官方文档是这样说的:

If you subclass UIView directly, your implementation of this method does not need to call super.

3.无所谓啊,其实没那么大影响,谈不上什么恶鬼!

好吧,其实我看见这样的回复的时候我就知道,你肯定是个大牛,可是还是有很多人特别是新人一开始并不会注意到这一点,他们可能没有类似的经验去保持警惕。我把标题定的这么吓人也是想给新人朋友们提个醒,drawRect用的时候要注意下内存问题。另外我也没有说过让大家弃用drawRect,我在文章结尾处已经明确说了,原则是尽量不去绘制,如果不能避免,要尽可能的减小绘制的范围,系统虽然会帮你创建比较合适的范围,也就是说不一定你创建多大的视图,图形上下文就有多大。但是别忘了,只要你设置一下背景颜色,布满view的寄宿图就会让你的图形上下文占用内存达到峰值。

4.drawRect一般在什么场景下用?

其实与问题3一样,尽量别用它,你可以试试我的Demo,就算你在里面什么都不画,它也会占用内存,而且这个内存量还不少。所以,你即使优化的再好,也不如不去绘制。当然了,很多需求用专有图层替代也很麻烦,如果影响不大,而且你对内存的把握很高或者不在乎,你可以直接使用drawRect

5.drawRect可以用算法来优化,CAShapeLayer性能也不是最优的。

首先感谢两位朋友的指教,恕笔者才疏学浅,笔者回头查了很久也没有查到相关的资料或者博客,比CAShapeLayer性能还要好的我也就能想到OpenGLES也不知道对不对,比较好奇,如果不忙加我微博@毕洪博咱们联系讨论一下~~







<think>好的,我现在需要分析用户提供的关于Qt事件过滤器的代码片段,并解释其原理。用户给出的代码是一个`eventFilter`函数,位于`MainWindow`类中。这个函数的作用是过滤特定对象的事件,特别是当`ui->widget`触发`Paint`事件时,调用`paintwidget()`方法。 首先,我要理解事件过滤器在Qt中的工作机制。事件过滤器允许对象拦截和处理其他对象的事件。通常通过`installEventFilter`将某个对象的事件传递到另一个对象的`eventFilter`方法中进行处理。用户可能在`MainWindow`中为`ui->widget`安装了事件过滤器,以便在主窗口中对这个widget的绘制事件进行自定义处理。 接下来,我需要确认代码中的各个部分。函数`bool MainWindow::eventFilter(QObject *watched, QEvent *event)`的参数是`watched`(被监视的对象)和`event`(发生的事件)。代码首先检查`watched`是否等于`ui->widget`,并且事件类型是否为`QEvent::Paint`。如果条件满足,调用`paintwidget()`方法,然后返回`QWidget::eventFilter(watched, event)`的结果。 这里可能存在几个需要注意的地方。首先,检查事件类型是否正确,`QEvent::Paint`是否正确拼写,实际应为`QEvent::Paint`,不过用户代码中写的是正确的。其次,`paintwidget()`是否是MainWindow的成员函数,是否实现了实际的绘制逻辑。另外,返回值是否正确处理了父类的事件过滤器。用户代码中返回的是`QWidget::eventFilter(watched, event)`,但`MainWindow`继承自`QMainWindow`,而`QMainWindow`的基类是`QWidget`,所以这里是否正确?或者是否应该返回基类`MainWindow`的父类的事件过滤器结果? 可能需要指出,通常在没有进一步处理的情况下,应该返回基类的`eventFilter`,但此处用户调用的是`QWidget::eventFilter`,而如果`MainWindow`的基类(如`QMainWindow`)没有重写`eventFilter`,那么调用`QWidget::eventFilter`是可行的,因为`QMainWindow`继承自`QWidget`。不过更准确的写法可能是`return QMainWindow::eventFilter(watched, event);`,但具体取决于实际继承结构。 另一个问题是,当处理Paint事件时,在事件过滤器中调用`paintwidget()`是否合适。通常,绘制应该在widget的`paintEvent`方法中处理,而不是在事件过滤器中直接触发绘制函数。这样做可能会绕过Qt的正常绘制流程,导致问题,比如重复绘制或者绘制顺序错误。 此外,当事件过滤器返回`true`时,表示事件已被处理,不再传递;返回`false`则继续传递事件。用户代码中返回的是基类的`eventFilter`的结果,但基类(QWidget)的`eventFilter`默认返回`false`,除非有安装其他事件过滤器。如果用户在这里处理了Paint事件,并希望事件继续传递,可能需要返回`false`,或者在处理之后调用基类的实现。但用户的代码中在条件满足时调用了`paintwidget()`,然后返回基类的返回值,这可能不正确,因为Paint事件已经被处理,是否需要阻止默认的绘制?或者是否应该在调用`paintwidget()`后让默认的绘制继续执行? 例如,正确的做法可能是,在事件过滤器中拦截Paint事件,执行自定义绘制,然后阻止事件进一步传递(返回`true`),或者执行自定义绘制后允许默认处理(返回`false`并确保基类处理)。如果用户在这里返回基类的`eventFilter`,而基类可能没有处理Paint事件,那么事件将继续传递到widget自己的`paintEvent`方法,导致两次绘制,这可能不是预期的效果。 因此,用户的代码可能存在逻辑错误,即在处理Paint事件后,是否需要阻止默认的绘制。如果用户希望完全替代默认的绘制,应该在事件过滤器中处理完Paint事件后返回`true`,这样widget自身的`paintEvent`不会被调用。如果用户希望在自己的处理之外仍然执行默认的绘制,那么应该返回`false`,或者在处理之后调用widget的`paintEvent`。 另外,`paintwidget()`函数的具体实现也需要考虑,是否正确使用`QPainter`,是否在正确的上下文中进行绘制。例如,在事件过滤器中直接进行绘制操作,可能需要获取widget的绘制设备,或者通过其他方式触发绘制,而不是直接调用一个函数。 总结来说,用户的代码意图是在`ui->widget`的Paint事件发生时执行自定义的绘制函数`paintwidget()`,但可能存在事件处理流程的问题,导致绘制被多次调用,或者未正确阻止默认绘制,从而产生视觉上的问题。正确的做法应确保在事件过滤器中正确处理事件,并根据需要决定是否阻止事件的进一步传递。</think>--- ### 一、代码功能解析 以下代码实现了对 `ui->widget` 控件绘制事件(Paint事件)的监听与拦截: ```cpp bool MainWindow::eventFilter(QObject *watched, QEvent *event) { if(watched == ui->widget && event->type() == QEvent::Paint) { paintwidget(); // 自定义绘制操作 } return QWidget::eventFilter(watched, event); } ``` --- ### 二、核心原理分步说明 #### 1. 事件过滤器注册 - **前置条件**:需通过 `ui->widget->installEventFilter(this)` 将 `MainWindow` 注册为 `ui->widget` 的事件监听者。 - **作用范围**:所有发生在 `ui->widget` 上的事件(如鼠标、键盘、绘制事件)会先传递到 `MainWindow::eventFilter`。 #### 2. 事件拦截逻辑 - **条件判断** `if(watched == ui->widget && event->type() == QEvent::Paint)` 筛选目标对象为 `ui->widget` 且事件类型为绘制事件。 - **自定义绘制** `paintwidget()` 方法中应包含实际的绘制代码(需自行实现)。 #### 3. 事件传递控制 - **返回值逻辑** - 返回 `true`:事件被完全拦截,`ui->widget` 自身的 `paintEvent` 不会被调用。 - 返回 `false` 或基类返回值:事件继续传递,触发控件的默认绘制行为。 - **当前代码问题**:`return QWidget::eventFilter(...)` 默认返回 `false`,导致 **绘制事件被重复处理**(`paintwidget()` 和 `ui->widget` 自身绘制同时执行)。 --- ### 三、典型问题修正 #### 场景1:完全替代默认绘制 ```cpp if(watched == ui->widget && event->type() == QEvent::Paint) { paintwidget(); // 执行自定义绘制 return true; // 拦截事件,阻止控件自身绘制 } ``` #### 场景2:叠加绘制 ```cpp if(watched == ui->widget && event->type() == QEvent::Paint) { QWidget::eventFilter(watched, event); // 先执行默认绘制 paintwidget(); // 再叠加自定义绘制 return true; // 终止事件传递 } ``` --- ### 四、关键注意事项 1. **绘制上下文安全** `paintwidget()` 中必须使用 `QPainter` 在目标控件上绘图: ```cpp void MainWindow::paintwidget() { QPainter painter(ui->widget); // 绑定到目标控件 painter.drawRect(...); } ``` 2. **多线程限制** 事件过滤器运行在主线程,禁止在非GUI线程中操作绘制。 3. **性能优化** 避免在 `eventFilter` 中频繁处理高开销操作,可通过 `update()` 延迟重绘请求。 --- ### 五、正确实现流程图 ```mermaid sequenceDiagram participant EventLoop participant MainWindow participant ui->widget EventLoop ->> MainWindow: 传递Paint事件 MainWindow ->> MainWindow: eventFilter判断 alt 匹配目标控件和事件类型 MainWindow ->> MainWindow: 执行paintwidget() MainWindow ->> EventLoop: 返回true(拦截) else MainWindow ->> ui->widget: 传递事件 ui->widget ->> ui->widget: 执行默认paintEvent() end ``` --- **总结**:此代码通过事件过滤器拦截绘制事件,但需明确是否需要完全替代或叠加默认绘制。返回值控制是核心关键点,错误处理会导致绘制重叠或失效。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值