Grand Central Dispatch Tutorial

Grand Central Dispatch Tutorial

In the first part of the tutorial, you learned some knowledge about concurrency, threads, and how GCD works. You used dispatch_barrier_async and dispatch_sync to ensure the linear safety of the PhotoManager singleton in the process of reading and writing photos. It is worth mentioning that you not only used dispatch_after to promptly remind the user to optimize the UX of the App, but also used dispatch_async to split part of the work from the instantiation process of a View Controller to another thread to achieve high-density CPU processing.

If you have been learning from the previous part of the tutorial, you can continue coding on the previous project file. But if you have not completed the last part of the tutorial or do not want to continue using your own project file, you can download the complete project file of the last part of the tutorial from here.

OK! It’s time to explore more about Grand Central Disk Drive.

Fix Popup that appears prematurely

Maybe you have noticed that when you add photos through Le Internet, AlertView pops up to remind you "Download Complete" before all photos are downloaded.

See That?

In fact, the problem lies in the downloadPhotosWithCompletion function of PhotoManaer:

  1. func downloadPhotosWithCompletion(completion: BatchPhotoDownloadingCompletionClosure?) {
  2. var storedError: NSError!
  3. for address in [OverlyAttachedGirlfriendURLString,
  4. SuccessKidURLString,
  5. LotsOfFacesURLString] {
  6. let url = NSURL(string: address)
  7. let photo = DownloadPhoto(url: url!) {
  8. image, error in
  9. if error != nil {
  10. storedError = error
  11. }
  12. }
  13. PhotoManager.sharedManager.addPhoto(photo)
  14. }
  15. if let completion = completion {
  16. completion(error: storedError)
  17. }
  18. }

At the end of the function, the completion closure is called - this means that you think all the download tasks of the photos have been completed. But unfortunately, at this moment you cannot guarantee that all the download tasks have been completed.

The instantiation method of the DownloadPhoto class starts downloading a file from a URL and returns immediately before the download is complete. In other words, downloadPhotosWithCompletion calls its own completion closure at the end of the function as if it were a method body with linear synchronous execution code, and each method calls completed after performing its work.

However, DownloadPhoto(url:) executes asynchronously and returns immediately — so this solution doesn’t work.

Furthermore, downloadPhotosWithCompletion should call its own completion closure after all photo download tasks have called completion closures. So the question is: how do you supervise those asynchronous events that are executed simultaneously? You don't know when and in what order they will end.

Maybe you could write multiple Bool values ​​to track the download status of each task. To be honest, this would feel a bit low-level and the code would look messy.

Fortunately, dispatch groups are designed to meet the needs of supervising multiple asynchronous completions.

Dispatch Group

A dispatch group notifies you when the entire group of tasks is completed. These tasks can be either asynchronous or synchronous and can be supervised in different queues. A dispatch group can notify you when the entire group of tasks is completed, either synchronously or asynchronously. As long as there are tasks being supervised in different queues, the dispatch_group_t instance will continue to supervise these different tasks in multiple queues.

When all tasks in the group are completed, the GCD API provides two ways to remind you.

The first one is dispatch_group_wait, which is a function that limits the current thread to run before all tasks in the group are completed or in the case of a processing timeout. In the case that AlertView appears early, using dispatch_group_wait is definitely your best solution.

Open PhotoManager.swift and replace the original downloadPhotosWithCompletion method with the following code:

  1. func downloadPhotosWithCompletion(completion: BatchPhotoDownloadingCompletionClosure?) {
  2. dispatch_async(GlobalUserInitiatedQueue) { // 1  
  3. var storedError: NSError!
  4. var downloadGroup = dispatch_group_create() // 2  
  5. for address in [OverlyAttachedGirlfriendURLString,
  6. SuccessKidURLString,
  7. LotsOfFacesURLString]
  8. {
  9. let url = NSURL(string: address)
  10. dispatch_group_enter(downloadGroup) // 3  
  11. let photo = DownloadPhoto(url: url!) {
  12. image, error in
  13. if let error = error {
  14. storedError = error
  15. }
  16. dispatch_group_leave(downloadGroup) // 4  
  17. }
  18. PhotoManager.sharedManager.addPhoto(photo)
  19. }
  20. dispatch_group_wait(downloadGroup, DISPATCH_TIME_FOREVER) // 5  
  21. dispatch_async(GlobalMainQueue) { // 6  
  22. if let completion = completion { // 7  
  23. completion(error: storedError)
  24. }
  25. }
  26. }
  27. }

Code step-by-step explanation:

Once you use the synchronous dispatch_group_wait that limits the current thread to run, you must use dispatch_async to transfer the entire method to the background queue to ensure the normal operation of the main thread.

A new dispatch group is claimed here which you can think of as a counter of unfinished tasks.

Dispatch_group_enter is used to notify the dispatch group of the start of a new task execution. You must make the number of calls to dispatch_group_enter equal to the number of calls to dispatch_group_leave, otherwise it will cause the App to crash.

Here, you alert the dispatch group that the task is finished. Once again, the number of times you enter and exit the dispatch group must be equal.

dispatch_group_wait will be executed after all tasks have finished or if the processing timeout occurs. If the processing timeout occurs before all tasks have finished, the function will return a non-zero result. You can put it in a special closure to check if the processing timeout occurs. Of course, in this tutorial, you can use DISPATCH_TIME_FOREVER to keep waiting for the request, which means it will wait forever because the photo download task will eventually complete.

So far, you have ensured that the photo download task either completes successfully or times out. You can then return to the main queue and run your completion closure. This will add the task to the main thread to be executed later.

Execute the completion closure when conditions permit.

Build and run your app, and you’ll notice that your completion closure executes at the correct time after tapping the option to download the photo.

Tip: When you run the app on a physical device, if the network mechanism runs too fast and you can't tell when the completion closure starts executing, you can make some network adjustments in the Developer Section of the App's Settings. Open Network Link Conditioner and select Very Bad Network.

If you are running your app on the simulator, you can adjust your network speed by using the Network Link Conditioner included in the Hardare IO Tools for Xcode. This is a great tool to use when you need to understand how your app performs under poor network conditions.

This solution has more benefits than that, but overall it avoids the possibility of limiting the normal operation of the thread in most cases. Your next task is to write a method that does the same and sends you a "photo download completed" reminder in an asynchronous manner.

Before we get started, here's a quick tutorial on when and how to use dispatch groups for the different types of queues.

Custom Serial Queue: Custom Serial Queue is a good choice when you need to send a reminder when all tasks in a group are completed.

Main Queue (Serial): You should be wary of using it on the main thread when you are waiting for all tasks to complete synchronously and you don't want to limit the main queue. But the asynchronous model is the best way to update the UI when long-running tasks such as network requests are completed.

Concurrent Queue: This is also a good choice for dispatch groups and completion reminders.

Distribution team, come again!

For the sake of perfection, isn't it a bit stupid to dispatch the download task to another queue asynchronously and limit its execution with dispatch_group_wait? Let's try another approach...

Replace the downloadPhotosWithCompletion function in PhtotManager.swift with the following implementation:

  1. func downloadPhotosWithCompletion(completion: BatchPhotoDownloadingCompletionClosure?) {
  2. // 1  
  3. var storedError: NSError!
  4. var downloadGroup = dispatch_group_create()
  5. for address in [OverlyAttachedGirlfriendURLString,
  6. SuccessKidURLString,
  7. LotsOfFacesURLString]
  8. {
  9. let url = NSURL(string: address)
  10. dispatch_group_enter(downloadGroup)
  11. let photo = DownloadPhoto(url: url!) {
  12. image, error in
  13. if let error = error {
  14. storedError = error
  15. }
  16. dispatch_group_leave(downloadGroup)
  17. }
  18. PhotoManager.sharedManager.addPhoto(photo)
  19. }
  20. dispatch_group_notify(downloadGroup, GlobalMainQueue) { // 2  
  21. if let completion = completion {
  22. completion(error: storedError)
  23. }
  24. }
  25. }

This is how your new async method works:

With this new implementation, you don't have to put it inside a dispatch_async call as you are no longer tied to the main thread.

dispatch_group_notify is equivalent to an asynchronous completion closure. This code will be executed when there are no more tasks left in the dispatch group and it is the turn of the completion closure to run. You can also define which queue your completion code runs on. In this code, you want to run it on the main queue.

This is a cleaner way to handle this special need example without limiting any threads to run.

The dangers of excessive concurrency

After learning so many new things, shouldn't you thread all your code?

[[138052]]

Do It!!!

Take a look at your downloadPhotosWithCompletion function in PhotoManger. You should notice the for loop that cycles through three parameters and downloads three different photos. Your next job is to try to speed up the for loop by using concurrency.

It's dispatch_apply's turn to play.

dispatch_apply is like a for loop that executes different iterations concurrently. Just like a normal for loop, dispatch_apply is a function that runs synchronously and returns only after all work is done.

Be careful when you maximize the number of iterations given the number of tasks in a closure, because having multiple iterations with small amounts of work each can offset the optimizations made by concurrent calls. This technique called striding helps you where you are handling each iteration of multiple tasks.

When is it appropriate to use dispatch_apply?

Custom Serial Queue: For a serial queue, dispatch_apply is not very useful; you should just use a plain old for loop.

Main Queue (Main Queue[Serial]): Same as above, just use a regular for loop.

Concurrent Queue: Concurrent loops are definitely a good idea when you need to monitor the progress of your tasks.

Go back to downloadPhotosWithCompletion and replace the code with the following:

  1. func downloadPhotosWithCompletion(completion: BatchPhotoDownloadingCompletionClosure?) {
  2. var storedError: NSError!
  3. var downloadGroup = dispatch_group_create()
  4. let addresses = [OverlyAttachedGirlfriendURLString,
  5. SuccessKidURLString,
  6. LotsOfFacesURLString]
  7. dispatch_apply(addresses.count, GlobalUserInitiatedQueue) {
  8. i in
  9. let index = Int(i)
  10. let address = addresses[index]
  11. let url = NSURL(string: address)
  12. dispatch_group_enter(downloadGroup)
  13. let photo = DownloadPhoto(url: url!) {
  14. image, error in
  15. if let error = error {
  16. storedError = error
  17. }
  18. dispatch_group_leave(downloadGroup)
  19. }
  20. PhotoManager.sharedManager.addPhoto(photo)
  21. }
  22. dispatch_group_notify(downloadGroup, GlobalMainQueue) {
  23. if let completion = completion {
  24. completion(error: storedError)
  25. }
  26. }
  27. }

Your loop block is now running in parallel; in the above code, you provide three parameters to the dispatch+apply call. The first parameter declares the number of iterations, the second parameter declares the queue where the multiple tasks will run, and the third parameter declares the closure.

Be aware that even though you have code that adds photos in thread-safe mode, the photos will be added in the order that the thread that added them completed last will be in.

Build and run, and add some photos via Le Internet. Notice anything different?

Running the modified code on a real device occasionally ran faster. So, were the changes above worth it?

Actually, it's not worth it in this case. Here's why:

You've already called up a thread that consumes more resources than a for loop would in the same situation. dispatch_apply is overkill here.

You have limited time to write apps - don't waste time on those "failure to achieve results" optimization code, spend your time on the optimization of the code with obvious effects. You can choose to use Instruments in Xcode to test the longest execution time of your app.

In some cases, optimized code may even make it more difficult for you and other developers to understand its logical structure, so the optimization effect must be worthwhile.

Remember, don't be obsessed with optimization, otherwise you will be at odds with yourself.

Cancel the execution of a dispatch block

You should know that a new feature called dispatch block objects was added in iOS 8 and OS X Yosemite. Dispatch block objects can do a lot of things, such as setting a QoS level for each object to determine its priority in the queue, but its most special feature is to cancel the execution of block objects. But you need to know that a block object can only be canceled before it reaches the top of the queue and starts executing.

We can describe in detail the mechanism of canceling the Dispatch Block by using Le Internet to start and then cancel the photo download task. Replace the downloadPhotosWithCompletion function in PhotoManager.swift with the following code:

  1. func downloadPhotosWithCompletion(completion: BatchPhotoDownloadingCompletionClosure?) {
  2. var storedError: NSError!
  3. let downloadGroup = dispatch_group_create()
  4. var addresses = [OverlyAttachedGirlfriendURLString,
  5. SuccessKidURLString,
  6. LotsOfFacesURLString]
  7. addresses += addresses + addresses // 1  
  8. var blocks: [dispatch_block_t] = [] // 2  
  9. for i in 0 ..< addresses.count {
  10. dispatch_group_enter(downloadGroup)
  11. let block = dispatch_block_create(DISPATCH_BLOCK_INHERIT_QOS_CLASS) { // 3  
  12. let index = Int(i)
  13. let address = addresses[index]
  14. let url = NSURL(string: address)
  15. let photo = DownloadPhoto(url: url!) {
  16. image, error in
  17. if let error = error {
  18. storedError = error
  19. }
  20. dispatch_group_leave(downloadGroup)
  21. }
  22. PhotoManager.sharedManager.addPhoto(photo)
  23. }
  24. blocks.append(block)
  25. dispatch_async(GlobalMainQueue, block) // 4  
  26. }
  27. for block in blocks[ 3 ..< blocks.count] { // 5  
  28. let cancel = arc4random_uniform( 2 ) // 6  
  29. if cancel == 1 {
  30. dispatch_block_cancel(block) // 7  
  31. dispatch_group_leave(downloadGroup) // 8  
  32. }
  33. }
  34. dispatch_group_notify(downloadGroup, GlobalMainQueue) {
  35. if let completion = completion {
  36. completion(error: storedError)
  37. }
  38. }
  39. }

The addresses array contains three copies of each address variable.

This array contains block objects that will be used later.

dispatch_block_create declares a new block object. The first parameter is a flag that defines different block characteristics. This flag makes the block inherit the QoS level of the queue it is assigned to. The second parameter is the block definition in the form of a closure.

This is a block that is dispatched to the global main queue asynchronously. The main queue used in this example is a continuous queue, so it is easier to cancel the selected blocks. The code that defines the dispatch block is executed on the main queue, ensuring that the download block is executed later.

Excluding the first three downloads, execute the for loop on the remaining array elements.

arc4random_uniform takes an integer in the range 0 to the upper limit (not including the upper limit). Just like flipping a coin, with 2 as the upper limit you will get an integer of 0 or 1.

If the random number is 1, the block is still in the queue and is not being executed, the block is canceled. A block in the process of execution cannot be canceled.

When all blocks are added to the dispatch queue, don't forget to delete the cancelled queue.

Compile and run the app, and add photos using Le Internet. You will find that the app will download a random number of photos after downloading the original three photos. After being assigned to the queue, the remaining blocks will be cancelled. Although this is a far-fetched example, it at least describes well how dispatch block objects can be used or cancelled.

There is a lot more that Dispatch block objects can do. Don't forget to read the official documentation before using them.

Various fun brought by GCD

Wait! Let me tell you something! Actually, there are some functions that are not commonly used, but they are very useful in special cases.

Testing asynchronous code

This may sound far-fetched, but did you know that Xcode has this testing feature? :] In some cases I pretend not to know about this feature, but when dealing with code with complex relationships, it is very important to write and run tests.

The test functionality in Xcode appears as a subclass of XCTestCase and any method that runs in it begins with test. The test functionality runs on the main thread, so you can assume that each test is run in a serial manner.

As soon as a given test method completes execution, the XCTest method considers one test to be complete and starts the next test, which means that asynchronous code in the previous test will continue to run while the new test is running.

When you are performing a network request task and do not want to limit the main thread to run, then such network tasks are usually performed asynchronously. This "the end of the test method represents the end of the entire test process" mechanism increases the difficulty of testing network code.

Don't worry, let's take a look at two common techniques specifically designed to test code that executes asynchronously: one using semaphores and the other using expectations.

Semaphores

In many schools' OS classes, when the famous Edsger W.Dijkstra is mentioned, the concept of semaphores, which is related to threads, will definitely be mentioned. The difficulty in understanding semaphores lies in the fact that they are built on the functions of those complex operating systems.

If you want to learn more about semaphores, please go here for more details about semaphore theory. If you are a guy who focuses on academic research, from the perspective of software development, the classic example of semaphores is definitely the dining philosophers problem.

Semaphores are useful for controlling resource consumption of multiple units when resources are limited. For example, if you declare a semaphore containing two resources, at most two threads can access the critical section at the same time. Other threads that want to use the resources must wait in a queue in FIFO (First Come, First Operate) order.

Open GooglyPuffTests.swift and replace the downloadImageURLWithString function with the following:

  1. func downloadImageURLWithString(urlString: String) {
  2. let url = NSURL(string: urlString)
  3. let semaphore = dispatch_semaphore_create( 0 ) // 1  
  4. let photo = DownloadPhoto(url: url!) {
  5. image, error in
  6. if let error = error {
  7. XCTFail( "\(urlString) failed. \(error.localizedDescription)" )
  8. }
  9. dispatch_semaphore_signal(semaphore) // 2  
  10. }
  11. let timeout = dispatch_time(DISPATCH_TIME_NOW, DefaultTimeoutLengthInNanoSeconds)
  12. if dispatch_semaphore_wait(semaphore, timeout) != 0 { // 3  
  13. XCTFail( "\(urlString) timed out" )
  14. }
  15. }

Here is an explanation of how semaphores work in the above code:

Create a semaphore. The parameter indicates the initial value of the semaphore. This number represents the number of threads that can access the semaphore. We often increase the semaphore by sending a signal.

You can declare to the semaphore in the completion closure that you no longer need the resource. In this case, the value of the semaphore will be incremented and the semaphore will be declared available to other resources.

Sets the timeout for semaphore requests. The current thread will be blocked until the semaphore is available. If the timeout occurs, the function will return a non-zero value. In this case the test fails because it assumes that the network request should not take more than ten seconds to return.

Test the app by using the Product/Test option in the menu or using the ?+U shortcut.

Run the test again after disconnecting from the network (if you are running on a real device, turn on airplane mode, if you are running on a simulator, disconnect). This test will fail after 10 seconds.

If you are a member of a server team, it is very important to complete these tests.

Expectations

The XCTest framework provides another solution for testing asynchronously executed code, expectations. This allows you to set an expectation before an asynchronous task begins execution - something you expect to happen. You can keep the test runner waiting until the asynchronous task's expectation is marked as fulfilled.

Replace the downloadImageWithString function in GooglyPufftests.swift with the following code:

  1. func downloadImageURLWithString(urlString: String) {
  2. let url = NSURL(string: urlString)
  3. let downloadExpectation = expectationWithDescription( "Image downloaded from \(urlString)" ) // 1  
  4. let photo = DownloadPhoto(url: url!) {
  5. image, error in
  6. if let error = error {
  7. XCTFail( "\(urlString) failed. \(error.localizedDescription)" )
  8. }
  9. downloadExpectation.fulfill() // 2  
  10. }
  11. waitForExpectationsWithTimeout( 10 ) { // 3  
  12. error in
  13. if let error = error {
  14. XCTFail(error.localizedDescription)
  15. }
  16. }
  17. }

To explain:

The expectationWithDescription parameter declares an expectation. When the test fails, the test runner will display this string parameter in the Log to represent what you expect to happen.

Call fulfill in a closure that asynchronously marks the expectation as completed.

The calling thread waits for the expectation to be marked as completed by the waitForExpectationsWithTimeout function. If the wait times out, the thread will be treated as an error.

Compile and run the test. Although the test results are not much different than using the semaphore mechanism, this is a way to make the XCTest framework more concise and easier to read.

Use of Dispatch Sources

A very interesting feature of GCD is the dispatch source, which contains a lot of low-level functions. These functions can help you respond to and detect Unix signals, file descriptors, Mach ports, and VFS Nodes. Although these things are beyond the scope of this tutorial, I think you should still try to implement a dispatch source object.

Many beginners of dispatch sources often get stuck on how to use a source, so you need to understand how dispatch_source_create works. The following function declares a source:

  1. func dispatch_source_create(
  2. type: dispatch_source_type_t,
  3. handle: UInt,
  4. mask: UInt,
  5. queue: dispatch_queue_t!) -> dispatch_source_t!

As the first parameter, type: dispatch_source_type_t determines the type of the following statement and mask parameters. You can read the official documentation of the relevant content to understand the usage and explanation of each dispatch_source_type_t.

Here you will monitor DISPATCH_SOURCE_TYPE_SIGNAL. As explained in the official documentation: The dispatch source monitors the signal of the current process. The value of its handle is an integer (int), and the mask value is not used for the time being and is set to 0.

These Unix signals can be found in a header file called signal.h. There are a number of #defines at the top of the file. Among the bunch of signals you will be monitoring is the SIGSTOP signal. This signal is sent when the process receives an unavoidable halt instruction. The same signal is also sent when you are debugging with the LLDB debugger.

Open PhotoCollectionViewController.swift and add the following code near viewDidLoad:

  1. # if DEBUG
  2. private var signalSource: dispatch_source_t!
  3. private var signalOnceToken = dispatch_once_t()
  4. #endif
  5. override func viewDidLoad() {
  6. super .viewDidLoad()
  7. # if DEBUG // 1  
  8. dispatch_once(&signalOnceToken) { // 2  
  9. let queue = dispatch_get_main_queue()
  10. self.signalSource = dispatch_source_create(DISPATCH_SOURCE_TYPE_SIGNAL,
  11. UInt(SIGSTOP), 0 , queue) // 3  
  12. if let source = self.signalSource { // 4  
  13. dispatch_source_set_event_handler(source) { // 5  
  14. NSLog( "Hi, I am: \(self.description)" )
  15. }
  16. dispatch_resume(source) // 6  
  17. }
  18. }
  19. #endif
  20. // The other stuff  
  21. }

The distribution is explained as follows:

From a security perspective, you should compile your code in DEBUG mode to prevent others from indirectly viewing your code. :] Define DEBUG by adding -D DEBUG to the Debug options in Project Settings -> Build Settings -> Swift Compiler – Custom Flags -> Other Swift Flags -> Debug.

Use dispatch_once to initialize the dispatch source once.

Here you instantiate a signalSource variable and indicate that you only want to monitor the signal and pass the SIGSTOP signal as the second parameter. Another thing you need to know is that you use the main queue to handle the received events - as for why, you will find out in a moment.

If you provide an incorrect parameter, the dispatch source object will not be created successfully. In short, you must make sure that you have created a usable dispatch source object before using it.

dispatch_source_set_event_handler registers an event handling closure that will be called when a specific task signal is received.

By default, all dispatch sources start execution in a suspended state. When you want to start monitoring events, you must restart the dispatch source object.

Compile and run the app; pause the app in debugger mode and resume it immediately. Check the console and you will see the following feedback:

  1. 2014-08-12   12 : 24 : 00.514 GooglyPuff[ 24985 : 5481978 ] Hi, I am:

Your app is now debug aware to some extent. This is great, but how do you actually use it?

When you resume the App, you can use it to debug an object and display its related data. When someone with bad intentions wants to use the debugger to affect the normal operation of the App, you can also write a custom security login module for your App.

Another interesting idea is to implement a stack tracer for objects in the debugger using the above mechanism.

[[138053]]

What?

Consider a situation where you accidentally stop the debugger and it is difficult for the debugger to stay on the expected stack frame. But now you can stop the debugger at any time and execute code anywhere. This is very useful if you want to execute code in a specific location in your app.

Add a breakpoint next to the NSLog code in the viewDidLoad event handler. Pause the debugger and resume; your app will run to the breakpoint. You can now access the PhotoCollectionViewController instance as you wish.

If you don't know which threads there are in the debugger, you can check it out. The main thread is always the first thread followed by libdispatch; the GCD coordinator is always the second thread; the other threads depend on the specific situation.

[[138054]]

With the breakpoint feature, you can gradually update the UI, test the properties of the class, and even execute specific methods without rerunning the App. It seems very convenient!

Where to Go From Here?

You can download the complete code for this tutorial here.

I hate to belabor the point, but I think you should check out this tutorial on how to use Instruments in Xcode. If you plan to optimize your app, you will definitely use Instruments. You should know that Instruments is very useful for inferring relative execution problems: comparing which of the different code blocks takes the longest relative execution time.

At the same time, you should also take a look at this tutorial How to Use NSOperations and NSOperationsQueue Tutorial in Swift. NSOperations can provide better control, realize the maximum number of concurrent tasks, and make the program more object-oriented at the expense of a certain running speed.

Remember! In most cases, unless you have a special reason, always try to use higher-level APIs. Only explore Apple's Dark Arts when you really want to learn or do something very interesting!

Good luck!

<<:  How to improve the game's user experience on the server side

>>:  11 great websites for learning iOS development

Recommend

The latest guide to French fry ads on Xiaohongshu!

Has Xiaohongshu made everyone feel like they’re o...

Short video competitor analysis

The short video field is undoubtedly the hottest ...

JD Power: 2022 China New Energy Vehicle Customer Experience Value Index

Well-known research organization JD Power officia...

Cook opened a Weibo account and the comments were bright

Around 3pm this afternoon, Apple CEO Tim Cook ope...

User Growth Formula: Pinduoduo’s Growth Game Thinking

Nowadays, we can often see some gamification sett...

The difference between iQiyi splash screen ads and information flow ads

Often, advertisers will ask, iQiyi has so many ad...

Wuhan tea sn post bar

Wuhan Tea Tasting Contact Information I strongly ...