Android: динамически подгружаем фрагменты из сети

    В этой статье мы рассмотрим, как загружать классы (в том числе, фрагменты) из сети во время выполнения программы, и использовать их в своем 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 в зависимости от типа аккаунта пользователя (платный/бесплатный), что должно несколько увеличить сложность реверс-инжиниринга приложения.
    Поделиться публикацией
    Похожие публикации
    Реклама помогает поддерживать и развивать наши сервисы

    Подробнее
    Реклама
    Комментарии 19
    • +2

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


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

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

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

        • +1

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

          • +3

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


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

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

              • +1

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

        • 0

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

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

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


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

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

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

            • +1

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


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

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

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

              • 0

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

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

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

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

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

                      • 0
                        Хотел сделать для своего приложения баг-трекер с возможностью оперативного фикса бага — на сервер отправляется отчет об ошибке и если такая проблема была зарегистрирована, возвращает код для исправления ошибки.
                        Уже даже реализовал, но подумал о том, что наверняка антивирусы Google Play заметят возможность рефлексии загружаемым кодом. А ведь я потенциально могу вписывать туда все что угодно. Хорошо что наткнулся на комментарии к этой статье, где опубликовали оригинальную трактовку правил, которые запрещают загружать любой код из внешних источников.

                  Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.