This article briefly discusses how to develop high quality code from the aspects of mobile architecture design, class design, method design, and best practices.
This article is also published on my personal blog
Overview
Quality code, architecture design, and refactoring are wise and require deep knowledge and practical experience, and should not be discussed at will. I just recently did a refactoring of two large modules in my project, plus a refresher on Corpus Codex and Refactoring, so I’m trying to share this with you. Code design itself is also a subject of varying opinions. Please correct any inaccuracies in the following discussion.
The first question to answer is what kind of code is high quality code, and the second question is how to design high quality code.
On a macro level, high quality code is nothing more than: scalable, maintainable, and readable. From the realization level, it mainly includes: reasonable partition level, concise data flow, legal communication between modules, good encapsulation and consistent abstraction of classes, high internal cohesion of entities, low coupling between entities, etc.
Architecture design
For terminal development, the most classic architecture design is MVC, based on which derived architectures like MVVM. I read an article earlier about whether to layer and then module, or module and then layer. Although these two ways make sense, I still suggest dividing modules first, and then layered modules according to MVC or other architectures, because modules have stronger independence for terminals, and a module is basically composed of M, V, and C.
A lot of articles have been written about MVC, MVVM, and other architectures, so this article will not be repeated.
Taking MVC as an example, the communication rules among M, V and C need to be emphasized:
In a project, one person is usually in charge of a module, which leads to different architectures for different modules. MVC, MVP, MVVM and other variants of architectures may coexist in a project, which makes it a bit confusing. It is better to unify and use only one architecture per project.
Class design
In object-oriented programming, the quality of the class directly affects the quality of the code, so what is a well-designed class? Similarly, at a macro level, well-designed classes have good abstraction and encapsulation.
Abstract and encapsulation are two concepts that are very closely related.
Abstract is the ability to focus on a concept while safely ignoring some of the details.
In terms of class, abstraction mainly refers to the abstraction of the interface, that is, to declare what kind of capability the class has and what kind of work it can accomplish. The actual implementation of the class is a black box to the outside world. Encapsulation is more about hiding details, forcing the outside world to not know the details inside the class.
Good abstraction
Abstract interfaces can simplify the use of the class by the outside world, so that it does not need to care about the complex implementation details inside the class. When designing an interface, you need to pay attention to:
-
Interfaces need to present a consistent level of abstraction
The biggest problem with this type of interface is that it doesn’t hide the fact that arrays are used internally, a detail that obviously shouldn’t be exposed in an abstract interface. The downside of this is that if you change the underlying container in the future and use Dictionary instead of Array, the set of interfaces becomes difficult to understand and gradually unmaintainable. The right thing to do is:
-
Interfaces should hide details interfaces should hide details that the business layer does not need to care about. In our project, visitor login and QQ login are supported, and the login module distinguishes the two login modes and notifies the login information related to the business layer with different notification. However, the vast majority of business logic does not care about the specific login method, if wechat and other login methods are added on this basis, the interface will be more difficult to maintain. Therefore, after the reconstruction of the login module, these details are hidden inside the login module and unified callback interface is provided externally.
-
High cohesion whether at class level or method level, high cohesion has always been one of our goals. For classes, the relationship between abstraction and cohesion is very close, and a class with a well-abstract interface generally means a high degree of cohesion.
In our QQ reading project, there is a very important class: QRBookInfo, which is supposed to be a class to represent book information. The external interface should be id(unique number), name(name), author(author), and format(format). However, in reality, the QRBookInfo class contains a lot of information that does not belong to it, such as: reading progress, reading time, adding time to the shelf, grouping ID on the shelf, and so on. Finally, the class has nearly 40 properties exposed to the outside world, reduced to a hobble, and obviously has no high cohesion.
If you want to refactor this Class, you can do so using the Extract Class technique, abstracting responsibilities that do not belong to this Class into a new Class. At least three classes can be extracted from QRBookInfo:
QRBook
— To describe the book itself, abstract interfaces include id, name, author, and format;QRBookShelfItem
Id(book number), addTime(when added to the shelf), categoryId(categoryId), and so on;QRReadingItem
— Used to describe the reading information. The abstract interfaces include ID, readTime and readProgress.
In the project, there is also a bookshelf class: BookShelfViewController, which has more than 5000 lines of code, registers more than 20 notifications, and is almost impossible to maintain. It contains not only the bookshelf logic, but also the bookopening logic. Later, when I needed to use the class in a new project, the smallest changes didn’t work. The refactoring was carried out on the shelf will open the book the logic of all moved to the other class, QRBookShelfViewController this class only focus on dealing with the bookshelf of logic.
As an important principle for managing class complexity, high cohesion should always be a weapon.
-
Make the interface as programmable as possible and reject hidden semantics
From the Code Book: Each interface consists of a programmatic section and a Semantic section. The programmable part consists of data types and other attributes in the interface that the compiler can enforce (checking for errors at compile time), while the semantic part consists of assumptions about how the interface will be used, which cannot be enforced by the compiler.
For example, before calling methodA, the method methodB must be called. This requirement belongs to the semantic part of methodA. Since there is no mandatory compiler check, this hidden semantics is likely to be ignored by the caller, resulting in errors.
Therefore, the interface design should not include the semantic part as far as possible, the semantic interface can be translated into programming interface by extracting new interface or adding implies, and indeed it is inevitable that the semantics should be explained through annotations in the interface. In the preceding example, you can add a new interface methodC and invoke methodB and methodA on the interface to eliminate the semantics of methodA.
-
As you can see from the previous discussion, a class should handle one task around a central responsibility. The class may be designed with high cohesion, but as it is developed, the class is constantly expanded, adding new functionality and data. The cohesiveness and abstractness of the class are likely to be broken in the process. It is not uncommon to see a class in code with a completely different style and abstract interface, often as a result of “tampering”. You’ll also see two, three, four, or more interfaces with the same functionality, but with different parameters. Some interfaces have an extra parameter of type bool, some have a parameter of type block, some have a parameter of type delegate, and behind this kind of roughness there is a danger of duplicate code. In our login module, there are as many as 10 login interfaces and various login callbacks, including blocks, delegates, and Notification. Therefore, it is very common to see all three callbacks in business layer classes, which is very expensive to maintain. I believe that it was not such a mess at the beginning of the design, but was slowly introduced as the development progressed. Therefore, we must think twice before modifying the existing class interface. We must not arbitrarily add and modify the interface at once, otherwise, it is likely to cause the class to lose control over time. If a change is necessary for business purposes, it is best to refer the requirement to the class author for modification.
-
We have a base class controller: QRBaseViewController, which defines an interface for subclasses to customize items on NavigationBar:
Can you see what’s wrong with this interface? Yes, the problem is the last block argument, because the class itself needs to hold the block, and it often needs to pass within the block
self
Another submethod or property is called, and cycle retain occurs. After some investigation found that there are 6, 7 classes exist such problems. (ps: the aboveactionBlock
Property should be usedcopy
Rather thanstrong
)
Good packaging
Abstraction is more about what the class is and what it can do, whereas encapsulation forces the details of the implementation from the outside world. Good encapsulation generally requires the following:
-
Keeping accessibility as low as possible is one of the principles that drives encapsulation. In Objective-C, putting the data members, properties, and methods of the class into anonymous classes as much as possible, and keeping the interface (.h file) files of the class as concise as possible.
-
In Objective-C, properties can be exposed as special data members, but think carefully before doing so. However, the data members of the container class (Array, Dictionary, Set, and so on) must not be exposed to the outside world because they are very low-level implementation details. Once these details are exposed, encapsulation will be severely damaged.
Imagine if you someday needed to change Array to Dictionary. If you expose a container that is mutable, you lose control, error checking, and possibly multithreading.
-
The private implementation details must not be exposed in the header file, which should be concise and only used to express to the outside world what the class can do. Concise headers also reduce the cost of using the class for users of the class.
-
Syntactic encapsulation can be achieved through private and anonymous categories, but semantic encapsulation is harder to control. Here are some examples of semantically breaking encapsulation in the completecode:
- Don’t call the InitialixeOpertaions() subroutine of class A, because you know that the PerformFirstOperation() subroutine of class A calls it automatically;
- Do not call the database.connect () subroutine before calling the Employee.retrive (database) because you know that the employee.retrive (database) will Connect to the database when the database connection is not established;
- Do not call the Terminate() subroutine of class A because you know that the PerformFinalOperation() subroutine of class A already calls this method.
The problem with the above example is that the caller does not depend on the abstract interface of the class, but on the internal implementation of the class. When you look at the internal implementation of a class to see how it can be used, you don’t program against the interface, you program against the internal implementation through the interface. This is a very dangerous move, the encapsulation of the class is broken, and can cause serious errors if the internal implementation of the class changes.
-
Low coupling The degree of association between two classes is called coupling, and low coupling has always been our goal. The encapsulation of a class directly affects the degree of coupling. If the class exposes too much details, it will undoubtedly increase the degree of coupling between the class and the user. Ideally, a class is a black box to the caller, who can use the class through its interface without having to go deep into the implementation details of the class, which requires good abstraction and encapsulation. On the other hand, if you need to know the internal implementation when using a class, there must be a high degree of coupling between the two. The relationship between abstraction and coupling is also very close, and good abstraction and encapsulation generally have low coupling.
In the project, there is a class for representing bookshelf groups: QRCategoryViewController, which not only handles the business logic related to the groups, but also reads and stores the user group data. The shelf also needs to know the current group information when deciding which books to display, so a QRCategoryViewController instance must also be initialized when the shelf is initialized to get the current group information. In this case, the QRCategoryViewController class’s lack of abstraction and encapsulation results in a high degree of coupling between the QRCategoryViewController and BookShelfViewController classes, which are rarely coupled. When refactoring, move the management of group data to a new class, QRCategoryManager, to completely decouple the QRCategoryViewController from the BookShelfViewController.
Inheritance is used carefully and well
Inheritance, one of the three main features of object orientation, is of great importance. If used well, it can simplify the program, whereas it can increase the complexity of the program. Before deciding to use inheritance, you need to think carefully about whether “is… If not, then inheritance is not the right choice. In this case, consider “has… Is the a”(inclusive) relationship more appropriate? Some classic descriptions of inheritance from code Conferences:
- The purpose of inheritance is to simplify code by defining a base class that provides common elements for multiple derived classes.
- The base class sets both expectations about what the derived class will do and limits how the derived class can operate.
- The derived class must be usable through the interface of the base class without the user knowing the difference.
- If the derived class is not prepared to fully comply with the same interface contract defined by the base class, inheritance is not the right choice. (Did you do it ^-^)
The project’s bookshelf class, BookShelfViewController, can be found everywhere in the code because it is designed for both iPhone and iPad: if(IS_IPHOEN)… Else. During refactoring, the iPhone and iPad UI logic is extracted into two subclasses, and the data-related logic they share (e.g., cloud bookshelves, chapter updates, etc.) is placed in the base class.
QRAuthenticatorDelegate
QRQQAuthenticator
QRWeChatAuthenticator
QRGuestAuthenticator
QRAuthenticatorDelegate
Because of the dynamic nature of Objective-C, its member functions are inherently virtual, and when the inheritance system is too complex, function overloading can further complicate the problem. Think twice before using inheritance. Maybe include or interface is a better choice. Improper use can add complexity to the program.
Isolate errors
The hull is equipped with isolation pods, and the building is equipped with firewalls, all of which are used to isolate danger. In defensive programming there is also a need to isolate risks. At the system level there can be specialized classes for handling errors and isolating risks (from the corpus of code) :
Errors in the program can be divided into two categories:
- Unexpected errors that should never have occurred (possibly because the program is buggy);
- An unusual or unusual condition that does not occur often.
You should use Assertions and error handling, respectively. Going back to the previous issue, error handling or assertions can be used as appropriate in the public interface of a class, whereas assertions can be used directly in the private methods of a class. (ps: Assertions are primarily used to quickly detect code errors during development)
Methods to design
“Code is written for humans first and machines second,” and this should be considered even more in method design.
Give it a good name
Naming is a skill that is essential for code maintainability and readability! Note that the method name should emphasize what the method does, not how it does it. In addition, method names are subject to memory management constraints: any method whose column name is prefixed returns an object, then the method caller holds that object: alloc, new, copy, and mutableCopy. Here’s an even stricter rule: Any method prefixed with init must follow the following rules:
- The method must be an instance method;
- The method must return type
id
Or its parent class, superclass, subclass; - The object returned by this method cannot be autorelese, that is, the method caller holds the returned object.
This is especially true for mixed-arc and MRC projects, as detailed in my previous article Inside Memory Management for iOS.
High cohesion
One way to do one thing! And the method name is very elegant to express! But in practice, many methods do much more, some of which are 100 or even hundreds or thousands of lines long. The consequences of a low-cohesion approach are:
- Difficult to maintain;
- There are too many things to do to have a good method name;
- This often leads to duplicated code, so imagine if methodA did task1 and task2, and methodB did task1 and task3, then the code associated with task1 would be duplicated.
Extracts the body of a compound statement into submethods
Will the if… else… The body of, for, switch and other statements is extracted as submethods, and readability is improved with good method names.
These two pieces of code do the same thing, loading the grid or line mode bookshelves depending on the user’s choice, but the readability is quite different.
Open the parentheses in the body of a compound statement
You’ll often see code like this:
In addition, there are different conventions about the format of if statements, but it is strongly not recommended to do so (in fact, a lot of if statements are written like this, because Xcode’s default code completion is like this) :
Extract complex conditional expressions into submethods or well-named assignment statements
If the conditional expression is too complex for readability, it should be extracted as a Boolean method if the expression is repeated, otherwise it can be assigned to a well-named variable. We have a rule on the shelf that for a custom group on the iPhone, if there are books in that group, you need to add an import book button at the end of the list of books.
Intermediate variable
Just because we said that complex Boolean expressions can be converted to well-named intermediate variables doesn’t mean you can add them at will. One more intermediate variable means one more complexity and one more state. For intermediate variables that are used less often, expressions can be directly inlined into statements.
indexPath.row
row
The same is true for classes, where it is common to introduce state member variables to solve certain problems, but be careful. Often state variables involve when to set and when to reset, adding complexity to the class and increasing the degree of coupling between methods within the class.
Prioritize error situations
If the method needs to check parameters or other conditions, put the check operation at the beginning of the method, and return immediately if the condition is not met.
The switch statement does not have a default branch
In particular, when using the switch to process enums, don’t write the default branch. This will cause the compiler to warn you if an enum value is not processed. There is no warning when there is a default branch.
Best practices
This section focuses on a few points of interest during the development process that are not necessarily design-related.
Make full use of generics
LLVM 7.0 supports generics at the compiler level, and the addition of generics support to the common containers in the system is a major benefit.
Use Nullable and NonNULL properly
Like generics, NullAbility is a feature supported by the compiler LLVM that describes whether a value in an interface can be nil. For nonnull’s interface, the compiler emits a warning if nil is passed. Similarly, NullAbility’s ability to self-describe an interface is more meaningful than Warning.
Use objective-C dynamics
The dynamic nature of Objective-C gives us a lot of room for imagination. Typical examples of using Objective-C dynamics are JSPatch, MJExtension, Swizzling, KVO, AOP, and so on.
- JSPatch is very popular for hot patches, needless to say;
- MJExtension is mainly used to automatically convert JSON into Objective-C objects, which can greatly improve development efficiency.
- The use of Swizzling is much more common, and we have a typical use: UIViewController with Swizzling
viewWillAppear:
Method and UIButtonsendAction:
Method, which records the user’s operation path and reports the operation path together with the crash log. This method solves the crash of many complicated diseases. - KVO is very useful and controversial, and can simplify the code. For details, see my previous article: KVO Rambles;
- AOP is mainly used to separate out unrelated logic, such as typing logs, etc. AOP ideas can be used when refactoring login modules. See my previous article: AOP Ramblings;
Managing queues
GCD, as one of the ways to realize multithreading, is simple and convenient to use in combination with blocks. Therefore, there is often a lot of usage of dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0), ^{in the code. Although the system controls the number of threads used by the GCD, when a thread is locked, new threads are created to be used by other blocks. Therefore, the system may create a large number of threads at a certain time, which will eventually preempt the main thread and affect the execution of the main thread. Therefore, these queues can be managed uniformly if necessary, YYDispatchQueuePool is a good implementation.
The submethods of a class are arranged by function
So, although we’re looking for highly cohesive classes, the classes that UI interacts with and UIViewController in general are more complex in terms of functionality, so it’s better to arrange methods by functionality, and put methods for the same functionality together. For example, these methods of UIViewController should be fixed at the top of the list:
#pragma mark -
Use category correctly
Categories extend the functionality of existing classes. There are several points to be noted when using categories:
- A category exists independently of the main class, which means deleting a category does not affect the main class.
- You can’t add member variables or properties to the main class header because you need them in a category. You can do that with an Associated Object.
- You cannot override a superclass method in a category. When the same method is defined in the main class and the category, the category method overrides the main class method. For example, it is defined in both main and category
dealloc
Method in the main classdealloc
Method will be overridden. This is especially not possible in a tripartite library with no source code.
Avoid macros
The first thing to say is that being able to use macros rather than hard coding is something to be encouraged. However, macros are often criticized for their shortcomings, including non-strong typing, text replacement during precompilation, and frequent errors caused by macro definitions without parentheses. In most cases, macros can be replaced with const or submethods. Here’s an interesting question: Is there a problem with the following macro definition:
Refused to warning
Warning indicates that the program is sick. Although some of the warnings seem insignificant, once the number of warnings increases, some important warnings may also be hidden and difficult to find.
For those that really don’t matter, you can hide them by #pragma clang Diagnostic Ignored (which is not recommended).
summary
In today’s mobile Internet era, many projects are pushed forward by requirements, in pursuit of agile development, rapid iteration, and small steps, with little regard for code quality. As the saying goes: “sharpen your knife and cut your wood by mistake.” A little more thought before you start, and a little less willfulness in the development process, will lead to higher quality code. In short, high quality is easier said than done. Not only must have rich practical experience, solid foundation of knowledge, but also must have a persistent heart!