从0开始写一个基于Flutter的开源中国客户端(8)——插件的使用

栏目: 软件资讯 · 发布时间: 7年前

内容简介:上一篇中我记录了基于Flutter的开源中国客户端里网络请求和数据存储的部分,本篇记录的是app中插件的使用,由于很多功能并没有内置到Flutter中,所以我们需要引入一些插件来帮助我们完成某些功能,比如app内网页的加载,图库选择照片等。要使用插件,必须知道插件叫什么名字,目前是什么版本,Flutter提供了一个插件仓库,可以去上面搜索相关的插件,仓库地址为:pub.dartlang.org/,但是这个网站在国内可能访问不了,国内可以用Flutter专门为中国开发者提供的网站:pub.flutter-i

上一篇中我记录了基于Flutter的开源中国客户端里网络请求和数据存储的部分,本篇记录的是app中插件的使用,由于很多功能并没有内置到Flutter中,所以我们需要引入一些插件来帮助我们完成某些功能,比如app内网页的加载,图库选择照片等。

索引 文章
1

从0开始写一个基于Flutter的开源中国客户端(1)

Flutter简介及开发环境搭建 | 掘金技术征文

2

从0开始写一个基于Flutter的开源中国客户端(2)

Dart语法基础

3

从0开始写一个基于Flutter的开源中国客户端(3)

初识Flutter & 常用的Widgets

4

从0开始写一个基于Flutter的开源中国客户端(4)

Flutter布局基础

5

从0开始写一个基于Flutter的开源中国客户端(5)

App整体布局框架搭建

6

从0开始写一个基于Flutter的开源中国客户端(6)

各个静态页面的实现

7

从0开始写一个基于Flutter的开源中国客户端(7)

App网络请求和数据存储

:point_right:8

从0开始写一个基于Flutter的开源中国客户端(8)

插件的使用

搜索插件包

要使用插件,必须知道插件叫什么名字,目前是什么版本,Flutter提供了一个插件仓库,可以去上面搜索相关的插件,仓库地址为:pub.dartlang.org/,但是这个网站在国内可能访问不了,国内可以用Flutter专门为中国开发者提供的网站:pub.flutter-io.cn/。该网站打开后直接在输入框中搜索关键字即可,如下图所示:

从0开始写一个基于Flutter的开源中国客户端(8)——插件的使用

比如我们需要在app中用WebView加载网页,可以直接搜索'web view',再或者我们需要调用图库选择图片的功能,可以搜索'image picker',搜索结果可能有一大堆,怎么选择合适的插件呢?

由于我们是开发Flutter应用,所以要在搜索结果中过滤出供Flutter使用的插件,如下图所示:

从0开始写一个基于Flutter的开源中国客户端(8)——插件的使用

过滤是第一步,过滤之后,还要查看插件包的更新日期,更新日期不能是很久前,因为很早之前发布的插件包,可能并不适合现在的Flutter版本,另外就是看这个插件后面的数字,数字越大表示插件匹配程度越高,如下图所示:

从0开始写一个基于Flutter的开源中国客户端(8)——插件的使用

上面两步过滤之后,选择你觉得合适的插件,点进去看看详情,里面有相关的插件说明,示例用法,确定可以完成你所需要的功能,就可以愉快的在项目中添加插件依赖了。

基本上每个插件的主页都会有说明如何在项目中添加该插件的依赖,比如在我们这个基于Flutter的开源中国客户端中,用到了 flutter_webview_plugin 这个插件,在该插件的主页里,就有怎么引入依赖的说明:

从0开始写一个基于Flutter的开源中国客户端(8)——插件的使用

使用flutter_webview_plugin插件

在基于Flutter的开源中国客户端项目中,用户登录和资讯详情等页面都使用了WebView加载网页,使用的是 flutter_webview_plugin 这个插件。该插件主要功能是可以在Flutter页面中加载一个WebView,并且可以监听WebView的各种状态比如加载中,加载完成等,而且还能读取WebView中的cookies,或者通过dart代码调用WebView中的js方法。

开源中国提供的基于oauth的认证流程大致如下:

  1. 在开源中国后台添加应用,完善应用的信息,最主要的是回调地址,该地址将会在后面用到;
  2. 使用浏览器或者WebView加载三方认证页面,在该页面中输入开源中国的用户名和密码(输入密码的页面为开源中国提供的页面,第三方是无法获取密码信息的);
  3. 输入用户名和密码后点击页面上的登录按钮,若登录成功,将会跳转到第一步我们在后台配置的回调地址上,并给该页面传入一个code参数(code参数直接拼接在URL上);
  4. 在该页面中接收code参数,并根据开源中国后台提供的 client_id client_secret 等参数换取token信息(这一步就是一个get请求,只不过放在我自己的服务端进行了);
  5. 上面的请求成功后,开源中国的openapi会返回token等信息,在我们的回调页面将这个信息通过js的一个 get() 方法暴露出来,让dart代码去调用。

具体的oauth认证流程可以查看开源中国的文档:文档地址

构造登录页面

lib/pages/ 目录下新建 LoginPage.dart 文件,并使用 flutter_webview_plugin 插件提供的 WebviewScaffold 组件,该组件会在页面上渲染一个WebView用于加载某个URL,代码如下:

@override
  Widget build(BuildContext context) {
    List<Widget> titleContent = [];
    titleContent.add(new Text(
      "登录开源中国",
      style: new TextStyle(color: Colors.white),
    ));
    if (loading) {
      // 如果还在加载中,就在标题栏上显示一个圆形进度条
      titleContent.add(new CupertinoActivityIndicator());
    }
    titleContent.add(new Container(width: 50.0));
    // WebviewScaffold是插件提供的组件,用于在页面上显示一个WebView并加载URL
    return new WebviewScaffold(
      key: _scaffoldKey,
      url: Constants.LOGIN_URL, // 登录的URL
      appBar: new AppBar(
        title: new Row(
          mainAxisAlignment: MainAxisAlignment.center,
          children: titleContent,
        ),
        iconTheme: new IconThemeData(color: Colors.white),
      ),
      withZoom: true,  // 允许网页缩放
      withLocalStorage: true, // 允许LocalStorage
      withJavascript: true, // 允许执行js代码
    );
  }
复制代码

上面的代码中,我们给AppBar组件上加了标题,还加了一个圆形的进度条,用于指示WebView加载的状态,如果在加载中,就显示进度条,否则就隐藏进度条(所以LoginPage类应该继承StatefulWidget)。

监听WebView的加载状态和URL变化

flutter_webview_plugin 插件提供的api可以监听WebView加载的状态和URL的变化,主要代码如下:

// 登录页面,使用网页加载的开源中国三方登录页面
class LoginPage extends StatefulWidget {
  @override
  State<StatefulWidget> createState() => new LoginPageState();
}

class LoginPageState extends State<LoginPage> {
  // 标记是否是加载中
  bool loading = true;
  // 标记当前页面是否是我们自定义的回调页面
  bool isLoadingCallbackPage = false;
  GlobalKey<ScaffoldState> _scaffoldKey = new GlobalKey();
  // URL变化监听器
  StreamSubscription<String> _onUrlChanged;
  // WebView加载状态变化监听器
  StreamSubscription<WebViewStateChanged> _onStateChanged;
  // 插件提供的对象,该对象用于WebView的各种操作
  FlutterWebviewPlugin flutterWebViewPlugin = new FlutterWebviewPlugin();

  @override
  void initState() {
    super.initState();
    // 监听WebView的加载事件,该监听器已不起作用,不回调
    _onStateChanged = flutterWebViewPlugin.onStateChanged.listen((WebViewStateChanged state) {
      // state.type是一个枚举类型,取值有:WebViewState.shouldStart, WebViewState.startLoad, WebViewState.finishLoad
      switch (state.type) {
        case WebViewState.shouldStart:
          // 准备加载
          setState(() {
            loading = true;
          });
          break;
        case WebViewState.startLoad:
          // 开始加载
          break;
        case WebViewState.finishLoad:
          // 加载完成
          setState(() {
            loading = false;
          });
          if (isLoadingCallbackPage) {
            // 当前是回调页面,则调用js方法获取数据
            parseResult();
          }
          break;
      }
    });
    _onUrlChanged = flutterWebViewPlugin.onUrlChanged.listen((url) {
      // 登录成功会跳转到自定义的回调页面,该页面地址为http://yubo725.top/osc/osc.php?code=xxx
      // 该页面会接收code,然后根据code换取AccessToken,并将获取到的token及其他信息,通过js的get()方法返回
      if (url != null && url.length > 0 && url.contains("osc/osc.php?code=")) {
        isLoadingCallbackPage = true;
      }
    });
  }
}
复制代码

上面代码的逻辑是:

parseResult()

dart调用js代码获取token信息

parseResult() 方法中就是dart调用js代码的逻辑了, flutter_webview_plugin 插件提供了API供我们很方便的用dart代码调用js代码,下面是 parseResult() 方法的代码:

// 解析WebView中的数据
  void parseResult() {
    flutterWebViewPlugin.evalJavascript("get();").then((result) {
      // result json字符串,包含token信息
      if (result != null && result.length > 0) {
        // 拿到了js中的数据
        try {
          // what the fuck?? need twice decode??
          var map = json.decode(result); // s is String
          if (map is String) {
            map = json.decode(map); // map is Map
          }
          if (map != null) {
            // 登录成功,取到了token,关闭当前页面
            DataUtils.saveLoginInfo(map);
            Navigator.pop(context, "refresh");
          }
        } catch (e) {
          print("parse login result error: $e");
        }
      }
    });
  }
复制代码

主要方法是 flutterWebViewPlugin.evalJavascript() 传入的参数是一个字符串,表示要执行的js代码。上面的代码意思是执行页面中的 get() 方法,在该方法中返回了token等信息,然后在 then 中解析这些信息,并调用 DataUtils.saveLoginInfo(map); 保存登录信息,这就到了上一篇中我记录的数据保存的部分了。数据保存后调用 Navigator.pop(context, "refresh"); 方法将当前页推出栈,后面的"refresh"参数有什么作用呢?

通知上一个页面登录成功,让上一个页面刷新

"refresh"的作用就是为了让上一个页面刷新(这里只是一个字符串参数,定义成什么样子完全取决于你自己)。如果是做过Android开发的朋友,应该会很熟悉,我们要把当前页的数据传递给上一个页面,一般会在上一个页面用startActivityForResult方法启动当前页,上一个页面会在onActivityResult回调方法中接收参数。Flutter的做法跟这个有点类似,在“我的”页面中打开登录页时,使用下面的方法:

_login() async {
    // 打开登录页并处理登录成功的回调
    final result = await Navigator
        .of(context)
        .push(new MaterialPageRoute(builder: (context) {
      return new LoginPage();
    }));
    // result为"refresh"代表登录成功
    if (result != null && result == "refresh") {
      // 刷新用户信息
      getUserInfo();
      // 通知动弹页面刷新
      Constants.eventBus.fire(new LoginEvent());
    }
  }
复制代码

上面的代码应该很明了了吧, Navigatorpush 方法返回的是一个Future对象,所以我们可以在then里面处理登录页返回的信息,登录页pop时传入的'refresh'字符串,将会在这里被接收,接收到就可以刷新“我的”页面了(刷新用户昵称和头像)。

使用event_bus插件

上面最后的 _login() 方法的代码中,我们收到了"refresh"参数后,获取并刷新了页面的用户信息,然后还调用了一行代码用于刷新动弹页面:

Constants.eventBus.fire(new LoginEvent());
复制代码

这行代码就用到了另外一个框架:event_bus

如果做过Android开发或者前端开发,应该对这个框架不陌生。EventBus是一个发布/订阅模式的框架,用于在某个页面订阅某个事件,然后在另外的地方触发这个事件,订阅这个事件的方法就会被执行。

该框架在pub仓库的主页是: pub.flutter-io.cn/packages/ev…

该插件的用法很简单,首先是导入包:

import 'package:event_bus/event_bus.dart';
复制代码

如果要订阅某个事件,使用下面的代码:

new EventBus().on(MyEvent).listen((event) {
    // 处理事件
});
复制代码

其中 MyEvent 是自定义的一个类,表示唯一的一个事件。如果要监听所有的事件, on 方法中可以不传参数。

要发送某个事件,可以用如下代码:

new EventBus().fire(new MyEvent());
复制代码

使用 fire 方法发送某个事件,参数就是这个自定义的事件对象,可以在这个对象中加入任何你需要的参数。

在基于Flutter的开源中国客户端项目中,可以只用到一个EventBus对象,没必要在每次用的时候都 new EventBus() ,所以我们在 lib/constants/Constants.dart 中定义了一个静态的eventBus变量,全局都可以共用这一个对象:

static EventBus eventBus = new EventBus();
复制代码

在登录成功后,调用如下代码来通知动弹列表刷新:

Constants.eventBus.fire(new LoginEvent());
复制代码

LoginEvent是一个空的类,表示登录成功的事件。

在动弹列表页,还要为登录成功的事件加上监听:

Constants.eventBus.on(LoginEvent).listen((event) {
  setState(() {
    this.isUserLogin = true;
  });
});
复制代码

动弹列表页根据上面的isUserLogin变量加载不同的页面,如果该变量为false,表示当前没有登录,则显示如下界面:

从0开始写一个基于Flutter的开源中国客户端(8)——插件的使用

如果该变量为true,则会调用开源中国的api去获取动弹信息,显示如下界面:

从0开始写一个基于Flutter的开源中国客户端(8)——插件的使用

关于动弹列表的加载,这里就不详细说明了,文末会给出源码链接。

使用image_picker插件

在发送动弹的页面,有选择图片的功能,如下图所示:

从0开始写一个基于Flutter的开源中国客户端(8)——插件的使用

Flutter并没有提供相关API供我们操作移动设备的图库,所以这里又用到了image_picker插件,该插件的地址在这里: pub.flutter-io.cn/packages/im…

导入插件的代码如下:

import 'package:image_picker/image_picker.dart';
复制代码

插件的使用方法也比较简单,如下代码:

// source是一个枚举值,可取值有ImageSource.camera和ImageSource.gallery,分别代表调用相机和图库
_imageFile = ImagePicker.pickImage(source: source);
复制代码

显示底部弹出菜单

上图中的弹出菜单在Flutter中已有内置的组件可直接使,当我们点击:heavy_plus_sign:选择图片时,调用 pickImage 方法,代码如下:

// 相机拍照或者从图库选择图片
  pickImage(ctx) {
    // 如果已添加了9张图片,则提示不允许添加更多
    num size = fileList.length;
    if (size >= 9) {
      Scaffold.of(ctx).showSnackBar(new SnackBar(
        content: new Text("最多只能添加9张图片!"),
      ));
      return;
    }
    // Flutter提供的API,用于显示一个底部弹出的Dialog
    showModalBottomSheet<void>(context: context, builder: _bottomSheetBuilder);
  }

  // 自定义底部菜单的布局
  Widget _bottomSheetBuilder(BuildContext context) {
    return new Container(
      height: 182.0,
      child: new Padding(
        padding: const EdgeInsets.fromLTRB(0.0, 30.0, 0.0, 30.0),
        child: new Column(
          children: <Widget>[
            _renderBottomMenuItem("相机拍照", ImageSource.camera),
            new Divider(height: 2.0,),
            _renderBottomMenuItem("图库选择照片", ImageSource.gallery)
          ],
        ),
      )
    );
  }

  // 渲染底部菜单的每个item
  _renderBottomMenuItem(title, ImageSource source) {
    var item = new Container(
      height: 60.0,
      child: new Center(
        child: new Text(title)
      ),
    );
    return new InkWell(
      child: item,
      onTap: () { 
        // 点击菜单item,关闭这个底部弹窗并调用相机或者图库
        Navigator.of(context).pop();
        setState(() {
          _imageFile = ImagePicker.pickImage(source: source);
        });
      },
    );
  }
复制代码

上面代码中的 _imageFile 是一个 Future<File> 对象,因为选择图片的操作是异步的,那么在什么地方接收选择的图片呢?不论是拍照还是图库选择,最后调用 ImagePicker.pickImage(source: source) 返回的都是一个文件对象,在 image_picker 主页给出的示例代码中,是以组件的形式返回一个 FutureBuilder<File> 对象,在该对象的 builder 方法中接收返回的图片文件的。

在基于Flutter的开源中国客户端项目中,接收选择的图片是放在build方法中的,PublishTweetPage页面的 build 方法代码如下:

@override
  Widget build(BuildContext context) {
    return new Scaffold(
      appBar: new AppBar(
        title: new Text("发布动弹", style: new TextStyle(color: Colors.white)),
        iconTheme: new IconThemeData(color: Colors.white),
        actions: <Widget>[
          new Builder(
            builder: (ctx) {
              return new IconButton(icon: new Icon(Icons.send), onPressed: () {
                // 发送动弹
                DataUtils.isLogin().then((isLogin) {
                  if (isLogin) {
                    return DataUtils.getAccessToken();
                  } else {
                    return null;
                  }
                }).then((token) {
                  sendTweet(ctx, token);
                });
              });
            },
          )
        ],
      ),
      // 在这里接收选择的图片
      body: new FutureBuilder(
        future: _imageFile,
        builder: (BuildContext context, AsyncSnapshot<File> snapshot) {
          if (snapshot.connectionState == ConnectionState.done &&
              snapshot.data != null && _imageFile != null) {
            // 选择了图片(拍照或图库选择),添加到List中
            fileList.add(snapshot.data);
            _imageFile = null;
          }
          // 返回的widget
          return getBody();
        },
      ),
    );
  }
复制代码

在AppBar的右边添加了一个按钮,用于发送动弹信息。在body部分返回了一个 FutureBuilder 对象,在该对象的 builder 方法中接收了选中的图片文件,并将该文件加入到图片列表中,然后调用 getBody() 方法返回整个页面,这么做的原因是因为每次选中一张图片后,都需要将页面刷新,在 getBody() 方法中会用到 fileList 变量, getBody() 方法代码如下:

Widget getBody() {
    // 输入框
    var textField = new TextField(
      decoration: new InputDecoration(
        hintText: "说点什么吧~",
        hintStyle: new TextStyle(
          color: const Color(0xFF808080)
        ),
        border: new OutlineInputBorder(
          borderRadius: const BorderRadius.all(const Radius.circular(10.0))
        )
      ),
      // 最多显示6行文本(不代表最多只能输入6行)
      maxLines: 6,
      // 最多输入的文字数
      maxLength: 150,
      // 通过_controller.text可以获取输入框中输入的文本
      controller: _controller,
    );
    // gridView用来显示选择的图片
    var gridView = new Builder(
      builder: (ctx) {
        return new GridView.count(
          // 分4列显示
          crossAxisCount: 4,
          children: new List.generate(fileList.length + 1, (index) {
            // 这个方法体用于生成GridView中的一个item
            var content;
            if (index == 0) {
              // 添加图片按钮
              var addCell = new Center(
                  child: new Image.asset('./images/ic_add_pics.png', width: 80.0, height: 80.0,)
              );
              content = new GestureDetector(
                onTap: () {
                  // 添加图片
                  pickImage(ctx);
                },
                child: addCell,
              );
            } else {
              // 被选中的图片
              content = new Center(
                  child: new Image.file(fileList[index - 1], width: 80.0, height: 80.0, fit: BoxFit.cover,)
              );
            }
            return new Container(
              margin: const EdgeInsets.all(2.0),
              width: 80.0,
              height: 80.0,
              color: const Color(0xFFECECEC),
              child: content,
            );
          }),
        );
      },
    );
    var children = [
      new Text("提示:由于OSC的openapi限制,发布动弹的接口只支持上传一张图片,本项目可添加最多9张图片,但OSC只会接收最后一张图片。", style: new TextStyle(fontSize: 12.0),),
      textField,
      new Container(
          margin: const EdgeInsets.fromLTRB(0.0, 10.0, 0.0, 0.0),
          height: 200.0,
          child: gridView
      )
    ];
    if (isLoading) { // 上传图片可能会比较慢,所以这里显示loading
      children.add(new Container(
        margin: const EdgeInsets.fromLTRB(0.0, 20.0, 0.0, 0.0),
        child: new Center(
          child: new CircularProgressIndicator(),
        ),
      ));
    } else { // 上传成功后显示msg
      children.add(new Container(
        margin: const EdgeInsets.fromLTRB(0.0, 20.0, 0.0, 0.0),
        child: new Center(
          child: new Text(msg),
        )
      ));
    }
    return new Container(
      padding: const EdgeInsets.all(5.0),
      child: new Column(
        children: children,
      ),
    );
  }
复制代码

获取到了选择的图片和输入的动弹内容,下一步是发送动弹,发送动弹调用的是开源中国的openapi,这里涉及到使用dart上传图片的问题,下面先上代码:

sendTweet(ctx, token) async {
    // 未登录或者未输入动弹内容时,使用SnackBar提示用户
    if (token == null) {
      Scaffold.of(ctx).showSnackBar(new SnackBar(
        content: new Text("未登录!"),
      ));
      return;
    }
    String content = _controller.text;
    if (content == null || content.length == 0 || content.trim().length == 0) {
      Scaffold.of(ctx).showSnackBar(new SnackBar(
        content: new Text("请输入动弹内容!"),
      ));
    }
    // 下面是调用接口发布动弹的逻辑
    try {
      Map<String, String> params = new Map();
      params['msg'] = content;
      params['access_token'] = token;
      // 构造一个MultipartRequest对象用于上传图片
      var request = new MultipartRequest('POST', Uri.parse(Api.PUB_TWEET));
      request.fields.addAll(params);
      if (fileList != null && fileList.length > 0) {
        // 这里虽然是添加了多个图片文件,但是开源中国提供的接口只接收一张图片
        for (File f in fileList) {
          // 文件流
          var stream = new http.ByteStream(
              DelegatingStream.typed(f.openRead()));
          // 文件长度
          var length = await f.length();
          // 文件名
          var filename = f.path.substring(f.path.lastIndexOf("/") + 1);
          // 将文件加入到请求体中
          request.files.add(new http.MultipartFile(
              'img', stream, length, filename: filename));
        }
      }
      setState(() {
        isLoading = true;
      });
      // 发送请求
      var response = await request.send();
      // 解析请求返回的数据
      response.stream.transform(utf8.decoder).listen((value) {
        print(value);
        if (value != null) {
          var obj = json.decode(value);
          var error = obj['error'];
          setState(() {
            if (error != null && error == '200') {
              // 成功
              setState(() {
                isLoading = false;
                msg = "发布成功";
                fileList.clear();
              });
              _controller.clear();
            } else {
              setState(() {
                isLoading = false;
                msg = "发布失败:$error";
              });
            }
          });
        }
      });
    } catch (exception) {
      print(exception);
    }
  }
复制代码

使用dart上传图片的代码和普通的get/post请求是完全不一样的,上传图片需要构造一个Request对象:

var request = new MultipartRequest('POST', Uri.parse(Api.PUB_TWEET));
复制代码

添加普通的参数需要调用request.field.addAll方法:

request.fields.addAll(params); // params是参数map
复制代码

添加文件参数时,需要调用request.files.add方法:

request.files.add(new http.MultipartFile(
    'img', stream, length, filename: filename));
复制代码

解析返回的数据时需要使用如下代码:

// 发送请求
  var response = await request.send();
  // 解析请求返回的数据
  response.stream.transform(utf8.decoder).listen((value) {})
复制代码

关于发送动弹的详细代码,可以参考文末的源码链接,这里不再说明。

源码

本篇相关的所有源码都在GitHub上 flutter-osc项目

后记

  • 本篇主要记录的是基于Flutter的开源中国客户端app中的各种插件的使用。

  • 二维码扫描的插件使用在本篇中没有做记录,各位小伙伴可自行上pub仓库搜索插件用法。

  • 本系列博客并未将所有功能的实现方法都记录下来,只是有选择性的记录了一部分功能的实现。

  • 本项目中还有很多功能暂未实现,比如动弹大图预览、个人信息页的展示等。大部分的功能都是以WebView的形式加载的,所以整体来看app的实现并不复杂,代码量也并不多,开源出来希望给学习Flutter的小伙伴们一点帮助。(如果对你有帮助,请在github给个start支持一下:joy:)

  • 本项目中还有一些已知和未知的bug,已知的bug是token过期后没有做自动刷新处理(开源中国给的token是有有效期的,过期后需要使用refresh_token去刷新access_token),未知的一些bug可能会导致app在运行过程中ANR,由于没有对各个机型做测试,所以暂时不知道ANR是什么原因导致的,但是在开发过程中会偶现插件的报错,希望各位发现bug可以及时与我联系(文末留言或者github提issue都行),感谢你们的支持!


以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持 码农网

查看所有标签

猜你喜欢:

本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们

The Master Switch

The Master Switch

Tim Wu / Knopf / 2010-11-2 / USD 27.95

In this age of an open Internet, it is easy to forget that every American information industry, beginning with the telephone, has eventually been taken captive by some ruthless monopoly or cartel. Wit......一起来看看 《The Master Switch》 这本书的介绍吧!

JS 压缩/解压工具
JS 压缩/解压工具

在线压缩/解压 JS 代码

在线进制转换器
在线进制转换器

各进制数互转换器

MD5 加密
MD5 加密

MD5 加密工具