2015年8月26日 星期三

cordova 讓網頁程式設計師半小時變身成手機 app 開發者; 順便學 docker

apache cordova

(對 android app 開發沒興趣的讀者還是可以把這篇當做 docker 的 「實用情境教學文」。 「試車」那一節可以只做前面一小段有感覺就好。)

如果你已熟悉 javascript/css/html, 那麼只需要學會操作 Apache cordova 就可以從一份原始碼產生多個標的 (target) 套件, 把你的 web 程式轉成 android/iOS/windows phone/... 等等 八九種手機平臺 的 apps, 一魚多吃! 雖然我目前只對 android 標的有興趣, 但以後如果 firefox OS 紅起來, 我可不想再把所有的 apps 重寫一次啊! 而且程式某些部分可以在網頁上免編譯直接測試, 這對我來說比 「從頭到尾被綁在 Android Studio」 要方便多了。 所以雖然 java 跟 javascript 我都不熟也都不是我的最愛, 但最後還是決定學 & 教 cordova。 這篇教你用偷吃步在 linux 上架快速備妥 cordova 的開發及 android 標的測試環境。

(當然, 如果你偏好 python、 想寫的程式很簡單, 那麼可以考慮 最低門檻的手機開發途徑: kivy。)

一、 安裝與設定 docker

我的開發環境是 lubuntu 15.04。 其他版本的 linux 只要夠新, 支援 docker, 應該也可以。

Docer? 對, 就是省略安裝 oracle jdk、 android sdk、 cordova、 ... 繁雜的步驟, 直接拿別人做好的 cordova 開發環境來用的意思 :-) 也順便拿一個具體情境來學 docker, 比較有感。 說好了這篇要用偷吃步的; 至於自己從零開始手工打造的步驟, 請見 這一篇

首先安裝 docker: apt-get install docker.io。 沒學過 docker? 沒關係, 我也才剛接觸; 我們只需要用到很少幾個指令。 你只需要知道: 每個 docker image 就是一部極輕量的虛擬機映象檔; 每個 docker 程序就是一部極輕量的虛擬機。 另外, 為了讓非 root 用戶也能使用 docker 指令, 請把自己加入 docker 這個群組: gpasswd -a ckhung docker 當然, 上面的 ckhung 應改成你自己的 user id。 以下皆同。

二、 準備工作 (資料) 目錄 & 推薦教學網站

再來建立一個 app 開發專用的工作目錄, 並且下載一些範例程式:

mkdir ~/app_dev
cd ~/app_dev
git clone https://github.com/ckhung/cordova-examples.git
git clone https://github.com/cfjedimaster/Cordova-Examples.git
git clone https://github.com/ccoenraets/olympic-dashboard-d3.git
以上假設你已事先 apt-get install git。 或者手動下載、 解壓縮、 改目錄名稱也可以, 不一定要安裝 git。 第一份是我寫的幾個範例程式, 目前沒有文件。 大推第二份 Raymond Camden 大大所寫的許多小範例程式, 非常適合新手入門。 他的部落格 也有一些 cordova 相關文章。 再來, 如果你搜尋 「cordova tutorial」, 第一筆就是這篇 詳盡教學文, 而第三份就是該文的範例程式。 作者 Christophe Coenraets 的部落格 的教學文寫得超讚的。

搜尋 「cordova 手機」 或 「phonegap 手機」 也會找到一些中文教學文/簡報/書籍/課程。

三、 下載虛擬機映像檔並啟用

接著我在 Docker Hub 搜尋 "cordova", 挑一個看起來人氣比較旺的 docker image 來下載: docker pull webratio/cordova 然後建立並啟動一部虛擬機, 命名為 cordova,

docker run -it --name cordova -v ~/app_dev:/data webratio/cordova /bin/bash

最好用的就是拿 -v 來把 host 的目錄分享給 guest, 是一種 「在 (分屬兩個平行宇宙的) 窗檯兩側分享食物」 的概念。 例如這裡就讓名為 cordova 的這部虛擬機可以在它的 /data 目錄裡面看見 host 的 ~/app_dev 目錄。 以上兩處請按照你的偏好修改; 其他照抄即可。

執行上面的指令之後, 你就進入 guest 的命令列。 照著以下執行兩個指令, 看起來類似這樣:

root@617e9519159c:/data# echo $PATH
/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/opt/ant/bin:/opt/android-sdk-linux/tools:/opt/android-sdk-linux/platform-tools:/opt/node/bin
root@617e9519159c:/data# ls -l
total 12
drwxr-xr-x 32 1006 users 4096 Aug 25 06:10 Cordova-Examples
drwxr-xr-x  9 1006 users 4096 Aug 23 14:23 cordova-examples
drwxr-xr-x  4 1006 users 4096 Aug 25 06:12 olympic-dashboard-d3

其中 root@ 後面那串十六進位數字, 就是虛擬機代號。 用 docker ps -a 也會看到相同的數字。 如果當初建立及啟動虛擬機時沒有用 --name 來取名字的話, 之後要管理這部虛擬機時, 就需要用這串數字來稱呼它。

四、 在平行宇宙裡跟自己的複製人分享食物

將來在虛擬機裡面下 cordova 指令編譯程式時, 所產生的檔案, 回到 host 裡面也看得見。 我們希望要用自己的身份而不要用 root 的身份來產生這些檔案, 所以在 guest 裡面建一個同樣名為 ckhung 的帳號, 連 uid 都要跟 host 一模一樣:

useradd -g users -u 1006 -s /bin/bash -d /home/ckhung -m ckhung
echo 'export PATH=剪貼上面看到的' >> /home/ckhung/.bashrc

其中 ckhung 請改成你的帳號名稱; 1006 請改成你看到的 uid; 「剪貼上面看到的」 指的是把上面 echo $PATH 所印出來的結果貼上來。

五、 試車

於是, 在虛擬機裡面就可以 su ckhung 變身成自己, 然後開始下 cordova 指令:

cordova create my1st_app info.frdm.my1st_app
cd my1st_app/
ls -l
cordova platform add android
cordova build

第一句話建立一個 app 的工作目錄, app 名為 my1st_app; 至於第二個參數, 因為我擁有 frdm.info 這個網域, 所以我的 app 的 (全球唯一、 不會撞山的) 名字長那樣。 只是測試的話, 當然隨便取沒關係。 倒數第二句話告訴 cordova 你要產生 android 平臺的 app 安裝檔。 最後一句就真的開始編譯, 產生一個 (什麼事也沒做的) apk 檔。

要測試的話, 我個人覺得 android-x86 比 android sdk 的模擬器要快多、 好用多了。

  1. 開另一個終端機 (現在已經回到 host!) 以 root 身份啟動 android-x86: kvm -monitor stdio -m 1024 -vga std -cpu host -soundhw es1370 -redir tcp:4444::5555 ..../android-x86.img
  2. 再開另一個終端機 (還是在 host, 非 root 用戶的身份) 指定與 android-x86 模擬器連線: adb connect localhost:4444
  3. 找到你的 apk 檔完整路徑: find ~/app_dev -iname '*.apk'
  4. 把它安裝起來: adb install -r /home/ckhung/app_dev/my1st_app/platforms/android/ant-build/MainActivity-debug.apk
  5. 啟動你的第一個 android app: adb shell am start -n info.frdm.my1st_app/info.frdm.my1st_app.MainActivity
    註: 如果有安裝 aapt 套件, aapt d badging xyz.apk | grep -i activity 就可以知道 -n 後面該放什麼參數。

如果你把 my1st_app 的整個子目錄砍掉重練, 裡面有些數位簽章的資訊會改變。 再重裝全新編譯的 apk 時, 會出現 INSTALL_PARSE_FAILED_INCONSISTENT_CERTIFICATES 這樣的錯誤。 這時必須先移除舊版的 apk: adb uninstall info.frdm.MainActivity-debug

不同版本的 Android 平臺對應到 不同的 API level, 例如 4.4 對應到 API level 19。 有時可能需要 在 (每個 app 工作目錄的) config.xml 設定檔裡面指定你偏好的版本, 例如:

<preference name="android-minSdkVersion" value="16" />
<preference name="android-targetSdkVersion" value="19" />

以免 太舊的手機誤裝你的 app、 出問題而給你的 app 打負評

六、 docker 虛擬機管理

在 docker 的虛擬機裡面, 只要按 ctrl-d 就退回 host, 而 guest 也就冷凍住了。 你可以開啟 (host 的) 另一個命令列分頁, 在 ctrl-d 之前與之後下 docker ps -a 查看有何不同 ("Up xx minutes" vs "Exited (0) 1 seconds ago")。

比方說你關機睡覺。 明天再開, 會看到 guest 冷凍住的狀態。 啟動: docker start cordova 連上線: docker attach cordova (沒反應? 再多按一次 Enter。) 又可以繼續開發編譯你的 app。

如果想把整部虛擬機砍掉重練, 可以: docker rm cordova。 當然, 第四節的成果也就全部泡湯了 -- 不只你的複製人, 連整個平行宇宙都毀了啊!

七、 結語

採用 cordova 的好處是: 有超級多現成的 javascript 函式庫可用。 壞處是: 諸如 「從 sdcard 讀資料」 之類你以為應該很簡單的事, 竟然花了我一個禮拜才摸索出來! 它所提供的 API 很低階, 用起來很辛苦。 官網推薦 幾個較高階的 UI 框架, 諸如 ionic 等等; 輕量級的 phonon 也是我正在考慮的選項之一。 對於網頁程式設計師而言, 這些可能都很簡單; 唯一的問題是: 有必要用 cordova 嗎? 也許 web app 就可以直接取代 cordova? ' + text + ''; } } } return text; } var parse = function(data) { cursor = null; var comments = []; if (data && data.feed && data.feed.entry) { for (var i = 0, entry; entry = data.feed.entry[i]; i++) { var comment = {}; // comment ID, parsed out of the original id format var id = /blog-(\d+).post-(\d+)/.exec(entry.id.$t); comment.id = id ? id[2] : null; comment.body = bodyFromEntry(entry); comment.timestamp = Date.parse(entry.published.$t) + ''; if (entry.author && entry.author.constructor === Array) { var auth = entry.author[0]; if (auth) { comment.author = { name: (auth.name ? auth.name.$t : undefined), profileUrl: (auth.uri ? auth.uri.$t : undefined), avatarUrl: (auth.gd$image ? auth.gd$image.src : undefined) }; } } if (entry.link) { if (entry.link[2]) { comment.link = comment.permalink = entry.link[2].href; } if (entry.link[3]) { var pid = /.*comments\/default\/(\d+)\?.*/.exec(entry.link[3].href); if (pid && pid[1]) { comment.parentId = pid[1]; } } } comment.deleteclass = 'item-control blog-admin'; if (entry.gd$extendedProperty) { for (var k in entry.gd$extendedProperty) { if (entry.gd$extendedProperty[k].name == 'blogger.itemClass') { comment.deleteclass += ' ' + entry.gd$extendedProperty[k].value; } else if (entry.gd$extendedProperty[k].name == 'blogger.displayTime') { comment.displayTime = entry.gd$extendedProperty[k].value; } } } comments.push(comment); } } return comments; }; var paginator = function(callback) { if (hasMore()) { var url = config.feed + '?alt=json&v=2&orderby=published&reverse=false&max-results=50'; if (cursor) { url += '&published-min=' + new Date(cursor).toISOString(); } window.bloggercomments = function(data) { var parsed = parse(data); cursor = parsed.length < 50 ? null : parseInt(parsed[parsed.length - 1].timestamp) + 1 callback(parsed); window.bloggercomments = null; } url += '&callback=bloggercomments'; var script = document.createElement('script'); script.type = 'text/javascript'; script.src = url; document.getElementsByTagName('head')[0].appendChild(script); } }; var hasMore = function() { return !!cursor; }; var getMeta = function(key, comment) { if ('iswriter' == key) { var matches = !!comment.author && comment.author.name == config.authorName && comment.author.profileUrl == config.authorUrl; return matches ? 'true' : ''; } else if ('deletelink' == key) { return config.baseUri + '/delete-comment.g?blogID=' + config.blogId + '&postID=' + comment.id; } else if ('deleteclass' == key) { return comment.deleteclass; } return ''; }; var replybox = null; var replyUrlParts = null; var replyParent = undefined; var onReply = function(commentId, domId) { if (replybox == null) { // lazily cache replybox, and adjust to suit this style: replybox = document.getElementById('comment-editor'); if (replybox != null) { replybox.height = '250px'; replybox.style.display = 'block'; replyUrlParts = replybox.src.split('#'); } } if (replybox && (commentId !== replyParent)) { replybox.src = ''; document.getElementById(domId).insertBefore(replybox, null); replybox.src = replyUrlParts[0] + (commentId ? '&parentID=' + commentId : '') + '#' + replyUrlParts[1]; replyParent = commentId; } }; var hash = (window.location.hash || '#').substring(1); var startThread, targetComment; if (/^comment-form_/.test(hash)) { startThread = hash.substring('comment-form_'.length); } else if (/^c[0-9]+$/.test(hash)) { targetComment = hash.substring(1); } // Configure commenting API: var configJso = { 'maxDepth': config.maxThreadDepth }; var provider = { 'id': config.postId, 'data': items, 'loadNext': paginator, 'hasMore': hasMore, 'getMeta': getMeta, 'onReply': onReply, 'rendered': true, 'initComment': targetComment, 'initReplyThread': startThread, 'config': configJso, 'messages': msgs }; var render = function() { if (window.goog && window.goog.comments) { var holder = document.getElementById('comment-holder'); window.goog.comments.render(holder, provider); } }; // render now, or queue to render when library loads: if (window.goog && window.goog.comments) { render(); } else { window.goog = window.goog || {}; window.goog.comments = window.goog.comments || {}; window.goog.comments.loadQueue = window.goog.comments.loadQueue || []; window.goog.comments.loadQueue.push(render); } })(); // ]]>

  1. 這樣出來的會是native嗎?

    回覆刪除
    回覆
    1. 其實是有著內建瀏覽器的APP,跑著你的網頁,只是多了一些原生功能
      例如瀏覽器無法控制相機、閃光燈...等等,但用 cordova 包的 app 可以
      這是我自己的感想啦,實際上也許不是這樣XD
      用同一個檔案就可以包 ipa / apk 很適合跨平台就是了

      刪除
    2. 兩位的問答促使我寫一整篇回應哦 :-) 謝謝啦!
      http://newtoypia.blogspot.tw/2015/08/chrome-web-app-hybrid.html
      『Chrome 的「禁讀令」 讓 web app 不太可能取代 hybrid app』

      刪除
    3. 感謝上面, 讓我看到這些寶貴經驗.

      刪除
  2. 感覺很神奇。ckhung 的文章也寫的蠻好的,謝謝介紹。

    回覆刪除
  3. It feels amazing. Ckhung's article is also written very well, thank you for your introduction.
    Sap Basis Training From India


    回覆刪除
  4. 碰到一個麻煩的問題,因為有手動創過群組,所以宿主機上使用者的 uid 和 gid 不一樣,但似乎無法分別指定新建立帳號的 uid 和 gid ?因為指定 gid 時需要該群組已經存在。

    回覆刪除
    回覆
    1. 那如果先用 groupadd 建一個 group 再用 useradd -g xxx ... 可以嗎? 會遇到什麼問題嗎?

      刪除

因為垃圾留言太多,現在改為審核後才發佈,請耐心等候一兩天。