preface
The previous refactoring of legacy mobile applications (13) -MVP Refactoring example explained how the file module team refactored the file home page to the MVP architecture, adding automated testing. After refactoring, the team’s development efficiency and release quality improved significantly. The business of dynamic modules is more complex than file modules, and this time the team decided to use the new development language Kotlin and the MVVM architecture.
In this article, we will continue to show you how to transition from Java code to Kotlin code, and how to refactor god classes into MVVM architecture step by step, using DynamicBundle as an example.
Video presentation address: mp.weixin.qq.com/s/vex4Kn6Ts…
The refactoring process
Recalling the refactoring process we analyzed in the last article, we recommend that you also go to Step 3 to convert to Kotlin, with guardian tests in place, as it is safer. The process is as follows:
Graph TD A(1. Combing business logic)-->B(2. Analyzing original code design) B-->C(3. Supplementary guardian test) C-->D(4. Simple design) D-->E(5. E-->F(6. Integration Acceptance Test)
1. Sort out the service logic
As mentioned in the last article, we can try to complete the information from the following aspects.
- Find someone: product manager, designer, tester to confirm and answer questions
- Find documentation: View existing requirements documents, design documents, test cases, design sketches
- Look at the code: Sort out the business from the original code design
After sorting and confirming, the existing services of FileBundle are as follows:
Graph of TD (into the dynamic page) - - > B (dynamic list data to be loaded from the network) - > B C {data is loaded successfully} C - > load success | | D (display dynamic list - content and date) C - > | network abnormal | E} {whether there is A local cache data C - > | | data is empty F (shows the empty data) E - > | | existence cache data D E - > | | G (display NetworkErrorException) there is no cache data F - > | click trigger refresh | G - B > | B | click trigger refreshing
The time is displayed in YYYY-MM-DD HH: MM :ss
2. Analyze the original code design
Let’s take the main DynamicFragment class as an example.
@Route(path = "/dynamicBundle/dynamic") @AndroidEntryPoint public class DynamicFragment extends Fragment { @Inject DynamicController dynamicController; Button btnUpload; @Inject TransferFile transferFile; private RecyclerView dynamicListRecycleView; private TextView tvMessage; public static DynamicFragment newInstance() { DynamicFragment fragment = new DynamicFragment(); Bundle args = new Bundle(); fragment.setArguments(args); return fragment; } @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); } @Override public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { View view = inflater.inflate(R.layout.fragment_dynamic, container, false); btnUpload = view.findViewById(R.id.btn_upload); btnUpload.setOnClickListener(v -> uploadDynamic()); dynamicListRecycleView = view.findViewById(R.id.file_list); tvMessage = view.findViewById(R.id.tv_message); tvMessage.setOnClickListener(v -> getDynamicList()); getDynamicList(); return view; } public void uploadDynamic() {FileInfo FileInfo = transferfile.upload ("/data/data/user.png"); Dynamiccontroller.post (new Dynamic(0, "first Dynamic ", system.currentTimemillis ()), fileInfo); } public void getDynamicList() { new Thread(() -> { Message message = new Message(); try { List<Dynamic> dynamicList = dynamicController.getDynamicList(); message.what = 1; message.obj = dynamicList; } catch (NetworkErrorException e) { message.what = 0; message.obj = "NetworkErrorException"; e.printStackTrace(); } mHandler.sendMessage(message); }).start(); } public Handler mHandler = new Handler(new Handler.Callback() { @Override public boolean handleMessage(@NonNull Message msg) { if (msg.what == 1) { showTip(false); List<Dynamic> dynamicList = (List<Dynamic>) msg.obj; if (dynamicList == null || dynamicList.size() == 0) { showTip(true); // Display empty data tvmessage.settext ("empty data"); } else { DynamicListAdapter fileListAdapter = new DynamicListAdapter(dynamicList, getActivity()); dynamicListRecycleView.addItemDecoration(new DividerItemDecoration( getActivity(), DividerItemDecoration.VERTICAL)); / / set the display format layout dynamicListRecycleView. SetLayoutManager (new LinearLayoutManager (getActivity ())); dynamicListRecycleView.setAdapter(fileListAdapter); / / updates to the data from network. Keep to cache dynamicController saveDynamicToCache (dynamicList); }} else if (MSG. What = = 0) {/ / try to read the data from the cache List < Dynamic > dynamicList = dynamicController. GetDynamicListFromCache (); if (dynamicList == null || dynamicList.size() == 0) { showTip(true); // Display the exception alert data tvmessage.settext (MSG.obj.toString()); } else { DynamicListAdapter fileListAdapter = new DynamicListAdapter(dynamicList, getActivity()); dynamicListRecycleView.addItemDecoration(new DividerItemDecoration( getActivity(), DividerItemDecoration.VERTICAL)); / / set the display format layout dynamicListRecycleView. SetLayoutManager (new LinearLayoutManager (getActivity ())); dynamicListRecycleView.setAdapter(fileListAdapter); } } return false; }}); public void showTip(boolean show) { if (show) { tvMessage.setVisibility(View.VISIBLE); dynamicListRecycleView.setVisibility(View.GONE); } else { tvMessage.setVisibility(View.GONE); dynamicListRecycleView.setVisibility(View.VISIBLE); }}}Copy the code
From the code we can see some of the major design issues as follows:
- The main fetching dynamic list, exception logic judgment, data cache judgment and interface refresh control are all in one class, which is not good for subsequent expansion and modification maintenance. We want the responsibility of the class to be more singular, with logic and view separated.
- There are rough new threads to manage
- Handler may cause memory leaks
- Duplicate code exists, such as the presentation of tabular data
- There are no guardian tests in the code
See Github for the full code
3. Supplementary guard tests
Referring to the strategy we laid out in refactoring, we can do the big tests first as guardian tests. We also mock database and network-related operations. Test cases mainly contain the main business logic that has been sorted out, including normal data display, network exception cache data, network exception no cache data, and empty data. The code is as follows:
@RunWith(AndroidJUnit4::class) @LargeTest @HiltAndroidTest @Config(application = HiltTestApplication::class, shadows = [ShadowDynamicFragment::class, ShadowDynamicController::class]) class DynamicFragmentTest { @get:Rule var hiltRule = HiltAndroidRule(this) @Test fun `show show dynamic list when get success`() { //given ShadowDynamicFragment.state = ShadowDynamicFragment.State.SUCCESS //when val scenario: ActivityScenario<DebugActivity> = ActivityScenario.launch(DebugActivity::class.java) scenario.onActivity { //then "What a beautiful day!" )).check(matches(isDisplayed())) onView(withText("2021-03-17 14:47:55")).check(matches(isDisplayed())) This series is worth watching! )).check(matches(isDisplayed())) onView(withText("2021-03-17 14:48:08")).check(matches(isDisplayed())) } } @Test fun `show show dynamic list when net work exception but have cache`() { //given ShadowDynamicFragment.state = ShadowDynamicFragment.State.ERROR ShadowDynamicController.state = ShadowDynamicController.State.DATA //when val scenario: ActivityScenario<DebugActivity> = ActivityScenario.launch(DebugActivity::class.java) scenario.onActivity { //then "What a beautiful day!" )).check(matches(isDisplayed())) onView(withText("2021-03-17 14:47:55")).check(matches(isDisplayed())) This series is worth watching! )).check(matches(isDisplayed())) onView(withText("2021-03-17 14:48:08")).check(matches(isDisplayed())) } } @Test fun `show show error tip when net work exception and not have cache`() { //given ShadowDynamicFragment.state = ShadowDynamicFragment.State.ERROR ShadowDynamicController.state = ShadowDynamicController.State.EMPTY //when val scenario: ActivityScenario<DebugActivity> = ActivityScenario.launch(DebugActivity::class.java) scenario.onActivity { //then onView(withText("NetworkErrorException")).check(matches(isDisplayed())) } } @Test fun `show show empty tip when not has data`() { //given ShadowDynamicFragment.state = ShadowDynamicFragment.State.EMPTY //when val scenario: ActivityScenario<DebugActivity> = ActivityScenario.launch(DebugActivity::class.java) scenario.onActivity { //then onView(withText("empty data")).check(matches(isDisplayed())) } } }Copy the code
Finally, we completed the basic daemon test, and the running result is as follows:
See Github for the full code
Kotlin
-
Kotlin is mixed with Java support, so we can write new code in Kotlin. Take a look at Java interoperability on the Kotlin website
-
AndroidStudio supports converting existing Java code into Kotlin code
- AndroidStudio support will view Kotlin bytecode, Decompile for Java code, easy to understand some syntax features
The Dynamic module team members decided to use solution 2 to convert the Dynamic home page to Kotlin code.
In the conversion process, try to convert one relatively cohesive package at a time, convert in small steps and test and verify, and Review and adjust manually after the conversion
After conversion, the current DynamicFragment code is as follows:
@Route(path = "/dynamicBundle/dynamic") @AndroidEntryPoint class DynamicFragment : Fragment() { @Inject lateinit var dynamicController: DynamicController @Inject lateinit var transferFile: TransferFile private lateinit var btnUpload: Button private lateinit var dynamicListRecycleView: RecyclerView private lateinit var tvMessage: TextView override fun onCreateView(inflater: LayoutInflater, container: ViewGroup? , savedInstanceState: Bundle?) : View? { val view = inflater.inflate(R.layout.fragment_dynamic, container, false) btnUpload = view.findViewById(R.id.btn_upload) btnUpload.setOnClickListener { uploadDynamic() } dynamicListRecycleView = view.findViewById(R.id.file_list) tvMessage = view.findViewById(R.id.tv_message) TvMessage. SetOnClickListener {getDynamicList ()} getDynamicList () return the view} private fun uploadDynamic () {/ / upload file Val fileInfo = transferfile.upload ("/data/data/user.png") fileInfo?.let {dynamiccontroller.post (Dynamic(0, "first Dynamic "), System.currentTimeMillis()), it) } } public fun getDynamicList() { Thread { val message = Message() val dynamicList = dynamicController.getDynamicList() message.what = 1 message.obj = dynamicList mHandler.sendMessage(message) }.start() } Var mHandler = Handler {MSG -> if (MSG. What == 1) {showTip(false) var dynamicList = mutableListOf<Dynamic>() Let {dynamicList = MSG. Obj as MutableList<Dynamic>} if (dynamicList.isempty ()) {showTip(true) // Display empty data tvMessage.text = "empty data" } else { val fileListAdapter = activity?.let { DynamicListAdapter(dynamicList, it) } dynamicListRecycleView.addItemDecoration(DividerItemDecoration( activity, DividerItemDecoration. VERTICAL)) / / set the display format layout dynamicListRecycleView. LayoutManager = LinearLayoutManager (activity) DynamicListRecycleView. Adapter = fileListAdapter / / updates to the data from the network maintain to cache dynamicController.. saveDynamicToCache (dynamicList)} } else if (MSG. What = = 0) {/ / try to read the data from the cache val dynamicList = dynamicController. GetDynamicListFromCache the if () (dynamicList.isEmpty()) {showTip(true) // Display the exception notification data tvmessage.text = MSG.obj.toString()} else {val fileListAdapter = activity?.let { DynamicListAdapter(dynamicList, it) } dynamicListRecycleView.addItemDecoration(DividerItemDecoration( activity, DividerItemDecoration. VERTICAL)) / / set the display format layout dynamicListRecycleView. LayoutManager = LinearLayoutManager (activity) dynamicListRecycleView.adapter = fileListAdapter } } false } fun showTip(show: Boolean) { if (show) { tvMessage.visibility = View.VISIBLE dynamicListRecycleView.visibility = View.GONE } else { tvMessage.visibility = View.GONE dynamicListRecycleView.visibility = View.VISIBLE } } companion object { fun newInstance(): DynamicFragment { val fragment = DynamicFragment() val args = Bundle() fragment.arguments = args return fragment } } }Copy the code
4. Simple design
The MVVM architecture
graph TD
B(MVVM)-->A(Model)
B-->|bingding|C(View)
C-->|bingding|B
Copy the code
- Separation of business logic and view
- A ViewModel is bound to a View using a binding, so you don’t have to define a lot of interfaces
- In order to manage threads more efficiently, the team decided to use coroutine for unified thread management. The architecture style is referred to architecture-samples
LiveData data design
Val dynamicListLiveData: LiveData<List<Dynamic>>Copy the code
Related third-party libraries
Implementation 'androidx. Core: the core - KTX: 1.3.2' implementation 'androidx. Lifecycle: lifecycle - livedata - KTX: 2.3.0' Implementation 'androidx. Lifecycle: lifecycle - viewmodel - KTX: 2.3.0' implementation 'org. Jetbrains. Kotlinx: kotlinx coroutines -- core: 1.4.1' implementation 'org. Jetbrains. Kotlinx: kotlinx coroutines - android: 1.4.1'Copy the code
5. Perform security reconstruction in small steps
- Extract the business logic of DynamicFragment to DynamicViewModel
- Define bindings for LiveData and XML
- Extracting DymaicRepository
- Extract DataSource interface
Refactoring techniques include extracting interfaces, moving methods, moving classes, extracting methods, inlining, extracting variables, and so on. The Kotlin IDE currently does not support moving methods to classes
See the video for a detailed demonstration
See Github for the detailed code
Supplement test cases
- Added DateUtil calculate date test
@SmallTest class DateUtilTest { @Test fun `should return 2021-03-17 14 47 5 when input is 1615963675000L`() { val format = DateUtil.getDateToString(1615963675000L) Assert.assertEquals("2021-03-17 14:47:55", format) } }Copy the code
- Added MVVM service logic tests
@ExperimentalCoroutinesApi @SmallTest class DynamicViewModelTest { private val testDispatcher = TestCoroutineDispatcher() @get:Rule val rule = InstantTaskExecutorRule() @Before fun setUp() { Dispatchers.setMain(testDispatcher) } @After fun tearDown() { Dispatchers.resetMain() testDispatcher.cleanupTestCoroutines() } @Test fun `show show dynamic list when get success`() = runBlocking { //given val mockTransferFile = mock(TransferFile::class.java) val mockDynamicRepository = mock(DynamicRepository::class.java) `when`(mockDynamicRepository.getDynamicList()).thenReturn(getMockData()) val dynamicViewModel = DynamicViewModel(mockTransferFile, mockDynamicRepository) //when dynamicViewModel.getDynamicList() //then val dynamicOne = LiveDataTestUtil.getValue(dynamicViewModel.dynamicListLiveData)[0] assertThat(dynamicOne.id).isEqualTo(1) AssertThat (dynamicone.content). IsEqualTo (" What a beautiful day!" ) assertThat(dynamicOne.date).isEqualTo(1615963675000L) val dynamicTwo = LiveDataTestUtil.getValue(dynamicViewModel.dynamicListLiveData)[1] assertThat(dynamicTwo.id).isEqualTo(2) AssertThat (dynamictwo-content). IsEqualTo (" This series is worth following! ") ) assertThat(dynamicTwo.date).isEqualTo(1615963688000L) } @Test fun `show show dynamic list when net work exception but have cache`() = runBlocking { //given val mockTransferFile = mock(TransferFile::class.java) val mockDynamicRepository = mock(DynamicRepository::class.java) `when`(mockDynamicRepository.getDynamicList()).thenThrow(NetWorkErrorException::class.java) `when`(mockDynamicRepository.getDynamicListFromCache()).thenReturn(getMockData()) val dynamicViewModel = DynamicViewModel(mockTransferFile, mockDynamicRepository) //when dynamicViewModel.getDynamicList() //then val dynamicOne = LiveDataTestUtil.getValue(dynamicViewModel.dynamicListLiveData)[0] assertThat(dynamicOne.id).isEqualTo(1) AssertThat (dynamicone.content). IsEqualTo (" What a beautiful day!" ) assertThat(dynamicOne.date).isEqualTo(1615963675000L) val dynamicTwo = LiveDataTestUtil.getValue(dynamicViewModel.dynamicListLiveData)[1] assertThat(dynamicTwo.id).isEqualTo(2) AssertThat (dynamictwo-content). IsEqualTo (" This series is worth following! ") ) assertThat(dynamicTwo.date).isEqualTo(1615963688000L) } @Test fun `show show error tip when net work exception and not have cache`() = runBlocking { //given val mockTransferFile = mock(TransferFile::class.java) val mockDynamicRepository = mock(DynamicRepository::class.java) `when`(mockDynamicRepository.getDynamicList()).thenThrow(NetWorkErrorException::class.java) val dynamicViewModel = DynamicViewModel(mockTransferFile, mockDynamicRepository) //when dynamicViewModel.getDynamicList() //then val errorMessage = LiveDataTestUtil.getValue(dynamicViewModel.errorMessageLiveData) assertThat(errorMessage).isEqualTo("NetWorkErrorException") val dynamicList = LiveDataTestUtil.getValue(dynamicViewModel.dynamicListLiveData) assertThat(dynamicList).isNull() } @Test fun `show show empty tip when not has data`() = runBlocking { //given val mockTransferFile = mock(TransferFile::class.java) val mockDynamicRepository = mock(DynamicRepository::class.java) `when`(mockDynamicRepository.getDynamicList()).thenReturn(null) val dynamicViewModel = DynamicViewModel(mockTransferFile, mockDynamicRepository) //when dynamicViewModel.getDynamicList() //then val dynamicList = LiveDataTestUtil.getValue(dynamicViewModel.dynamicListLiveData) assertThat(dynamicList).isNull() } private fun GetMockData (): ArrayList<Dynamic> {val dynamicList = ArrayList<Dynamic>() dynamiclist.add (Dynamic(1, "What a nice day!" , 1615963675000L)) dynamiclist.add (2, "This series is worth following!" , 1615963688000L)) return dynamicList } }Copy the code
Dynamic Bundle all tests are reported as follows:
DataBinging
Fragment modification is as follows:
@Route(path = "/dynamicBundle/dynamic") @AndroidEntryPoint class DynamicFragment : Fragment() { private lateinit var binding: FragmentDynamicBinding private val dynamicViewModel: DynamicViewModel by viewModels() override fun onCreateView(inflater: LayoutInflater, container: ViewGroup? , savedInstanceState: Bundle?) : View? { binding = FragmentDynamicBinding.inflate(inflater, container, false) binding.btnUpload.setOnClickListener { dynamicViewModel.uploadDynamic() } binding.tvMessage.setOnClickListener { dynamicViewModel.getDynamicList() } dynamicViewModel.getDynamicList() subscribeUi() return binding.root } private fun subscribeUi() { dynamicViewModel.dynamicListLiveData.observe(viewLifecycleOwner, { if (it.isNullOrEmpty()) { showEmptyData() } else { showDynamicList(it) } }) dynamicViewModel.errorMessageLiveData.observe(viewLifecycleOwner, { showErrorMessage(it) }) } private fun showErrorMessage(errorMessage: String) {binding. ShowTip = true // Display exception notification data binding. Tvmessage. text = errorMessage} private fun showDynamicList(dynamicList: List<Dynamic>) { binding.showTip = false val dynamicListAdapter = DynamicListAdapter() dynamicListAdapter.submitList(dynamicList) binding.dynamicList.addItemDecoration(DividerItemDecoration( activity, DividerItemDecoration. VERTICAL)) / / set the display format layout binding. DynamicList. LayoutManager = LinearLayoutManager (activity) Binding. DynamicList. Adapter = dynamicListAdapter} private fun showEmptyData () {binding. ShowTip = true / / show the empty data binding.tvMessage.text = "empty data" } companion object { fun newInstance(): DynamicFragment { val fragment = DynamicFragment() val args = Bundle() fragment.arguments = args return fragment } } } <? The XML version = "1.0" encoding = "utf-8"? > <layout xmlns:android="http://schemas.android.com/apk/res/android"> <data> <import type="android.view.View" /> <variable name="showTip" type="boolean" /> </data> <FrameLayout android:layout_width="match_parent" android:layout_height="match_parent"> <TextView android:id="@+id/tv_message" android:layout_width="match_parent" android:layout_height="match_parent" android:gravity="center" android:text="" android:visibility="@{showTip ? View.VISIBLE:View.GONE}" /> <androidx.recyclerview.widget.RecyclerView android:id="@+id/dynamicList" android:layout_width="match_parent" android:layout_height="match_parent" android:visibility="@{showTip ? View.GONE:View.VISIBLE}" /> <Button android:id="@+id/btn_upload" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_gravity="right|bottom" android:layout_margin="10dp" android:text="upload" /> </FrameLayout> </layout>Copy the code
Adapter modification is as follows:
class DynamicListAdapter() : ListAdapter<Dynamic, DynamicListAdapter.DynamicVH>(DynamicDiffCallback()) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DynamicVH {
return DynamicVH(DataBindingUtil.inflate(LayoutInflater.from(parent.context), R.layout.dynamic_list_item, parent, false))
}
override fun onBindViewHolder(holder: DynamicVH, position: Int) {
holder.binding.dynamic = getItem(position)
holder.binding.executePendingBindings()
}
class DynamicVH(val binding: DynamicListItemBinding) : RecyclerView.ViewHolder(binding.root)
private class DynamicDiffCallback : DiffUtil.ItemCallback<Dynamic>() {
override fun areItemsTheSame(oldItem: Dynamic, newItem: Dynamic): Boolean {
return oldItem.id == newItem.id
}
override fun areContentsTheSame(oldItem: Dynamic, newItem: Dynamic): Boolean {
return oldItem == newItem
}
}
}
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android">
<data>
<import type="android.view.View" />
<variable
name="dynamic"
type="com.cloud.disk.bundle.dynamic.Dynamic" />
</data>
<FrameLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="10dp">
<TextView
android:id="@+id/tv_content"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="left"
android:text="@{dynamic.content}" />
<TextView
android:id="@+id/tv_date"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="right"
android:text="@{dynamic.formatDate}" />
</FrameLayout>
</layout>
Copy the code
See Github for the detailed code
6. Integration acceptance test
- The Dynamic Bundle module released version 1.0.1
Implementation 'com. Cloud. Disk. Bundle: dynamic: 1.0.1'Copy the code
- The injection issue was modified in other modules
Implementation 'com. Cloud. Disk. Bundle: file: 1.0.2' implementation 'com. Cloud. Disk. Bundle: user: 1.0.1'Copy the code
- Running a daemon test
We found an exception in the file generated for binding
Field <com.cloud.dynamicbundle.databinding.DynamicListItemBinding.mDynamic> has type <com.cloud.disk.bundle.dynamic.Dynamic> in (DynamicListItemBinding.java:0)
Method <com.cloud.dynamicbundle.databinding.DynamicListItemBinding.getDynamic()> has return type <com.cloud.disk.bundle.dynamic.Dynamic> in (DynamicListItemBinding.java:0)
Method <com.cloud.dynamicbundle.databinding.DynamicListItemBinding.setDynamic(com.cloud.disk.bundle.dynamic.Dynamic)> has parameter of type <com.cloud.disk.bundle.dynamic.Dynamic> in (DynamicListItemBinding.java:0)
Copy the code
Adding filtering Rules
.*com.cloud.*.databinding.*ItemBinding.*
Copy the code
- Run to check
conclusion
This article describes how the Dynamic module team switched the dynamic widget home page to Kotlin code, refactored it to the MVVM architecture, and added automated testing. After refactoring, the team’s development efficiency and release quality improved significantly. However, local database management is still a lot of SQL statement spelling, very difficult to extend and maintain, and writing automated tests is very difficult.
Next, Mobile Application legacy System Refactoring (15) – Sample database refactoring. We will continue to restructure the database.
CloudDisk example code
CloudDisk
Series of links
Refactoring legacy Systems for mobile Applications (1) – Start
Refactoring legacy systems for mobile applications (2) – Architecture
Refactoring legacy systems for mobile applications (3) – Examples
Refactoring legacy Systems in Mobile Applications (4) – Analysis
Mobile application legacy System refactoring (5) – Refactoring methods
Refactoring legacy Systems for mobile applications (6) – Test
Mobile application legacy System refactoring (7) – Decoupled refactoring Demonstration (1)+ video demonstration
Refactoring legacy Systems for mobile applications (8) – Dependency Injection
Refactoring legacy systems for mobile applications (9) – Routing
Refactoring legacy Systems in Mobile applications (10) — Decoupled Refactoring (2)
Refactoring legacy systems for mobile applications (11) – Product management
Refactoring legacy mobile applications (12) – Compile the debug
Refactoring legacy mobile applications (13) – Compile the debugger
The outline
about
- Author: Huang Junbin
- Blog: junbin. Tech
- GitHub: junbin1011
- Zhihu: @ JunBin