【小程序】384- 如何一人五天开发完复杂小程序(前端必看)
隨著業務需求的不斷累加、小程序追求快速產出。
在人手不足且開發周期較短的情況下,我們需要找到一個最大化開發效率的方法。
而高效率的開發離不開規范化、工程化、組件化。
為此整理寫下總結,細數小程序中的坑與實踐。
介紹我們對小程序高效率開發的思考與探索。
布局方案
導航欄
TabBar
BasicPage
用戶系統
登錄方案
初始化登錄
鑒權
優化及 Bug 追蹤
日志收集
數據分析
常用優化方案
preLoad
獨立分包加載
布局方案
我們首先思考的是,在小程序中如何快速且高還原產出頁面。
為此我們封裝了一套頁面組件。
導航欄
目前小程序有如下兩種導航欄:常規、自定義導航欄
常規 | 自定義導航欄 | 自定義導航欄 |
常規布局下,頂部導航欄部分直接使用小程序提供導航欄。
自定義導航欄布局下,我們可以完全控制導航欄樣式,賦予導航欄更多交互及 UI 設計上的可能。如上圖所示,Readhub 在導航欄中加入了設置按鈕,喜茶在個人頁中標題漸隱及沉浸式導航欄效果。
可根據具體業務選擇具體布局方案,在我們小程序中,我們選擇了全部使用自定義導航欄的方式并對其進行了一定封裝。
在確定使用自定義導航欄方案后,我們對導航欄進行了拆解
拆解后,我們發現可以將自定義導航欄分為兩個部分:StatusBar 及 NavigationBar 。
通過查閱微信 API ,我們分別通過?wx.getSystemInfoSync?及?wx.getMenuButtonBoundingClientRect?獲取到 StatusBarHeight 及 MenuButton 的布局信息。
由拆解圖可知
1NavigationBarPaddingTop?=?MenuButtonTop?-?StatusBarHeight 2 3NavigationBarPaddingBottom?=?NavigationBarPaddingTop 4 5NavigationBar?=?StatusBarHeight?+?NavigationBarPaddingTop?+?NavigationBarPaddingBottom?+?MenuButtonHeight得到上述數據后,結果簡單封裝, 我們得到如下方案
StatusBar 部分, 我們使用 PaddingTop 填充。
可在此基礎上可再進一步封裝一些通用 NavigationBar 組件。
我們封裝了一些常用 NavigationBar 組件, 如下所示:
沉浸式導航欄自定義 TabBar
目前小程序 TabBar 中也存在兩種方案。
常規 TabBar :微信提供方案,可修改 icon 、 文字及其對應選中狀態。
自定義 TabBar :小程序基礎庫 2.5.0 開始支持。可通過其實現異形 TabBar 或各種自定義樣式。
普通TabBar | 異形TabBar | 僅圖標TabBar |
由于小程序基礎庫 2.5.0 之后官方才開始支持自定義 TabBar 。我們此處不直接選擇使用 custom-tab-bar 方案。選擇結合 custom-tab-bar 、 自定義組件及?wx.hideTabBar?的方案實現。
具體方案為放置空節點 custom-tab-bar 文件。在頁面中按需引入自定義 TabBar 組件。在頁面初始化完成后調用?wx.hideTabBar?隱藏原 TabBar 。
這樣做的好處在于,在基礎庫 2.5.0 及更高版本時正常顯示,在低版本時以最小代價兼容。
| 普通 | 異形TabBar |
推薦如無特殊需求,建議直接使用微信提供方案,在自定義 TabBar 方案中 安卓手機下拉刷新時, TabBar 會被拉出可視區域。需自定義下拉刷新組件解決
方案整合 BasicPage
以上方案在線上運行一段時間后穩定后。對自定義導航欄及自定義 TabBar 方案進行了整合。封裝了 BasicPage 組件。
以我們線上典型頁面為例,我們可以將頁面分為兩大類。
| 三段式結構 | 無 TabBar |
Taro 框架偽代碼,可根據各自使用框架進行封裝,思路一致
1class?BasicPage?extends?Taro.Component?{23??state?=?{4????menuButtonHeight:?32,5????menuButtonTop:?48,6????statusBarHeight:?44,7??};89??componentDidMount()?{ 10????????//?...獲取并設置?menuButtonHeight?、?menuButtonTop?、?statusBarHeight 11??} 12 13??render()?{ 14????return?( 15??????<View?className='basic-page'> 16????????{ 17??????????this.props.header?&&?<View?className={`basic-page-header${this.props.fixed???'?fixed'?:?''}`}?style={{ 18????????????paddingTop:?`${this.state.statusBarHeight}px`, 19????????????height:?`${(this.state.menuButtonTop?-?this.state.statusBarHeight)?*?2?+?this.state.menuButtonHeight}px`, 20??????????}} 21??????????> 22????????????{this.props.renderHeader} 23??????????</View> 24????????} 25????????<View?className={`basic-page-body${this.props.tab???'?tab'?:?''}`}> 26??????????{this.props.renderBody} 27????????</View> 28????????{this.props.tab?&&?<TabBar?active={this.props.tabActive}?/>} 29??????</View> 30????); 31??} 32} 33 34BasicPage.defaultProps?=?{ 35??fixed:?false,?//?header?是否浮動 36??tab:?false, 37??header:?false, 38??tabActive:?'template', 39}; 40使用中會經常用到 自定義 TabBar 、 自定義 NavigationBar 布局數據。再封裝一個工具類獲取。
1import?Taro?from?"@tarojs/taro";23function?rpx2px(rpx,?windowWidth)?{4??return?rpx?/?750?*?windowWidth;5}67export?default?class?customConfig?{89??static?fetchAllConfig()?{ 10????const?menuButton?=?Taro.getMenuButtonBoundingClientRect(); 11????const?systemInfo?=?Taro.getSystemInfoSync(); 12 13????const?statusBarHeight?=?systemInfo.statusBarHeight; 14????const?headerHeight?=?(menuButton.top?-?systemInfo.statusBarHeight)?*?2?+?menuButton.height; 15????const?footerHeight?=?systemInfo.model.indexOf('iPhone?X')?===?-1 16??????? 17??????rpx2px(100,?systemInfo.windowWidth) 18??????: 19??????rpx2px(168,?systemInfo.windowWidth);??//?50??84 20????const?bodyHeight?=?systemInfo.windowHeight?-?statusBarHeight?-?headerHeight?-?footerHeight; 21????const?noTabBodyHeight?=?systemInfo.windowHeight?-?statusBarHeight?-?headerHeight; 22 23????let?data?=?{ 24??????source:?{ 25????????menu:?menuButton, 26????????system:?systemInfo, 27??????}, 28??????height:?{ 29????????statusBar:?statusBarHeight, 30????????header:?headerHeight, 31????????body:?bodyHeight, 32????????noTabBody:?noTabBodyHeight, 33????????footer:?footerHeight, 34??????}, 35????}; 36????Taro.setStorageSync('customConfig',?data); 37????return?data; 38??} 39 40??static?get?config()?{ 41????let?storageInfoSync?=?Taro.getStorageSync('customConfig'); 42????if(!storageInfoSync)?{ 43??????storageInfoSync?=?this.fetchAllConfig(); 44????} 45????return?storageInfoSync; 46??} 47}到此,我們完成對基礎頁面組件的封裝。目前線上運行小程序所有頁面都基于該組件進行開發。
開發新頁面時只需要引用該組件即可。
1<BasicPage?header?tab?tabActive='index'2????????renderHeader={3??????????<View4????????????className='my-index-header'5??????????>6????????????<Text>Title</Text>7??????????</View>8????????}9????????renderBody={ 10??????????<View?className='my-index-header'> 11????????????Body 12??????????</View> 13????????} 14/>用戶系統
在一個應用中,用戶系統是至關重要的。我們通過數個小程序的開發,整理了一套我們目前正在使用的用戶系統實踐。
登錄、獲取用戶信息
| 登錄流程 | 獲取用戶信息 |
如上圖所示,我們將小程序登錄及獲取用戶信息拆分為兩部分。
主要有如下考慮:
降低用戶使用門檻,可先讓用戶體驗部分功能。后續分享或互動時提示授權完善用戶信息
保證始終持有用戶登錄態,方便程序處理。如把用戶登錄及完善用戶信息放置一起,在未授權時無法獲取自定義登錄態。判斷變得復雜且無法提前收集 formId
同一開發者賬號下,多小程序互通時,如有一小程序用戶授權過,可通過返回 unionid 直接同步信息,無需再授權,提升用戶體驗。
處理注意點
授權獲取用戶信息時,如果服務端未記錄用戶 sessionKey ,在 Button type = getUserInfo 回調事件中使用?wx.login?方法獲取 code 的話,會導致 sessionKey 變化。從而導致 getUserInfo 時使用 sessionKey 與新 sessionKey 不匹配。從而導致解密用戶信息失敗。
解決方案有如下兩種:
Button type = getUserInfo 回調事件中使用?wx.login?方法后,再次調用?wx.getUserInfo?方法重新獲取加密用戶信息。
服務端記錄 sessionKey ,Button type = getUserInfo 回調后無需調用?wx.login?,直接提交供服務端處理。
第一種方案適合簡單改造舊項目、快速開發,但強烈建議使用服務端處理方式解決。
完善用戶信息時,解密用戶信息部分請查看官方文檔,這里不敘述具體流程https://developers.weixin.qq.com/miniprogram/dev/framework/open-ability/signature.html
unionid 機制
另外,在登錄流程中服務端向微信換取 sessionKey 過程中,如果滿足一定條件,會直接返回 unionid 。同開發者賬號下多個小程序時可用 unionid 做用戶信息同步,無需再授權。提升用戶體驗。
unionid 機制:?https://developers.weixin.qq.com/miniprogram/dev/framework/open-ability/union-id.html
小程序初始化及頁面初始化處理
在日常開發中,我們通常會把登錄獲取 token 操作放置在小程序初始化中即 app.js 定義的?onLaunch?中。而該生命周期與頁面初始化生命周期為同步進行。
此時,如果在頁面初始化中,需要攜帶用戶登錄態請求接口獲取信息時,可能出現如下情況
因為小程序初始化及頁面初始化是同步進行的。若頁面初始化時,小程序初始化中登錄請求仍未完成。會導致未攜帶 token 或其他鑒權信息,鑒權失敗。
最開始我們通過在組件中掛載一個特殊事件?componentDidInit?,待小程序初始化登錄請求后獲取當前頁面實例進行調用。但該方案對代碼侵入性太強,最終我們選擇維護一個登錄請求隊列。
用上隊列的原因在于,在產品需求上經常會有先跳入首頁,再從首頁跳入二級頁的需求,這樣能讓用戶回退一次后,仍然能回到首頁。但會導致在不同頁面中近乎同時調用?login?方法。
在第一種方案中,解決該問題需要獲得所有頁面實例進行調用。而引入隊列后只需要輪詢消費隊列中函數執行即可。上述流程可解決此問題。偽代碼如下:
代碼僅供理解思路
1let?loginDoing?=?false;2const?loginEvent?=?[];34const?userProfile?=?observable({5??user:?{6????avatar:?'',7????isCompleted:?false,8????nickname:?'',9????uid:?0, 10????token:?'', 11??}, 12??async?loginProcess()?{ 13????if(this.user.token)?{ 14??????return?this.user; 15????} 16????loginDoing?=?true; 17????let?code; 18????try?{ 19??????const?codeResult?=?await?Taro.login(); 20??????if(codeResult.errMsg?!==?'login:ok')?{ 21????????throw?new?Error('Taro.login?失敗'); 22??????} 23??????code?=?codeResult.code; 24????}?catch?(e)?{ 25??????loginDoing?=?false; 26??????throw?e; 27????} 28????const?result?=?await?post(URL().user.login,?{ 29??????code, 30????}); 31????let?user?=?{ 32??????...result.user, 33??????token:?result.token, 34????}; 35????this.user?=?user; 36????loginDoing?=?false; 37????setTimeout(()?=>?{ 38??????let?length?=?loginEvent.length; 39??????for(let?i?=?0;?i?<?length;?i++)?{ 40????????loginEvent.pop()(user); 41??????} 42????}); 43????return?user; 44??}, 45??login()?{ 46????if(loginDoing)?{ 47??????return?new?Promise((resolve)?=>?{ 48????????loginEvent.push(resolve); 49??????}); 50????}?else?{ 51??????return?this.loginProcess() 52????} 53??}, 54});鑒權
業務需求中,通常存在某些操作需要 【 用戶授權完善信息 】 后才能繼續進行,早期項目中都是各自頁面中寫鑒權代碼。因而會涉及大量重復代碼,也不利于快速開發。為此我們封裝了一套鑒權方案。
BasePage
通過所有頁面基礎一個基類 BasePage 。在 BasePage 中寫入鑒權邏輯來實現。配合在主頁面中使用 AuthorizationModal 組件實現鑒權。
代碼僅供理解思路
1export?default?class?BasePage?extends?Component?{23????state?=?{4????????//?鑒權相關5????????showAuthorizationModal:?false,6????};78????/**9?????*?鑒權相關 10?????*/ 11????//?授權成功事件 12????authSuccessEvent()?{ 13????} 14 15????//?取消授權事件 16????authFailEvent()?{ 17????} 18 19????async?checkAuthorization()?{ 20????????//?當前是否有已驗證 21????????let?globalData?=?getGlobalData(STORAGE_KEY.VERIFY); 22????????if(globalData)?{ 23????????????return?{ 24????????????????isNew:?false, 25????????????}; 26????????}?else?{ 27????????????Taro.showLoading({ 28????????????????title:?'檢查授權中...', 29????????????????mask:?true, 30????????????????showTicketModal:?false, 31????????????}); 32????????????//?如果本地不存在時,先請求接口 33????????????//?未登錄過,或新機器 34????????????//?請求token及授權狀態 35????????????let?res; 36????????????try?{ 37????????????????res?=?await?Taro.login(); 38????????????}?catch()?{ 39????????????????Toast.fail('登錄失敗~'); 40????????????????Taro.hideLoading(); 41????????????????throw?new?Error('Taro.login?失敗'); 42????????????} 43????????????//?請求授權接口 44????????????const?result?=?{}; 45????????????if(result.errno?===?0)?{ 46????????????????resolve({ 47????????????????????isNew:?false, 48????????????????}); 49????????????}?else?{ 50????????????????//?未授權過 51????????????????//?彈窗提示授權 52????????????????this.setState({ 53????????????????????showAuthorizationModal:?true, 54????????????????}); 55????????????????this.authSuccessEvent?=?()?=>?{ 56????????????????????this.setState({ 57????????????????????????showAuthorizationModal:?false, 58????????????????????}); 59????????????????????resolve({ 60????????????????????????isNew:?true, 61????????????????????}); 62????????????????}; 63????????????????this.authFailEvent?=?()?=>?{ 64????????????????????this.setState({ 65????????????????????????showAuthorizationModal:?false, 66????????????????????}); 67????????????????????reject(); 68????????????????}; 69????????????} 70????????} 71????} 72}頁面繼承該基類
1class?LaunchIndex?extends?BasePage?{}在頁面中置入組件
1{this.state.showAuthorizationModal?&& 2<AuthorizationModal?onSuccess={this.authSuccessEvent}?onFail={this.authFailEvent}/>} 3 AuthorizationModal 組件接下來,我們只需要在需要鑒權的操作中如下使用即可
1this.checkAuthorization() 2??.then((res)?=>?{ 3???//?授權成功邏輯 4???????console.log('是否新用戶',?res.isNew); 5???}) 6???.catch(()?=>?{ 7????//?授權失敗邏輯 8????})該方案好處在于,授權由狀態驅動,只需在代碼中調用 checkAuthorization 方法即可。
AuthorizationView
后來,由于第一種方案過于重,對頁面代碼侵入性較強。為此我們又封裝了一套較輕的組件。
大部分邏輯中,需要用戶主動點擊時才進行鑒權,我們基于此思路封裝了 AuthorizationView 。對外暴露 onAgree 、 onDeny 方法實現對部分區域的點擊鑒權操作。
代碼僅供理解思路
1class?AuthorizationView?extends?Taro.Component?{23??state?=?{4????showLoginPanel:?false,5??};67??/**8???*?登錄9???*/ 10??click()?{ 11????const?{?userProfile:?{?user,?},?}?=?this.props; 12????if(user.isCompleted)?{ 13??????this.props.onAgree(user); 14????}?else?{ 15??????//?顯示登錄框 16??????this.setState({ 17????????showLoginPanel:?true, 18??????}); 19????} 20??} 21 22??/** 23???*?授權登錄 24???*?@param?e 25???*/ 26??async?bindGetUserInfo(e)?{ 27????if(e.detail.errMsg?===?'getUserInfo:ok')?{ 28??????const?{?userProfile,?}?=?this.props; 29??????const?userResult?=?await?userProfile.login(true); 30??????this.setState({ 31????????showLoginPanel:?false, 32??????}); 33??????this.props.onAgree(userResult); 34????}?else?{ 35??????this.props.onDeny(); 36????} 37??} 38 39??cancel()?{ 40????this.setState({ 41??????showLoginPanel:?false, 42????}); 43??} 44 45??render()?{ 46????return?( 47??????<Block> 48????????<View?onClick={this.click}>{this.props.children}</View> 49????????{ 50??????????this.state.showLoginPanel?&&?<View?className='login-panel'> 51????????????<View?className='login-panel-main'> 52??????????????<View?className='login-panel-main-title'>您還未登錄</View> 53??????????????<View?className='login-panel-main-subtitle'>請先登錄再進行操作</View> 54??????????????<Image?className='login-panel-main-image'?src='https://p0.ssl.qhimg.com/t01a1e495cc2be1e651.png'?/> 55??????????????<View?className='login-panel-main-footer'> 56????????????????<View?className='login-panel-main-footer-button?cancel'?onClick={this.cancel.bind(this)}>暫不登錄</View> 57????????????????<Button?className='btn-reset'?openType='getUserInfo'?onGetUserInfo={this.bindGetUserInfo}> 58??????????????????<View?className='login-panel-main-footer-button?confirm'>立即登錄</View> 59????????????????</Button> 60??????????????</View> 61????????????</View> 62??????????</View> 63????????} 64??????</Block> 65????); 66??} 67} 68 69AuthorizationView.defaultProps?=?{ 70??onAgree:?()?=>?{ 71??}, 72??onDeny:?()?=>?{ 73??}, 74}; 75 76export?default?AuthorizationView; 77代碼中只需要使用該組件包裹子組件即可使用
1<AuthorizationView?onAgree={this.onAgree.bind(this)}?onDeny={this.onDeny.bind(this)}> 2??<View>生成海報</View> 3</AuthorizationView> 4以上兩種方案都有在線上業務中使用,具體選型看業務決定
優化及Bug追蹤
在維護階段,我們會更加關注于用戶反饋 bug 時如何復現場景及數據分析。
日志收集
在小程序基礎庫版本 2.1.0 后,微信提供了一套日志相關接口:LogManager 。
在用戶反饋時,通過該接口記錄的日志會同步上傳至微信后臺,可下載查看追蹤 Bug。
我們通過簡單的對其封裝,實現一套日志收集機制。
1const?_logger?=?Taro.getLogManager({?level:?0,?});23const?Logger?=?{4??debug(...args)?{5????_logger.debug(`${dayjs().format('YYYY-MM-DD?HH:mm:ss')}??`,?...args);6????console.debug(`${dayjs().format('YYYY-MM-DD?HH:mm:ss')}??`,?...args);7??},8??info(...args)?{9????_logger.info(`${dayjs().format('YYYY-MM-DD?HH:mm:ss')}??`,?...args); 10????console.info(`${dayjs().format('YYYY-MM-DD?HH:mm:ss')}??`,?...args); 11??}, 12??warn(...args)?{ 13????_logger.warn(`${dayjs().format('YYYY-MM-DD?HH:mm:ss')}???`,?...args); 14????console.warn(`${dayjs().format('YYYY-MM-DD?HH:mm:ss')}???`,?...args); 15??}, 16??error(...args)?{ 17????_logger.warn(`${dayjs().format('YYYY-MM-DD?HH:mm:ss')}?[?Error?]???`,?...args); 18????console.error(`${dayjs().format('YYYY-MM-DD?HH:mm:ss')}?[?Error?]???`,?...args); 19??}, 20}; 21 22export?default?Logger;在使用時,最好按照一定規范進行使用,方便后續查找。例如
1Logger.error('[?MyIndex?]?獲取用戶信息失敗',?e); 2Logger.debug('[?LaunchIndex?]?init?response',?info);實時日志分析:小程序基礎庫 2.7.1 之后還提供了 實時日志分析功能。https://developers.weixin.qq.com/miniprogram/dev/api/base/debug/wx.getRealtimeLogManager.html
數據分析
數據分析模型在產品迭代過程中,我們一般會依照上面模型進行迭代。
數據獲取 → 數據分析 → 數據應用 → 數據反饋
在小程序中,數據獲取的方案主要有
小程序后臺自定義分析
小程序本身提供的數據平臺。
優點在于能隨時不發版本添加數據打點位置。能滿足大部分需求。
主要依靠產品后臺自行添加數據打點項目。
第三方數據平臺
這里以阿拉丁自定義數據分析為例。依靠第三方平臺提供 API 進行打點。
阿拉丁
……
自有數據分析平臺
一般大廠都會有自己自有數據分析平臺,聯系數據組拓展即可
推薦使用小程序后臺自定義分析進行打點。各數據平臺打點大同小異,能不發版本添加數據打點才是大殺器。
阿拉丁數據平臺打點封裝,代碼僅供理解思路
1import?Taro?from?'@tarojs/taro';23export?default?class?Monitor?{4??static?sendEvent(moduleName,?eventName,?options)?{5????let?aldstat?=?Taro.getApp().aldstat;6????if(aldstat)?{7??????aldstat.sendEvent(`[?${moduleName}?]?${eventName}`,?options);8????}9??} 10} 11 12Monitor.sendEvent('LaunchIndex',?'返回',?{ 13??id:?this.state.id, 14}); 15 16Monitor.sendEvent('LaunchIndex',?'點擊制作',?{ 17??id:?this.state.id, 18});小程序自定義分析API方式依葫蘆畫瓢封裝即可。
需要注意的是封裝時要有邏輯、有規則的封裝,方便后面篩選具體頁面具體操作。
常用優化方案
preLoad
在微信小程序中,頁面路由跳轉時 ( 例如調用?wx.navigateTo?、wx.redirectTo?或?wx.switchTab?) ,到頁面觸發?componentWillMount?會有一定延時。因此一些網絡請求可以提前到跳轉前一刻請求。而后在觸發?componentWillMount?后取得該請求實例。
目前各框架均提供了預加載請求實現。原生開發可自行拓展,思路一致。以下以 Taro 為例。代碼僅供理解思路。
1export?default?class?Preload?extends?BasePage?{2????componentWillMount()?{3????????let?initData;4????????//?兼容直接進入的場景5????????if(this.$preloadData)?{6????????????initData?=?this.$preloadData;7????????}?else?{8????????????initData?=?request(URL().user.defaultAddress,?{9????????????????token:?getGlobalData(STORAGE_KEY.ACCESS_TOKEN), 10????????????}); 11????????} 12????????initData 13????????????.then((initInfo)?=>?{ 14????????????}) 15????????????.catch(()?=>?{ 16????????????}); 17????} 18 19????componentWillPreload?(params)?{ 20????????return?request(URL().user.defaultAddress,?{ 21????????????token:?getGlobalData(STORAGE_KEY.ACCESS_TOKEN), 22????????}); 23????} 24}獨立分包加載
https://developers.weixin.qq.com/miniprogram/dev/framework/subpackages.html
除上面列出嘗試外。我們還做了以下工作:
通用分享圖解決方案
小程序云開發應用
自定義下拉刷新組件 RefreshView
Protobuf
圖片裁剪組件
還有一些你可能遇不到的坑
原生組件使用問題
Video 、 innerAudioContext
由于不是必要部分,篇幅有限,不在此一一列舉
價值
在對小程序進行上述實踐后,我們已經能夠基于該實踐快速開發復制小程序。我們最近一個小程序 【嘟嘟卡點相冊】 僅開發5天后就上線了。
紙上得來終覺淺,絕知此事要躬行。
文章內容基本囊括了開發維護階段可能會用到的點及我們對此作出的應對方案。供參考。
本文僅為拋磚引玉, 軟件開發沒有銀彈,好的方案一定是與業務息息相關的。歡迎交流。
面向未來
小程序腳手架 CLI
▼ 原創系列推薦 ▼4.? 5.? 6.? 7.?
回復“加群”與大佬們一起交流學習~
點這,與大家一起分享本文吧~總結
以上是生活随笔為你收集整理的【小程序】384- 如何一人五天开发完复杂小程序(前端必看)的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: SPIKE创新科技套装做探路机器人(上)
- 下一篇: 2017年html5行业报告,云适配发布