Canvas2D渲染庫簡析:(三)Pixi

fabric和konva主要是用於實現編輯器的場景,而Pixi則是一個高性能2D動畫渲染庫,一般用於一些H5的小遊戲或可交互頁面。git

本次經過如下幾個方面來對其進行分析:github

  • WebGL與Canvas渲染器
  • 資源加載器與紋理
  • 場景、精靈與圖形對象
  • 變換、交互及動畫處理

系列目錄canvas

Pixi

Pixi是一個基於WebGL Renderer的高性能跨平臺渲染庫。其中默認使用WebGL相關插件(回退使用CanvasRenderer)去渲染2D圖形,而且在資源加載和動畫處理方面也有比較好的設計和優化。數組

本文所用的Pixi版本爲5.2.0。瀏覽器

在使用Pixi前,須要建立一個Application對象,做爲最外層的應用對象。markdown

Application是Pixi中統領全局的對象,其中包含了使用的渲染器(render)、舞臺(stage)、安裝的插件等主要屬性及操做器。app

export class Application {
    constructor(options)
    {
        // 處理配置
        options = Object.assign({
            forceCanvas: false,
        }, options);
        // 初始化渲染器
        this.renderer = autoDetectRenderer(options);
        // 初始化舞臺容器
        this.stage = new Container();
        // 安裝插件
        Application._plugins.forEach((plugin) =>
        {
            plugin.init.call(this, options);
        });
    }
    // ...
}
複製代碼

提供的方法也是從stage和renderer對象中取得的屬性或其餘操做,如view(), screen()等。框架

渲染器

能夠看到在App的建立過程當中,會根據當前環境選擇可用的渲染器。編輯器

默認採用WebGLRenderer,若當前瀏覽器環境不支持WebGL則使用Canvas。根據渲染方式初始化對應的renderer函數

  • WenGL: WebGLRenderer
  • Canvas: CanvasRenderer

這兩種渲染器均實現自AbstractRenderer類,在這個類中保存了渲染器所的綁定的canvas元素、設置透明度與分辨率等屬性。

WebGLRenderer

packages/core/src/Renderer

在WebGLRenderer的初始化過程當中,會在Renderer類上註冊不一樣類型的系統插件(均繼承自System類),如上下文插件(ContextSystem)、着色器插件(ShaderSystem)、紋理插件(TextureSystem)等等,而且在註冊系統插件時會插入表明不一樣階段的生命週期鉤子(runner: prerender | postrender | resize | update | contextChange),

來看看System這個類,其實很簡單,就是用一個於在renderer類上擴展相關屬性與方法的類。

export class System {
    constructor(renderer) {
        this.renderer = renderer;
    }
    destroy() {
        this.renderer = null;
    }
}
複製代碼

這些System插件主要有:

  • GeometrySystem - 管理VAO(VertexArrayObject)數據的相關操做及緩衝區(buffer)操做
  • StateSystem - 當前WebGL狀態機,處理offset、blend和depth test等狀態
  • ShaderSystem - 管理頂點與片元着色器,如其中attribute和uniform屬性的操做,也有常規的解析shader和綁定program等過程
  • MaskSystem - 管理圖形遮罩,按照指定幾何圖形的範圍顯示紋理圖像
  • FilterSystem - 管理濾鏡,處理紋理變換

做爲一個renderer,最重要的方法便是它的render()方法,它的執行過程(省去了生命週期函數)以下:

render(displayObject, renderTexture, clear, transform, skipUpdateTransform) {
    // 1. 應用變換(GPU級別)
    this.projection.transform = transform;
    // 2. 渲染紋理綁定與BatchRendering處理
    this.renderTexture.bind(renderTexture);
    this.batch.currentRenderer.start();
    // 3. 執行元素渲染,將頂點、索引和紋理等數據添加到BatchRendering中
    displayObject.render();
    // 4. 執行renderer的繪製方法
    this.batch.currentRenderer.flush();
    // 根據傳入的clear與renderTexture參數對紋理的處理...
    // 5. 清空變換
    this.projection.transform = null;
}
複製代碼

有關渲染的工做主要由BatchSystem插件負責執行,BatchRenderer

CanvasRenderer

packages/canvas/canvas-renderer/src/CanvasRenderer

較WebGLRenderer的實現比較簡單,在構建函數中並無加載其餘插件,僅初始化了一些屬性,如mask與blendMode等,

CanvasRenderer的render()執行流程以下:

render(displayObject, renderTexture, clear, transform, skipUpdateTransform) {
    const context = this.context;
    // 1. 當前狀態壓入狀態棧
    context.save();
    // 2. 初始化變換及樣式屬性
    context.setTransform(1, 0, 0, 1, 0, 0);
    context.globalAlpha = 1;
    this._activeBlendMode = BLEND_MODES.NORMAL;
    this._outerBlend = false;
    context.globalCompositeOperation = this.blendModes[BLEND_MODES.NORMAL];
    // 3.執行元素渲染
    const tempContext = this.context;
    this.context = context;
    displayObject.renderCanvas(this);
    this.context = tempContext;
    // 4. 從狀態棧恢復以前狀態
    context.restore();
}
複製代碼

場景、精靈與圖形

場景 - Stage

Stage本質是一個Container對象,與Konva中的概念相似。

Pixi的Container是一種DisplayObject容器,負責children的管理、變換的應用及包圍盒(bounds)計算。Container中能夠包含精靈(Sprite)或圖形(Graphic)對象,實現分組的效果,須要注意的是在Container應用的變換會做用到全部子元素上。

DisplayObject是顯示的基礎元素,其中包含元素的變換矩陣、alpha係數和層級係數等屬性及相關數據操做的方法,每一個繼承它的類的對象要想渲染出來必須實現它的_render方法。

精靈 - Sprite

Pixi中的精靈(Sprite)爲一種可交互的紋理對象,繼承自Container類,所以也能夠嵌套其餘DisplayObject對象,造成圖形樹。

Sprite類中包含用於頂點計算和目標檢測等方法,用於爲渲染提供關鍵數據及爲交互事件的處理提供輔助方法等。

vertex的計算

calculateVertices() {
    const texture = this._texture;
    // 1. 解析變換矩陣
    const wt = this.transform.worldTransform;
    const tx = wt.tx;
    // ...
    // 2. 計算當前區域
    const vertexData = this.vertexData;
    const anchor = this._anchor;
    let w1 = -anchor._x * orig.width;
    let w0 = w1 + orig.width;
    let h1 = -anchor._y * orig.height;
    let h0 = h1 + orig.height;

    // 3. 計算經過世界變換後的四個頂點座標
    vertexData[0] = (a * w1) + (c * h1) + tx;
    vertexData[1] = (d * h1) + (b * w1) + ty;
    // ...
}
複製代碼

判斷點是否在該精靈的區域中

containsPoint(point) {
    // 1. 在世界空間上應用逆變換獲得模型空間座標
    this.worldTransform.applyInverse(point, tempPoint);
    // 2. 經過紋理與錨點計算精靈幾何屬性
    const width = this._texture.orig.width;
    const height = this._texture.orig.height;
    const x1 = -width * this.anchor.x;
    let y1 = 0;
    // 3. 判斷是否位於對象區域
    if (tempPoint.x >= x1 && tempPoint.x < x1 + width) {
        y1 = -height * this.anchor.y;
        if (tempPoint.y >= y1 && tempPoint.y < y1 + height) {
            return true;
        }
    }
    return false;
}
複製代碼

在Sprite類中默認使用BatchRenderer對精靈進行渲染,BatchRenderer爲WebGLRenderer中的一個插件,用於記錄相關數據,統一執行繪製(flush)。

// 經過修改該pluginName屬性設置負責渲染該精靈的插件
this.pluginName = 'batch';
_render(renderer) {
    this.calculateVertices();
    renderer.batch.setObjectRenderer(renderer.plugins[this.pluginName]);
    renderer.plugins[this.pluginName].render(this);
}
複製代碼

圖形 - Graphic

在場景中除了加載紋理圖像生成的精靈外,還能夠經過常規或自定義的幾何圖形來添加圖形對象,

Graphic中提供相似CanvasContext上的繪圖API,好比drawRect、drawCircle等,將這些基礎圖形的數據通過處理後(如三角化),再使用WebGL的API進行繪製。Graphic一樣繼承自Container類。

// packages/graphics/src/Graphics.js
drawRect(x, y, width, height) {
    return this.drawShape(new Rectangle(x, y, width, height));
}
複製代碼

對於每種圖形,除了保存關鍵屬性外,還實現一些輔助方法,如點與圖形的碰撞檢測函數等:

// packages/math/src/shapes/Rectangle.ts
contains(x: number, y: number): boolean {
    if (this.width <= 0 || this.height <= 0) { return false; }
    if (x >= this.x && x < this.x + this.width) {
        if (y >= this.y && y < this.y + this.height) { return true; }
    }
    return false;
}
複製代碼

Pixi對於曲線圖形並無提供碰撞檢測的方法,若須要實現吸附點操做之類的功能只能自定義一些hitDetect的方法,或在外面使用isPointInStroke這類API。

在Graphics對象的geometry屬性中存儲緩衝區中使用的幾何數據,在drawShape時會將圖形數據及樣式屬性打包成GraphicsData對象添加到當前的圖形數組中,用於以後的實際繪製。

// packages/graphics/src/GraphicsGeometry.js
drawShape(shape, fillStyle, lineStyle, matrix)
{
    const data = new GraphicsData(shape, fillStyle, lineStyle, matrix);
    this.graphicsData.push(data);
    this.dirty++;
    return this;
}
複製代碼

在繪製(更新batch指令、執行填充)時,會計算圖形的頂點位置並將三角化後的頂點數據及索引添加到Geometry對象的頂點數組中。

// packages/graphics/src/utils/buildRectangle
// 1. 頂點座標計算
build() {
  points.push(x, y,
    x + width, y,
    x + width, y + height,
    x, y + height);
}

// 2. 圖形三角化,插入頂點數據及三角形頂點索引,用於以後繪製
triangulate() {
  const vertPos = verts.length / 2;
  verts.push(points[0], points[1],
      points[2], points[3],
      points[6], points[7],
      points[4], points[5]);
  graphicsGeometry.indices.push(vertPos, vertPos + 1, vertPos + 2,
      vertPos + 1, vertPos + 2, vertPos + 3);
}
複製代碼

Graphic在執行渲染時會經過圖形的batchable屬性來決定是使用BatchRender仍是DirectRender的方式:

_render(renderer) {
    // 多邊形對象繪製(本質是PathDrawing)
    this.finishPoly();
    // 讀取geometry,生成batch數據
    const geometry = this.geometry;
    geometry.updateBatches();
    // 執行渲染
    if (geometry.batchable) {
        // 判斷batch數據是否須要更新
        if (this.batchDirty !== geometry.batchDirty) {
            this._populateBatches();
        }
        // 執行BatchRender
        this._renderBatched(renderer);
    } else {
        renderer.batch.flush();
        // 執行DirectRender
        this._renderDirect(renderer);
    }
}
複製代碼

其中BatchRender與精靈中渲染的方式相似,均爲調用BatchSystem執行繪製,在以前須要一些頂點與索引計算等工做。DirectRender中也比較簡單,設置了渲染着色器,執行geometry中存儲的drawCalls渲染指令。

_renderDirect(renderer) {
    // 設置uniform
    uniforms.translationMatrix = this.transform.worldTransform;
    uniforms.tint[0] = (((tint >> 16) & 0xFF) / 255) * worldAlpha;
    uniforms.tint[1] = (((tint >> 8) & 0xFF) / 255) * worldAlpha;
    uniforms.tint[2] = ((tint & 0xFF) / 255) * worldAlpha;
    uniforms.tint[3] = worldAlpha;
    // 設置着色器及狀態
    renderer.shader.bind(shader);
    renderer.geometry.bind(geometry, shader);
    renderer.state.set(this.state);
    // 解析存儲的繪製指令,執行渲染
    for (let i = 0, l = drawCalls.length; i < l; i++) {   
        this._renderDrawCallDirect(renderer, geometry.drawCalls[i]);
    }
}
複製代碼

資源加載器與紋理

資源加載器 - Loader

Pixi的應用場景中多數都須要加載圖像或音頻資源,如其餘遊戲框架同樣,所以具備專門的Loader工具對資源進行處理。

Pixi中使用了resource-loader這個庫來在內部處理資源加載,將其封裝爲通用的資源加載類Loader及紋理加載類TextureLoader。

在TextureLoader中只作了一件事,在加載完成的回調中判斷若資源爲Image類型,則經過resource生成Texture對象並添加到texture屬性

export class TextureLoader {
    static use(resource, next) {
        if (resource.data && resource.type === Resource.TYPE.IMAGE) {
            resource.texture = Texture.fromLoader(
                resource.data,
                resource.url,
                resource.name
            );
        }
        next();
    }
}
複製代碼

接下來看看其中重要的表示所展現圖像的Texture對象是什麼。

紋理 - Texture

紋理爲精靈對象提供渲染的圖像數據,支持多種圖像數據類型。

當經過以下方法建立精靈時:

const bunny = PIXI.Sprite.from('examples/assets/bunny.png');
複製代碼

在內部執行了:

// packages/sprite/src/Sprite
from(source, options) {
    const texture = (source instanceof Texture)
        ? source
        : Texture.from(source, options);

    return new Sprite(texture);
}
// packages/core/src/textures/Texture
from(source, options = {}, strict = settings.STRICT_TEXTURE_CACHE) {
    texture = new Texture(new BaseTexture(source, options));
    texture.baseTexture.cacheId = cacheId;
    BaseTexture.addToCache(texture.baseTexture, cacheId);
    Texture.addToCache(texture, cacheId);
}
複製代碼

能夠看出在精靈的from中實際調用了Texture的from方法用來解析與生成紋理。

在BaseTexture中會根據傳入的source自動判斷該資源的類型(autoDetectResource),判斷是否爲SVG、Canvas、Buffer等資源類型,若通過test後該source的特徵均不知足這些類型,則做爲Image類型加載,關鍵部分以下:

autoDetectResource(source, options) {
    for (let i = INSTALLED.length - 1; i >= 0; --i) {
        const ResourcePlugin = INSTALLED[i];
        if (ResourcePlugin.test && ResourcePlugin.test(source, extension)) {
            return new ResourcePlugin(source, options);
        }
    }
    return new ImageResource(source, options);
}
複製代碼

ImageResource中會使用ImageElement對象來加載圖片。

外層的Texture類中則

變換、交互及動畫

說完基礎元素及資源處理,就到了與實際展現或操做有關的變換、交互及動畫部分了。

變換處理

packages/interaction/Matrix & Transform

爲了高效,採用一維數組的格式保存變換矩陣,使用math庫中的Matrix和Transform的組合實現變換數據的相關操做。

Pixi並無爲精靈提供顯式調用的變換相關方法(rotate, translate, scale),僅能經過直接改變變換屬性來實現變換,這些變換屬性位於DisplayObject類中,即Container和Sprite的父類。

能夠看看這個例子,經過改變精靈的rotation屬性來控制旋轉

app.ticker.add((delta) => {
    bunny.rotation += 0.1 * delta;
});
複製代碼

改變屬性後執行的流程

  1. Sprite

    set rotation(value) {
        this.transform.rotation = value;
    }
    複製代碼
  2. Transform

    set rotation(value) {
        if (this._rotation !== value)
        {
            this._rotation = value;
            this.updateSkew();
        }
    }
    protected updateSkew(): void {
        // 計算變換矩陣中scale與skew參數
        this._cx = Math.cos(this._rotation + this.skew.y);
        this._sx = Math.sin(this._rotation + this.skew.y);
        this._cy = -Math.sin(this._rotation - this.skew.x); // cos, added PI/2
        this._sy = Math.cos(this._rotation - this.skew.x); // sin, added PI/2
    }
    複製代碼

交互處理

packages/interaction/src/InteractionManager

默認狀況下,負責交互事件的InteractionManager(如下簡稱IManager)是做爲一個插件加載到renderer上。

  • IManager負責處理mouse、touch與pointer事件,
  • 當DisplayObject的interactive屬性爲true時會加入到IManager的檢測對象中

Manager在初始化時在renderer的view屬性對應的元素上一股腦的綁定了相關事件的事件監聽函數:

var element = this.renderer.view;
this.interactionDOMElement = element;
// ...
if (this.supportsPointerEvents) {
    window.document.addEventListener('pointermove', this.onPointerMove, true);
    this.interactionDOMElement.addEventListener('pointerdown', this.onPointerDown, true);
    this.interactionDOMElement.addEventListener('pointerleave', this.onPointerOut, true);
    this.interactionDOMElement.addEventListener('pointerover', this.onPointerOver, true);
    window.addEventListener('pointercancel', this.onPointerCancel, true);
    window.addEventListener('pointerup', this.onPointerUp, true);
} else  {
// ...
複製代碼

這裏相比較的話仍是Konva的綁定事件監聽的方式較爲科學,Konva考慮到了不一樣事件觸發的次序來對事件與監聽函數進行綁定,而不是單純在某一時間點統一的綁定與移除。

IManager在監聽交互事件時除了觸發相關事件外,還會在內部的DisplayObject上執行目標檢測與事件分發:

processInteractive(interactionEvent, displayObject, func, hitTest) {
    // 目標檢測,並向內部的interactive DisplayObject分發事件
    const hit = this.search.findHit(interactionEvent, displayObject, func, hitTest);
    // 處理延遲事件,當多個mouse/pointer事件觸發時
    const delayedEvents = this.delayedEvents;
    if (!delayedEvents.length) { return hit; }
    // 重置hint,爲了在tree中繼續搜索
    interactionEvent.stopPropagationHint = false;
    const delayedLen = delayedEvents.length;
    this.delayedEvents = [];
    // 向DisplayObjects分發事件
    for (let i = 0; i < delayedLen; i++) {
        const { displayObject, eventString, eventData } = delayedEvents[i];
        // 當到達須要中止的地方設置
        if (eventData.stopsPropagatingAt === displayObject) {
            eventData.stopPropagationHint = true;
        }
        this.dispatchEvent(displayObject, eventString, eventData);
    }
    return hit;
}
複製代碼

其中findHit爲TreeSearch的對象方法,用於執行實際的目標檢測與事件分發行爲。

目標檢測

packages/interaction/src/TreeSearch

TreeSearch使用recursiveFindHit這個遞歸函數來在DisplayObject上執行目標檢測

findHit(interactionEvent, displayObject, func, hitTest) {
    this.recursiveFindHit(interactionEvent, displayObject, func, hitTest, false);
}
// ...
recursiveFindHit(interactionEvent, displayObject, func, hitTest, interactive) {
    // 1. hitArea與mask判斷
    if (displayObject.hitArea) {
        // 若存在hitArea,經過contains判斷該點是否在模型空間的目標區域內
        if (hitTest) {
            displayObject.worldTransform.applyInverse(point, this._tempPoint);
            if (!displayObject.hitArea.contains(this._tempPoint.x, this._tempPoint.y)) {
                hitTest = false;
                hitTestChildren = false;
            } else {
                hit = true;
            }
        }
        interactiveParent = false;
    // 若存在
    } else if (displayObject._mask) {
        // 若存在mask,經過contains判斷該點是否在mask區域內
        if (hitTest) {
            if (!(displayObject._mask.containsPoint && displayObject._mask.containsPoint(point))) {
                hitTest = false;
            }
        }
    }
    // 2. 執行遞歸函數檢測子元素的碰撞狀況
    if (hitTestChildren && displayObject.interactiveChildren && displayObject.children) {
        const children = displayObject.children;
        for (let i = children.length - 1; i >= 0; i--) {
            const child = children[i];
            // 遞歸調用,若爲true說明檢測到碰撞對象
            const childHit = this.recursiveFindHit(interactionEvent, child, func, hitTest, interactiveParent);
            if (childHit)
            {
                // 若當前子元素的父輩被移除,則跳過檢測
                if (!child.parent) { continue; }
                interactiveParent = false;
                // PS: 這裏的if(childHit)檢測是多餘的?
                if (childHit) {
                    if (interactionEvent.target) {
                        hitTest = false;
                    }
                    hit = true;
                }
            }
        }
    }
    // 3. 執行目標檢測
    if (interactive) {
        if (hitTest && !interactionEvent.target) {
            // 以前檢測過hitArea,這裏再也不處理
            if (!displayObject.hitArea && displayObject.containsPoint) {
                if (displayObject.containsPoint(point))
                {
                    hit = true;
                }
            }
        }
        // 若該元素interactive爲true,則設置爲當前事件的target,並執行傳入的回調函數
        if (displayObject.interactive) {
            if (hit && !interactionEvent.target) {
                interactionEvent.target = displayObject;
            }
            if (func) {
                func(interactionEvent, displayObject, !!hit);
            }
        }
    }
    return hit;
}
複製代碼

Ticker與rAF動畫

packages/ticker

動畫是Pixi中比較重要的一個模塊,它將rAF動畫封裝成了一個Ticker類,主要有以下三個特性:

  1. 可控制的rAF動畫運行狀態:開始與中止
  2. 靈活的MainLoop任務管理:分離了執行任務,能夠根據須要單獨在Ticker對象上添加或移除在幀動畫中執行的任務
  3. 可自定義的執行頻率:能夠經過設置指定的最大與最小FPS值,內部通過執行時間差的計算判斷是否在下一幀執行後續任務

一般咱們執行rAF動畫時都是簡單的遞歸調用,以下:

function render() {
    work();
    requestAnimationFrame(render);
}
複製代碼

使用Ticker操做幀動畫的執行函數:

let numA = 0;
let numB = 0;
const renderTaskInit = () => { initWork() }
const renderTaskA = () => { renderWork() }
const renderTaskB = () => { renderWork() }
app.ticker.addOnce() // 僅執行一次的任務
app.ticker.add(renderTaskA); // 循環執行的任務
app.ticker.add(renderTaskB, this); // 循環執行的任務,可傳入context對象
app.ticker.remove(renderTaskA) // 移除任務
複製代碼

Ticker的原理

內部實現主要由Ticker與TickerListener這兩個類組成。

1.動畫開始與中止的控制

start(): void {
        if (!this.started) {
            this.started = true;
            this._requestIfNeeded();
        }
    }
    private _requestIfNeeded(): void {
        if (this._requestId === null && this._head.next) {
            this.lastTime = performance.now();
            this._lastFrame = this.lastTime;
            this._requestId = requestAnimationFrame(this._tick);
        }
    }
    stop(): void {
        if (this.started) {
            this.started = false;
            this._cancelIfNeeded();
        }
    }
    private _cancelIfNeeded(): void {
        if (this._requestId !== null) {
            cancelAnimationFrame(this._requestId);
            this._requestId = null;
        }
    }
複製代碼

2.MainLoop中的任務管理

Ticker類的對象在初始化時會建立_ticker來執行rAF的遞歸:

this._tick = (time: number): void =>{
    this._requestId = null;
    if (this.started) {
        // 調用事件監聽器
        this.update(time);
        // 當執行
        if (this.started && this._requestId === null && this._head.next)
        {
            this._requestId = requestAnimationFrame(this._tick);
        }
    }
};
複製代碼

在update方法中會遍歷一個監聽器鏈表

update(currentTime = performance.now()): void {
    // ...
    const head = this._head;
    let listener = head.next;
    while (listener) {
        listener = listener.emit(this.deltaTime);
    }
    if (!head.next) {
        this._cancelIfNeeded();
    }
    // ...
}
複製代碼

其中的listener爲一個TickerListener對象,在這個對象中以鏈表的結構存儲多個監聽事件的處理函數,每次emit時執行當前函數,並返回next值對應的下一個listener,若listener爲空則表示執行完畢。

emit(deltaTime: number): TickerListener {
    if (this.fn) {
        if (this.context) {
            this.fn.call(this.context, deltaTime);
        } else {
            (this as TickerListener<any>).fn(deltaTime);
        }
    }
    const redirect = this.next;
    // ...
    return redirect;
}
複製代碼

3. 控制任務執行頻率

當設置最大FPS時,會計算每秒內幀之間的最短間隔:

set maxFPS(fps) {
    if (fps === 0){
        this._minElapsedMS = 0;
    } else {
        const maxFPS = Math.max(this.minFPS, fps);
        this._minElapsedMS = 1 / (maxFPS / 1000);
    }
}
複製代碼

則在update()方法中會根據這個時間判斷是否在這一幀內執行後續任務:

update(currentTime = performance.now()): void {
    // ...
    if (this._minElapsedMS) {
        const delta = currentTime - this._lastFrame | 0;
        if (delta < this._minElapsedMS) {
            return;
        }
        this._lastFrame = currentTime - (delta % this._minElapsedMS);
    }
    // ...
}
複製代碼

總結

能夠看出,Pixi實現了高性能2D渲染的目標,背後的付出則是大量額外實現的WebGL圖形繪製(貝塞爾曲線、基礎圖形等)與輔助方法(碰撞檢測)的代碼,而且針對動畫與資源加載也作了許多優化和額外的功能,不失爲一個優秀的框架。

參考

相關文章
相關標籤/搜索