over 3 years ago

Chapter 1 Solving the Right Problem

Both, R&D and agile tackle the uncertainties in a nontraditional manner influenced by the trial-and-error process. p. 9

Chapter 2 Relying on a Stable Foundation

Here is the list of guardrails to put in place to provide the basis for tackling uncertainties:
- A healthy team
- The involvement of all stakeholders
- A shared vision
- A meaningful common goal
- A set of high-level features
- A "can-exist" assumption p. 14

This short, one-line summary should provide a shared understanding of what the software is supposed to be and do. A clear vision provides context for making better decisions and taking responsibility throughout the course of the software development life cycle, p. 18

Furthermore, when a new member joins the team, one of the first duties of the Scrum master is to communicate, in a one-on-one conversation, the vision, the meaningful common goal. and the high-level features of the software. p. 22

Chapter 3 Discovering Through Short Feedback Loops and Stakeholders' Desirements

Therefore, fail early and offten. p. 25

Keep in mind that there are no real mistakes, just learning opportunities. Each and everything you do, whether you achieve your goal, leads you to another place. When there are no instructions to follow, trial and error is an efficient path to discovery. p. 27

This problem-solving approach enables you to find what works and eliminate what doesn't. Deliberate discovery does not happen from the failure itself but rather from understanding the failure, making an improvement, and the traying again. p. 28

Chapter 4 Expressing Desirements with User Stories

A user story describes functionality that will be valuable to either a user or purchaser of a system or software. p. 36

A well-writen user story follows the INVEST mnemonic developed by Bill Wake. It fulfills the criteria of Independent, Negotiable, Valuable, Estimable, Small, and Testable. p. 37

User stories encourage a process whereby software is iteratively refined based on conversation and confirmation between stakeholders and the team. The details are worked out in the conversation, and the success criteria are recorded in the confirmation. p. 37

Chapter 5 Refining User Stories by Gromming the Product Backlog

Grooming is the act of ranking, illustrating, sizing, and splitting user stories. p. 45

The product owner is responsible for ensuring that the product backlog is always in a health state. ...(skipped)... The development team activly takes a hand in backlog management. p. 46

When dealing with emerging needs, it is impossible to keep the entire backlog in a ready state; only the top elements need to be. A healthy backlog provides a set of high-value, ready desirements, about equal in size, that are small enough so that the team can deliver them in the up-coming sprints. To obtainb desirements that are ready to iterate, you need periodically groom the backlog. p. 48

The meeting is then time-boxed, at usually one hour, and each story is considered. Don't worry if you don't have time to discuss all the stories in the backlog. They will be addressed in future meetings. p. 56

Stop measuring absolute values and start comparing relative vales. When estimating, you should not measure effort but instead compare effors using a reference point.
Humans are poor at estimating absolute sizes. However, we are great at assessing relative sizes. p. 57

A rule of thumb used to determine whether a story is small enough is to take the average velocity of the team per iteration and divide it by two. p. 61 (so a team may be able to do two or more stories, not just a very large story)

Chapter 6 Confirming User Stories with Scenarios

Success criteria establish the conditions of acceptation from the stakeholders point of view. p. 74

The reality is these two techniques (FIT tabular format and BDD Given-When-Then syntax) are equally effective. p. 80

A concept is a unit of meaning that expresses the behavior of the problem's domain. Each concept within a precondition, an action, or a consequence should have a unique name that follows the domain's terminology. ... (skipped)... If in doubt, do not invent a new term; always use the language of the domain and seek agreement between stakeholders so that everyone uses a consistent vocabulary. p. 81 (so that that become a domain-specific language, DSL)

Try to limit the meatings (specification workshop) to no more than two hours because after that much time people tend to be less productive. p. 88

A good practice, during the specification workshop, is to name an analyst for each user story, The analyst is always a member of the development team. p. 88

Because desinging the technical solution is not the purpose of the specification, you should focus only on writing scenarios that relate to the business rules. This does not mean you should not focus on the user experience; storyboards are used for that. p. 89

Chapter 7 Automating Confirmation with Acceptance Tests

As we all know, when there is more than one version of the truth, there are synchronization issues. p. 98

Unfortunately, unit tests do not help in this matter (confirm stakeholders' desirements). Unit tests always pass because they test against the programmer's assumptions. p. 99 (unit test are still important for iterative development)

The development cycle must help synchronize the development team's assumptions early. The team needs faster feedback loops for discovering whether an implementation is correct for the scenario's assumptions. p. 100

TDD places constraints on programmers; it requires that they define the interface before deciding on the implementation. TDD tends to lead better designs. It makes code inherently reusable because programmer must use it in both tests and production circumstances. p. 102

Finally, in a more complex case, the testers can decide to bypass the user interface and connect the test directly to the application's controller (if it exists). In all cases, the real challenge is to properly design the programming interface. p. 110

As a minimum, daily confirmation is what you should aim for. Testing the "executable" scenarios during the nightly build ensuires that every morning the team can easily confirm that the software under construction still meets the evolving specifications. p. 118

All this work was done with on goal in mind: to easily identify which previously successful tests are now failing. p. 119

Chapter 8 Addressing Nonfunctional Requirements

The architect leads the design of the structural foundation upon which the solution is built by the team. This leadership is not done in isolation. The architect works collaboratively with every team member to remove accidental complexity and pursues simplicity in the design. p. 126

Deferring the meeting of a restriction can lead to a large amount of reworking in future sprints, due to architectural considerations. p. 136

When looking at examples of Definition of Done (not acceptance criteria)in various teams, they usually include points like
- Code completed.
- 0 (known) bugs.
- Passed unit tests.
- Code peer-reviewed or paired.
- Code checked in.
- Deployed to test environment and passed tests.
- Documentation updated.
The benefits of having an explict set of practices is that after it is defined, the team can apply those practices, story after story, spring after sprint. p. 138

-- End --

 
over 3 years ago

第一篇 人員

團隊中最有影響力的人並不一定是最優秀的程式設計師。事實上,他們也不是經理或領導者。他們是真誠的人:他們在團隊中是為了完成工作,在完成工作時能夠保持自我。他們能夠聆聽周遭人們的意見,知道是什麼在激勵著人們、人們的動力何在。這並不是因為他們從書上或研討會中了解到如何才能成為激勵人的領導者。他們之所以那樣做是因為他們關心周圍的人。他們花費精神讓每個人都保持相同的節奏 - 不需要很好,甚至不需要幫助,只要以一種相互和睦的方式工作就可以了。不論是什麼原因,當團隊不能一起融洽地工作時,他們都能只找出問題的癥結所在,並做一些事情來解決這個問題。 p. 15

第二章 醜陋團隊的致勝之道

我們大多數人受困於一種變形的、做作的、過於簡化的美學認知,總覺得漂亮的就一定好,而醜陋的就必然糟;我們甚至都不願去探究其他的可能。p. 18

唯一適合用於形容團隊之美的詞彙,是日語的"wabi-sabi" (侘寂),是一種"殘缺之美"。簡而言之wahi-sabi是指從已經用過的事物中感受到一種特殊的美。你特別喜歡的那雙舊鞋,它已殘破不堪,曾經陪著你在沙灘上散布。這雙鞋有種美的感受是其他新鞋無法取代。即使它又髒又破,任何人想買新鞋絕對不會對這雙鞋產生美的聯想,但在你眼中它自有一種"殘缺之美" p. 20

第三章 建構視訊遊戲

我們一直都是這樣工作的 - 真的,我們不會在一開始就弄一大堆設計文件,說:「這些就是我們準備要做的。」在開始的時候想法很模糊。「我們要做一個有2D物理引擎的動作遊戲,再加上一些使用者自創內容。」行動激發了創意,於是經常會聽到像這樣的話:「好極了。快來看看這個,我想到一個好點子。」我們可以互相腦力激盪。這是一個亂中有序的環境,這種方式只有在人少的時候適用。隨著團隊人數的增加,我們處理這種情況的方式是創造幾個「分子」,也就是把人們分成幾個小組,在不同的區域工作。這樣人們依舊可以採用那種亂中有序的方式。 p. 31

你做出一些東西來放在螢幕上,大家可以試玩,然後對於進一步的發展方向每個人都會提出自己的想法,如果你不能坐下來,讓大家達成一致意見,人們就可能偏離原來的方向,開始依照自己的想法工作,而使彼此有了一些衝突,突然間這個團隊就不再是一個整體了。

我們努力做到的是取得一個合理的平衡點 - 我和Dave名義上是專案的首席設計師,但實際上大家都參與了遊戲設計。自然而然地,我的工作最後變成了廣納眾議,接納並整理有意義的方案,截短取長讓它們的條理更清晰,但有時候我也不得不說:「不行,這些不能做,那樣對我們想要實現的目的沒有意義。」既要保持原有的願景,又要考慮人們提出的、具有進化的設計意見。」 p. 31

有時候幾位主管互不相讓,彼此恨之入骨,這種態度看起來很糟糕,但我覺得不錯。這表示我們真正關心我們正在做的東西。大家的士氣都很高昂。我們最後總是能得到最佳的解決方案。p. 32

不停地試玩。在製作遊戲過程中,最重要的事情就是要不停地試玩這款遊戲。很多人都忘記了這一點。你可以坐下來,一邊試玩,一邊思考類似這樣的問題:我喜歡這款遊戲嗎?我玩得起勁嗎?它讓人感覺很好還是越來越煩躁?它無聊嗎?太棒了,這正是我想玩的遊戲。在開始的時候,你自己必須成為遊戲的玩家。你必須做一些符合自己標準的東西或是連你自己都願意玩的遊戲。 (中間省略部分) 另外一種重要的方法是找一些對這個遊戲毫無所知的人來進行測試。因為你在製作遊戲時可能會過於專注某些細節,一直牽掛某個特定的地方。但當你把遊戲拿給從未接觸過它的人玩時,你會很痛苦的發覺原來妳根本是杞人憂天,那些事他根本一點也不在乎。突然間你會恍然大悟你會想到一些其他很明顯的、應該注意的地方。我覺得製作遊戲時需要不斷地試玩,同時也需要接納其他人的意見。關鍵是測試、測試、再測試。p. 33

以我的經驗,當團隊變大之後,例如超過100人,就會不可避免地產生冗員,人們來上班只是為了一份工資,而不是為了做一些讓他們感到自豪的事。在我們的團隊中沒有這種情況。這裡每個人都十分敬業,為自己的工作而感到自豪。 p. 34

我能說的就是這個歷程很艱辛,但是得到的回饋也很大。在這個過程中我領悟到了很多做人的道理。對於開發這樣的遊戲,我不響低估工作的困難度,但它是完全可以做到的。不過如果你不去嘗試,那麼永遠也不會成功。如果真的想要做,那就開始行動,動手做一做,看看會發生什麼事情。需要行動起來,即使遇到困難也不能放棄。p. 37

後註:可能因為是自己從小就想加入的產業(話說現在不是嗎?笑...),加上這一章的內容是以訪談的方式,訪談《小小大星球》遊戲公司Media Molecule創辦人Mark Healey,所以讀起來特別有感受。

第四章 打造完美團隊

事實上我們對每天收到的那些鼓舞士氣的電子郵件嗤之以鼻,那些只不過是為了想提高我們的生產率,避免我們的抗爭,所說的一些場面話而已。 p. 49

第五章 激勵開發人員的因素

因此,瞭解你要加入一個什麼樣的團隊,或者現在身處於什麼樣的團隊,這不但要有自知之明,還得觀察你周邊的世界。 (中間省略) 我要說的是,任何人群中都有他們的人情世故。要了解它很困難,因為你得對周邊的人、事、物要有敏銳的感覺。渾然不覺會讓你操受挫折。 p. 54

首先,你身為領導者或經理,一定要明瞭團隊要共同在一起工作。人與人的相處問題是你第一個潛在的障礙,任何一個讓團員無法共事的因素都會讓你的專案一敗塗地。 p. 60

事實上我們主要是想了解應徵者是如何跟他人互動。我會告訴他我這個人選人很嚴格,我們現在有個空缺的職位,我對於想得到這個職位的人特別挑剔,因為我熱愛我的團隊,我喜歡我的工作,我不想隨便找個人加入團隊。當然,還有其他實際的問題要考量。第一,想要開除某個人真的很痛苦。 p. 61

第六章 激勵隊員

第一個願景是我們將圍成公司最優秀的工程團隊:其他人會把我們當作是先鋒,領導流行的人,認為我們都在做一些偉大而創新的事。每當有艱難任務的時候,總是會想到我們。 p. 68

我花了一年的時間才讓他們瞭解,我不會因為他們勇於冒險或為了學習新事物而改變計畫就責怪他們。學習新事物的規則就是要勇敢,要勇於嘗試。我們都是聰明人。我們知道我們學習了新事情,並改變了我們的思維。只有笨蛋才會責罵人們為什麼不知道那些沒學過的東西。但是工程師總是受到處罰,因為每個人都要向他們提出進度要求。我不來這一套。人們開始了解我的作法。 p. 71

於是有一天我和那位優秀的工程師Jason坐下來聊天,「Jason告訴我,你是怎麼思考工作的,你整天玩遊戲,但你卻是我工作生涯中見過的效率最好的工程師。在玩耍和完成這種高水準的表現有什麼關係嗎?」
他說:「我只是需要釐清一下思緒。在遇到問題時,如果能玩一玩再回來工作,就能解決問題。」
於是我說,「Jason,我們來做個約定,我不知道別人會怎麼說,但是如果你覺得需要去玩一下,你就去玩,這是你獲得那些神奇表現的方式,你愛玩多久就玩多久。」
而他從來也沒讓我失望。這就是如何得到創造力的部分答案。作為一個領導者,你必須找出能激勵每個人工作熱情的方式,然後把他們帶入那種狀態之中。 p. 73

事實上關於創造熱情,我認為有10點要素。其中最重要的一個就是要歌頌錯誤。
如果你做一件零風險的事,那麼你的報酬率是零。因為如果沒有風險,那你為什麼會得到報償呢?所以沒有風險的事是不可能獲得成功的。但是什麼是風險呢?風險就是壞事會發生的可能性。
好,如果沒有風險事情不能取得成功,而風險意外著會發生不好的事情,那麼如果你把風險降為零,那還有什麼東西也會跟著降為零呢?當然是成功的機率也會跟著降為零。
(中間省略)
在問題分析出來後,接著就是要找那個犯錯的人。其他人都張大眼睛盯著看,犯了錯要被「斬首示眾」。每個人都得到一個經驗:「不能犯錯。」這無疑是雪上加霜,給人們增加了無形的壓力,這種壓力就是不能犯錯,這樣的環境是沒有人敢冒險的。p. 79

在你創造的環境中,人們會勇敢的嘗試一些有風險的事情。你必須鼓勵人們這麼做。如果人們冒了風險,而一些瞭解情況的人可以看到這個決定是沒有經過深思熟慮的,那麼當然他們可以立刻制止,別人也不會認為這樣做不合理。
但如果們做了一個合理的決定,結果卻很糟糕,這也只是說明了他們冒了一次險,剛好碰到了出錯的機率。你猜會怎麼樣?有時候結果就是這樣。如果你不會大發脾氣,反而欣然接受這個事實,那麼人們就會比較放心地去利用機會。如果他們抓住機會,他們就會創新,偉大的事情就開始發生了。
這也就是為什麼有些公司在創立初期都會抓住難得的機會。他們取得了成功,在接下來的日子裡卻又讓那些成果化為烏有,因為他們害怕失去已經擁有的東西。這種患得患失的行為,終究會導致失敗。 p. 80

後註:聽起來好像:對啊!就是這樣,但實際上要真的能激勵隊員是件很不容易的事情!

第七章 將音樂帶向21世紀

這似乎成了MP3.com工程師的一個共同遵守的路線:可以說他們都是富有才華的專業人員,為了完成工作不辭辛苦,隨時準備捲起袖子全力以赴,必提出創新的解決方案。我已經沒辦法告訴你有多少人第一天來到MP3.com就因為周圍環境的快節奏而不知所措。但是接下來發生的事情可就無獨有偶,令人驚奇:他們自己找事情做。在那裡不是:"我要做什麼?"而是"我能做什麼?"就像是你生命中的有機體一班自然的呼吸。p. 85

我們正在改變網站的音樂對客戶提供服務的方式。是由我們自己設計,而且是公司同意的。事情就是這麼酷。 (中間省略) 這就是MP3.com的工作方式:充分的授權你和很酷的人去做不同凡響的事情,分析如何解決問題,然後直接去做它,並學習其中的來龍去脈。 p. 90

接下來大約兩個月的時間,我們慢慢的錄製完成了公司從商店或員工手上買來的CD。看起來是個巨大的工程,而且也是一個團隊建設的流程。看看公司裡的每個人,銷售副總裁旁邊的辦公室,身材矮小的女按摩師Linda,還有Paradise,我們的首席音樂學家之一、嘻哈音樂之父。雖然對於整個行動的合法性還有質疑。但是大家開始活耀起來,我們逐漸找回了在員工擴充過程中失去的凝聚力,六個月前我們有六、七十人成長到現在已經將近二百人。p. 93

大約在此之前的一個星期,Michael拿到了第一個版本進行測試,他大發雷霆:這是誰負責的?一轉眼間,工程副總裁,網頁設計主管,及參與決策過程的每一個人都擁入了Michael的辦公室。你可能已經知道問題出在哪裡:JavaScript[1]!這是MP3.com"採取主動"心態的黑暗面:你可以採取主動,只要你沒做錯事或不讓Michael生氣。這真是恥辱,但這是它的運作方式:你拼命的工作,認真的思考,非常成功,表現出色,是被選中的人,只要出了錯...什麼都沒有,這是你沒有權利再做任何事。p. 97

[1] 書在這前面說明時空背景:當時JavaScript在瀏覽器的相容性還不是很好,所以網頁中使用JavaScript須冒點風險。

第八章 內部開放原始碼

你在一個成功的開放原始碼專案中經常會看到人們很容易地提出缺陷報告。他們的團隊讓這個過程變得簡單,他們幫助有困難的人,他們花時間幫助人們找出問題所在。一方面是因為他們把它當成自己的事,認為這樣做是對自己最有利,另一方面是因為他們本身原先是個使用者,進而成為專家級使用者,最後成為一個專業的開發人員。因此開放原始碼的團隊總是能以自己的方式工作取得成功。p. 111

第二篇 目標

敏捷團隊中的程式設計師能夠把工作做好的原因之一是他們持續不斷的回顧目標。他們為了讓每個人朝著目標前進,團隊把目標記在白板上提醒大家,並且經由開會讓團員知道事情的任何變化。而且他們讓客戶餐與專案的日常工作,因為這是確保每個人的方向都和目標保持一致的最有效的方法。p. 120

良好的軟體,或者如果它不是客戶需要的軟體,這些都是其次。整個團隊一起工作最大的挑戰是讓大家的目標保持一致,使他們能建構適當的軟體。即使是最好的團隊也可能在這些目標上有衝突,衝突可以破壞一個團隊的團結。但是如果你從一開始就能讓每個人的方向和目標保持一致,當目標有所變化時能讓每個人都知道 - 目標總是不停的變化,那麼專案更容易取得成功。p. 121

第九章 創造團隊文化

普通的流程帶來的會是一般的產品。這不依定是壞事,因為有許多一般的傳統產品要生產,但如果你在一個地方,做一件富有創新意義的產品,而你缺少信任的文化,那麼這個創新絕對會受到傷害。 p. 125

每一個組織都會有這種問題。有些團隊可以結合成一個整體,有些則不能。關鍵是要能提供一個機制,讓團隊找到自己的路。如果你的流程太過繁複沉重,那麼人們連一點自我組織的空間都沒有了,但是如果你的流程太過於鬆散,那就沒有組織結構,事情變得不可預測了。p. 127

隨著人們繼續前進,你會遇到有關這個架構裡的一些有趣問題,這些細節問題很少會記錄在文件中,經常是沒有文件可供查詢,雖然程式碼最重要,但程式碼並不能代表一切,因為它不能把原理和衡量結果的內容保存下來,用程式碼難以辨別的東西也不法保存。他們是超越程式碼本身的一種模式。那樣的東西保存在部落記憶之中。 (中間省略) 你還必須處理知識產權洩漏的問題,因為把它保存在部落記憶裡的代價太昂貴 - 其實,它並不貴,但是將這些知識產權抽取出來卻非常不便宜,而且如果這些內容離你而去,成本可就特別的高。p. 127

在開發流程中有很多的創新,但會丟棄大量的程式碼,也會開始累積程式碼,成為一項資本投資,並成為文化本身的一部分,這時再也不能隨便丟棄。你必須將這個決策流程的部分內容形成一種制度。p. 128

擔任那個職位(這指經理)需要培養的另一項能力是向權力部門說真話,雖然這麼做是在拿自己的前途開玩笑,但是站在這個位置確實必須如此。你必須不作假,否則,你的整個過程變成了一連串的謊言。要對權力部門說真話,這點很重要的。這是能把你的團隊從令人不習慣的政治中隔離的另外一面。 p. 130

一個健康的組織是有跡象可辨識的,他們面對失敗是謹慎沉默的。一個極度不知變通、拒絕失敗的組織,通常也都是那些缺少創新的組織,那些人非常害怕失敗,總是會採用一些最保守的行動,所以他們幾乎沒有任何樂趣可言。另一方面,在不會破壞公司業務為前提之下能夠比較自由的面對失敗的組織,反而會是生產力較高的團隊,他們面對失敗給予一定的自由度,這樣人們就不會編寫每一行程式碼都要擔心受怕。p. 131

我看到的那些跨越不同區域、生產率超高的組織都遵循這樣的作法。分為三個部分。第一,把重點放在可執行程式碼上。 (中略) 第二,以增量、迭代的方式完成這些事情,這樣做意味著你們做事會有一定的規律,因此你們可以從中引入重構, (中略)第三項因素,敏捷的成分少一點,RUP成分多一點,要把重點放在架構上,把架構當成一種控管的手段。 p. 132

後註:這一章密度蠻高的。

To be continued...

第十章 讓"我"置身於錯誤之中吧!

第十一章 制定計劃

第十二章 公眾利益鬥士攻佔邪惡之城

第十三章 保衛自由世界

第十四章 拯救生命

第三篇 實踐

第十五章 建構協同作業型和學習型的團隊

第十六章 更好的實踐

第十七章 TRW軟體生產率專案回憶錄

第十八章 建造太空船

第十九章 成功的需求

第二十章 在Google的開發工作

第二十一章 團隊與工具

第二十二章 研究團隊

第二十三章 HADS團隊

第四篇 障礙

第二十四章 糟糕的上司

第二十五章 歡迎使用軟體開發流程

第二十六章 跨越障礙

第二十七章 品質與速度

第二十八章 層層障礙,不是嗎?

第二十九章 辦公室內外

第三十章 匯集團隊的心聲

第五篇 音樂

第三十一章 製作音樂

 
almost 4 years ago

最近團隊對refinement meeting有一些討論,過去也曾討論過,我認為這一連串的討論很有意思,所以把從過去到現在refinement meeting如何進行與調整記錄下來。要開始寫這篇時才發現,書到用時方恨少,我手邊竟然沒有半本關於Scrum的書,看完幾本卻始終沒有一本是擺在自己身邊的,只好請Google大神幫我找些資料了。

目前工作的團隊算是成功的Scrum團隊,特別是自省會議後對團隊的改善是玩真的,而不只是說說感謝誰或是只聽到好話,檢討起來常常會超出預定的會議時間,有時候還會火力四射,之前的自省會議提到一件事情,就是sprint的planning meeting越來越久,甚至到快用掉半個sprint的時間才把目標確認、task切割和時數估完,原因和scrum的另一個活動有關:refinement meeting。

在繼續討論原因前,我覺得需先說明一下團隊過去refinement meeting的運作情況,我加入團隊時就已有refinement meeting的慣例,由團隊全部成員參與(大約1x人),時間在每個sprint (一個sprint兩周)第一周的周五。在refinement meeting召開前,PO會將接下來要做的需求(feature)其發想的成果寫成一份詳細的企劃文件(跟團隊過去開發遊戲的經驗有關)。會議有時會搭配可簡單操作的blueprint雛形展示,然後針對PO將企劃文件所條列的功能切割而成若干的user stories進行討論與估點,當時,這些user stories常遭到抱怨,原因有幾項:(1) 常有非end-to-end的user story、(2) 從模組開發的角度去思考,造成user story間的dependency變高、(3) 從畫面的角度切割,造成user story的內容有重複性、(4) user story的敘述超長卻看不出價值。幾次下來後,在一次的自省會議中,達成了一個共識:user story的切割應該要以使用者能得到的價值為主。

Mon Tue Wed Thu Fri
Week 1 Planning Refinement
Week 2 Polish Review / Retrospective

雖然常被抱怨,但當時開發的情況其實還算穩定(burn down chart還算平穩下降),但有一個現象在當時已經開始浮現:需求從發想、refinement後到開工的時間相當長,因此導致這段時間若干的story後來是被捨棄沒有開發的,我稱之為現象而不是問題主要是,若這些story真的是不需要的,捨棄不開發反而是減少後期開發時間的浪費。

第一次調整後,PO調整了user story的寫法,確實滿足了團隊當初所提的重點:價值導向,但幾次下來,PO開始有點抱怨,而團隊成員也有點抱怨,因為每個story都很大,當時估點story point常常有40這樣的數字出現,當然這樣的story不會被接受,因此refinement meeting常常都是在做切割story,事實上這本來就是refinement meeting要做的事,只是當每次的story都很大,開會時間都超長,團隊中非程式的人員例如美術,常常只是陪程式人員耗著。成員會覺得為什麼不乾脆一開始就讓我們自己寫user story,還比較能節省refinement meeting的時間,此外,PO也覺得每次refinement meeting結束後,為了追蹤確保事情都被完成,他要修改許多文件,包含企劃文件、資訊系統上user story的關聯等等。

因此再次在自省會議中檢討refinement meeting該怎麼進行,會議的結論是:PO不再需要寫一份超完整的企劃文件,只要UX完成一份大概的流程圖就能送進refinement meeting,refinement meeting分成若階段:(1) 第一階段由PO用流程圖解釋需求,全員在當下釐清可行性或是提出改善;(2) 第二階段由程式根據流程圖、end-to-end、價值和施工工法進行story的切割;(3) 對story進行估點,估點時美術團隊會再次加入。這三階段不一定要在一次的refinement meeting中全部完成。

但第一次調整後團隊開發是否有遭遇問題呢?這段期間大概實行了8個sprint,事實上有遇到問題,但和refinement無關,而是別的因素造成,為此還畫了幾次魚骨圖檢討原因(有機會再談)。除此之外,因為由團隊進行再切割,story的規模穩定,且在review時,每個story都有可展示的部分,而時程的估計和實際的開發時間也算吻合,可以說是進入開發速度的穩定期,但後期團隊的成員開始增加,開發速度再次出現變化。

第二次調整只實施了兩個sprint,而用新refinement方式所產出的story,因為整體App上市時程上的考量被封存,並沒有真正地被排入planning meeting中,但平心而論,那兩次的refinement meeting是相當有效率的,而且story的品質是全團隊都認為相當優質的結果,只能說相當可惜。

只實施兩個sprint的主因是,在成員增加到2x人(程式、美術、UX、企劃和PO)後,一次的自省會議上,PO轉達一位(相當重要的)stakeholder的意見:他在一次活動中看到許多新創團隊在很短(相較於我們目前App的已開發時間)的時間就能上架一款App,他覺得團隊的應變速度不夠快。針對這點,長久以來的現象終於被拿出來討論,scrum master將資訊系統中,一個需求從開始發想到開工需要時間大概是兩個月(與團隊討論可行性,幾次往返才能完成發想),開工後平均的開發時間大概也是一個月到兩個月(視需求大小),平均來說,一個需求大概要三個月,也可以說團隊的應變時間大概是三個月,雖然不能稱之為waterfall,但在需求釐清確實花了不少時間。

因此,會議中提出了一個建議,團隊分成A、B兩組,每組都有企劃、UX、美術和程式,針對接下來兩個feature,撥出sprint約30%的時間由兩組成員進行發想,發想結果由stakeholder選擇後,馬上進行planning並開發,就時間軸上來看,這兩個feature從發想到完工只花了2-3個sprint,也就是應變時間從三個月縮短到一個月,能縮短到這麼短,真的是因為改變方法嗎?不完全是,就規模來說,這兩個feature相較於過去開發的feature小很多。而且也出現了副作用:sprint n和sprint n + 1這兩個sprint陷入少見的加班期,原因簡單說是過多的context switch,說是撥出30%的時間,但發想會議常常無法如期結束,有時還要加開,延誤到原本的開發,成員為了追回進度自願加班,就長遠來看這不是好現象。但目前仍有不少團隊成員認為是最好的方式,因為能參與到最一開始的發想,有一種產品就是自己的小孩,而不是幫別人開發產品。

sprint goal
sprint n 功能開發 (70%)、feature 1 發想 (30%)
sprint n + 1 feature 1 開發 (70%)、feature 2 發想 (30%)
sprint n + 2 feature 2 開發 (100%)

就在這兩個需求完成後,團隊開始了另一個client平台Android的開發(原先全力專注在iOS的開發),也就是文章在一開始提到的情況,Server組和iOS組進入收尾期,Server組開始進行performance tuning,iOS組開始修bug與iOS 8 SDK的升級,由於Android組是移植iOS上所有的功能(直接由我和PO決定story優先順序),iOS組和Server組要做的事都是技術細節,PO就交由團隊自行選擇,refinement meeting也好幾個sprint沒有舉行,但這出現了一個問題,story的優先順序也由團隊自行選擇,兩組對於微調的優先順序認知不同,有些微調整需要兩組間的配合,於是iOS組想確定Server組是否能配合某些story,Server組也想知道iOS組能配合那些story,雖然最後都能取得共識,但長時間的會議容易造成會議效率的下降,確認這些優先順序花了不少時間。

因此在本文一開始提到的自省會議中再次討論起refinement meeting,決議是refinement meeting還是視情況召開,除有新需求一定會召開外,只要任何成員認為有需要討論story順序或細節就能跟PO要求召開,而且也將時間固定在原本每個sprint第一周的周五,但不相關的成員可以自由參加,例如技術細節的story討論,美術可以不用參加。該自省會議後確實召開了一次refinement meeting,不過是因為新需求而召開,進行方式原則上算是第二次調整和第三次調整的混和體,PO先講解這次UX調整的理念(這部分還是由UX團隊與公司另外一個部門討論後,由stakeholder定案的),而細節由全成員討論,確認後由開發成員切割story (但竟然忘記估點了XD),和討論施工的優先順序。

Scrum和過去其他開發法不同,只明確定義了極少的幾個活動,其中refinement meeting大多只提到怎樣的story算是ready to plan,但refinement meeting如何進行較少有討論,畢竟如何進行與團隊特性有關,接案團隊與創業(開發自有產品的)團隊的進行就不一樣,對接案團隊來說,對story本身的喜好,團隊較少會有想法(即便有,通常也不具有決定權),只要需求明確能夠施工即可;但創業團隊就會很在意story的內容,會有相當多的意見,refinement meeting就是一個很好的場合讓大家把對需求的想法提出來,否則讓成員失去參與感,這對創業團隊是很大的傷害。

目前refinement meeting在幾次的檢討後,算是抓到一個平衡點,但我也不認為我們的團隊會滿足於現況,重要的是持續改善,對於refinement meeting如何進行,這裡簡單分享我們如何改善給大家參考。

 
over 4 years ago

先前和剛開始寫iOS的同事pair programming,同事對於我習慣將private member data宣告成read-only property感到有點疑惑,老實說,這習慣是只有寫Objective C時才有的,而且不是全部的private member data都是如此,主因是Objective C對於存取限制的設計不像C++ / Java / C#那樣顯著,但這確實讓我時常思考:存取限制的目的是什麼?像是JavaScript或最近新推出的Swift語言(在XCode 6 Beta 4的release note中有提到publicprivateinternal三種存取層級了),存取限制的設計也不明顯,最後還是回到OO的基本特性之一:封裝。封裝有時會被誤以為是information hidding,我認為封裝最大的用意是讓使用者以既定規範使用被封裝的元件,避免元件被破壞。因此,在不違反這原則下,我能接受部分資訊以唯讀的方式暴露出來,有時這些唯讀資訊還能讓測試變容易。

另外還有一個要思考的是,除了語言本身提供對存取限制的支援外,沒有其他方法能達到同樣的效果嗎?事實上我見過很多Java工程師在宣告完private member data後就立即撰寫對應的getter和setter,甚至還因為覺得寫getter/setter很麻煩有了像lombok這樣的third-party library,如果是這樣,一開就把該member data設計成public不是更簡單?可是有時候確實會面臨一個兩難,某個member data確實是可以改的,但又不是任何人都可以改,所以若不開放setter會變成所有人都無法修改,但開放後又變成所有人都能改,因此像Java提供了protected和package層級的限制,前者讓繼承的物件能夠修改,後者讓屬於同個package的物件能夠修改,但有時候A物件和B物件既非繼承關係也非同package關係,此時這些限制都無法滿足一個需求:讓物件暫時變成immutable (Java官方有建議的方式設計永久性immutable object,但不是我要的)。

一個簡單的例子,在一個行動裝置的App上,可能會使用一個User類別記錄使用者的暱稱、ID以及照片,除了ID可能是一創建後無法修改外,其他像暱稱或是照片都是可以修改的,因此,通常會替類別加上對應的setter,但這會有個問題,修改暱稱或是照片可能需要和server同步及儲存,但持有User物件的對象(例如:UI),可能誤以為呼叫對應的setter即可完成任務,事實上確實可以在setter做完這些事情,但這會讓User的responsibility變繁重,或是透過observer的方式,當任何屬性有變更時,通知observer做對應的動作(和server同步及儲存),但若observer發生錯誤,處理錯誤變得很不乾脆,所以這兩種方式我都沒使用過。

為了不讓不合適的物件持有者修改物件狀態,可以將getter和setter分離,將getter全部集中到介面上,一個只有getter的interface,稱之為Immutable interface (原本我不知道有沒有相似的pattern,所以給了一個Getter-only interface的名字,後來真的找到了),setter則保留在實作的類別上,如Figure 1,UserInfo僅宣告getter,而實作的User依舊提供setter。當UI需要呈現使用者的暱稱和照片時,可以只傳遞UserInfo給UI,雖然UI取得的是只有getter的物件,但這些getter以提供充足的資訊完成顯示的任務,但如果要修改暱稱,由於沒有setter,是無法擅自修改暱稱,只能透過UserManager進行修改。

Figure 1 - UserInfo, User, and UserManager

這只是program to interface的一個小把戲,事實上,傳遞給UI和UserManager所持有的物件可以是同一個物件,只是彼此看到的介面不同罷了。例如Code List 1中的DefaultUserManager實際上所持有的是User實體。

Code List 1 - The actual instances kept in DefaultUserManager
public class DefaultUserManager implements UserManager {

    private UserDao _userDao;
    private UserWebService _userWebService;
    private Map<String, User> _users;

    /* implement the methos in UserManager */
}

當UI需要取得UserInfo時,呼叫UserManagergetUser(String),Code List 2的實作,先從記憶體當中先找有沒有符合的實體,若沒有則呼叫web service嘗試向server取得指定的使用者資訊,若沒有發生任何錯誤,web service一樣會回傳immutable的UserInfo實體,接著呼叫DAO將該使用者的資訊儲存到資料庫中,若沒有錯誤,用該資訊建立一個User實體放入_users中,待下次使用,接著回傳User實體,但對UI而言取得的是immutable object。若擔心強制轉型,可以複製一份再回傳。

Code List 2 - The implementation of getUser(String)
public UserInfo getUser(String userId) {
    if (_users.containsKey(userId)) {
        return _users.get(userId);
    }
    User user = null;
    try {
        UserInfo userInfo = _userWebService.getUser(userId);
        _userDao.save(userInfo);
        user = new User(userInfo);
        _users.put(userId, user);
    } catch(WebServiceException we) {
        we.printStackTrace();
    } catch(DaoException de) {
        de.printStackTrace();
    }
    return user;
}

由於資料物件常常有多個屬性要初始化,以User為例有三個屬性要初始化,若都透過constructor建立,constructor的參數會變得相當多,這變成long parameter list的壞味道,此時,UserInfo正好扮演Parameter Object的規範,而且還是immutable的parameter object (雖然Java有final關鍵字可以用,但只限制該變數無法改變所指向的物件,無法阻止呼叫setter改變物件內的狀態)。這樣也可以避免常常寫「用預設建構子建立物件,然後呼叫很多的setter完成初始化」這類重複的程式碼。

Code List 3 - The constructor that constructs object from the immutable object
public User(UserInfo info) {
    _photo = info.getPhoto();
    _userId = info.getUserId();
    _nickname = info.getNickname();
}

以剛剛的Code List 2為例,如果是一個回傳JSON的web service,只需替JSON parser分析出來的結果寫一個wrapper (Code List 4),就能當做是建立User實體所需要的parameter object了,雖然很多JSON parser都有data binding的功能,能直接將JSON轉成對應的POJO物件,可是如果JSON內的屬性名稱和POJO的屬性名稱不相同時,在model object中加入與domain無關的annotation (例如@SerializedName)就讓我有點討厭。

Code List 4 - The wrapper of JSON object
public class UserGsonWrapper implements UserInfo {

    private JsonObject _json;

    public UserGsonWrapper(JsonObject json) {
        _json = json;
    }

    @Override
    public String getUserId() {
        return _json.get("user_id").getAsString();
    }

    @Override
    public String getNickname() {
        return _json.get("user_nickname").getAsString();
    }

    @Override
    public String getPhoto() {
        return _json.get("user_photo").getAsString();
    }
}

此外,Java不像C++在語言上就支援copy constructor,雖然提供一個clone()函式用來複製既有的物件,但使用上,必須在想支援複製的類別上加上Cloneable的實作,否則呼叫clone()會拋出CloneNotSupportedException例外,而且clone()的回傳值是Object型別,每次都要轉型確實有點討厭。但有類似Code List 3的建構子後(其實有點像copy constructor),複製一個物件,只需將要被複製當成建構子的參數即可。使用的方式就像Code List 5一樣,由於Dao的save(UserInfo)函式需要一個帶有最新狀態的物件好將狀態寫入資料庫,但在沒有成功之前,又不想改變既有的物件狀態,此時只需複製一份新的物件,將新的暱稱設定到複製出來的物件內,當成save(UserInfo)的參數,若儲存成功,在將新的暱稱設回原先的物件,如此一來,若儲存失敗,既有的物件也不會受影響。

Code List 5 - Easy clone an object
public UserInfo updateNickname(String userId, String nickname) {
    User user = null;
    if (!_users.containsKey(userId)) {
        return user;
    }
    try {
        user = _users.get(userId);
        _userWebService.updateNickname(userId, nickname);
        User tmp = new User(user);
        tmp.setNickname(nickname);
        _userDao.save(tmp);
        user.setNickname(nickname);
    } catch (WebServiceException we) {
        we.printStackTrace();
    } catch (DaoException de) {
        de.printStackTrace();
    }
    return user;
}

由於Java的interface是可以繼承另一個interface,所以Immutable interface也可以用在原有的繼承架構上,只是會變成像Figure 2那樣是個平行的繼承架構。最近IM (Instant Messaging)很熱門,有各式各樣的IM App,而且傳遞的訊息也不再僅僅是文字訊息,以程式來說,要表現出不同的訊息類型,可能會有Figure 2右半邊的繼承架構,一個抽象類別Message代表一則訊息,實際的訊息種類則用TextMessageImageMessage分別代表文字訊息或是圖片訊息。在導入Immutable interface時,為了符合右邊的繼承架構,可以看到Figure 2左半邊有一個相似的繼承關係,在這繼承關係中都有一個對應的Immutable interface,MessageInfo對應MessageTextMessageInfo對應TextMessage,而ImageMessageInfo對應ImageMessage

Figure 2 - Inheritance of Immutable interfaces

在這樣的平行繼承關係中,先前提到的建構子一樣能適用,如Code List 6中,TextMessage的建構子直接把參數傳給Message的建構子,實際上,一則文字訊息一旦送出,內容不會在修改(好吧,最近有些IM主打閱過即焚,就好像Mission: Impossible中的任務錄影帶一樣),所以TextMessage除了透過建構子將訊息內容傳入外,並沒有setter能夠修改訊息內容,但因為是繼承Message,所以保留訊息ID,可以在實際送達server取得訊息ID後再更新。

Code List 6 - The implementation of Figure 2
public interface MessageInfo {

    public long getMessageId();

    public String getSenderId();
}

public abstract class Message implements MessageInfo {

    private long _messageId;
    private String _senderId;

    protected Message() {
        _senderId = null;
        _messageId = -1;
    }

    protected Message(MessageInfo info) {
        _senderId = info.getSenderId();
        _messageId = info.getMessageId();
    }

    @Override
    public String getSenderId() {
        return _senderId;
    }

    @Override
    public long getMessageId() {
        return _messageId;
    }

    public void setSenderId(String senderId) {
        _senderId = senderId;
    }

    public void setMessageId(long messageId) {
        _messageId = messageId;
    }
}

public interface TextMessageInfo extends MessageInfo {

    public String getContent();
}

public class TextMessage extends Message implements TextMessageInfo {

    private String _content;

    public TextMessage(String content) {
        _content = content;
    }

    public TextMessage(TextMessageInfo info) {
        super(info);
        _content = info.getContent();
    }

    @Override
    public String getContent() {
        return _content;
    }
}

從上面的例子,可以看出Immutable interface讓封裝更有彈性,不用擔心setter的過度開放。當不希望物件被不允許的對象修改時,只需讓對方取得getter的介面即可,反之,讓能夠允許修改的對象取得有setter的物件即可。雖然沒有提到functional programming,但immutable object是functional programming中很重要的一環,也是常用的一種thread-safe技巧,Immutable interface某種程度上(若不用強制轉型),不需複製物件就能達成immutable object的目標了。

後記:雖然C++已經有相當久的歷史,但若善用const關鍵字(使用const point或const reference搭配const member functions),確實不需要Immutable interface就能輕鬆達成immutable object的目的。

 
over 4 years ago

大概七八個月前,因工作上需要,研究一些讓鄰近的行動裝置(不論是iDevice或是Android裝置)能夠知道彼此的方法,至於用途是什麼就不能說了。若不考量行動裝置作業系統版本的話,Bluetooth Low Engery是一個不錯的方案,但從2014年6月Android 4.3的市佔率來看,很難讓軟體開發者直接捨棄4.3以前的使用者。傳統的Bluetooth方案,受限於iDevice只允許通過MFi Program認證的Bluetooth裝置才能連線,因此iDevice和Android無法透過Bluetooth建立連線。最後,雖然iOS支援Bonjour over Bluetooth,但Android目前不支援,反之,Android支援Bonjour over WiFi Direct,但iOS目前不支援,可是若使用者在有WiFi AP且能取得IP的環境(Bonjour是在TCP/IP層上的通訊協定)這前提下,Bonjour也許是一個可行的方案,也是本文的主題。至於下表中的iBeacon待下回分曉。

Technology iOS Android
Bluetooth iOS 5 & MFi required Android 2.0
Bluetooth LE iOS 5 Android 4.3
Bonjour Over WiFi & Bluetooth (iOS 5) Over WiFi (4.1 or 1.6 with JmDNS) &
WiFi Direct (4.3)
iBeacon iOS 7 Android 4.3 and third-party library required

首先,先看Android吧!畢竟Bonjour幾乎是iOS/OS X的原生居民,不太需要擔心。本文使用Android SDK 4.1原生的Network Service Discovery API,如果想讓更早之前的Android能使用Bonjour,可以使用third-party的JmDNS,網路上也有相當完整的使用範例。要使用Bonjour,應用程式需要取得INTERNETCHANGE_WIFI_MULTICAST_STATE權限,因此要在AndroidManifest.xml中加入Code List 1所列的二行敘述。

Code List 1 - Android use permission
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE"/>

接著替Activity的layout XML中加入一個ListViewButton用來顯示找到的Bonjour服務和發佈及啟動服務搜尋。

Code List 2 - Activity layout
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:paddingBottom="@dimen/activity_vertical_margin"
    android:paddingLeft="@dimen/activity_horizontal_margin"
    android:paddingRight="@dimen/activity_horizontal_margin"
    android:paddingTop="@dimen/activity_vertical_margin" >
    <ListView
        android:id="@+id/servicesView"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:layout_alignParentBottom="true"
        android:layout_alignParentLeft="true"
        android:layout_alignParentRight="true"
        android:layout_alignParentTop="true" >
    </ListView>
    <RelativeLayout
        android:id="@+id/buttons"
        android:layout_width="match_parent"
        android:layout_height="wrap_content" >
    <Button
        android:id="@+id/discoverButton"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_alignParentBottom="true"
        android:layout_alignParentRight="true"
        android:text="@+string/discover"
        android:onClick="discoverButtonPressed" />
    <Button
        android:id="@+id/publishButton"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_alignParentBottom="true"
        android:layout_alignParentLeft="true"
        android:text="@+string/publish"
        android:onClick="publishButtonPressed" />
    </RelativeLayout>
</RelativeLayout>

不論發佈服務或是搜尋服務,都需要NsdManager,所以如Code List 3所示,在建立Activity被時,除取得ListViewButton物件外,也一併取得NsdManager物件。為了簡化,兩個按鈕都是雙態開關,發佈或取消發佈服務,開始或停止搜尋服務,所以有兩個boolean變數_published_discovering記住按鈕的狀態。

Code List 3 - Obtain the ServiceDiscoverManager on creating activity
@Override
public void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_main);

    _published = false;
    _discovering = false;

    _servicesView = (ListView)findViewById(R.id.servicesView);
    _publishButton = (Button)findViewById(R.id.publishButton);
    _discoverButton = (Button)findViewById(R.id.discoverButton);
    _serviceDiscoverManager = (NsdManager)getSystemService(Context.NSD_SERVICE);

    _discoveredServices = new ArrayAdapter<String>(this, android.R.layout.simple_list_item_1);
    _servicesView.setAdapter(_discoveredServices);
}

取得NsdManager後可以發佈(註冊)服務,讓其他裝置能搜尋到,要注意的是,Bonjour只是讓一個讓網路上的裝置知道彼此有什麼服務,實際的服務並不是Bonjour提供,因此發佈服務時,會需要實際提供服務的TCP/UDP Port編號,所以程式在發佈服務前,先建立一個ServerSocket以取得port編號。除了編號,一個NsdServiceInfo需要一個可以辨識服務的唯一名稱,以及服務的類型,需要注意的是,服務類型是由應用類型_chat.搭配協定類型_tcp.組成,基本上是受IANA控管的,服務類型可以在這裡查,當然也可以註冊一個新的。準備好NsdServiceInfo後,就可以透過NsdManagerregisterService(NsdServiceInfo, int, RegistrationListener)函式註冊。

Code List 4 - Prepare and publish a service
private void publishService() {
    int port = startServerSocket();
    if (port == 0) {
        Toast.makeText(this, "unable to create a server socket for the service", 3);
        _publishButton.setClickable(true);
        _publishButton.setText(R.string.publish);
        return;
    }
    NsdServiceInfo service = createService(port);
    _serviceDiscoverManager.registerService(service, NsdManager.PROTOCOL_DNS_SD, this);
    _published = true;
    Log.d("network", "registering a service " + service);
}

private NsdServiceInfo createService(int port) {
    NsdServiceInfo service  = new NsdServiceInfo();
    service.setServiceName("someone on Android");
    service.setServiceType("_chat._tcp.");
    service.setPort(port);
    return service;
}

註冊服務函式是一個非同步的API,註冊成功或失敗會透過註冊時的第三個參數RegistrationListener通知。所以在onServiceRegistered(NsdServiceInfo)onRegistrationFailed(NsdServiceInfo, int)更新UI的狀態,要注意的是,這兩個函式並不會在main thread中呼叫,所以,要更新UI,必須用runOnUiThread(Runnable)將更新UI的程式在main thread中執行。

Code List 5 - Update UI based on the registration status
public void onRegistrationFailed(NsdServiceInfo serviceInfo, final int errorCode) {
    closeServerSocket();
    final Context context = this;
    runOnUiThread(new Runnable() {
        public void run() {
            _publishButton.setClickable(true);
            _publishButton.setText(R.string.publish);
            Toast.makeText(context, "failed to publish a bonjour service", 3).show();
            Log.d("network", "failed to publish a bounjour serverice, error code: " + errorCode);
        }
    });
}

public void onServiceRegistered(NsdServiceInfo serviceInfo) {
    final Context context = this;
    runOnUiThread(new Runnable() {
        public void run() {
            _publishButton.setClickable(true);
            _publishButton.setText(R.string.depublish);
            Toast.makeText(context, "a bonjour service published", 3).show();
        }
    });
}

搜尋服務就相對簡單一點,呼叫NsdManagerdiscoverServices(String, int, DiscoveryListener),第一個參數可以限定想搜尋的服務類型,和註冊一樣,這是一個非同步API,成功、失敗、找到新服務或是某個服務消失了,都會透過第三個參數的DiscoveryListener告知,所以在onServiceFound(NsdServiceInfo)將找到的服務加到清單中,onServiceLost(NsdServiceInfo)將消失的服務從清單中移除。

Code List 6 - Update the discovered services to the list view
private void stopDiscovery() {
    if (_discovering) {
        _discovering = false;
        _discoveredServices.clear();
        _serviceDiscoverManager.stopServiceDiscovery(this);
    }
}

private void startDiscovery() {
    if (!_discovering) {
        _discovering = true;
        _serviceDiscoverManager.discoverServices("_chat._tcp.", NsdManager.PROTOCOL_DNS_SD, this);
    }
}

public void onServiceFound(final NsdServiceInfo serviceInfo) {
    Log.d("network", "service found: " + serviceInfo.getServiceName());
    runOnUiThread(new Runnable() {
        public void run() {
            _discoveredServices.add(serviceInfo.getServiceName());
        }
    });
}

public void onServiceLost(final NsdServiceInfo serviceInfo) {
    Log.d("network", "service lost: " + serviceInfo.getServiceName());
    runOnUiThread(new Runnable() {
        public void run() {
            _discoveredServices.remove(serviceInfo.getServiceName());
        }
    });
}

DiscoveryListenerRegistrationListener其他函式,以及剩餘沒介紹到的實作請參考我放在GitHub上的範例程式碼,接著提供兩個函式處理按鈕被按下去的事件就算是大致完成了(有些錯誤處理沒有處理到),可以開始寫iOS版。

Code List 7 - Handle the button pressed event
public void publishButtonPressed(View view) {
    if(_published) {
        depublishService();
    }
    else {
        startPublishService();
    }
}

public void discoverButtonPressed(View view) {
    if(!_discovering) {
        _discoverButton.setText(R.string.discovering);
        startDiscovery();
    }
    else {
        stopDiscovery();
    }
}

和Android相同,用XCode拉出一個顯示結果的畫面檔並產生對應的BonjourDiscoveredServicesViewController

iOS版當初寫的時候比較用心在再封裝上(Android寫的比較趕一點),所以結構上和Android上有些不同,首先在搜尋的部分,有一個BonjourDiscoveredServices管理搜尋到的服務,然後透過BonjourDiscoveredServicesDelegate通知UI服務內容數量上的變化。

Code List 8 - The protocol to handle the services changed event
#import <Foundation/Foundation.h>

@protocol BonjourDiscoveredServicesDelegate <NSObject>

- (void)discoveredServicesChanged;

@end

BonjourDiscoveredServices封裝搜尋服務的NSNetServiceBrowser,同時實作NSNetServiceBrowserDelegateUITableViewDataSource兩個Protocol,前者負責處理找到服務及服務消失的callback,後者根據找到的服務數量,提供UITableView所需要的UITableViewCell (BonjourDiscoveredServiceCell實作請參考GitHub)。然後提供startDiscoverystopDiscovery供外部使用,其餘細節都封裝在BonjourDiscoveredServices內部。

Code List 9 - The header file of BonjourDiscoveredServices
#import "BonjourDiscoveredServicesDelegate.h"

@interface BonjourDiscoveredServices : NSObject<NSNetServiceBrowserDelegate, UITableViewDataSource>

- (void)startDiscovery;

- (void)stopDiscovery;

@property (weak, nonatomic) id<BonjourDiscoveredServicesDelegate> delegate;

@end

NSNetServiceBrowser很容易使用,建立物件,設定delegate然後呼叫searchForServicesOfType:inDomain,若設定includesPeerToPeerYES,會使用Bonjour over Bluetooth搜尋Bluetooth提供的服務。當找到服務時,會呼叫netServiceBrowser:(NSNetServiceBrowser*) didFindService:(NSNetService*) moreComing:(BOOL)函式。服務消失時呼叫netServiceBrowser:(NSNetServiceBrowser*) didRemoveService:(NSNetService*) moreComing:(BOOL)。比較有意思是的時第三個參數會告知事件接收者還有沒有後續服務,所以可以一次處理完所有找到的服務後,再通知UI更新,不過,我不想等,所以每當有新服務或消失,我更新容器後馬上通知UI更新。

Code List 10 - The implementation of BonjourDiscoveredServices
#import "BonjourDiscoveredServices.h"

#import "BonjourDiscoveredServiceCell.h"

@implementation BonjourDiscoveredServices {
    NSArray* _serviceNames;
    NSNetServiceBrowser* _browser;
    NSMutableDictionary* _servcies;
}

- (instancetype)init {
    if (self = [super init]) {
        _servcies = [NSMutableDictionary new];
        _browser = [NSNetServiceBrowser new];
        _browser.delegate = self;
        _browser.includesPeerToPeer = YES;
    }
    return self;
}

- (void)startDiscovery {
    [_browser searchForServicesOfType:@"_chat._tcp." inDomain:@"local."];
}

- (void)stopDiscovery {
    [_browser stop];
    dispatch_async(dispatch_get_main_queue(), ^() {
        [self removeAll];
    });
}

- (void)addService:(NSNetService*)service {
    if (service.name != nil && service.name.length > 0) {
        [_servcies setObject:service forKey:service.name];
        _serviceNames = [[_servcies allKeys] sortedArrayUsingSelector:@selector(compare:)];
    }
    [self.delegate discoveredServicesChanged];
}

- (void)removeService:(NSNetService*)service {
    if (service.name != nil && service.name.length > 0) {
        [_servcies removeObjectForKey:service.name];
        _serviceNames = [[_servcies allKeys] sortedArrayUsingSelector:@selector(compare:)];
    }
    [self.delegate discoveredServicesChanged];
}

- (void)removeAll {
    [_servcies removeAllObjects];
    _serviceNames = @[];
    [self.delegate discoveredServicesChanged];
}

#pragma mark - UITableViewDataSource
- (NSInteger)tableView:(UITableView*)tableView numberOfRowsInSection:(NSInteger)section {
    return [_servcies count];
}

- (UITableViewCell*)tableView:(UITableView*)tableView cellForRowAtIndexPath:(NSIndexPath*)indexPath {
    BonjourDiscoveredServiceCell* cell = (BonjourDiscoveredServiceCell*)[tableView dequeueReusableCellWithIdentifier:BonjourDiscoveredServiceCellIdentifier];
    if (cell == nil) {
        cell = [[[NSBundle mainBundle] loadNibNamed:BonjourDiscoveredServiceCellIdentifier owner:nil options:nil] firstObject];
    }
    NSNetService* service = [_servcies objectForKey:[_serviceNames objectAtIndex:indexPath.row]];
    cell.service = service;
    return cell;
}

#pragma mark - NSNetServiceBrowserDelegate
- (void)netServiceBrowser:(NSNetServiceBrowser*)browser didFindService:(NSNetService*)service moreComing:(BOOL)moreComing {
    NSLog(@"service: %@ found", service);
    dispatch_async(dispatch_get_main_queue(), ^() {
        [self addService:service];
    });
}

- (void)netServiceBrowser:(NSNetServiceBrowser*)aNetServiceBrowser didNotSearch:(NSDictionary*)errorDict {
    NSLog(@"failed to discover services, due to %@", errorDict);
}

- (void)netServiceBrowser:(NSNetServiceBrowser*)browser didRemoveService:(NSNetService*)service moreComing:(BOOL)moreComing {
    NSLog(@"service: %@ removed", service);
    dispatch_async(dispatch_get_main_queue(), ^() {
        [self removeService:service];
    });
}

- (void)netServiceBrowserDidStopSearch:(NSNetServiceBrowser*)aNetServiceBrowser {
    NSLog(@"service browser stopped");
}

@end

如果目的是想知道附近其他裝置的存在,有沒有真的提供服務到沒這麼重要,所以Code List 11中建立NSNetService物件時,並沒有建立提供服務用的server socket,發佈服務也很簡單,建立NSNetService物件,剛剛沒有提到的,不論是iOS或是Android,服務類型的_chat._tcp.前綴底線是必須的,建立服務時,domain可以填空字串,預設會是host.。設定delegate,然後呼叫publish,想結束服務,呼叫stop即可,所有結果都透過delegate通知,例如透過呼叫netServiceDidPublish:(NSNetService*)sender函式通知服務成功發佈。

Code List 11 - The implementation of BonjourDiscoveredServicesViewController
#import "BonjourDiscoveredServicesViewController.h"

#import "BonjourDiscoveredServices.h"

@implementation BonjourDiscoveredServicesViewController {
    NSNetService* _publishingService;
    BonjourDiscoveredServices* _services;
}

- (void)viewDidLoad {
    [super viewDidLoad];
    self.title = @"Bonjour";
    _services = [[BonjourDiscoveredServices alloc] init];
    _services.delegate = self;
    self.servicesView.dataSource = _services;
    _publishingService = [[NSNetService alloc] initWithDomain:@"" type:@"_chat._tcp." name:@"someone on iOS" port:9166];
    _publishingService.delegate = self;
}

- (void)discoveredServicesChanged {
    [self.servicesView reloadData];
}

- (IBAction)pushlishService:(id)sender {
    [self.publishButton setEnabled:NO];
    if (self.published) {
        [_publishingService stop];
    }
    else {
        [self.publishButton setTitle:@"Publishing" forState:UIControlStateNormal];
        [_publishingService publish];
    }
}

- (IBAction)discoverServices:(id)sender {
    if (self.discovering) {
        [_services stopDiscovery];
        self.discovering = NO;
        [self.discoverButton setTitle:@"Discover" forState:UIControlStateNormal];
    }
    else {
        [_services startDiscovery];
        self.discovering = YES;
        [self.discoverButton setTitle:@"Discovering" forState:UIControlStateNormal];
    }
}

- (void)netServiceDidStop:(NSNetService*)sender {
    [self resetPublishStatus];
    NSLog(@"service depublished");
}

- (void)netService:(NSNetService*)sender didNotPublish:(NSDictionary*)errorDict {
    [self resetPublishStatus];
    NSLog(@"failed to publish services: %@, due to %@", sender.description, errorDict);
}

- (void)netServiceDidPublish:(NSNetService*)sender {
    self.published = YES;
    [self.publishButton setEnabled:YES];
    [self.publishButton setTitle:@"Depublish" forState:UIControlStateNormal];
    NSLog(@"service: %@ published", sender.description);
}

- (void)resetPublishStatus {
    self.published = NO;
    [self.publishButton setEnabled:YES];
    [self.publishButton setTitle:@"Publish" forState:UIControlStateNormal];
}

@end

Ok,程式寫完,該開始玩玩看了,在iOS及Android各自打開程式,讓兩個裝置在同個AP中取得IP,按下Publish和Discover按鈕,稍微等個幾秒,雙方的畫面上應該都會出現自己發佈的服務和彼此發出的服務,這時若在某一方按下Depublish按鈕,彼此的畫面上應該會突然少了一個服務,有趣吧。

需要IP的這個前提是否是真的呢?是真的,實驗剛開始時,我借用的Android裝置並沒有被我加到家裡AP的白名單中,程式寫完試了半天,沒有得到任何錯誤,但就是找不到iOS裝置上的服務,當時還懷疑Network Service Discovery API是不是和Bonjour協定不相容,差一點想換JmDNS試試,後來突然想到Android一直沒拿到IP,將裝置加到AP的白名單後順利拿到IP,同時也在畫面上看到iOS提供的服務了。

最後,完整的程式碼,包含iOS版及Android版都放在GitHub上,對程式有任何疑問都可以讓我知道。

 
over 4 years ago

近幾年電腦的運算效能已經好到一個程式語言的抽象程度遠比執行效率重要的層級,所以最近超多種程式語言冒出來(有種好累的感覺),特別是Domain Specific Language,不過今天討論的還是Genernal Purpose Language,只是在語法的抽象程度都過去的語言要好。

WWDC 2014後就開始看《The Swift Programming Language》,看到optionsals時,總覺得這好像看過耶,原來是在當初研究Java的Stream API時,就看過Java 8內建相同的概念,但概念相同,實作卻不全然一樣,Java的Optional偏API level的支援,Swift則是從language level做支援。但如果真要說誰抄誰就很難說了(ScalaOption或是Groovy的safe navigation operator都是相似的的設計),畢竟最近的幾個新程式語言都從functional programming language借了很多特色,幾乎都支援Lambda就是一個明顯的例子。

不管是Scala、Java或是Swift,概念上,Optioanl是一個容器,裡面可能有值也可能沒有值,但是這個容器本身絕對不是null (Java)或nil (Swift),因此在操作這個容器時,是絕對不會拋出NullPointerException,但如果無視裡面是否有值就硬要取值還是會拋出NoSuchElementException (Java)或Runtime error (Swift)。但是多了一層Optional是否真的能提高抽象程度呢?畢竟『是否有值』這件事還是得判斷,難道用了Optional就可以省去什麼麻煩嗎?首先看使用Optional後,使用上的差異吧!

Code List 1 - The code to check for null
public void greet(String name) {
    if(name != null) {
        System.out.println("Hi, " + name);
    }
}
Code List 2 - The code with Optional.ifPresent
public void greet(Optional<String> name) {
    name.ifPresent(s -> System.out.println(s));
}
Code List 3 - The evaluation of optional object
func greet(name possibleName: String?) {
    if possibleName {
        println("Hi, \(possibleName)")
    }
}
Code List 4 - The Swift optional binding
func greetWithOptionalBinding(name possibleName: String?) {
    if let name = possibleName {
        println("Hi, \(name)")
    }
}

以Java來說,Code List 2搭配新的Lambda expression確實看起來賞心悅目許多。接著看Code List 3中Swift的例子,Swift的if和Java一樣,只接受能產生boolean為結果的述句(expression),所以if possibleName這個判斷式在解讀上和C/C++不同(C/C++的if是判斷述句的結果是否為非0),但Swift是對possibleName這個optional物件先進行evaluaton,若結果為true,表示該物件是有值的,才執行大括弧裡的程式片段。除此之外,Swift還有一種optional binding機制,即Code List 4的if let name = possibleName,這一行的解讀為:先對possibleName物件進行evaluation,若結果為true,則將possibleName所代表的物件指派給name,因此在大括弧中name物件保證是非nil可以安全存取的。

就上述的例子,是否有覺得抽象程度提高呢?或許再看二個例子吧:Code List 5及Code List 6,假設在使用某個沒有API文件的函式庫時,有optioanl和沒optional哪個版本能比較清楚知道解碼這個函式可能回傳一個不存在的物件呢?我想這應該很明顯,有optional的版本應該清楚很多,所以對我來說optional的引入,第一個好處是在做API設計時,可以提供一個很明確的回傳值定義,而不是透過文件的方式解釋回傳值可能不存在的情況。

Code List 5 - The optional result in Java
public String decode(String encodedContent) {
}

public Optional<String> decode(String encodedContent) {
}
Code List 6 - The optional result in Swift
func decode(encodedContent: String) -> String {
}

func decode(encodedContent: String) -> String? {
}

剛才有提到Swift對optional是language level的支援,除了if會自動對optional物件進行evaluation和提供optional binding外,和Groovy一樣提供optional chaining。假設Person物件有個可能不存在的residence屬性,代表其居住地,型別為ResidenceResidenceAddress紀錄地址,同樣可能不存在,Address有個street屬性紀錄街名,同樣可能不存在,所以想透過person物件存取居住地的地址街名時,除了用optional binding一層層解開外,Swift提供optional chaining:person.residence?.address?.street,只要在這串存取中任何一個屬性是不存在的,if就會得到false的結果,也就不會執行指定的區塊,程式看起來較清爽簡潔許多。

Code List 7 - The optional chaining example
class Address {
    var street: String?
}

class Residence {
    var address: Address?
}

class Person {
    var residence: Residence?
}

/* access the street name without the optional chaining */
if let residence = person.residence {
    if let address = residence.address {
        if let street = address.street {
            println(person.fullName + " live in " + street)
        }
    }
}

/* access the street name with the optional chaining */
if let street = person.residence?.address?.street {
    println(person.fullName + " live in " + street)
}

那Java如何呢?Java對Optional的支援大多是以API的形式存在,以剛剛的例子,若不想檢查null,如Code List 8所示,需要搭配Stream API來使用,map(Function)透過傳入的Function物件將Optional<Person>依序換轉(想像成取得property)成Optional<Residence>Optional<Address>Optional<String>,最後用ifPresent(Consumer)印出結果,就簡潔度來說optional binding確實簡潔多了。就抽象程度來說,用Stream API還真的需要一點想像力才能寫出Code List 8的程式碼,所以對我來說optional chaining的抽象度還是比較高一點。

Code List 8 - Using Stream API to retrive the property
Optional<Person> person = getPerson();
person.map(Person::getResidence)
    .map(Residence::getAddress)
    .map(Address::getStreet)
    .ifPresent(street -> System.out.println(person.get().getFullName() + " live in " + street));

整體來說,不論是Swift或是Java,使用Optional來設計API (注意,如果Code List 8中getter的回傳值是Optional,那要用flatMap(Function)取代map(Function),不然會拿到類似Optional<Optional<Residence>>的結果)應該都能提供更清楚的語義:該值可能不存在。只是Swift以language level支援Optional確實比用API level支援的Java要簡潔和更具可讀性。不過,Java是一個歷史悠久(1995至今)的語言,很難對language本身做出太大幅度的改變,反之,Swift是一個全新的語言,從一開始的設計就將許多好的語言特性加入,確實讓人驚豔。

延伸閱讀
補救 null 的策略

 
over 4 years ago

在《關於Android App Architecture》文章提到利用MavenGradle來管理模組之間的關係,雖然我不太喜歡XML瑣碎的設定,不過先前曾實驗過用Maven管理單純的Android project搭配Pure Java模組,但是沒試過和Native模組搭配的組合,所以週末下午試了一下其實還蠻有意思的(花了些時間才發現有地方設定錯了),所以留個筆記,避免自己以後再犯這樣的錯誤。

要使用Maven管理有Native模組的Android專案,需安裝下列工具及SDK:

  1. Java SE 6 or later
  2. Eclipse
  3. Android SDK (包含Extras的部份)
  4. Android NDK
  5. Maven 3 (3.1.1 or later)

接著用Eclipse的安裝管理員安裝下列Plugins:

  1. C/C++ Development Tools
  2. ADT, Android Development Tools (Developers Tool和NDK Plugin都裝)
  3. Android for Maven Eclipse Connector (會一併安裝Maven Android Plugin)

然後如Figure 1及Figure 2將Android SDK及Android NDK的安裝路徑加到Eclipse的ADT設定頁中對應的地方。另外將路徑設定到PATH、ANDROID_HOME和ANDROID_NDK_HOME等環境變數。原則上開發時,Maven會根據POM檔將必要的JAR下載並放到到Local repository中,但如果想在Local repository取得大多數SDK的JAR檔,可以使用Maven Android SDK Deployer

Figure 1 - Setup Android SDK in Eclipse

Figure 2 - Setup Android NDK in Eclipse

先前的架構有提到Model、Android等區塊,所以會建立一個fun的POM專案,包含一個fun-core模組代表Model區塊,一個fun-android代表Android區塊,另外還有一個fun-android-jni模組代表Native的部份(例如Cocos2dx之類的程式),然後利用dependency的方式讓fun-android使用fun-android-jni模組,然後讓fun-android-jni實作fun-core裡的介面。結構大致上是這樣,開始動工,首先建立一個POM專案,如Figure 3在Eclipse中建立Maven專案,在對話框中按下一步,會有一個archetype的選擇頁面,在filter欄位中輸入pom,然後下方應該會出現一個pom-root的項目,選取該項目。然後在下一步設定Group ID (通常是公司網域名稱,本例中是tw.fun)及Artifact ID (產品ID,本例中是fun)。

Figure 3 - Create Maven Project in Eclipse

Figure 4 - Use the pom-root archetype

設定好後按下Finish後,Eclipse會建立一個只有一個pom.xml的專案,開啟pom.xml檔,將內容置換成Code List 1所列的內容。主要設定Android平台的版本(platform.version),共用的套件(android)和Maven plugin的設定。接下來建立的模組會共用這份設定。

Code List 1 - The updated content of the pom.xml in fun project
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <groupId>tw.fun</groupId>
    <artifactId>fun</artifactId>
    <version>1.0.0</version>
    <packaging>pom</packaging>
    <name>fun</name>
    <properties>
        <platform.version>4.1.1.4</platform.version>
        <android.plugin.version>3.6.0</android.plugin.version>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    </properties>
    <dependencies>
        <dependency>
            <groupId>com.google.android</groupId>
            <artifactId>android</artifactId>
            <version>${platform.version}</version>
            <scope>provided</scope>
        </dependency>
    </dependencies>
    <build>
        <pluginManagement>
            <plugins>
                <plugin>
                    <groupId>com.jayway.maven.plugins.android.generation2</groupId>
                    <artifactId>android-maven-plugin</artifactId>
                    <version>${android.plugin.version}</version>
                    <configuration>
                        <sdk>
                            <platform>18</platform>
                        </sdk>
                    </configuration>
                </plugin>
                <plugin>
                    <artifactId>maven-compiler-plugin</artifactId>
                    <version>3.1</version>
                    <configuration>
                        <source>1.6</source>
                        <target>1.6</target>
                    </configuration>
                </plugin>
            </plugins>
        </pluginManagement>
    </build>
</project>

POM檔建立完成後,對剛剛建立的fun專案按右鍵,於選單中選擇Run As -> Maven install,將POM檔安裝到Local repository中,Local repository是一個共用的函式庫池,每個函式庫都會有一份POM檔,讓Maven分析跟載入有關係的函式庫,所以這一步要先做,讓後面建立的模組能找到歸屬的群組。

接著建立fun-core模組,在Eclipse的專案列表中,對剛剛建立的fun專案按右鍵,於選單中選擇Maven -> New Maven Module Project,在對話中模組名稱(Module Name)輸入fun-core,然後勾選Create a simple project (skip archetype selection),按下一步確認內容沒錯後按Finish,此時Eclipse會建立一個標準的Maven Java專案fun-core,而原本的fun專案則多了一個fun-core資料夾。一個標準的Maven Java專案會用src/main/java放production code及src/test/java放測試程式碼,所以在src/main/java中先建立一個tw.fun.core的package,接著建立一個內容如Code List 2的NativeService介面,這介面定義一個將字串反轉的函式。

Code List 2 - Create NativeService interface
package tw.fun.core;

public interface NativeService {

    public String reverse(String string);
}

同樣,對fun-core專案執行Run As -> Maven install,如果注意console介面會發現,除了安裝POM檔外,Maven會變編譯程式、跑測試,然後將編譯好的JAR檔也安裝到Local repository。基本上,已經沒什麼要對fun-core修改了,接著建立一個fun-android-jni的模組,此時選擇artifactId為android-library-quickstart的項目,在下一頁中可以設定package的名稱(為了和fun-core的規則一致,將package名稱改成tw.fun.android.jni)和SDK的API Level (預設是16),若不修改API Level可以按Finish,如此Eclipse會建立一個名為fun-android-jni的Android Library Project,接著修改fun-android-jni專案中的pom.xml如Code List 3,因為要新增一個dependency:fun-core,此外,在建置的plugin部分也做了些許修改,例如加入<goal>ndk-build</goal>這個建置目標。

Code List 3 - The updated content of the pom.xml in fun-android-jni project
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <artifactId>fun</artifactId>
        <groupId>tw.fun</groupId>
        <version>1.0.0</version>
    </parent>
    <artifactId>fun-android-jni</artifactId>
    <packaging>apklib</packaging>
    <name>fun-android-jni</name>
    <dependencies>
        <dependency>
            <groupId>tw.fun</groupId>
            <artifactId>fun-core</artifactId>
            <version>1.0.0</version>
        </dependency>
    </dependencies>
    <build>
        <plugins>
            <plugin>
                <artifactId>maven-clean-plugin</artifactId>
                <configuration>
                    <filesets>
                        <fileset>
                            <directory>libs</directory>
                        </fileset>
                        <fileset>
                            <directory>obj</directory>
                        </fileset>
                    </filesets>
                </configuration>
            </plugin>
            <plugin>
                <groupId>com.jayway.maven.plugins.android.generation2</groupId>
                <artifactId>android-maven-plugin</artifactId>
                <goals>
                    <goal>ndk-build</goal>
                </goals>
                <configuration>
                    <deleteConflictingFiles>true</deleteConflictingFiles>
                    <attachNativeArtifacts>true</attachNativeArtifacts>
                    <clearNativeArtifacts>false</clearNativeArtifacts>
                    <sign>
                        <debug>false</debug>
                    </sign>
                    <proguard>
                        <skip>true</skip>
                    </proguard>
                </configuration>
                <extensions>true</extensions>
            </plugin>
        </plugins>
    </build>
</project>

有時候Eclipse無法立即更新dependency,這時可以對fun專案(root專案)按右鍵,在選單中選取Maven -> Update Project,選取剛建立的模組後按Ok。將tw.fun.android.jni package中預設建立的Library給刪除,由於剛加入dependency的關係,fun-android-jni已經能看到NativeService的介面,於是建立一個實作NativeService介面的NativeServiceImpl的class,並將內容改成如Code List 4所示。注意,新增的reverseImpl(String)函式並沒有實作,並且前方多了一個native修飾字,這是表示此函式是一個JNI函式,也就是Android NDK用來建立Java程式和C/C++程式之間的窗口,所以reverse(String)呼叫reverseImpl(String)函式等於呼叫C/C++程式。

Code List 4 - The NativeServiceImpl implementation
package tw.fun.android.jni;

import tw.fun.core.NativeService;

public class NativeServiceImpl implements NativeService {

    public native String reverseImpl(String string);

    static {
        System.loadLibrary("fun-android-jni");
    }

    @Override
    public String reverse(String string) {
        return reverseImpl(string);
    }
}

但C/C++程式呢?接著對fun-android-jni專案按右鍵,於選單中選擇Android Tools -> Add Native Support,在跳出來的對話框中,會問native library的名稱,這裡的名稱必須和Code List 4中System.loadLibrary()傳入的名稱相同,按下Finish後,專案會多出一個jni的資料夾,和一個fun-android-jni.cpp檔,然後打開console於專案路徑下輸入指令,用javah產生對應的header檔,若沒有任何錯誤訊息及表示成功了,回到Eclipse對jni資料夾按F5更新外部資源,此時應該會看到多一個名稱怪異的.h檔,這個檔案不需要修改(每次執行javah都會重新產生一份新的)。

Code List 5 - Generate header for JNI methods
javah -d jni -classpath bin/classes tw.fun.android.jni.NativeServiceImpl 

需要修改的是剛剛產生的fun-android-jni.cpp,如Code List 6所示,將產生的.h檔引入,接著實作裡面定義的函式。

Code List 6 - The implementation of fun-android-jni.cpp
#include <jni.h>
#include <stdio.h>
#include <string.h>

#include "tw_fun_android_jni_NativeServiceImpl.h"

JNIEXPORT jstring JNICALL Java_tw_fun_android_jni_NativeServiceImpl_reverseImpl(JNIEnv* jenv, jobject obj, jstring str) {
    const char* cs = jenv->GetStringUTFChars (str, NULL);
    char* cstring = new char [strlen(cs) + 1];
    sprintf (cstring, "%s", cs);
    jenv->ReleaseStringUTFChars(str, cs);

    int len = strlen(cstring);
    char* reversed = new char [len+1];
    for(int i = 0; i < len; i++) {
        reversed[i] = cstring[len-i-1];
    }
    reversed[len] = 0;
    return jenv->NewStringUTF(reversed);
}

接著,這裡將fun-android-jni安裝到Local repository的方法和fun-core有點不一樣,一樣是對fun-android-jni按右鍵,然後執行Run As -> Maven build,在開啟對話框的Goals欄位中,輸入下列目標,第一個clean會將工作區清除,第二個 android:ndk-build指令會用NDK的編譯器編譯剛剛寫的C++程式並包裝成apklib,接著第三個install指令會將POM檔案和apklib都安裝到Local repository中,其中,-Dandroid.ndk.path="/Applications/android-ndk"是選用的,如果ANDROID_NDK_HOME有設定,基本上應該不需要,但如果在Eclipse中運作不正常,就需要加上這敘述,並將雙引號中的內容指向Android NDK安裝的路徑。

clean android:ndk-build -Dandroid.ndk.path="/Applications/android-ndk" install

Figure 5 - Customize Maven build goals

有了fun-core和fun-android-jni後,接著新增第三個模組:fun-android,步驟和前面相似,但選擇artifactId為android-quickstart的archetype,同樣記得改platform和package內容,按下Finish後,有了第一個Android專案。同樣,修改pom.xml如Code List 7所示,將fun-android-jni加到dependency中,因為fun-android-jni的dependency中有fun-core,所以fun-core也會被自動推導並加到dependency中。

Code List 7 - The updated content of the pom.xml in fun-android project
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <artifactId>fun</artifactId>
        <groupId>tw.fun</groupId>
        <version>1.0.0</version>
    </parent>
    <artifactId>fun-android</artifactId>
    <packaging>apk</packaging>
    <name>fun-android</name>
    <dependencies>
        <dependency>
            <groupId>tw.fun</groupId>
            <artifactId>fun-android-jni</artifactId>
            <version>1.0.0</version>
            <type>apklib</type>
        </dependency>
    </dependencies>
    <build>
        <finalName>${project.artifactId}</finalName>
        <pluginManagement>
            <plugins>
                <plugin>
                    <groupId>com.jayway.maven.plugins.android.generation2</groupId>
                    <artifactId>android-maven-plugin</artifactId>
                    <version>${android.plugin.version}</version>
                    <extensions>true</extensions>
                </plugin>
            </plugins>
        </pluginManagement>
        <plugins>
            <plugin>
                <groupId>com.jayway.maven.plugins.android.generation2</groupId>
                <artifactId>android-maven-plugin</artifactId>
                <configuration>
                    <sdk>
                        <platform>18</platform>
                    </sdk>
                </configuration>
            </plugin>
        </plugins>
    </build>
</project>

接著修改Maven自動產生的HelloAndroidActivity.java和res/layout/activity_main.xml如Code List 8和Code List 9所示,就快要可以看到成品了,先啟動Android模擬器(Maven Android plugin似乎還無法自動啟動模擬器),接著對fun-android專案按右鍵,執行Run As -> Maven build,在對話框的Goal欄位輸入下列目標,前兩個已經看過,不一樣的是第三個android:deploy,這會將編譯並打包好的apk安裝到啟動的模擬器中,第四個android:run會在模擬器中啟動App。

clean install android:deploy android:run
Code List 8 - The implementation of HelloAndroidActivity
package tw.fun.android;

import android.app.Activity;
import android.os.Bundle;
import android.view.Menu;
import android.view.View;
import android.widget.TextView;

import tw.fun.android.jni.NativeServiceImpl;
import tw.fun.core.NativeService;

public class HelloAndroidActivity extends Activity {

    private TextView _inputTextView;
    private TextView _resultTextView;
    private NativeService _service;

    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        _service = new NativeServiceImpl();
        setContentView(R.layout.activity_main);
        _inputTextView = (TextView)findViewById(R.id.input);
        _resultTextView = (TextView)findViewById(R.id.result);
    }

    @Override
    public boolean onCreateOptionsMenu(Menu menu) {
        getMenuInflater().inflate(tw.fun.android.R.menu.main, menu);
        return true;
    }

    public void reverseButtonClicked(View view) {
        String text = _inputTextView.getText().toString();
        _resultTextView.setText(_service.reverse(text));
    }
}
Code List 9 - The content of activity_main.xml
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:paddingBottom="@dimen/activity_vertical_margin"
    android:paddingLeft="@dimen/activity_horizontal_margin"
    android:paddingRight="@dimen/activity_horizontal_margin"
    android:paddingTop="@dimen/activity_vertical_margin" >
    <TextView
        android:id="@+id/input"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="@string/hello_world" />
    <TextView
        android:id="@+id/result"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_below="@+id/input"
        />
    <Button
        android:id="@+id/reverseButton"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_alignLeft="@+id/input"
        android:layout_below="@+id/result"
        android:layout_marginTop="76dp"
        android:onClick="reverseButtonClicked"
        android:text="@string/reverse" />
</RelativeLayout>

輸入完畢按下Run就會在模擬器中啟動App,如Figure 6所示,整個過程就像是原本用Eclipse開發Android程式並用Run執行一樣。最後,花了這麼多功夫,用Maven的好處是什麼?大概有四點,第一,許多Java常見的第三方函式庫都支援Maven的dependency管理,因此可以透過Maven使用並管理Android專案的第三方函式庫;第二,用Maven管理系統架構裡模組間的關係;第三,若有使用subversion做版本控管,Maven在Eclipse中有subversion的connector,透過connector從check out到重建所有專案只需要幾個步驟,很適合讓新人建立開發環境;最後,有Maven後可以和Jenkins之類的CI做整合,方便進行持續整合和測試。

Figure 6 - The app screenshot

ps. 想試玩的可以下載這個,解開到Eclipse的工作區,用Maven匯入專案。

 
over 4 years ago

Recently, I had to explain the existing design in the project to the programmers new to iOS. I can give some explanation, but, sometimes, I am not 100% sure what I am talking about. For example, in the project, there are many customized UI component for the project's special needs (well, I can't talk about this too much). These components need some attributes from the User Defined Runtime Attributes (e.g. the shadowColor, shadowOffset, and someKey in Figure 1) provided in XCode. For that, I explained why these attributes are required and how these attributes work. However, sometimes the freshmen (including me) forgot to specify the attributes, and the resulting UI displays incorrectly.

To understand more details, I searched some network resources, and I finally saw The Nib Object Life Cycle in the Resource Programming Guide and Key-Value Coding Programming Guide. Then, I found that similar design can be seen in many frameworks (usually this is called good design or pattern). First, Key-Value Coding and JavaBeans are similar designs - to provide a mechanism to access an object's properties by using the property names and don't have to call the getters and setters of the properties directly. Therefore, XCode can user Key-Value mechanism to set the User Defined Runtime Attributes into the customized UI component objects, and the access these values at runtime.

Let's see an example directly! The native UITextField does not provide the properties to specify the text shadow. Thus, the codes in Code List 1 and Code List 2 provide a customized ShadowedTextField UI component. At the runtime, the sequence to load a UI component from a nib file is to call the initWithCoder: method to initialize the object first (although the designated initializer of UIView is initWithFrame:, if the component is loaded from a nib file, initWithCoder: is called, not initWithFrame:). Then, call the setValue:forKey: method to set the User Defined Runtime Attributes into the initialized UI components. Finally, call the awakeFromNib method to notify the component that it has been awaked from the nib file. Therefore, the initWithCoder: method of the ShadowedTextField class sets the default values of shadow color and shadow offset. And then, awakeFromNib method applies the shadow on the text field based on these two properties.

Code List 1 - ShadowedTextField Header
#import <UIKit/UIKit.h>

@interface ShadowedTextField : UITextField

@property (nonatomic) CGSize shadowOffset;
@property (nonatomic) UIColor* shadowColor;

@end
Code List 2 - ShadowedTextField Implementation
#import "ShadowedTextField.h"

@implementation ShadowedTextField

@dynamic shadowColor;
@dynamic shadowOffset;

- (instancetype)initWithCoder:(NSCoder*)aDecoder {
    if(self = [super initWithCoder:aDecoder]) {
        [self initializeDefaultValues];
    }
    return self;
}

- (instancetype)initWithFrame:(CGRect)frame {
    if(self = [super initWithFrame:frame]) {
        [self initializeDefaultValues];
    }
    return self;
}

- (void)initializeDefaultValues {
    self.shadowColor = [UIColor grayColor];
    self.shadowOffset = CGSizeMake(1.0f, 1.0f);
}

- (void)awakeFromNib {
    [super awakeFromNib];
    [self applyShadow];
}

- (void)setValue:(id)value forUndefinedKey:(NSString*)key {
    NSLog(@"undefined key-value: %@-%@", key, value);
}

- (void)applyShadow {
    NSShadow* shadow = [[NSShadow alloc] init];
    shadow.shadowColor = self.shadowColor;
    shadow.shadowOffset = self.shadowOffset;
    id attributes = @{
        NSShadowAttributeName: shadow,
        NSFontAttributeName: self.font
    };
    self.attributedText = [[NSAttributedString alloc] initWithString:self.text attributes:attributes];
}
@end

To use the customized component, in the Interface Builder, drag and drop a UITextField into the view. Specify the Class of the component to ShadowedTextField, and then add shadowColor and shadowOffset two attributes as shown in Figure 1. Run the app, and the result will be like Figure 2 - the text has a purple shadow (in Code List 2, the initializeDefaultValues set the default value of shadowColor to gray, but the value has benn updated by the User Defined Runtime Attributes). Although these two properties are not like the native properties that has specific editors to edit their values, the customized properties still can be edit in Interface Builder.

Figure 1 - Specify the user defined runtime attributes

Figure 2 - The actual result

Okay, there is a problem. Before the runtime calling the setValue:forKey: method, it will check whether the object has the property or not. For example, while calling [person setValue:@1982 forKey:@"birthYear"];, the runtime will check whether person object has the birthYear property. If there is no corresponding property, the setValue: forUndefinedKey: method is called to handle the special case. The default implementation in NSObject is to throw a NSUndefinedKeyException exception that terminates the app. Therefore, to customize a UI component, it is recommended to override setValue: forUndefinedKey: as Code List 2. As a result, when running tha app, something like 2014-06-07 22:30:00.359 CustomAttributes[13452:60b] set undefined key-value: someKey-23 will be shown in Console.

This seems all problems are solved, but there are still some interesting phenomena found when I wrote the example. First, change the accessors generation method of the shadowColor and shadowOffset properties from @dynamic to @synthesize. If no default text is set in the nib file, the text input at the runtime will not have shadow, but change the accessor generation method back to @dynamic, the input txt will have shadow. Second, even that the accessor generation method is @dynamic, if nothing is keyed before dismissing the keyboard on the first time editing, any text keyin on the second time editing (without changing view) will not have shadow, neither. However, if a default text is set in the nib file and all the text is deleted on the first time editing, any text keyin on the second on the second time editing still have shadow. I still do not know the reason for the phenomena. If anyone knows why, please let me know.

 
over 4 years ago

最近帶iOS新人常要回答一些既有設計上的問題,有時候雖能解釋,但也不見得能百分之百確定自己說的東西,像是專案中有許多客製化的UI元件完成某些特殊的需求(嘿~這應該不能說吧),這些元件依賴某些屬性是需要透過XCode的User Defined Runtime Attributes (例如Figure 1右下角的shadowColor、shadowOffset和someKey),解釋時,會解釋為何要設定這些屬性以及如何運作,但有時新人忘記設定(自己有時也會忘記),畫面上的元件就不太正確。

最近趁機找了一些網路文章,最後看了一下Resource Programming Guide中關於The Nib Object Life Cycle的敘述及Key-Value Coding Programming Guide後發現:可以從很多framework找到相似的設計(通常這也被稱為是好的設計或pattern),首先,Key-Value Coding和JavaBeans是相似的設計,讓外部不需實際呼叫getter或setter而是直接用屬性的名稱修改或讀取屬性的值。因此在XCode中設定的User Defined Runtime Attributes就是在載入nib後透過Key-Value機制將值設定給物件的某個屬性,然後就可以在程式中使用這些值了。

直接看例子吧!原有UITextField沒有屬性能設定文字的陰影,所以Code List 1和Code List 2提供了一個客製化的ShadowedTextField。UI元件從nib檔載入的順序是先呼叫initWithCoder:初始化物件(雖然UIView的designated initializer是initWithFrame:,但若是從nib載入的話,並不會呼叫initWithFrame:而是呼叫initWithCoder:),接著透過setValue:forKey:將User Defined Runtime Attributes設定給初始化過的UI元件,然後呼叫awakeFromNib告知客製化的元件說已經從nib檔中被喚醒了。因此,ShadowedTextFieldinitWithCoder:中設定陰影顏色和偏移量的初始值,然後在awakeFromNib套用陰影。

Code List 1 - ShadowedTextField Header
#import <UIKit/UIKit.h>

@interface ShadowedTextField : UITextField

@property (nonatomic) CGSize shadowOffset;
@property (nonatomic) UIColor* shadowColor;

@end
Code List 2 - ShadowedTextField Implementation
#import "ShadowedTextField.h"

@implementation ShadowedTextField

@dynamic shadowColor;
@dynamic shadowOffset;

- (instancetype)initWithCoder:(NSCoder*)aDecoder {
    if(self = [super initWithCoder:aDecoder]) {
        [self initializeDefaultValues];
    }
    return self;
}

- (instancetype)initWithFrame:(CGRect)frame {
    if(self = [super initWithFrame:frame]) {
        [self initializeDefaultValues];
    }
    return self;
}

- (void)initializeDefaultValues {
    self.shadowColor = [UIColor grayColor];
    self.shadowOffset = CGSizeMake(1.0f, 1.0f);
}

- (void)awakeFromNib {
    [super awakeFromNib];
    [self applyShadow];
}

- (void)setValue:(id)value forUndefinedKey:(NSString*)key {
    NSLog(@"undefined key-value: %@-%@", key, value);
}

- (void)applyShadow {
    NSShadow* shadow = [[NSShadow alloc] init];
    shadow.shadowColor = self.shadowColor;
    shadow.shadowOffset = self.shadowOffset;
    id attributes = @{
        NSShadowAttributeName: shadow,
        NSFontAttributeName: self.font
    };
    self.attributedText = [[NSAttributedString alloc] initWithString:self.text attributes:attributes];
}
@end

使用時,在Interface Builder中拉進一個UITextField,接著將該元件的Class設為客製化的ShadowedTextField,然後如Figure 1新增shadowColorshadowOffset兩筆屬性,執行後就可以看到Figure 2的樣子,陰影顏色被改成紫色(Code List 2的initializeDefaultValues將預設的shadowColor設為灰色,但執行後會被User Defined Runtime Attribues的值取代)。雖然說這兩個屬性不像原生的屬性那樣有相對應的編輯器可以編輯屬性值,但還算是能在Interface Builder中設定了。

Figure 1 - Specify the user defined runtime attributes

Figure 2 - The actual result

問題來了,執行環境(runtime)在呼叫setValue:forKey:時會尋找該物件是否有相對應的屬性,例如:在執行 [person setValue:@1982 forKey:@"birthYear"];時會檢查person物件是否有birthYear屬性,若無對應屬性會呼叫setValue: forUndefinedKey:讓該物件處理這特殊情況,NSObject預設的實作是拋出NSUndefinedKeyException例外讓程式終止,所以,若要客製化UI元件時,可以像Code List 2覆寫setValue: forUndefinedKey:處理這狀況,所以Console畫面上應該會印出類似2014-06-07 22:30:00.359 CustomAttributes[13452:60b] set undefined key-value: someKey-23的字樣。

看似問題都解決了,其實還是有些小問題,寫例子時發現幾個有趣現象。大家可以試試看,第一,將shadowColorshadowOffset兩屬性的accessors生成方法從@dynamic改成@synthesize,如果nib中沒有給text field的文字初始值,執行時輸入的文字其實並不會有陰影,但改回@dynamic就會有。第二,在accessors生成方法是@dynamic的情況下,若第一次點擊空白的text field後卻不輸入任何文字就收回鍵盤,第二次再點擊該text field後輸入的文字也不會有陰影,但如果有輸入文字,即使再次編輯將全部文字刪除,之後輸入的文字還是有陰影。上述現象我暫時還沒找到答案,知道答案的麻煩跟我說一聲XD

 
over 4 years ago

Until become a iOS team member, I spent most of my time to design and prepare the infrastructure for the Android version of the same App. The recent App is almost an Internetl App. Considering the UI responsive time, UX (user experience), and network bandwidth consumption, the data ususally is saved in the mobile device using some kind of form, e.g., SQLite or file). As a result, the App should synchronize the data between mobile devices and remote servers on need. In addition, the mobile network is not as stabile as the PC network (I'm not sure that 4G will be better). Therefore, a lots of issues should be considered. Since the Android App development is suspended for some reason (I become a iOS team member), the design and the thought just in my mind, and I don't have chance to write them down.

Recently, the Android App development is prepared to continue. Thus, I used some free time to write down the thought. I persist in the separation of Model and View, so the conceptual architecture diagram is like Figure 1 - the diagram is very generic, and could be used in most Internet Apps。The architecture is primary divied into few colored parts. The green part is Model. Business logics are all located here. In principle this part will be platform-independent - this part only uses the APIs common in Java SE and Android SDK. Therefore, to test this part, the Android simulator is not required and can use JUnit to achieve the maximum test coverage in least time.

For the developers that only consider the data structure as model, the business logics may locate at View or Controller (the Controller here is the Controller of MVC). For me, the data structure is only a part of Model, i.e., the Domain Data Model block in the figure. How to maintain the relationship between data objects or how to response the system events defined in the use case or user story? For me, these logics to handle system events in main controller all belong Model, i.e., the Business Logic Managers block in the figure. The responsibility is to communicate with server through Web Service Interfaces, maintain the relationship between data objects based on the server's responses, and then persist the status (Persistence) in the mobile device through DAO Interfaces.

Figure 1 - Android App Conceptual Architecture

The orange part is the platform-dependent implementation. For example, use Android SQLite API to implement DAO, and then inject the platform-dependent implementation into Model by Setter Injection or Interface Injection. To achieve this, the green part can not direct depend on the platform-dependent implementation. Therefore, both the Web Service Interfaces and the DAO Interfaces blocks only define the interfaces without implementation. The implementation is provided by the Web Service Implementation and the DAO Implementation block respectively in the orange part. This results in Dependency inversion. Thus, it is easy to inject mock objects of Web Service Interfaces and DAO Interfaces for testing.

Another role of the orange part is to play the Android Service. Android allows Service running in background. For the App prepared to develop, a service can be run in background is very useful. When an Activity becomes active, it is easy to obtain the latest status by binding the running service.

DAO Implementation is platform-dependent, so color it as orange is fine, but Web Service Implementation is colored as violet. This is because that it is possible to implement the Restful Web Services which exchanges JSON data only with the APIs common in Java SE and Android SDK and the JSON library also available in Android. That also means a platform-independent Web Service Implementation is possible, and can be tested with JUnit.

The gray part is View. The primary block is Android Activities & UI Flow Controls. To test this block, an Android simulator or a physical device is required, and is time-consuming. However, this block determines whether App is user-friendly or not. Besides, the API in the green part and orange part will be synchronous. In order not to block UI, Android SDK provides some asynchronous helper class (e.g., AsyncTask), but I feel that is not good enough. Therefore, Asynchronous Supports & UI Components will provides some customized helper classes for the App special needs. In addition, since Android does not allow the program running in non UI Thread to update the UI, the Asynchronous Supports & UI Components block is also responsible for transforming the UI update request from non UI thread to UI Thread. This block is colored as blue, and should be designed to cross projects as the assets of the company in the long-term.

This is a general design, but the App prepared to develop will integrate a third-party game engine - the architecture may have some modification, e.g., more interfaces in the Domain Model or Android Service to communicate with the game engine, using IoC to protect Domain Model, etc. Finally, in order not to affect objects in different parts, the maven (or gradle) module mechanism is used to group objects in different modules and control their visibility by the dependency between modules. I hope the Android app can be developed smoothly under a clear requirement (iOS version is complete).