В этой статье мы рассмотрим, как загружать классы (в том числе, фрагменты) из сети во время выполнения программы, и использовать их в своем Android-приложении. Область применения подобной технологии на практике — это отдельная тема для разговора, мне же сама по себе реализация данной функциональности показалась довольно интересной задачей.

Приступим.

Создаем фрагмент


Для начала создадим некий фрагмент Fragment0 и реализуем у него метод onCreateView():

@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container,
                             Bundle savedInstanceState) {
    // Inflate the layout for this fragment
    //return inflater.inflate(R.layout.fragment1, container, false);

    LinearLayout linearLayout = new LinearLayout(getActivity());
    linearLayout.setOrientation(LinearLayout.VERTICAL);
    linearLayout.setGravity(Gravity.CENTER);
    LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams(
                ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);

    Button button = new Button(getActivity());
    button.setText("Кнопка");
    button.setOnClickListener(new View.OnClickListener() {
        @Override
        public void onClick(View v) {
            showFragment("jatx.networkingclassloader.dx.Fragment1", null); // рассмотрим чуть позже
        }
    });
    linearLayout.addView(button, lp);

    return linearLayout;
}

Стандартный метод создания разметки из xml в нашем случае работать не будет, поэтому для первого фрагмента мы создаем ее программно.

Далее нам нужно на основе модуля, содержащего фрагмент, создать APK, распаковать его с помощью unzip, и выложить файл classes.dex на сервер.

Реализуем загрузку классов


В отдельном модуле создадим класс NetworkingActivity и реализуем в нем следующие методы:

@Override
protected void onCreate(Bundle savedInstanceState) {
    // ......
    dataDir = getApplicationInfo().dataDir;
    frameLayout = (FrameLayout) findViewById(R.id.main_frame);

    progressDialog = new ProgressDialog(this);
    progressDialog.setIndeterminate(true);
    progressDialog.setMessage("Загружаем классы из сети");
    progressDialog.show();

    // Загружаем classes.dex с сервера, подробно рассматривать не будем:
    DownloadTask downloadTask = new DownloadTask(this, dataDir); 
    downloadTask.execute(null, null, null); 

    // receiver нужен для того, чтобы мы могли из фрагмента открывать другие фрагменты:
    BroadcastReceiver receiver = new BroadcastReceiver() {
        @Override
        public void onReceive(Context context, Intent intent) {
            String className = intent.getStringExtra("className");
            Bundle args = intent.getBundleExtra("args");
            showFragment(className, args);
        }
    };
    IntentFilter filter = new IntentFilter("jatx.networkingclassloader.ShowFragment");
    registerReceiver(receiver, filter);
}

// Вызывается, когда наш AsyncTask успешно загрузил c сервера classes.dex:
public void downloadReady() {
    Toast.makeText(this, "Классы из сети загружены", Toast.LENGTH_SHORT).show();
    progressDialog.dismiss();
    showFragment("jatx.networkingclassloader.dx.Fragment0", null);
}

public void showFragment(String className, Bundle arguments) {
    // Наш загруженный файл:
    File dexFile = new File(dataDir, "classes.dex");
    Log.e("Networking activity", "Loading from dex: " + dexFile.getAbsolutePath());
    // Каталог кэша, нужен для DexClassLoader:
    File codeCacheDir = new File(getCacheDir() + File.separator + "codeCache");
    codeCacheDir.mkdirs();
    // Создаем ClassLoader:
    DexClassLoader dexClassLoader = new DexClassLoader(
                dexFile.getAbsolutePath(), codeCacheDir.getAbsolutePath(), null, getClassLoader());
    try {
        // Загружаем класс фрагмента по имени:
        Class clazz = dexClassLoader.loadClass(className);
        // Создаем объект класса:
        Fragment fragment = (Fragment) clazz.newInstance();
        // Передаем фрагменту аргументы и отображаем его:
        fragment.setArguments(arguments);
        FragmentManager fragmentManager = getFragmentManager();
        FragmentTransaction fragmentTransaction = fragmentManager.beginTransaction();
        fragmentTransaction.add(R.id.main_frame, fragment);
        fragmentTransaction.commit();
    } catch (Exception e) {
        e.printStackTrace();
    }
}

Открываем из фрагмента другие фрагменты


Для этого в классе LoadableFragment (суперкласс всех наших фрагментов) реализуем следующий метод:

public void showFragment(String className, Bundle args) {
    Intent intent = new Intent("jatx.networkingclassloader.ShowFragment");
    intent.putExtra("className", className);
    intent.putExtra("args", args);
    getActivity().sendBroadcast(intent);
}

Надеюсь, здесь все понятно.

Наш следующий фрагмент мы попробуем создать несколько иначе.

Подгружаем из сети xml-разметку


Для начала, создаем и выкладываем на сервер файл разметки. Я нашел на github библиотеку, которая умеет парсить xml layout из строки. Для корректной работы пришлось ее немного подпилить.

И так, добавим в наш класс LoadableFragment следующие методы:

protected void loadLayoutFromURL(FrameLayout container, String url) {
    this.container = container;
    // загружаем файл разметки:
    LayoutDownloadTask layoutDownloadTask = new LayoutDownloadTask(this, url);
    layoutDownloadTask.execute(null, null, null);
}

// Вызывается, если xml-разметка успешно загружена:
public void onLayoutDownloadSuccess(String xmlAsString) {}

Теперь с помощью этого всего создадим фрагмент Fragment1:

@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container,
                             Bundle savedInstanceState) {
    FrameLayout frameLayout = new FrameLayout(getActivity());
    loadLayoutFromURL(frameLayout, "http://tabatsky.ru/testing/fragment1.xml");
    return frameLayout;
}

@Override
public void onLayoutDownloadSuccess(String xmlAsString) {
    LinearLayout linearLayout = (LinearLayout) DynamicLayoutInflator.inflate(getActivity(), xmlAsString, container);
    FrameLayout.LayoutParams lp = new FrameLayout.LayoutParams(
                ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT);
    linearLayout.setLayoutParams(lp);
    final EditText editText = (EditText) DynamicLayoutInflator.findViewByIdString(linearLayout, "edit_text");
    Button button = (Button) DynamicLayoutInflator.findViewByIdString(linearLayout, "button");
    button.setOnClickListener(new View.OnClickListener() {
        @Override
        public void onClick(View v) {
            Bundle args = new Bundle();
            args.putString("userName", editText.getText().toString());
            showFragment("jatx.networkingclassloader.dx.Fragment2", args);
        }
    });
}

Послесловие


Полностью исходный код проекта можно посмотреть на github. Готовый APK можно скачать здесь.

Ну и напоследок, хочу сказать пару слов о возможном применении подобной технологии: например, можно выдавать с сервера разные classes.dex в зависимости от типа аккаунта пользователя (платный/бесплатный), что должно несколько увеличить сложность реверс-инжиниринга приложения.
Поделиться с друзьями
-->

Комментарии (18)


  1. ChPr
    18.06.2017 08:52
    +2

    Обязательно стоит упомянуть, что такое поведение запрещено правилами Google Play Store.


    Ниже перечислены запрещенные программы:

    Приложения или пакеты разработчика, которые скачивают исполняемый код (например, файлы DEX или внутренний код), созданный сторонними разработчиками.


    1. jatx
      18.06.2017 08:55

      Созданный сторонними разработчиками

      Интересно, что в данном случае подразумевается под "сторонними разработчиками"? Подходит ли под это определение код, созданный тем же разработчиком, который публикует APK на Google Play?


      1. Borz
        18.06.2017 09:15
        +1

        учитывая что в начале написано про "разработчика", то имеется в виду всё-таки сторонний, т.е. не тот же самый разработчик


        1. ChPr
          18.06.2017 09:42
          +3

          Проверил английскую версию:


          Likewise, an app may not download executable code (e.g. dex, JAR, .so files) from a source other than Google Play.


          1. Borz
            18.06.2017 09:45

            на территории РФ же будет действовать русская версия соглашения. Или я не прав?


            1. jatx
              18.06.2017 09:52
              +1

              Не уверен. Каждый раз при публикации там нужно ставить галочку, что-то вроде "приложение не нарушает экспортное законодательство США" (точную формулировку не помню), так что полагаю, что английская версия в данном случае первична, а в русской просто неточности перевода.


    1. thelongrunsmoke
      18.06.2017 11:36

      В корпоративном сегменте, класс лоадеры используются широко, дабы разгрузить тяжёлые приложения.
      Но в Google Play за это забанят.


    1. olegchir
      19.06.2017 00:26

      что такое «внутренний код»? Если например, я использую свой простенький интерпретатор внутри приложения, а скрипты загружаю из интернетов, считается ли это кодом?


      1. jatx
        19.06.2017 04:28

        С той же страницы правил:


        Это правило не распространяется на код, который запускается на виртуальной машине и имеет ограниченный доступ к API Android (например, код JavaScript в компоненте WebView или браузере).


  1. ser-mk
    18.06.2017 16:41

    Очень интересно, спасибо за статью!

    По реализации возникло пару вопросов
    Данный dex файл сохраняется в папке с приложением и его уже больше не требуется грузить повторно?

    Есть еще такая штука AAR, в ней можно хранить и ресурсы и разметку, что бы не грузить с сервера.
    Вам не доводилось, подобным образом, динамически загружать такой тип ресурсов?


    1. jatx
      19.06.2017 00:45
      +1

      dex-файл загружается каждый раз при запуске.
      Смысл как раз в том, что мы можем изменить на сервере dex-файл, не обновляя само приложение.
      В принципе, при желании не сложно добавить проверку на время обновления dex на сервере, и загружать его по новой, только если он изменился.


      По поводу AAR — как я понял из описания, он используется на этапе компиляции и сборки приложения.
      Лично мне этим пользоваться не приходилось.


  1. kolipass
    19.06.2017 05:53

    можно выдавать с сервера разные classes.dex в зависимости от типа аккаунта пользователя (платный/бесплатный)

    Интересно виденье автора, как это реализовать, чтобы не было возможности перепаковать apk с обратной проверкой, или вообще включить платный classes.dex внутрь apk.
    Я так полагаю, необходима комбинация нескольких разных подходов.


    1. jatx
      19.06.2017 07:05

      Вопрос интересный.
      Мое мнение — стопроцентную защиту приложения от взлома сделать невозможно в принципе.
      Но этот самый процент защиты всегда можно увеличить )


      1. kolipass
        20.06.2017 06:25

        И всё таки, можно какой-то абстрактный пример «на котиках», что можно вынести на сервер?
        Я придумал только такое: в игрушках можно вынести автогенерируемые уровни, но смысла хранить уровни в classes.dex не придумал. Более-менее интересное решение пришло на ум такое: в клиентском apk есть интерфейс-протокол общения с _платным_ сервером, а в classes.dex нам сервер авторизации подсунет реализацию этого нитерфейса, которая будет динамически генерироваться.


        1. jatx
          20.06.2017 09:31

          Мне еще такой пример пришел на ум. Только не с платным, а с корпоративным сервером.
          Не знаю, может ли такое реально быть кому-нибудь нужно, но можно сделать так, чтобы classes.dex загружался только из корпоративной сети, а при смене сети самоудалялся.


          1. kolipass
            20.06.2017 12:47
            +1

            Интересная идея: эдакий полноприводный кровавый энтерпрайз!


          1. onepavel
            21.06.2017 18:25

            Если не ошибаюсь класс лоадер внешний dex файл грузит со стораджа, куда предварительно выкачивается и сохраняется. И даже видел реализации, которые таскают с собой дополнительный dex в зашифрованном виде, но при подгрузке и расшифровке все-равно кладут на сторадж. Было бы интереснее если научить класс лоадер загружать dex, который лежит только в памяти в хипе. Снятие дампа может кидс хакеров отсеить.


            1. jatx
              22.06.2017 02:07

              Тоже думал о том, как хранить его в памяти. Пока не придумал.
              Еще такая идея была — dex загружается, и первым делом проверяет на устройстве root. И дальше работает, только если устройство не рутованное.