עבודה אסינכרונית עם שרשורי Java

כל האפליקציות ל-Android משתמשות ב-thread הראשי כדי לטפל בפעולות של ממשק המשתמש. התקשרות לטווח ארוך פעולות מה-thread הראשי הזה עלולות להוביל לקיפאון ולאי תגובה. עבור לדוגמה, אם האפליקציה שולחת בקשת רשת מה-thread הראשי, ממשק המשתמש של האפליקציה מוקפא עד שהוא מקבל את תגובת הרשת. אם אתם משתמשים ב-Java, תוכלו ליצור שרשורי רקע נוספים כדי לטפל בפעולות ממושכות, ה-thread הראשי ממשיך לטפל בעדכונים של ממשק המשתמש.

המדריך הזה מראה איך מפתחים שמשתמשים בשפת Java יכולים להשתמש מאגר שרשורים כדי להגדיר מספר שרשורים ולהשתמש בהם באפליקציה ל-Android. המדריך הזה מראה גם איך להגדיר קוד שרץ על שרשור ואיך בין אחד מהשרשורים האלה ל-thread הראשי.

ספריות בו-זמניות (concurrency)

חשוב להבין את העקרונות הבסיסיים של שרשורים על מנגנוני תשומת לב. עם זאת, יש ספריות פופולריות רבות שמציעות רמה גבוהה יותר של הפשטות של המושגים האלה וכלי שירות מוכנים לשימוש להעברת נתונים בין שרשורים. הספריות האלה כוללות את Guava וגם את RxJava למשתמשי שפת התכנות Java ולקורוטינים, אנחנו ממליצים למשתמשי Kotlin.

בפועל, כדאי לבחור את השיטה המתאימה ביותר לאפליקציה שלכם בצוות הפיתוח, אבל כללי השרשור לא משתנים.

סקירה כללית של דוגמאות

על סמך המדריך לארכיטקטורה של אפליקציות, הדוגמאות בנושא הזה גורמות בקשה לרשת ולהחזיר את התוצאה ל-thread הראשי, שבו האפליקציה עשויה להציג את התוצאה הזו על המסך.

באופן ספציפי, ה-ViewModel קורא לשכבת הנתונים ב-thread הראשי כדי תפעיל את בקשת הרשת. שכבת הנתונים שאחראית על העברת ביצוע בקשת הרשת מחוץ ל-thread הראשי ופרסום התוצאה בחזרה לשרשור הראשי באמצעות קריאה חוזרת (callback).

כדי להעביר את הביצוע של בקשת הרשת מה-thread הראשי, צריך ליצור שרשורים אחרים באפליקציה שלנו.

יצירת כמה שרשורים

מאגר שרשורים הוא אוסף מנוהל של שרשורים שמפעיל משימות ב- מקבילה של תור. משימות חדשות מתבצעות בשרשורים קיימים בתור השרשורים הופכים ללא פעילים. כדי לשלוח משימה למאגר שרשורים, משתמשים ExecutorService. חשוב לדעת: ExecutorService לא צריך לעשות שום דבר עם שירותים, רכיב האפליקציה ל-Android.

כדי ליצור שרשורים צריך להיות יקר, לכן כדאי ליצור מאגר שרשורים רק פעם אחת. מופעלת באפליקציה. חשוב לשמור את המופע של ExecutorService במחלקה Application או במאגרי החדרת תלות. בדוגמה הבאה נוצר מאגר שרשורים שכולל ארבעה שרשורים שנוכל להשתמש בו כדי להריץ משימות ברקע.

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

יש דרכים אחרות להגדיר מאגר שרשורים בהתאם למצופה עומס עבודה. מידע נוסף זמין במאמר הגדרה של מאגר שרשורים.

הפעלה בשרשור ברקע

שליחת בקשת רשת ב-thread הראשי גורמת להמתנה של ה-thread, או לחסום, עד שהוא יקבל תגובה. מכיוון שה-thread חסום, מערכת ההפעלה לא יכולה קוראים לפונקציה onDraw(), והאפליקציה קופאת תיבת דו-שיח של תגובה (ANR). במקום זאת, נריץ את הפעולה הזו ברקע של שרשור.

יצירת הבקשה

קודם כול, נבחן את הקורס LoginRepository ונראה איך הוא מניב בקשת הרשת:

// 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() סינכרוני וחוסם את השרשור של השיחה. כדי ליצור מודל לתגובה לבקשת הרשת, יש לנו מחלקה משלנו Result.

שליחת הבקשה

השדה ViewModel מפעיל את בקשת הרשת כשהמשתמש מקיש, למשל על לחצן:

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);
    }
}

בקוד הקודם, LoginViewModel חוסם את ה-thread הראשי כשיוצרים בקשת הרשת. אפשר להשתמש במאגר השרשורים שיצרנו כדי להעביר את ההפעלה לשרשור ברקע.

טיפול בהחדרת תלות

קודם כול, פועלים לפי העקרונות של החדרת תלות, LoginRepository לוקחת מופע של מפעיל במקום ExecutorService, כי הרצת קוד ולא ניהול שרשורים:

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

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

שיטת execute() של הביצוע משתמשת ב-Runnable. Runnable הוא ממשק של שיטה מופשטת יחידה (SAM) עם שיטה run() שמופעלת ב- שרשור כשמפעילים אותו.

הפעלה ברקע

בואו ניצור פונקציה נוספת בשם makeLoginRequest() שמעבירה את לשרשור הרקע, ומתעלם מהתשובה בינתיים:

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
    }
    ...
}

בתוך ה-method execute(), אנחנו יוצרים Runnable חדש עם בלוק הקוד אנחנו רוצים לבצע בשרשור ברקע — במקרה שלנו, הרשת המסונכרנת שיטת הבקשה. באופן פנימי, ExecutorService מנהל את Runnable ואת מפעיל אותו בשרשור זמין.

שיקולים

כל שרשור באפליקציה יכול לפעול במקביל לשרשורים אחרים, כולל השרשור הראשי לכן עליכם לוודא שהקוד בטוח לשרשורים. שימו לב שבמודלים דוגמה לכך שאנחנו נמנעים מכתיבה למשתנים שמשותפים בין שרשורים, מהעברת שרשורים במקום זאת, נתונים שאינם משתנים. זו שיטה מומלצת, כי כל שרשור עובד עם מופע נתונים משלו, ואנחנו נמנעים מהמורכבות של הסנכרון.

אם צריך לשתף מצב בין שרשורים, חשוב להפעיל שיקול דעת בניהול הגישה משרשורים באמצעות מנגנוני סנכרון כמו מנעולים. זה מחוץ ל- היקף המדריך הזה. באופן כללי, צריך להימנע משיתוף של מצב שניתן לשינוי בין שרשורים, כשהדבר אפשרי.

תקשורת עם ה-thread הראשי

בשלב הקודם, התעלמנו מתגובה לבקשת הרשת. כדי להציג את תוצאה במסך, LoginViewModel צריכה לדעת עליה. כדי שנוכל לעשות את זה, באמצעות קריאות חוזרות (callback).

הפונקציה makeLoginRequest() צריכה לקבל קריאה חוזרת כפרמטר כדי הוא יכול להחזיר ערך באופן אסינכרוני. הקריאה החוזרת (callback) של התוצאה נקראת בכל פעם שבקשת הרשת מסתיימת או כשמתרחש כשל. ב-Kotlin אנחנו יכולים להשתמש בפונקציה שגודלה גבוה יותר. אבל ב-Java, אנחנו צריכים ליצור קריאה חוזרת (callback) חדשה לספק את אותה פונקציונליות:

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 צריך להטמיע את הקריאה החוזרת (callback) עכשיו. הוא יכול לבצע ביצועים שונים בהתאם לתוצאה:

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
                }
            }
        });
    }
}

בדוגמה הזאת, הקריאה החוזרת מתבצעת בשרשור הקריאה, שרשור ברקע. פירוש הדבר הוא שאתם לא יכולים לשנות או לתקשר באופן ישיר בשכבת ה-UI עד שחוזרים ל-thread הראשי.

שימוש ברכיבי handler

אפשר להשתמש ברכיבי Handler כדי להוסיף פעולה לביצוע ברשימה אחרת של שרשור. כדי לציין את ה-thread שבו תפעל הפעולה, יוצרים את Handler באמצעות Looper לשרשור. Looper הוא אובייקט שרץ את לולאת ההודעה לשרשור משויך. אחרי שיוצרים Handler, יכול להשתמש בשיטה post(Runnable) כדי להריץ בלוק של קוד השרשור המתאים.

Looper כוללת פונקציית עזר, getMainLooper(), שמאחזרת את Looper מה-thread הראשי. אפשר להריץ את הקוד בשרשור הראשי באמצעות Looper כדי ליצור Handler. זה משהו שאתם עשויים לעשות לעיתים קרובות, אפשר גם לשמור מופע של Handler באותו מקום שבו שמרתם את ExecutorService:

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

מומלץ להחדיר את ה-handler למאגר, כי זה נותן יותר גמישות. לדוגמה, בעתיד ייתכן שתרצו להעביר ערך Handler שונה כדי לתזמן משימות בשרשור נפרד. אם אתם תמיד לאותו שרשור, אפשר להעביר את Handler של מאגר הנתונים, כפי שמוצג בדוגמה הבאה.

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);
            }
        });
    }
    ...
}

לחלופין, אם רוצים יותר גמישות, אפשר להעביר Handler לכל פונקציה:

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);
            }
        });
    }
}

בדוגמה הזו, הקריאה החוזרת (callback) מועברת אל makeLoginRequest של המאגר תתבצע ב-thread הראשי. זה אומר שאפשר לשנות ישירות את ממשק המשתמש מהקריאה החוזרת (callback) או להשתמש ב-LiveData.setValue() כדי לתקשר עם ממשק המשתמש.

הגדרת מאגר של שרשורים

אפשר ליצור מאגר שרשורים באמצעות אחת מפונקציות העזרה של Executor עם הגדרות מוגדרות מראש, כמו שמוצג בקוד לדוגמה הקודם. לחלופין, אם רוצים להתאים אישית את הפרטים של מאגר השרשורים, אפשר ליצור באמצעות ThreadPoolExecutor באופן ישיר. אפשר להגדיר את ההגדרות הבאות פרטים:

  • גודל מאגר ראשוני ומקסימלי.
  • עדכון זמן אמת ויחידת זמן. שמירה על 'זמן אמת' היא משך הזמן המקסימלי ה-thread יכול להישאר לא פעיל לפני שהוא נסגר.
  • תור קלט שמכיל Runnable משימות. בתור הזה יש ליישם את הממשק blockQueue. כדי להתאים לדרישות האפליקציה, אפשר בחירה מתוך האפליקציות הזמינות בתור. מידע נוסף זמין בכיתה סקירה כללית של ThreadPoolExecutor.

דוגמה שמציינת את הגודל של מאגר השרשורים לפי המספר הכולל של השרשורים ליבות של מעבד, אורך חיים של שנייה אחת ותור קלט.

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
    );
    ...
}