Home Record of Practical Application of Design Patterns
Post
Cancel

Record of Practical Application of Design Patterns

Record of Practical Application of Design Patterns

Record of problem scenarios encountered and solutions applied when encapsulating Socket.IO Client Library requirements using Design Patterns

Photo by [Daniel McCullough](https://unsplash.com/@d_mccullough?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText){:target="_blank"}

Photo by Daniel McCullough

Preface

This article is a record of real-world development requirements, where Design Patterns were applied to solve problems. The content will cover the background of the requirements, the actual problem scenarios encountered (What?), why Design Patterns were applied to solve the problems (Why?), and how they were implemented (How?). It is recommended to read from the beginning for coherence.

This article will introduce four scenarios encountered in developing this requirement and the application of seven Design Patterns to solve these scenarios.

Background

Organizational Structure

This year, the company split into Feature Teams (multiple) and Platform Team; the former mainly focuses on user-side requirements, while the Platform Team deals with internal members of the company. One of their tasks is to introduce technology, build infrastructure, and ensure systematic integration to pave the way for Feature Teams when developing requirements.

Current Requirement

The Feature Teams needed to change the original messaging feature (fetching message data by calling APIs on the page, requiring a refresh for the latest messages) to real-time communication (receiving the latest messages instantly, and sending messages).

Platform Team’s Work

The Platform Team’s focus was not only on the immediate real-time communication requirement but also on long-term development and reusability. After evaluation, it was deemed essential to have a WebSocket bidirectional communication mechanism in modern apps. Apart from the current requirement, there will be many future opportunities to use this mechanism. With the available resources, efforts were put into designing and developing the interface.

Goals:

  • Encapsulate communication between Pinkoi Server Side and Socket.IO, including authentication logic
  • Simplify Socket.IO operations, providing an extensible and user-friendly interface based on Pinkoi’s business requirements
  • Standardize the interface for both platforms (Socket.IO’s Android and iOS Client Side Libraries have different functionalities and interfaces)
  • Feature side does not need to understand Socket.IO mechanisms
  • Feature side does not need to manage complex connection states
  • Future bidirectional communication requirements using WebSocket can be directly implemented

Time and Resources:

  • One developer each for iOS and Android
  • Development timeline: 3 weeks

Technical Details

This Feature will be supported on Web, iOS, and Android platforms. WebSocket bidirectional communication protocol will be introduced for implementation, with the backend expected to directly use Socket.io service.

Firstly, Socket != WebSocket

For more information on Socket and WebSocket and technical details, refer to the following two articles:

In short:

1
2
Socket is an abstract encapsulation interface for the TCP/UDP transport layer, while WebSocket is a transmission protocol at the application layer.
The relationship between Socket and WebSocket is like that of a dog and a hot dog, they are unrelated.

Socket.IO is a layer of abstract operation encapsulation for Engine.IO, which encapsulates the use of WebSocket. Each layer is only responsible for communication between the upper and lower layers and does not allow operations to pass through (e.g. Socket.IO directly operating WebSocket connections).

In addition to basic WebSocket connections, Socket.IO/Engine.IO also implements many convenient and useful feature sets (e.g. offline event sending mechanism, similar to HTTP request mechanism, room/group mechanism, etc.).

The main responsibility of the Platform Team is to bridge the logic between Socket.IO and Pinkoi Server Side for use by the upper Feature Teams during development.

Socket.IO Swift Client has pitfalls

  • Has not been updated for a long time (latest version is still in 2019), unsure if it is still being maintained.
  • Client & Server Side Socket IO Version must be aligned, Server Side can add {allowEIO3: true} / or Client Side specify the same version .version Otherwise, it won’t connect.
  • Naming conventions, interfaces, and many examples on the official website do not match.
  • Socket.IO official website examples are based on web, but in reality, the Swift Client may not fully support the functionalities written on the website. In this implementation, we found that the iOS library did not implement the offline event sending mechanism (we implemented it ourselves, please continue reading)

It is recommended to experiment with the mechanisms you want to use before adopting Socket.IO.

Socket.IO Swift Client is based on Starscream WebSocket Library, and can be downgraded to use Starscream if necessary.

1
Background information supplement ends here, let's move on to the main topic.

Design Patterns

Design patterns are simply solutions to common problems in software design. You don’t necessarily have to use design patterns to develop; design patterns may not be applicable to all scenarios, and there’s no rule against deriving new design patterns on your own.

[The Catalog of Design Patterns](https://refactoring.guru/design-patterns/catalog){:target="_blank"}

The Catalog of Design Patterns

However, existing design patterns (The 23 Gang of Four Design Patterns) are common knowledge in software design. Just mentioning an XXX Pattern will trigger a corresponding mental blueprint in everyone’s mind, without the need for much explanation. It is easier to understand the context for future maintenance, and these methods have been validated by the industry, so there’s no need to spend time examining object dependency issues. Choosing the right pattern for the right scenario can reduce communication and maintenance costs, and improve development efficiency.

Design patterns can be combined, but it is not recommended to modify existing design patterns, forcibly apply patterns that do not fit, or apply patterns that do not belong to the category (e.g. using the Chain of Responsibility pattern to create objects), as it may lose its meaning and potentially cause misunderstandings for future maintainers.

Design Patterns mentioned in this article:

I will translate the content into English:


This article focuses on the application of Design Patterns, not the operation of Socket.IO. Some examples may be simplified for descriptive purposes and may not be applicable to real Socket.IO encapsulation.

Due to space limitations, this article will not provide detailed introductions to the architecture of each design pattern. Please click on the links for each pattern to understand its architecture before continuing to read.

Demo Code will be written in Swift.

Scenario 1.

What?

  • Reuse the same Path to obtain the same object when requesting a Connection on different pages or Objects.
  • The Connection should be an abstract interface and should not directly depend on the Socket.IO Object.

Why?

  • Reduce memory overhead and the time and cost of repeated connections.
  • Reserve space for future replacement with other frameworks.

How?

  • Singleton Pattern: A creational pattern that ensures only one instance of an object.
  • Flyweight Pattern: A structural pattern that shares the state of multiple objects and reuses them.
  • Factory Pattern: A creational pattern that provides a method for creating abstract objects, allowing them to be swapped externally.

Real-world usage:

  • Singleton Pattern: ConnectionManager exists as a single object in the App Lifecycle, used to manage Connection operations.
  • Flyweight Pattern: ConnectionPool is a shared pool of Connections, where Connections are retrieved from this pool, and the logic includes providing an existing Connection when the URL Path matches. ConnectionHandler acts as an external operator and state manager for Connection.
  • Factory Pattern: ConnectionFactory works with the Flyweight Pattern. When no reusable Connection is found in the pool, this factory interface is used to create one.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
import Combine
import Foundation

protocol Connection {
    var url: URL {get}
    var id: UUID {get}
    
    init(url: URL)
    
    func connect()
    func disconnect()
    
    func sendEvent(_ event: String)
    func onEvent(_ event: String) -> AnyPublisher<Data?, Never>
}

protocol ConnectionFactory {
    func create(url: URL) -> Connection
}

class ConnectionPool {
    
    private let connectionFactory: ConnectionFactory
    private var connections: [Connection] = []
    
    init(connectionFactory: ConnectionFactory) {
        self.connectionFactory = connectionFactory
    }
    
    func getOrCreateConnection(url: URL) -> Connection {
        if let connection = connections.first(where: { $0.url == url }) {
            return connection
        } else {
            let connection = connectionFactory.create(url: url)
            connections.append(connection)
            return connection
        }
    }
    
}

class ConnectionHandler {
    private let connection: Connection
    init(connection: Connection) {
        self.connection = connection
    }
    
    func getConnectionUUID() -> UUID {
        return connection.id
    }
}

class ConnectionManager {
    static let shared = ConnectionManager(connectionPool: ConnectionPool(connectionFactory: SIOConnectionFactory()))
    private let connectionPool: ConnectionPool
    private init(connectionPool: ConnectionPool) {
        self.connectionPool = connectionPool
    }
    
    //
    func requestConnectionHandler(url: URL) -> ConnectionHandler {
        let connection = connectionPool.getOrCreateConnection(url: url)
        return ConnectionHandler(connection: connection)
    }
}

// Socket.IO Implementation
class SIOConnection: Connection {
    let url: URL
    let id: UUID = UUID()
    
    required init(url: URL) {
        self.url = url
        //
    }
    
    func connect() {
        //
    }
    
    func disconnect() {
        //
    }
    
    func sendEvent(_ event: String) {
        //
    }
    
    func onEvent(_ event: String) -> AnyPublisher<Data?, Never> {
        //
        return PassthroughSubject<Data?, Never>().eraseToAnyPublisher()
    }
}

class SIOConnectionFactory: ConnectionFactory {
    func create(url: URL) -> Connection {
        //
        return SIOConnection(url: url)
    }
}
//

print(ConnectionManager.shared.requestConnectionHandler(url: URL(string: "wss://pinkoi.com/1")!).getConnectionUUID().uuidString)
print(ConnectionManager.shared.requestConnectionHandler(url: URL(string: "wss://pinkoi.com/1")!).getConnectionUUID().uuidString)

print(ConnectionManager.shared.requestConnectionHandler(url: URL(string: "wss://pinkoi.com/2")!).getConnectionUUID().uuidString)

// output:
// D99F5429-1C6D-4EB5-A56E-9373D6F37307
// D99F5429-1C6D-4EB5-A56E-9373D6F37307
// 599CF16F-3D7C-49CF-817B-5A57C119FE31

Scenario 2.

What?

As mentioned in the background technical details, the Send Event of the Socket.IO Swift Client does not support offline sending (but the Web/Android versions of the library do), so iOS needs to implement this feature on its own.

1
Interestingly, the Socket.IO Swift Client - onEvent supports offline subscription.

Why?

  • Unified cross-platform functionality
  • Easy-to-understand code

How?

  • Command Pattern: A behavioral pattern that encapsulates operations into objects, providing a collection of operations such as queuing, delaying, canceling, etc.

  • Command Pattern: SIOManager is the lowest-level encapsulation for communicating with Socket.IO, where the send and request methods are operations for Socket.IO Send Event. When the current Socket.IO is found to be disconnected, the request parameters are placed in bufferedCommands, and when connected, they are processed one by one (First In First Out).
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
protocol BufferedCommand {
    var sioManager: SIOManagerSpec? { get set }
    var event: String { get }
    
    func execute()
}

struct SendBufferedCommand: BufferedCommand {
    let event: String
    weak var sioManager: SIOManagerSpec?
    
    func execute() {
        sioManager?.send(event)
    }
}

struct RequestBufferedCommand: BufferedCommand {
    let event: String
    let callback: (Data?) -> Void
    weak var sioManager: SIOManagerSpec?
    
    func execute() {
        sioManager?.request(event, callback: callback)
    }
}

protocol SIOManagerSpec: AnyObject {
    func connect()
    func disconnect()
    func onEvent(event: String, callback: @escaping (Data?) -> Void)
    func send(_ event: String)
    func request(_ event: String, callback: @escaping (Data?) -> Void)
}

enum ConnectionState {
    case created
    case connected
    case disconnected
    case reconnecting
    case released
}

class SIOManager: SIOManagerSpec {
        
    var state: ConnectionState = .disconnected {
        didSet {
            if state == .connected {
                executeBufferedCommands()
            }
        }
    }
    
    private var bufferedCommands: [BufferedCommand] = []
    
    func connect() {
        state = .connected
    }
    
    func disconnect() {
        state = .disconnected
    }
    
    func send(_ event: String) {
        guard state == .connected else {
            appendBufferedCommands(connectionCommand: SendBufferedCommand(event: event, sioManager: self))
            return
        }
        
        print("Send:\(event)")
    }
    
    func request(_ event: String, callback: @escaping (Data?) -> Void) {
        guard state == .connected else {
            appendBufferedCommands(connectionCommand: RequestBufferedCommand(event: event, callback: callback, sioManager: self))
            return
        }
        
        print("request:\(event)")
    }
    
    func onEvent(event: String, callback: @escaping (Data?) -> Void) {
        //
    }
    
    func appendBufferedCommands(connectionCommand: BufferedCommand) {
        bufferedCommands.append(connectionCommand)
    }
    
    func executeBufferedCommands() {
        // First in, first out
        bufferedCommands.forEach { connectionCommand in
            connectionCommand.execute()
        }
        bufferedCommands.removeAll()
    }
    
    func removeAllBufferedCommands() {
        bufferedCommands.removeAll()
    }
}

let manager = SIOManager()
manager.send("send_event_1")
manager.send("send_event_2")
manager.request("request_event_1") { _ in
    //
}
manager.state = .connected

Similarly, this can also be implemented on onEvent.

Extension: You can further apply the Proxy Pattern to treat Buffer functionality as a type of Proxy.

Scenario 3.

What?

The Connection has multiple states, with ordered states and transitions between states, allowing different operations in each state.

  • Created: Object is created, allowing transition to Connected or directly to Disconnected
  • Connected: Connected to Socket.IO, allowing transition to Disconnected
  • Disconnected: Disconnected from Socket.IO, allowing transition to Reconnectiong or Released
  • Reconnectiong: Attempting to reconnect to Socket.IO, allowing transition to Connected or Disconnected
  • Released: Object marked for pending memory release, no operations or state transitions allowed

Why?

  • The logic and representation of state transitions are not straightforward
  • Restricting operations in each state (e.g., State = Released cannot Call Send Event) using if…else directly makes the code hard to maintain and read

How?

  • Finite State Machine: SIOConnectionStateMachine implements the state machine, currentSIOConnectionState represents the current state, and created, connected, disconnected, reconnecting, released list the possible state transitions of this state machine. enterXXXState() throws implements the allowed and disallowed (throw error) actions when transitioning from the Current State to a specific state.
  • State Pattern: SIOConnectionState is the interface abstraction for all operations that states may use.
1
// Code block translated comments only, code remains in English

Combining scenarios 1 and 2, with the ConnectionPool flyweight pool and State Pattern state management; we continue to extend as described in the background goals, the Feature side does not need to worry about the connection mechanism behind the Connection; therefore, we have created a poller (named ConnectionKeeper) that will periodically scan the ConnectionPool for actively held Connection and perform operations when the following conditions occur:

  • If a Connection is in use and the state is not Connected: change the state to Reconnecting and attempt to reconnect.
  • If a Connection is not in use and the state is Connected: change the state to Disconnected.
  • If a Connection is not in use and the state is Disconnected: change the state to Released and remove it from the ConnectionPool.

Why?

  • The three operations have a logical order and are mutually exclusive (disconnected -> released or reconnecting).
  • Flexibility to swap and add operational scenarios.
  • Without encapsulation, one would have to directly write the three checks and operations in a method (difficult to test the logic within).
  • e.g.:
1
2
3
4
5
6
7
if !connection.isOccupied() && connection.state == .connected then
... connection.disconnected()
else if !connection.isOccupied() && state == .released then
... connection.release()
else if connection.isOccupied() && state == .disconnected then
... connection.reconnecting()
end

How?

  • Chain Of Responsibility: A behavioral pattern, as the name suggests, is a chain where each node has corresponding operations. After inputting data, a node can decide whether to operate or pass it to the next node for processing. Another real-world application is the iOS Responder Chain.

By definition, the Chain of Responsibility Pattern does not allow a node to take over processing data and then pass it to the next node to continue processing. Either do it completely or don’t do it at all.

If the above scenario is more suitable, it should be the Interceptor Pattern.

  • Chain of responsibility: ConnectionKeeperHandler is an abstract node of the chain, specifically extracting the canExecute method to avoid the situation where this node takes over processing but then wants to call the next node to continue execution, handle connects the nodes in the chain, and execute is the logic of how to handle the processing. ConnectionKeeperHandlerContext is used to store data that will be used, isOccupied indicates whether the Connection is in use.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
enum ConnectionState {
    case created
    case connected
    case disconnected
    case reconnecting
    case released
}

protocol Connection {
    var connectionState: ConnectionState {get}
    var url: URL {get}
    var id: UUID {get}
    
    init(url: URL)
    
    func connect()
    func reconnect()
    func disconnect()
    
    func sendEvent(_ event: String)
    func onEvent(_ event: String) -> AnyPublisher<Data?, Never>
}

// Socket.IO Implementation
class SIOConnection: Connection {
    let connectionState: ConnectionState = .created
    let url: URL
    let id: UUID = UUID()
    
    required init(url: URL) {
        self.url = url
        //
    }
    
    func connect() {
        //
    }
    
    func disconnect() {
        //
    }
    
    func reconnect() {
        //
    }
    
    func sendEvent(_ event: String) {
        //
    }
    
    func onEvent(_ event: String) -> AnyPublisher<Data?, Never> {
        //
        return PassthroughSubject<Data?, Never>().eraseToAnyPublisher()
    }
}

//

struct ConnectionKeeperHandlerContext {
    let connection: Connection
    let isOccupied: Bool
}

protocol ConnectionKeeperHandler {
    var nextHandler: ConnectionKeeperHandler? { get set }
    
    func handle(context: ConnectionKeeperHandlerContext)
    func execute(context: ConnectionKeeperHandlerContext)
    func canExecute(context: ConnectionKeeperHandlerContext) -> Bool
}

extension ConnectionKeeperHandler {
    func handle(context: ConnectionKeeperHandlerContext) {
        if canExecute(context: context) {
            execute(context: context)
        } else {
            nextHandler?.handle(context: context)
        }
    }
}

class DisconnectedConnectionKeeperHandler: ConnectionKeeperHandler {
    var nextHandler: ConnectionKeeperHandler?
    
    func execute(context: ConnectionKeeperHandlerContext) {
        context.connection.disconnect()
    }
    
    func canExecute(context: ConnectionKeeperHandlerContext) -> Bool {
        if context.connection.connectionState == .connected && !context.isOccupied {
            return true
        }
        return false
    }
}

class ReconnectConnectionKeeperHandler: ConnectionKeeperHandler {
    var nextHandler: ConnectionKeeperHandler?
    
    func execute(context: ConnectionKeeperHandlerContext) {
        context.connection.reconnect()
    }
    
    func canExecute(context: ConnectionKeeperHandlerContext) -> Bool {
        if context.connection.connectionState == .disconnected && context.isOccupied {
            return true
        }
        return false
    }
}

class ReleasedConnectionKeeperHandler: ConnectionKeeperHandler {
    var nextHandler: ConnectionKeeperHandler?
    
    func execute(context: ConnectionKeeperHandlerContext) {
        context.connection.disconnect()
    }
    
    func canExecute(context: ConnectionKeeperHandlerContext) -> Bool {
        if context.connection.connectionState == .disconnected && !context.isOccupied {
            return true
        }
        return false
    }
}
let connection = SIOConnection(url: URL(string: "wss://pinkoi.com")!)
let disconnectedHandler = DisconnectedConnectionKeeperHandler()
let reconnectHandler = ReconnectConnectionKeeperHandler()
let releasedHandler = ReleasedConnectionKeeperHandler()
disconnectedHandler.nextHandler = reconnectHandler
reconnectHandler.nextHandler = releasedHandler

disconnectedHandler.handle(context: ConnectionKeeperHandlerContext(connection: connection, isOccupied: false))

Requirement Scenario 4.

What?

We need to go through the setup of the Connection we encapsulated before using it, such as providing the URL Path, setting Config, etc.

Why?

  • Flexibility to add or remove building interfaces
  • Reusability of building logic
  • Without encapsulation, external entities can operate on classes unexpectedly
  • e.g.:
1
2
3
4
5
6
7
8
❌
let connection = Connection()
connection.send(event) // unexpected method call, should call .connect() first
✅
let connection = Connection()
connection.connect()
connection.send(event)
// but...who knows???

How?

  • Builder Pattern: A creational pattern that allows step-by-step construction of objects and reuses construction methods.

  • Builder Pattern: SIOConnectionBuilder is the builder for Connection, responsible for setting and storing data needed to build Connection; ConnectionConfiguration abstract interface ensures that .connect() must be called before using Connection to get the Connection instance.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
enum ConnectionState {
    case created
    case connected
    case disconnected
    case reconnecting
    case released
}

protocol Connection {
    var connectionState: ConnectionState {get}
    var url: URL {get}
    var id: UUID {get}
    
    init(url: URL)
    
    func connect()
    func reconnect()
    func disconnect()
    
    func sendEvent(_ event: String)
    func onEvent(_ event: String) -> AnyPublisher<Data?, Never>
}

// Socket.IO Implementation
class SIOConnection: Connection {
    let connectionState: ConnectionState = .created
    let url: URL
    let id: UUID = UUID()
    
    required init(url: URL) {
        self.url = url
        //
    }
    
    func connect() {
        //
    }
    
    func disconnect() {
        //
    }
    
    func reconnect() {
        //
    }
    
    func sendEvent(_ event: String) {
        //
    }
    
    func onEvent(_ event: String) -> AnyPublisher<Data?, Never> {
        //
        return PassthroughSubject<Data?, Never>().eraseToAnyPublisher()
    }
}

//
class SIOConnectionClient: ConnectionConfiguration {
    private let url: URL
    private let config: [String: Any]
    
    init(url: URL, config: [String: Any]) {
        self.url = url
        self.config = config
    }
    
    func connect() -> Connection {
        // set config
        return SIOConnection(url: url)
    }
}

protocol ConnectionConfiguration {
    func connect() -> Connection
}

class SIOConnectionBuilder {
    private(set) var config: [String: Any] = [:]
    
    func setConfig(_ config: [String: Any]) -> SIOConnectionBuilder {
        self.config = config
        return self
    }
    
    // url is required parameter
    func build(url: URL) -> ConnectionConfiguration {
        return SIOConnectionClient(url: url, config: self.config)
    }
}

let builder = SIOConnectionBuilder().setConfig(["test":123])


let connection1 = builder.build(url: URL(string: "wss://pinkoi.com/1")!).connect()
let connection2 = builder.build(url: URL(string: "wss://pinkoi.com/1")!).connect()

Extension: Here you can also apply the Factory Pattern, to produce SIOConnection using a factory.

End!

The above are the four scenarios encountered in encapsulating Socket.IO and the seven Design Patterns used to solve the problems.

Finally, here is the complete design blueprint for encapsulating Socket.IO

Contrary to the naming and demonstration in the text, this image represents the actual design architecture; there may be an opportunity for the original designer to share design concepts and open source the project.

Who?

Who designed these and is responsible for the Socket.IO encapsulation project?

Sean Zheng, Android Engineer @ Pinkoi

Main architect, evaluation and application of Design Patterns, implementation of design in Kotlin on the Android side.

ZhgChgLi, Enginner Lead/iOS Enginner @ Pinkoi

Project lead of the Platform Team, Pair programming, implementation of design in Swift on the iOS side, discussion and raising questions (a.k.a. speaking up), and finally writing this article to share with everyone.

Further Reading

If you have any questions or suggestions, feel free to contact me.

===

本文中文版本

===

This article was first published in Traditional Chinese on Medium ➡️ View Here



This post is licensed under CC BY 4.0 by the author.

Crashlytics + Google Analytics Automatically Query App Crash-Free Users Rate

Converting Medium Posts to Markdown