uni-app(二):体验心得

2,479 阅读4分钟

本篇将带来我在实际开发中总结的一些经验。

前方大量代码预警❗️❗️❗️❗️❗️❗️

工具封装

项目中一些通用方法的封装,由于项目时间紧,代码不够优雅,主要是分享封装的思路,欢迎大家进行优化~🎉

在项目根目录创建 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 进行补充、指正。

欢迎点赞、收藏、订阅专栏。🎉 🎉 🎉