抖音iOS最複雜功能的重構之路--播放器交互區重構實踐

{"type":"doc","content":[{"type":"heading","attrs":{"align":null,"level":2},"content":[{"type":"text","text":"背景介紹"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"本文以抖音中最爲複雜的功能,也是最重要的功能之一的交互區爲例,和大家分享一下此次重構過程中的思考和方法,主要側重在架構、結構方面。"}]},{"type":"heading","attrs":{"align":null,"level":2},"content":[{"type":"text","text":"交互區簡介"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"交互區是指播放頁面中可以操作的區域,簡單理解就是除視頻播放器外附着的功能,如下圖紅色區域中的作者名稱、描述文案、頭像、點贊、評論、分享按鈕、蒙層、彈出面板等等,幾乎是用戶看到、用到最多的功能,也是最主要的流量入口。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"image","attrs":{"src":"https:\/\/static001.geekbang.org\/infoq\/20\/20f1783e0f1cd5995017b09f2f596c47.png","alt":"圖片","title":null,"style":[{"key":"width","value":"75%"},{"key":"bordertype","value":"none"}],"href":null,"fromPaste":true,"pastePass":true}},{"type":"heading","attrs":{"align":null,"level":2},"content":[{"type":"text","text":"發現問題"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","marks":[{"type":"strong"}],"text":"不要急於改代碼,先梳理清楚功能、問題、代碼,建立全局觀,找到問題根本原因。"}]},{"type":"heading","attrs":{"align":null,"level":3},"content":[{"type":"text","text":"現狀"}]},{"type":"image","attrs":{"src":"https:\/\/static001.geekbang.org\/infoq\/3f\/3fa49ab22864f750a77cca60feffd261.png","alt":"圖片","title":null,"style":[{"key":"width","value":"75%"},{"key":"bordertype","value":"none"}],"href":null,"fromPaste":true,"pastePass":true}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"上圖是代碼量排行,排在首位的就是交互區的 ViewController,遙遙領先其他類,數據來源自研的代碼量化系統,這是一個輔助業務發現架構、設計、代碼問題的工具。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"可進一步查看版本變化:"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"image","attrs":{"src":"https:\/\/static001.geekbang.org\/infoq\/a7\/a79070708bfc10bf8fafd5bcbb4cd4b5.png","alt":"圖片","title":null,"style":[{"key":"width","value":"75%"},{"key":"bordertype","value":"none"}],"href":null,"fromPaste":true,"pastePass":true}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"每週 1 版,在不到 1 年的時間,代碼量翻倍,個別版本代碼量減少,是局部在做優化,大趨勢仍是快速增長。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"除此之外:"}]},{"type":"bulletedlist","content":[{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","marks":[{"type":"strong"}],"text":"可讀性差"},{"type":"text","text":":ViewController 代碼量 1.8+萬行,是抖音中最大的類,超過第 2 大的類一倍有餘,另外交互區使用了 VIPER 結構(iOS 常用的結構:MVC、MVVM、MVP、VIPER),加上 IPER 另外 4 層,總代碼規模超過了 3 萬行,這樣規模的代碼,很難記清某個功能在哪,某個業務邏輯是什麼樣的,爲了修改一處,需要讀懂全部代碼,非常不友好"}]}]},{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","marks":[{"type":"strong"}],"text":"擴展性差"},{"type":"text","text":":新增、修改每個功能需要改動 VIPER 結構中的 5 個類,明明業務邏輯獨立的功能,卻需要大量耦合已有功能,修改已有代碼,甚至引起連鎖問題,修一個問題,結果又出了一個新問題"}]}]},{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","marks":[{"type":"strong"}],"text":"維護人員多"},{"type":"text","text":":統計 commit 歷史,每個月都有數個業務線、數十人提交代碼,改動時相互的影響、衝突不斷"}]}]}]},{"type":"heading","attrs":{"align":null,"level":3},"content":[{"type":"text","text":"理清業務"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"作者是抖音基礎技術組,負責業務架構工作,交互區業務完全不瞭解,需要重新梳理。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"事實上已經沒有一個人瞭解所有業務,包括產品經理,也沒有一個完整的需求文檔查閱,需要根據代碼、功能頁面、操作來梳理清楚業務邏輯,不確定的再找相關開發、產品同學,省略中間過程,總計梳理了 10+個業務線,100+子功能,梳理這些功能的目的是:"}]},{"type":"bulletedlist","content":[{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"按重要性分清主次,核心功能優先保障,分配更多的時間開發、測試"}]}]},{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"子功能之間的佈局、交互是有一定的規律的,這些規律可以指導重構的設計"}]}]},{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"判斷產品演化趨勢,設計既要滿足當下、也要有一定的前瞻性"}]}]},{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"自測時需要用,避免遺漏"}]}]}]},{"type":"heading","attrs":{"align":null,"level":3},"content":[{"type":"text","text":"理清代碼"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"所有業務功能、問題最終都要落在代碼上,理清代碼才能真正理清問題,解決也從代碼中體現,梳理如下:"}]},{"type":"bulletedlist","content":[{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"代碼量:VC 1.8 萬行、總代碼量超過 3 萬行"}]}]},{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"接口:對外暴露了超過 200 個方法、100 個屬性"}]}]},{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"依賴關係:VIPER 結構使用的不理想,Presenter 中直接依賴了 VC,互相耦合"}]}]},{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"內聚、耦合:一個子功能,代碼散落在各處,並和其他子功能產生過多耦合"}]}]},{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"無用代碼:大量無用的代碼、不知道做什麼的代碼"}]}]},{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"View 層級:所有的子功能 View 都放在 VC 的直接子 View 中,也就是說 VC 有 100+個 subView,實際僅需要顯示 10 個左右的子功能,其他的通過設置了 hidden 隱藏,但是創建並參與佈局,會嚴重消耗性能"}]}]},{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"ABTest(分組對照試驗):有幾十個 ABTest,最長時間可以追溯到數年前,這些 ABTest 在自測、測試都難以全面覆蓋"}]}]}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"簡單概括就是,需要完整的讀完代碼,重點是類之間的依賴關係,可以畫類圖結合着理解。"}]},{"type":"blockquote","content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"每一行代碼都是有原因的,即便感覺沒用,刪一行可能就是一個線上事故。"}]}]},{"type":"heading","attrs":{"align":null,"level":3},"content":[{"type":"text","text":"趨勢"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"抖音產品特性決定,視頻播放頁面佔據絕大部分流量,各業務線都想要播放頁面的導流,隨着業務發展,不斷向多樣性、複雜性演化。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"從播放頁面的形態上看,已經經過多次探索、嘗試,目前的播放頁面模式相對穩定,業務主要以導流形式的入口擴展。"}]},{"type":"heading","attrs":{"align":null,"level":3},"content":[{"type":"text","text":"曾經嘗試過的方式"}]},{"type":"heading","attrs":{"align":null,"level":4},"content":[{"type":"text","text":"ViewController 拆分 Category"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"將 ViewController 拆分爲多個 Category,按 View 構造、佈局、更新、業務線邏輯將代碼拆分到 Category。這個方式可以解決部分問題,但有限,當功能非常複雜時就無法很好的支撐了,主要問題有:"}]},{"type":"bulletedlist","content":[{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"拆分了 ViewController,但是 IPER 層沒有拆分,拆分的不徹底,職責還是相互耦合"}]}]},{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"Category 之間互相訪問需要的屬性、內部方法時,需要暴露在頭文件中,而這些是應該隱藏的"}]}]},{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"無法支持批量調用,如 ViewDidLoad 時機,需要各個 Category 方法定義不同方法(同名會被覆蓋),逐個調用"}]}]}]},{"type":"heading","attrs":{"align":null,"level":4},"content":[{"type":"text","text":"左側和底部的子功能放在一個 UIStackView 中"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"這個思路方向大體正確了,但是在嘗試大半年後失敗,刪掉了代碼。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"正確的點在於:抽象了子功能之間的關係,利用 UIStackView 做佈局。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"失敗的點在於:"}]},{"type":"bulletedlist","content":[{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"局部重構:僅僅是局部重構,沒有深入的分析整體功能、邏輯,沒有徹底解決問題,Masonry 佈局代碼和 UIStackView 使用方式都放在 ViewController 中,不同功能的 view 很容易耦合,劣化依然存在,很快又然難以維護,這類似破窗效應"}]}]},{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"實施方案不完善:佈局需要實現 2 套代碼,開發、測試同學非常容易忽略,線上經常出問題"}]}]},{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"UIStackView crash:概率性 crash,崩在系統庫中,大半年時間也沒有找到原因"}]}]}]},{"type":"heading","attrs":{"align":null,"level":4},"content":[{"type":"text","text":"其他"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"還有一些提出 MVP、MVVM 等結構的方案,有的淺嘗輒止、有的通過不了技術評審、有的不了了之。"}]},{"type":"heading","attrs":{"align":null,"level":3},"content":[{"type":"text","text":"關鍵問題"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"上面僅列舉部分問題,如果按人頭收集,那將數不勝數,但這些基本都是表象問題,找到問題的本質、原因,解決關鍵問題,才能徹底解決問題,很多表象問題也會被順帶解決。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"經常提到的內聚、耦合、封裝、分層等等思想感覺很好,用時卻又沒有真正解決問題,下面擴展兩點,輔助分析、解決問題:"}]},{"type":"bulletedlist","content":[{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"複雜度"}]}]},{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"“變量”與“常量”"}]}]}]},{"type":"heading","attrs":{"align":null,"level":4},"content":[{"type":"text","text":"複雜度"}]},{"type":"blockquote","content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"複雜功能難以維護的原因的是因爲複雜。"}]}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"是的,很直接,相對的,設計、重構等手法都是讓事情變得簡單,但變簡單的過程並不簡單,從 2 個角度切入來拆解:"}]},{"type":"bulletedlist","content":[{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"量"}]}]},{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"關係"}]}]}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","marks":[{"type":"strong"}],"text":"量"},{"type":"text","text":":量是顯性的,功能不斷增加,相應的需要更多人來開發、維護,需要寫更多代碼,也就越來越難維護,這些是顯而易見的。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","marks":[{"type":"strong"}],"text":"關係"},{"type":"text","text":":關係是隱性的,功能之間產生耦合即爲發生關係,假設 2 個功能之間有依賴,關係數量記爲 1,那 3 者之間關係數量爲 3,4 者之間關係數量爲 6,這是一個指數增加的,當數量足夠大時,複雜度會很誇張,關係並不容易看出來,因此很容易產生讓人意想不到的變化。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"功能的數量大體可以認爲是隨產品人數線性增長的,即複雜度也是線性增長,隨着開發人數同步增長是可以繼續維護的。如果關係數量指數級增長,那麼很快就無法維護了。"}]},{"type":"image","attrs":{"src":"https:\/\/static001.geekbang.org\/infoq\/a6\/a6b02700b692582df35477ad482f2396.png","alt":"圖片","title":null,"style":[{"key":"width","value":"75%"},{"key":"bordertype","value":"none"}],"href":null,"fromPaste":true,"pastePass":true}},{"type":"heading","attrs":{"align":null,"level":4},"content":[{"type":"text","text":"“變量”與“常量”"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"“變量”是指相比上幾個版本,哪些代碼變了,與之對應的“常量”即哪些代碼沒變,目的是:"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"blockquote","content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"從過去的變化中找到規律,以適應未來的變化。"}]}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"平常提到的封裝、內聚、解耦等概念,都是靜態的,即某一個時間點合理,不意味着未來也合理,期望改進可以在更長的時間範圍內合理,稱之爲動態,找到代碼中的“變量”與“常量”是比較有效的手段,相應的代碼也有不同的優化趨向:"}]},{"type":"bulletedlist","content":[{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"對於“變量”,需要保證職責內聚、單一,易擴展"}]}]},{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"對於“常量”,需要封裝,減少干擾,對使用者透明"}]}]}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"回到交互區重構場景,發現新加的子功能,基本都加在固定的 3 個區域中,佈局是上下撐開,這裏的變指的就是新加的子功能,不變指的是加的位置和其他子功能的位置關係、邏輯關係,那麼變化的部分,可以提供一個靈活的擴展機制來支持,不變的部分中,業務無關的下沉爲底層框架,業務相關的封裝爲獨立模塊,這樣整體的結構也就出來了。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"“變量”與“常量”同樣可以檢驗重構效果,比如模塊間常常通過抽象出的協議進行通信,如果通信方法都是具體業務的,那每個同學都可能往裏添加各自的方法,這個“變量”就會失去控制,難以維護。"}]},{"type":"heading","attrs":{"align":null,"level":2},"content":[{"type":"text","text":"設計方案"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"梳理問題的過程中,已經在不斷的在思考什麼樣的方式可以解決問題,大致雛形已經有了,這部分更多的是將設計方案系統化。"}]},{"type":"heading","attrs":{"align":null,"level":3},"content":[{"type":"text","text":"思路"}]},{"type":"bulletedlist","content":[{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"通過上述梳理功能發現 UI 設計和產品的規律:"}]}]},{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"整體可分爲 3 個區域,左側、右側、底部,每個子功能都可以歸到 3 個區域中,按需顯示,數據驅動"}]}]},{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"左側區域中的作者名稱、描述、音樂信息是自底向上挨個排列"}]}]},{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"右側主要是按鈕類型,頭像、點贊、評論,排列方式和左側規律相同"}]}]},{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"底部可能有個警告、熱點,只顯示 1 個或者不顯示"}]}]},{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"爲了統一概念,將 3 個區域定義爲容器、容器中放置的子功能定義爲元素,容器邊界和能力可以放寬一些,支持弱類型實例化,這樣就能支持物理隔離元素代碼,形成一個可插拔的機制。"}]}]},{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"元素將 View、佈局、業務邏輯代碼都內聚在一起,元素和交互區、元素和元素之間不直接依賴,職責內聚,便於維護。"}]}]},{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"衆多的接口可以抽象歸類,大體可分爲 UI 生命週期調用、播放器生命週期調用,將業務性的接口抽象,分發到具體的元素中處理邏輯。"}]}]}]},{"type":"heading","attrs":{"align":null,"level":3},"content":[{"type":"text","text":"架構設計"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"下圖是期望達到的最終目標形態,"},{"type":"text","marks":[{"type":"strong"}],"text":"實施過程會分爲多步,確定最終形態,避免實施時偏離目標"},{"type":"text","text":"。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"image","attrs":{"src":"https:\/\/static001.geekbang.org\/infoq\/41\/41ac5c37d0a61a3d1b347699204456e0.png","alt":"圖片","title":null,"style":[{"key":"width","value":"75%"},{"key":"bordertype","value":"none"}],"href":null,"fromPaste":true,"pastePass":true}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"blockquote","content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"整體指導原則:簡單、適用、可演化。"}]}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"bulletedlist","content":[{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"SDK 層:抽象出和業務完全無關的 SDK 層,SDK 負責管理 Element、Element 間通信"}]}]},{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"業務框架層:將通用業務、共性代碼等低頻率修改代碼獨立出來,形成框架層,這層代碼是可由專人維護,業務線同學無法修改"}]}]},{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"業務擴展層:各業務線具體的子功能在此層實現,提供靈活的註冊、插拔能力,Element 間無耦合,代碼影響限定在 Element 內部"}]}]}]},{"type":"heading","attrs":{"align":null,"level":3},"content":[{"type":"text","text":"SDK 層"}]},{"type":"heading","attrs":{"align":null,"level":4},"content":[{"type":"text","text":"Container"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"所有的 Element 都通過 Container 來管理,包括 2 部分:"}]},{"type":"bulletedlist","content":[{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"對 Element 的創建、持有"}]}]},{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"持有了一個 UIStackView,Element 創建的 View 都加入到此 UIStackView 中"}]}]}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"使用 UIStackView 是爲了實現自底向上的流式佈局。"}]},{"type":"heading","attrs":{"align":null,"level":4},"content":[{"type":"text","text":"Element"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"子功能的 UI、邏輯、操作等所有代碼封裝的集合體,定義爲 Element,借鑑了網頁中的 Element 概念,對外的行爲可抽象爲:"}]},{"type":"bulletedlist","content":[{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"View:最終顯示的 View,lazy 的形式構造"}]}]},{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"佈局:自適應撐開,Container 中的 UIStackView 可以支持"}]}]},{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"事件:通用的事件,處理 handler 即可,view 內部也可自行添加事件"}]}]},{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"更新:傳入模型,內部根據模型內容,賦值到 view 中"}]}]}]},{"type":"heading","attrs":{"align":null,"level":4},"content":[{"type":"text","text":"View"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"View 在 BaseElement 中的定義如下:"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"text"},"content":[{"type":"text","text":"@interface BaseElement : NSObject \n\n@property (nonatomic, strong, nullable) UIView *view;\n@property (nonatomic, assign) BOOL appear;\n\n- (void)viewDidLoad;\n\n@end"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"bulletedlist","content":[{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"BaseElement 是抽象基類,公開 view 屬性形式上看 view 屬性、viewDidLoad 方法,和 UIViewController 使用方式的非常類似,設計意圖是想靠向 UIViewController,以便讓大家更快的接受和理解"}]}]},{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"appear 表示 element 是否顯示,appear 爲 YES 時,view 被自動創建,viewDidLoad 方法被調用,相關的子 view、佈局等業務代碼在 viewDidLoad 方法中複寫,和 UIViewController 使用類似"}]}]},{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"appear 和 hidden 的區別在於,hidden 只是視覺看不到了,內存並沒有釋放掉,而低頻次使用的 view 沒必要常駐內存,因此 appear 爲 NO 時,會移除 view 並釋放內存"}]}]}]},{"type":"heading","attrs":{"align":null,"level":4},"content":[{"type":"text","text":"佈局"}]},{"type":"bulletedlist","content":[{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"UIStackView 的 axis 設置了 UILayoutConstraintAxisVertical,佈局時自底向上的流式排列"}]}]},{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"容器內的元素自下向上佈局,最底部的元素參照容器底部約束,依次佈局,容器高度參照最上面的元素位置"}]}]},{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"元素內部自動撐開,可直接設置固定高度,也可以用 autolayout 撐開"}]}]}]},{"type":"heading","attrs":{"align":null,"level":4},"content":[{"type":"text","text":"事件"}]},{"type":"codeblock","attrs":{"lang":"text"},"content":[{"type":"text","text":"@protocol BaseElementProtocol \n@optional\n- (void)tapHandler:(UITapGestureRecognizer *)sender;\n\n@end"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"bulletedlist","content":[{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"實現協議方法,自動添加手勢,支持點擊事件"}]}]},{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"也可以自行添加事件,如按鈕,使用原生的 addTarget 點擊體驗更好"}]}]}]},{"type":"heading","attrs":{"align":null,"level":4},"content":[{"type":"text","text":"更新"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"data 屬性賦值,觸發更新,通過 setter 形式實現。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"text"},"content":[{"type":"text","text":"@property (nonatomic, strong, nullable) id data;"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"賦值時會調用 setData 方法。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"text"},"content":[{"type":"text","text":"- (void)setData:(id)data {\n _data = data;\n [self processAppear:self.appear];\n}"}]},{"type":"heading","attrs":{"align":null,"level":4},"content":[{"type":"text","text":"數據流圖"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"Element 的生命週期、更新時的數據流向示意圖,這裏就不細講了。"}]},{"type":"image","attrs":{"src":"https:\/\/static001.geekbang.org\/infoq\/ca\/caad224b91bf380eb52f9e1205b63385.png","alt":"圖片","title":null,"style":[{"key":"width","value":"75%"},{"key":"bordertype","value":"none"}],"href":null,"fromPaste":true,"pastePass":true}},{"type":"heading","attrs":{"align":null,"level":4},"content":[{"type":"text","text":"動畫特效"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"image","attrs":{"src":"https:\/\/static001.geekbang.org\/infoq\/63\/63b6960a9019737aaa2b39172ac007bc.gif","alt":"圖片","title":null,"style":[{"key":"width","value":"75%"},{"key":"bordertype","value":"none"}],"href":null,"fromPaste":true,"pastePass":true}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"圖中是實際需要支持的業務場景,目前是 ABTest 階段,老代碼實現方式主要問題:"}]},{"type":"bulletedlist","content":[{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"對每處 view 都用 GET_AB_TEST_CASE(videoPlayerInteractionOptimization)判斷處理了,代碼中共有 32 處判斷"}]}]},{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"每個 View 使用 Transform 動畫隱藏"}]}]}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"這個實現方式非常分散,加新 view 時很容易被遺漏,Element 支持更優的方式:"}]},{"type":"bulletedlist","content":[{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"左側所有子功能都在一個容器中,因此隱藏容器即可,不需要操作每個子功能"}]}]},{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"右側單獨隱藏頭像、音樂單獨處理即可"}]}]}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"image","attrs":{"src":"https:\/\/static001.geekbang.org\/infoq\/a2\/a2d0a1255a6cdc8c35fcfc3c61664839.png","alt":"圖片","title":null,"style":[{"key":"width","value":"75%"},{"key":"bordertype","value":"none"}],"href":null,"fromPaste":true,"pastePass":true}},{"type":"heading","attrs":{"align":null,"level":4},"content":[{"type":"text","text":"擴展性"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"Element 之間無依賴,可以做到每個 Element 物理隔離,代碼放在各自的業務組件中,業務組件依賴交互區業務框架層即可,獨立的 Element 通過 runtime 形式,使用註冊的方式提供給交互區,框架會將字符串的類實例化,讓其正常工作。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"text"},"content":[{"type":"text","text":"[self.container addElementByClassName:@\"PlayInteractionAuthorElement\"];\n[self.container addElementByClassName:@\"PlayInteractionRateElement\"];\n[self.container addElementByClassName:@\"PlayInteractionDescriptionElement\"];\n"}]},{"type":"heading","attrs":{"align":null,"level":2},"content":[{"type":"text","text":"業務框架層"}]},{"type":"heading","attrs":{"align":null,"level":3},"content":[{"type":"text","text":"容器管理"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"SDK 中僅提供了容器的抽象定義和實現,在業務場景中,需要結合具體業務場景,進一步定義容器的範圍和職責。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"上面梳理了功能中將整個頁面分爲左側、右側、底部 3 個區域,那麼這 3 個區域就是相應的容器,所有子功能都可以歸到這 3 個容器中,如下圖:"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"image","attrs":{"src":"https:\/\/static001.geekbang.org\/infoq\/3a\/3a479ff6888dc3928be01234746b65c7.png","alt":"圖片","title":null,"style":[{"key":"width","value":"75%"},{"key":"bordertype","value":"none"}],"href":null,"fromPaste":true,"pastePass":true}},{"type":"heading","attrs":{"align":null,"level":3},"content":[{"type":"text","text":"協議"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"Feed 是用 UITableView 實現,Cell 中除了交互區外只有播放器,因此所有的外部調用都可以抽象,如下圖所示。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"image","attrs":{"src":"https:\/\/static001.geekbang.org\/infoq\/0c\/0c696dd86e233e5f1cd3d55a802b5c11.png","alt":"圖片","title":null,"style":[{"key":"width","value":"75%"},{"key":"bordertype","value":"none"}],"href":null,"fromPaste":true,"pastePass":true}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"從概念上講只需要 1 個交互區協議,但這裏可以細分爲 2 部分:"}]},{"type":"bulletedlist","content":[{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"頁面生命週期"}]}]},{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"播放器生命週期"}]}]}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"所有 Element 都要實現這個協議,因此在 SDK 中的 Element 基類之上,繼承實現了 PlayInteractionBaseElement,這樣具體 Element 中不需要實現的方法可以不寫。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"text"},"content":[{"type":"text","text":"@interface PlayInteractionBaseElement : BaseElement \n@end"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"爲了更清晰定義協議職責,用接口隔離的思想繼續拆分,PlayInteractionDispatcherProtocol 作爲統一的聚合協議。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"text"},"content":[{"type":"text","text":"@protocol PlayInteractionDispatcherProtocol \n\n@end"}]},{"type":"heading","attrs":{"align":null,"level":4},"content":[{"type":"text","text":"頁面生命週期協議:PlayInteractionCycleLifeDispatcherProtocol"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"簡單列了部分方法,這些方法都是 ViewController、TableView、Cell 對應的生命週期方法,是完全抽象的、和業務無關的,因此不會隨着業務量的增加而膨脹。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"text"},"content":[{"type":"text","text":"@protocol PlayInteractionCycleLifeDispatcherProtocol \n\n- (void)willDisplay;\n\n- (void)setHide:(BOOL)flag;\n\n- (void)reset;\n\n@end"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"heading","attrs":{"align":null,"level":4},"content":[{"type":"text","text":"播放器生命週期協議:PlayInteractionPlayerDispatcherProtocol"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"播放器的狀態和方法,也是抽象的、和業務無關。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"text"},"content":[{"type":"text","text":"@protocol PlayInteractionPlayerDispatcherProtocol \n\n@property (nonatomic, assign) PlayInteractionPlayerStatus playerStatus;\n\n- (void)pause;\n\n- (void)resume;\n\n- (void)videoDidActivity;\n\n@end"}]},{"type":"heading","attrs":{"align":null,"level":3},"content":[{"type":"text","text":"Manager - 彈窗、蒙層"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"彈窗、蒙層的 view 規律並不在容器管理之中,所以需要一套額外的管理方式,這裏定義了 Manager 概念,是一個相對抽象的概念,即可以實現彈窗、蒙層等功能,也可以實現 View 無關的功能,和 Element 同樣,將代碼拆分開。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"text"},"content":[{"type":"text","text":"@interface PlayInteractionBaseManager : NSObject \n\n- (UIView *)view;\n\n@end"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"bulletedlist","content":[{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"PlayInteractionBaseManager 同樣實現了 PlayInteractionDispatcherProtocol 協議,因此具備了所有的交互區協議調用能力"}]}]},{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"Manager 不提供 View 的創建能力,這裏的 view 是 UIViewController 的 view 引用,比如需要加蒙層,那麼加到 manager 的 view 中就相當於加到 UIViewController 的 view 中"}]}]},{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"彈窗、蒙層通過此種方式實現,Manager 並不負責彈窗、蒙層間的互斥、優先級邏輯處理,需要單獨的機制去做"}]}]}]},{"type":"heading","attrs":{"align":null,"level":3},"content":[{"type":"text","text":"方法派發"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"業務框架層中定義的協議,需要框架層調用,SDK 層是感知不到的,由於 Element、Manager 衆多,需要一個機制來封裝批量調用過程,如下圖所示:"}]},{"type":"image","attrs":{"src":"https:\/\/static001.geekbang.org\/infoq\/88\/8869e83c50816ad221b88fd1795e5f0a.png","alt":"圖片","title":null,"style":[{"key":"width","value":"75%"},{"key":"bordertype","value":"none"}],"href":null,"fromPaste":true,"pastePass":true}},{"type":"heading","attrs":{"align":null,"level":3},"content":[{"type":"text","text":"分層結構"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"舊交互區使用了 VIPER 範式,抖音裏整體使用的 MVVM,多套範式會增加學習、維護成本,並且使用 Element 開發時,VIPER 層級過多,因此考慮統一爲 MVVM。"}]},{"type":"heading","attrs":{"align":null,"level":4},"content":[{"type":"text","text":"VIPER 整體分層結構"}]},{"type":"image","attrs":{"src":"https:\/\/static001.geekbang.org\/infoq\/14\/1488e5fc84b501619bd8536b6e99efb0.png","alt":"圖片","title":null,"style":[{"key":"width","value":"75%"},{"key":"bordertype","value":"none"}],"href":null,"fromPaste":true,"pastePass":true}},{"type":"heading","attrs":{"align":null,"level":4},"content":[{"type":"text","text":"MVVM 整體分層結構"}]},{"type":"image","attrs":{"src":"https:\/\/static001.geekbang.org\/infoq\/9d\/9d5252ffd8e9ff712fc2ddef44a29381.png","alt":"圖片","title":null,"style":[{"key":"width","value":"75%"},{"key":"bordertype","value":"none"}],"href":null,"fromPaste":true,"pastePass":true}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"在 MVVM 結構中,Element 職責和 ViewController 概念很接近,也可以理解爲更純粹、更專用的的 ViewController。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"經過 Element 拆分後,每個子功能已經內聚在一起,代碼量是有限的,可以比較好的支撐業務開發。"}]},{"type":"heading","attrs":{"align":null,"level":4},"content":[{"type":"text","text":"Element 結合 MVVM 結構"}]},{"type":"image","attrs":{"src":"https:\/\/static001.geekbang.org\/infoq\/66\/66bdb651f7114d795c07818553790ea9.png","alt":"圖片","title":null,"style":[{"key":"width","value":"75%"},{"key":"bordertype","value":"none"}],"href":null,"fromPaste":true,"pastePass":true}},{"type":"bulletedlist","content":[{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"Element:如果是特別簡單的元素,那麼只提供 Element 的實現即可,Element 層負責基本的實現和跳轉"}]}]},{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"ViewModel:部分元素邏輯比較複雜,需要將邏輯抽離出來,作爲 ViewModel,對應目前的 Presentor 層"}]}]},{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"Tracker:埋點工具,埋點也可以寫在 VM 中,對應目前的 Interactor"}]}]},{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"Model:絕大多數使用主 Model 即可"}]}]}]},{"type":"heading","attrs":{"align":null,"level":3},"content":[{"type":"text","text":"業務層"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"業務層中存放的是 Element 實現,主要有兩種類型:"}]},{"type":"bulletedlist","content":[{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"通用業務:如作者信息、描述、頭像、點贊、評論等通用的功能"}]}]},{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"子業務線業務:十幾條子業務線,不一一列舉"}]}]}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"通用業務 Element 和交互區代碼放在一起,子業務線 Element 放在業務線中,代碼物理隔離後,職責會更明確,但是這也帶來一個問題,當框架調整時,需要改多個倉庫,並且可能修改遺漏,所以重構初期可以先放一起,穩定後再遷出去。"}]},{"type":"heading","attrs":{"align":null,"level":2},"content":[{"type":"text","text":"過度設計誤區"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"設計往往會走兩個極端,沒有設計、過度設計。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"所謂沒有設計是在現有的架構、模式下,沒有額外思考過差異、特點,照搬使用。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"過渡設計往往是在吃了沒有設計的虧後,成了驚弓之鳥,看什麼都要搞一堆配置、組合、擴展的設計,簡單的反而搞複雜了,過猶不及。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"blockquote","content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"設計是在質量、成本、時間等因素之間做出權衡的藝術。"}]}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"heading","attrs":{"align":null,"level":2},"content":[{"type":"text","text":"實施方案"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"業務開發不能停,一邊開發、一邊重構,相當於在高速公路上不停車換輪胎,需要有足夠的預案、備案,才能保證設計方案順利落地。"}]},{"type":"heading","attrs":{"align":null,"level":3},"content":[{"type":"text","text":"改動評估"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"先估算一下修改規模、週期:"}]},{"type":"bulletedlist","content":[{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"代碼修改量:近 4 萬行"}]}]},{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"時間:半年"}]}]}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"改動巨大、時間很長,風險是難以控制的,每個版本都有大量業務需求,需要改大量的代碼,在重構的同時,如果重構的代碼和新需求代碼衝突,是非常難解的,因此考慮分期。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"上面已經多次說到功能的重要性,需要考慮重構後,功能是否正常,如果出了問題如何處理、如何證明重構後的功能和之前是一致的,對產品數據無影響。"}]},{"type":"heading","attrs":{"align":null,"level":3},"content":[{"type":"text","text":"實施策略"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"基本思路是實現一個新頁面,通過 ABTest 來切換,核心指標無明顯負向則放量,全量後刪除舊代碼,示意圖如下:"}]},{"type":"image","attrs":{"src":"https:\/\/static001.geekbang.org\/infoq\/64\/64efd5d03dc4998db9b124af849c62b8.png","alt":"圖片","title":null,"style":[{"key":"width","value":"75%"},{"key":"bordertype","value":"none"}],"href":null,"fromPaste":true,"pastePass":true}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"共分爲三期:"}]},{"type":"bulletedlist","content":[{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"一期改造內容如上圖紅色所示:抽取協議,面向協議編程,不依賴具體類,改造舊 VC,實現協議,將協議之外暴露的方法、屬性收斂到內部"}]}]},{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"二期改造內容如藍色所示:新建個新 VC,新 VC 和舊 VC 在功能上是完全一致,實現協議,通過 ABTest 來控制使用方拿到的是舊 VC 還是新 VC"}]}]},{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"三期內容:刪掉舊 VC、ABTest,協議、新 VC 保留,完成替換工作"}]}]}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"image","attrs":{"src":"https:\/\/static001.geekbang.org\/infoq\/61\/61151bc52384bc908b59a93794b50fcc.png","alt":"圖片","title":null,"style":[{"key":"width","value":"75%"},{"key":"bordertype","value":"none"}],"href":null,"fromPaste":true,"pastePass":true}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"其中二期是重點,佔用時間最多,此階段需要同時維護新舊兩套頁面,開發、測試工作量翻倍,因此要儘可能的縮短二期時間,不要着急改代碼,可以將一期做完善了、各方面的設計準備好再開始。"}]},{"type":"heading","attrs":{"align":null,"level":3},"content":[{"type":"text","text":"ABTest"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"2 個目的:"}]},{"type":"bulletedlist","content":[{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"利用 ABTest 作爲開關,可以靈活的切換新舊頁面"}]}]},{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"用數據證明新舊頁面是一致的,從業務功能上來說,二者完全一致,但實際情況是否符合預期,需要用留存、播放、滲透率等核心指標證明"}]}]}]},{"type":"heading","attrs":{"align":null,"level":3},"content":[{"type":"text","text":"兩套頁面的開發方式"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"在二期中,兩套頁面 ABTest 切換方式是有成本的,需求開發兩套、測試兩遍,雖然部分代碼可共用,但成本還是大大增加,因此需要將這個階段儘可能縮短。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"另外開發、測試兩套,不容易發現問題,而一旦出問題,即便能用 ABTest 靈活切換,但修復問題、重新上線、ABTest 數據有結論,也需要非常長的週期。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"如果每個版本都出問題,那將會是上線、發現問題,重新修復再上線,又發現了新問題,無限循環,可能一直無法全量。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"image","attrs":{"src":"https:\/\/static001.geekbang.org\/infoq\/04\/045f6d8e1cae441957afadbddcb0bdb8.png","alt":"圖片","title":null,"style":[{"key":"width","value":"75%"},{"key":"bordertype","value":"none"}],"href":null,"fromPaste":true,"pastePass":true}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"如上圖所示,版本單週迭代,發現問題跟下週修復,那麼需要經過灰度、上線灰度(AppStore 的灰度放量)、ABTest 驗證(AB 數據穩定要 2 周),總計要 6 周的時間。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"讓每個同學理解整體運作機制、成本,有助於統一目標,縮短此階段週期。"}]},{"type":"heading","attrs":{"align":null,"level":3},"content":[{"type":"text","text":"刪掉舊代碼"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"架構設計上準備充足,刪掉舊代碼非常簡單,刪掉舊文件、ABTest 即可,事實上也是如此,1 天內就完成了。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"代碼後入後,有些長尾的事情會持續 2、3 個版本,例如有些分支,已經修改了刪掉的代碼,因爲文件已經不存在了,只要修改,必定會衝突,合之前,需要 git merge 一下源分支,將有衝突的老頁面再刪掉。"}]},{"type":"heading","attrs":{"align":null,"level":3},"content":[{"type":"text","text":"防崩潰兜底"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"面向協議開發兩套頁面,如果增加一個功能時,新頁面遺漏了某個方法的話,期望可以不崩潰。利用 Objective-C 語言消息轉發可以實現這特性,在 forwardingTargetForSelector 方法中判斷方法是否存在,如果不存在,添加一個兜底方法即可,用來處理即可。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"text"},"content":[{"type":"text","text":"\n- (id)forwardingTargetForSelector:(SEL)aSelector {\n Class clazz = NSClassFromString(@\"TestObject\");\n if (![self isExistSelector:aSelector inClass:clazz]) {\n class_addMethod(clazz, aSelector, [self safeImplementation:aSelector], [NSStringFromSelector(aSelector) UTF8String]);\n }\n\n Class Protector = [clazz class];\n id instance = [[Protector alloc] init];\n return instance;\n}\n\n- (BOOL)isExistSelector:(SEL)aSelector inClass:(Class)clazz {\n BOOL isExist = NO;\n unsigned int methodCount = 0;\n Method *methods = class_copyMethodList(clazz, &methodCount);\n NSString *aSelectorName = NSStringFromSelector(aSelector);\n for (int i = 0; i < methodCount; i++) {\n Method method = methods[i];\n SEL selector = method_getName(method);\n NSString *selectorName = NSStringFromSelector(selector);\n if ([selectorName isEqualToString: aSelectorName]) {\n isExist = YES;\n break;\n }\n }\n return isExist;\n}\n\n- (IMP)safeImplementation:(SEL)aSelector {\n IMP imp = imp_implementationWithBlock(^(){\n \/\/ log\n });\n return imp;\n}"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"線上兜底降低影響範圍,內測提示儘早發現,在開發、內測階段時可以用比較強的交互手段提示,如 toast、彈窗等,另外可以接打點上報統計。"}]},{"type":"heading","attrs":{"align":null,"level":3},"content":[{"type":"text","text":"防劣化"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"需要明確的規則、機制防劣化,並持續投入精力維護。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"不是每個人都能理解設計意圖,不同職責的代碼放在應該放的位置,比如業務無關的代碼,應該下沉到框架層,降低被破壞的概率,緊密的開發節奏,即便簡單的 if else 也容易寫出問題,例如再加 1 個條件,幾乎都會再寫 1 個 if,直至寫了幾十個後,發現寫不下去了,再推倒重構,期望重構一次後,可以保持得儘可能久一些。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"更嚴重的是在重構過程中,代碼就可能劣化,如果問題出現的速度超過解決的速度,那麼將會一直疲於救火,永遠無法徹底解決。"}]},{"type":"image","attrs":{"src":"https:\/\/static001.geekbang.org\/infoq\/30\/303ff1b0873902f2060edf61e81e0773.png","alt":"圖片","title":null,"style":[{"key":"width","value":"75%"},{"key":"bordertype","value":"none"}],"href":null,"fromPaste":true,"pastePass":true}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"新方案中,業務邏輯都放在了 Element 中,ViewController、容器中剩下通用的代碼,這部分代碼業務同學是沒必要去修改,不理解整體也容易改出問題,因此這部分代碼由專人來維護,各業務同學有需要改框架層代碼的需求,專人來修改。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"各 Element 按照業務線劃分爲獨立文件,自己維護的文件可以加 reviewer 或文件變更通知,也可以遷到業務倉庫中,進行物理隔離。"}]},{"type":"heading","attrs":{"align":null,"level":3},"content":[{"type":"text","text":"日誌 & 問題排查"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"穩定復現的問題,比較容易排查和解決,但概率性的問題,尤其是 iOS 系統問題引起的概率性問題,比較難排查,即便猜測可能引起問題的原因,修改後,也難以自測驗證,只能上線再觀察。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"關鍵信息提前加日誌記錄,如用戶反饋某個視頻有問題,那麼需要根據日誌,找到相應的 model、Element、View、佈局、約束等信息。"}]},{"type":"heading","attrs":{"align":null,"level":3},"content":[{"type":"text","text":"信息同步"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"改動過廣,需要及時周知業務線的開發、測試、產品同學,幾個方式:"}]},{"type":"bulletedlist","content":[{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"拉羣通知"}]}]},{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"週會、週報"}]}]}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"開發同學最關注的點是什麼時候放量、什麼時候全量、什麼時候可以刪掉老代碼,不用維護 2 套代碼。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"其次是改動,框架在不夠穩定時,是需要經常改的,如果改動,需要相應受影響的功能的維護同學驗證,以及確認測試是否介入。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"產品同學也要周知,雖然產品不關注怎麼做,但是一旦出問題,沒有周知,很麻煩。"}]},{"type":"heading","attrs":{"align":null,"level":3},"content":[{"type":"text","text":"保證質量"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"最重要的是及時發現問題,這是避免或者減少影響的前提條件。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"常規的 RD 自測、QA 功能測試、集成測試等是必備的,這裏不多說,主要探討其他哪些手段可以更加及時的發現問題。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"新開發的需求,需要開發新、老頁面兩套代碼,同樣,也要測試兩次,雖然多次強調,但涉及到多個業務線、跨團隊、跨職責、時間線長,很容易遺漏,而新頁面 ABTest 放量很小,一旦出問題,很難被發現,因此對線上和測試用戶區分處理:"}]},{"type":"bulletedlist","content":[{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"線上、線下流量策略:線上 AppStore 渠道 ABTest 按數據分析師設計放量;內測、灰度等線下渠道放量 50%,新舊兩套各佔一半,內測、灰度人員還是有一定規模的,如果是明顯的問題,比較容易發現的"}]}]},{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"ABTest 產品指標對照:灰度、線上數據都是有參考價值的,按照 ABTest 數據量,粗評一下是否有問題,如果有明顯問題,可及時深入排查"}]}]},{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"Slardar ABTest 技術指標對照:最常用的是 crash 率,對比對照組和實驗組的 crash 率,看下是否有新 crash,實驗組放量比較小,單獨的看 crash 數量是很難發現的,也容易忽略。另外還要別的技術指標,也可以關注下"}]}]},{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"Slardar 技術打點告警配置:重構週期比較長,難以做到每天都盯着,關鍵位置加入技術打點,系統中配置告警,設置好條件,這樣在出現問題時,會及時通知你"}]}]},{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"單元測試:單測是保證重構的必要手段,在框架、SDK 等核心代碼,都加入了單測"}]}]},{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"UI 自動化測試:如果有完整的驗證用例,可以一定程度上幫助發現問題"}]}]}]},{"type":"heading","attrs":{"align":null,"level":3},"content":[{"type":"text","text":"排查問題"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"穩定復現的問題比較容易定位解決,兩類問題比較頭疼,詳細講一下:"}]},{"type":"bulletedlist","content":[{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"ABTest 指標負向"}]}]},{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"概率性出現的問題"}]}]}]},{"type":"heading","attrs":{"align":null,"level":4},"content":[{"type":"text","text":"ABTest 指標負向"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"ABTest 核心指標負向,是無法放量的,甚至要關掉實驗排查問題。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"有個分享例子,分享總量、人均分享量都明顯負向,大體經過這樣幾個排查過程:"}]},{"type":"image","attrs":{"src":"https:\/\/static001.geekbang.org\/infoq\/24\/2450d1d54524ba3954d91e00a498e320.png","alt":"圖片","title":null,"style":[{"key":"width","value":"75%"},{"key":"bordertype","value":"none"}],"href":null,"fromPaste":true,"pastePass":true}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"排查 ABTest 指標和排查 bug 類似,都是找到差異,縮小範圍,最終定位代碼。"}]},{"type":"bulletedlist","content":[{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"對比功能:從用戶使用角度找差異,交互設計師、測試、開發自測都沒有發現有差異"}]}]},{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"對比代碼:對比新老兩套打點代碼邏輯,尤其是進入打點的條件邏輯,沒有發現差異"}]}]},{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"拆分指標:很多功能都可以分享,打點平臺可以按分享頁面來源拆分指標,發現長按彈出面板中的分享減少,其他來源相差不大,進一步排查彈出面板出現的概率發現明顯變低了,大體定位問題範圍。另外值得一提的是,不喜歡不是很核心的指標,並且不喜歡變少,意味着視頻質量更高,所以這點是從 ABTest 數據中難以發現的"}]}]},{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"定位代碼:排查面板出現條件發現,老代碼中是在長按手勢中,排除了個別的點贊、評論等按鈕,其他位置(如果沒有添加事件)都是可點的,比如點贊、評論按鈕之間的空白位置,而新代碼中是將右側按鈕區域、底部統一排除了,這樣空白區域就不能點了,點擊區域變小了,因此出現概率變小了"}]}]},{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"解決問題:定位問題後,修復比較簡單,還原了舊代碼實現方式"}]}]}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"這個問題能思考的點是比較多的,重構時,看到了不好的代碼,到底要不要改?"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"比如上面的問題,增加了功能後,不知道是否應該排除點擊,很容易被忽略,長按屬於底層邏輯,具體按鈕屬於業務細節,底層邏輯依賴了細節是不好的,可維護性很差,但是修改後,很可能影響交互體驗和產品指標,尤其是核心指標,一旦影響,沒有太多探討空間。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"具體情況具體評估,如果預估到影響了功能、交互,儘量不要改,大重構儘可能先解決核心問題,局部問題可以後續單獨解決。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"下面是長按面板中的分享數據截圖,明顯降低,其他來源基本保持一致,就不貼圖了。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"image","attrs":{"src":"https:\/\/static001.geekbang.org\/infoq\/e3\/e3300e46a935436a722206bbbe5d95b2.png","alt":"圖片","title":null,"style":[{"key":"width","value":"75%"},{"key":"bordertype","value":"none"}],"href":null,"fromPaste":true,"pastePass":true}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"長按蒙層出現率降低 10%左右,比較自然的猜測蒙層出現率降低。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"image","attrs":{"src":"https:\/\/static001.geekbang.org\/infoq\/14\/14376cef3ed7557b922b636c668378a9.png","alt":"圖片","title":null,"style":[{"key":"width","value":"75%"},{"key":"bordertype","value":"none"}],"href":null,"fromPaste":true,"pastePass":true}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"對比 View 視圖差異確認問題。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"image","attrs":{"src":"https:\/\/static001.geekbang.org\/infoq\/9f\/9fe59a3c648baba4a807ef74d2e67dfa.png","alt":"圖片","title":null,"style":[{"key":"width","value":"75%"},{"key":"bordertype","value":"none"}],"href":null,"fromPaste":true,"pastePass":true}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"類似的問題很多,ABTest 放量、全量過程要有充足的估時和耐心,這個過程會大大超過預期。抖音核心指標幾乎都和交互區相關,衆多分析師和產品都要關注,因此先理解一下分析師、產品和開發同學對 ABTest 指標負向的認知差別。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"blockquote","content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"大部分指標是正向,個別指標負向,那麼會被判斷爲負向。"}]}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"開發同學可能想的是設計的合理性、代碼的合理性,或者從整體的收益、損失角度的差值考慮,但分析師會優先考慮不出問題、別有隱患。兩種方式是站在不同角度、目標考慮的,沒有對錯之分,事實上分析師幫忙發現了非常多的問題。目前的分析師、產品衆多,每個指標都有分析師、產品負責,如果某個核心指標明顯負向,找相應的分析師、產品討論,是非常難達成一致的,即使是先放量再排查的方案也很難接受,建議自己學會看指標,儘早跟進,關鍵時找人幫忙推進。"}]},{"type":"heading","attrs":{"align":null,"level":4},"content":[{"type":"text","text":"概率性出現的問題"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"概率性出現的問題難點在於,很難復現,無法調試定位問題,修改後無法測試驗證,需要上線後才能確定是否修復,舉一個實際的例子的 iOS9 上 crash 例子,發現過程:"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"bulletedlist","content":[{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"通過 slardar=>AB 實驗=>指定實驗=>監控類型=>崩潰 發現的,可以看到實驗組和對照組的 crash 率,其他的 OOM 等指標也可以用這個功能查看"}]}]}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"下面是 crash 的堆棧,crash 率比較高,大約 50%的 iOS 9 的用戶會出現:"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"image","attrs":{"src":"https:\/\/static001.geekbang.org\/infoq\/80\/803ad830f0731369938c68f345521cea.png","alt":"圖片","title":null,"style":[{"key":"width","value":"75%"},{"key":"bordertype","value":"none"}],"href":null,"fromPaste":true,"pastePass":true}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"crash 堆棧在系統庫中,無法看到源碼,堆棧中也無法找到相關的問題代碼,無法定位問題 ,整個解決過程比較長,嘗試用過的方式,供大家參考:"}]},{"type":"bulletedlist","content":[{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"手動復現,嘗試修改,可以復現,但刷一天也復現不了幾次,效率太低,對部分問題來說,判斷準的話,可以比較快的解決"}]}]},{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"swizzle 系統崩潰的方法,日誌記錄最後崩潰的 View、相關 View 的層次結構,縮小排查範圍"}]}]},{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"自動化測試復現,可以用來驗證是否修復問題,無法定位問題"}]}]},{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"逆向看 UIKit 系統實現,分析崩潰原因"}]}]}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","marks":[{"type":"strong"}],"text":"逆向大體過程:"}]},{"type":"bulletedlist","content":[{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"下載 iOS9 Xcode & 模擬器文件"}]}]},{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"提取 UIKit 動態庫"}]}]},{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"分析 crash 堆棧,通過 crash 最後所在的_layoutEngine、_addOrRemoveConstraints、_withUnsatisfiableConstraintsLoggingSuspendedIfEngineDelegateExists 3 個關鍵方法,找到調用路徑,如下圖所示:"}]}]}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"image","attrs":{"src":"https:\/\/static001.geekbang.org\/infoq\/36\/36f867263e345d96c0ed45d406f28a86.png","alt":"圖片","title":null,"style":[{"key":"width","value":"75%"},{"key":"bordertype","value":"none"}],"href":null,"fromPaste":true,"pastePass":true}},{"type":"bulletedlist","content":[{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"_withUnsatisfiableConstraintsLoggingSuspendedIfEngineDelegateExists 中調用了 deactivateConstraints 方法,deactivateConstraints 中又調用了_addOrRemoveConstraints 方法,和 crash 堆棧中第 3 行匹配,那麼問題就出在此處,爲方便排查,逆向出相關方法的具體實現,大體如下:"}]}]}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"text"},"content":[{"type":"text","text":"@implementation UIView\n- (void)_withUnsatisfiableConstraintsLoggingSuspendedIfEngineDelegateExists:(Block)action {\n id engine = [self _layoutEngine];\n id delegate = [engine delegate];\n BOOL suspended = [delegate _isUnsatisfiableConstraintsLoggingSuspended];\n [delegate _setUnsatisfiableConstraintsLoggingSuspended:YES];\n action();\n [delegate _setUnsatisfiableConstraintsLoggingSuspended:suspended];\n if (suspended == YES) {\n return;\n }\n NSArray *constraints = [self _constraintsBrokenWhileUnsatisfiableConstraintsLoggingSuspended];\n if (constraints.count != 0) {\n NSMutableArray *array = [[NSMutableArray alloc] init];\n for (NSLayoutConstraint *_cons : constraints) {\n if ([_cons isActive]) {\n [array addObject:_cons];\n }\n }\n if (array.count != 0) {\n [NSLayoutConstraint deactivateConstraints:array]; \/\/ NSLayoutConstraint 入口\n [NSLayoutConstraint activateConstraints:array];\n }\n }\n objc_setAssociatedObject(\n self,\n @selector(_constraintsBrokenWhileUnsatisfiableConstraintsLoggingSuspended),\n nil,\n OBJC_ASSOCIATION_RETAIN_NONATOMIC);\n}\n\n@end\n\n@implementation NSLayoutConstraint\n+ (void)activateConstraints:(NSArray *)_array {\n [self _addOrRemoveConstraints:_array activate:YES]; \/\/ crash堆棧中倒數第3個調用\n}\n+ (void)deactivateConstraints:(NSArray *)_array {\n [self _addOrRemoveConstraints:_array activate:NO];\n}\n@end"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"bulletedlist","content":[{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"從代碼邏輯和_constraintsBrokenWhileUnsatisfiableConstraintsLoggingSuspended 方法的命名語義上看,此處代碼主要是用來處理無法滿足約束日誌的,應該不會影響功能邏輯"}]}]},{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"另外,分析時如果無法準確判斷 crash 位置,則需要逆向真機文件,相比模擬器,真機的堆棧是準確的,通過原始 crash 堆棧偏移量找到最後的代碼調用"}]}]}]},{"type":"heading","attrs":{"align":null,"level":2},"content":[{"type":"text","text":"拿到結果"}]},{"type":"bulletedlist","content":[{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"開發效率:將之前 VIPER 結構的 5 個文件,拆分了大約 50 個文件,每個功能的職責都在業務線內部,添加、修改不再需要看所有的代碼了,調研問卷顯示開發效率提升在 20%以上"}]}]},{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"開發質量:從 bug、線上故障來看,新頁面問題是比較少的,而且出問題一般的都是框架的問題,修復後是可以避免批量的問題的"}]}]},{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"產品收益:雖然功能一致,但因爲重構設計的性能是有改進的,核心指標正向收益明顯,實驗開啓多次,核心指標結論一致"}]}]}]},{"type":"heading","attrs":{"align":null,"level":2},"content":[{"type":"text","text":"勇氣"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"最後這部分是思考良久後加上的,重構本身就是開發的一部分,再正常不過,但重構總是難以進行,有的淺嘗輒止,甚至半途而廢。公司嚴格的招聘下,能進來的都是聰明人,不缺少解決問題的智慧,缺少的是勇氣,回顧這次重構和上面提到過的“曾經嘗試過的方式”,也正是如此。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"代碼難以維護時是比較容易發現的,優化、重構的想法也很自然,但是有兩點讓重構無法有效開展:"}]},{"type":"bulletedlist","content":[{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"什麼時候開始"}]}]},{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"局部重構試試"}]}]}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"在討論什麼時候開始前,可以先看個詞,工作中有個流行詞叫 ROI,大意是投入和收益比率,投入越少、收益越高越好,最好是空手套白狼,這個詞指導了很多決策。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"重構無疑是個費力的事情,需要投入非常大的心力、時間,而能看到的直接收益不明顯,一旦改出問題,還要承擔風險,重構也很難獲得其他人認可,比如在產品看來,功能完全沒變,代碼還能跑,爲什麼要現在重構,新需求還等着開發呢,有問題的代碼就是這樣不斷的拖着,越來越嚴重。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"誠然,有足夠的痛點時重構是收益最高的,但只是看起來,真實的收益是不變的,在這之前需要大量額外的維護成本,以及劣化後的重構成本,從長期收益看,既然要改就趁早改。決定要做比較難,說服大家更難,每個人的理解可能都不一樣,對長期收益的判斷也不一樣,很難達成一致。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"image","attrs":{"src":"https:\/\/static001.infoq.cn\/resource\/image\/00\/c6\/00101e29352f1c541dbcc5c0b4ebc6c6.jpg","alt":null,"title":"","style":[{"key":"width","value":"75%"},{"key":"bordertype","value":"none"}],"href":"","fromPaste":false,"pastePass":false}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"思者衆、行者寡,未知的事情大家偏向謹慎,支持繼續前行的是對技術追求的勇氣。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"blockquote","content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"重構最好的時間就是當下。"}]}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"局部重構,積少成多,最終整體完成,即便出問題,影響也是局部的,這是自下向上的方式,本身是沒問題的,也經常使用,與之對應的是自上向下的整體重構,這裏想強調的是,局部重構、整體重構只是手段,選擇什麼手段要看解決什麼問題,如果根本問題是整體結構、架構的問題,局部重構是無法解決的。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"比如這次重構時,非常多的人都提出,能否改動小一點、謹慎一點,但是設計方案是經過分析梳理的,已經明確是結構性問題,局部重構是無法解決的,曾經那些嘗試過的方式也證明了這一點。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"blockquote","content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"不能因爲怕扯到蛋而忘記奔跑。"}]}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"本文轉載自:字節跳動技術團隊(ID:BytedanceTechBlog)"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"原文鏈接:"},{"type":"link","attrs":{"href":"https:\/\/mp.weixin.qq.com\/s\/ZmF5w3zzpqJb7AiBWGJUvA","title":"xxx","type":null},"content":[{"type":"text","text":"抖音iOS最複雜功能的重構之路--播放器交互區重構實踐"}]}]}]}
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章