Flutter: 轮播组件

实现一个轮播

在PC端实现轮播一般都是直接使用JavaScript库进行实现.但在Flutter上想要实现一个轮播,可能只有自己写一下了.

轮播的特点:

1. 可以自动播放;
2. 图片可以左右滑动(使用Flutter自带的PageView初始只能向右滑动,不能向左);
3. 可以定义自动播放的间隔时间.

大概就这么几个特点,可以实现个简单的例子.

image-3203

功能部分

目前仅实现简单功能:
1. 可以自定义网络图片或本地图片(可以混用本地图片和网络图片),
2. 自定义图片切换时间,
3. 自定义是否开启自动轮播.

上述几条可以满足一个简单轮播的需要,如果需要更多功能.可以在此基础上增加.

image-3204

源码

完整代码: Gitee仓库


/// 轮播组件
///
///  目前仅实现简单功能:
///   1. 可以自定义网络图片或本地图片(可以混用本地图片和网络图片),
///   2. 自定义图片切换时间,
///   3. 自定义是否开启自动轮播.
///
///  调用示例:
///
///  ```dart
///  static const List _images = [
///   'assets/images/3302e58f9a181d2509f3dc0fa68b0jpeg.jpeg',
///   'assets/images/19aa98b1fcb2781c4fba33d850549jpeg.jpeg',
///   'https://fuss10.elemecdn.com/0/6f/e35ff375812e6b0020b6b4e8f9583jpeg.jpeg',
///   'https://fuss10.elemecdn.com/9/bb/e27858e973f5d7d3904835f46abbdjpeg.jpeg',
///   'https://fuss10.elemecdn.com/d/e6/c4d93a3805b3ce3f323f7974e6f78jpeg.jpeg',
///   'https://fuss10.elemecdn.com/3/28/bbf893f792f03a54408b3b7a7ebf0jpeg.jpeg',
///   'https://fuss10.elemecdn.com/2/11/6535bcfb26e4c79b48ddde44f4b6fjpeg.jpeg',
///   'https://fuss10.elemecdn.com/a/3f/3302e58f9a181d2509f3dc0fa68b0jpeg.jpeg',
///   'https://fuss10.elemecdn.com/1/34/19aa98b1fcb2781c4fba33d850549jpeg.jpeg',
///   'https://fuss10.elemecdn.com/0/6f/e35ff375812e6b0020b6b4e8f9583jpeg.jpeg'
///  ];
///
/// ......
///
/// SizedBox(
//            width: double.infinity,
//            height: 200,
//            child: Carousel(
//              _images,
//              autoPlayer: false,
//            ),
//          ),
///
/// ```
///
///
///
///
class Carousel extends StatefulWidget {
  Carousel(
    this.imagesList, {
    Key key,
    this.autoPlayer = true,
    this.seconds = 5,
  })  : assert(imagesList.isNotEmpty),
        assert(seconds > 2),
        super(key: key);

  final int _maxPage = 2000000000;

  /// 轮播切换间隔秒数.
  final int seconds;

  /// 是否自动播放
  final bool autoPlayer;

  final List imagesList;

  @override
  _CarouselState createState() => new _CarouselState();
}

class _CarouselState extends State {

  PageController _pageController;
  Timer _timer;

  var _currIndex = 0;

  @override
  void initState() {
    super.initState();
    if (widget.autoPlayer) {
      startTimeout();
    }
    _pageController = new PageController(initialPage: widget._maxPage);
  }

  @override
  void dispose() {
    super.dispose();
    _pageController.dispose();
    if (_timer != null) {
      _timer.cancel();
    }

  }

  void startTimeout() {
    var timeout = Duration(seconds: widget.seconds);
    Timer.periodic(timeout, (Timer timer) {
      var tempIndex = _currIndex + 1;
      if (tempIndex >= widget.imagesList.length) {
        tempIndex = 0;
      }
      setState(() {
        _timer = timer;
        _currIndex = tempIndex;
      });
    });
  }

  List getBottomIcon() {
    var forLength = widget.imagesList.length;
    var res = List();
    for (var i = 0; i < forLength; i++) {
      if (_currIndex == i) {
        res.add(GestureDetector(
          /// 选中时的图标,可以更换为图片.
          child: Icon(Icons.brightness_1),
          onTap: () {
            setState(() {
              _currIndex = i;
            });
          },
        ));
      } else {
        res.add(GestureDetector(
          /// 未选中时的图标,可以更换为图片.
          child: Icon(Icons.panorama_fish_eye),
          onTap: () {
            setState(() {
              _currIndex = i;
            });
          },
        ));
      }
    }
    return res;
  }

  @override
  Widget build(BuildContext context) {
    var width = MediaQuery.of(context).size.width;
    return Scaffold(
      body: PageView.builder(
          controller: _pageController,
          onPageChanged: (int index) {
            var renderIndex = index - widget._maxPage;
            renderIndex = renderIndex % widget.imagesList.length;
            if (renderIndex < 0) {
              renderIndex += widget.imagesList.length;
            }
            setState(() {
              _currIndex = renderIndex;
            });
          },
          itemBuilder: (BuildContext context, int index) {
            var renderIndex = _currIndex - widget._maxPage;
            renderIndex = renderIndex % widget.imagesList.length;
            if (renderIndex < 0) {
              renderIndex += widget.imagesList.length;
            }
            return Container(
                child: Stack(children: [
              Center(
                child: widget.imagesList[_currIndex].startsWith("https://") ||
                        widget.imagesList[_currIndex].startsWith("http://")
                    ? CachedNetworkImage(
                        imageUrl: widget.imagesList[_currIndex],
                        fit: BoxFit.fill,
                        width: width,
                        height: double.infinity,
                        placeholder: (context, url) =>
                            CircularProgressIndicator(),
                        errorWidget: (context, url, error) => Icon(Icons.error),
                      )
                    : FadeInImage(
                        image: AssetImage(widget.imagesList[_currIndex]),
                        placeholder: AssetImage("assets/images/loading.gif"),
                        fit: BoxFit.fill,
                        width: width,
                        height: double.infinity,
                      ),
              ),
              Positioned(
                  left: 2.0,
                  right: 2.0,
                  bottom: 10.0,
                  child: Container(

                      /// padding: EdgeInsets.all(5.0),
                      child: widget.imagesList.length > 10
                          ? SingleChildScrollView(
                              scrollDirection: Axis.horizontal,
                              child: Row(
                                mainAxisAlignment: MainAxisAlignment.center,
                                crossAxisAlignment: CrossAxisAlignment.start,
                                children: getBottomIcon(),
                              ),
                            )
                          : Row(
                              mainAxisAlignment: MainAxisAlignment.center,
                              crossAxisAlignment: CrossAxisAlignment.start,
                              children: getBottomIcon(),
                            )))
            ]));
          }),
    );
  }
}

Flutter: 无限滚动

为什么需要无限滚动

之前做过一个横向图片展示的页面,发现就需要无限滚动(可以往左滑,也可以往右滑).第一时间首先想到的是用PageView,但是发现PageView不支持往左滑动,只能往右滑.这明显限制了功能的实用性.

于是在网上参考了一些教程之后,有了下面的代码.

image-3198

原理

原理其实很简单,就是初始化的时候给PageView设置一个特别大的值.这样无论往左或者往右都可以滑动.通过滑动的值,计算实际的下标,进行展示对应的数据即可.

在线运行效果

源码

源码如下:


import 'package:flutter/material.dart';

/// run to dartpad.dev or dartpad.cn
void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) => MaterialApp(
        home: HomePage(),
      );
}

int maxPage = 2000000000;

class HomePage extends StatelessWidget {
  static const List<Color> _colors = [Colors.blue, Colors.green, Colors.red];

  final pageController = new PageController(initialPage: maxPage);

  @override
  Widget build(BuildContext context) => Scaffold(
        body: PageView.builder(
          controller: pageController,
          itemBuilder: (BuildContext context, int index) {
            var renderIndex = index - maxPage;
            renderIndex = renderIndex % _colors.length;
            if (renderIndex < 0) {
              renderIndex += _colors.length;
            }
            return Container(
              color:_colors[index % _colors.length],
                child: Center(
                    child: Text("text $renderIndex, default Index: $index",
                        style: TextStyle(
                            fontSize: 36.0,
                            color: Colors.white))));
          },
        ),
      );
}

Flutter: 动态修改应用主题

应用主题

现在很多应用都提供了手动修改应用主题的功能,一般也就两个主题: dark和light,简单说就是: 夜间和日间模式.部分没有提供手动修改的,则选择了跟随手机系统的主题.而在Flutter中,提供了这两种主题,可以很方便的切换.前提是在应用中,需要减少定制化的部分.同时需要针对两种主题进行测试.

实现说明

主题的修改是存在于: MaterialApp上面的theme属性,而这个属性的值为ThemeData,也就是除了系统自带的.也可以自定义主题.

原理分为以下几步:

  1. 父类: MaterialApp中的theme需要使用属性动态判断,以便进行主题切换;
  2. 父类: 需要定义一个类型方法,以便在其它页面调用并修改主题
  3. 子类: 定义一个类属性,用于接收父类定义的类型方法.并在需要修改主题时,传递参数调用该方法
  4. 父类: 定义具体方法,处理修改主题操作.

说起来好复杂,但是一看代码,一下就明白了.

image-3194

一个简单的”小栗子”

下面是一个简单的例子,只在首页实现了一个切换主题的功能.

例子用了CodePen.io的在线编辑,因为CodePen.io不支持导入第三方包,因此注释了SharedPreferences相关内容.如需使用,在本地导入即可

在线预览

源码


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



/// Flutter 动态修改应用主题
/// 
/// 
/// [特别说明]
/// 
/// 因为CodePen不支持引入第三方包,所以此处注释了SharedPreferences相关的内容(SharedPreferences可以使应用重启后设置依然生效).
/// 安装SharedPreferences: https://pub.dev/packages/shared_preferences
/// 
/// [原理]
/// 
/// 在MyApp中传递给SettingApp一个回调方法,如果SettingApp需要更新MyApp中的任意值,直接调用此方法即可(目前通过Map传递值,因为无法动态调用setState).
/// 
/// @version 2020--05-31
/// @author prd(pruidong@gmail.com)(bckf.cn)
/// 


/// Flutter dynamically modify the application theme
///
///
/// [Special Note]
///
/// Because CodePen does not support the introduction of third-party packages, the contents related to SharedPreferences are annotated here (SharedPreferences can make the settings still effective after the application restarts).
/// Install SharedPreferences: https://pub.dev/packages/shared_preferences
///
/// [Principle]
///
/// Pass a callback method to SettingApp in MyApp. If SettingApp needs to update any value in MyApp, just call this method (currently pass the value through Map  because setState cannot be called dynamically).
///
/// @version 2020--05-31
/// @author prd (pruidong@gmail.com)(bckf.cn)



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

typedef MainStateUpdateCall = void Function(Map map);


class MyApp extends StatefulWidget {
  const MyApp();
  @override
  _MyAppState createState() => _MyAppState();
}

class _MyAppState extends State {
  bool _darkTheme = false;
  /// final _ThemeKey = "THEME";


  /// 外部接口调用此方法修改本类的属性.
  ///
  /// 可以通过传递在map中传递值,来修改系统属性.
  /// 使用可以参考:
  /// 
  /// The external interface calls this method to modify the properties of this class.
  ///
  /// You can modify system properties by passing values in the map.
  /// Use can refer to:
  ///
  /// [_SettingAppState]的setAndReloadTheme方法.
  void mainStateUpdateMethod(Map map) {
    map.forEach((key, value) {
      setState((){
        switch(key){
          case "_darkTheme":
            _darkTheme=value;
            break;
        }
      });
    });
  }

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

  /// CodePen cannot introduce third-party packages, so comment the code below.
  /// 
  /// 
  void initTheme() async {
    // final prefs = await SharedPreferences.getInstance();
    // bool tempTheme = false;
    // if (prefs.containsKey(_ThemeKey)) {
    //   tempTheme = prefs.getBool(_ThemeKey);
    // }
    // setState(() {
    //   _darkTheme = tempTheme;
    // });
  }



  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      theme:_darkTheme ? ThemeData.dark() : ThemeData.light(), 
      home: SettingApp(mainStateUpdateCall: mainStateUpdateMethod,),
    );
  }
}



/// Setting.
///
///
class SettingApp extends StatefulWidget {
  const SettingApp({this.mainStateUpdateCall});

  final MainStateUpdateCall mainStateUpdateCall;

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

class _SettingAppState extends State {
  bool _darkTheme = false;
  /// final _themeKey = "THEME";

  var resData = [];

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

  void initSetting() async {
    /// final prefs = await SharedPreferences.getInstance();
    /// bool tempTheme = false;
    ///if (prefs.containsKey(_ThemeKey)) {
    ///  tempTheme = prefs.getBool(_ThemeKey);
    ///  }
    // setState(() {
    //   _darkTheme = tempTheme;
    // });
    var tempResData = [
      Card(
        child: ListTile(
          title: Text('dark'),
          trailing: Switch(
              value: _darkTheme,
              onChanged: (value) {
                setAndReloadTheme(value);
                /// Call this method again to generate the list-so that the displayed value is correct.
                /// 重新调用此方法生成列表-以使显示的值正确.
                initSetting();
              }),
        ),
      ),
      Text("Support: bckf.cn / pruidong@gmail.com")
    ];
    setState(() {
      resData = tempResData;
    });
  }

  /// 保存设置和重新加载主题.
  /// Save the settings and reload the theme.
  ///
  void setAndReloadTheme(bool value) async {
    /// final prefs = await SharedPreferences.getInstance();
    /// prefs.setBool(_ThemeKey, value);
    /// 
    /// 
    /// 调用全局更换.
    /// Call global replacement.
    setState(() {
       _darkTheme = value;
     });
    print(value);
    print(_darkTheme);
    if (widget.mainStateUpdateCall != null) {
      var paramMap={"_darkTheme":value};
      widget.mainStateUpdateCall(paramMap);
    }
  }

  @override
  Widget build(BuildContext context) {
    var listView = ListView.separated(
      padding: const EdgeInsets.all(2),
      itemCount: resData.length,
      itemBuilder: (BuildContext itemBuildContext, int index) {
        return Padding(
          padding: EdgeInsets.all(5.0),
          child: resData[index],
        );
      },
      separatorBuilder: (BuildContext context, int index) => Divider(
        color: Theme.of(context).dividerColor,
      ),
    );
    return Scaffold(
      appBar: AppBar(title: Text("Flutter Dynamic Update App Theme")),
      body: listView,
    );
  }
}

代码比较简单,就不详细解释了.

JavaScript:Prism实现代码高亮

代码高亮解决方案

在之前用过Crayon Syntax Highlighter,Enlighter这两个代码高亮的插件.可能是js或其余部分存在冲突,总是导致奇奇怪怪的问题.也找了一些纯js的代码高亮框架.比如: Highlight.js.但最后发现一个高亮框架比较适合: Prism.js.

Prism.js

从我的角度介绍一下Prism.js:

  • 核心文件小(约2kb);
  • 可以按语言自动加载高亮文件(有条件)
  • 代码行号,复制代码等功能作为插件提供
  • 可按照插件架构自行扩展
  • 多种主题可选

最主要的不会存在升级Wordpress插件导致样式混乱的问题了.同时,在Prism.js的下载页面可以按照自己需要的部分进行下载,最大程度减少文件体积.

image-3180

综合引用

可以在Prism官网,直接下载一个打包好的JavaScript文件和CSS文件:

参考地址:

官方打包下载地址

打包之后的文件,只需要引入一个CSS和一个JavaScript文件即可.

示例

我目前使用的body下面的第一个div添加的class和data-toolbar-order:


		<div data-toolbar-order="show-language,copy-to-clipboard" class="line-numbers match-braces rainbow-braces">
		</div>

使用方式

  1. 先引入css
  2. 然后在pre,code中包含代码,同时为code设置class,class格式为: language-css,language-html,language-java或者lang-java,lang-css,lang-html.更多支持语言的简写,参考这里的Supported languages.
  3. 最后引入prism.js文件

下面的代码只会显示代码高亮(未引用语言文件的情况下,默认只包含了css,html,js语言的高亮文件):



<!DOCTYPE html>
<html>
<head>
	<title>test</title>
	<link href="https://cdn.bootcdn.net/ajax/libs/prism/9000.0.1/themes/prism-dark.min.css" rel="stylesheet">
</head>
<body>
<pre><code class="language-css">p { color: red }</code></pre>
</body>
<script src="https://cdn.bootcdn.net/ajax/libs/prism/9000.0.1/prism.min.js"></script>
</html>


使用插件

目前我使用了如下插件:

  • Line Numbers(显示行号)
  • Remove initial line feed(删除初始换行)
  • Normalize Whitespace(空白规范)
  • Toolbar(工具栏)
  • Copy to Clipboard Button(复制按钮)
  • Show Language(显示语言)
  • Match braces(括号高亮[选中单个括号,另外一个对应的括号同时高亮])

显示行号

引入方式:

css:


	<link href="https://cdn.bootcdn.net/ajax/libs/prism/1.20.0/plugins/line-numbers/prism-line-numbers.min.css" rel="stylesheet">

JavaScript:

	
		<script src="https://cdn.bootcdn.net/ajax/libs/prism/1.20.0/plugins/line-numbers/prism-line-numbers.min.js"></script>
	

然后在body下面的第一个div设置:

	
		<div id="page" class="line-numbers">
			......内容
		</div>

之后直接使用即可:


<pre><code class="language-css">
p { color: red }

</code></pre>

括号高亮

引入:

css:


		<link href="https://cdn.bootcdn.net/ajax/libs/prism/1.20.0/plugins/match-braces/prism-match-braces.min.css" rel="stylesheet">

JavaScript:


<script src="https://cdn.bootcdn.net/ajax/libs/prism/1.20.0/plugins/match-braces/prism-match-braces.min.js"></script>

参考效果: 官方文档

实现效果:

添加class: match-braces 到body下面的第一个div即可.例如:

	
		<div id="page" class="line-numbers match-braces">
			......内容
		</div>

如果要显示五颜六色的括号,则继续添加: rainbow-braces,例如:

	
		<div id="page" class="line-numbers match-braces rainbow-braces">
			......内容
		</div>

工具栏

引入:

CSS:

	
	<link href="https://cdn.bootcdn.net/ajax/libs/prism/1.20.0/plugins/toolbar/prism-toolbar.min.css" rel="stylesheet">

JavaScript:

	
	<script src="https://cdn.bootcdn.net/ajax/libs/prism/1.20.0/plugins/toolbar/prism-toolbar.min.js"></script>

然后给body设置工具栏要显示的内容:

	
<div data-toolbar-order="show-language,copy-to-clipboard" class="line-numbers match-braces rainbow-braces">
	......
</div>

show-language,copy-to-clipboard

第一个是显示代码块中的语言,第二个是复制代码.

工具栏可以自定义.具体参考: 工具栏插件文档

显示语言

引入:

	
<script src="https://cdn.bootcdn.net/ajax/libs/prism/1.20.0/plugins/show-language/prism-show-language.min.js"></script>

复制代码

引入:

	
<script src="https://cdn.bootcdn.net/ajax/libs/prism/1.20.0/plugins/copy-to-clipboard/prism-copy-to-clipboard.min.js"></script>

即可完成工具栏的效果.

Java: 小程序获取用户开放信息(OpenId,UnionID)

关于OpenId和UnionID

OpenId是用户唯一标识.也就是用户信息的主键.

UnionID 机制说明
如果开发者拥有多个移动应用、网站应用、和公众帐号(包括小程序),可通过 UnionID 来区分用户的唯一性,因为只要是同一个微信开放平台帐号下的移动应用、网站应用和公众帐号(包括小程序),用户的 UnionID 是唯一的。换句话说,同一用户,对同一个微信开放平台下的不同应用,UnionID是相同的。

获取流程

UnionID只有满足条件时才会返回.

优化后流程(小程序减少请求一次后台)

在小程序启动时,会运行app.js里面的onLaunch方法.获取信息从onLaunch方法开始:

1. 在调用 wx.login时将res.code缓存到this.globalData.privateUserCode中,在后面使用;
2. 获取用户信息的时候: wx.getSetting中调用wx.getUserInfo(需要附加参数: withCredentials: true,)时,将res.code,encryptedData,iv这三个数据发送给后台,用来换取OpenId和UnionID().

未优化流程

1. 用wx.login中的res.code换取session_key,OpenId;
2. 用session_key,iv,encryptedData解密出UnionID

微信小程序中的修改

app.js

返回结果信息格式(仅供参考):


{
    "code":"0",
    "data":{
        "city":"xxxxxxxxxxxx",
        "province":"xxxxxxxxxxxx",
        "country":"China",
        "watermark":{
            "appid":"xxxxxxxxxxxx",
            "timestamp":"xxxxxxxxxxxx"
        },
        "openId":"xxxxxxxxxxxx",
        "nickName":"xxxxxxxxxxxx",
        "avatarUrl":"xxxxxxxxxxxx",
        "unionId":null
    },
    "message":""
}

修改参考:


App({
  onLaunch: function () {

    wx.getSystemInfo({
      success: e => {
      }
    })
    // 展示本地存储能力
    var logs = wx.getStorageSync('logs') || []
    logs.unshift(Date.now())
    wx.setStorageSync('logs', logs)

    // 登录
    wx.login({
      success: res => {
        console.log(res);
        if (res && res.code) {
          this.globalData.privateUserCode = res.code;
        }
        // 发送 res.code 到后台换取 openId, sessionKey, unionId
      }
    })
    // 获取用户信息
    wx.getSetting({
      success: res => {
        if (res.authSetting['scope.userInfo']) {
          // 已经授权,可以直接调用 getUserInfo 获取头像昵称,不会弹框
          wx.getUserInfo({
            withCredentials: true,
            success: res => {
              // 可以将 res 发送给后台解码出 unionId
              this.globalData.userInfo = res.userInfo
              if (res && res.encryptedData && res.iv && this.globalData.privateUserCode) {
                const postData = {
                  openCode: this.globalData.privateUserCode,
                  encryptedData: res.encryptedData,
                  iv: res.iv
                }
                wx.request({
                  url:'http://localhost:8080/getWeChatUnionIdAndOpenId',
                  data: postData,
                  method: "POST",
                  header: {
                    'content-type': 'application/x-www-form-urlencoded' //修改此处即可
                  },
                  success: decodeData => {
                      try {
                        const objInfo = decodeData.data;
                        if (objInfo.code == "0") {
                          this.globalData.constWxUserOpenId = objInfo.data.openId;
                          this.globalData.constWxUserUnionId=objInfo.data.unionId;
                        } else {
                          console.error(`获取微信UNIONID返回结果处理错误,错误信息: ${objInfo.message}`);
                        }
                      } catch (err) {
                        console.error(`获取微信UNIONID错误,错误信息: ${err}`);
                      }
                  }
                })
              }
              // 由于 getUserInfo 是网络请求,可能会在 Page.onLoad 之后才返回
              // 所以此处加入 callback 以防止这种情况
              if (this.userInfoReadyCallback) {
                this.userInfoReadyCallback(res)
              }
            }
          })
        }
      }
    })
  },
  globalData: {
    userInfo: null,
    extUserInfo: null,
    privateUserCode: null,
  }
})

java标志
image-3175

Java服务端

此处需使用Java JDK 14或高于14的版本,采用Spring Boot搭建.

在application.properties中配置小程序相关信息

application.properties文件中修改小程序appid和小程序秘钥:


#################################### common config : ####################################
spring.application.name=wxopenid
# 应用服务web访问端口
server.port=8080
# ActuatorWeb访问端口
management.server.port=8081
management.endpoints.jmx.exposure.include=*
management.endpoints.web.exposure.include=*
management.endpoint.health.show-details=always
#************************************************
# 微信小程序-配置信息
cn.bckf.wechat.appid=配置你自己的小程序appid
cn.bckf.wechat.secret=配置你自己的小程序秘钥
logging.level.cn.bckf=debug

将配置文件中的信息注入到Java Bean


import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;

/**
 * 读取application.properties文件中的微信小程序相关信息.
 *
 */
@Component
@ConfigurationProperties(prefix = "cn.bckf.wechat")
@Data
@NoArgsConstructor
public class SystemInfo {

    String appId;

    String secret;
}

JSON数据处理

访问微信的接口,微信会返回json数据,在Java中,需要对json数据进行解析处理.


import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;


/**
 * 微信解密数据实体类.(字段可能会增加)
 * (参考地址: https://developers.weixin.qq.com/miniprogram/dev/framework/open-ability/signature.html)
 *
 * @author prd
 * @version 2020-05-16
 */
@AllArgsConstructor
@NoArgsConstructor
@Data
public class WeChatDecryptData {


    @JsonProperty("openId")
    private String openid;
    @JsonProperty("nickName")
    private String nickname;
    private String gender;
    private String city;
    private String province;
    private String country;
    @JsonProperty("avatarUrl")
    private String avatarUrl;
    @JsonProperty("unionId")
    private String unionId;
    private Watermark watermark;

    public class Watermark {
        private String appid;
        private String timestamp;

        public void setAppid(String appid) {
            this.appid = appid;
        }

        public String getAppid() {
            return appid;
        }

        public void setTimestamp(String timestamp) {
            this.timestamp = timestamp;
        }

        public String getTimestamp() {
            return timestamp;
        }
    }
}




import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@AllArgsConstructor
@NoArgsConstructor
public class WeChatOpenIdRes {

    @JsonProperty("session_key")
    private String sessionKey;
    private String openid;

}

解密及调用微信接口

该类中主要定义了解密方法,请求微信接口.

关于解密,也可以参考微信官方文档: https://developers.weixin.qq.com/miniprogram/dev/framework/open-ability/signature.html



import lombok.extern.slf4j.Slf4j;
import org.springframework.util.StringUtils;

import javax.crypto.Cipher;
import javax.crypto.KeyGenerator;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.Base64;

/**
 * 解密及调用微信接口工具
 *
 *
 */
@Slf4j
public class WebUtil {
    /**
     * 解密微信加密信息获取UnionId.
     */
    private static int AES_128 = 128;
    private static String ALGORITHM = "AES";
    private static String AES_CBS_PADDING = "AES/CBC/PKCS5Padding";

    public static byte[] encrypt(final byte[] key, final byte[] IV, final byte[] message) throws Exception {
        return encryptDecrypt(Cipher.ENCRYPT_MODE, key, IV, message);
    }

    public static byte[] decrypt(final byte[] key, final byte[] IV, final byte[] message) throws Exception {
        return encryptDecrypt(Cipher.DECRYPT_MODE, key, IV, message);
    }

    private static byte[] encryptDecrypt(final int mode, final byte[] key, final byte[] IV, final byte[] message)
            throws Exception {
        final Cipher cipher = Cipher.getInstance(AES_CBS_PADDING);
        final SecretKeySpec keySpec = new SecretKeySpec(key, ALGORITHM);
        final IvParameterSpec ivSpec = new IvParameterSpec(IV);
        cipher.init(mode, keySpec, ivSpec);
        return cipher.doFinal(message);
    }

    private static String getWeChatAPI() {
        return "https://api.weixin.qq.com/sns/jscode2session?appid=%s&secret=%s&js_code=%s&grant_type=authorization_code";
    }


    /**
     * 解密微信加密的数据(获取UnionId及其余相关信息).
     *
     * @param sessionKey
     * @param encryptedData
     * @param iv
     * @return
     */
    public static String decryptWeChatInfo(String sessionKey, String encryptedData, String iv) {
        if (StringUtils.hasText(sessionKey) && StringUtils.hasText(encryptedData) && StringUtils.hasText(iv)) {
            try {
                var decodeSessionKey = Base64.getDecoder().decode(sessionKey);
                var decodeEncryptedData = Base64.getDecoder().decode(encryptedData);
                var deCodeIV = Base64.getDecoder().decode(iv);
                var keyGenerator = KeyGenerator.getInstance(ALGORITHM);
                keyGenerator.init(AES_128);
                var decryptedString = decrypt(decodeSessionKey, deCodeIV, decodeEncryptedData);
                var resStr = new String(decryptedString);
                log.debug("解密结果 : {}",resStr);
                return resStr;
            } catch (Exception ex) {
                log.debug("________________________________decryptWeChatInfo: sessionKey:{},encryptedData: {} , iv: {}", sessionKey, encryptedData, iv);
            }
        }
        return null;
    }


    /**
     * 用微信小程序获得的code换取微信用户的openId.
     *
     * @param weChatOpenCode 微信小程序获得的code.
     * @return
     */
    public static String getWeChatOpenId(String weChatOpenCode, String appId, String secret) {
        log.debug("________________________________wxOpenCode:{}", weChatOpenCode);
        if (!StringUtils.hasLength(weChatOpenCode)) {
            return "";
        }
        try {
            var client = HttpClient.newHttpClient();
            var url = getWeChatAPI().formatted(appId, secret, weChatOpenCode);
            HttpRequest request = HttpRequest.newBuilder()
                    .uri(URI.create(url))
                    .build();
            var response = client.send(request, HttpResponse.BodyHandlers.ofString());
            log.info(response.body());
            return response.body();
        } catch (IOException e) {
            log.error("getWeChatOpenId:{}", e.getMessage());
        } catch (InterruptedException e) {
            log.error("getWeChatOpenId:{}", e.getMessage());
        }
        return "";
    }


}


对外提供服务

对外提供访问服务:

/appid 可以访问当前application.properties配置的小程序相关信息.
/getWeChatOpenId 微信loginCode获取微信用户的openId
/getWeChatUnionId 解密微信加密的数据(获取UnionId)(关于UnionId的限制请参考官方文档).
(推荐,只需调用一次即可同时获得UnionId和OpenId) /getWeChatUnionIdAndOpenId 解密微信加密的数据(获取UnionId和OpenId).(用loginCode换取OpenId然后换取UnionId)(关于UnionId的限制请参考官方文档).



import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.Map;


/**
 * 微信小程序服务端解密用户的OpenId和UnionId.
 * 
 * 基本参考: JDK>=14
 * 
 * 参考文档:
 * 
 * 服务端获取开放数据:
 * 
 * https://developers.weixin.qq.com/miniprogram/dev/framework/open-ability/signature.html
 * 
 * UnionID 机制说明:
 * 
 * https://developers.weixin.qq.com/miniprogram/dev/framework/open-ability/union-id.html
 *
 * @author prd
 * @version 2020-05-16
 */
@SpringBootApplication
@RestController
@RequestMapping("/")
@Slf4j
public class WeChatOpenDataDecryptApplication {

    @Autowired
    private ObjectMapper objectMapper;

    @Autowired
    private SystemInfo systemInfo;

    @RequestMapping("/appid")
    public SystemInfo getAppId() {
        return systemInfo;
    }


    /**
     * 用微信loginCode获取微信用户的openId.
     *
     * @param loginCode
     * @return
     */
    @RequestMapping("getWeChatOpenId")
    public String getWeChatOpenId(String loginCode) {
        return WebUtil.getWeChatOpenId(loginCode, systemInfo.getAppId(), systemInfo.getSecret());
    }


    /**
     * 返回UnionId,通用方法.
     *
     * @param resStr
     * @return
     * @throws JsonProcessingException
     */
    private Map getUnionIdCommon(String resStr) throws JsonProcessingException {
        if (StringUtils.hasText(resStr)) {
            var resObj = objectMapper.readValue(resStr, WeChatDecryptData.class);
            var waterMarkAppId = resObj.getWatermark().getAppid();
            var currentWxAppId = systemInfo.getAppId();
            if (waterMarkAppId.equalsIgnoreCase(currentWxAppId)) {
                return Map.of("code", "0", "message", "", "data", resObj);
            } else {
                var resMessage = String.format("解密微信加密的数据(获取UnionId).发生错误,错误原因: 解密后的APPID与当前小程序的APPID不一致!解密后的APPID: %s,当前APPID: %s.", waterMarkAppId, currentWxAppId);
                log.error("解密微信加密的数据(获取UnionId).发生错误,错误原因: 解密后的APPID与当前小程序的APPID不一致!解密后的APPID: {},当前APPID: {}.", waterMarkAppId, currentWxAppId);
                return Map.of("code", "-1", "message", resMessage);
            }
        }
        return Map.of("code", "-1", "message", "返回数据为空,或处理出现错误!");
    }

    /**
     * 解密微信加密的数据(获取UnionId)(关于UnionId的限制请参考官方文档).
     *
     * @param encryptedData
     * @param sessionKey
     * @param iv
     * @return
     */
    @RequestMapping("getWeChatUnionId")
    public Map getWeChatUnionId(String sessionKey, String encryptedData, String iv) {
        try {
            var resStr = WebUtil.decryptWeChatInfo(sessionKey, encryptedData, iv);
            return getUnionIdCommon(resStr);
        } catch (Exception ex) {
            return Map.of("code", "-1", "message", "处理出现错误!错误信息: " + ex.getMessage());
        }
    }


    /**
     * 解密微信加密的数据(获取UnionId和OpenId).(用loginCode换取OpenId然后换取UnionId)(关于UnionId的限制请参考官方文档).
     *
     * @param encryptedData
     * @param iv
     * @param loginCode
     * @return
     */
    @RequestMapping("getWeChatUnionIdAndOpenId")
    public Map getWeChatUnionIdAndOpenId(String encryptedData, String iv, String loginCode) {
        try {
            if (!StringUtils.hasText(loginCode) || !StringUtils.hasText(encryptedData) || !StringUtils.hasText(iv)) {
                return Map.of("code", "-1", "message", "必填参数为空!");
            }
            var openIdRes = WebUtil.getWeChatOpenId(loginCode, systemInfo.getAppId(), systemInfo.getSecret());
            if (StringUtils.hasText(openIdRes)) {
                var wxOpenIdResObj = objectMapper.readValue(openIdRes, WeChatOpenIdRes.class);
                if (wxOpenIdResObj != null) {
                    var resStr1 = WebUtil.decryptWeChatInfo(wxOpenIdResObj.getSessionKey(), encryptedData, iv);
                    return getUnionIdCommon(resStr1);
                }
            }
        } catch (Exception ex) {
            return Map.of("code", "-1", "message", "处理出现错误!错误信息: " + ex.getMessage());
        }
        return Map.of("code", "-1", "message", "返回数据为空,或处理出现错误!");
    }


    public static void main(String[] args) {
        SpringApplication.run(WeChatOpenDataDecryptApplication.class, args);
    }

}


项目地址

完整代码可以访问: https://gitee.com/pruidong/SpringBootDemoProjects/tree/master/WeChatGetOpenIdDemo