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. Gọi trong thời gian dài Các hoạt động từ luồng chính này có thể khiến bạn bị treo và không phản hồi. Cho ví dụ: nếu ứng dụng của bạn đưa ra yêu cầu mạng từ luồng chính, thì giao diện người dùng của ứng dụng bị treo cho đến khi nhận được phản hồi mạng. Nếu sử dụng Java, bạn có thể tạo các luồng trong nền bổ sung để xử lý các thao tác diễn ra trong thời gian dài trong khi luồng chính sẽ tiếp tục xử lý các bản cập nhật giao diện người dùng.

Hướng dẫn này cho biết cách nhà phát triển sử dụng Ngôn ngữ lập trình Java có thể sử dụng nhóm luồng để thiết lập và sử dụng nhiều luồng trong một ứng dụng Android. Hướng dẫn này hướng dẫn bạn 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

Bạn cần nắm được kiến thức cơ bản về việc tạo luồng và cơ sở cơ chế cụ thể. Tuy nhiên, có nhiều thư viện phổ biến cung cấp cấp độ cao hơn trừu tượng hơn các khái niệm này và các tiện ích sẵn sàng sử dụng để truyền dữ liệu giữa các chuỗi. 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, Đây là tính năng mà chúng tôi đề xuất cho người dùng Kotlin.

Trên thực tế, bạn nên chọn phương thức phù hợp nhất với ứng dụng và mặc dù các quy tắc về 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ẽ tạo ra yêu cầu mạng và trả về kết quả cho luồng chính, nơi sau đó ứng dụng có thể hiển thị 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 thực thi yêu cầu mạng bên ngoài luồng chính rồi đăng lại kết quả đến luồng chính bằng cách sử dụng lệnh gọi lại.

Để chuyển việc thực thi yêu cầu mạng ra khỏi luồng chính, chúng ta cần phải tạo các luồng khác trong ứng dụng của chúng tôi.

Tạo nhiều chuỗi

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ụ trong song song từ một hàng đợi. Các tác vụ mới được thực thi trên các luồng hiện có dưới dạng những luồng đó các luồng ở trạng thái rảnh. Để gửi một việc cần làm đến một nhóm luồng, hãy sử dụng Giao diện ExecutorService. Lưu ý rằng ExecutorService không có tác dụng gì với Dịch vụ, thành phần ứ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 một nhóm luồng một lần vì ứng dụng của bạn khởi chạy. Hãy nhớ lưu bản sao 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ể sử dụng để chạy 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 dự kiến khối lượng công việc. Xem phần Đị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 phải đợi, hoặc block cho đến khi nhận được phản hồi. Vì luồng này bị chặn nên hệ điều hành không thể gọi onDraw() và ứng dụng của bạn bị treo, có khả năng dẫn đến trường hợp Ứng dụng không Hộp thoại phản hồi (ANR). Thay vào đó, hãy chạy thao tác này ở chế độ nền chuỗi.

Đưa ra yêu cầu

Trước tiên, hãy xem lớp LoginRepository của chúng ta để biết cách tạo yêu cầu mạng:

// 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 của riêng mình.

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ư bậ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 thực hiện yêu cầu mạng. Chúng ta có thể dùng nhóm luồng mà chúng ta đã tạo thực thể để di chuyển quá trình thực thi đến một luồng trong nền.

Xử lý thao tác chèn phần phụ thuộc

Trước tiên, tuân theo nguyên tắc chèn phần phụ thuộc, LoginRepository lấy một thực thể của Trình thực thi thay vì ExecutorService vì đây là thực thi mã và 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 execute() của Trình thực thi 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 ở chế độ nền

Hãy tạo một hàm khác có tên là makeLoginRequest(). Hàm này di chuyển thực thi 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 bằng khối mã mà chúng ta muốn thực thi trong luồng nền—trong trường hợp của chúng ta là mạng đồng bộ . Trong nội bộ, ExecutorService quản lý Runnable và thực thi phương thức đó trong một chuỗi 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 luồng, do đó bạn cần đảm bảo mã của mình an toàn cho luồng. Lưu ý rằng trong Ví dụ: chúng ta tránh ghi vào các biến được chia sẻ giữa các luồng, truyền thay vào đó là dữ liệu bất biến. Đâ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 và chúng tôi tránh được việc đồng bộ hoá phức tạp.

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ế đồng bộ hoá chẳng hạn như khoá. Địa điểm 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 chuỗi bất cứ khi nào có thể.

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

Trong bước trước, chúng ta đã bỏ qua phản hồi 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 tôi có thể thực hiện điều đó bằng cách bằng cách sử dụng lệnh gọi lại.

Hàm makeLoginRequest() phải lấy lệnh gọi lại làm tham số để mã này có thể trả về giá trị không đồng bộ. Lệnh gọi lại với kết quả được gọi bất cứ khi nào yêu cầu mạng hoàn tất hoặc có lỗi. Trong Kotlin, chúng ta có thể dùng hàm bậc cao hơn. Tuy nhiên, trong Java, chúng ta phải tạo một lệnh gọi lại mới giao diện 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 bây giờ. Có thể thực hiện nhiều hoạt động logic tùy 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 gọi, đây 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 trao đổi trực tiếp với lớp giao diện người dùng cho đến khi bạn quay lại luồng chính.

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

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

Looper bao gồm một hàm trợ giúp getMainLooper(), giúp 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 dùng Looper để tạo một Handler. Vì đây là việc bạn có thể làm khá thường xuyên, bạn cũng có thể lưu bản sao của Handler ở cùng vị trí bạn đã 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ì nó sẽ cho bạn linh hoạt hơn. Ví dụ: trong tương lai, bạn có thể muốn chuyển một Handler khác nhau để lên lịch các tác vụ trên một luồng riêng. Nếu bạn thường xuyên giao tiếp lại 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 cho mỗi 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 makeLoginRequest của Kho lưu trữ lệnh gọi được thực thi trên luồng chính. Tức là bạn có thể trực tiếp sửa đổi giao diện người dùng từ 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 một nhóm luồng bằng một trong các hàm trợ giúp của Executor (Trình thực thi) với các chế độ cài đặt định sẵn, như được thể hiện 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ể bằng cách sử dụng trực tiếp ThreadPoolExecutor. Bạn có thể định cấu hình các mục sau chi tiết:

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

Dưới đây là ví dụ chỉ định quy mô nhóm luồng dựa trên tổng số lõi xử lý, thời gian hoạt động là một 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
    );
    ...
}