zoukankan      html  css  js  c++  java
  • 【转】Qt事件循环与线程 二

    转自:http://blog.csdn.net/changsheng230/article/details/6153449

    续上文:http://blog.csdn.net/changsheng230/archive/2010/12/27/6101232.aspx

    由于最近工作比较忙,出了趟差,还是把这篇长文、好文翻译出来了,以飨读者。同时也是自己很好的消化、学习过程

    Qt 线程类

    Qt对线程的支持已经有很多年了(发布于2000年九月22日的Qt2.2引入了QThread类),Qt 4.0版本的release则对其所有所支持平台默认地是对多线程支持的。(当然你也可以关掉对线程的支持,参见这里)。现在Qt提供了不少类用于处理线程,让你我们首先预览一下:

    QThread

    QThread 是Qt中一个对线程支持的核心的底层类。 每个线程对象代表了一个运行的线程。由于Qt的跨平台特性,QThread成功隐藏了所有在不同操作系统里使用线程的平台相关性代码。

    为了运用QThread从而让代码在一个线程里运行,我们可以创建一个QThread的子类,并重载QThread::run() 方法:

    1. class Thread : public QThread {  
    2. protected:  
    3. void run() {  
    4. /* your thread implementation goes here */  
    5. }  
    6. };  

    接着,我们可以使用:

    1. class Thread : public QThread {  
    2. protected:  
    3. void run() {  
    4. /* your thread implementation goes here */  
    5. }  
    6. };  


    来真正的启动一个新的线程。 请注意,Qt 4.4版本之后,QThread不再支持抽象类;现在虚函数QThread::run()实际上是简单调用了QThread::exec(),而它启动了线程的事件循环。(更多信息见后文)

    QRunnable 和 QThreadPool

    QRunnable [doc.qt.nokia.com] 是一种轻量级的、以“run and forget”方式来在另一个线程开启任务的抽象类,为了实现这一功能,我们所需要做的全部事情是派生QRunnable 类,并实现纯虚函数方法run()

    1. class Task : public QRunnable {  
    2. public:  
    3. void run() {  
    4. /* your runnable implementation goes here */  
    5. }  
    6. };  

     

    事实上,我们是使用QThreadPool 类来运行一个QRunnable 对象,它维护了一个线程池。通过调用QThreadPool::start(runnable) ,我们把一个QRunnable 放入了QThreadPool的运行队列中;只要线程是可见得,QRunnable 将会被拾起并且在那个线程里运行。尽管所有的Qt应用程序都有一个全局的线程池,且它是通过调用 QThreadPool::globalInstance()可见得,但我们总是显式地创建并管理一个私有的QThreadPool 实例。

    请注意,QRunnable 并不是一个QObject类,它并没有一个内置的与其他组件显式通讯的方法。你必须使用底层的线程原语(比如收集结构的枷锁保护队列等)来亲自编写代码。

    QtConcurrent

    QtConcurrent 是一个构建在QThreadPool之上的上层API,它用于处理最普通的并行计算模式:map [en.wikipedia.org], reduce [en.wikipedia.org], and filter [en.wikipedia.org] 。同时,QtConcurrent::run()方法提供了一种便于在另一个线程运行一个函数的方法。

    不像QThread 以及QRunnable,QtConcurrent 没有要求我们使用底层的同步原语,QtConcurrent 所有的方法会返回一个QFuture 对象,它包含了结果而且可以用来查询线程计算的状态(它的进度),从而暂停、继续、取消计算。QFutureWatcher 可以用来监听一个QFuture 进度,并且通过信号和槽与之交互(注意QFuture是一个基于数值的类,它并没有继承自QObject).

    功能比较

    /QThreadQRunnableQtConcurrent1
    High level API
    Job-oriented
    Builtin support for pause/resume/cancel
    Can run at a different priority
    Can run an event loop

    线程与QObjects

    线程的事件循环

    我们在上文中已经讨论了事件循环,我们可能理所当然地认为在Qt的应用程序中只有一个事件循环,但事实并不是这样:QThread对象在它们所代表的线程中开启了新的事件循环。因此,我们说main 事件循环是由调用main()的线程通过QCoreApplication::exec() 创建的。 它也被称做是GUI线程,因为它是界面相关操作唯一允许的进程。一个QThread的局部事件循环可以通过调用QThread::exec() 来开启(它包含在run()方法的内部)

    1. class Thread : public QThread {  
    2. protected:  
    3. void run() {  
    4. /* ... initialize ... */  
    5. exec();  
    6. }  
    7. };  

     

    正如我们之前所提到的,自从Qt 4.4 的QThread::run() 方法不再是一个纯虚函数,它调用了QThread::exec()。就像QCoreApplication,QThread 也有QThread::quit() 和QThread::exit()来停止事件循环。

    一个线程的事件循环为驻足在该线程中的所有QObjects派发了所有事件,其中包括在这个线程中创建的所有对象,或是移植到这个线程中的对象。我们说一个QObject的线程依附性(thread affinity)是指某一个线程,该对象驻足在该线程内。我们在任何时间都可以通过调用QObject::thread()来查询线程依附性,它适用于在QThread对象构造函数中构建的对象。

    1. class MyThread : public QThread  
    2. {  
    3. public:  
    4. MyThread()  
    5. {  
    6. otherObj = new QObject;  
    7. }     
    8. private:  
    9. QObject obj;  
    10. QObject *otherObj;  
    11. QScopedPointer<QObject> yetAnotherObj;  
    12. };  

     

    如上述代码,我们在创建了MyThread 对象后,obj, otherObj, yetAnotherObj 的线程依附性是怎么样的?要回答这个问题,我们必须要看一下创建他们的线程:是这个运行MyThread 构造函数的线程创建了他们。因此,这三个对象并没有驻足在MyThread 线程,而是驻足在创建MyThread 实例的线程中。

    要注意的是在QCoreApplication 对象之前创建的QObjects没有依附于某一个线程。因此,没有人会为它们做事件派发处理。(换句话说,QCoreApplication 构建了代表主线程的QThread 对象)

    我们可以使用线程安全的QCoreApplication::postEvent() 方法来为某个对象分发事件。它将把事件加入到对象所驻足的线程事件队列中。因此,除非事件对象依附的线程有一个正在运行的事件循环,否则事件不会被派发。

    理解QObject和它所有的子类不是线程安全的(尽管是可重入的)非常重要;因此,除非你序列化对象内部数据 所有可访问的接口、数据,否则你不能让多个线程同一时刻访问相同的QObject(比如,用一个锁来保护)。请注意,尽管你可以从另一个线程访问对象,但 是该对象此时可能正在处理它所驻足的线程事件循环派发给它的事件! 基于这种原因,你不能从另一个线程去删除一个QObject,一定要使用QObject::deleteLater(),它会Post一个事件,目标删除 对象最终会在它所生存的线程中被删除。(译者注:QObject::deleteLater作用是,当控制流回到该对象所依附的线程事件循环时,该对象才会被“本”线程中删除)。

    此外,QWidget 和它所有的子类,以及所有与GUI相关的类(即便不是基于QObject的,像QPixmap)并不是可重入的。它们必须专属于GUI线程。

    我们可以通过调用QObject::moveToThread()来改变一个QObject的依附性;它将改变这个对象以及它的孩子们的依附性。因为QObject不是线程安全的,我们必须在对象所驻足的线程中使用此函数;也就是说,你只能将对象从它所驻足的线程中推送到其他线程中,而不能从其他线程中回来。此外,Qt要求一个QObject的孩子必须与它们的双亲驻足在同一个线程中。这意味着:

    • 你不能使用QObject::moveToThread()作用于有双亲的对象;
    • 你千万不要在一个线程中创建对象的同时把QThread对象自己作为它的双亲。 (译者注:两者不在同一个线程中):
    1. class Thread : public QThread {  
    2. void run() {  
    3. QObject obj = new QObject(this); // WRONG!!!  
    4. }  
    5. };  

     

    这是因为,QThread 对象驻足在另一个线程中,即QThread 对象它自己被创建的那个线程中。

    Qt同样要求所有的对象应该在代表该线程的QThread对象销毁之前得以删除;实现这一点并不难:只要我们所有的对象是在QThread::run() 方法中创建即可。(译者注:run函数的局部变量,函数返回时得以销毁)。

    跨线程的信号与槽

    接着上面讨论的,我们如何应用驻足在其他线程里的QObject方法呢?Qt提供了一种非常友好而且干净的解决方案:向事件队列post一个事件, 事件的处理将以调用我们所感兴趣的方法为主(当然这需要线程有一个正在运行的事件循环)。而触发机制的实现是由moc提供的内省方法实现的(译者注:有关 内省的讨论请参见我的另一篇文章Qt的内省机制剖析):因此,只有信号、槽以及被标记成Q_INVOKABLE的方法才能够被其它线程所触发调用。

    静态方法QMetaObject::invokeMethod() 为我们做了如下工作:

    1. QMetaObject::invokeMethod(object, "methodName",  
    2. Qt::QueuedConnection,  
    3. Q_ARG(type1, arg1),  
    4. Q_ARG(type2, arg2));  

     

    请注意,因为上面所示的参数需要被在构建事件时进行硬拷贝,参数的自定义型别所对应的类需要提供一个共有的构造函数、析构函数以及拷贝构造函数。而且必须使用注册Qt型别系统所提供的qRegisterMetaType() 方法来注册这一自定义型别。

    跨线程的信号槽的工作方式相类似。当我们把信号连接到一个槽的时候,QObject::connect的第五个可选输入参数用来特化这一连接类型:

    • direct connection 是指:发起信号的线程会直接触发其所连接的槽;
    • queued connection 是指:一个事件被派发到接收者所在的线程中,在这里,事件循环会之后的某一时间将该事件拾起并引起槽的调用;
    • blocking queued connection 与queued connection的区别在于,发送者的线程会被阻塞,直至接收者所在线程的事件循环处理发送者发送(入栈)的事件,当连接信号的槽被触发后,阻塞被解除;
    • automatic connection (缺省默认参数) 是指: 如果接收者所依附的线程和当前线程是同一个线程,direct connection会被使用。否则使用queued connection。

    请注意,在上述四种连接方式当中,发送对象驻足于哪一个线程并不重要!对于automatic connection,Qt会检查触发信号的线程,并且与接收者所驻足的线程相比较从而决定到底使用哪一种连接类型。特别要指出的是:当前的Qt文档的声明(4.7.1) 是错误的:

    如果发射者和接受者在同一线程,其行为与Direct Connection相同;,如果发射者和接受者不在同一线程,其行为Queued Connection相同

    因为,发送者对象的线程依附性在这里无关紧要。举例子说明

    1. class Thread : public QThread  
    2. {  
    3. Q_OBJECT  
    4. signals:  
    5. void aSignal();  
    6. protected:  
    7. void run() {  
    8. emit aSignal();  
    9. }  
    10. };  
    11. /* ... */  
    12. Thread thread;  
    13. Object obj;  
    14. QObject::connect(&thread, SIGNAL(aSignal()), &obj, SLOT(aSlot()));  
    15. thread.start();  

     

    如上述代码,信号aSignal() 将在一个新的线程里被发射(由线程对象所代表);因为它并不是Object 对象驻足的线程,所以尽管Thread对象thread与Object对象obj在同一个线程,但仍然是queued connection被使用。

    译者注:这里作者分析的很透彻,希望读者仔细揣摩Qt文档的这个错误。 也就是说 发送者对象本身在哪一个线程对与信号槽连接类型不起任何作用,起到决定作用的是接收者对象所驻足的线程以及发射信号(该信号与接受者连接)的线程是不是在 同一个线程,本例中aSignal()在新的线程中被发射,所以采用queued connection)。

    另外一个常见的错误如下:

    [c-sharp] view plaincopy
    1. class Thread : public QThread  
    2. {  
    3. Q_OBJECT  
    4. slots:  
    5. void aSlot() {  
    6. /* ... */  
    7. }  
    8. protected:  
    9. void run() {  
    10. /* ... */  
    11. }  
    12. };  
    13. /* ... */  
    14. Thread thread;  
    15. Object obj;  
    16. QObject::connect(&obj, SIGNAL(aSignal()), &thread, SLOT(aSlot()));  
    17. thread.start();  
    18. obj.emitSignal();  

     

    当“obj”发射了一个aSignal()信号是,哪种连接将被使用呢?你也许已经猜到了:direct connection。这 是因为Thread对象实在发射该信号的线程中生存。在aSlot()槽里,我们可能接着去访问线程里的一些成员变量,然而这些成员变量可能同时正在被 run()方法访问:这可是导致完美灾难的秘诀。可能你经常在论坛、博客里面找到的解决方案是在线程的构造函数里加一个 moveToThread(this)方法。

    class Thread : public QThread {

    Q_OBJECT

    public:

    Thread() {

    moveToThread(this); // 错误

    }

    /* ... */

    };

    (译注:moveToThread(this)

    这样做确实可以工作(因为现在线程对象的依附性已经发生了改变),但这是一个非常不好的设计。这里的错误在于我们正在误解线程对象的目的(QThread子类):QThread对象们不是线程;他们是围绕在新产生的线程周围用于控制管理新线程的对象,因此,它们应该用在另一个线程(往往在它们所驻足的那一个线程)

    一个比较好而且能够得到相同结果的做法是将“工作”部分从“控制”部分剥离出来,也就是说,写一个QObject子类并使用QObject::moveToThread()方法来改变它的线程依附性:

    1. class Worker : public QObject  
    2. {  
    3. Q_OBJECT  
    4. public slots:  
    5. void doWork() {  
    6. /* ... */  
    7. }  
    8. };  
    9. /* ... */  
    10. QThread thread;  
    11. Worker worker;  
    12. connect(obj, SIGNAL(workReady()), &worker, SLOT(doWork()));  
    13. worker.moveToThread(&thread);  
    14. thread.start();  

     

    我应该什么时候使用线程

    当你不得不使用一个阻塞式API时

    当你需要(通过信号和槽,或者是事件、回调函数)使用一个没有提供非阻塞式API的库或者代码时,为了阻止冻结事件循环的唯一可行的解决方案是开启一个进程或者线程。由于创建一个新的进程的开销显然要比开启一个线程的开销大,后者往往是最常见的一种选择。

    这种API的一个很好的例子是地址解析 方法(只是想说我们并不准备谈论蹩脚的第三方API, 地址解析方法它是每个C库都要包含的),它负责将主机名转化为地址。这个过程涉及到启动一个查询(通常是远程的)系统:域名系统或者叫DNS。尽管通常情 况下响应会在瞬间发生,但远程服务器可能会失败:一些数据包可能会丢失,网络连接可能断开等等。简而言之,我们也许要等待几十秒才能得到查询的响应。

    UNIX系统可见的标准API只有阻塞式的(不仅过时的gethostbyname(3)是阻塞式的,而且更新的getservbyname(3) 以及getaddrinfo(3)也是阻塞式的)。QHostInfo [doc.qt.nokia.com],  它是一个负责处理域名查找的Qt类,该类使用了QThreadPool 从而使得查询可以在后台进行)(参见here [qt.gitorious.com]);如果屏蔽了多线程支持,它将切换回到阻塞式API).

    另一个简单的例子是图像装载和放大。QImageReader [doc.qt.nokia.com]QImage [doc.qt.nokia.com]仅仅提供了阻塞式方法来从一个设备读取图像,或者放大图像到一个不同的分辨率。如果你正在处理一个非常大的图像,这些处理会持续数(十)秒。

    当你想扩展至多核

    多线程允许你的程序利用多核系统的优势。因为每个线程都是被操作系统独立调度的,因此如果你的应用运行在这样多核机器上,调度器很可能同时在不同的处理器上运行每个线程。

    例如,考虑到一个通过图像集生成缩略图的应用。一个_n_ threads的线程农场(也就是说,一个有着固定 数量线程的线程池),在系统中可见的CPU运行一个线程(可参见QThread::idealThreadCount()),可以将缩小图像至缩略图的工 作交付给所有的进程,从而有效地提高了并行加速比,它与处理器的数量成线性关系。(简单的讲,我们认为CPU正成为一个瓶颈)。

    什么时候你可能不想别人阻塞

    这是一个很高级的话题,你可以忽略该小节。一个比较好的例子来自于Webkit里使用的QNetworkAccessManager 。Webkit是一个时髦的浏览器引擎,也就是说,它是一组用于处理网页的布局和显示的类集合。使用Webkit的Qt widget是QWebView。

    QNetworkAccessManager 是一个用于处理HTTP任何请求和响应的Qt类,我们可以把它当作一个web浏览器的网络引擎;所有的网络访问被同一个QNetworkAccessManager 以及它的QNetworkReplys 驻足的线程所处理。

    尽管在网络处理时不使用线程是一个很好的主意,它也有一个很大的缺点:如果你没有从socket中尽快地读取数据,内核的缓存将会被填满,数据包可能开始丢失而且传输速率也将迅速下降。

    Sokcet活动(即,从一个socket读取一些数据的可见性)由Qt的事件循环管理。阻塞事件循环因此会导致传输性能的损失,因为没有人会被通知将有数据可以读取(从而没人会去读数据)。

    但究竟什么会阻塞事件循环呢?令人沮丧地回答: WebKit它自己!只要有数据被接收到,WebKit便用其来布局网页。不幸地是,布局处理过程相当复杂,而且开销巨大。因此,它阻塞事件循环的一小段 时间足以影响到正在进行地传输(宽带连接这里起到了作用,在短短几秒内就可填满内核缓存)。

    总结一下上述所发生的事情:

    • WebKit提出了一个请求;
    • 一些响应数据开始到达;
    • WebKit开始使用接收到的数据布局网页,从而阻塞了事件循环;
    • 数据被OS接受,但没有一个正在运行的事件循环为之派发,所以并没有被QNetworkAccessManager sockets所读取;
    • 内核缓存将被填满,传输将变慢。

    网页的总体装载时间因其自发引起的传输速率降低而变得越来越坏。

    诺基亚的工程师正在试验一个支持多线程的QNetworkAccessManager来解决这个问题。请注意因为 QNetworkAccessManagers 和QNetworkReplys 是QObjects,他们不是线程安全的,因此你不能简单地将他们移到另一个线程中并且继续在你的线程中使用他们,原因在于,由于事件将被随后线程的事件 循环所派发,他们可能同时被两个线程访问:你自己的线程以及已经它们驻足的线程。

    是么时候不需要使用线程

    If you think you need threads then your processes are too fat.—Rob Pike

    计时器

    这也许是线程滥用最坏的一种形式。如果我们不得不重复调用一个方法(比如每秒),许多人会这样做:

    1. // 非常之错误  
    2. while (condition) {  
    3. doWork();  
    4. sleep(1); // this is sleep(3) from the C library  
    5. }  

     

    然后他们发现这会阻塞事件循环,因此决定引入线程:

    1. // 错误  
    2. class Thread : public QThread {  
    3. protected:  
    4. void run() {  
    5. while (condition) {  
    6. // notice that "condition" may also need volatiness and mutex protection  
    7. // if we modify it from other threads (!)  
    8. doWork();  
    9. sleep(1); // this is QThread::sleep()  
    10. }  
    11. }  
    12. };  

     

    一个更好也更简单的获得相同效果的方法是使用timers,即一个QTimer[doc.qt.nokia.com]对象,并设置一秒的超时时间,并让doWork方法成为它的槽:

    1. class Worker : public QObject  
    2. {  
    3. Q_OBJECT  
    4. public:  
    5. Worker() {  
    6. connect(&timer, SIGNAL(timeout()), this, SLOT(doWork()));  
    7. timer.start(1000);  
    8. }  
    9. private slots:  
    10. void doWork() {  
    11. /* ... */  
    12. }  
    13. private:  
    14. QTimer timer;  
    15. };  

     

    所有我们需要做的就是运行一个事件循环,然后doWork()方法将会被每隔秒钟调用一次。

    网络/状态机

    一个处理网络操作非常之常见的设计模式如下:

    1. socket->connect(host);  
    2. socket->waitForConnected();  
    3. data = getData();  
    4. socket->write(data);  
    5. socket->waitForBytesWritten();  
    6. socket->waitForReadyRead();  
    7. socket->read(response);  
    8. reply = process(response);  
    9. socket->write(reply);  
    10. socket->waitForBytesWritten();  
    11. /* ... and so on ... */  

     

    不用多说,各种各样的waitFor*()函数阻塞了调用者使其无法返回到事件循环,UI被冻结等等。请注意上面的这段代码并没有考虑到错误处理,否则它会更加地笨重。这个设计中非常错误的地方是我们正在忘却网络编程是异步的设计,如果我们构建一个同步的处理方法,则是自己给自己找麻烦。为了解决这个问题,许多人简单得将这些代码移到另一个线程中。

    另一个更加抽象的例子:

    1. result = process_one_thing();  
    2. if (result->something())  
    3. process_this();  
    4. else  
    5. process_that();  
    6. wait_for_user_input();  
    7. input = read_user_input();  
    8. process_user_input(input);  
    9. /* ... */  

     

    它多少反映了网络编程相同的陷阱。

    让我们回过头来从更高的角度来想一下我们这里正在构建的代码:我们想创造一个状态机,用以反映某类的输入并相对应的作某些动作。比如,上面的这段网络代码,我们可能想做如下这些事情:

    • 空闲→ 正在连接 (当调用connectToHost());
    • 正在连接→ 已经连接(当connected() 信号被发射);
    • 已经连接→ 发送录入数据 (当我们发送录入的数据给服务器);
    • 发送录入数据 → 录入 (服务器响应一个ACK)
    • 发送录入数据→ 录入错误(服务器响应一个NACK)

    以此类推。

    现在,有很多种方式来构建状态机(Qt甚至提供了QStateMachine[doc.qt.nokia.com]类),最简单的方式是用一个枚举值(及,一个整数)来记忆当前的状态。我们可以这样重写以下上面的代码:

    1. class Object : public QObject  
    2. {  
    3. Q_OBJECT  
    4. enum State {  
    5. State1, State2, State3 /* and so on */  
    6. };  
    7. State state;  
    8. public:  
    9. Object() : state(State1)  
    10. {  
    11. connect(source, SIGNAL(ready()), this, SLOT(doWork()));  
    12. }  
    13. private slots:  
    14. void doWork() {  
    15. switch (state) {  
    16. case State1:  
    17. /* ... */  
    18. state = State2;  
    19. break;  
    20. case State2:  
    21. /* ... */  
    22. state = State3;  
    23. break;  
    24. /* etc. */  
    25. }  
    26. }  
    27. };  

     

    那么“souce”对象和它的信号“ready()” 究竟是什么? 我们想让它们是什么就是什么:比如说,在这个例子中,我们可能想把我们的槽连接到socket的 QAbstractSocket::connected() 以及QIODevice::readyRead() 信号中,当然,我们也可以简单地在我们的用例中加更多的槽(比如一个槽用于处理错误情况,它将会被QAbstractSocket::error() 信号所通知)。这是一个真正的异步的,信号驱动的设计!

    分解任务拆成不同的块

    假如我们有一个开销很大的计算,它不能够轻易的移到另一个线程中(或者说它根本不能被移动,举个例子,它必须运行在GUI线程中)。如果我们能将计算拆分成小的块,我们就能返回到事件循环,让它来派发事件,并让它激活处理下一个块相应的函数。如果我们还记得queued connections是怎么实现的,那么会觉得这是很容易能够做到的:一个事件派发到接收者所驻足的线程的事件循环;当事件被传递,相应的槽随之被激活。

    我们可以使用特化QMetaObject::invokeMethod() 的激活类型为Qt::QueuedConnection 来得到相同的结果;这需要函数是可激活的。因此它需要一个槽或者用Q_INVOKABLE宏来标识。如果我们同时想给函数中传入参数,他们需要使用Qt元 对象类型系统里的qRegisterMetaType()进行注册。请看下面这段代码:

    1. class Worker : public QObject  
    2. {  
    3. Q_OBJECT  
    4. public slots:  
    5. void startProcessing()  
    6. {  
    7. processItem(0);  
    8. }  
    9. void processItem(int index)  
    10. {  
    11. /* process items[index] ... */  
    12. if (index < numberOfItems)  
    13. QMetaObject::invokeMethod(this,  
    14. "processItem",  
    15. Qt::QueuedConnection,  
    16. Q_ARG(int, index + 1));  
    17. }  
    18. };  

     

    因为并没有引入多线程,所以暂停/进行/取消这样的计算并收集回结果变得简单。(结束

    原文出处:

    http://developer.qt.nokia.com/wiki/ThreadsEventsQObjects

    请尊重原创作品和译文。转载请保持文章完整性,并以超链接形式注明原始作者主站点地址,方便其他朋友提问和指正。

  • 相关阅读:
    toj 2975 Encription
    poj 1797 Heavy Transportation
    toj 2971 Rotating Numbers
    zoj 2281 Way to Freedom
    toj 2483 Nasty Hacks
    toj 2972 MOVING DHAKA
    toj 2696 Collecting Beepers
    toj 2970 Hackle Number
    toj 2485 Card Tric
    js页面定位,相关几个属性
  • 原文地址:https://www.cnblogs.com/hdjjun/p/3261984.html
Copyright © 2011-2022 走看看