Công việc không đồng bộ với các luồng Java

Tất cả ứng dụng Android đều dùng một luồng chính để xử lý các thao tác trên giao diện người dùng. Việc gọi các hoạt động diễn ra trong thời gian dài từ luồng chính này có thể dẫn đến tình trạng treo và không phản hồi. Ví dụ: nếu ứng dụng của bạn đưa ra một yêu cầu mạng từ luồng chính, thì giao diện người dùng của ứng dụng sẽ bị treo cho đến khi nhận được phản hồi của mạng. Nếu sử dụng Java, bạn có thể tạo thêm luồng nền để xử lý các hoạt động diễn ra trong thời gian dài trong khi luồng chính tiếp tục xử lý các bản cập nhật giao diện người dùng.

Tài liệu này hướng dẫn cách các nhà phát triển sử dụng Ngôn ngữ lập trình Java có thể dùng một nhóm luồng để thiết lập và dùng nhiều luồng trong một ứng dụng Android. Hướng dẫn này cũng cho bạn biết cách xác định mã để chạy trên một luồng cũng như cách giao tiếp giữa một trong các luồng này và luồng chính.

Thư viện đồng thời

Quan trọng là bạn phải nắm được thông tin cơ bản về việc tạo luồng và cơ chế cơ bản của luồng. Tuy nhiên, có nhiều thư viện phổ biến cung cấp các thành phần trừu tượng cấp cao hơn đối với các khái niệm này, cũng như các tiện ích sẵn sàng sử dụng để truyền dữ liệu giữa các luồng. Các thư viện này bao gồm GuavaRxJava cho người dùng Ngôn ngữ lập trình Java và Coroutine mà chúng tôi đề xuất cho người dùng Kotlin.

Trong thực tế, bạn nên chọn một tuỳ chọn phù hợp nhất với ứng dụng và nhóm phát triển của mình, mặc dù các quy tắc phân luồng vẫn giữ nguyên.

Tổng quan về ví dụ

Dựa trên Hướng dẫn về cấu trúc ứng dụng, các ví dụ trong chủ đề này sẽ gửi một yêu cầu mạng và trả về kết quả cho luồng chính, tại đó ứng dụng có thể cho thấy kết quả đó trên màn hình.

Cụ thể, ViewModel gọi lớp dữ liệu trên luồng chính để kích hoạt yêu cầu mạng. Lớp dữ liệu chịu trách nhiệm di chuyển quá trình thực thi yêu cầu mạng ra khỏi luồng chính và đăng kết quả trở lại luồng chính thông qua lệnh gọi lại.

Để di chuyển quá trình thực thi yêu cầu mạng ra khỏi luồng chính, chúng ta cần tạo các luồng khác trong ứng dụng.

Tạo nhiều luồng

Nhóm luồng là một tập hợp các luồng được quản lý, chạy các tác vụ song song từ một hàng đợi. Các tác vụ mới sẽ được thực thi trên các luồng hiện có khi các luồng đó không hoạt động. Để gửi một tác vụ đến nhóm luồng, hãy sử dụng giao diện ExecutorService. Xin lưu ý rằng ExecutorService không liên quan gì đến Services (Dịch vụ), thành phần của ứng dụng Android.

Việc tạo luồng rất tốn kém, vì vậy, bạn chỉ nên tạo nhóm luồng một lần khi ứng dụng khởi chạy. Hãy nhớ lưu thực thể của ExecutorService trong lớp Application hoặc trong vùng chứa chèn phần phụ thuộc. Ví dụ sau đây sẽ tạo một nhóm luồng gồm 4 luồng mà chúng ta có thể dùng để chạy các tác vụ trong nền.

public class MyApplication extends Application {
    ExecutorService executorService = Executors.newFixedThreadPool(4);
}

Có nhiều cách khác để bạn định cấu hình nhóm luồng tuỳ thuộc vào khối lượng công việc dự kiến. Xem bài viết Định cấu hình nhóm luồng để biết thêm thông tin.

Thực thi trong luồng ở chế độ nền

Việc tạo yêu cầu mạng trên luồng chính sẽ khiến luồng này chờ hoặc chặn cho đến khi nhận được phản hồi. Vì luồng bị chặn, hệ điều hành không thể gọi onDraw(), ứng dụng của bạn sẽ bị treo, có thể dẫn đến hộp thoại Ứng dụng không phản hồi (ANR). Thay vào đó, hãy chạy thao tác này trên một luồng ở chế độ nền.

Đưa ra yêu cầu

Trước tiên, hãy xem lớp LoginRepository của chúng ta và xem lớp này tạo yêu cầu mạng như thế nào:

// Result.java
public abstract class Result<T> {
    private Result() {}

    public static final class Success<T> extends Result<T> {
        public T data;

        public Success(T data) {
            this.data = data;
        }
    }

    public static final class Error<T> extends Result<T> {
        public Exception exception;

        public Error(Exception exception) {
            this.exception = exception;
        }
    }
}

// LoginRepository.java
public class LoginRepository {

    private final String loginUrl = "https://example.com/login";
    private final LoginResponseParser responseParser;

    public LoginRepository(LoginResponseParser responseParser) {
        this.responseParser = responseParser;
    }

    public Result<LoginResponse> makeLoginRequest(String jsonBody) {
        try {
            URL url = new URL(loginUrl);
            HttpURLConnection httpConnection = (HttpURLConnection) url.openConnection();
            httpConnection.setRequestMethod("POST");
            httpConnection.setRequestProperty("Content-Type", "application/json; charset=utf-8");
            httpConnection.setRequestProperty("Accept", "application/json");
            httpConnection.setDoOutput(true);
            httpConnection.getOutputStream().write(jsonBody.getBytes("utf-8"));

            LoginResponse loginResponse = responseParser.parse(httpConnection.getInputStream());
            return new Result.Success<LoginResponse>(loginResponse);
        } catch (Exception e) {
            return new Result.Error<LoginResponse>(e);
        }
    }
}

makeLoginRequest() đồng bộ và chặn luồng lệnh gọi. Để lập mô hình phản hồi của yêu cầu mạng, chúng ta có lớp Result riêng.

Kích hoạt yêu cầu

ViewModel kích hoạt yêu cầu mạng khi người dùng nhấn vào, chẳng hạn như trên một nút:

public class LoginViewModel {

    private final LoginRepository loginRepository;

    public LoginViewModel(LoginRepository loginRepository) {
        this.loginRepository = loginRepository;
    }

    public void makeLoginRequest(String username, String token) {
        String jsonBody = "{ username: \"" + username + "\", token: \"" + token + "\" }";
        loginRepository.makeLoginRequest(jsonBody);
    }
}

Với mã trước, LoginViewModel đang chặn luồng chính khi đưa ra yêu cầu mạng. Chúng ta có thể sử dụng nhóm luồng mà chúng ta đã tạo thực thể để chuyển quá trình thực thi sang một luồng trong nền.

Xử lý quá trình chèn phần phụ thuộc

Trước tiên, theo nguyên tắc chèn phần phụ thuộc, LoginRepository lấy một thực thể của Executor thay vì ExecutorService vì trình duyệt này thực thi mã chứ không quản lý luồng:

public class LoginRepository {
    ...
    private final Executor executor;

    public LoginRepository(LoginResponseParser responseParser, Executor executor) {
        this.responseParser = responseParser;
        this.executor = executor;
    }
    ...
}

Phương thức Execution() của Executor sẽ lấy một Runnable (Có thể chạy). Runnable là một giao diện Phương thức trừu tượng đơn (SAM) có phương thức run() được thực thi trong một luồng khi được gọi.

Thực thi trong nền

Hãy tạo một hàm khác có tên là makeLoginRequest(). Hàm này sẽ di chuyển quá trình thực thi sang luồng trong nền và tạm thời bỏ qua phản hồi:

public class LoginRepository {
    ...
    public void makeLoginRequest(final String jsonBody) {
        executor.execute(new Runnable() {
            @Override
            public void run() {
                Result<LoginResponse> ignoredResponse = makeSynchronousLoginRequest(jsonBody);
            }
        });
    }

    public Result<LoginResponse> makeSynchronousLoginRequest(String jsonBody) {
        ... // HttpURLConnection logic
    }
    ...
}

Bên trong phương thức execute(), chúng ta tạo một Runnable mới với khối mã mà chúng ta muốn thực thi trong luồng trong nền – trong trường hợp này là phương thức yêu cầu mạng đồng bộ. Trong nội bộ, ExecutorService quản lý Runnable và thực thi trong một luồng có sẵn.

Những yếu tố nên cân nhắc

Bất kỳ luồng nào trong ứng dụng của bạn đều có thể chạy song song với các luồng khác, bao gồm cả luồng chính. Vì vậy, bạn cần đảm bảo rằng mã của mình an toàn với luồng. Xin lưu ý rằng trong ví dụ này, chúng ta tránh ghi vào các biến được chia sẻ giữa các luồng, thay vào đó hãy truyền dữ liệu không thể thay đổi. Đây là một phương pháp hay vì mỗi luồng hoạt động với thực thể dữ liệu riêng nên chúng tôi tránh được sự phức tạp của quá trình đồng bộ hoá.

Nếu cần chia sẻ trạng thái giữa các luồng, bạn phải cẩn thận quản lý quyền truy cập từ các luồng bằng cách sử dụng cơ chế đồng bộ hoá, chẳng hạn như khoá. Nội dung này nằm ngoài phạm vi của hướng dẫn này. Nhìn chung, bạn nên tránh chia sẻ trạng thái có thể thay đổi giữa các luồng bất cứ khi nào có thể.

Giao tiếp với luồng chính

Ở bước trước, chúng ta đã bỏ qua phản hồi của yêu cầu mạng. Để hiển thị kết quả trên màn hình, LoginViewModel cần biết về kết quả đó. Chúng ta có thể thực hiện việc đó bằng cách sử dụng lệnh gọi lại.

Hàm makeLoginRequest() phải nhận lệnh gọi lại dưới dạng tham số để có thể trả về một giá trị không đồng bộ. Lệnh gọi lại có kết quả được gọi bất cứ khi nào yêu cầu mạng hoàn tất hoặc xảy ra lỗi. Trong Kotlin, chúng ta có thể sử dụng một hàm bậc cao hơn. Tuy nhiên, trong Java, chúng ta phải tạo một giao diện gọi lại mới để có cùng chức năng:

interface RepositoryCallback<T> {
    void onComplete(Result<T> result);
}

public class LoginRepository {
    ...
    public void makeLoginRequest(
        final String jsonBody,
        final RepositoryCallback<LoginResponse> callback
    ) {
        executor.execute(new Runnable() {
            @Override
            public void run() {
                try {
                    Result<LoginResponse> result = makeSynchronousLoginRequest(jsonBody);
                    callback.onComplete(result);
                } catch (Exception e) {
                    Result<LoginResponse> errorResult = new Result.Error<>(e);
                    callback.onComplete(errorResult);
                }
            }
        });
    }
  ...
}

ViewModel cần triển khai lệnh gọi lại ngay. Tệp này có thể thực hiện logic khác nhau tuỳ thuộc vào kết quả:

public class LoginViewModel {
    ...
    public void makeLoginRequest(String username, String token) {
        String jsonBody = "{ username: \"" + username + "\", token: \"" + token + "\" }";
        loginRepository.makeLoginRequest(jsonBody, new RepositoryCallback<LoginResponse>() {
            @Override
            public void onComplete(Result<LoginResponse> result) {
                if (result instanceof Result.Success) {
                    // Happy path
                } else {
                    // Show error in UI
                }
            }
        });
    }
}

Trong ví dụ này, lệnh gọi lại được thực thi trong luồng lệnh gọi, vốn là một luồng trong nền. Điều này có nghĩa là bạn không thể sửa đổi hoặc giao tiếp trực tiếp với lớp giao diện người dùng cho đến khi chuyển lại luồng chính.

Sử dụng trình xử lý

Bạn có thể sử dụng Trình xử lý để đưa một thao tác cần được thực hiện vào một luồng khác vào hàng đợi. Để chỉ định luồng chạy hành động, hãy tạo Handler bằng cách sử dụng Looper cho luồng đó. Looper là một đối tượng chạy vòng lặp thông báo cho một chuỗi tin nhắn được liên kết. Sau khi tạo Handler, bạn có thể sử dụng phương thức post(Runnable) để chạy một khối mã trong luồng tương ứng.

Looper bao gồm một hàm trợ giúp getMainLooper(). Hàm này sẽ truy xuất Looper của luồng chính. Bạn có thể chạy mã trong luồng chính bằng cách sử dụng Looper này để tạo Handler. Vì đây là việc bạn có thể làm khá thường xuyên, nên bạn cũng có thể lưu một thực thể của Handler vào cùng nơi đã lưu ExecutorService:

public class MyApplication extends Application {
    ExecutorService executorService = Executors.newFixedThreadPool(4);
    Handler mainThreadHandler = HandlerCompat.createAsync(Looper.getMainLooper());
}

Bạn nên chèn trình xử lý vào kho lưu trữ, vì điều này giúp bạn linh hoạt hơn. Ví dụ: trong tương lai, bạn có thể muốn truyền một Handler khác để lên lịch các tác vụ trên một luồng riêng. Nếu thường xuyên giao tiếp với cùng một luồng, bạn có thể truyền Handler vào hàm khởi tạo kho lưu trữ, như trong ví dụ sau.

public class LoginRepository {
    ...
    private final Handler resultHandler;

    public LoginRepository(LoginResponseParser responseParser, Executor executor,
            Handler resultHandler) {
        this.responseParser = responseParser;
        this.executor = executor;
        this.resultHandler = resultHandler;
    }

    public void makeLoginRequest(
        final String jsonBody,
        final RepositoryCallback<LoginResponse> callback
    ) {
        executor.execute(new Runnable() {
            @Override
            public void run() {
                try {
                    Result<LoginResponse> result = makeSynchronousLoginRequest(jsonBody);
                    notifyResult(result, callback);
                } catch (Exception e) {
                    Result<LoginResponse> errorResult = new Result.Error<>(e);
                    notifyResult(errorResult, callback);
                }
            }
        });
    }

    private void notifyResult(
        final Result<LoginResponse> result,
        final RepositoryCallback<LoginResponse> callback,
    ) {
        resultHandler.post(new Runnable() {
            @Override
            public void run() {
                callback.onComplete(result);
            }
        });
    }
    ...
}

Ngoài ra, nếu muốn linh hoạt hơn, bạn có thể truyền Handler vào từng hàm:

public class LoginRepository {
    ...

    public void makeLoginRequest(
        final String jsonBody,
        final RepositoryCallback<LoginResponse> callback,
        final Handler resultHandler,
    ) {
        executor.execute(new Runnable() {
            @Override
            public void run() {
                try {
                    Result<LoginResponse> result = makeSynchronousLoginRequest(jsonBody);
                    notifyResult(result, callback, resultHandler);
                } catch (Exception e) {
                    Result<LoginResponse> errorResult = new Result.Error<>(e);
                    notifyResult(errorResult, callback, resultHandler);
                }
            }
        });
    }

    private void notifyResult(
        final Result<LoginResponse> result,
        final RepositoryCallback<LoginResponse> callback,
        final Handler resultHandler
    ) {
        resultHandler.post(new Runnable() {
            @Override
            public void run() {
                callback.onComplete(result);
            }
        });
    }
}

Trong ví dụ này, lệnh gọi lại đã truyền vào lệnh gọi makeLoginRequest của Kho lưu trữ được thực thi trên luồng chính. Điều đó có nghĩa là bạn có thể trực tiếp sửa đổi giao diện người dùng qua lệnh gọi lại hoặc sử dụng LiveData.setValue() để giao tiếp với giao diện người dùng.

Định cấu hình nhóm luồng

Bạn có thể tạo nhóm luồng bằng cách sử dụng một trong các hàm trợ giúp Executor với các chế độ cài đặt định sẵn, như minh hoạ trong mã ví dụ trước. Ngoài ra, nếu muốn tuỳ chỉnh thông tin chi tiết của nhóm luồng, bạn có thể tạo một thực thể trực tiếp bằng ThreadPoolExecutor. Bạn có thể định cấu hình các thông tin chi tiết sau:

  • Kích thước nhóm ban đầu và tối đa.
  • Thời gian tồn tại và đơn vị thời gian. Thời gian hoạt động là khoảng thời gian tối đa mà một luồng có thể ở trạng thái rảnh trước khi bị tắt.
  • Hàng đợi đầu vào lưu giữ Runnable công việc. Hàng đợi này phải triển khai giao diện BlockingQueue. Để phù hợp với yêu cầu của ứng dụng, bạn có thể chọn trong số các cách triển khai hàng đợi hiện có. Để tìm hiểu thêm, hãy xem thông tin tổng quan về lớp cho ThreadPoolExecutor.

Dưới đây là một ví dụ chỉ định kích thước nhóm luồng dựa trên tổng số lõi xử lý, thời gian tồn tại là 1 giây và hàng đợi đầu vào.

public class MyApplication extends Application {
    /*
     * Gets the number of available cores
     * (not always the same as the maximum number of cores)
     */
    private static int NUMBER_OF_CORES = Runtime.getRuntime().availableProcessors();

    // Instantiates the queue of Runnables as a LinkedBlockingQueue
    private final BlockingQueue<Runnable> workQueue = new LinkedBlockingQueue<Runnable>();

    // Sets the amount of time an idle thread waits before terminating
    private static final int KEEP_ALIVE_TIME = 1;
    // Sets the Time Unit to seconds
    private static final TimeUnit KEEP_ALIVE_TIME_UNIT = TimeUnit.SECONDS;

    // Creates a thread pool manager
    ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(
            NUMBER_OF_CORES,       // Initial pool size
            NUMBER_OF_CORES,       // Max pool size
            KEEP_ALIVE_TIME,
            KEEP_ALIVE_TIME_UNIT,
            workQueue
    );
    ...
}