用 Go 实现 Flutter

我最近发现了 Flutter —— 谷歌的一个新的移动开发框架,我甚至曾经将 Flutter 基础知识教给没有编程经验的人。Flutter 是用 Dart 编写的,这是一种诞生于 Chrome 浏览器的编程语言,后来改用到了控制台。这不禁让我想到“Flutter 也许可以很轻易地用 Go 来实现”!

为什么不用 Go 实现呢?Go 和 Dart 都是诞生于谷歌(并且有很多的大会分享使它们变得更好),它们都是强类型的编译语言 —— 如果情形发生一些改变,Go 也完全可以成为像 Flutter 这样热门项目的选择。而那时候 Go 会更容易地向没有编程经验的人解释或传授。

假如 Flutter 已经是用 Go 开发的。那它的代码会是什么样的?

Dart 的问题

自从 Dart 在 Chrome 中出现以来,我就一直在关注它的开发情况,我也一直认为 Dart 最终会在所有浏览器中取代 JS。2015 年,得知有关谷歌在 Chrome 中放弃 Dart 支持的消息时,我非常沮丧。

Dart 是非常奇妙的!是的,当你从 JS 升级转向到 Dart 时,会感觉一切都还不错;可如果你从 Go 降级转过来,就没那么惊奇了,但是…… Dart 拥有非常多的特性 —— 类、泛型、异常、Futures、异步等待、事件循环、JIT、AOT、垃圾回收、重载 —— 你能想到的它都有。它有用于 getter/setter 的特殊语法、有用于构造函数自动初始化的特殊语法、有用于特殊语句的特殊语法等。

虽然它让能让拥有其他语言经验的人更容易熟悉 Dart —— 这很不错,也降低了入门门槛 —— 但我发现很难向没有编程经验的新手讲解它。

  • 所有“特殊”的东西易被混淆 —— “名为构造方法的特殊方法”,“用于初始化的特殊语法”,“用于覆盖的特殊语法”等等。
  • 所有“隐式”的东西令人困惑 —— “这个类是从哪儿导入的?它是隐藏的,你看不到它的实现代码”,“为什么我们在这个类中写一个构造方法而不是其他方法?它在那里,可是它是隐藏的”等等。
  • 所有“有歧义的语法”易被混淆 —— “所以我应该在这里使用命名或者对应位置的参数吗?”,“应该使用 final 还是用 const 进行变量声明?”,“应该使用普通函数语法还是‘箭头函数语法’”等等。

这三个标签 —— “特殊”、“隐式”和“歧义” —— 可能更符合人们在编程语言中所说的“魔法”的本质。这些特性旨在帮助我们编写更简单、更干净的代码,但实际上,它们给阅读程序增加了更多的混乱和心智负担。

而这正是 Go 截然不同并且有着自己强烈特色的地方。Go 实际上是一个非魔法的语言 —— 它将特殊、隐式、歧义之类的东西的数量讲到最低。然而,它也有一些缺点。

Go 的问题

当我们讨论 Flutter 这种 UI 框架时,我们必须把 Go 看作一个描述/指明 UI 的工具。UI 框架是一个非常复杂的主题,它需要创建一种专门的语言来处理大量的底层复杂性。最流行的方法之一是创建 DSL —— 特定领域的语言 —— 众所周知,Go 在这方面不那么尽如人意。

创建 DSL 意味着创建开发人员可以使用的自定义术语和谓词。生成的代码应该可以捕捉 UI 布局和交互的本质,并且足够灵活,可以应对设计师的想象流,又足够的严格,符合 UI 框架的限制。例如,你应该能够将按钮放入容器中,然后将图标和文本小组件放入按钮中,可如果你试图将按钮放入文本中,编译器应该给你提示一个错误。

特定于 UI 的语言通常也是声明性的 —— 实际上,这意味着你应该能够使用构造代码(包括空格缩进!)来可视化的捕获 UI 组件树的结构,然后让 UI 框架找出要运行的代码。

有些语言更适合这样的使用方式,而 Go 从来没有被设计来完成这类的任务。因此,在 Go 中编写 Flutter 代码应该是一个相当大的挑战!

Flutter 的优势

如果你不熟悉 Flutter,我强烈建议你花一两个周末的时间来观看教程或阅读文档,因为它无疑会改变移动开发领域的游戏规则。而且,可能不仅仅是移动端 —— 还有原生桌面应用程序和 web 应用程序的渲染器(用 Flutter 的术语来说就是嵌入式)。Flutter 容易学习,它是合乎逻辑的,它汇集了大量的 Material Design 强大组件库,有活跃的社区和丰富的工具链(如果你喜欢“构建/测试/运行”的工作流,你也能在 Flutter 中找到同样的“构建/测试/运行”的工作方式)还有大量其他的用于实践的工具箱。

在一年前我需要一个相对简单的移动应用(很明显就是 IOS 或 Android),但我深知精通这两个平台开发的复杂性是非常非常大的(至少对于这个 app 是这样),所以我不得不将其外包给另一个团队并为此付钱。对于像我这样一个拥有近 20 年的编程经验的开发者来说,开发这样的移动应用几乎是无法忍受的。

使用 Flutter,我用了 3 个晚上的时间就编写了同样的应用程序,与此同时,我是从头开始学习这个框架的!这是一个数量级的提升,也是游戏规则的巨大改变。

我记得上一次看到类似这种开发生产力革命是在 5 年前,当时我发现了 Go。并且它改变了我的生活。

我建议你从这个很棒的视频教程开始。

Flutter 的 Hello, world

当你用 flutter create 创建一个新的 Flutter 项目,你会得到这个“Hello, world”应用程序和代码文本、计数器和一个按钮,点击增加按钮,计数器会增加。

我认为用我们假想的 Go 版的 Flutter 重写这个例子是非常好的。它与我们的主题有密切的关联。看一下它的代码(它是一个文件):

lib/main.dart:

import 'package:flutter/material.dart';void main() => runApp(MyApp());class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( title: 'Flutter Demo', theme: ThemeData( primarySwatch: Colors.blue, ), home: MyHomePage(title: 'Flutter Demo Home Page'), ); }}class MyHomePage extends StatefulWidget { MyHomePage({Key key, this.title}) : super(key: key); final String title; @override _MyHomePageState createState() => _MyHomePageState();}class _MyHomePageState extends State { int _counter = 0; void _incrementCounter() { setState(() { _counter++; }); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text(widget.title), ), body: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Text( 'You have pushed the button this many times:', ), Text( '$_counter', style: Theme.of(context).textTheme.display1, ), ], ), ), floatingActionButton: FloatingActionButton( onPressed: _incrementCounter, tooltip: 'Increment', child: Icon(Icons.add), ), ); }}复制代码

我们先把它分解成几个部分,分析哪些可以映射到 Go 中,哪些不能映射,并探索目前我们拥有的选项。

映射到 Go

一开始是相对比较简单的 —— 导入依赖项并启动 main() 函数。这里没有什么挑战性也不太有意思,只是语法上的变化:

package helloimport "github.com/flutter/flutter"func main() { app := NewApp() flutter.Run(app)}复制代码

唯一的不同的是不使用魔法的 MyApp() 函数,它是一个构造方法,也是一个特殊的函数,它隐藏在被称为 MyApp 的类中,我们只是调用一个显示定义的 NewApp() 函数 —— 它做了同样的事情,但它更易于阅读、理解和弄懂。

Widget 类

在 Flutter 中,一切皆 widget(小组件)。在 Flutter 的 Dart 版本中,每个小组件都代表一个类,这个类扩展了 Flutter 中特殊的 Widget 类。

Go 中没有类,因此也没有类层次,因为 Go 的世界不是面向对象的,更不必说类层次了。对于只熟悉基于类的 OOP 的人来说,这可能是一个不太好的情况,但也不尽然。这个世界是一个巨大的相互关联的事物和关系图谱。它不是混沌的,可也不是完全的结构化,并且尝试将所有内容都放入类层次结构中可能会导致代码难以维护,到目前为止,世界上的大多数代码库都是这样子。

我喜欢 Go 的设计者们努力重新思考这个无处不在的基于 OOP 思维,并提出了与之不同的 OOP 概念,这与 OOP 的发明者 Alan Kay 所要表达的真实意义更接近,这不是偶然。

在 Go 中,我们用一个具体的类型 —— 一个结构体来表示这种抽象:

type MyApp struct { // ...}复制代码

在一个 Flutter 的 Dart 版本中,MyApp必须继承于 StatelessWidget 类并覆盖它的 build 方法,这样做有两个作用:

  1. 自动地给予 MyApp 一些 widget 属性/方法
  2. 通过调用 build,允许 Flutter 在其构建/渲染管道中使用跟我们的组件

我不知道 Flutter 的内部原理,所以让我们不要怀疑我们是否能用 Go 实现它。为此,我们只有一个选择 —— 类型嵌入

type MyApp struct { flutter.Core // ...}复制代码

这将增加 flutter.Core 中所有导出的属性和方法到我们的 MyApp 中。我将它称为 Core 而不是 Widget,因为嵌入的这种类型还不能使我们的 MyApp 称为一个 widget,而且,这是我在 Vecty GopherJS 框架中看到的类似场景的选择。稍后我将简要的探讨 Flutter 和 Vecty 之间的相似之处。

第二部分 —— Flutter 引擎中的 build 方法 —— 当然应该简单的通过添加方法来实现,满足在 Go 版本的 Flutter 中定义的一些接口:

flutter.go 文件:

type Widget interface { Build(ctx BuildContext) Widget}复制代码

我们的 main.go 文件:

type MyApp struct { flutter.Core // ...}// 构建渲染 MyApp 组件。实现 Widget 的接口func (m *MyApp) Build(ctx flutter.BuildContext) flutter.Widget { return flutter.MaterialApp()}复制代码

我们可能会注意到这里和 Dart 版的 Flutter 有些不同:

  • 代码更加冗长 —— BuildContext,Widget 和 MaterialApp 等方法前都明显地提到了 flutter。
  • 代码更简洁 —— 没有 extends Widget 或者 @override 子句。
  • Build 方法是大写开头的,因为在 Go 中它的意思是“公共”可见性。在 Dart 中,大写开头小写开头都可以,但是要使属性或方法“私有化”,名称需要使用下划线(_)开头。

为了实现一个 Go 版的 Flutter Widget,现在我们需要嵌入 flutter.Core 并实现 flutter.Widget 接口。好了,非常清楚了,我们继续往下实现。

状态

在 Dart 版的 Flutter 中,这是我发现的第一个令人困惑的地方。Flutter 中有两种组件 —— StatelessWidget 和 StatefulWidget。嗯,对我来说,无状态组件只是一个没有状态的组件,所以,为什么这里要创建一个新的类呢?好吧,我也能接受。但是你不能仅仅以相同的方式扩展 StatefulWidget,你应该执行以下神奇的操作(安装了 Flutter 插件的 IDE 都可以做到,但这不是重点):

class MyHomePage extends StatefulWidget { @override _MyHomePageState createState() => _MyHomePageState();}class _MyHomePageState extends State { int _counter = 0; void _incrementCounter() { setState(() { _counter++; }); } @override Widget build(BuildContext context) { return Scaffold() }}复制代码

呃,我们不仅仅要理解这里写的是什么,还要理解,为什么这样写?

这里要解决的任务是向组件中添加状态(counter)时,并允许 Flutter 在状态更改时重绘组件。这就是复杂性的根源。

其余的都是偶然的复杂性。Dart 版的 Flutter 中的办法是引入一个新的 State 类,它使用泛型并以小组件作为参数。所以 _MyAppState 是一个来源于 State of a widget MyApp 的类。好了,有点道理...但是为什么 build() 方法是在一个状态而非组件上定义的呢?这个问题在 Flutter 仓库的 FAQ 中有回答,这里也有详细的讨论,概括一下就是:子类 StatefulWidget 被实例化时,为了避免 bug 之类的。换句话说,它是基于类的 OOP 设计的一种变通方法。

我们如何用 Go 来设计它呢?

首先,我个人会尽量避免为 State 创建一个新概念 —— 我们已经在任意具体类型中隐式地包含了“state” —— 它只是结构体的属性(字段)。可以说,语言已经具备了这种状态的概念。因此,创建一个新状态只会让开发人员赶到困惑 —— 为什么我们不能在这里使用类型的“标准状态”。

当然,挑战在于使 Flutter 引擎跟踪状态发生变化并对其作出反应(毕竟这是响应式编程的要点)。我们不需要为状态的更改创建特殊方法和包装器,我们只需要让开发人员手动告诉 Flutter 何时需要更新小组件。并不是所有的状态更改都需要立即重绘 —— 有很多典型场景能说明这个问题。我们来看看:

type MyHomePage struct { flutter.Core counter int}// Build 渲染了 MyHomePage 组件。实现了 Widget 接口func (m *MyHomePage) Build(ctx flutter.BuildContext) flutter.Widget { return flutter.Scaffold()}// 给计数器组件加一func (m *MyHomePage) incrementCounter() { m.counter++ flutter.Rerender(m) // or m.Rerender() // or m.NeedsUpdate()}复制代码

这里有很多命名和设计选项 —— 我喜欢其中的 NeedsUpdate(),因为它很明确,而且是 flutter.Core(每个组件都有它)的一个方法,但 flutter.Rerender() 也可以正常工作。它给人一种即时重绘的错觉,但是 —— 并不会经常这样 —— 它将在下一帧时重绘,状态更新的频率可能比帧的重绘的频率高的多。

但问题是,我们只是实现了相同的任务,也就是添加一个状态响应到小组件中,下面的一些问题还未解决:

  • 新的类型
  • 泛型
  • 读/写状态的特殊规则
  • 新的特殊的方法覆盖

另外,API 更简洁也更明确 —— 只需增加计数器并请求 flutter 重新渲染 —— 当你要求调用特殊函数 setState 时,有些变化并不明显,该函数返回另一个实际状态更改的函数。同样,隐式的魔法会有损可读性,我们设法避免了这一点。因此,代码更简单,并且精简了两倍。

有状态的子组件

继续这个逻辑,让我们仔细看看在 Flutter 中,“有状态的小组件”是如何在另一个组件中使用的:

@overrideWidget build(BuildContext context) { return MaterialApp( title: 'Flutter Demo', home: MyHomePage(title: 'Flutter Demo Home Page'), );}复制代码

这里的 MyHomePage 是一个“有状态的小组件”(它有一个计数器),我们通过在构建过程中调用构造函数 MyHomePage(title:"...") 来创建它...等等,构建的是什么?

调用 build() 重绘小组件,可能每秒有多次绘制。为什么我们要在每次渲染中创建一个小组件?更别说在每次重绘循环中,重绘有状态的小组件了。

结论是,Flutter 用小组件和状态之间的这种分离来隐藏这个初始化/状态记录,不让开发者过多关注。它确实每次都会创建一个新的 MyHomePage 组件,但它保留了原始状态(以单例的方式),并自动找到这个“唯一”状态,将其附加到新创建的 MyHomePage 组件上。

对我来说,这没有多大意义 —— 更多的隐式,更多的魔法也更容易令人模糊(我们仍然可以添加小组件作为类属性,并在创建小组件时实例化它们)。我理解为什么这种方式不错了(不需要跟踪组件的子组件),并且它具有良好的简化重构作用(只有在一个地方删除构造函数的调用才能删除子组件),但任何开发者试图真正搞懂整个工作原理时,都可能会有些困惑。

对于 Go 版的 Flutter,我肯定更倾向于初始化了的状态显式且清晰的小组件,虽然这意味着代码会更冗长。Dart 版的 Flutter 可能也可以实现这种方式,但我喜欢 Go 的非魔法特性,而这种哲学也适用于 Go 框架。因此,我的有状态子组件的代码应该类似这样:

// MyApp 是应用顶层的组件。type MyApp struct { flutter.Core homePage *MyHomePage}// NewMyApp 实例化一个 MyApp 组件func NewMyApp() *MyApp { app := &MyApp{} app.homePage = &MyHomePage{} return app}// Build 渲染了 MyApp 组件。实现了 Widget 接口func (m *MyApp) Build(ctx flutter.BuildContext) flutter.Widget { return m.homePage}// MyHomePage 是一个首页组件type MyHomePage struct { flutter.Core counter int}// Build 渲染 MyHomePage 组件。实现 Widget 接口func (m *MyHomePage) Build(ctx flutter.BuildContext) flutter.Widget { return flutter.Scaffold()}// 增量计数器让 app 的计数器增加一func (m *MyHomePage) incrementCounter() { m.counter++ flutter.Rerender(m)}复制代码

代码更加冗长了,如果我们必须在 MyApp 中更改/替换 MyHomeWidget,那我们需要在 3 个地方有所改动,还有一个作用是,我们对代码执行的每个阶段都有一个完整而清晰的了解。没有隐藏的东西在幕后发生,我们可以 100% 自信的推断代码、性能和每个类型以及函数的依赖关系。对于一些人来说,这就是最终目标,即编写可靠且可维护的代码。

顺便说一下,Flutter 有一个名为 StatefulBuilder 的特殊组件,它为隐藏的状态管理增加了更多的魔力。

DSL

现在,到了有趣的部分。我们如何在 Go 中构建一个 Flutter 的组件树?我们希望我们的组件树简洁、易读、易重构并且易于更新、描述组件之间的空间关系,增加足够的灵活性来插入自定义代码,比如,按下按钮时的程序处理等等。

我认为 Dart 版的 Flutter 是非常好看的,不言自明:

return Scaffold( appBar: AppBar( title: Text(widget.title), ), body: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Text('You have pushed the button this many times:'), Text( '$_counter', style: Theme.of(context).textTheme.display1, ), ], ), ), floatingActionButton: FloatingActionButton( onPressed: _incrementCounter, tooltip: 'Increment', child: Icon(Icons.add), ), );复制代码

每个小组件都有一个构造方法,它接收可选的参数,而令这种声明式方法真正好用的技巧是 函数的命名参数。

命名参数

为了防止你不熟悉,详细说明一下,在大多数语言中,参数被称为“位置参数”,因为它们在函数调用中的参数位置很重要:

Foo(arg1, arg2, arg3)复制代码

使用命名参数时,可以在函数调用中写入它们的名称:

Foo(name: arg1, description: arg2, size: arg3)复制代码

它虽增加了冗余性,但帮你省略了你点击跳转函数来理解这些参数的意思。

对于 UI 组件树,它们在可读性方面起着至关重要的作用。考虑一下跟上面相同的代码,在没有命名参数的情况下:

return Scaffold( AppBar( Text(widget.title), ), Center( Column( MainAxisAlignment.center, [ Text('You have pushed the button this many times:'), Text( '$_counter', Theme.of(context).textTheme.display1, ), ], ), ), FloatingActionButton( _incrementCounter, 'Increment', Icon(Icons.add), ), );复制代码

咩,是不是?它不仅难以阅读和理解(你需要记住每个参数的含义、类型,这是一个很大的心智负担),而且我们在传递那些参数时没有灵活性。例如,你可能不希望你的 Material 应用有 FloatingButton,所以你只是不传递 floatingActionButton。如果没有命名参数,你将被迫传递它(例如可能是 null/nil),或者使用一些带有反射的脏魔法来确定用户通过构造函数传递了哪些参数。

由于 Go 没有函数重载或命名参数,因此这会是一个棘手的问题。

用 Go 实现组件树

版本 1

这个版本的例子可能只是拷贝 Dart 表示组件树的方法,但我们真正需要的是后退一步并回答这个问题 —— 在语言的约束下,哪种方法是表示这种类型数据的最佳方法呢?

让我们仔细看看 Scaffold 对象,它是构建外观美观的现代 UI 的好帮手。它有这些属性 —— appBar,drawer,home,bottomNavigationBar,floatingActionButton —— 所有都是 Widget。我们创建类型为 Scaffold 的对象的同时初始化这些属性。这样看来,它与任何普通对象实例化没有什么不同,不是吗?

我们用代码实现:

return flutter.NewScaffold( flutter.NewAppBar( flutter.Text("Flutter Go app

谷歌放弃go_用 Go 实现 Flutter相关推荐

  1. 谷歌放弃go_盘点国内可以使用的十种谷歌服务

    谷歌在大陆好像用不了,每次我想打开谷歌的网站,浏览器总提示我要检查网络连接,结果检查完之后还是连不上,我就放弃了,谷歌是少数几家不能在国内使用的国外互联网服务,像苹果.微软其实都是可以在国内使用的,谷 ...

  2. 谷歌放弃C++语言,Python将要一统江湖了?

    最近Google工程师表示,目前Chrime代码库存在的安全漏洞70%是内存管理的安全漏洞,其中50的内存漏洞是ues-after-free漏洞,因为这些漏洞给与了攻击者机会,然后就有人吐槽C++不行 ...

  3. 谷歌开源的跨平台UI开发框架Flutter

    谷歌开源的跨平台UI开发框架Flutter Flutter是Google一个新的用于构建跨平台的手机App的SDK.写一份代码,在Android 和iOS平台上都可以运行.与React Native. ...

  4. 谷歌放弃竞标美国国防部100亿美元的云计算合同

    文章来源:ATYUN AI平台 谷歌表示,它已经不再争夺美国国防部100亿美元的云计算合同,部分原因是该公司的新道德准则与该项目不一致,但并没有详细说明. 谷歌在一份声明中表示,"我们无法保 ...

  5. 谷歌技术团队出品,Android Flutter全家桶学习资料【全新版】

    Flutter 是谷歌的移动端 UI 框架,可在极短的时间内构建 Android 和 iOS 上高质量的原生级应用. Flutter 可与现有代码一起工作, 它被世界各地的开发者和组织使用, 并且 F ...

  6. 谷歌游览器插件html5,谷歌放弃Gears浏览器插件 重点开发HTML5

    据国外媒体报道,谷歌Gears项目经理伊安-费特(Ian Fette)周五通过博客宣布,谷歌将放弃对Gears项目的支持. Gears是一款浏览器插件,可以允许用户离线访问Gmail等网页应用.谷歌早 ...

  7. 互联网日报 | 2月4日 星期四 | 阿里云首次实现盈亏平衡;百度“2021好运中国年”春节活动上线;谷歌放弃自研游戏计划...

    今日看点 ✦ 阿里巴巴2021财年第三季度营收2210.8亿元,阿里云首次实现盈亏平衡 ✦ 百度"2021好运中国年"春节活动上线,六大活动瓜分22亿福利 ✦ 苏宁有货正式上线,2 ...

  8. 为何谷歌放弃以甜品命名android,甜点不见了 谷歌变更Android命名方式

    原标题:甜点不见了 谷歌变更Android命名方式 来源:张金梁 中关村在线消息:据外媒报道,谷歌Android Pie已经发布,但是从Android Pie开始,谷歌将不再使用甜点作为版本号的名字. ...

  9. 谷歌放弃python-老大离开Google,去了Dropbox

    [ 在 iJava (简单美好) 的大作中提到: ] : 标 题: Re: 老大离开Google,去了Dropbox : 发信站: 水木社区 (Sat Dec 8 14:55:36 2012), 站内 ...

  10. 使用Flutter一年后,这是我得到的经验

    在这篇文章中,我将分享我使用Flutter的经验,以及我在整个过程中发现的所有Flutter的优缺点. 在过去的一年里,我是如何使用Flutter的呢?我做了以下这些事情: 使用Flutter重写一款 ...

最新文章

  1. Web前端学习有哪些技巧?
  2. 什么是ATL? (与COM的关系,及MFC与COM的关系)
  3. PAT-1124. Raffle for Weibo Followers (20)
  4. Makefile 使用总结【转】
  5. Android ramdisk.img system.img userdata.img 介绍与使用
  6. PL/SQL控制结构
  7. Java中的Atomic包使用指南
  8. java计数器策略模式_java设计模式(二十一)--策略模式
  9. Post传值时间特殊字符处理比如 p/p当作参数传递到后台
  10. Win10/Win11:恢复Win7照片查看器
  11. DDR3各个频率详解
  12. 百病皆由痰作祟~一碗神奇的水?(生姜红糖水、姜糖水、中医)
  13. Linux进程间通信——管道通信详解
  14. vue 导出word带图片
  15. 屠杀熊猫烧香方法记录
  16. java面试题saas
  17. 1.试用期个人工作总结(篇一)
  18. css样式文件的引入方式
  19. Shiro实现session限制登录数量踢人下线
  20. moviepy中视频时长修改

热门文章

  1. [转]Redis几个认识误区
  2. 嵌入式Linux编程--我的第一次艰难跋涉
  3. c盘local文件太大_win7 c盘清理的方法教程
  4. SpringBoot学习---页面国际化
  5. 对文档的编辑过多_Wizard 开源文档管理系统1.0发布啦
  6. 百度seo排名点击器app_手机端百度搜索排名seo优化_百度移动端整站关键词排名优化...
  7. mysql join 去重_对mysql left join 出现的重复结果去重
  8. android图片管理实例,Android图片处理实例介绍(图)
  9. fluentmigrator连接mysql_如何利用FluentMigrator实现数据库迁移
  10. The file is absent or does not have execute permission This file is needed to run this program