本篇将带来我在实际开发中总结的一些经验。
前方大量代码预警❗️❗️❗️❗️❗️❗️
工具封装
项目中一些通用方法的封装,由于项目时间紧,代码不够优雅,主要是分享封装的思路,欢迎大家进行优化~🎉
在项目根目录创建 utils 文件夹,然后新建 index.js 文件,用于将部分工具方法挂载到全局:
/* /utils/index.js */
// 项目根目录下自定义的全局变量文件
// 自定义全局变量 & 读取开发/生产配置文件 然后将数据export
import config from "@/config.js"
// 封装的请求方法
import {
request,
showErrorMsg,
showSuccessMsg,
toLogin,
LoginPersistence
} from './request.js'
// 文件下载类
import {
Downloader
} from './downloader.js'
const install = function(Vue, options) {
let utils = {
config,
request,
showErrorMsg,
showSuccessMsg,
toLogin,
// 登录持久化
loginPersistence: new LoginPersistence(),
downloader: new Downloader()
}
// 挂载到 uni 对象,供全局调用
uni.$myUtils = utils
// Vue.prototype.$myUtils = utils
}
// 导出,可以在 main.js 通过 Vue.use 初始化
export default {
install
}
接口请求封装
utils 目录下新建 request.js 文件
/* /utils/request.js */
import config from "@/config.js"
const loginPath = config.loginPath
// 后端接口模块化管理
// 将后端接口定义成对象 进行维护
// 对应到 request 方法传入的 api 参数
export const sysConfig = {
app: 'cloudStorage',
path: '/api/v2/defaultConfig/system/get',
method: 'get'
}
export const request = function({
api,
params,
header = {},
needLoading = true,
loadingText = '加载中...',
timeout = 60000,
successCb,
failCb,
completeCb
}) {
// 项目中存在多个后端服务 所以通过接口对象模块化维护 定义app字段
// 区分每个接口应该拼接的后端服务地址
const apiApp = api.app || 'cloudStorage'
const options = {
url: config.baseUrl[apiApp] + api.path,
data: params,
method: api.method.toUpperCase() || 'GET',
dataType: 'json',
responseType: api.responseType,
// contentType: 'json',
// 自定义请求头
header: {
'X-Requested-With': 'XMLHttpRequest', // 标记ajax的异步请求
'CS-PLATFORM': config.VUE_APP_PLATFORM, // 平台标示
'CS-NETWORKENV': config.VUE_APP_NETWORK_ENVRIOMENT, // 网络环境
...header
},
timeout,
// h5 端开启跨域,该字段只支持 h5 平台
withCredentials: true,
enableCookie: true
}
// app端手动设置请求头cookie
// #ifdef APP-PLUS || MP-WEIXIN
if (uni.$myUtils.loginPersistence.cookieValue) {
options.header['Cookie'] = uni.$myUtils.loginPersistence.cookieValue
}
// #endif
if (options.method === 'POST') {
options.header['content-type'] = api.dataType || 'application/json'
}
if (needLoading) {
uni.showLoading({
title: loadingText
})
}
// uni.request 有两种调用返回值
// 当至少传入一个回调函数时 会返回一个 requestTask 对象 此时可以通过 requestTask 对象 中断请求
// 不传入回调 返回 Promise 对象
if (successCb || failCb || completeCb) {
// 至少传入了一个自定义回调函数 返回 requestTask 对象
return uni.request({
...options,
success: (res) => {
if (successCb && typeof successCb === 'function') {
successCb(res)
} else {
// 默认成功回调
uni.showToast({
title: '成功!'
})
}
},
fail: (err) => {
// 错误码统一处理
handleStatusCode(err)
if (failCb && typeof failCb === 'function') {
failCb(err)
} else {
// 默认失败回调
uni.showToast({
icon: 'error',
title: '失败!'
})
}
},
complete: () => {
if (needLoading) {
uni.hideLoading()
}
if (completeCb && typeof completeCb === 'function') {
completeCb()
} else {
}
}
})
} else {
// 没有传入自定义回调函数 返回封装后的 Promise 对象
// 需要在外部进行链式调用
return new Promise((resolve, reject) => {
uni.request({
...options
}).then((data) => {
if (needLoading) {
uni.hideLoading()
}
// data为一个数组
// 数组第一项为错误信息 即为 fail 回调
// 第二项为返回数据
let [err, res] = data
if (err) {
handleResult(err)
reject(err)
} else {
handleResult(res)
const resultCode = res.statusCode
const successCode = [200]
const errorCode = [400, 401, 500]
if (successCode.includes(resultCode)) {
resolve(res)
} else if (errorCode.includes(resultCode)) {
handleStatusCode(res)
reject(res)
} else {
// 业务代码中处理异常
reject(res)
}
}
})
})
}
}
function handleResult(data) {
// app端 保存cookie
// #ifdef APP-PLUS || MP-WEIXIN
if (data.cookies.length) {
let sessionCookie = data.cookies.filter((data) => {
return data.indexOf('SESSION=') !== -1
})
uni.$myUtils.loginPersistence.setCookie(sessionCookie[0])
}
// #endif
}
function handleStatusCode(error) {
let statusCode = error.statusCode
let resData = error.data
switch (statusCode) {
case 400:
if (resData.errorCode && resData.errorCode === '400sec-core-301') {
// 特殊错误码 退出登录
toLogin()
} else {
showErrorMsg(resData)
}
break;
case 401:
toLogin()
break;
case 500:
showErrorMsg(resData)
break;
default:
break;
}
}
export function showErrorMsg(data) {
let msg = data.errorMessage || data.message || '请求异常!'
uni.showToast({
title: msg,
icon: "error",
duration: 2000
})
}
export function showSuccessMsg(msg = '成功!') {
uni.showToast({
title: msg,
duration: 2000
})
}
export function toLogin() {
uni.$myStore.commit('SET_isSignedIn', false)
uni.$myRoute.router({
url: uni.$myUtils.config.loginPath,
type: 'redirectTo'
})
uni.$myUtils.loginPersistence.clearCookie()
}
// cookie 管理类 用于保存cookie 实现非 h5 平台的登录持久化
export class LoginPersistence {
constructor(options = {}) {
this.key = 'cookie_key'
this.cookieValue = this.getCookie()
}
setCookie(value = '') {
this.cookieValue = value
uni.setStorageSync(this.key, this.cookieValue)
}
getCookie() {
let value = uni.getStorageSync(this.key) || ''
return value
}
clearCookie() {
this.setCookie()
}
}
app 自动更新
通过调用后端接口,查看是否有新版本 app 安装包,如果有,则进行下载、安装。
需要先在 manifest.json 文件中配置安装应用权限:
- "app-plus" - "distribute" - "android" - "permissions" 添加:
"<uses-permission android:name=\"android.permission.INSTALL_PACKAGES\"/>",
后端接口:
export const getAppVersion = {
app: 'cloudStorage',
path: '/api/clientVersion/get',
method: 'get'
}
// 接口模块划维护的获取版本号接口对象 具体内容如上
import {
getAppVersion
} from '@/common/apis/appConfig/appConfig.js'
import utils from '@/utils/utils.js'
export function checkVersion() {
plus.runtime.getProperty(plus.runtime.appid, function(widgetInfo) {
const version = widgetInfo.version
const versionCode = parseInt(widgetInfo.versionCode)
uni.$myUtils.request({
api: getAppVersion,
params: {
clientType: 1
},
needLoading: false
}).then((res) => {
let isNeedUpdate = false
let isForceUpdate = false
// 获取的服务端数据
const latestVersion = res.data.versionName || '0.0.0'
const versionDesc = res.data.versionDesc
const updateUrl = res.data.updateUrl
const latestVersionCode = res.data.versionCode || 0
const isForce = res.data.isForce
const lastForceVersion = res.data.lastForceVersion || 0
// 项目配置了是否强制更新
if (versionCode < latestVersionCode && (isForce === 1 || versionCode < lastForceVersion)) {
// 本地版本号小于服务端版本号 并且 (为强制更新 或者 非强制更新时本地版本号小于最小强制更新版本号) 需要强制更新
isForceUpdate = true
isNeedUpdate = true
}
if (versionCode < latestVersionCode && isForce === 0) {
// 本地版本号小于服务端版本号 并且 不是强制更新
isForceUpdate = false
isNeedUpdate = true
}
if (isNeedUpdate) {
utils.showModal({
title: '新版本提示',
content: `发现新的应用安装包(${latestVersion}),点击确定立即更新!`,
showCancel: !isForceUpdate,
success: function(res) {
if (res.confirm) {
downloadApp(updateUrl)
} else if (res.cancel) {}
}
})
} else {}
})
})
}
function downloadApp(downloadUrl) {
uni.showLoading({
title: '下载更新中...'
})
uni.downloadFile({
// 存放最新安装包的地址
url: downloadUrl,
success: (downloadResult) => {
uni.hideLoading()
if (downloadResult.statusCode === 200) {
uni.showLoading({
title: '安装更新中...'
})
plus.runtime.install(
downloadResult.tempFilePath, {
force: false
},
function() {
uni.hideLoading()
plus.runtime.restart()
},
function(e) {
uni.hideLoading()
uni.showToast({
title: '安装失败!',
icon: "error",
duration: 2000
})
}
)
} else {
}
}
})
}
app 端自定义下载保存路径
需要先在 manifest.json 文件中配置获取手机存储权限:
- "app-plus" - "distribute" - "android" - "permissions" 添加:
"<uses-permission android:name=\"android.permission.READ_EXTERNAL_STORAGE\"/>",
"<uses-permission android:name=\"android.permission.WRITE_EXTERNAL_STORAGE\"/>",
- "app-plus" - "distribute" - "android" - "permissionExternalStorage" 配置:
"permissionExternalStorage" : {
"request" : "once",
"prompt" : "应用保存数据等信息,需要获取读写手机存储(系统提示为访问设备上的照片、媒体内容和文件)权限,请允许。"
}
通过业务代码调用 getPath 方法,打开本地目录选择界面,进行自定义目录,当触发下载时,将用户自定义的目录传入下载方法。
// uni-app 暂不支持自定义保存路径为系统公共目录 需要借用native能力才能实现
// 配置获取存储权限 持久化保存自定义路径
getPath() {
return new Promise((resolve, reject) => {
let CODE_REQUEST = 1000
let that = this
let main = plus.android.runtimeMainActivity()
if (plus.os.name == 'Android') {
let Intent = plus.android.importClass('android.content.Intent')
let intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
// intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
main.onActivityResult = function(requestCode, resultCode, data) {
if (requestCode == CODE_REQUEST) {
let uri = data.getData()
plus.android.importClass(uri)
let path = uri.getPath()
that.handlePath(path)
resolve(that.savePath)
}
}
main.startActivityForResult(intent, CODE_REQUEST)
}
})
}
handlePath(path) {
console.log('handleDownloadPath path', path)
// this.absoluteDownloadPath = plus.io.convertLocalFileSystemURL(path)
// console.log('this.absoluteDownloadPath', this.absoluteDownloadPath)
let prefixPath = plus.io.convertLocalFileSystemURL('_downloads').split('/0/Android/data')[0]
let array = path.split('/tree/primary:')
console.log('handleDownloadPath array', array)
let relativePath = array[1]
let fullPath = `${prefixPath}/0/${relativePath}/`
this.setSavePath(fullPath)
}
setSavePath(path = '') {
this.savePath = path
uni.setStorageSync('downloadSavePath', path)
}
getSavePath() {
let value = uni.getStorageSync('downloadSavePath') || ''
return value
}
clearSavePath() {
this.setSavePath()
}
以下是部分下载文件的代码,仅支持 app 端:
// 获取用户自定义的保存路径 或者 默认的保存路径
let fullDownloadPath = 'file://' + (this.savePath || Downloader.downloadPath)
let filename = fullDownloadPath + fileName // 利用保存路径,实现下载文件的重命名
let dtask = plus.downloader.createDownload(url, {
filename
}, function(d, status) { // d为下载的文件对象 status下载状态
console.log('createDownload 下载成功', d)
if (status === 200) {
uni.$myUtils.showSuccessMsg("下载成功")
// d.filename是文件在保存在本地的相对路径,使用下面的API可转为平台绝对路径
let fileSaveUrl = plus.io.convertLocalFileSystemURL(d.filename)
console.log('fileSaveUrl', fileSaveUrl)
} else {
uni.$myUtils.showErrorMsg({
message: "下载失败"
})
console.log('dtask.state 下载失败', dtask.state)
uni.$myStore.commit('SET_DOWNLOAD_fileState', {
guid: file.guid,
stateCode: 'error',
state: '失败',
stateText: '文件下载失败'
})
// plus.downloader.clear() // 清除下载任务?
}
})
dtask.addEventListener('statechanged', this.onStateChanged.bind(this, file), false)
dtask.start() //启用
踩坑心得
接下来是一些兼容性为主的问题梳理。
h5 平台
开发环境跨域
h5 在本地开发环境,会因为跨域,导致 cookie 无法保存、获取,系统无法登录等问题。
解决方法:
设置本地代理。
// manifest.json 文件的 h5 节点做如下设置
// 然后业务代码中进行 request 请求时,接口前拼接 “/api”
"h5" : {
"devServer" : {
"port" : 8088, // 浏览器运行端口
"disableHostCheck" : true, // 设置跳过host检查
"proxy" : {
"/api" : {
"target" : "http://172.16.18.49:29901", // 目标接口域名
"changeOrigin" : true, // 是否跨域
"secure" : false, // 设置支持https协议的代理
"pathRewrite" : {
"^/api" : ""
}
}
}
}
},
Android 平台
video 标签播放视频失败
video 标签 src 属性在安卓上传入远程地址,部分视频因编码问题可能会播放失败。
解决方法:
通过 uni.downloadFile 下载视频,获取到资源临时路径,src 属性传入临时路径,进行播放。
web-view 标签加载第三方页面不够灵活
项目中存在的场景是文档预览时,需要添加水印、进行双指缩放。然后后端接口返回的只是文件内容解析后生成的标准的 html 文档字符串。
解决方法:
在项目根目录新建 hybrid 目录,并在目录下新建 html 目录,然后可以在 html 目录下新建一个 [name].html 文件。因为 app 端的 web-view 标签是可以加载本地 html的。
这样就可以在本地 html 文件中编写代码,灵活度很高。
平板适配
项目在打包成 apk 后,需要兼容平板终端,所以就存在宽屏适配、跟随旋转屏幕等问题。
我们项目的平板是需要兼容到宽度为 500px 的设备(就是一些小型号的平板😓),一般默认都是 960px 宽度。
解决方法:
- 配置
pages.json文件
// "globalStyle" 节点做如下配置
"rpxCalcMaxDeviceWidth": 500,
"pageOrientation": "auto"
- 监听屏幕旋转,定义全局变量,(配合媒体查询)进行样式适配
Vue.prototype.isLandscape = false
// #ifdef APP-PLUS
// 判断是否是平板
// 具体宽度看情况 我们项目是需要兼容到 500px
const padWidth = 500
uni.getSystemInfo({
success: (res) => {
console.log("屏幕尺寸:", res)
if (res.windowWidth >= padWidth) {
// 平板
setInterval(() => {
const orit = plus.navigator.getOrientation()
if (orit === 90 || orit === -90) {
Vue.prototype.isLandscape = true
} else {
Vue.prototype.isLandscape = false
}
}, 500)
// 横屏正向
// plus.screen.lockOrientation('landscape-primary')
} else {
// 手机 固定竖屏正向
plus.screen.lockOrientation('portrait-primary')
Vue.prototype.isLandscape = false
}
}
})
// #endif
微信小程序
真机上传文件 formdata 异常导致报错
这是因为 uni.uploadFile API 默认添加 file 字段,不需要在 formData 参数里手动添加 file 字段。
iOS保存视频到相册不成功(无效的资源)
通过 uni.downloadFile + wx.saveVideoToPhotosAlbum 保存视频时,uni.downloadFile 需要传入具体的 filePath 参数,不能是临时路径。
比如:
const filePath = `${wx.env.USER_DATA_PATH}/${fileName}${new Date().valueOf()}.${fileType}`
其他
总结一些开发中需要注意的细节事项。
pages.josn 中的条件编译
需要注意逗号,多余的逗号会导致编译失败。
比如:
"globalStyle": {
"backgroundColor": "#F8F8F8",
"app-plus": {
"background": "#efeff4"
}
// #ifdef APP-PLUS
// 这个逗号需要写到条件编译内部
// 否则在非 app 平台,json文件会多一个逗号,编译失败
,
"rpxCalcMaxDeviceWidth": 500,
"pageOrientation": "auto"
// #endif
}
iPhone底部内容安全距离
除了 iOS 内置的 css 变量,还可以通过元素占位实现底部安全距离适配。
核心代码:
function safeArea() {
const {
statusBarHeight,
windowWidth,
windowHeight,
windowTop,
safeArea,
screenHeight,
safeAreaInsets
} = uni.getSystemInfoSync()
const safeWidth = windowWidth
const safeHeight = windowHeight + (windowTop || 0)
// 安全的 padding-bottom 数值
// 设置为占位元素的高,将元素置于页面底部
let newSafeAreaInsets
// TODO fix by mehaotian 是否适配底部安全区 ,目前微信ios 、和 app ios 计算有差异,需要手动修复
if (safeArea) {
// #ifdef MP-WEIXIN
newSafeAreaInsets = screenHeight - safeArea.bottom
// #endif
// #ifndef MP-WEIXIN
newSafeAreaInsets = safeAreaInsets.bottom
// #endif
} else {
newSafeAreaInsets = 0
}
return {
paddingBottom: newSafeAreaInsets,
screenHeight,
safeHeight,
statusBarHeight
}
}
结语
以上,就是本人在 uniapp 项目开发过程中,总结的大部分经验心得,会有遗漏,后期想起来,随时都会更新。也欢迎 jym 进行补充、指正。
欢迎点赞、收藏、订阅专栏。🎉 🎉 🎉