浅尝跨平台框架--uniapp

389 阅读10分钟

前言

最近在工作中有使用uniapp做app开发的经历,项目不算大但是细节和可以记录的东西也不少,所以特地写一个文章记录一下。关于跨平台框架的选型,我这边有使用taro开发小程序的经验,但是使用跨平台框架打包app还是头一次,并且uniapp也是第一次使用,如果下文中有使用不当的地方请指正。

框架比较(taro vs uniapp

对于uniapptaro的比较,我个人开发下来的感觉是这样的:taro文档清晰明了,并且可以根据自身喜好选择react或者vue来做开发;uniapp我开发下来感觉社区非常庞大,有自己的插件市场,开发app有 HBuilderX这种编译工具,还有比较容易上手的云服务支持云打包也非常方便。反正个人感觉二者都是非常优秀的框架(但是坑都不少),大家可以根据自己的喜好选择。

选择前需要考虑

如果大家也在工作中想使用这种跨平台框架开发app需要考虑几点:1.性能问题:多端框架打包出来的app在性能上肯定是比不上原生的,使用前和领导沟通好;2.兼容问题:提前了解自己所开发的功能需要的依赖是否有多端框架所支持的版本(这件事很重要,别写半天写不下去了)

项目搭建

uniapp提供了两种创建项目的方式:

  1. 使用HBuilderX选择合适的模板搭建项目,如下图所示:

image.png 编译器上不仅提供了丰富的模板,而且可以选择vue版本,并且有云服务选项等等,也是非常的方便

  1. 使用脚手架来创建,使用脚手架我们能更加灵活的选择我们想用的整体技术框架,详见文档

我这边是使用脚手架新建的项目,整体的项目架构是vue3 + vite + ts,数据管理使用的尤大大强势推荐的pinia

下面是我的目录结构供大家参考: image.png

请求封装

可以对uni.request做一下封装更方便我们使用

import { useUserStore } from '@/store/modules/user'
import { ContentTypeEnum, HTTP_STATUS, RES_STATUS } from '@/enums/http'
type RequestOptionsMethod = 'OPTIONS' | 'GET' | 'HEAD' | 'POST' | 'PUT' | 'DELETE' | 'TRACE' | 'CONNECT'
type RequestOptionsMethodAll = RequestOptionsMethod | Lowercase<RequestOptionsMethod>

/**
 * 发送请求
 */
function baseRequest(
  url: string,
  method: RequestOptionsMethod,
  data: any,
  opt: unknown
) {
  const userStore = useUserStore();
  const token = userStore.getToken;
  const currentCompanyId = userStore.getCurrentCompanyId;
  const Url = import.meta.env.VITE_BASE_URL;
  const commonHeader = {
	  'content-type': ContentTypeEnum.JSON,
	  'authorization': token ? `Bearer ${token}` : '',
	  'corporation-id': currentCompanyId || ''
  }
  return new Promise((reslove, reject) => {
    uni.showLoading({
      title: '加载中',
      mask: true,
    })
    uni.request({
      url: Url + url,
      method: method || 'GET',
      header: {
		  ...commonHeader,
		  ...opt
	  },
      data: data || {},
      success: (res: any) => {
        console.log('res', res)
        uni.hideLoading()
        if (res.statusCode === HTTP_STATUS.SUCCESS) {
		  if (res.data.errcode === RES_STATUS.EXPIRED) {
			  uni.redirectTo({
			  	url: '/pages/login/login'
			  })
		  } else {
			reslove(res.data)
		  }
        } else {
          reject(res.data.msg || '系统错误')
        }
      },
      fail: (msg) => {
        uni.hideLoading()
        reject('请求失败')
      },
    })
  })
}

// const request: Request = {}
const requestOptions: RequestOptionsMethodAll[] = [
  'options',
  'get',
  'post',
  'put',
  'head',
  'delete',
  'trace',
  'connect',
]
type Methods = typeof requestOptions[number]
const request: { [key in Methods]?: Function } = {}

requestOptions.forEach((method) => {
  const m = method.toUpperCase() as unknown as RequestOptionsMethod
  request[method] = (api, data, opt) => baseRequest(api, m, data, opt || {})
})

export default request

值得一说的功能

手机号一键登录

介绍

uni一键登录是DCloud联合个推公司推出的,整合了三大运营商网关认证能力的服务。通过运营商的底层SDK,实现App端无需短信验证码直接获取手机号,也就是很多主流App都提供的一键登录功能。和普通的三方平台登录类似,通过授权获取到一个access_token,然后通过服务将access_token转成用户的真实手机号。

准备工作

1.申请一键登录服务:在开发者平台申请一键登录服务,价格是0.02元/次,失败不计费;

2.开通云服务空间并关联项目:使用HBuilderX创建项目的时候有这个选项,没选的也可以在项目->右键去创建uniCloud允开发环境

image.png 3.uniCloud下的cloudfunctions文件夹下存放的是咱们的云函数右键这个文件夹可以新建云函数或者对本地的云函数进行上传部署操作

image.png 4.上传之后我们可以打开uniCloud web控制台查看我们已经上传的云函数,在这里我们还可以看到每个云函数的调用情况及日志

image.png

image.png

开发

获取access_tokenopenid

使用uni提供的原始api
  • uni.getProvider:客户端获取可用的服务提供商,univerify为一键登录的provider ID,如果返回值包含则说明当前环境包含了一键登录的sdk
uni.getProvider({
  service: 'oauth',
  success: function (res) {
    console.log(res.provider)// ['qq', 'univerify']
  }
});
  • uni.preLogin(options):预登录api,检查客户端支不支持一键登录,可以在不支持的时候做一些隐藏的操作(可选)
uni.preLogin({
    provider: 'univerify',
    success(){  //预登录成功
       // 显示一键登录选项
    },
    fail(res){  // 预登录失败
        // 不显示一键登录选项(或置灰)
        // 根据错误信息判断失败原因,如有需要可将错误提交给统计服务器
        console.log(res.errCode)
        console.log(res.errMsg)
    }
})
  • uni.login(options):吊起登录授权弹窗,获取access_tokenopenid
uni.login({
    provider: 'univerify',
    univerifyStyle: { // 自定义登录框样式
    //参考`univerifyStyle 数据结构`
    },
    success(res){ // 登录成功
       console.log(res.authResult);  // {openid:'登录授权唯一标识',access_token:'接口返回的 token'}
    },
    fail(res){  // 登录失败
        console.log(res.errCode)
        console.log(res.errMsg)
    }
})
使用univerifyManager对象(3.2.13+)

因为我是用的是比较新的版本,所以可以使用uni封装好的univerifyManager对象做一键登录,代码会少一些,意思都是一样的,目的都是获取access_tokenopenid

const univerifyManager = uni.getUniverifyManager()

// 预登录
univerifyManager.preLogin()

// 调用一键登录弹框
univerifyManager.login({
  univerifyStyle: {
    "fullScreen": true,
    "buttons": {
        "iconWidth": "45px",
        "list": [
            {
                "provider": "apple",
                "iconPath": "/static/apple.png"
            }, 
            {
                "provider": "weixin",****
                "iconPath": "/static/wechat.png"
            }
        ]
    }
  },
  success (res) {
    console.log('login success', res)
  }
})

access_tokenopenid换取真实手机号

在云服务空间云函数文件夹下新建一个云函数(我的命名为getPhoneNumber),这个云函数用来获取手机号

'use strict';
exports.main = async (event, context) => {
    const { access_token, openid } = JSON.parse(event.body)
    const res = await uniCloud.getPhoneNumber({
        appid: 'your appid', 
        provider: 'univerify',
        apiKey: 'your apikey',
        apiSecret: 'your apisecret',
        access_token, // 获取到的access_token
        openid  // 获取到的openid
    })
    console.log(res)
    return res
};

其中应用的appid在项目的manifest.json中可以查看,没有的话就点击获取;apiKeyapiSecret在开通一键登录服务之后在开发者平台可以查看;

与服务端交互

理论上来讲通过云函数获取到的真实手机号最好不要直接暴露给前端,可以从云函数直接传递给服务端,我这边是将云函数url化之后让服务端去请求云函数,我这边只要拿到access_token和openid之后请求咱们自己的服务端即可,这样的流程更符合我们普通的开发流程

云函数url化方法

打开uniCloud web控制台,在对应的云函数中点击详情,在详情页对云函数的path进行修改。

image.png

需要注意的点

在本地开发的时候一键登录不需要添加应用,但是当我们进行云打包的时候,我们需要在开发者平台添加应用,并在打包的时候配置oauth模块,否则会提示模块缺失。开通应用需要证书,证书生成可以使用uniCloud生成云端证书,或者自己生成证书都可以,这一部分大家可以自行查阅。

image.png

声网实现语音会议

说到这里想必一些小伙伴已经知道我要做的是啥了,因为我前两篇文章也是关于声网和腾讯云的使用,之前是web,现在是app,我们做的是一个办公类型的app。

uniapp声网插件

使用

点击插件链接,直接导入插件。需要注意的事,需要同时导入native插件js插件,可以这么理解,原本这一版的声网插件是为react native提供的,为了咱们web前端用起来更顺手,用js封装了一下,JS 插件主要是为了做代码提示,且包含一些JS的逻辑,便于开发者使用 Native 插件。

保持单一声网实例

这个因人而异,可以挂载到全局,也可以存在store里,我选择的后者。

import { defineStore } from "pinia"
import permision from "@/js_sdk/wa-permission/permission"
import RtcEngine, { RtcChannel } from '@/js_sdk/Agora-RTC-JS/index'
import { ClientRole, ChannelProfile } from '@/js_sdk/Agora-RTC-JS/common/Enums'
import { getAgoraToken } from "@/api/sys"

const APPID = import.meta.env.VITE_AGORA_APPID
export const useAgoraStore = defineStore({
	id: 'app-agora',
	state: () => ({
		engine: null,
		openMicrophone: true,
		enableSpeakerphone: true
	}),
	getters: {
		getEngine() {
			return this.engine
		},
		getOpenMicPhone() {
			return this.openMicrophone
		}
	},
	actions: {
		async initEngine() {
			this.engine =  await RtcEngine.create(APPID)
			this.addListeners()
			await this.engine.enableAudio()
			await this.engine.setChannelProfile(ChannelProfile.LiveBroadcasting)
			await this.engine.setClientRole(ClientRole.Broadcaster)
		},
		addListeners() {
			this.engine.addListener('JoinChannelSuccess', (channel, uid, elapsed) => {
				console.info('JoinChannelSuccess', channel, uid, elapsed)
			});
			this.engine.addListener('LeaveChannel', (stats) => {
				console.info('LeaveChannel', stats)
			});
		},
		async getToken (channel, uId) {
			const res = await getAgoraToken({
				channel_name: channel,
				id: uId
			})
			if (res.errcode === 0) {
				return res.data.token
			} else {
				uni.showToast({
					title: res.msg,
					icon: 'none'
				})
			}
		},
		async joinChannel(channel, uId) {
			if (uni.getSystemInfoSync().platform === 'android') {
				await permision.requestAndroidPermission('android.permission.RECORD_AUDIO');
			}
			if (!this.engine) {
				await this.initEngine()
			}
			const token = await this.getToken(channel, uId)
			await this.engine.joinChannel(token, channel, null, uId)
		},
		async leaveChannel() {
			await this.engine && this.engine.leaveChannel()
		},
		switchMicrophone() {
			this.engine && this.engine.enableLocalAudio(!this.openMicrophone)
				.then(() => {
					this.openMicrophone = !this.openMicrophone;
				})
				.catch((err) => {
					console.warn('enableLocalAudio', err);
				});
		},
		switchSpeakerphone() {
			this.engine && this.engine.setEnableSpeakerphone(!this.enableSpeakerphone)
				.then(() => {
					this.enableSpeakerphone = !this.enableSpeakerphone;
				})
				.catch((err) => {
					console.warn('setEnableSpeakerphone', err);
				});
		}
	}
})

腾讯云语音识别

关于在uniapp中使用腾讯云实时语音识别,我多说两句,这个可真是拉了大胯了。这也是我在开头所说,在选用uniapp的时候一定要事先了解插件是否有支持的版本也就是技术的可行性分析,不然会遇到我这样的问题。

遇到的问题

uniapp打包成app的时候无法使用腾讯云实时语音识别(暂时没找到办法,如果有人找到方法可以评论给我)

尝试方法一:使用插件市场的腾讯云语音识别插件 地址

结果:不支持流式语音识别

尝试方法二:采集音频流数据给腾讯云获取识别结果

阻碍:uniapp在app端不支持frameSize设置和onFrameRecorded回调,拿不到音频流数据

image.png 结果:失败

尝试方法三:通过声网拿音频原始数据,发给腾讯云获取识别结果,声网react native文档

阻碍:声网在其他平台(原生安卓和ios)都提供了获取因识别原始数据的事件,但是react native的api里面并没有找到,他们只封装了主要的功能 原生安卓文档截图 image.png 结果:失败

最终解决方案

最后我只好使用h5实现这一部分功能,用webview内嵌到app里

webview需要注意的地方

  • 由于浏览器限制,web端无法在页面加载的时候打开音频权限,但是我们的功能要求是进入页面就要开始识别。最后发现在使用远程页面(单独一个地址)的时候不能页面加载的时候开始识别,但是在uniapp本地加载html页面的时候是可以的。最后我想了一下应该是uniapp项目本身在进入页面的时候调声网已经获取了音频权限,所以本地的html不用再重复获取音频权限了,本地的html需要放在hybrid/html文件夹下

image.png

  • 因为语音识别功能是在html页面里,座椅想要使用这个功能必须在html加载之后,但是有的时候我们需要在不展示页面的情况下依然开启识别,这样我们就需要在页面中使用webview组件并将webview的大小设置成0。(不知道是否合理现在是这样处理的)
<web-view :src="webViewUrl" :webview-styles="{width: '0', height: '0'}"></web-view>
  • 使用webview就避免不了与app端的通信,这一点可以看文档,注意,在h5中使用uni api需要引用sdk,cdn地址

关于构建

我这边使用的是云构建,在测试阶段可以使用公共的测试证书,真正发布的时候咱们需要自己生成证书,并且在构建之前在manifest.json中配置好logo,启动图,以及一些用到的模块等。之后我会琢磨琢磨自动化构建相关的内容。

结束语

这个项目还没做完,可能还会遇到更多的问题,先记录到这,其中如果有错误望指正。另外我提的一些问题如果有哪位老哥有解决方案也可以直接在评论区留言,抱拳了!