談談App混合開發


 

混合開發的App(Hybrid App)就是在一個App中內嵌一個輕量級的瀏覽器,一部分原生的功能改為Html 5來開發,這部分功能不僅能夠在不升級App的情況下動態更新,而且可以在Android或iOS的App上同時運行,讓用戶的體驗更好又可以節省開發的資源。

下面來談談Hybrid App開發中的技術問題。iOS方面的我不太了解,我就主要談談Android開發中的,其中可能會有很多說錯的,請大家輕噴

Hybrid開發中關鍵問題是什么

想要在一個App中顯示一個Html 5網頁的功能,其實很簡單,只要一個WebView就可以了。你可以點擊鏈接來跳轉網頁。像這樣的功能就能叫做Hybrid 開發了嘛?顯然不是的。

我覺得一個Hybrid開發的App中必須要要有的功能就是Html 5頁面和Native App怎么進行交互。比如,我點了一個Html 5頁面上的一個按鈕或鏈接,我能不能夠跳轉到Native App的某個頁面;比如我點了Html 5頁面上的分享按鈕,我能不能調用Native App的分享功能;比如Html加載的時候能不能獲取Native App的用戶信息等等。

看下圖,在網易雲音樂中進入這個Html 5頁面時,你點擊作者:空虛小編你會進入他的主頁,這個主頁是Native頁面,而你點擊上面那個播放按鈕時,雲音樂會啟動Native的播放界面播放音樂,你點擊評論時,你會進入Native的評論頁

此處輸入圖片的描述

Html 5和Native的交互

WebView本來就支持js和Java相互調用,你只需要開啟WebView的JavaScript腳本執行,然后通過代碼mWebView.addJavascriptInterface(new JsBridge(), "bxbxbai");向Html 5頁面時注入一個Java對象,然后就可以在Html 5頁面中調用Native的功能了

微信怎么做的

微信應該是Hybrid 開發做的最好的App之一,它是怎么做交互的呢?

答案就是微信JS-SDK,去微信開發者文檔中可以看到,微信JS-SDK封裝了各種微信的功能,比如分享到朋友圈,圖像接口,音頻接口,支付接口地理位置接口等等。開發者只需要調用微信JS-SDK中的函數,然后統一由JS-SDK來調用微信中的功能,這樣好處就是我寫了一個Html 5的應用或網頁,在Android和iOS的微信中都可以正常運行了

下面會詳細講到

網易雲音樂怎么做的

那么網易雲音樂是怎么做的呢?我用黑科技知道了上圖雲音樂的界面Activity是CommonSubjectActivity(名字好奇怪,如果要我從代碼里找,我肯定找不到,因為還有一個類叫做EmbedBrowserActivity),我就在反編譯后的雲音樂代碼中找相應的功能實現代碼,實在沒找到。不過我拿到了那個Html 5頁面的地址:http://music.163.com/m/topic/194001

用Chrome打開后發現和App中顯示的不一樣,然后我用Charles截了進入那個Html 5的請求,發現雲音樂加載的地址是http://music.163.com/m/topic/194001?type=android ,就是加了手機系統類型

然后在我自己的App中加載這個Html 5頁面就可以看到下圖,@小比比說這樣的文字是可以點擊跳轉到個人,點擊播放按鈕是可以播放音樂的

此處輸入圖片的描述

從Html源代碼中可以看到如下信息:

此處輸入圖片的描述

也就是說,當我點擊一個用戶名的時候就請求跳轉到orpheus://user/30868859,因為WebView可以攔截跳轉的url,所以App在攔截每一個url,如果host是orpheus的話就啟動用戶首頁

反編譯代碼后,在雲音樂的代碼中找到了this.mWebView.setWebViewClient(new cf(this));這么一句代碼,進入cf類,發現下面代碼:

public boolean shouldOverrideUrlLoading(WebView webView, String url) { if (url.startsWith("orpheus://")) { RedirectActivity.a(this.activity, url); return true; } if ((url.toLowerCase().startsWith("http://")) || (url.toLowerCase().startsWith("https://"))) { return false; } try { this.activity.startActivity(new Intent("android.intent.action.VIEW", Uri.parse(url))); return true; } catch (ActivityNotFoundException localActivityNotFoundException) { localActivityNotFoundException.printStackTrace(); } return true; } 

果然如此,再進入RedirectActivity,這是一個沒有任何界面的Activity,專門用於處理頁面跳轉信息,它會調用一個方法NeteaseMusicUtils.redirect(this, getIntent().getData().toString(), false)來處理url,redirect方法的名字是我自己寫的,部分代碼如下:

此處輸入圖片的描述

可以看到orpheus://user/30868859中的用戶id被傳入了ProfileAcvitiy,因此啟動了用戶首頁顯示了用戶信息

然后我自己寫了代碼攔截Html 5的跳轉,打印出的Log如下:

此處輸入圖片的描述

可以看到Html 5頁面可以跳轉到各種頁面,比如用戶首頁、播放音樂、MV界面、評論頁、電台節目等等

總結

一般來講,也是我目前知道的兩種主流的方式就是

  1. js調用Native中的代碼
  2. Schema:WebView攔截頁面跳轉

第2種方式實現起來很簡單,但是一個致命的問題就是這種交互方式是單向的,Html 5無法實現回調。像雲音樂App中這種點擊跳轉到具體頁面的功能,Schema的方式確實可以簡單實現,而且也非常適合。如果需求變得復雜,假如Html 5需要獲取Native App中的用戶信息,那么最好使用js調用的方式。

js和Native進行交互

上面講到WebViewbe本身就是支持js調用Native代碼的,不過WebView的這個功能在Android 4.2(API 17)一下存在高危的漏洞。這個漏洞的原理就是Android系統通過WebView.addJavascriptInterface(Object o, String interface)方法注冊可供js調用的Java對象,但是系統並沒有對注冊的Java對象方法調用做限制。導致攻擊者可以利用反射調用未注冊的其他任何Java對象,攻擊者可以根據客戶端的能力做任何事情。這篇文章詳細的介紹了這個漏洞

出於安全考慮,Android 4.2以后的系統規定允許被js調用的Java方法必須以@JavascriptInterface進行注解

Cordova的解決方案

Cordova是一個廣泛使用的Hybrid開發框架,它提供了一套js和Native交互規范

在Cordova的SystemWebViewEngine類中可以看到

private static void exposeJsInterface(WebView webView, CordovaBridge bridge) { if ((Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN_MR1)) { Log.i(TAG, "Disabled addJavascriptInterface() bridge since Android version is old."); // Bug being that Java Strings do not get converted to JS strings automatically. // This isn't hard to work-around on the JS side, but it's easier to just // use the prompt bridge instead. return; } webView.addJavascriptInterface(new SystemExposedJsApi(bridge), "_cordovaNative"); } 

因此當Android系統高於4.2時,Cordova還是使用addJavascriptInterface這種方式,因為這個方法在高版本上安全而且簡單,低於4.2的時候,用什么方法呢?

答案是WebChromeClient.onJsPrompt方法

WebView可以設置一個WebChromeClient對象,它可以處理js的3個方法

  • onJsAlert
  • onJsConfirm
  • onJsPrompt

這3個方法分別對應js的alertconfirmprompt方法,因為只有prompt接收返回值,所以js調用一個Native方法后可以等待Native返回一個參數。下面是cordova.js中的一段代碼:

/** * Implements the API of ExposedJsApi.java, but uses prompt() to communicate. * This is used pre-JellyBean, where addJavascriptInterface() is disabled. */ module.exports = { exec: function(bridgeSecret, service, action, callbackId, argsJson) { return prompt(argsJson, 'gap:'+JSON.stringify([bridgeSecret, service, action, callbackId])); }, setNativeToJsBridgeMode: function(bridgeSecret, value) { prompt(value, 'gap_bridge_mode:' + bridgeSecret); }, retrieveJsMessages: function(bridgeSecret, fromOnlineEvent) { return prompt(+fromOnlineEvent, 'gap_poll:' + bridgeSecret); } }; 

然后只要在onJsPrompt方法中使用CordovaBridge來處理js的prompt調用

/**
 * Tell the client to display a prompt dialog to the user. If the client returns true, WebView will assume that the client will handle the prompt dialog and call the appropriate JsPromptResult method. * <p/> * Since we are hacking prompts for our own purposes, we should not be using them for this purpose, perhaps we should hack console.log to do this instead! */ @Override public boolean onJsPrompt(WebView view, String origin, String message, String defaultValue, final JsPromptResult result) { // Unlike the @JavascriptInterface bridge, this method is always called on the UI thread. String handledRet = parentEngine.bridge.promptOnJsPrompt(origin, message, defaultValue); if (handledRet != null) { result.confirm(handledRet); } else { dialogsHelper.showPrompt(message, defaultValue, new CordovaDialogsHelper.Result() { @Override public void gotResult(boolean success, String value) { if (success) { result.confirm(value); } else { result.cancel(); } } }); } return true; } 

一種開源的解決方案

Cordova是Apache的一個開源解決方案,不過它需要xml配置CordovaPlugin信息,使用會比較麻煩,而且這個框架很重,具體請自行搜索Cordova使用教程

下面這個開源項目是我個人覺得比較合理的解決方案,也比較輕量級,下圖就是一個Demo

https://github.com/pedant/safe-java-js-webview-bridge

此處輸入圖片的描述

這個項目的原理就是使用WebChromeClient.onJsPrompt方法來進行交互,本質上都是js調用prompt函數,傳輸一些參數,onJsPrompt方法攔截到prompt動作,然后解析數據,最后調用相應的Native方法

HostJsScope類中定義了所有可以被js調用的方法,這些方法都必須是靜態方法,並且所有的方法第一個參數必須是WebView

/** * HostJsScope中需要被JS調用的函數,必須定義成public static,且必須包含WebView這個參數 */ public class HostJsScope { /** * 短暫氣泡提醒 * @param webView 瀏覽器 * @param message 提示信息 * */ public static void toast(WebView webView, String message) { Toast.makeText(webView.getContext(), message, Toast.LENGTH_SHORT).show(); } /** * 系統彈出提示框 * @param webView 瀏覽器 * @param message 提示信息 * */ public static void alert(WebView webView, String message) { // 構建一個Builder來顯示網頁中的alert對話框 AlertDialog.Builder builder = new AlertDialog.Builder(webView.getContext()); builder.setPositiveButton(android.R.string.ok, new AlertDialog.OnClickListener() { @Override public void onClick(DialogInterface dialog, int which) { dialog.dismiss(); } }); builder.setTitle("Hello world") .setMessage(message) .setCancelable(false) .create() .show(); } // 其他代碼 } 

上面代碼列舉了最基本的點擊Html 5按鈕彈出對話框的功能

這個庫中一個最關鍵的叫做JsCallJava,這個實現的就是js來調用Java方法的功能,這個類只用於InjectedWebChromeClient

public class InjectedChromeClient extends WebChromeClient { private JsCallJava mJsCallJava; private boolean mIsInjectedJS; public InjectedChromeClient(String injectedName, Class injectedCls) { this(new JsCallJava(injectedName, injectedCls)); } public InjectedChromeClient(JsCallJava jsCallJava) { mJsCallJava = jsCallJava; } // 處理Alert事件 @Override public boolean onJsAlert(WebView view, String url, String message, final JsResult result) { result.confirm(); return true; } @Override public void onProgressChanged(WebView view, int newProgress) { //為什么要在這里注入JS //1 OnPageStarted中注入有可能全局注入不成功,導致頁面腳本上所有接口任何時候都不可用 //2 OnPageFinished中注入,雖然最后都會全局注入成功,但是完成時間有可能太晚,當頁面在初始化調用接口函數時會等待時間過長 //3 在進度變化時注入,剛好可以在上面兩個問題中得到一個折中處理 //為什么是進度大於25%才進行注入,因為從測試看來只有進度大於這個數字頁面才真正得到框架刷新加載,保證100%注入成功 if (newProgress <= 25) { mIsInjectedJS = false; } else if (!mIsInjectedJS) { view.loadUrl(mJsCallJava.getPreloadInterfaceJS()); mIsInjectedJS = true; StopWatch.log(" inject js interface completely on progress " + newProgress); } super.onProgressChanged(view, newProgress); } @Override public boolean onJsPrompt(WebView view, String url, String message, String defaultValue, JsPromptResult result) { result.confirm(mJsCallJava.call(view, message)); StopWatch.log("onJsPrompt: " + view.toString() +", " + url +", " + message +", " + defaultValue + ", " + result) ; return true; } } 

這個InjectedWebChromeClient是設給WebView的,這里一個非常重要的細節需要注意一下,在onProgressChange方法中,向WebView注入了一段js代碼,這段js代碼如下:

javascript: (function(b) {
    console.log("HostApp initialization begin"); var a = { queue: [], callback: function() { var d = Array.prototype.slice.call(arguments, 0); var c = d.shift(); var e = d.shift(); this.queue[c].apply(this, d); if (!e) { delete this.queue[c] } } }; a.alert = a.alert = a.alert = a.delayJsCallBack = a.getIMSI = a.getOsSdk = a.goBack = a.overloadMethod = a.overloadMethod = a.passJson2Java = a.passLongType = a.retBackPassJson = a.retJavaObject = a.testLossTime = a.toast = a.toast = function() { var f = Array.prototype.slice.call(arguments, 0); if (f.length < 1) { throw "HostApp call error, message:miss method name" } var e = []; for (var h = 1; h < f.length; h++) { var c = f[h]; var j = typeof c; e[e.length] = j; if (j == "function") { var d = a.queue.length; a.queue[d] = c; f[h] = d } } var g = JSON.parse(prompt(JSON.stringify({ method: f.shift(), types: e, args: f }))); if (g.code != 200) { throw "HostApp call error, code:" + g.code + ", message:" + g.result } return g.result }; //有時候,我們希望在該方法執行前插入一些其他的行為用來檢查當前狀態或是監測 //代碼行為,這就要用到攔截(Interception)或者叫注入(Injection)技術了 /** * Object.getOwnPropertyName 返回一個數組,內容是指定對象的所有屬性 * * 其后遍歷這個數組,分別做以下處理: * 1. 備份原始屬性; * 2. 檢查屬性是否為 function(即方法); * 3. 若是重新定義該方法,做你需要做的事情,之后 apply 原來的方法體。 */ Object.getOwnPropertyNames(a).forEach(function(d) { var c = a[d]; if (typeof c === "function" && d !== "callback") { a[d] = function() { return c.apply(a, [d].concat(Array.prototype.slice.call(arguments, 0))) } } }); b.HostApp = a; console.log("HostApp initialization end") })(window); 

那么這段js代碼是如何生成的呢?答案就在JsCallJava類的構造函數方法中,這個構造方法做的事情就是解析HostJsScope類中的方法,把每一個方法的簽名都保持到private Map<String, Method> mMethodsMap中,再看上面那段js代碼中

a.alert = a.alert = a.alert = a.delayJsCallBack = a.getIMSI = a.getOsSdk = a.goBack = a.overloadMethod = a.overloadMethod
= a.passJson2Java = a.passLongType = a.retBackPassJson = a.retJavaObject = a.testLossTime = a.toast = a.toast = function()

這些都是HostJsScope類中定義的方法名

那么這個庫的整個執行流程是這樣的:

  1. JsCallJava類解析了HostJsScope類中所有的靜態方法,將它們放到一個Map中,並且生成一段js代碼
  2. 向WebView設置InjectedChromeClient,在onProgressChanged方法中將那段js代碼注入到Html5頁面中,這個過程通俗點講就是,Native告訴Html 5頁面,我開放了什么功能給你,你就來調用我
  3. 這樣js就可以調用Native提供的這些方法,那段js代碼還會將js執行的方法轉換成一段json字符串,通過js的prompt方法傳到onJsPrompt方法中,JsCallJava調用call(WebView view, String msg)解析json字符串,包括要執行的方法名字參數類型方法參數,其中還會驗證json中的方法參數類型和HostJsScope中同名方法參數類型是否一致等等。
  4. 最后,如果方法正確執行,call方法就返回一個json字符串code=200,否則就傳code=500,這個信息會通過prompt方法的返回值傳給js,這樣Html 5 代碼就能知道有沒有正確執行了

以上就是這個開源庫的整個原理,我個人覺得非常適合用於Hybrid開發,這個解決方案中js可以收到Native的返回值,而且沒有使用addJavascriptInterface方法,在低版本手機上也不會有安全問題,這個方法比Cordova的實現和配置簡單

那么當我點擊Html 5頁面上的一個按鈕,比如彈出對話框,這個過程的整體流程是怎么樣的呢

微信的解決方案?

什么?你問我微信是怎么解決的?我也反編譯了微信的代碼,想研究一下他們是解決的,其實我非常好奇微信的這種js 調用Native,並且又返回的調用方法

首先,我去微信的js sdk官網看了一下js sdk提供的功能,提供了各種強大的功能,各位可以自己去看一下。那么問題來了,微信是怎么做到js 調用Native並且能夠成功返回的呢?

帶着疑問我反編譯了微信Android客戶端,在assers/jsapi中看到了wxjs.js文件,我想這個就是微信js sdk的源碼了吧。。。

我首先說一下,我不太懂js的代碼, 我只能連蒙帶猜的看微信的js代碼,如果有js大神對這方面也感興趣,希望可以一起(jian)探(fei)討(zao)

wxjs.js中看到了一下代碼,我猜微信就是用這個__WeixinJSBridge當時js和Native進行通信的數據結構吧?

var __WeixinJSBridge = {
    // public
    invoke:_call,
    call:_call,
    on:_onfor3rd,
    env:_env,
    log:_log, // private // _test_start:_test_start, _fetchQueue: _fetchQueue, _handleMessageFromWeixin: _handleMessageFromWeixin, _hasInit: false, _continueSetResult: _continueSetResult }; 

然后我又看到了下面的代碼,我想應該是提供分享內容到朋友圈功能的吧

// share timeline
_on('menu:share:timeline',function(argv){ _log('share timeline'); var data; if (typeof argv.title === 'string') { data = argv; _call('shareTimeline',data); }else{ data = { // "img_url": "", // "img_width": "", // "img_height": "", "link": document.documentURI || _session_data.init_url, "desc": document.documentURI || _session_data.init_url, "title": document.title }; var shareFunc = function(_img){ if (_img) { data['img_url'] = _img.src; data['img_width'] = _img.width; data['img_height'] = _img.height; } _call('shareTimeline',data); }; getSharePreviewImage(shareFunc); } }); 

請注意最后這句:_call('shareTimeline',data);,在看看__WeixinJSBridge中的call屬性,接着我找到了_call方法。

function _call(func,params,callback) { var curFuncIdentifier = __WeixinJSBridge.call; if (curFuncIdentifier !== _callIdentifier) { return; } if (!func || typeof func !== 'string') { return; }; if (typeof params !== 'object') { params = {}; }; var callbackID = (_callback_count++).toString(); if (typeof callback === 'function') { _callback_map[callbackID] = callback; }; var msgObj = {'func':func,'params':params}; msgObj[_MESSAGE_TYPE] = 'call'; msgObj[_CALLBACK_ID] = callbackID; _sendMessage(JSON.stringify(msgObj)); } 

大致意思應該就是:就是將這個東西_call('shareTimeline',data);轉換成一個json字符串吧,從這里看到微信的做法和上面那個開源庫非常類似,簡單並且安全。_call方法最后調用_sendMessage方法發送消息

//將消息添加到發送隊列,iframe的准備隊列為weixin://dispatch_message/ function _sendMessage(message) { _sendMessageQueue.push(message); _readyMessageIframe.src = _CUSTOM_PROTOCOL_SCHEME + '://' + _QUEUE_HAS_MESSAGE; // var ifm = _WXJS('iframe#__WeixinJSBridgeIframe')[0]; // if (!ifm) { // ifm = _createQueueReadyIframe(document); // } // ifm.src = _CUSTOM_PROTOCOL_SCHEME + '://' + _QUEUE_HAS_MESSAGE; }; 

從上面代碼可以看到微信的js sdk也是將js的方法調用換成一個類似weixin://dispatch_message/這樣的url,上面說的json封裝的數據。那么我猜測微信的做法是類似網易雲音樂的攔截url嗎?如果真的是這樣的話,就非常不安全了,隨便一個Html 5頁面可以偽造一個類似:weixin://dispatch_message/這樣的url來調用微信的功能了,不過好在微信對每個js調用都必須帶上appid。

在反編譯后的微信代碼,我看到了下面代碼:

image

我想這寫就是微信想Html 5開放的接口吧?不過對比了一下微信js sdk的官網,我看到好多App提供的功能在js sdk中並沒有找到,這樣也沒有太大關系,以為微信只要升級js sdk就可以使用其他功能了,因為Native已經開放了嘛~

從上面__WeixinJSBridge可以看到有一個熟悉_handleMessageFromWeixin,這個就是js來處理Native的回調接口,我用這個字符串在微信代碼中搜索,結果如下:

image

因此,我大致猜測,微信中的js調Native功能是用攔截url的方式,而Native回調的話是使用evaluateJavascript方法

我也在js sdk中找到了相應的函數:

function _handleMessageFromWeixin(message) { var curFuncIdentifier = __WeixinJSBridge._handleMessageFromWeixin; if (curFuncIdentifier !== _handleMessageIdentifier) { return '{}'; } var ret; var msgWrap if (_isUseMd5 === 'yes') { var realMessage = message[_JSON_MESSAGE]; var shaStr = message[_SHA_KEY]; var arr = new Array; arr[0] = JSON.stringify(realMessage); arr[1] = _xxyy; var str = arr.join(""); var msgSha = ''; var shaObj = CryptoJS.SHA1(str); msgSha = shaObj.toString(); if (msgSha !== shaStr) { _log('_handleMessageFromWeixin , shaStr : ' + shaStr + ' , str : ' + str + ' , msgSha : ' + msgSha); return '{}'; } msgWrap = realMessage; } //省略很多代碼 

微信的做法應該說非常基礎,使用了原生的功能,但是安全,由於微信客戶端對每一個js調用都有驗證(appid),因此這也增加了一定的安全性

以上說的都是建立在我的分析正確的情況下。

一些個人的想法

現在各種新的技術也在想辦法解決Native開發的效率問題,想用技術來解決一套代碼運行在Android和iOS客戶端,我相信隨着技術的發展這些問題都會解決。我也好期待Facebook即將推出的React Native Android

Hybrid開發適用於哪些功能

本文講的Hybrid開發就是Native客戶端中嵌入了Html App的功能,這方面微信應該是做的最好的,由於Html 5的效率以及耗電問題,我個人覺得用戶是不能滿足Web App的體驗的,Hybrid App也只適用於某些場景。一些基礎的功能,比如調用手機的攝像頭,獲取地理位置,登錄注冊功能等等,做成Native的功能,讓Html 5來調用更好,這樣的體驗也更好。

如果你把一個登錄和注冊功能也做成Html 5,在弱網絡環境下,這個體驗應該會非常的差,或許你等半天還沒加載出頁面。你可能會說,我可以預先加載Html 5的代碼,打開App時直接加載,那么我說你在給自己找麻煩,如果要這樣的話,Native開發或許更快一點。

那么什么情況適合Html 5開發呢?像一些活動頁面,比如秒殺、團購等適合做Html 5,因為這些頁面可能涉及的非常炫而且復雜,Html 5開發或許會簡單點,關鍵是這些頁面時效性短,更新更快,因為一個活動說不定就一周時間,下周換活動,如果這樣的話,你還做Native是肯定不行的

總結

有那么一句古老的箴言

如果你手里有一把錘子,所有東西看上去都想釘子

千萬不要以為Hybrid開發能夠誇平台運行,就使用Hybrid開發任何功能。其實Facebook早期也是這么想的,后來就是因為WebView渲染效率底下,把整個應用改為Native開發,請看這里

引用Facebook的一段話:

Today, we’re releasing a new version of Facebook for Android that’s been rebuilt in native code to improve speed and performance. To support the unique complexity of Facebook stories across devices, we’re moving from a hybrid native/webview to pure native code, allowing us to optimize the Facebook experience for faster loading, new user interfaces, disk cache, and so on.

本文主要還是從技術上談談Hybrid開發中js和Native交互的技術實現原理。拋磚引玉,寫的估計也有很多錯的,希望技術大牛指出。

最后,我覺得那個開源的庫是一個非常不錯的解決方案,解決辦法巧妙、簡單而且安全。當時我debug了半天弄明白其中的原理后,我一拍大腿,這辦法真好啊!!網易雲音樂的解決辦法適用於它的場景,不需要回調,Native只需要處理相應的信息,然后來實現頁面跳轉、播放音樂、播放MV等功能,這個方法也簡單好用。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM