Flutter for Android Developers

 

https://flutter.io/flutter-for-android/#what-is-the-equivalent-of-okhttp-on-flutter

这是一篇长文,介绍了关于Flutter的很多东西,开发一个app用到的技巧,差不多都涉及了.

这篇文章旨在给开发者介绍构建app的技巧.如果你理解了Android的基础框架,你可以非常容易地融入Flutter的开发.

如果有Android的开发经验,对于Flutter就非常有益了,因为Flutter是基于移动系统的.Flutter使用了一个新的方式来构建ui.但它有插件系统与Android/IOS通信,如果精通了Android,不需要重新学习所有Flutter的东西.

根据你的需求来查看文档:

Views.视图

Flutter中的view是什么,与什么对等

在Android,view是在屏幕上显示的基础元素按钮,工具栏,输入框,所有东西都是view.在Flutter,与View相等的东西是控件Widget.控件不是一对一地完全映射在Android上的view,如果你想理解Flutter的控件如何工作的,可以参考Android上的view.

与view还有些不同,控件有不同的寿命,他们不可改,一直存在的,直到被修改.当控件的状态改变,Flutter的框架创建一个新的控件树实例.在Android里,view被画 了除非调用invalidate就不会再画了.

Flutter的控件是轻量经的,因为不可改.他们不自己直接绘制.inflated进真实的view对象.来展现ui. 

(这里我的理解是,Android里的view要自己绘制,所以有很多事要做,而Flutter里的控件,不控制绘制,它只会有一棵控件树,而绘制是另一个对象来处理的,所以是轻量级)

Flutter包含了Material组件库,这些是实现了Material设计的控件,Material设计是一个可伸缩的设计,已经针对平台优化了,包括ios.

但Flutter是可伸缩的,而且也很容易实现其它设计语言,如ios,使用Cpuertino控件来产生ui,像ios上的设计语言.

 

我如何更新控件?

在Android,你更新views,需要修改它们,然而,Flutter,控件是不可改的,不可以直接更新,而是修改控件的状态.

这也是有状态的控件与无状态的控件来来由.一个无状态的控件,像它名字一样,没有状态对象.

如在Android里,类似于一个ImageView里放一个logo,logo是不可变的.所以在Flutter里用无状态控件.

如果你要动态地修改ui,特别是在通过http下载数据后,或者在用户交互后,就要用有状态的控件了.StatefulWidget,然后告诉Flutter框架,这些控件的状态,改变了,更新它们.

重要的事:核心的机制有状态与无状态是一样的,他们在每一帧都重建,不同是有状态的有一个状态对象,存储着状态值,可以在帧之间.

如果你有怀疑,记住这个规则,如果控件改变了,它是有状态的,如果控件对改变有反应,父容器控件,却是有可能不会改变的.

下面的示例,展示了如何使用无状态的控件,一个普通的文本无状态控件,如果你查看Text控件的实现,可以找到子类StatelessWidget.

Text(
  'I like Flutter!',
  style: TextStyle(fontWeight: FontWeight.bold),
);

像你看到的,文本控件,没有状态信息,它只渲染传入构造函数的东西.

如果你要让它动态改变,如点击FloatingActionBotton

要完成这个,需要包装一个Text成有状态的控件.

import 'package:flutter/material.dart';

void main() {
  runApp(SampleApp());
}

class SampleApp extends StatelessWidget {
  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Sample App',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: SampleAppPage(),
    );
  }
}

class SampleAppPage extends StatefulWidget {
  SampleAppPage({Key key}) : super(key: key);

  @override
  _SampleAppPageState createState() => _SampleAppPageState();
}

class _SampleAppPageState extends State<SampleAppPage> {
  // Default placeholder text
  String textToShow = "I Like Flutter";

  void _updateText() {
    setState(() {
      // update the text
      textToShow = "Flutter is Awesome!";
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text("Sample App"),
      ),
      body: Center(child: Text(textToShow)),
      floatingActionButton: FloatingActionButton(
        onPressed: _updateText,
        tooltip: 'Update Text',
        child: Icon(Icons.update),
      ),
    );
  }
}

 

如何布局我的控件,我的xml布局文件在哪?

在Android里面通过xml来布局,在Flutter里面则通过控件树.

下面的示例展示了,如何显示一个控件,还补白的.

@override
Widget build(BuildContext context) {
  return Scaffold(
    appBar: AppBar(
      title: Text("Sample App"),
    ),
    body: Center(
      child: MaterialButton(
        onPressed: () {},
        child: Text('Hello'),
        padding: EdgeInsets.only(left: 10.0, right: 10.0),
      ),
    ),
  );
}

可以在widget catalog章节查看Flutter的布局.

 

如何添加一个组件到我的布局?

在Android里面addChild(),removeChild(),可以对父节点添加/删除子节点,在Flutter里面历为控件是不可修改的,没有直接添加子节点的方法.可以通过一个父容器的方法,返回一个控件,然后来控制子节点的创建.

如:展示了一个两个控件在点击FloatingActionBotton时的状态切换.

import 'package:flutter/material.dart';

void main() {
  runApp(SampleApp());
}

class SampleApp extends StatelessWidget {
  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Sample App',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: SampleAppPage(),
    );
  }
}

class SampleAppPage extends StatefulWidget {
  SampleAppPage({Key key}) : super(key: key);

  @override
  _SampleAppPageState createState() => _SampleAppPageState();
}

class _SampleAppPageState extends State<SampleAppPage> {
  // Default value for toggle
  bool toggle = true;
  void _toggle() {
    setState(() {
      toggle = !toggle;
    });
  }

  _getToggleChild() {
    if (toggle) {
      return Text('Toggle One');
    } else {
      return MaterialButton(onPressed: () {}, child: Text('Toggle Two'));
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text("Sample App"),
      ),
      body: Center(
        child: _getToggleChild(),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _toggle,
        tooltip: 'Update Text',
        child: Icon(Icons.update),
      ),
    );
  }
}

 

如何让控件动画?

在Android里面,创建动画的xml,或使用View.animate()方法就可以实现动画了, 在Flutter里面控件通过包装控件,来使用动画库.

Flutter里面使用AnimationController,它是一个Animation<double>,可以暂停,查找,停止,反向动画. 垂直同步时,要一个Ticker信号,产生一个线性的加速度0-1间.可以创建多个Animation然后attch到controller上.

例如,使用CuredAnimation来实现动画.它是一个弯曲的加速器.控制器是主要的源,然后CuredAnimation计算弯曲度,替换控制器默认的线性加速器.像控件一样,在Flutter里的动画是组合的.

当构建控件树,给控件的动画属性(如FadeTransition)分配动画,然后告诉控制器开始动画.

下面的示例,展示了,如何写一个过渡的转换,当按下FloatingActionBotton时让控件淡入淡出logo.

import 'package:flutter/material.dart';

void main() {
  runApp(FadeAppTest());
}

class FadeAppTest extends StatelessWidget {
  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Fade Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: MyFadeTest(title: 'Fade Demo'),
    );
  }
}

class MyFadeTest extends StatefulWidget {
  MyFadeTest({Key key, this.title}) : super(key: key);
  final String title;
  @override
  _MyFadeTest createState() => _MyFadeTest();
}

class _MyFadeTest extends State<MyFadeTest> with TickerProviderStateMixin {
  AnimationController controller;
  CurvedAnimation curve;

  @override
  void initState() {
    controller = AnimationController(duration: const Duration(milliseconds: 2000), vsync: this);
    curve = CurvedAnimation(parent: controller, curve: Curves.easeIn);
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: Center(
          child: Container(
              child: FadeTransition(
                  opacity: curve,
                  child: FlutterLogo(
                    size: 100.0,
                  )))),
      floatingActionButton: FloatingActionButton(
        tooltip: 'Fade',
        child: Icon(Icons.brush),
        onPressed: () {
          controller.forward();
        },
      ),
    );
  }
}

查看更多信息:Animation & Motion widgets, the Animations tutorial, and the Animations overview.

 

如何使用画布来画?

在Android里面,使用Canvas和Drawable来画图,形状到屏幕上.Flutter有一个类似的canvas api,它也是基于低阶的绘制引擎skia.所以在Flutter里在画布里画东西和Android里很像.

Flutter里有两个类,来帮助你绘画.CustomPaint 和CustomPainter.后者实现了绘画到画布上的算法.

查看更多:StackOverflow.

import 'package:flutter/material.dart';

void main() => runApp(MaterialApp(home: DemoApp()));

class DemoApp extends StatelessWidget {
  Widget build(BuildContext context) => Scaffold(body: Signature());
}

class Signature extends StatefulWidget {
  SignatureState createState() => SignatureState();
}

class SignatureState extends State<Signature> {
  List<Offset> _points = <Offset>[];
  Widget build(BuildContext context) {
    return GestureDetector(
      onPanUpdate: (DragUpdateDetails details) {
        setState(() {
          RenderBox referenceBox = context.findRenderObject();
          Offset localPosition =
          referenceBox.globalToLocal(details.globalPosition);
          _points = List.from(_points)..add(localPosition);
        });
      },
      onPanEnd: (DragEndDetails details) => _points.add(null),
      child: CustomPaint(painter: SignaturePainter(_points), size: Size.infinite),
    );
  }
}

class SignaturePainter extends CustomPainter {
  SignaturePainter(this.points);
  final List<Offset> points;
  void paint(Canvas canvas, Size size) {
    var paint = Paint()
      ..color = Colors.black
      ..strokeCap = StrokeCap.round
      ..strokeWidth = 5.0;
    for (int i = 0; i < points.length - 1; i++) {
      if (points[i] != null && points[i + 1] != null)
        canvas.drawLine(points[i], points[i + 1], paint);
    }
  }
  bool shouldRepaint(SignaturePainter other) => other.points != points;
}

 

如何自定义控件?

在Android里面可以继承View,或已经存在的view,来覆盖一些方法.

在Flutter里,构建一个自定义的控件,是通过组合不同的小控件实现的.像Android里的ViewGroup一样.只是提供了不一样的逻辑.

如你要构建一个自定义的按钮,有一个label的构造器.创建一个CustomButton,包含着一个RaisedButton,带一个label而不是扩展自RaisedButton.

class CustomButton extends StatelessWidget {
  final String label;

  CustomButton(this.label);

  @override
  Widget build(BuildContext context) {
    return RaisedButton(onPressed: () {}, child: Text(label));
  }
}

然后可以使用CustomButton在其它控件中.

@override
Widget build(BuildContext context) {
  return Center(
    child: CustomButton("Hello"),
  );
}

 

Intents意图?

在Flutter里的intent替代物是什么?

在Android,有两个主要的用例,intent用于Activity间的导航,来组件交互.在Flutter,没有intent的概念,当然也可以使用插件来启动一个intent.

Flutter,没有真实的与Activity/Fragment对等的东西.在Flutter,你在不同屏幕中导航,使用Navigator和Route.

Route是屏幕的抽象.Navigator是管理Route的控件.一个Route映射到一个Activity,但不是相同的意思.Navigator通过push/pop路由,在屏幕间移动.Navigator像是一个栈你可以push入栈,到新的路由,然后pop出栈,返回.

在Android,需要在AndroidManifest.xml.声明.

在Flutter,有两个可选的:

分配一组路由名Map.MaterialApp

直接导航到一个路由.WidgetApp

下面的示例,构建一个map:

void main() {
  runApp(MaterialApp(
    home: MyAppHome(), // becomes the route named '/'
    routes: <String, WidgetBuilder> {
      '/a': (BuildContext context) => MyPage(title: 'page A'),
      '/b': (BuildContext context) => MyPage(title: 'page B'),
      '/c': (BuildContext context) => MyPage(title: 'page C'),
    },
  ));
}

通过Navigator导航到路由,使用push名字的方式:

Navigator.of(context).pushNamed('/b');

另一个流行的intent用例,像调用相机或文件获取器组件,你需要创建一个本地平台集成的功能.可以使用existing plugin

查看如何构建一个本地平台集成插件Developing Packages and Plugins.

 

如何处理Intent?

Flutter可以处理来自Android的intent.直接对话Android层,请求数据.

下面的示例,注册了一个文件共享的intent过滤器在Activity里,然后运行在Flutter代码上.所以,app可以共享文本.

基本的流程是,Android里面共享数据,然后等待Flutter通过MethodChannel请求数据.

先注册:

<activity
  android:name=".MainActivity"
  android:launchMode="singleTop"
  android:theme="@style/LaunchTheme"
  android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|layoutDirection"
  android:hardwareAccelerated="true"
  android:windowSoftInputMode="adjustResize">
  <!-- ... -->
  <intent-filter>
    <action android:name="android.intent.action.SEND" />
    <category android:name="android.intent.category.DEFAULT" />
    <data android:mimeType="text/plain" />
  </intent-filter>
</activity>

然后在MainActivity,处理intent.从intent里获取文本,并处理.当Flutter准备好,它使用平台的channel来请求数据,然后发送到本地.

package com.example.shared;

import android.content.Intent;
import android.os.Bundle;

import java.nio.ByteBuffer;

import io.flutter.app.FlutterActivity;
import io.flutter.plugin.common.ActivityLifecycleListener;
import io.flutter.plugin.common.MethodCall;
import io.flutter.plugin.common.MethodChannel;
import io.flutter.plugins.GeneratedPluginRegistrant;

public class MainActivity extends FlutterActivity {

  private String sharedText;

  @Override
  protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    GeneratedPluginRegistrant.registerWith(this);
    Intent intent = getIntent();
    String action = intent.getAction();
    String type = intent.getType();

    if (Intent.ACTION_SEND.equals(action) && type != null) {
      if ("text/plain".equals(type)) {
        handleSendText(intent); // Handle text being sent
      }
    }

    MethodChannel(getFlutterView(), "app.channel.shared.data")
      .setMethodCallHandler(MethodChannel.MethodCallHandler() {
        @Override
        public void onMethodCall(MethodCall methodCall, MethodChannel.Result result) {
          if (methodCall.method.contentEquals("getSharedText")) {
            result.success(sharedText);
            sharedText = null;
          }
        }
      });
  }

  void handleSendText(Intent intent) {
    sharedText = intent.getStringExtra(Intent.EXTRA_TEXT);
  }
}

由Flutter端请求数据.

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

void main() {
  runApp(SampleApp());
}

class SampleApp extends StatelessWidget {
  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Sample Shared App Handler',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: SampleAppPage(),
    );
  }
}

class SampleAppPage extends StatefulWidget {
  SampleAppPage({Key key}) : super(key: key);

  @override
  _SampleAppPageState createState() => _SampleAppPageState();
}

class _SampleAppPageState extends State<SampleAppPage> {
  static const platform = const MethodChannel('app.channel.shared.data');
  String dataShared = "No data";

  @override
  void initState() {
    super.initState();
    getSharedText();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(body: Center(child: Text(dataShared)));
  }

  getSharedText() async {
    var sharedData = await platform.invokeMethod("getSharedText");
    if (sharedData != null) {
      setState(() {
        dataShared = sharedData;
      });
    }
  }
}

 

什么与startActivityForResult对等?

导航Navigator类处理路由,然后获取一个结果.这是通过push返回的Future上的await实现的.

如,启动一个定位的路由,让用户选择一个位置,可以这么做:

Map coordinates = await Navigator.of(context).pushNamed('/location');

然后定位路由中,用户选择了他们的位置,然后pop栈,带着返回值
 

Navigator.of(context).pop({"lat":43.821757,"long":-79.226392});

 

异步的UI

Flutter里的runOnUiThread()对等物是什么?

Dart是单线程模型,通过isolate实现线程.dart代码是运行在主线程上的,除非你切换了.Flutter的事件循环队列像Android里的main looper.

Dart的单线程,不表示你在主线程做所有的事,让ui冻结.不像Android,总是要保持ui主线程的空间.Flutter,使用dart提供的异步工具集,像async/await,来产生异步的工作.你可能已经熟悉了async/await,如果你使用了c#,javascript,或kotlin.

你可以使用async/await运行一些网络代码,不会造成ui的冻结.

loadData() async {
  String dataURL = "https://jsonplaceholder.typicode.com/posts";
  http.Response response = await http.get(dataURL);
  setState(() {
    widgets = json.decode(response.body);
  });
}

一旦await等的网络调用完成,更新ui是通过调用setState().它触发了控件树的重建,并更新数据.

下面的示例,异步加载数据,然后显示在列表中.

import 'dart:convert';

import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;

void main() {
  runApp(SampleApp());
}

class SampleApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Sample App',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: SampleAppPage(),
    );
  }
}

class SampleAppPage extends StatefulWidget {
  SampleAppPage({Key key}) : super(key: key);

  @override
  _SampleAppPageState createState() => _SampleAppPageState();
}

class _SampleAppPageState extends State<SampleAppPage> {
  List widgets = [];

  @override
  void initState() {
    super.initState();

    loadData();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text("Sample App"),
      ),
      body: ListView.builder(
          itemCount: widgets.length,
          itemBuilder: (BuildContext context, int position) {
            return getRow(position);
          }));
  }

  Widget getRow(int i) {
    return Padding(
      padding: EdgeInsets.all(10.0),
      child: Text("Row ${widgets[i]["title"]}")
    );
  }

  loadData() async {
    String dataURL = "https://jsonplaceholder.typicode.com/posts";
    http.Response response = await http.get(dataURL);
    setState(() {
      widgets = json.decode(response.body);
    });
  }
}

下面的章节,有更多关于异步后台处理的信息,展示了Flutter与Android的不同.

 

如何将工作移入后台线程中?

在Android中,当需要访问网络资源,典型的是,放入后台线程.不阻塞主线程,避免anr.如你需要使用AsyncTask,LiveData,IntentService,JobScheduler或RxJava管道来执行后台的线程.

Flutter是单线程的,运行一个事件循环队列.你不需要担心线程管理的问题.如果你做一些i/o工作,像磁盘访问,网络调用,可以安全地使用async/await. 另一种情况,需要cpu计算的工作,你可以移入isolate,避免事件阻塞,像Android里面的在主线程外处理排序一样.

I/O工作,声明一个async方法,然后await长时间的任务.

loadData() async {
  String dataURL = "https://jsonplaceholder.typicode.com/posts";
  http.Response response = await http.get(dataURL);
  setState(() {
    widgets = json.decode(response.body);
  });
}

这是典型的处理网络或数据库调用.这些都是i/o操作

在Android里面扩展AsyncTask,可以覆盖3个方法.onPreExecute()doInBackground() and onPostExecute()但Flutter没有这样的.Flutter只能等待最终结果.

然而,有时也是要处理大量的数据,同时更新ui.在Flutter,使用isolate,充分利用多cpu核心来实现复杂的计算任务.

isolate是单独的线程,不共享主线程的内存.这表示,你不能访问主线程的变量来更新ui,不可调用setState().不像Android的线程,ioslate像名字一样,隔离不共享内存.

下面的示例展示了,如何返回数据到主线程去更新ui.

loadData() async {
  ReceivePort receivePort = ReceivePort();
  await Isolate.spawn(dataLoader, receivePort.sendPort);

  // The 'echo' isolate sends its SendPort as the first message
  SendPort sendPort = await receivePort.first;

  List msg = await sendReceive(sendPort, "https://jsonplaceholder.typicode.com/posts");

  setState(() {
    widgets = msg;
  });
}

// The entry point for the isolate
static dataLoader(SendPort sendPort) async {
  // Open the ReceivePort for incoming messages.
  ReceivePort port = ReceivePort();

  // Notify any other isolates what port this isolate listens to.
  sendPort.send(port.sendPort);

  await for (var msg in port) {
    String data = msg[0];
    SendPort replyTo = msg[1];

    String dataURL = data;
    http.Response response = await http.get(dataURL);
    // Lots of JSON to parse
    replyTo.send(json.decode(response.body));
  }
}

Future sendReceive(SendPort port, msg) {
  ReceivePort response = ReceivePort();
  port.send([msg, response.sendPort]);
  return response.first;
}

这里的dataLoader是isolate运行在它线程的.在isoloate,可以利用多cpu核心,处理复杂的运算像加密,解密.

运行完整的示例:

import 'dart:convert';

import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'dart:async';
import 'dart:isolate';

void main() {
  runApp(SampleApp());
}

class SampleApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Sample App',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: SampleAppPage(),
    );
  }
}

class SampleAppPage extends StatefulWidget {
  SampleAppPage({Key key}) : super(key: key);

  @override
  _SampleAppPageState createState() => _SampleAppPageState();
}

class _SampleAppPageState extends State<SampleAppPage> {
  List widgets = [];

  @override
  void initState() {
    super.initState();
    loadData();
  }

  showLoadingDialog() {
    if (widgets.length == 0) {
      return true;
    }

    return false;
  }

  getBody() {
    if (showLoadingDialog()) {
      return getProgressDialog();
    } else {
      return getListView();
    }
  }

  getProgressDialog() {
    return Center(child: CircularProgressIndicator());
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
        appBar: AppBar(
          title: Text("Sample App"),
        ),
        body: getBody());
  }

  ListView getListView() => ListView.builder(
      itemCount: widgets.length,
      itemBuilder: (BuildContext context, int position) {
        return getRow(position);
      });

  Widget getRow(int i) {
    return Padding(padding: EdgeInsets.all(10.0), child: Text("Row ${widgets[i]["title"]}"));
  }

  loadData() async {
    ReceivePort receivePort = ReceivePort();
    await Isolate.spawn(dataLoader, receivePort.sendPort);

    // The 'echo' isolate sends its SendPort as the first message
    SendPort sendPort = await receivePort.first;

    List msg = await sendReceive(sendPort, "https://jsonplaceholder.typicode.com/posts");

    setState(() {
      widgets = msg;
    });
  }

  // the entry point for the isolate
  static dataLoader(SendPort sendPort) async {
    // Open the ReceivePort for incoming messages.
    ReceivePort port = ReceivePort();

    // Notify any other isolates what port this isolate listens to.
    sendPort.send(port.sendPort);

    await for (var msg in port) {
      String data = msg[0];
      SendPort replyTo = msg[1];

      String dataURL = data;
      http.Response response = await http.get(dataURL);
      // Lots of JSON to parse
      replyTo.send(json.decode(response.body));
    }
  }

  Future sendReceive(SendPort port, msg) {
    ReceivePort response = ReceivePort();
    port.send([msg, response.sendPort]);
    return response.first;
  }
}

这似乎是表示 ,如果不需要利用多核心,似乎不需要isolate.用async就可以了.

 

Flutter里OkHttp的等价物?

Flutter里创建一个网络调用是非常容易的.使用http包.

http包并没有OkHttp里面的所有特性,它处理了多数场景,让你更方便地调用.

先要在pub里添加

dependencies:
  ...
  http: ^0.11.3+16

使用await/async方法:

import 'dart:convert';

import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
[...]
  loadData() async {
    String dataURL = "https://jsonplaceholder.typicode.com/posts";
    http.Response response = await http.get(dataURL);
    setState(() {
      widgets = json.decode(response.body);
    });
  }
}

 

如何显示长任务的进度?

在Android里面ProgressBar在执行任务时,显示进度.

Flutter里面ProgressIndicator 控件,通过控制flag来展示进度的.

下面的示例,build方法分三步,showLoadingDialog()返回true,就渲染ProgressIndicator,否则返回ListView 

import 'dart:convert';

import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;

void main() {
  runApp(SampleApp());
}

class SampleApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Sample App',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: SampleAppPage(),
    );
  }
}

class SampleAppPage extends StatefulWidget {
  SampleAppPage({Key key}) : super(key: key);

  @override
  _SampleAppPageState createState() => _SampleAppPageState();
}

class _SampleAppPageState extends State<SampleAppPage> {
  List widgets = [];

  @override
  void initState() {
    super.initState();
    loadData();
  }

  showLoadingDialog() {
    return widgets.length == 0;
  }

  getBody() {
    if (showLoadingDialog()) {
      return getProgressDialog();
    } else {
      return getListView();
    }
  }

  getProgressDialog() {
    return Center(child: CircularProgressIndicator());
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
        appBar: AppBar(
          title: Text("Sample App"),
        ),
        body: getBody());
  }

  ListView getListView() => ListView.builder(
      itemCount: widgets.length,
      itemBuilder: (BuildContext context, int position) {
        return getRow(position);
      });

  Widget getRow(int i) {
    return Padding(padding: EdgeInsets.all(10.0), child: Text("Row ${widgets[i]["title"]}"));
  }

  loadData() async {
    String dataURL = "https://jsonplaceholder.typicode.com/posts";
    http.Response response = await http.get(dataURL);
    setState(() {
      widgets = json.decode(response.body);
    });
  }
}

 

项目结构与资源

我在哪里存储多分辨率图片?

Android处理资源与assets是不同的.Flutter只有assets/所有的在Android里面res/drawable-*的资源,都放在assets文件夹里.

Flutter遵循一个ios的格式规则,asset可以是1.0x2.0x3.0x,或其它的.没有dp但有逻辑的像素,基于不同的设备像素.devicePixelRatio是设备像素映射为逻辑像素的方式.

二者对应关系:

assets可以在任何目录里,Flutter没有预定义结构.在pub里面声明:

Flutter1.0beta2 以前,本地是无法访问assets里的东西的.之后的版本,放在本地assets里面的文件,可以通过AssetManager访问到.

val flutterAssetStream = assetManager.open("flutter_assets/assets/my_flutter_asset.png")

添加my_icon.png到项目中,存储在images目录中,1.0x的放在images里面,其它分辨率的放各自目录:
 

images/my_icon.png       // Base: 1.0x image
images/2.0x/my_icon.png  // 2.0x image
images/3.0x/my_icon.png  // 3.0x image

然后在pubspec.yaml里面声明:

assets:
 - images/my_icon.jpeg

通过AssetImage访问:

return AssetImage("images/a_dot_burr.jpeg");

或直接使用Image控件:

@override
Widget build(BuildContext context) {
  return Image.asset("images/my_image.png");
}

 

在哪里存字符串,实现本地化?

Flutter,目前没有字符串的资源处理.最好的办法是在类里面使用静态的字符.

class Strings {
  static String welcomeMessage = "Welcome To Flutter";
}

然后代码中使用:

Text(Strings.welcomeMessage)

Flutter有基础的访问Android的东西,其它还在工作中.

Flutter开发者被鼓励使用 intl package实现本地化.

 

Gradle的等价物是什么,如何添加依赖?

在Android里面添加依赖是添加gradle脚本,Flutter使用dart构建系统,pub包管理.这些工具代理了本地平台的构建.

Flutter工程里面的Android目录,有gradle脚本文件,只有在你添加了本地依赖,需 要集成平台时用到.通常,pubspec.yaml来声明依赖的.可以在Pub.里查找依赖

Activity与Fragment

Flutter里的等价物是什么?

Android里面Activity,表现为用户可以处理的单一的事情,Flutter是ui的一部分.Flutter是模块化代码的一种方式,组件这些满足了你的大屏幕的需求,使app更具伸缩性.Flutter,两个想法都整合进来了,使用的是控件.

像前面的intent章节,屏幕是通过控件来展现的.Flutter里面每一个东西都是控件.可以使用Navigator在不同的路由间,展示不同的屏幕,页面,或不同的数据渲染状态.

 

如何监听Android的生命周期.

Android里面覆盖生命周期的方法就可以捕获事件,或自己注册ActivityLifecycleCallbacks .Flutter里面可以监听生命周期事件,通过WidgetsBinding 观察者监听didChangeAppLifecycleState()事件.

这些生命周期可参赛者是:

inactive--应用是未激活状态,不接收用户的输入,只在ios上有效.Android上没有对应的.

paused--应用不可见,或不响应用户的输入,或运行后台.对应Android的onPause()

resumed--应用可见,可交互,对应Android的onPostResume()

suspending--应用挂起,ios上的没有,与Android的onstop对应.

查看更详细的状态:AppLifecycleStatus documentation.

你可能也注意到了,只有少数的Activity生命周期方法是可用的,FlutterActivity不会捕获所有的生命周期事件.Flutter的任务,由引擎启动,停止,不太需要Activity的生命周期.如果你需要观察生命周期事件,释放本地资源,最好是在本地代码里做这事.

这是生命周期的示例:

import 'package:flutter/widgets.dart';

class LifecycleWatcher extends StatefulWidget {
  @override
  _LifecycleWatcherState createState() => _LifecycleWatcherState();
}

class _LifecycleWatcherState extends State<LifecycleWatcher> with WidgetsBindingObserver {
  AppLifecycleState _lastLifecycleState;

  @override
  void initState() {
    super.initState();
    WidgetsBinding.instance.addObserver(this);
  }

  @override
  void dispose() {
    WidgetsBinding.instance.removeObserver(this);
    super.dispose();
  }

  @override
  void didChangeAppLifecycleState(AppLifecycleState state) {
    setState(() {
      _lastLifecycleState = state;
    });
  }

  @override
  Widget build(BuildContext context) {
    if (_lastLifecycleState == null)
      return Text('This widget has not observed any lifecycle changes.', textDirection: TextDirection.ltr);

    return Text('The most recent lifecycle state this widget observed was: $_lastLifecycleState.',
        textDirection: TextDirection.ltr);
  }
}

void main() {
  runApp(Center(child: LifecycleWatcher()));
}

 

布局:

有没有和LinearLayout的等价物?

在Android里LinearLayout线性地布局控件,垂直或水平.在Flutter里面使用Row/Column控件,也会有相同的效果.

如果你注意到示例代码,子元素是相同的,

@override
Widget build(BuildContext context) {
  return Row(
    mainAxisAlignment: MainAxisAlignment.center,
    children: <Widget>[
      Text('Row One'),
      Text('Row Two'),
      Text('Row Three'),
      Text('Row Four'),
    ],
  );
}
@override
Widget build(BuildContext context) {
  return Column(
    mainAxisAlignment: MainAxisAlignment.center,
    children: <Widget>[
      Text('Column One'),
      Text('Column Two'),
      Text('Column Three'),
      Text('Column Four'),
    ],
  );
}

查看更多:Flutter For Android Developers : How to design LinearLayout in Flutter?.

 

有没有RelativeLayout的等价物?

RelativeLayout布局控件,是相对位置,在Flutter里面却没有这样的.

可以使用Column与Row的联合使用,来实现相同的效果.

关于RelativeLayout的效果可以查看:StackOverflow.

 

ScrollView的等价物?

Android里面的ScrollView布局,可以在小屏幕里滚动控件.

Flutter,使用ListView可以很方便地实现,这看起来像是过度地使用了ListView,但在Flutter里面ListView控件是Android里面的ScrollView与ListView整合.

@override
Widget build(BuildContext context) {
  return ListView(
    children: <Widget>[
      Text('Row One'),
      Text('Row Two'),
      Text('Row Three'),
      Text('Row Four'),
    ],
  );
}

如何处理横竖屏转换?

FlutterView处理配置改变,如果Manifest文件里包含了

android:configChanges="orientation|screenSize"

 

手势与触摸事件?

如何在Flutter里添加Click事件.

Android里面可以添加onClick到view,通过设置setOnClickListener.

在Flutter里面有两个方式添加触摸事件:

1.如果控件支持事件,传递一个方法,在这个方法里处理.如RaisedButton有一个onPressed参数.

@override
Widget build(BuildContext context) {
  return RaisedButton(
      onPressed: () {
        print("click");
      },
      child: Text("Button"));
}

2如果不支持事件检测,就包装控件,使用GestureDetector ,的onTap参数:

class SampleApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
        body: Center(
      child: GestureDetector(
        child: FlutterLogo(
          size: 200.0,
        ),
        onTap: () {
          print("tap");
        },
      ),
    ));
  }
}

如何处理控件上的其它事件?

使用GestureDetector可以监听各种事件:

  • Tap 点击

    • onTapDown - A pointer that might cause a tap has contacted the screen at a particular location.
    • onTapUp - A pointer that triggers a tap has stopped contacting the screen at a particular location.
    • onTap - A tap has occurred.
    • onTapCancel - The pointer that previously triggered the onTapDown won’t cause a tap.
  • Double tap 双击

    • onDoubleTap - The user tapped the screen at the same location twice in quick succession.
  • Long press 长按

    • onLongPress - A pointer has remained in contact with the screen at the same location for a long period of time.
  • Vertical drag 垂直拖动

    • onVerticalDragStart - A pointer has contacted the screen and might begin to move vertically.
    • onVerticalDragUpdate - A pointer in contact with the screen has moved further in the vertical direction.
    • onVerticalDragEnd - A pointer that was previously in contact with the screen and moving vertically is no longer in contact with the screen and was moving at a specific velocity when it stopped contacting the screen.
  • Horizontal drag 水平拖动

    • onHorizontalDragStart - A pointer has contacted the screen and might begin to move horizontally.
    • onHorizontalDragUpdate - A pointer in contact with the screen has moved further in the horizontal direction.
    • onHorizontalDragEnd - A pointer that was previously in contact with the screen and moving horizontally is no longer in contact with the screen and was moving at a specific velocity when it stopped contacting the screen.

下面的示例展示了GestureDetector 的使用,在logo上双击:

AnimationController controller;
CurvedAnimation curve;

@override
void initState() {
  controller = AnimationController(duration: const Duration(milliseconds: 2000), vsync: this);
  curve = CurvedAnimation(parent: controller, curve: Curves.easeIn);
}

class SampleApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
        body: Center(
          child: GestureDetector(
            child: RotationTransition(
                turns: curve,
                child: FlutterLogo(
                  size: 200.0,
                )),
            onDoubleTap: () {
              if (controller.isCompleted) {
                controller.reverse();
              } else {
                controller.forward();
              }
            },
        ),
    ));
  }
}

 

ListView与Adapter

Flutter里的ListView替代物?

Flutter里面的ListView就是ListView.

Android里的ListView,你创建一个适配器,然后传递给ListView,渲染每一行由适配器返回的view.你还要关心如何回收,要处理内存等事情.

因为Flutter的不可变控件,你传递一个控件给ListView,Flutter会处理好滚动的.

import 'package:flutter/material.dart';

void main() {
  runApp(SampleApp());
}

class SampleApp extends StatelessWidget {
  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Sample App',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: SampleAppPage(),
    );
  }
}

class SampleAppPage extends StatefulWidget {
  SampleAppPage({Key key}) : super(key: key);

  @override
  _SampleAppPageState createState() => _SampleAppPageState();
}

class _SampleAppPageState extends State<SampleAppPage> {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text("Sample App"),
      ),
      body: ListView(children: _getListData()),
    );
  }

  _getListData() {
    List<Widget> widgets = [];
    for (int i = 0; i < 100; i++) {
      widgets.add(Padding(padding: EdgeInsets.all(10.0), child: Text("Row $i")));
    }
    return widgets;
  }
}

由于实现的方式不同,不能用以前的想法来思考Flutter里的ListView实现.我觉得它更像ios的列表实现方式.

 

列表项的点击如何处理?

Android里面的ListView,有一个方法,可以处理每项的点击,Flutter,使用传递给控件的事件处理器.

import 'package:flutter/material.dart';

void main() {
  runApp(SampleApp());
}

class SampleApp extends StatelessWidget {
  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Sample App',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: SampleAppPage(),
    );
  }
}

class SampleAppPage extends StatefulWidget {
  SampleAppPage({Key key}) : super(key: key);

  @override
  _SampleAppPageState createState() => _SampleAppPageState();
}

class _SampleAppPageState extends State<SampleAppPage> {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text("Sample App"),
      ),
      body: ListView(children: _getListData()),
    );
  }

  _getListData() {
    List<Widget> widgets = [];
    for (int i = 0; i < 100; i++) {
      widgets.add(GestureDetector(
        child: Padding(
            padding: EdgeInsets.all(10.0),
            child: Text("Row $i")),
        onTap: () {
          print('row tapped');
        },
      ));
    }
    return widgets;
  }
}

这个做法似乎有点恶心了,每一项都需要包装一下GestureDetector.

 

如何动态地更新ListView?

Android里面更新适配器,然后调用notifyDataSetChanged

Flutter,如果你在控件内更新setState(),你会发现,没有什么变化,因为Flutter会检测两个ListView是不是相等.

要更新ListView,就只能在setState()里面创建一个新的列表.复制数据到新的列表中,这不适合大量的数据:

import 'package:flutter/material.dart';

void main() {
  runApp(SampleApp());
}

class SampleApp extends StatelessWidget {
  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Sample App',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: SampleAppPage(),
    );
  }
}

class SampleAppPage extends StatefulWidget {
  SampleAppPage({Key key}) : super(key: key);

  @override
  _SampleAppPageState createState() => _SampleAppPageState();
}

class _SampleAppPageState extends State<SampleAppPage> {
  List widgets = [];

  @override
  void initState() {
    super.initState();
    for (int i = 0; i < 100; i++) {
      widgets.add(getRow(i));
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text("Sample App"),
      ),
      body: ListView(children: widgets),
    );
  }

  Widget getRow(int i) {
    return GestureDetector(
      child: Padding(
          padding: EdgeInsets.all(10.0),
          child: Text("Row $i")),
      onTap: () {
        setState(() {
          widgets = List.from(widgets);
          widgets.add(getRow(widgets.length + 1));
          print('row $i');
        });
      },
    );
  }
}

建议,使用ListView的builder来高效地构建列表.当你有一个动态列表,或有大量的数据,这是个高效的方法,这像RecyclerView一样,自动回收元素的.

import 'package:flutter/material.dart';

void main() {
  runApp(SampleApp());
}

class SampleApp extends StatelessWidget {
  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Sample App',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: SampleAppPage(),
    );
  }
}

class SampleAppPage extends StatefulWidget {
  SampleAppPage({Key key}) : super(key: key);

  @override
  _SampleAppPageState createState() => _SampleAppPageState();
}

class _SampleAppPageState extends State<SampleAppPage> {
  List widgets = [];

  @override
  void initState() {
    super.initState();
    for (int i = 0; i < 100; i++) {
      widgets.add(getRow(i));
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
        appBar: AppBar(
          title: Text("Sample App"),
        ),
        body: ListView.builder(
            itemCount: widgets.length,
            itemBuilder: (BuildContext context, int position) {
              return getRow(position);
            }));
  }

  Widget getRow(int i) {
    return GestureDetector(
      child: Padding(
          padding: EdgeInsets.all(10.0),
          child: Text("Row $i")),
      onTap: () {
        setState(() {
          widgets.add(getRow(widgets.length + 1));
          print('row $i');
        });
      },
    );
  }
}

不创建ListView,而创建ListView.builder,两个参数,初始化长度与ItemBuilder方法,ItemBuilder方法像getView方法,返回行.

重要的点:onTap()方法,不用重建,只是添加进去.

 

处理文本

Android sdk你可以创建字体资源,传递给字体家族到TextView中.

Flutter,将字体放入文件夹,配置到pubspec.yaml中,

fonts:
   - family: MyCustomFont
     fonts:
       - asset: fonts/MyCustomFont.ttf
       - style: italic

然后分配给Text控件:

@override
Widget build(BuildContext context) {
  return Scaffold(
    appBar: AppBar(
      title: Text("Sample App"),
    ),
    body: Center(
      child: Text(
        'This is a custom font text',
        style: TextStyle(fontFamily: 'MyCustomFont'),
      ),
    ),
  );
}

 

如何定义文本的风格?

不只是字体,你可以自定义Text控件的风格,style参数,会持有TextStyle对象,它可以有自定义参数:

  • color
  • decoration
  • decorationColor
  • decorationStyle
  • fontFamily
  • fontSize
  • fontStyle
  • fontWeight
  • hashCode
  • height
  • inherit
  • letterSpacing
  • textBaseline
  • wordSpacing

 

表单输入

查看更多: Retrieve the value of a text field

hint的等价物?

Flutter,可以很容易地显示一个hint信息,添加InputDecoration 装饰对象就行:

body: Center(
  child: TextField(
    decoration: InputDecoration(hintText: "This is a hint"),
  )
)

如何显示验证异常?

如果你添加hint,你不需要显示错误,当用户输入一个非法数据,更新状态,传递一个新的InputDecoration对象.

import 'package:flutter/material.dart';

void main() {
  runApp(SampleApp());
}

class SampleApp extends StatelessWidget {
  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Sample App',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: SampleAppPage(),
    );
  }
}

class SampleAppPage extends StatefulWidget {
  SampleAppPage({Key key}) : super(key: key);

  @override
  _SampleAppPageState createState() => _SampleAppPageState();
}

class _SampleAppPageState extends State<SampleAppPage> {
  String _errorText;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text("Sample App"),
      ),
      body: Center(
        child: TextField(
          onSubmitted: (String text) {
            setState(() {
              if (!isEmail(text)) {
                _errorText = 'Error: This is not an email';
              } else {
                _errorText = null;
              }
            });
          },
          decoration: InputDecoration(hintText: "This is a hint", errorText: _getErrorText()),
        ),
      ),
    );
  }

  _getErrorText() {
    return _errorText;
  }

  bool isEmail(String em) {
    String emailRegexp =
        r'^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$';

    RegExp regExp = RegExp(emailRegexp);

    return regExp.hasMatch(em);
  }
}

Flutter插件:

How do I access the GPS sensor?

Use the geolocator community plugin.

How do I access the camera?

The image_picker plugin is popular for accessing the camera.

How do I log in with Facebook?

To Log in with Facebook, use the flutter_facebook_login community plugin.

How do I use Firebase features?

Most Firebase functions are covered by first party plugins. These plugins are first-party integrations, maintained by the Flutter team:

You can also find some third-party Firebase plugins on Pub that cover areas not directly covered by the first-party plugins.

How do I build my own custom native integrations?

If there is platform-specific functionality that Flutter or its community Plugins are missing, you can build your own following the developing packages and plugins page.

Flutter’s plugin architecture, in a nutshell, is much like using an Event bus in Android: you fire off a message and let the receiver process and emit a result back to you. In this case, the receiver is code running on the native side on Android or iOS.

How do I use the NDK in my Flutter application?

If you use the NDK in your current Android application and want your Flutter application to take advantage of your native libraries then it’s possible by building a custom plugin.

Your custom plugin first talks to your Android app, where you call your native functions over JNI. Once a response is ready, send a message back to Flutter and render the result.

Calling native code directly from Flutter is currently not supported.

不支持调用本地代码.

主题

Themes

How do I theme my app?

Out of the box, Flutter comes with a beautiful implementation of Material Design, which takes care of a lot of styling and theming needs that you would typically do. Unlike Android where you declare themes in XML and then assign it to your application using AndroidManifest.xml, in Flutter you declare themes in the top level widget.

To take full advantage of Material Components in your app, you can declare a top level widget MaterialApp as the entry point to your application. MaterialApp is a convenience widget that wraps a number of widgets that are commonly required for applications implementing Material Design. It builds upon a WidgetsApp by adding Material specific functionality.

You can also use a WidgetApp as your app widget, which provides some of the same functionality, but is not as rich as MaterialApp.

To customize the colors and styles of any child components, pass a ThemeData object to the MaterialApp widget. For example, in the code below, the primary swatch is set to blue and text selection color is red.

class SampleApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Sample App',
      theme: ThemeData(
        primarySwatch: Colors.blue,
        textSelectionColor: Colors.red
      ),
      home: SampleAppPage(),
    );
  }
}

Databases and local storage

How do I access Shared Preferences?

In Android, you can store a small collection of key-value pairs using the SharedPreferences API.

In Flutter, access this functionality using the Shared_Preferences plugin. This plugin wraps the functionality of both Shared Preferences and NSUserDefaults (the iOS equivalent).

import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';

void main() {
  runApp(
    MaterialApp(
      home: Scaffold(
        body: Center(
          child: RaisedButton(
            onPressed: _incrementCounter,
            child: Text('Increment Counter'),
          ),
        ),
      ),
    ),
  );
}

_incrementCounter() async {
  SharedPreferences prefs = await SharedPreferences.getInstance();
  int counter = (prefs.getInt('counter') ?? 0) + 1;
  print('Pressed $counter times.');
  prefs.setInt('counter', counter);
}

How do I access SQLite in Flutter?

In Android, you use SQLite to store structured data that you can query using SQL.

In Flutter, access this functionality using the SQFlite plugin.

Notifications

How do I set up push notifications?

In Android, you use Firebase Cloud Messaging to setup push notifications for your app.

In Flutter, access this functionality using the Firebase_Messaging plugin. For more information on using the Firebase Cloud Messaging API, see the firebase_messaging plugin documentation.

 

 

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值