子木

子木 查看完整档案

深圳编辑华中科技大学文华学院  |  计算机 编辑智人科技  |  前端工程师 编辑 lishaoy.net 编辑
编辑

爱好摄影的程序猿

个人动态

子木 发布了文章 · 2月24日

Flutter 使用 Riverpod+Retrofit 构建MVVM开发模式

最近,在使用 Flutter 做一个图片分享的应用,自己创建出一套 Flutter 版的 MVVM 开发模式,觉得还挺好用,所以在此分享出来。

应用功能展示

首先,我们来看看我们这套MVVM开发模式,开发出来的应用是个什么样子,大概的一部分功能如下:(也可以点击观看 演示视频)

下拉刷新,如图:

refresh

上拉加载更多,如图:

load more

点赞,如图:

liked

缺省页(空数据),如图:

empty

loading页,如图:

loading

渐变的Appbar,如图:

appbar

评论,如图:

comment

我的页面,如图:

profile

以上只是 App 的一部分功能,大家也可以也可以点击观看 演示视频,或者扫描二维码下载 App(android) 体验:

apk

在介绍这套 MVVM 开发模式之前,我们首先需要了解 riverpodretrofit 是什么。

下面我们来分别了解他们是什么。

riverpod

riverpodFlutter 状态管理库,flutter 的状态管理库有很多,例如: ReduxBlocProvider 等,flutter 官方推荐我们使用 provider,一般我们使用 provider 的时候,会结合 ChangeNotifierStateNotifierfreezed 去使用,而 riverpodprovider 的一个升级加强版,解决了 provider 一些疑难杂症,在这里就不过多介绍,如想了解更多 riverpod 信息,可以访问 riverpod官网 ,也可以参考我之前写的以下Demo

retrofit

retrofit 是一个网络请求库,做过 android 的同学应该比较熟悉,可以用注解的方式生成请求 Rest Api 的各种方法,如,以下的简单的用法:

import 'package:retrofit/retrofit.dart';

part 'api_client.g.dart';

@RestApi(baseUrl: 'https://api.lishaoy.net')
abstract class ApiClient {
  factory ApiClient({Dio dio, String baseUrl}) {
    dio ??= BaseDio.getInstance().getDio();
    return _ApiClient(dio, baseUrl: baseUrl);
  }

  /**
   * 获取首页推荐文章
   */
  @GET('/posts')
  Future<PostModel> getPosts(
      @Query('pageIndex') String pageIndex, @Query('pageSize') String pageSize,
      {@Query('sort') String sort = 'recommend'});

  /**
   * 获取文章详情
   */
  @GET('/posts/{postId}')
  Future<SinglePostModel> getPostsById(@Path('postId') int postId,
      {@Query('notView') bool notView});

  /**
   * 登录
   */
  @POST('/login')
  Future<LoginModel> login(@Body() Login login);

}

更多详情可以访问 pub.dev retrofit

目录结构

接下来我们来看看项目的目录结构,如下:

.
├── android  ## 原生android目录
│   ├── app
│   └── gradle
├── assets  ## 资源文件目录
│   ├── fonts
│   ├── images
│   └── json
├── ios ## 原生iOS目录
│   ├── Flutter
│   ├── Frameworks
│   ├── Pods
│   ├── Runner
│   ├── Runner.xcodeproj
│   └── Runner.xcworkspace
└── lib ## 项目文件目录
    ├── http ##对网格请求相关的封装
    │   ├── api_client.dart ## rest api 请求类
    │   ├── api_client.g.dart ## retrofit 自动生成的类
    │   ├── base_dio.dart ## 对dio封装类
    │   ├── base_error.dart ## 服务端基本错误类型封装类
    │   └── header_interceptor.dart  ##网络请求拦截器
    ├── models ## json序列化的model类,相对于MVVM的 M 层
    ├── pages ## 主要的UI页面目录,相对于MVVM的 V 层
    ├── utils ## 一些工具类
    │   ├── date_util.dart
    │   ├── screen_util.dart
    │   ├── status_bar_util.dart
    │   ├── timeline_util.dart
    │   └── widget_util.dart
    ├── view_model ## 处理数据状态,业务逻辑,相对于 MVVM的 VM 层
    │   ├── details_view_model.dart
    │   ├── login_view_model.dart
    │   ├── posts_view_model.dart
    │   └── profile_view_model.dart
    └── widgets ##公用或自定义组件
        ├── cache_image.dart
        ├── custom_circular_rect_angle.dart
        ├── custom_indicator.dart
        ├── custom_tabs.dart
        ├── error_page.dart
        ├── gradient_button.dart
        ├── icon_animation_widget.dart
        ├── iconfont.dart
        ├── image_paper.dart
        ├── over_scroll_behavior.dart
        ├── page_state.dart
        ├── per_flexible_space_bar.dart
        ├── pic_swiper.dart
        └── refresh.dart

从目录结构可知, modelspagesview_model 分别是 MVVM 开发模式的 M(数据层)、 V(视图层)、 VM(通过riverpod的StateNotifier将数据层和视图层绑定,state变化时数据层也跟着变化,当然这里也可以处理一些页面逻辑),做过 android 的同学应该知道 android 的MVVM是使用 jetpack 组件库里的 DataBinding 和 LiveData 完成的,我这套开发模式灵感就是来源于此。

网络请求模块

首先,我们来对网络请求模块封装一把,让它能够通用易用。

retrofit 是依赖网络请求库的,我们可以选择不同的库,例如:httpDio 等。

在这里我们选择 Dio ,如下,是官方提供的案例代码:

@RestApi(baseUrl: "https://5d42a6e2bc64f90014a56ca0.mockapi.io/api/v1/")
abstract class RestClient {
  factory RestClient(Dio dio, {String baseUrl}) = _RestClient;

  @GET("/tasks")
  Future<List<Task>> getTasks();
}

Dio的封装

它需要传一个 Dio 的实例和一个可选的 baseUrl,我们需要对这里重新封装一下,使用者不用传递任何参数就可以使用,也可以选择使用不同的网络库和 baseUrl;所以,我们要封装一个 baseDio 单例类,如果用户没有传,我们就传递一个默认的 baseDio 类,代码大概如下所示:

@RestApi(baseUrl: 'https://api.lishaoy.net')
abstract class ApiClient {
  factory ApiClient({Dio dio, String baseUrl}) {
    dio ??= BaseDio.getInstance().getDio();
    return _ApiClient(dio, baseUrl: baseUrl);

  @POST('/login')
  Future<LoginModel> login(@Body() Login login);
}  

所以我要对 Dio 进行一次封装,代码如下:

import 'package:dio/dio.dart';
import 'package:pretty_dio_logger/pretty_dio_logger.dart';
import 'package:pro_flutter/http/base_error.dart';
import 'package:pro_flutter/http/header_interceptor.dart';

class BaseDio {
  BaseDio._(); // 把构造方法私有化

  static BaseDio _instance; 

  static BaseDio getInstance() {  // 通过 getInstance 获取实例
    _instance ??= BaseDio._();

    return _instance;
  }

  Dio getDio() {
    final Dio dio = Dio();
    dio.options = BaseOptions(receiveTimeout: 66000, connectTimeout: 66000); // 设置超时时间等 ...
    dio.interceptors.add(HeaderInterceptor()); // 添加拦截器,如 token之类,需要全局使用的参数
    dio.interceptors.add(PrettyDioLogger(  // 添加日志格式化工具类
      requestHeader: true,
      requestBody: true,
      responseBody: true,
      responseHeader: false,
      compact: false,
    ));

    return dio;
  }

  BaseError getDioError(Object obj) {  // 这里封装了一个 BaseError 类,会根据后端返回的code返回不同的错误类
    switch (obj.runtimeType) {
      case DioError:
        if ((obj as DioError).type == DioErrorType.RESPONSE) {
          final response = (obj as DioError).response;
          if (response.statusCode == 401) {
            return NeedLogin();
          } else if (response.statusCode == 403) {
            return NeedAuth();
          } else if (response.statusCode == 408) {
            return UserNotExist();
          } else if (response.statusCode == 409) {
            return PwdNotMatch();
          } else if (response.statusCode == 405) {
            return UserNameEmpty();
          } else if (response.statusCode == 406) {
            return PwdEmpty();
          } else {
            return OtherError(
              statusCode: response.statusCode,
              statusMessage: response.statusMessage,
            );
          }
        }
    }

    return OtherError();
  }
}

BaseError的封装

以上代码中的 BaseError 类是一个抽象类,我们可以实现这个抽象类,告诉UI不同的错误类型,UI只需要用实现类就可以访问错误码和错误消息,代码如下:

abstract class BaseError {
  final int code;
  final String message;

  BaseError({this.code, this.message});
}

class NeedLogin implements BaseError {
  @override
  int get code => 401;

  @override
  String get message => "请先登录";
}

class NeedAuth implements BaseError {
  @override
  int get code => 403;

  @override
  String get message => "非法访问,请使用正确的token";
}

class UserNotExist implements BaseError {
  @override
  int get code => 408;

  @override
  String get message => "用户不存在";
}

class UserNameEmpty implements BaseError {
  @override
  int get code => 405;

  @override
  String get message => "用户名不能为空";
}

class PwdNotMatch implements BaseError {
  @override
  int get code => 409;

  @override
  String get message => "用户密码不正确";
}

class PwdEmpty implements BaseError {
  @override
  int get code => 406;

  @override
  String get message => "用户密码不能为空";
}

class OtherError implements BaseError {

  final int statusCode;
  final String statusMessage;

  OtherError({this.statusCode, this.statusMessage});

  @override
  int get code => statusCode;

  @override
  String get message => statusMessage;

}

网络模块的使用

这样我们的一个网络请求模块基本就封装好了,使用起来非常简单,首先我们需要定义接口,代码如下:

@RestApi(baseUrl: 'https://api.lishaoy.net')
abstract class ApiClient {
  factory ApiClient({Dio dio, String baseUrl}) {
    dio ??= BaseDio.getInstance().getDio();
    return _ApiClient(dio, baseUrl: baseUrl);
  }

  /**
   * 获取首页推荐文章
   */
  @GET('/posts')
  Future<PostModel> getPosts(
      @Query('pageIndex') String pageIndex, @Query('pageSize') String pageSize,
      {@Query('sort') String sort = 'recommend'});

  /**
   * 获取文章详情
   */
  @GET('/posts/{postId}')
  Future<SinglePostModel> getPostsById(@Path('postId') int postId,
      {@Query('notView') bool notView});

  /**
   * 登录
   */
  @POST('/login')
  Future<LoginModel> login(@Body() Login login);

  /**
   * 点赞
   */
  @POST('/posts/{postId}/like')
  Future<BaseModel> like(@Path('postId') int postId);

  ...

然后,我们会在 view model 使用它,如下:

  /**
   * 点赞
   */
  Future<void> clickLike(int postId, int index) async {
    try {
      BaseModel data = await ApiClient().like(postId); // 使用非常简单一句代码即可
      if (data.message == 'success') {
        updatePostById(postId, index);
      }
    } catch (e) {
      state = state.copyWith(
          pageState: PageState.errorState,
          error: BaseDio.getInstance().getDioError(e));
    }
  }

View Model 模块

View Model 模块主要处理数据和状态的绑定、业务逻辑等。

创建状态类

我们首先需要创建一个状态类,来存放数据状态和页面状态等,如下:

/// 存储页面状态和数据状态(如,缺省页、错误页、加载中...)
class PostState {
  final List<Post> posts;
  final List<Category> categories;
  final int pageIndex;
  final PageState pageState; // 页面状态类
  final BaseError error; // 根据后端返回的错误的错误类

  PostState(
      {this.posts,
      this.categories,
      this.pageIndex,
      this.pageState,
      this.error});

  PostState.initial()
      : posts = [],
        categories = [],
        pageIndex = 1,
        pageState = PageState.initializedState,
        error = null;

  PostState copyWith({
    List<Post> posts,
    List<Category> categories,
    int pageIndex,
    PageState pageState,
    BaseError error,
  }) {
    return PostState(
      posts: posts ?? this.posts,
      categories: categories ?? this.categories,
      pageIndex: pageIndex ?? this.pageIndex,
      pageState: pageState ?? this.pageState,
      error: error ?? this.error,
    );
  }
}

当然这个状态类也可以用 freezed 自动生成。

请求网络数据和处理页面状态

我们会返回这个状态类给UI,riverpod 的 StateNotifier 会监听这个状态类里的所有成员变量,当我们更改这些数据之后,UI会自动刷新,代码如下:

/**
   * 获取文章列表
   */
  Future<void> getPosts(int categoryId, {bool isRefresh = false}) async {
    if (state.pageState == PageState.initializedState) {
      state = state.copyWith(pageState: PageState.busyState); // UI收到这个状态可以呈现loading页面
    }
    try {
      if (isRefresh) {  // 下拉刷新
        PostModel postModel;
        if(categoryId == -2) {
          state = state.copyWith(pageState: PageState.emptyDataState); // UI收到这个状态,可以显示缺省页空数据
          return;
        } else if (categoryId == -1) {
          postModel = await ApiClient().getPosts('1', '10'); // 请求网络接口
        } else {
          postModel =
              await ApiClient().getPostsByCategoryId('1', '10', categoryId);
        }
        if (postModel.data.posts.isEmpty && state.pageIndex == 1) {
          state = state.copyWith(pageState: PageState.emptyDataState);
        } else {
          initPostState();
          state = state.copyWith(
            posts: [...postModel.data.posts],  // 把数据发给UI
            pageState: PageState.refreshState, // 更改页面状态为刷新
            pageIndex: 2,
          );
        }
      } else {  // 下拉加载更多
        PostModel postModel;
        if(categoryId == -2) {
          state = state.copyWith(pageState: PageState.emptyDataState); // UI收到这个状态可以呈现loading页面
          return;
        } else if (categoryId == -1) {
          postModel =
              await ApiClient().getPosts(state.pageIndex.toString(), '10'); // 请求网络接口
        } else {
          postModel = await ApiClient().getPostsByCategoryId(
              state.pageIndex.toString(), '10', categoryId);
        }
        if (postModel.data.posts.isEmpty && state.pageIndex == 1) {
          state = state.copyWith(pageState: PageState.emptyDataState);
        } else {
          state = state.copyWith(
              posts: [...state.posts, ...postModel.data.posts],  // 把数据发给UI
              pageIndex: state.pageIndex + 1,
              pageState: PageState.dataFetchState); // 更改页面状态
          if (postModel.data.posts.isEmpty ||
              postModel.data.posts.length < 10) {
            state = state.copyWith(pageState: PageState.noMoreDataState);
          }
        }
      }
    } catch (e) {
      state = state.copyWith(
          pageState: PageState.errorState,  // 如果发生错误,更改页面状态
          error: BaseDio.getInstance().getDioError(e));
    }
  }

以上一个方面就完成了应用首页的所有列表数据请求和页面状态处理,在UI层,不需要写 setState() 和 请求数据的任何代码,UI层只是呈现UI。

View 模块

那么在UI层怎么处理这些状态呢?

这也非常简单,代码如下:

// 创建provider,返回viewModel
final postsProvider = StateNotifierProvider.family<PostsViewModel, int>(
    (ref, categoryId) => PostsViewModel(categoryId));

class PostsPageCategory extends ConsumerWidget {  // 继承 ConsumerWidget

  final int categoryId;
  final ScrollController scrollController;
  final RefreshController refreshController;

  PostsPageCategory(
      {this.categoryId, this.scrollController, this.refreshController});

  @override
  Widget build(BuildContext context, ScopedReader watch) { 
    final postsViewModel = watch(postsProvider(categoryId)); // 使用 watch 来监听Provider
    final postState = watch(postsProvider(categoryId).state); // 使用 watch 来监听Provider的状态
    return Refresh(
      controller: refreshController,
      onLoading: () async {  // 加载更多处理
        await postsViewModel.getPosts(categoryId);
        if (postState.pageState == PageState.noMoreDataState) {
          refreshController.loadNoData();
        } else {
          refreshController.loadComplete();
        }
      },
      onRefresh: () async { // 刷新处理
        await context
            .read(postsProvider(categoryId))
            .getPosts(categoryId, isRefresh: true);
        refreshController.refreshCompleted();
        refreshController.footerMode.value = LoadStatus.canLoading;
      },
      content: _createContent(postState, context),
    );
  }

  Widget _createContent(PostState postState, BuildContext context) {
    if (postState.pageState == PageState.busyState ||
        postState.pageState == PageState.initializedState) {  // loading 状态处理
      return Center(
        child: Lottie.asset(
          'assets/json/loading2.json',
          width: 126,
          fit: BoxFit.cover,
          alignment: Alignment.center,
        ),
      );
    }

    if (postState.pageState == PageState.emptyDataState) {
      return ErrorPage( // 错误处理
        isEmptyPage: true,
        icon: Lottie.asset(
          'assets/json/empty3.json',
          width: ScreenUtil.instance.width / 1.8,
          height: 220,
          fit: BoxFit.contain,
          alignment: Alignment.center,
        ),
        desc: '暂 无 数 据',
        buttonAction: () => context.refresh(postsProvider(categoryId)),
      );
    }

    if (postState.pageState == PageState.errorState) {
      return ErrorPage(
        title: postState.error is NeedLogin
            ? '😮 你竟然忘记登录 😮'
            : postState.error.code?.toString(),
        desc: postState.error.message,
        buttonAction: () async {
          if (postState.error is NeedLogin) {
            LoginState loginState = await Navigator.of(context).push(
                MaterialPageRoute(builder: (context) => FlareSignInDemo()));
            if (loginState.isLogin) {
              context.refresh(postsProvider(categoryId));
            }
          } else {
            context.refresh(postsProvider(categoryId));
          }
        },
        buttonText: postState.error is NeedLogin ? '登录' : null,
      );
    }
    return ListView.separated(  // 加载数据,现在页面
      shrinkWrap: true,
      separatorBuilder: (context, index) {
        return Padding(padding: EdgeInsets.only(top: 12));
      },
      padding: EdgeInsets.fromLTRB(12, 18, 12, 18),
      reverse: false,
      itemCount: postState.posts.length,
      controller: scrollController,
      itemBuilder: (BuildContext context, int index) {
        return PostsPageItem(
          post: postState.posts[index],
          index: index,
          categoryId: categoryId,
        );
      },
    );
  }
}

是不是非常简单,不需要写 setState() 和 请求数据的任何代码,代码结构也非常清晰。在上述APP应用里的首页以及分类页面列表数据及页面的loading和缺省页等都是这一个简单 PostsPageCategory 完成的。

其他相关

以上这套开发模式我给出了大概的思路和部分代码,大家也可以顺着这个思路试试;这套开发模式后续还会继续优化它。

应用功能相关

用过 Flutter TabBar 同学应该知道,它在字体放大时会卡顿,以及如何自定义指示器等, 如图:

TabBar

以及,渐变的高斯模糊背景和图片标题动画的实现等,如图:

profile

及更多这个应用的功能实现和细节并没有在这里讲述,这篇文章主要介绍 MVVM,关于这个图片分享APP,只是我在业余时间对Flutter的研究探索和学习,这个应用大概只完成了一半,后续应该还好写关于这个APP的文章。

REST API接口相关

还有,这个APP的后端API也是我自己开发的,使用的是 nodejs 的 express + ts 开发的,如首页推荐接口及分类页接口数据都是通过这个API查询到的: 首页API接口

具体的实现是使用一条SQL语句查询得到,代码如下:

    SELECT 
    post.id, 
    post.content, 
    post.title,
    category.name as category,
    post.views,
    JSON_OBJECT(
      'id', user.id,
      'name', user.name,
      'avatar', CAST(
        IF(COUNT(avatar.id), 
          GROUP_CONCAT(
            DISTINCT JSON_OBJECT(
              'largeAvatarUrl', concat('http://localhost:3001/avatar/', user.id, '|@u003f|size=large'),
              'mediumAvatarUrl', concat('http://localhost:3001/avatar/', user.id, '|@u003f|size=medium'),
              'smallAvatarUrl', concat('http://localhost:3001/avatar/', user.id, '|@u003f|size=small')
            )
          ),
        NULL)
      AS JSON)
    ) as user,
    (
      SELECT COUNT(comment.id) FROM comment
      WHERE comment.postId = post.id
      GROUP BY comment.postId
      ) as totalComments,   
    CAST(
      IF(
        COUNT(cover.id),
            GROUP_CONCAT(
              DISTINCT JSON_OBJECT(
                'id', cover.id,
                'width', cover.width,
                'height', cover.height,
                'largeImageUrl', concat('http://localhost:3001/files/', cover.id, '/serve|@u003f|size=large'),
                'mediumImageUrl', concat('http://localhost:3001/files/', cover.id, '/serve|@u003f|size=medium'),
                'small', concat('http://localhost:3001/files/', cover.id, '/serve|@u003f|size=thumbnail')
              ) ORDER BY cover.id DESC
            ),
        NULL
      ) AS JSON
    ) AS coverImage,
    CAST(
      IF(
        COUNT(file.id),
        CONCAT(
          '[',
            GROUP_CONCAT(
              DISTINCT JSON_OBJECT(
                'id', file.id,
                'width', file.width,
                'height', file.height,
                'largeImageUrl', concat('http://localhost:3001/files/', file.id, '/serve|@u003f|size=large'),
                'mediumImageUrl', concat('http://localhost:3001/files/', file.id, '/serve|@u003f|size=medium'),
                'small', concat('http://localhost:3001/files/', file.id, '/serve|@u003f|size=thumbnail')
              ) ORDER BY file.id DESC
            ),
          ']'
        ),
        NULL
      ) AS JSON
    ) AS files,
    CAST(
      IF(
        COUNT(tag.id),
        CONCAT(
          '[', 
            GROUP_CONCAT(
              DISTINCT JSON_OBJECT(
                'id', tag.id,
                'name', tag.name
              )
            ),
          ']'
        ),
        NULL
      ) AS JSON
    ) AS tags,
    (
      SELECT COUNT(user_like_post.postId)
      FROM user_like_post
      WHERE user_like_post.postId = post.id
    ) AS totalLikes
  FROM post 
    LEFT JOIN user 
      ON user.id = post.userId
    LEFT JOIN avatar
      ON avatar.userId = user.id
    LEFT JOIN LATERAL (
      SELECT * FROM file
      WHERE file.postId = post.id
      ORDER BY file.id DESC
      LIMIT 9
    ) AS file ON file.postId = post.id
    LEFT JOIN LATERAL(
          SELECT * FROM file
          WHERE file.isCover = 1 AND file.postId = post.id
          GROUP BY file.id
          LIMIT 1
    ) AS cover ON cover.postId = post.id and cover.isCover = 1 
    LEFT JOIN post_tag
    ON post_tag.postId = post.id
    LEFT JOIN tag
    ON tag.id = post_tag.tagId
    LEFT JOIN category 
    ON post.categoryId = category.id
  WHERE post.id IS NOT NULL
  GROUP BY post.id
  ORDER BY post.id DESC
  LIMIT 10
  OFFSET 0

这个是打印出来的log,具体的代码如下(可根据不同的参数查询不同的数据),如下:

export const getPosts = async (options: GetPostOptions) => {
  const {
    sort,
    filter,
    pagination: { limit, offset },
    userId,
  } = options;
  let params: Array<any> = [limit, offset];
  if (filter.param) {
    params = [filter.param, ...params];
  }
  if (userId) {
    params = [userId, ...params];
  }
  console.log(`params: ${params}`);

  const sql = `
  SELECT 
    post.id, 
    post.content, 
    post.title,
    category.name as category,
    post.views,
    post.createdAt,
    post.updatedAt,
    ${sqlFragment.user},
    ${sqlFragment.totalComments},
    ${sqlFragment.coverImage},
    ${sqlFragment.file},
    ${sqlFragment.tags}
    ${userId ? `, ${sqlFragment.liked} ` : ''},
    ${sqlFragment.totalLikes}
  FROM post 
    ${sqlFragment.leftJoinUser}
    ${sqlFragment.leftJoinOneFile}
    ${sqlFragment.leftJoinCover}
    ${sqlFragment.leftJoinTag}
    ${sqlFragment.leftJoinCategory}
    ${filter.name == 'userLiked' ? sqlFragment.innerJoinUserLikePost : ''}
  WHERE ${filter.sql}
  GROUP BY post.id
  ORDER BY ${sort}
  LIMIT ?
  OFFSET ?
  `;

  console.log(sql);

  const [data] = await connection.promise().query(sql, params);

  return data;
};

如果这个后端 REST API 接口应用感兴趣的同学可以参考 宁皓网 的视频,我就是根据这套视频做的,不过自己加了很多东西。

最后附上我的博客地址:
博客地址:https://lishaoy.net
文章地址:https://h.lishaoy.net/fluttermvvm

查看原文

赞 3 收藏 2 评论 0

子木 发布了文章 · 2020-08-03

Android coder 需要理解的注解、反射和动态代理

注解我们经常使用它,很多框架也提供了很多注解给我们使用,如 ARouter@Route(path = "/test/activity")butterknife@BindView(R.id.user) EditText username; 等,但是,你有没有自定义过注解,写过自己的注解处理器呢?反射听起来很高大上,但是实际上你真的了解他之后,只是一些API的调用而已;动态代理其实只是在静态代理(代理模式)基础上使用了反射技术;本篇文章将带领大家对注解、反射及动态代理有更清晰的认知。


本篇文章的示例代码放在 Github 上,所有知识点,如图:

注解

注解(Annotations),元数据的一种形式,提供有关于程序但不属于程序本身的数据。注解对它们注解的代码的操作没有直接影响。

注解有多种用途,例如:

  • 为编译器提供信息:编译器可以使用注解来检查错误或抑制警告
  • 编译或部署时处理:可以生成代码、XML、文件等
  • 运行时处理:注解可以在运行时检查

注解的格式

注解的格式如下:

@Persilee
class MyClass { ... }

注解已 @ 开头后面跟上内容,注解可以包含元素,例如:

@Persilee(id=666, value = "lsy")
class MyClass { ... }

如果,只有一个 value 元素,则可以省略该名称,如果,没有元素,则可以省略括号,例如

@Persilee("lsy") // 只有一个 value 元素
class MyClass { ... }

@Persilee // 没有元素
class MyClass { ... }

如果,注解有相同的类型,则是重复注解,如

@Persilee("lsy")
@Persilee("zimu")
class MyClass { ... }

注解声明

注解的定义类似于接口的定义,在关键字 interface 前加上 @,如:

@interface Persilee {
    int id();
    String value();
}

注解类型

int id()String value() 是注解类型(annotation type),它们也可以定义可选的默认值,如:

@interface Persilee {
    int id();
    String value() default "lsy";
}

在使用注解时,如果定义的注解的注解类型没有默认值,则必须进行赋值,如:

@Persilee(id = 666) // id 必须要赋值,如,@Persilee 会提示 id 必须赋值
class MyClass { ... }

元注解

在注解上面的注解称为元注解(meta-annotations),如

@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.SOURCE)
@interface Persilee {
    int id();
    String value() default "lsy";
}

java.lang.annotation 中定义了几种元注解类型(常使用的是 @Retention、@Target),如

@Retention 指定注解的存储方式,我们由 RetentionPolicy.java (是一个枚举)可知,如:

public enum RetentionPolicy {
    SOURCE, // 标记的注解仅保留在源级别中,并被编译器忽略。
    CLASS, // 标记的注解在编译时由编译器保留,但 Java 虚拟机(JVM)会忽略。
    RUNTIME // 标记的注解由 JVM 保留,因此运行时环境可以使用它。
}

@Target 指定注解可以使用的范围,我们由 ElementType.java (是一个枚举)可知使用范围,如下:

public enum ElementType {
    TYPE, // 类
    FIELD, // 字段或属性
    METHOD, // 方法
    PARAMETER, // 参数
    CONSTRUCTOR, // 构造方法
    LOCAL_VARIABLE, // 局部变量
    ANNOTATION_TYPE, // 也可以使用在注解上
    PACKAGE, // 包
    TYPE_PARAMETER, // 类型参数
    TYPE_USE // 任何类型
}

对于 TYPE_PARAMETER (类型参数) 、 TYPE_USE (任何类型名称) 可能不是很好理解,如果把 Target 设置成 @Target({ElementType.TYPE_PARAMETER}),表示可以使用在泛型(上篇文章有介绍过泛型)的类型参数上,如:

public class TypeParameterClass<@Persilee T> {
    public <@Persilee T> T foo(T t) {
        return null;
    }
}

如果把 Target 设置成 @Target({ElementType.TYPE_USE}),表示可以使用在任何类型上,如:

TypeParameterClass<@Persilee String> typeParameterClass = new TypeParameterClass<>();
@Persilee String text = (@Persilee String)new Object();

@Documented 注解表示使用了指定的注解,将使用 Javadoc 工具记录这些元素。

@Inherited 注解表示注解类型可以从超类继承。

@Repeatable 注解表明标记的注解可以多次应用于同一声明或类型使用。

注解应用场景

根据 @Retention 元注解定义的存储方式,注解一般可以使用在以下3种场景中,如:

级别技术说明
源码APT在编译期能获取注解与注解声明的类和类中所有成员信息,一般用于生成额外的辅助类。
字节码      字节码增强      在编译出Class后,通过修改Class数据以实现修改代码逻辑目的,对于是否需要修改的区分或者修改为不同逻辑的判断可以使用注解。
运行时反射在程序运行时,通过反射技术动态获取注解与其元素,从而完成不同的逻辑判断。

小案例(使用注解实现语法检查)

我们定义一个 weekDay 字段,类型是 WeekDay 枚举类型,方便我们设置枚举中指定的值,如:

class WeekDayDemo {

    private static WeekDay weekDay;

    enum WeekDay {
        SATURDAY,SUNDAY
    }

    public static WeekDay getWeekDay() {
        return weekDay;
    }

    public static void setWeekDay(WeekDay weekDay) {
        WeekDayDemo.weekDay = weekDay;
    }

    public static void main(String[] args) {
        setWeekDay(WeekDay.SATURDAY);
        System.out.println(getWeekDay());
    }
}

众所周知,在 Java 中枚举的实质是特殊的静态成员变量,在运行时候,所有的枚举会作为单例加载到内存中,非常消耗内存,那么,有没有什么优化的方案呢,在此,我们使用注解来取代枚举。

我们使用常量和 @intDef (语法检查)元注解去代替枚举,如:

class IntdefDemo {

    private static final int SATURDAY = 0;
    private static final int SUNDAY = 1;

    private static int weekDay;

    @IntDef({SATURDAY, SUNDAY})
    @Target({ElementType.FIELD, ElementType.PARAMETER})
    @Retention(RetentionPolicy.SOURCE)
    @interface WeekDay { //自定义一个 WeekDay 注解

    }

    public static void setWeekDay(@WeekDay int weekDay) { // 使用 WeekDay 注解限制参数类型
        IntdefDemo.weekDay = weekDay;
    }

    public static void main(String[] args) {
        setWeekDay(SATURDAY); // 只能 传入 SATURDAY, SUNDAY
    }
}

APT注解处理器

APT(Annotation Processor Tools) 注解处理器,用于处理注解,编写好的 Java 文件,需要经过 Javac 的编译,编译为虚拟机能够加载的字节码(Class)文件,注解处理器是 Javac 自带的一个工具,用来在编译时期处理注解信息。

上文中我们已自定义好了 @Persilee 注解,下面我们来编写一个简单的注解处理器来处理 @Persilee 注解,我们可以新建一个 Java 的 Module,创建一个 PersileeProcessor 的类,如:

@SupportedAnnotationTypes("net.lishaoy.anreprdemo.Persilee")  //指定要处理的注解
public class PersileeProcessor extends AbstractProcessor {

    @Override
    public boolean process(Set<? extends TypeElement> set, RoundEnvironment roundEnvironment) {
        Messager messager = processingEnv.getMessager(); //
        messager.printMessage(Diagnostic.Kind.NOTE, "APT working ...");
        for (TypeElement typeElement: set) {
            messager.printMessage(Diagnostic.Kind.NOTE,"===>" + typeElement.getQualifiedName());
            Set<? extends Element> elements = roundEnvironment.getElementsAnnotatedWith(typeElement);
            for (Element element: elements) {
                messager.printMessage(Diagnostic.Kind.NOTE,"===>" + element.getSimpleName());
            }
        }

        return false;
    }
}

然后,在 main 目录下新建 resources 目录,如图:

这个目录结构是规定死的,必须这样写,然后在 javax.annotation.processing.Processor 文件里注册需要处理的注解处理器,如

net.lishaoy.aptlib.PersileeProcessor

最后,在 appbuild.gradle 文件引入模块,如

dependencies {
  ...

  annotationProcessor project(':aptlib')
}

在你 Build 工程时候,会在 Task :app:compileDebugJavaWithJavac 任务打印我们在注解处理程序的日志信息,如:

注: APT working ...
注: ===>net.lishaoy.anreprdemo.Persilee
注: ===>MainActivity

因为,我们只在 MainActivity 中使用了 @Persilee 注解,如下:

@Persilee(id = 666, value = "lsy")
public class MainActivity extends AppCompatActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        setContentView(R.layout.activity_main);
    }
}

反射

一般情况下,我们使用某个类时必定知道它是什么类,用来做什么的。于是我们直接对这个类进行实例化,之后使用这个类对象进行操作。

Cook cook = new Cook(); // 实例化一个对象,标准用法
cook.cookService("🍅");

反射是一开始并不知道初始化的类对象是什么,也不能使用 new 关键字来创建对象,反射是在运行的时才知道要操作的类是什么,并且可以在运行时获取类的完整构造,调用对应的方法。

Java 反射机制主要提供了以下功能:

  • 在运行时构造任意一个类的对象
  • 在运行时获取或修改任意一个类所具有的成员变量和方法
  • 在运行时调用任意一个对象的方法(属性)

Class类

Class是一个类,封装了当前对象所对应的类的信息,我们写的每一个类都可以看成一个对象,是 java.lang.Class 类的对象,Class是用来描述类的类。

获得Class对象

Class对象的获取有3种方式,如下:

  • 通过类名获取 类名.class
  • 通过对象获取 对象名.getClass()
  • 通过全类名获取 Class.forName(全类名)
Cook cook = new Cook();
Class cookClass = Cook.class;
Class cookClass1 = cook.getClass();
Class cookClass2 = Class.forName("net.lishaoy.reflectdemo.Cook");

创建实例

我们可以通过反射来生成对象的实例,如:

Class cookClass = Cook.class;
Cook cook1 = (Cook) cookClass.newInstance();

获取构造器

获取构造器的方法有,如下:

  • Constructor getConstructor(Class[] params):获得使用特殊的参数类型的public构造函数(包括父类)
  • Constructor[] getConstructors():获得类的所有公共构造函数
  • Constructor getDeclaredConstructor(Class[] params):获得使用特定参数类型的构造函数(包括私有)
  • Constructor[] getDeclaredConstructors():获得类的所有构造函数(与接入级别无关)

我们来新建一个 Person ,以便我们的演示,如:

public class Person {

    public String name;
    private int age;

    public Person(String name, int age) {
        this.name = name;
        this.age = age;
    }

    public Person() {
        super();
    }

    public String getName() {
        System.out.println("get name: " + name);
        return name;
    }

    public void setName(String name) {
        this.name = name;
        System.out.println("set name: " + this.name);
    }

    public int getAge() {
        System.out.println("get age: " + age);
        return age;
    }

    public void setAge(int age) {
        this.age = age;
        System.out.println("set age: " + this.age);
    }

    private void privateMethod(){
        System.out.println("the private method!");
    }
}

很常规的一个类,里面有私有的属性和方法。

下面,我们新建一个 GetConstructor 的类来演示,获取构造器方法如何使用,如:

class GetConstructor {

    public static void main(String[] args) throws
            ClassNotFoundException,
            NoSuchMethodException,
            IllegalAccessException,
            InvocationTargetException,
            InstantiationException {

        String className = "net.lishaoy.reflectdemo.entity.Person";
        Class<Person> personClass = (Class<Person>) Class.forName(className);

        //获取全部的constructor对象
        Constructor<?>[] constructors = personClass.getConstructors();
        for (Constructor<?> constructor: constructors) {
            System.out.println("获取全部的constructor对象: " + constructor);
        }

        //获取某一个constructor对象
        Constructor<Person> constructor = personClass.getConstructor(String.class, int.class);
        System.out.println("获取某一个constructor对象: " + constructor);

        //调用构造器的 newInstance() 方法创建对象
        Person person = constructor.newInstance("lsy", 66);
        System.out.println(person.getName() + ", " + person.getAge() );
    }

}

输出结果,如下:

获取全部的constructor对象: public net.lishaoy.reflectdemo.entity.Person(java.lang.String,int)
获取全部的constructor对象: public net.lishaoy.reflectdemo.entity.Person()
获取某一个constructor对象: public net.lishaoy.reflectdemo.entity.Person(java.lang.String,int)
lsy, 66

获取方法

获取方法的方法有,如下:

  • Method getMethod(String name, Class[] params):使用特定的参数类型,获得命名的公共方法
  • Method[] getMethods():获得类的所有公共方法
  • Method getDeclaredMethod(String name, Class[] params):使用特写的参数类型,获得类声明的命名的方法
  • Method[] getDeclaredMethods():获得类声明的所有方法

我们新创建一个 GetMethod 来演示如何来获取和调用方法,如:

class GetMethod {

    public static void main(String[] args) throws
            ClassNotFoundException,
            NoSuchMethodException,
            IllegalAccessException,
            InstantiationException,
            InvocationTargetException {

        Class<?> aClass = Class.forName("net.lishaoy.reflectdemo.entity.Person");

        //获取所有的public方法(包含从父类继承的方法)
        Method[] methods = aClass.getMethods();
        for (Method method: methods) {
            System.out.println("获取所有public方法: " + method.getName() + "()");
        }

        System.out.println("===========================");

        //获取所有方法(不包含父类方法)
        methods = aClass.getDeclaredMethods();
        for (Method method: methods) {
            System.out.println("获取所有方法: " + method.getName() + "()");
        }

        System.out.println("===========================");

        //获取指定的方法
        Method method = aClass.getDeclaredMethod("setAge", int.class);
        System.out.println("获取指定的方法:" + method);

        //调用方法
        Object instance = aClass.newInstance();
        method.invoke(instance, 66);

        //调用私有方法
        method = aClass.getDeclaredMethod("privateMethod");
        method.setAccessible(true); // 需要调用此方法且设置为 true
        method.invoke(instance);

    }

}

运行结果,如下:

获取所有public方法: getName()
获取所有public方法: setName()
获取所有public方法: setAge()
获取所有public方法: getAge()
获取所有public方法: wait()
获取所有public方法: wait()
获取所有public方法: wait()
获取所有public方法: equals()
获取所有public方法: toString()
获取所有public方法: hashCode()
获取所有public方法: getClass()
获取所有public方法: notify()
获取所有public方法: notifyAll()
===========================
获取所有方法: getName()
获取所有方法: setName()
获取所有方法: setAge()
获取所有方法: privateMethod()
获取所有方法: getAge()
===========================
获取指定的方法:public void net.lishaoy.reflectdemo.entity.Person.setAge(int)
set age: 66
the private method!

BUILD SUCCESSFUL in 395ms

获取成员变量

获取成员变量的方法有,如下:

  • Field getField(String name):获得命名的公共字段
  • Field[] getFields():获得类的所有公共字段
  • Field getDeclaredField(String name):获得类声明的命名的字段
  • Field[] getDeclaredFields():获得类声明的所有字段

我们再来新建一个 GetField 的类来演示如何获取成员变量,如下:

class GetField {

    public static void main(String[] args) throws
            ClassNotFoundException,
            NoSuchFieldException,
            IllegalAccessException,
            InstantiationException {

        Class<?> aClass = Class.forName("net.lishaoy.reflectdemo.entity.Person");

        // 获取所有字段(不包含父类字段)
        Field[] fields = aClass.getDeclaredFields();
        for (Field field: fields) {
            System.out.println("获取所有字段: " + field.getName());
        }

        System.out.println("================");

        // 获取指定字段
        Field name = aClass.getDeclaredField("name");
        System.out.println("获取指定字段: " + name.getName());

        // 设置指定字段的值
        Object instance = aClass.newInstance();
        name.set(instance, "per");

        // 获取指定字段的值
        Object o = name.get(instance);
        System.out.println("获取指定字段的值: " + o);

        // 设置和获取私有字段的值
        Field age = aClass.getDeclaredField("age");
        age.setAccessible(true); // 需要调用此方法且设置为 true
        age.set(instance, 66);
        System.out.println("获取私有字段的值: " + age.get(instance));

    }

}

运行结果,如下:

获取所有字段: name
获取所有字段: age
================
获取指定字段: name
获取指定字段的值: per
获取私有字段的值: 66

BUILD SUCCESSFUL in 395ms

使用注解和反射实现自动findViewById(案例)

我们已经对注解和反射有了更清晰的认知,下面我们通过一个小案例来巩固我们的学习:使用注解和反射完成类似 butterknife 的自动 findViewById 的功能。

新建一个空的 Android 工程,在工程目录下新建 inject 目录,在此目录下新建一个 InjectView 的类和 BindView 的自定义注解,如:

创建InjectView

InjectView 类通过反射完成 findViewById 功能:

public class InjectView {

    public static void init(Activity activity) {
        // 获取 activity 的 class 对象
        Class<? extends Activity> aClass = activity.getClass();
        // 获取 activity 的所以成员变量
        Field[] declaredFields = aClass.getDeclaredFields();
        // 变量所以成员变量
        for (Field field: declaredFields) {
            // 判断属性是否加上了 @BindView 注解
            if(field.isAnnotationPresent(BindView.class)){
                // 获取注解 BindView 对象
                BindView bindView = field.getAnnotation(BindView.class);
                // 获取注解类型元素 id
                int id = bindView.value();
                // 通过资源 id 找到对应的 view
                View view = activity.findViewById(id);
                // 设置可以访问私有字段
                field.setAccessible(true);
                try {
                    // 给字段赋值
                    field.set(activity,view);
                } catch (IllegalAccessException e) {
                    e.printStackTrace();
                }
            }
        }
    }
}

创建@BindView注解

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
public @interface BindView {
    @IdRes int value(); // @IdRes 只能传 id 资源
}

使用@BindView注解

MainActivity 里使用 @BindView 注解,如:

public class MainActivity extends AppCompatActivity {

    // 使用注解
    @BindView(R.id.text_view)
    private TextView textView;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        setContentView(R.layout.activity_main);

        // 初始化 InjectView,完成自动 findViewById 功能
        InjectView.init(this);
        // 测试 R.id.text_view 是否自动赋值给 textView
        textView.setText("通过 @BindView 注解自动完成 findViewById");
    }
}

运行结果,如图:

是不是很简单,一个类就完成了自动 findViewById 的功能。

动态代理

在了解动态代理之前,我们先来回顾下静态代理。

静态代理

代理模式给某一个对象提供一个代理对象,并由代理对象控制对原对象的引用,如,我们生活中常见的中介。

代理模式一般会有3个角色,如图:

  • 抽象角色:指代理角色和真实角色对外提供的公共方法,一般为一个接口
  • 真实角色:需要实现抽象角色接口,定义了真实角色所要实现的业务逻辑,以便供代理角色调用
  • 代理角色:需要实现抽象角色接口,是真实角色的代理,通过真实角色的业务逻辑方法来实现抽象方法,并可以附加自己的操作

为什么要使用代理模式

  • 可以间接访问对象,防止直接访问对象来的不必要复杂性
  • 通过代理对象对访问进行控制

静态代理案例

场景如下:

小明可以在某网站上购买国内的东西,但是,不能买海外的东西,于是,他找了海外代购帮他买东西。

如何用代码描述呢?根据代理模式的3个角色,我们分别定义1个接口2个类,如:OrderService 接口(抽象角色)、ImplJapanOrderService 类(真实角色)、ProxyJapanOrder 类(代理角色)

OrderService 接口(抽象角色),代码如下:

public interface OrderService {
    int saveOrder();
}

ImplJapanOrderService 类(真实角色),代码如下:

// 实现抽象角色接口
public class ImplJapanOrderService implements OrderService {
    @Override
    public int saveOrder() {
        System.out.println("下单成功,订单号为:888888");
        return 888888;
    }
}

ProxyJapanOrder 类(代理角色),代码如下:

// 实现抽象角色接口
public class ProxyJapanOrder implements OrderService {

    private OrderService orderService; // 持有真实角色

    public OrderService getOrderService() {
        return orderService;
    }

    public void setOrderService(OrderService orderService) {
        this.orderService = orderService;
    }

    @Override
    public int saveOrder() {
        System.out.print("日本代购订单,");
        return orderService.saveOrder(); // 调用真实角色的行为方法
    }
}

在创建一个 Client 类来测试我们的代码,如下:

public class Client {

    public static void main(String[] args) {
        // 日本代购订单
        OrderService orderJapan = new ImplJapanOrderService();
        ProxyJapanOrder proxyJapanOrder = new ProxyJapanOrder();
        proxyJapanOrder.setOrderService(orderJapan);
        proxyJapanOrder.saveOrder();
    }
}

运行结果,如下:

日本代购订单,下单成功,订单号为:888888

BUILD SUCCESSFUL in 1s

如果,需要购买韩国的东西,需要新增一个 ImplKoreaOrderService 类(韩国服务商) 和 ProxyKoreaOrder 类(韩国代理),如还需要购买其他国家的东西,需要新增不同的类,则会出现静态代理对象量多、代码量大,从而导致代码复杂,可维护性差的问题,如是,我们需要使用动态代理。

动态代理

动态代理是在运行时才创建代理类和其实例,因此,我们可以传不同的真实角色,实现一个代理类完成多个真实角色的行为方法,当然,其效率比静态代理低。那么如何实现动态代理呢,JDK已为我们提供了 Proxy 类 和 InvocationHandler 接口来完成这件事情。

我们来创建一个 ProxyDynamicOrder 类(动态代理类),代码如下:

public class ProxyDynamicOrder implements InvocationHandler {

    private Object orderService; // 持有真实角色

    public Object getOrderService() {
        return orderService;
    }

    public void setOrderService(Object orderService) {
        this.orderService = orderService;
    }
    // 通过 Proxy 动态创建真实角色
    public Object getProxyInstance(){
        return Proxy.newProxyInstance(
                orderService.getClass().getClassLoader(),
                orderService.getClass().getInterfaces(),
                this
                );
    }

    @Override
    public Object invoke(Object o, Method method, Object[] objects) throws Throwable {
        return method.invoke(orderService, objects); // 通过反射执行真实角色的行为方法
    }
}

在来看看,Client 类里如何调用,代码如下:

public class Client {

    public static void main(String[] args) {

        // 静态代理模式
        // 国内订单
        OrderService order = new ImplOrderService();
        order.saveOrder();
        // 日本代购订单
        OrderService orderJapan = new ImplJapanOrderService();
        ProxyJapanOrder proxyJapanOrder = new ProxyJapanOrder();
        proxyJapanOrder.setOrderService(orderJapan);
        proxyJapanOrder.saveOrder();
        // 韩国代购订单
        OrderService orderKorea = new ImplKoreaOrderService();
        ProxyKoreaOrder proxyKoreaOrder = new ProxyKoreaOrder();
        proxyKoreaOrder.setOrderService(orderKorea);
        proxyKoreaOrder.saveOrder();

        // 动态代理模式
        // 国内订单
        ProxyDynamicOrder proxyDynamicOrder = new ProxyDynamicOrder();
        OrderService orderService = new ImplOrderService();
        proxyDynamicOrder.setOrderService(orderService);
        OrderService orderService1 = (OrderService) proxyDynamicOrder.getProxyInstance();
        orderService1.saveOrder();

        // 日本代购订单
        OrderService japanOrderService = new ImplJapanOrderService();
        proxyDynamicOrder.setOrderService(japanOrderService);
        OrderService japanOrderService1 = (OrderService) proxyDynamicOrder.getProxyInstance();
        japanOrderService1.saveOrder();

        // 韩国代购订单
        OrderService koreaOrderService = new ImplKoreaOrderService();
        proxyDynamicOrder.setOrderService(koreaOrderService);
        OrderService koreaOrderService1 = (OrderService) proxyDynamicOrder.getProxyInstance();
        koreaOrderService1.saveOrder();

        // 生成动态代理生成的class文件
        //ProxyUtil.generateClassFile(koreaOrderService.getClass(), koreaOrderService1.getClass().getSimpleName());

    }
}

运行结果,如下:

下单成功,订单号为:666666
日本代购订单,下单成功,订单号为:888888
韩国代购订单,下单成功,订单号为:666888
下单成功,订单号为:666666
下单成功,订单号为:888888
下单成功,订单号为:666888

BUILD SUCCESSFUL in 1s

只需要一个 ProxyDynamicOrder 代理类即可完成 ImplOrderServiceImplJapanOrderServiceImplKoreaOrderService 真实角色提供的服务。

动态代理原理

我们在 proxyDynamicOrder.getProxyInstance() 代码上打个断点,通过调试模式发现,如图:

代理类的名字是 $Proxy0@507,为什么是这个名字,我们在编译后的目录里也找不到 $Proxy0@507 类文件,如图:

我们通过查看 Proxy.newProxyInstance 方法源码,可知,如:

@CallerSensitive
public static Object newProxyInstance(ClassLoader var0, Class<?>[] var1, InvocationHandler var2) throws IllegalArgumentException {
    Objects.requireNonNull(var2);
    Class[] var3 = (Class[])var1.clone();
    SecurityManager var4 = System.getSecurityManager();
    if (var4 != null) {
        checkProxyAccess(Reflection.getCallerClass(), var0, var3);
    }
    // 获取代理类的 class 对象
    Class var5 = getProxyClass0(var0, var3);

    try {
        if (var4 != null) {
            checkNewProxyPermission(Reflection.getCallerClass(), var5);
        }
        // 获取代理类的构造器
        final Constructor var6 = var5.getConstructor(constructorParams);
        if (!Modifier.isPublic(var5.getModifiers())) {
            AccessController.doPrivileged(new PrivilegedAction<Void>() {
                public Void run() {
                    var6.setAccessible(true);
                    return null;
                }
            });
        }
        // 创建代理类的示例
        return var6.newInstance(var2);
    } catch (InstantiationException | IllegalAccessException var8) {
        throw new InternalError(var8.toString(), var8);
    } catch (InvocationTargetException var9) {
        Throwable var7 = var9.getCause();
        if (var7 instanceof RuntimeException) {
            throw (RuntimeException)var7;
        } else {
            throw new InternalError(var7.toString(), var7);
        }
    } catch (NoSuchMethodException var10) {
        throw new InternalError(var10.toString(), var10);
    }
}

然后,跟进 getProxyClass0(var0, var3) 看看是如何获取代理类的 class 对象的,点击进入,如下:

private static Class<?> getProxyClass0(ClassLoader var0, Class<?>... var1) {
    if (var1.length > 65535) {
        throw new IllegalArgumentException("interface limit exceeded");
    } else {
        // 缓存了代理类的 class 对象
        return (Class)proxyClassCache.get(var0, var1);
    }
}

然后,我们来看看这个 var1 是个什么东西,我们往上找了找,果然发现,如下:

// var1 就是我们实现的 InvocationHandler 接口
protected Proxy(InvocationHandler var1) {
    Objects.requireNonNull(var1);
    this.h = var1;
}

然后,我们点进 proxyClassCache.get(var0, var1) 方法,如图:

使用关键代码 this.subKeyFactory.apply(var1, var2) 去获取我们的代理类的 class 对象,我们进入 apply 实现类 ProxyClassFactory,如:

public Class<?> apply(ClassLoader var1, Class<?>[] var2) {
    IdentityHashMap var3 = new IdentityHashMap(var2.length);
    Class[] var4 = var2;
    int var5 = var2.length;

    ...

    if (var16 == null) {
        var16 = "com.sun.proxy.";
    }

    long var19 = nextUniqueNumber.getAndIncrement();
    // 生成代理类的类名
    String var23 = var16 + "$Proxy" + var19;
    // 生成代理类的字节码
    byte[] var22 = ProxyGenerator.generateProxyClass(var23, var2, var17);

    try {
        // 生成代理类的 class 对象
        return Proxy.defineClass0(var1, var23, var22, 0, var22.length);
    } catch (ClassFormatError var14) {
        throw new IllegalArgumentException(var14.toString());
    }
}

然后,我们点进 Proxy.defineClass0 方法,如下:

private static native Class<?> defineClass0(ClassLoader var0, String var1, byte[] var2, int var3, int var4);

是一个 native 方法,所以涉及到 C 或 C++ ,我们就不往后追踪。

那么,代理的 Class 文件到底存在哪儿呢,由一个类的生命周期,如图:

代理的 Class 文件通过反射存在内存中,所以我们可以通过 byte[] 写入文件,我们新建一个工具类来把内存中的 class 字节码写入文件,如:

public class ProxyUtil {

    public static void generateClassFile(Class aClass, String proxyName) {

        byte[] proxyClassFile = ProxyGenerator.generateProxyClass(
                proxyName,
                new Class[]{aClass}
        );
        String path = aClass.getResource(".").getPath();
        System.out.println(path);
        FileOutputStream outputStream = null;

        try {
            outputStream = new FileOutputStream(path + proxyName + ".class");
            outputStream.write(proxyClassFile);
            outputStream.flush();
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            try {
                outputStream.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }
}

通过输出的 path 路径,找到文件,如:

/Users/lishaoying/Documents/APP/Android/practice/annotation_reflect/anRePrDemo/proxyDemo/build/classes/java/main/net/lishaoy/proxydemo/service/impl/

文件代码,如下:

// 继承了 Proxy 实现了 ImplKoreaOrderService 接口
public final class $Proxy0 extends Proxy implements ImplKoreaOrderService {

    // 生成了各种方法
    private static Method m1;
    private static Method m8;
    private static Method m3;
    private static Method m2;
    private static Method m5;
    private static Method m4;
    private static Method m7;
    private static Method m9;
    private static Method m0;
    private static Method m6;

    public $Proxy0(InvocationHandler var1) throws  {
        super(var1);
    }

    ...

    // 生成了 真实角色的 saveOrder 方法
    public final int saveOrder() throws  {
        try {
            // h 是什?,点进去发现就是我们 传入的 InvocationHandler 接口
            // m3 是什么? 下面 static 代码块,就是我们的 saveOrder 方法
            return (Integer)super.h.invoke(this, m3, (Object[])null);
        } catch (RuntimeException | Error var2) {
            throw var2;
        } catch (Throwable var3) {
            throw new UndeclaredThrowableException(var3);
        }
    }

    ...

    public final Class getClass() throws  {
        try {
            return (Class)super.h.invoke(this, m7, (Object[])null);
        } catch (RuntimeException | Error var2) {
            throw var2;
        } catch (Throwable var3) {
            throw new UndeclaredThrowableException(var3);
        }
    }

    ...

    static {
        try {
            m1 = Class.forName("java.lang.Object").getMethod("equals", Class.forName("java.lang.Object"));
            m8 = Class.forName("net.lishaoy.proxydemo.service.impl.ImplKoreaOrderService").getMethod("notify");
            m3 = Class.forName("net.lishaoy.proxydemo.service.impl.ImplKoreaOrderService").getMethod("saveOrder");
            m2 = Class.forName("java.lang.Object").getMethod("toString");
            m5 = Class.forName("net.lishaoy.proxydemo.service.impl.ImplKoreaOrderService").getMethod("wait", Long.TYPE);
            m4 = Class.forName("net.lishaoy.proxydemo.service.impl.ImplKoreaOrderService").getMethod("wait", Long.TYPE, Integer.TYPE);
            m7 = Class.forName("net.lishaoy.proxydemo.service.impl.ImplKoreaOrderService").getMethod("getClass");
            m9 = Class.forName("net.lishaoy.proxydemo.service.impl.ImplKoreaOrderService").getMethod("notifyAll");
            m0 = Class.forName("java.lang.Object").getMethod("hashCode");
            m6 = Class.forName("net.lishaoy.proxydemo.service.impl.ImplKoreaOrderService").getMethod("wait");
        } catch (NoSuchMethodException var2) {
            throw new NoSuchMethodError(var2.getMessage());
        } catch (ClassNotFoundException var3) {
            throw new NoClassDefFoundError(var3.getMessage());
        }
    }
}

使用注解、反射、动态代理完成简单的Retrofit

由于文章篇幅已经很长,且使用注解、反射、动态代理完成简单的 Retrofit 的案例代码过多,所以就不再这里展示,感兴趣的小伙伴可以去 GitHub 查看源码。

最后附上博客和GitHub地址,如下:

博客地址:https://h.lishaoy.net
GitHub地址:https://github.com/persilee

查看原文

赞 5 收藏 4 评论 0

子木 发布了文章 · 2020-07-26

Android:写了这么多代码,你真的理解泛型吗

generics

在我们的实际工作中 泛型(Generics) 是无处不在的,我们也写过不少,看到的更多,如,源码、开源框架... 随处可见,但是,我们真正理解泛型吗?理解多少呢?例如:BoxBox<Object>Box<?>Box<T>Box<? extends T>Box<? super T> 之间的区别是什么?本篇文章将会对 泛型(Generics) 进行全面的解析,让我们对泛型有更深入的理解。

本篇文章的示例代码放在 Github 上,所有知识点,如图:

Lucy 喜欢吃🍊(为什么要使用泛型)

首先,通过一个盘子装水果小故事来打开我们的泛型探索之旅(我们为什么要使用泛型),故事场景如下:

Lucy 到 James 家做客,James 需要招待客人,且知道 Lucy 喜欢吃橘子🍊,于是使用水果盘装满了🍊来招待客人

这个场景怎么用代码表现呢,我们来新建几个类,如下:

Fruit:水果类

package entity;

public class Fruit {

    @Override
    public String toString() {

        return "This is Fruit";
    }
}

Apple:苹果类,继承水果类

package entity;

public class Apple extends Fruit {

    @Override
    public String toString() {

        return " Apple 🍎";
    }
}

Orange:橘子类,继承水果类

package entity;

public class Orange extends Fruit {

    @Override
    public String toString() {

        return " Orange 🍊";
    }
}

Plate:水果盘接口

package entity;

public interface Plate<T> {

    public void set(T t);

    public T get();

}

FruitPlate:水果盘类,实现水果盘接口

package entity;

import java.util.ArrayList;
import java.util.List;

public class FruitPlate implements Plate {

    private List items = new ArrayList(6);

    @Override
    public void set(Object o) {
        items.add(o);
    }

    @Override
    public Fruit get() {
        int index = items.size() - 1;
        if(index >= 0) return (Fruit) items.get(index);
        return null;
    }

}

AiFruitPlate:智能水果盘,实现水果盘接口

package entity;

import java.util.ArrayList;
import java.util.List;
/**
 * 使用泛型类定义
 * @param <T>
 */
public class AiFruitPlate<T> implements Plate<T> {

    private List<T> fruits = new ArrayList<T>(6);
    @Override
    public void set(T t) {
        fruits.add(t);
    }

    @Override
    public T get() {
        int index = fruits.size() - 1;
        if(index >= 0) return fruits.get(index);
        return null;
    }
}

Person:人类

package entity;

public class Person {

}

Lucy:Lucy类,继承 Person 类,她拥有吃橘子的能力 eat

import entity.Orange;
import entity.Person;

public class Lucy extends Person {

    public void eat(Orange orange) {

        System.out.println("Lucy like eat" + orange);

    }

}

James:James类,继承 Person 类,他拥有获取水果盘的能力 getAiFruitPlate

import entity.*;

public class James extends Person {

    public FruitPlate getPlate() {
        return new FruitPlate();
    }

    public AiFruitPlate getAiFruitPlate() {
        return new AiFruitPlate();
    }

    public void addFruit(FruitPlate fruitPlate, Fruit fruit) {
        fruitPlate.set(fruit);
    }

    public void add(AiFruitPlate<Orange> aiFruitPlate, Orange orange) {
        aiFruitPlate.set(orange);
    }

}

Scenario:测试类

import entity.*;

public class Scenario {

    public static void main(String[] args) {
        scenario1();
        scenario2();
    }
    //没有使用泛型
    private static void scenario1() {
        James james = new James();
        Lucy lucy = new Lucy();
        FruitPlate fruitPlate = james.getPlate(); // James 拿出水果盘
        james.addFruit(fruitPlate,new Orange()); // James 往水果盘里装橘子
        lucy.eat((Orange) fruitPlate.get()); // 需要转型为 Orange
    }
    //使用了泛型
    private static void scenario2() {
        James james = new James();
        Lucy lucy = new Lucy();
        AiFruitPlate<Orange> aiFruitPlate = james.getAiFruitPlate(); // James 拿出智能水果盘(知道你需要装橘子)
        james.add(aiFruitPlate, new Orange()); // James 往水果盘里装橘子(如果,装的不是橘子会提醒)
        lucy.eat(aiFruitPlate.get()); // 不需要转型
    }

}

运行结果,如下:

Lucy like eat  Orange 🍊
Lucy like eat  Orange 🍊

Process finished with exit code 0

我们可以很明显的看出,使用了泛型之后,不需要类型转换,如果,我们把 scenario1() 方法,稍微改下,如下:

    private static void scenario1() {
        James james = new James();
        Lucy lucy = new Lucy();
        FruitPlate fruitPlate = james.getPlate();
        james.addFruit(fruitPlate,new Apple()); //new Orange() 改成 new Apple()
        lucy.eat((Orange) fruitPlate.get());
    }

编译器不会提示有问题,但是运行之后报错,如下:

Exception in thread "main" java.lang.ClassCastException: entity.Apple cannot be cast to entity.Orange
    at Scenario.scenario1(Scenario.java:21)
    at Scenario.main(Scenario.java:7)

Process finished with exit code 1

而,我们把 scenario2() (使用了泛型)做出同样的修改,如下:

    private static void scenario2() {
        James james = new James();
        Lucy lucy = new Lucy();
        AiFruitPlate<Orange> aiFruitPlate = james.getAiFruitPlate();
        james.add(aiFruitPlate, new Apple());
        lucy.eat(aiFruitPlate.get());
    }

编译器,会提示我们有错误,如图:

error

通过以上案例,很清晰的知道我们为什么要使用泛型,如下:

  • 消除类型转换
  • 在编译时进行更强的类型检查
  • 增加代码的复用性

泛型类(Generic Class)

泛型类是通过类型进行参数化的类,这样说可能不是很好理解,之后我们用代码演示。

普通类(A Simple Class)

首先,我们来定义一个普通的类,如下:

package definegeneric;

public class SimpleClass {

    private Object object;

    public Object getObject() {
        return object;
    }

    public void setObject(Object object) {
        this.object = object;
    }
}

它的 getset 方法接受和返回一个 Object,所以,我们可以随意的传递任何类型。在编译时无法检查类型的使用,我们可以传入 Integer 且取出 Integer,也可以传入 String ,从而容易导致运行时错误。

泛型类(A Generic Class)

泛型类的定义格式如下:

class name<T1,T2,...,Tn>{
  ...
}

在类名之后的 <> 尖括号,称之为类型参数(类型变量),定义一个泛型类就是使用 <> 给它定义类型参数:T1、T2 ... Tn。

然后,我们把 SimpleClass 改成泛型类,如下:

package definegeneric;

public class GenericClass<T> {

    private T t;

    public T getT() {
        return t;
    }

    public void setT(T t) {
        this.t = t;
    }
}

所以的 object 都替换成为 T,类型参数可以定义为任何的非基本类型,如:class类型、interface类型、数组类型、甚至是另一个类型参数。

调用和实例化泛型类型(nvoking and Instantiating a Generic Type)

要想使用泛型类,必须执行泛型类调用,如:

GenericClass<String> genericClass;

泛型类的调用类似于方法的调用(传递了一个参数),但是,我们没有将参数传递给方法,而是,将类型参数(String)传递给了 GenericClass 类本身。

此代码不会创建新的 GenericClass 对象,它只是声明了 genericClass 将保存对 String 的引用

要实例化此类,要使用 new 关键字,如:

GenericClass<String> genericClass = new GenericClass<String>();

或者

GenericClass<String> genericClass = new GenericClass<>();

在 Java SE 7 或更高的版本中,编译器可以从上下文推断出类型参数,因此,可以使用 <> 替换泛型类的构造函数所需的类型参数

类型参数命名规范(Type Parameter Naming Conventions)

我们的类型参数是否一定要写成 T 呢,按照规范,类型参数名称是单个大写字母。

常用的类型参数名称有,如:

类型参数含义
EElement
KKey
NNumber
VValue
S,U,V...2nd, 3rd, 4th type

多类型参数(Multiple Type Parameters)

泛型类可以有多个类型参数,如:

public interface MultipleGeneric<K,V> {
    public K getKey();
    public V getValue();
}

public class ImplMultipleGeneric<K, V> implements MultipleGeneric<K, V> {

    private K key;
    private V value;

    public ImplMultipleGeneric(K key, V value) {
        this.key = key;
        this.value = value;
    }

    @Override
    public K getKey() {
        return key;
    }

    @Override
    public V getValue() {
        return value;
    }

    public static void main(String[] args) {
        MultipleGeneric<String, Integer> m1 = new ImplMultipleGeneric<String, Integer>("per",6);
        System.out.println("key:" + m1.getKey() + ", value:" + m1.getValue());

        MultipleGeneric<String,String> m2 = new ImplMultipleGeneric<String, String>("per","lsy");
        System.out.println("key:" + m2.getKey() + ", value:" + m2.getValue());
    }
}

输出结果:

key:per, value:6
key:per, value:lsy

Process finished with exit code 0

如上代码,new ImplMultipleGenericK 实例化为 String,将 V 实例化为 Integer ,因此, ImplMultipleGeneric 构造函数参数类型分别为 StringInteger,在编写 new ImplMultipleGeneric 代码时,编辑器会自动填写 <> 的值

由于,Java 编译器会从声明 ImplMultipleGeneric 推断出 KV 的类型,因此我们可以简写为,如下:

MultipleGeneric<String, Integer> m1 = new ImplMultipleGeneric<>("per",6);
System.out.println("key:" + m1.getKey() + ", value:" + m1.getValue());

MultipleGeneric<String,String> m2 = new ImplMultipleGeneric<>("per","lsy");
System.out.println("key:" + m2.getKey() + ", value:" + m2.getValue());

泛型接口(Generic Interface)

定义泛型接口和定义泛型类相似(泛型类的技术可同用于泛型接口),如下:

interface name<T1,T2,...,Tn>{
  ...
}

我们来定义一个泛型接口,如下:

package definegeneric;

public interface Genertor<T> {
    public T next();
}

那么,如何实现一个泛型接口呢,我们使用两种方式来实现泛型接口,如下:

使用泛型类,实现泛型接口,且不指定确切的类型参数,所以,实现的 next() 返回值自动变成 T

package definegeneric.impl;

import definegeneric.Genertor;

public class ImplGenertor<T> implements Genertor<T> {

    @Override
    public T next() {
        return null;
    }
}

使用普通类,实现泛型接口,且指定确切的类型参数为 String,所以,实现的 next() 返回值自动变成 String

package definegeneric.impl;

import definegeneric.Genertor;

public class ImplGenertor2 implements Genertor<String> {

    @Override
    public String next() {
        return null;
    }
}

泛型方法(Generic Methods)

泛型方法使用了类型参数的方法,泛型方法比较独立,可以声明在 普通类、泛型类、普通接口、泛型接口中。

泛型方法定义格式,如下:

public <K, V> boolean compare(Pair<K, V> p1, Pair<K, V> p2)

泛型方法的类型参数列表,在 <> 内,该列表必须在方法返回类型之前;对于静态的泛型方法,类型参数必须在 static 之后,方法返回类型之前。

普通类里定义泛型方法(Generic methods in a Simple Class)

我们在普通类中定义泛型方法,如下:

package methodgeneric;

public class MethodGeneric {

    //定义一个泛型方法
    public <T> T genericMethod(T...t) {
        return t[t.length/2];
    }

    public static void main(String[] args) {
        MethodGeneric methodGeneric = new MethodGeneric();
        System.out.println(methodGeneric.<String>genericMethod("java","dart","kotlin"));
    }
}

methodGeneric.<String>genericMethod("java","dart","kotlin") 通常可以省略掉 <> 的内容,编译器将推断出所需的类型,和调用普通方法一样,如:

methodGeneric.genericMethod("java","dart","kotlin")

泛型类里定义泛型方法(Generic methods in a Generic Class)

我们在泛型类中定义泛型方法,如下:

package methodgeneric;

public class MethodGeneric2 {

    static class Fruit{

        @Override
        public String toString() {
            return "fruit";
        }
    }

    static class Apple extends Fruit {

        @Override
        public String toString() {
            return "Apple";
        }
    }

    static class Person{

        @Override
        public String toString() {
            return "person";
        }
    }
    //定义了泛型类
    static class ShowClass<T> {
        //定义了普通方法
        public void show1(T t){
            System.out.println(t.toString());
        }
        //定义了泛型方法
        public <E> void show2(E e) {
            System.out.println(e.toString());
        }
        //定义了泛型方法
        public <T> void show3(T t) {
            System.out.println(t.toString());
        }
    }

    public static void main(String[] args) {

        Apple apple = new Apple();
        Person person = new Person();

        ShowClass<Fruit> showClass = new ShowClass<>();
        showClass.show1(apple);   //可以放入 apple,因为 apple 是 fruit 的子类
        showClass.show1(person); //此时,编译器会报错,因为 ShowClass<Fruit> 已经限定类型

        showClass.show2(apple); //可以放入,泛型方法 <E> 可以是任何非基本类型
        showClass.show2(person);//可以放入,泛型方法 <E> 可以是任何非基本类型

        showClass.show3(apple); //可以放入,泛型方法 <T> 和泛型类中的 <T> 不是同一条 T,可以是任何非基本类型
        showClass.show3(person); //可以放入,泛型方法 <T> 和泛型类中的 <T> 不是同一条 T,可以是任何非基本类型
    }
}

在泛型类中定义泛型方法时,需要注意,泛型类里的泛型参数 <T> 和泛型方法里的泛型参数 <T> 不是同一个。

限定类型参数(Bounded Type Parameters)

我们经常看到类似 public <U extends Number> void inspect(U u) 的代码,<U extends Number> 就是限制类型参数,只对数字进行操作且只接受 Number 或其子类。

要声明一个限定的类型参数,需要在参数类型后加上 extends 关键字,然后是其上限类型(类或接口)。

限定类型参数的泛型类(Generic Class of Bounded Type Parameters)

泛型类也可以使用限定类型参数,如下:

package boundedgeneric;

public class BoundedClass<T extends Comparable> {

    private T t;

    public void setT(T t) {
        this.t = t;
    }

    public T min(T outter){
        if(this.t.compareTo(outter) > 0)
            return outter;
        else
            return this.t;
    }

    public static void main(String[] args) {
        BoundedClass<String> boundedClass = new BoundedClass<>(); //只能传入实现了 Comparable 接口的类型
        boundedClass.setT("iOS");
        System.out.println(boundedClass.min("android"));
    }
}

限定类型参数的泛型方法(Generic methods of Bounded Type Parameters)

泛型方法也可以使用限定类型参数,如下:

package boundedgeneric;

public class BoundedGeneric {

    public static <T extends Comparable> T min(T a, T b) {
        if (a.compareTo(b) < 0)
            return a;
        else
            return b;
    }

    public static void main(String[] args) {
        System.out.println(BoundedGeneric.min(66,666));
    }
}

多重限定(Multiple Bounds)

限定类型参数,也可以为多个限定,如:

<T extends B1 & B2 & B3>

多个限定参数,如果其中有类,类必须放在第一个位置,例如:

interface A { ... }
interface B { ... }
class C { ... }

class D <T extends C & A & B>

泛型,继承和子类型(Generics, Inheritance, and Subtypes)

在前面的盘子装水果小故事里我们已经创建好了一些水果类,如下:

public class Fruit {
    @Override
    public String toString() {
        return "This is Fruit";
    }
}

public class Apple extends Fruit {
    @Override
    public String toString() {
        return " Apple 🍎";
    }
}

public class Orange extends Fruit {
    @Override
    public String toString() {
        return " Orange 🍊";
    }
}

public class QIOrange extends Orange {
    @Override
    public String toString() {
        return "qi Orange 🍊";
    }
}

他们的继承关系,如图:

no-shadow

众所周知,我们可以把子类赋值给父类,例如:

Apple apple = new Apple();
Fruit fruit = new Fruit();
fruit = apple;

泛型也是如此,我们定义一个水果盘子的泛型类,如下:

public class FruitPlateGen<Fruit> implements Plate<Fruit> {

    private List<Fruit> fruits = new ArrayList<>(6);

    @Override
    public void set(Fruit fruit) {
        fruits.add(fruit);
    }

    @Override
    public Fruit get() {
        int index = fruits.size() - 1;
        if(index >= 0) return fruits.get(index);
        return null;
    }
}

所以,是 Fruit 的子类都可以放入水果盘里,如下:

FruitPlateGen<Fruit> fruitPlate = new FruitPlateGen<Fruit>();
fruitPlate.set(new Apple());
fruitPlate.set(new Orange());

现在,James 可以获取盘子,如下:

public class James extends Person {
    public FruitPlateGen getAiFruitPlateGen(FruitPlateGen<Fruit> plate) {
        return new FruitPlateGen();
    }
}

如是,James 想获取放橘子的盘子,如下:

James james = new James();
james.getAiFruitPlateGen(new FruitPlateGen<Fruit>()); //获取成功
james.getAiFruitPlateGen(new FruitPlateGen<Orange>()); //编译器报错

虽然,OrangeFruit 的子类,但是,FruitPlateGen<Orange> 不是 FruitPlateGen<Fruit> 的子类,所以,不能传递产生继承关系。

泛型类和子类型(Generic Classes and Subtyping)

我们可以通过继承(extends)或实现(implements)泛型类或接口,例如:

private static class ExtendFruitPlate<Orange> extends FruitPlateGen<Fruit> {

}

此时,ExtendFruitPlate<Orange> 就是 FruitPlateGen<Fruit> 的子类,James 再去拿盘子,就不会有错误提示:

james.getAiFruitPlateGen(new ExtendFruitPlate<Orange>());

通配符(Wildcards)

我们经常看到类似 List<? extends Number> 的代码,? 就是通配符,表示未知类型。

上限通配符(Upper Bounded Wildcards)

我们可以使用上限通配符来放宽对变量的限制,例如,上文提到的 FruitPlateGen<Fruit>FruitPlateGen<Orange>() 就可以使用上限通配符。

我们来改写一下 getAiFruitPlateGen 方法,如下:

public FruitPlateGen getAiFruitPlateGen2(FruitPlateGen<? extends Fruit> plate) {
    return new FruitPlateGen();
}

这时候,James 想获取放橘子的盘子,如下:

James james = new James();
james.getAiFruitPlateGen2(new FruitPlateGen<Fruit>()); //获取成功
james.getAiFruitPlateGen2(new FruitPlateGen<Orange>()); //获取成功

上限通配符 FruitPlateGen<? extends Fruit> 匹配 FruitFruit 的任何子类型,所以,我们可以传入 AppleOrange 都没有问题。

下限通配符(Lower Bounded Wildcards)

上限通配符将未知类型限定为该类型或其子类型,使用 extends 关键字,而下限通配符将未知类型限定为该类型或其父类型,使用 super 关键字。

我们再来宽展一下 getAiFruitPlateGen 方法,如下:

public FruitPlateGen getAiFruitPlateGen3(FruitPlateGen<? super Apple> plate) {
    return new FruitPlateGen();
}

这时候,James 只能获取 FruitPlateGen<Fruit>FruitPlateGen<Apple> 的盘子,如下:

James james = new James();
james.getAiFruitPlateGen3(new FruitPlateGen<Apple>());
james.getAiFruitPlateGen3(new FruitPlateGen<Fruit>());

下限通配符 FruitPlateGen<? super Apple> 匹配 AppleApple 的任何父类型,所以,我们可以传入 AppleFruit

通配符和子类型(Wildcards and Subtyping)

泛型,继承和子类型 章节有讲到,虽然,OrangeFruit 的子类,但是,FruitPlateGen<Orange> 不是 FruitPlateGen<Fruit> 的子类。但是,你可以使用通配符在泛型类或接口之间创建关系。

我们再来回顾下 Fruit 的继承关系,如图:

代码,如下:

Apple apple = new Apple();
Fruit fruit = apple;

这个代码是没有问题的,FruitApple 的父类,所以,可以把子类赋值给父类。

代码如下:

List<Apple> apples = new ArrayList<>();
List<Fruit> fruits = apples; // 编辑器报错

因为,List<Apple> 不是 List<Fruit> 的子类,实际上这两者无关,那么,它们的关系是什么?如图:

List<Apple>List<Fruit> 的公共父级是 List<?>

我们可以使用上下限通配符,在这些类之间创建关系,如下:

List<Apple> apples = new ArrayList<>();
List<? extends Fruit> fruits1 = apples; // OK
List<? super Apple> fruits2 = apples; // OK

下图展示了上下限通配符声明的几个类的关系,如图:

PECS原则(Producer extends Consumer super)

在上文中有 FruitPlateGen 水果盘子的类,我们尝试使用上下限通配符来实例化水果盘,代码如下:

Apple apple = new Apple();
Orange orange = new Orange();
Fruit fruit = new Fruit();

FruitPlateGen<? extends Fruit> fruitPlateGen = new FruitPlateGen<>();
fruitPlateGen.set(apple); // error
fruitPlateGen.set(orange); // error
fruitPlateGen.set(fruit); // error
Fruit fruit1 = fruitPlateGen.get(); // OK
Orange orange1 = fruitPlateGen.get(); // error
Apple apple1 = fruitPlateGen.get(); // error

上限通配符无法 set 数据,但是,可以 get 数据且只能 get 到其上限 Fruit,所以,上限通配符可以安全的访问数据。

在来看一下代码,如下:

FruitPlateGen<? super Apple> fruitPlateGen1 = new FruitPlateGen<>();
fruitPlateGen1.set(apple); // OK
fruitPlateGen1.set(orange); // error
fruitPlateGen1.set(fruit); // error
Object object = fruitPlateGen1.get(); // OK
Fruit fruit2 = fruitPlateGen1.get(); // error
Apple apple2 = fruitPlateGen1.get(); // error
Orange orange2 = fruitPlateGen1.get(); // error

下限通配符可以且只能 set 其下限 Apple,也可以 get 数据,但只能用 Object 接收(因为Object是所有类型的父类,这是一个特例),所以,下限通配符可以安全的写入数据。

所以,在使用上下限通配符时,可以遵循以下准则:

  • 如果你只需要从集合中获得类型T , 使用<? extends T>通配符
  • 如果你只需要将类型T放到集合中, 使用<? super T>通配符
  • 如果你既要获取又要放置元素,则不使用任何通配符

类型擦除(Type Erasure)

Java 语言使用类型擦除机制实现了泛型,类型擦除机制,如下:

  • 编译器会把所有的类型参数替换为其边界(上下限)或 Object,因此,编译出的字节码中只包含普通类、接口和方法。
  • 在必要时插入类型转换,已保持类型安全
  • 生成桥接方法以在扩展泛型类时保持多态性

泛型类型的擦除(Erasure of Generic Types)

Java 编译器在擦除过程中,会擦除所有类型参数,如果类型参数是有界的,则替换为第一个边界,如果是无界的,则替换为 Object。

我们定义了一个泛型类,代码如下:

public class Node<T> {
  private T data;
  private Node<T> next;
  public Node(T data, Node<T> next) { this.data = data;
  this.next = next;
}
  public T getData() { return data; }
  ...
}

由于类型参数 T 是无界的,因此,Java 编译器将其替换为 Object,如下:

public class Node {
  private Object data;
  private Node next;
  public Node(Object data, Node next) { this.data = data;
  this.next = next;
}
  public Object getData() { return data; }
  ...
}

我们再来定义一个有界的泛型类,代码如下:

public class Node<T extends Comparable<T>> {
  private T data;
  private Node<T> next;
  public Node(T data, Node<T> next) { this.data = data;
  this.next = next;
}
  public T getData() { return data; }
  ...
}

Java 编译器其替换为第一个边界 Comparable,如下:

public class Node {
  private Comparable data;
  private Node next;
  public Node(Comparable data, Node next) { this.data = data;
  this.next = next;
}
  public Comparable getData() { return data; }
  ...
}

泛型方法的擦除(Erasure of Generic Methods)

Java 编译器同样会擦除泛型方法中的类型参数,例如:

public static <T> int count(T[] anArray, T elem) {
  int cnt = 0;
  for (T e : anArray)
}

由于 T 是无界的,因此,Java 编译器将其替换为 Object,如下:

public static int count(Object[] anArray, Object elem) {
  int cnt = 0;
  for (Object e : anArray) if (e.equals(elem))
}

如下代码:

class Shape {  ...  }
class Circle extends Shape {  ...  } 
class Rectangle extends Shape {  ...  }

有一个泛型方法,如下:

public static<T extends Shape> void draw(T shape){
  ...
}

Java 编译器将用第一个边界 Shape 替换 T,如下:

public static void draw(Shape shape){
  ...
}

桥接方法(Bridge Methods)

有时类型擦除会导致无法预料的情况,如下:

public class Node<T> {
  public T data;
  public Node(T data) { this.data = data; }
  public void setData(T data) { 
    System.out.println("Node.setData"); 
    this.data = data;
  } 
}
public class MyNode extends Node<Integer> {
  public MyNode(Integer data) { super(data); }
  public void setData(Integer data) { 
    System.out.println("MyNode.setData"); 
    super.setData(data);
  } 
}

类型擦除后,代码如下:

public class Node {
  public Object data;
  public Node(Object data) { this.data = data; }
  public void setData(Object data) { 
    System.out.println("Node.setData"); 
    this.data = data;
  } 
}
public class MyNode extends Node {
  public MyNode(Integer data) { super(data); }
  public void setData(Integer data) { 
    System.out.println("MyNode.setData");
    super.setData(data);
  } 
}

此时,Node 的方法变为 setData(Object data) 和 MyNode 的 setData(Integer data) 不会覆盖。

为了解决此问题并保留泛型类型的多态性,Java 编译器会生成一个桥接方法,如下:

class MyNode extends Node {
  // 生成的桥接方法
  public void setData(Object data) {
      setData((Integer) data);
  }
  public void setData(Integer data) { 
    System.out.println("MyNode.setData"); 
    super.setData(data);
  }
  ...
}

这样 Node 的方法 setData(Object data) 和 MyNode 生成的桥接方法 setData(Object data) 可以完成方法的覆盖。

泛型的限制(Restrictions on Generics)

为了有效的使用泛型,需要考虑以下限制:

  • 无法实例化具有基本类型的泛型类型
  • 无法创建类型参数的实例
  • 无法声明类型为类型参数的静态字段
  • 无法将Casts或instanceof与参数化类型一起使用
  • 无法创建参数化类型的数组
  • 无法创建,捕获或抛出参数化类型的对象
  • 无法重载每个重载的形式参数类型都擦除为相同原始类型的方法

无法实例化具有基本类型的泛型类型

代码如下:

class Pair<K, V> {
  private K key;
  private V value;
  public Pair(K key, V value) { 
    this.key = key;
    this.value = value; 
  }
  ...
}

创建对象时,不能使用基本类型替换参数类型:

Pair<int, char> p = new Pair<>(8, 'a'); // error

无法创建类型参数的实例

代码如下:

public static <E> void append(List<E> list) {
   E elem = new E(); // error 
   list.add(elem);
}

无法声明类型为类型参数的静态字段

代码如下:

public class MobileDevice<T> {
  private static T os; // error
  ...
}

类的静态字段是所有非静态对象共享的变量,因此,不允许使用类型参数的静态字段。

无法将Casts或instanceof与参数化类型一起使用

代码如下:

public static <E> void rtti(List<E> list) {
  if (list instanceof ArrayList<Integer>) { // error
    ...
  } 
}

Java 编译器会擦除所有类型参数,所有,无法验证在运行时使用的参数化类型。

无法创建参数化类型的数组

代码如下:

List<Integer>[] arrayOfLists = new List<Integer>[2]; // error

无法创建,捕获或抛出参数化类型的对象

代码如下:

class MathException<T> extends Exception {  ...  } // error
class QueueFullException<T> extends Throwable{ ... } // error

无法重载每个重载的形式参数类型都 擦除为相同原始类型的方法

代码如下:

public class Example {
  public void print(Set<String> strSet) { }
  public void print(Set<Integer> intSet) { }
}

print(Set<String> strSet)print(Set<Integer> intSet) 在类型擦除后是完全相同的类型,所以,无法重载。

最后,附上自己的博客和GitHub地址:如下

博客地址:https://h.lishaoy.net
GitHub地址:https://github.com/persilee

查看原文

赞 14 收藏 9 评论 1

子木 发布了文章 · 2020-07-04

Flutter(Flare) 最有趣用户交互动画没有之一

flutter flare cover

2019年12月12日,FlutterFlutter Interact '19 上发布了如何使用 RiveFlutter 制作动态可交互的动画经验分享,我看了之后,觉得非常有趣,因此,写了3个小 demo,把它写成文章记录分享给大家。

名词理解

首先,我们来理解几个名词,不然后续文章,可能看着有些晕,如下:

  • Flare:是 Flutter 的动画插件名称,完整名称是 flare_flutter 我们要在 pubspec.yaml 文件里引入
  • Rive:是制作 Flare 动画的网站,它既是一个网站也是制作工具,在此网站里有很多用户分享 Flare 动画供我们下载使用、Flare API使用文档、制作 Flare 动画的视频教程(大家也可以通过学习制作自己喜欢的动画)等

交互动画预览

登录交互动画

登录交互动画,包含如下6种动画:

  • idle:无任何操作时的状态(熊的身体会上下浮动和眨眼睛)
  • test:当我们在 email 输入框中输入时的状态(熊会看向输入框,且随着你输入的长度旋转头部)
  • hands_up:当我们在 password 输入框中输入时的状态 (熊会用手蒙上眼睛)
  • hands_down:当我们在 password 输入框输入完成时的状态 (熊会放下双手)
  • fail:当我们登录失败时的状态(熊会做出难过的表情)
  • success:当我们登录成功时的状态(熊会做出高兴的表情)

以上6种状态,可以在 Rive 网站查看具体动画,点击进入查看

下面,我们来看看案例里实现动画效果

idle:无任何操作时的状态,如图:

idle

test:当我们在 email 输入框中输入时的状态,如图:

test

hands_up:当我们在 password 输入框中输入时的状态,hands_down:当我们在 password 输入框输入完成时的状态,如图:

hands_up & hands_down

fail:当我们登录失败时的状态,如图:

fail

success:当我们登录成功时的状态,如图:

success

Button交互动画

button 交互动画,如图:

button

Menu交互动画

menu 交互动画,如图:

menu

以上所有动画,也可以 点击观看视频

代码实现

如何用代码实现,分为以下2个步骤:

  • 引入插件和资源:引入相关插件 flare_fluttersmart_flare
  • 编写代码:编写相关代码

引入插件和资源

引入插件和资源,如下:

dependencies:
  ...
  flare_flutter: ^2.0.4  # flare 插件
  smart_flare: any  # 对 flare API进行封装的插件,使用少量的代码即可实现交互动画
  ...

assets:
  ...
  - assets/Teddy.flr
  - assets/button-animation.flr
  - assets/slideout-menu.flr
  ...

编写代码

由于,登录交互动画稍复杂一些,在此就不展示实现的代码,如感兴趣,可移步GitHub查看源码

Button交互动画代码实现

button 交互动画代码实现如下:

import 'package:flutter/material.dart';
import 'package:smart_flare/actors/smart_flare_actor.dart';
import 'package:smart_flare/models.dart';

class FlareButtonDemo extends StatefulWidget {
  @override
  _FlareButtonDemoState createState() => _FlareButtonDemoState();
}

class _FlareButtonDemoState extends State<FlareButtonDemo> {
  @override
  Widget build(BuildContext context) {
    var animationWidth = 295.0;
    var animationHeight = 251.0;
    var animationWidthThirds = animationWidth / 3;
    var halfAnimationHeight = animationHeight / 2;

    var activeAreas = [

      ActiveArea(
        area: Rect.fromLTWH(0, 0, animationWidthThirds, halfAnimationHeight),
        debugArea: false,
        guardComingFrom: ['deactivate'],
        animationName: 'camera_tapped',
      ),

      ActiveArea(
          area: Rect.fromLTWH(animationWidthThirds, 0, animationWidthThirds, halfAnimationHeight),
          debugArea: false,
          guardComingFrom: ['deactivate'],
          animationName: 'pulse_tapped'),

      ActiveArea(
          area: Rect.fromLTWH(animationWidthThirds * 2, 0, animationWidthThirds, halfAnimationHeight),
          debugArea: false,
          guardComingFrom: ['deactivate'],
          animationName: 'image_tapped'),

      ActiveArea(
          area: Rect.fromLTWH(0, animationHeight / 2, animationWidth, animationHeight / 2),
          debugArea: false,
          animationsToCycle: ['activate', 'deactivate'],
          onAreaTapped: () {
            print('Button tapped!');
          })

    ];

    return Scaffold(
      appBar: AppBar(
        title: Text('Flare Button Demo'),
      ),
      body: Container(
        decoration: BoxDecoration(
          gradient: LinearGradient(
              begin: Alignment.topCenter,
              end: Alignment.bottomCenter,
              colors: [
                Color(0x3fffeb3b),
                Colors.orange,
              ]),
        ),
        child: Align(
          alignment: Alignment.bottomCenter,
          child: SmartFlareActor(
            width: animationWidth,
            height: animationHeight,
            filename: 'assets/button-animation.flr',
            startingAnimation: 'deactivate',
            activeAreas: activeAreas,
          ),
        ),
      ),
    );
  }
}

Menu交互动画代码实现

menu 交互动画代码实现,如下:

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

class FlareSidebarMenuDemo extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    print(MediaQuery.of(context).size.height);
    return Scaffold(
      body: Container(
        child: Align(
          alignment: Alignment.centerRight,
          child: PanFlareActor(
            width: MediaQuery.of(context).size.width / 2.366,
            height: MediaQuery.of(context).size.height,
            filename: 'assets/slideout-menu.flr',
            openAnimation: 'open',
            closeAnimation: 'close',
            direction: ActorAdvancingDirection.RightToLeft,
            threshold: 20.0,
            reverseOnRelease: true,
            completeOnThresholdReached: true,
            activeAreas: [
              RelativePanArea(
                  area: Rect.fromLTWH(0, .7, 1.0, .3), debugArea: false),
            ],
          ),
        ),
      ),
    );
  }
}

以上3个交互动画案例的源码,放在了我2年前写的一个 Flutter案例 的项目里了,此项目现已维护起来,以后会长期更新,感兴趣的小伙伴可以收藏,没事时来看看可能会有新的发现 😲

此篇文章到此结束,下篇文章计划给大家分享,Flutter 里的路由,会总结归纳所有的路由使用方法,最后来封装一个优秀的路由管理类。

最后附上博客和项目地址,如下:

博客地址:https://h.lishaoy.net/flutter-flare
项目地址:https://github.com/persilee/flutter_pro

查看原文

赞 7 收藏 3 评论 0

子木 发布了文章 · 2020-06-30

FutureBuilder and StreamBuilder 优雅的构建高质量项目

image

本篇文章将介绍从 setState 开始,到 futureBuilderstreamBuilder 来优雅的构建你的高质量项目,而不引发 setState 带来的副作用,如对文章感兴趣,请 点击查看源码

基础的setState更新数据

首先,我们使用基础的 StatefulWidget 来创建页面,如下:

class BaseStatefulDemo extends StatefulWidget {
  @override
  _BaseStatefulDemoState createState() => _BaseStatefulDemoState();
}

class _BaseStatefulDemoState extends State<BaseStatefulDemo> {
  @override
  Widget build(BuildContext context) {
    return Container();
  }
}

然后,我们使用 Future 来创建一些数据,来模拟网络请求,如下:

  Future<List<String>> _getListData() async {
    await Future.delayed(Duration(seconds: 1)); // 1秒之后返回数据
    return List<String>.generate(10, (index) => '$index content');
  }

initState() 方法中调用 _getListData() 来初始化数据,如下:

  List<String> _pageData = List<String>();

  @override
  void initState() {
    _getListData().then((data) => setState(() {
              _pageData = data;
            }));
    super.initState();
  }

使用 ListView.builder 来处理这些数据构建UI,如下:

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Base Stateful Demo'),
      ),
      body: ListView.builder(
        itemCount: _pageData.length,
        itemBuilder: (buildContext, index) {
          return Column(
            children: <Widget>[
              ListTile(
                title: Text(_pageData[index]),
              ),
              Divider(),
            ],
          );
        },
      ),
    );
  }

最后,我们就可以看到界面了 😎 ,如图:

no-shadow

当然,你也可以将 UI 显示单独提取成一个方法,方便后期维护,使代码层次更清晰,如下:

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Base Stateful Demo'),
      ),
      body: ListView.builder(
        itemCount: _pageData.length,
        itemBuilder: (buildContext, index) {
          return getListDataUi(int index);
        },
      ),
    );
  }

  Widget getListDataUi(int index) {
    return Column(
                children: <Widget>[
                  ListTile(
                    title: Text(_pageData[index]),
                  ),
                  Divider(),
                ],
              );
  }

继续,我们来完善它,正常从后端获取数据,后端应该会给我们返回不同信息,根据这些信息需要处理不同的状态,如:

  • BusyState(加载中):我们在界面上显示一个加载指示器
  • DataFetchedState(数据加载完成):我们延迟2秒,来模拟数据加载完成
  • ErrorState(错误):显示错误提示
  • NoData(没有数据):请求成功,但没有数据,显示提示

先来处理 BusyState 加载指示器,如下:

bool get _fetchingData => _pageData == null; // 判断数据是否为空

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Base Stateful Demo'),
      ),
      body: _fetchingData
          ? Center(
              child: CircularProgressIndicator( // 加载指示器 
                valueColor: AlwaysStoppedAnimation<Color>(Colors.yellow), // 设置指示器颜色
                backgroundColor: Colors.yellow[100],  // 设置背景色
              ),
            )
          : ListView.builder(
              itemCount: _pageData.length,
              itemBuilder: (buildContext, index) {
                return getListDataUi(index);
              },
            ),
    );
  }

效果如图:

no-shadow

接着,我们来处理 ErrorState ,我给 _getListData() 添加 hasError 参数来模拟后端返回的错误,如下

  Future<List<String>> _getListData({bool hasError = false}) async {
    await Future.delayed(Duration(seconds: 1)); // 1秒之后返回数据

    if (hasError) {
      return Future.error('获取数据出现问题,请再试一次');
    }

    return List<String>.generate(10, (index) => '$index content');
  }

然后,在 initState() 方法中捕获异常更新数据,如下:

  @override
  void initState() {
    _getListData(hasError: true)
        .then((data) => setState(() {
              _pageData = data;
            }))
        .catchError((error) => setState(() {
              _pageData = [error];
            }));
    super.initState();
  }

效果如图( 当然这里可以使用一个错误页面来展示 ):

no-shadow

接着,我们来处理 NoData ,我给 _getListData() 添加 hasData 参数来模拟后端返回空数据,如下:

  Future<List<String>> _getListData(
      {bool hasError = false, bool hasData = true}) async {
    await Future.delayed(Duration(seconds: 1));

    if (hasError) {
      return Future.error('获取数据出现问题,请再试一次');
    }

    if (!hasData) {
      return List<String>();
    }

    return List<String>.generate(10, (index) => '$index content');
  }

然后,在 initState() 方法更新数据,如下:

  @override
  void initState() {
    _getListData(hasError: false, hasData: false)
        .then((data) => setState(() {
              if (data.length == 0) {
                data.add('No data fount');
              }
              _pageData = data;
            }))
        .catchError((error) => setState(() {
              _pageData = [error];
            }));
    super.initState();
  }

效果如图:

no-shadow

这就是通过 setState() 来更新数据,是不是很简单,通常情况下我们这么使用是没什么问题,但是,如果我们的页面足够复杂,要处理的状态足够多,我们需要使用更多的 setState() ,意味着我们要更多的代码来更新数据,而且,我们每次 setState() 的时候 build() 方法就会重新执行一次( 这就是上文提到的副作用 )。

其实,Flutter 已经提供了更优雅的方式来更新我们的数据及处理状态,它就是我们接下来要介绍的 futureBuilder

FutureBuilder

FutureBuilder 通过 future: 参数可以接收一个 Future ,并且通过 builder: 参数来构建 UIbuilder: 参数是一个函数,它提供了一个 snapshot 参数里面带着我们需要的状态和数据。

接下来,我们将上面的 StatefulWidget 改成 StatelessWidget ,并使用 FutureBuilder 替换,如下:

class FutureBuilderDemo extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Future Builder Demo'),
      ),
      body: FutureBuilder(
        future: _getListData(),
        builder: (buildContext, snapshot) {
          if (snapshot.hasError) {  // FutureBuilder 已经给我们提供好了 error 状态
            return _getInfoMessage(snapshot.error);
          }

          if (!snapshot.hasData) { // FutureBuilder 已经给我们提供好了空数据状态
            return Center(
              child: CircularProgressIndicator(
                valueColor: AlwaysStoppedAnimation<Color>(Colors.yellow),
                backgroundColor: Colors.yellow[100],
              ),
            );
          }
          var listData = snapshot.data;
          if (listData.length == 0) {
            return _getInfoMessage('No data found');
          }

          return ListView.builder(
            itemCount: listData.length,
            itemBuilder: (buildContext, index) {
              return Column(
                children: <Widget>[
                  ListTile(
                    title: Text(listData[index]),
                  ),
                  Divider(),
                ],
              );
            },
          );
        },
      ),
    );
  }

  ...

通过查看源码,我们可以了解的 FutureBuilder 已经给我处理好了一些基本状态,如图


snapshot

我们使用 _getInfoMessage() 方法来处理状态提示,如下:

  Widget _getInfoMessage(String msg) {
    return Center(
      child: Text(msg),
    );
  }

就这样我们不使用任何一个 setState() 就能完成和上面一样的效果,并且不会产生副作用,是不是很给力 💪。

但是,它并不是完美的,比如,我们想刷新数据,我们需要重新调用 _getListData() 方法,结果它并没有刷新。

StreamBuilder

StreamBuilder 通过 stream: 参数可以接收一个 stream ,同样,通过 builder: 参数来构建 UI ,和 futureBuilder 用法类似,唯一的好处就是,我们可以随意控制 stream 的输入输出,添加任何的状态来更新指定状态下的 UI

首先,我们使用 enum 来表示我们的状态,在文件的头部添加它,如下:

enum StreamViewState { Busy, DataRetrieved, NoData }

接着,使用 StreamController 创建一个流控制器,把 FutureBuilder 替换成 StreamBuilder ,把 future: 参数 改成 stream: 参数,如下:


final StreamController<StreamDemoState> _stateController = StreamController<StreamDemoState>();

@override
  Widget build(BuildContext context) {
    return Scaffold(

      ...

      body: StreamBuilder(
        stream: model.homeState,
        builder: (buildContext, snapshot) {
          if (snapshot.hasError) {
            return _getInfoMessage(snapshot.error);
          }
          // 使用 枚举的 Busy 来更新数据
          if (!snapshot.hasData || StreamViewState.Busy) {
            return Center(
              child: CircularProgressIndicator(
                valueColor: AlwaysStoppedAnimation<Color>(Colors.yellow),
                backgroundColor: Colors.yellow[100],
              ),
            );
          }
          //使用 枚举的 NoData 来更新数据
          if (listItems.length == StreamViewState.NoData) {
            return _getInfoMessage('No data found');
          }

          return ListView.builder(
            itemCount: listItems.length,
            itemBuilder: (buildContext, index) {
              return Column(
                children: <Widget>[
                  ListTile(
                    title: Text(listItems[index]),
                  ),
                  Divider(),
                ],
              );
            },
          );
        },
      ),
    );
  }

只是新增了枚举值来判断是否需要更新数据,其他基本保持不变。

接下来,我需要修改 _getListData() 方法,使用流控制器添加状态及数据,如下:

  Future _getListData({bool hasError = false, bool hasData = true}) async {
    _stateController.add(StreamViewState.Busy);
    await Future.delayed(Duration(seconds: 2));

    if (hasError) {
      return _stateController.addError('error'); // 往 stream 里新增 error 数据
    }

    if (!hasData) {
      return _stateController.add(StreamViewState.NoData); // 往 stream 里新增无数据状态
    }

    _listItems = List<String>.generate(10, (index) => '$index content');
    _stateController.add(StreamViewState.DataRetrieved); // 往 stream 里新增数据获取完成状态
  }

此时我们并没有返回数据,所以我们需要创建 listItems 存储数据,然后把 StatelessWidget 改成 StatefulWidget ,以便我们根据 stream 的输出来更新数据,这个转换非常方便,VS Code 编辑器可以使用 Option + Shift + R (Mac)或者 Ctrl + Shift + R (Win)快捷键 ,Android Studio 使用Option + Enter 快捷键,之后在 initState() 方法中初始化数据,如下:

List<String> listItems;

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

到这里我们已经解决了 FutureBuilder 的局限性问题,我们可以新增一个 FloatingActionButton 来刷新数据,如下:

@override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Stream Builder Demo'),
      ),
      floatingActionButton: FloatingActionButton(
        backgroundColor: Colors.yellow,
        child: Icon(
          Icons.cached,
          color: Colors.black87,
        ),
        onPressed: () {
          model.dispatch(FetchData());
        },
      ),
      body: StreamBuilder(

        ...
        
      ),
    );
  }

现在,点击 FloatingActionButton 加载指示器已经显示,但是,我们的 listItems 数据并没真正的更新,点击 FloatingActionButton 只是更新的加载状态而已,而且我们的业务逻辑代码和 UI 代码还在同一个文件中,很显然,他们已经解耦,所以,我们可以继续完善它,将业务逻辑代码和 UI 代码分离出来。

分离业务逻辑代码和 UI 代码

我们可以把处理 stream 的代码抽离成一个类,如下:

import 'dart:async';
import 'dart:math';

import 'package:pro_flutter/demo/stream_demo/stream_demo_event.dart';
import 'package:pro_flutter/demo/stream_demo/stream_demo_state.dart';


enum StreamViewState { Busy, DataRetrieved, NoData }

class StreamDemoModel {
  final StreamController<StreamDemoState> _stateController = StreamController<StreamDemoState>();

  List<String> _listItems;

  Stream<StreamDemoState> get streamState => _stateController.stream;

  void dispatch(StreamDemoEvent event){
    print('Event dispatched: $event');
    if(event is FetchData) {
      _getListData(hasData: event.hasData, hasError: event.hasError);
    }
  }

  Future _getListData({bool hasError = false, bool hasData = true}) async {
    _stateController.add(BusyState());
    await Future.delayed(Duration(seconds: 2));

    if (hasError) {
      return _stateController.addError('error');
    }

    if (!hasData) {
      return _stateController.add(DataFetchedState(data: List<String>()));
    }

    _listItems = List<String>.generate(10, (index) => '$index content');
    _stateController.add(DataFetchedState(data: _listItems));
  }
}

然后,把状态也封装成一个文件且将数据和状态关联,如下:

class StreamDemoState{}

class InitializedState extends StreamDemoState {}

class DataFetchedState extends StreamDemoState {
  final List<String> data;

  DataFetchedState({this.data});

  bool get hasData => data.length > 0;
}

class ErrorState extends StreamDemoState{}

class BusyState extends StreamDemoState{}

再封装一个事件文件,如下:

class StreamDemoEvent{}

class FetchData extends StreamDemoEvent{
  final bool hasError;
  final bool hasData;

  FetchData({this.hasError = false, this.hasData = true});

  @override
  String toString() {
    return 'FetchData { hasError: $hasError, hasData: $hasData }';
  }
}

最后,我们 UI 部分的代码如下:

class _StreamBuilderDemoState extends State<StreamBuilderDemo> {
  final model = StreamDemoModel(); // 创建 model

  @override
  void initState() {
    model.dispatch(FetchData(hasData: true)); // 获取 model 里的数据
    super.initState();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(

      ...

      body: StreamBuilder(
        stream: model.streamState,
        builder: (buildContext, snapshot) {
          if (snapshot.hasError) {
            return _getInformationMessage(snapshot.error);
          }

          var streamState = snapshot.data;

          if (!snapshot.hasData || streamState is BusyState) {  // 通过封装的状态类来判断是否更新UI
            return Center(
              child: CircularProgressIndicator(
                valueColor: AlwaysStoppedAnimation<Color>(Colors.yellow),
                backgroundColor: Colors.yellow[100],
              ),
            );
          }

          if (streamState is DataFetchedState) { // 通过封装的状态类来判断是否更新UI
            if (!homeState.hasData) {
              return _getInformationMessage('not found data');
            }
          }
          return ListView.builder(
            itemCount: streamState.data.length,  // 此时,数据不再是本地数据,而是从 stream 中输出的数据
            itemBuilder: (buildContext, index) =>
                _getListItem(index, streamState.data),
          );
        },
      ),
    );
  }

  ...

}

此时,业务逻辑代码和 UI 代码已完全分离,且可扩展性和维护增强,且我们的数据和状态已关联起来,此时,点击 FloatingActionButton 效果和上面一样,且数据已更新。

最后附上我的博客、GitHub地址:

博客地址:https://h.lishaoy.net/futruebuilder-streambuilder
GitHub地址:https://github.com/persilee/flutter_pro

查看原文

赞 4 收藏 2 评论 2

子木 发布了文章 · 2020-06-19

Android Flutter 混合开发高仿大厂App

自上篇 Flutter 10天高仿大厂App及小技巧积累总结 的续篇,这次更是干货满满。

这篇文章将概述 Android组件化的架构搭建FlutterAndroid 如何混合开发 (整个App只有首页是用原生Android完成,其他页面都是引入之前的做好的Flutter页面) ,主宿主程序由 Android 搭建,采用了组件化的架构搭建整个 App ,不同业务,对应不同的 module 工程,业务之间采用接口通信 (ARouter) ,以 module 的形式混入 Flutter,通过 MethodChannelFlutter 端进行数据通信等,且这些功能实现源码开源,感兴趣的小伙伴可以移步至 GitHub


以下博文会分为4个部分概述:

  • 项目完成的功能预览
  • 项目组件化结构分析
  • 项目功能详细概述(所用知识点)
  • Android Flutter 混合开发

项目完成的功能预览

首先,我们还是通过一个视频来快速预览下项目完成的功能和运行效果,如下

大家也可以 点击观看视频(点击齿轮 --> 更多播放设置,可以隐藏黑边)

看完视频后,其实大部分功能和之前的 纯flutter项目 功能相同,只是首页新增了4个tab推荐页面及携程二楼和布局改变。

大家也可扫描,安装体验:

手机扫描二维码安装

项目组件化结构分析

项目结构图预览

其次,分析梳理下项目结构,项目的结构大致如图,还有一些细枝末节的没有体现在图里:

project structure

项目结构分析

业务工程

把具体独立的业务都拆分成单独的 module 减小项目的维护压力

  • ft_home: 首页模块,这个模块其实还可以继续拆分,可把4个 tab (精选、附近、景点、美食) 页都拆成模块,这里我暂时没有拆分,后续会完成
  • ft_destination: 目的地模块,其实并没有建立这个模块,因为直接引入了之前做好的 flutter 页面
  • ft_travel: 旅拍模块,同样也使用了 flutter 页面
  • flutter: flutter模块,这个模块是从 flutter_module 中自动生成的,后面介绍到

基础库工程

把具体的功能都封装成独立的库供业务模块使用,降低项目的维护成本及代码之间耦合性

  • lib_network: 网络库,使用 okhttp 插件二次封装,业务层简单的调用即可
  • lib_webview: 打开网页的webview库,使用了 agentweb 插件二次封装,业务层只需要一句代码即可完成网页的跳转
  • lib_image_loader: 图片加载库,使用了 glide 插件二次封装,业务层只需一句代码即可加载不同参数的图片
  • lib_asr: 百度AI语音库,通过 Android 集成好供 Flutter 端使用
  • lib_common_ui: 公共UI库,重复多次使用的页面集中管理
  • lib_base: 基础库,通过 ARouter 的 service 功能暴露接口提供服务给业务层,当然业务层也可以在这里暴露接口供外界使用

这里有一些使用的插件并没有在项目结构图里体现出来(结构图空间有限)。

插件

在这里把项目使用的插件整理列举出来供大家参考:

  • magicindicator 强大、可定制、易扩展的 ViewPager 指示器框架,首页的4个 tab (精选、附近、景点、美食) 就是用这个实现的。
  • immersionbar 一句代码轻松实现状态栏、导航栏沉浸式管理
  • pagerBottomTabStrip 页面底部和侧边的导航栏,首页、目的地、旅拍、我的页面切换就是用这个实现的。
  • rxjava/rxandroid 异步和链式编程
  • butterknife view注入插件,配合Android插件使用,可快速自动生成 init view的代码,不用写一句 findViewById 的代码。
  • gson json解析,配合Android插件使用,可快速生成实体类
  • smartRefreshLayout 智能下拉刷新框架,携程二楼及下拉刷新加载更多就是用这个实现的
  • eventbus 发布/订阅事件总线,优雅的完成组件之间通信
  • arouter 依赖注入、路由跳转、注册service,优雅的完成模块之间的通信
  • okhttp 网络请求插件
  • agentweb webview框架,进行简单的二次封装可优雅的进行网页跳转
  • glide 高性能、可扩展的图片加载插件
  • banner 图片轮播控件

基本就是这些了,应该没有漏的,插件的详细使用,请进入各插件的 GitHub 主页。

在此,把我项目的插件引入代码及版本管理的 gradle 代码贴出来,如下:

插件引入代码:

dependencies {
    implementation fileTree(dir: 'libs', include: ['*.jar'])

    implementation rootProject.depsLibs.appcompat
    implementation rootProject.depsLibs.legacy
    implementation rootProject.depsLibs.recyclerview
    implementation rootProject.depsLibs.constraintlayout
    implementation rootProject.depsLibs.cardview

    //tab指示器
    implementation rootProject.depsLibs.magicindicator
    //沉浸式
    implementation rootProject.depsLibs.immersionbar
    //导航栏
    implementation rootProject.depsLibs.pagerBottomTabStrip
    //rxjava
    implementation rootProject.depsLibs.rxjava
    //rxandroid
    implementation rootProject.depsLibs.rxandroid
    //view 注入
    implementation rootProject.depsLibs.butterknife
    //view 注入
    annotationProcessor rootProject.depsLibs.butterknifeCompiler
    //gson
    implementation rootProject.depsLibs.gson
    //banner
    implementation rootProject.depsLibs.banner
    //smartRefreshLayout 上下拉刷新
    implementation rootProject.depsLibs.smartRefreshLayout
    implementation rootProject.depsLibs.refreshHeader
    implementation rootProject.depsLibs.refreshHeaderTwoLevel
    implementation rootProject.depsLibs.refreshFooter
    //eventbus
    implementation rootProject.depsLibs.eventbus
    //arouter库
    implementation(rootProject.depsLibs.arouterapi) {
        exclude group: 'com.android.support'
    }
    annotationProcessor rootProject.depsLibs.aroutercompiler

    //引入home模块
    implementation project(':ft_home')
    //引入图片加载库
    implementation project(':lib_image_loader')
    //引入网络库
    implementation project(':lib_network')
    //webview
    implementation project(':lib_webview')
    //引入基础ui库
    implementation project(':lib_common_ui')
    //base库
    implementation project(':lib_base')
    //引入flutter模块
    implementation project(':flutter')
    //引入百度AI语音库
    implementation project(':lib_asr')
}

版本管理代码 (统一管理版本号) :

ext {
    android = [
            compileSdkVersion: 29,
            buildToolsVersion: "29.0.0",
            minSdkVersion    : 19,
            targetSdkVersion : 29,
            applicationId    : 'net.lishaoy.android_ctrip',
            versionCode      : 1,
            versionName      : '1.0',
            multiDexEnabled  : true,
    ]

    depsVersion = [
            appcompat            : '1.1.0',
            legacy               : '1.0.0',
            recyclerview         : '1.0.0',
            constraintlayout     : '1.1.3',
            cardview             : '1.0.0',
            magicindicator       : '1.5.0',
            immersionbar         : '3.0.0',
            pagerBottomTabStrip  : '2.3.0X',
            glide                : '4.11.0',
            glidecompiler        : '4.11.0',
            butterknife          : '10.2.1',
            butterknifeCompiler  : '10.2.1',
            rxjava               : '3.0.0',
            rxandroid            : '3.0.0',
            okhttp               : '4.7.2',
            okhttpLogging        : '4.7.2',
            gson                 : '2.8.6',
            banner               : '2.0.10',
            smartRefreshLayout   : '2.0.1',
            refreshHeader        : '2.0.1',
            refreshFooter        : '2.0.1',
            refreshHeaderTwoLevel: '2.0.1',
            eventbus             : '3.2.0',
            agentweb             : '4.1.3',
            arouterapi           : '1.5.0',
            aroutercompiler      : '1.2.2',

    ]

    depsLibs = [
            appcompat            : "androidx.appcompat:appcompat:${depsVersion.appcompat}",
            legacy               : "androidx.legacy:legacy-support-v4:${depsVersion.legacy}",
            recyclerview         : "androidx.recyclerview:recyclerview:${depsVersion.recyclerview}",
            constraintlayout     : "androidx.constraintlayout:constraintlayout:${depsVersion.constraintlayout}",
            cardview             : "androidx.cardview:cardview:${depsVersion.cardview}",
            magicindicator       : "com.github.hackware1993:MagicIndicator:${depsVersion.magicindicator}",
            immersionbar         : "com.gyf.immersionbar:immersionbar:${depsVersion.immersionbar}",
            pagerBottomTabStrip  : "me.majiajie:pager-bottom-tab-strip:${depsVersion.pagerBottomTabStrip}",
            glide                : "com.github.bumptech.glide:glide:${depsVersion.glide}",
            glidecompiler        : "com.github.bumptech.glide:compiler:${depsVersion.glidecompiler}",
            butterknife          : "com.jakewharton:butterknife:${depsVersion.butterknife}",
            butterknifeCompiler  : "com.jakewharton:butterknife-compiler:${depsVersion.butterknifeCompiler}",
            rxjava               : "io.reactivex.rxjava3:rxjava:${depsVersion.rxjava}",
            rxandroid            : "io.reactivex.rxjava3:rxandroid:${depsVersion.rxandroid}",
            okhttp               : "com.squareup.okhttp3:okhttp:${depsVersion.okhttp}",
            okhttpLogging        : "com.squareup.okhttp3:logging-interceptor:${depsVersion.okhttpLogging}",
            gson                 : "com.google.code.gson:gson:${depsVersion.gson}",
            banner               : "com.youth.banner:banner:${depsVersion.banner}",
            smartRefreshLayout   : "com.scwang.smart:refresh-layout-kernel:${depsVersion.smartRefreshLayout}",
            refreshHeader        : "com.scwang.smart:refresh-header-classics:${depsVersion.refreshHeader}",
            refreshHeaderTwoLevel: "com.scwang.smart:refresh-header-two-level:${depsVersion.refreshHeader}",
            refreshFooter        : "com.scwang.smart:refresh-footer-classics:${depsVersion.refreshFooter}",
            eventbus             : "org.greenrobot:eventbus:${depsVersion.eventbus}",
            agentweb             : "com.just.agentweb:agentweb:${depsVersion.agentweb}",
            arouterapi           : "com.alibaba:arouter-api:${depsVersion.arouterapi}",
            aroutercompiler      : "com.alibaba:arouter-compiler:${depsVersion.aroutercompiler}",
    ]
}

项目功能详细概述(所用知识点)

这里主要对首页功能及知识点进行概述,由于其他页面是引入了之前的 Flutter 页面, 具体功能在 Flutter 10天高仿大厂App及小技巧积累总结 已经介绍过了,在这就不再阐述。

首页重点概述以下功能的实现:

  • 下拉刷新、携程二楼
  • 搜索appBar
  • 渐变色网格导航
  • banner组件
  • 多状态的tab指示器 (滚动固定顶部)

下拉刷新、携程二楼

首先,看看具体的效果图,如图:

second floor

下拉刷新和携程二楼是使用 smartRefreshLayout 插件完成的,实现代码如下:

private void initRefreshMore() {
    homeHeader.setRefreshHeader(new ClassicsHeader(getContext()), -1, (int) Utils.dp2px(76)); //设置下拉刷新及二楼header的高度
    homeHeader.setFloorRate(1.6f); //设置二楼触发比率
    homeRefreshContainer.setPrimaryColorsId(R.color.colorPrimary, R.color.white); //设置下拉刷新及二楼提示文字颜色
    homeRefreshContainer.setOnMultiListener(new SimpleMultiListener() {
        @Override
        public void onLoadMore(@NonNull RefreshLayout refreshLayout) {
            loadMore(refreshLayout); //加载更多
        }

        @Override
        public void onRefresh(@NonNull RefreshLayout refreshLayout) {
            refreshLayout.finishRefresh(1600); //设置下拉刷新延迟
        }

        @Override
        public void onHeaderMoving(RefreshHeader header, boolean isDragging, float percent, int offset, int headerHeight, int maxDragHeight) {
            homeSecondFloorImg.setVisibility(View.VISIBLE);  //隐藏二楼背景图
            homeSearchBarContainer.setAlpha(1 - Math.min(percent, 1)); //改变searchBar透明度
        }

        @Override
        public void onStateChanged(@NonNull RefreshLayout refreshLayout, @NonNull RefreshState oldState, @NonNull RefreshState newState) {
            if (oldState == RefreshState.ReleaseToTwoLevel) {  //即将去往二楼状态处理
                homeSecondFloorImg.setVisibility(View.GONE);
                homeHeaderContent.animate().alpha(1).setDuration(666);
            } else if (newState == RefreshState.PullDownCanceled) { //下拉取消状态处理
                homeHeaderContent.animate().alpha(0).setDuration(666);
            } else if (newState == RefreshState.Refreshing) { //正在刷新状态处理
                homeHeaderContent.animate().alpha(0).setDuration(666);
            } else if (oldState == RefreshState.TwoLevelReleased) { // 准备去往二楼完成状态处理,这里打开webview
                WebViewImpl.getInstance().gotoWebView("https://m.ctrip.com/webapp/you/tsnap/secondFloorIndex.html?isHideNavBar=YES&s_guid=feb780be-c55a-4f92-a6cd-2d81e04d3241", true);
                homeHeader.finishTwoLevel();
            } else if (oldState == RefreshState.TwoLevel) { //到达二楼状态处理
                homeCustomScrollView.setVisibility(View.GONE);
                homeHeaderContent.animate().alpha(0).setDuration(666);
            } else if (oldState == RefreshState.TwoLevelFinish) { //二楼完成状态处理
                homeCustomScrollView.setVisibility(View.VISIBLE);
                homeCustomScrollView.animate().alpha(1).setDuration(666);
            }
        }

    });

}

XML 页面布局文件代码如下:

<com.scwang.smart.refresh.layout.SmartRefreshLayout
    android:id="@+id/home_refresh_container"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:clipChildren="false"
    app:srlAccentColor="@color/colorPrimary"
    app:srlPrimaryColor="@color/colorPrimary">
    <com.scwang.smart.refresh.header.TwoLevelHeader
        android:id="@+id/home_header"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:gravity="top">

        <ImageView
            android:id="@+id/home_second_floor_img"
            android:layout_width="match_parent"
            android:layout_height="460dp"
            android:layout_alignTop="@+id/home_header"
            android:scaleType="fitXY"
            android:data-original="@drawable/second_floor"
            android:visibility="gone"/>
        <FrameLayout
            android:id="@+id/home_header_content"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:alpha="0">
            <ImageView
                android:layout_width="match_parent"
                android:layout_height="match_parent"
                android:scaleType="fitXY"
                android:data-original="@drawable/second_floor" />
        </FrameLayout>

    </com.scwang.smart.refresh.header.TwoLevelHeader>
    
    ...

    <com.scwang.smart.refresh.footer.ClassicsFooter
        android:layout_width="match_parent"
        android:layout_height="wrap_content" />

</com.scwang.smart.refresh.layout.SmartRefreshLayout>

具体实现详情,可移步 GitHub 查看源码。

搜索appBar

搜索栏的滚动的 placeholder 文字是使用 banner 插件实现的,点击搜索框可跳转到搜索页面 (flutter写的搜索页面) ,跳转页面后可以把 placeholder 文字带到 flutter 搜索页面。

效果如图:

search bar

滚动的placeholder文字实现代码如下 (搜索框的实现就不再这里展示都是一些XML布局代码)

        homeSearchBarPlaceholder
                .setAdapter(new HomeSearchBarPlaceHolderAdapter(homeData.getSearchPlaceHolderList())) // 设置适配器
                .setOrientation(Banner.VERTICAL) // 设置滚动方向
                .setDelayTime(3600) // 设置间隔时间
                .setOnBannerListener(new OnBannerListener() {
                    @Override
                    public void OnBannerClick(Object data, int position) {  //点击打开 flutter 搜索页面
                        ARouter.getInstance()
                                .build("/home/search")
                                .withString("placeHolder", ((Home.SearchPlaceHolderListBean) data).getText())
                                .navigation();
                    }
                });
    }

searchBar的具体功能不过多阐述,和之前的项目一致。

渐变色网格导航

渐变色网格导航基本都是一些 XML 页面布局代码,只是我把它封装成了单独的组件,效果如图

GridNav

封装之后的引入就非常简单,代码如下:

<LinearLayout
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:orientation="vertical"
    android:background="@color/white">

    <!-- 网格导航 -->
    <net.lishaoy.ft_home.GridNavView
        android:id="@+id/home_grid_nav_container"
        android:layout_width="match_parent"
        android:layout_height="wrap_content" />

    ...

</LinearLayout>

具体实现详情,可移步 GitHub 查看源码。

banner组件

banner组件也是用 banner 插件实现的,如图

banner

实现代码如下:

private void initBanner() {
    homeBanner.addBannerLifecycleObserver(this)
            .setAdapter(new HomeBannerAdapter(homeData.getBannerList())) //设置适配器
            .setIndicator(new EllipseIndicator(getContext()))           //设置指示器,如图的指示器是我自定义的插件里并没有提供
            .setIndicatorSelectedColorRes(R.color.white)                //设置指示器颜色
            .setIndicatorSpace((int) BannerUtils.dp2px(10))             //设置间距
            .setBannerRound(BannerUtils.dp2px(6));                      //设置圆角

}

多状态的tab指示器

多状态的tab指示器的实现需要注意很多细节,因为它是在首页的 fragmentScrollView 里嵌入 viewPaper,首先你会发现 viewPaper 不显示的问题,其次是滚动不流畅的问题,这两个问题我的解决方案是:

  • viewPaper 不显示的问题:使用自定义的 ViewPager 重写 onMeasure 方法,重新计算高度
  • 滚动不流畅的问题:使用自定义的 ScrollView,重写 computeScrollonScrollChanged 重新获取滚动距离

实现效果如图:

tab page

这个功能实现代码过多不便在这里展示,具体实现详情,可移步 GitHub 查看源码。

Android Flutter 混合开发

这个项目的实现只有首页是用 Android 原生实现,其他的页面均是 Flutter 实现的,之前 纯Flutter项目

Android 引入 Flutter 进行混合开发,需要以下几个步骤

  • 建立一个flutter module
  • 编写flutter代码 (创建 flutter 路由)
  • flutter 和 android 之间相互通信

下面依次概述这几部分是如何操作实现的。

建立一个flutter module

这个应该不用过多描述,基本操作大家都会 File --> New --> New Module 如图:

no-shadow

新建完成之后,android studio 会自动生成配置代码到 gradle 配置文件里,且生成一个 flutter 的 library 模块。

Tips:
新建的时候最好 flutter module 和 android 项目放到同级目录下;
新版的 android studio 才会自动生成 gradle 配置代码,老版本貌似需要手动配置

如,没有生成 gradle 配置代码,你需要在根项目的 settings.gradle 文件里手动加入如下配置:

setBinding(new Binding([gradle: this]))
evaluate(new File(
  settingsDir, //设置根路径,根据具体flutter module路径配置
  'flutter_module/.android/include_flutter.groovy'
))

include ':flutter_module'

还需在宿主工程 (没改名的话都是app)build.gradle 引入 flutter, 如下:

dependencies {
    ...
    //引入flutter模块
    implementation project(':flutter')
    ...
}

编写flutter代码

编写flutter代码,在 flutter module 里按照正常 flutter 开发流程编写 flutter 代码即可。 (我项目里的 flutter 的代码是之前项目都写好的,复制过来,改改包的引入问题,就可以运行了。)

这里需要注意的是,flutter 有且只有一个入口,就是 main() 函数,我们需要在这里处理好 flutter 页面的跳转问题。

在 android 端,创建 flutter 页面,代码如下:

    Flutter.createView(getActivity(),getLifecycle(),"destination");

Flutter.createView 需要3个参数 activitylifecycleroute ,这个 route 就是要传递到 flutter 端的,当然,它是 String 类型的,我们可以自由发挥传递普通字符串或 json 字符串等。

我们也可以通过其他的方式创建 flutter 页面,如: Flutter.createFragment()FlutterActivity.withNewEngine()FlutterFragment.createDefault() 等。

具体的使用,可前往 Flutter官方文档 查阅。

那么,flutter 端如何接收这个 route 参数,是通过 window.defaultRouteName,此项目里管理 flutter 端路由代码如下:

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      title: 'Flutter model',
      theme: ThemeData(
        primarySwatch: Colors.blue,
        fontFamily: 'PingFang',
      ),
      home: _widgetRoute(window.defaultRouteName), // 通过 window.defaultRouteName 接收 android 端传来的参数
    );
  }
}

Widget _widgetRoute(String defaultRouteName) {
    Map<String, dynamic> params = convert.jsonDecode(defaultRouteName); //解析参数
    defaultRouteName = params['routeName'];
    placeHolder = params['placeHolder'];

    switch (defaultRouteName) { // 根据参数返回对应的页面
        ...
        case 'destination/search':
            return DestinationSearchPage(
                hideLeft: false,
        );
        ...
        default:
            return Center(
                child: Column(
                mainAxisAlignment: MainAxisAlignment.center,
                children: <Widget>[
                    Text('not found $defaultRouteName',
                        textDirection: TextDirection.ltr),
                ],
                ),
            );
    }
}

其实,flutter 端接收这个 route 参数,还有一种方法,就是通过 onGenerateRoute,它是 MaterialApp 里的一个方法。

代码如下:

onGenerateRoute: (settings){ //通过 settings.name 获取android端传来的参数
    return _widgetRoute(settings.name);
},

flutter 和 android 之间相互通信

flutter 端可以调用 android 端的方法及相互传递数据是如何实现的,flutter 官方提供了3个方法可以实现,分别是:

  • EventChannel:单向的持续通信,如:网络变化、传感器等。
  • MethodChannel:一次性通信,一般适用如方法的调用。
  • BasicMessageChannel:持续的双向通信。

此项目里采用了 MethodChannel 方法进行通信,如:flutter 端调用 android 端的AI智能语音方法以及 flutter 打开 android 端页面就是用 MethodChannel 实现的。

flutter 端调用 android 端的AI智能语音方法代码如下:

class AsrManager {
  static const MethodChannel _channel = const MethodChannel('lib_asr');
  //开始录音
  static Future<String> start({Map params}) async {
    return await _channel.invokeMethod('start', params ?? {});
  }
  //停止录音
    ...
  //取消录音
    ...
  //销毁
    ...
}

flutter 打开 android 端页面代码如下:

class MethodChannelPlugin {

  static const MethodChannel methodChannel = MethodChannel('MethodChannelPlugin');

  static Future<void> gotoDestinationSearchPage() async {
    try {
      await methodChannel.invokeMethod('gotoDestinationSearchPage'); //gotoDestinationSearchPage 参数会传到android端
    } on PlatformException {
      print('Failed go to gotoDestinationSearchPage');
    }
  }
    ...
}

android 接收也是通过 MethodChannel ,具体实现代码如下:

public class MethodChannelPlugin implements MethodChannel.MethodCallHandler {

    private static MethodChannel methodChannel;
    private Activity activity;

    private MethodChannelPlugin(Activity activity) {
        this.activity = activity;
    }

    //调用方通过 registerWith 来注册flutter页面
    public static void registerWith(FlutterView flutterView) {
        methodChannel = new MethodChannel(flutterView, "MethodChannelPlugin");
        MethodChannelPlugin instance = new MethodChannelPlugin((Activity) flutterView.getContext());
        methodChannel.setMethodCallHandler(instance);
    }

    @Override
    public void onMethodCall(MethodCall methodCall, MethodChannel.Result result) {
        if (methodCall.method.equals("gotoDestinationSearchPage")) { // 收到消息进行具体操作
            EventBus.getDefault().post(new GotoDestinationSearchPageEvent());
            result.success(200);
        } 
        ...
        else {
            result.notImplemented();
        }
    }
}

android flutter 混合开发基本就是这3个步骤,其他一些细节及具体的流程请参考 GitHub 项目源码。

最后附上项目地址和博客地址:

项目地址:https://github.com/persilee/android_ctrip
博客地址:https://h.lishaoy.net/androidctrip

查看原文

赞 13 收藏 8 评论 3

子木 发布了文章 · 2020-05-12

Flutter 10天高仿大厂App及小技巧积累总结

2880040dd20bac8885929d5859b5e525.jpg

之前,也写过几篇关于 Flutter 的博文,最近,又花了一些时间学习研究 Flutter,完成了高仿大厂 App 项目 (项目使用的接口都是来自线上真实App抓包而来,可以做到和线上项目相同的效果),也总结积累了一些小技巧和知识点,所以,在这里记录分享出来,也希望 Flutter 生态越来越好 (flutter开发App效率真的很高,开发体验也是很好的 🙂)

以下博文会分为3个部分概述:

  • 项目结构分析
  • 项目功能详细概述(所用知识点)
  • 小技巧积累总结

项目结构分析

其次,梳理下项目的目录结构,理解每个文件都是干什么的,我们先来看看一级目录,如下:

├── README.md  # 描述文件
├── android    # android 宿主环境
├── build      # 项目构建目录,由flutter自动完成
├── flutter_ctrip.iml
├── fonts      # 自己创建的目录,用于存放字体
├── images     # 自己创建的目录,用于存放图片
├── ios        # iOS 宿主环境
├── lib        # flutter 执行文件,自己写的代码都在这
├── pubspec.lock # 用来记录锁定插件版本
├── pubspec.yaml # 插件及资源配置文件
└── test       # 测试目录

这个就不用多解释,大多是 flutter 生成及管理的,我们需要关注的是 lib 目录。

我们再来看看二级目录,如下 (重点关注下lib目录)

├── README.md
├── android
│   ├── android.iml
  ...
│   └── settings.gradle
├── build
│   ├── app
  ...
│   └── snapshot_blob.bin.d.fingerprint
├── flutter_ctrip.iml
├── fonts
│   ├── PingFang-Italic.ttf
│   ├── PingFang-Regular.ttf
│   └── PingFang_Bold.ttf
├── images
│   ├── grid-nav-items-dingzhi.png
  ...
│   └── yuyin.png
├── ios
│   ├── Flutter
  ...
│   └── ServiceDefinitions.json
├── lib
│   ├── dao           # 请求接口的类
│   ├── main.dart     # flutter 入口文件
│   ├── model         # 实体类,把服务器返回的 json 数据,转换成 dart 类
│   ├── navigator     # bottom bar 首页底部导航路由
│   ├── pages         # 所以的页面
│   ├── plugin        # 封装的插件
│   ├── util          # 工具类,避免重复代码,封装成工具类以便各个 page 调用
│   └── widget        # 封装的组件
├── pubspec.lock
├── pubspec.yaml
└── test
    └── widget_test.dart

再来看看,lib 目录下二级目录,看看整个项目创建了多少个文件,写了多少代码,如下 (其实,并不是很多)

├── dao/
│   ├── destination_dao.dart*
│   ├── destination_search_dao.dart*
│   ├── home_dao.dart
│   ├── search_dao.dart*
│   ├── trave_hot_keyword_dao.dart*
│   ├── trave_search_dao.dart*
│   ├── trave_search_hot_dao.dart*
│   ├── travel_dao.dart*
│   ├── travel_params_dao.dart*
│   └── travel_tab_dao.dart*
├── main.dart
├── model/
│   ├── common_model.dart
│   ├── config_model.dart
│   ├── destination_model.dart
│   ├── destination_search_model.dart
│   ├── grid_nav_model.dart
│   ├── home_model.dart
│   ├── sales_box_model.dart
│   ├── seach_model.dart*
│   ├── travel_hot_keyword_model.dart
│   ├── travel_model.dart*
│   ├── travel_params_model.dart*
│   ├── travel_search_hot_model.dart
│   ├── travel_search_model.dart
│   └── travel_tab_model.dart
├── navigator/
│   └── tab_navigater.dart
├── pages/
│   ├── destination_page.dart
│   ├── destination_search_page.dart
│   ├── home_page.dart
│   ├── my_page.dart
│   ├── search_page.dart
│   ├── speak_page.dart*
│   ├── test_page.dart
│   ├── travel_page.dart
│   ├── travel_search_page.dart
│   └── travel_tab_page.dart*
├── plugin/
│   ├── asr_manager.dart*
│   ├── side_page_view.dart
│   ├── square_swiper_pagination.dart
│   └── vertical_tab_view.dart
├── util/
│   └── navigator_util.dart*
└── widget/
    ├── grid_nav.dart
    ├── grid_nav_new.dart
    ├── loading_container.dart
    ├── local_nav.dart
    ├── sales_box.dart
    ├── scalable_box.dart
    ├── search_bar.dart*
    ├── sub_nav.dart
    └── webview.dart

整个项目就是以上这些文件了 (具体的就不一个一个分析了,如,感兴趣,大家可以 clone 源码运行起来,自然就清除了)

项目功能详细概述(所用知识点)

首先,来看看首页功能及所用知识点,首页重点看下以下功能实现:

  • 渐隐渐现的 appBbar
  • 搜索组件的封装
  • 语音搜索页面
  • banner组件
  • 浮动的 icon 导航
  • 渐变不规则带有背景图的网格导航

渐隐渐现的 appBbar

先来看看具体效果,一睹芳容,如图:

appBar

滚动的时候 appBar 背景色从透明变成白色或白色变成透明,这里主要用了 flutterNotificationListener 组件,它会去监听组件树冒泡事件,当被它包裹的的组件(子组件) 发生变化时,Notification 回调函数会被触发,所以,通过它可以去监听页面的滚动,来动态改变 appBar 的透明度(alpha),代码如下:

NotificationListener(
  onNotification: (scrollNotification) {
    if (scrollNotification is ScrollUpdateNotification &&
        scrollNotification.depth == 0) {
      _onScroll(scrollNotification.metrics.pixels);
    }
    return true;
  },
  child: ...

Tips:
scrollNotification.depth 的值 0 表示其子组件(只监听子组件,不监听孙组件)
scrollNotification is ScrollUpdateNotification 来判断组件是否已更新,ScrollUpdateNotification 是 notifications 的生命周期一种情况,分别有一下几种:

  • ScrollStartNotification 组件开始滚动
  • ScrollUpdateNotification 组件位置已经发生改变
  • ScrollEndNotification 组件停止滚动
  • UserScrollNotification 不清楚

这里,我们不探究太深入,如想了解可多查看源码。

_onScroll 方法代码如下:

  void _onScroll(offset) {
    double alpha = offset / APPBAR_SCROLL_OFFSET;  // APPBAR_SCROLL_OFFSET 常量,值:100;offset 滚动的距离

    //把 alpha 值控制值 0-1 之间
    if (alpha < 0) {
      alpha = 0;
    } else if (alpha > 1) {
      alpha = 1;
    }
    setState(() {
      appBarAlpha = alpha;
    });
    print(alpha);
  }

搜索组件的封装

搜索组件效果如图:

searchBar

以下是首页调用 searchBar 的代码:

SearchBar(
  searchBarType: appBarAlpha > 0.2  //searchBar 的类:暗色、亮色
      ? SearchBarType.homeLight
      : SearchBarType.home,
  inputBoxClick: _jumpToSearch,     //点击回调函数
  defaultText: SEARCH_BAR_DEFAULT_TEXT,   // 提示文字
  leftButtonClick: () {},           //左边边按钮点击回调函数
  speakClick: _jumpToSpeak,         //点击话筒回调函数
  rightButtonClick: _jumpToUser,    //右边边按钮点击回调函数
),

其实就是用 TextField 组件,再加一些样式,需要注意点是:onChanged,他是 TextField 用来监听文本框是否变化,通过它我们来监听用户输入,来请求接口数据;
具体的实现细节,请查阅源码: 点击查看searchBar源码

语音搜索页面

语音搜索页面效果如图:由于模拟器无法录音,所以无法展示正常流程,如果录音识别成功后会返回搜索页面,在项目预览视频中可以看到正常流程。

no-shadow

语音搜索功能使用的是百度的语言识别SDK,原生接入之后,通过 MethodChannel 和原生Native端通信,这里不做重点讲述(这里会涉及原生Native的知识)。

重点看看点击录音按钮时的动画实现,这个动画用了 AnimatedWidget 实现的,代码如下:

class AnimatedWear extends AnimatedWidget {
  final bool isStart;
  static final _opacityTween = Tween<double>(begin: 0.5, end: 0); // 设置透明度变化值
  static final _sizeTween = Tween<double>(begin: 90, end: 260);   // 设置圆形线的扩散值

  AnimatedWear({Key key, this.isStart, Animation<double> animation})
      : super(key: key, listenable: animation);

  @override
  Widget build(BuildContext context) {
    final Animation<double> animation = listenable;  // listenable 继承 AnimatedWidget,其实就是控制器,会自动监听组件的变化
    return Container(
      height: 90,
      width: 90,
      child: Stack(
        overflow: Overflow.visible,
        alignment: Alignment.center,
        children: <Widget>[
          ...
          // 扩散的圆线,其实就是用一个圆实现的,设置圆为透明,设置border
          Positioned(
            left: -((_sizeTween.evaluate(animation) - 90) / 2), // 根据 _sizeTween 动态设置left偏移值
            top: -((_sizeTween.evaluate(animation) - 90) / 2), //  根据 _sizeTween 动态设置top偏移值
            child: Opacity(
              opacity: _opacityTween.evaluate(animation),      // 根据 _opacityTween 动态设置透明值
              child: Container(
                width: isStart ? _sizeTween.evaluate(animation) : 0, // 设置 宽
                height: _sizeTween.evaluate(animation),              // 设置 高
                decoration: BoxDecoration(
                    color: Colors.transparent,
                    borderRadius: BorderRadius.circular(
                        _sizeTween.evaluate(animation) / 2),
                    border: Border.all(
                      color: Color(0xa8000000),
                    )),
              ),
            ),
          ),
        ],
      ),
    );
  }
}

其他细节,如:点击时提示录音,录音失败提示,点击录音按钮出现半透明黑色圆边框,停止后消失等,请查看源码

banner组件

效果如图:

no-shadow

banner使用的是flutter的 flutter_swiper 插件实现的,代码如下:

Swiper(
  itemCount: bannerList.length,              // 滚动图片的数量
  autoplay: true,                            // 自动播放
  pagination: SwiperPagination(              // 指示器
      builder: SquareSwiperPagination(
        size: 6,                             // 指示器的大小
        activeSize: 6,                       // 激活状态指示器的大小
        color: Colors.white.withAlpha(80),   // 颜色
        activeColor: Colors.white,           // 激活状态的颜色
      ),
    alignment: Alignment.bottomRight,        // 对齐方式
    margin: EdgeInsets.fromLTRB(0, 0, 14, 28), // 边距
  ),
  itemBuilder: (BuildContext context, int index) { // 构造器
    return GestureDetector(
      onTap: () {
        CommonModel model = bannerList[index];
        Navigator.push(
          context,
          MaterialPageRoute(
            builder: (context) => WebView(
              url: model.url,
            ),
          ),
        );
      },
      child: Image.network(
        bannerList[index].icon,
        fit: BoxFit.fill,
      ),
    );
  },
),

具体使用方法,可以去 flutter的官方插件库 pub.dev 查看:点击flutter_swiper查看

Tips:
需要注意的是,我稍改造了一下指示器的样式,flutter_swiper 只提供了 3 种指示器样式,如下:

  • dots = const DotSwiperPaginationBuilder(),圆形
  • fraction = const FractionPaginationBuilder(),百分数类型的,如:1/6,表示6页的第一页
  • rect = const RectSwiperPaginationBuilder(),矩形

并没有上图的激活状态的长椭圆形,其实就是按葫芦画瓢,自己实现一个长椭圆类型,如知详情,可点击查看长椭圆形指示器源码

浮动的 icon 导航

icon导航效果如图:

iconBar

icon导航浮动在banner之上,其实用的是 flutterStack 组件,Stack 组件能让其子组件堆叠显示,它通常和 Positioned 组件配合使用,布局结构代码如下:

ListView(
  children: <Widget>[
    Container(
      child: Stack(
        children: <Widget>[
          Container( ... ), //这里放的是banner的代码
          Positioned( ... ), //这个就是icon导航,通过 Positioned 固定显示位置
        ],
      ),
    ),
    Container( ... ), // 这里放的网格导航及其他
  ],
),

渐变不规则带有背景图的网格导航

网格导航效果如图:

gridNav

如图,网格导航分为三行四栏,而第一行分为三栏,每一行的第一栏宽度大于其余三栏,其余三栏均等,每一行都有渐变色,而且第一、二栏都有背景图;
flutterColumn 组件能让子组件竖轴排列, Row 组件能让子组件横轴排列,布局代码如下:

Column(                      // 最外面放在 Column 组件
  children: <Widget>[
    Container(               // 第一行包裹 Container 设置其渐变色
      height: 72,
      decoration: BoxDecoration(
        gradient: LinearGradient(colors: [  //设置渐变色
          Color(0xfffa5956),
          Color(0xffef9c76).withAlpha(45)
        ]),
      ),
      child: Row( ... ),    // 第一行
    ),
    Padding(
      padding: EdgeInsets.only(top: 1),  // 设置行直接的间隔
    ),
    Container(
      height: 72,
      decoration: BoxDecoration(
        gradient: LinearGradient(colors: [  //设置渐变色
          Color(0xff4b8fed),
          Color(0xff53bced),
        ]),
      ),
      child: Row( ... ),  // 第二行
    ),
    Padding(
      padding: EdgeInsets.only(top: 1),   // 设置行直接的间隔
    ),
    Container(
      height: 72,
      decoration: BoxDecoration(
        gradient: LinearGradient(colors: [  //设置渐变色
          Color(0xff34c2aa),
          Color(0xff6cd557),
        ]),
      ),
      child: Row( ... ),  // 第三行
    ),
  ],
),

其实,具体实现的细节还是很多的,比如:

  • 怎么设置第一栏宽度偏大,其他均等;
  • 第一行最后一栏宽度是其他的2倍;
  • 第一、二栏的别截图及浮动的红色气泡tip等;

在这里就不细讲,否则篇幅太长,如想了解详情 点击查看源码

其次,再来看看目的地页面功能及所用知识点,重点看下以下功能实现:

  • 左右布局tabBarListView
  • 目的地搜索页面

左右布局tabBarListView

具体效果如图:点击左边标签可以切换页面,左右滑动也可切换页面,点击展开显示更多等

no-shadow

其实官方已经提供了 tabBarTabBarView 组件可以实现上下布局的效果(旅拍页面就是用这个实现的),但是它无法实现左右布局,而且不太灵活,所以,我使用的是 vertical_tabs插件, 代码如下:

VerticalTabView(
    tabsWidth: 88,
    tabsElevation: 0,
    indicatorWidth: 0,
    selectedTabBackgroundColor: Colors.white,
    backgroundColor: Colors.white,
    tabTextStyle: TextStyle(
      height: 60,
      color: Color(0xff333333),
    ),
    tabs: tabs,
    contents: tabPages,
  ),
),

具体使用方法,在这里就不赘述了,点击vertical_tabs查看

Tips:
这里需要注意的是:展开显示更多span标签组件的实现,因为,这个组件在很多的其他组件里用到而且要根据接口数据动态渲染,且组件自身存在状态的变化,这种情况下,最好是把他单独封装成一个组件(widget),否则,很难控制自身状态的变化,出现点击没有效果,或点击影响其他组件。

目的地搜索页面

效果如图:点击搜索结果,如:点击‘一日游‘,会搜索到‘一日游‘的相关数据

no-shadow

目的地搜索页面,大多都是和布局和对接接口的代码,在这里就不再赘述。

然后就是旅拍页面功能及所用知识点,重点看下以下功能实现:

  • 左右布局tabBarListView
  • 瀑布流卡片
  • 旅拍搜索页

左右布局tabBarListView

效果如图:可左右滑动切换页面,上拉加载更多,下拉刷新等

no-shadow

这个是flutter 提供的组件,tabBarTabBarView,代码如下:

Container(
  color: Colors.white,
  padding: EdgeInsets.only(left: 2),
  child: TabBar(
    controller: _controller,
    isScrollable: true,
    labelColor: Colors.black,
    labelPadding: EdgeInsets.fromLTRB(8, 6, 8, 0),
    indicatorColor: Color(0xff2FCFBB),
    indicatorPadding: EdgeInsets.all(6),
    indicatorSize: TabBarIndicatorSize.label,
    indicatorWeight: 2.2,
    labelStyle: TextStyle(fontSize: 18),
    unselectedLabelStyle: TextStyle(fontSize: 15),
    tabs: tabs.map<Tab>((Groups tab) {
      return Tab(
        text: tab.name,
      );
    }).toList(),
  ),
),
Flexible(
    child: Container(
  padding: EdgeInsets.fromLTRB(6, 3, 6, 0),
  child: TabBarView(
      controller: _controller,
      children: tabs.map((Groups tab) {
        return TravelTabPage(
          travelUrl: travelParamsModel?.url,
          params: travelParamsModel?.params,
          groupChannelCode: tab?.code,
        );
      }).toList()),
)),

瀑布流卡片

瀑布流卡片 用的是 flutter_staggered_grid_view 插件,代码如下:

StaggeredGridView.countBuilder(
  controller: _scrollController,
  crossAxisCount: 4,
  itemCount: travelItems?.length ?? 0,
  itemBuilder: (BuildContext context, int index) => _TravelItem(
        index: index,
        item: travelItems[index],
      ),
  staggeredTileBuilder: (int index) => new StaggeredTile.fit(2),
  mainAxisSpacing: 2.0,
  crossAxisSpacing: 2.0,
),

如下了解更多相关信息,点击flutter_staggered_grid_view查看

旅拍搜索页

效果如图:首先显示热门旅拍标签,点击可搜索相关内容,输入关键字可搜索相关旅拍信息,地点、景点、用户等

no-shadow

旅拍搜索页,大多也是和布局和对接接口的代码,在这里就不再赘述。

小技巧积累总结

以下都是我在项目里使用的知识点,在这里记录分享出来,希望能帮到大家。

PhysicalModel

PhysicalModel 可以裁剪带背景图的容器,如,你在一个 Container 里放了一张图片,想设置图片圆角,设置 Container 的 decoration 的 borderRadius 是无效的,这时候就要用到 PhysicalModel,代码如下:

PhysicalModel(
  borderRadius: BorderRadius.circular(6),  // 设置圆角
  clipBehavior: Clip.antiAlias,            // 裁剪行为
  color: Colors.transparent,               // 颜色
  elevation: 5,                            // 设置阴影
  child: Container(
        child: Image.network(
          picUrl,
          fit: BoxFit.cover,
        ),
      ),
),

LinearGradient

给容器添加渐变色,在网格导航、appBar等地方都使用到,代码如下:

Container(
  height: 72,
  decoration: BoxDecoration(
    gradient: LinearGradient(colors: [
      Color(0xff4b8fed),
      Color(0xff53bced),
    ]),
  ),
  child: ...
),

Color(int.parse('0xff' + gridNavItem.startColor))

颜色值转换成颜色,如果,没有变量的话,也可直接这样用 Color(0xff53bced)

  • ox:flutter要求,可固定不变
  • ff:代表透明贴,不知道如何设置的话,可以用取色器,或者 withOpacity(opacity) 、 withAlpha(a)
  • 53bced: 常规的6位RGB值

Expanded、FractionallySizedBox

Expanded 可以让子组件撑满父容器,通常和 RowColumn 组件搭配使用;


FractionallySizedBox 可以让子组件撑满或超出父容器,可以单独使用,大小受 widthFactor 和 heightFactor 宽高因子的影响

MediaQuery.removePadding

MediaQuery.removePadding 可以移除组件的边距,有些组件自带有边距,有时候布局的时候,不需要边距,这时候就可以用 MediaQuery.removePadding,代码如下:

MediaQuery.removePadding(
  removeTop: true,
  context: context,
  child: ...
)

MediaQuery.of(context).size.width

MediaQuery.of(context).size.width 获取屏幕的宽度,同理,MediaQuery.of(context).size.height 获取屏幕的高度;
如,想一行平均3等分: 0.3 * MediaQuery.of(context).size.width,在目的地页面的标签组件就使用到它,代码如下:

Container(
  alignment: Alignment.center,
  ...
  width: 0.3*MediaQuery.of(context).size.width - 12, // 屏幕平分三等分, - 12 是给每份中间留出空间 
  height: 40,
  ...
  child: ...
),

Theme.of(context).platform == TargetPlatform.iOS

判断操作系统类型,有时候可能有给 Andorid 和 iOS 做出不同的布局,就需要用到它。

with AutomaticKeepAliveClientMixin

flutter 在切换页面时候每次都会重新加载数据,如果想让页面保留状态,不重新加载,就需要使用 AutomaticKeepAliveClientMixin,代码如下:(在旅拍页面就有使用到它,为了让tabBar 和 tabBarView在切换时不重新加载)

class TravelTabPage extends StatefulWidget {
  ...
  //需要重写 wantKeepAlive 且 设置成 true
  @override
  bool get wantKeepAlive => true;
}

暂时只能想到这些常用的知识点,以后如有新的会慢慢补充。

博客地址:https://lishaoy.net
博客Notes地址:https://h.lishaoy.net
项目GitHub地址:https://github.com/persilee/flutter_ctrip

查看原文

赞 68 收藏 45 评论 8

子木 发布了文章 · 2019-03-05

三招让你成为程序猿中优秀的射鸡湿

goodDesiger

这篇文章总结一下之前项目中一些 前端 工具及技巧,主要包括 Iconfont的正确使用姿势如何使用酷炫漂亮的动画(Lottie)如何加入页面滚动入场离场动效

个人博客欢迎访问 https://lishaoy.net

Iconfont的正确使用姿势

Iconfont 是阿里巴巴打造的矢量图标库,图标丰富多彩(单色和彩色),使用方便快捷(可筛选图片创建自己项目图标库),支持在线使用,摆脱了传统的图片的繁琐和css字体图标库引入的冗余。

下面介绍下如何使用 Iconfont

搜索选择图标

点击 Iconfont 打开页面如图,可以搜索 🔍 关键字,找到想要的图标

Iconfont

例如,搜索 ‘image’ 关键字,如图

Iconfont

鼠标放到图标上会出现 添加入库收藏下载图标 选项,一般我会选择 添加入库 ,之后统一添加到项目,生成在线地址引入项目(后面会介绍到)

右边的蓝色皇冠按钮可以进行 精选全部 的筛选,红色的按钮可以进行 单色多色全部 的筛选

点击 下载 会弹出下载页面,可以进行图标的编辑和不同格式的下载,如图

Iconfont

添加入库生成在线连接

选择 添加入库 的图标,会在右上角的购物车显示数量,点击购物车图标,会弹出右侧栏,如图

Iconfont

可以批量下载和添加到项目,点击 添加至项目 如图

Iconfont

给项目取一个名字,点击确定,如图

Iconfont

可以看到有三种图标引入的方式,默认选中的是 Font class 的方式,也推荐使用这种方式

Unicode : 是以字体的方式引入,如下

@font-face {
  font-family: 'iconfont';  /* project id 1066942 */
  src: url('//at.alicdn.com/t/font_1066942_yvi703p2pv.eot');
  src: url('//at.alicdn.com/t/font_1066942_yvi703p2pv.eot?#iefix') format('embedded-opentype'),
  url('//at.alicdn.com/t/font_1066942_yvi703p2pv.woff2') format('woff2'),
  url('//at.alicdn.com/t/font_1066942_yvi703p2pv.woff') format('woff'),
  url('//at.alicdn.com/t/font_1066942_yvi703p2pv.ttf') format('truetype'),
  url('//at.alicdn.com/t/font_1066942_yvi703p2pv.svg#iconfont') format('svg');
}

Font class : 是以Css的方式引入,如下

//at.alicdn.com/t/font_1066942_yvi703p2pv.css

Symbol : 是以js的方式引入,如下

//at.alicdn.com/t/font_1066942_yvi703p2pv.js

如想了解3中方式具体用法,可点击 官方文档,这里我主要介绍 Font class 的方式

引入项目使用

首先,把生成的链接引入到页面中,如下

<link rel="stylesheet" href="//at.alicdn.com/t/font_1066942_yvi703p2pv.css">

其次,用 <i class="iconfont icon-Userselect"></i> 在页面中使用,大小颜色都可以用 css 调整

阿里在代码的复制、图标的搜索、编辑、下载、筛选等一些操作上做的很到位,使用起来方便快捷

我在之前的项目里也使用过,还是很漂亮的 https://a.lishaoy.net ,如图是我在项目里使用的一些图标,每个图标是不是都做的很精致

Iconfont

Lottie开源动画库

Lottie 是Airbnb开源的一个面向 iOS、Android、React Native 的动画库,能够直接把 AE 导出的动画文件(json),引入到页面使用,以下是官方给出的效果图

no-shadow

no-shadow

Lottie 支持 iOS、Android、React Native 、Web ,这里主要介绍 lottie-Web 是如何使用,更多使用方法可以参考 http://airbnb.io/lottie/

首先,在页面中引入 CDN 上的文件,如下

<script data-original="https://cdnjs.cloudflare.com/ajax/libs/bodymovin/5.4.2/lottie.min.js" type="text/javascript"></script>

其次,使用 AE 制作动画(UI提供或者自己制作)导出的 json,或者可以在 Lottie Files 下载 (Lottie Files 是一个拥有高质量 Lottie 文件格式动画的网站,不仅设计师可以在上面陈列他们的动画而且还提供免费下载)

以下是我在项目里使用的效果图(上传图片中会加重动画,上传成功动画停止),具体效果可以去我的项目上传图片体验 上传图片动画效果

Lottie

具体的代码如下,在页面中创建需要加重动画的容器(HTML 代码)

<div class="image-load d-flex justify-content-center align-items-center">
  <div class="box">
    <div class="lottie"></div>
    <div class="text text-muted text-center">The picture is being uploaded ...</div>
  </div>
</div>

然后,用 js 初始化动画,如下

    let anim = lottie.loadAnimation({
      container: $('.image-load .lottie')[0], //动画容器的元素
      renderer: 'svg', //支持 svg 和 canvas
      loop: true, //是否循环
      autoplay: false, // 是否自动播放
      path: '/EmojiReaction.json' //动画json文件的位置
    })
    anim.addEventListener('loopComplete', () => {  // 监听 `loopComplete` 事件,每次播放完成执行
      anim.pause() //停止播放
      $('.image-load').removeClass('loading') //隐藏容器
      $('.image-load .box .text').text('The picture is being uploaded...').removeClass('text-success').addClass('text-muted') //改变说明文字状态及颜色
    })

更多的参数和事件可查阅官方文档 Lottie-Web

如何加入页面滚动入场离场动效

首先,让我们来先看看效果,如图(效果来源我的博客 https://lishaoy.net

Lottie

以上动效就是用的 AOS 这个库,具体的使用方法也很简单

在页面上引入 cssjs 文件

<link rel="stylesheet" href="https://unpkg.com/aos@next/dist/aos.css"/>

<script data-original="https://unpkg.com/aos@next/dist/aos.js"></script>

然后,用 AOS.init() 初始化,这样初始化,使用的是默认设置,具体有很多参数可以调整,详情可查阅 项目文档,如

AOS.init({
  // Global settings:
  disable: false, // accepts following values: 'phone', 'tablet', 'mobile', boolean, expression or function
  startEvent: 'DOMContentLoaded', // name of the event dispatched on the document, that AOS should initialize on
  initClassName: 'aos-init', // class applied after initialization
  animatedClassName: 'aos-animate', // class applied on animation
  useClassNames: false, // if true, will add content of `data-aos` as classes on scroll
  disableMutationObserver: false, // disables automatic mutations' detections (advanced)
  debounceDelay: 50, // the delay on debounce used while resizing window (advanced)
  throttleDelay: 99, // the delay on throttle used while scrolling the page (advanced)
  

  // Settings that can be overridden on per-element basis, by `data-aos-*` attributes:
  offset: 120, // offset (in px) from the original trigger point
  delay: 0, // values from 0 to 3000, with step 50ms
  duration: 400, // values from 0 to 3000, with step 50ms
  easing: 'ease', // default easing for AOS animations
  once: false, // whether animation should happen only once - while scrolling down
  mirror: false, // whether elements should animate out while scrolling past them
  anchorPlacement: 'top-bottom', // defines which position of the element regarding to window should trigger the animation

});

最后,在页面上使用即可,如

<div data-aos="fade-in"></div>

或者,也可以单独给元素设置参数,使用 data-aos-* ,如

<div data-aos="fade-in"
  data-aos-offset="200"
  data-aos-delay="50"
  data-aos-duration="1000"
  data-aos-easing="ease-in-out"
></div>

更多的载入效果可以参考官方给出的 示例

如在您做的页面或应用中使用了这三招,精致小图标炫酷的动画页面滚动载入动效 ,那么瞬间让您的页面或应用高大上且生动活泼起来。

查看原文

赞 49 收藏 39 评论 0

子木 评论了文章 · 2018-12-18

用 Node.js 快速开发出多功能的多人在线的文章分享平台

no-shadow

最近在学习使用 Node.js 框架,边学习边使用,花了大概 3周 时间做完这个 Web应用 且在 <time>12月16</time> 凌晨左右上线成功(其实就是把开发环境搬到服务器), 地址: https://a.lishaoy.net

这个 Web应用 的代码是开源的,如对这个应用感兴趣,想知道代码是如何运行的,可以去我 GitHub 下载或 clone应用源码


首先,来看看用 3周 时间做出来的应用都有些什么功能,之后再看看选用的 Node.js 框架,最后看看 Node.js 项目如何部署到服务器。

Web应用功能

登录、注册验证

登录功能

  • 输入框没有输入点击登录会提示:用户名、密码不能为空
  • 输入的用户错误或不存在会提示:用户不存在
  • 输入的密码错误会提示:密码错误
  • 登录后会重定向到用户上次访问的地址

no-shadow

注册功能

  • 输入框没有输入点击注册会提示:用户名、邮箱、密码不能为空
  • 用户名和邮箱与其他用户相同会提示:用户名、邮箱已存在
  • 密码小于6位数会提示:最小长度是6位
  • 注册成功后会发送验证邮件到用户邮箱,需点击邮箱按钮验证

no-shadow

文章列表

登录进来,会显示文章列表页面,显示内容如下:

  • 文章标题:点击可进入文章详情页
  • 作者头像、作者名称:点击可进入作者信息页
  • 时间:显示创建时间(多久以前方式显示)
  • 阅读次数、点赞次数
  • 文章简要:自动摘取章头文章
  • 缩略图:自动摘取文章第一张图片

no-shadow

文章详情

点击文章标题可进入文章详情页面,内容如下:

  • 文章标题
  • 作者头像、作者名称
  • 发布时间
  • 阅读次数和点赞次数
  • 编辑按钮(仅作者可见)
  • 左侧浮动工具栏(点赞、发送邮件到自己邮箱、返回顶部、分享)
  • 点赞:文章被点赞后,作者可以收到消息通知,且将文章收录到点赞列表(支持匿名点赞,但不会记录通知,只会加点赞数)

no-shadow

编辑文章支持 Markdown

新建文章和修改文章都支持 Markdown 语法,且会每隔6秒钟自动保存

no-shadow

个人信息

个人信息页面显示内容如下

  • 作者的头像、姓名、简介(支持emoji)
  • 信息栏:GitHub 链接、个人网站链接、发布文章数、总阅读次数、总点赞次数
  • 发布文章列表:个人发布的所有文章(有删除和编辑按钮)
  • 已赞文章列表:点过赞的文章会记录在这里
  • 关注者列表:关注你的用户(关注过的用户,关注按钮高亮显示)
  • 已关注列表:你关注的用户(关注过的用户,关注按钮高亮显示)
  • 关注按钮:作者本人不可见,点击可关注,再次点击取消关注,关注后,用户会收到消息通知

no-shadow

文章删除编辑快捷入口,如图

no-shadow

下面是我用另一个用户登录,进入到个人信息页面就会显示关注按钮,如图

no-shadow

文件上传

点击文件上传小图标可进入文件上传页面,点击 Files 链接可进入文件上传列表,显示内容如图:

no-shadow

no-shadow

文件预览和编辑

从文件列表页面点击标题可进入文件预览页面,显示内容如下:

  • 如果是图片显示图片,如果是视频显示视频
  • 工具栏:发送邮件到自己邮箱(登录可见)、编辑按钮、删除按钮(登录自己上传可见)
  • 文件名称
  • 下载按钮
  • 上传者头像

no-shadow

消息通知

点击铃铛小图标可进入消息通知页面,内容如下:

  • 点赞消息列表:收到用户点赞通知,最新的未读消息会高亮显示,点击点赞者头像进入个人信息页面,点击文章标题进入你的文章详情页面
  • 关注者列表:收到关注者的通知,最新未读消息会高亮显示,点关注按钮也可关注他,再点击取消关注
  • 系统消息:目前还没有做功能实现

no-shadow

工具栏列表

点击个人头像可展开工具栏列表,内容如下:

  • 写文章:点击可新建文章编辑页面,和 ➕ 小图标是同样功能
  • 上传文件:点击可打开文件上传页面,和上传小图标是同样功能
  • 个人信息: 点击可进入个人信息页面
  • 已赞:点击可查看已赞过得文章
  • 设置:点击可打开个人设置页面
  • 登出:点击退出登录

no-shadow

设置

点击工具栏上的设置按钮可以设置页面,内容如下:

个人信息设置

  • 头像:头像是使用的 Gravatar 提供的功能,根据邮箱生成头像
  • 用户名
  • 邮箱:已验证通过会显示验证小图标,没有通过的会显示提示
  • GitHub:只需填写有户名
  • 个人简介:支持emoji
  • 个人网站

no-shadow

修改密码设置

需填写原密码,新密码,再次输入密码

no-shadow

聊天室

点击 Chatroom 链接可进入聊天室,当然这个是用的 websocket 做的,内容如下:

  • 状态图标:显示链接状态
  • 活动用户:左侧黑色区域会动态显示活动用户
  • 消息:会显示发送消息,进入、离开房间通知消息(支持匿名发送消息,但不会保存消息)
  • 消息输入:消息输入框可输入消息,CmdEnter 换行(Windows会显示提示Ctrl+Enter),回车发送消息

no-shadow

加入房间和离开房间都有消息通知,如图

no-shadow

Node.js 框架

这个应用的开发我选择的是 Adonisjs 框架,他和 PHPLaravel 有些像,Adonisjs 是在操作系统上运行的 Node.jsMVC 框架。

接下来,来看看 Adonisjs 框架有哪些特性:

环境安装简单

不管是开发环境还是生产环境,安装 Adonisjs 运行环境都是非常简单,先来看看开发环境的安装,生产环境后面会提到。

首先,我们的电脑上需要安装好 Node.js大于 8.00 版本,管理 Node.js 可以使用 nvm

其次,就可以使用 npm 安装 Adonis CLI 命令行工具(管理 npm 使用源可以使用 nrm

npm i -g @adonisjs/cli

这样就可以在全局使用 adonis 命令

再次,可以是 adonis new 命令创建项目

adonis new adonis_pro

cd 进入项目,执行 adonis serve --dev 运行项目

cd adonis_pro
adonis serve --dev

这样您的开发环境就搭建完成。

RMVC

RMVC 就是路由、模型、视图、控制器。

路由

创建一条路由非常简单,如

Route.get('liked/:userId/:postId', 'LikedController.liked')

这条路由就是用来处理上面提到的点赞功能的

当然,Adonisjs 提供了 资源路由 以便您更方便的创建路由,例如

Route.resource('posts', 'PostController').middleware(
   new Map([
        [ [ 'create', 'store', 'edit', 'update', 'destroy' ], [ 'auth' ] ],
        [ [ 'update', 'destroy', 'edit' ], [ 'own:post' ] ]
   ])
).validator(new Map([
  [['posts.update', 'posts.store'], ['StorePost']]
]))

这个路由是来处理上面应用提到的文章的 增、删、改、查 ,这个可能有些复杂,使用了 中间件 来处理用户登录状态和操作权限,使用了 验证器 来处理表单验证,这里不介绍的太复杂,如想了解这些具体功能,可以需要花点时间了解学习。

我们可以去掉 中间件验证器 ,如下:

Route.resource('posts', 'PostController')

这条资源路由,其实就包含了以下路由:

Route.get(url, closure)
Route.post(url, closure)
Route.put(url, closure)
Route.patch(url, closure)
Route.delete(url, closure)

Adonisjs 还提供了路由组和其他一些功能,路由组如下:

Route.group(() => {
    Route.get('profile', 'ProfileController.edit').as('profile.edit')
    Route.post('profile', 'ProfileController.update').as('profile.update').validator('UpdateProfile')
    Route.get('password', 'PasswordController.edit').as('password.edit')
    Route.post('password', 'PasswordController.update').as('password.update').validator('UpdatePassword')
})
    .prefix('settings')
    .middleware([ 'auth' ])

使用 .prefixRoute.group 来创建路由组,这条路由组是处理 个人信息设置 功能的,这样访问页面是就统一要带上 settings/**

控制器

Adonisjs 提供了命令行来创建控制器,如

adonis make:controller User --type http

这样就创建了一个 User 控制器,自动生成代码如下:

'use strict'

class UserController {
}

module.exports = UserController

当然,我们还可以使用 --resource 创建资源类型的控制器

adonis make:controller Post --resource

自动生成代码,代码如下:

'use strict'

class PostController {
 /**
   * Show a list of all posts.
   * GET posts
   */
async index ({ request, response, view }) {}

 /**
   * Render a form to be used for creating a new posts.
   * GET posts/create
   */
async create ({ request, response, view }) {}

 /**
   * Create/save a new posts.
   * POST posts
   */
async store ({ request, response, view }) {}
 /**
   * Display a single posts.
   * GET posts/:id
   */
async show ({ request, response, view }) {}

 /**
   * Render a form to update an existing posts.
   * GET posts/:id/edit
   */
async edit ({ request, response, view }) {}

 /**
   * Update posts details.
   * PUT or PATCH posts/:id
   */
async update ({ request, response, view}) {}

 /**
   * Delete a posts with id.
   * DELETE posts/:id
   */
async destroy ({ params, request, response }) {}
}

module.exports = PostController

和上面的资源路由是对应的,如用 GET 请求访问 posts 就会调用 index 方法(一般用来显示) ,再如:用 DELETE 请求访问 posts/1 就会执行 destroy 方法(一般用来删除)。

模型

Adonisjs 提供了两种模式来处理数据,Query builderLUCID

首先,我们可以通过 adonis make:migration 来创建数据表

adonis make:migration users

会自动生成代码,如下:

'use strict'

const Schema = use('Schema')

class UsersSchema extends Schema {
  up () {
    this.create('users', (table) => {
      table.increments()
      table.timestamps()
    })
  }

  down () {
    this.drop('users')
  }
}

module.exports = UsersSchema

这是我们只需在其中添加想要的字段就行,如:

'use strict'

const Schema = use('Schema')

class UsersSchema extends Schema {
  up () {
    this.create('users', (table) => {
      table.increments()
      table.string('username', 80).notNullable().unique()
      table.string('email', 254).notNullable().unique()
      table.string('password', 60).notNullable()
      table.timestamps()
    })
  }

  down () {
    this.drop('users')
  }
}

module.exports = UsersSchema

在执行 adonis migration:run 命令就可以在数据库生成数据表

再来看看,如何获取数据,可以使用 Query builderLUCID 两种方式

先来看看 Query builder

const Database = use('Database')

class UserController {

  async index (request, response) {
    return await Database
      .table('users')
      .where('username', 'admin')
      .first()
  }

}

查询 usernameadmin 的用户

Adonisjs 提供了非常多的方法去操作数据,不是特复杂的关系都够用,如果,关系比较复杂,还可以用原生的 sql 操作,如

'use strict'

const Database = use('Database')

  class NotificationController {
  async followNotice ({ auth, view }) {
    const notices = await Database.raw('select users.id as user_id,users.username,users.email,b.title,b.created_at,b.is_read,b.id as post_id from adonis.users , (select posts.id,posts.title, a.user_id,a.created_at,a.is_read from adonis.posts,(SELECT post_user.post_id, post_user.user_id, post_user.created_at, post_user.is_read FROM adonis.post_user where post_user.post_id in (SELECT posts.id FROM adonis.posts where user_id = ?)) as a where posts.id = a.post_id) as b where b.user_id = users.id and b.user_id <> ? order by b.created_at desc limit 50',[ auth.user.id, auth.user.id ])
  }
}

module.exports = NotificationController

使用 Database.raw 来运行原生的 sql,以上这条 sql 是用来查询所有用户给自己所有文章点赞的用户信息和文章信息用于消息通知。

再来看看,LUCID 的模式是如何操作数据的:

使用 LUCID 模式,我们先需要用命令行工具创建 Models,如:

adonis make:model User

自动生成代码如下:

'use strict'

const Model = use('Model')

class User extends Model {
}

module.exports = User

模型和模型之间需要定义一些关系,如:

const Model = use('Model')

class User extends Model {
  profile () {
    return this.hasOne('App/Models/Profile')
  }
}

module.exports = User

意思是 一个用户对应一个用户信息档案,一对一 的关系

定义好关系之后,就可以方便的获取数据,如:

const User = use('App/Models/User')

const user = await User.find(1)
const userProfile = await user.profile().fetch()

意思是,从用户表和用户个人信息表里获取用户 id1 的用户信息及个人信息,

其中,关系可以定义为 3一对一、一对多、多对多 ,多对多需要定义中间表

再来看看,上面的应用中的实际应用,如:

async update ({ params, request, response, session, auth }) {
  const { title, content, user_id, tags } = request.all()

  const post = await Post.findOrFail(params.id)
  post.merge({ title, content})
  await post.save()

  await post.tags().sync(tags)

  session.flash({
    type: 'primary',
    message: 'Post updated successfully.'
  })

  return response.redirect(
    Route.url('PostController.show', {
      id: post.id
    })
  )
}

以上,是更新文章的方法,文章标签多对多 的关系,一个标签可以属于多篇文章,一篇文章可以有多个标签,await post.tags().sync(tags) 这句代码就可以通过 Models 里定义的关系自动把标签和文章关联起来保存到 poststags 表里且把关联关系保存到中间表 post_tag

当然,Adonisjs 提供了很多方便的方法,想了解更多的话需要您花点时间去了解学习。

视图

Adonisjs 框架里视图使用了 edge 模板,我们可以使用命令行工具创建视图文件,如:

adonis make:view post

我看可以看下简单的例子:

@loggedIn
  <h2> You are logged in </h2>
@else
  <p> <a href="/login">Click here</a> to login </p>
@endloggedIn

视图模板里可以使用标签来做逻辑判断,视图模板就没什么好说的,基本都是通用的,关于 edge 视图模板更多语法 Edge官方文档

最后,Adonisjs 框架还提供了很多其它的实用工具,如:Middleware 中间件、Validator 验证器、Error Handling 自定义异常、Events 事件、Mails 邮件、Websocket 等来处理各种问题。

Node.js项目发布到阿里云服务器

首先,我们需要用 ssh 连接到阿里云(或者其他服务器供应商)的主机上,安装一些必要的工具。

工具安装

安装 epel-release 软件包仓库

我们需要安装 epel-release 软件包仓库,epel-release 里面有很多最新的软件包,如,之后安装的 git 就会用到

sudo yum install epel-release - y

安装 Git 版本控制命令行工具

sudo yum install git -y

准备 Node.js 运行环境

接下来,我们需要安装 Node.js 以便我们的 Node.js 项目能够跑起来,我们可以使用 nvm 安装和管理 Node.js ,使用 nrm 来管理切换安装源。

安装 nvm

curl -o- https://raw.githubusercontent.com/creationix/nvm/v0.33.11/install.sh | bash

安装好之后,我们需要配置下环境变量,以便能够在命令行使用 nvm 命令,用 vi ~/.bash_profile 编辑下配置文件

vi ~/.bash_profile

加入以下代码:

export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
[ -s "$NVM_DIR/bash_completion" ] && \. "$NVM_DIR/bash_completion"

然后,在 source ~/.bash_profile 刷新下配置文件,让它生效

source ~/.bash_profile

此时,我们就可以使用 nvm 来安装 Node.js

nvm install node

安装好后,可以使用 nvm list 来查看有哪些版本可以使用

nvm list

结果:

->     v10.13.0
        v11.2.0
         system
default -> v10.13.0
node -> stable (-> v11.2.0) (default)
stable -> 11.2 (-> v11.2.0) (default)
iojs -> N/A (default)
lts/* -> lts/dubnium (-> v10.13.0)
lts/argon -> v4.9.1 (-> N/A)
lts/boron -> v6.14.4 (-> N/A)
lts/carbon -> v8.13.0 (-> N/A)
lts/dubnium -> v10.13.0

我使用的是 v10.13.0 的版本,默认安装的都是比较新的版本,可能是 v11.2.0v11.1.0,所以我们也可以用 nvm install v10.13.0 来安装指定版本。

nvm install v10.13.0

然后,就可以使用 nvm use v10.13.0 来使用指定版本

nvm use nvm v10.13.0

结果:

Now using node v10.13.0 (npm v6.4.1)

安装 nrm 管理安装源

使用 npm 安装的程序包,默认的来源是 http://registry.npmjs.org,国内的下载速度会有些慢,我们可以是 nrm 来切换到 taobao 的源

安装 nrm

npm install nrm --global

切换到 taobao 源

nrm use taobao

准备项目

以上工作完成之后,我们的服务器就可以正常运行 Node.js 项目,现在我们需要把本地的项目上传到服务器,上传方法有很多,如:

  • 可以使用 git,先把项目传到 GitHub,然后用 git 下载到服务器
  • 可以是 FTP 工具
  • 可以是命令上传 scp -r 本地目录 root@服务器IP:/var/www/

发项目文件上传到服务器的指定目录下,如:www

接下来,我们可以是 PM2 来管理 Node 进程,先需要安装 PM2

安装PM2

npm install pm2@latest --global

这些工作作为之后,就可以来测试一下,启动项目,在本地访问服务器 IP:PORT 来测试是否可以访问

测试项目是否可以运行

在测试之前,我们需要改下应用的配置文件,adonisjs 框架里是 .env 文件,修改下 HOST 的值:

HOST=0.0.0.0
PORT=3333
...

HOST 默认是 127.0.0.1,需要改成 0.0.0.0 这样就可以在自己电脑上用服务器 IP:PORT 来访问应用

改完后,进入到项目的根目录,运行应用,adonisjs 的启动文件是 server.js,如:

pm2 start server.js

如启动成功会提示:

[PM2] Applying action restartProcessId on app [server](ids: 0)
[PM2] [server](0) ✓
[PM2] Process successfully started
┌──────────┬────┬─────────┬──────┬──────┬────────┬─────────┬────────┬─────┬──────────┬──────┬──────────┐
│ App name │ id │ version │ mode │ pid  │ status │ restart │ uptime │ cpu │ mem      │ user │ watching │
├──────────┼────┼─────────┼──────┼──────┼────────┼─────────┼────────┼─────┼──────────┼──────┼──────────┤
│ server   │ 0  │ 4.1.0   │ fork │ 7171 │ online │ 30      │ 0s     │ 0%  │ 3.4 MB   │ root │ disabled │
└──────────┴────┴─────────┴──────┴──────┴────────┴─────────┴────────┴─────┴──────────┴──────┴──────────┘
 Use `pm2 show <id|name>` to get more details about an app

然后,在自己电脑上用服务器 IP:PORT 来访问应用。

Nginx 代理

为了让服务器更好地处理网络请求,我们需要添加使用 Nginx 反向代理 把请求转发给 Node.js 应用

安装 Nginx

sudo yum install nginx -y

如果你的服务之前安装过可不用安装,我的阿里云服务器运行了 4 个站点之前安装过,之后我只需添加配置就行。

启动 Nginx

sudo systemctl start nginx

配置 Nginx

一般情况 Nginx 安装好后会有 /etc/nginx/conf.d 目录,进入这个目录,创建一个配置文件为 Node.js 而准备,名字可随意命名,如:adonis.conf

server {
  listen 80;
  location / {
      proxy_pass http://127.0.0.1:3333;
      proxy_http_version 1.1;
      proxy_set_header Upgrade $http_upgrade;
      proxy_set_header Connection 'upgrade';
      proxy_set_header Host $host;
      proxy_set_header X-Real-IP $remote_addr;
      proxy_set_header X-Forwarded-Proto $scheme;
      proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
      proxy_cache_bypass $http_upgrade;
  }
}

然后,在 Nginx 的主配置文件里把刚才新创建的配置文件(/etc/nginx/nginx.confinclude 进去就可以,如:

include /etc/nginx/conf.d/*.conf;

因为,我的主机里运行了4个站点,* 的意思就是加载这个目录下的所有配置文件

然后,记得把刚才项目里的 .env 配置文件改成 127.0.0.1 ,因为我们现在使用了代理,网络请求交给了 Nginx

再进入到项目的根目录下运行:

pm2 stop server.js #停止项目
pm2 start server.js #启动项目

这时候再用服务器 IP 访问就是用的 Nginx 去处理请求

域名和SSL

如果你有域名可以去对应的供应商解析好,如想使用 https 协议,也可以去对应的供应商下载好证书(下载好的证书要放到服务器某个目录里)。

再修改下刚才创建的配置文件,让它能够支持 https 和 域名 访问:

server {
  listen 80;
      listen 443 ssl http2; #SSL
  server_name a.lishaoy.net; #域名
  ssl on;

  ssl_certificate /etc/letsencrypt/live/a.lishaoy.net/server.pem; #证书目录
  ssl_certificate_key /etc/letsencrypt/live/a.lishaoy.net/server.key; #证书目录
  ssl_protocols TLSv1.1 TLSv1.2;
  ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:HIGH:!aNULL:!MD5:!RC4:!DHE;
  ssl_prefer_server_ciphers on;
  ssl_session_cache shared:SSL:10m;
  ssl_session_timeout 10m;
  if ($ssl_protocol = "") {
    rewrite ^(.*) https://$host$1 permanent;
  }
  error_page 497  https://$host$request_uri;

  error_page 404 /404.html;
  error_page 502 /502.html;

  location / {
      proxy_pass http://localhost:3333;
      proxy_http_version 1.1;
      proxy_set_header Upgrade $http_upgrade;
      proxy_set_header Connection 'upgrade';
      proxy_set_header Host $host;
      proxy_set_header X-Real-IP $remote_addr;
      proxy_set_header X-Forwarded-Proto $scheme;
      proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
      proxy_cache_bypass $http_upgrade;
  }
}

这样再重启 Ningx 服务和项目的服务,就大功告成了。

查看原文

子木 发布了文章 · 2018-12-18

用 Node.js 快速开发出多功能的多人在线的文章分享平台

no-shadow

最近在学习使用 Node.js 框架,边学习边使用,花了大概 3周 时间做完这个 Web应用 且在 <time>12月16</time> 凌晨左右上线成功(其实就是把开发环境搬到服务器), 地址: https://a.lishaoy.net

这个 Web应用 的代码是开源的,如对这个应用感兴趣,想知道代码是如何运行的,可以去我 GitHub 下载或 clone应用源码


首先,来看看用 3周 时间做出来的应用都有些什么功能,之后再看看选用的 Node.js 框架,最后看看 Node.js 项目如何部署到服务器。

Web应用功能

登录、注册验证

登录功能

  • 输入框没有输入点击登录会提示:用户名、密码不能为空
  • 输入的用户错误或不存在会提示:用户不存在
  • 输入的密码错误会提示:密码错误
  • 登录后会重定向到用户上次访问的地址

no-shadow

注册功能

  • 输入框没有输入点击注册会提示:用户名、邮箱、密码不能为空
  • 用户名和邮箱与其他用户相同会提示:用户名、邮箱已存在
  • 密码小于6位数会提示:最小长度是6位
  • 注册成功后会发送验证邮件到用户邮箱,需点击邮箱按钮验证

no-shadow

文章列表

登录进来,会显示文章列表页面,显示内容如下:

  • 文章标题:点击可进入文章详情页
  • 作者头像、作者名称:点击可进入作者信息页
  • 时间:显示创建时间(多久以前方式显示)
  • 阅读次数、点赞次数
  • 文章简要:自动摘取章头文章
  • 缩略图:自动摘取文章第一张图片

no-shadow

文章详情

点击文章标题可进入文章详情页面,内容如下:

  • 文章标题
  • 作者头像、作者名称
  • 发布时间
  • 阅读次数和点赞次数
  • 编辑按钮(仅作者可见)
  • 左侧浮动工具栏(点赞、发送邮件到自己邮箱、返回顶部、分享)
  • 点赞:文章被点赞后,作者可以收到消息通知,且将文章收录到点赞列表(支持匿名点赞,但不会记录通知,只会加点赞数)

no-shadow

编辑文章支持 Markdown

新建文章和修改文章都支持 Markdown 语法,且会每隔6秒钟自动保存

no-shadow

个人信息

个人信息页面显示内容如下

  • 作者的头像、姓名、简介(支持emoji)
  • 信息栏:GitHub 链接、个人网站链接、发布文章数、总阅读次数、总点赞次数
  • 发布文章列表:个人发布的所有文章(有删除和编辑按钮)
  • 已赞文章列表:点过赞的文章会记录在这里
  • 关注者列表:关注你的用户(关注过的用户,关注按钮高亮显示)
  • 已关注列表:你关注的用户(关注过的用户,关注按钮高亮显示)
  • 关注按钮:作者本人不可见,点击可关注,再次点击取消关注,关注后,用户会收到消息通知

no-shadow

文章删除编辑快捷入口,如图

no-shadow

下面是我用另一个用户登录,进入到个人信息页面就会显示关注按钮,如图

no-shadow

文件上传

点击文件上传小图标可进入文件上传页面,点击 Files 链接可进入文件上传列表,显示内容如图:

no-shadow

no-shadow

文件预览和编辑

从文件列表页面点击标题可进入文件预览页面,显示内容如下:

  • 如果是图片显示图片,如果是视频显示视频
  • 工具栏:发送邮件到自己邮箱(登录可见)、编辑按钮、删除按钮(登录自己上传可见)
  • 文件名称
  • 下载按钮
  • 上传者头像

no-shadow

消息通知

点击铃铛小图标可进入消息通知页面,内容如下:

  • 点赞消息列表:收到用户点赞通知,最新的未读消息会高亮显示,点击点赞者头像进入个人信息页面,点击文章标题进入你的文章详情页面
  • 关注者列表:收到关注者的通知,最新未读消息会高亮显示,点关注按钮也可关注他,再点击取消关注
  • 系统消息:目前还没有做功能实现

no-shadow

工具栏列表

点击个人头像可展开工具栏列表,内容如下:

  • 写文章:点击可新建文章编辑页面,和 ➕ 小图标是同样功能
  • 上传文件:点击可打开文件上传页面,和上传小图标是同样功能
  • 个人信息: 点击可进入个人信息页面
  • 已赞:点击可查看已赞过得文章
  • 设置:点击可打开个人设置页面
  • 登出:点击退出登录

no-shadow

设置

点击工具栏上的设置按钮可以设置页面,内容如下:

个人信息设置

  • 头像:头像是使用的 Gravatar 提供的功能,根据邮箱生成头像
  • 用户名
  • 邮箱:已验证通过会显示验证小图标,没有通过的会显示提示
  • GitHub:只需填写有户名
  • 个人简介:支持emoji
  • 个人网站

no-shadow

修改密码设置

需填写原密码,新密码,再次输入密码

no-shadow

聊天室

点击 Chatroom 链接可进入聊天室,当然这个是用的 websocket 做的,内容如下:

  • 状态图标:显示链接状态
  • 活动用户:左侧黑色区域会动态显示活动用户
  • 消息:会显示发送消息,进入、离开房间通知消息(支持匿名发送消息,但不会保存消息)
  • 消息输入:消息输入框可输入消息,CmdEnter 换行(Windows会显示提示Ctrl+Enter),回车发送消息

no-shadow

加入房间和离开房间都有消息通知,如图

no-shadow

Node.js 框架

这个应用的开发我选择的是 Adonisjs 框架,他和 PHPLaravel 有些像,Adonisjs 是在操作系统上运行的 Node.jsMVC 框架。

接下来,来看看 Adonisjs 框架有哪些特性:

环境安装简单

不管是开发环境还是生产环境,安装 Adonisjs 运行环境都是非常简单,先来看看开发环境的安装,生产环境后面会提到。

首先,我们的电脑上需要安装好 Node.js大于 8.00 版本,管理 Node.js 可以使用 nvm

其次,就可以使用 npm 安装 Adonis CLI 命令行工具(管理 npm 使用源可以使用 nrm

npm i -g @adonisjs/cli

这样就可以在全局使用 adonis 命令

再次,可以是 adonis new 命令创建项目

adonis new adonis_pro

cd 进入项目,执行 adonis serve --dev 运行项目

cd adonis_pro
adonis serve --dev

这样您的开发环境就搭建完成。

RMVC

RMVC 就是路由、模型、视图、控制器。

路由

创建一条路由非常简单,如

Route.get('liked/:userId/:postId', 'LikedController.liked')

这条路由就是用来处理上面提到的点赞功能的

当然,Adonisjs 提供了 资源路由 以便您更方便的创建路由,例如

Route.resource('posts', 'PostController').middleware(
   new Map([
        [ [ 'create', 'store', 'edit', 'update', 'destroy' ], [ 'auth' ] ],
        [ [ 'update', 'destroy', 'edit' ], [ 'own:post' ] ]
   ])
).validator(new Map([
  [['posts.update', 'posts.store'], ['StorePost']]
]))

这个路由是来处理上面应用提到的文章的 增、删、改、查 ,这个可能有些复杂,使用了 中间件 来处理用户登录状态和操作权限,使用了 验证器 来处理表单验证,这里不介绍的太复杂,如想了解这些具体功能,可以需要花点时间了解学习。

我们可以去掉 中间件验证器 ,如下:

Route.resource('posts', 'PostController')

这条资源路由,其实就包含了以下路由:

Route.get(url, closure)
Route.post(url, closure)
Route.put(url, closure)
Route.patch(url, closure)
Route.delete(url, closure)

Adonisjs 还提供了路由组和其他一些功能,路由组如下:

Route.group(() => {
    Route.get('profile', 'ProfileController.edit').as('profile.edit')
    Route.post('profile', 'ProfileController.update').as('profile.update').validator('UpdateProfile')
    Route.get('password', 'PasswordController.edit').as('password.edit')
    Route.post('password', 'PasswordController.update').as('password.update').validator('UpdatePassword')
})
    .prefix('settings')
    .middleware([ 'auth' ])

使用 .prefixRoute.group 来创建路由组,这条路由组是处理 个人信息设置 功能的,这样访问页面是就统一要带上 settings/**

控制器

Adonisjs 提供了命令行来创建控制器,如

adonis make:controller User --type http

这样就创建了一个 User 控制器,自动生成代码如下:

'use strict'

class UserController {
}

module.exports = UserController

当然,我们还可以使用 --resource 创建资源类型的控制器

adonis make:controller Post --resource

自动生成代码,代码如下:

'use strict'

class PostController {
 /**
   * Show a list of all posts.
   * GET posts
   */
async index ({ request, response, view }) {}

 /**
   * Render a form to be used for creating a new posts.
   * GET posts/create
   */
async create ({ request, response, view }) {}

 /**
   * Create/save a new posts.
   * POST posts
   */
async store ({ request, response, view }) {}
 /**
   * Display a single posts.
   * GET posts/:id
   */
async show ({ request, response, view }) {}

 /**
   * Render a form to update an existing posts.
   * GET posts/:id/edit
   */
async edit ({ request, response, view }) {}

 /**
   * Update posts details.
   * PUT or PATCH posts/:id
   */
async update ({ request, response, view}) {}

 /**
   * Delete a posts with id.
   * DELETE posts/:id
   */
async destroy ({ params, request, response }) {}
}

module.exports = PostController

和上面的资源路由是对应的,如用 GET 请求访问 posts 就会调用 index 方法(一般用来显示) ,再如:用 DELETE 请求访问 posts/1 就会执行 destroy 方法(一般用来删除)。

模型

Adonisjs 提供了两种模式来处理数据,Query builderLUCID

首先,我们可以通过 adonis make:migration 来创建数据表

adonis make:migration users

会自动生成代码,如下:

'use strict'

const Schema = use('Schema')

class UsersSchema extends Schema {
  up () {
    this.create('users', (table) => {
      table.increments()
      table.timestamps()
    })
  }

  down () {
    this.drop('users')
  }
}

module.exports = UsersSchema

这是我们只需在其中添加想要的字段就行,如:

'use strict'

const Schema = use('Schema')

class UsersSchema extends Schema {
  up () {
    this.create('users', (table) => {
      table.increments()
      table.string('username', 80).notNullable().unique()
      table.string('email', 254).notNullable().unique()
      table.string('password', 60).notNullable()
      table.timestamps()
    })
  }

  down () {
    this.drop('users')
  }
}

module.exports = UsersSchema

在执行 adonis migration:run 命令就可以在数据库生成数据表

再来看看,如何获取数据,可以使用 Query builderLUCID 两种方式

先来看看 Query builder

const Database = use('Database')

class UserController {

  async index (request, response) {
    return await Database
      .table('users')
      .where('username', 'admin')
      .first()
  }

}

查询 usernameadmin 的用户

Adonisjs 提供了非常多的方法去操作数据,不是特复杂的关系都够用,如果,关系比较复杂,还可以用原生的 sql 操作,如

'use strict'

const Database = use('Database')

  class NotificationController {
  async followNotice ({ auth, view }) {
    const notices = await Database.raw('select users.id as user_id,users.username,users.email,b.title,b.created_at,b.is_read,b.id as post_id from adonis.users , (select posts.id,posts.title, a.user_id,a.created_at,a.is_read from adonis.posts,(SELECT post_user.post_id, post_user.user_id, post_user.created_at, post_user.is_read FROM adonis.post_user where post_user.post_id in (SELECT posts.id FROM adonis.posts where user_id = ?)) as a where posts.id = a.post_id) as b where b.user_id = users.id and b.user_id <> ? order by b.created_at desc limit 50',[ auth.user.id, auth.user.id ])
  }
}

module.exports = NotificationController

使用 Database.raw 来运行原生的 sql,以上这条 sql 是用来查询所有用户给自己所有文章点赞的用户信息和文章信息用于消息通知。

再来看看,LUCID 的模式是如何操作数据的:

使用 LUCID 模式,我们先需要用命令行工具创建 Models,如:

adonis make:model User

自动生成代码如下:

'use strict'

const Model = use('Model')

class User extends Model {
}

module.exports = User

模型和模型之间需要定义一些关系,如:

const Model = use('Model')

class User extends Model {
  profile () {
    return this.hasOne('App/Models/Profile')
  }
}

module.exports = User

意思是 一个用户对应一个用户信息档案,一对一 的关系

定义好关系之后,就可以方便的获取数据,如:

const User = use('App/Models/User')

const user = await User.find(1)
const userProfile = await user.profile().fetch()

意思是,从用户表和用户个人信息表里获取用户 id1 的用户信息及个人信息,

其中,关系可以定义为 3一对一、一对多、多对多 ,多对多需要定义中间表

再来看看,上面的应用中的实际应用,如:

async update ({ params, request, response, session, auth }) {
  const { title, content, user_id, tags } = request.all()

  const post = await Post.findOrFail(params.id)
  post.merge({ title, content})
  await post.save()

  await post.tags().sync(tags)

  session.flash({
    type: 'primary',
    message: 'Post updated successfully.'
  })

  return response.redirect(
    Route.url('PostController.show', {
      id: post.id
    })
  )
}

以上,是更新文章的方法,文章标签多对多 的关系,一个标签可以属于多篇文章,一篇文章可以有多个标签,await post.tags().sync(tags) 这句代码就可以通过 Models 里定义的关系自动把标签和文章关联起来保存到 poststags 表里且把关联关系保存到中间表 post_tag

当然,Adonisjs 提供了很多方便的方法,想了解更多的话需要您花点时间去了解学习。

视图

Adonisjs 框架里视图使用了 edge 模板,我们可以使用命令行工具创建视图文件,如:

adonis make:view post

我看可以看下简单的例子:

@loggedIn
  <h2> You are logged in </h2>
@else
  <p> <a href="/login">Click here</a> to login </p>
@endloggedIn

视图模板里可以使用标签来做逻辑判断,视图模板就没什么好说的,基本都是通用的,关于 edge 视图模板更多语法 Edge官方文档

最后,Adonisjs 框架还提供了很多其它的实用工具,如:Middleware 中间件、Validator 验证器、Error Handling 自定义异常、Events 事件、Mails 邮件、Websocket 等来处理各种问题。

Node.js项目发布到阿里云服务器

首先,我们需要用 ssh 连接到阿里云(或者其他服务器供应商)的主机上,安装一些必要的工具。

工具安装

安装 epel-release 软件包仓库

我们需要安装 epel-release 软件包仓库,epel-release 里面有很多最新的软件包,如,之后安装的 git 就会用到

sudo yum install epel-release - y

安装 Git 版本控制命令行工具

sudo yum install git -y

准备 Node.js 运行环境

接下来,我们需要安装 Node.js 以便我们的 Node.js 项目能够跑起来,我们可以使用 nvm 安装和管理 Node.js ,使用 nrm 来管理切换安装源。

安装 nvm

curl -o- https://raw.githubusercontent.com/creationix/nvm/v0.33.11/install.sh | bash

安装好之后,我们需要配置下环境变量,以便能够在命令行使用 nvm 命令,用 vi ~/.bash_profile 编辑下配置文件

vi ~/.bash_profile

加入以下代码:

export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
[ -s "$NVM_DIR/bash_completion" ] && \. "$NVM_DIR/bash_completion"

然后,在 source ~/.bash_profile 刷新下配置文件,让它生效

source ~/.bash_profile

此时,我们就可以使用 nvm 来安装 Node.js

nvm install node

安装好后,可以使用 nvm list 来查看有哪些版本可以使用

nvm list

结果:

->     v10.13.0
        v11.2.0
         system
default -> v10.13.0
node -> stable (-> v11.2.0) (default)
stable -> 11.2 (-> v11.2.0) (default)
iojs -> N/A (default)
lts/* -> lts/dubnium (-> v10.13.0)
lts/argon -> v4.9.1 (-> N/A)
lts/boron -> v6.14.4 (-> N/A)
lts/carbon -> v8.13.0 (-> N/A)
lts/dubnium -> v10.13.0

我使用的是 v10.13.0 的版本,默认安装的都是比较新的版本,可能是 v11.2.0v11.1.0,所以我们也可以用 nvm install v10.13.0 来安装指定版本。

nvm install v10.13.0

然后,就可以使用 nvm use v10.13.0 来使用指定版本

nvm use nvm v10.13.0

结果:

Now using node v10.13.0 (npm v6.4.1)

安装 nrm 管理安装源

使用 npm 安装的程序包,默认的来源是 http://registry.npmjs.org,国内的下载速度会有些慢,我们可以是 nrm 来切换到 taobao 的源

安装 nrm

npm install nrm --global

切换到 taobao 源

nrm use taobao

准备项目

以上工作完成之后,我们的服务器就可以正常运行 Node.js 项目,现在我们需要把本地的项目上传到服务器,上传方法有很多,如:

  • 可以使用 git,先把项目传到 GitHub,然后用 git 下载到服务器
  • 可以是 FTP 工具
  • 可以是命令上传 scp -r 本地目录 root@服务器IP:/var/www/

发项目文件上传到服务器的指定目录下,如:www

接下来,我们可以是 PM2 来管理 Node 进程,先需要安装 PM2

安装PM2

npm install pm2@latest --global

这些工作作为之后,就可以来测试一下,启动项目,在本地访问服务器 IP:PORT 来测试是否可以访问

测试项目是否可以运行

在测试之前,我们需要改下应用的配置文件,adonisjs 框架里是 .env 文件,修改下 HOST 的值:

HOST=0.0.0.0
PORT=3333
...

HOST 默认是 127.0.0.1,需要改成 0.0.0.0 这样就可以在自己电脑上用服务器 IP:PORT 来访问应用

改完后,进入到项目的根目录,运行应用,adonisjs 的启动文件是 server.js,如:

pm2 start server.js

如启动成功会提示:

[PM2] Applying action restartProcessId on app [server](ids: 0)
[PM2] [server](0) ✓
[PM2] Process successfully started
┌──────────┬────┬─────────┬──────┬──────┬────────┬─────────┬────────┬─────┬──────────┬──────┬──────────┐
│ App name │ id │ version │ mode │ pid  │ status │ restart │ uptime │ cpu │ mem      │ user │ watching │
├──────────┼────┼─────────┼──────┼──────┼────────┼─────────┼────────┼─────┼──────────┼──────┼──────────┤
│ server   │ 0  │ 4.1.0   │ fork │ 7171 │ online │ 30      │ 0s     │ 0%  │ 3.4 MB   │ root │ disabled │
└──────────┴────┴─────────┴──────┴──────┴────────┴─────────┴────────┴─────┴──────────┴──────┴──────────┘
 Use `pm2 show <id|name>` to get more details about an app

然后,在自己电脑上用服务器 IP:PORT 来访问应用。

Nginx 代理

为了让服务器更好地处理网络请求,我们需要添加使用 Nginx 反向代理 把请求转发给 Node.js 应用

安装 Nginx

sudo yum install nginx -y

如果你的服务之前安装过可不用安装,我的阿里云服务器运行了 4 个站点之前安装过,之后我只需添加配置就行。

启动 Nginx

sudo systemctl start nginx

配置 Nginx

一般情况 Nginx 安装好后会有 /etc/nginx/conf.d 目录,进入这个目录,创建一个配置文件为 Node.js 而准备,名字可随意命名,如:adonis.conf

server {
  listen 80;
  location / {
      proxy_pass http://127.0.0.1:3333;
      proxy_http_version 1.1;
      proxy_set_header Upgrade $http_upgrade;
      proxy_set_header Connection 'upgrade';
      proxy_set_header Host $host;
      proxy_set_header X-Real-IP $remote_addr;
      proxy_set_header X-Forwarded-Proto $scheme;
      proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
      proxy_cache_bypass $http_upgrade;
  }
}

然后,在 Nginx 的主配置文件里把刚才新创建的配置文件(/etc/nginx/nginx.confinclude 进去就可以,如:

include /etc/nginx/conf.d/*.conf;

因为,我的主机里运行了4个站点,* 的意思就是加载这个目录下的所有配置文件

然后,记得把刚才项目里的 .env 配置文件改成 127.0.0.1 ,因为我们现在使用了代理,网络请求交给了 Nginx

再进入到项目的根目录下运行:

pm2 stop server.js #停止项目
pm2 start server.js #启动项目

这时候再用服务器 IP 访问就是用的 Nginx 去处理请求

域名和SSL

如果你有域名可以去对应的供应商解析好,如想使用 https 协议,也可以去对应的供应商下载好证书(下载好的证书要放到服务器某个目录里)。

再修改下刚才创建的配置文件,让它能够支持 https 和 域名 访问:

server {
  listen 80;
      listen 443 ssl http2; #SSL
  server_name a.lishaoy.net; #域名
  ssl on;

  ssl_certificate /etc/letsencrypt/live/a.lishaoy.net/server.pem; #证书目录
  ssl_certificate_key /etc/letsencrypt/live/a.lishaoy.net/server.key; #证书目录
  ssl_protocols TLSv1.1 TLSv1.2;
  ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:HIGH:!aNULL:!MD5:!RC4:!DHE;
  ssl_prefer_server_ciphers on;
  ssl_session_cache shared:SSL:10m;
  ssl_session_timeout 10m;
  if ($ssl_protocol = "") {
    rewrite ^(.*) https://$host$1 permanent;
  }
  error_page 497  https://$host$request_uri;

  error_page 404 /404.html;
  error_page 502 /502.html;

  location / {
      proxy_pass http://localhost:3333;
      proxy_http_version 1.1;
      proxy_set_header Upgrade $http_upgrade;
      proxy_set_header Connection 'upgrade';
      proxy_set_header Host $host;
      proxy_set_header X-Real-IP $remote_addr;
      proxy_set_header X-Forwarded-Proto $scheme;
      proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
      proxy_cache_bypass $http_upgrade;
  }
}

这样再重启 Ningx 服务和项目的服务,就大功告成了。

查看原文

赞 48 收藏 33 评论 3

子木 回答了问题 · 2018-11-25

解决gulp 报错

rm -fr node_modules
rm -fr package-lock.json
npm cache clean --force
npm install

已经测试过了,有效 ?

关注 4 回答 4

子木 分享了头条 · 2018-11-22

可以熟悉及理解使用 vue cli 创建的 vue 项目是如何运行,使用 webpack 模块化管理项目,且在这个小 App 里使用了 vuex 数据管理,可以更好的理解 State 、 Mutations 、 Getters 、 Actions 的概念且运用它们

赞 0 收藏 0 评论 0

子木 发布了文章 · 2018-11-12

手拉手带你极速构建漂亮的跨平台(iOS/Android)移动应用 ✿ 环境搭建

no-shadow

上篇文章带大家认识了 Flutter ,想必大家已迫不及待的想练练手,所以要行动起来,现在这篇文章就带您搭建一个 Flutter 运行及开发环境。

文章详情可查阅我的博客 https://h.lishaoy.net ,欢迎大家访问。

安装 Flutter SDK

想要在本地电脑上运行 Flutter ,需要安装 Flutter SDK 才可以运行, SDK 里面有一些用于创建、构建、测试和编译应用程序的命令行工具等,这些在开发的时候会用到。

首先,我们有 2 种方法获取 SDK

git clone -b master https://github.com/flutter/flutter.git

其次,把下载下来的 Flutter SDK 解压,放到系统的某个目录,比如我是放到: /Applications/flutter ,如图:

no-shadow

配置环境变量

配置环境变量的目的是为了让 Flutter SDK 命令行工具在全局范围都起作用,以便开发使用。

首先,您可以用编辑器打开主目录下的 .bash_profile,或者用 vi 命令编辑,我习惯用 vi 命令,如下

vi $HOME/.bash_profile

新增以下配置

export PATH=$PATH:/Applications/flutter/bin
export PUB_HOSTED_URL=https://pub.flutter-io.cn
export FLUTTER_STORAGE_BASE_URL=https://storage.flutter-io.cn
Tips:
第一行 export PATH=$PATH:/Applications/flutter/bin 中的 /Applications/flutter/bin 就是刚才下载的 Flutter SDK 解压后放在本地电脑的目录,您要根据自己操作更改为自己电脑对应的目录。
第二、三行为解决国内下载或更新资源慢的国内镜像,配置这个下载或更新资源会快一些。

再执行 source $HOME/.bash_profile 命令刷新当前命令行窗口,或者关掉当前命令行窗口重新打开,效果一样

source $HOME/.bash_profile

再执行 flutter --help,来测试环境变量是否配置成功,如图:

no-shadow

Tips: 如果你使用的是 zsh,需要在 ~/.zshrc 文件中添加:source ~/.bash_profile ,否则 flutter 命令将无法运行。

配置 iOS 开发环境

想用 Flutter 为 iOS 平台开发应用,需要安装 Xcode,我们可以去苹果应用商店下载。

安装好 Xcode 后,你需要打开一次 Xcode 同意许可协议(会提示),或者执行 sudo xcodebuild -license 同意许可协议。

然后执行 open -a Simulator 命令,就可以打开一个模拟器,来运行和测试 Flutter 程序,如图

no-shadow

配置 Android 开发环境

想用 Flutter 为 Android 平台开发应用,需要下载安装 Android Studio

安装好 Android Studio 后,启动它,首次启动会安装最新的 Android SDK ,但是你可能会遇到这样的问题,如图:

no-shadow

如果遇到这个问题应该就是网络问题(需要科学上网),点 Setup Proxy 来设置代理,如图:

no-shadow

如一切正常,就会提示你需要下载一些东西,如图

no-shadow

点击 Finish 按钮后就会下载安装以上列表的东西,下载安装完 SDK 后,如图:

no-shadow

需要我们打开一个项目,我们可以用刚才已经配置好的 Flutter SDK 的命令行创建一个 Flutter 项目,如执行以下命令

cd ~/desktop
flutter create new_flutter

命令执行完成后,在桌面就会生成一个 Flutter 项目,再用 Android Studio 打开,项目打开后会提示安装 Flutter 插件和依赖 Dart 语言插件 ,安装完之后我们可以去创建一个模拟器。

打开 Tools>AVD Manager ,点击 Create Virtual Device... 来创建一个模拟器,选择一个设备,点击 Next,如图

no-shadow

为模拟器选择一个系统镜像(我选择的是第一个),点击 Download ,下载完成后,点击 Next 后,如图

no-shadow

最后,在模拟性能这里选择 Hardware - GLES 2.0 启动硬件加速,点击 Finish 完成

no-shadow

配置编辑器

前面我们已经配置好了 Flutter SDKiOS 模拟器Android 模拟器 ,最后我们还需要配置一下编辑器,当然您可以选择 Android Studio 或者 VS Code,这里我选择的是轻量级的 VS Code

如对 VS Code 不是很熟悉,可参考我之前写的 VS Code 编辑技巧

打开终端进入我们刚才新建的 Flutter 项目

cd new_flutter

再用 VS Code 打开项目

code ./

打开项目之后 - - X ,打开扩展,安装 Flutter 插件,如图

no-shadow

完成之后,打开项目目录 lib->main.dart 文件, VS Code 会自动提示你安装 Dart 语言扩展包。

运行项目

现在,所有的准备工作都完成了,就可以开发、测试或运行项目了,在上面我们用 Flutter create 命令创建的 Flutter 项目,自带一个计数器的小功能,我们可以运行看看效果

首先,您需要执行 flutter doctor 来检查一下环境是否正常

no-shadow

如上图第二项提示 Android license status unknown. 意思是 Android 协议没安装好,可以执行以下命令,来解决问题

flutter doctor --android-licenses

如上图第三项是 iOS 真机的检查项,可以按照提示操作
>
如上图第四项是 Java 的编辑器检查,可不用理会,如你没有安装 IDEA 也不会有这个提示

其实在我另一台电脑上全部都配置好了 😝 ,如图

no-shadow

最后,在 VS Code 编辑器里按 F5 后,会让你选择模拟器来运行 Flutter 程序,如图

no-shadow

这个是分别在 iOS 和 Android 运行 Flutter 的效果,如图

no-shadow

运行 Flutter 案例

现在所有的都准备好了,您可以去我的 GitHub 上下载上篇文章中的案例代码,也可以 git clone

cd $HOME/Desktop #进到桌面
git clone https://github.com/persilee/flutter_pro.git #下载案例
cd flutter_pro #进入案例目录
flutter packages get #获取依赖包
code ./ #用 VS Code 打开

完成以上步骤后,在 VS Code 按 F5 选择模拟器,查看运行效果,如图

no-shadow

好的,大功告成,这篇到处为止,下篇将手拉手带大家完成一个实操小案例 。

查看原文

赞 14 收藏 12 评论 0

子木 发布了文章 · 2018-11-07

Flutter:手拉手带你极速构建漂亮的跨平台(iOS/Android)移动应用 ✿ 初识

no-shadow

最近,学习了一些 Flutter 相关的知识,做了如下的小移动应用,当然是一套代码即可在 iOS 平台运行,也可以在 Android 运行。

下面我将手拉手带您快速构建出漂亮的移动应用界面(如下👇小视频)

此处有视频,请移步到我的博客

初识 Flutter

Flutter 是谷歌的移动UI框架,可以快速在iOS和Android上构建高质量的原生用户界面。

Google 推出 Flutter 移动应用框架已经有三年,直到今年的 Google I/O 开发大会才正式介绍 Flutter 且发布 Beta 版本, Flutter Google I/O 视频 这个是 Flutter 在油管(YouTube)的 Google I/O 开发者大会的视频,请自行观看。

再来看看更加生动的视频介绍,加速您的初识 Flutter

此处有视频,请移步到我的博客

Google 的广告应用 Adwords,阿里的闲鱼 App 都是基于 Flutter 框架开发的。

以下是阿里巴巴用 Flutter 打造了5000多万用户闲鱼 App (Flutter Developer Story) 的故事视频

此处有视频,请移步到我的博客

Flutter 作为谷歌推出的跨平台开发框架,一经推出便吸引了不少注意,在 GitHub 上的 Star 数已超过 4W+

CMTC全球大前端技术大会 ㄧ Flutter视频 这是 Google中国在 bilibili 上发布的视频,请自行观看。

Flutter 特性

通过以上的视频,对于 Flutter 已有所认识,下面再来看看 Flutter 的以下特性

热重载

当你修改了代码 - S ,可立刻看到效果,而且可以保持界面状态不变(如文本框输入的信息不会改变),如图:

no-shadow

设计

Flutter 自带 Google 推行的设计系统:Material Design ,它提供了丰富的 Material Design 风格的组件(比如:按钮、输入框、对话框、导航栏、边栏等),而且也提供了丰富的 iOS(Cupertino) 风格的组件,利用这些风格的组件能够快速的构建应用,如图

no-shadow

widget

widgetFlutter 应用程序基本构建块, Flutter 既不使用 WebView,也不使用操作系统的原生控件,相反 Flutter 使用自己的高性能渲染引擎来绘制widgetFlutter 的中心思想是用 widget 构建你的 UI(一切皆为 widget) ,如图是官网给出的框架图:

no-shadow

在这个架构里,你可以实现 Flutter 提供的所有现成的 widget ,也可以创建自己定制的 widget ,每个 widget 都是公开的,你可以从高层次且统一的 widget 中获得开发效率优势,这个设计的目标是为了用更少的代码做更多的事情。

语言

Flutter 使用 C、C ++、Dart 和 Skia(2D渲染引擎)技术构建,如上图,底层(engine)是用 C ++ ,框架是用 Dart ,当然我们开发使用的也是 Dart。

Dart 是 Google 发布的一种高效、简洁、拥有完整类型系统的 结构化的Web编程 语言, Flutter 官方给出为什么选择 Dart 作为开发语言的原因,如下

  • 开发人员的效率
  • 面向对象
  • 可预测,高性能
  • 快速内存分配

对于开发者(语言使用者)来说,不必太担心,Dart 和 Java 、 JavaScript 比较类似,有一些程序语言基础,便可拿来即用(不清楚的看看语法、关键字、类型即可)。

现在想必您对 Flutter 已经有了一定的认识,在之后的篇幅了会手拉手带你极速构如文头小视频的小应用。

查看原文

赞 10 收藏 9 评论 0

子木 评论了文章 · 2018-10-29

VS Code:让你工作效率翻倍的23个插件和23个编辑技巧

VS Code

总结了一些平时常用且好用的 VS Code 的插件和编辑技巧分享出来。

文章详情可查阅我的博客:https://lishaoy.net

外观

主题

这里我分享两款主题:

1.Material Theme

效果如图:

Material Theme

2.An Old Hope Theme

效果如图:

An Old Hope Theme

图标

3.Material Icon Theme 当然,这两款主题的文件管理器(左侧)的 icon 小图标使用的是 Material Icon Theme

字体及其他

其他和外观相关的设置如下:

{
    "editor.multiCursorModifier": "ctrlCmd",
    "editor.formatOnPaste": false,
    "workbench.activityBar.visible": false,
    "workbench.iconTheme": "eq-material-theme-icons-darker",
    "workbench.colorCustomizations": {},
    "materialTheme.cache.workbench.settings": {
        "themeColours": "Darker",
        "accentPrevious": "Acid Lime"
    },
    "workbench.colorTheme": "Material Theme Darker",
    "material-icon-theme.angular.iconsEnabled": true,
    "material-icon-theme.folders.icons": "specific",
    "editor.lineHeight": 24,
    "editor.fontLigatures": true,
    "editor.fontFamily": "FiraCode-Medium"
}

特别注意的是 "editor.lineHeight": 24,"editor.fontFamily": "FiraCode-Medium"

"editor.lineHeight": 24, : 设置代码的行间距,这里比默认的稍大些,就这一点小小的改变,让代码看起来清爽整洁。

"editor.fontFamily": "FiraCode-Medium" : 设置字体,这种字体会让代码看起来更形象生动,如下

no-shadow

红色竖线左边是使用了 FiraCode-Medium 字体的效果,红色竖线右边是没有使用 FiraCode-Medium 字体的效果

关于 FiraCode-Medium 字体更多效果可查阅 https://github.com/tonsky/FiraCode 地址。

代码管理

格式化

4.Beautify :格式化的时候,给出格式化文本选项,如下

Beautify

5.Prettier :个人比较喜欢这个,看起来代码更清晰,如下

Prettier

当然,大家可以自定义快捷键,也可以按 - - P 来搜索相关命令

代码检查

6.ESLint :检查 js 语法规范,你可以使用不同的规范,如 airbnbstandardgoogle
7.TSLint :检查 typescript 语法规范。
8.Stylelint :检查 CSS/SCSS/Less 语法规范。
9.Markdownlint :检查 markdown 语法规范。

自动补全

以下插件点击链接可以查看gif动图,详细了解具体功能。

10.Emmet :大家应该很熟悉这个插件了(很好用),VS Code 已经内置了,很到位。
11.Auto Close Tag :自动闭合 html 等标签 (</...>)。
12.Auto Rename Tag :修改 html 标签时,自动修改闭合标签。
13.Path Intellisense :自动提示补全路径。

代码片段

14.snippets :搭建可以自己安装各种代码片段(vue、react、angular等),这里就不列举。

功能扩展

以下的功能扩展插件大部分都有gif动图,可点击链接了解详细功能

15.Bracket Pair Colorizer :让代码的各种括号呈现不同的颜色。
16.Code Runner :可以在编辑器里直接运行代码,查看结果。
17.Color Picker :可以直接在编辑器里打开色板,选择各种模式的颜色。
18.Document This :可以给函数、类等自动的加上详细的注释。
19.Git History :方便的查看git版本管理的详细信息。
20.Live Server :可以一键在本地启动服务器。
21.Settings Sync :重点介绍下这个插件,如果你有两台电脑(比如,家里和公司)都使用 VS Code ,可是在公司或家里对 VS Code 安装了插件或者修改了配置,回到家或公司又要重新弄一次,这个插件就能解决问题,同步多台电脑设置。

只需要把配置上传到GitHub,在另一个地方下载配置即可,如下

Settings Sync

22.gi :可以给 .gitignore 文件添加各种语言忽略文件配置。
23.Polacode :可以把代码生成图片(有些地方发代码结构会乱也没有代码高亮,这时候就可以生成图片再发)。

编辑技巧

光标

1.把光标移到文件的首部或尾部

⌘ - ↑ 或 ⌘ - ↓

2.把光标移动到行的首部或者尾部

⌘ - ← 或 ⌘ - →

3.按单词移动

⌥ - ← 或 ⌥ - →

4.按单词大小写分解移动光标

⌥ - ⌃ - ← 或 ⌥ - ⌃ - →

选择

5.选择行以上或以下全部内容

⇧ - ⌘ - ↑ 或 ⇧ - ⌘ - ↓

6.选择到行首或行尾的内容

⇧ - ⌘ - ← 或 ⇧ - ⌘ - →

7.按字母或单词选择

  • - - 按字母选择
  • - - - - 按单词选择

⇧ - ← 、 ⇧ - → 或 ⇧ - ⌥ - ← 、 ⇧ - ⌥ - →

8.伸缩选择

⇧ - ⌃ - ⌘ - ← 或 ⇧ - ⌃ - ⌘ - →

9.选择匹配单词

⌘ - D 或 ⌘ - U

10.向上或向下移动行

⌥ - ↑ 或 ⌥ - ↓

11.复制或删除行

⌥ - ⇧ - ↓ 或 ⌘ - ⇧ - K

12.多行合并成一行

⌘ - J

13.缩进或伸缩行

⌘ - [ 或 ⌘ - ]

14.在当前行之上或下插入行

⌘ - ↩ 或 ⌘ - ⇧ - ↩

多行

15.鼠标点击,多行编辑

选择编辑点,按 退出多行编辑

⌘

16.使用快捷键多行编辑

⌘ - ⌥ - ↓ 或 ⌘ - ⌥ - ↑

17.在所选择的行的结尾插入编辑点

⇧ - ⌥ - I

18.选择栏位

- 再选择栏位

⇧ - ⌘

高级

19.查看类或方法的定义

  • 点击,可以在新页面查看
  • - - 点击,可以在新组查看
  • - F12 点击,可以在当前页面查看

查看定义

20.折叠代码

⌥ - ⌘ - ] 或 ⌥ - ⌘ - [

  1. 去掉选择行的尾部空格

⌘ - K 、 ⌘ - X

22.定位到指定行号

⌃ - G

23.在文件里查找类或方法

@

最后,如果记不住这些快捷键,可以按 - K - S 搜索对应快捷键绑定

搜索快捷键

查看原文

子木 评论了文章 · 2018-10-24

VS Code:让你工作效率翻倍的23个插件和23个编辑技巧

VS Code

总结了一些平时常用且好用的 VS Code 的插件和编辑技巧分享出来。

文章详情可查阅我的博客:https://lishaoy.net

外观

主题

这里我分享两款主题:

1.Material Theme

效果如图:

Material Theme

2.An Old Hope Theme

效果如图:

An Old Hope Theme

图标

3.Material Icon Theme 当然,这两款主题的文件管理器(左侧)的 icon 小图标使用的是 Material Icon Theme

字体及其他

其他和外观相关的设置如下:

{
    "editor.multiCursorModifier": "ctrlCmd",
    "editor.formatOnPaste": false,
    "workbench.activityBar.visible": false,
    "workbench.iconTheme": "eq-material-theme-icons-darker",
    "workbench.colorCustomizations": {},
    "materialTheme.cache.workbench.settings": {
        "themeColours": "Darker",
        "accentPrevious": "Acid Lime"
    },
    "workbench.colorTheme": "Material Theme Darker",
    "material-icon-theme.angular.iconsEnabled": true,
    "material-icon-theme.folders.icons": "specific",
    "editor.lineHeight": 24,
    "editor.fontLigatures": true,
    "editor.fontFamily": "FiraCode-Medium"
}

特别注意的是 "editor.lineHeight": 24,"editor.fontFamily": "FiraCode-Medium"

"editor.lineHeight": 24, : 设置代码的行间距,这里比默认的稍大些,就这一点小小的改变,让代码看起来清爽整洁。

"editor.fontFamily": "FiraCode-Medium" : 设置字体,这种字体会让代码看起来更形象生动,如下

no-shadow

红色竖线左边是使用了 FiraCode-Medium 字体的效果,红色竖线右边是没有使用 FiraCode-Medium 字体的效果

关于 FiraCode-Medium 字体更多效果可查阅 https://github.com/tonsky/FiraCode 地址。

代码管理

格式化

4.Beautify :格式化的时候,给出格式化文本选项,如下

Beautify

5.Prettier :个人比较喜欢这个,看起来代码更清晰,如下

Prettier

当然,大家可以自定义快捷键,也可以按 - - P 来搜索相关命令

代码检查

6.ESLint :检查 js 语法规范,你可以使用不同的规范,如 airbnbstandardgoogle
7.TSLint :检查 typescript 语法规范。
8.Stylelint :检查 CSS/SCSS/Less 语法规范。
9.Markdownlint :检查 markdown 语法规范。

自动补全

以下插件点击链接可以查看gif动图,详细了解具体功能。

10.Emmet :大家应该很熟悉这个插件了(很好用),VS Code 已经内置了,很到位。
11.Auto Close Tag :自动闭合 html 等标签 (</...>)。
12.Auto Rename Tag :修改 html 标签时,自动修改闭合标签。
13.Path Intellisense :自动提示补全路径。

代码片段

14.snippets :搭建可以自己安装各种代码片段(vue、react、angular等),这里就不列举。

功能扩展

以下的功能扩展插件大部分都有gif动图,可点击链接了解详细功能

15.Bracket Pair Colorizer :让代码的各种括号呈现不同的颜色。
16.Code Runner :可以在编辑器里直接运行代码,查看结果。
17.Color Picker :可以直接在编辑器里打开色板,选择各种模式的颜色。
18.Document This :可以给函数、类等自动的加上详细的注释。
19.Git History :方便的查看git版本管理的详细信息。
20.Live Server :可以一键在本地启动服务器。
21.Settings Sync :重点介绍下这个插件,如果你有两台电脑(比如,家里和公司)都使用 VS Code ,可是在公司或家里对 VS Code 安装了插件或者修改了配置,回到家或公司又要重新弄一次,这个插件就能解决问题,同步多台电脑设置。

只需要把配置上传到GitHub,在另一个地方下载配置即可,如下

Settings Sync

22.gi :可以给 .gitignore 文件添加各种语言忽略文件配置。
23.Polacode :可以把代码生成图片(有些地方发代码结构会乱也没有代码高亮,这时候就可以生成图片再发)。

编辑技巧

光标

1.把光标移到文件的首部或尾部

⌘ - ↑ 或 ⌘ - ↓

2.把光标移动到行的首部或者尾部

⌘ - ← 或 ⌘ - →

3.按单词移动

⌥ - ← 或 ⌥ - →

4.按单词大小写分解移动光标

⌥ - ⌃ - ← 或 ⌥ - ⌃ - →

选择

5.选择行以上或以下全部内容

⇧ - ⌘ - ↑ 或 ⇧ - ⌘ - ↓

6.选择到行首或行尾的内容

⇧ - ⌘ - ← 或 ⇧ - ⌘ - →

7.按字母或单词选择

  • - - 按字母选择
  • - - - - 按单词选择

⇧ - ← 、 ⇧ - → 或 ⇧ - ⌥ - ← 、 ⇧ - ⌥ - →

8.伸缩选择

⇧ - ⌃ - ⌘ - ← 或 ⇧ - ⌃ - ⌘ - →

9.选择匹配单词

⌘ - D 或 ⌘ - U

10.向上或向下移动行

⌥ - ↑ 或 ⌥ - ↓

11.复制或删除行

⌥ - ⇧ - ↓ 或 ⌘ - ⇧ - K

12.多行合并成一行

⌘ - J

13.缩进或伸缩行

⌘ - [ 或 ⌘ - ]

14.在当前行之上或下插入行

⌘ - ↩ 或 ⌘ - ⇧ - ↩

多行

15.鼠标点击,多行编辑

选择编辑点,按 退出多行编辑

⌘

16.使用快捷键多行编辑

⌘ - ⌥ - ↓ 或 ⌘ - ⌥ - ↑

17.在所选择的行的结尾插入编辑点

⇧ - ⌥ - I

18.选择栏位

- 再选择栏位

⇧ - ⌘

高级

19.查看类或方法的定义

  • 点击,可以在新页面查看
  • - - 点击,可以在新组查看
  • - F12 点击,可以在当前页面查看

查看定义

20.折叠代码

⌥ - ⌘ - ] 或 ⌥ - ⌘ - [

  1. 去掉选择行的尾部空格

⌘ - K 、 ⌘ - X

22.定位到指定行号

⌃ - G

23.在文件里查找类或方法

@

最后,如果记不住这些快捷键,可以按 - K - S 搜索对应快捷键绑定

搜索快捷键

查看原文

子木 发布了文章 · 2018-10-15

VS Code:让你工作效率翻倍的23个插件和23个编辑技巧

VS Code

总结了一些平时常用且好用的 VS Code 的插件和编辑技巧分享出来。

文章详情可查阅我的博客:https://lishaoy.net

外观

主题

这里我分享两款主题:

1.Material Theme

效果如图:

Material Theme

2.An Old Hope Theme

效果如图:

An Old Hope Theme

图标

3.Material Icon Theme 当然,这两款主题的文件管理器(左侧)的 icon 小图标使用的是 Material Icon Theme

字体及其他

其他和外观相关的设置如下:

{
    "editor.multiCursorModifier": "ctrlCmd",
    "editor.formatOnPaste": false,
    "workbench.activityBar.visible": false,
    "workbench.iconTheme": "eq-material-theme-icons-darker",
    "workbench.colorCustomizations": {},
    "materialTheme.cache.workbench.settings": {
        "themeColours": "Darker",
        "accentPrevious": "Acid Lime"
    },
    "workbench.colorTheme": "Material Theme Darker",
    "material-icon-theme.angular.iconsEnabled": true,
    "material-icon-theme.folders.icons": "specific",
    "editor.lineHeight": 24,
    "editor.fontLigatures": true,
    "editor.fontFamily": "FiraCode-Medium"
}

特别注意的是 "editor.lineHeight": 24,"editor.fontFamily": "FiraCode-Medium"

"editor.lineHeight": 24, : 设置代码的行间距,这里比默认的稍大些,就这一点小小的改变,让代码看起来清爽整洁。

"editor.fontFamily": "FiraCode-Medium" : 设置字体,这种字体会让代码看起来更形象生动,如下

no-shadow

红色竖线左边是使用了 FiraCode-Medium 字体的效果,红色竖线右边是没有使用 FiraCode-Medium 字体的效果

关于 FiraCode-Medium 字体更多效果可查阅 https://github.com/tonsky/FiraCode 地址。

代码管理

格式化

4.Beautify :格式化的时候,给出格式化文本选项,如下

Beautify

5.Prettier :个人比较喜欢这个,看起来代码更清晰,如下

Prettier

当然,大家可以自定义快捷键,也可以按 - - P 来搜索相关命令

代码检查

6.ESLint :检查 js 语法规范,你可以使用不同的规范,如 airbnbstandardgoogle
7.TSLint :检查 typescript 语法规范。
8.Stylelint :检查 CSS/SCSS/Less 语法规范。
9.Markdownlint :检查 markdown 语法规范。

自动补全

以下插件点击链接可以查看gif动图,详细了解具体功能。

10.Emmet :大家应该很熟悉这个插件了(很好用),VS Code 已经内置了,很到位。
11.Auto Close Tag :自动闭合 html 等标签 (</...>)。
12.Auto Rename Tag :修改 html 标签时,自动修改闭合标签。
13.Path Intellisense :自动提示补全路径。

代码片段

14.snippets :搭建可以自己安装各种代码片段(vue、react、angular等),这里就不列举。

功能扩展

以下的功能扩展插件大部分都有gif动图,可点击链接了解详细功能

15.Bracket Pair Colorizer :让代码的各种括号呈现不同的颜色。
16.Code Runner :可以在编辑器里直接运行代码,查看结果。
17.Color Picker :可以直接在编辑器里打开色板,选择各种模式的颜色。
18.Document This :可以给函数、类等自动的加上详细的注释。
19.Git History :方便的查看git版本管理的详细信息。
20.Live Server :可以一键在本地启动服务器。
21.Settings Sync :重点介绍下这个插件,如果你有两台电脑(比如,家里和公司)都使用 VS Code ,可是在公司或家里对 VS Code 安装了插件或者修改了配置,回到家或公司又要重新弄一次,这个插件就能解决问题,同步多台电脑设置。

只需要把配置上传到GitHub,在另一个地方下载配置即可,如下

Settings Sync

22.gi :可以给 .gitignore 文件添加各种语言忽略文件配置。
23.Polacode :可以把代码生成图片(有些地方发代码结构会乱也没有代码高亮,这时候就可以生成图片再发)。

编辑技巧

光标

1.把光标移到文件的首部或尾部

⌘ - ↑ 或 ⌘ - ↓

2.把光标移动到行的首部或者尾部

⌘ - ← 或 ⌘ - →

3.按单词移动

⌥ - ← 或 ⌥ - →

4.按单词大小写分解移动光标

⌥ - ⌃ - ← 或 ⌥ - ⌃ - →

选择

5.选择行以上或以下全部内容

⇧ - ⌘ - ↑ 或 ⇧ - ⌘ - ↓

6.选择到行首或行尾的内容

⇧ - ⌘ - ← 或 ⇧ - ⌘ - →

7.按字母或单词选择

  • - - 按字母选择
  • - - - - 按单词选择

⇧ - ← 、 ⇧ - → 或 ⇧ - ⌥ - ← 、 ⇧ - ⌥ - →

8.伸缩选择

⇧ - ⌃ - ⌘ - ← 或 ⇧ - ⌃ - ⌘ - →

9.选择匹配单词

⌘ - D 或 ⌘ - U

10.向上或向下移动行

⌥ - ↑ 或 ⌥ - ↓

11.复制或删除行

⌥ - ⇧ - ↓ 或 ⌘ - ⇧ - K

12.多行合并成一行

⌘ - J

13.缩进或伸缩行

⌘ - [ 或 ⌘ - ]

14.在当前行之上或下插入行

⌘ - ↩ 或 ⌘ - ⇧ - ↩

多行

15.鼠标点击,多行编辑

选择编辑点,按 退出多行编辑

⌘

16.使用快捷键多行编辑

⌘ - ⌥ - ↓ 或 ⌘ - ⌥ - ↑

17.在所选择的行的结尾插入编辑点

⇧ - ⌥ - I

18.选择栏位

- 再选择栏位

⇧ - ⌘

高级

19.查看类或方法的定义

  • 点击,可以在新页面查看
  • - - 点击,可以在新组查看
  • - F12 点击,可以在当前页面查看

查看定义

20.折叠代码

⌥ - ⌘ - ] 或 ⌥ - ⌘ - [

  1. 去掉选择行的尾部空格

⌘ - K 、 ⌘ - X

22.定位到指定行号

⌃ - G

23.在文件里查找类或方法

@

最后,如果记不住这些快捷键,可以按 - K - S 搜索对应快捷键绑定

搜索快捷键

查看原文

赞 211 收藏 171 评论 7

子木 分享了头条 · 2018-06-25

通俗易懂的方式简单的介绍了 HTTP、HTTPS、HTTP2,并且介绍了基于nginx服务器如何部署HTTPS和HTTP2

赞 0 收藏 1 评论 0

子木 评论了文章 · 2018-06-20

前端性能优化 -- 从 10 多秒到 1.05 秒

关于 性能优化 是个大的面,这篇文章主要涉及到 前端 的几个点,如 前端性能优化 的流程、常见技术手段、工具等。

提及 前端性能优化 ,大家应该都会想到 雅虎军规,本文会结合 雅虎军规 融入自己的了解知识,进行的总结和梳理 😜

详情,可以查阅我的 博客lishaoy.net

首先,我们先来看看 👀 雅虎军规35 条。

  1. 尽量减少 HTTP 请求个数——须权衡
  2. 使用 CDN(内容分发网络)
  3. 为文件头指定 Expires 或 Cache-Control ,使内容具有缓存性。
  4. 避免空的 src 和 href
  5. 使用 gzip 压缩内容
  6. 把 CSS 放到顶部
  7. 把 JS 放到底部
  8. 避免使用 CSS 表达式
  9. 将 CSS 和 JS 放到外部文件中
  10. 减少 DNS 查找次数
  11. 精简 CSS 和 JS
  12. 避免跳转
  13. 剔除重复的 JS 和 CSS
  14. 配置 ETags
  15. 使 AJAX 可缓存
  16. 尽早刷新输出缓冲
  17. 使用 GET 来完成 AJAX 请求
  18. 延迟加载
  19. 预加载
  20. 减少 DOM 元素个数
  21. 根据域名划分页面内容
  22. 尽量减少 iframe 的个数
  23. 避免 404
  24. 减少 Cookie 的大小
  25. 使用无 cookie 的域
  26. 减少 DOM 访问
  27. 开发智能事件处理程序
  28. 用 <link> 代替 @import
  29. 避免使用滤镜
  30. 优化图像
  31. 优化 CSS Spirite
  32. 不要在 HTML 中缩放图像——须权衡
  33. favicon.ico要小而且可缓存
  34. 保持单个内容小于25K
  35. 打包组件成复合文本

如对 雅虎军规 的具体细则内容不是很了解,可自行去各搜索 🔍 引擎 ,搜索 雅虎军规 了解详情。

压缩 合并

对于 前端性能优化 自然要关注 首屏 打开速度,而这个速度,很大因素是花费在网络请求上,那么怎么减少网络请求的时间呢?

  • 减少网络请求次数
  • 减小文件体积
  • 使用 CDN 加速

所以 压缩、合并 就是一个解决方案,当然可以用 gulpwebpackgrunt 等构建工具 压缩、合并

JS、CSS 压缩 合并

例如:gulp js、css 压缩、合并代码如下 👇

//压缩、合并js
gulp.task('scripts', function () {
    return gulp.src([
        './public/lib/fastclick/lib/fastclick.min.js',
        './public/lib/jquery_lazyload/jquery.lazyload.js',
        './public/lib/velocity/velocity.min.js',
        './public/lib/velocity/velocity.ui.min.js',
        './public/lib/fancybox/source/jquery.fancybox.pack.js',
        './public/js/src/utils.js',
        './public/js/src/motion.js',
        './public/js/src/scrollspy.js',
        './public/js/src/post-details.js',
        './public/js/src/bootstrap.js',
        './public/js/src/push.js',
        './public/live2dw/js/perTips.js',
        './public/live2dw/lib/L2Dwidget.min.js',
        './public/js/src/love.js',
        './public/js/src/busuanzi.pure.mini.js',
        './public/js/src/activate-power-mode.js'
    ]).pipe(concat('all.js')).pipe(minify()).pipe(gulp.dest('./public/dist/'));
});

// 压缩、合并 CSS 
gulp.task('css', function () {
    return gulp.src([
        './public/lib/font-awesome/css/font-awesome.min.css',
        './public/lib/fancybox/source/jquery.fancybox.css',
        './public/css/main.css',
        './public/css/lib.css',
        './public/live2dw/css/perTips.css'
    ]).pipe(concat('all.css')).pipe(minify()).pipe(gulp.dest('./public/dist/'));
});

然后,再把 压缩、合并JS、CSS 放入 CDN , 👀 看看效果如何

如图:压缩、合并 且放入 CND 之后的效果

首页请求速度(js)

首页请求速度(css)

以上是 lishaoy.net 清除缓存后的 首页 请求速度。

可见,请求时间是 4.59 s ,总请求个数 51 , 而 js 的请求个数是 8css 的请求个数是 3 _(其实就 all.css 一个,其它 2 个是 Google浏览器加载的)_, 而没使用 压缩、合并 时候,请求时间是 10 多秒,总请求个数有 70 多个,js 的请求个数是 20 多个 ,对比请求时间 性能 提升 1倍

如图:有缓存下的首页效果

首页请求速度(缓存)

基本都是秒开 😝

Tips:在 压缩、合并 后,单个文件控制在 25 ~ 30 KB左右,同一个域下,最好不要多于5个资源

图片压缩、合并

例如:gulp 图片压缩代码如下 👇

//压缩image
gulp.task('imagemin', function () {
    gulp.src('./public/**/*.{png,jpg,gif,ico,jpeg}')
        .pipe(imagemin())
        .pipe(gulp.dest('./public'));
});

图片的合并可以采用 CSS Spirite,方法就是把一些小图用 PS 合成一张图,用 css 定位显示每张图片的位置

.top_right .phone {
    background: url(../images/top_right.png) no-repeat 7px -17px;
    padding: 0 38px;
}

.top_right .help {
    background: url(../images/top_right.png) no-repeat 0 -47px;
    padding: 0 38px;
}

然后,把 压缩 的图片放入 CDN , 👀 看看,效果如何

首页请求速度(images)

可见,请求时间是 1.70 s ,总请求个数 50 , 而 img 的请求个数是 15(这里因为首页都是大图,就没有合并,只是压缩了) ,但是,效果很好 😀 ,从 4.59 s 缩短到 1.70 s, 性能又提升一倍。

再看看有缓存情况如何 😏

首页请求速度(images 缓存)

请求时间是 1.05 s ,有缓存和无缓存基本差不多

Tips:大的图片在不同终端,应该使用不同分辨率,而不应该使用缩放(百分比)

整个 压缩、合并(js、css、img) 再放入 CDN ,请求时间从 10 多秒 ,到最后的 1.70 s ,性能提升 5 倍多,可见,这个操作必要性。

缓存

缓存会根据请求保存输出内容的副本,例如 页面、图片、文件,当下一个请求来到的时候:如果是相同的URL,缓存直接使 用本地的副本响应访问请求,而不是向源服务器再次发送请求。因此,可以从以下 2 个方面提升性能。

  • 减少相应延迟,提升响应时间
  • 减少网络带宽消耗,节省流量

我们用两幅图来了解下浏览器的 缓存机制

浏览器第一次请求

第一次请求

浏览器再次请求

再次请求

从以上两幅图中,可以清楚的了解浏览器 缓存 的过程。
首次访问一个 URL ,没有 缓存 ,但是,服务器会响应一些 header 信息,如:expires、cache-control、last-modified、etag 等,来记录下次请求是否缓存、如何缓存。
再次访问这个 URL 时候,浏览器会根据首次访问返回的 header 信息,来决策是否缓存、如何缓存。
我们重点来分析下第二幅图,其实是分两条线路,如下 👇

  • 第一条线路: 当浏览器再次访问某个 URL 时,会先获取资源的 header 信息,判断是否命中强缓存 (cache-control和expires) ,如命中,直接从缓存获取资源,包括响应的 header 信息 (请求不会和服务器通信) ,也就是 强缓存 ,如图

强缓存

  • 第二条线路: 如没有命中 强缓存 ,浏览器会发送请求到服务器,请求会携带第一次请求返回的有关缓存的 header 信息 (Last-Modified/If-Modified-Since和Etag/If-None-Match) ,由服务器根据请求中的相关 header 信息来比对结果是否协商缓存命中;若命中,则服务器返回新的响应 header 信息更新缓存中的对应 header 信息,但是并不返回资源内容,它会告知浏览器可以直接从缓存获取;否则返回最新的资源内容,也就是 协商缓存

现在,我们了解到浏览器缓存机制分为 强缓存、协商缓存,再来看看他们的区别 👇

缓存策略获取资源形式状态码发送请求到服务器
强缓存从缓存取200(from memory cache)否,直接从缓存取
协商缓存从缓存取304(not modified)是,通过服务器来告知缓存是否可用

强缓存

与强缓存相关的 header 字段有两个:

expires

expires: 这是 http1.0 时的规范,它的值为一个绝对时间的 GMT 格式的时间字符串,如 Mon, 10 Jun 2015 21:31:12 GMT ,如果发送请求的时间在 expires 之前,那么本地缓存始终有效,否则就会发送请求到服务器来获取资源

cache-control

cache-control:max-age=number ,这是 http1.1 时出现的 header 信息,主要是利用该字段的 max-age 值来进行判断,它是一个相对值;资源第一次的请求时间和 Cache-Control 设定的有效期,计算出一个资源过期时间,再拿这个过期时间跟当前的请求时间比较,如果请求时间在过期时间之前,就能命中缓存,否则未命中, cache-control 除了该字段外,还有下面几个比较常用的设置值:

  • no-cache: 不使用本地缓存。需要使用缓存协商,先与服务器确认返回的响应是否被更改,如果之前的响应中存在 ETag ,那么请求的时候会与服务端验证,如果资源未被更改,则可以避免重新下载。
  • no-store: 直接禁止游览器缓存数据,每次用户请求该资源,都会向服务器发送一个请求,每次都会下载完整的资源。
  • public: 可以被所有的用户缓存,包括终端用户和 CDN 等中间代理服务器。
  • private: 只能被终端用户的浏览器缓存,不允许 CDN 等中继缓存服务器对其缓存。
Tips:如果 cache-control 与 expires 同时存在的话,cache-control 的优先级高于 expires

协商缓存

协商缓存都是由浏览器和服务器协商,来确定是否缓存,协商主要通过下面两组 header 字段,这两组字段都是成对出现的,即第一次请求的响应头带上某个字段 Last-Modified 或者 Etag ,则后续请求会带上对应的请求字段 If-Modified-Since 或者 If-None-Match ,若响应头没有 Last-Modified 或者 Etag 字段,则请求头也不会有对应的字段。

Last-Modified/If-Modified-Since

二者的值都是 GMT 格式的时间字符串,具体过程:

  • 浏览器第一次跟服务器请求一个资源,服务器在返回这个资源的同时,在 responeheader 加上 Last-Modified 字段,这个 header 字段表示这个资源在服务器上的最后修改时间
  • 浏览器再次跟服务器请求这个资源时,在 requestheader 上加上 If-Modified-Since 字段,这个 header 字段的值就是上一次请求时返回的 Last-Modified 的值
  • 服务器再次收到资源请求时,根据浏览器传过来 If-Modified-Since 和资源在服务器上的最后修改时间判断资源是否有变化,如果没有变化则返回 304 Not Modified ,但是不会返回资源内容;如果有变化,就正常返回资源内容。当服务器返回 304 Not Modified 的响应时,response header 中不会再添加 Last-Modified的header ,因为既然资源没有变化,那么 Last-Modified 也就不会改变,这是服务器返回 304 时的 response header
  • 浏览器收到 304 的响应后,就会从缓存中加载资源
  • 如果协商缓存没有命中,浏览器直接从服务器加载资源时,Last-ModifiedHeader 在重新加载的时候会被更新,下次请求时,If-Modified-Since 会启用上次返回的Last-Modified

Etag/If-None-Match

这两个值是由服务器生成的每个资源的唯一标识字符串,只要资源有变化就这个值就会改变;其判断过程与 Last-Modified、If-Modified-Since 类似,与 Last-Modified 不一样的是,当服务器返回 304 Not Modified 的响应时,由于 ETag 重新生成过,response header 中还会把这个 ETag 返回,即使这个 ETag 跟之前的没有变化。

Tips:Last-Modified与ETag是可以一起使用的,服务器会优先验证ETag,一致的情况下,才会继续比对Last-Modified,最后才决定是否返回304。

Service Worker

什么是 Service Worker

Service Worker 本质上充当Web应用程序与浏览器之间的代理服务器,也可以在网络可用时作为浏览器和网络间的代理。它们旨在(除其他之外)使得能够创建有效的离线体验,拦截网络请求并基于网络是否可用以及更新的资源是否驻留在服务器上来采取适当的动作。他们还允许访问推送通知和后台同步API。

Service worker 可以解决目前离线应用的问题,同时也可以做更多的事。 Service Worker 可以使你的应用先访问本地缓存资源,所以在离线状态时,在没有通过网络接收到更多的数据前,仍可以提供基本的功能(一般称之为 Offline First)。这是原生APP 本来就支持的功能,这也是相比于 web app ,原生 app 更受青睐的主要原因。

再来看看 👀 service worker 能做些什么:

  • 后台消息传递
  • 网络代理,转发请求,伪造响应
  • 离线缓存
  • 消息推送
  • … …
本文主要以(lishaoy.net)资源缓存为例,阐述下 service worker如何工作

生命周期

service worker 初次安装的生命周期,如图 🌠

no-shadow

从上 👆 图可知,service worker 工作的流程:

  1. 安装:service worker URL 通过 serviceWorkerContainer.register() 来获取和注册。
  2. 激活:service worker 安装完成后,会接收到一个激活事件(activate event)。 onactivate 主要用途是清理先前版本的 service worker 脚本中使用的资源。
  3. 监听: 两种状态

    • 终止以节省内存;
    • 监听获取 fetch 和消息 message 事件。
  4. 销毁: 是否销毁由浏览器决定,如果一个 service worker 长期不使用或者机器内存有限,则可能会销毁这个 worker
Tips:激活成功之后,在 Chrome 浏览器里,可以访问 chrome://inspect/#service-workers和 chrome://serviceworker-internals/ 可以查看到当前运行的service worker ,如图 👇。

service worker

现在,我们来写个简单的例子 🌰

注册 service worker

要安装 service worker ,你需要在你的页面上注册它。这个步骤告诉浏览器你的 service worker 脚本在哪里。

if ('serviceWorker' in navigator) {
  navigator.serviceWorker.register('/sw.js').then(function(registration) {
    // Registration was successful
    console.log('ServiceWorker registration successful with scope: ',    registration.scope);
  }).catch(function(err) {
    // registration failed :(
    console.log('ServiceWorker registration failed: ', err);
  });
}

上面的代码检查 service worker API 是否可用,如果可用,service worker /sw.js 被注册。如果这个 service worker 已经被注册过,浏览器会自动忽略上面的代码。

激活 service worker

在你的 service worker 注册之后,浏览器会尝试为你的页面或站点安装并激活它。
install 事件会在安装完成之后触发。install 事件一般是被用来填充你的浏览器的离线缓存能力。你需要为 install 事件定义一个 callback ,并决定哪些文件你想要缓存.

// The files we want to cache
var CACHE_NAME = 'my-site-cache-v1';
var urlsToCache = [
  '/',
  '/css/main.css',
  '/js/main.js'
];

self.addEventListener('install', function(event) {
  // Perform install steps
  event.waitUntil(
    caches.open(CACHE_NAME)
      .then(function(cache) {
        console.log('Opened cache');
        return cache.addAll(urlsToCache);
      })
  );
});

在我们的 install callback 中,我们需要执行以下步骤:

  • 开启一个缓存
  • 缓存我们的文件
  • 决定是否所有的资源是否要被缓存

上面的代码中,我们通过 caches.open 打开我们指定的 cache 文件名,然后我们调用 cache.addAll 并传入我们的文件数组。这是通过一连串 promise(caches.open 和 cache.addAll) 完成的。event.waitUntil 拿到一个 promise 并使用它来获得安装耗费的时间以及是否安装成功。

监听 service worker

现在我们已经将你的站点资源缓存了,你需要告诉 service worker 让它用这些缓存内容来做点什么。有了 fetch 事件,这是很容易做到的。

每次任何被 service worker 控制的资源被请求到时,都会触发 fetch 事件,我们可以给 service worker 添加一个 fetch 的事件监听器,接着调用 event 上的 respondWith() 方法来劫持我们的 HTTP 响应,然后你用可以用自己的方法来更新他们。

self.addEventListener('fetch', function(event) {
  event.respondWith(
    caches.match(event.request);
  );
});

caches.match(event.request) 允许我们对网络请求的资源和 cache 里可获取的资源进行匹配,查看是否缓存中有相应的资源。这个匹配通过 urlvary header 进行,就像正常的 HTTP 请求一样。

那么,我们如何返回 request 呢,下面 👇 就是一个例子 🌰

self.addEventListener('fetch', function(event) {
  event.respondWith(
    caches.match(event.request)
      .then(function(response) {
        // Cache hit - return response
        if (response) {
          return response;
        }

        return fetch(event.request);
      }
    )
  );
});

上面的代码里我们定义了 fetch 事件,在 event.respondWith 里,我们传入了一个由 caches.match 产生的 promise.caches.match 查找 request 中被 service worker 缓存命中的 response
如果我们有一个命中的 response ,我们返回被缓存的值,否则我们返回一个实时从网络请求 fetch 的结果。

sw-toolbox

当然,我也可以使用第三方库,例如:lishaoy.net 使用了 sw-toolbox

sw-toolbox 使用非常简单,下面 👇 就是 lishaoy.net 的一个例子 🌰

  "serviceWorker" in navigator ? navigator.serviceWorker.register('/sw.js').then(function () {
    navigator.serviceWorker.controller ? console.log("Assets cached by the controlling service worker.") : console.log("Please reload this page to allow the service worker to handle network operations.")
  }).catch(function (e) {
    console.log("ERROR: " + e)
  }) : console.log("Service workers are not supported in the current browser.")

以上是 注册 一个 service woker

"use strict";
(function () {
    var cacheVersion = "20180527";
    var staticImageCacheName = "image" + cacheVersion;
    var staticAssetsCacheName = "assets" + cacheVersion;
    var contentCacheName = "content" + cacheVersion;
    var vendorCacheName = "vendor" + cacheVersion;
    var maxEntries = 100;
    self.importScripts("/lib/sw-toolbox/sw-toolbox.js");
    self.toolbox.options.debug = false;
    self.toolbox.options.networkTimeoutSeconds = 3;

    self.toolbox.router.get("/images/(.*)", self.toolbox.cacheFirst, {
        cache: {
            name: staticImageCacheName,
            maxEntries: maxEntries
        }
    });

    self.toolbox.router.get('/js/(.*)', self.toolbox.cacheFirst, {
        cache: {
            name: staticAssetsCacheName,
            maxEntries: maxEntries
        }
    });
    self.toolbox.router.get('/css/(.*)', self.toolbox.cacheFirst, {
        cache: {
            name: staticAssetsCacheName,
            maxEntries: maxEntries
        }
    
    ......

    self.addEventListener("install", function (event) {
        return event.waitUntil(self.skipWaiting())
    });
    self.addEventListener("activate", function (event) {
        return event.waitUntil(self.clients.claim())
    })
})();

就这样搞定了 🍉 (具体的用法可以去 sw-toolbox 查看)

有的同学就问,service worker 这么好用,这个缓存空间到底是多大?其实,在 Chrome 可以看到,如图

storage quota

可以看到,大概有 30G ,我的站点只用了 183MB ,完全够用了 🍓

最后,来两张图

from ServiceWorker

Cache Storage

由于,文章篇幅过长,后续还会继续总结 架构 方面的优化,例如

  • bigpipe分块输出
  • bigrender分块渲染
  • ...

以及,渲染 方面的优化,例如

  • requestAnimationFrame
  • well-change
  • 硬件加速 GPU
  • ...

以及,性能测试工具,例如

  • PageSpeed
  • audits
  • ...
查看原文

子木 评论了文章 · 2018-06-02

前端性能优化 -- 从 10 多秒到 1.05 秒

关于 性能优化 是个大的面,这篇文章主要涉及到 前端 的几个点,如 前端性能优化 的流程、常见技术手段、工具等。

提及 前端性能优化 ,大家应该都会想到 雅虎军规,本文会结合 雅虎军规 融入自己的了解知识,进行的总结和梳理 😜

详情,可以查阅我的 博客lishaoy.net

首先,我们先来看看 👀 雅虎军规35 条。

  1. 尽量减少 HTTP 请求个数——须权衡
  2. 使用 CDN(内容分发网络)
  3. 为文件头指定 Expires 或 Cache-Control ,使内容具有缓存性。
  4. 避免空的 src 和 href
  5. 使用 gzip 压缩内容
  6. 把 CSS 放到顶部
  7. 把 JS 放到底部
  8. 避免使用 CSS 表达式
  9. 将 CSS 和 JS 放到外部文件中
  10. 减少 DNS 查找次数
  11. 精简 CSS 和 JS
  12. 避免跳转
  13. 剔除重复的 JS 和 CSS
  14. 配置 ETags
  15. 使 AJAX 可缓存
  16. 尽早刷新输出缓冲
  17. 使用 GET 来完成 AJAX 请求
  18. 延迟加载
  19. 预加载
  20. 减少 DOM 元素个数
  21. 根据域名划分页面内容
  22. 尽量减少 iframe 的个数
  23. 避免 404
  24. 减少 Cookie 的大小
  25. 使用无 cookie 的域
  26. 减少 DOM 访问
  27. 开发智能事件处理程序
  28. 用 <link> 代替 @import
  29. 避免使用滤镜
  30. 优化图像
  31. 优化 CSS Spirite
  32. 不要在 HTML 中缩放图像——须权衡
  33. favicon.ico要小而且可缓存
  34. 保持单个内容小于25K
  35. 打包组件成复合文本

如对 雅虎军规 的具体细则内容不是很了解,可自行去各搜索 🔍 引擎 ,搜索 雅虎军规 了解详情。

压缩 合并

对于 前端性能优化 自然要关注 首屏 打开速度,而这个速度,很大因素是花费在网络请求上,那么怎么减少网络请求的时间呢?

  • 减少网络请求次数
  • 减小文件体积
  • 使用 CDN 加速

所以 压缩、合并 就是一个解决方案,当然可以用 gulpwebpackgrunt 等构建工具 压缩、合并

JS、CSS 压缩 合并

例如:gulp js、css 压缩、合并代码如下 👇

//压缩、合并js
gulp.task('scripts', function () {
    return gulp.src([
        './public/lib/fastclick/lib/fastclick.min.js',
        './public/lib/jquery_lazyload/jquery.lazyload.js',
        './public/lib/velocity/velocity.min.js',
        './public/lib/velocity/velocity.ui.min.js',
        './public/lib/fancybox/source/jquery.fancybox.pack.js',
        './public/js/src/utils.js',
        './public/js/src/motion.js',
        './public/js/src/scrollspy.js',
        './public/js/src/post-details.js',
        './public/js/src/bootstrap.js',
        './public/js/src/push.js',
        './public/live2dw/js/perTips.js',
        './public/live2dw/lib/L2Dwidget.min.js',
        './public/js/src/love.js',
        './public/js/src/busuanzi.pure.mini.js',
        './public/js/src/activate-power-mode.js'
    ]).pipe(concat('all.js')).pipe(minify()).pipe(gulp.dest('./public/dist/'));
});

// 压缩、合并 CSS 
gulp.task('css', function () {
    return gulp.src([
        './public/lib/font-awesome/css/font-awesome.min.css',
        './public/lib/fancybox/source/jquery.fancybox.css',
        './public/css/main.css',
        './public/css/lib.css',
        './public/live2dw/css/perTips.css'
    ]).pipe(concat('all.css')).pipe(minify()).pipe(gulp.dest('./public/dist/'));
});

然后,再把 压缩、合并JS、CSS 放入 CDN , 👀 看看效果如何

如图:压缩、合并 且放入 CND 之后的效果

首页请求速度(js)

首页请求速度(css)

以上是 lishaoy.net 清除缓存后的 首页 请求速度。

可见,请求时间是 4.59 s ,总请求个数 51 , 而 js 的请求个数是 8css 的请求个数是 3 _(其实就 all.css 一个,其它 2 个是 Google浏览器加载的)_, 而没使用 压缩、合并 时候,请求时间是 10 多秒,总请求个数有 70 多个,js 的请求个数是 20 多个 ,对比请求时间 性能 提升 1倍

如图:有缓存下的首页效果

首页请求速度(缓存)

基本都是秒开 😝

Tips:在 压缩、合并 后,单个文件控制在 25 ~ 30 KB左右,同一个域下,最好不要多于5个资源

图片压缩、合并

例如:gulp 图片压缩代码如下 👇

//压缩image
gulp.task('imagemin', function () {
    gulp.src('./public/**/*.{png,jpg,gif,ico,jpeg}')
        .pipe(imagemin())
        .pipe(gulp.dest('./public'));
});

图片的合并可以采用 CSS Spirite,方法就是把一些小图用 PS 合成一张图,用 css 定位显示每张图片的位置

.top_right .phone {
    background: url(../images/top_right.png) no-repeat 7px -17px;
    padding: 0 38px;
}

.top_right .help {
    background: url(../images/top_right.png) no-repeat 0 -47px;
    padding: 0 38px;
}

然后,把 压缩 的图片放入 CDN , 👀 看看,效果如何

首页请求速度(images)

可见,请求时间是 1.70 s ,总请求个数 50 , 而 img 的请求个数是 15(这里因为首页都是大图,就没有合并,只是压缩了) ,但是,效果很好 😀 ,从 4.59 s 缩短到 1.70 s, 性能又提升一倍。

再看看有缓存情况如何 😏

首页请求速度(images 缓存)

请求时间是 1.05 s ,有缓存和无缓存基本差不多

Tips:大的图片在不同终端,应该使用不同分辨率,而不应该使用缩放(百分比)

整个 压缩、合并(js、css、img) 再放入 CDN ,请求时间从 10 多秒 ,到最后的 1.70 s ,性能提升 5 倍多,可见,这个操作必要性。

缓存

缓存会根据请求保存输出内容的副本,例如 页面、图片、文件,当下一个请求来到的时候:如果是相同的URL,缓存直接使 用本地的副本响应访问请求,而不是向源服务器再次发送请求。因此,可以从以下 2 个方面提升性能。

  • 减少相应延迟,提升响应时间
  • 减少网络带宽消耗,节省流量

我们用两幅图来了解下浏览器的 缓存机制

浏览器第一次请求

第一次请求

浏览器再次请求

再次请求

从以上两幅图中,可以清楚的了解浏览器 缓存 的过程。
首次访问一个 URL ,没有 缓存 ,但是,服务器会响应一些 header 信息,如:expires、cache-control、last-modified、etag 等,来记录下次请求是否缓存、如何缓存。
再次访问这个 URL 时候,浏览器会根据首次访问返回的 header 信息,来决策是否缓存、如何缓存。
我们重点来分析下第二幅图,其实是分两条线路,如下 👇

  • 第一条线路: 当浏览器再次访问某个 URL 时,会先获取资源的 header 信息,判断是否命中强缓存 (cache-control和expires) ,如命中,直接从缓存获取资源,包括响应的 header 信息 (请求不会和服务器通信) ,也就是 强缓存 ,如图

强缓存

  • 第二条线路: 如没有命中 强缓存 ,浏览器会发送请求到服务器,请求会携带第一次请求返回的有关缓存的 header 信息 (Last-Modified/If-Modified-Since和Etag/If-None-Match) ,由服务器根据请求中的相关 header 信息来比对结果是否协商缓存命中;若命中,则服务器返回新的响应 header 信息更新缓存中的对应 header 信息,但是并不返回资源内容,它会告知浏览器可以直接从缓存获取;否则返回最新的资源内容,也就是 协商缓存

现在,我们了解到浏览器缓存机制分为 强缓存、协商缓存,再来看看他们的区别 👇

缓存策略获取资源形式状态码发送请求到服务器
强缓存从缓存取200(from memory cache)否,直接从缓存取
协商缓存从缓存取304(not modified)是,通过服务器来告知缓存是否可用

强缓存

与强缓存相关的 header 字段有两个:

expires

expires: 这是 http1.0 时的规范,它的值为一个绝对时间的 GMT 格式的时间字符串,如 Mon, 10 Jun 2015 21:31:12 GMT ,如果发送请求的时间在 expires 之前,那么本地缓存始终有效,否则就会发送请求到服务器来获取资源

cache-control

cache-control:max-age=number ,这是 http1.1 时出现的 header 信息,主要是利用该字段的 max-age 值来进行判断,它是一个相对值;资源第一次的请求时间和 Cache-Control 设定的有效期,计算出一个资源过期时间,再拿这个过期时间跟当前的请求时间比较,如果请求时间在过期时间之前,就能命中缓存,否则未命中, cache-control 除了该字段外,还有下面几个比较常用的设置值:

  • no-cache: 不使用本地缓存。需要使用缓存协商,先与服务器确认返回的响应是否被更改,如果之前的响应中存在 ETag ,那么请求的时候会与服务端验证,如果资源未被更改,则可以避免重新下载。
  • no-store: 直接禁止游览器缓存数据,每次用户请求该资源,都会向服务器发送一个请求,每次都会下载完整的资源。
  • public: 可以被所有的用户缓存,包括终端用户和 CDN 等中间代理服务器。
  • private: 只能被终端用户的浏览器缓存,不允许 CDN 等中继缓存服务器对其缓存。
Tips:如果 cache-control 与 expires 同时存在的话,cache-control 的优先级高于 expires

协商缓存

协商缓存都是由浏览器和服务器协商,来确定是否缓存,协商主要通过下面两组 header 字段,这两组字段都是成对出现的,即第一次请求的响应头带上某个字段 Last-Modified 或者 Etag ,则后续请求会带上对应的请求字段 If-Modified-Since 或者 If-None-Match ,若响应头没有 Last-Modified 或者 Etag 字段,则请求头也不会有对应的字段。

Last-Modified/If-Modified-Since

二者的值都是 GMT 格式的时间字符串,具体过程:

  • 浏览器第一次跟服务器请求一个资源,服务器在返回这个资源的同时,在 responeheader 加上 Last-Modified 字段,这个 header 字段表示这个资源在服务器上的最后修改时间
  • 浏览器再次跟服务器请求这个资源时,在 requestheader 上加上 If-Modified-Since 字段,这个 header 字段的值就是上一次请求时返回的 Last-Modified 的值
  • 服务器再次收到资源请求时,根据浏览器传过来 If-Modified-Since 和资源在服务器上的最后修改时间判断资源是否有变化,如果没有变化则返回 304 Not Modified ,但是不会返回资源内容;如果有变化,就正常返回资源内容。当服务器返回 304 Not Modified 的响应时,response header 中不会再添加 Last-Modified的header ,因为既然资源没有变化,那么 Last-Modified 也就不会改变,这是服务器返回 304 时的 response header
  • 浏览器收到 304 的响应后,就会从缓存中加载资源
  • 如果协商缓存没有命中,浏览器直接从服务器加载资源时,Last-ModifiedHeader 在重新加载的时候会被更新,下次请求时,If-Modified-Since 会启用上次返回的Last-Modified

Etag/If-None-Match

这两个值是由服务器生成的每个资源的唯一标识字符串,只要资源有变化就这个值就会改变;其判断过程与 Last-Modified、If-Modified-Since 类似,与 Last-Modified 不一样的是,当服务器返回 304 Not Modified 的响应时,由于 ETag 重新生成过,response header 中还会把这个 ETag 返回,即使这个 ETag 跟之前的没有变化。

Tips:Last-Modified与ETag是可以一起使用的,服务器会优先验证ETag,一致的情况下,才会继续比对Last-Modified,最后才决定是否返回304。

Service Worker

什么是 Service Worker

Service Worker 本质上充当Web应用程序与浏览器之间的代理服务器,也可以在网络可用时作为浏览器和网络间的代理。它们旨在(除其他之外)使得能够创建有效的离线体验,拦截网络请求并基于网络是否可用以及更新的资源是否驻留在服务器上来采取适当的动作。他们还允许访问推送通知和后台同步API。

Service worker 可以解决目前离线应用的问题,同时也可以做更多的事。 Service Worker 可以使你的应用先访问本地缓存资源,所以在离线状态时,在没有通过网络接收到更多的数据前,仍可以提供基本的功能(一般称之为 Offline First)。这是原生APP 本来就支持的功能,这也是相比于 web app ,原生 app 更受青睐的主要原因。

再来看看 👀 service worker 能做些什么:

  • 后台消息传递
  • 网络代理,转发请求,伪造响应
  • 离线缓存
  • 消息推送
  • … …
本文主要以(lishaoy.net)资源缓存为例,阐述下 service worker如何工作

生命周期

service worker 初次安装的生命周期,如图 🌠

no-shadow

从上 👆 图可知,service worker 工作的流程:

  1. 安装:service worker URL 通过 serviceWorkerContainer.register() 来获取和注册。
  2. 激活:service worker 安装完成后,会接收到一个激活事件(activate event)。 onactivate 主要用途是清理先前版本的 service worker 脚本中使用的资源。
  3. 监听: 两种状态

    • 终止以节省内存;
    • 监听获取 fetch 和消息 message 事件。
  4. 销毁: 是否销毁由浏览器决定,如果一个 service worker 长期不使用或者机器内存有限,则可能会销毁这个 worker
Tips:激活成功之后,在 Chrome 浏览器里,可以访问 chrome://inspect/#service-workers和 chrome://serviceworker-internals/ 可以查看到当前运行的service worker ,如图 👇。

service worker

现在,我们来写个简单的例子 🌰

注册 service worker

要安装 service worker ,你需要在你的页面上注册它。这个步骤告诉浏览器你的 service worker 脚本在哪里。

if ('serviceWorker' in navigator) {
  navigator.serviceWorker.register('/sw.js').then(function(registration) {
    // Registration was successful
    console.log('ServiceWorker registration successful with scope: ',    registration.scope);
  }).catch(function(err) {
    // registration failed :(
    console.log('ServiceWorker registration failed: ', err);
  });
}

上面的代码检查 service worker API 是否可用,如果可用,service worker /sw.js 被注册。如果这个 service worker 已经被注册过,浏览器会自动忽略上面的代码。

激活 service worker

在你的 service worker 注册之后,浏览器会尝试为你的页面或站点安装并激活它。
install 事件会在安装完成之后触发。install 事件一般是被用来填充你的浏览器的离线缓存能力。你需要为 install 事件定义一个 callback ,并决定哪些文件你想要缓存.

// The files we want to cache
var CACHE_NAME = 'my-site-cache-v1';
var urlsToCache = [
  '/',
  '/css/main.css',
  '/js/main.js'
];

self.addEventListener('install', function(event) {
  // Perform install steps
  event.waitUntil(
    caches.open(CACHE_NAME)
      .then(function(cache) {
        console.log('Opened cache');
        return cache.addAll(urlsToCache);
      })
  );
});

在我们的 install callback 中,我们需要执行以下步骤:

  • 开启一个缓存
  • 缓存我们的文件
  • 决定是否所有的资源是否要被缓存

上面的代码中,我们通过 caches.open 打开我们指定的 cache 文件名,然后我们调用 cache.addAll 并传入我们的文件数组。这是通过一连串 promise(caches.open 和 cache.addAll) 完成的。event.waitUntil 拿到一个 promise 并使用它来获得安装耗费的时间以及是否安装成功。

监听 service worker

现在我们已经将你的站点资源缓存了,你需要告诉 service worker 让它用这些缓存内容来做点什么。有了 fetch 事件,这是很容易做到的。

每次任何被 service worker 控制的资源被请求到时,都会触发 fetch 事件,我们可以给 service worker 添加一个 fetch 的事件监听器,接着调用 event 上的 respondWith() 方法来劫持我们的 HTTP 响应,然后你用可以用自己的方法来更新他们。

self.addEventListener('fetch', function(event) {
  event.respondWith(
    caches.match(event.request);
  );
});

caches.match(event.request) 允许我们对网络请求的资源和 cache 里可获取的资源进行匹配,查看是否缓存中有相应的资源。这个匹配通过 urlvary header 进行,就像正常的 HTTP 请求一样。

那么,我们如何返回 request 呢,下面 👇 就是一个例子 🌰

self.addEventListener('fetch', function(event) {
  event.respondWith(
    caches.match(event.request)
      .then(function(response) {
        // Cache hit - return response
        if (response) {
          return response;
        }

        return fetch(event.request);
      }
    )
  );
});

上面的代码里我们定义了 fetch 事件,在 event.respondWith 里,我们传入了一个由 caches.match 产生的 promise.caches.match 查找 request 中被 service worker 缓存命中的 response
如果我们有一个命中的 response ,我们返回被缓存的值,否则我们返回一个实时从网络请求 fetch 的结果。

sw-toolbox

当然,我也可以使用第三方库,例如:lishaoy.net 使用了 sw-toolbox

sw-toolbox 使用非常简单,下面 👇 就是 lishaoy.net 的一个例子 🌰

  "serviceWorker" in navigator ? navigator.serviceWorker.register('/sw.js').then(function () {
    navigator.serviceWorker.controller ? console.log("Assets cached by the controlling service worker.") : console.log("Please reload this page to allow the service worker to handle network operations.")
  }).catch(function (e) {
    console.log("ERROR: " + e)
  }) : console.log("Service workers are not supported in the current browser.")

以上是 注册 一个 service woker

"use strict";
(function () {
    var cacheVersion = "20180527";
    var staticImageCacheName = "image" + cacheVersion;
    var staticAssetsCacheName = "assets" + cacheVersion;
    var contentCacheName = "content" + cacheVersion;
    var vendorCacheName = "vendor" + cacheVersion;
    var maxEntries = 100;
    self.importScripts("/lib/sw-toolbox/sw-toolbox.js");
    self.toolbox.options.debug = false;
    self.toolbox.options.networkTimeoutSeconds = 3;

    self.toolbox.router.get("/images/(.*)", self.toolbox.cacheFirst, {
        cache: {
            name: staticImageCacheName,
            maxEntries: maxEntries
        }
    });

    self.toolbox.router.get('/js/(.*)', self.toolbox.cacheFirst, {
        cache: {
            name: staticAssetsCacheName,
            maxEntries: maxEntries
        }
    });
    self.toolbox.router.get('/css/(.*)', self.toolbox.cacheFirst, {
        cache: {
            name: staticAssetsCacheName,
            maxEntries: maxEntries
        }
    
    ......

    self.addEventListener("install", function (event) {
        return event.waitUntil(self.skipWaiting())
    });
    self.addEventListener("activate", function (event) {
        return event.waitUntil(self.clients.claim())
    })
})();

就这样搞定了 🍉 (具体的用法可以去 sw-toolbox 查看)

有的同学就问,service worker 这么好用,这个缓存空间到底是多大?其实,在 Chrome 可以看到,如图

storage quota

可以看到,大概有 30G ,我的站点只用了 183MB ,完全够用了 🍓

最后,来两张图

from ServiceWorker

Cache Storage

由于,文章篇幅过长,后续还会继续总结 架构 方面的优化,例如

  • bigpipe分块输出
  • bigrender分块渲染
  • ...

以及,渲染 方面的优化,例如

  • requestAnimationFrame
  • well-change
  • 硬件加速 GPU
  • ...

以及,性能测试工具,例如

  • PageSpeed
  • audits
  • ...
查看原文