The topic of componentization naturally came to my mind recently when I was thinking about how to maintain efficient team output in the context of team expansion and increasing number of projects. Review the discussion about componentization in iOS development circle some time ago, and here do some sorting and my own thinking.

The driving force of componentization

Before we start talking about componentization technical solutions, consider the driving force behind componentization. Let’s assume that the company has three projects A, B and C operating in appstore. The three projects are respectively developed and maintained by Team A, Team B and Team C. Each Team is composed of five engineers, one of whom acts as the Team Leader, and one Leader and one architect are assigned to the three teams. At this time, the company decided to open up a new business area, set up project D, and hired five new engineers to develop. At this time, the primary work of architects and leaders is to select technical solutions so that project D can be started quickly and stably, while avoiding the possible side effects of the run-in period of new engineers. If there is A component-based design, project D can reuse some of the components of A, B and C, such as [user login], [memory management], [log log system], [personal Profile module], etc., and new members can quickly get started on the basis of the existing Codebase. If there is no componentalization process, it will be quite painful to separate independent modules such as [user login] from A, B and C. The highly coupled code is intertwined, time-consuming and laborious to reuse, which is A waste of team manpower and affects the overall project progress. Our goal is to reuse highly abstract units of code.

Back to the technical scheme of componentization, Limboy shared a technical scheme of componentization of Mogujie at first, and then Casa proposed different opinions. Limboy further optimized his scheme based on Casa’s feedback, and finally Bang made a clear summary on the basis of the first three articles. After reading through, I benefit a lot, and the problems faced by componentization and the possible solutions become clearer.

Component Definition

First we need to define a component, whether we call it a component or a module, but let’s just say we’re talking about a separate business or functional unit. As for the granularity of this unit, engineers need to grasp. When we write a class, we will keep in mind that high cohesion and low coupling principles to design the class, when involving multiple interactions between classes, we will use SOLID principles, or the existing design patterns to optimization design, but in the realization of the complete business module, it is easy to forget for this module to do the design thinking, the greater the particle size, The more difficult it is to make a fine and stable design, I’ll think of this granularity as component granularity for now. A component is a functional unit composed of one or more classes that can completely describe a business scenario and be reused by other business scenarios. Components are just like the components that individuals bought when assembling computers in the PC era, such as memory, hard disk, CPU, monitor, etc. Any one of these components can be used by other PCS.

So a component can be a broad concept, not necessarily a page jump, but other service providers without UI attributes, such as logging services, VOIP services, memory management services, and so on. To put it bluntly, our goal is to stand in a higher dimension to encapsulate functional units. Further classification of these functional units can make a more reasonable design in specific business scenarios. Based on my personal experience, components can be divided into the following categories:

  1. A separate business module with UI attributes.

  2. A separate business module without UI attributes.

  3. Does not have the function module of the service scenario.

The first category is Limboy, the components Casa discusses a lot, which have very specific business scenarios. For example, the home page module of an App retrieves the list from the Server and presents it through the Controller. Such modules typically have an entry Controller that can be pushed or presented as an entry. Most scenes of e-commerce apps can be classified into this category. Controller, as the basic unit of the Page, has a high degree of similarity with Web Page. I think this is why Mogujie adopts URL registration to mark each local Controller with URL, which is not only convenient for local jump, It can also support the Server to send jump instructions, which is perfect for the operations team. In theory, componentization has nothing to do with urls themselves. Urls are just a way to access components. There are limitations to this access, such as the inability to pass non-primitive data like UIImage. It is debatable how many side effects this limitation will bring in the e-commerce APP business environment. According to my experience, there are not many scenarios of transferring complex objects between complete and independent business modules, and even if there are, they can be transferred through memory cache or disk cache. If I remember correctly, the jump between different business modules of Tmall wireless client was also realized through URL. There was an intermediate class similar to Router for URL parsing and jump, and no Mediator for further encapsulation of components. To access components in the way of URL registration, under the background of small side effects and convenient business operation, Mogujie’s choice may not be counted as “wrong direction”.

The second type of business module does not have UI scenarios, but is relevant to specific businesses. For example, for the log reporting module, the APP may need to count the path of each Controller in the user registration module to analyze the user loss rate at each step. This type of business module can be very tricky to express and access using URLS. Imagine enabling logging with a code call like this:

[[MGJRouter sharedInstance] openURL:@"mgj://log/start" withParams:@{}];
Copy the code

This is also the irrationality of Mogujie to realize componenization by URL scheme. According to Casa’s classification, components are called into remote and local. The call of this log service is a local type of call, and it is quite a detour to mark such local service with URL.

The third type of module is independent of the specific business scenario, such as the Database module, which provides data reading and writing services and includes multi-threaded processing. For example, the Network module provides the way to interact with Server data, including concurrency control, Network optimization and other processing. The image processing class, for example, provides asynchronous drawing of rounded heads. These modules can be used by any module, but are not related to any business. This component belongs to the basic service provider of our APP, which is more like an SDK or Toolkit. I don’t know how Mogujie handles this type of component access, it’s obvious that URL access is not suitable. Many of the well-known third-party libraries we use with Pods fall into this category, like FMDB, SDWebImage, etc.

Let’s take a look at the capabilities and strengths of each solution for the above three components.

As a developer, it is particularly important to have a learning atmosphere and a communication circle. This is my iOS communication skirt: [891 488 181], no matter you are small white or big ox welcome to enter, share BAT, Ali interview questions, interview experience, discuss technology, we exchange learning and growth together!

Mushroom Street URL scheme

First of all, it can be seen from the above analysis that there is no major problem with this scheme for the first type of components, but it is not suitable for the second and third type of components.

When the URL scheme is started, there is a module initialization process, which registers various services provided by the module itself:

[MGJRouter registerURLPattern:@"mgj://detail?id=:id" toHandler:^(NSDictionary *routerParameters) {
    NSNumber *id = routerParameters[@"id"];
    // create view controller with id
    // push view controller
}];
Copy the code

The user of the component makes the call by passing in a specific URL Pattern:

[MGJRouter openURL:@"mgj://detail?id=404"]
Copy the code

Bang raises three questions about this approach:

  1. There needs to be a place to list what URL interfaces are available in each component. Mogustreet has a back office.

  2. Each component needs to be initialized and a table needs to be kept in memory. If there are too many components, there will be memory problems.

  3. The format of the parameter is not clear, it is a flexible dictionary, and there needs to be a place to look up the format of the parameter.

The first problem, the most obvious, is that the user of the component must complete the invocation by referring to the Web document and then writing a string. This approach to component invocation does have some efficiency issues.

In the second question, I did not understand the table and memory problems. I calculated the extra memory cost of the Router. An NSMutableDictionary is used to store Mapping. There are many scenarios in iOS App that use Dictionary, and the memory cost caused by Dictionary mainly depends on the key and value it strongly references. Casa hardcode the actions as strings, which will also cause these strings to live in memory. In essence, the function symbol in the Text area is replaced by the string in the Data area. This part of the memory consumption is also within the normal range. Finally, the handler block is used for this part of the overhead, which is not fundamentally different from a function call. Maybe there’s something else I haven’t considered.

The third problem is similar to the first one in that you need to consult the documentation for the hardcode parameter name.

In my opinion, the essence of this URL registration method is to replace the original function declaration with a string. String avoids header references and achieves compiler decoupling, but at the cost of no interface and parameter declarations, which affects the efficiency of the component user.

The MGJRouter also acts as Mediator, but mostly passes data between the component and its user. If the Router parses the URL itself, it can also add intermediate logic to determine whether the component exists.

Casa’s Mediator scheme

Casa first pointed out the problem that Mogujie scheme confused local call and remote call before proposing Mediator scheme. This makes sense, and makes componentized usage scenarios more explicit.

Casa proposed Mediator scheme, in which Mediator undertook most of the component access codes, which can be shown as follows:

The dotted arrows in the figure represent Casa’s “process of discovering services through Runtime”. Bang also believes that the dotted arrows are decoupled and components can be connected through Runtime without the need for import headers.

Here I am confused about the concept of “discovering services”. The WSDL I know can be used to discover specific services provided by Web Sevice. You need to send a Web request to get the WSDL file, which can be called “discovering services”. But using the OC runtime mechanism to do function calls with strings is a way of “using services”. You still need additional documentation from the component side to describe what services are available, or where to “find” the strings? So the Runtime doesn’t find the service, it just calls the service in a different way, using [Object performSelector:@””] instead of [Object sendMessage]. Of course the Runtime approach doesn’t look coupled.

The import header file is a type of coupling, because missing a header file can cause compilation errors. Business coupling is another dimension of coupling, I don’t think business coupling can be eliminated much, you need to use component services because business needs every one, if the component side changes the business interface, even if you can compile it, the component you call won’t work. You can call it differently, but the call itself is there, and the business coupling that I’ve shown with the dotted arrows in the figure above cannot be undone. It can be “weakened” syntactically, in code tricks, but this “weakening” has its costs.

This cost is the same as the cost of the Mogustreet URL registration, replacing function and parameter declarations with String, and working with runtime to complete component calls. This approach also makes access more difficult. Let’s take a look at the engineering structure of Casa Demo:

Mediators provide categories to the user of the component to expose the supported service, making it clear to the user. While Mediators are actually maintained by the component user, let’s look at the code inside them. CTMediator + CTMediatorModuleAActions. M of complete a service access code is as follows:

//CTMediator+CTMediatorModuleAActions.mNSString * const //CTMediator+CTMediatorModuleAActions.m NSString * const kCTMediatorTargetA = @"A"; NSString * const kCTMediatorActionNativFetchDetailViewController = @"nativeFetchDetailViewController"; - (UIViewController *)CTMediator_viewControllerForDetail { UIViewController *viewController = [self performTarget:kCTMediatorTargetA action:kCTMediatorActionNativFetchDetailViewController params:@{@"key":@"value"}]; If ([viewController isKindOfClass:[UIViewController class]]) { You can choose push or present Return viewController from the outside; Return [[UIViewController alloc] init]; return [[UIViewController alloc] init]; }}Copy the code

Target, Action, and Params are all represented by strings. There is a problem:

If the component usage team is in Hangzhou and the component development team is in Beijing, how do I get these strings?

If you are using a Web document, you need to manually type Target, Action, and each Param according to the document. None of them can be wrong. When passing in a Param value, you need to see whether the other party needs a long or NSNumber, because there is no type checking, you can only rely on the naked eye. If there is no documentation, users need to look at the component’s header file themselves and translate the interfaces exposed in the header file into strings. This approach seems inefficient and error-prone, especially if there are a large number of components.

There are two problems under DemoModule.

The first is that target needs hardcode again to parse the component param:

- (UIViewController *) Action_nativeFetchDetailViewController params: (NSDictionary *) {/ / because the action from belong to ModuleA, So the action directly can use all the statements in the ModuleA DemoModuleADetailViewController * viewController = [[DemoModuleADetailViewController alloc] init]; viewController.valueLabel.text = params[@"key"]; return viewController; }Copy the code

The same @ “key” appears at the same time on both the component side and the component caller. I don’t know how to coordinate this hardcode efficiently. I may still have to rely on web documents, but looking up documents is an inefficient process for programmers to write code.

The second problem is that parameters are passed in from a Dictionary, and I don’t know how many SDK or component teams choose to define “function inputs” in Dictionary fashion. Using Dictionary is very consistent with Casa’s style of “de-modelization”. I have always been skeptical about Casa’s “de-modelization”. I have read the explanation of “de-modelization” on its blog carefully. Also read Martin Fowler’s article against the Anemic Domain Model. Martin Fowler does not oppose the use of the Model, but advocates having the Model assume more Domain Logic. In my own coding experience, it’s much more intuitive to describe data using a model than a Dictionary, and it’s much more intuitive to use the function entry parameter declarations shown here. Third-party libraries rarely provide an interface that takes Dictionary as an input.

As you can see from the above two issues, Mediator’s approach does not reduce the component user’s access effort. Instead, it introduces additional labor costs to the Hardcode String by reducing the coupling required to use Runtime.

Protocol + Version

Bang drew two interesting pictures as he combed through the options:

The first diagram looks chaotic and coupled. The second image uses Mediator to make the structure much clearer.

These two graphs actually express a classic topic in the industry: Distributed Design vs. Centralized Design

The first image looks like a lump, but it’s typical Distributed Design. The second image is more consistent with the “aesthetic” of the human brain, and the Centralized is structurally easier for the brain to sort out. When it comes to engineering scenarios, one is not always better than the other.

This is a classic scenario of Distributed Design Vs Centralized Design. Centralized Design enables us to “instantaneously” calculate the shortest path between two routers using a central router with infinite cache space and no bottleneck in packet processing capacity, but obviously no such router exists. The reality is that the information of surrounding routers cached by each router is very limited, and packet processing capacity is also very limited. As a result, each router can only calculate the shortest path within its cognitive range. However, this is the Design of Distributed Design used in today’s Internet.

Centralized design increases the burden on the central Node when the number of nodes increases. Mediator is the central node. The workload has not decreased and the future risks are unpredictable.

I personally prefer Distributed Design in terms of componentization. Each component “cleans its own house”, with a formal protocol declaration, plus strict version control to provide component services. Call it the Protocol+Version scheme.

This scheme can be explained in two parts.

Protocol

Choosing Protocol as the access method provides a degree of coupling, as @import is required after all. Protocol provides coupling between the Runtime and the.h file of the class. Compared with The Runtime, protocol has the compiled coupling of header files, but the business description is clearer. Function names and parameter types are clearly defined, and many times you can understand how components are used without even looking at the documentation. I personally prefer protocol as the way to access and use components. We use two types of protocol to regulate components.

Component Common Protocol

Different component types are connected in different ways.

The third category of components is the base component, similar to a toolbox. Most of the third-party libraries we use belong to this category. We usually use CocoaPods to access the third-party libraries directly. If we are more careful, we can make another layer of encapsulation for these third-party library interfaces, which will save more effort when upgrading or replacing them. Large factories typically write their own base components and put them in private Pods sources. These components tend to be stable, suitable for integration in a Framework manner, and we do not need to do special processing when we plug in.

Components of the first and second categories have business scenarios and business states, and their access is closely related to the business, requiring special protocols to define their behavior. This protocol is used to specify the common behavior of each component, as well as some callback handling for the full life cycle of the component. Similar to:

@protocol IAppModule <NSObject>
//module life cycle
- (void)initModule;
- (void)destroyModule;
//common behavior
- (NSString*)getModuleVersion;
- (BOOL)handleUrl:(NSString*)url;
- (UIViewController*)getDefaultController;
@end
Copy the code

Each component can be compiled separately as a separate App, so it should experience the full life cycle of an iOS App.

At the time of didFinishLaunchingWithOptions initModule.

Call destroyModule when you exit or need to destroy a component.

As for applicationWillResignActive, applicationWillEnterForeground etc. To handle among components by notice.

For the scenario of external URL jump, use the following code to handle:

for (int i = 0; i < _modules.count; i ++) { id<IAppModule> module = _modules[i]; if ([module respondsToSelector:@selector(handleUrl:)]) { BOOL ret = [module handleUrl:url]; if (ret) { break; }}}Copy the code

The Url Pattern needs a unified web background management page, and each component needs to register its own Controller.

For scenarios that require Controller access (the first type of component has entry Controller), do as follows:

id<IAppModule> homeModule = [HomeModule new]; [homeModule initModule]; if ([homeModule respondsToSelector:@selector(getDefaultController)]) { UIViewController* defaultCtrl = [homeModule getDefaultController]; if (defaultCtrl) { [self.navigationController pushViewController:defaultCtrl animated:true]; }}Copy the code

As more services are connected and business components become more diverse, we may need to add more common interfaces to the IAppModule to regulate behavior.

Component Service Protocol

Each component needs its own business protocol, which completely describes the list of services provided by the component. You don’t need to consult additional documentation to get an overview of the types and details of the business, thanks to OC’s verbose to the point of verbose method signatures. This is where Protocol has an advantage over Runtime. For example, we need to import the shopping cart component:

//IOrderCartModule.h
@protocol IOrderCartModule <NSObject>
- (int)getOrderCount;
- (Order*)getOrderByID:(NSString*)orderID;
- (void)insertNewOrder:(Order*)order;
- (void)removeOrderByID:(NSString*)orderID;
- (void)clearCart;
@end

//OrderCartModule
@interface OrderCartModule : NSObject <IAppModule, IOrderCartModule>
@end
Copy the code

Simply @import IOrderCartModule, @import OrderCartModule to start using cart components.

id<IOrderCartModule> orderCart = [OrderCartModule new];
int orderCount = [orderCart getOrderCount];
lbOrderCount.text = @(orderCount).stringValue;
Copy the code

Component generation code needs to be managed uniformly, so we need a ModuleManager to manage incoming business components (components that follow the IAppModule), including component initialization, lifecycle management, and so on.

//ModuleManager.h
@interface ModuleManager : NSObject
+ (instancetype)sharedInstance;
- (id<IOrderCartModule>)getOrderCartModule;
- (void)handleModuleURL:(NSString*)url;
@end
Copy the code

The ModuleManager is only responsible for managing component declaration cycles and general component behavior. It does not register urls as MGJRouter does, nor does it need to reencapsulate interfaces as Mediator does.

Consider the coupling that comes with this component connection:

In addition to introducing iOrderCartModule. h, orderCartModule. h, some models are also referenced, such as

- (void)insertNewOrder:(Order*)order;
Copy the code

This involves the description of complex business objects, and it is a choice between introducing order.h or using NSDictionary to describe them. Personally, I prefer to use model for the same reason that protocol is used instead of Runtime, which is clearer and more intuitive. There is no denying that the coupling degree will be higher in this way. Let’s look at the impact of the actual project on our development.

Assuming that the shopping cart component was developed by team D, the Order definition for the first release is as follows:

@interface Order : NSObject
@property (nonatomic, strong) NSString*                 orderID;
@property (nonatomic, strong) NSString*                 orderName;
@end
Copy the code

Order 2 adds the ability to query the generation time of an Order:

@interface Order : NSObject
@property (nonatomic, strong) NSString*                 orderID;
@property (nonatomic, strong) NSString*                 orderName;
@property (nonatomic, strong) NSNumber*                 createdDate;
@end
Copy the code

This scenario has little impact on the access party of the component. It is a new feature and the use of createdDate depends on the business progress of the access party.

But if you change the orderID’s management:

@interface Order : NSObject
@property (nonatomic, strong) NSNumber*                 orderID;
@property (nonatomic, strong) NSString*                 orderName;
@end
Copy the code

The NSString is replaced with NSNumber. This change has a significant impact, and the component access side needs to make a type change wherever orderID is used. Does this mean that the import Model approach is actually less efficient? Assuming that we use NSDitionary to describe Order data, the access party cannot find the Order change through compilation at the first time. Instead, it needs debugging to find the Type change under the crash scenario of Runtime, which is less efficient than using Model. Because business changes in this scenario must be adapted, we need a way to quickly locate component changes to update components. Service access itself is “intrusive”. Even if the isolation is made at the language level, the change of components will still affect the change of access parties. Otherwise, how can the new business logic take effect?

As you can see, our focus is not on reducing business coupling at the language level, but on regulating component evolution and change through proper processes, which is the second part of our component solution, Version Control.

Version Control

We can use Semantic Versioning to regulate the version evolution of our components, and then work with CocoaPods to configure the version. Semantic Versioning is defined as follows:

Given a version number MAJOR.MINOR.PATCH, increment the:

MAJOR version when you make incompatible API changes,

MINOR version when you add functionality in a backwards-compatible manner, and

PATCH version when you make backwards-compatible bug fixes.

Additional labels for pre-release and build metadata are available as extensions to the MAJOR.MINOR.PATCH format.

Therefore, the above changes to the orderID type require the Major version number to be changed. The component access party can arrange the update as soon as it sees the Major update.

Finally, we can get the following architecture diagram:

The three components at the bottom are our overall library of components, from which any new project can select the appropriate components as codebase.

Another topic worth mentioning in this category is component granularity, when we need to reabstract a new component. I personally think that not all business modules are suitable for abstraction into components. Now mobile Internet companies’ business changes very quickly, and most of the business will not be reused. It is not cost-effective to spend energy on packaging design for modules that are not reused, and it will cause component library expansion and maintenance problems. As for which business needs to be abstracted into components, the team leaders and the chief mobile architect need to communicate and negotiate. Componentized packages for different tabs within a small five-person team are unnecessary and can slow down the project. For example, the home page module in Project A is very unlikely to be reused by other projects, and componentization has its costs.

Dependency Hell

Dependency Hell can easily occur when there are too many components, such as the shopping cart component and the payment component that depend on different versions of the log component. Resolving these Dependency conflicts can cost additional team communication time, and can reduce development efficiency due to componentalization.

conclusion

It’s a good idea to go back to the original Protocol. Runtime is a good idea, but many languages come with runtime attributes. Of course, I don’t have a lot of actual experience in componentization. All the above is theoretical analysis. In my opinion, whether componentization is needed in specific business environment is a question worth weighing. What are the “efficiency” benefits of componentization for small startup teams?