понедельник, 16 января 2012 г.

Android: делаем редактор исходного кода с подсветкой синтаксиса

Иногда, когда под рукой только мой верный Android, хочется чего-нибудь почитать. Например, исходники любимого проекта :) Есть минимум десяток приложений, которые отображают файлы исходников с удобной подсветкой синтаксиса, некоторые из них ещё и позволяют редактировать файлы. Вот мне и стало и нтересно, насколько сложно реализуется такой функционал. Оказалось - очень просто.
 
Итак, для приготовления "блокнота с подсветкой синтаксиса" в Android нам понадобится:
  1. Хорошая библиотека для подсветки синтаксиса на JavaScript+CSS. Можно выбрать тут, мне больше всего понравилась CodeMirror, её и будем использовать. 
  2. WebView для интеграции всего этого богатства с нашим приложением
  3. Немного кода, для взаимодействия с WebView и работающим в нём JavaScript.

Делаем локальную web-страницу и включаем в неё библиотеку CodeMirror

Код страницы:
<html>
<head>
<link rel="stylesheet" href="codemirror.css"><link>
<script type="text/javascript" src="codemirror.js"></script>
<script type="text/javascript" src="clike.js"></script>
<link rel="stylesheet" href="docs.css"><link>
<style>
body {
 margin: 0;
 padding: 0;
}
 
.CodeMirror-scroll {
 height: auto;
 overflow-y: hidden;
 overflow-x: auto;
 width: 100%;
}
</style>
</head>
<body>
 <form>
  <textarea id="code" name="code" style="width: 100%; height: 100%"></textarea>
 </form>
 <script type="text/javascript">
  var delay;
  var editor = CodeMirror.fromTextArea(document.getElementById("code"), {
   lineNumbers : true,
   matchBrackets : true,
   mode : "text/x-java",
   onChange: function() {
             clearTimeout(delay);
             delay = setTimeout(updateRez, 300);
           }
  });
  function updateRez() {
   Android.contOut(editor.getValue());
  }
  function setContent(content) {
   editor.setValue(decodeURIComponent(content));
  } 
  delay = setTimeout(updateRez, 300);
 </script>
</body>
</html>

Тут мы подключаем два js-файла: ядро codemirror и файл с форматированием для "c-like" языков (с, java и т.п.). Тут же используем css-файл от CodeMirror. На нашей странице помещаем textarea c id="code", к которому и будет привязано отображение нашего "раскрашенного" исходника.
Две JavaScript функции описанные ниже textarea отвечают за установку содержимого в окно редактора и получение результата. Вынимаем результат мы спустя 300 ms после изменения содержимого редактора (WebView склонно тормозить), а устанавливаем, само собой, функцией setContent.

Отображаем локальную web-страницу с подгрузкой скриптов из asset-ов
 
public static String FILE_NAME = "filename";
private File mEdited;
private EditText mNewFileName;
private WebView wv;
private String mRezContent;
private ProgressDialog progressDialog;
 
@Override
protected void onCreate(Bundle savedInstanceState) {
 super.onCreate(savedInstanceState);
 
 progressDialog = new ProgressDialog(this);
 progressDialog.setProgressStyle(ProgressDialog.STYLE_HORIZONTAL);
 progressDialog.setMessage(getResources().getString(R.string.msg_loading));
 progressDialog.setCancelable(false);
 
 mEdited = new File(getIntent().getExtras().getString(FILE_NAME));
 
 mRezContent = getFileContent();
 
 wv = new WebView(this);
 wv.getSettings().setSupportZoom(true);
 wv.getSettings().setJavaScriptEnabled(true);
 wv.addJavascriptInterface(new JavascriptInterface(), "Android");
 wv.setWebChromeClient(new FileWebClient());
 wv.loadUrl("file:///android_asset/editor.html");
 wv.requestFocusFromTouch();
 
 LinearLayout ll = new LinearLayout(this);
 ll.setOrientation(LinearLayout.VERTICAL);
 ll.addView(wv, LayoutParams.FILL_PARENT, LayoutParams.FILL_PARENT);
 
 setContentView(ll);
 progressDialog.setProgress(0);
 progressDialog.show();
}

Тут, во-первых, учитываем, что любая загрузка в WebView (даже локального файла) занимает время. Поэтому отображаем ProgressDialog. Во-вторых hml-страницу редактора (и все нужные библиотеки) укладываем в каталог assets в корне нашего проекта и загружаем его как file:///android_asset/имя файла.

Передаём данные в WebView и получаем их обратно

Имя файла, который будем редактировать передаётся в наше Activity через intent и получается как getIntent().getExtras().getString("key"). Контент файла получаем так:

private String getFileContent() {
 StringBuilder sb = new StringBuilder();
 try {
  BufferedReader br = new BufferedReader(new InputStreamReader(new FileInputStream(mEdited)));
  String tmp = null;
  do {
   tmp = br.readLine();
   if (tmp != null)
    sb.append(tmp).append("\n");
  } while (tmp != null);
 } catch (Exception e) {
  e.printStackTrace();
 }
 return sb.toString();
}

...а для того, чтобы уложить его в нашу страничку-редактор нужно дождаться полной её загрузки. Для этого мы при создании WebView указали для него клиента:
wv.setWebChromeClient(new FileWebClient());
который будет обновлять ProgressDialog по мере загрузки редактора, а при окончании загрузки укладывать в редактор тест редактируемого файла и останавливать ProgressDialog:

private class FileWebClient extends WebChromeClient {
 
 @Override
 public void onProgressChanged(WebView view, int newProgress) {
  if (newProgress<100) {
   progressDialog.setProgress(newProgress);
  } else {
   progressDialog.dismiss();
   wv.loadUrl("javascript:setContent('" + UrlEncoder.encode(mRezContent) + "');");
  }
 }
}

Как видно, тут мы взаимодействуем со страницей, загруженной в WebView так же как это делают букмарклеты: "загружаем" в неё JavaScript-вызов по протоколу "javascript:". Получать данные из страницы мы будем иначе.
При создании WebView мы прописали ему
wv.addJavascriptInterface(new JavascriptInterface(), "Android");
Это значит, что из JavaScript внутри страницы-редактора будет доступен объект "Android", с методами, котрые мы реализуем в ещё одном inner-классе внутри нашего Activity:

private class JavascriptInterface {
 
 @SuppressWarnings("unused")
 public void contOut(String html) {
  mRezContent = html;
 }
}

В результате (довольно-таки тяжело отлаживаемого) взаимодействия JavaScript-функции внутри WebView и Java-кода снаружи при каждом изменении контента в редакторе, в глобальной переменной mRezContent отобразится актуальный контент. Осталось его сохранить в файл. При этом клиент должен будет воспользоваться аппаратной кнопкой меню и выбрать опцию "сохранить" (файл будет переписан) или "сохранить как", клиент получит диалог, где сможет указать новое имя файла. Вот код требуемых для этого методов:

@Override
public boolean onCreateOptionsMenu(Menu menu) {
 MenuInflater inflater = getMenuInflater();
 inflater.inflate(R.menu.editor_menu, menu);
 return true;
}
 
@Override
public boolean onOptionsItemSelected(MenuItem item) {
 switch (item.getItemId()) {
 case R.id.save:
  saveToFile(false);
  return true;
 case R.id.save_as:
  saveToFile(true);
  return true;
 default:
  return super.onOptionsItemSelected(item);
 }
}
 
private void saveToFile(boolean needNewName) {
 if (needNewName) {
  mNewFileName = new EditText(this);
  mNewFileName.setText(mEdited.getName());
  mNewFileName.setSingleLine();
  AlertDialog.Builder builder = new AlertDialog.Builder(this);
  builder.setMessage(R.string.msg_newfilename).setCancelable(false).setView(mNewFileName).setPositiveButton(R.string.btn_saveas, new DialogInterface.OnClickListener() {
   public void onClick(DialogInterface dialog, int id) {
    String newFname = mNewFileName.getText().toString();
    mEdited = new File(mEdited.getParent() + "/" + newFname);
    saveToFile(false);
   }
  }).setNegativeButton(R.string.btn_cancel, new DialogInterface.OnClickListener() {
   public void onClick(DialogInterface dialog, int id) {
    dialog.dismiss();
   }
  });
  builder.create().show();
 } else {
  BufferedWriter out = null;
  try {
   out = new BufferedWriter(new OutputStreamWriter(new FileOutputStream(mEdited)));
   out.write(mRezContent);
  } catch (Exception e) {
   e.printStackTrace();
   Toast.makeText(this, e.getMessage(), Toast.LENGTH_SHORT).show();
  } finally {
   try {
    if (out != null) {
     out.flush();
     out.close();
    }
   } catch (IOException ee) {
   }
  }
 }
}

Первый метод строит меню из xml-ресурса, описанного в res/menu/editor-menu.xml:

<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android" >
    <item
        android:id="@+id/save"
        android:icon="@android:drawable/ic_menu_save"
        android:title="@string/btn_save"/>
    <item
        android:id="@+id/save_as"
        android:icon="@android:drawable/ic_menu_set_as"
        android:title="@string/btn_saveas"/>
</menu>

Второй обрабатывает нажатие на элементы меню, третий - собственно сохраняет данные в файл, спрашивая имя файла, если это требуется.

Как теперь становится ясно, базовый функционал android-приложения для редактирования файлов с подсветкой исходного кода можно реализовать в одном Activity в 160 строк кода, используя кроме этого только пару функций на JavaScript и одну замечательную открытую библиотеку. Просто, не правда ли?

1 комментарий:

  1. UrlEncoder у вас свой? Потому что URLEncoder из Java.net все пробелы меняет на "+". Если возможно, подскажите, пожалуйста как этого избежать?

    ОтветитьУдалить