内容简介:本篇文章会结合Flutter源码给大家介绍一下渲染流水线最后一步的绘制(paint)阶段。本文涉及的内容可能离大家平时开发Flutter app所需要知道的框架知识相对于前面几章会跟遥远一些。目前可能需要注意的地方就是我们都知道,Flutter框架中render tree负责布局和渲染。在渲染的时候,Flutter会遍历需要重绘的函数这么简单了,而是很多地方都涉及到layer tree的管理。
本篇文章会结合Flutter源码给大家介绍一下渲染流水线最后一步的绘制(paint)阶段。本文涉及的内容可能离大家平时开发Flutter app所需要知道的框架知识相对于前面几章会跟遥远一些。目前可能需要注意的地方就是 RepaintBoundary
这个 Widget
,其对应的 RenderObject
是 RenderRepaintBoundary
。这个 Widget
的作用在介绍完渲染流水线的绘制阶段相信大家会有一个更明确的理解。
概述
我们都知道,Flutter框架中render tree负责布局和渲染。在渲染的时候,Flutter会遍历需要重绘的 RenderObject
子树来逐一绘制。我们在屏幕上看到的Flutter app页面其实是由不同的图层(layers)组合(compsite)而成的。这些图层是以树的形式组织起来的,也就是我们在Flutter中见到的又一个比较重要的树:layer tree。
paint()
函数这么简单了,而是很多地方都涉及到layer tree的管理。
Layer
Flutter中的图层用类 Layer
来代表。
abstract class Layer extends AbstractNode with DiagnosticableTreeMixin { @override ContainerLayer get parent => super.parent; Layer get nextSibling => _nextSibling; Layer _nextSibling; Layer get previousSibling => _previousSibling; Layer _previousSibling; } 复制代码
类 Layer
是个抽象类,和 RenderObject
一样,继承自 AbstractNode
。表明它也是个树形结构。属性 parent
代表其父节点,类型是 ContainerLayer
。这个类继承自 Layer
。只有 ContainerLayer
类型及其子类的图层可以拥有孩子,其他类型的 Layer
子类都是叶子图层。 nextSibling
和 previousSibling
表示同一图层的前一个和后一个兄弟节点,也就是图层孩子节点们是用双向链表存储的。
class ContainerLayer extends Layer { Layer _firstChild; Layer _lastChild; void append(Layer child) { adoptChild(child); child._previousSibling = lastChild; if (lastChild != null) lastChild._nextSibling = child; _lastChild = child; _firstChild ??= child; } void _removeChild(Layer child) { if (child._previousSibling == null) { _firstChild = child._nextSibling; } else { child._previousSibling._nextSibling = child.nextSibling; } if (child._nextSibling == null) { _lastChild = child.previousSibling; } else { child.nextSibling._previousSibling = child.previousSibling; } child._previousSibling = null; child._nextSibling = null; dropChild(child); } void removeAllChildren() { Layer child = firstChild; while (child != null) { final Layer next = child.nextSibling; child._previousSibling = null; child._nextSibling = null; dropChild(child); child = next; } _firstChild = null; _lastChild = null; } } 复制代码
ContainerLayer
增加了头和尾两个孩子节点属性,并提供了新增及删除孩子节点的方法。
ContainerLayer
的子类有 OffsetLayer
, ClipRectLayer
等等。
叶子类型的图层有 TextureLayer
, PlatformViewLayer
, PerformanceOverlayLayer
, PictureLayer
等等,框架中大部分 RenderObject
的绘制的目标图层都是 PictureLayer
。
class PictureLayer extends Layer { final Rect canvasBounds; ui.Picture _picture; } 复制代码
属性 canvasBounds
代表图层画布的边界,但这个属性是建议性质的。 属性 picture
来自 dart:ui
库。
分析
回到我们熟悉的 drawFrame()
函数中, pipelineOwner.flushLayout()
调用完成以后渲染流水线就进入了绘制(paint)阶段。
void drawFrame() { pipelineOwner.flushLayout(); pipelineOwner.flushCompositingBits(); pipelineOwner.flushPaint(); renderView.compositeFrame(); // this sends the bits to the GPU pipelineOwner.flushSemantics(); // this also sends the semantics to the OS. } 复制代码
绘制阶段的第一个调用是 pipelineOwner.flushCompositingBits()
。
pipelineOwner.flushCompositingBits()
这个调用是用来更新render tree 中 RenderObject
的 _needsCompositing
标志位的。
在介绍这个调用之前我们,我们先来了解一些 RenderObject
的标志位。
bool _needsCompositing
:标志自身或者某个孩子节点有合成层(compositing layer)。如果当前节点需要合成,那么所有祖先节点也都需要合成。
bool _needsCompositingBitsUpdate
:标志当前节点是否需要更新 _needsCompositing
。这个标志位由下方的 markNeedsCompositingBitsUpdate()
函数设置。
bool get isRepaintBoundary => false;
:标志当前节点是否与父节点分开来重绘。当这个标志位为 true
的时候,父节点重绘的时候子节点不一定也需要重绘,同样的,当自身重绘的时候父节点不一定需要重绘。此标志位为 true
的 RenderObject
有render tree的根节点 RenderView
,有我们熟悉的 RenderRepaintBoundary
, TextureBox
等。
bool get alwaysNeedsCompositing => false;
:标志当前节点是否总是需要合成。这个标志位为 true
的话意味着当前节点绘制的时候总是会新开合成层(composited layer)。例如 TextureBox
, 以及我们熟悉的显示运行时性能的 RenderPerformanceOverlay
等。
在渲染流水线的构建阶段,有些情况下render tree里的节点需要重新更新 _needsCompositing
,比如说render tree里节点的增加,删除。这个标记工作由函数 markNeedsCompositingBitsUpdate()
完成。
void markNeedsCompositingBitsUpdate() { if (_needsCompositingBitsUpdate) return; _needsCompositingBitsUpdate = true; if (parent is RenderObject) { final RenderObject parent = this.parent; if (parent._needsCompositingBitsUpdate) return; if (!isRepaintBoundary && !parent.isRepaintBoundary) { parent.markNeedsCompositingBitsUpdate(); return; } } if (owner != null) owner._nodesNeedingCompositingBitsUpdate.add(this); } 复制代码
这个调用会从当前节点往上找,把所有父节点的 _needsCompositingBitsUpdate
标志位都置位 true
。直到自己或者父节点的 isRepaintBoundary
为 true
。最后会把自己加入到 PipelineOwner
的 _nodesNeedingCompositingBitsUpdate
列表里面。而函数调用 pipelineOwner.flushCompositingBits()
正是用来处理这个列表的。
flushCompositingBits()
源码如下:
void flushCompositingBits() { _nodesNeedingCompositingBitsUpdate.sort((RenderObject a, RenderObject b) => a.depth - b.depth); for (RenderObject node in _nodesNeedingCompositingBitsUpdate) { if (node._needsCompositingBitsUpdate && node.owner == this) node._updateCompositingBits(); } _nodesNeedingCompositingBitsUpdate.clear(); } 复制代码
首先把列表 _nodesNeedingCompositingBitsUpdate
按照节点在树中的深度排序。然后遍历调用 node._updateCompositingBits()
void _updateCompositingBits() { if (!_needsCompositingBitsUpdate) return; final bool oldNeedsCompositing = _needsCompositing; _needsCompositing = false; visitChildren((RenderObject child) { child._updateCompositingBits(); if (child.needsCompositing) _needsCompositing = true; }); if (isRepaintBoundary || alwaysNeedsCompositing) _needsCompositing = true; if (oldNeedsCompositing != _needsCompositing) markNeedsPaint(); _needsCompositingBitsUpdate = false; } 复制代码
这里做的事情是从当前节点往下找,如果某个子节点 isRepaintBoundary
为 true
或 alwaysNeedsCompositing
为 true
则设置 _needsCompositing
为 true
。子节点这个标志位为 true
的话,那么父节点的该标志位也会被设置为 true
。如果 _needsCompositing
发生了变化,那么会调用 markNeedsPaint()
通知渲染流水线本 RenderObject
需要重绘了。为啥要重绘呢?原因是本``RenderObject`所在的图层(layer)可能发生了变化。
pipelineOwner.flushPaint()
函数 flushPaint()
处理的是之前加入到列表 _nodesNeedingPaint
里的节点。当某个 RenderObject
需要被重绘的时候会调用 markNeedsPaint()
void markNeedsPaint() { if (_needsPaint) return; _needsPaint = true; if (isRepaintBoundary) { if (owner != null) { owner._nodesNeedingPaint.add(this); owner.requestVisualUpdate(); } } else if (parent is RenderObject) { final RenderObject parent = this.parent; parent.markNeedsPaint(); } else { if (owner != null) owner.requestVisualUpdate(); } } 复制代码
函数 markNeedsPaint()
首先做的是把自己的标志位 _needsPaint
设置为 true
。然后会向上查找最近的一个 isRepaintBoundary
为 true
的祖先节点。直到找到这样的节点,才会把这个节点加入到 _nodesNeedingPaint
列表中,也就是说,并不是任意一个需要重绘的 RenderObject
就会被加入这个列表,而是往上找直到找到最近的一个 isRepaintBoundary
为 true
才会放入这个列表,换句话说,这个列表里只有 isRepaintBoundary
为 true
这种类型的节点。也就是说重绘的起点是从“重绘边界”开始的。
void flushPaint() { try { final List<RenderObject> dirtyNodes = _nodesNeedingPaint; _nodesNeedingPaint = <RenderObject>[]; // Sort the dirty nodes in reverse order (deepest first). for (RenderObject node in dirtyNodes..sort((RenderObject a, RenderObject b) => b.depth - a.depth)) { if (node._needsPaint && node.owner == this) { if (node._layer.attached) { PaintingContext.repaintCompositedChild(node); } else { node._skippedPaintingOnLayer(); } } } } finally { ... } } 复制代码
在处理需要重绘的节点的时候,会先给这些节点做个排序,这里需要注意的是,和之前 flushLayout()
里的 排序 不同,这里的排序是深度度深的节点在前。在循环体里,会判断当前节点的 _layer
属性是否处于 attached
的状态。如果 _layer.attached
为 true
的话调用 PaintingContext.repaintCompositedChild(node);
去做绘制,否则的话调用 node._skippedPaintingOnLayer()
将自身以及到上层绘制边界之间的节点的 _needsPaint
全部置为 true
。这样在下次 _layer.attached
变为 true
的时候会直接绘制。
从上述代码也可以看出,重绘边界相当于把Flutter的绘制做了分块处理,重绘的从上层重绘边界开始,到下层重绘边界为止,在此之间的 RenderObject
都需要重绘,而边界之外的就可能不需要重绘,这也是一个性能上的考虑,尽量避免不必要的绘制。所以如何合理安排 RepaintBoundary
是我们在做Flutter app的性能优化时候需要考虑的一个方向。
这里的 _layer
属性就是我们之前说的图层,这个属性只有绘制边界的 RenderObject
才会有值。一般的 RenderObject
这个属性是 null
。
static void _repaintCompositedChild( RenderObject child, { bool debugAlsoPaintedParent = false, PaintingContext childContext, }) { if (child._layer == null) { child._layer = OffsetLayer(); } else { child._layer.removeAllChildren(); } childContext ??= PaintingContext(child._layer, child.paintBounds); child._paintWithContext(childContext, Offset.zero); childContext.stopRecordingIfNeeded(); } 复制代码
函数 _repaintCompositedChild()
会先检查 RenderObject
的图层属性,为空则新建一个 OffsetLayer
实例。如果图层已经存在的话就把孩子清空。
如果没有 PaintingContext
的话会新建一个,然后让开始绘制。我们先来看一下 PaintingContext
这个类:
class PaintingContext extends ClipContext { @protected PaintingContext(this._containerLayer, this.estimatedBounds) final ContainerLayer _containerLayer; final Rect estimatedBounds; PictureLayer _currentLayer; ui.PictureRecorder _recorder; Canvas _canvas; @override Canvas get canvas { if (_canvas == null) _startRecording(); return _canvas; } void _startRecording() { _currentLayer = PictureLayer(estimatedBounds); _recorder = ui.PictureRecorder(); _canvas = Canvas(_recorder); _containerLayer.append(_currentLayer); } void stopRecordingIfNeeded() { if (!_isRecording) return; _currentLayer.picture = _recorder.endRecording(); _currentLayer = null; _recorder = null; _canvas = null; } 复制代码
类 PaintingContext
字面意思是绘制上下文,其属性 _containerLayer
是容器图层,来自构造时的入参。也就是说 PaintingContext
是和容器图层关联的。接下来还有 PictureLayer
类型的 _currentLayer
属性, ui.PictureRecorder
类型的 _recorder
属性和我们熟悉的 Canvas
类型的属性 _canvas
。函数 _startRecording()
实例化了这几个属性。 _recorder
用来录制绘制命令, _canvas
绑定一个录制器。最后, _currentLayer
会作为子节点加入到 _containerLayer
中。有开始那么就会有结束, stopRecordingIfNeeded()
用来结束当前绘制的录制。结束时会把绘制完毕的 Picture
赋值给当前的 PictureLayer.picture
。
有了 PaintingContext
以后,就可以调用 RenderObject._paintWithContext()
开始绘制了,这个函数会直接调用到我们熟悉的 RenderObject.paint(context, offset)
,我们知道函数 paint()
由 RenderObject
子类自己实现。从之前的源码分析我们知道绘制起点都是“绘制边界”。这里我们就拿我们熟悉的一个“绘制边界”, RenderRepaintBoundary
,为例来走一下绘制流程,它的绘制函数的实现在 RenderProxyBoxMixin
类中:
@override void paint(PaintingContext context, Offset offset) { if (child != null) context.paintChild(child, offset); } 复制代码
这个调用又回到了 PaintingContext
的 paintChild()
方法:
void paintChild(RenderObject child, Offset offset) { if (child.isRepaintBoundary) { stopRecordingIfNeeded(); _compositeChild(child, offset); } else { child._paintWithContext(this, offset); } } 复制代码
这里会检查子节点是不是绘制边界,如果不是的话,就是普通的绘制了,接着往下调用 _paintWithContext()
,继续往当前的 PictureLayer
上绘制。如果是的话就把当前的绘制先停掉。然后调用 _compositeChild(child, offset);
void _compositeChild(RenderObject child, Offset offset) { if (child._needsPaint) { repaintCompositedChild(child, debugAlsoPaintedParent: true); } child._layer.offset = offset; appendLayer(child._layer); } 复制代码
如果这个子绘制边界被标记为需要重绘的话,那么就调用 repaintCompositedChild()
来重新生成图层然后重绘。如果这个子绘制边界 没有 被标记为需要重绘的话,就跳过了重新生成图层和重绘。最后只需要把子图层加入到当前容器图层中就行了。
上面说的是子节点是绘制边界的时候的绘制流程,那如果子节点是普通的一个 RenderObject
呢?这里就拿Flutter app出错控件的绘制做个例子:
void paint(PaintingContext context, Offset offset) { try { context.canvas.drawRect(offset & size, Paint() .. color = backgroundColor); double width; if (_paragraph != null) { // See the comment in the RenderErrorBox constructor. This is not the // code you want to be copying and pasting. :-) if (parent is RenderBox) { final RenderBox parentBox = parent; width = parentBox.size.width; } else { width = size.width; } _paragraph.layout(ui.ParagraphConstraints(width: width)); context.canvas.drawParagraph(_paragraph, offset); } } catch (e) { // Intentionally left empty. } } 复制代码
这看起来就像个正常的绘制了,我们会用来自 PaintingContext
的画布 canvas
来绘制矩形,绘制文本等等。从前面的分析也可以看出,这里的绘制都是在一个 PictureLayer
的图层上所做的。
至此 pipelineOwner.flushPaint();
这个函数的调用就跑完了,通过分析我们可以知道,绘制工作其实主要是在这个函数中完成的。接下来我们再来看一下绘制流程的最后一个重要的函数调用:
renderView.compositeFrame()
这里的 renderView
就是我们之前说的render tree的根节点。这个函数调用主要是把整个layer tree生成 scene
送到engine去显示。
void compositeFrame() { try { final ui.SceneBuilder builder = ui.SceneBuilder(); final ui.Scene scene = layer.buildScene(builder); if (automaticSystemUiAdjustment) _updateSystemChrome(); _window.render(scene); scene.dispose(); } finally { Timeline.finishSync(); } } 复制代码
ui.SceneBuilder()
最终调用Native方法 SceneBuilder_constructor
。也就是说 ui.SceneBuilder
实例是由engine创建的。接下来就是调用 layer.buildScene(builder)
方法,这个方法会返回一个 ui.Scene
实例。由于方法 compositeFrame()
的调用者是 renderView
。所以这里这个 layer
是来自 renderView
的属性,我们前面说过只有绘制边界节点才有 layer
。所以可见render tree的根节点 renderView
也是一个绘制边界。那么这个 layer
是从哪里来的呢?在文章《Flutter框架分析(二)-- 初始化》我们讲过,框架初始化的过程中 renderView
会调度开天辟地的第一帧:
void scheduleInitialFrame() { scheduleInitialLayout(); scheduleInitialPaint(_updateMatricesAndCreateNewRootLayer()); owner.requestVisualUpdate(); } Layer _updateMatricesAndCreateNewRootLayer() { _rootTransform = configuration.toMatrix(); final ContainerLayer rootLayer = TransformLayer(transform: _rootTransform); rootLayer.attach(this); return rootLayer; } void scheduleInitialPaint(ContainerLayer rootLayer) { _layer = rootLayer; owner._nodesNeedingPaint.add(this); } 复制代码
在方法 _updateMatricesAndCreateNewRootLayer()
中,我们看到这里实例化了一个 TransformLayer
。 TransformLayer
继承自 OffsetLayer
。构造时需要传入 Matrix4
类型的参数 transform
。这个 Matrix4
其实和我们在Android中见到的 Matrix
是一回事。代表着矩阵变换。这里的 transform
来自我们之前讲过的 ViewConfiguration
,它就是把设备像素比例转化成了矩阵的形式。最终这个 layer
关联上了 renderView
。所以这里这个 TransformLayer
其实也是layer tree的根节点了。
回到我们的绘制流程。 layer.buildScene(builder);
这个调用我们自然是去 TransformLayer
里找了,但这个方法是在其父类 OffsetLayer
内,从这个调用开始就都是对图层进行操作,最终把layer tree转换为场景 scene
:
ui.Scene buildScene(ui.SceneBuilder builder) { List<PictureLayer> temporaryLayers; updateSubtreeNeedsAddToScene(); addToScene(builder); final ui.Scene scene = builder.build(); return scene; } 复制代码
函数调用 updateSubtreeNeedsAddToScene();
会遍历layer tree来设置 _subtreeNeedsAddToScene
标志位,如果有任意子图层的添加、删除操作,则该子图层及其祖先图层都会被置上 _subtreeNeedsAddToScene
标志位。然后会调用addToScene(builder);
@override @override ui.EngineLayer addToScene(ui.SceneBuilder builder, [ Offset layerOffset = Offset.zero ]) { _lastEffectiveTransform = transform; final Offset totalOffset = offset + layerOffset; if (totalOffset != Offset.zero) { _lastEffectiveTransform = Matrix4.translationValues(totalOffset.dx, totalOffset.dy, 0.0) ..multiply(_lastEffectiveTransform); } builder.pushTransform(_lastEffectiveTransform.storage); addChildrenToScene(builder); builder.pop(); return null; // this does not return an engine layer yet. } 复制代码
builder.pushTransform
会调用到engine层。相当于告诉engine这里我要加一个变换图层。然后调用 ddChildrenToScene(builder)
将子图层加入场景中,完了还要把之前压栈的变换图层出栈。
void addChildrenToScene(ui.SceneBuilder builder, [ Offset childOffset = Offset.zero ]) { Layer child = firstChild; while (child != null) { if (childOffset == Offset.zero) { child._addToSceneWithRetainedRendering(builder); } else { child.addToScene(builder, childOffset); } child = child.nextSibling; } } 复制代码
这就是遍历添加子图层的调用。主要还是逐层向下的调用 addToScene()
。这个方法不同的图层会有不同的实现,对于容器类图层而言,主要就是做三件事:1.添加自己图层的效果然后入栈,2.添加子图层,3. 出栈。
在所有图层都处理完成之后。回到 renderView.compositeFrame()
,可见最后会把处理完得到的场景通过 _window.render(scene);
调用送入engine去显示了。
至此渲染流水线的绘制(paint)阶段就算是跑完了。
等等,好像缺了点什么,在分析绘制的过程中我们看到有个主要的调用 pipelineOwner.flushCompositingBits()
是在更新render tree里节点的 _needsCompositing
标志位的。但是我们这都把流程说完了,貌似没有看到这个标志位在哪里用到啊。这个标志位肯定在哪里被用到了,否则我们费这么大劲更新有啥用呢?回去再研究一下代码......
这个标志位某些 RenderObject
在其 paint()
函数中会用到,作用呢,就体现在 PaintingContext
的这几个函数的调用上了:
void pushClipRect(bool needsCompositing, Offset offset, Rect clipRect, PaintingContextCallback painter, { Clip clipBehavior = Clip.hardEdge }) { final Rect offsetClipRect = clipRect.shift(offset); if (needsCompositing) { pushLayer(ClipRectLayer(clipRect: offsetClipRect, clipBehavior: clipBehavior), painter, offset, childPaintBounds: offsetClipRect); } else { clipRectAndPaint(offsetClipRect, clipBehavior, offsetClipRect, () => painter(this, offset)); } } void pushClipRRect(bool needsCompositing, Offset offset, Rect bounds, RRect clipRRect, PaintingContextCallback painter, { Clip clipBehavior = Clip.antiAlias }) { final Rect offsetBounds = bounds.shift(offset); final RRect offsetClipRRect = clipRRect.shift(offset); if (needsCompositing) { pushLayer(ClipRRectLayer(clipRRect: offsetClipRRect, clipBehavior: clipBehavior), painter, offset, childPaintBounds: offsetBounds); } else { clipRRectAndPaint(offsetClipRRect, clipBehavior, offsetBounds, () => painter(this, offset)); } } void pushClipPath(bool needsCompositing, Offset offset, Rect bounds, Path clipPath, PaintingContextCallback painter, { Clip clipBehavior = Clip.antiAlias }) { final Rect offsetBounds = bounds.shift(offset); final Path offsetClipPath = clipPath.shift(offset); if (needsCompositing) { pushLayer(ClipPathLayer(clipPath: offsetClipPath, clipBehavior: clipBehavior), painter, offset, childPaintBounds: offsetBounds); } else { clipPathAndPaint(offsetClipPath, clipBehavior, offsetBounds, () => painter(this, offset)); } } void pushTransform(bool needsCompositing, Offset offset, Matrix4 transform, PaintingContextCallback painter) { final Matrix4 effectiveTransform = Matrix4.translationValues(offset.dx, offset.dy, 0.0) ..multiply(transform)..translate(-offset.dx, -offset.dy); if (needsCompositing) { pushLayer( TransformLayer(transform: effectiveTransform), painter, offset, childPaintBounds: MatrixUtils.inverseTransformRect(effectiveTransform, estimatedBounds), ); } else { canvas ..save() ..transform(effectiveTransform.storage); painter(this, offset); canvas ..restore(); } } 复制代码
needsCompositing
作为这几个函数的入参,从代码可见其作用主要是控制这几种特殊的绘制操作的具体实现方式,如果 needsCompositing
为 true
的话,则会调用 pushLayer
,参数我们之前见过的各种图层
void pushLayer(ContainerLayer childLayer, PaintingContextCallback painter, Offset offset, { Rect childPaintBounds }) { stopRecordingIfNeeded(); appendLayer(childLayer); final PaintingContext childContext = createChildContext(childLayer, childPaintBounds ?? estimatedBounds); painter(childContext, offset); childContext.stopRecordingIfNeeded(); } @protected PaintingContext createChildContext(ContainerLayer childLayer, Rect bounds) { return PaintingContext(childLayer, bounds); } 复制代码
流程基本上和我们之前看到的重绘的时候新增一个图层的操作是一样的。
而如果 needsCompositing
为 false
的话则走的是 canvas
的各种变换了。大家感兴趣的话可以去看一下源码,这里就不细说了。
总结
至此Flutter框架渲染流水线的绘制(paint)阶段就分析完了。绘制流程并不像之前的构建,布局流程那样直接,只要遍历element tree或者render tree就行了。渲染阶段会出现另一个树,图层树,layer tree。整个绘制流程就是在把render tree转化为适合的layer tree,最后再生成场景(scene)的一个过程。
最后,在了解渲染过程的基础上,推荐大家再看一下这个来自Google工程师的视频: 深入了解 Flutter 的高性能图形渲染 。相信大家在看过这个视频之后,会对Flutter框架的渲染,以及可能遇到的一些性能问题会有进一步的理解。
以上就是本文的全部内容,希望本文的内容对大家的学习或者工作能带来一定的帮助,也希望大家多多支持 码农网
猜你喜欢:- javascript – 2D图形框架如Pixi.js如何使画布绘制更快?
- ViewGroup 默认顺序绘制子 View,如何修改?什么场景需要修改绘制顺序?
- Shader 绘制基础图形
- css绘制特殊图形
- View 绘制流程分析
- CSS图形绘制总结
本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们。
Apache Tomcat 6高级编程
Vivek Chopra、Sing Li、Jeff Genender / 人民邮电出版社 / 2009-3 / 79.00元
《Apache Tomcat 6高级编程》全面介绍了安装、配置和运行Apache Tomcat服务器的知识。书中不仅提供了配置选项的逐行分析,还探究了Tomcat的特性和功能,可以帮助读者解决出现在系统管理的各个阶段的各种问题,包括共享主机、安全、系统测试和性能测试及调优。 《Apache Tomcat 6高级编程》重点讲解Tomcat 6的应用知识。从基本的Tomcat和Web应用程序配置......一起来看看 《Apache Tomcat 6高级编程》 这本书的介绍吧!