专栏首页前端自习课【小程序】384- 如何一人五天开发完复杂小程序(前端必看)

【小程序】384- 如何一人五天开发完复杂小程序(前端必看)

随着业务需求的不断累加、小程序追求快速产出。

在人手不足且开发周期较短的情况下,我们需要找到一个最大化开发效率的方法。

而高效率的开发离不开规范化、工程化、组件化。

为此整理写下总结,细数小程序中的坑与实践。

介绍我们对小程序高效率开发的思考与探索。

  • 布局方案
    • 导航栏
    • TabBar
    • BasicPage
  • 用户系统
    • 登录方案
    • 初始化登录
    • 鉴权
  • 优化及 Bug 追踪
    • 日志收集
    • 数据分析
  • 常用优化方案
    • preLoad
    • 独立分包加载

布局方案

我们首先思考的是,在小程序中如何快速且高还原产出页面。

为此我们封装了一套页面组件。

导航栏

目前小程序有如下两种导航栏:常规、自定义导航栏

常规布局下,顶部导航栏部分直接使用小程序提供导航栏。

自定义导航栏布局下,我们可以完全控制导航栏样式,赋予导航栏更多交互及 UI 设计上的可能。如上图所示,Readhub 在导航栏中加入了设置按钮,喜茶在个人页中标题渐隐及沉浸式导航栏效果。

可根据具体业务选择具体布局方案,在我们小程序中,我们选择了全部使用自定义导航栏的方式并对其进行了一定封装。

在确定使用自定义导航栏方案后,我们对导航栏进行了拆解

拆解后,我们发现可以将自定义导航栏分为两个部分:StatusBar 及 NavigationBar 。

通过查阅微信 API ,我们分别通过 wx.getSystemInfoSyncwx.getMenuButtonBoundingClientRect 获取到 StatusBarHeight 及 MenuButton 的布局信息。

由拆解图可知

NavigationBarPaddingTop = MenuButtonTop - StatusBarHeight

NavigationBarPaddingBottom = NavigationBarPaddingTop

NavigationBar = StatusBarHeight + NavigationBarPaddingTop + NavigationBarPaddingBottom + MenuButtonHeight

得到上述数据后,结果简单封装, 我们得到如下方案

StatusBar 部分, 我们使用 PaddingTop 填充。

可在此基础上可再进一步封装一些通用 NavigationBar 组件。

我们封装了一些常用 NavigationBar 组件, 如下所示:

沉浸式导航栏

自定义 TabBar

目前小程序 TabBar 中也存在两种方案。

常规 TabBar :微信提供方案,可修改 icon 、 文字及其对应选中状态。

自定义 TabBar :小程序基础库 2.5.0 开始支持。可通过其实现异形 TabBar 或各种自定义样式。

普通TabBar

在我们小程序中,我们选择全部使用自定义 TabBar 来实现业务。

由于小程序基础库 2.5.0 之后官方才开始支持自定义 TabBar 。我们此处不直接选择使用 custom-tab-bar 方案。选择结合 custom-tab-bar 、 自定义组件及 wx.hideTabBar 的方案实现。

具体方案为放置空节点 custom-tab-bar 文件。在页面中按需引入自定义 TabBar 组件。在页面初始化完成后调用 wx.hideTabBar 隐藏原 TabBar 。

这样做的好处在于,在基础库 2.5.0 及更高版本时正常显示,在低版本时以最小代价兼容。

普通

异形TabBar

在 iPhone X 系列下的底部安全区兼容方案如下

@mixin media-style() {
  .tab {
    padding-bottom: 84px;
  }
}
// 适配iPhone X系列下巴
@media screen and (device-width: 375px) and (device-height: 812px) and (-webkit-device-pixel-ratio: 3) {
    @include media-style();
}

@media only screen and (device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio:3) {
    @include media-style();
}

@media only screen and (device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio:2) {
    @include media-style();
}
// 下面代码只为适配iPhone X在微信调试模拟器中为724px
@media screen and (device-width: 375px) and (device-height: 724px) and (-webkit-device-pixel-ratio: 3) {
    @include media-style();
}

推荐如无特殊需求,建议直接使用微信提供方案,在自定义 TabBar 方案中 安卓手机下拉刷新时, TabBar 会被拉出可视区域。需自定义下拉刷新组件解决

方案整合 BasicPage

以上方案在线上运行一段时间后稳定后。对自定义导航栏及自定义 TabBar 方案进行了整合。封装了 BasicPage 组件。

以我们线上典型页面为例,我们可以将页面分为两大类。

三段式结构

无 TabBar

基于以上分析结合线上需求,我们对此基础组件进行封装。

Taro 框架伪代码,可根据各自使用框架进行封装,思路一致

class BasicPage extends Taro.Component {

  state = {
    menuButtonHeight: 32,
    menuButtonTop: 48,
    statusBarHeight: 44,
  };

  componentDidMount() {
        // ...获取并设置 menuButtonHeight 、 menuButtonTop 、 statusBarHeight
  }

  render() {
    return (
      <View className='basic-page'>
        {
          this.props.header && <View className={`basic-page-header${this.props.fixed ? ' fixed' : ''}`} style={{
            paddingTop: `${this.state.statusBarHeight}px`,
            height: `${(this.state.menuButtonTop - this.state.statusBarHeight) * 2 + this.state.menuButtonHeight}px`,
          }}
          >
            {this.props.renderHeader}
          </View>
        }
        <View className={`basic-page-body${this.props.tab ? ' tab' : ''}`}>
          {this.props.renderBody}
        </View>
        {this.props.tab && <TabBar active={this.props.tabActive} />}
      </View>
    );
  }
}

BasicPage.defaultProps = {
  fixed: false, // header 是否浮动
  tab: false,
  header: false,
  tabActive: 'template',
};

使用中会经常用到 自定义 TabBar 、 自定义 NavigationBar 布局数据。再封装一个工具类获取。

import Taro from "@tarojs/taro";

function rpx2px(rpx, windowWidth) {
  return rpx / 750 * windowWidth;
}

export default class customConfig {

  static fetchAllConfig() {
    const menuButton = Taro.getMenuButtonBoundingClientRect();
    const systemInfo = Taro.getSystemInfoSync();

    const statusBarHeight = systemInfo.statusBarHeight;
    const headerHeight = (menuButton.top - systemInfo.statusBarHeight) * 2 + menuButton.height;
    const footerHeight = systemInfo.model.indexOf('iPhone X') === -1
      ?
      rpx2px(100, systemInfo.windowWidth)
      :
      rpx2px(168, systemInfo.windowWidth);  // 50  84
    const bodyHeight = systemInfo.windowHeight - statusBarHeight - headerHeight - footerHeight;
    const noTabBodyHeight = systemInfo.windowHeight - statusBarHeight - headerHeight;

    let data = {
      source: {
        menu: menuButton,
        system: systemInfo,
      },
      height: {
        statusBar: statusBarHeight,
        header: headerHeight,
        body: bodyHeight,
        noTabBody: noTabBodyHeight,
        footer: footerHeight,
      },
    };
    Taro.setStorageSync('customConfig', data);
    return data;
  }

  static get config() {
    let storageInfoSync = Taro.getStorageSync('customConfig');
    if(!storageInfoSync) {
      storageInfoSync = this.fetchAllConfig();
    }
    return storageInfoSync;
  }
}

到此,我们完成对基础页面组件的封装。目前线上运行小程序所有页面都基于该组件进行开发。

开发新页面时只需要引用该组件即可。

<BasicPage header tab tabActive='index'
        renderHeader={
          <View
            className='my-index-header'
          >
            <Text>Title</Text>
          </View>
        }
        renderBody={
          <View className='my-index-header'>
            Body
          </View>
        }
/>

用户系统

在一个应用中,用户系统是至关重要的。我们通过数个小程序的开发,整理了一套我们目前正在使用的用户系统实践。

登录、获取用户信息

登录流程

获取用户信息

如上图所示,我们将小程序登录及获取用户信息拆分为两部分。

主要有如下考虑:

  1. 降低用户使用门槛,可先让用户体验部分功能。后续分享或互动时提示授权完善用户信息
  2. 保证始终持有用户登录态,方便程序处理。如把用户登录及完善用户信息放置一起,在未授权时无法获取自定义登录态。判断变得复杂且无法提前收集 formId
  3. 同一开发者账号下,多小程序互通时,如有一小程序用户授权过,可通过返回 unionid 直接同步信息,无需再授权,提升用户体验。

处理注意点

授权获取用户信息时,如果服务端未记录用户 sessionKey ,在 Button type = getUserInfo 回调事件中使用 wx.login 方法获取 code 的话,会导致 sessionKey 变化。从而导致 getUserInfo 时使用 sessionKey 与新 sessionKey 不匹配。从而导致解密用户信息失败。

解决方案有如下两种:

  • Button type = getUserInfo 回调事件中使用 wx.login 方法后,再次调用 wx.getUserInfo 方法重新获取加密用户信息。
  • 服务端记录 sessionKey ,Button type = getUserInfo 回调后无需调用 wx.login ,直接提交供服务端处理。

第一种方案适合简单改造旧项目、快速开发,但强烈建议使用服务端处理方式解决。

完善用户信息时,解密用户信息部分请查看官方文档,这里不叙述具体流程https://developers.weixin.qq.com/miniprogram/dev/framework/open-ability/signature.html

unionid 机制

另外,在登录流程中服务端向微信换取 sessionKey 过程中,如果满足一定条件,会直接返回 unionid 。同开发者账号下多个小程序时可用 unionid 做用户信息同步,无需再授权。提升用户体验。

unionid 机制: https://developers.weixin.qq.com/miniprogram/dev/framework/open-ability/union-id.html

小程序初始化及页面初始化处理

在日常开发中,我们通常会把登录获取 token 操作放置在小程序初始化中即 app.js 定义的 onLaunch 中。而该生命周期与页面初始化生命周期为同步进行。

此时,如果在页面初始化中,需要携带用户登录态请求接口获取信息时,可能出现如下情况

因为小程序初始化及页面初始化是同步进行的。若页面初始化时,小程序初始化中登录请求仍未完成。会导致未携带 token 或其他鉴权信息,鉴权失败。

最开始我们通过在组件中挂载一个特殊事件 componentDidInit ,待小程序初始化登录请求后获取当前页面实例进行调用。但该方案对代码侵入性太强,最终我们选择维护一个登录请求队列。

用上队列的原因在于,在产品需求上经常会有先跳入首页,再从首页跳入二级页的需求,这样能让用户回退一次后,仍然能回到首页。但会导致在不同页面中近乎同时调用 login 方法。

在第一种方案中,解决该问题需要获得所有页面实例进行调用。而引入队列后只需要轮询消费队列中函数执行即可。上述流程可解决此问题。伪代码如下:

代码仅供理解思路

let loginDoing = false;
const loginEvent = [];

const userProfile = observable({
  user: {
    avatar: '',
    isCompleted: false,
    nickname: '',
    uid: 0,
    token: '',
  },
  async loginProcess() {
    if(this.user.token) {
      return this.user;
    }
    loginDoing = true;
    let code;
    try {
      const codeResult = await Taro.login();
      if(codeResult.errMsg !== 'login:ok') {
        throw new Error('Taro.login 失败');
      }
      code = codeResult.code;
    } catch (e) {
      loginDoing = false;
      throw e;
    }
    const result = await post(URL().user.login, {
      code,
    });
    let user = {
      ...result.user,
      token: result.token,
    };
    this.user = user;
    loginDoing = false;
    setTimeout(() => {
      let length = loginEvent.length;
      for(let i = 0; i < length; i++) {
        loginEvent.pop()(user);
      }
    });
    return user;
  },
  login() {
    if(loginDoing) {
      return new Promise((resolve) => {
        loginEvent.push(resolve);
      });
    } else {
      return this.loginProcess()
    }
  },
});

鉴权

业务需求中,通常存在某些操作需要 【 用户授权完善信息 】 后才能继续进行,早期项目中都是各自页面中写鉴权代码。因而会涉及大量重复代码,也不利于快速开发。为此我们封装了一套鉴权方案。

BasePage

通过所有页面基础一个基类 BasePage 。在 BasePage 中写入鉴权逻辑来实现。配合在主页面中使用 AuthorizationModal 组件实现鉴权。

代码仅供理解思路

export default class BasePage extends Component {

    state = {
        // 鉴权相关
        showAuthorizationModal: false,
    };

    /**
     * 鉴权相关
     */
    // 授权成功事件
    authSuccessEvent() {
    }

    // 取消授权事件
    authFailEvent() {
    }

    async checkAuthorization() {
        // 当前是否有已验证
        let globalData = getGlobalData(STORAGE_KEY.VERIFY);
        if(globalData) {
            return {
                isNew: false,
            };
        } else {
            Taro.showLoading({
                title: '检查授权中...',
                mask: true,
                showTicketModal: false,
            });
            // 如果本地不存在时,先请求接口
            // 未登录过,或新机器
            // 请求token及授权状态
            let res;
            try {
                res = await Taro.login();
            } catch() {
                Toast.fail('登录失败~');
                Taro.hideLoading();
                throw new Error('Taro.login 失败');
            }
            // 请求授权接口
            const result = {};
            if(result.errno === 0) {
                resolve({
                    isNew: false,
                });
            } else {
                // 未授权过
                // 弹窗提示授权
                this.setState({
                    showAuthorizationModal: true,
                });
                this.authSuccessEvent = () => {
                    this.setState({
                        showAuthorizationModal: false,
                    });
                    resolve({
                        isNew: true,
                    });
                };
                this.authFailEvent = () => {
                    this.setState({
                        showAuthorizationModal: false,
                    });
                    reject();
                };
            }
        }
    }
}

页面继承该基类

class LaunchIndex extends BasePage {}

在页面中置入组件

{this.state.showAuthorizationModal &&
<AuthorizationModal onSuccess={this.authSuccessEvent} onFail={this.authFailEvent}/>}

AuthorizationModal 组件

接下来,我们只需要在需要鉴权的操作中如下使用即可

this.checkAuthorization()
  .then((res) => {
   // 授权成功逻辑
       console.log('是否新用户', res.isNew);
   })
   .catch(() => {
    // 授权失败逻辑
    })

该方案好处在于,授权由状态驱动,只需在代码中调用 checkAuthorization 方法即可。

AuthorizationView

后来,由于第一种方案过于重,对页面代码侵入性较强。为此我们又封装了一套较轻的组件。

大部分逻辑中,需要用户主动点击时才进行鉴权,我们基于此思路封装了 AuthorizationView 。对外暴露 onAgree 、 onDeny 方法实现对部分区域的点击鉴权操作。

代码仅供理解思路

class AuthorizationView extends Taro.Component {

  state = {
    showLoginPanel: false,
  };

  /**
   * 登录
   */
  click() {
    const { userProfile: { user, }, } = this.props;
    if(user.isCompleted) {
      this.props.onAgree(user);
    } else {
      // 显示登录框
      this.setState({
        showLoginPanel: true,
      });
    }
  }

  /**
   * 授权登录
   * @param e
   */
  async bindGetUserInfo(e) {
    if(e.detail.errMsg === 'getUserInfo:ok') {
      const { userProfile, } = this.props;
      const userResult = await userProfile.login(true);
      this.setState({
        showLoginPanel: false,
      });
      this.props.onAgree(userResult);
    } else {
      this.props.onDeny();
    }
  }

  cancel() {
    this.setState({
      showLoginPanel: false,
    });
  }

  render() {
    return (
      <Block>
        <View onClick={this.click}>{this.props.children}</View>
        {
          this.state.showLoginPanel && <View className='login-panel'>
            <View className='login-panel-main'>
              <View className='login-panel-main-title'>您还未登录</View>
              <View className='login-panel-main-subtitle'>请先登录再进行操作</View>
              <Image className='login-panel-main-image' src='https://p0.ssl.qhimg.com/t01a1e495cc2be1e651.png' />
              <View className='login-panel-main-footer'>
                <View className='login-panel-main-footer-button cancel' onClick={this.cancel.bind(this)}>暂不登录</View>
                <Button className='btn-reset' openType='getUserInfo' onGetUserInfo={this.bindGetUserInfo}>
                  <View className='login-panel-main-footer-button confirm'>立即登录</View>
                </Button>
              </View>
            </View>
          </View>
        }
      </Block>
    );
  }
}

AuthorizationView.defaultProps = {
  onAgree: () => {
  },
  onDeny: () => {
  },
};

export default AuthorizationView;

代码中只需要使用该组件包裹子组件即可使用

<AuthorizationView onAgree={this.onAgree.bind(this)} onDeny={this.onDeny.bind(this)}>
  <View>生成海报</View>
</AuthorizationView>

以上两种方案都有在线上业务中使用,具体选型看业务决定

优化及Bug追踪

在维护阶段,我们会更加关注于用户反馈 bug 时如何复现场景及数据分析。

日志收集

在小程序基础库版本 2.1.0 后,微信提供了一套日志相关接口:LogManager 。

在用户反馈时,通过该接口记录的日志会同步上传至微信后台,可下载查看追踪 Bug。

我们通过简单的对其封装,实现一套日志收集机制。

const _logger = Taro.getLogManager({ level: 0, });

const Logger = {
  debug(...args) {
    _logger.debug(`${dayjs().format('YYYY-MM-DD HH:mm:ss')} ?`, ...args);
    console.debug(`${dayjs().format('YYYY-MM-DD HH:mm:ss')} ?`, ...args);
  },
  info(...args) {
    _logger.info(`${dayjs().format('YYYY-MM-DD HH:mm:ss')} ?`, ...args);
    console.info(`${dayjs().format('YYYY-MM-DD HH:mm:ss')} ?`, ...args);
  },
  warn(...args) {
    _logger.warn(`${dayjs().format('YYYY-MM-DD HH:mm:ss')} ⚠️`, ...args);
    console.warn(`${dayjs().format('YYYY-MM-DD HH:mm:ss')} ⚠️`, ...args);
  },
  error(...args) {
    _logger.warn(`${dayjs().format('YYYY-MM-DD HH:mm:ss')} [ Error ] ❌️`, ...args);
    console.error(`${dayjs().format('YYYY-MM-DD HH:mm:ss')} [ Error ] ❌️`, ...args);
  },
};

export default Logger;

在使用时,最好按照一定规范进行使用,方便后续查找。例如

Logger.error('[ MyIndex ] 获取用户信息失败', e);
Logger.debug('[ LaunchIndex ] init response', info);

实时日志分析:小程序基础库 2.7.1 之后还提供了 实时日志分析功能。https://developers.weixin.qq.com/miniprogram/dev/api/base/debug/wx.getRealtimeLogManager.html

数据分析

数据分析模型

在产品迭代过程中,我们一般会依照上面模型进行迭代。

数据获取 → 数据分析 → 数据应用 → 数据反馈

在小程序中,数据获取的方案主要有

  • 小程序后台自定义分析 小程序本身提供的数据平台。 优点在于能随时不发版本添加数据打点位置。能满足大部分需求。 主要依靠产品后台自行添加数据打点项目。
  • 第三方数据平台 这里以阿拉丁自定义数据分析为例。依靠第三方平台提供 API 进行打点。
    • 阿拉丁
    • ……
  • 自有数据分析平台 一般大厂都会有自己自有数据分析平台,联系数据组拓展即可

推荐使用小程序后台自定义分析进行打点。各数据平台打点大同小异,能不发版本添加数据打点才是大杀器。

阿拉丁数据平台打点封装,代码仅供理解思路

import Taro from '@tarojs/taro';

export default class Monitor {
  static sendEvent(moduleName, eventName, options) {
    let aldstat = Taro.getApp().aldstat;
    if(aldstat) {
      aldstat.sendEvent(`[ ${moduleName} ] ${eventName}`, options);
    }
  }
}

Monitor.sendEvent('LaunchIndex', '返回', {
  id: this.state.id,
});

Monitor.sendEvent('LaunchIndex', '点击制作', {
  id: this.state.id,
});

小程序自定义分析API方式依葫芦画瓢封装即可。

需要注意的是封装时要有逻辑、有规则的封装,方便后面筛选具体页面具体操作。

常用优化方案

preLoad

在微信小程序中,页面路由跳转时 ( 例如调用 wx.navigateTowx.redirectTowx.switchTab ) ,到页面触发 componentWillMount 会有一定延时。因此一些网络请求可以提前到跳转前一刻请求。而后在触发 componentWillMount 后取得该请求实例。

目前各框架均提供了预加载请求实现。原生开发可自行拓展,思路一致。以下以 Taro 为例。代码仅供理解思路。

export default class Preload extends BasePage {
    componentWillMount() {
        let initData;
        // 兼容直接进入的场景
        if(this.$preloadData) {
            initData = this.$preloadData;
        } else {
            initData = request(URL().user.defaultAddress, {
                token: getGlobalData(STORAGE_KEY.ACCESS_TOKEN),
            });
        }
        initData
            .then((initInfo) => {
            })
            .catch(() => {
            });
    }

    componentWillPreload (params) {
        return request(URL().user.defaultAddress, {
            token: getGlobalData(STORAGE_KEY.ACCESS_TOKEN),
        });
    }
}

独立分包加载

https://developers.weixin.qq.com/miniprogram/dev/framework/subpackages.html

除上面列出尝试外。我们还做了以下工作:

  • 通用分享图解决方案
  • 小程序云开发应用
  • 自定义下拉刷新组件 RefreshView
  • Protobuf
  • 图片裁剪组件

还有一些你可能遇不到的坑

  • 原生组件使用问题
  • Video 、 innerAudioContext

由于不是必要部分,篇幅有限,不在此一一列举

价值

在对小程序进行上述实践后,我们已经能够基于该实践快速开发复制小程序。我们最近一个小程序 【嘟嘟卡点相册】 仅开发5天后就上线了。

纸上得来终觉浅,绝知此事要躬行。

文章内容基本囊括了开发维护阶段可能会用到的点及我们对此作出的应对方案。供参考。

本文仅为抛砖引玉, 软件开发没有银弹,好的方案一定是与业务息息相关的。欢迎交流。

面向未来

小程序脚手架 CLI

本文分享自微信公众号 - 前端自习课(FE-study)

原文出处及转载信息见文内详细说明,如有侵权,请联系 yunjia_community@tencent.com 删除。

原始发表时间:2019-10-19

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

我来说两句

0 条评论
登录 后参与评论

相关文章

  • 【CSS】470- 是时候开始用 CSS 自定义属性了

    自定义属性(有时候也被称作CSS变量或者级联变量)是由CSS作者定义的,它包含的值可以在整个文档中重复使用。由自定义属性标记设定值(比如:--main-colo...

    pingan8787
  • 【拓展】700- MVVM模式理解

    Vue.js 是一个提供了 MVVM 风格的双向数据绑定的 Javascript 库,专注于View 层。它的核心是 MVVM 中的 VM,也就是 ViewMo...

    pingan8787
  • 【Redis】349- Redis 入门指南

    Redis 是速度非常快的非关系型(NoSQL)内存键值数据库,可以存储键和五种不同类型的值之间的映射。

    pingan8787
  • 如何一人五天开发完复杂小程序(前端必看)

    自定义导航栏布局下,我们可以完全控制导航栏样式,赋予导航栏更多交互及 UI 设计上的可能。如上图所示,Readhub 在导航栏中加入了设置按钮,喜茶在个人页中标...

    ConardLi
  • 谈谈关于MVP模式中V-P交互问题

    在差不多两年的时间内,我们项目组几十来号人都扑在一个项目上面。这是一个基于微软SCSF(Smart Client Software Factory)的项目,客户...

    蒋金楠
  • 生产技巧:如何不停机修改Zookeeper日志路径?

    由于Kafka集群的运维兄弟没对线上环境Zookeeper做处理,因此 zookeeper.out 文件会不断增大,没几天时间,文件已经有6G。故而需要做一些改...

    用户1516716
  • 你们要的高级面试题来了,30K月薪?安排!

    由于这周工作上Bug比较多,没有及时更新,现在干货来了,下面这些面试内容你都会了,30K不在话下,由于高级篇内容较多,预计阅读需要....3个月

    Android扫地僧
  • Django中Aggregation聚合的基本使用方法

    Django 的 filter、exclude 等方法使得对数据库的查询很方便了。这在数据量较小的时候还不错,但如果数据量很大,或者查询条件比较复杂,那么查询效...

    砸漏
  • 浅谈Volley请求

    先简单介绍一下Volley的诞生背景 Volley诞生于 2013年 Google I/O大会上 是Google开发工程师写的一个网络请求框架 特点是进行数据量...

    全栈自学社区
  • Spring事件监听机制

    看一下这个方法AbstractApplicationContext.refresh,在IOC源码解析那篇文章已经把这个方法分析完了,所以直接关注事件广播器和事件...

    Java学习录

扫码关注云+社区

领取腾讯云代金券