zoukankan      html  css  js  c++  java
  • 精通 Grails: 测试 Grails 应用程序

    排除 bug,构建可执行文档
    Grails 可以轻松确保您的应用程序从始至终都远离 bug。这还有另一个好处,您可以利用测试代码生成一组通常是最新的可执行文档。本月 Grails 专家 Scott Davis 向您展示如何对 Grails 进行测试。
    查看本系列更多内容 |   评论:
    Scott Davis , 主编, AboutGroovy.com
    2008 年 10 月 31 日
    +
    内容
    我是测试驱动开发(test-driven development,TDD)的大力支持者。Neal Ford(The Productive Programmer 的作者)说道 “不测试所编写的代码就是失职”。Michael Feathers(Working Effectively with Legacy Code 的作者)将 “遗留代码” 定义为没有经过相应测试的任何软件 — 这表明编写代码而不进行测试是一种过时的实践。我常说每编写一定数量的生产代码,就要编写两倍的测试代码。
    精通 Grails 尚未讨论 TDD,因为到目前为止,这个系列主要关注如何利用 Grails 的核心功能。测试基础设施代码(不用您编写的代码)有一定的价值,但我很少这样做。我相信 Grails 能够正确地将我的 POGO 呈现为 XML,或在我调用 trip.save() 时将我的 Trip 保存到数据库。当您检查自己编写的代码时,测试的真正价值就体现出来了。如果您编写一个复杂的算法,您应该有一个或多个补充单元测试,确保该算法正常工作。在本文,您将看到 Grails 如何帮助和鼓励您进行应用程序测试。
    编写第一个测试
    在开始测试之前,我将介绍一个新的域类。这个类的一些定制功能必须经过测试才能进入到生产中。输入 grails create-domain-class HotelStay,如清单 1 所示:
    关于本系列
    Grails 是一种新型 Web 开发框架,它将常见的 Spring 和 Hibernate 等 Java™ 技术与当前流行的约定优于配置等实践相结合。Grails 是用 Groovy 编写的,它可以提供与遗留 Java 代码的无缝集成,同时还可以加入脚本编制语言的灵活性和动态性。学习完 Grails 之后,您将彻底改变看待 Web 开发的方式。
    清单 1. 创建 HotelStay 类
    $ grails create-domain-class HotelStay

    Environment set to development
         [copy] Copying 1 file to /src/trip-planner2/grails-app/domain
    Created Domain Class for HotelStay
         [copy] Copying 1 file to /src/trip-planner2/test/integration
    Created Tests for HotelStay
    从清单 1 可以看到,Grails 在 grails-app/domain 目录中为您创建了一个空的域类。它还在 test/integration 目录中创建了一个带有空的 testSomething() 方法的 GroovyTestCase 类(稍后我将进一步讲述单元测试和集成测试的区别)。清单 2 展示了一个带有生成的测试的空 HotelStay 类:
    清单 2. 带有生成的测试的空类
    class HotelStay {
    }

    class HotelStayTests extends GroovyTestCase {
        void testSomething() {
        }
    }
    GroovyTestCase 是在 JUnit 3.x 单元测试之上的一层 Groovy。如果您熟悉 JUnit TestCase,您肯定知道 GroovyTestCase 是如何工作的。对于这两种情况,您通过断言代码正常工作来测试它们。JUnit 有各种不同的断言方法,包括 assertEquals、assertTrue 和 assertNull 等等。它使您通过编程的方式表明 “我断言这个代码按照预期工作”。
    为什么是 JUnit 3.x 而不是 4.x?
    由于历史原因,GroovyTestCase 就是一个 JUnit 3.x TestCase。当 Groovy 1.0 于 2007 年 1 月发布时,它支持 Java 1.4 语言结构。它可以在 Java 1.4、1.5 和 1.6 JVM 上运行,但在语言级别上仅与 Java 1.4 兼容。
    接下来 Groovy 的主要发布版是 1.5,在 2008 年 1 月发布。Groovy 1.5 支持所有 Java 1.5 语言特性,比如泛型、静态导入、for/in 循环和注释(后者最值得讨论)。不过 Groovy 1.5 仍然可以在 Java 1.4 JVM 上运行。Groovy 开发团队许诺所有 Groovy 1.x 版本都将与 Java 1.4 保持向后兼容性。当 Groovy 2.x 发布时(可能是 2009 年末或 2010 年),它将不支持 Java 1.4。
    因此,这些与 GroovyTestCase 打包的 JUnit 版本有什么关系呢?JUnit 4.x 引入了一些注释,比如 @test、@before 和 @after。尽管这些新特性非常有趣,但 JUnit 3.x 仍然是 GroovyTestCase 向后兼容 Java 1.4 的基础。
    这就是说,您完全可以使用 JUnit 4.x(参见 参考资料 获得 Groovy 站点相关文档的链接)。引入其他使用注释和 Java 5 语言特性的测试框架是完全有可能的(参见 参考资料 获得结合使用 TestNG 和 Groovy 的示例)。Groovy 的字节码与 Java 编程兼容,因此您可以通过 Groovy 使用任何 Java 测试框架。
    将清单 3 中的代码添加到 grails-app/domain/HotelStay.groovy 和 test/integration/HotelStayTests.groovy:
    清单 3. 一个简单的测试
    class HotelStay{
      String hotel
    }

    class HotelStayTests extends GroovyTestCase {
      void testSomething(){
        HotelStay hs = new HotelStay(hotel:"Sheraton")
        assertEquals "Sheraton", hs.hotel
      }
    }
    清单 3 正是我前面提到那种低级 Grails 基础设施测试。您应该相信 Grails 能够正确执行这个操作,因此这是一个典型的错误测试类型。但它允许您编写最简单的测试并观察其运行,实现了本文的目的。
    要运行所有测试,请输入 grails test-app。要仅运行这个测试,请输入 grails test-app HotelStay(由于约定优于配置,Tests 后缀可以省略)。不管输入哪个命令,您应该会在命令提示中看到如清单 4 所示的输出(注意:为了突出重要的特性,我删减了许多代码)。
    清单 4. 运行测试时的输出
    $ grails test-app
    Environment set to test

    No tests found in test/unit to execute ...

    -------------------------------------------------------
    Running 1 Integration Test...
    Running test HotelStayTests...
                        testSomething...SUCCESS
    Integration Tests Completed in 253ms
    -------------------------------------------------------

    Tests passed. View reports in /src/trip-planner2/test/reports
    这里发生了 4 件重要的事情:
    可以看到,environment 被设置为 test。这意味着 conf/DataSource.groovy 文件中的 test 块的数据库设置已生效。
    test/unit 中的脚本已运行。您尚未编写任何单元测试,所以不能找到任何单元测试,这并不奇怪。
    test/integration 中的脚本已经运行。您可以看到 HotelStayTests.groovy 脚本的输出 — 它的旁边有个很大的 SUCCESS。
    这个脚本向您展示一组报告。
    如果您在 Web 浏览器中打开 /src/trip-planner2/test/reports/html/index.html,应该会看到一个关于所有已运行的测试的报告。如图 1 所示。
    图 1. JUnit 顶级汇总报告
    JUnit 顶级汇总报告
    如果您单击 HotelStayTests 链接,应该会看到 doSomething() 测试,如图 2 所示:
    图 2. JUnit 类级报告
    JUnit 类级报告
    如果测试意外失败,命令提示输出和 HTML 报告(如图 3 所示) 将通知您:
    图 3. 失败的 JUnit 测试
    失败的 JUnit 测试
    回页首
    编写第一个有价值的测试
    以上是第一个正常运行的简单测试,接下来将展示一个更加实用的 测试示例。假设您的 HotelStay 类有两个字段:Date checkIn 和 Date checkOut。根据一个用户情景,toString 方法的输出应该像这样:Hilton (Wednesday to Sunday)。通过 java.text.SimpleDateFormat 类,获取正确格式的日期非常简单。您应该为此编写一个测试,但不需验证 SimpleDateFormat 是否正确工作。您的测试做两件事情:它验证 toString 方法是否按照预期运行;它证明您是否满足用户情景。
    单元测试是可执行的文档
    用户需求常常是桌面上的某些文档。作为开发人员,您应该将这些需求转换成有效的软件。
    需求文档的问题是:在进行实际软件开发时它通常已经过时。它不是可以随着软件的发展而变化的 “活动文档”。工件 一词完美地描述了这种情况 — 文档描述软件最初的、历史性的任务是什么,而不是当前实现要做什么。
    要想准备一组全面的、优秀的测试,仅仅保持代码没有 bug 是不够的。这样的测试有一个附带的好处,即您可以得到 “可执行的文档”:用代码表示活动的、不断变化的项目需求。如果将测试映射到需求,则可以和用户共享某些内容。您必须保证代码的健全,保证满足了用户的需求。将这个可执行文档与 CruiseControl 等持续集成服务器(持续反复地运行测试的服务器)相结合,就可以得到一个安全保障机制,它保证新特性不会对原本良好的软件造成损害。
    行为驱动的开发(Behavior-Driven Development,BDD)完全采用了可执行文档的想法。easyb 是一个用 Groovy 编写的 BDD,它允许您将测试编写成用户和开发人员都可以阅读的用户需求(参见 参考资料)。如果一些用户思想比较前卫,宁愿放弃 Microsoft® Word(例如),easyb 可以排除所有过时的需求文档。因此,项目需求从一开始就是可执行的。
    将清单 5 中的代码输入到 HotelStay.groovy 和 HotelStayTests.groovy:
    清单 5. 使用 assertToString
    import java.text.SimpleDateFormat
    class HotelStay {
      String hotel
      Date checkIn
      Date checkOut
      
      String toString(){
        def sdf = new SimpleDateFormat("EEEE")
        "${hotel} (${sdf.format(checkIn)} to ${sdf.format(checkOut)})"
      } 
    }


    import java.text.SimpleDateFormat
    class HotelStayTests extends GroovyTestCase {

        void testSomething(){...}

        void testToString() {
          def h = new HotelStay(hotel:"Hilton")
          def df = new SimpleDateFormat("MM/dd/yyyy")
          h.checkIn = df.parse("10/1/2008")
          h.checkOut = df.parse("10/5/2008")
          println h
          assertToString h, "Hilton (Wednesday to Sunday)"
        }
    }
    输入 grails test-app 验证第二个测试是否通过。
    testToString 方法使用了新的断言方法之一 —assertToString— 它由 GroovyTestCase 引入。使用 JUnit assertEquals 方法肯定会获得相同的结果,但是 assertToString 的表达能力更强。测试方法的名称和最终的断言清楚地表明了这个测试的目的(参见 参考资料 获得一个链接,它列出了 GroovyTestCase 支持的所有断言,包括 assertArrayEquals、assertContains 和 assertLength)。
    回页首
    添加控制器和视图
    到目前为止,您一直以编程的方式与 HotelStay 域类交互。添加一个 HotelStayController,如清单 6 所示,它使您能够在 Web 浏览器上使用该类:
    清单 6. HotelStayController 源代码
    class HotelStayController {
      def scaffold = HotelStay
    }
    您应该对 create 表单进行仔细的 UI 调试。默认情况下,日期字段包括 day、month、year、hours 和 minutes,如图 4 所示:
    图 4. 默认显示日期和时间
    默认显示日期和时间
    在这里,忽略日期字段的时间戳部分是安全的。输入 grails generate-views HotelStay。要创建图 5 所示的经过修改的 UI,请将 precision="day" 添加到 views/hotelStay/create.gsp 和 views/hotelStay/edit.gsp 中的 <g:datePicker> 元素:
    图 5. 仅显示日期
    仅显示日期
    有了运行在 servlet 容器中的活动的、有效的 HotelStay 之后,就要开始讨论测试了:单元测试还是集成测试?
    回页首
    对比单元测试和集成测试
    如我前面所述,Grails 支持两种基本类型的测试:单元测试和集成测试。这两者之间没有语法区别 — 它们都是用相同的断言写的 GroovyTestCase。它们的区别在于语义。单元测试孤立地测试类,而集成测试在一个完整的运行环境中测试类。
    坦白地说,如果您想将所有的 Grails 测试都编写成集成测试,则刚好符合我的想法。所有 Grails create-* 命令都生成相应的集成测试,所以很多人都使用现成的集成测试。正如稍后看到的一样,很多测试需要在完整的运行环境中进行,因此默认使用集成测试是很好的选择。
    如果您想测试一些非核心 Grails 类,则适合使用单元测试。要创建一个单元测试,请输入 grails create-unit-test MyTestUnit。因为测试脚本不是在不同的包中创建的,所以单元测试和集成测试的名称应该是惟一的。如果不是这样的话,将会收到清单 7 所示的错误消息:
    清单 7. 单元测试和集成测试同名时收到的错误消息
    The sources
    /src/trip-planner2/test/integration/HotelStayTests.groovy and
       /src/trip-planner2/test/unit/HotelStayTests.groovy are
       containing both a class of the name HotelStayTests.
    @ line 3, column 1.
       class HotelStayTests extends GroovyTestCase {
       ^

    1 error
    因为集成测试默认使用后缀 Tests,所以我在所有单元测试上都使用后缀 UnitTests,避免混淆。
    回页首
    为简单的验证错误消息编写测试
    下一个用户场景说明 hotel 字段不能留空。这很容易通过内置的 Grails 验证框架来实现。将一个 static constraints 块添加到 HotelStay,如清单 8 所示:
    清单 8. 将一个 static constraints 块添加到 HotelStay
    class HotelStay {
      static constraints = {
        hotel(blank:false)
        checkIn()
        checkOut()
      }
     
      String hotel
      Date checkIn
      Date checkOut
     
      //the rest of the class remains the same
    }
    输入 grails run-app。如果您尝试在留空 hotel 字段的情况下创建一个 HotelStay,将收到如图 6 所示的错误消息:
    图 6. 空字段的默认错误消息
    空字段的默认错误消息
    我敢保证您的用户会喜欢这个特性,但对默认的错误消息还不是很满意。假设他们稍微改动了一下用户场景:hotel 字段不能留空;如果留空,错误消息会提示 “Please provide a hotel name”。
    现在您已经添加了一些定制代码 — 尽管它就像一个定制的 String 那么简单 — 接下来应该添加测试了(当然,编写一个验证用户场景的完整性的测试 — 尽管不涉及到定制代码 — 也是完全可以接受的。
    打开 grails-app/i18n/messages.properties 并添加 hotelStay.hotel.blank=Please provide a hotel name。尝试在浏览器中提交一个空 hotel。这时您将看到自己的定制消息,如图 7 所示:
    图 7. 显示定制的验证错误消息
    显示定制的验证错误消息
    向 HotelStayTests.groovy 添加一个新测试,检验对空字段的验证是否有效,如清单 9 所示:
    清单 9. 测试验证错误
    class HotelStayTests extends GroovyTestCase {
      void testBlankHotel(){
        def h = new HotelStay(hotel:"")
        assertFalse "there should be errors", h.validate()
        assertTrue "another way to check for errors after you call validate()", h.hasErrors()
      }

      //the rest of the tests remain unchanged
    }
    在生成的控制器中,您已经看到添加到域类中的 save() 方法。在这里,我本来也可以调用 save(),但事实上我并不想把新的类保存到数据库。我只关注验证是否发生。由 validate() 方法来完成这个任务。如果验证失败,则返回 false。如验证成功,则返回 true。
    hasErrors() 是另一个很有价值的测试方法。在调用 save() 或 validate() 之后,hasErrors() 允许您查看验证错误。
    清单 10 是经过扩展的 testBlankHotel(),它引入了其他一些很有用的验证方法:
    清单 10. 验证错误的高级测试
    class HotelStayTests extends GroovyTestCase {
      void testBlankHotel(){
       def h = new HotelStay(hotel:"")
       assertFalse "there should be errors", h.validate()
       assertTrue "another way to check for errors after you call validate()", h.hasErrors() 
     
       println " Errors:"
       println h.errors ?: "no errors found"  
        
       def badField = h.errors.getFieldError('hotel')
       println " BadField:"
       println badField ?: "hotel wasn't a bad field"
       assertNotNull "I'm expecting to find an error on the hotel field", badField


       def code = badField?.codes.find {it == 'hotelStay.hotel.blank'}
       println " Code:"
       println code ?: "the blank hotel code wasn't found"
       assertNotNull "the blank hotel field should be the culprit", code
      }
    }
    确定类没有通过验证之后,您可以调用 getErrors() 方法(在这里,借助 Groovy 简洁的 getter 语法,它被缩略为 errors),返回一个 org.springframework.validation.BeanPropertyBindingResult。就像 GORM 与 Hibernate 相比是一个瘦 Groovy 层一样,Grails 验证只不过是一个简单的 Spring 验证。
    调用 println 的结果不会在命令行上显示,但它们出现在 HTML 报告中,如图 8 所示:
    图 8. 查看测试的 println 输出
    查看测试的 println 输出
    在 HotelStayTests 报告的右下角单击 System.out 链接。
    清单 10 中给人亲切感觉的 Elvis 操作符(转过脸来 — 看见他向后梳起的发型和那双眼睛吗?)是一个缩略的 Groovy 三元操作符。如果 ?: 左边的对象为 null,将使用右边的值。
    将 hotel 字段更改为 "Holiday Inn" 并重新运行测试。您将在 HTML 报告中看到另一个 Elvis 输出,如图 9 所示:
    图 9. 测试输出中的 Elvis
    测试输出中的 Elvis
    看见 Elvis 之后,不要忘记清空 hotel 字段 — 如果您不希望留下中断的测试的话。
    如果仍然显示关于 checkIn 和 checkOut 的验证错误,您不必担心。就这个测试而言,您完全可以忽略它们。但是这表明您不应该仅测试错误是否出现 — 您应该确保特定的 错误被抛出。
    注意,我没有断言定制错误消息的确切文本。为什么我上一次关注匹配的字符串(测试 toString 的输出时)而这一次没有关注?toString 方法的定制输出便是上一个测试的目的。这一次,我更关心的是确定验证代码的执行,而不是 Grails 是否正确呈现消息。这表明测试更像一门艺术,而不是科学(如果我想验证准确的消息输出,则应该使用 Web 层测试工具,比如 Canoo WebTest 或 ThoughtWorks Selenium)。
    回页首
    创建和测试定制验证
    现在,应该处理下一个用户场景了。您需要确保 checkOut 日期发生在 checkIn 日期之后。要解决这个问题,您需要编写一个定制验证。编写完之后,要验证它。
    将清单 11 中的定制验证代码添加到 static constraints 块:
    清单 11. 一个定制的验证
    class HotelStay {
      static constraints = {
        hotel(blank:false)
        checkIn()
        checkOut(validator:{val, obj->
          return val.after(obj.checkIn)
        })
      }
     
      //the rest of the class remains the same
    }
    val 变量是当前的字段。obj 变量表示当前的 HotelStay 实例。Groovy 将 before() 和 after() 方法添加到所有 Date 对象,所以这个验证仅返回 after() 方法调用的结果。如果 checkOut 发生在 checkIn 之后,验证返回 true。否则,它返回 false 并触发一个错误。
    现在,输入 grails run-app。确保不能创建一个 checkOut 日期早于 checkIn 日期的新 HotelStay 实例。如图 10 所示:
    图 10. 默认的定制验证错误消息
    默认的定制验证错误消息
    打开 grails-app/i18n/messages.properties,并向 checkOut 字段添加一个定制验证消息:hotelStay.checkOut.validator.invalid=Sorry, you cannot check out before you check in。
    保存 messages.properties 文件并尝试保存有缺陷的 HotelStay。您将看到如清单 11 所示的错误消息:
    清单 11. 定制验证错误消息
    定制验证错误消息
    现在应该编写测试了,如清单 12 所示:
    清单 12. 测试定制的验证
    import java.text.SimpleDateFormat
    class HotelStayTests extends GroovyTestCase {
      void testCheckOutIsNotBeforeCheckIn(){
        def h = new HotelStay(hotel:"Radisson")
        def df = new SimpleDateFormat("MM/dd/yyyy")
        h.checkIn = df.parse("10/15/2008")
        h.checkOut = df.parse("10/10/2008")
     
        assertFalse "there should be errors", h.validate()
        def badField = h.errors.getFieldError('checkOut')
        assertNotNull "I'm expecting to find an error on the checkOut field", badField
        def code = badField?.codes.find {it == 'hotelStay.checkOut.validator.invalid'}
        assertNotNull "the checkOut field should be the culprit", code                
      }
    }
    回页首
    测试定制的 TagLib
    接下来是最后一个需要处理的用户场景。您已经在 create 和 edit 视图中成功地处理了 checkIn 和 checkOut 的时间戳 部分,但它在 list 和 show 视图中仍然是错误的,如图 12 所示:
    图 12. 默认的 Grails 日期输入(包括时间戳)
    默认的 Grails 日期输入(包括时间戳)
    最简单的解决办法是定义一个新的 TagLib。您可以利用 Grails 已经定义的 <g:formatDate> 标记,但创建一个自己的定制标记也很容易。我想创建一个可以以两种方式使用的 <g:customDateFormat> 标记。
    一种形式的 <g:customDateFormat> 标记打包一个 Date,并接受一个接受任何有效 SimpleDateFormat 模式的定制格式属性:
    <g:customDateFormat format="EEEE">${new Date()}</g:customDateFormat>
    因为大多数用例都以美国的 “MM/dd/yyyy” 格式返回日期,所以如果没有特别指定,我将采用这种格式:
    <g:customDateFormat>${new Date()}</g:customDateFormat>
    现在,您已经知道了每个用户场景的需求,那么请输入 grails create-tag-lib Date(如清单 13 所示),以创建一个全新的 DateTagLib.groovy 文件和一个相应的 DateTagLibTests.groovy 文件:
    清单 13. 创建一个新的 TagLib
    $ grails create-tag-lib Date
    [copy] Copying 1 file to /src/trip-planner2/grails-app/taglib
    Created TagLib for Date
    [copy] Copying 1 file to /src/trip-planner2/test/integration
    Created TagLibTests for Date
    将清单 14 中的代码添加到 DateTagLib.groovy:
    清单 14. 创建定制的 TagLib
    import java.text.SimpleDateFormat

    class DateTagLib {
      def customDateFormat = {attrs, body ->
        def b = attrs.body ?: body()
        def d = new SimpleDateFormat("yyyy-MM-dd hh:mm:ss").parse(b)
       
        //if no format attribute is supplied, use this
        def pattern = attrs["format"] ?: "MM/dd/yyyy"
        out << new SimpleDateFormat(pattern).format(d)
      }
    }
    TagLib 接受属性形式的简单的 String 值和标记体,并将一个 String 发送到输出流。由于您将使用这个定制标记封装未格式化的 Date 字段,所以需要两个 SimpleDateFormat 对象。输入对象读入一个与 Date.toString() 调用的默认格式相匹配的 String。当将其解析为适当的 Date 对象之后,您就可以创建第二个 SimpleDateFormat 对象,以便以另一种格式的 String 将它传回。
    使用新的 TagLib 在 list.gsp 和 show.gsp 中封装 checkIn 和 checkOut 字段。如清单 15 所示:
    清单 15. 使用定制的 TagLib
    <g:customDateFormat>${fieldValue(bean:hotelStay, field:'checkIn')}</g:customDateFormat>
    输入 grails run-app,然后访问 http://localhost:9090/trip/hotelStay/list,检查实际使用中的定制 TagLib,如图 13 所示:
    图 13. 使用定制 TagLib 的数据输出
    使用定制 TagLib 的数据输出
    现在,编写清单 16 中的几个测试,用来检查 TagLib 是否按照预期工作:
    清单 16. 测试定制的 TagLib
    import java.text.SimpleDateFormat

    class DateTagLibTests extends GroovyTestCase {
        void testNoFormat() {
          def output =
             new DateTagLib().customDateFormat(format:null, body:"2008-10-01 00:00:00.0")
          println " customDateFormat using the default format:"
          println output
         
          assertEquals "was the default format used?", "10/01/2008", output
        }

        void testCustomFormat() {
          def output =
             new DateTagLib().customDateFormat(format:"EEEE", body:"2008-10-01 00:00:00.0")
          assertEquals "was the custom format used?", "Wednesday", output
        }
    }
    回页首
    结束语
    到目前为止,您已经编写了几个测试,并看到了用它们测试 Grails 组件是多么简单!但是您可以继续开拓,不断取得进步,这会让您对工作更加自信。将自己的测试和用户场景匹配起来有这样的好处:您将拥有一组永远保持最新的可执行文档。
    在下一篇文章中,我将重点讨论 JavaScript Object Notation (JSON)。Grails 具有出色的开箱即用的 JSON 支持。您将了解如何通过控制器生成 JSON,以及如何在 GSP 中使用它。在此期间,享受精通 Grails 带来的乐趣吧。
  • 相关阅读:
    RUST实践.md
    redis.md
    opencvrust.md
    aws rds can't connect to mysql server on 'xx'
    Foundation ActionScript 3.0 With Flash CS3 And Flex
    Foundation Flash Applications for Mobile Devices
    Flash Mobile Developing Android and iOS Applications
    Flash Game Development by Example
    Actionscript 3.0 迁移指南
    在SWT中非UI线程控制界面
  • 原文地址:https://www.cnblogs.com/iamconan/p/7383446.html
Copyright © 2011-2022 走看看