Scripto — замена стандартному JavaScriptInterface
Приветствую пользователей Хабра. Наверное, многие из более менее опытных пользователей слышали про JavaScriptInterface — «мостик» между Java и JavaScript, при помощи которого можно вызывать Java методы. У JavaScriptInterface есть несколько довольно значимых недостатков:
1) Методы вызываются не в UI-потоке, а в специальном потоке Java Bridge, который нельзя забивать, иначе WebView перестанет отвечать.
2) При обращении к UI из методов, вызванных при помощи JavaScriptInterface, ничего не происходит, что может привести к нескольким часам дебага у незнающих разработчиков. Как решение, приходится использовать метод runOnUi или хендлеры.
3) Невозможно передавать пользовательские типы данных
Вызов JS-функций стандартным способом происходит так:
myWebView.loadUrl("myFunction('Hello World!')");
Минус данного подхода в том, что вызов функции — это, фактически, строка, и при передаче аргументов всех их нужно конвертировать в String.
Столкнувшись с этими проблемами в одном из своих проектов, в котором Java и JavaScript взаимодействуют очень тесно, я решил написать библиотеку облегчающую вызовы JS из Java и наоборот.
Основной идеей библиотеки стало то, что пользователь (читайте программист) вызывает Java-методы, а библиотека сама вызывает JavaScript-функции и передает ей аргументы. Также можно вызывать функции с коллбеками. Все это работает и в обратном направлении — из JS в Java.
Вот основные преимущества библиотеки:
— удобный вызов JS-функций с параметрами
— вызов с коллбеками и обработкой ошибок при исполнении JS
— возможность передачи собственных типов данных
— перенос выполнения кода в UI поток
При написании библиотеки я ориентировался на библиотеку Retrofit и даже использовал некоторые куски кода из ее исходников.
В Scripto есть два типа сущностей:
Script — служит для вызова JS-функций из Java.
Interface — предназначен для вызова Java-методов из JS.
Перед дальнейшим прочтением статьи советую быстро пробежаться по Readme библиотеки для полного понимания сути происходящего.
Итак, условия задачи:
Есть HTML-документ с формой ввода пользовательских данных. После ввода данных пользователя и нажатия кнопки «Save» приложение должно сохранить данные в SharedPreferences. При закрытии и повторном открытии приложения данные в форме восстанавливаются из настроек. Задача полностью выдуманная и не несет в себе никакого смысла.
Итак первое, что нам нужно сделать — это создать форму:
Пять полей с разными типами значений: строка, целое число, вещественное число, булево значение.
Ниже представлен код скрипта test.js, который сохраняет и восстанавливает данные пользователя:
function loadUserData() {
PreferencesInterface.getUserData(function(userJson) {
var user = JSON.parse(userJson);
document.getElementById('name_field').value = user.name;
document.getElementById('surname_field').value = user.surname;
document.getElementById('age_field').value = user.age;
document.getElementById('height_field').value = user.height;
document.getElementById('married_checkbox').checked = user.married;
});
}
function saveUserData() {
var user = getUserData();
PreferencesInterface.saveUserData(user);
}
function getUserData() {
var user = {};
user['name'] = document.getElementById('name_field').value;
user['surname'] = document.getElementById('surname_field').value;
user['age'] = document.getElementById('age_field').value;
user['height'] = document.getElementById('height_field').value;
user['married'] = document.getElementById('married_checkbox').checked;
return JSON.stringify(user);
}
//после окончания загрузки документа, грузим данные пользователя
document.addEventListener('DOMContentLoaded', function() {
loadUserData();
}, false);
JS-скрипт android_interface.js, вызывающий наши Java-методы:
function PreferencesInterface() {}
PreferencesInterface.saveUserData = function(user) {
Scripto.call('Preferences', arguments);
};
PreferencesInterface.getUserData = function(callback) {
Scripto.callWithCallback('Preferences', arguments);
};
В интерфейсе мы вызываем специальную функцию callнашей библиотеки, а также передаем ей аргументы. Благодаря этому библиотека сможет получить имя функции, вызвавшей ее и вызвать одноименный Java-метод, передав ему аргументы.
Давайте создадим модель для нашего пользователя:
public class User {
@SerializedName("name")
private String name;
@SerializedName("surname")
private String surname;
@SerializedName("age")
private int age;
@SerializedName("height")
private float height;
@SerializedName("married")
private boolean married;
public User() {
}
public User(String name, String surname, int age, float height, boolean married) {
this.name = name;
this.surname = surname;
this.age = age;
this.height = height;
this.married = married;
}
public String getName() {
return name;
}
public String getSurname() {
return surname;
}
public int getAge() {
return age;
}
public float getHeight() {
return height;
}
public boolean isMarried() {
return married;
}
public String getUserInfo() {
return String.format("Name: %s \nSurname: %s \nAge: %d \nHeight: %s \nMarried: %s", name, surname, age, height, married);
}
}
Т. к. библиотека использует GSON для конвертации пользовательских типов данных, мы используем аннотацию SerializedName.
Теперь создадим Java-интерфейс настроек для сохранения данных:
public class PreferencesInterface {
private Context context;
private SharedPreferences prefs;
public PreferencesInterface(Context context) {
this.context = context;
this.prefs = context.getSharedPreferences("MyPrefs", Context.MODE_PRIVATE);
}
public void saveUserData(User user) {
prefs.edit().putString("user_name", user.getName()).apply();
prefs.edit().putString("user_surname", user.getSurname()).apply();
prefs.edit().putInt("user_age", user.getAge()).apply();
prefs.edit().putFloat("user_height", user.getHeight()).apply();
prefs.edit().putBoolean("user_married", user.isMarried()).apply();
Toast.makeText(context, user.getUserInfo(), Toast.LENGTH_SHORT).show();
}
public User getUserData() {
String userName = prefs.getString("user_name", "");
String userSurname = prefs.getString("user_surname", "");
int userAge = prefs.getInt("user_age", 0);
float userHeight = prefs.getFloat("user_height", 0.0f);
boolean userMarried = prefs.getBoolean("user_married", false);
return new User (userName, userSurname, userAge, userHeight, userMarried);
}
}
Все готово, осталось только связать интерфейсы и скрипты между собой.
Scripto scripto = new Scripto.Builder(webView).build();
scripto.addInterface("Preferences", new PreferencesInterface(this));
Для того, чтобы узнать, что библиотека готова к работе нам нужно установить слушатель. После того, как библиотека готова мы вызываем функцию для восстановления данных:
scripto.onPrepared(new ScriptoPrepareListener() {
@Override
public void onScriptoPrepared() {
userInfoScript.loadUserData();
}
});
Загружаем нашу HTML-страницу:
Готово. Теперь при нажатии на кнопку «Save» мы сохраним наши данные в SharedPreferences, а при следующем запуске приложения они восстановятся.
Давайте еще сделаем вывод информации о пользователе в Toast при нажатии на кнопку «Show user info»:
public void getUserData(View view) {
userInfoScript.getUserData()
.onResponse(new ScriptoResponseCallback() {
@Override
public void onResponse(User user) {
Toast.makeText(MainActivity.this, user.getUserInfo(), Toast.LENGTH_LONG).show();
}
})
.onError(new ScriptoErrorCallback() {
@Override
public void onError(JavaScriptException error) {
Toast.makeText(MainActivity.this, error.getMessage(), Toast.LENGTH_SHORT).show();
}
}).call();
}
В методе onResponse мы получаем уже сконвертированный из JSON объект. Если при выполнении скрипта произошла ошибка мы получим исключение в метод onError. Если не прописывать метод onError, библиотека выбросит исключение JavaScriptException.
Результат:
Библиотека на Github: Scripto.