2022年1月25日 星期二

jQuery Mobile 學習筆記 (二十二) : 用 Web Storage 製作備忘錄 App (上)

上週因為要因應 IE 走入歷史須將輔助工作自動化的網頁程式改版, 在翻閱 HTML5 書籍尋求 ActiveX 的替代技術時看到 localStorage 物件, 覺得是可行方案之一, 於是便做了一些測試, 覺得雖然僅有約 5MB 容量, 倒也足以應付工作需求. 

做完測試後剛好想起同事去年底的囑託, 希望我可以將我十幾年前幫單位寫的工作日誌系統原始碼給他, 因為覺得很好用想要做為自己的私人資訊系統, 但我說那系統太大也太舊了不合用, 現在是行動通訊時代人手一機, 有空我寫個微小版的個人工作日誌系統給他. 既然應承了, 剛好又學完 localStorage, 不如打鐵趁熱, 結合 jQuery Mobile 與 localStorage 寫個網頁應用程式吧! 還可以用線上服務轉成 App. 

同事的要求很簡單, 就是可以新增, 編輯, 以及刪除記事, 相當於以前工作日誌系統裡面的 CRUD 最基本功能, 但去掉可追蹤工作流程的堆疊式文章架構, 當然也不需要稽核要看的主管閱覽等管理功能, 純粹就是表格式的記事本. 希望保留記事類別欄位, 還可以搜尋云云, 但這對 localStoage 來說有點過分了, 等後續我測試完 indexedDB 再看看唄! 

本篇先來完成備忘錄的 CRUD 基本功能就好, 順便複習一下 jQuery Mobile 的用法. 比起現在紅到發紫的 React 等, jQuery Mobile 算是上個世紀的技術了, 雖然老狗玩不出新把戲, 但老把戲還是能用的. 

關於 jQuery Mobile 參考 : 


我的規劃是使用單頁應用架構 (SPA), 所以 CRUD 似乎需要四個 page, 但其實 Delete 動作是透過函式完成, 刪除完就跳回備忘錄列表頁面, 所以其實只需要三個 Page (C, R, 與 U), 另外加上兩個 Dialog 對話框頁面 (負責確認 removeItem 與 clear). 

在這三天測試過程中才發現我對 jQuery Mobile 的操控還是有許多盲點, 例如用程式碼 $.mobile.changePage() 換頁, 以及用 trigger("create") 來動態新增 DOM 元素, 這是這幾天才學會的新用法. 

記事列表我使用 TABLE 表格來呈現, 原本想用 ListView, 但發現要放編輯 icon 有困難, 所以還是用表格唄, 這也跟之前的工作日誌系統較像. 在 jQuery 中使用表格的方法參考 :


不過本篇用的是下列這篇文章中所使用的特殊樣式表格, 它的功能較簡單, 就是單純顯示表列資料而已, 沒有分頁功能, 下回再試試有分頁的表格 :



一. 製作 jQuery Mobile 網頁程式 : 

儲存在 localStorage 中的備忘記事以格式為 YYYY-MM-DDTHH:mm:SS 的日期時間為鍵, 以記事內容為值, 例如 :

{"2022-01-24T08:45:27": "Hello World"}
{"2022-01-24T09:31:27": "你是在說哈囉嗎?"}

其中 key 是用呼叫 new Date() 得到的日期時間物件傳給 JSON.stringify() 轉成字串後再取出前 19 個字元而得. 省略秒後面的資訊, 畢竟一般不會在同一秒內輸入兩筆備忘事項. 

底下先列出整個 SPA 專案的完整原始碼, 然後再進行說明 :



<!DOCTYPE html>
<html>
  <head>
    <title></title>
    <meta charset="utf-8">
    <meta http-equiv="cache-control" content="no-cache">
    <meta name="viewport" content="width=device-width,initial-scale=1">
    <script src="https://code.jquery.com/jquery-1.11.1.min.js"></script>
    <script src="https://code.jquery.com/mobile/1.4.5/jquery.mobile-1.4.5.min.js"></script>
    <link href="https://code.jquery.com/mobile/1.4.5/jquery.mobile-1.4.5.min.css" rel="stylesheet">
    <style>
      table {
          color: black;
          background: #fff;
          border: 1px solid #b4b4b4;
          padding: 0;
          margin-top:10px;
          width: 100%;
          -webkit-border-radius: 8px;
          }
      table tr td {
          color: #666;
          border-bottom: 1px solid #b4b4b4;
          border-right: 1px solid #b4b4b4;
          padding: 5px 5px 5px 5px;
          background-image: -webkit-linear-gradient(top, #fdfdfd, #eee);
          }
      table tr:first-child td {
          border-top: 1px solid #b4b4b4;
          }
      table tr td:last-child {
          border-right: none;
          }
      table tr:last-child td {
          border-bottom: none;
          }
    </style>
  </head>
  <body>
    <!-- 第一頁頁面 (顯示備忘事項列表) -->
    <section data-role="page" id="page1">
      <header data-role="header" data-position="fixed">
        <h1>我的備忘錄</h1>
        <a href="#confirm_clear_dialog" id="clear_memo_btn" class="ui-btn-right" data-rel="dialog">清除</a>
      </header>
      <article data-role="content">
        <a href="#page2" data-role="button" data-icon="plus">新增</a>
        <table>
          <thead>
            <tr>
              <th>備忘事項</th>
              <th>編輯</th>
            </tr>
          </thead>
          <tbody id="memo_list">
          </tbody>
        </table>
      </article>
      <footer data-role="footer" data-position="fixed">
        <h3>© 2022 LF.Studio</h3>
      </footer>
    </section>
    <!-- 第二頁頁面 (新增備忘事項) -->
    <section data-role="page" id="page2">
      <header data-role="header" data-position="fixed" data-add-back-btn="true" data-back-btn-text="返回">
        <h1>新增備忘事項</h1>
      </header>
      <article data-role="content">
        <form data-ajax="false">
          <div data-role="fieldcontain">
            <label for="add_memo">備忘事項 : </label>
            <textarea id="add_memo"></textarea>
          </div>
          <input type="button" id="add_memo_btn" value="儲存" data-icon="check">
        </form>
      </article>
      <footer data-role="footer" data-position="fixed">
        <h3>© 2022 LF.Studio</h3>
      </footer>
    </section>
    <!-- 第三頁頁面 (編輯備忘事項) -->
    <section data-role="page" id="page3">
      <header data-role="header" data-position="fixed" data-add-back-btn="true" data-back-btn-text="返回">
        <h1>編輯備忘事項</h1>
      </header>
      <article data-role="content">
        <form data-ajax="false">
          <div data-role="fieldcontain">          
            <input type="hidden" id="edit_memo_key">
            <label for="edit_memo_value">備忘事項 : </label>
            <textarea id="edit_memo_value"></textarea>
          </div>
          <div data-role="controlgroup" data-type="horizontal">
            <a href="#" data-role='button' data-rel="back" data-icon="back" data-inline="true">取消</a>
            <a href="#confirm_remove_dialog" id="remove_memo_btn" data-role='button' data-rel="dialog" data-icon="delete" data-inline="true">刪除</a>
            <a href="#" id="update_memo_btn" data-role='button' data-icon="check" data-inline="true">更新</a>
          </div>
        </form>
      </article>
      <footer data-role="footer" data-position="fixed">
        <h3>© 2022 LF.Studio</h3>
      </footer>
    </section>
    <!-- 對話框頁面 1 (清除全部資料) -->
    <section data-role="dialog" id="confirm_clear_dialog" data-overlay-theme="b">
      <header data-role="header">
        <h1>請確認</h1>
      </header>
      <article data-role="content">
        <p>確定要清除全部資料嗎?</p>
        <div data-role="controlgroup" data-type="horizontal">
          <a href="#" data-role='button' data-icon='back'  data-inline="true" data-rel="back">取消</a>
          <a href="#" id="confirm_clear_btn" data-role='button' data-icon='check'  data-inline="true">確定</a>
        </div>
      </article>
    </section>
    <!-- 對話框頁面 2 (刪除單筆資料) -->
    <section data-role="dialog" id="confirm_remove_dialog" data-overlay-theme="b">
      <header data-role="header">
        <h1>請確認</h1>
      </header>
      <article data-role="content">
        <p>確定要刪除這筆備忘事項嗎?</p>
        <div data-role="controlgroup" data-type="horizontal">
          <a href="#" data-role='button' data-icon='back' data-inline="true" data-rel="back">取消</a>
          <a href="#" id="confirm_remove_btn" data-role='button' data-icon='check'  data-inline="true">確定</a>
        </div>
      </article>
    </section>
    <script>
      function sort_local_storage(){
        var arr=[];
        for (var i=0; i<localStorage.length; i++){
          var k=localStorage.key(i);
          var v=localStorage.getItem(k); 
          arr.push(k + "+" + v);
          }
        arr.sort();
        arr.reverse();
        return arr;
        }
      function set_edit_memo(k){
        var v=localStorage.getItem(k);
        $("#edit_memo_key").val(k);
        $("#edit_memo_value").val(v);
        render_memo_list();
        $.mobile.changePage($("#page3"));
        }
      function render_memo_list(){
        $("#memo_list").empty();
        var memo_arr=sort_local_storage();
        for (var i=0; i<memo_arr.length; i++){
          var arr=memo_arr[i].split("+");
          var k=arr[0];
          var v=arr[1];
          var item="<tr><td>" + v + "</td>" +
                   "<td style='text-align: center;'>" + 
                   "<a href='#' data-role='button' " + 
                   "data-icon='edit' data-iconpos='notext' " + 
                   "data-mini='true' data-inline='true' " + 
                   "onclick='set_edit_memo(" + '"' + k + '"' +
                   ")'>編輯</a></td></tr>";
          $(item).appendTo("#memo_list").trigger("create");
          }
        }
      $(document).one("pageinit", function(){
        if (localStorage.length == 0){
          var item="<tr><td colspan=2>無備忘事項</td></tr>";
          $(item).appendTo("#memo_list").trigger("create");
          }
        else {render_memo_list();}
        $("#add_memo_btn").on("click", function(e) {
          var k=JSON.stringify(new Date()).substr(1,19);
          var v=$("#add_memo").val();
          localStorage.setItem(k, v); 
          $("#add_memo").val("");
          render_memo_list();
          $.mobile.changePage($("#page1"));
          });
        $("#confirm_clear_btn").on("click", function(e) {
          localStorage.clear();
          $("#memo_list").empty();
          var item="<tr><td colspan=2>無備忘事項</td></tr>";
          $(item).appendTo("#memo_list").trigger("create");
          $.mobile.changePage($("#page1"));
          });
        $("#edit_memo_btn").on("click", function(e) {
          $.mobile.changePage($("#page3"));
          });
        $("#confirm_remove_btn").on("click", function(e) {
          var k=$("#edit_memo_key").val();
          localStorage.removeItem(k);
          render_memo_list();
          $.mobile.changePage($("#page1"));
          });
        $("#update_memo_btn").on("click", function(e) {
          var k=$("#edit_memo_key").val();
          var v=$("#edit_memo_value").val();
          localStorage.setItem(k, v);
          render_memo_list();
          $.mobile.changePage($("#page1"));
          });
        }); 
    </script>
  </body>
</html>

上面的網頁程式原始碼中有三個 page 與兩個 dialog, 功能說明如下 : 
  • page1 :
    以表格顯示備忘記事列表 (上方有新增按鈕至 page2, 表格第二欄有編輯鈕至 page3.
  • page2 : 
    新增記事表單, 按 "儲存" 鈕將記事存入 localStorage 後自動返回 page1 記事列表.
  • page3 :
    編輯記事表單, 更改記事內容後按 "確定" 更新 localStorage 後自動返回 page1 記事列表; 按 "刪除" 則從 localStorage 刪除該資料後自動返回 page1 記事列表.
  • dialog1 : 
    詢問是否清除全部 localStorage 資料的對話框, 按 "確定" 清除 localStorage 全部資料後自動返回 page1 記事列表.
  • dialog2 :
    詢問是否刪除 localStorage 中指定 key 資料的對話框, 按 "確定" 刪除 localStorage 該筆資料後自動返回 page1 記事列表 
Javascript 程式碼部分有三個函式 :
  • sort_local_storage() :
    此函式用來將 localStorage 內儲存的資料進行排序, 由於物件的內容 (鍵值對) 是無序的, 因此沒有 sort() 方法可用來排序, 此函式的作法是用迴圈走訪 localStorage 儲存區的全部資料, 然後將 key 與 value 用 "+" 串接後存入空陣列, 再用陣列的 sort() 方法做升序排序, 最後呼叫 reverse() 做降序排序, 再將此陣列傳回. 呼叫者走訪此陣列, 將元素以 "+" 拆分即可得到排序後的 key 與 value 了, 參考 :
    HTML5 local storage sort
  • set_edit_memo(k) : 
    此函式是使用者在按下記事列表中的編輯鈕後被呼叫, 目的是將傳進來的鍵 (k) 與查得之值設定到 page2 的編輯欄位中 (key 用隱藏欄位儲存), 以便稍後切換到 page2 時會顯示被編輯的記事內容. 
  • render_memo_list() : 
    此函式會先呼叫 sort_local_storage() 取得排序後的資料陣列, 然後在走訪此陣列的迴圈中以 "+" 為界拆解出 key 與 value, 用來產生表格內容 (tbody 元素的子元素 tr 與 td), 然後呼叫 appendTo() 方法將這些內容掛到 tbody 底下, 接著呼叫 trigger("create") 方法更新 DOM 樹. 每次做新增與刪除這兩種改變儲存區內容的動作後都會先呼叫此函式來更新內容, 再切換到 page1 去. 
其他在 pageinit 事件內部的程式碼都是用 jQuery 來處理元件觸發的事件. 結果如下 : 

剛使用此網頁 App 時因為 localStorage 為空, 故記事列表亦為空 :




按 "新增" 鈕會切換到記事輸入頁 (page2), 填寫後按 "確定" 會將此資料存入 localStorage 儲存區, 並轉回記事列表頁 (page1) : 





繼續輸入第二筆備忘記事 : 





按下第二欄的編輯按鈕會切換到 page3, 可對記事內容進行修改, 按 "確定" 會更新 localStorage 內的資料, 然後轉回記事列表頁 (page1) : 





若在編輯頁面按下 "刪除" 鈕, 則會跳出確認對話框 (dialog2), 按 "確定" 會從 localStorage 內刪除該筆資料, 然後轉到記事列表 (page1) 頁面 : 





按頁面右上角的 "清除" 鈕也會跳出對話框 (dialog1), 詢問是否要清除全部 localStorage 資料, 按 "確定" 會刪除全部資料後跳轉 page1, 這時就會回復到空的記事列表了 : 




感覺似乎與後端資料庫的 CRUD 操作一模一樣, 但其實是在存取本機客戶端的 localStorage 而已, 資料並沒有透過網路傳輸, 亦即完全是是離線作業, 非常適合用來儲存個人資料. 


二. 將 jQuery Mobile 行動網頁程式轉成 Android App (apk 檔) : 

完成 jQuery Mobile 網頁應用程式設計後即可利用 appsgeyser 這個免費的線上轉換服務將行動網頁 App 轉成 Android 的 App, 其網址為 :


作法參考 :


為了讓 appgeyser 能讀取網頁原始檔, 要先將上面的 jQuery Mobile 網頁放到一個伺服器上, 最方便的是靜態網頁伺服器 GitHub, 參考 : 


然後還要幫 App 準備一個 icon, 我搜尋 "free memo icon" 找到一個適合的免費圖檔來修改, appgeyser 要求圖檔大小不可超過 512*512. 準備好就可以按下 "CREATE APP NOW" 鈕了 : 




在 "Website URL" 欄填入行動網頁 App 的網址後按 "NEXT" : 




在 "APP NAME" 欄填入 App 名稱 (中英文皆可, 不要太長), 按 "NEXT" :




點選 "Custom icon", 再按右邊的 "Upload 512*512" 上傳圖檔 : 



在彈出頁面中移動底下的滑桿選擇範圍, 然後按底下的 "Crop" 鈕裁切並返回設定頁面 : 




按 "NEXT" 鈕 :




按 "CREATE" 鈕進行 Apk 檔製作, 這時會要求註冊帳號 (過程略) : 




Apk 檔建立完成顯示如下頁面, 按最上方的下載按鈕即可下載 apk 檔 : 

 


也可以用手機掃 QR code 下載或郵寄到註冊信箱 : 






下載後開啟 apk 檔安裝 : 






彈出安全性提示, 選擇 "仍要安裝" 與 "不傳送" : 






完成安裝後桌面就出現此 App 了, 點選執行結果與上面行動網頁 App 完全一樣 : 





OK, 總算完工啦!

參考資料 : 


沒有留言 :