[新闻资讯] AS的异步执行

介绍


在Flash Player中AS的执行跟屏幕重绘都是在单线程处理的。为了便于屏幕重绘,必须要让所有执行代码都执行完毕。对一个帧率24fps的SWF来说,这意味着所有AS操作都在一个帧里运行,醉倒42ms会执行完毕。这还不考虑会随时变化的重绘屏幕的时间。如果代码执行需要更多时间,那Flash Player将会加锁,知道完成为止或者经过默认一个时间片后停止代码执行。


1.png

本超时出错对话框(只能在调试版本的Flash Player中出现哦)

本教程将涵盖对此问题的解决方案-如果你的操作里含有AS计算需要超过给定帧的时间时,这些计算将会异步执行;它们不会被阻塞,并且会在调用它们执行结束后统一结束。这样会在计算结束前给重绘更多时间来防止Player加锁。

需要的东西

内容目录

  • 介绍
  • 需要的东西
  • 概念
  • 单维度循环
    • 例子:Caesar Cipher
    • 基于时间的退出条件
  • 多维度循环
    • 例子:颜色梯度
  • 处理for …infor each in
  • 顺序
  • 递归
    • 例子:阶乘
    • 例子:快速排序
  • 同步跟异步
    • 例子:读取在线跟远程文本
  • 总结

概念


运行中的Flash Player 实例会处理连续帧的固定循环,即使有时候不一定是基于时间轴的动画,由于每个表示SWF内容的帧都会被重复重绘。每个帧包括两部分:AS的AVM虚拟机执行跟Flash Player重绘器的重绘。代码执行则包括AS的执行和到重绘之间的空余时间。AS是基于操作跟事件运行的,而它们可能在屏幕重绘之间执行,可能会导致空余时间或者发生时间有变化。


2.png


帧包括AS运行期和屏幕重绘期

当空余时间被CPU密集的AS代码占用后,每个重绘间隔时间就会变长,帧率就会下降。原来1帧耗费42ms现在可能需要耗费62ms或者更多。


3.png


AS占用太多时间来执行导致重绘延迟

如果AS可以多线程处理执行,那就会防止重绘阻塞。然而那是不可能的,因为Flash Player从一开始就只用一个线程来处理代码执行和屏幕重绘。因此为了避免处理器集中的AS代码阻塞重绘,那些计算操作将被分为更小、单个的片段或者条条,以便独立在多个帧里处理。在每个片段之间,重绘器可以利用回放重绘来保证不被打断。

4.png


AS代码拆开到多个帧里

将一段执行代码分割放到多个帧里通常是很细致的。代码不仅需要知道什么时候停止,也需要知道它什么时候在哪里停下的。这增加了独立数据的独立性。以前含有本地变量的函数块现在就编程含有属性值的对象了。

单维度循环


最简单最通常情况下降代码分割成段的方法是用循环。很长的循环会占用很多处理时间,特别是计算中有很多循环嵌套的时候。

通常在迭代处理中,循环会用一个索引变量来保存元素在列表里的位置。标准的数组循环如下:

// 伪码

var i, n = array.length;

for (i=0; i<n; i++){

             process(array);

}

将循环分段,这样就可以在多个迭代里处理。处理中就需要有返回在迭代循环过程中中断位置的能力。I的值需要用长久保持,便于在另外的帧里引用它。

// pseudo code

var savedIndex = 0;

var i, n = array.length;

for (i=savedIndex; i<n; i++){


             if (needToExit()){

                             savedIndex = i;

                             break;

             }


             process(array);

}


由于分段的目的时为了重绘, 每段代码都在Event.ENTER_FRMAE事件里处理。当循环开始时事件会设定,在循环结束时事件被删除。当循环循环结束但没退出时循环就会结束,在函数中就是return了。

// pseudo code

var savedIndex = 0;

function enterFrame() {


             var i, n = array.length;

             for (i=savedIndex; i<n; i++){


                             if (needToExit()){

                                            savedIndex = i;

                                            return;

                             }


                             process(array);

             }


             complete();

}


循环是否退出是由needToExit()决定的,它的实现是由你来决定的。可以通过迭代设置数值或者用getTimer()计时来达到。理想状况下,分配给代码执行的时间将正好填满下一个重绘空余时间。没有方法知道具体花费多久,因此此部分只能是大概的。

例子:Caesar Cipher

本例将字符串里的循环字符分到多个帧里作为实际应用的案例。为了便于控制,我们需要一个CaesarCipher类来保存循环里必需的持久数据,通过此数据可以获得当前工作状态。

CaesarCipher类将Caesar cipher转换为一个字符串。此操作的一个标准、同步的函数如下所示:

function caesarCipher(text:String, shift:int = 3):String {


             var result:String = "";

             var i:int;

             var n:int = text.length;


             for (i=0; i<n; i++){


                             var code:int = text.charCodeAt(i);


                             // shift capital letters

                             if (code >= 65 && code <= 90){

                                            code = 65 + (code - 65 + shift) % 26;

                             }


                             // shift lowercase letters

                             if (code >= 97 && code <= 122){

                                            code = 97 + (code - 97 + shift) % 26;

                             }


                             result += String.fromCharCode(code);

             }


             return result;

}


CaesarCipher类使用同样的逻辑但是允许多帧上的操作在完毕前继续运行。这样处理就变成异步的了。实现时需要考虑如下几点:





  • 只显示接收Event.ENTER_FRAME事件的对象
  • 返回值对异步操作没用时

CaesarCipher类目的时用来处理数据。它的实例不是用在屏幕上显示。因此它不会依赖那些有Event.ENTER_FRAME事件的可视对象。即使那个简单的可视实例永远不会显示。这种独立性可以利用组合(包含一个简单的显示对象,如CaesarCipher类里定义的那样的Shape实例)很简单的实现。也仍然会触发CaesarCipher实例里使用的Event.ENTER_FRMAE事件。考虑到返回值,可以用存在的异步操作来实现。例如,参考URLLoader类,实例是通过异步方式调用load()开始加载外部内容的。加载完毕时,会触发Event.COMPLETE事件,加载数据可以通过data属性获得。CaesarCipher类用类似的方式实现。可以用run()方法来启动操作。当结束时,会派发Event.COMPLETE事件,就可以利用result属性来获得返回值。

CaesarCipher类(在源文件里有)

使用:

var text:String = "Lorem ipsum dolor sit amet, consectetur adipiscing elit.";

var cipher:CaesarCipher = new CaesarCipher(text, 3);

cipher.addEventListener(Event.COMPLETE, complete);

cipher.run();




function complete(event:Event):void {

             trace("Result: " + cipher.result);

             // Result: Oruhp lsvxp groru vlw dphw, frqvhfwhwxu dglslvflqj holw.

}


看下真实的CaesarCipher实例

尽管类是用成员变量来保存结果数据,它也可以传递给事件。例如:如果使用一个TextEvent结束事件。这种方法需要创建新类来处理结果数据,因此还是用类中成员变量比较简单。

由于此例使用一个短的字符串,又因为Caesar cipher对AS来说不是很困难的操作,用一个迭代计数器来决定处理阶段是否允许重绘。用基于时间的方法来确定帧中需要计算的时间是常见方法。

基于时间的退出条件


从耗时的计算处理中异步退出是处理分段的好方法。为此需要知道当前帧开始计算花费的时间,每帧允许的计算处理时间。

//pseudo code

var allowedTime = 1000/fps - renderTime - otherScriptsTime;

var startTime = 0;

var savedIndex = 0;

function enterFrame() {

             startTime = getTimer();


             var i, n = array.length;

             for (i=savedIndex; i<n; i++){


                             if (getTimer() - startTime > allowedTime){

                                            savedIndex = i;

                                            return;

                             }


                             process(array);

             }


             complete();

}


需要估计重绘跟其他脚本的时间,越精确,你越能更好的利用帧的时间。但需要注意这些值会随着处理器运行脚本的速度而在运行时改变。最接近的是填满帧的时间。你可能会在操作执行时损失展示给用户的帧率的情况下让计算尽快进行。

对那些包含大量循环计算的操作,尤其在使用getTimer()的情况下,你可能不会想在每个迭代循环时都检测退出条件,因为这样可以减少很多查找及函数调用的开销。检测越少,操作执行越快。什么时候做以及做多少次的确是个需要注意的问题。

虽然时间检测好,尤其是对于大型计算,但为了便于演示,本文的很多案例都不用时间作为退出检测条件。

多维度循环


像网格及图形像素这样的多维度数据表示形式通常需要使用嵌套循环。当将这些循环转换为异步操作时,我们有个跟异步单维度循环类似的方法可以用。在处理嵌套循环时需要额外注意在重置循环时需要重新初始化循环变量。记住在下次迭代时候,需要将保存嵌套循环的索引重置为0。

// pseudo code

for (i=savedIndexI; i<n; j++){


             for (j=savedIndexJ; j<m; j++){


                             process(array[j]);

             }


             savedIndexJ = 0;

}


重置索引值是在每次嵌套循环跟第一次已经完成时执行。这是为了保证在第一次循环时不会重置保存的索引值。

例子:颜色梯度

在处理一个位图里的像素时通常会用到嵌套循环。此例子中用到一个RenderGradient类来用BitmapData类的setPixe()函数来画位图像素的梯度。

RenderGradient类跟先前的CaesarCipher类功能差不多。类的结构只有少许特别:





  • 用到两个循环,内循环变量savedX在循环结束时重置。
  • 一个单独的计数器变量iterationCounter来计算为分段的循环操作计数。
  • 每次操作重置后都需要检测引用的BitmapData类。
  • 不需要返回值。操作知识对存在内容进行处理而不是产生新的。

检测的那步不值得一提。由于所有功能数据都被拷贝到CaesarCipher实例的类成员变量里,Caesar cipher例子里就没有这个问题。对RenderGradient类来说,一个BitmapData对象被作为引用保存,而不是拷贝。操作进行时BitmapData才被修改,这是非常重要的,如果某时BitmapData实例被另一个操作重置,RenderGradient类的计算就会失败。 这跟多线程中的并行问题不同,实际上这些异步操作跟线程很相似,在有对外部对象引用后,很多地方都跟线程处理顺序类似了。在AS中至少可以保证同一时刻没有两段脚本都在执行。这保证了在开始处理不连续或在引用数据中意想不到的状态操作时,保证了只能在一个时间检测。RenderGradient类在循环处理中定义来自引用的BitmapData类的宽、高的变量xn,yn时使用try…catch语句来操作的。重置后的BitmapData对象在获取宽高时会引发错误,保证了注册Event.CANCEL事件的RenderGradient类退出时会抛出错误。

RenderGradient类的检测不能防止多实例对同一个位图的操作。有方法可以让多个RenderGradient类编辑同一个位图的像素。最后一个实例的调用很明显会覆写先前做的修改。其他地方可能不同程度上也需要检测。

RenderGradient类

用法:

var renderer:RenderGradient = new RenderGradient(bmp, 0xFF0000, 0x0000FF);

renderer.run();


看下显示的RenderGradient类的例子

由于操作中会改变很多值,并且不需要结果值。虽然可能在其他等待结束的依赖上可能是必须的,但是这里没有包括任何结束事件句柄。

处理for …in和for …each..in循环


上面的每个例子中都是使用循环索引来在不同帧里暂停或重置迭代,而没有考虑那些没有索引的循环,如for…in跟for…each..in循环。像这样的循环取法在迭代内部任意位置重新开始的能力。这样在将循环分为更小部分的分段时,它们不会处理的很好。

为了处理这样的循环,需要首先将它们转换为索引列表,然后利用索引列表进行分段。

// pseudo code

var indexedList = [];

for each(value in object){

             indexedList.push(value);

}


// proceed normally...


虽然看起来在操作初期会增加额外的操作但是for/for..each循环本身不是拖慢处理的原因,因此可以忽略。

顺序


有时你的处理不会包括循环,而是一些简单但是需要耗费很长时间执行的代码,当执行时可能会导致屏幕重绘延迟。像这些例子,每段代码都被分为独自的函数并且单独运行,这就可以通过一个数组或者链表来操作。

顺序数组

在顺序数组里,每个函数都保存在数组里,并且一帧一次只能调用一个。函数的顺序变成了循环。对数组元素的处理变成了对元素内函数的调用。

// pseudo code

function processA(){

             // time consuming process

}

function processB(){

             // time consuming process

}

function processC(){

             // time consuming process

}


var sequence:Array = [processA, processB, processC];

// Event.ENTER_FRAME loop through sequence array ...


顺序数组可以在运行时动态生成并允许混合跟匹配顺序函数的调用。最后剩下的最多是单维度循环里解决的那个问题了。

顺序链表

也可以利用链表实现顺序操作。在链表里每个函数在一个操作中被调用时意味着下一个函数将被调用。Event.ENTER_FRAME循环在有函数引用不为空时会不断调用的。

// pseudo code

function processA(){

             // time consuming process

             next = processB;

}

function processB(){

             // time consuming process

             next = processC;

}

function processC(){

             // time consuming process

             next = null;

}


var next = processA;

// Event.ENTER_FRAME call next while non-null ...


像这样的顺序操作通常对迭代的处理时间控制不强。函数中不同计算是通过人工方式来实行,这样就很难达到事件分配或者开发。如果分为更小的计算,Event.ENTER_FRAME事件处理句柄就可以通过自己的判别在什么时候等待下一帧继续来调用序列中的多个函数。

顺序链表的优势在于当被调用函数是通过自身的逻辑被调用,后面的函数会在顺序中被调用。

例子:自定义颜色过滤

此例子通过像素循环访问自定义过滤器的位图。每个过滤器都是一个同步循环访问位图像素的函数。每个应用的过滤器都会添加到异步运行的序列中,每帧每次应用一个过滤器。通过ApplyFilters类来处理所有的,此类接受一个Bitmapdata实例和应用到BitmapData提供的有Bitmapdata输入的过滤器函数序列。

ApplyFilters类

使用:

var sequence:Array = [twirl, wave, bubble]; // custom functions

var applicator:ApplyFilters = new ApplyFilters();

applicator.apply(bmp, sequence);


看下真实的ApplyFilters类的例子:

在设计ApplyFilters类时需要小的改动。以前叫run()的函数现在叫apply()了,apply()函数也是传递数据给ApplyFilters类的。先前例子中传递数据都是在构造函数中。这两种都可以,看你选择吧。这里使用的方法远不止用类的实例来表示函数调用并把它们当做工具来实现任务那么简单。也支持重用,因为在一个实例中可以用不同数据来操作不同操作。

这特殊的序列实现假设每个过滤器函数都会等一个帧结束,至少假设每帧多个同时运行可能会太多,虽然每帧多个可以在每帧中调用。这样做就需要一个有保存索引的循环的方法。

通常来说,序列化更加通用。这个例子依赖一个bitmapdata实例,序列中每隔函数都需要提供。但是你可以想想没有参数使用的情况,一个顺序类可以在多个地方应用。另外一些顺序类甚至可以在下一个函数调用前监听其他顺序类的结束事件。

递归


递归-当函数调用本身时-表示另一种方式的循环。递归操作既不是索引也不是可以轻易转换为for…in或for…each形式的循环。这就需要区分跟以前的解决方式。

// pseudo code

function recursive(){

             recursive();

}

recursive();


当函数调用另一个函数时,它们会被添加到调用栈里。调用栈里包括所有当前被调用的函数。当函数结束或返回值后,就会从调用栈清除。调用它的函数就会继续执行。递归函数会在函数调用栈里重复添加自身知道某些条件到达,函数返回值而不是继续调用本身。

5.png


递归函数的调用栈

当创建异步递归函数时,栈上可能含有任何函数的调用,而这些函数又都是相同的,因此操作需要在下一帧重新开始。当重新开始后,函数调用栈需要重建但是上次栈计算过的操作就不需要再创建了。为了实现这样的目的,需要条件来判断只执行一次。

// pseudo code

var processed = false;

function recursive(){


             if (needToExit()){

                             throw error;

             }


             if (!processed){


                             // process


                             processed = true;

             }


             recursive();

}


在上述例子里,首先需要注意needToExit()函数条件会抛错而不是返回。因为这个调用可能在很多嵌套函数调用中,抛错是最简单的退出所有栈的方式。可以在Event.ENTER_FRAME的事件句柄里用try…catch来处理并在下一帧时继续执行。

这个例子展示处理过程中全部执行,但是只会执行一次。如果任何嵌套函数抛错,原来的函数就会在下帧重新调用。随着栈重建,所有已经处理过的操作会跳过知道遇到没有执行的操作才会继续执行

6.png


重新建立递归函数的调用栈

数据需要重新获得,而不仅是保留处理逻辑中计算的结果,也需要保留processed标记值。每个函数调用都被封装到对象里。当操作一结束,根对象会被调用并在任何嵌套调用中都没有抛错情况下成功完成。

此处需要两个类来实现,一个运行类调用根递归对象,另一个来定义每个递归对象。运行类是公开处理Event.ENTER_FRAME事件并派发在操作完成后Event.COMPLETE事件的。递归类用来处理递归调用。运行类如下工作:

// pseudo code

var rootRecursive = new Recursive();

function enterFrame() {


             try {

                             rootRecursive.call();

                             complete();


             }catch(error){}

}


任何递归实例都可以抛错来打断操作,这样就会在下一帧造成延迟。如果没有错误捕获到,递归计算就会结束。需要注意什么错误抛出以及什么情况下会抛出,以防止它们跟操作异步没关系或暗示其他错误。

例子:阶乘

递归经典的例子就是求阶乘的函数:

function factorial(n:int):int {

    if(n == 0)

                             return 1;


    return n * factorial(n - 1);

}


这是一个根据给定n的值返回其阶乘的简单的应用递归的函数。为了让这个函数成为异步操作,需要两个复杂点的类。一个用来循环处理Event.ENTER_FRAME事件,另一个来处理递归逻辑。Factorial类用作运行类,FactorialOP类含有递归逻辑,用作辅助。

Factorial类

使用:

var factor:Factorial = new Factorial(8);

factor.addEventListener(Event.COMPLETE, complete);

factor.run();


function complete(event:Event):void {

             trace("Result: "+factor.result);

             // Result: 40320

}


看下阶乘的实际运行例子:

Factorial类的大多数东西以前都涵盖了。Run()函数,Event.ENTER_FRAME循环跟操作返回值result。但是以前没见过的就是循环句柄函数。

跟循环处理并获取索引不同,FunctionOp的实例在每帧都被调用。当结束时没抛出错误的话,会派发Event.COMPLETE事件。FunctionOP每次被调用都会进入到更深的递归中,重新创建那些跳过任何在先前调用的计算的调用栈。

实际上没有明确的processed变量来表明这个例子中运行了什么。实际上当前的下一个递归的FunctionOp对象,recursiveOp会被用到。如果存在,那表示创建它的代码已经运行,并可以在下次迭代中忽略。

现在定义:每次第一次调用FunctionOp将会抛出一个错误,我们用interrupted标记。实际上这样会在总体处理时间变得更糟糕。因为帧执行的时间是不会多于逻辑块的时间的。这个方法只是为了演示目的。对于实际应用来说,需要在调用call()函数时添加一些多于信息来知道当前消耗时间或者计算时间来制造一个更为精确的退出时机。

例子:快速排序

快速排序算法是另一个递归操作例子。跟阶乘不同,快速排序使用两个递归调用。当调用多个递归时,需要更多条件判断逻辑,也需要一个变量来区分每个逻辑。Quicksort跟QuicksortOp类正是这样做的。

快速排序案例使用一个processed值来判断下一次QuicksortOP的执行逻辑,而不是使用引用。用一个计数器来判断当前执行的是QuicksortOp里的哪个逻辑代码。

快速排序类(源码里有)

使用:

var array:Array = [2,4,7,9,1,9,8,5,3,0,3,5,6,7,8,8,1,2,0,3,7,5,5,9];


var sorteruicksort = new Quicksort(array);

sorter.addEventListener(Event.COMPLETE, complete);

sorter.run();


function complete(event:Event):void {

             trace("Result: "+array);

             // Result: 0,0,1,1,2,2,3,3,3,4,5,5,5,5,6,7,7,7,8,8,8,9,9,9

}


看下真实的快速排序案例:

同步与异步


有时候需要创建一个不受制于处理速度的异步操作,而不是其他异步操作。如果一个操作取决于另一个异步操作的结果,那个操作必须是异步的。任何网络操作,例如从远程服务器加载数据都需要异步。

在处理其他异步操作时,你在处理事件。事件会表明异步操作的完成或失败,就像我们目前用到的例子一样。当一个异步操作需要另外操作时,就需要管理操作的独立并在完成后继续自身的操作。所有这些都是AS开发工具运行时的基本操作。

在你的很多操作有的是同步的,有的不是的时候可能就需要点技巧了。因为这些操作可能是异步的,不论操作是否真是异步,事件也需要辨别是否结束。当多个操作同时运行时在一个操作的结束处理句柄中另一个会开始。当操作结束时,另外相同的句柄会用来在其他函数中循环处理。如果这些操作同步结束,在结束句柄里需要创建递归循环。当系列操作多了后会导致栈溢出,从而会在栈调用时出现两个函数。真正的异步操作不会出现上面的情况。因为它们的结束事件时从系列循环中使用的调用栈中得到的。

解决方法就是避免使用递归。这意味着等待一个帧来重启下一段的操作。在含有大量同步变化时,将操作弄系列化与单帧的解决方案相比会更加耗时。

一个可选的方法是用循环来代替递归。可以在结束事件处理中加一个标志来辨别主循环中使用的异步结果是否能同步。如果异步,同样的标志可以让事件处理句柄知道需要重新启动新栈调用的东西。

// pseudo code

var savedIndex = 0;

var resume = false;

function loop(){

             var i, n = array.length;

             for (i=savedIndex; i<n; i++){


                             resume = false;

                             process(array);


                             if (!resume){

                                            resume = true;

                                            savedIndex = i + 1;

                                            return;

                             }

             }


}


function processComplete(){

             if (resume){

                             loop();

             }else{

                             resume = true;

             }

}


这里的resume变量用来表示结束处理函数是否需要重新开始loop()函数或让在栈中的loop()函数继续同步执行。循环中从resume值没改变表示事件结束没有发生,那就需要等待发生并退出。

对resume的处理可以通过process()的返回值来表明操作是否是同步的。但是这样会意味着run()操作时跟那个一起设计的。所有以前涵盖的案例,都没有实现这样的想法。但是可以通过不在下一帧里延迟计算来保证同步。

例子:读取本地跟远程文本

假如一个指向很多需要合并到单文档的文字集的XML文件.可能XML文件里含有内联的XML或者连接到外部需要加载到Player中需要获取的文件。内联文本需要同步读取而外部指向文本需要异步读取。

用GetElementText类来获取单XML文档元素中内联的文本或指向的外部文本。此类包含所决定谁被使用跟如何获取的逻辑。当读取文本时,会触发Event.COMPLETE事件。

另一个CreateDocument类来生成文档。它通过遍历XML文件来创建GetElementByIdText对象来获得每个文本元素的内容并添加代码结束的字符串。结束后当然也会触发Event.COMPLETE事件。为了便于管理GetElementText事件完毕条件跟避免递归导致的栈溢出,我们可以用一个resume变量来在XML迭代循环中查看GetElementText的结果。

GetElementText类

CreateDocument类

Usage:

var document:CreateDocument = new CreateDocument();

document.addEventListener(Event.COMPLETE, documentComplete);

document.create(xml);


function documentComplete(event:Event):void {

             trace("Result:\n" + document.text);

}


看下CreateDocument例子演示

当然即使每个XML节点在一条直线,或者没防止栈的下益,在此例中这两个都不会出现。然而在大型应用并处理大量数据时,可能就不同了。为了让你的应用更加健壮,最好还是需要处理这样的问题的。

结论


虽然创建异步版本的操作很不少见,但是它可以帮助你在处理流畅动画跟不间断交互中起到很重要的作用。那样用户会更加期待你的应用。

可能你觉得此文的话题对你起不到帮助。但或多或少总会在与服务器进行复杂操作或者在给定时间里用页来处理给定数据为你一个选择的机会。这都是为了实现目的找到合适的解决方案。

原文链接:http://www.senocular.com/flash/tutorials/asyncoperations/?page=1

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值