Flutter学习知识点总结

一、Dart语言简介

Dart语言是一种真正面向对象的语言。下面我挑几点比较重要的简单实用的介绍一下,因为文章后面会涉及这些语法。

函数式编程

说Dart是真正的面向对象的语言就在于它把函数也认为是对象,类型为Function。这意味着函数可以赋值给变量或者作为参数传递给其他函数,这是函数式编程的典型特征。

//将函数赋值给变量
var say= (str){
  print(str);
};

//对于只含一个表达式的函数,可以使用简写语法,如下:
var say = (str) => print(str);

//使用变量去调用函数
say("hi world");
//作为参数传递
void execute(var callback){
    callback();
}
execute(()=>print("xxx"))

可选位置参数

//函数
String say(String from, String msg, [String device]) {
	...
}

//调用
say('Bob', 'Howdy');

say('Bob', 'Howdy', 'smoke signal');

即函数声明的时候,可以使用[]标记哪些参数是可以选填的。

可选命名参数

//函数
void enableFlags({bool bold, bool hidden}) {
    // ... 
}

//调用
enableFlags(bold: true, hidden: false);

即函数声明的时候,使用{}指定命名参数,调用的时候传参数要进行命名指定,这样的好处在于位置不固定,而且只需要传递需要传入的参数,不用进行方法重载,使用灵活。这种方式在后续的Flutter开发中使用的非常多。

Future

Future是用于处理异步操作的,其API的返回值仍然是一个Future对象,所以可以方便的进行链式调用

Future.delayed(new Duration(seconds: 2), () {
    throw AssertionError("Error");
}).then((data) {
	//执行成功会走到这里 
    print("success");
}, onError: (e) {
	 //执行失败会走到这里   
    print(e);
}).whenComplete((){
   //无论成功或失败都会走到这里
});

如果需要等待多个异步任务执行完成才能进行后续操作,可以使用Future.wait

Future.wait([
  // 2秒后返回结果  
  Future.delayed(new Duration(seconds: 2), () {
    return "hello";
  }),
  // 4秒后返回结果  
  Future.delayed(new Duration(seconds: 4), () {
    return " world";
  })
]).then((results){
  print(results[0]+results[1]);
}).catchError((e){
  print(e);
});

Async/await

async/await也可以实现异步操作

   /// 模拟异步加载用户信息
  Future _getUserInfo() async{
    await new Future.delayed(new Duration(milliseconds: 3000));
  }
  • async用来表示函数是异步的,定义的函数会返回一个Future对象,可以使用then方法添加回调函数。
  • await 后面是一个Future,表示等待该异步任务完成,异步完成后才会往下走;await必须出现在 async 函数内部。

二、Flutter路由及导航

1、概念

Route

路由(Route)在移动开发中通常指页面,在Android中通常指一个Activity,在iOS中通常指一个ViewController。所谓路由管理,就是来管理页面之间如何跳转。

路由管理会维护一个路由栈,路由入栈(push)操作对应打开一个新界面,路由出栈(pop)操作对应页面关闭操作,所以路由管理也就是指管理路由栈。

命名路由(Named Route)

即给路由起一个名字,然后可以通过路由名字直接打开新的路由,这位路由管理带来了一种直观、简单的方式。路由名称通常使用路径结构:“/a/b/c”,主页默认为 “/”。

路由表

要想使用命名路由,我们必须先提供并注册一个路由表(route table),这样应用程序才知道哪个名称和哪个路由Widget对应,路由表的定义如下

Map<String,WidgetBuilder> routes;

它是一个Map,key为路由的名称,是一个字符串;value是个builder的回调函数,用于生成相应的路由widget。我们在通过路由名称入栈新路由时,应用会根据路由名称在路由表中找到对应的WidgetBuilder回调函数,然后调用回调函数生成路由Widget并返回。

MaterialPageRoute

切换路由的时候通常使用该widget。

MaterialPageRoute继承自PageRoute类,PageRoute类是一个抽象类,表示占有整个屏幕空间的一个模块路由界面,它还定义了路由构建及切换是过渡动画的相关接口及属性。MaterialPageRoute是Material组件库的一个Widget,它可以针对不同平台,实现与平台页面切换动画风格一致的路由切换动画。

  • 对于Android,当打开新页面时,新的页面会从屏幕底部滑动到屏幕顶部;当关闭页面时,当前页面会从屏幕顶部滑动到底部后消失,同时上一个界面会显示在屏幕上。
  • 对于iOS,当打开页面时,新的页面会从屏幕右侧边缘滑动到屏幕左边;当关闭页面时,当前页面会从屏幕右侧滑出,同时上一个页面也会显示在屏幕上。

下面介绍一个MaterialPageRoute构造函数的各个参数的意义:

  MaterialPageRoute({
    WidgetBuilder builder,
    RouteSettings settings,
    bool maintainState = true,
    bool fullscreenDialog = false,
  })
  • builder: 是一个WidgetBuilder类型的回调函数,它的作用是构建路由页面的具体内容,返回值是一个widget。我们通常要实现此回调,返回新路由的实例。
  • settings: 包含路由的配置信息,如路由名称、是否初始路由(首页)。
  • maintainState:默认情况下,当入栈一个新路由时,原来的路由仍然会被保存在内存中,如果想在路由没用的时候释放其所占用的所有资源,可以设置maintainState为false。
  • fullScreenDialog:表示新的路由页面是否是一个全屏的模态对话框,在iOS中,如果fullscreenDialog为true,新页面将会从屏幕底部滑入(而不是水平方向)
Navigator

Navigator是一个路由管理的widget,他通过一个栈来管理一个路由widget集合,通常当前屏幕显示的页面就是栈顶的路由。Navigator提供了一系列的方法来管理路由栈,这里我们介绍常用的两个方法

//将给定的路由入栈(即打开新的页面),返回值是一个Future对象,用以接收新路由出栈时返回的数据
Future push(BuildContext context,Route route)
//将栈顶路由出栈,result为页面关闭时返回给上一个页面的数据。
bool pop(BuildContext context, [ result ])

2、使用示例

(1) 简单跳转

首先我们创建两个路由

void main() => runApp(new MainRoute());

class MainRoute extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(
          title: Text("MainRoute"),
        ),
        body: Center(
          child: FlatButton(
              onPressed: () {
                //点击事件里面去打开第二个界面
                Navigator.push(context,
                    new MaterialPageRoute(builder: (context) {
                  return new SecondRoutePage();
                }));
              },
              child: Text("this is route main")),
        ),
      ),
    );
  }
}
class SecondRoutePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text("SecondRoute"),
      ),
      body: Center(
        child: Text("this is route second"),
      ),
    );
  }
}

每一个路由都是一个页面,都继承自StatelessWidget

当我们运行以上代码,你会发现是报错的,具体报错原因如下:

I/flutter (22941): ══╡ EXCEPTION CAUGHT BY GESTURE ╞═══════════════════════════════════════════════════════════════════
The following assertion was thrown while handling a gesture:

Navigator operation requested with a context that does not include a Navigator.

The context used to push or pop routes from the Navigator must be that of a widget that is a descendant(后代) of a Navigator widget.

在stackoverflow也能找到对应的问题https://stackoverflow.com/questions/50124355/flutter-navigator-not-working

在这里插入图片描述

问题总结就是说: Navigator.push(context,…)中你给的context是不包含Navigator的,即你拿这个context找不到对应的Navigator,这个context应该必须是Navigator widget的后代。

BuildContext class

build方法内部的widget的context跟build方法返回的widget的context是不一样的,这就会引起一些微妙的问题。

Flutter:问题收录

解决方式就是改为下面的写法,就是将子widget再抽取一层。

class MainRoute extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: MainRoutePage(),
    );
  }
}

class MainRoutePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text("MainRoute"),
      ),
      body: Center(
        child: FlatButton(
            onPressed: () {
              Navigator.push(context, new MaterialPageRoute(builder: (context) {
                return new SecondRoutePage();
              }));
            },
            child: Text("this is route main")),
      ),
    );
  }
}

还有一种解决方案可以了解一下WanAndroid-Flutter,请看上面这个项目里面的WanAndroidPage.dart的写法,使用GlobalKey,然后使用currentState Properties。

(2) 命名路由的用法

首先我们需要先注册路由表,注册方式很简单,我们在MaterialApp中添加routes属性,代码如下:

class MainRoute extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: MainRoutePage(),
      routes: {
        "/a": (context) => SecondRoutePage("title1"),
        "/b": (context) => TestPage("title2"),
      },
    );
  }
}

然后通过路由名称来打开路由,可以使用:

Future pushNamed(BuildContext context,String routeName);

对应代码为:

onPressed: () {
              Navigator.pushNamed(context, "/a");
//              Navigator.push(context, new MaterialPageRoute(builder: (context) {
//                return new SecondRoutePage("title param");
//              }));
            },

热重载应用,可以看到能正常打开第二个页面。

可以看到命名路由最大优点是直观、简单,我们可以通过语义化的字符串来管理路由,而且在一个地方统一配置,方便后续维护。但有一个明显缺点:不能传递变化的路由参数,虽然官方没有提供明确的解决方案,但是我们还是有一种方式可以解决这样的问题的,具体看下面的代码:

class MainRoute extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: MainRoutePage(),
      routes: {
        "/a": (context) => SecondRoutePage("title1"),
        "/b": (context) => TestPage("title2"),
      },
      onGenerateRoute: (RouteSettings settings) {
        WidgetBuilder builder;
        if (settings.name != null) {
          var split = settings.name.split('?');
          String param1 = split[0];
          String param2 = split[1];
          if (param2 != null && param2.length > 0) {
            switch (param1) {
              case "/a":
                builder = (context) => SecondRoutePage(param2);
                break;
            }
          }
        }
        if (builder != null) {
          return new MaterialPageRoute(builder: builder, settings: settings);
        }
      },
    );
  }
}

即在MaterialApp中监听onGenerateRoute,然后根据自己定义的传参规则取出参数再生成新的跳转路由。

对应的跳转代码可以写成:

 onPressed: () {
              Navigator.pushNamed(context, "/a?custume title");
//              Navigator.push(context, new MaterialPageRoute(builder: (context) {
//                return new SecondRoutePage("title param");
//              }));
            },
(3) 页面跳转返回传值

在进行页面切换时,通常需要将一些数据传递给新页面,或是从新页面返回数据,数据传递给新页面直接在创建Route的时候调用对应的构造函数传值就行,从新页面返回数据我们就需要上面讲到的打开新Route的返回值Future了。

首先我们需要监听AppBar和物理返回键,然后在pop的时候传返回值

class SecondRoutePage extends StatelessWidget {
  final String title;

  SecondRoutePage(this.title);

  @override
  Widget build(BuildContext context) {
    return new WillPopScope(
      child: Scaffold(
        appBar: AppBar(
          title: Text("SecondRoute"),
        ),
        body: Center(
          child: FlatButton(
            onPressed: () => Navigator.pushNamed(context, "/b"),
            child: Text("this is route second, title = " + title.toString()),
          ),
        ),
      ),
      onWillPop: () {
        Navigator.pop(context, 'result');
      },
    );
  }
}

然后在第一个界面取出返回值并显示

class MainRoutePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text("MainRoute"),
      ),
      body: Builder(
        builder: (context) {
          return Center(
            child: FlatButton(
                onPressed: () {
//                  Navigator.pushNamed(context, "/a?custom title")
//                      .then((result) {
//                    if (result != null) {
//                      Scaffold.of(context)
//                          .showSnackBar(SnackBar(content: Text("$result")));
//                    }
//                  });

                  Navigator.push(
                    context,
                    new MaterialPageRoute(builder: (context) {
                      return new SecondRoutePage("custom title");
                    }),
                  ).then((result) {
                    if (result != null) {
                      Scaffold.of(context)
                          .showSnackBar(SnackBar(content: Text("$result")));
                    }
                  });
                },
                child: Text("this is route main")),
          );
        },
      ),
    );
  }
}

Scaffold/of问题

3、嵌套路由

一个App中可以有多个导航器,将一个导航器嵌套在另一个导航器下面可以创建一个内部的路由历史。例如:正常来说App有主页,有登录页面,从主页跳转到登录界面是一级导航,App主页可以有底部tab,每个tab也可以对应一个Navigator,这样就形成了嵌套路由。

具体代码为:


void main() => runApp(new MainRoute());

class MainRoute extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: MainNestedNavigator(),
    );
  }
}

class MainNestedNavigator extends StatefulWidget {
  @override
  State<StatefulWidget> createState() {
    return _MainNestedNavigatorState();
  }
}

class _MainNestedNavigatorState extends State<MainNestedNavigator> {
  int _currentIndex = 0;
  final List<Widget> _children = [_MainNavigator(), _MainFragment('profile')];

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: _children[_currentIndex],
      bottomNavigationBar: BottomNavigationBar(
          currentIndex: _currentIndex,
          onTap: (index) {
            setState(() {
              _currentIndex = index;
            });
          },
          items: [
            BottomNavigationBarItem(
              icon: Icon(Icons.home),
              title: Text('Home'),
            ),
            BottomNavigationBarItem(
              icon: Icon(Icons.person),
              title: Text('Profile'),
            )
          ]),
    );
  }
}

class _MainNavigator extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Navigator(
      initialRoute: 'home',
      onGenerateRoute: (RouteSettings settings) {
        WidgetBuilder builder;
        switch (settings.name) {
          case 'home':
            builder = (context) => new _MainFragment('home');
            break;
        }
        if (builder != null) {
          return new MaterialPageRoute(builder: builder, settings: settings);
        }
      },
    );
  }
}

class _MainFragment extends StatelessWidget {
  final String text;

  _MainFragment(this.text);

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(text),
      ),
      body: Center(
        child: RaisedButton(
          onPressed: () {
            Navigator.push(context, new MaterialPageRoute(builder: (context) {
              return new SecondRoutePage("second route");
            }));
          },
          child: Text(text),
        ),
      ),
    );
  }
}

上面的代码得到的结果为:

首页分两个tab,‘home’和’profile’,在’home’中点击按钮跳转第二个页面,这个页面展示的时候下面的tab还是展示的,并且还是处于’home’状态下(此时该页面属于二级导航),但是在’profile’中点击按钮跳转第二个页面,此时这个页面展示的就是全屏页面了(此时该页面属于一级导航)。

可以看到,在不同等级的导航中跳转相同的界面,看到的效果也有相应的差别。

4、定制路由

通常,我们可能需要定制路由以实现自定义的过渡效果等。定制路由有两种方式

  • 继承路由子类PopupRoute,ModalRoute,PageRoute等。
  • 使用PageRouteBuilder类通过回调函数定义路由

由于定制路由一般涉及动画,所以这个我们将在下节Flutter动画中详细解析。

三、Flutter动画

1、概念

在Flutter中,动画主要涉及的角色有:Animation、Controller、Curve、Tween。

Animation

Animation是一个抽象类,它用于保存动画的插值和状态。Animation对象的输出值可以是线性的、曲线的、一个步进函数或者任何其他曲线函数。根据Animtaion对象的控制方式,动画可以反向运行,甚至可以在中间切换方向。可以通过Animation对象的value属性获取动画的当前值。

我们可以通过Animation来监听动画的帧和状态的变化:

  1. addListener()可以给Animation添加帧监听器,在每一帧都会被调用。帧监听器中最常见的行为是改变状态后调用setState()来触发UI构建。
  2. addStatusListener()可以给Animation添加动画状态改变监听器,动画开始、结束、正向、反向时会调用StatusListener。
AnimationController

AnimationController派生自Animation,用于控制动画,它包含动画的启动forward()、停止stop()、反向播放reverse()等方法。AnimationController会在动画的每一帧,生成一个新的值,生成新的数字后,每个Animation对象会调用其Listener对象回调,等动画状态改变时(如动画结束)会调用StatusListener监听器。默认情况下,AnimationController在给定的时间段内线性的生成从0.0到1.0(默认区间)的数字。例如:

  @override
  void initState() {
    super.initState();
    final AnimationController controller = AnimationController(
      duration: Duration(milliseconds: 2000),
      vsync: this,
      lowerBound: 10.0,
      upperBound: 20.0,
    );
  }

可以看到,当创建AnimationController时,需要一个vsync参数,它接收一个TickerProvider类型的对象,它的主要职责是创建Ticker,Ticker是用来驱动动画的,Flutter应用在启动时都会绑定一个SchedulerBinding,通过它可以给每一次屏幕刷新添加回调,而Ticker就是通过SchedulerBinding来添加屏幕刷新回调,这样一来,使用Ticker驱动动画就能防止屏幕外动画(动画的UI不在当前屏幕,比如锁屏)消耗不必要的资源,因为锁屏后屏幕停止刷新,就不会通知到绑定的SchedulerBinder,进而也不会触发Ticker。具体使用方式就是将SingleTickerProviderStateMixin添加到State的定义中,然后将State对象作为vsync值,这在后面的例子中可以见到。

Curve

Flutter中通过Curve(曲线)来描述动画过程,可以是匀速的、加速的、线性的或者非线性的。

CurvedAnimation将动画过程定义为一个非线性曲线

 final CurvedAnimation curvedAnimation =
        CurvedAnimation(parent: controller, curve: Curves.easeIn);

CurvedAnimation跟AnimationController一样也是派生自Animation,CurvedAnimation通过包装AnimationController和Curve生成一个新的动画对象。

Curves 类类定义了许多常用的曲线,也可以创建自己的,例如:

class ShakeCurve extends Curve {
  @override
  double transform(double t) {
    return math.sin(t * math.PI * 2);
  }
}
Tween

Tween的职责就是定义从输入范围到输出范围的映射,输入范围通常是0.0到1.0,当然也可以自定义需要的范围

 final Tween doubleTween = Tween<double>(
      begin: -200.0,
      end: 0.0,
    );

Tween继承自Animatable,而不是Animation,Animatable不是必须输出double值,例如,可以指定两种颜色之间的过渡:

 final Tween colorTween = ColorTween(
      begin: Colors.transparent,
      end: Colors.black54,
    );

Tween不存储任何状态,它提供了evaluate(Animation<double> animation)方法获取动画当前值。Animation对象的当前值可以通过value()方法取到。

要使用Tween对象,需要调动其animate()方法,然后传入一个Animation对象,可以使用上面生成的AnimationController或CurvedAnimation对象,如

Animation<int> alpha =
        IntTween(begin: 0, end: 255).animate(curvedAnimation);

2、动画基本结构

我们通过实现一个图片逐渐放大的示例来演示一下Flutter中动画的基本结构:

class ScaleAnimationRoute extends StatefulWidget {
  @override
  _ScaleAnimationRouteState createState() => new _ScaleAnimationRouteState();
}

//需要继承TickerProvider,如果有多个AnimationController,则应该使用TickerProviderStateMixin。
class _ScaleAnimationRouteState extends State<ScaleAnimationRoute>  with SingleTickerProviderStateMixin{ 

  Animation<double> animation;
  AnimationController controller;

  initState() {
    super.initState();
        controller = new AnimationController(
        duration: const Duration(seconds: 3), vsync: this);
    //使用弹性曲线
    animation=CurvedAnimation(parent: controller, curve: Curves.bounceIn);
    //图片宽高从0变到300
    animation = new Tween(begin: 0.0, end: 300.0).animate(animation)
      ..addListener(() {
        setState(() {
        });
      });
    //启动动画
    controller.forward();
  }

  @override
  Widget build(BuildContext context) {
    return new Center(
       child: Image.asset("images/avatar.png",
          width: animation.value,
          height: animation.value
      ),
    );
  }

  dispose() {
    //路由销毁时需要释放动画资源
    controller.dispose();
    super.dispose();
  }
}

上面的代码中addListener()函数调用了setState(),所以每次生成一个新的数字,会导致widget的build方法会再次调用。值得要注意的是动画完成时要释放控制器(调用dispose防止内存泄漏)

使用AnimatedWidget简化

上面示例通过addListener()函数调用了setState()来更新UI,这一步是通用,如果每次都这样去写是比较繁琐的。AnimatedWidget类封装调用了setState()细节,并允许我们将Widget分离出来,所以重构后的代码如下:

class AnimatedImage extends AnimatedWidget {
  AnimatedImage({Key key, Animation<double> animation})
      : super(key: key, listenable: animation);

  Widget build(BuildContext context) {
    final Animation<double> animation = listenable;
    return new Center(
      child: Image.asset("images/avatar.png",
          width: animation.value,
          height: animation.value
      ),
    );
  }
}


class ScaleAnimationRoute extends StatefulWidget {
  @override
  _ScaleAnimationRouteState createState() => new _ScaleAnimationRouteState();
}

class _ScaleAnimationRouteState extends State<ScaleAnimationRoute>
    with SingleTickerProviderStateMixin {

  Animation<double> animation;
  AnimationController controller;

  initState() {
    super.initState();
    controller = new AnimationController(
        duration: const Duration(seconds: 3), vsync: this);
    //图片宽高从0变到300
    animation = new Tween(begin: 0.0, end: 300.0).animate(controller);
    //启动动画
    controller.forward();
  }

  @override
  Widget build(BuildContext context) {
    return AnimatedImage(animation: animation,);
  }

  dispose() {
    //路由销毁时需要释放动画资源
    controller.dispose();
    super.dispose();
  }
}
使用AnimatedBuilder重构

用AnimatedWidget可以从动画中分离出widget,而动画的渲染过程(即设置宽高)仍然在AnimatedWidget中,假设如果我们再添加一个widget透明度变化的动画,那么我们需要再实现一个AnimatedWidget,这样不是很优雅,如果我们能把渲染过程也抽象出来,那就会好很多,而AnimatedBuilder正是将渲染逻辑分离出来, 上面的build方法中的代码可以改为:

@override
Widget build(BuildContext context) {
  //return AnimatedImage(animation: animation,);
    return AnimatedBuilder(
      animation: animation,
      child: Image.asset("images/avatar.png"),
      builder: (BuildContext ctx, Widget child) {
        return new Center(
          child: Container(
              height: animation.value, 
              width: animation.value, 
              child: child,
          ),
        );
      },
    );
}

最终这种写法会有三个好处:

  1. 不用显式的去添加帧监听器,然后再调用setState() 了,这个好处和AnimatedWidget是一样的。

  2. 动画构建的范围缩小了,如果没有builder,setState()将会在父widget上下文调用,这将会导致父widget的build方法重新调用,而有了builder之后,只会导致动画widget的build重新调用,这在复杂布局下性能会提高。

  3. 通过AnimatedBuilder可以封装常见的过渡效果来复用动画。下面我们通过封装一个GrowTransition来说明,它可以对子widget实现放大动画:

class GrowTransition extends StatelessWidget {
  GrowTransition({this.child, this.animation});

  final Widget child;
  final Animation<double> animation;

  Widget build(BuildContext context) {
    return new Center(
      child: new AnimatedBuilder(
          animation: animation,
          child: child,
          builder: (BuildContext context, Widget child) {
            return new Container(
                height: animation.value, 
                width: animation.value, 
                child: child
            );
          },
      ),
    );
  }
}

这样,最初的示例就可以改为:

...
Widget build(BuildContext context) {
    return GrowTransition(
    child: Image.asset("images/avatar.png"), 
    animation: animation,
    );
}

Flutter中正是通过这种方式封装了很多动画,如:FadeTransition、ScaleTransition、SizeTransition、FractionalTranslation等,很多时候都可以复用这些预置的过渡类。

动画状态监听

上面说过,我们可以通过Animation的addStatusListener()方法来添加动画状态改变监听器。Flutter中,有四种动画状态,在AnimationStatus枚举类中定义,下面我们逐个说明:

枚举值含义
dismissed动画在起始点停止
forward动画正在正向执行
reverse动画正在反向执行
completed动画在终点停止

所以我们可以做如下操作,让其循环放大缩小

animation.addStatusListener((status) {
      if (status == AnimationStatus.completed) {
        //动画执行结束时反向执行动画
        controller.reverse();
      } else if (status == AnimationStatus.dismissed) {
        //动画恢复到初始状态时执行动画(正向)
        controller.forward();
      }
    });

3、自定义路由切换动画

上面我们讲到定制路由有两种方式,下面我们使用PageRouteBuilder实现渐隐渐入动画实现路由过渡:

  Navigator.push(
                context,
                PageRouteBuilder(
                  transitionDuration: Duration(milliseconds: 500),
                  //动画时间为500毫秒
                  pageBuilder: (BuildContext context, Animation animation,
                      Animation secondaryAnimation) {
                    return SecondRoutePage("second route");
                  },
                  transitionsBuilder: (BuildContext context,
                      Animation animation,
                      Animation secondaryAnimation,
                      Widget child) {
                    //使用渐隐渐入过渡,
                    return FadeTransition(
                      opacity: animation,
                      child: child,
                    );
                  },
                ));

我们可以看到PageRouteBuilder通过指定pageBuilder和transitionsBuilder来达到自定义路由动画的效果。

无论是MaterialPageRoute、CupertinoPageRoute,还是PageRouteBuilder,它们都继承自PageRoute类,而PageRouteBuilder其实只是PageRoute的一个包装,我们可以直接继承PageRoute类来实现自定义路由,上面的例子可以通过如下方式实现:

class FadeRoute extends PageRoute {
  FadeRoute({
    @required this.builder,
    this.transitionDuration = const Duration(milliseconds: 300),
    this.opaque = true,
    this.barrierDismissible = false,
    this.barrierColor,
    this.barrierLabel,
    this.maintainState = true,
  });

  final WidgetBuilder builder;

  @override
  final Duration transitionDuration;

  @override
  final bool opaque;

  @override
  final bool barrierDismissible;

  @override
  final Color barrierColor;

  @override
  final String barrierLabel;

  @override
  final bool maintainState;

  @override
  Widget buildPage(BuildContext context, Animation<double> animation,
      Animation<double> secondaryAnimation) => builder(context);

  @override
  Widget buildTransitions(BuildContext context, Animation<double> animation,
      Animation<double> secondaryAnimation, Widget child) {
     return FadeTransition( 
       opacity: animation,
       child: builder(context),
     );
  }
}

使用:

Navigator.push(context, FadeRoute(builder: (context) {
  return PageB();
}));

虽然上面的两种方法都可以实现自定义切换动画,但实际使用时应考虑优先使用PageRouteBuilder,这样无需定义一个新的路由类,使用起来会比较方便。但是有些时候PageRouteBuilder是不能满足需求的,例如在应用过渡动画时我们需要读取当前路由的一些属性,这时就只能通过继承PageRoute的方式了,举个例子,假如我们只想在打开新路由时应用动画,而在返回时不使用动画,那么我们在构建过渡动画时就必须判断当前路由isActive属性是否为true,代码如下:

@override
Widget buildTransitions(BuildContext context, Animation<double> animation,
    Animation<double> secondaryAnimation, Widget child) {
 //当前路由被激活,是打开新路由
 if(isActive) {
   return FadeTransition(
     opacity: animation,
     child: builder(context),
   );
 }else{
   //是返回,则不应用过渡动画
   return Padding(padding: EdgeInsets.zero);
 }
}

4、Hero动画(共享元素动画)

Hero指的是可以在路由(页面)之间“飞行”的widget,简单来说Hero动画就是在路由切换时,有一个共享的Widget可以在新旧路由间切换,由于共享的Widget在新旧路由页面上的位置、外观可能有所差异,所以在路由切换时会逐渐过渡,这样就会产生一个Hero动画。

示例

假设有两个路由A和B,他们的内容交互如下:

A:包含一个用户头像,圆形,点击后跳到B路由,可以查看大图。

B:显示用户头像原图,矩形;

在AB两个路由之间跳转的时候,用户头像会逐渐过渡到目标路由页的头像上,接下来我们先看看代码,然后再解析:

// 路由A
class HeroAnimationRoute extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Container(
      alignment: Alignment.topCenter,
      child: InkWell(
        child: Hero(
          tag: "avatar", //唯一标记,前后两个路由页Hero的tag必须相同
          child: ClipOval(
            child: Image.asset("images/avatar.png",
              width: 50.0,
            ),
          ),
        ),
        onTap: () {
          //打开B路由  
          Navigator.push(context, PageRouteBuilder(
              pageBuilder: (BuildContext context, Animation animation,
                  Animation secondaryAnimation) {
                return new FadeTransition(
                  opacity: animation,
                  child: PageScaffold(
                    title: "原图",
                    body: HeroAnimationRouteB(),
                  ),
                );
              })
          );
        },
      ),
    );
  }
}
// 路由B
class HeroAnimationRouteB extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Center(
      child: Hero(
          tag: "avatar", //唯一标记,前后两个路由页Hero的tag必须相同
          child: Image.asset("images/avatar.png")),
    );
  }
}

我们可以看到,实现Hero动画只需要用Hero Widget将要共享的Widget包装起来,并提供一个相同的tag即可,中间的过渡帧都是Flutter Framework自动完成的。必须要注意, 前后路由页的共享Hero的tag必须是相同的,Flutter Framework内部正式通过tag来对应新旧路由页Widget的对应关系的。

Hero动画的原理比较简单,Flutter Framework知道新旧路由页中共享元素的位置和大小,所以根据这两个端点,在动画执行过程中求出过渡时的插值即可,幸运的是,这些事情Flutter已经帮我们做了。

5、交错动画(组合动画)

有些时候我们可能会需要一些复杂的动画,这些动画可能由一个动画序列或重叠的动画组成。这时我们就需要使用交错动画(Stagger Animation),交错动画需要注意以下几点:

  1. 要创建交错动画,需要使用多个动画对象
  2. 一个AnimationController控制所有动画
  3. 给每一个动画对象指定间隔(Interval

所有动画都由同一个AnimationController驱动,无论动画实时持续多长时间,控制器的值必须介于0.0和1.0之间,而每个动画的间隔(Interval)介于0.0和1.0之间。对于在间隔中设置动画的每个属性,请创建一个Tween。 Tween指定该属性的开始值和结束值。也就是说0.0到1.0代表整个动画过程,我们可以给不同动画指定起始点和终止点来决定它们的开始时间和终止时间。

示例

下面我们看一个例子,实现一个柱状图增长的动画:

  1. 开始时高度从0增长到300像素,同时颜色由绿色渐变为红色;这个过程占据整个动画时间的60%。
  2. 高度增长到300后,开始沿X轴向右平移100像素;这个过程占用整个动画时间的40%。
    我们将执行动画的Widget分离出来:
class StaggerAnimation extends StatelessWidget {
  StaggerAnimation({ Key key, this.controller }): super(key: key){
    //高度动画
    height = Tween<double>(
      begin:.0 ,
      end: 300.0,
    ).animate(
      CurvedAnimation(
        parent: controller,
        curve: Interval(
          0.0, 0.6, //间隔,前60%的动画时间
          curve: Curves.ease,
        ),
      ),
    );

    color = ColorTween(
      begin:Colors.green ,
      end:Colors.red,
    ).animate(
      CurvedAnimation(
        parent: controller,
        curve: Interval(
          0.0, 0.6,//间隔,前60%的动画时间
          curve: Curves.ease,
        ),
      ),
    );

    padding = Tween<EdgeInsets>(
      begin:EdgeInsets.only(left: .0),
      end:EdgeInsets.only(left: 100.0),
    ).animate(
      CurvedAnimation(
        parent: controller,
        curve: Interval(
          0.6, 1.0, //间隔,后40%的动画时间
          curve: Curves.ease,
        ),
      ),
    );
  }


  final Animation<double> controller;
  Animation<double> height;
  Animation<EdgeInsets> padding;
  Animation<Color> color;

  Widget _buildAnimation(BuildContext context, Widget child) {
    return Container(
      alignment: Alignment.bottomCenter,
      padding:padding.value ,
      child: Container(
        color: color.value,
        width: 50.0,
        height: height.value,
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    return AnimatedBuilder(
      builder: _buildAnimation,
      animation: controller,
    );
  }
}

StaggerAnimation中定义了三个动画,分别是对Container的height、color、padding属性设置的动画,然后通过Interval来为每个动画指定在整个动画过程的起始点和终点。

下面我们来实现启动动画的路由:

class StaggerDemo extends StatefulWidget {
  @override
  _StaggerDemoState createState() => _StaggerDemoState();
}

class _StaggerDemoState extends State<StaggerDemo> with TickerProviderStateMixin {
  AnimationController _controller;

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

    _controller = AnimationController(
        duration: const Duration(milliseconds: 2000),
        vsync: this
    );
  }


  Future<Null> _playAnimation() async {
    try {
      //先正向执行动画
      await _controller.forward().orCancel;
      //再反向执行动画
      await _controller.reverse().orCancel;
    } on TickerCanceled {
      // the animation got canceled, probably because we were disposed
    }
  }

  @override
  Widget build(BuildContext context) {
    return  GestureDetector(
      behavior: HitTestBehavior.opaque,
      onTap: () {
        _playAnimation();
      },
      child: Center(
        child: Container(
          width: 300.0,
          height: 300.0,
          decoration: BoxDecoration(
            color: Colors.black.withOpacity(0.1),
            border: Border.all(
              color:  Colors.black.withOpacity(0.5),
            ),
          ),
          //调用我们定义的交错动画Widget
          child: StaggerAnimation(
              controller: _controller
          ),
        ),
      ),
    );
  }
}

四、Flutter与原生通信

Flutter与原生通信的桥梁是平台通道,它也是Flutter插件的底层基础设施。

使用平台通道在Flutter与原生进行通信,如下图所示:

在这里插入图片描述

  • 应用的Flutter部分通过平台通道将消息发送到应用程序所在的宿主(iOS或Android应用)。
  • 宿主监听平台通道,并接收该消息。然后它会调用该平台的API,并将相应发送回Flutter部分,然后完成通信。

需要注意的是:消息传递是异步的。

下面通过Flutter获取电池电量来举例说明通信:

首先在Android端(MainActivity中):

public class MainActivity extends FlutterActivity {
    public static FlutterActivity mContext;
    private BatteryPlugin mBatteryPlugin;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        mContext = this;
        GeneratedPluginRegistrant.registerWith(this);
        mBatteryPlugin = new BatteryPlugin();
        mBatteryPlugin.registerWith(registrarFor(BatteryPlugin.class.getCanonicalName()));
    }    
}

public class BatteryPlugin implements MethodChannel.MethodCallHandler, MethodChannel.Result {
    private static final String CHANNEL_BATTERY = "samples.flutter.io/battery";
    private MethodChannel mMethodChannel;

    public void registerWith(PluginRegistry.Registrar registrar) {
        mMethodChannel = new MethodChannel(registrar.messenger(), CHANNEL_BATTERY);
        mMethodChannel.setMethodCallHandler(this);
    }

    @Override
    public void onMethodCall(MethodCall methodCall, MethodChannel.Result result) {
        if (methodCall.method.equals("getBatteryLevel")) {
            int batteryLevel = getBatteryLevel();

            if (batteryLevel != -1) {
                result.success(batteryLevel);
            } else {
                result.error("UNAVAILABLE", "Battery level not available.", null);
            }
        } else {
            result.notImplemented();
        }
    }

    private int getBatteryLevel() {
        int batteryLevel = -1;
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
            BatteryManager batteryManager = (BatteryManager) MainActivity.mContext.getSystemService(BATTERY_SERVICE);
            batteryLevel = batteryManager.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY);
        } else {
            Intent intent = new ContextWrapper(MainActivity.mContext.getApplicationContext()).
                    registerReceiver(null, new IntentFilter(Intent.ACTION_BATTERY_CHANGED));
            batteryLevel = (intent.getIntExtra(BatteryManager.EXTRA_LEVEL, -1) * 100) /
                    intent.getIntExtra(BatteryManager.EXTRA_SCALE, -1);
        }

        return batteryLevel;
    }
}

然后在Flutter代码中调用:

class BatteryLevelPage extends StatefulWidget {
  @override
  State<StatefulWidget> createState() {
    return _BatteryLevelPageState();
  }
}

class _BatteryLevelPageState extends State<BatteryLevelPage> {
  static const platform = const MethodChannel('samples.flutter.io/battery');
  String _batteryLevel = 'Unknow battery level.';

  Future<Null> _getBatteryLevel() async {
    String batteryLevel;
    try {
      final int result = await platform.invokeMethod('getBatteryLevel');
      batteryLevel = 'Battery level at $result % .';
    } on PlatformException catch (e) {
      batteryLevel = "Failed to get battery level: '${e.message}'.";
    }

    setState(() {
      _batteryLevel = batteryLevel;
    });
  }

  @override
  Widget build(BuildContext context) {
    return new Material(
      child: new Center(
        child: new Column(
          mainAxisAlignment: MainAxisAlignment.spaceEvenly,
          children: [
            new RaisedButton(
              child: new Text('Get Battery Level'),
              onPressed: _getBatteryLevel,
            ),
            new Text(_batteryLevel),
          ],
        ),
      ),
    );
  }
}

需要注意的是,MethodChannel构造方法传参的时候Flutter部分和原生部分必须保持一致。

另外通过MethodChannel,原生也能调用Flutter方法,这是一个双向通道。

 
  @override
  void initState() {
    super.initState();
    platform.setMethodCallHandler(_platformCallHandler);
  }
  
  Future<dynamic> _platformCallHandler(MethodCall methodCall) async {
    switch (methodCall.method) {
      case 'getName':
        return "star";
    }
  }

//MainActivity
@Override
    protected void onRestart() {
        super.onRestart();
        mBatteryPlugin.invokeMethod("getName", null);
    }

//BatteryPlugin
public void invokeMethod(String methodName, Object arguments) {
        mMethodChannel.invokeMethod(methodName, arguments, this);
    }
    
 @Override
    public void success(Object o) {
        Toast.makeText(MainActivity.mContext, "success getName = " + o.toString(), Toast.LENGTH_SHORT).show();
    }

    @Override
    public void error(String s, String s1, Object o) {
        Toast.makeText(MainActivity.mContext, "error " + s, Toast.LENGTH_SHORT).show();
    }

    @Override
    public void notImplemented() {

    }

上面我们是在Flutter中调用原生获取电池电量,我们知道,手机的电池状态是不停变化的,我们如果想一直监听电量的变化怎么办呢?

当然我们能想到的就是Flutter告诉原生我想监听电量变化,然后原生端开始监听电量变化,当发生变化的时候主动跟Flutter通信通知Flutter电池电量有变化, 最后在Flutter不需要监听电量变化的时候再告诉原生不需要监听了,然后原生做资源的释放操作等。

其实官方提供了一种方式,就是使用EventChannel,它跟MethodChannel使用很类似,只不过是回调不同。

示例如下:

//BatteryPlugin

private EventChannel mEventChannel;
private BroadcastReceiver mBroadcastReceiver;

public void registerWith(PluginRegistry.Registrar registrar) {
        mMethodChannel = new MethodChannel(registrar.messenger(), CHANNEL_BATTERY);
        mMethodChannel.setMethodCallHandler(this);

        mEventChannel = new EventChannel(registrar.messenger(), CHANNEL_BATTERY_CHANGING);
        mEventChannel.setStreamHandler(this);
    }


 @Override
    public void onListen(Object o, final EventChannel.EventSink eventSink) {
        Toast.makeText(MainActivity.mContext, "onListen", Toast.LENGTH_SHORT).show();
        if (mBroadcastReceiver == null) {
            mBroadcastReceiver = new BroadcastReceiver() {
                @Override
                public void onReceive(Context context, Intent intent) {
                    int status = intent.getIntExtra(BatteryManager.EXTRA_STATUS, -1);

                    if (status == BatteryManager.BATTERY_STATUS_UNKNOWN) {
                        eventSink.error("UNAVAILABLE", "Charging status unavailable", null);
                    } else {
                        boolean isCharging = status == BatteryManager.BATTERY_STATUS_CHARGING ||
                                status == BatteryManager.BATTERY_STATUS_FULL;
                        // 把电池状态发给Flutter
                        int batteryLevel = getBatteryLevel();

                        if (batteryLevel != -1) {
                            eventSink.success(batteryLevel);
                        } else {
                            eventSink.error("UNAVAILABLE", "Battery level not available.", null);
                        }
                    }

                }
            };
        }
        MainActivity.mContext.registerReceiver(mBroadcastReceiver, new IntentFilter(Intent.ACTION_BATTERY_CHANGED));
    }

    @Override
    public void onCancel(Object o) {
        Toast.makeText(MainActivity.mContext, "onCancel", Toast.LENGTH_SHORT).show();
        MainActivity.mContext.unregisterReceiver(mBroadcastReceiver);
    }

Flutter端部分代码


StreamSubscription _streamSubscription;

@override
  Widget build(BuildContext context) {
    return Material(
      child: WillPopScope(
        child: new Center(
          child: new Column(
            mainAxisAlignment: MainAxisAlignment.spaceEvenly,
            children: [
              new RaisedButton(
                child: new Text('Get Battery Level'),
                onPressed: _getBatteryLevel,
              ),
              new RaisedButton(
                onPressed: _startOrCancelListen,
                child: Text(_streamSubscription != null
                    ? 'cancel listen battery'
                    : 'start listen battery'),
              ),
              new Text(_batteryLevel),
            ],
          ),
        ),
        onWillPop: () {
          _cancelListen();
          Navigator.pop(context);
          return new Future.value(false);
        },
      ),
    );
  }

  void _startOrCancelListen() {
    setState(() {
      if (_streamSubscription != null) {
        _streamSubscription.cancel();
        _streamSubscription = null;
      } else {
        _streamSubscription = eventChannel.receiveBroadcastStream().listen(
              _onEventData,
              onError: _onEventError,
            );
      }
    });
  }

  void _cancelListen() {
    if (_streamSubscription != null) {
      _streamSubscription.cancel();
      _streamSubscription = null;
    }
  }
    
    void _onEventData(Object data) {
    setState(() {
      _batteryLevel = 'Battery level change to $data % .';
    });
  }

  void _onEventError(Object error) {
    setState(() {
      _batteryLevel = 'Battery status: unknown.';
    });
  }

五、Flutter Packages

1、概念

使用package可以创建共享的模块化代码,可见简单的理解为第三方库。一个package至少要包括:

  1. pubspec.yaml文件:生命了package的名称、版本、作者等元数据文件
  2. 一个lib文件夹:包含dart代码,最少应该有一个<package_name>.dart文件

Flutter packages分为两种:

  1. Dart包:使用Dart语言编写的一些API等。
  2. 插件包:一种专用的dart包,其中包含Dart语言编写的API,以及针对Android(使用Java或Kotlin)和针对iOS(使用OC或Swift)平台的特定实现代码,也就是说插件包包含Dart代码和原生代码。

普通package和插件package的区别就在于:普通package不包含android和ios的包,只包含lib目录和其他相关文件,而插件包包含android和ios原生代码,并且在pubspec.yaml配置了插件类。

2、创建package

创建package有两种方式:

  1. 使用Android studio : File -> New -> new Flutter Project 然后选择创建Flutter plugin 或 Flutter package
  2. 使用命令行创建 flutter create --org com.example --template=package/plugin -i swift -a kotlin hello,具体参数含义可以通过flutter create -h查看含义。

如果不加 --template,那么默认创建的是一个Flutter工程,或者module,其实工程跟module是一样的,只不过是我们把一个工程内部的工程叫做了module

3、开发package

对于纯Dart包,只需要主lib/<package_name>.dart文件内或者lib目录中的文件添加新功能即可。其中test目录的代码用于单元测试。

对于插件包,就正常的开发原生代码,实现方式在上节Flutter与原生交互已经讲解过,只不过区别在于,主工程如果写原生代码交互的话是需要自己注册插件的,但如果是插件包的话,需要在pubspec.yaml中配置插件类就可以

flutter:

  # This section identifies this Flutter project as a plugin project.
  # The androidPackage and pluginClass identifiers should not ordinarily
  # be modified. They are used by the tooling to maintain consistency when
  # adding or updating assets for this project.
  
  plugin:
    androidPackage: com.example.flutterappplugin
    pluginClass: FlutterAppPlugin

那么到时候该插件被主工程依赖的话,那么主工程的GeneratedPluginRegistrant就会自动注册插件:

/**
 * Generated file. Do not edit.
 */
public final class GeneratedPluginRegistrant {
  public static void registerWith(PluginRegistry registry) {
    if (alreadyRegisteredWith(registry)) {
      return;
    }
    
    //这个就是自动生成的注册插件包的插件
    FlutterAppPlugin.registerWith(registry.registrarFor("com.example.flutterappplugin.FlutterAppPlugin"));
  }

  private static boolean alreadyRegisteredWith(PluginRegistry registry) {
    final String key = GeneratedPluginRegistrant.class.getCanonicalName();
    if (registry.hasPlugin(key)) {
      return true;
    }
    registry.registrarFor(key);
    return false;
  }
}

4、发布package

一旦你实现了一个包,你可以在Pub上发布它 ,这样其他开发人员就可以轻松使用它

在发布之前,检查pubspec.yaml、README.md以及CHANGELOG.md文件,以确保其内容的完整性和正确性。

然后, 运行 dry-run 命令以查看是否都准备OK了:

$ flutter packages pub publish --dry-run

最后, 运行发布命令:

$ flutter packages pub publish

有关发布的详细信息,请参阅Pub publishing docs

5、依赖package

  • 本地package:
dependencies:

  flutter_app_package:
    path: ./flutter_app_package

  • 远程package:
dependencies:

 	json_annotation: ^2.0.0
处理包的相互依赖
  • 一个插件包的原生代码如果想要另外一个插件包中的原生代码
android {
    dependencies {
        provided rootProject.findProject(":url_launcher")
    }
}
  • 假设在flutter_app_first中依赖了两个package,但是这两个包都依赖url_launcher,但是依赖的url_launcher是不同的版本,那么就存在潜在的冲突。避免这种情况最好方法是在指定依赖关系时,程序包作者使用版本范围而不是特定版本。
dependencies:
  url_launcher: ^0.4.2     //等价于 '>=0.4.2 <1.0.0'
  image_picker: '0.1.1'   # Not so good, only 0.1.1 will do.

如果some_package声明了上面的依赖关系,other_package声明了url_launcher版本像’0.4.5’或’^0.4.0’,pub将能够自动解决问题

为什么在一个范围内,pub能够自动解决问题呢,因为Dart社区使用语义化版本,(类似于一套版本控制的规范,要求开发者上传package按照这个规则来)它帮助你知道哪些版本能工作,比如你依赖的package1.2.3能工作,那么它保证至少在2.0.0以下是能工作的。

即使some_package和other_package声明了不兼容的url_launcher版本,它仍然可能会和url_launcher以兼容的方式正常工作。 你可以通过向flutter_app_first包的pubspec.yaml文件中添加依赖性覆盖声明来处理冲突,从而强制使用特定版本:

dependencies:
  some_package:
  other_package:
dependency_overrides:
  url_launcher: '0.4.3'

如果两个package使用的url_launcher实在不兼容,比如url_launcher在5.0.0的时候进行了大改版,some_package使用的是^4.2.0, other_package使用的是^5.1.0,而这两个版本的代码已经完全不兼容,那么只能让some_package去升级版本了,然后改代码了,这跟Android中Glide的4.0升级是一个道理。

如果冲突的依赖不是一个包,而是一个特定于Android的库,比如guava,那么必须将依赖重写声明添加到Gradle构建逻辑中。

configurations.all {
    resolutionStrategy {
        force 'com.google.guava:guava:23.0-android'
    }
}
  • 1
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值