发表时间:2022-03-24来源:网络
微信小程序开发须要基于微信提供的开发者工具与 SDK。若是开发者对小程序开发流程不熟悉,建议先系统学习:微信小程序开发官方文档。php
因为微信官方文档比较详细,本文对小程序开发流程中的框架说明、API 调用、组件使用等,再也不赘述,而是重点介绍如何使用 ZEGO SDK 开发出支持音视频直播的微信小程序。css
SDK 集成指引详见:微信小程序 SDK集成指引html
SDK 提供的 API 说明详见:微信小程序 SDK API 说明java
小程序开发主要用到 web 开发知识( js、html、css )。mysql
一、组件说明web
微信小程序中的推拉流功能,须要用到微信提供的 live-player live-pusher 标签。其余的常规组件同原生 App 开发相似,此处不一一介绍。算法
1.1 live-playersql
live-player 是微信提供的支持实时音视频播放的组件,官方介绍详见组件介绍。apache
开发者建立组件成功后,须要在 js 文件中,调用 API 操做对应的组件来实现需求,微信官方 API 详见 API 说明。json
即构音视频云小程序中,建立 live-player 的演示源码以下:
ZegoLive/pages/liveroom/room/room.wxml {{item.streamID}}请注意:
live 模式主要用于直播类场景,好比赛事直播、在线教育、远程培训等等。该模式下,小程序内部的模块会优先保证观看体验的流畅,经过调整min-cache 和 max-cache 属性,您能够调节观众(播放)端所感觉到的时间延迟的大小,文档下面会详细介绍这两个参数。
RTC 则主要用于双向视频通话或多人视频通话场景,好比金融开会、在线客服、车险定损、培训会议 等等。在此模式下,对 min-cache 和 max-cache 的设置不会起做用,由于小程序内部会自动将延迟控制在一个很低的水平(500ms 左右)。
1.2 live-pusher
live-pusher 是微信提供的支持实时音视频录制的组件,官方介绍详见:组件介绍
开发者建立组件成功后,须要在 js 文件中,调用 API 操做对应的组件来实现需求,官方 API 详见:API 说明
即构音视频云小程序中,建立 live-pusher 的演示源码以下:
ZegoLive/pages/liveroom/room/room.wxml {{isPublishing ? "我(" + publishStreamID + ")": ""}}请注意:
SD、HD 和 FHD 主要用于直播类场景,好比赛事直播、在线教育、远程培训等等。SD、HD 和 FHD二、实现流程
2.1 推流 API 调用流程图
详细讲解请参考 2.6 小节。
2.2 拉流 API 调用流程图
2.3 初始化 SDK
2.4 获取登陆 token
登陆 token 的获取详见后文 安全方案 中的 房间登陆安全 小节。
即构小程序中演示源码片断以下:
ZegoLive/pages/liveroom/room/room.js // 获取登陆 token getLoginToken: function () { var self = this; const re = wx.request({ url: 'xxxx', // 该接口由开发者后台自行实现,开发者的 Token 从各自后台获取 data: { app_id: self.data.appID, id_name: self.data.userID, }, header: { 'content-type': 'text/plain' }, success: function (res) { console.log(">>>[liveroom-room] get login token success. token is: " + res.data); if (res.statusCode != 200) { return; } zg.setUserStateUpdate(true); self.loginRoom(res.data, self); }, fail: function (e) { console.log(">>>[liveroom-room] get login token fail, error is: ") console.log(e); } }); },2.5 登陆房间
登陆房间成功是后续全部操做的前提。即构音视频云小程序中演示源码片断以下,仅供参考:
ZegoLive/pages/liveroom/room/room.js zg.login(self.data.roomID, self.data.loginType == "anchor" ? 1 : 2, token, function (streamList) { // 登陆成功处理 console.log('>>>[liveroom-room] login succeeded'); }, function (err) { // 登陆失败处理 console.log('>>>[liveroom-room] login failed, error is: ', err); });登陆错误码列表以下:
2.6 单主播直播
单主播直播时,一个房间内仅有一个主播,主播不会与观众进行互动。
2.6.1 开始推流
主播登陆房间成功后,根据业务逻辑准备推流。使用 SDK 推流播放须要遵循以下步骤:
触发推流
调用 SDK 的 startPublishingStream 获取 streamID 对应的推流地址
在 SDK 的回调 onStreamUrlUpdate 中获推流地址
调用微信小程序的 wx.createLivePusherContext 建立 live-pusher ,将步骤 3 中获取的推流地址设置为
live-pusher 的 url,而后调用 live-pusher 的 start() 录制视频
演示源码片断以下,仅供参考:
ZegoLive/pages/liveroom/room/room.js // 1/2. 主播登陆房间成功后触发推流,调用 SDK 的 startPublishingStream 获取 streamID 对应的推流地址 zg.login(self.data.roomID, self.data.loginType == "anchor" ? 1 : 2, token, function (streamList) { // 主播登陆成功即推流 if (self.data.loginType == 'anchor') { console.log('>>>[liveroom-room] anchor startPublishingStream, publishStreamID: ' + self.data.publishStreamID); zg.setPreferPublishSourceType(1); // 0:推流到 CDN,观众拉流延迟在 2 秒左右;1:推流到 ZEGO 服务器,延迟在 400ms 左右 zg.startPublishingStream(self.data.publishStreamID, ''); } }, function (err) { console.log('>>>[liveroom-room] login failed, error is: ', err); }); // 3. 在 SDK 的回调 onStreamUrlUpdate 中获取推流地址 // type: {play: 0, publish: 1}; zg.onStreamUrlUpdate = function (streamid, url, type) { console.log(">>>[liveroom-room] zg onStreamUrlUpdate, streamId: " + streamid + ', type: ' + (type == 0 ? 'play' : 'publish') + ', url: ' + url); ... }; // 4. 调用微信小程序的 wx.createLivePusherContext 建立 live-pusher ,将步骤 3 中获取的推流流地址设置为 live-player 的 url,而后调用 live-pusher 的 start 录制视频 setPushUrl: function (url) { console.log('>>>[liveroom-room] setPushUrl: ', url); var self = this; ... self.setData({ pushUrl: url, pusherVideoContext : wx.createLivePusherContext("video-livePusher", self), }, function () { self.data.pusherVideoContext.stop(); self.data.pusherVideoContext.start(); }); },2.6.2 开始拉流
观众登陆房间成功后,根据业务逻辑准备拉流。使用 SDK 拉流播放须要遵循以下步骤:
触发拉流
调用 SDK 的 startPlayingStream 获取 streamID 对应的播放地址
在 SDK 的回调 onStreamUrlUpdate 中获取拉流地址
调用微信小程序的 wx.createLivePlayerContext 建立 live-player ,将步骤 3 中获取的推流地址设置为
live-player 的 src,而后调用 live-player 的 play() 播放视频。此步骤也能够设置 live-player
为 autoplay,此时播放器会自动播放,无需再手动调用 play()。
演示源码片断以下,仅供参考:
ZegoLive/pages/liveroom/room/room.js // 1. 登陆后拉流 zg.login(self.data.roomID, self.data.loginType == "anchor" ? 1 : 2, token, function (streamList) { // 房间内已经有流,拉流 self.startPlayingStreamList(streamList); }, function (err) { console.log('>>>[liveroom-room] login failed, error is: ', err); }); // 2. 经过 SDK 获取 streamID 对应的播放地址 startPlayingStreamList: function (streamList) { var self = this; ... // 设置拉流目标地址,可选,0:auto;1:从 bgp 拉流 zg.setPreferPlaySourceType(1); // 获取每一个 streamID 对应的拉流 url var playStreamList = self.data.playStreamList; for (var i = 0; i < streamList.length; i++) { var streamID = streamList[i].stream_id; // 调用 SDK 的 startPlayingStream 获取 streamID 对应的播放地址 zg.startPlayingStream(streamID); } }, // 3. 在 SDK 的回调 onStreamUrlUpdate 中获取拉流地址 // type: {play: 0, publish: 1}; zg.onStreamUrlUpdate = function (streamid, url, type) { console.log(">>>[liveroom-room] zg onStreamUrlUpdate, streamId: " + streamid + ', type: ' + (type == 0 ? 'play' : 'publish') + ', url: ' + url); ... }; // 4. 调用微信小程序的 wx.createLivePlayerContext 建立 live-player ,将步骤 3 中获取的拉流地址设置为 live-player 的 src,而后调用 live-player 的 play() 播放视频。此步骤也能够设置 live-player 为 autoplay,此时播放器会自动播放,无需再手动调用 play() setPlayUrl: function (streamid, url, self) { ... // 相同 streamid 的源不存在,建立新 player if (!isStreamRepeated) { streamInfo['streamID'] = streamid; streamInfo['playUrl'] = url; streamInfo['playContext'] = wx.createLivePlayerContext(streamid, self); self.data.playStreamList.push(streamInfo); } ... self.setData({ playStreamList: self.data.playStreamList, }, function(){}); },2.6.3 拉流、推流事件处理
微信小程序会在 live-player 和 live-pusher 的 bindstatechange 绑定的方法中通知出推拉流状态事件,开发者须要:
在 bindstatechange 绑定的回调函数中,调用 SDK 提供的 API updatePlayerState 将推流事件透传给 SDK
在 SDK 提供的 onPlayStateUpdate onPublishStateUpdate 回调中处理播推、拉流的开始、失败状态
演示源码片断以下,仅供参考:
ZegoLive/pages/liveroom/room/room.js // live-player 绑定的拉流事件 onPlayStateChange(e) { // 透传拉流事件给 SDK,type 0 拉流 zg.updatePlayerState(e.currentTarget.id, e, 0); }, // 服务端主动推过来的 流的播放状态, 视频播放状态通知;type: { start:0, stop:1}; zg.onPlayStateUpdate = function (updatedType, streamID) { console.log(">>>[liveroom-room] zg onPlayStateUpdate, " + (updatedType == 0 ? 'start ' : 'stop ') + streamID); }; // live-pusher 绑定推流事件 onPushStateChange(e) { // 透传推流事件给 SDK,type 1 推流 zg.updatePlayerState(this.data.publishStreamID, e, 1); }, // 推流后,服务器主动推过来的,流状态更新;type: { start: 0, stop: 1 },主动中止推流没有回调,其余状况均回调 zg.onPublishStateUpdate = function (type, streamid, error) { console.log('>>>[liveroom-room] zg onPublishStateUpdate, streamid: ' + streamid + ', type: ' + (type == 0 ? 'start' : 'stop') + ', error: ' + error); };另外,小程序会在 live-player 和 live-pusher 的 bindnetstatus 绑定的方法中通知出推拉流网络事件,开发者也须要在对应的小程序回调中,调用 updatePlayerNetStatus 将推流事件透传给 SDK。
演示源码片断以下,仅供参考:
// live-player 绑定网络状态事件 onPlayNetStateChange(e) { // 透传网络状态事件给 SDK,type 0 拉流 zg.updatePlayerNetStatus(e.currentTarget.id, e, 0); }, // SDK 拉流网络质量回调 zg.onPlay = function (streamId, stream) { ... }, // live-pusher 绑定网络状态事件 onPushNetStateChange(e) { //透传网络状态事件给 SDK,type 1 推流 zg.updatePlayerNetStatus(this.data.publishStreamID, e, 1); }, // SDK 推流网络质量回调 zg.onPublish = function (streamId, stream) { ... },2.6.4 中止推、拉流
中止推拉流,开发者须要:
调用 SDK 提供的 stopPublishingStream(streamid) stopPlayingStream(streamid) 清空推、拉流状态
调用 live-pusher 和 live-player 提供的 stop() 中止推、拉流
请注意,上述第 1 点必定要处理,不然可能致使 SDK 状态异常!
演示源码片断以下,仅供参考:
// 中止拉流 zg.stopPlayingStream(this.data.playStreamList[i]['streamID']); this.data.playStreamList[i]['playContext'] && this.data.playStreamList[i]['playContext'].stop(); // 中止推流 zg.stopPublishingStream(this.data.publishStreamID); this.data.pusherVideoContext.stop();2.7 多主播直播
多主播直播是主播与观众连麦,使观众也成为主播的互动功能。视频会议也可经过该模式实现。多主播直播较单主播直播多出了连麦信令交互过程。推流、拉流流程同单主播基本一致,本节再也不赘述。
2.7.1 连麦交互
观众申请连麦交互的主要步骤是:
观众申请连麦,SDK 接口: re
主播收到观众的申请连麦,SDK 接口: onRecvJoinLiveRequest
主播赞成/拒绝连麦,SDK 接口: respondJoinLive
观众或主播结束连麦,SDK 接口: endJoinLive
主播邀请连麦交互的主要步骤是:
主播邀请观众连麦,SDK 接口: inviteJoinLive
观众收到主播的邀请连麦,SDK 接口: onRecvJoinLiveRequest
观众赞成/拒绝连麦,SDK 接口: respondJoinLive
主播或观众结束连麦,SDK 接口: endJoinLive
开发者可按需调用 SDK 连麦相关 API。即构音视频云小程序中演示源码片断以下,仅供参考:
ZegoLive/pages/liveroom/room/room.js // 观众请求连麦 re: function () { var self = this; // 观众正在连麦时点击,则结束连麦 if (self.data.isPublishing) { zg.endJoinLive(self.data.anchorID, function(result, userID, userName) { console.log('>>>[liveroom-room] endJoinLive, result: ' + result); }, null); // 中止推流 zg.stopPublishingStream(this.data.publishStreamID); this.setData({ isPublishing: false, publishTitle : "未推流", }); this.data.pusherVideoContext.stop(); return; } // 观众未连麦,点击开始推流 console.log('>>>[liveroom-room] audience re'); zg.re(this.data.anchorID, null, null, function(result, userID, userName) { console.log('>>>[liveroom-room] re, result: ' + result); if (result == false) { wx.showToast({ title: '主播拒绝连麦', icon: 'none', duration: 2000 }) } else { wx.showToast({ title: '主播赞成连麦,准备推流', icon: 'none', duration: 2000 }); // 主播赞成连麦后,观众开始推流 console.log('>>>[liveroom-room] startPublishingStream, userID: ' + userID + ', publishStreamID: ' + self.data.publishStreamID); zg.setPreferPublishSourceType(1); // 0:推流到 CDN,观众拉流延迟在 2 秒左右;1:推流到 ZEGO 服务器,延迟在 400ms 左右 zg.startPublishingStream(self.data.publishStreamID, ''); } }, // 收到连麦请求 zg.onRecvJoinLiveRequest = function(re, fromUserId, fromUsername, roomId) { console.log('>>>[liveroom-room] onRecvJoinLiveRequest, roomId: ' + roomId + 're: ' + fromUserId + ', re: ' + fromUsername); var content = '观众 ' + fromUsername + ' 请求连麦,是否容许?'; wx.showModal({ title: '提示', content: content, success: function(res) { if (res.confirm) { console.log('>>>[liveroom-room] onRecvJoinLiveRequest accept join live'); zg.respondJoinLive(re, true); // true:赞成;false:拒绝 } else if (res.cancel) { console.log('>>>[liveroom-room] onRecvJoinLiveRequest refuse join live'); zg.respondJoinLive(re, false); // true:赞成;false:拒绝 } } }); };2.8 混流
混流能够把多路直播的流根据配置信息,输出成一路流。开发者可按需调用 SDK 混流相关 API。演示源码片断以下,仅供参考:
//更新混流配置 updateMixStream: function () { console.log('>>>[liveroom-room] updateMixStream'); var self = this; if (self.data.loginType !== 'anchor') { return; } var width = 360; var height = 640; var mixConfig = {}; //混流输出流ID mixConfig.outputStreamId = "mix-" + self.data.publishStreamID; //混流输出帧率 mixConfig.outputFps = 15; //混流输出码率 mixConfig.outputBitrate = 800 * 1000; //混流输出分辨率 mixConfig.outputWidth = width; mixConfig.outputHeight = height; //混流背景色 mixConfig.outputBgColor = 0xc8c8c800; //混流输入流信息 mixConfig.streamList = []; var margin = 0; if (self.data.isPublishing) { var streamInfo = { streamId: self.data.publishStreamID, top: margin, left: margin, bottom: height - margin, right: width - margin }; mixConfig.streamList.push(streamInfo); } for (var i = 0; i < self.data.playStreamList.length; i++) { var streamId = self.data.playStreamList[i].streamID; var streamInfo = { streamId: streamId, top: 0, left: 0, bottom: 0, right: 0 }; if (mixConfig.streamList.length == 0) { streamInfo.bottom = height; streamInfo.right = width; } else if (mixConfig.streamList.length == 1) { streamInfo.top = parseInt(height * 2 / 3); streamInfo.left = parseInt(width * 2 / 3); streamInfo.bottom = height; streamInfo.right = width; } else if (mixConfig.streamList.length == 2) { streamInfo.top = parseInt(height * 2 / 3); streamInfo.left = 0; streamInfo.bottom = height; streamInfo.right = parseInt(width / 3); } mixConfig.streamList.push(streamInfo); } zg.updateMixStream(mixConfig, function(mixStreamId, mixStreamInfo) { console.log(">>>[liveroom-room] updateMixStream success"); var rtmpUrls = mixStreamInfo["rtmpUrls"]; for (var i = 0; i < rtmpUrls.length; i++) { console.log(">>>[liveroom-room] updateMixStream mix Rtmp: " + rtmpUrls[i]); } wx.showModal({ title: '提示', content: '混流成功', showCancel: false }); }, function(error, info) { console.error(">>>[liveroom-room] updateMixStream error " + error.code); if (info) { for (var i = 0; i < info.length; i++) { console.error(">>>[liveroom-room] input stream not exist " + info[i]); } } wx.showModal({ title: '提示', content: '混流失败', showCancel: false }); }); }, //中止混流 stopMixStream: function () { console.log(">>>[liveroom-room] stopMixStream"); var self = this; var mixConfig = {}; mixConfig.outputStreamId = "mix-" + self.data.publishStreamID; zg.stopMixStream(mixConfig, undefined, undefined); },三、退后台处理
切后台,会中止视频采集,但不会禁用音频采集。开发者能够经过 background-mute 属性来设置是否禁用音频采集。
切后台,会中止视频播放,默认退后台静音。
四、安全方案
4.1 房间登陆安全
4.1.1 基本流程
小程序与业务后台创建通讯,获取 Token 信息。
小程序调用 ZegoClient.login 登陆 Zego 服务器,传入 Token 信息,验证经过后,完成登陆。
ZegoClient 会保持与 Zego 服务器的长链接,处理发送或接收的消息。
小程序调用 ZegoClient.logout 登出 Zego 服务器。
请注意:
生成 Token 信息须要业务后台自行开发。
4.1.2 login_token 信息
login_token 信息为标准 json 格式,具体为:
{ "ver": 1, "hash": xxxxx, "nonce": xxxxx, "expired": xxxxx, }字段说明以下:
对于 hash 计算中使用字段的更详细说明:
请注意:
login_token 传输过程当中,会通过 base64 加密。每次登陆都要从新获取 login_token。
业务方开发的小程序须要和业务后台创建一种安全通信和鉴权机制,业务方使用自有的帐户体系或第三方认证体系的登陆完成后,业务方小程序和业务后台交互获取该
login_token, AppSecret 是存储在业务后台的。
4.2 login_token 生成示例代码
go 语言 login_token 生成示例代码以下:
func makeTokenSample(appid uint32, app_key string, idname string, expired_add int64) (ret string, err error){ nonce := UniqueId() expired := time.Now().Unix() + expired_add //单位:秒 app_key = strings.Replace(app_key, "0x","",-1) app_key = strings.Replace(app_key, ",", "", -1) if len(app_key) < 32 { return "", fmt.Errorf("app_key wrong") } app_key_32 := app_key[0:32] source := fmt.Sprintf("%d%s%s%s%d",appid,app_key_32,idname,nonce,expired) sum := GetMd5String(source) token := tokenInfo{} token.Ver = 1 token.Hash = sum token.Nonce = nonce token.Expired = expired buf, err := json.Marshal(token) if err != nil { return "", err } encodeString := base64.StdEncoding.EncodeToString(buf) return encodeString, nil }php 语言 login_token 生成示例代码以下:
public function getToken(int $app_id, string $app_key, string $idname, int $expired_add) { $nonce = uniqid(); $expired = time() + $expired_add; //单位:秒 $app_key = str_replace("0x", "", $app_key); $app_key = str_replace(",", "", $app_key); if(strlen($app_key) < 32) { return false; } $app_key_32 = substr($app_key, 0, 32); $source = $app_id.$app_key_32.$idname.$nonce.$expired; $sum = md5($source); $tokenInfo = [ 'ver' => 1, 'hash' => $sum, 'nonce' => $nonce, 'expired' => $expired, ]; $token = base64_encode(json_encode($tokenInfo)); return $token; }java 语言 login_token 生成示例代码以下:
package demo; import org.json.JSONObject; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.util.Date; import java.util.UUID; import org.apache.commons.codec.binary.Base64; public class ZegouUtils { public static void main(String[] args) { String appid = "0000000000"; //即构分配的appId String appKey = "0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00"; //即构分配的appKey String idName = "xxxxxxx"; //业务系统用户惟一标识 String Token = getZeGouToken(appid,appKey,idName); System.out.println("--Token--:"+Token); } /** * 拉流端获取登陆token * @param appId 即构分配的appId * @param appKey 即构分配的appKey * @param idName 业务系统用户惟一标识 * @return */ public static String getZeGouToken(String appId,String appKey,String idName){ String nonce= UUID.randomUUID().toString().replaceAll("-", ""); long time=new Date().getTime()/1000+30*60; String appKey32=new String(appKey.replace("0x", "").replace(",", "").substring(0, 32)); System.out.println("appKey:"+time+" "+appKey32+" "+nonce); if(appKey32.length()=0&&= 0) { // 手动补上一个“0” hexString = hexString + "0" + Integer.toHexString(temp); } else { hexString = hexString + Integer.toHexString(temp); } } return hexString; } catch (NoSuchAlgorithmException e) { // TODO Auto-generated catch block e.printStackTrace(); } return ""; } }五、状态码
5.1 推流状态码
onPublishStateUpdate stateCode 含义以下:
其余 4 位错误码均为小程序框架的报错,请参考:小程序官方文档
5.2 拉流状态码
onPlayStateUpdate stateCode 含义以下:
其余 4 位错误码均为小程序框架的报错,请参考:小程序官方文档
六、常见错误
6.1 登陆房间时,控制台输出返回 ZegoClient.Error.Timeout
解决方案: 请检查 初始化配置 ZegoClient.config 里面每一个参数的类型是否正确,通常状况下都是类型不正确引发的问题。
6.2 登陆房间时,控制台输出返回 result=1000001002
缘由: 观众角色不容许建立房间,也就是这个房间不存在。
解决方案:
1)在 ZegoClient.config 里面的 option.audienceCreateRoom 设置为 TRUE
2)在 ZegoClient.login 里面将 role 设置为 1,主播角色。
6.3 登陆房间时,控制台输出返回 token Error 的报错
缘由:计算 token 的时候,算法不对引发,可经过这个连接:http://sig-wstoken.zego.im:8181/tokenindex 来验证是否正确:
首先验证 hash 字段是否有错,这里要注意 id_name 是 string 类型,不是数值类型; expired 是 uninx 的时间戳,不是北京时间。
login_token 由业务侧后台生成后,将 json 里面的字段通过 base64 加密后再下发给小程序端。
详情请参考: 4.1 房间登陆安全
6.4 小程序如何设置分辨率?
微信小程序提供设置清晰度的 mode,没法指定具体的分辨率,码率和帧率,具体可参考:
微信小程序组件 live-pusher
6.5 小程序如何切换摄像头、截图?
微信小程序的 LivePusherContext 里面有提供相关的接口,你们可参考:微信小程序 API LivePusherContext
切换摄像头的示例代码可参考:
七、微信公众平台域名配置
ZEGO 分配给开发者的 URL(包含 HTTPS、WSS 协议),须要在微信公众平台进行“合法域名”配置后,小程序才能正常访问。
微信后台配置地址:微信公众平台 -> 设置 -> 开发设置 -> 服务器域名。
请开发者将 ZEGO 分配的请求域名,按照协议分类,填到指定的 request合法域名 或者 socket合法域名 中。例如:
上一篇:上报服务系统开发(详细源码)
下一篇:Java基础知识梳理
皓盘云建最新版下载v9.0 安卓版
53.38MB |商务办公
ris云客移动销售系统最新版下载v1.1.25 安卓手机版
42.71M |商务办公
粤语翻译帮app下载v1.1.1 安卓版
60.01MB |生活服务
人生笔记app官方版下载v1.19.4 安卓版
125.88MB |系统工具
萝卜笔记app下载v1.1.6 安卓版
46.29MB |生活服务
贯联商户端app下载v6.1.8 安卓版
12.54MB |商务办公
jotmo笔记app下载v2.30.0 安卓版
50.06MB |系统工具
鑫钜出行共享汽车app下载v1.5.2
44.7M |生活服务
2022-03-26
2022-03-26
2022-03-26
2022-03-26
2022-03-26
2022-03-26
2022-03-26
2022-03-26
2022-02-15
2022-02-14