/** * 跨端双向通讯桥 * @author Tevin * @version 1.3.0 * @tutorial * 通讯策略 * 1. App 对网页一般为单向通知,无资源管理要求 * 2. 网页对 App 发起单向通知时,也无资源管理要求 * 3. 网页对 App 发起单次通讯时,如诺 App 未执行回调,前端将在5分钟后字段释放资源 * 4. 网页对 App 发起持续性通讯时,应转换为 App 对网页发起通知,通知无资源管理要求(而不是使用轮询或监听,需要手动停止释放资源) * 5. 网页对 App 发起通讯,两端具体处理方式如下: * - 如果 callback 有值,App 端在各种情况下,都尽量执行回调,以便于前端更快释放资源 * - 如果 callback 无值,即为单向通知,此时 App 无需处理回调(消息提示由 App 端执行) * - 如果 App 出现报错或异常,执行 callback 回调时不传任何内容(不能传空对象或空字符串,直接为空)(错误提醒由 App 执行) * - 如果通讯正常,但是业务为支线流程,例如取消操作,则返回标识表明取消操作(消息提示由网页端执行) * 网页对 App 发起通讯操作步骤 * 1. 【App端】App 注册接收器,用于接收网页通知 * 在页面打开之前,App 注入方法 linking = function(paramStr) { } * 2. 网页业务中 js 发起通讯 * $bridge.invoking('methodName', {key1:'value1'}, function(res) {console.log(res)}); * 3. 由 Bridge 转换为调用注入的方法(含callback) * window.linking('{method:\'methodName\', param:{key1:\'value1\'}, callback:\'bridge.cb111at1541994536008\'}'); * 4. 【App端】App 处理完后直接调用网页上的全局回调,并传递结果完成通讯 * bridge.cb101at1541994536008('{key2:\'value2\'}'); * App 对网页发起通讯操作步骤 * 1. 网页注册接收器,用于接收 App 通知 * window.telling = function(dataStr) { someJs }; * 2. 网页业务中注册接收通讯 * $bridge.register('methodName', function(res, callback) {}); * 3. 【App端】App 调用网页中的全局方法(至少需要在页面完成并延迟至少1秒之后再调用) * telling('{method:\'methodName\', param:{key1:\'value1\'}, marker:\'mk222at1541994536008\'}'); * 4. 网页完成业务逻辑处理后调用注入的方法,并传递结果完成通讯(含marker、不含callback) * window.linking('{method:\'methodName\', param:{key2:\'value2\'}, marker:\'mk222at1541994536008\'}'); */ import { Fetcher } from '@components/bases/Fetcher'; import { Tools } from '@components/common/Tools'; import { $fileTrans } from '@components/common/FileTransform'; export class Bridge { constructor() { this._data = { count: 100, fileSaved: {}, // 已保存图片名称列表 { 'blob:***' : 'bridge:***' } fileLoaded: {}, // 已读取图片名称列表 { 'bridge:***' : 'blob:***' } }; this._receives = {}; this._earlyInvok = []; this._init(); // 挂载到全局 window.bridge = this; } /** * 检查 linking 方法 * @return {string} * @private */ _checkLinking() { // 安卓注入 if (window.aisim && window.aisim.linking) { return 'android'; } // 没有注入 else { return ''; } } /** * 初始化 * @private */ _init(count = 0) { // 500 * 20 毫秒后,仍然未注入,视为不工作 if (!this._checkLinking() && count < 20) { setTimeout(() => { this._init(++count); }, 500); } // 开始工作 else { this._initReceive(); // 补发 if (this._earlyInvok.length > 0) { this._earlyInvok.forEach(invok => { const { method, param, callback } = invok; this._sendLinking(method, param, callback); }); } } } /** * 发起一次发送 * @param {String} method * @param {Object} [param] * @param {Function} [callback] - 来自网页业务逻辑的回调 * @private */ _sendLinking(method, param, callback) { // 数据检查 if (!Tools.isObject(param)) { console.error('$bridge.invoking 需要接受 JSON 对象!'); return; } // 转换发送参数键名为下划线 param = this.transKeyName('underline', param); // 如果无回调,视为单向通知,不全局挂载 // 如果有回调,转存回调 let cbName = ''; if (callback && Tools.isFunction(callback)) { cbName = 'cb' + this._data.count++ + 'at' + Date.now(); // 5分钟后超时,自动清除资源 const timer = setTimeout(() => { // 释放资源 callback = null; this[cbName] = () => { delete this[cbName]; Tools.toast('跨端通讯超时,请关掉页面重试!'); }; }, 5 * 60 * 1000); // 挂载到全局 this[cbName] = res => { // res有值,正常通讯 if (typeof res !== 'undefined') { let data; try { // 转对象 data = typeof res === 'string' ? JSON.parse(res) : res; // 转换接收参数键名为驼峰 data = this.transKeyName('camel', data); } catch (e) { Tools.toast('跨端通讯异常:解析数据失败!'); return; } // 回传业务层,忽略来自业务层的报错 callback(data); } // res 无值,视为 java 层异常,由 java 层进行异常提醒 else { // do nothing } // 执行一次后释放资源 callback = null; delete this[cbName]; clearTimeout(timer); }; } // 发送 window.aisim.linking( JSON.stringify({ method, param, callback: cbName ? 'bridge.' + cbName : '', }), ); } /** * 向 app 发起通讯 * @param {String} method * @param {Object|Function} [param] * @param {Function} [callback] */ invoking(method, param = {}, callback) { // param 无值时 if (!param) { param = {}; } // param 为函数时 else if (param && Tools.isFunction(param)) { callback = param; param = {}; } if (this._checkLinking()) { this._sendLinking(method, param, callback); } // 尚未准备好,挂起 else { this._earlyInvok.push({ method, param, callback }); } } /** * 初始化接收 * @private */ _initReceive() { window.telling = res => { const data = typeof res === 'string' ? JSON.parse(res) : res; const { method, param, marker } = data; // 转换接收参数键名为驼峰 const param2 = this.transKeyName('camel', param); // 已注册协议 if (this._receives[method]) { // 有通知回调 if (marker) { this._receives[method](param2, param2 => { this._sendTelling(method, param2 || {}, marker); }); } // 无通知回调 else { this._receives[method](param2); } } // 未注册的协议 else { console.warn('BridgeTelling:通讯协议【' + method + '】尚未注册!'); } }; } /** * 回发App通知的响应 * @param {string} method * @param {object} param * @param {string} marker * @private */ _sendTelling(method, param, marker) { // 数据检查 if (!Tools.isObject(param)) { console.error('$bridge.register 注册的函数需要接受 JSON 对象!'); return; } // 转换发送参数键名为下划线 param = this.transKeyName('underline', param); // 发送 window.aisim.linking( JSON.stringify({ method, param, marker, }), ); } /** * 注册接收指令,可接收 app 通知 * @param method * @param callback */ register(method, callback) { this._receives[method] = callback; } /** * 是否有开始工作 */ isWorking() { const platform = this._checkLinking(); return platform === 'android' || platform === 'iOS'; } /** * 键名转换 * @param type * @param json */ transKeyName(type, json) { return Fetcher.prototype.transKeyName(type, json); } /* ----- 文件系统 ----- */ /** * 保存文件到 java 端,并返回文件名 * @param objUrl * @param callback * @param onError */ fileSave(objUrl = '', callback, onError) { // 非 ObjectURL 跳过 if (objUrl.indexOf('blob:') < 0) { callback(objUrl); return; } if (this._data.fileSaved[objUrl]) { callback(this._data.fileSaved[objUrl]); return; } // 分段存储 const saveFileChunk = (baseData, index) => { const writeData = { fileName: baseData.fileName, currentIdx: index, totalIdx: baseData.total, data: baseData.baseArr[index], }; this.invoking('img_write', writeData, res => { if (res.result === false) { Tools.toast('离线图片存储:' + res.msg); onError({ method: 'img_write', request: { fileName: writeData.fileName, currentIdx: writeData.currentIdx, totalIdx: writeData.total, data: (writeData.data || '').substr(0, 10) + '...(共' + (writeData.data || '').length + '个base64字符)', }, response: res, }); return; } // 按分段递归保存 if (index < baseData.total - 1) { setTimeout(() => { saveFileChunk(baseData, index + 1); }, 10); } // 已完成 else { callback && callback('bridge:' + baseData.fileName); } }); }; $fileTrans.transObjUrlToBaseData(objUrl, baseData => { this._data.fileSaved[objUrl] = 'bridge:' + baseData.fileName; saveFileChunk(baseData, 0); }); } /** * 从 java 读取文件 base64 * @param bridgeName * @param callback * @param onError */ fileLoad(bridgeName = '', callback, onError) { // 非存储地址,跳过 if (bridgeName.indexOf('bridge:') < 0) { callback(bridgeName); return; } if (this._data.fileLoaded[bridgeName]) { callback(this._data.fileLoaded[bridgeName]); return; } const fileName = bridgeName.split(':')[1]; const chunkSize = $fileTrans.getChunkSize(); const baseArr = []; let totalSize = 0; let totalCount = 0; const loadFileChunk = index => { const loadData = { fileName, offset: chunkSize * index, length: chunkSize, }; this.invoking('img_read', loadData, res => { if (res.result === false) { Tools.toast('离线图片读取:' + res.msg); onError({ method: 'img_read', request: { ...loadData, totalCount, }, response: { result: res.result, msg: res.msg, total_size: res.totalSize, data: (res.data || '').substr(0, 10) + '...(共' + (res.data || '').length + '个base64字符)', }, }); return; } if (totalSize === 0) { totalSize = res.totalSize; totalCount = Math.ceil(res.totalSize / chunkSize); } baseArr.push(res.data); // 按分段递归读取 if (totalCount > 1 && totalCount - 1 > index) { loadFileChunk(index + 1); } // 读取完成 else { const baseData = { baseArr, fileName, }; try { $fileTrans.transBaseDataToObjUrl(baseData, objUrl => { this._data.fileLoaded[bridgeName] = objUrl; callback && callback(objUrl); }); } catch (e) { onError({ method: 'img_read@merge_after_base64_loaded', request: { ...loadData, totalCount, }, response: { result: res.result, msg: res.msg, total_size: res.totalSize, data: (res.data || '').substr(0, 10) + '...(共' + (res.data || '').length + '个base64字符)', }, base64Arr: baseData.baseArr.map( baseItem => (baseItem || []).substr(0, 10) + '...(共' + (res.data || '').length + '个base64字符)', ), message: 'Base64合并解析异常!', }); } } }); }; loadFileChunk(0); } /** * 从 java 端移除文件 * @param bridgeName */ fileRemove(bridgeName = '') { if (bridgeName.indexOf('bridge:') < 0) { return; } const fileName = bridgeName.split(':')[1]; this.invoking('img_del', { fileName }); // 移除 Object.keys(this._data.fileSaved).forEach(objUrl => { if (bridgeName === this._data.fileSaved[objUrl]) { delete this._data.fileSaved[objUrl]; } }); } /** * 文件存储批量操作服务 * @param type * @param names * @param callback */ fileStore(type, names = [], callback) { if (!names || names.length === 0) { callback && callback([]); return; } if (typeof names === 'string') { names = names.split(','); } // 保存 if (type === 'save') { const list = []; const save = index => { this.fileSave( names[index], bridgeName => { list.push(bridgeName); // 递归下一个 if (index < names.length - 1) { setTimeout(() => { save(index + 1); }, 10); } // 完成 else { callback && callback(list); } }, err => { callback && callback(null, err); }, ); }; save(0); } // 读取 else if (type === 'load') { const list = []; const load = index => { this.fileLoad( names[index], objUrl => { list.push(objUrl); // 递归下一个 if (index < names.length - 1) { setTimeout(() => { load(index + 1); }, 10); } // 完成 else { callback && callback(list); } }, err => { callback && callback(null, err); }, ); }; load(0); } // 移除 else if (type === 'remove') { names.forEach((name, index) => { setTimeout(() => { this.fileRemove(name); }, 10 * index); }); } } } // 全局服务实例 export const $bridge = new Bridge();