-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathatom.xml
More file actions
357 lines (189 loc) · 211 KB
/
atom.xml
File metadata and controls
357 lines (189 loc) · 211 KB
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
<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>Coding Story</title>
<subtitle>在黑暗中寫故事 👻</subtitle>
<link href="https://wm4n.github.io/atom.xml" rel="self"/>
<link href="https://wm4n.github.io/"/>
<updated>2021-03-23T16:54:23.484Z</updated>
<id>https://wm4n.github.io/</id>
<author>
<name>William</name>
</author>
<generator uri="https://hexo.io/">Hexo</generator>
<entry>
<title>Android WebView 悲劇!如何判斷 App 有受影響</title>
<link href="https://wm4n.github.io/Android-WebView-%E6%82%B2%E5%8A%87%EF%BC%81%E5%A6%82%E4%BD%95%E5%88%A4%E6%96%B7-App-%E6%9C%89%E5%8F%97%E5%BD%B1%E9%9F%BF/"/>
<id>https://wm4n.github.io/Android-WebView-%E6%82%B2%E5%8A%87%EF%BC%81%E5%A6%82%E4%BD%95%E5%88%A4%E6%96%B7-App-%E6%9C%89%E5%8F%97%E5%BD%B1%E9%9F%BF/</id>
<published>2021-03-23T15:48:05.000Z</published>
<updated>2021-03-23T16:54:23.484Z</updated>
<content type="html"><![CDATA[<h2 id="前言故事"><a href="#前言故事" class="headerlink" title="前言故事"></a>前言故事</h2><p>今天一大早,小唯的信箱開始不斷湧入詢問信件、Slack 群組上比平日更多的 @Mention,坐在位置上也是不斷地有人敲椅背,這全都指向一個問題「有使用者回報 App 一開就閃退」!🙄 小唯直覺回覆「我們前後端這幾天都沒更新啊!」接著網路新聞就不斷出現 Android WebView 的災情,立刻讓小唯想調查看看是不是相關,照著網路上的爬文,拿幾台測試機來更新 Play Store 上的 WebView,但卻無法複製問題(試過 Pixel、Samsung、Oppo 等廠牌與型號),所以小唯要如何給個合理解釋呢?<br><br/></p><h2 id="偵測當機的工具"><a href="#偵測當機的工具" class="headerlink" title="偵測當機的工具"></a>偵測當機的工具</h2><p>Firebase Crashlytics 是 Android 工程經常用來記錄使用者當機資訊的工具之一,但在這次 WebView 災情中,並沒有記錄到有異常的當機事件,所以 Crashlytics 這次並沒有太大幫助。反之,Play console 的 Android Vitals 這次卻相當有幫助(在小唯公司中,工程們不常用這工具,推測是 Play console 一般被誤認為是產品經理上架用的工具 😣)。</p><p>以下是 Android Vitals 的異常紀錄,可以看到 3/23 這天異常的當機:</p><img src="/Android-WebView-%E6%82%B2%E5%8A%87%EF%BC%81%E5%A6%82%E4%BD%95%E5%88%A4%E6%96%B7-App-%E6%9C%89%E5%8F%97%E5%BD%B1%E9%9F%BF/vitals_02.png" class=""><br/><p>點入看細節,會發現這幾個 <code>signal 11 (SIGSEGV), code 1 (SEGV_MAPERR)</code> 都是主要出現在 3/23 日</p><img src="/Android-WebView-%E6%82%B2%E5%8A%87%EF%BC%81%E5%A6%82%E4%BD%95%E5%88%A4%E6%96%B7-App-%E6%9C%89%E5%8F%97%E5%BD%B1%E9%9F%BF/vitals_01.png" class=""><br/><p>如果觀察堆疊追蹤,會發現它們都跟 <code>webview</code>、<code>trichromelibrary</code> 與 <code>chrome</code> 相關:</p><img src="/Android-WebView-%E6%82%B2%E5%8A%87%EF%BC%81%E5%A6%82%E4%BD%95%E5%88%A4%E6%96%B7-App-%E6%9C%89%E5%8F%97%E5%BD%B1%E9%9F%BF/vitals_03.png" class=""><br/><img src="/Android-WebView-%E6%82%B2%E5%8A%87%EF%BC%81%E5%A6%82%E4%BD%95%E5%88%A4%E6%96%B7-App-%E6%9C%89%E5%8F%97%E5%BD%B1%E9%9F%BF/vitals_04.png" class=""><br/><img src="/Android-WebView-%E6%82%B2%E5%8A%87%EF%BC%81%E5%A6%82%E4%BD%95%E5%88%A4%E6%96%B7-App-%E6%9C%89%E5%8F%97%E5%BD%B1%E9%9F%BF/vitals_05.png" class=""><br/><h2 id="後續發展"><a href="#後續發展" class="headerlink" title="後續發展"></a>後續發展</h2><p>雖然沒辦法直接證明今天早上回報的問題都是與 WebView 相關,但至少有個說法,讓產品經理有方向讓使用者去試試(除非真的很影響使用,要不小唯是不太建議照網路的做法,反安裝 WebView 更新,畢竟有些更新與安全性相關…,雖然產品經理不這麼想)</p><p>就這樣,小唯暫時度過了這漫長的一天… 😞<br><br/><br><br/></p><div> <div style="text-align:left;font-size:1em;"><p style="text-align:center;color: #ccc;">------------- 本文结束 <i class="far fa-surprise"></i> 每天都有驚喜 -------------</p> </div> </div><br/>]]></content>
<summary type="html">Android 開發時,使用系統 WebView 是經常有的事,不論是外開網頁或是利用現成網頁來做些 POC 實驗,WebView 都是相當便利的一個選項。但是 3/23 的今天,一個 Play Store 的 WebView 更新,竟然造成了大多數 Android 使用者的惡夢,十個 App 可能有七八個會遭遇閃退。小唯今天一早也不斷接到不明所以的閃退回報,但如同大多數問題,使用者只提說會閃退,要更多的資訊幾乎是不可能,在這情況下要如何判斷自己的 App 是否也遭受 WebView 波及呢?</summary>
<category term="小唯的故事" scheme="https://wm4n.github.io/categories/%E5%B0%8F%E5%94%AF%E7%9A%84%E6%95%85%E4%BA%8B/"/>
<category term="Android" scheme="https://wm4n.github.io/categories/%E5%B0%8F%E5%94%AF%E7%9A%84%E6%95%85%E4%BA%8B/Android/"/>
<category term="Android" scheme="https://wm4n.github.io/tags/Android/"/>
<category term="WebView" scheme="https://wm4n.github.io/tags/WebView/"/>
</entry>
<entry>
<title>Android Room Database 立馬上手就看這篇</title>
<link href="https://wm4n.github.io/Android-Room%20Database-%E7%AB%8B%E9%A6%AC%E4%B8%8A%E6%89%8B%E5%B0%B1%E7%9C%8B%E9%80%99%E7%AF%87/"/>
<id>https://wm4n.github.io/Android-Room%20Database-%E7%AB%8B%E9%A6%AC%E4%B8%8A%E6%89%8B%E5%B0%B1%E7%9C%8B%E9%80%99%E7%AF%87/</id>
<published>2021-02-20T08:59:34.000Z</published>
<updated>2021-03-23T16:54:23.484Z</updated>
<content type="html"><![CDATA[<h2 id="前言故事"><a href="#前言故事" class="headerlink" title="前言故事"></a>前言故事</h2><p>自從團隊重新打造新 App 開始,小唯就四處查看是否有可用的新技術,Android Jetpack 中的 Room 就是其中之一(話說那時候好像還不是 Part of Jetpack)。Room 是 Google 官方前幾年推出來的技術,官方也建議開發者使用 Room 而不直接使用 SQLite APIs。小唯的團隊當下就開始使用 Room 來取代原本的 SQLite 程式碼,當然途中一定遇到了不少大小問題,也是小唯紀錄這幾篇的主因之一,希望避免再有人浪費時間在這些問題上。</p><div class="note success"> <p>(如何從現有的 db 檔來建立在 2.2 版 Room 已有支援,可查看這篇<a href="https://developer.android.com/training/data-storage/room/prepopulate">官方文章</a>。當初小唯團隊使用時可還沒有,那時是使用 <a href="https://github.com/humazed/RoomAsset">humazed/RoomAsset</a>。</p> </div><h2 id="建立-Room-DB"><a href="#建立-Room-DB" class="headerlink" title="建立 Room DB"></a>建立 Room DB</h2><p>要使用 Room,須先定義 database 中要包含哪些 table,裡面有哪些動作可以執行等,例:</p><figure class="highlight java"><figcaption><span>AppDatabase.java</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line"><span class="meta">@Database(entities = {SearchHistoryEntity.class}, version = 1)</span></span><br><span class="line"><span class="meta">@TypeConverters({RoomConverter.class})</span></span><br><span class="line"><span class="keyword">public</span> <span class="keyword">abstract</span> <span class="class"><span class="keyword">class</span> <span class="title">AppDatabase</span> <span class="keyword">extends</span> <span class="title">RoomDatabase</span> </span>{</span><br><span class="line"> <span class="function"><span class="keyword">public</span> <span class="keyword">abstract</span> SearchHistoryDao <span class="title">searchHistoryDao</span><span class="params">()</span></span>;</span><br><span class="line">}</span><br></pre></td></tr></table></figure><p><code>@Database(entities = {SearchHistoryEntity.class}, version = 1)</code> 定義這個 database 內含有什麼 table,和 table 中有什麼欄位。</p><p><code>@TypeConverters({RoomConverter.class})</code> 定義當 Room 遇到不能識別的類型,該要如何做轉換。</p><p><code>SearchHistoryDao</code> 定義 database 支援什麼類型的 database 動作,像是新增、刪除、讀取,通常一個動作就是一個 sqlite statement。<br><br/></p><h2 id="定義-Entity-資料"><a href="#定義-Entity-資料" class="headerlink" title="定義 Entity 資料"></a>定義 Entity 資料</h2><p>Entity 將相關的資料欄位合併在一起,要比擬的話可以看做 sqlite 中的 table row 。先來看 <code>SearchHistoryEntity.java</code>,這個 entity 定義了搜尋紀錄的資料,內容很直覺的。這邊有特地舉出使用三個非 sqlite primitive 的欄位:</p><figure class="highlight java"><figcaption><span>SearchHistoryEntity.java</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br><span class="line">45</span><br><span class="line">46</span><br><span class="line">47</span><br><span class="line">48</span><br><span class="line">49</span><br><span class="line">50</span><br><span class="line">51</span><br><span class="line">52</span><br><span class="line">53</span><br><span class="line">54</span><br><span class="line">55</span><br><span class="line">56</span><br><span class="line">57</span><br><span class="line">58</span><br><span class="line">59</span><br><span class="line">60</span><br><span class="line">61</span><br><span class="line">62</span><br><span class="line">63</span><br><span class="line">64</span><br><span class="line">65</span><br><span class="line">66</span><br><span class="line">67</span><br><span class="line">68</span><br><span class="line">69</span><br><span class="line">70</span><br><span class="line">71</span><br><span class="line">72</span><br><span class="line">73</span><br><span class="line">74</span><br><span class="line">75</span><br><span class="line">76</span><br><span class="line">77</span><br><span class="line">78</span><br><span class="line">79</span><br><span class="line">80</span><br><span class="line">81</span><br></pre></td><td class="code"><pre><span class="line"><span class="meta">@Entity(tableName = "search_history")</span></span><br><span class="line"><span class="keyword">public</span> <span class="class"><span class="keyword">class</span> <span class="title">SearchHistoryEntity</span> </span>{</span><br><span class="line"></span><br><span class="line"> <span class="keyword">public</span> <span class="class"><span class="keyword">enum</span> <span class="title">TYPE</span> </span>{</span><br><span class="line"> TYPE_ONE,</span><br><span class="line"> TYPE_TWO,</span><br><span class="line"> TYPE_THREE,</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> <span class="comment">// 像是 table 中的 _id 欄位</span></span><br><span class="line"> <span class="meta">@PrimaryKey(autoGenerate = true)</span></span><br><span class="line"> <span class="keyword">private</span> <span class="keyword">int</span> uid;</span><br><span class="line"></span><br><span class="line"> <span class="comment">// enum 類型,需透過 Room converter 來轉換,後面會接受</span></span><br><span class="line"> <span class="meta">@ColumnInfo(name = "type")</span></span><br><span class="line"> <span class="keyword">private</span> TYPE type;</span><br><span class="line"></span><br><span class="line"> <span class="comment">// 一般 String,沒什麼特別的</span></span><br><span class="line"> <span class="meta">@ColumnInfo(name = "display_text")</span></span><br><span class="line"> <span class="keyword">private</span> String displayText;</span><br><span class="line"></span><br><span class="line"> <span class="comment">// 透過 JSON dictionary 來把文字跟 Map 互相轉換</span></span><br><span class="line"> <span class="meta">@ColumnInfo(name = "query_string")</span></span><br><span class="line"> <span class="keyword">private</span> Map<String, String> queryString;</span><br><span class="line"></span><br><span class="line"> <span class="comment">// ISO 8601 的日期格式</span></span><br><span class="line"> <span class="meta">@ColumnInfo(name = "date")</span></span><br><span class="line"> <span class="keyword">private</span> String date;</span><br><span class="line"></span><br><span class="line"> <span class="function"><span class="keyword">public</span> <span class="title">SearchHistoryEntity</span><span class="params">(</span></span></span><br><span class="line"><span class="function"><span class="params"> TYPE type,</span></span></span><br><span class="line"><span class="function"><span class="params"> String displayText,</span></span></span><br><span class="line"><span class="function"><span class="params"> Map<String, String> queryString,</span></span></span><br><span class="line"><span class="function"><span class="params"> String date)</span> </span>{</span><br><span class="line"> <span class="keyword">this</span>.type = type;</span><br><span class="line"> <span class="keyword">this</span>.displayText = displayText;</span><br><span class="line"> <span class="keyword">this</span>.queryString = queryString;</span><br><span class="line"> <span class="keyword">this</span>.date = date;</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> <span class="comment">// Getters and setters are required for Room to work.</span></span><br><span class="line"> <span class="function"><span class="keyword">public</span> <span class="keyword">int</span> <span class="title">getUid</span><span class="params">()</span> </span>{</span><br><span class="line"> <span class="keyword">return</span> uid;</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> <span class="function"><span class="keyword">public</span> <span class="keyword">void</span> <span class="title">setUid</span><span class="params">(<span class="keyword">int</span> uid)</span> </span>{</span><br><span class="line"> <span class="keyword">this</span>.uid = uid;</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> <span class="function"><span class="keyword">public</span> TYPE <span class="title">getType</span><span class="params">()</span> </span>{</span><br><span class="line"> <span class="keyword">return</span> type;</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> <span class="function"><span class="keyword">public</span> <span class="keyword">void</span> <span class="title">setType</span><span class="params">(TYPE type)</span> </span>{</span><br><span class="line"> <span class="keyword">this</span>.type = type;</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> <span class="function"><span class="keyword">public</span> String <span class="title">getDisplayText</span><span class="params">()</span> </span>{</span><br><span class="line"> <span class="keyword">return</span> displayText;</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> <span class="function"><span class="keyword">public</span> <span class="keyword">void</span> <span class="title">setDisplayText</span><span class="params">(String displayText)</span> </span>{</span><br><span class="line"> <span class="keyword">this</span>.displayText = displayText;</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> <span class="function"><span class="keyword">public</span> Map<String, String> <span class="title">getQueryString</span><span class="params">()</span> </span>{</span><br><span class="line"> <span class="keyword">return</span> queryString;</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> <span class="function"><span class="keyword">public</span> <span class="keyword">void</span> <span class="title">setQueryString</span><span class="params">(Map<String, String> queryString)</span> </span>{</span><br><span class="line"> <span class="keyword">this</span>.queryString = <span class="keyword">new</span> HashMap<>(queryString);</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> <span class="function"><span class="keyword">public</span> String <span class="title">getDate</span><span class="params">()</span> </span>{</span><br><span class="line"> <span class="keyword">return</span> date;</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> <span class="function"><span class="keyword">public</span> <span class="keyword">void</span> <span class="title">setDate</span><span class="params">(String date)</span> </span>{</span><br><span class="line"> <span class="keyword">this</span>.date = date;</span><br><span class="line"> }</span><br><span class="line">}</span><br></pre></td></tr></table></figure><br/><h2 id="DAO-定義資料操作方式"><a href="#DAO-定義資料操作方式" class="headerlink" title="DAO 定義資料操作方式"></a>DAO 定義資料操作方式</h2><p>有了資料格式與轉換方式,接著需要的就是定義 DAO (Data Access Object),這步將原本 sqlite 中會使用的 sqlite statement 轉為 API。依照 Room 的方式,只需把 sqlite statement 放在 annotation 上:</p><figure class="highlight java"><figcaption><span>SearchHistoryDao.java</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br><span class="line">45</span><br></pre></td><td class="code"><pre><span class="line"><span class="meta">@Dao</span></span><br><span class="line"><span class="keyword">public</span> <span class="class"><span class="keyword">interface</span> <span class="title">SearchHistoryDao</span> </span>{</span><br><span class="line"></span><br><span class="line"> <span class="comment">/**</span></span><br><span class="line"><span class="comment"> * 取得所有 Search History 紀錄,依時間先後順序排序</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"> <span class="meta">@Query("SELECT * FROM search_history order by timestamp desc")</span></span><br><span class="line"> Flowable<List<SearchHistoryEntity>> getAll();</span><br><span class="line"></span><br><span class="line"> <span class="comment">/**</span></span><br><span class="line"><span class="comment"> * 取得某幾筆 Search History 紀錄,依時間先後順序排序</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"> <span class="meta">@Query("SELECT * FROM search_history WHERE uid IN (:ids) order by timestamp desc")</span></span><br><span class="line"> Flowable<List<SearchHistoryEntity>> getByIds(<span class="keyword">int</span>[] ids);</span><br><span class="line"></span><br><span class="line"> <span class="comment">/**</span></span><br><span class="line"><span class="comment"> * 取得某特定類型的 Search History 紀錄,依時間先後順序排序</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"> <span class="meta">@Query("SELECT * FROM search_history WHERE type = :type order by timestamp desc")</span></span><br><span class="line"> Flowable<List<SearchHistoryEntity>> getByType(SearchHistoryEntity.TYPE type);</span><br><span class="line"></span><br><span class="line"> <span class="comment">/**</span></span><br><span class="line"><span class="comment"> * 取得某特定類型的 Search History 紀錄,指定最多取得的筆數,依時間先後順序排序</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"> <span class="meta">@Query("SELECT * FROM search_history WHERE type = :type order by timestamp desc LIMIT :limit")</span></span><br><span class="line"> Flowable<List<SearchHistoryEntity>> getByTypeWithLimit(SearchHistoryEntity.TYPE type, <span class="keyword">int</span> limit);</span><br><span class="line"></span><br><span class="line"> <span class="comment">/**</span></span><br><span class="line"><span class="comment"> * 新增 Search History 至 Room</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"> <span class="meta">@Insert</span></span><br><span class="line"> Long[] insertAll(SearchHistoryEntity... searchHistories);</span><br><span class="line"></span><br><span class="line"> <span class="comment">/**</span></span><br><span class="line"><span class="comment"> * 刪除某筆 Search History</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"> <span class="meta">@Delete</span></span><br><span class="line"> <span class="function"><span class="keyword">int</span> <span class="title">delete</span><span class="params">(SearchHistoryEntity searchHistory)</span></span>;</span><br><span class="line"></span><br><span class="line"> <span class="comment">/**</span></span><br><span class="line"><span class="comment"> * 刪除所有 Search History</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"> <span class="meta">@Query("DELETE FROM search_history")</span></span><br><span class="line"> <span class="function"><span class="keyword">int</span> <span class="title">clearTable</span><span class="params">()</span></span>;</span><br><span class="line">}</span><br></pre></td></tr></table></figure><br/><h2 id="使用-Converter-來互換資料類型"><a href="#使用-Converter-來互換資料類型" class="headerlink" title="使用 Converter 來互換資料類型"></a>使用 Converter 來互換資料類型</h2><p>如果資料中有使用到一些非基本的資料類型,像是 Text、Integer、Numeric、Blob 等,就需要用 Converter 協助轉換程式中的物件到 Room DB 的資料結構(上述的那幾種),例如說,程式中可能直接使用 Date 物件,但如果要存到 Room DB 就需要轉換為像 2021-02-22T18:01Z 的 String。</p><p>來看看 RoomConverter.java 怎麼樣把 Map 轉 Json 存入、Enum 物件轉為 int 存入、還有 Date 物件轉為 ISO 8601 String 存入:</p><figure class="highlight java"><figcaption><span>RoomConverter.java</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br><span class="line">45</span><br><span class="line">46</span><br><span class="line">47</span><br><span class="line">48</span><br><span class="line">49</span><br><span class="line">50</span><br><span class="line">51</span><br><span class="line">52</span><br><span class="line">53</span><br><span class="line">54</span><br><span class="line">55</span><br><span class="line">56</span><br><span class="line">57</span><br><span class="line">58</span><br><span class="line">59</span><br><span class="line">60</span><br><span class="line">61</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">public</span> <span class="class"><span class="keyword">class</span> <span class="title">RoomConverter</span> </span>{</span><br><span class="line"></span><br><span class="line"> <span class="keyword">private</span> <span class="keyword">static</span> <span class="keyword">final</span> DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ISO_OFFSET_DATE_TIME;</span><br><span class="line"></span><br><span class="line"> <span class="comment">/**</span></span><br><span class="line"><span class="comment"> * 將 Map 轉為 Json,並存入 Room</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"> <span class="meta">@TypeConverter</span></span><br><span class="line"> <span class="function"><span class="keyword">public</span> <span class="keyword">static</span> String <span class="title">mapToJson</span><span class="params">(Map<String, String> params)</span> </span>{</span><br><span class="line"> <span class="keyword">return</span> <span class="keyword">new</span> JSONObject(params).toString();</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> <span class="comment">/**</span></span><br><span class="line"><span class="comment"> * 把 Json 從 Room 讀取出,並轉換為 Map 給 App 使用</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"> <span class="meta">@TypeConverter</span></span><br><span class="line"> <span class="function"><span class="keyword">public</span> <span class="keyword">static</span> Map<String, String> <span class="title">jsonToMap</span><span class="params">(String json)</span> </span>{</span><br><span class="line"> <span class="keyword">if</span> (TextUtils.isEmpty(json)) {</span><br><span class="line"> <span class="keyword">return</span> Collections.emptyMap();</span><br><span class="line"> }</span><br><span class="line"> Gson gson = <span class="keyword">new</span> Gson();</span><br><span class="line"> Type type = <span class="keyword">new</span> TypeToken<Map<String, String>>() {}.getType();</span><br><span class="line"> <span class="keyword">return</span> gson.fromJson(json, type);</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> <span class="comment">/**</span></span><br><span class="line"><span class="comment"> * 把 App 中的 Enum 物件轉為 Room 可服用的格式</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"> <span class="meta">@TypeConverter</span></span><br><span class="line"> <span class="function"><span class="keyword">public</span> <span class="keyword">static</span> <span class="keyword">int</span> <span class="title">searchHistoryTypeToInt</span><span class="params">(SearchHistoryEntity.TYPE type)</span> </span>{</span><br><span class="line"> <span class="keyword">return</span> type.ordinal();</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> <span class="comment">/**</span></span><br><span class="line"><span class="comment"> * 把 Room 的資料轉為 App 可識得的 Enum 物件</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"> <span class="meta">@TypeConverter</span></span><br><span class="line"> <span class="keyword">public</span> <span class="keyword">static</span> SearchHistoryEntity.<span class="function">TYPE <span class="title">intToSearchHistoryType</span><span class="params">(<span class="keyword">int</span> type)</span> </span>{</span><br><span class="line"> <span class="keyword">return</span> SearchHistoryEntity.TYPE.values()[type];</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> <span class="comment">/**</span></span><br><span class="line"><span class="comment"> * 把 Room 的 ISO 8601 的日期字串轉為 OffsetDateTime 物件</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"> <span class="meta">@TypeConverter</span></span><br><span class="line"> <span class="function"><span class="keyword">public</span> <span class="keyword">static</span> OffsetDateTime <span class="title">toOffsetDateTime</span><span class="params">(String offsetDateTime)</span> </span>{</span><br><span class="line"> <span class="keyword">if</span> (!TextUtils.isEmpty(offsetDateTime)) {</span><br><span class="line"> <span class="keyword">return</span> OffsetDateTime.from(dateTimeFormatter.parse(offsetDateTime));</span><br><span class="line"> } <span class="keyword">else</span> {</span><br><span class="line"> <span class="keyword">return</span> <span class="keyword">null</span>;</span><br><span class="line"> }</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> <span class="comment">/**</span></span><br><span class="line"><span class="comment"> * 把 OffsetDateTime 物件轉為 Room 可服用的 ISO 8601 的日期字串</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"> <span class="meta">@TypeConverter</span></span><br><span class="line"> <span class="function"><span class="keyword">public</span> <span class="keyword">static</span> String <span class="title">fromOffsetDateTime</span><span class="params">(OffsetDateTime dateTime)</span> </span>{</span><br><span class="line"> <span class="keyword">return</span> dateTimeFormatter.format(dateTime);</span><br><span class="line"> }</span><br><span class="line">}</span><br></pre></td></tr></table></figure><br/><h2 id="設定-sqlite-trigger"><a href="#設定-sqlite-trigger" class="headerlink" title="設定 sqlite trigger"></a>設定 sqlite trigger</h2><p>以往用 sqlite 時,常會定義一些自動化 trigger 來調整資料內容,但是換作 Room 後,使用的方法就有點不太一樣。新的方式要定義在 <code>AppDatabase.java</code> 中,插入 callback 在 DB 建立時獲取通知並建立 trigger:</p><figure class="highlight java"><figcaption><span>AppDatabase.java</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br></pre></td><td class="code"><pre><span class="line"><span class="meta">@Database(entities = {SearchHistoryEntity.class}, version = 1)</span></span><br><span class="line"><span class="meta">@TypeConverters({RoomConverter.class})</span></span><br><span class="line"><span class="keyword">public</span> <span class="keyword">abstract</span> <span class="class"><span class="keyword">class</span> <span class="title">AppDatabase</span> <span class="keyword">extends</span> <span class="title">RoomDatabase</span> </span>{</span><br><span class="line"></span><br><span class="line"> <span class="function"><span class="keyword">public</span> <span class="keyword">abstract</span> SearchHistoryDao <span class="title">searchHistoryDao</span><span class="params">()</span></span>;</span><br><span class="line"></span><br><span class="line"> <span class="function"><span class="keyword">public</span> <span class="keyword">static</span> AppDatabase <span class="title">getAppDatabase</span><span class="params">(Context context)</span> </span>{</span><br><span class="line"> <span class="keyword">if</span> (sInstance == <span class="keyword">null</span>) {</span><br><span class="line"> RoomDatabase.Builder<AppDatabase> builder =</span><br><span class="line"> Room.databaseBuilder(context.getApplicationContext(), AppDatabase.class, <span class="string">"userdata"</span>);</span><br><span class="line"> builder.addCallback(callback);</span><br><span class="line"> sInstance = builder.build();</span><br><span class="line"> }</span><br><span class="line"> <span class="keyword">return</span> sInstance;</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> <span class="comment">// 可利用 callback 取得 DB 建立的時機,在建立時一併建立 trigger</span></span><br><span class="line"> <span class="keyword">private</span> <span class="keyword">static</span> Callback callback =</span><br><span class="line"> <span class="keyword">new</span> Callback() {</span><br><span class="line"> <span class="meta">@Override</span></span><br><span class="line"> <span class="function"><span class="keyword">public</span> <span class="keyword">void</span> <span class="title">onCreate</span><span class="params">(<span class="meta">@NonNull</span> SupportSQLiteDatabase db)</span> </span>{</span><br><span class="line"> <span class="keyword">super</span>.onCreate(db);</span><br><span class="line"></span><br><span class="line"> <span class="comment">// 最多保留 200 筆搜尋紀錄,每當有新搜尋紀錄新增,就刪除 200 筆後的資料</span></span><br><span class="line"> db.execSQL(</span><br><span class="line"> <span class="string">"CREATE TRIGGER IF NOT EXISTS truncate_search_history AFTER INSERT ON search_history BEGIN "</span></span><br><span class="line"> + <span class="string">"delete from search_history where timestamp < "</span></span><br><span class="line"> + <span class="string">" (select timestamp from search_history order by timestamp desc limit 1 offset 200);"</span></span><br><span class="line"> + <span class="string">"END;"</span>);</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> <span class="meta">@Override</span></span><br><span class="line"> <span class="function"><span class="keyword">public</span> <span class="keyword">void</span> <span class="title">onOpen</span><span class="params">(<span class="meta">@NonNull</span> SupportSQLiteDatabase db)</span> </span>{</span><br><span class="line"> <span class="keyword">super</span>.onOpen(db);</span><br><span class="line"> }</span><br><span class="line"> };</span><br><span class="line"></span><br><span class="line">}</span><br></pre></td></tr></table></figure><br/><h2 id="心得"><a href="#心得" class="headerlink" title="心得"></a>心得</h2><p>小唯團隊開始使用 Room 時,那時技術還很新,很多功能支援不是很完善,像是不支援 prepolutate 資料庫。但這些日子下來,可以看到 Room 的支援度越來越好,不但加入了 Android Jetpack,更新也很頻繁。下次專案上如有需要,應該還是會推薦團隊使用。<br><br/></p><div> <div style="text-align:left;font-size:1em;"><p style="text-align:center;color: #ccc;">------------- 本文结束 <i class="far fa-laugh-squint"></i> 話說為什麼叫做 Room~ -------------</p> </div> </div><br/>]]></content>
<summary type="html">Android Room Database 是 Google 官方前幾年推出來的技術,官方也建議開發者使用 Room 而不直接使用 SQLite APIs。小唯的團隊也使用 Room 來取代原本的 SQLite 程式碼,中間遇到了不少大小問題,這篇小唯紀錄自己團隊使用 Room 的一些經驗,方便之後參照...</summary>
<category term="小唯的故事" scheme="https://wm4n.github.io/categories/%E5%B0%8F%E5%94%AF%E7%9A%84%E6%95%85%E4%BA%8B/"/>
<category term="Android" scheme="https://wm4n.github.io/categories/%E5%B0%8F%E5%94%AF%E7%9A%84%E6%95%85%E4%BA%8B/Android/"/>
<category term="Android" scheme="https://wm4n.github.io/tags/Android/"/>
<category term="Database" scheme="https://wm4n.github.io/tags/Database/"/>
<category term="Room" scheme="https://wm4n.github.io/tags/Room/"/>
</entry>
<entry>
<title>Android Clean Architecture - 仍然好用的架構</title>
<link href="https://wm4n.github.io/Android-Clean-Architecture-%E4%BB%8D%E7%84%B6%E5%A5%BD%E7%94%A8%E7%9A%84%E6%9E%B6%E6%A7%8B/"/>
<id>https://wm4n.github.io/Android-Clean-Architecture-%E4%BB%8D%E7%84%B6%E5%A5%BD%E7%94%A8%E7%9A%84%E6%9E%B6%E6%A7%8B/</id>
<published>2021-01-27T16:05:29.000Z</published>
<updated>2021-03-23T16:54:23.484Z</updated>
<content type="html"><![CDATA[<h2 id="前言故事"><a href="#前言故事" class="headerlink" title="前言故事"></a>前言故事</h2><p>小唯加入當前的公司後,主要負責一款 Android App 的開發以及維護,那是一款相當有歷史年代的產品,介面是老式的風格、使用了許多 deprecated 的技術、連內部商務邏輯也都幾乎已經不可考了,要不是 Google 要求每年的更新 Android Target SDK 版本,相信有許多部份應該是會更老舊。小唯進公司沒多久後,機緣巧合內部設計師與企劃有對 App 改版的需求,想將產品改造成一個介面更現代化、資訊架構更有條理、以及為將來要推出的新服務事先鋪路,所以小唯也趁此機會,針對 App 的軟體架構進行了一次徹底的打掉重練。<br><br/></p><h2 id="改版之前"><a href="#改版之前" class="headerlink" title="改版之前"></a>改版之前</h2><p>原始版本的 App,是一款集所有需求於一身的龐然大物,內含少說上百種以上的大小功能,但本身可以說是幾乎沒有程式架構可言。試想當年這款產品是怎麼開始的(以下假設這款產品是一個通訊錄 App):</p><p>2010年,產品企劃:「嗨~ 公司打算將目前線上那款通訊錄服務,上架到當前正在流行的手機平台(Android & iOS),我們打算先做聯絡人的基本功能…」</p><p>一年後,產品企劃:「之前上架的那款 App,反應不錯,我們打算將它與 Facebook 做整合…」</p><p>半年後,產品企劃:「半年前 Facebook 的整合頗受好評,所以我們將另一個通訊錄服務做整合…」</p><p>…<br><br/></p><p>接著,更多的功能不斷上架,即時線上狀態、聯絡人相簿、Instagram 整合,需求一個接一個的來</p><p>最終,這款通訊錄 App 就變成這樣:</p><img src="/Android-Clean-Architecture-%E4%BB%8D%E7%84%B6%E5%A5%BD%E7%94%A8%E7%9A%84%E6%9E%B6%E6%A7%8B/architecture1.svg" class="" title="Software Architecture History"><br/><br/><p>功能需求一個接一個,當初的開發人員就是一個個疊加上去,上層的功能開發時,直接或間接的使用到下層的功能、服務、或介面等,造成強烈的耦合,如下圖:</p><img src="/Android-Clean-Architecture-%E4%BB%8D%E7%84%B6%E5%A5%BD%E7%94%A8%E7%9A%84%E6%9E%B6%E6%A7%8B/architecture2.svg" class="" title="Software Architecture Coupling"><br/><br/><p>圖片中,聯絡人的線上狀態依賴之前 Facebook 整合的程式,Facebook 又依賴下層聯絡人的基本功能,每個功能都是如此的環環相扣,每一次修改,都需要確定對不同功能的影響範圍。到最後,功能越做越多;相依性就越串越大;後面接手的人也越來越痛苦。如果某天需要拔除某個功能,會拉出一串串的相依性,讓整個過程幾乎是難以預估工時以及風險程度。也因如此,程式碼中有需多看似沒用到的程式碼,誰也不敢移除,因為它的確有被引用,但一堆引用點又無法確認到底哪些是有用,哪些是無用的。歷史悠久的程式碼中途已經不知道經過幾手了,許多的當初的合理行為,現在已經變成歷史包袱,到此地步,整個 App 重新設計已經是勢在必行了!</p><h2 id="理想架構"><a href="#理想架構" class="headerlink" title="理想架構"></a>理想架構</h2><p>自從改版規劃確定後,小唯就在四處尋找合適的架構,最後是選用 <a href="https://github.com/android10">Frnando Cejas(Android10)</a> 的 <a href="https://github.com/android10/Android-CleanArchitecture">Android Clean Architecture</a>(以下稱 Android10 架構)。因為是幾年前選的架構,所以小唯是用 Java 版的,作者也有移植 Kotlin 版本。</p><p>Android10 架構有幾個重點功能,對於改造架構上有很大的影響:</p><ol><li><p>明確的相依性設計</p><p>首先,這架構上的相依性設計上非常的簡單:</p><img src="/Android-Clean-Architecture-%E4%BB%8D%E7%84%B6%E5%A5%BD%E7%94%A8%E7%9A%84%E6%9E%B6%E6%A7%8B/architecture5.png" class="" title="Dependencies"><ul><li><p>Entities 是商務邏輯上的最基礎元件,以通訊錄這個 App 來說,Entities 定義了聯絡人的格式,通常是使用 POJO 格式,可由 Clean Architecture 中的 Domain 看得出來,這是個純 Java library,連對 Android 都沒有相依,在 App 中所有行為都是針對 Entities 來做操作。</p></li><li><p>接著 Use cases 定義了 Entities 可以有什麼樣式的操作,例如取得聯絡人列表、聯絡人詳細資訊、新增、刪除等操作。</p></li><li><p>Presenter 則是實作了 App 中的操作邏輯,並把 UI 與 uses cases 串接起來,它本身不限制對應的 UI 要如何呈現,只要符合 presenter 定義的規格。</p></li><li><p>最後,UI 實作了畫面該如何呈現給使用者,它必須符合 presenter 定義的規格,但不限定方式,所以不管是 Activity、Fragment、Dialog、甚至是 console (也許 debug 用)都能使用。</p><br/></li></ul></li><li><p>工作分明的架構</p><p>在 Android10 的 Clean Architecture 規劃了三個模組,分別是:</p><img src="/Android-Clean-Architecture-%E4%BB%8D%E7%84%B6%E5%A5%BD%E7%94%A8%E7%9A%84%E6%9E%B6%E6%A7%8B/architecture3.png" class="" title="Domain, Data and Presentation"><ul><li><p>Domain 包含了所有商務邏輯上的基礎元件與互動行為的定義,也就是 Entities、資料層的介面接口,以及提供給 Presentation 模組的 Use cases。常見的誤解是,這個模組並不限制資料層面該如何實作,也不管 Uses cases 會被誰使用,如何使用。</p></li><li><p>Data 實際處理資料的來源,資料該從哪裡來、該如何處理、是否該快取等等。只要最後回傳的格式符合 Domain 的定義,資料則是可以從各式來源而來,像是雲端、資料庫、檔案、快取、甚至是模擬數據。</p></li><li><p>Presentation 負責將 Uses cases 的功能,轉為介面提供給使用者。</p><br/></li></ul></li><li><p>易替換的 presentation 模組</p><p>Android10 的 Clean Architecture 在 Presentation 中使用的是 MVP:</p><img src="/Android-Clean-Architecture-%E4%BB%8D%E7%84%B6%E5%A5%BD%E7%94%A8%E7%9A%84%E6%9E%B6%E6%A7%8B/architecture4.png" class="" title="What" alt="s MVP"><p>如<a href="https://fernandocejas.com/blog/engineering/2014-09-03-architecting-android-the-clean-way/#presentation-layer">原作者所提</a>,Presentation 層所使用的架構不拘,可以是 MVP,也可是 MVC 或 MVVM,唯一一致的地方只有是都透過 Domain 層的 Use cases 來與資料做互動。</p><br/></li><li><p>reactive 的資料流向</p><p>Clean Architecture 使用 RxJava 來聯繫所有資料的流動,所有來自 data 層的資料都是使用 <a href="http://reactivex.io/RxJava/javadoc/io/reactivex/Observer.html">Observer</a> 包裝過。RxJava 提供了相當多便捷的操作,像是之前提過的,<a href="../RxJava-%E7%89%B9%E6%95%88%EF%BC%9AAPI-%E6%90%AD%E9%85%8D-zip-%E7%9A%84%E5%A6%99%E7%94%A8/">利用 RxJava 來把多個 API 組成一個結果</a>,或是監測資料是否有更新,並做即時的畫面處理。</p><br/></li></ol><h2 id="更新結果"><a href="#更新結果" class="headerlink" title="更新結果"></a>更新結果</h2><p>利用 Clean Architecture 重整後,原本通訊錄的 App 重構新的樣式:</p><img src="/Android-Clean-Architecture-%E4%BB%8D%E7%84%B6%E5%A5%BD%E7%94%A8%E7%9A%84%E6%9E%B6%E6%A7%8B/architecture6.svg" class="" title="New architecture"><br/><p>原本堆疊的功能,經過重新設計後,Domain 與 Data 的部分被取出來,剩下的則以垂直的方式排在 Domain 層上,原本相互引用的相依性,變成只向下相依,功能之間的依賴,也變成需透過 Domain 間接提供。如此一來,各功能間的介面也得以被抽象出來。新的架構讓原本的 App 變得:</p><ol><li><p>相依性大幅降低,各功能介面被抽象後,更容易開發、維護與測試。</p></li><li><p>Presentation、Domain 跟 Data 切分後,分工方式也更多元,從原本按功能分工的方式,之後也能以不同階層分工,有點類似前端與後端的分工。</p></li><li><p>與企劃規格更容易相互應對。Domain 層內的 Entities 與 Use cases 對應了規格上的元件與行為操作。</p></li></ol><p>新架構花了小唯團隊將近一年的時間,才把所有功能陸續移植,中途還不斷收到需求變更,但靠著 Clean Architecture 架構上的彈性,移植上減少了很多工時的浪費。例如過程中,有一變更是將所原本直接透過 Retrofit 使用線上 API 的存取服務,改由透過 SDK 來存取資料,在這個例子中,只需要修改 Data 層中如何存取資料的方式,Domain 與 Presentation 兩層完全不需要變動。這正是 Clean Architecture 的優勢所在,雖然剛開始資訊架構較複雜,但是等專案成長到一定階段,所有辛苦都是遲早能回收的~<br><br/></p><p><strong>相關連結:</strong><a href="https://github.com/wm4n/Android-CleanArchitecture"><i class="fab fa-github"></i> Demo 專案</a><br><br/></p><div> <div style="text-align:left;font-size:1em;"><p style="text-align:center;color: #ccc;">------------- 本文结束 <i class="fas fa-layer-group"></i> 程式架構就是最重要地~ -------------</p> </div> </div><br/>]]></content>
<summary type="html">小唯的團隊使用 Clean Architecture 來重構自家的 Android 產品已經有一年多的時間。團隊從一開始毫無架構可言的 App,到最後完全重寫套用,中間歷經了許多甘苦,也收穫許多經驗。事後也證明(團隊一致認同),好的架構的確有效率的提升工作效率,以及程式的可擴展性等優點。所以小唯決定將使用經驗保留下來,也順道留一份模板程式,方便之後的產品更容易套用...</summary>
<category term="小唯的故事" scheme="https://wm4n.github.io/categories/%E5%B0%8F%E5%94%AF%E7%9A%84%E6%95%85%E4%BA%8B/"/>
<category term="Android" scheme="https://wm4n.github.io/categories/%E5%B0%8F%E5%94%AF%E7%9A%84%E6%95%85%E4%BA%8B/Android/"/>
<category term="Android, 程式架構" scheme="https://wm4n.github.io/tags/Android-%E7%A8%8B%E5%BC%8F%E6%9E%B6%E6%A7%8B/"/>
</entry>
<entry>
<title>Android 特效:RemoteViews 的應用和滿滿的坑</title>
<link href="https://wm4n.github.io/Android-%E7%89%B9%E6%95%88%EF%BC%9ARemoteViews-%E7%9A%84%E6%87%89%E7%94%A8%E5%92%8C%E6%BB%BF%E6%BB%BF%E7%9A%84%E5%9D%91/"/>
<id>https://wm4n.github.io/Android-%E7%89%B9%E6%95%88%EF%BC%9ARemoteViews-%E7%9A%84%E6%87%89%E7%94%A8%E5%92%8C%E6%BB%BF%E6%BB%BF%E7%9A%84%E5%9D%91/</id>
<published>2020-12-28T08:59:01.000Z</published>
<updated>2021-03-23T16:54:23.516Z</updated>
<content type="html"><![CDATA[<h2 id="前言故事"><a href="#前言故事" class="headerlink" title="前言故事"></a>前言故事</h2><p>如何讓系統 UI 畫出客製化的 View,是小唯團隊最近一直在實驗的玩意。要在 Android 上面做到,就要使用 <a href="https://developer.android.com/reference/android/widget/RemoteViews">RemoteViews</a>,通常第一次接觸到的開發者,都是經過 Google 搜尋如何實作某樣某樣功能,才間接找到 RemoteViews,然後就開始照著搜尋結果去做,接著就遇到許許多多問題、踩了很多坑,然後就是更多的 Google 搜尋,不斷的循環。小唯的團隊也是這樣走過來,所以特地把一些經驗記錄下來。<br><br/></p><h2 id="客製化通知訊息"><a href="#客製化通知訊息" class="headerlink" title="客製化通知訊息"></a>客製化通知訊息</h2><p>小唯一開始也是為了找如何實作「客製化推播訊息的 UI 樣式」,所以找到了 RemoteViews,目標是在收到推播訊息後,顯示一個接受或拒絕來電的通知。照著搜尋結果,小唯組出了預期的畫面:</p><img src="/Android-%E7%89%B9%E6%95%88%EF%BC%9ARemoteViews-%E7%9A%84%E6%87%89%E7%94%A8%E5%92%8C%E6%BB%BF%E6%BB%BF%E7%9A%84%E5%9D%91/screen1.png" class="" width="320"><p>如圖片所示,點選 [Answer] 可以接通來電,[Decline] 則是掛斷來電,兩個按鈕都可直接運作,而不用透過另一個來電介面去做接通或掛斷,這就是一個典型客製化通知訊息的一個例子,要做這種效果的通知,需要準備幾樣東西:</p><h3 id="1-通知訊息頻道"><a href="#1-通知訊息頻道" class="headerlink" title="1. 通知訊息頻道"></a>1. 通知訊息頻道</h3><p> 首先,需要一個用來發送通知訊息的頻道(<a href="https://developer.android.com/training/notify-user/channels">Notification Channel</a>)。從 Android 8 開始,所有通知訊息都要有一個對應的發送頻道,這些頻道會出現在系統設定中的 [App & notifications] 內,各別的 App 通知設定中:</p> <img src="/Android-%E7%89%B9%E6%95%88%EF%BC%9ARemoteViews-%E7%9A%84%E6%87%89%E7%94%A8%E5%92%8C%E6%BB%BF%E6%BB%BF%E7%9A%84%E5%9D%91/screen2.png" class="" width="320"><p> 透過這介面,使用者可以個別去開關某個通知訊息,應該是說可以有限度地去控制自己如何「被」訊息通知。但小唯覺得,這設計雖然立意良好,但是一來會落實使用的開發者不多(誰家的企劃規格會寫到這個?),二是知道如何進入調整的使用者那真是少之又少,兩個比率相乘下,造成了這個設計實際上不太具有太大意義。<br> <br/></p><h3 id="2-準備一個-RemoteViews"><a href="#2-準備一個-RemoteViews" class="headerlink" title="2. 準備一個 RemoteViews"></a>2. 準備一個 RemoteViews</h3><p> 它可以透過一般 inflate XML 的方式來建立出來,「大多數」規則如同一般在 App 中使用 View 一致,注意這裡寫大多數,表示其中有先不一樣的限制,例如:<br> <br/></p> <div class="note warning"> <p>一號坑!不是所有 Android View/ViewGroup 都可以使用在 RemoteViews 內!</p> </div> <br/><p> 使用不支援的 View/ViewGroup,通知會跳不出來,但沒有強制關閉提示,需要看背景 log 才知道發生什麼事 😆。<a href="https://developer.android.com/guide/topics/appwidgets/index.html#CreatingLayout">>> 支援的 View/ViewGroup 清單可以看這 <<</a><br> <br/></p> <div class="note warning"> <p>二號坑!XML layout 中不能使用 App theme 的參數!</p> </div> <br/><p> 如果使用到 App theme 中的參數(e.g. background 引用到 theme 內容 <code>android:background="?attr/colorPrimary"</code>),通知也是會跳不出來,但沒有強制關閉提示,看背景 log 會發現報錯說 view 無法被建立,如下:<br> <br/></p> <figure class="highlight plain"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line">E StatusBar: android.view.InflateException: Binary XML file line #2: Binary XML file line #2: Error inflating class android.widget.RelativeLayout</span><br><span class="line">E StatusBar: Caused by: android.view.InflateException: Binary XML file line #2: Error inflating class android.widget.RelativeLayout</span><br><span class="line">E StatusBar: Caused by: java.lang.reflect.InvocationTargetException</span><br><span class="line">E StatusBar: at java.lang.reflect.Constructor.newInstance0(Native Method)</span><br><span class="line">E StatusBar: at java.lang.reflect.Constructor.newInstance(Constructor.java:343)</span><br><span class="line"> </span><br></pre></td></tr></table></figure> <br/><p> 這是可預期的,因為其他 process 無從得知 App theme 的設定為何。雖然不能用 theme,但是一般的 <code>resources_name-qualifier</code> 仍是可以使用(也是合理可預期,畢竟我們還是要用 qualifier 來設定寬高等的設定)。這也間接地讓小唯遇到了第三個坑:<br> <br/></p> <div class="note warning"> <p>三號坑!即使的 App 不支援黑暗模式,我們還是要為 RemoteViews 準備黑暗模式。</p> </div> <br/><p> 既然跑在他人的 process 中,按照人家的規矩來顯示呈現也是應該的,設定 <code>-night</code> 資源來依照目前的模式來呈現,才不會造成在黑暗模式中顯示白底或一般模式中顯示黑底的尷尬窘境,像下面這張畫面,黑色的客製化訊息在白色佈景上:<br> <br/></p> <img src="/Android-%E7%89%B9%E6%95%88%EF%BC%9ARemoteViews-%E7%9A%84%E6%87%89%E7%94%A8%E5%92%8C%E6%BB%BF%E6%BB%BF%E7%9A%84%E5%9D%91/screen3.png" class="" width="320"> <br/><p> 以下是 XML 全文:</p><figure class="highlight xml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br><span class="line">45</span><br><span class="line">46</span><br><span class="line">47</span><br><span class="line">48</span><br><span class="line">49</span><br><span class="line">50</span><br><span class="line">51</span><br><span class="line">52</span><br><span class="line">53</span><br><span class="line">54</span><br><span class="line">55</span><br><span class="line">56</span><br><span class="line">57</span><br><span class="line">58</span><br><span class="line">59</span><br><span class="line">60</span><br><span class="line">61</span><br><span class="line">62</span><br><span class="line">63</span><br><span class="line">64</span><br><span class="line">65</span><br><span class="line">66</span><br><span class="line">67</span><br><span class="line">68</span><br><span class="line">69</span><br><span class="line">70</span><br><span class="line">71</span><br><span class="line">72</span><br><span class="line">73</span><br><span class="line">74</span><br><span class="line">75</span><br><span class="line">76</span><br><span class="line">77</span><br><span class="line">78</span><br><span class="line">79</span><br><span class="line">80</span><br><span class="line">81</span><br><span class="line">82</span><br><span class="line">83</span><br><span class="line">84</span><br><span class="line">85</span><br><span class="line">86</span><br><span class="line">87</span><br><span class="line">88</span><br><span class="line">89</span><br></pre></td><td class="code"><pre><span class="line"><span class="meta"><?xml version="1.0" encoding="utf-8"?></span></span><br><span class="line"><span class="tag"><<span class="name">RelativeLayout</span></span></span><br><span class="line"><span class="tag"> <span class="attr">xmlns:android</span>=<span class="string">"http://schemas.android.com/apk/res/android"</span></span></span><br><span class="line"><span class="tag"> <span class="attr">android:layout_width</span>=<span class="string">"match_parent"</span></span></span><br><span class="line"><span class="tag"> <span class="attr">android:layout_height</span>=<span class="string">"wrap_content"</span></span></span><br><span class="line"><span class="tag"> <span class="attr">android:background</span>=<span class="string">"@color/custom_notification_background"</span></span></span><br><span class="line"><span class="tag"> <span class="attr">android:padding</span>=<span class="string">"16dp"</span>></span></span><br><span class="line"> </span><br><span class="line"> <span class="tag"><<span class="name">ImageView</span></span></span><br><span class="line"><span class="tag"> <span class="attr">android:id</span>=<span class="string">"@+id/icon"</span></span></span><br><span class="line"><span class="tag"> <span class="attr">android:layout_width</span>=<span class="string">"16dp"</span></span></span><br><span class="line"><span class="tag"> <span class="attr">android:layout_height</span>=<span class="string">"16dp"</span></span></span><br><span class="line"><span class="tag"> <span class="attr">android:layout_alignParentTop</span>=<span class="string">"true"</span></span></span><br><span class="line"><span class="tag"> <span class="attr">android:layout_alignParentStart</span>=<span class="string">"true"</span> /></span></span><br><span class="line"></span><br><span class="line"> <span class="tag"><<span class="name">ImageView</span></span></span><br><span class="line"><span class="tag"> <span class="attr">android:id</span>=<span class="string">"@+id/avatar"</span></span></span><br><span class="line"><span class="tag"> <span class="attr">android:layout_width</span>=<span class="string">"48dp"</span></span></span><br><span class="line"><span class="tag"> <span class="attr">android:layout_height</span>=<span class="string">"48dp"</span></span></span><br><span class="line"><span class="tag"> <span class="attr">android:layout_alignParentTop</span>=<span class="string">"true"</span></span></span><br><span class="line"><span class="tag"> <span class="attr">android:layout_alignParentEnd</span>=<span class="string">"true"</span> /></span></span><br><span class="line"></span><br><span class="line"> <span class="tag"><<span class="name">TextView</span></span></span><br><span class="line"><span class="tag"> <span class="attr">android:id</span>=<span class="string">"@+id/title"</span></span></span><br><span class="line"><span class="tag"> <span class="attr">android:layout_width</span>=<span class="string">"0dp"</span></span></span><br><span class="line"><span class="tag"> <span class="attr">android:layout_height</span>=<span class="string">"wrap_content"</span></span></span><br><span class="line"><span class="tag"> <span class="attr">android:layout_gravity</span>=<span class="string">"center_vertical"</span></span></span><br><span class="line"><span class="tag"> <span class="attr">android:layout_marginStart</span>=<span class="string">"8dp"</span></span></span><br><span class="line"><span class="tag"> <span class="attr">android:ellipsize</span>=<span class="string">"end"</span></span></span><br><span class="line"><span class="tag"> <span class="attr">android:fontFamily</span>=<span class="string">"sans-serif"</span></span></span><br><span class="line"><span class="tag"> <span class="attr">android:maxLines</span>=<span class="string">"1"</span></span></span><br><span class="line"><span class="tag"> <span class="attr">android:text</span>=<span class="string">""</span></span></span><br><span class="line"><span class="tag"> <span class="attr">android:textColor</span>=<span class="string">"@color/custom_notification_text"</span></span></span><br><span class="line"><span class="tag"> <span class="attr">android:textSize</span>=<span class="string">"14sp"</span></span></span><br><span class="line"><span class="tag"> <span class="attr">android:layout_alignParentTop</span>=<span class="string">"true"</span></span></span><br><span class="line"><span class="tag"> <span class="attr">android:layout_toEndOf</span>=<span class="string">"@id/icon"</span></span></span><br><span class="line"><span class="tag"> <span class="attr">android:layout_toStartOf</span>=<span class="string">"@id/avatar"</span>/></span></span><br><span class="line"></span><br><span class="line"> <span class="tag"><<span class="name">TextView</span></span></span><br><span class="line"><span class="tag"> <span class="attr">android:id</span>=<span class="string">"@+id/text"</span></span></span><br><span class="line"><span class="tag"> <span class="attr">android:layout_width</span>=<span class="string">"match_parent"</span></span></span><br><span class="line"><span class="tag"> <span class="attr">android:layout_height</span>=<span class="string">"wrap_content"</span></span></span><br><span class="line"><span class="tag"> <span class="attr">android:layout_marginTop</span>=<span class="string">"8dp"</span></span></span><br><span class="line"><span class="tag"> <span class="attr">android:ellipsize</span>=<span class="string">"end"</span></span></span><br><span class="line"><span class="tag"> <span class="attr">android:fontFamily</span>=<span class="string">"sans-serif"</span></span></span><br><span class="line"><span class="tag"> <span class="attr">android:maxLines</span>=<span class="string">"1"</span></span></span><br><span class="line"><span class="tag"> <span class="attr">android:text</span>=<span class="string">""</span></span></span><br><span class="line"><span class="tag"> <span class="attr">android:textColor</span>=<span class="string">"@color/custom_notification_text"</span></span></span><br><span class="line"><span class="tag"> <span class="attr">android:textSize</span>=<span class="string">"14sp"</span></span></span><br><span class="line"><span class="tag"> <span class="attr">android:layout_below</span>=<span class="string">"@id/title"</span></span></span><br><span class="line"><span class="tag"> <span class="attr">android:layout_toEndOf</span>=<span class="string">"@id/icon"</span></span></span><br><span class="line"><span class="tag"> <span class="attr">android:layout_alignParentStart</span>=<span class="string">"true"</span> /></span></span><br><span class="line"></span><br><span class="line"> <span class="tag"><<span class="name">LinearLayout</span></span></span><br><span class="line"><span class="tag"> <span class="attr">android:layout_width</span>=<span class="string">"match_parent"</span></span></span><br><span class="line"><span class="tag"> <span class="attr">android:layout_height</span>=<span class="string">"wrap_content"</span></span></span><br><span class="line"><span class="tag"> <span class="attr">android:layout_marginTop</span>=<span class="string">"16dp"</span></span></span><br><span class="line"><span class="tag"> <span class="attr">android:orientation</span>=<span class="string">"horizontal"</span></span></span><br><span class="line"><span class="tag"> <span class="attr">android:layout_below</span>=<span class="string">"@id/text"</span></span></span><br><span class="line"><span class="tag"> <span class="attr">android:layout_alignParentStart</span>=<span class="string">"true"</span></span></span><br><span class="line"><span class="tag"> <span class="attr">android:layout_alignParentEnd</span>=<span class="string">"true"</span>></span></span><br><span class="line"></span><br><span class="line"> <span class="tag"><<span class="name">TextView</span></span></span><br><span class="line"><span class="tag"> <span class="attr">android:id</span>=<span class="string">"@+id/button_decline"</span></span></span><br><span class="line"><span class="tag"> <span class="attr">android:layout_width</span>=<span class="string">"0dp"</span></span></span><br><span class="line"><span class="tag"> <span class="attr">android:layout_height</span>=<span class="string">"40dp"</span></span></span><br><span class="line"><span class="tag"> <span class="attr">android:layout_marginEnd</span>=<span class="string">"8dp"</span></span></span><br><span class="line"><span class="tag"> <span class="attr">android:layout_weight</span>=<span class="string">"1"</span></span></span><br><span class="line"><span class="tag"> <span class="attr">android:background</span>=<span class="string">"@drawable/bg_decline"</span></span></span><br><span class="line"><span class="tag"> <span class="attr">android:fontFamily</span>=<span class="string">"sans-serif"</span></span></span><br><span class="line"><span class="tag"> <span class="attr">android:gravity</span>=<span class="string">"center"</span></span></span><br><span class="line"><span class="tag"> <span class="attr">android:text</span>=<span class="string">"Decline"</span></span></span><br><span class="line"><span class="tag"> <span class="attr">android:textColor</span>=<span class="string">"@color/white"</span></span></span><br><span class="line"><span class="tag"> <span class="attr">android:textSize</span>=<span class="string">"14sp"</span> /></span></span><br><span class="line"></span><br><span class="line"> <span class="tag"><<span class="name">TextView</span></span></span><br><span class="line"><span class="tag"> <span class="attr">android:id</span>=<span class="string">"@+id/button_accept"</span></span></span><br><span class="line"><span class="tag"> <span class="attr">android:layout_width</span>=<span class="string">"0dp"</span></span></span><br><span class="line"><span class="tag"> <span class="attr">android:layout_height</span>=<span class="string">"40dp"</span></span></span><br><span class="line"><span class="tag"> <span class="attr">android:layout_marginStart</span>=<span class="string">"8dp"</span></span></span><br><span class="line"><span class="tag"> <span class="attr">android:layout_weight</span>=<span class="string">"1"</span></span></span><br><span class="line"><span class="tag"> <span class="attr">android:background</span>=<span class="string">"@drawable/bg_accept"</span></span></span><br><span class="line"><span class="tag"> <span class="attr">android:fontFamily</span>=<span class="string">"sans-serif"</span></span></span><br><span class="line"><span class="tag"> <span class="attr">android:gravity</span>=<span class="string">"center"</span></span></span><br><span class="line"><span class="tag"> <span class="attr">android:text</span>=<span class="string">"Answer"</span></span></span><br><span class="line"><span class="tag"> <span class="attr">android:textColor</span>=<span class="string">"@color/white"</span></span></span><br><span class="line"><span class="tag"> <span class="attr">android:textSize</span>=<span class="string">"14sp"</span> /></span></span><br><span class="line"> <span class="tag"></<span class="name">LinearLayout</span>></span></span><br><span class="line"><span class="tag"></<span class="name">RelativeLayout</span>></span></span><br></pre></td></tr></table></figure><h3 id="3-準備按鈕行為"><a href="#3-準備按鈕行為" class="headerlink" title="3. 準備按鈕行為"></a>3. 準備按鈕行為</h3><p> 透過客製化的通知訊息,使用者有三個動作可以執行,點選 [Answer]、點選 [Decline]、或是點選訊息本身,所以小唯這三個動作準備了三個 <a href="https://developer.android.com/reference/android/app/PendingIntent">PendingIntent</a>,已正式線上產品來看,三個動作應該分別對應到接通電話、掛斷電話、和進入來電介面,讓使用者看更多訊息,再決定是否要接通電話。以下是小唯建立通知訊息的程式碼與每個步驟對應註解:</p> <figure class="highlight kotlin"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br><span class="line">45</span><br><span class="line">46</span><br><span class="line">47</span><br><span class="line">48</span><br><span class="line">49</span><br><span class="line">50</span><br><span class="line">51</span><br><span class="line">52</span><br><span class="line">53</span><br><span class="line">54</span><br><span class="line">55</span><br><span class="line">56</span><br><span class="line">57</span><br><span class="line">58</span><br><span class="line">59</span><br><span class="line">60</span><br><span class="line">61</span><br><span class="line">62</span><br><span class="line">63</span><br><span class="line">64</span><br><span class="line">65</span><br><span class="line">66</span><br><span class="line">67</span><br><span class="line">68</span><br><span class="line">69</span><br><span class="line">70</span><br><span class="line">71</span><br><span class="line">72</span><br><span class="line">73</span><br><span class="line">74</span><br><span class="line">75</span><br><span class="line">76</span><br><span class="line">77</span><br><span class="line">78</span><br><span class="line">79</span><br><span class="line">80</span><br><span class="line">81</span><br><span class="line">82</span><br><span class="line">83</span><br><span class="line">84</span><br><span class="line">85</span><br><span class="line">86</span><br><span class="line">87</span><br><span class="line">88</span><br><span class="line">89</span><br><span class="line">90</span><br><span class="line">91</span><br><span class="line">92</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="keyword">fun</span> <span class="title">showCustomHeadsUpNotification</span><span class="params">(context: <span class="type">Context</span>, message: <span class="type">String</span>)</span></span> {</span><br><span class="line"> <span class="keyword">val</span> notificationManager =</span><br><span class="line"> context.getSystemService(Context.NOTIFICATION_SERVICE) <span class="keyword">as</span> NotificationManager</span><br><span class="line"></span><br><span class="line"> <span class="comment">// 1. 為 Android 8+ 準備通知頻道</span></span><br><span class="line"> <span class="keyword">if</span> (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {</span><br><span class="line"> <span class="keyword">val</span> channel = NotificationChannel(</span><br><span class="line"> HEADS_UP_CHANNEL_ID,</span><br><span class="line"> HEADS_UP_CHANNEL_NAME,</span><br><span class="line"> NotificationManager.IMPORTANCE_HIGH</span><br><span class="line"> )</span><br><span class="line"></span><br><span class="line"> <span class="comment">// 設定通知頻道</span></span><br><span class="line"> channel.enableLights(<span class="literal">true</span>)</span><br><span class="line"> channel.enableVibration(<span class="literal">true</span>)</span><br><span class="line"> notificationManager.createNotificationChannel(channel)</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> <span class="comment">// 2. 準備一個 RemoteViews,之後用在 setCustomHeadsUpContentView</span></span><br><span class="line"> <span class="keyword">val</span> headsUpRemoteView = RemoteViews(context.packageName, R.layout.heads_up_notification).apply <span class="symbol">RemoteViews@</span> {</span><br><span class="line"></span><br><span class="line"> <span class="comment">// 3. 設定 RemoteView 上面顯示的資訊</span></span><br><span class="line"> setImageViewResource(R.id.icon, R.drawable.ic_round_call_24)</span><br><span class="line"> setImageViewResource(R.id.avatar, R.drawable.mom_avatar)</span><br><span class="line"> setTextViewText(R.id.title, context.getString(R.string.app_name))</span><br><span class="line"> setTextViewText(R.id.text, message)</span><br><span class="line"></span><br><span class="line"> <span class="comment">// 3.1 點選 [Answer] 按鈕後,要執行的動作</span></span><br><span class="line"> Intent().apply {</span><br><span class="line"> flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP</span><br><span class="line"> <span class="comment">// 展示 PendingIntent 用!先導去 MainActivity 並顯示指定訊息 </span></span><br><span class="line"> setClass(context, MainActivity::<span class="keyword">class</span>.java)</span><br><span class="line"> putExtra(SNACK_BAR_MESSAGE, <span class="string">"Answer call clicked"</span>)</span><br><span class="line"> putExtra(TAG_NOTIFICATION_ID, HEADS_UP_NOTIFICATION_ID)</span><br><span class="line"> <span class="comment">// 為 [Answer] 按鈕設定 PendingIntent</span></span><br><span class="line"> <span class="keyword">this</span><span class="symbol">@RemoteViews</span>.setOnClickPendingIntent(</span><br><span class="line"> R.id.button_accept,</span><br><span class="line"> PendingIntent.getActivity(context, <span class="number">5001</span>, <span class="keyword">this</span>, PendingIntent.FLAG_UPDATE_CURRENT)</span><br><span class="line"> )</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> <span class="comment">// 3.2 點選 [Decline] 按鈕後,要執行的動作</span></span><br><span class="line"> Intent().apply {</span><br><span class="line"> flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP</span><br><span class="line"> <span class="comment">// 展示 PendingIntent 用!先導去 MainActivity 並顯示指定訊息 </span></span><br><span class="line"> setClass(context, MainActivity::<span class="keyword">class</span>.java)</span><br><span class="line"> putExtra(SNACK_BAR_MESSAGE, <span class="string">"Decline call clicked"</span>)</span><br><span class="line"> putExtra(TAG_NOTIFICATION_ID, HEADS_UP_NOTIFICATION_ID)</span><br><span class="line"> <span class="comment">// 為 [Decline] 按鈕設定 PendingIntent</span></span><br><span class="line"> <span class="keyword">this</span><span class="symbol">@RemoteViews</span>.setOnClickPendingIntent(</span><br><span class="line"> R.id.button_decline,</span><br><span class="line"> PendingIntent.getActivity(context, <span class="number">5002</span>, <span class="keyword">this</span>, PendingIntent.FLAG_UPDATE_CURRENT)</span><br><span class="line"> )</span><br><span class="line"> }</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> <span class="comment">// 3.3 點選 notification 本身後,要執行的動作</span></span><br><span class="line"> <span class="keyword">val</span> pendingIntent = Intent().let {</span><br><span class="line"> it.flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP</span><br><span class="line"> it.setClass(context, MainActivity::<span class="keyword">class</span>.java)</span><br><span class="line"> it.putExtra(SNACK_BAR_MESSAGE, <span class="string">"Notification clicked"</span>)</span><br><span class="line"> it.putExtra(TAG_NOTIFICATION_ID, HEADS_UP_NOTIFICATION_ID)</span><br><span class="line"> PendingIntent.getActivity(context, <span class="number">5003</span>, it, PendingIntent.FLAG_UPDATE_CURRENT)</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> <span class="comment">// 4. 設定通知訊息(這邊注意 setStyle 樣式,某些樣式會截掉 RemoteView)</span></span><br><span class="line"> <span class="keyword">val</span> notification = NotificationCompat.Builder(context, HEADS_UP_CHANNEL_ID).let {</span><br><span class="line"> it.setStyle(NotificationCompat.BigTextStyle())</span><br><span class="line"> NotificationCompat.Style</span><br><span class="line"> it.priority = NotificationCompat.PRIORITY_HIGH</span><br><span class="line"> it.color = ContextCompat.getColor(context, R.color.custom_notification_text)</span><br><span class="line"> it.setSmallIcon(R.drawable.ic_round_call_24)</span><br><span class="line"> it.setContentTitle(message)</span><br><span class="line"> it.setContentText(<span class="string">"Tap to enter the dialing screen..."</span>)</span><br><span class="line"> it.setCategory(NotificationCompat.CATEGORY_CALL)</span><br><span class="line"> it.setDefaults(NotificationCompat.DEFAULT_ALL)</span><br><span class="line"> it.setVibrate(longArrayOf(<span class="number">1000</span>, <span class="number">1000</span>))</span><br><span class="line"> it.setOngoing(<span class="literal">true</span>)</span><br><span class="line"> it.setAutoCancel(<span class="literal">false</span>)</span><br><span class="line"> it.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)</span><br><span class="line"> it.setCustomHeadsUpContentView(headsUpRemoteView)</span><br><span class="line"> it.setFullScreenIntent(pendingIntent, <span class="literal">true</span>)</span><br><span class="line"> it.setContentIntent(pendingIntent)</span><br><span class="line"> it.build()</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> <span class="comment">// 5. 一切就緒,發送通知</span></span><br><span class="line"> notificationManager.notify(</span><br><span class="line"> HEADS_UP_NOTIFICATION_ID,</span><br><span class="line"> notification</span><br><span class="line"> )</span><br><span class="line">}</span><br></pre></td></tr></table></figure><h3 id="4-設定通知訊息"><a href="#4-設定通知訊息" class="headerlink" title="4. 設定通知訊息"></a>4. 設定通知訊息</h3><p> 有了 RemoteViews 和 PendingIntent 定義的動作後,就可以用 <code>NotificationCompat.Builder</code> 來把一切都接起來,這邊要注意使用的 style:<br> <br/></p> <div class="note warning"> <p>四號坑!慎選 style 樣式,要不然 RemoteViews 畫面很可能被截掉 😭</p> </div> <br/><p> 小唯這邊選擇的是 <code>BigTextStyle</code>,因為在眾多現成 Style 中,只有這個剛好適用,又不會截掉 RemoteViews 畫面。其他像是 <code>DecoratedCustomViewStyle</code>,實驗發現會把 heads-up 通知的底部給截掉,<code>BigPictureStyle</code>、<code>InboxStyle</code>、<code>MessagingStyle</code> 的應用則相對不適合來電通知。<br> <br/></p><h3 id="5-一切就緒,發送通知"><a href="#5-一切就緒,發送通知" class="headerlink" title="5. 一切就緒,發送通知"></a>5. 一切就緒,發送通知</h3><p> 記得將 notification ID 記錄下來,之後動作執行後,要針對紀錄的 notification ID 去做 cancel 的動作,否則 ongoing notification 會一直存在於通知列裡面。<br> <br/></p><h2 id="更多限制"><a href="#更多限制" class="headerlink" title="更多限制"></a>更多限制</h2><p>RemoteViews 其實在不同廠牌的手機上,行為或顯示都會有不盡相同的效果,在使用上,需要多多測試不同廠牌,甚至不同型號的手機。舉例來說,在小米手機上,小唯團隊就遇到了 heads-up 的畫面被截掉。無奈是 Android 手機廠牌、型號多到數不清,實在要驗也驗不完啊~~ 😭<br><br/></p><p><strong>相關連結:</strong><a href="https://github.com/wm4n/android-effect-demo"><i class="fab fa-github"></i> Demo 專案</a>, <a href="https://developer.android.com/guide/topics/appwidgets/index.html#CreatingLayout"><i class="fab fa-android"></i> RemoteViewes 支援的元件</a><br><br/></p><div> <div style="text-align:left;font-size:1em;"><p style="text-align:center;color: #ccc;">------------- 本文结束 <i class="far fa-question-circle"></i> 有比 RemoteViews 更好的選擇嗎? -------------</p> </div> </div><br/>]]></content>
<summary type="html">如何讓系統 UI 畫出客製化的 View,是小唯團隊最近一直在實驗的玩意。要在 Android 上面做到,就要使用 RemoteViews,通常第一次接觸到的開發者,都是經過 Google 搜尋如何實作某樣某樣功能,才間接找到 RemoteViews,然後就開始照著搜尋結果去做,接著就遇到許許多多問題,然後就是更多的 Google 搜尋,不斷的循環...</summary>
<category term="小唯的故事" scheme="https://wm4n.github.io/categories/%E5%B0%8F%E5%94%AF%E7%9A%84%E6%95%85%E4%BA%8B/"/>
<category term="Android" scheme="https://wm4n.github.io/categories/%E5%B0%8F%E5%94%AF%E7%9A%84%E6%95%85%E4%BA%8B/Android/"/>
<category term="Android" scheme="https://wm4n.github.io/tags/Android/"/>
</entry>
<entry>
<title>Android 如何設計使用 Adaptive Icon</title>
<link href="https://wm4n.github.io/Android-%E5%A6%82%E4%BD%95%E8%A8%AD%E8%A8%88%E4%BD%BF%E7%94%A8-Adaptive-Icon/"/>
<id>https://wm4n.github.io/Android-%E5%A6%82%E4%BD%95%E8%A8%AD%E8%A8%88%E4%BD%BF%E7%94%A8-Adaptive-Icon/</id>
<published>2020-12-21T15:32:06.000Z</published>
<updated>2021-03-23T16:54:23.488Z</updated>
<content type="html"><![CDATA[<h2 id="前言故事"><a href="#前言故事" class="headerlink" title="前言故事"></a>前言故事</h2><p>Adaptive icon 是自 Android 8 新增的 App icon 技術,支援相當多有趣的特效,雖然它已經上市一段時間,但是市面上支援的 App 仍然不多,當看到一個 App icon 不太對勁時,那大概就是沒未 adaptive icon 去量身定做。小唯的團隊雖然已經不是第一次使用,但是每隔一陣子請設計重畫新 icon 時,一開始總是會收到不符合格式的圖檔,小唯再三思考為什麼,終於發現這一切應該是 Google 的文件問題 😑。</p><p>每次小唯遇到不同的設計,總是把 Android Developer 上面的 <a href="https://developer.android.com/guide/practices/ui_guidelines/icon_design_adaptive">adaptive icon</a> 文件丟給設計,請設計提供圖檔,結果不是檔案東缺西少,就是大小不符格式,直到後來發現,一份文件,十個設計就算沒有十種,也有八種解讀方式,所以小唯決定自己寫一份超簡易 guideline 給自家設計。<br><br/></p><h2 id="1-準備-Adaptive-Icon"><a href="#1-準備-Adaptive-Icon" class="headerlink" title="1. 準備 Adaptive Icon"></a>1. 準備 Adaptive Icon</h2><h3 id="1-1-設計提供"><a href="#1-1-設計提供" class="headerlink" title="1.1 設計提供"></a>1.1 設計提供</h3><p>提供兩個 SVG 格式的 108dp x 108dp 圖檔,讓工程使用 File > New > Vector Asset 匯入,分別用於前景圖層、與背景圖層:</p><p><strong>前景圖</strong> 命名為 <code>ic_launcher_foreground.xml</code> (藍色背景色實際上為透明色) <img src="/Android-%E5%A6%82%E4%BD%95%E8%A8%AD%E8%A8%88%E4%BD%BF%E7%94%A8-Adaptive-Icon/vector_foreground.png" class="" width="324" height="324" title="前景圖"> </p><p><strong>背景圖</strong> 命名為 <code>ic_launcher_background.xml</code> <img src="/Android-%E5%A6%82%E4%BD%95%E8%A8%AD%E8%A8%88%E4%BD%BF%E7%94%A8-Adaptive-Icon/vector_background.png" class="" width="324" height="324" title="背景圖"> </p><p>兩張圖需要注意的重點為:</p><ol><li><p>兩張圖必須為 108dp x 108dp 大小的圖檔,讓工程放在 <code>res/drawable</code> 資料夾內</p></li><li><p>前景圖除了主題物外,其餘背景為透明色。所有需要跟著主題物一起移動的特效,都必須放在前景圖內</p></li><li><p>背景圖使用不透明色為背景色,任何不希望跟主題一起移動的內容,放在背景圖中</p></li><li><p>最常被忽略的一點。兩張圖中,<strong>所有可視物件,必須放在中間的 72dp x 72dp 中</strong>,也就是『不要放內容物在四邊的 18dp 中』(要不然顯示時就會被截掉)</p><img src="/Android-%E5%A6%82%E4%BD%95%E8%A8%AD%E8%A8%88%E4%BD%BF%E7%94%A8-Adaptive-Icon/adaptive_icon_margin.svg" class="" width="452" height="452" title="Adaptive Icon Margin"><br/></li></ol><h3 id="1-2-工程提供"><a href="#1-2-工程提供" class="headerlink" title="1.2 工程提供"></a>1.2 工程提供</h3><p>在 <code>res/mipmap-anydpi-v26</code> 資料夾下,準備兩個 xml 檔案:</p><ol><li><p><code>ic_launcher.xml</code></p></li><li><p><code>ic_launcher_round.xml</code></p></li></ol><p>內容都放入相同的:</p><figure class="highlight xml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line"><span class="meta"><?xml version="1.0" encoding="utf-8"?></span></span><br><span class="line"><span class="tag"><<span class="name">adaptive-icon</span> <span class="attr">xmlns:android</span>=<span class="string">"http://schemas.android.com/apk/res/android"</span>></span></span><br><span class="line"> <span class="tag"><<span class="name">background</span> <span class="attr">android:drawable</span>=<span class="string">"@drawable/ic_launcher_background"</span> /></span></span><br><span class="line"> <span class="tag"><<span class="name">foreground</span> <span class="attr">android:drawable</span>=<span class="string">"@drawable/ic_launcher_foreground"</span> /></span></span><br><span class="line"><span class="tag"></<span class="name">adaptive-icon</span>></span></span><br></pre></td></tr></table></figure><br/><p>Android 系統會自動在不同的使用者主題佈景下,套上相對應的遮罩:</p><img src="/Android-%E5%A6%82%E4%BD%95%E8%A8%AD%E8%A8%88%E4%BD%BF%E7%94%A8-Adaptive-Icon/adaptive_anim.gif" class=""><h2 id="2-為舊系統準備-Icon"><a href="#2-為舊系統準備-Icon" class="headerlink" title="2. 為舊系統準備 Icon"></a>2. 為舊系統準備 Icon</h2><h3 id="2-1-方形-Icon"><a href="#2-1-方形-Icon" class="headerlink" title="2.1 方形 Icon"></a>2.1 方形 Icon</h3><p>設計提供 5 張大小分別為 <code>192px x 192px</code>、<code>144px x 144px</code>、<code>96px x 96px</code>、<code>72px x 72px</code>、<code>48px x 48px</code> 的方形圖:</p><img src="/Android-%E5%A6%82%E4%BD%95%E8%A8%AD%E8%A8%88%E4%BD%BF%E7%94%A8-Adaptive-Icon/5_rect_icons.png" class=""> <p>讓工程將每張圖放入對應的資料夾中:</p><p><code>192px x 192px</code> > res/mipmap-xxxhdpi/ic_launcher.png</p><p><code>144px x 144px</code> > res/mipmap-xxhdpi/ic_launcher.png</p><p><code>96px x 96px</code> > res/mipmap-xhdpi/ic_launcher.png</p><p><code>72px x 72px</code> > res/mipmap-xhdpi/ic_launcher.png</p><p><code>48px x 48px</code> > res/mipmap-xhdpi/ic_launcher.png<br><br/></p><h3 id="2-2-圓形-Icon"><a href="#2-2-圓形-Icon" class="headerlink" title="2.2 圓形 Icon"></a>2.2 圓形 Icon</h3><p>如方形 icon,設計提供 5 張大小分別為 <code>192px x 192px</code>、<code>144px x 144px</code>、<code>96px x 96px</code>、<code>72px x 72px</code>、<code>48px x 48px</code> 的圓形圖(圓角用透明處理):</p><img src="/Android-%E5%A6%82%E4%BD%95%E8%A8%AD%E8%A8%88%E4%BD%BF%E7%94%A8-Adaptive-Icon/5_rect_icons.png" class=""> <p>讓工程將每張圖放入對應的資料夾中:</p><p><code>192px x 192px</code> > res/mipmap-xxxhdpi/ic_launcher_round.png</p><p><code>144px x 144px</code> > res/mipmap-xxhdpi/ic_launcher_round.png</p><p><code>96px x 96px</code> > res/mipmap-xhdpi/ic_launcher_round.png</p><p><code>72px x 72px</code> > res/mipmap-xhdpi/ic_launcher_round.png</p><p><code>48px x 48px</code> > res/mipmap-xhdpi/ic_launcher_round.png<br><br/></p><h2 id="工程部署"><a href="#工程部署" class="headerlink" title="工程部署"></a>工程部署</h2><p>工程需在 App 的 AndroidManifest.xml 中添加:</p><figure class="highlight xml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line"><span class="tag"><<span class="name">application</span></span></span><br><span class="line"><span class="tag"> <span class="attr">...</span></span></span><br><span class="line"><span class="tag"> <span class="attr">android:icon</span>=<span class="string">"@mipmap/ic_launcher"</span></span></span><br><span class="line"><span class="tag"> <span class="attr">android:roundIcon</span>=<span class="string">"@mipmap/ic_launcher_round"</span></span></span><br><span class="line"><span class="tag"> <span class="attr">...</span>></span></span><br></pre></td></tr></table></figure><br/><div> <div style="text-align:left;font-size:1em;"><p style="text-align:center;color: #ccc;">------------- 本文结束 <i class="fas fa-icons"></i> 不太為人所知的 Adaptive Icon -------------</p> </div> </div>]]></content>
<summary type="html"><img src='Android-如何設計使用-Adaptive-Icon/adaptive_anim2.gif' width='400' height='400'/>Adaptive icon 是自 Android 8 新增的 App icon 技術,支援相當多有趣的特效,雖然它已經上市一段時間,但是市面上支援的 App 仍然不多,當看到一個 App icon 不太對勁時,那大概就是沒未 adaptive icon 去量身定做。小唯的團隊雖然已經不是第一次使用,但是每隔一陣子請設計重畫新 icon 時,一開始總是會收到不符合格式的圖檔,小唯再三思考為什麼,終於發現應該是,Google 的文件問題...</summary>
<category term="小唯的故事" scheme="https://wm4n.github.io/categories/%E5%B0%8F%E5%94%AF%E7%9A%84%E6%95%85%E4%BA%8B/"/>
<category term="Android" scheme="https://wm4n.github.io/categories/%E5%B0%8F%E5%94%AF%E7%9A%84%E6%95%85%E4%BA%8B/Android/"/>
<category term="Android" scheme="https://wm4n.github.io/tags/Android/"/>
</entry>
<entry>
<title>RxJava 特效:API 搭配 zip 的妙用</title>
<link href="https://wm4n.github.io/RxJava-%E7%89%B9%E6%95%88%EF%BC%9AAPI-%E6%90%AD%E9%85%8D-zip-%E7%9A%84%E5%A6%99%E7%94%A8/"/>
<id>https://wm4n.github.io/RxJava-%E7%89%B9%E6%95%88%EF%BC%9AAPI-%E6%90%AD%E9%85%8D-zip-%E7%9A%84%E5%A6%99%E7%94%A8/</id>
<published>2020-12-14T16:14:20.000Z</published>
<updated>2021-03-23T16:54:23.524Z</updated>
<content type="html"><![CDATA[<h2 id="前言故事"><a href="#前言故事" class="headerlink" title="前言故事"></a>前言故事</h2><p>小唯公司的門口角落,放有幾張小桌子,小唯正與另一位負責 API 介面開發的同事,在其中一張桌上討論要如何為既有 API 設計新的 API 接口。以小唯負責的手機 App 前端角色,當然是希望越簡單越好,一個畫面一支 API 是對 App 最簡單的方式,進入畫面時使用 API 更新資料、重整介面,但是 API 同事卻有其他考量,因為架構問題,他無法把每個功能畫面,都依照小唯的期待去設計介面。最後結果是有數個畫面,需要使用超過一支 API 去將資料整合起來。這對於原本就在使用 <a href="https://github.com/ReactiveX/RxJava">RxJava</a> 的小唯團隊,腦中冒出一個之前看過的 <a href="http://reactivex.io/documentation/operators/zip.html">operator - Zip</a>。<br><br/></p><h2 id="在沒有的-ReactiveX-之前"><a href="#在沒有的-ReactiveX-之前" class="headerlink" title="在沒有的 ReactiveX 之前"></a>在沒有的 ReactiveX 之前</h2><p>當一個畫面對應一支 API,流程是相當單純:</p><img src="/RxJava-%E7%89%B9%E6%95%88%EF%BC%9AAPI-%E6%90%AD%E9%85%8D-zip-%E7%9A%84%E5%A6%99%E7%94%A8/1api.svg" class="" title="1 API / screen"><br/><p>但是當一個畫面有需要兩支或以上 API 才能呈現內容,就「稍微」有點複雜了,有多種組合都可以達到目的,通常會視狀況使用。</p><h3 id="相依性高的-API-組合"><a href="#相依性高的-API-組合" class="headerlink" title="相依性高的 API 組合"></a>相依性高的 API 組合</h3><p>在有強烈相依性下的狀況,當一支 API 失敗,接下來的都會變成毫無意義,這時可以讓一支 API 接著另一支,當第一支結果回來後,先處理結果,再決定是否要執行第二支 API。這就有兩種可能的組合:</p><img src="/RxJava-%E7%89%B9%E6%95%88%EF%BC%9AAPI-%E6%90%AD%E9%85%8D-zip-%E7%9A%84%E5%A6%99%E7%94%A8/2api_1.svg" class="" title="2 API / screen flow #1"><br/><img src="/RxJava-%E7%89%B9%E6%95%88%EF%BC%9AAPI-%E6%90%AD%E9%85%8D-zip-%E7%9A%84%E5%A6%99%E7%94%A8/2api_2.svg" class="" title="2 API / screen flow #2"><br/><h3 id="最重要的先顯示"><a href="#最重要的先顯示" class="headerlink" title="最重要的先顯示"></a>最重要的先顯示</h3><p>當第二支 API 只是單純的附加資訊時,這時使用者先看到第一支 API 資訊比較重要,就可以使用以下變形。這讓第一支結果回來後,就立刻渲染,等第二支 API 回來,再渲染第二支結果,即使後面的 API 失敗,對使用者的影響也較小:</p><img src="/RxJava-%E7%89%B9%E6%95%88%EF%BC%9AAPI-%E6%90%AD%E9%85%8D-zip-%E7%9A%84%E5%A6%99%E7%94%A8/2api_3.svg" class="" title="2 API / screen flow #3"><br/><img src="/RxJava-%E7%89%B9%E6%95%88%EF%BC%9AAPI-%E6%90%AD%E9%85%8D-zip-%E7%9A%84%E5%A6%99%E7%94%A8/2api_4.svg" class="" title="2 API / screen flow #4"><br/><h3 id="大家都重要,且無相依性"><a href="#大家都重要,且無相依性" class="headerlink" title="大家都重要,且無相依性"></a>大家都重要,且無相依性</h3><p>當 API 誰先誰後的順序不重要,而且沒有相依性時,線性同步發送且等待結果都回來後,再渲染畫面:</p><img src="/RxJava-%E7%89%B9%E6%95%88%EF%BC%9AAPI-%E6%90%AD%E9%85%8D-zip-%E7%9A%84%E5%A6%99%E7%94%A8/2api_5.svg" class="" title="2 API / screen flow #5"><br/><img src="/RxJava-%E7%89%B9%E6%95%88%EF%BC%9AAPI-%E6%90%AD%E9%85%8D-zip-%E7%9A%84%E5%A6%99%E7%94%A8/2api_6.svg" class="" title="2 API / screen flow #6"><br/><h3 id="非同步進行"><a href="#非同步進行" class="headerlink" title="非同步進行"></a>非同步進行</h3><p>非同步進行也是常用的方式之一,如果 API 無相依性,或者資料順序不重要,也可以非同步發送 API,哪個先回來就先渲染哪個。缺點也是常常會看到畫面先長出一部分,接著馬上在跳出剩下的部分,至於哪部分先,哪個後出來,則無法預測:</p><img src="/RxJava-%E7%89%B9%E6%95%88%EF%BC%9AAPI-%E6%90%AD%E9%85%8D-zip-%E7%9A%84%E5%A6%99%E7%94%A8/2api_parallel2.svg" class="" title="2 API / screen flow #7"><br/><p>當資料內容必須一同顯示,那就得等待結果都回來後才渲染,這是小唯公司內常見的用法,優點是不會看到畫面分別跳出來,缺點是不太容易實作,常常看到有寫錯亂的狀況(不會用又愛用,為什麼不乾脆使用同步方式,一個接一個):</p><img src="/RxJava-%E7%89%B9%E6%95%88%EF%BC%9AAPI-%E6%90%AD%E9%85%8D-zip-%E7%9A%84%E5%A6%99%E7%94%A8/2api_parallel.svg" class="" title="2 API / screen flow #8"><br/><h2 id="ReactiveX-的出現"><a href="#ReactiveX-的出現" class="headerlink" title="ReactiveX 的出現"></a>ReactiveX 的出現</h2><p>自從小唯團隊使用 RxJava zip 後,之前不容易實作的非同步寫法,突然變成無難度,zip 的特性是將多個資料來源,只在所有來源都接收到資料後,才一起發通知處理。下方的圖可以看成,白圈是 API 一號回傳的結果,黃色倒三角是 API 二號回傳的結果,黃色圈則是兩個 API 合併後的結果,也就預期達成的效果:</p><img src="/RxJava-%E7%89%B9%E6%95%88%EF%BC%9AAPI-%E6%90%AD%E9%85%8D-zip-%E7%9A%84%E5%A6%99%E7%94%A8/rxjava-zip.png" class="" title="RxJava zip operator"><br/><p>使用 zip 後流程變成如下:</p><img src="/RxJava-%E7%89%B9%E6%95%88%EF%BC%9AAPI-%E6%90%AD%E9%85%8D-zip-%E7%9A%84%E5%A6%99%E7%94%A8/2api_zip.svg" class="" title="2 API / zip operator"><br/><p>程式碼寫起來出奇的簡單:</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="keyword">public</span> Single<Result> <span class="title">getScreenData</span><span class="params">()</span> </span>{</span><br><span class="line"> <span class="comment">// 非同步呼叫 API 1</span></span><br><span class="line"> Single<ApiResult> api_1_result = api1();</span><br><span class="line"></span><br><span class="line"> <span class="comment">// 非同步呼叫 API 2</span></span><br><span class="line"> Single<ApiResult> api_2_result = api2();</span><br><span class="line"></span><br><span class="line"> <span class="comment">// 等待兩個結果都回來後,使用 handleApiResult 將資料轉換成 UI 可使用的 data model</span></span><br><span class="line"> <span class="keyword">return</span> Single.zip(api_1_result, api_2_result, <span class="keyword">this</span>::handleApiResult);</span><br><span class="line">}</span><br><span class="line"></span><br><span class="line"><span class="function"><span class="keyword">private</span> Result <span class="title">handleApiResult</span><span class="params">(</span></span></span><br><span class="line"><span class="function"><span class="params"> ApiResult api_1_result,</span></span></span><br><span class="line"><span class="function"><span class="params"> ApiResult api_2_result)</span> </span>{</span><br><span class="line"> Result result = <span class="keyword">new</span> Result();</span><br><span class="line"> <span class="comment">// 模擬將資料整合</span></span><br><span class="line"> result.setData1(api_1_result);</span><br><span class="line"> result.setData2(api_2_result);</span><br><span class="line"> <span class="keyword">return</span> result;</span><br><span class="line">}</span><br></pre></td></tr></table></figure><br/><br/><div> <div style="text-align:left;font-size:1em;"><p style="text-align:center;color: #ccc;">------------- 本文结束 <i class="fas fa-laptop-code"></i> 好想精通 RxJava 啊~ -------------</p> </div> </div><br/>]]></content>
<summary type="html"><img src='RxJava-特效:API-搭配-zip-的妙用/rxjava-zip.png'/>接近年底的這一個月,小唯團隊的工程們正忙著串接公司新的 API。新開的 API 不像是以往團隊所熟悉的,以一個畫面一支 API 為基礎的模式,反而會有一個畫面需要多支 API 相互組成狀況,在這種條件下,API 使用的時機就變得更有意思了...</summary>
<category term="小唯的故事" scheme="https://wm4n.github.io/categories/%E5%B0%8F%E5%94%AF%E7%9A%84%E6%95%85%E4%BA%8B/"/>
<category term="Android" scheme="https://wm4n.github.io/categories/%E5%B0%8F%E5%94%AF%E7%9A%84%E6%95%85%E4%BA%8B/Android/"/>
<category term="Android" scheme="https://wm4n.github.io/tags/Android/"/>
<category term="RxJava" scheme="https://wm4n.github.io/tags/RxJava/"/>
</entry>
<entry>
<title>Android 特效:動態變更標籤元件(TabLayout)</title>
<link href="https://wm4n.github.io/Android-%E7%89%B9%E6%95%88%EF%BC%9A%E5%8B%95%E6%85%8B%E8%AE%8A%E6%9B%B4%E6%A8%99%E7%B1%A4%E5%85%83%E4%BB%B6-TabLayout/"/>
<id>https://wm4n.github.io/Android-%E7%89%B9%E6%95%88%EF%BC%9A%E5%8B%95%E6%85%8B%E8%AE%8A%E6%9B%B4%E6%A8%99%E7%B1%A4%E5%85%83%E4%BB%B6-TabLayout/</id>
<published>2020-12-09T16:04:31.000Z</published>
<updated>2021-03-23T16:54:23.516Z</updated>
<content type="html"><![CDATA[<h2 id="前言故事"><a href="#前言故事" class="headerlink" title="前言故事"></a>前言故事</h2><p>電腦前,小唯不斷地調整程式碼,重複編譯,檢查模擬器上的結果,一整個上午小唯一直在試一個 Android 標籤元件的特效(Tab 特效)- 動態新增新的標籤頁,及移除現有的標籤頁。這問題已經困擾小唯一段時間了,原本以為是個很簡單的效果,一個上午絕對可以搞定,但卻在最後一步,怎麼試效果都不正確,標籤頁雖然可以新增或移除,但標籤頁的內容卻怪怪地。<br><br/></p><h2 id="簡單、或不簡單"><a href="#簡單、或不簡單" class="headerlink" title="簡單、或不簡單"></a>簡單、或不簡單</h2><p>小唯要求的規格很簡單,準備一個標籤元件,透過程式的方式,可隨意添加新標籤頁,或移除現有標籤頁(除非只剩下一個標籤頁)。規格聽起來很簡單,小唯心裡盤點著:「需要 TabLayout、ViewPager、FragmentPagerAdapter…」。團隊以往使用標籤元件,常常都是固定數量的標籤頁,只需透過 FragmentPagerAdapter 定義標籤頁數量、標籤名稱、內容等,就能顯示預期的效果:</p><figure class="highlight kotlin"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br></pre></td><td class="code"><pre><span class="line"><span class="class"><span class="keyword">class</span> <span class="title">UpdatableTabAdapter</span></span>(fm: FragmentManager): FragmentPagerAdapter(</span><br><span class="line"> fm,</span><br><span class="line"> BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT</span><br><span class="line">) {</span><br><span class="line"></span><br><span class="line"> ...</span><br><span class="line"></span><br><span class="line"> <span class="keyword">override</span> <span class="function"><span class="keyword">fun</span> <span class="title">getCount</span><span class="params">()</span></span>: <span class="built_in">Int</span> {</span><br><span class="line"> <span class="keyword">return</span> <span class="number">3</span> <span class="comment">// 三個標籤頁</span></span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> <span class="keyword">override</span> <span class="function"><span class="keyword">fun</span> <span class="title">getItem</span><span class="params">(position: <span class="type">Int</span>)</span></span>: Fragment {</span><br><span class="line"> <span class="comment">// 回傳指定位置的標籤頁 Fragment</span></span><br><span class="line"> }</span><br><span class="line"> </span><br><span class="line"> ...</span><br><span class="line">}</span><br></pre></td></tr></table></figure><p>但如果要能支援新增刪除,就稍微複雜一點,但只要把固定標籤頁的寫法,改為透過 MutableList 來控制標籤頁的數量,應該也不會太複雜吧:</p><figure class="highlight kotlin"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br></pre></td><td class="code"><pre><span class="line"><span class="class"><span class="keyword">class</span> <span class="title">UpdatableTabAdapter</span></span>(fm: FragmentManager): FragmentPagerAdapter(</span><br><span class="line"> fm,</span><br><span class="line"> BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT</span><br><span class="line">) {</span><br><span class="line"></span><br><span class="line"> <span class="keyword">private</span> <span class="keyword">val</span> tabs: MutableList<<span class="built_in">Int</span>> = mutableListOf(<span class="number">1</span>, <span class="number">2</span>, <span class="number">3</span>)</span><br><span class="line"></span><br><span class="line"> <span class="keyword">override</span> <span class="function"><span class="keyword">fun</span> <span class="title">getCount</span><span class="params">()</span></span>: <span class="built_in">Int</span> {</span><br><span class="line"> <span class="keyword">return</span> tabs.size</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> <span class="keyword">override</span> <span class="function"><span class="keyword">fun</span> <span class="title">getItem</span><span class="params">(position: <span class="type">Int</span>)</span></span>: Fragment {</span><br><span class="line"> <span class="comment">// 回傳指定位置的標籤頁 Fragment</span></span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> <span class="keyword">override</span> <span class="function"><span class="keyword">fun</span> <span class="title">getPageTitle</span><span class="params">(position: <span class="type">Int</span>)</span></span>: CharSequence? {</span><br><span class="line"> <span class="keyword">return</span> tabs[position].toString()</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> <span class="keyword">override</span> <span class="function"><span class="keyword">fun</span> <span class="title">getItemId</span><span class="params">(position: <span class="type">Int</span>)</span></span>: <span class="built_in">Long</span> {</span><br><span class="line"> <span class="keyword">return</span> tabs[position].toLong()</span><br><span class="line"> }</span><br><span class="line">}</span><br></pre></td></tr></table></figure><p>結果卻不如預期 😫:</p><div class="video-container"><iframe src="https://www.youtube.com/embed/dKKFvtnAWHE" frameborder="0" loading="lazy" allowfullscreen></iframe></div><br/><h2 id="意想不到的解決方案"><a href="#意想不到的解決方案" class="headerlink" title="意想不到的解決方案"></a>意想不到的解決方案</h2><p>上述做法是,只要在標籤頁上點選了新增符號,就會在右邊加一個新的標籤頁。反之,點選移除符號則移除最左邊的標籤頁,但怎知移除標籤頁後,卻出現了標籤混亂的狀態,這是因為 FragmentPagerAdapter 保存了標籤頁,並沒有在每次新增移除後都重建標籤頁,即使使用了 <code>notifyDataSetChanged()</code> 結果也是一樣。</p><p>經過一個上午的努力,小唯終於找到了關鍵原因,就是在 PagerAdapter 內的 getItemPosition,以下是 PagerAdapter 的內容及說明文件:</p><figure class="highlight java"><figcaption><span>PagerAdapter.java</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">/**</span></span><br><span class="line"><span class="comment"> * Called when the host view is attempting to determine if an item's position</span></span><br><span class="line"><span class="comment"> * has changed. Returns {<span class="doctag">@link</span> #POSITION_UNCHANGED} if the position of the given</span></span><br><span class="line"><span class="comment"> * item has not changed or {<span class="doctag">@link</span> #POSITION_NONE} if the item is no longer present</span></span><br><span class="line"><span class="comment"> * in the adapter.</span></span><br><span class="line"><span class="comment"> *</span></span><br><span class="line"><span class="comment"> * <p>The default implementation assumes that items will never</span></span><br><span class="line"><span class="comment"> * change position and always returns {<span class="doctag">@link</span> #POSITION_UNCHANGED}.</span></span><br><span class="line"><span class="comment"> *</span></span><br><span class="line"><span class="comment"> * <span class="doctag">@param</span> object Object representing an item, previously returned by a call to</span></span><br><span class="line"><span class="comment"> * {<span class="doctag">@link</span> #instantiateItem(View, int)}.</span></span><br><span class="line"><span class="comment"> * <span class="doctag">@return</span> object's new position index from [0, {<span class="doctag">@link</span> #getCount()}),</span></span><br><span class="line"><span class="comment"> * {<span class="doctag">@link</span> #POSITION_UNCHANGED} if the object's position has not changed,</span></span><br><span class="line"><span class="comment"> * or {<span class="doctag">@link</span> #POSITION_NONE} if the item is no longer present.</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"><span class="function"><span class="keyword">public</span> <span class="keyword">int</span> <span class="title">getItemPosition</span><span class="params">(<span class="meta">@NonNull</span> Object object)</span> </span>{</span><br><span class="line"> <span class="keyword">return</span> POSITION_UNCHANGED;</span><br><span class="line">}</span><br></pre></td></tr></table></figure><p>原來 PagerAdapter 的預設是 POSITION_UNCHANGED,這也是告訴自己,這個位置上的內容沒有變喔。小唯立刻在 UpdatableTabAdapter class 中覆蓋這個 function:</p><figure class="highlight kotlin"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">override</span> <span class="function"><span class="keyword">fun</span> <span class="title">getItemPosition</span><span class="params">(`<span class="keyword">object</span>`: <span class="type">Any</span>)</span></span>: <span class="built_in">Int</span> {</span><br><span class="line"> <span class="keyword">return</span> PagerAdapter.POSITION_NONE</span><br><span class="line">}</span><br></pre></td></tr></table></figure><p>重新編譯,檢查模擬器!結果真如預期,任務達成~ 🎉🎉🎉<br><br/></p><p><strong>相關連結:</strong><a href="https://github.com/wm4n/android-effect-demo"><i class="fab fa-github"></i> Demo 專案</a><br><br/></p><div> <div style="text-align:left;font-size:1em;"><p style="text-align:center;color: #ccc;">------------- 本文结束 <i class="far fa-question-circle"></i> 下次改用 ViewPager2 吧~ -------------</p> </div> </div><br/>]]></content>
<summary type="html"><img src='Android-特效:動態變更標籤元件-TabLayout/tab.jpg'/>電腦前,小唯不斷地調整程式碼,重複編譯,檢查模擬器上的結果,一整個上午小唯一直在試一個 Android 標籤元件的特效(Tab 特效)- 動態新增新的標籤頁,及移除現有的標籤頁。這問題已經困擾小唯一段時間了,原本以為是個很簡單的效果,一個上午絕對可以搞定,但卻在最後一步,怎麼試效果都不正確,標籤頁雖然可以新增或移除,但標籤頁的內容卻怪怪地...</summary>
<category term="小唯的故事" scheme="https://wm4n.github.io/categories/%E5%B0%8F%E5%94%AF%E7%9A%84%E6%95%85%E4%BA%8B/"/>
<category term="Android" scheme="https://wm4n.github.io/categories/%E5%B0%8F%E5%94%AF%E7%9A%84%E6%95%85%E4%BA%8B/Android/"/>
<category term="Android" scheme="https://wm4n.github.io/tags/Android/"/>
<category term="UI" scheme="https://wm4n.github.io/tags/UI/"/>
</entry>
<entry>
<title>「后翼棄兵」是如何說故事 - 靶心人公式</title>
<link href="https://wm4n.github.io/%E9%9D%B6%E5%BF%83%E4%BA%BA%E5%85%AC%E5%BC%8F%E8%88%87%E5%90%8E%E7%BF%BC%E6%A3%84%E5%85%B5/"/>
<id>https://wm4n.github.io/%E9%9D%B6%E5%BF%83%E4%BA%BA%E5%85%AC%E5%BC%8F%E8%88%87%E5%90%8E%E7%BF%BC%E6%A3%84%E5%85%B5/</id>
<published>2020-12-06T08:48:46.000Z</published>
<updated>2021-03-23T16:54:23.528Z</updated>
<content type="html"><![CDATA[<h2 id="前言故事"><a href="#前言故事" class="headerlink" title="前言故事"></a>前言故事</h2><p>最近很火熱的 Netflix 影集「后翼棄兵」,老皮終於也在週末時趁有空時,看完了整部劇。說來也剛好,老皮最近也讀了許榮哲老師的「3分鐘說18萬個故事」,所以影集一結束,老皮立刻拿起紙筆,試著將故事課中的技巧,針對影片去做一個分析,迫不及待的想驗證,如此好看的影集,是不是也都是所謂的套路~</p><h2 id="靶心人公式"><a href="#靶心人公式" class="headerlink" title="靶心人公式"></a>靶心人公式</h2><p>依照許榮哲老師的介紹,所有隱藏在故事裡的內在邏輯,都有一張大同小異的相似臉孔。這也就是「靶心人公式」的基本套路,也叫做的「七個問題的故事公式」,依照這個套路,將這些內在邏輯,分解成七個部分:</p><ol><li><p>主人翁的「目標」是什麼?</p></li><li><p>他的「阻礙」是什麼?</p></li><li><p>他如何「努力」?</p></li><li><p>「結果」如何?(通常是不好的結果。)</p></li><li><p>如果結果不理想,代表努力無效,那麼,有超越努力的「意外」可以改變這一切嗎?</p></li><li><p>意外發生,情節會如何「轉彎」?</p></li><li><p>最後的「結局」是什麼?</p></li></ol><p>那如果套用在「后翼棄兵」上?會是怎樣呢?老皮試著回答這七個問題。</p><h3 id="1-主人翁的「目標」是什麼?"><a href="#1-主人翁的「目標」是什麼?" class="headerlink" title="1. 主人翁的「目標」是什麼?"></a>1. 主人翁的「目標」是什麼?</h3><p>主人公貝絲,一場意外使她成為孤兒,被送到一所孤兒院。在孤兒院中,工友薛波先生帶著貝絲接觸了西洋棋,隨即貝絲展現了驚人的天賦,從完全不懂西洋棋,到沒多久就輕鬆擊敗薛波先生、附近學校的西洋棋指導老師等。隨著劇情之後被艾瑪領養,開始接觸了更大的西洋棋賽世界,目標是與頂尖的西洋棋手對弈,並取得獲勝。</p><h3 id="2-他的「阻礙」是什麼?"><a href="#2-他的「阻礙」是什麼?" class="headerlink" title="2. 他的「阻礙」是什麼?"></a>2. 他的「阻礙」是什麼?</h3><p>從一開始的無積分棋士,貝絲是沒有資格與高等棋士對弈,她必須一路取得勝利,獲得積分,才能與更高段位的棋士對弈,從地區的州冠居、各聯賽冠居、全美冠軍,接著才能獲得與世界冠軍對弈的資格。</p><h3 id="3-他如何「努力」?"><a href="#3-他如何「努力」?" class="headerlink" title="3. 他如何「努力」?"></a>3. 他如何「努力」?</h3><p>貝絲除了天生的棋藝外,自己對西洋棋路的渴求,也驅使她不斷的進步,從一開始在地下室與工友薛波先生的對弈,到州、全美賽事,對勝利的渴求都驅使她不斷精進自己的棋藝,甚至藉由酒精藥物,讓自己的精神更為專注的思考棋局(天花板中的移動棋路)。</p><h3 id="4-「結果」如何?"><a href="#4-「結果」如何?" class="headerlink" title="4.「結果」如何?"></a>4.「結果」如何?</h3><p>貝絲兩度獲得與世紀第一的蘇聯棋士博戈夫交手的機會,卻都大敗而歸,尤其是在巴黎的那一局,貝絲更是受到過度的酒精影響,在宿醉的狀態下與博戈夫對弈。這一場讓貝斯大受打擊,即使回國後,仍封閉自己與過往幫助過他的人,整日與酒精藥物為伍。</p><h3 id="5-有超越努力的「意外」可以改變這一切嗎?"><a href="#5-有超越努力的「意外」可以改變這一切嗎?" class="headerlink" title="5. 有超越努力的「意外」可以改變這一切嗎?"></a>5. 有超越努力的「意外」可以改變這一切嗎?</h3><p>有一天,貝絲家的門被敲響了,開了門意外的是敲門之人正是貝絲在孤兒院中的好友喬琳。與許久不見的好友再度相見,貝絲順間稍微醒了過來,得知喬琳離開孤兒院後,到現在成就與經歷,貝絲很是開心,但真正讓貝絲醒過來的是,孤兒院工友薛波先生的死訊。</p><h3 id="6-意外發生,情節會如何「轉彎」?"><a href="#6-意外發生,情節會如何「轉彎」?" class="headerlink" title="6. 意外發生,情節會如何「轉彎」?"></a>6. 意外發生,情節會如何「轉彎」?</h3><p>喬琳帶著貝絲去了薛波先生的靈前,貝絲的心彷彿又回到了在孤兒院時期,再度回到孤兒院,這裡的一切讓他感慨不已,走入曾經與薛波先生一起對弈的地下室,貝絲看到的是牆上,從報紙、雜誌上一篇篇剪貼下來,所有關於自己的報導。原來薛波先生就算在貝絲離開孤兒院後,仍是關注她的所有西洋棋賽事。貝絲從牆上拿下來一張照片,那張照片是薛波先生特地從附近中學請來的西洋棋指導老師,在地下室幫他們拍下來的合照。回到喬琳的車上,這一切讓貝絲大哭了起來,從這那一刻起,貝絲有轉變了。</p><h3 id="7-最後的「結局」是什麼?"><a href="#7-最後的「結局」是什麼?" class="headerlink" title="7. 最後的「結局」是什麼?"></a>7. 最後的「結局」是什麼?</h3><p>回到家中後,貝絲不再酒精所困,四處籌集資金前往莫斯科與蘇聯西洋棋高手再戰,她擺脫了酒精與藥物,在好友們的支持與協助下,與博戈夫做最決賽的對弈,貝絲擊敗了博戈夫,取得了勝利。賽後,貝絲甩開了跟隨人員,獨自一人在莫斯科公園中散步,走到了公園老年人聚集的西洋棋桌區,就像當年與薛波先生對弈一般,貝絲與對方擺開了棋局…</p><h2 id="更多的可能性"><a href="#更多的可能性" class="headerlink" title="更多的可能性"></a>更多的可能性</h2><p>老皮很興奮,這證明了許榮哲老師說的,所有的好故事都有套路的。而且以上的「靶心人公式」只是基本套路,還有其他18萬種變化的可能性~ 🤩</p><hr><p><strong>相關連結:</strong></p><img src="/%E9%9D%B6%E5%BF%83%E4%BA%BA%E5%85%AC%E5%BC%8F%E8%88%87%E5%90%8E%E7%BF%BC%E6%A3%84%E5%85%B5/book.jpeg" class="book cover"><p><a href="https://tpml.gov.taipei/News_Content.aspx?n=D4B1C88D10A4B867&s=B6F2F362D2B63DD7">故事課:3分鐘說18萬個故事,打造影響力</a><br><br/></p><div> <div style="text-align:left;font-size:1em;"><p style="text-align:center;color: #ccc;">------------- 本文结束 <i class="far fa-question-circle"></i> 改天也要來玩西洋棋 -------------</p> </div> </div>]]></content>
<summary type="html"><img src='靶心人公式與后翼棄兵/chess.jpg'/>最近很火熱的 Netflix 影集「后翼棄兵」,老皮終於也在週末時趁有空時,看完了整部劇。說來也剛好,老皮最近也讀了許榮哲老師的「3分鐘說18萬個故事」,所以影集一結束,老皮立刻拿起紙筆,試著將故事課中的技巧,針對影片去做一個分析,迫不及待的想驗證,如此好看的影集,是不是也都是所謂的套路...</summary>
<category term="老皮的故事" scheme="https://wm4n.github.io/categories/%E8%80%81%E7%9A%AE%E7%9A%84%E6%95%85%E4%BA%8B/"/>
<category term="讀書心得" scheme="https://wm4n.github.io/categories/%E8%80%81%E7%9A%AE%E7%9A%84%E6%95%85%E4%BA%8B/%E8%AE%80%E6%9B%B8%E5%BF%83%E5%BE%97/"/>
<category term="讀書心得" scheme="https://wm4n.github.io/tags/%E8%AE%80%E6%9B%B8%E5%BF%83%E5%BE%97/"/>
</entry>
<entry>
<title>Android 特效:底部滑出視窗</title>
<link href="https://wm4n.github.io/Android-%E7%89%B9%E6%95%88%EF%BC%9A%E5%BA%95%E9%83%A8%E6%BB%91%E5%87%BA%E8%A6%96%E7%AA%97/"/>
<id>https://wm4n.github.io/Android-%E7%89%B9%E6%95%88%EF%BC%9A%E5%BA%95%E9%83%A8%E6%BB%91%E5%87%BA%E8%A6%96%E7%AA%97/</id>
<published>2020-12-03T15:30:49.000Z</published>
<updated>2021-03-23T16:54:23.520Z</updated>
<content type="html"><![CDATA[<h2 id="前言故事"><a href="#前言故事" class="headerlink" title="前言故事"></a>前言故事</h2><p>小唯的老闆,今天拿了自己的 iPhone 手機過來,問道:「幫我看看,這是怎麼做的。」小唯探過頭去,老闆手機上開著 LINE,一篇對話視窗內,其中一則對話內容是個連結 <code>line://app/1557539795-mrYlWQp7</code>。老闆點了連結,app 底部跳出一個繪圖區,看起來 LINE 很不搭,不像是原生的功能,接著看著老闆在繪圖區畫了畫,點選送出,剛畫完的作品就貼到了對話視窗內 😦</p><img src="/Android-%E7%89%B9%E6%95%88%EF%BC%9A%E5%BA%95%E9%83%A8%E6%BB%91%E5%87%BA%E8%A6%96%E7%AA%97/screen.jpg" class="iPhone screen" width="400"><br/><p>事後上網查詢後,小唯才知道這技術叫做 LIFF,一句話來解釋就是讓開發者能透過 WebView 與 JavaScript 等技術與 LINE app 做互動,當然實際背後有更多技術成分讓互動上更便利與安全。<br><br/></p><h2 id="如何從底部跳出視窗"><a href="#如何從底部跳出視窗" class="headerlink" title="如何從底部跳出視窗"></a>如何從底部跳出視窗</h2><p>底部跳出視窗,這個看似 iOS 預設顯示選單的方式,在 Android 上要如何實現呢?🤔 這應該不是預設 Dialog 就能直接做到的效果,小唯這邊選擇使用 DialogFragment:</p><figure class="highlight kotlin"><figcaption><span>BottomDialogFragment.kt</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br></pre></td><td class="code"><pre><span class="line"><span class="class"><span class="keyword">class</span> <span class="title">BottomDialogFragment</span> : <span class="type">DialogFragment</span></span>() {</span><br><span class="line"></span><br><span class="line"> <span class="keyword">private</span> <span class="keyword">var</span> _binding : BottomDialogFragmentBinding? = <span class="literal">null</span></span><br><span class="line"> <span class="keyword">private</span> <span class="keyword">val</span> binding <span class="keyword">get</span>() = _binding!!</span><br><span class="line"></span><br><span class="line"> <span class="keyword">override</span> <span class="function"><span class="keyword">fun</span> <span class="title">onCreate</span><span class="params">(savedInstanceState: <span class="type">Bundle</span>?)</span></span> {</span><br><span class="line"> <span class="keyword">super</span>.onCreate(savedInstanceState)</span><br><span class="line"></span><br><span class="line"> setStyle(</span><br><span class="line"> STYLE_NORMAL,</span><br><span class="line"> R.style.BottomDialogFragment</span><br><span class="line"> )</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> <span class="keyword">override</span> <span class="function"><span class="keyword">fun</span> <span class="title">onCreateView</span><span class="params">(</span></span></span><br><span class="line"><span class="function"><span class="params"> inflater: <span class="type">LayoutInflater</span>,</span></span></span><br><span class="line"><span class="function"><span class="params"> container: <span class="type">ViewGroup</span>?,</span></span></span><br><span class="line"><span class="function"><span class="params"> savedInstanceState: <span class="type">Bundle</span>?</span></span></span><br><span class="line"><span class="function"><span class="params"> )</span></span>: View? {</span><br><span class="line"> _binding = BottomDialogFragmentBinding.inflate(inflater, container, <span class="literal">false</span>)</span><br><span class="line"> <span class="keyword">return</span> _binding?.viewRoot</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> <span class="keyword">override</span> <span class="function"><span class="keyword">fun</span> <span class="title">onViewCreated</span><span class="params">(view: <span class="type">View</span>, savedInstanceState: <span class="type">Bundle</span>?)</span></span> {</span><br><span class="line"> <span class="keyword">super</span>.onViewCreated(view, savedInstanceState)</span><br><span class="line"> _binding?.webView?.loadUrl(<span class="string">"https://wm4n.github.io"</span>)</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> <span class="keyword">override</span> <span class="function"><span class="keyword">fun</span> <span class="title">onDestroyView</span><span class="params">()</span></span> {</span><br><span class="line"> <span class="keyword">super</span>.onDestroyView()</span><br><span class="line"> _binding = <span class="literal">null</span></span><br><span class="line"> }</span><br><span class="line">}</span><br></pre></td></tr></table></figure><br/><h3 id="Layout-樣式"><a href="#Layout-樣式" class="headerlink" title="Layout 樣式"></a>Layout 樣式</h3><p>以上程式碼應該就是 DialogFragment 的標準做法,網路上很多基本範例都是這樣,其中有幾個小地方,經過小唯特別修改,以達成預期的效果。</p><p>首先,layout 的樣式,也就是 <code>BottomDialogFragmentBinding</code> 的實作(因為是 DataBinding,所以實際 xml 名稱為 bottom_dialog_fragment):</p><figure class="highlight xml"><figcaption><span>bottom_dialog_fragment.xml</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br></pre></td><td class="code"><pre><span class="line"><span class="meta"><?xml version="1.0" encoding="utf-8"?></span></span><br><span class="line"><span class="tag"><<span class="name">layout</span> <span class="attr">xmlns:android</span>=<span class="string">"http://schemas.android.com/apk/res/android"</span></span></span><br><span class="line"><span class="tag"> <span class="attr">xmlns:app</span>=<span class="string">"http://schemas.android.com/apk/res-auto"</span>></span></span><br><span class="line"></span><br><span class="line"> <span class="tag"><<span class="name">data</span>></span></span><br><span class="line"> <span class="tag"></<span class="name">data</span>></span></span><br><span class="line"></span><br><span class="line"> <span class="tag"><<span class="name">androidx.constraintlayout.widget.ConstraintLayout</span></span></span><br><span class="line"><span class="tag"> <span class="attr">android:id</span>=<span class="string">"@+id/view_root"</span></span></span><br><span class="line"><span class="tag"> <span class="attr">android:layout_width</span>=<span class="string">"match_parent"</span></span></span><br><span class="line"><span class="tag"> <span class="attr">android:layout_height</span>=<span class="string">"match_parent"</span></span></span><br><span class="line"><span class="tag"> <span class="attr">android:fitsSystemWindows</span>=<span class="string">"true"</span></span></span><br><span class="line"><span class="tag"> <span class="attr">android:background</span>=<span class="string">"@android:color/transparent"</span>></span></span><br><span class="line"></span><br><span class="line"> <span class="tag"><<span class="name">androidx.cardview.widget.CardView</span></span></span><br><span class="line"><span class="tag"> <span class="attr">android:id</span>=<span class="string">"@+id/card_view"</span></span></span><br><span class="line"><span class="tag"> <span class="attr">android:layout_width</span>=<span class="string">"0dp"</span></span></span><br><span class="line"><span class="tag"> <span class="attr">android:layout_height</span>=<span class="string">"350dp"</span></span></span><br><span class="line"><span class="tag"> <span class="attr">android:elevation</span>=<span class="string">"8dp"</span></span></span><br><span class="line"><span class="tag"> <span class="attr">app:cardCornerRadius</span>=<span class="string">"12dp"</span></span></span><br><span class="line"><span class="tag"> <span class="attr">app:cardBackgroundColor</span>=<span class="string">"?colorPrimary"</span></span></span><br><span class="line"><span class="tag"> <span class="attr">app:cardElevation</span>=<span class="string">"8dp"</span></span></span><br><span class="line"><span class="tag"> <span class="attr">app:contentPaddingTop</span>=<span class="string">"1dp"</span></span></span><br><span class="line"><span class="tag"> <span class="attr">app:layout_constraintBottom_toBottomOf</span>=<span class="string">"parent"</span></span></span><br><span class="line"><span class="tag"> <span class="attr">app:layout_constraintEnd_toEndOf</span>=<span class="string">"parent"</span></span></span><br><span class="line"><span class="tag"> <span class="attr">app:layout_constraintStart_toStartOf</span>=<span class="string">"parent"</span>></span></span><br><span class="line"></span><br><span class="line"> <span class="tag"><<span class="name">WebView</span></span></span><br><span class="line"><span class="tag"> <span class="attr">android:id</span>=<span class="string">"@+id/web_view"</span></span></span><br><span class="line"><span class="tag"> <span class="attr">android:layout_width</span>=<span class="string">"match_parent"</span></span></span><br><span class="line"><span class="tag"> <span class="attr">android:layout_height</span>=<span class="string">"match_parent"</span>/></span></span><br><span class="line"></span><br><span class="line"> <span class="tag"></<span class="name">androidx.cardview.widget.CardView</span>></span></span><br><span class="line"> <span class="tag"></<span class="name">androidx.constraintlayout.widget.ConstraintLayout</span>></span></span><br><span class="line"><span class="tag"></<span class="name">layout</span>></span></span><br><span class="line"></span><br></pre></td></tr></table></figure><p>這在畫面的下方,放一個 CardView,裡面包著一個 WebView,範例使用 350dp 來當底部高度來模擬,實際上應該要是一個可控的參數,如下圖:</p><img src="/Android-%E7%89%B9%E6%95%88%EF%BC%9A%E5%BA%95%E9%83%A8%E6%BB%91%E5%87%BA%E8%A6%96%E7%AA%97/layout.png" class="layout"><br/><h3 id="Style-樣式"><a href="#Style-樣式" class="headerlink" title="Style 樣式"></a>Style 樣式</h3><p>Style 樣式可說是這效果的重點,也是小唯花費最多的時間在嘗試的部分,網路上資源很少,官方文件說明也很不容易懂,加上各個設定需互相搭配,組合起來數量可真是不少,以下是在 onCreate 中先指定 style 樣式:</p><figure class="highlight kotlin"><figcaption><span>onCreate</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">override</span> <span class="function"><span class="keyword">fun</span> <span class="title">onCreate</span><span class="params">(savedInstanceState: <span class="type">Bundle</span>?)</span></span> {</span><br><span class="line"> <span class="keyword">super</span>.onCreate(savedInstanceState)</span><br><span class="line"></span><br><span class="line"> setStyle(</span><br><span class="line"> STYLE_NORMAL,</span><br><span class="line"> R.style.BottomDialogFragment</span><br><span class="line"> )</span><br><span class="line">}</span><br></pre></td></tr></table></figure><p>另外在 styles.xml 中定義實際的樣式設定:</p><figure class="highlight xml"><figcaption><span>styles.xml</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br></pre></td><td class="code"><pre><span class="line"><span class="tag"><<span class="name">style</span> <span class="attr">name</span>=<span class="string">"BottomDialogFragment"</span> <span class="attr">parent</span>=<span class="string">"android:Theme.Translucent.NoTitleBar"</span>></span></span><br><span class="line"><span class="xml"> <span class="tag"><<span class="name">item</span> <span class="attr">name</span>=<span class="string">"android:windowAnimationStyle"</span>></span>@style/BottomDialogFragmentAnimation<span class="tag"></<span class="name">item</span>></span></span></span><br><span class="line"><span class="xml"> <span class="tag"><<span class="name">item</span> <span class="attr">name</span>=<span class="string">"android:windowTranslucentStatus"</span>></span>false<span class="tag"></<span class="name">item</span>></span></span></span><br><span class="line"><span class="xml"> <span class="tag"><<span class="name">item</span> <span class="attr">name</span>=<span class="string">"android:statusBarColor"</span>></span>@android:color/transparent<span class="tag"></<span class="name">item</span>></span></span></span><br><span class="line"><span class="xml"> <span class="tag"><<span class="name">item</span> <span class="attr">name</span>=<span class="string">"android:windowDrawsSystemBarBackgrounds"</span>></span>true<span class="tag"></<span class="name">item</span>></span></span></span><br><span class="line"><span class="tag"></<span class="name">style</span>></span></span><br><span class="line"></span><br><span class="line"><span class="tag"><<span class="name">style</span> <span class="attr">name</span>=<span class="string">"BottomDialogFragmentAnimation"</span> <span class="attr">parent</span>=<span class="string">"@android:style/Animation.Activity"</span>></span></span><br><span class="line"><span class="xml"> <span class="tag"><<span class="name">item</span> <span class="attr">name</span>=<span class="string">"android:windowEnterAnimation"</span>></span>@anim/slide_in_from_bottom<span class="tag"></<span class="name">item</span>></span></span></span><br><span class="line"><span class="xml"> <span class="tag"><<span class="name">item</span> <span class="attr">name</span>=<span class="string">"android:windowExitAnimation"</span>></span>@anim/slide_out_to_bottom<span class="tag"></<span class="name">item</span>></span></span></span><br><span class="line"><span class="tag"></<span class="name">style</span>></span></span><br></pre></td></tr></table></figure><p>以上是小唯測試過,最少 style 樣式設定內,可達成指定效果的,每行說明如下:</p><ol><li><p>parent 繼承 <code>android:Theme.Translucent.NoTitleBar</code>,確保使用透明背景時,同時保留 status bar (status 事情況保留,如果本身 App 中隱藏 status bar,這邊可以使用 <code>android:Theme.Translucent.NoTitleBar.Fullscreen</code>)</p></li><li><p><code>windowTranslucentStatus</code>、<code>statusBarColor</code>、<code>windowDrawsSystemBarBackgrounds</code> 搭配一起使用,三個設定讓 DialogFragment 的 status bar 變成完全透明,這讓向上移動的動畫效果不會出現殘影</p></li><li><p>slide 動畫只是單純的 translation 如下</p> <figure class="highlight xml"><figcaption><span>slide_in_from_bottom.xml</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line"><span class="tag"><<span class="name">set</span> <span class="attr">xmlns:android</span>=<span class="string">"http://schemas.android.com/apk/res/android"</span></span></span><br><span class="line"><span class="tag"><span class="attr">android:duration</span>=<span class="string">"@android:integer/config_mediumAnimTime"</span></span></span><br><span class="line"><span class="tag"><span class="attr">android:interpolator</span>=<span class="string">"@android:interpolator/linear"</span>></span></span><br><span class="line"><span class="tag"><<span class="name">translate</span></span></span><br><span class="line"><span class="tag"> <span class="attr">android:fromYDelta</span>=<span class="string">"50%p"</span></span></span><br><span class="line"><span class="tag"> <span class="attr">android:toYDelta</span>=<span class="string">"0"</span> /></span></span><br><span class="line"><span class="tag"></<span class="name">set</span>></span></span><br></pre></td></tr></table></figure> <figure class="highlight xml"><figcaption><span>slide_out_to_bottom.xml</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line"><span class="tag"><<span class="name">set</span> <span class="attr">xmlns:android</span>=<span class="string">"http://schemas.android.com/apk/res/android"</span></span></span><br><span class="line"><span class="tag"><span class="attr">android:duration</span>=<span class="string">"@android:integer/config_mediumAnimTime"</span></span></span><br><span class="line"><span class="tag"><span class="attr">android:interpolator</span>=<span class="string">"@android:interpolator/linear"</span>></span></span><br><span class="line"><span class="tag"><<span class="name">translate</span></span></span><br><span class="line"><span class="tag"> <span class="attr">android:fromYDelta</span>=<span class="string">"0"</span></span></span><br><span class="line"><span class="tag"> <span class="attr">android:toYDelta</span>=<span class="string">"50%p"</span> /></span></span><br><span class="line"><span class="tag"></<span class="name">set</span>></span></span><br></pre></td></tr></table></figure><br/></li></ol><h2 id="執行與驗證"><a href="#執行與驗證" class="headerlink" title="執行與驗證"></a>執行與驗證</h2><p>最後,要使用 DialogFragment 也是相當容易:</p><figure class="highlight kotlin"><figcaption><span>MainActivity.kt</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">val</span> f = BottomDialogFragment()</span><br><span class="line">f.show(supportFragmentManager, <span class="string">"BottomDialogFragment"</span>)</span><br></pre></td></tr></table></figure><p>效果如下:</p><div class="video-container"><iframe src="https://www.youtube.com/embed/n1Fh-ZnZ7u8" frameborder="0" loading="lazy" allowfullscreen></iframe></div><br/><p><strong>相關連結:</strong><a href="https://github.com/wm4n/android-effect-demo"><i class="fab fa-github"></i> Demo 專案</a><br><br/></p><div> <div style="text-align:left;font-size:1em;"><p style="text-align:center;color: #ccc;">------------- 本文结束 <i class="far fa-question-circle"></i> 我不懂 styles.xml 的設定啊~ -------------</p> </div> </div><br/>]]></content>
<summary type="html">小唯的老闆,今天拿了自己的 iPhone 手機過來,問道:「幫我看看,這是怎麼做的。」小唯探過頭去,老闆手機上開著 LINE,一篇對話視窗內,其中一則對話內容是個連結 `line://app/xxxxxxxxxx`。老闆點了連結,app 底部跳出一個繪圖區,看起來 LINE 很不搭,不像是原生的功能,接著看著老闆在繪圖區畫了畫,點選送出,剛畫完的作品就貼到了對話視窗內...</summary>
<category term="小唯的故事" scheme="https://wm4n.github.io/categories/%E5%B0%8F%E5%94%AF%E7%9A%84%E6%95%85%E4%BA%8B/"/>
<category term="Android" scheme="https://wm4n.github.io/categories/%E5%B0%8F%E5%94%AF%E7%9A%84%E6%95%85%E4%BA%8B/Android/"/>
<category term="Android" scheme="https://wm4n.github.io/tags/Android/"/>
<category term="UI" scheme="https://wm4n.github.io/tags/UI/"/>
</entry>
<entry>
<title>App 命名與取口號的哲學</title>
<link href="https://wm4n.github.io/App-%E5%91%BD%E5%90%8D%E8%88%87%E5%8F%96%E5%8F%A3%E8%99%9F%E7%9A%84%E5%93%B2%E5%AD%B8/"/>
<id>https://wm4n.github.io/App-%E5%91%BD%E5%90%8D%E8%88%87%E5%8F%96%E5%8F%A3%E8%99%9F%E7%9A%84%E5%93%B2%E5%AD%B8/</id>
<published>2020-11-28T19:17:36.000Z</published>
<updated>2021-03-23T16:54:23.520Z</updated>
<content type="html"><![CDATA[<h2 id="前言故事"><a href="#前言故事" class="headerlink" title="前言故事"></a>前言故事</h2><p>在 2006,作家艾爾特與奧本海默教授,這兩位進行了一項有趣的實驗,他們分析了紐約證交所和美國證交所在 1990 - 2004 年間,新掛牌上市的公司股票。實驗發現,一間公司股票的剛上市價格,竟然跟它上市的名稱代號有相關性。儘管股票的價格回逐漸回歸市場機制,但名稱較容易發音的公司,股價表現優於那些較難發音的公司。例,股票代號 KAR 在剛上市的同期,就優於代號 RDO 的股價,前者大部分有英文基礎的人,應該都能很直覺地唸出,但相對後者,大多數人都會先思考一下它的唸法。<br><br/> </p><img src="/App-%E5%91%BD%E5%90%8D%E8%88%87%E5%8F%96%E5%8F%A3%E8%99%9F%E7%9A%84%E5%93%B2%E5%AD%B8/F1.large.jpg" class="紐約證交所,股價與代號"><p> <span style="color:grey;font-size: 11pt">紐約證交所,股票代號是否易讀與上市後的一天、一週、六個月、與一年後的股價關係圖(Alter & Oppenheimer, 2006)</span><br><br/></p><img src="/App-%E5%91%BD%E5%90%8D%E8%88%87%E5%8F%96%E5%8F%A3%E8%99%9F%E7%9A%84%E5%93%B2%E5%AD%B8/F2.large.jpg" class="美國證交所,股價與代號"><p> <span style="color:grey;font-size: 11pt">美國證交所,股票代號是否易讀與上市後的一天、一週、六個月、與一年後的股價關係圖 (Alter & Oppenheimer, 2006)</span></p><p>由以上證據來看,人們在日常生活中,時時地受到其他人為環境所影響。<br><br/></p><h2 id="大師的巨作"><a href="#大師的巨作" class="headerlink" title="大師的巨作"></a>大師的巨作</h2><p>羅伯特·席爾迪尼是行為心理學的大師,他有一本「鋪梗力」的書,其中講到一點:命名其實也是「鋪梗」的一種方式,其中以「輕鬆」的方式命名,就會起相當的作用。作者舉例,在數間有名的律師事務所中,姓名較好發音的律師,受到的委託也是高於姓名較不易發音的律師。可見人們的第一直覺,除了外觀外,輕鬆容易發音,也對使用者有相當的吸引效果(人類大腦懶惰的特性 😅)。</p><p>同理可以用在 App 的命名,在使用者還沒使用到產品,光只要看到 App Store 或 Play Store 的 App 名,就有可能影響他們的抉擇。<br><br/></p><h2 id="人力銀行的命名與口號"><a href="#人力銀行的命名與口號" class="headerlink" title="人力銀行的命名與口號"></a>人力銀行的命名與口號</h2><p>老皮最近發現台灣的數間人力銀行,紛紛對其產品 App 進行了改版,老皮將各間的產品命名與口號拿出,交互比一比,找到了其中一些有趣的點:</p><img src="/App-%E5%91%BD%E5%90%8D%E8%88%87%E5%8F%96%E5%8F%A3%E8%99%9F%E7%9A%84%E5%93%B2%E5%AD%B8/1130_1.png" class="screenshot"><ol><li><p>首先,以「輕鬆鋪梗」的方式來看,「518熊班 - 找工作很簡單!」是所以 app 中做得最好的,其一是名稱短而簡潔,使用者可以在一句話中讀完,它不像前幾個 app,名稱加口號都無法顯示完全。其二是中文特有的押韻,「班」與「單」都有結尾「ㄢ」的音,唸起來就是順(實際唸唸就知道~),有種古時為了使訊息理念的傳播,將訊息編成歌曲用唱更有效果。 </p></li><li><p>找工作是使用者的目的,也是他們下載 app 的主因,在工作與未來的不確定因素下,「找工作很簡單!」的口號,讓使用者清楚的了解到使用這款 App 能帶給的好處是什麼?就是能快點找到工作啊~,實在地講出了使用者心底想的。相比其他幾款,口號大多是推銷自己的功能,在人性化設計上,518 似乎又略勝一籌。 </p></li><li><p>定義產業的第一,在行銷上相當的重要,因為沒人記得第二,只有第一名會被記得。在所有 app 中,只有 104 的「工作機會最多」、小雞上工的「全台最大的學生打工APP」有將自己定位為第一。其中小雞上工厲害的地方是,它知道自己產品在職缺數量上無法與產業龍頭 104 相比,所以就定義了自己的第一,也就是學生打工中第一,真是相當厲害的口號。接著是「全台最大」四個字,這明確刻畫自己的領定範圍,相比只是單單描述「工作機會最多」,「全台最大」讓使用者更能聯想,而聯想就是影響力的關鍵。 </p><br/></li></ol><p>有什麼想法告訴老皮,請在下方留言喔~ </p><hr><p><strong>相關連結:</strong></p><p><a href="https://www.pnas.org/content/103/24/9369">Adam L. Alter, Daniel M. Oppenheimer, (2006), Predicting short-term stock fluctuations by using processing fluency</a> </p><div> <div style="text-align:left;font-size:1em;"><p style="text-align:center;color: #ccc;">------------- 本文结束 <i class="far fa-meh-rolling-eyes"></i> 我也要一個好名字啊 -------------</p> </div> </div>]]></content>
<summary type="html"> 2006,作家艾爾特與奧本海默教授,這兩位進行了一項有趣的實驗,他們分析了紐約證交所和美國證交所在 1990 - 2004 年間,新掛牌上市的公司股票。實驗發現,一間公司股票的剛上市價格,竟然跟它上市的名稱代號有相關性。儘管股票的價格回逐漸回歸市場機制,但名稱較容易發音的公司,股價表現優於那些較難發音的公司...</summary>
<category term="老皮的故事" scheme="https://wm4n.github.io/categories/%E8%80%81%E7%9A%AE%E7%9A%84%E6%95%85%E4%BA%8B/"/>
<category term="讀書心得" scheme="https://wm4n.github.io/categories/%E8%80%81%E7%9A%AE%E7%9A%84%E6%95%85%E4%BA%8B/%E8%AE%80%E6%9B%B8%E5%BF%83%E5%BE%97/"/>
<category term="App" scheme="https://wm4n.github.io/tags/App/"/>
<category term="行銷" scheme="https://wm4n.github.io/tags/%E8%A1%8C%E9%8A%B7/"/>
<category term="讀書心得" scheme="https://wm4n.github.io/tags/%E8%AE%80%E6%9B%B8%E5%BF%83%E5%BE%97/"/>
</entry>
<entry>
<title>為什麼 Android 跳出方塊通知!</title>
<link href="https://wm4n.github.io/%E8%B7%B3%E5%87%BA%E6%96%B9%E5%A1%8A%E7%9A%84-Android/"/>
<id>https://wm4n.github.io/%E8%B7%B3%E5%87%BA%E6%96%B9%E5%A1%8A%E7%9A%84-Android/</id>
<published>2020-11-19T12:31:04.000Z</published>
<updated>2021-03-23T16:54:23.528Z</updated>
<content type="html"><![CDATA[<h2 id="前言故事"><a href="#前言故事" class="headerlink" title="前言故事"></a>前言故事</h2><p>老皮是小唯公司裡的好友,平常最愛的就是偷閒看幾本書,工作休息時,就會拉著小唯,分享最近又從書裡學了哪招哪招,批評一下市面上產品、集團策略…。今天一早,老皮也是很興奮的跑來,小唯本以為他又要講書裡的哪招,但沒想到老皮竟然掏出他的 Pixel 手機,說道:「你看看,我收集到這麼多的方塊和圈圈。」</p><p>小唯好奇問:「什麼!什麼方塊和圈圈」</p><h2 id="App-通知的-icon-🟥-🔵-⬛️-🟤"><a href="#App-通知的-icon-🟥-🔵-⬛️-🟤" class="headerlink" title="App 通知的 icon 🟥 🔵 ⬛️ 🟤"></a>App 通知的 icon 🟥 🔵 ⬛️ 🟤</h2><p>老皮興奮地指著畫面上的方塊和圈圈:</p><img src="/%E8%B7%B3%E5%87%BA%E6%96%B9%E5%A1%8A%E7%9A%84-Android/1120_1.png" class="slug" width="324" height="666"><p>「這是什麼!?」小唯思考著 🤔,接著老皮把畫面解鎖,從畫面上方拉下了通知列表</p><img src="/%E8%B7%B3%E5%87%BA%E6%96%B9%E5%A1%8A%E7%9A%84-Android/1119_7.png" class="blah" width="324" height="666"><p>「喔~ 原來如此」小唯暗自說道。老皮接著說:「還有 status bar 上的方塊和圈圈,也很有趣:</p><img src="/%E8%B7%B3%E5%87%BA%E6%96%B9%E5%A1%8A%E7%9A%84-Android/1119_8.png" class="blah" width="324"><p>這讓小唯會心一笑,這搞笑的 App 通知方塊和圈圈他之前也遇過。小唯記得自從 Android 5 開始後,就應使用單色透明背景的通知 icon,如果直接拿桌面用的 icon,或是其他全彩 icon,只會看到像上面截圖一樣,白色或黑色一塊一塊,完全不知道是哪個 App 來的通知。</p><h2 id="該怎麼做❔"><a href="#該怎麼做❔" class="headerlink" title="該怎麼做❔"></a>該怎麼做❔</h2><p>雖然 icon 不能用全彩的,但是 Android 允許我們使用一個單色,給 icon 添點色彩,一般通常使用的是企業或產品的品牌色。</p><p>所以正確步驟:</p><ol><li><p>使用單色透明背景的 icon 如下,Android 系統會自動在一般模式、夜晚模式中幫我們調整顏色:</p><img src="/%E8%B7%B3%E5%87%BA%E6%96%B9%E5%A1%8A%E7%9A%84-Android/1119_9.png" class="blah"></li><li><p>使用 NotificationCompat.Builder 的 <a href="https://developer.android.com/reference/androidx/core/app/NotificationCompat.Builder#setColor(int)">setColor</a> 指定想要用的單色:</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">new</span> NotificationCompat.Builder(context, <span class="string">"channelId"</span>)</span><br><span class="line"> .setContentTitle(<span class="string">"title"</span>)</span><br><span class="line"> .setContentText(<span class="string">"message"</span>)</span><br><span class="line"> .setSmallIcon(R.drawable.ic_notify_icon)</span><br><span class="line"> .setColor(Color.RED)</span><br><span class="line"> .setContentIntent(pendingIntent).build();</span><br></pre></td></tr></table></figure></li></ol><p>差不多就這樣~ icon 也是產品的門面,共是使用在鎖頻介面中,用來判斷通知來源的方式,所以還是需要好好關注一下啊 😏</p><div> <div style="text-align:left;font-size:1em;"><p style="text-align:center;color: #ccc;">------------- 本文结束 <i class="far fa-meh-rolling-eyes"></i> 我也要一個好 icon -------------</p> </div> </div>]]></content>
<summary type="html"><img src='跳出方塊的-Android/brick.jpg'/>App 通知出現這搞笑的方塊和圈圈小唯之前也遇過。記得自從 Android 5 開始後,就應使用單色透明背景的通知 icon,如果直接拿桌面用的 icon,只會看到像上面一樣,白色或黑色一塊一塊,什麼都看不清楚...</summary>
<category term="小唯的故事" scheme="https://wm4n.github.io/categories/%E5%B0%8F%E5%94%AF%E7%9A%84%E6%95%85%E4%BA%8B/"/>
<category term="Android" scheme="https://wm4n.github.io/categories/%E5%B0%8F%E5%94%AF%E7%9A%84%E6%95%85%E4%BA%8B/Android/"/>
<category term="Android" scheme="https://wm4n.github.io/tags/Android/"/>
</entry>
<entry>
<title>為什麼 RD 給的 Android App Bundle 連結不能用!</title>
<link href="https://wm4n.github.io/%E7%82%BA%E4%BB%80%E9%BA%BC-RD-%E7%B5%A6%E7%9A%84-Android-%E6%B8%AC%E8%A9%A6%E9%80%A3%E7%B5%90%E4%B8%8D%E8%83%BD%E7%94%A8%EF%BC%81/"/>
<id>https://wm4n.github.io/%E7%82%BA%E4%BB%80%E9%BA%BC-RD-%E7%B5%A6%E7%9A%84-Android-%E6%B8%AC%E8%A9%A6%E9%80%A3%E7%B5%90%E4%B8%8D%E8%83%BD%E7%94%A8%EF%BC%81/</id>
<published>2020-11-18T08:21:24.000Z</published>
<updated>2021-03-23T16:54:23.524Z</updated>
<content type="html"><![CDATA[<h2 id="前言故事"><a href="#前言故事" class="headerlink" title="前言故事"></a>前言故事</h2><p>星期三的下午,負責小唯團隊的 PM,匆忙的跑到小唯的座位旁,問到:「小唯~ 為什麼你剛剛給我的連結不能用啊!」</p><p>小唯回問道:「什麼安裝連結?」</p><p>PM:「就是那個新版 App 的安裝連結啊~」說完,給小唯看他手機上的畫面:</p><div class="video-container"><iframe src="https://www.youtube.com/embed/BoxECeCH4sg" frameborder="0" loading="lazy" allowfullscreen></iframe></div><p>原來自從小唯團隊轉用 <a href="https://developer.android.com/platform/technology/app-bundle">Android App Bundle 技術</a> 打包產品後,由原本直接傳送 APK 發佈更新,改為使用 <a href="https://play.google.com/console/about/internalappsharing/">Internal App Sharing</a> 的方式發佈。新的發佈方式會產生一個安裝用連結,在手機上點擊後,Google Play 會自動開啟安裝畫面。</p><p>雖然聽起來簡單簡單,但是用起來真是問題不斷,其中最常見的就是上面這種「內部應用程式分享功能已關閉」,連結點開後,跳出不知所謂的訊息 😕!不但訊息沒幫助,上面的連結更是沒幫助!只能說 Google 工程、技術邏輯一流,但人性化上真是有待加強 🤖</p><h2 id="開啟關鍵開關"><a href="#開啟關鍵開關" class="headerlink" title="開啟關鍵開關"></a>開啟關鍵開關</h2><p>其實上述的訊息,只是想講一件事!這位 PM,你沒有開啟一個關鍵開關!</p><p>小唯接著解釋到,該如何開啟:</p><ol><li><p>開啟 Google Play</p><img src="/%E7%82%BA%E4%BB%80%E9%BA%BC-RD-%E7%B5%A6%E7%9A%84-Android-%E6%B8%AC%E8%A9%A6%E9%80%A3%E7%B5%90%E4%B8%8D%E8%83%BD%E7%94%A8%EF%BC%81/1119_1.png" class="slug"></li><li><p>點選左上角的漢堡選單</p><img src="/%E7%82%BA%E4%BB%80%E9%BA%BC-RD-%E7%B5%A6%E7%9A%84-Android-%E6%B8%AC%E8%A9%A6%E9%80%A3%E7%B5%90%E4%B8%8D%E8%83%BD%E7%94%A8%EF%BC%81/1119_2.png" class="slug"></li><li><p>找到設定,點開進去</p><img src="/%E7%82%BA%E4%BB%80%E9%BA%BC-RD-%E7%B5%A6%E7%9A%84-Android-%E6%B8%AC%E8%A9%A6%E9%80%A3%E7%B5%90%E4%B8%8D%E8%83%BD%E7%94%A8%EF%BC%81/1119_3.png" class="slug"></li><li><p>滑到最下面,應該會看到「Play 商店版本」</p><img src="/%E7%82%BA%E4%BB%80%E9%BA%BC-RD-%E7%B5%A6%E7%9A%84-Android-%E6%B8%AC%E8%A9%A6%E9%80%A3%E7%B5%90%E4%B8%8D%E8%83%BD%E7%94%A8%EF%BC%81/1119_4.png" class="slug"></li><li><p>點它,一直點到「你已啟用開發人員設定」的訊息跳出</p><img src="/%E7%82%BA%E4%BB%80%E9%BA%BC-RD-%E7%B5%A6%E7%9A%84-Android-%E6%B8%AC%E8%A9%A6%E9%80%A3%E7%B5%90%E4%B8%8D%E8%83%BD%E7%94%A8%EF%BC%81/1119_5.png" class="slug"></li><li><p>接著找到「內部應用程式分享」,開啟它</p><img src="/%E7%82%BA%E4%BB%80%E9%BA%BC-RD-%E7%B5%A6%E7%9A%84-Android-%E6%B8%AC%E8%A9%A6%E9%80%A3%E7%B5%90%E4%B8%8D%E8%83%BD%E7%94%A8%EF%BC%81/1119_6.png" class="slug"></li></ol><p>噹噹~ 搞定</p><div class="note danger"> <p>‼️ 特別提醒,開啟內部應用程式分享,代表你可以透過連結安裝別人的程式。所以小唯還是要提醒各位,不要點選或安裝你不知道的安裝連結!‼️</p> </div><div> <div style="text-align:left;font-size:1em;"><p style="text-align:center;color: #ccc;">------------- 本文结束 <i class="far fa-question-circle"></i> UI 人性化比實質功能更重要啊 -------------</p> </div> </div>]]></content>
<summary type="html">自從轉用 Android App Bundle 技術打包產品後,由原本直接傳送 APK 發佈更新,改為使用 Internal App Sharing 的方式發佈。新的發佈方式會產生一個安裝用連結,在手機上點擊後,Google Play 會自動開啟安裝畫面。雖然聽起來簡單簡單,但是用起來真是問題不斷,其中最常見的就是「內部應用程式分享功能已關閉」這種,連結點開後,跳出不知所謂的訊息... </summary>
<category term="小唯的故事" scheme="https://wm4n.github.io/categories/%E5%B0%8F%E5%94%AF%E7%9A%84%E6%95%85%E4%BA%8B/"/>
<category term="Android" scheme="https://wm4n.github.io/categories/%E5%B0%8F%E5%94%AF%E7%9A%84%E6%95%85%E4%BA%8B/Android/"/>
<category term="Android" scheme="https://wm4n.github.io/tags/Android/"/>
</entry>
<entry>
<title>關於 Android 憑證過期</title>
<link href="https://wm4n.github.io/%E9%97%9C%E6%96%BCAndroid%E6%86%91%E8%AD%89%E9%81%8E%E6%9C%9F/"/>
<id>https://wm4n.github.io/%E9%97%9C%E6%96%BCAndroid%E6%86%91%E8%AD%89%E9%81%8E%E6%9C%9F/</id>
<published>2020-11-10T15:28:56.000Z</published>
<updated>2021-03-23T16:54:23.528Z</updated>
<content type="html"><![CDATA[<h2 id="前言故事"><a href="#前言故事" class="headerlink" title="前言故事"></a>前言故事</h2><p>最近網路新聞在流傳著一篇「Android 使用者注意!7.1.1 前舊機將不能正常瀏覽網頁」,小唯的公司也對此在做一些可能的準備動作。之所以會對這議題這麼敏感,正是因為不久前,小唯公司才因為 <a href="https://support.sectigo.com/articles/Knowledge/Sectigo-AddTrust-External-CA-Root-Expiring-May-30-2020">AddTrust External Root CA 過期</a>,換了自己網站的簽署 root CA 憑證,造成在 Android 5 以下的手機,使用 WebView 時發生錯誤…</p><h2 id="不建議的解決辦法"><a href="#不建議的解決辦法" class="headerlink" title="不建議的解決辦法"></a>不建議的解決辦法</h2><p>小唯對這件事仍是記憶猶新,當時的解決辦法是,把新憑證一起打包進 App 中,使用 Android 更替 <a href="https://developer.android.com/reference/javax/net/ssl/X509TrustManager">X509TrustManager</a> 的方式,將需要允許連線的憑證包進去。</p><p>過程中,有個錯誤做法值得一提,因為網路上會搜尋到這種做法:</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// "信任全部的" X509TrustManager</span></span><br><span class="line"><span class="keyword">final</span> TrustManager[] tm =</span><br><span class="line"> <span class="keyword">new</span> TrustManager[] {</span><br><span class="line"> <span class="keyword">new</span> X509TrustManager() {</span><br><span class="line"> <span class="meta">@Override</span></span><br><span class="line"> <span class="function"><span class="keyword">public</span> <span class="keyword">void</span> <span class="title">checkClientTrusted</span><span class="params">(java.security.cert.X509Certificate[] chain, String authType)</span> <span class="keyword">throws</span> CertificateException </span>{</span><br><span class="line"> <span class="comment">// 不做事就是默認信任</span></span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> <span class="meta">@Override</span></span><br><span class="line"> <span class="function"><span class="keyword">public</span> <span class="keyword">void</span> <span class="title">checkServerTrusted</span><span class="params">(java.security.cert.X509Certificate[] chain, String authType)</span> <span class="keyword">throws</span> CertificateException </span>{</span><br><span class="line"> <span class="comment">// 不做事就是默認信任</span></span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> <span class="meta">@Override</span></span><br><span class="line"> <span class="keyword">public</span> java.security.cert.X509Certificate[] getAcceptedIssuers() {</span><br><span class="line"> <span class="keyword">return</span> <span class="keyword">new</span> java.security.cert.X509Certificate[] {};</span><br><span class="line"> }</span><br><span class="line"> }</span><br><span class="line"> };</span><br><span class="line"></span><br><span class="line"><span class="keyword">final</span> SSLContext sslContext = SSLContext.getInstance(<span class="string">"TLS"</span>);</span><br><span class="line">sslContext.init(<span class="keyword">null</span>, tm, <span class="keyword">new</span> java.security.SecureRandom());</span><br><span class="line"><span class="keyword">final</span> SSLSocketFactory sslSocketFactory = sslContext.getSocketFactory();</span><br><span class="line"></span><br><span class="line"><span class="comment">// 準備建立 OkHttpClient</span></span><br><span class="line">OkHttpClient.Builder builder = ...</span><br><span class="line">builder.sslSocketFactory(sslSocketFactory, (X509TrustManager) tm[<span class="number">0</span>]);</span><br><span class="line">OkHttpClient = builder.build();</span><br></pre></td></tr></table></figure><p>這是網路常見的做法之一,不建議的原因是還是安全性考量,默認相信所有憑證會讓惡意網頁有機可乘。</p><h2 id="更好的做法"><a href="#更好的做法" class="headerlink" title="更好的做法"></a>更好的做法</h2><p>小唯團隊使用的做法是,建立一個全新的鏈狀 <code>X509TrustManager</code> 如下,它會先檢查 Android 系統憑證,再檢查公司的新憑證,除此之外的一率不放行:</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br><span class="line">45</span><br><span class="line">46</span><br><span class="line">47</span><br><span class="line">48</span><br><span class="line">49</span><br><span class="line">50</span><br><span class="line">51</span><br><span class="line">52</span><br><span class="line">53</span><br><span class="line">54</span><br><span class="line">55</span><br><span class="line">56</span><br><span class="line">57</span><br><span class="line">58</span><br><span class="line">59</span><br><span class="line">60</span><br><span class="line">61</span><br><span class="line">62</span><br><span class="line">63</span><br><span class="line">64</span><br><span class="line">65</span><br><span class="line">66</span><br><span class="line">67</span><br><span class="line">68</span><br><span class="line">69</span><br><span class="line">70</span><br><span class="line">71</span><br><span class="line">72</span><br><span class="line">73</span><br><span class="line">74</span><br><span class="line">75</span><br><span class="line">76</span><br><span class="line">77</span><br><span class="line">78</span><br><span class="line">79</span><br><span class="line">80</span><br><span class="line">81</span><br><span class="line">82</span><br><span class="line">83</span><br><span class="line">84</span><br><span class="line">85</span><br><span class="line">86</span><br><span class="line">87</span><br><span class="line">88</span><br><span class="line">89</span><br><span class="line">90</span><br><span class="line">91</span><br><span class="line">92</span><br><span class="line">93</span><br><span class="line">94</span><br><span class="line">95</span><br><span class="line">96</span><br><span class="line">97</span><br><span class="line">98</span><br><span class="line">99</span><br><span class="line">100</span><br><span class="line">101</span><br><span class="line">102</span><br><span class="line">103</span><br><span class="line">104</span><br><span class="line">105</span><br><span class="line">106</span><br><span class="line">107</span><br><span class="line">108</span><br><span class="line">109</span><br><span class="line">110</span><br><span class="line">111</span><br><span class="line">112</span><br><span class="line">113</span><br><span class="line">114</span><br><span class="line">115</span><br><span class="line">116</span><br><span class="line">117</span><br><span class="line">118</span><br><span class="line">119</span><br><span class="line">120</span><br><span class="line">121</span><br><span class="line">122</span><br><span class="line">123</span><br><span class="line">124</span><br><span class="line">125</span><br><span class="line">126</span><br><span class="line">127</span><br><span class="line">128</span><br><span class="line">129</span><br><span class="line">130</span><br><span class="line">131</span><br><span class="line">132</span><br><span class="line">133</span><br><span class="line">134</span><br><span class="line">135</span><br><span class="line">136</span><br><span class="line">137</span><br><span class="line">138</span><br><span class="line">139</span><br><span class="line">140</span><br><span class="line">141</span><br><span class="line">142</span><br><span class="line">143</span><br><span class="line">144</span><br><span class="line">145</span><br><span class="line">146</span><br><span class="line">147</span><br><span class="line">148</span><br><span class="line">149</span><br><span class="line">150</span><br><span class="line">151</span><br><span class="line">152</span><br><span class="line">153</span><br><span class="line">154</span><br><span class="line">155</span><br><span class="line">156</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">public</span> <span class="class"><span class="keyword">class</span> <span class="title">CustomX509TrustManager</span> <span class="keyword">implements</span> <span class="title">X509TrustManager</span> </span>{</span><br><span class="line"></span><br><span class="line"> <span class="comment">// 建立憑證鏈,會依序檢查是否符合憑證</span></span><br><span class="line"> <span class="keyword">private</span> <span class="keyword">final</span> List<X509TrustManager> trustManagers;</span><br><span class="line"></span><br><span class="line"> <span class="keyword">public</span> <span class="keyword">static</span> TrustManager[] getTrustManagers(KeyStore keyStore) {</span><br><span class="line"> <span class="keyword">return</span> <span class="keyword">new</span> TrustManager[] {<span class="keyword">new</span> CustomX509TrustManager(keyStore)};</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> <span class="function"><span class="keyword">public</span> <span class="title">CustomX509TrustManager</span><span class="params">(KeyStore keystore)</span> </span>{</span><br><span class="line"> <span class="keyword">this</span>.trustManagers = <span class="keyword">new</span> ArrayList<>();</span><br><span class="line"> <span class="comment">// 加入原本的 TrustManager</span></span><br><span class="line"> <span class="keyword">this</span>.trustManagers.add(getDefaultTrustManager());</span><br><span class="line"> <span class="comment">// 加入指定 keystore TrustManager</span></span><br><span class="line"> <span class="keyword">this</span>.trustManagers.add(getTrustManager(keystore));</span><br><span class="line"> <span class="keyword">try</span> {</span><br><span class="line"> <span class="keyword">this</span>.trustManagers.add(trustManagerForCertificates(trustedCertificatesInputStream()));</span><br><span class="line"> } <span class="keyword">catch</span> (Exception e) {</span><br><span class="line"> <span class="comment">// 無法添加我們自己的 cert,使用系統預設的</span></span><br><span class="line"> }</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> <span class="meta">@Override</span></span><br><span class="line"> <span class="function"><span class="keyword">public</span> <span class="keyword">void</span> <span class="title">checkClientTrusted</span><span class="params">(X509Certificate[] chain, String authType)</span></span></span><br><span class="line"><span class="function"> <span class="keyword">throws</span> CertificateException </span>{</span><br><span class="line"> <span class="keyword">for</span> (X509TrustManager trustManager : trustManagers) {</span><br><span class="line"> <span class="keyword">try</span> {</span><br><span class="line"> trustManager.checkClientTrusted(chain, authType);</span><br><span class="line"> <span class="keyword">return</span>; <span class="comment">// 有其中一個 cert 通過,就當作通過</span></span><br><span class="line"> } <span class="keyword">catch</span> (CertificateException e) {</span><br><span class="line"> <span class="comment">// 這個不通過,換下一個</span></span><br><span class="line"> }</span><br><span class="line"> }</span><br><span class="line"> <span class="keyword">throw</span> <span class="keyword">new</span> CertificateException(<span class="string">"None of the TrustManagers trust this certificate chain"</span>);</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> <span class="meta">@Override</span></span><br><span class="line"> <span class="function"><span class="keyword">public</span> <span class="keyword">void</span> <span class="title">checkServerTrusted</span><span class="params">(X509Certificate[] chain, String authType)</span></span></span><br><span class="line"><span class="function"> <span class="keyword">throws</span> CertificateException </span>{</span><br><span class="line"> <span class="keyword">for</span> (X509TrustManager trustManager : trustManagers) {</span><br><span class="line"> <span class="keyword">try</span> {</span><br><span class="line"> trustManager.checkServerTrusted(chain, authType);</span><br><span class="line"> <span class="keyword">return</span>; <span class="comment">// 有其中一個 cert 通過,就當作通過</span></span><br><span class="line"> } <span class="keyword">catch</span> (CertificateException e) {</span><br><span class="line"> <span class="comment">// 這個不通過,換下一個</span></span><br><span class="line"> }</span><br><span class="line"> }</span><br><span class="line"> <span class="keyword">throw</span> <span class="keyword">new</span> CertificateException(<span class="string">"None of the TrustManagers trust this certificate chain"</span>);</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> <span class="meta">@Override</span></span><br><span class="line"> <span class="keyword">public</span> X509Certificate[] getAcceptedIssuers() {</span><br><span class="line"> List<X509Certificate> certificates = <span class="keyword">new</span> ArrayList<>();</span><br><span class="line"> <span class="keyword">for</span> (X509TrustManager trustManager : trustManagers) {</span><br><span class="line"> certificates.addAll(Arrays.asList(trustManager.getAcceptedIssuers()));</span><br><span class="line"> }</span><br><span class="line"> <span class="keyword">return</span> certificates.toArray(<span class="keyword">new</span> X509Certificate[<span class="number">0</span>]);</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> <span class="function"><span class="keyword">private</span> X509TrustManager <span class="title">getDefaultTrustManager</span><span class="params">()</span> </span>{</span><br><span class="line"> <span class="keyword">return</span> getTrustManager(<span class="keyword">null</span>);</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> <span class="function"><span class="keyword">private</span> X509TrustManager <span class="title">getTrustManager</span><span class="params">(KeyStore keystore)</span> </span>{</span><br><span class="line"> <span class="keyword">return</span> getTrustManager(TrustManagerFactory.getDefaultAlgorithm(), keystore);</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> <span class="comment">/**</span></span><br><span class="line"><span class="comment"> * 只取出 keystore 中 X509 TrustManager</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"> <span class="function"><span class="keyword">private</span> X509TrustManager <span class="title">getTrustManager</span><span class="params">(String algorithm, KeyStore keystore)</span> </span>{</span><br><span class="line"> TrustManagerFactory factory;</span><br><span class="line"> <span class="keyword">try</span> {</span><br><span class="line"> factory = TrustManagerFactory.getInstance(algorithm);</span><br><span class="line"> factory.init(keystore);</span><br><span class="line"> List<TrustManager> trustManagerList = Arrays.asList(factory.getTrustManagers());</span><br><span class="line"> List<X509TrustManager> x509TrustManagerList = <span class="keyword">new</span> ArrayList<>();</span><br><span class="line"> <span class="keyword">if</span> (trustManagerList != <span class="keyword">null</span>) {</span><br><span class="line"> <span class="keyword">for</span> (TrustManager tm : trustManagerList) {</span><br><span class="line"> <span class="keyword">if</span> (tm <span class="keyword">instanceof</span> X509TrustManager) {</span><br><span class="line"> x509TrustManagerList.add((X509TrustManager) tm);</span><br><span class="line"> }</span><br><span class="line"> }</span><br><span class="line"> }</span><br><span class="line"> <span class="keyword">if</span> (x509TrustManagerList.size() > <span class="number">0</span>) {</span><br><span class="line"> <span class="keyword">return</span> x509TrustManagerList.get(<span class="number">0</span>);</span><br><span class="line"> }</span><br><span class="line"> } <span class="keyword">catch</span> (NoSuchAlgorithmException | KeyStoreException e) {</span><br><span class="line"> e.printStackTrace();</span><br><span class="line"> }</span><br><span class="line"> <span class="keyword">return</span> <span class="keyword">null</span>;</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> <span class="comment">/**</span></span><br><span class="line"><span class="comment"> * 將指定 InputStream 的的憑證建成 TrustManager</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"> <span class="function"><span class="keyword">private</span> X509TrustManager <span class="title">trustManagerForCertificates</span><span class="params">(InputStream in)</span></span></span><br><span class="line"><span class="function"> <span class="keyword">throws</span> GeneralSecurityException </span>{</span><br><span class="line"> CertificateFactory certificateFactory = CertificateFactory.getInstance(<span class="string">"X.509"</span>);</span><br><span class="line"> Collection<? extends Certificate> certificates = certificateFactory.generateCertificates(in);</span><br><span class="line"> <span class="keyword">if</span> (certificates.isEmpty()) {</span><br><span class="line"> <span class="keyword">throw</span> <span class="keyword">new</span> IllegalArgumentException(<span class="string">"Unexpected empty InputStream"</span>);</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> <span class="comment">// 建立 keystore</span></span><br><span class="line"> <span class="keyword">char</span>[] password = <span class="string">"password"</span>.toCharArray(); <span class="comment">// Any password will work.</span></span><br><span class="line"> KeyStore keyStore = newEmptyKeyStore(password);</span><br><span class="line"> <span class="keyword">int</span> index = <span class="number">0</span>;</span><br><span class="line"> <span class="keyword">for</span> (Certificate certificate : certificates) {</span><br><span class="line"> String certificateAlias = Integer.toString(index++);</span><br><span class="line"> keyStore.setCertificateEntry(certificateAlias, certificate);</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> <span class="comment">// 使用 keystore 建立 TrustManager</span></span><br><span class="line"> KeyManagerFactory keyManagerFactory =</span><br><span class="line"> KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());</span><br><span class="line"> keyManagerFactory.init(keyStore, password);</span><br><span class="line"> TrustManagerFactory trustManagerFactory =</span><br><span class="line"> TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());</span><br><span class="line"> trustManagerFactory.init(keyStore);</span><br><span class="line"> List<TrustManager> trustManagerList = Arrays.asList(trustManagerFactory.getTrustManagers());</span><br><span class="line"> List<X509TrustManager> x509TrustManagerList = <span class="keyword">new</span> ArrayList<>();</span><br><span class="line"> <span class="keyword">if</span> (trustManagerList != <span class="keyword">null</span>) {</span><br><span class="line"> <span class="keyword">for</span> (TrustManager tm : trustManagerList) {</span><br><span class="line"> <span class="keyword">if</span> (tm <span class="keyword">instanceof</span> X509TrustManager) {</span><br><span class="line"> x509TrustManagerList.add((X509TrustManager) tm);</span><br><span class="line"> }</span><br><span class="line"> }</span><br><span class="line"> }</span><br><span class="line"> <span class="keyword">if</span> (x509TrustManagerList.size() > <span class="number">0</span>) {</span><br><span class="line"> <span class="keyword">return</span> x509TrustManagerList.get(<span class="number">0</span>);</span><br><span class="line"> }</span><br><span class="line"> <span class="keyword">return</span> <span class="keyword">null</span>;</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> <span class="function"><span class="keyword">private</span> KeyStore <span class="title">newEmptyKeyStore</span><span class="params">(<span class="keyword">char</span>[] password)</span> <span class="keyword">throws</span> GeneralSecurityException </span>{</span><br><span class="line"> <span class="keyword">try</span> {</span><br><span class="line"> KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType());</span><br><span class="line"> InputStream in = <span class="keyword">null</span>; <span class="comment">// By convention, 'null' creates an empty key store.</span></span><br><span class="line"> keyStore.load(in, password);</span><br><span class="line"> <span class="keyword">return</span> keyStore;</span><br><span class="line"> } <span class="keyword">catch</span> (IOException e) {</span><br><span class="line"> <span class="keyword">throw</span> <span class="keyword">new</span> AssertionError(e);</span><br><span class="line"> }</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> <span class="function"><span class="keyword">private</span> <span class="keyword">static</span> InputStream <span class="title">trustedCertificatesInputStream</span><span class="params">()</span> </span>{</span><br><span class="line"> <span class="comment">// 憑證也能放在 resource 內,demo 放這容易看</span></span><br><span class="line"> String cert =</span><br><span class="line"> <span class="string">""</span></span><br><span class="line"> + <span class="string">"-----BEGIN CERTIFICATE-----\n"</span></span><br><span class="line"> <span class="comment">// 憑證內容放這</span></span><br><span class="line"> + <span class="string">"-----END CERTIFICATE-----\n"</span>;</span><br><span class="line"> <span class="keyword">return</span> <span class="keyword">new</span> Buffer().writeUtf8(cert).inputStream();</span><br><span class="line"> }</span><br><span class="line">}</span><br></pre></td></tr></table></figure><p>以上從 <a href="https://square.github.io/okhttp/https/#customizing-trusted-certificates-kt-java">https://square.github.io/okhttp/https/#customizing-trusted-certificates-kt-java</a> 變化而來</p><h2 id="其他做法"><a href="#其他做法" class="headerlink" title="其他做法"></a>其他做法</h2><p>小唯之後又發現,okhttp 官方 github 也有類似的 sample,之後也許也能試試</p><p><a href="https://github.com/square/okhttp/blob/master/samples/guide/src/main/java/okhttp3/recipes/CustomTrust.java">okhttp 官方 github CustomTrust.java</a></p><h2 id="關於-Android-7-1-1"><a href="#關於-Android-7-1-1" class="headerlink" title="關於 Android 7.1.1"></a>關於 Android 7.1.1</h2><p>至於 Android 7.1.1 版本之前的裝置呢?因為許多網站簽署的憑證即將到期,勢必會換成新的憑證,造成舊機 WebView 或瀏覽器可能不支援新的憑證,解決方法還是建議更新系統(如果可以的話),要不就是 App 要準備處理過期憑證的錯誤,適時提醒使用者憑證問題,以及建議的解決方式,避免一開啟網頁就遇到一片空白,甚至 App 強制關閉 😖</p><p>另一個小唯在思索的事情是,非自己產品的相關的網頁,是否該用 WebView 開啟?嚴格來說其實不應該,因為不清楚其他人會在相關網頁內,做什麼事情,尤其時當開啟 JavaScript 後,這個問題更值得所有用 App WebView 去開啟第三方網頁的團隊去思索~</p><hr><p><strong>相關連結:</strong><a href="https://gist.github.com/HughJeffner/6eac419b18c6001aeadb"><i class="fab fa-github"></i> CompositeTrustManager</a>, <a href="https://square.github.io/okhttp/https/#customizing-trusted-certificates-kt-java"><i class="fas fa-code"></i> okhttp customizing trusted certificates</a>, <a href="https://square.github.io/okhttp/https/#customizing-trusted-certificates-kt-java"><i class="fab fa-github"></i> okhttp customizing trusted certificates</a></p><div> <div style="text-align:left;font-size:1em;"><p style="text-align:center;color: #ccc;">------------- 本文结束 <i class="far fa-question-circle"></i> App minSdkVersion 到底什麼時候才能提升啊 -------------</p> </div> </div>]]></content>
<summary type="html">最近網路新聞在流傳著一篇「Android 使用者注意!7.1.1 前舊機將不能正常瀏覽網頁」,小唯的公司也對此在做一些可能的準備動作。之所以會對這議題這麼敏感,正是因為不久前,小唯公司才因為 AddTrust External Root CA 過期,換了自己網站的簽署 root CA 憑證,造成在 Android 5 以下的手機,使用 WebView 時發生錯誤...</summary>
<category term="小唯的故事" scheme="https://wm4n.github.io/categories/%E5%B0%8F%E5%94%AF%E7%9A%84%E6%95%85%E4%BA%8B/"/>
<category term="Android" scheme="https://wm4n.github.io/categories/%E5%B0%8F%E5%94%AF%E7%9A%84%E6%95%85%E4%BA%8B/Android/"/>
<category term="Android" scheme="https://wm4n.github.io/tags/Android/"/>
</entry>
<entry>
<title>git submodule 的分支追蹤</title>
<link href="https://wm4n.github.io/git-submodule-%E7%9A%84%E5%88%86%E6%94%AF%E8%BF%BD%E8%B9%A4/"/>
<id>https://wm4n.github.io/git-submodule-%E7%9A%84%E5%88%86%E6%94%AF%E8%BF%BD%E8%B9%A4/</id>
<published>2020-10-31T19:24:47.000Z</published>
<updated>2021-03-23T16:54:23.524Z</updated>
<content type="html"><![CDATA[<h2 id="前言故事"><a href="#前言故事" class="headerlink" title="前言故事"></a>前言故事</h2><p>小唯在一間資訊公司擔任 Android 軟體工程師,負責公司內的主力產品開發,這是一項功能複雜的產品,它累計了數年的程式碼、商務邏輯、出包經驗等的功能與修正。數年後,團隊中正在推動全新 2.0 的產品,以及公司其他服務也即將推出 App。不可避免的,有許多相似功能相繼橫跨各個產品 App,這讓小唯自覺必須有一套更好的方式管理自己團隊的程式碼,改善之後擴充性、維護性,和保護團隊工程師們的肝~</p><p>一個從頭打造的產品,功能是不斷的疊加上去,鮮少有團隊能真的從開頭就搞好架構設計,小唯團隊的產品也不例外。除了自身的核心功能外,還使用了圖隊之前開發的功能,以及許多現成 github 的模組,有的已提供 gradle 的安裝與使用,有的卻只是單純的 Java/Kotlin 程式碼。在初期,最直接暴力的方式就是直接抓下來,塞進當前的 App 中使用,累積到今時今日,已有數個相同模組,分別的複製到數個專案上 😱</p><p>小唯團隊有幾個選擇:</p><ol><li>維持現狀,每個 App 複製一份程式碼<ul><li><strong>優點</strong>:現有架構、不需更改、各個 App 工程彈性自由,想怎麼改就怎麼改</li><li><strong>缺點</strong>:維上非常不便,一個修正需要同步到所有 App,之後工程師要記得去手動 merge 更新的程式碼</li></ul></li></ol><ol start="2"><li>將可模組化的,改寫成 gradle<ul><li><strong>優點</strong>:不必要再包一份程式碼到各個 App,之後其他專案要使用會更便利。模組更新,只需要修改一份程式碼,gradle 版號升級便可更新程式</li><li><strong>缺點</strong>:之後各個 App 要做客製化,會相對不容易</li></ul></li></ol><ol start="3"><li>將可模組化的,改成 submodule<ul><li><strong>優點</strong>:每個模組只需要維護一份程式碼,需要更新時,使用 git 內建方式,即可同步最新的程式。App 也有一定自由度去做產品客製化</li><li><strong>缺點</strong>:還是仰賴各 App 去 merge 更新,但不一定需要手動(透過 git 同步 submodule 即可)</li></ul></li></ol><p>最後,小唯團隊選擇 submodule 模式去進行,以下是小唯的手稿紀錄:</p><h2 id="將專案切分成-submodule"><a href="#將專案切分成-submodule" class="headerlink" title="將專案切分成 submodule"></a>將專案切分成 submodule</h2><p>目前小唯團隊負責的產品 git 架構如下,一個巨型 git repo,內容包山包海,含之前團隊開發的功能模組,以及第三方 github 的功能模組等:</p><img src="/git-submodule-%E7%9A%84%E5%88%86%E6%94%AF%E8%BF%BD%E8%B9%A4/app_diagram_1.svg" class="" title="產品架構圖 - 之前"><p>重新打造後,新的 git 架構如下,可被多個產品共用的模組,都會拉成獨立的 git repo,由一個主要 git repo 來把其他功能,以 submodule 方式添加進來:</p><img src="/git-submodule-%E7%9A%84%E5%88%86%E6%94%AF%E8%BF%BD%E8%B9%A4/app_diagram_2.svg" class="" title="產品架構圖 - 之後"><h3 id="加入-submodule-指令"><a href="#加入-submodule-指令" class="headerlink" title="加入 submodule 指令"></a>加入 submodule 指令</h3><p>以下用其中一個功能為範例。將 function1 以 submodule 方式加入主 App:</p><figure class="highlight bash"><figcaption><span>shell</span></figcaption><table><tr><td class="code"><pre><span class="line">❯ git submodule add https://github.com/wm4n/function1.gitfunction1</span><br><span class="line">正複製到 <span class="string">'/Users/[path]/app-host/function1'</span>...</span><br><span class="line">remote: Enumerating objects: 4, <span class="keyword">done</span>.</span><br><span class="line">remote: Counting objects: 100% (4/4), <span class="keyword">done</span>.</span><br><span class="line">remote: Compressing objects: 100% (3/3), <span class="keyword">done</span>.</span><br><span class="line">remote: Total 4 (delta 0), reused 0 (delta 0), pack-reused 0</span><br><span class="line">接收物件中: 100% (4/4), 完成.</span><br></pre></td></tr></table></figure><p>以上指令會將 function1 repo 內容抓到 function1 目錄下,如果印出 .gitmodules 檔案(使用 <code>git submodule add</code> 後自動建立)內容,會發現:</p><figure class="highlight bash"><figcaption><span>shell</span></figcaption><table><tr><td class="code"><pre><span class="line">❯ cat .gitmodules</span><br><span class="line">[submodule <span class="string">"function1"</span>]</span><br><span class="line">path = function1</span><br><span class="line">url = https://github.com/wm4n/function1.git</span><br></pre></td></tr></table></figure><h3 id="上傳添加的-submodule"><a href="#上傳添加的-submodule" class="headerlink" title="上傳添加的 submodule"></a>上傳添加的 submodule</h3><p>照平常 git 使用的方式,將添加好的 submodule 設定,上傳至 github</p><figure class="highlight bash"><figcaption><span>shell</span></figcaption><table><tr><td class="code"><pre><span class="line">❯ git add .gitmodules</span><br><span class="line"></span><br><span class="line">❯ git add function1</span><br><span class="line"></span><br><span class="line">❯ git commit -m <span class="string">"add function1 submodule"</span></span><br><span class="line">[main 26d42dc] add function1 submodule</span><br><span class="line"> 2 files changed, 4 insertions(+)</span><br><span class="line"> create mode 100644 .gitmodules</span><br><span class="line"> create mode 160000 function1</span><br><span class="line"></span><br><span class="line">❯ git push</span><br><span class="line">枚舉物件: 4, 完成.</span><br><span class="line">物件計數中: 100% (4/4), 完成.</span><br><span class="line">使用 4 個執行緒進行壓縮</span><br><span class="line">壓縮物件中: 100% (3/3), 完成.</span><br><span class="line">寫入物件中: 100% (3/3), 419 位元組 | 419.00 KiB/s, 完成.</span><br><span class="line">總共 3 (差異 0),復用 0 (差異 0),重用包 0</span><br><span class="line">To https://github.com/wm4n/app-host.git</span><br><span class="line"> 278058e..26d42dc main -> main</span><br></pre></td></tr></table></figure><h3 id="更新-repo-內容"><a href="#更新-repo-內容" class="headerlink" title="更新 repo 內容"></a>更新 repo 內容</h3><p>當要同步線上程式碼時,一般會用 <code>git pull</code> 來抓取最新內容,但改用 submodule 方式後,會發現 function1 不會同步。如果要更新,則必須進入到 function1 目錄下再次執行 <code>git pull</code></p><figure class="highlight bash"><figcaption><span>shell</span></figcaption><table><tr><td class="code"><pre><span class="line">❯ <span class="built_in">cd</span> function1</span><br><span class="line"></span><br><span class="line">❯ git pull</span><br><span class="line">remote: Enumerating objects: 5, <span class="keyword">done</span>.</span><br><span class="line">remote: Counting objects: 100% (5/5), <span class="keyword">done</span>.</span><br><span class="line">remote: Compressing objects: 100% (3/3), <span class="keyword">done</span>.</span><br><span class="line">remote: Total 3 (delta 0), reused 0 (delta 0), pack-reused 0</span><br><span class="line">展開物件中: 100% (3/3), 691 位元組 | 138.00 KiB/s, 完成.</span><br><span class="line">來自 https://github.com/wm4n/function1</span><br><span class="line"> 5f9ae22..e6843b4 main -> origin/main</span><br><span class="line">更新 5f9ae22..e6843b4</span><br><span class="line">Fast-forward</span><br><span class="line"> README.md | 2 +-</span><br><span class="line"> 1 file changed, 1 insertion(+), 1 deletion(-)</span><br></pre></td></tr></table></figure><h3 id="當-submodule-數量成長後"><a href="#當-submodule-數量成長後" class="headerlink" title="當 submodule 數量成長後"></a>當 submodule 數量成長後</h3><p>小唯團隊負責的產品,如今被拆解成 5 個 submodule 後,發現今後如果要更新,就要進入每個 submodule 的目錄,分別執行 <code>git pull</code> 指令,這表示一個專案如果有 X 個 submodule,每次更新最多就會有 X + 1 個 <code>git pull</code>。這不僅讓小唯受不了,團隊也常為了忘記要確實執行,反而造成許多問題。</p><p>為了改善這問題,小唯在模組設定檔中,<strong>指定讓模組 submodule 追蹤某個特定的遠端分支</strong></p><figure class="highlight bash"><figcaption><span>shell</span></figcaption><table><tr><td class="code"><pre><span class="line">❯ cat .gitmodules</span><br><span class="line">[submodule <span class="string">"function1"</span>]</span><br><span class="line">path = function1</span><br><span class="line">url = https://wm4n@github.com/wm4n/function1.git</span><br><span class="line">branch = main</span><br></pre></td></tr></table></figure><p>branch 代表這個 submodule 將追蹤指定的遠端分支。之後,團隊只要使用 <code>git submodule update --remote</code> 指令<strong>一次</strong>,所有的 submodule 都會依照指定的<strong>分支</strong>去抓最新的內容,如下:</p><figure class="highlight bash"><figcaption><span>shell</span></figcaption><table><tr><td class="code"><pre><span class="line">❯ git submodule update --remote</span><br><span class="line">remote: Enumerating objects: 5, <span class="keyword">done</span>.</span><br><span class="line">remote: Counting objects: 100% (5/5), <span class="keyword">done</span>.</span><br><span class="line">remote: Compressing objects: 100% (3/3), <span class="keyword">done</span>.</span><br><span class="line">remote: Total 3 (delta 0), reused 0 (delta 0), pack-reused 0</span><br><span class="line">展開物件中: 100% (3/3), 689 位元組 | 344.00 KiB/s, 完成.</span><br><span class="line">來自 https://github.com/wm4n/function1</span><br><span class="line"> e6843b4..c7df259 main -> origin/main</span><br><span class="line">子模組路徑 <span class="string">'function1'</span>:檢出 <span class="string">'c7df259c57f4c6143c4e29488399b13b9fb188c6'</span></span><br></pre></td></tr></table></figure><h3 id="令人分心的-git-status"><a href="#令人分心的-git-status" class="headerlink" title="令人分心的 git status"></a>令人分心的 git status</h3><p>一切設定好,分支追蹤功能滿足了團隊的需求,從此之後,可重複使用的功能順利地抽出成獨立 git repo,不同 App 依照各自需求,添加自己需要的 submodule,然後一鍵更新所有模組也不再是夢想 🥳。一切是如此的美好,直到團隊發現… 經常 <code>git status</code>,就會出現:</p><figure class="highlight bash"><figcaption><span>shell</span></figcaption><table><tr><td class="code"><pre><span class="line">❯ git status</span><br><span class="line">位於分支 main</span><br><span class="line">您的分支與上游分支 <span class="string">'origin/main'</span> 一致。</span><br><span class="line"></span><br><span class="line">尚未暫存以備提交的變更:</span><br><span class="line"> (使用 <span class="string">"git add <檔案>..."</span> 更新要提交的內容)</span><br><span class="line"> (使用 <span class="string">"git restore <檔案>..."</span> 捨棄工作區的改動)</span><br><span class="line">修改: function1 (新提交)</span><br><span class="line"></span><br><span class="line">修改尚未加入提交(使用 <span class="string">"git add"</span> 和/或 <span class="string">"git commit -a"</span>)</span><br></pre></td></tr></table></figure><p>在使用分支追蹤之前,小唯的團隊總是會在跟新模組後,緊接著跟新主 App 的模組 commit ID。使用分支追蹤後,雖然不必要再跟新模組 commit ID(因為 <code>git submodule update --remote</code> 指令會自動同步分支最新的 commit ID),但 <code>git status</code> 指令還是會提醒有更新尚未提交。久而久之,團隊都覺得這訊息實在惱人啊~ 😖</p><p>小唯試了數個方式後,包括把相關檔案都加到 <code>.gitignore</code>(此方法無效),最後是在模組設定中,加上 <code>ignore = all</code>,就搞定了:</p><figure class="highlight bash"><figcaption><span>shell</span></figcaption><table><tr><td class="code"><pre><span class="line">❯ cat .gitmodules</span><br><span class="line">[submodule <span class="string">"function1"</span>]</span><br><span class="line">path = function1</span><br><span class="line">url = https://wm4n@github.com/wm4n/function1.git</span><br><span class="line">branch = main</span><br><span class="line">ignore = all</span><br></pre></td></tr></table></figure><h3 id="對-github-上的第三方專案做客製化"><a href="#對-github-上的第三方專案做客製化" class="headerlink" title="對 github 上的第三方專案做客製化"></a>對 github 上的第三方專案做客製化</h3><p>沒多久後,小唯團隊發現,他們想對 github 上的第三方專案做客製化,以符合規格需求,但要如何追蹤 github 專案分支的同時,又做客製化呢?畢竟無法對第三方的專案做修改(除非經由 PR 流程,但一般來說不會接受客製化…)</p><p>小唯團隊利用 github fork 專案的方式,讓自己的專案轉追蹤 fork 的拷貝,利用 fork 拷貝來做客製化,同時又能<a href="https://docs.github.com/en/free-pro-team@latest/github/collaborating-with-issues-and-pull-requests/syncing-a-fork">不斷從 upstream 專案中更新</a></p><hr><p>從此之後,<code>git status</code> 也不再困擾小唯團隊了 🥳</p><p><strong>相關連結:</strong><a href="https://github.com/wm4n/app-host"><i class="fab fa-github"></i> Demo 專案</a></p><div> <div style="text-align:left;font-size:1em;"><p style="text-align:center;color: #ccc;">------------- 本文结束 <i class="fab fa-git-alt"></i> git 就是這麼的難搞!-------------</p> </div> </div>]]></content>
<summary type="html">某天的上班的早晨,小唯突然被叫進會議室,會議中說明,小唯公司已確定為目前主力產品 App 推出新的版本,但當前的舊版仍需繼續維運,直到新版的各項指標皆達成預期。這消息一方面令人振奮,因為公司正朝 App 轉型的方向前進,但另一方面也令小唯團隊擔憂,原因是同時開發新版本,與維運舊版,這段時間將耗費大量的人力資源。 <br/><br/> 小唯團隊使用 submodule 的方式,提高兩個 App 的程式碼共用率,並把 submodule 使用的方式記錄在此篇...</summary>
<category term="小唯的故事" scheme="https://wm4n.github.io/categories/%E5%B0%8F%E5%94%AF%E7%9A%84%E6%95%85%E4%BA%8B/"/>
<category term="git" scheme="https://wm4n.github.io/categories/%E5%B0%8F%E5%94%AF%E7%9A%84%E6%95%85%E4%BA%8B/git/"/>
<category term="git" scheme="https://wm4n.github.io/tags/git/"/>
</entry>
</feed>